
做多軸運動控制的朋友應(yīng)該都有過這種體驗八個軸一起動步進驅(qū)動器的脈沖頻率一拉高CPU就被中斷淹沒加減速稍微算得慢一點脈沖間隔就肉眼可見地抖動。我之前在某平臺項目里折騰8軸插補開始用定時器中斷直接翻轉(zhuǎn)IO跑到300kHz左右就到頭了再往上要么丟脈沖要么電機失步。后來把方案推倒用雙DMA架構(gòu)重新實現(xiàn)了脈沖輸出定時器只負責(zé)計數(shù)DMA負責(zé)搬運頻率字CPU只處理插補規(guī)劃和緩沖填充實測可以穩(wěn)定輸出到2MHz級別加減速全程不打斷脈沖流8軸聯(lián)動也沒再出現(xiàn)抖動。這篇文章打算把整套源碼的核心思路拆開講清楚雙DMA到底怎么分工、加減速曲線怎么變成DMA能“直接吃”的頻率字數(shù)組、插補算法怎么和DMA緩沖對接以及我在示波器上看到的那些坑。想搞3D打印固件、CNC控制板、自動化點膠或者貼片設(shè)備的朋友應(yīng)該能從這里直接抄走一套可用的框架。1. 先想清楚瓶頸8軸插補為什么不能用中斷硬扛1.1 定時器中斷輸出脈沖的極限在哪很多人第一版方案是把脈沖輸出放在定時器更新中斷里每次中斷翻轉(zhuǎn)GPIO順便算下一個頻率。這條路線在小規(guī)模、低頻率下沒問題但一旦頻率超過幾百kHz就會全線崩潰。原因是每次中斷的開銷太大。以一顆主頻168MHz的MCU為例Cortex-M4內(nèi)核進入中斷大約需要12個時鐘周期做壓棧和取向量中斷處理函數(shù)里要開關(guān)標志位、重新裝載比較值、計算下一拍再加上中斷返回的出棧一次中斷輕松吃掉100多個時鐘周期。1MHz的脈沖輸出意味著1微秒就要來一次中斷CPU連喘氣的機會都沒有更別提8個軸同時動。就算主頻再高一點時序抖動也會要命。中斷響應(yīng)有不確定性相同優(yōu)先級下可能會出現(xiàn)幾十納秒到幾百納秒的延遲這在電機低速時無所謂高速時直接表現(xiàn)為“咔噠”一聲丟步然后整個插補軌跡就偏了。1.2 DMA方案解決的三個核心矛盾把“定時器中斷翻轉(zhuǎn)脈沖”換成“定時器硬件輸出脈沖 DMA搬運頻率字”本質(zhì)上是在回答三個問題CPU占用脈沖周期的動態(tài)變化不需要CPU干預(yù)定時器每產(chǎn)生一次事件DMA就把內(nèi)存里的下一個頻率字搬到定時器比較寄存器CPU完全旁觀。時序確定性DMA搬運由硬件事件觸發(fā)觸發(fā)到寄存器更新之間只有DMA取指、讀內(nèi)存、寫外設(shè)這幾個固定總線周期抖動遠小于中斷響應(yīng)。脈沖連續(xù)性加減速過程中每個脈沖的周期都在變DMA按順序搬數(shù)組里的數(shù)據(jù)天然保持節(jié)奏不會因為CPU被其他任務(wù)搶占而突然卡頓。這三個問題解決后脈沖頻率的上限就跑到了DMA和定時器本身能承受的極限而不是CPU的調(diào)度極限。1.3 雙DMA的具體分工頻率字搬運與方向字寫IO標題里的“雙DMA”在我這套源碼里有兩層含義第一層是物理上的兩個DMA控制器。常見的MCU內(nèi)部通常有兩個DMA控制器比如F4系列有DMA1和DMA2各自帶若干個獨立的stream互不搶通道。8個軸的脈沖輸出我把前4軸掛在DMA1上后4軸掛在DMA2上兩條DMA路徑分別跑各自的定時器事件總線負載被分散開避免了所有軸堆在一個控制器上排隊。第二層含義更重要每個軸脈沖鏈路的“頻率字搬運”和“方向字寫入”由不同的DMA事務(wù)完成。脈沖頻率必須逐個周期更新屬于高頻小粒度事務(wù)方向信號只需要在運動段切換時變化一次屬于低頻一次性事務(wù)。源碼里把這兩類事務(wù)拆開頻率字走定時器比較事件的DMA請求方向字則在開啟運動段前由另一個DMA事務(wù)直接把打包好的32位方向字寫入GPIO的BSRR寄存器。這種分工的好處是8個軸的方向變化可以一次完成而不是8次獨立寫IO從機制上保證多軸方向切換的同步性。2. 雙DMA脈沖引擎的內(nèi)部機制2.1 輸出比較翻轉(zhuǎn)模式下頻率字就是CCR先看信號怎么產(chǎn)生。我用的是定時器輸出比較翻轉(zhuǎn)模式而不是大多數(shù)人習(xí)慣的PWM比較模式。PWM模式下ARR決定周期CCR決定占空比想改變輸出頻率就得改ARR但ARR一改當(dāng)前周期可能被截斷很容易產(chǎn)生一個不完整脈沖。翻轉(zhuǎn)模式則簡單得多定時器計數(shù)器CNT遞增當(dāng)CNT和CCR相等時輸出引腳電平翻轉(zhuǎn)一次然后計數(shù)器繼續(xù)跑等下一次匹配時再翻轉(zhuǎn)。在這個模式下輸出頻率和CCR的關(guān)系非常直接f_pulse f_timer_clk / (2 * CCR)比如定時器時鐘是84MHz想讓脈沖輸出2MHzCCR就是21想輸出100kHzCCR就是420。因為每次翻轉(zhuǎn)電平一個完整脈沖需要兩次匹配所以頻率表達式里有個系數(shù)2寫代碼時千萬別漏。這個關(guān)系帶來的好處是加減速控制徹底變成“按時間序排列的CCR數(shù)組”。加速就是讓CCR從大值往小值走減速就是從小值往大值走DMA每次搬一個數(shù)到CCR寄存器脈沖頻率就跟著變一次。2.2 定時器事件如何“喂”給DMADMA不會自己知道什么時候該搬運它需要一個觸發(fā)源。在源碼配置里我把DMA請求設(shè)置為定時器的比較事件每次CNT和CCR匹配產(chǎn)生一次比較事件DMA就搬一次數(shù)據(jù)到CCR寄存器。配合翻轉(zhuǎn)模式整個時序就是某個脈沖周期開始時DMA已經(jīng)在前一次匹配時把新的CCR值搬進去了。計數(shù)器繼續(xù)增數(shù)到這個新的CCR時匹配引腳翻轉(zhuǎn)同時DMA再搬下一個值過來。頻率隨CCR變化脈沖鏈不會斷。初始化代碼大致長這樣static void axis_pulse_dma_init(TIM_HandleTypeDef *htim, DMA_HandleTypeDef *hdma, uint32_t channel) { hdma-Init.Request channel; // 例如 DMA_REQUEST_TIM1_CC1 hdma-Init.Direction DMA_MEMORY_TO_PERIPH; hdma-Init.PeriphInc DMA_PINC_DISABLE; // 外設(shè)地址固定為CCR hdma-Init.MemInc DMA_MINC_ENABLE; // 內(nèi)存地址遞增 hdma-Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma-Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma-Init.Mode DMA_NORMAL; // 配合半傳輸中斷重填 hdma-Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma); __HAL_LINKDMA(htim, hdma[TIM_DMA_CC1], *hdma); HAL_TIM_OC_Start_IT(htim, channel); // 開啟比較中斷或事件 HAL_DMA_Start_IT(hdma, (uint32_t)freq_table_axis, (uint32_t)htim-Instance-CCR1, segment_len); }注意DMA_MODE_NORMAL這是有意為之的。因為正常模式搬完指定長度就會停方便我控制“一段運動正好跑完”不會自動循環(huán)到錯誤的數(shù)據(jù)上。2.3 乒乓緩沖與半傳輸中斷的重填邏輯加減速過程往往持續(xù)幾千甚至上萬個脈沖周期DMA緩沖不可能一次性放完整條曲線。我的做法是給每個軸分配一個大小固定的緩沖比如512個字DMA跑到一半時觸發(fā)半傳輸中斷跑到末尾時觸發(fā)傳輸完成中斷在這兩個中斷里把下一批頻率字填進去。這和乒乓緩沖的本質(zhì)思路一樣前半段搬運的同時CPU處理的是后半段要用的數(shù)據(jù)等DMA進入后半段前半段已經(jīng)被重新填好隨時可以接著跑。重填邏輯用狀態(tài)機管理volatile uint32_t *dma_buf (uint32_t *)axis_freq_buf[axis_id]; volatile uint32_t seg_fill_index 0; volatile uint32_t seg_total_len 0; void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim) { // 半傳輸中斷 if (__HAL_DMA_GET_FLAG(axis_dma[axis_id], DMA_FLAG_HT)) { __HAL_DMA_CLEAR_FLAG(axis_dma[axis_id], DMA_FLAG_HT); fill_freq_half(axis_id, 0); } // 傳輸完成中斷 if (__HAL_DMA_GET_FLAG(axis_dma[axis_id], DMA_FLAG_TC)) { __HAL_DMA_CLEAR_FLAG(axis_dma[axis_id], DMA_FLAG_TC); if (seg_fill_index seg_total_len) { fill_freq_half(axis_id, 1); } else { // 整個運動段結(jié)束停止定時器 HAL_TIM_OC_Stop(htim, channel); } } }填充函數(shù)里預(yù)處理下一批頻率字然后直接更新DMA源地址。這里有個容易寫錯的點正常模式下DMA跑完一次就停止半傳輸中斷觸發(fā)時DMA還在跑剩余的一半此時不能調(diào)用HAL_DMA_Start_IT重啟DMA否則會打斷正在進行的搬運只需要更新當(dāng)前DMA句柄里的SxNDTR和SxM0AR讓它在當(dāng)前事務(wù)結(jié)束后無縫銜接下一批。這也是為什么源碼里半傳輸中斷只改寄存器不做重啟。3. 加減速曲線從數(shù)學(xué)函數(shù)到DMA緩沖數(shù)組3.1 梯形與S形曲線的離散化公式加減速控制的本質(zhì)是給出一串隨時間變化的脈沖頻率值。梯形加減速的公式最直觀加速段頻率線性增長勻速段保持不變減速段線性下降。寫成代碼就是void gen_freq_table_trapezoid(uint32_t *ccr_table, uint32_t len, uint32_t f_start, uint32_t f_max, uint32_t f_end) { uint32_t accel_len (f_max - f_start) * len / (2*f_max - f_start - f_end); uint32_t decel_len len - accel_len - 1; for (uint32_t i 0; i len; i) { uint32_t f; if (i accel_len) { f f_start (f_max - f_start) * i / accel_len; } else if (i accel_len 1) { f f_max; } else { f f_max - (f_max - f_end) * (i - accel_len - 1) / decel_len; } ccr_table[i] timer_clock / (2 * f); } }實際用起來我發(fā)現(xiàn)梯形加減速在高速段有比較明顯的沖擊感尤其是平臺設(shè)備走到角落突然換向的時候。S形曲線會更順滑工程上不用真的去算完整的正弦積分用余弦近似就夠了void gen_freq_table_s(uint32_t *ccr_table, uint32_t len, uint32_t f_start, uint32_t f_max, uint32_t f_end) { for (uint32_t i 0; i len; i) { float t (float)i / (len - 1); float accel_factor 0.5f - 0.5f * cosf((float)M_PI * t); // 加速段平滑過渡 float decel_factor 0.5f - 0.5f * cosf((float)M_PI * t (float)M_PI); // 減速段反向過渡 uint32_t f; if (t 0.5f) { f f_start (f_max - f_start) * accel_factor; } else { f f_max - (f_max - f_end) * decel_factor; } ccr_table[i] timer_clock / (2 * f); } }注意CCR和頻率是反比關(guān)系頻率變化線性并不代表CCR變化線性。如果直接拿CCR做線性插值實際頻率會呈現(xiàn)嚴重非線性加速前段沖得太猛后段又拖沓。我最初就是直接在CCR表里插值上機一測加速過程有明顯頓挫后來改回“先算頻率表再轉(zhuǎn)換成CCR表”才正常。3.2 緩沖尺寸、加減速點數(shù)與起始頻率的匹配這里涉及一個繞不開的內(nèi)存問題如果每個脈沖周期都更新CCR那么加速10毫秒、平均頻率500kHz就需要約5000個頻率字8個軸就是20000個uint32_t大約80KB內(nèi)存。單段運動還好說如果需要多條加減速曲線排隊內(nèi)存根本不夠。所以源碼里不能把整條曲線一次性裝進DMA緩沖。我的做法是DMA緩沖固定512字CPU在中斷里按需從“規(guī)劃隊列”中取出下一批頻率字實時填充。規(guī)劃隊列保存的不是具體的CCR值而是曲線參數(shù)每次半傳輸中斷時用前面兩個公式現(xiàn)算一批。起始頻率也有講究。頻率太低步進電機會在低速段共振太高則會直接從加速起始點丟步。我一般把起始頻率設(shè)在1kHz到2kHz之間具體看驅(qū)動器的細分和電機負載。再結(jié)合加速時間和目標頻率就能算出加速點數(shù)然后決定中斷里每批生成多少點。一個經(jīng)驗值加速時間150ms、目標頻率1MHz、起始頻率2kHz加速點數(shù)約75000個每批512字需要約300次半傳輸中斷每次中斷里做兩次余弦運算生成512個CCR值在168MHz的MCU上耗時約0.2ms遠小于半緩沖被用完的時間來得及。3.3 參數(shù)整定經(jīng)驗?zāi)檬静ㄆ骺磶状尉投訙p速參數(shù)沒法一上來就拍腦袋定我建議先固定目標頻率只調(diào)加速時間用示波器測脈沖間隔變化。先把曲線拉成純加速段從2kHz加速到目標頻率加速時間從500ms開始往小調(diào)。每次調(diào)完看示波器脈沖間隔的包絡(luò)是否平滑。如果包絡(luò)末端出現(xiàn)陡峭的臺階或者間隔突變說明加減速點數(shù)還沒被平滑算法覆蓋問題通常出在曲線末段的頻率差太小導(dǎo)致兩個相鄰CCR之間差了多個周期。還有一個容易忽略的點停止瞬間也需要減速曲線。如果運動段結(jié)束前不降速DMA直接停了最后一個脈沖周期可能極其短促驅(qū)動器實際上是拖著失步的電機急停。我在規(guī)劃器里強制要求每段運動都帶完整的減速尾段除非是點動操作或者特意要急停。4. 插補源碼設(shè)計DDA粗插補與DMA精插補的分層4.1 DDA累加器溢出的多軸聯(lián)動原理多軸插補最常見也最穩(wěn)的實現(xiàn)是DDA也就是數(shù)字微分分析器。思路是給每個軸維護一個累加器每個插補周期累加一個“步進量”當(dāng)累加器溢出時這個軸輸出一個脈沖。各軸的步進量比例決定了它們實際速度的比例。用代碼表示typedef struct { int32_t accum; // 累加器 int32_t step; // 每個周期的步進量 uint8_t overflow; } dda_axis_t; void dda_tick(dda_axis_t *axes, uint32_t n_axes) { for (uint32_t i 0; i n_axes; i) { axes[i].accum axes[i].step; if (axes[i].accum 0x7FFFFFFF) { axes[i].accum - 0x7FFFFFFF; axes[i].overflow 1; // 這個周期該軸輸出脈沖 } else { axes[i].overflow 0; } } }如果一條直線從(0,0,0,...,0)插補到(target0, target1, ..., target7)那么每軸step target_i * 基準頻率 / 最長軸步數(shù)。最長軸在每個周期都溢出其他軸按比例出脈沖。這樣8軸在同一時間基準上各走各的步數(shù)最后同時到達終點。DDA的工程意義在于粗插補階段只需要定期計算step精插補階段則根據(jù)溢出結(jié)果決定一段時間內(nèi)的脈沖組合完全不用在脈沖級重新規(guī)劃。4.2 運動段數(shù)據(jù)結(jié)構(gòu)與下發(fā)流程源碼里運動規(guī)劃被分成兩層。粗插補層負責(zé)把用戶輸入的軌跡比如一條長直線或圓弧切分成若干直線運動段每段給出終點坐標、合成速度和加減速參數(shù)。精插補層負責(zé)把每個運動段轉(zhuǎn)換成一串可下發(fā)的DMA頻率字。運動段的結(jié)構(gòu)體設(shè)計如下typedef struct { uint32_t axis_mask; // 參與本段的軸掩碼 int32_t target_pos[8]; // 相對于起點的脈沖增量 uint32_t f_start; // 起始頻率 uint32_t f_max; // 段內(nèi)最大頻率 uint32_t f_end; // 結(jié)束頻率 uint32_t accel_time_ms; // 加速時間 uint32_t decel_time_ms; // 減速時間 uint32_t direction_word; // 8軸方向打包值直接寫B(tài)SRR } motion_segment_t;下發(fā)流程是粗插補器生成一個運動段填入motion_segment_t。規(guī)劃器調(diào)用gen_freq_table_*生成頻率曲線參數(shù)同時把方向打包字準備好。配置對應(yīng)軸的DMA源地址和傳輸長度啟動DMA。半傳輸中斷中繼續(xù)生成下一批頻率字。方向字打包很關(guān)鍵。8個軸的方向位分別映射到一個32位寄存器的不同bit方向不變時這個字在運動中全程不變所以一次DMA通道寫GPIO的BSRR就完成了8個軸的換向同步性比逐軸寫IO好很多。4.3 雙DMA在多軸上的分配每4軸一組8軸脈沖輸出我用了兩個定時器每個定時器貢獻4個比較通道。DMA1控制第一個定時器的4個通道DMA2控制第二個定時器的4個通道兩者獨立工作。選型時的硬件限制要提前確認。每個DMA控制器的stream數(shù)量是有限的比如DMA1和DMA2各8個stream如果每路脈沖占一個stream8路正好各占一個控制器的4個stream剩下的stream留給UART、ADC等常規(guī)外設(shè)。這種分配方式的好處是兩個DMA控制器的總線訪問是并行的不會在同一個控制器上讓8路脈沖互相排隊。如果MCU資源更緊張也可以用“少量定時器通道分時復(fù)用”的軟件切換方案但代價是CPU要介入切換時機精度會下降。能上雙DMA就上雙DMA這是最舒服的架構(gòu)。void motion_start_segment(motion_segment_t *seg) { // 前4軸交給DMA1對應(yīng)的stream for (uint32_t i 0; i 4; i) { if (seg-axis_mask (1 i)) { hal_dma1_pulse_go(i, seg); } } // 后4軸交給DMA2對應(yīng)的stream for (uint32_t i 4; i 8; i) { if (seg-axis_mask (1 i)) { hal_dma2_pulse_go(i, seg); } } // 方向字一次性寫入BSRR MODIFY_REG(GPIO_DIR_BSRR, 0xFFFFFFFF, seg-direction_word); // 兩個定時器用從模式同步啟動保證8軸同一拍出發(fā) __HAL_TIM_MOE_ENABLE(htim_pulse_l); __HAL_TIM_MOE_ENABLE(htim_pulse_h); }必須強調(diào)啟動同步。8個軸如果不同時起跑哪怕只差幾十微秒長距離插補終點也可能偏出好幾個脈沖當(dāng)量。我的做法是用一個主定時器的更新事件去觸發(fā)另一個定時器的啟動兩個定時器同步起振DMA的首次搬運也都在同一拍完成。5. 高頻脈沖實測與避坑記錄5.1 實測波形從100kHz到2MHz的建立過程把電機拆下來用示波器直接觀察脈沖輸出腳。加速段起點2kHz、終點2MHz、加速時間800毫秒。示波器上看脈沖間隔從一開始的250微秒逐漸收窄到0.25微秒包絡(luò)是一條漂亮的指數(shù)曲線中間沒有斷裂。減速段也同樣平滑。最緊張的時刻是加速切換到勻速的那一拍頻率字從加速表末端跳到勻速值示波器上看是一個極小的時間跳變但電機并沒有明顯的共震聲或丟步跡象。8軸同跑的實測更說明問題兩個定時器同步啟動后8路脈沖在相位上沒有偏移軸線聯(lián)動軌跡的終點位置誤差始終在1個脈沖當(dāng)量以內(nèi)。這個結(jié)果是靠雙DMA的硬件時序保證的換回中斷方案很難做到。5.2 高頻信號的驅(qū)動級注意事項2MHz輸出在芯片引腳上很漂亮但到了步進驅(qū)動器那邊可能會變成災(zāi)難。高頻方波經(jīng)過長距離的杜邦線或者走線不規(guī)范的PCB上升沿會出現(xiàn)明顯過沖和振鈴驅(qū)動器會把振鈴誤當(dāng)成額外脈沖電機直接多走一步。我在調(diào)試過程中吃過一次虧一根40厘米的普通杜邦線接驅(qū)動器脈沖端2MHz時示波器看出來上升沿振鈴幅度超過1V驅(qū)動器隔三差五丟步。后來把線換成雙絞屏蔽線并且在驅(qū)動器輸入端串了33歐姆電阻振鈴基本消除。還要留意驅(qū)動器本身的硬件上限。很多細分驅(qū)動器支持的步進脈沖頻率上限就是500kHz左右2MHz脈沖打過去驅(qū)動器內(nèi)部的整形電路跟不上等于白給。真正需要高線速度的場景建議降低細分或者升級成總線式閉環(huán)驅(qū)動器頻率指標會寬裕很多。5.3 三個容易翻車的DMA細節(jié)調(diào)試這套源碼時有三個地方反反復(fù)復(fù)出了莫名其妙的問題這里集中記錄一下。第一個坑是DMA觸發(fā)源選錯。定時器的更新事件和比較事件都能作為DMA請求但用PWM模式時應(yīng)該觸發(fā)更新事件去更新ARR用翻轉(zhuǎn)模式時必須觸發(fā)比較事件。如果不想清楚就復(fù)制代碼會出現(xiàn)頻率字搬了但不生效或者脈沖頻率每次滯后一個周期減速段最后多出一個超長脈沖。排查方法是邏輯分析儀抓脈沖間隔看第一個變化周期是不是比預(yù)期晚了一拍。第二個坑是半傳輸中斷和傳輸完成中斷的填充操作互相踩。源地址更新不及時會導(dǎo)致DMA重跑舊數(shù)據(jù)頻率曲線出現(xiàn)周期性回跳。我最后是用一個原子標志位保護填充流程半傳輸中斷里填前半段時如果傳完中斷也來湊熱鬧直接丟棄本次填充請求避免兩個中斷上下文同時改同一個DMA句柄。第三個坑是關(guān)停時序。運動段結(jié)束時如果直接停定時器最后一個脈沖可能只走到一半就斷掉驅(qū)動器的輸入信號被截斷成毛刺。正確做法是DMA跑完傳輸完成中斷后先把優(yōu)先級調(diào)低、清掉比較中斷掛起標志再停止定時器確保完整輸出最后一個低電平。還有一個小細節(jié)是DMA優(yōu)先級。8路DMA同時跑時總線仲裁確實會帶來微小的搬運延遲但對脈沖輸出影響不大因為CCR更新只要求在下一次匹配前完成即可頻率字提前幾百個總線周期就被搬完了。真正要優(yōu)先保的是穩(wěn)定性和可確定性優(yōu)先級上讓兩路低頻關(guān)鍵數(shù)據(jù)比如UART低于任何一路脈沖DMA。這套雙DMA框架最后跑下來最讓我滿意的不是2MHz這個數(shù)字而是CPU負載降下來之后插補規(guī)劃器可以放心地把時間花在更復(fù)雜的路徑前瞻和圓滑過渡上。運動控制永遠是軟硬協(xié)同的事把高頻重復(fù)勞動交給硬件把決策交給CPU這條路值得推到底。