
做多轴运动控制的朋友应该都有过这种体验八个轴一起动步进驱动器的脉冲频率一拉高CPU就被中断淹没加减速稍微算得慢一点脉冲间隔就肉眼可见地抖动。我之前在某平台项目里折腾8轴插补开始用定时器中断直接翻转IO跑到300kHz左右就到头了再往上要么丢脉冲要么电机失步。后来把方案推倒用双DMA架构重新实现了脉冲输出定时器只负责计数DMA负责搬运频率字CPU只处理插补规划和缓冲填充实测可以稳定输出到2MHz级别加减速全程不打断脉冲流8轴联动也没再出现抖动。这篇文章打算把整套源码的核心思路拆开讲清楚双DMA到底怎么分工、加减速曲线怎么变成DMA能“直接吃”的频率字数组、插补算法怎么和DMA缓冲对接以及我在示波器上看到的那些坑。想搞3D打印固件、CNC控制板、自动化点胶或者贴片设备的朋友应该能从这里直接抄走一套可用的框架。1. 先想清楚瓶颈8轴插补为什么不能用中断硬扛1.1 定时器中断输出脉冲的极限在哪很多人第一版方案是把脉冲输出放在定时器更新中断里每次中断翻转GPIO顺便算下一个频率。这条路线在小规模、低频率下没问题但一旦频率超过几百kHz就会全线崩溃。原因是每次中断的开销太大。以一颗主频168MHz的MCU为例Cortex-M4内核进入中断大约需要12个时钟周期做压栈和取向量中断处理函数里要开关标志位、重新装载比较值、计算下一拍再加上中断返回的出栈一次中断轻松吃掉100多个时钟周期。1MHz的脉冲输出意味着1微秒就要来一次中断CPU连喘气的机会都没有更别提8个轴同时动。就算主频再高一点时序抖动也会要命。中断响应有不确定性相同优先级下可能会出现几十纳秒到几百纳秒的延迟这在电机低速时无所谓高速时直接表现为“咔哒”一声丢步然后整个插补轨迹就偏了。1.2 DMA方案解决的三个核心矛盾把“定时器中断翻转脉冲”换成“定时器硬件输出脉冲 DMA搬运频率字”本质上是在回答三个问题CPU占用脉冲周期的动态变化不需要CPU干预定时器每产生一次事件DMA就把内存里的下一个频率字搬到定时器比较寄存器CPU完全旁观。时序确定性DMA搬运由硬件事件触发触发到寄存器更新之间只有DMA取指、读内存、写外设这几个固定总线周期抖动远小于中断响应。脉冲连续性加减速过程中每个脉冲的周期都在变DMA按顺序搬数组里的数据天然保持节奏不会因为CPU被其他任务抢占而突然卡顿。这三个问题解决后脉冲频率的上限就跑到了DMA和定时器本身能承受的极限而不是CPU的调度极限。1.3 双DMA的具体分工频率字搬运与方向字写IO标题里的“双DMA”在我这套源码里有两层含义第一层是物理上的两个DMA控制器。常见的MCU内部通常有两个DMA控制器比如F4系列有DMA1和DMA2各自带若干个独立的stream互不抢通道。8个轴的脉冲输出我把前4轴挂在DMA1上后4轴挂在DMA2上两条DMA路径分别跑各自的定时器事件总线负载被分散开避免了所有轴堆在一个控制器上排队。第二层含义更重要每个轴脉冲链路的“频率字搬运”和“方向字写入”由不同的DMA事务完成。脉冲频率必须逐个周期更新属于高频小粒度事务方向信号只需要在运动段切换时变化一次属于低频一次性事务。源码里把这两类事务拆开频率字走定时器比较事件的DMA请求方向字则在开启运动段前由另一个DMA事务直接把打包好的32位方向字写入GPIO的BSRR寄存器。这种分工的好处是8个轴的方向变化可以一次完成而不是8次独立写IO从机制上保证多轴方向切换的同步性。2. 双DMA脉冲引擎的内部机制2.1 输出比较翻转模式下频率字就是CCR先看信号怎么产生。我用的是定时器输出比较翻转模式而不是大多数人习惯的PWM比较模式。PWM模式下ARR决定周期CCR决定占空比想改变输出频率就得改ARR但ARR一改当前周期可能被截断很容易产生一个不完整脉冲。翻转模式则简单得多定时器计数器CNT递增当CNT和CCR相等时输出引脚电平翻转一次然后计数器继续跑等下一次匹配时再翻转。在这个模式下输出频率和CCR的关系非常直接f_pulse f_timer_clk / (2 * CCR)比如定时器时钟是84MHz想让脉冲输出2MHzCCR就是21想输出100kHzCCR就是420。因为每次翻转电平一个完整脉冲需要两次匹配所以频率表达式里有个系数2写代码时千万别漏。这个关系带来的好处是加减速控制彻底变成“按时间序排列的CCR数组”。加速就是让CCR从大值往小值走减速就是从小值往大值走DMA每次搬一个数到CCR寄存器脉冲频率就跟着变一次。2.2 定时器事件如何“喂”给DMADMA不会自己知道什么时候该搬运它需要一个触发源。在源码配置里我把DMA请求设置为定时器的比较事件每次CNT和CCR匹配产生一次比较事件DMA就搬一次数据到CCR寄存器。配合翻转模式整个时序就是某个脉冲周期开始时DMA已经在前一次匹配时把新的CCR值搬进去了。计数器继续增数到这个新的CCR时匹配引脚翻转同时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; // 外设地址固定为CCR hdma-Init.MemInc DMA_MINC_ENABLE; // 内存地址递增 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这是有意为之的。因为正常模式搬完指定长度就会停方便我控制“一段运动正好跑完”不会自动循环到错误的数据上。2.3 乒乓缓冲与半传输中断的重填逻辑加减速过程往往持续几千甚至上万个脉冲周期DMA缓冲不可能一次性放完整条曲线。我的做法是给每个轴分配一个大小固定的缓冲比如512个字DMA跑到一半时触发半传输中断跑到末尾时触发传输完成中断在这两个中断里把下一批频率字填进去。这和乒乓缓冲的本质思路一样前半段搬运的同时CPU处理的是后半段要用的数据等DMA进入后半段前半段已经被重新填好随时可以接着跑。重填逻辑用状态机管理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 { // 整个运动段结束停止定时器 HAL_TIM_OC_Stop(htim, channel); } } }填充函数里预处理下一批频率字然后直接更新DMA源地址。这里有个容易写错的点正常模式下DMA跑完一次就停止半传输中断触发时DMA还在跑剩余的一半此时不能调用HAL_DMA_Start_IT重启DMA否则会打断正在进行的搬运只需要更新当前DMA句柄里的SxNDTR和SxM0AR让它在当前事务结束后无缝衔接下一批。这也是为什么源码里半传输中断只改寄存器不做重启。3. 加减速曲线从数学函数到DMA缓冲数组3.1 梯形与S形曲线的离散化公式加减速控制的本质是给出一串随时间变化的脉冲频率值。梯形加减速的公式最直观加速段频率线性增长匀速段保持不变减速段线性下降。写成代码就是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); } }实际用起来我发现梯形加减速在高速段有比较明显的冲击感尤其是平台设备走到角落突然换向的时候。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和频率是反比关系频率变化线性并不代表CCR变化线性。如果直接拿CCR做线性插值实际频率会呈现严重非线性加速前段冲得太猛后段又拖沓。我最初就是直接在CCR表里插值上机一测加速过程有明显顿挫后来改回“先算频率表再转换成CCR表”才正常。3.2 缓冲尺寸、加减速点数与起始频率的匹配这里涉及一个绕不开的内存问题如果每个脉冲周期都更新CCR那么加速10毫秒、平均频率500kHz就需要约5000个频率字8个轴就是20000个uint32_t大约80KB内存。单段运动还好说如果需要多条加减速曲线排队内存根本不够。所以源码里不能把整条曲线一次性装进DMA缓冲。我的做法是DMA缓冲固定512字CPU在中断里按需从“规划队列”中取出下一批频率字实时填充。规划队列保存的不是具体的CCR值而是曲线参数每次半传输中断时用前面两个公式现算一批。起始频率也有讲究。频率太低步进电机会在低速段共振太高则会直接从加速起始点丢步。我一般把起始频率设在1kHz到2kHz之间具体看驱动器的细分和电机负载。再结合加速时间和目标频率就能算出加速点数然后决定中断里每批生成多少点。一个经验值加速时间150ms、目标频率1MHz、起始频率2kHz加速点数约75000个每批512字需要约300次半传输中断每次中断里做两次余弦运算生成512个CCR值在168MHz的MCU上耗时约0.2ms远小于半缓冲被用完的时间来得及。3.3 参数整定经验拿示波器看几次就懂加减速参数没法一上来就拍脑袋定我建议先固定目标频率只调加速时间用示波器测脉冲间隔变化。先把曲线拉成纯加速段从2kHz加速到目标频率加速时间从500ms开始往小调。每次调完看示波器脉冲间隔的包络是否平滑。如果包络末端出现陡峭的台阶或者间隔突变说明加减速点数还没被平滑算法覆盖问题通常出在曲线末段的频率差太小导致两个相邻CCR之间差了多个周期。还有一个容易忽略的点停止瞬间也需要减速曲线。如果运动段结束前不降速DMA直接停了最后一个脉冲周期可能极其短促驱动器实际上是拖着失步的电机急停。我在规划器里强制要求每段运动都带完整的减速尾段除非是点动操作或者特意要急停。4. 插补源码设计DDA粗插补与DMA精插补的分层4.1 DDA累加器溢出的多轴联动原理多轴插补最常见也最稳的实现是DDA也就是数字微分分析器。思路是给每个轴维护一个累加器每个插补周期累加一个“步进量”当累加器溢出时这个轴输出一个脉冲。各轴的步进量比例决定了它们实际速度的比例。用代码表示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 * 基准频率 / 最长轴步数。最长轴在每个周期都溢出其他轴按比例出脉冲。这样8轴在同一时间基准上各走各的步数最后同时到达终点。DDA的工程意义在于粗插补阶段只需要定期计算step精插补阶段则根据溢出结果决定一段时间内的脉冲组合完全不用在脉冲级重新规划。4.2 运动段数据结构与下发流程源码里运动规划被分成两层。粗插补层负责把用户输入的轨迹比如一条长直线或圆弧切分成若干直线运动段每段给出终点坐标、合成速度和加减速参数。精插补层负责把每个运动段转换成一串可下发的DMA频率字。运动段的结构体设计如下typedef struct { uint32_t axis_mask; // 参与本段的轴掩码 int32_t target_pos[8]; // 相对于起点的脉冲增量 uint32_t f_start; // 起始频率 uint32_t f_max; // 段内最大频率 uint32_t f_end; // 结束频率 uint32_t accel_time_ms; // 加速时间 uint32_t decel_time_ms; // 减速时间 uint32_t direction_word; // 8轴方向打包值直接写BSRR } motion_segment_t;下发流程是粗插补器生成一个运动段填入motion_segment_t。规划器调用gen_freq_table_*生成频率曲线参数同时把方向打包字准备好。配置对应轴的DMA源地址和传输长度启动DMA。半传输中断中继续生成下一批频率字。方向字打包很关键。8个轴的方向位分别映射到一个32位寄存器的不同bit方向不变时这个字在运动中全程不变所以一次DMA通道写GPIO的BSRR就完成了8个轴的换向同步性比逐轴写IO好很多。4.3 双DMA在多轴上的分配每4轴一组8轴脉冲输出我用了两个定时器每个定时器贡献4个比较通道。DMA1控制第一个定时器的4个通道DMA2控制第二个定时器的4个通道两者独立工作。选型时的硬件限制要提前确认。每个DMA控制器的stream数量是有限的比如DMA1和DMA2各8个stream如果每路脉冲占一个stream8路正好各占一个控制器的4个stream剩下的stream留给UART、ADC等常规外设。这种分配方式的好处是两个DMA控制器的总线访问是并行的不会在同一个控制器上让8路脉冲互相排队。如果MCU资源更紧张也可以用“少量定时器通道分时复用”的软件切换方案但代价是CPU要介入切换时机精度会下降。能上双DMA就上双DMA这是最舒服的架构。void motion_start_segment(motion_segment_t *seg) { // 前4轴交给DMA1对应的stream for (uint32_t i 0; i 4; i) { if (seg-axis_mask (1 i)) { hal_dma1_pulse_go(i, seg); } } // 后4轴交给DMA2对应的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轴同一拍出发 __HAL_TIM_MOE_ENABLE(htim_pulse_l); __HAL_TIM_MOE_ENABLE(htim_pulse_h); }必须强调启动同步。8个轴如果不同时起跑哪怕只差几十微秒长距离插补终点也可能偏出好几个脉冲当量。我的做法是用一个主定时器的更新事件去触发另一个定时器的启动两个定时器同步起振DMA的首次搬运也都在同一拍完成。5. 高频脉冲实测与避坑记录5.1 实测波形从100kHz到2MHz的建立过程把电机拆下来用示波器直接观察脉冲输出脚。加速段起点2kHz、终点2MHz、加速时间800毫秒。示波器上看脉冲间隔从一开始的250微秒逐渐收窄到0.25微秒包络是一条漂亮的指数曲线中间没有断裂。减速段也同样平滑。最紧张的时刻是加速切换到匀速的那一拍频率字从加速表末端跳到匀速值示波器上看是一个极小的时间跳变但电机并没有明显的共震声或丢步迹象。8轴同跑的实测更说明问题两个定时器同步启动后8路脉冲在相位上没有偏移轴线联动轨迹的终点位置误差始终在1个脉冲当量以内。这个结果是靠双DMA的硬件时序保证的换回中断方案很难做到。5.2 高频信号的驱动级注意事项2MHz输出在芯片引脚上很漂亮但到了步进驱动器那边可能会变成灾难。高频方波经过长距离的杜邦线或者走线不规范的PCB上升沿会出现明显过冲和振铃驱动器会把振铃误当成额外脉冲电机直接多走一步。我在调试过程中吃过一次亏一根40厘米的普通杜邦线接驱动器脉冲端2MHz时示波器看出来上升沿振铃幅度超过1V驱动器隔三差五丢步。后来把线换成双绞屏蔽线并且在驱动器输入端串了33欧姆电阻振铃基本消除。还要留意驱动器本身的硬件上限。很多细分驱动器支持的步进脉冲频率上限就是500kHz左右2MHz脉冲打过去驱动器内部的整形电路跟不上等于白给。真正需要高线速度的场景建议降低细分或者升级成总线式闭环驱动器频率指标会宽裕很多。5.3 三个容易翻车的DMA细节调试这套源码时有三个地方反反复复出了莫名其妙的问题这里集中记录一下。第一个坑是DMA触发源选错。定时器的更新事件和比较事件都能作为DMA请求但用PWM模式时应该触发更新事件去更新ARR用翻转模式时必须触发比较事件。如果不想清楚就复制代码会出现频率字搬了但不生效或者脉冲频率每次滞后一个周期减速段最后多出一个超长脉冲。排查方法是逻辑分析仪抓脉冲间隔看第一个变化周期是不是比预期晚了一拍。第二个坑是半传输中断和传输完成中断的填充操作互相踩。源地址更新不及时会导致DMA重跑旧数据频率曲线出现周期性回跳。我最后是用一个原子标志位保护填充流程半传输中断里填前半段时如果传完中断也来凑热闹直接丢弃本次填充请求避免两个中断上下文同时改同一个DMA句柄。第三个坑是关停时序。运动段结束时如果直接停定时器最后一个脉冲可能只走到一半就断掉驱动器的输入信号被截断成毛刺。正确做法是DMA跑完传输完成中断后先把优先级调低、清掉比较中断挂起标志再停止定时器确保完整输出最后一个低电平。还有一个小细节是DMA优先级。8路DMA同时跑时总线仲裁确实会带来微小的搬运延迟但对脉冲输出影响不大因为CCR更新只要求在下一次匹配前完成即可频率字提前几百个总线周期就被搬完了。真正要优先保的是稳定性和可确定性优先级上让两路低频关键数据比如UART低于任何一路脉冲DMA。这套双DMA框架最后跑下来最让我满意的不是2MHz这个数字而是CPU负载降下来之后插补规划器可以放心地把时间花在更复杂的路径前瞻和圆滑过渡上。运动控制永远是软硬协同的事把高频重复劳动交给硬件把决策交给CPU这条路值得推到底。