☰
双DMA配合定时器实现8轴插补脉冲输出与加减速控制方案
2026/10/20 16:45:53 网站建设 项目流程

做运动控制的同行应该都有体会:一旦轴数多起来,脉冲输出就成了系统里最考验设计功底的一环。最近我又折腾完一套8轴插补运动控制的源码方案,核心是用双DMA配合定时器完成高频率脉冲输出,外加一整套加减速规划。整个过程踩了不少坑,今天抽空写出来,跟准备做多轴控制的朋友们聊聊。

这套方案解决的核心问题有三个:8路脉冲的同步输出与插补联动、高频率脉冲下CPU负载撑不住的问题、加减速过程的平滑性。如果你正在调多轴点胶机、绕线机、雕刻机、3D打印机这类设备,或者单纯想搞明白DMA配合定时器生成脉冲的原理,这篇都值得往下看。源码整体用C写,思路不复杂,真正花时间的是对定时器事件、DMA触发方式、插补数据表的理解。

1. 项目背景与核心设计思路

1.1 8轴插补的典型场景与核心矛盾

8轴插补不是8个轴各自跑各自的点位,而是要求它们在一段运动里按比例、按时序联动,形成一个空间轨迹。典型场景比如多工位点胶机的八轴联动、绕线机的主轴与排线轴同步、大型料盘收放卷系统的多轴张力协调。这里的关键是:插补结果不是某一个轴跑得快、某一个跑得慢那么简单,而是每一个参考时刻,各轴的位移比例都必须严格满足设定值。偏差一旦积累起来,轻则轨迹失真,重则撞机。

要做到这一点,脉冲级的时间控制必须非常精确。电机每收到一个脉冲走一步,脉冲之间的时间间隔就是这一瞬间的速度,多个轴脉冲之间的先后关系就是轨迹的微观形态。所以整个系统最核心的矛盾是:既要让脉冲频率足够高(高速运行时每秒钟几万到几十万步),又要在每一个脉冲间隔里完成多轴插补的判定和脉冲输出,还不能让CPU陷入中断风暴。

1.2 为什么单靠定时器中断撑不住

最容易想到的做法是:开一个定时器中断,每次中断里按插补算法决定哪些轴输出脉冲,然后手动翻转GPIO或者写定时器比较值。这个做法在2轴、3轴、速度不高的时候完全没问题。但轴数到8、频率到几十千赫兹之后,问题就来了。假设参考脉冲频率50kHz,一个周期20微秒,中断里要做8个DDA累加器的更新,再算方向、写8个输出引脚,这已经是很紧张的时间预算;如果频率上到200kHz,周期变成5微秒,几乎所有的CPU时间都花在中断进出上,主循环里的通信、解析、状态管理全部会被拖垮。

更麻烦的是抖动。中断响应存在延迟,如果中断里有分支判断或者别的任务抢占,脉冲间隔就会忽长忽短。低速时不明显,高速时会表现为电机啸叫、震动,甚至失步。这是我在调试中体会很深的一点:软件延时和中断翻转在高频脉冲场景下不可靠,必须让硬件把脉冲一个个"吐"出来。

1.3 双DMA方案的选择逻辑

所以我最后选择的是定时器加DMA的硬件自动化方案。核心思想:把每一次要输出的脉冲参数预先算好,放到内存表里,然后让DMA按定时器的节奏,自动把表里的数据搬进定时器寄存器。CPU只在运动开始前做规划,运动过程中完全放手。

这里用了两路DMA,而不是一路,原因是脉冲输出有两个维度:时间维度和空间维度。时间维度是"什么时候出脉冲",由定时器重装值ARR决定,也就是频率变化;空间维度是"这一次该哪几个轴出脉冲、方向如何",由轴掩码表和方向表决定。第一路DMA专门搬运ARR值序列,控制频率变化,这就是加减速;第二路DMA专门搬运各轴的本周期输出状态,控制插补的数据分发。两路DMA都由同一个定时器更新事件触发,天然同步。

2. 双DMA脉冲输出机制拆解

2.1 定时器配合DMA生成脉冲的原理

先讲基础。定时器工作在PWM模式时,计数到ARR值会产生更新事件,同时自动重装载,输出引脚按照比较值CCR翻转,从而形成一个周期脉冲。周期的长短由ARR决定,也就是频率由ARR决定。平时我们改频率,是CPU写一次ARR;但如果把ARR改成DMA的目标地址,让DMA在每次更新事件后自动写入一个预存的ARR值,那么每出一个脉冲,DMA就把下一个脉冲的周期装好,频率曲线就这样被硬件一步步"读"了出来。

这里有个关键细节:DMA搬运的是内存表里的数据到ARR寄存器。每次更新事件产生一个DMA请求,DMA搬一次数据,搬完内存地址自动加一,指向表中下一个周期值。表的元素个数就等于要输出的脉冲个数(步数),DMA传输次数设定为总步数,搬完就产生传输完成中断,告诉CPU这一段运动结束了。

2.2 两路DMA的角色分工

现在看双DMA。第一路DMA负责把速度剖面表搬到主定时器的ARR。速度剖面表在规划阶段生成,每一行就是一个ARR值,对应参考脉冲的一个周期。加速阶段ARR从大变小,匀速阶段ARR保持不变,减速阶段ARR从小变大。CPU只要把表填好,剩下的频率变化完全交给硬件。

第二路DMA负责把轴状态表搬到输出端口的寄存器。轴状态表也是规划阶段生成的,每一行包含8个轴的脉冲输出掩码和方向掩码。因为8轴共用同一个参考脉冲节拍,所以表长度和速度剖面表严格一致,第n条记录对应第n个参考脉冲时刻,8个轴该不该动、往哪个方向动,全在这一行里。

两条DMA都由同一个定时器的更新事件触发,从而保证:每产生一个参考脉冲,ARR被改成下一个周期值的同时,8个轴的状态也被同步刷新。这样硬件层面就在同一个时间戳内完成了频率更新和插补分发,不需要中断,也不存在先后错位。实测下来这个同步关系非常稳定,脉冲间的相对抖动可以控制在极小的范围内。

2.3 关键配置参数与寄存器细节

参数选择上,有几个点必须算清楚。

一是定时器时钟和频率分辨率。以一款168MHz主频的MCU为例,定时器时钟也是168MHz的话,ARR寄存器的值就是定时器时钟除以目标脉冲频率,再减一。对于1kHz到200kHz的常见脉冲频率,ARR值范围在839到168000之间,16位寄存器足够。但如果要更低频率,比如0.1Hz做点动,ARR值会超过65535,就得考虑预分频或者单独走一个低速定时器。这个点在实际选型时很容易被忽略。

二是DMA传输宽度。ARR建议用16位,和定时器寄存器宽度匹配,避免DMA拆分字节带来的额外开销。轴状态表一行通常用一个16位字就够了,高8位放方向,低8位放掩码,DMA按半字搬运,一次一个周期,干净利落。

三是DMA触发方式。定时器更新事件作为DMA请求源,这里必须是内存到外设,即内存里的表搬到外设寄存器。方向别搞反,我见过不止一个人在这上面浪费一晚上。配置完DMA后,建议先把定时器输出关掉,等DMA搬运确认无误后再使能更新事件,否则初始化阶段很容易丢第一个脉冲周期。

提示:使用DMA之前,先关掉定时器输出,等DMA搬运完成并确认内存地址和传输计数无误后再使能更新事件。否则很容易在初始化阶段就丢一个脉冲周期。

3. 加减速控制算法实现

3.1 梯形加减速与S曲线的取舍

脉冲频率不能突变。电机物理上受不了,机械振动也会让精度报废,所以必须做加减速规划。最常见的两个方案是梯形和S曲线。

方案计算量平滑度适用场景
梯形加减速小,规划简单加速度在启停瞬间突变,高速下可能有低频振动大多数步进电机场景
S曲线加减速大,规划复杂加速度连续变化,振动小,轨迹更柔顺高精度伺服联动、刚性弱的结构

梯形加减速就是匀加速、匀速、匀减速三段,加速度恒定,速度曲线呈梯形。优点就是计算量小,规划简单。缺点是加减速率突变,加速度在启动和变向瞬间会跳变,刚性较强的结构容易出现低频振动。

S曲线则在加速度上再加一层平滑,让加速度从零缓变,速度曲线呈S形,振动更小,但规划复杂,计算量大。对8轴联动的系统来说,如果每轴都单独跑S曲线,规划时间会翻好几倍。我的做法是:默认用梯形,要求特别高的场合把加速度斜率分段,用若干段梯形拼出近似的S形状。因为8轴共用同一条参考速度曲线,再加各轴的比例映射,规划成本只增加一点点。

3.2 速度剖面表里面的数值是怎么算出来的

速度剖面表要回答一个问题:给定起点频率、终点频率、加速度,每一步脉冲的周期值是多少。之所以用"每一步",是因为步进电机的速度跟步频率成正比,而每出一个脉冲,时间前进一个周期,频率就按加速度更新一次。

以匀加速段为例,假设当前频率是f,当前脉冲的周期T=1/f。在这个周期里速度增量是a乘以T,所以下一个脉冲的频率f_next等于f加上a/f。写成离散递推就是:f_n+1 = f_n + a / f_n。对应的ARR值就是定时器时钟除以f_n。这个递推式虽然简单,但在高频段要注意浮点运算开销。规划阶段离线算好放到表里,执行阶段就不需要再算;如果要在运行中动态生成,建议转成Q格式定点运算,否则占用CPU时间会很明显。

不过这里有个很多人踩过的坑:直接拿f_n+1 = f_n + a/f_n递推算出来的周期,和物理上严格的匀加速运动有细微误差,原因是每个脉冲实际占用的时间间隔不完全等于1/f_n,而是和硬件装载ARR的时刻有关。对大多数步进电机来说这个误差可以忽略,但如果你做的是高精度伺服联动,建议用时间积分的方式重建脉冲时刻表,即从t=0开始,不断累加时间,直到越过下一个脉冲时刻点为止。

// 计算一个脉冲周期对应的ARR值 static uint32_t MakeArr(uint32_t freq_hz, uint32_t timer_hz) { return (timer_hz / freq_hz) - 1u; } // 生成梯形加减速速度剖面表,返回实际表项数量 uint32_t BuildProfile(uint16_t *arr_tbl, uint32_t max_steps, uint32_t f_start, uint32_t f_max, float accel_step, float decel_step, uint32_t timer_hz) { uint32_t idx = 0; float f = (float)f_start; // 加速段:每个脉冲周期后频率增加 a * dt,dt约等于1/f while (f < (float)f_max && idx < max_steps) { arr_tbl[idx++] = (uint16_t)MakeArr((uint32_t)f, timer_hz); f += accel_step / f; } // 匀速段:恒定频率,但要预留减速距离 while (idx < max_steps) { float need = (f - (float)f_start) * f / decel_step; // 粗略估算减速距离 if (need >= (float)(max_steps - idx)) break; arr_tbl[idx++] = (uint16_t)MakeArr((uint32_t)f, timer_hz); } // 减速段:频率降到起始频率 while (idx < max_steps && f > (float)f_start) { f -= decel_step / f; if (f < (float)f_start) f = (float)f_start; arr_tbl[idx++] = (uint16_t)MakeArr((uint32_t)f, timer_hz); } return idx; }

上面这段代码里的减速距离估算是简化版,生产环境建议在规划前先计算好减速段需要的步数,再去填充匀速段,这样更可控。我调试时发现,如果匀速段剩余步数判断不准,会出现减速提前或过冲两个极端,前者浪费行程时间,后者直接丢步报警。

3.3 表驱动与实时计算的权衡

速度剖面表放在内存里,8个轴真的要用8张表吗?不需要。很多项目里,8个轴的脉冲当量不同,但加减速时间律是一样的,所以理论上可以共用一张单位速度曲线。每个轴根据自己的目标步数和参考轴步数的比例,决定在哪个参考脉冲上出脉冲,这本来就是DDA插补干的活。真正要慎重的是内存开销。

假设每次运动最长5万步,ARR表每项16位,就是100KB;轴状态表每项16位,又是100KB;合计200KB,很多MCU的RAM没有这么大。我的处理办法是把一次长运动拆成多段:加减速段各生成一张小表,匀速段用固定ARR值跑,不需要把整条匀速轨迹都存下来。这样表的总长度通常只有几千项,内存占用小一个数量级。在代码里就是分段的DMA搬运,段与段之间用DMA传输完成中断衔接。

4. 8轴插补算法与同步机制

4.1 用DDA实现多轴直线插补

DDA(数字微分分析器)是经典的插补方法,原理非常直白。假设8个轴的目标步数分别是N0到N7,取其中最大的作为参考轴步数R。对每个轴维护一个累加器acc[i],初始为0。每来一个参考脉冲,就把N[i]加进acc[i];如果acc[i]大于等于R,说明这个轴在这一参考周期内应该走一步,就给它发一个脉冲,同时acc[i]减去R。如果目标步数恰好等于参考轴步数,那它每个周期都发脉冲;如果目标步数是参考轴的一半,它每隔一个周期发一次脉冲,误差被分散在整个运动过程里,不会在开头或结尾集中。

实际测试中这个算法非常稳。它本质上是用整数除法的时间平均来逼近连续比例,误差最多一个脉冲,且分散在整段运动里。对于8轴而言,只需要在每个参考脉冲到来时做8次累加和比较,计算量可以忽略不计。8轴同时联动时,只要状态表生成正确,末端轨迹就能维持很直的线性度。

4.2 生成轴状态表的具体流程

有了DDA逻辑,轴状态表的生成就有章可循了。规划阶段拿到参考速度剖面后,确定总参考脉冲数R;然后从第一个参考脉冲开始,模拟一遍DDA,每次得到8个轴的输出掩码,连同方向位一起写进轴状态表。方向位由目标步数的正负决定,一次直线插补期间方向不变。如果有圆弧插补,方向会变化,可以把方向也放到状态表的高位,让DMA在每一个周期同时更新方向和脉冲使能,实现更复杂的轨迹。

typedef struct { uint8_t step_mask; // bit0~bit7:本周期需要输出脉冲的轴 uint8_t dir_mask; // bit0~bit7:方向位,1为正,0为负 } AxisState; // 生成8轴直线插补状态表 uint32_t BuildDDATable(AxisState *state_tbl, const int32_t target_step[AXIS_NUM], uint32_t ref_steps) { int32_t acc[AXIS_NUM] = {0}; for (uint32_t n = 0; n < ref_steps; n++) { uint8_t mask = 0, dir = 0; for (int i = 0; i < AXIS_NUM; i++) { if (target_step[i] < 0) dir |= (1u << i); int32_t abs_step = target_step[i] > 0 ? target_step[i] : -target_step[i]; acc[i] += abs_step; if (acc[i] >= (int32_t)ref_steps) { acc[i] -= (int32_t)ref_steps; mask |= (1u << i); } } state_tbl[n].step_mask = mask; state_tbl[n].dir_mask = dir; } return ref_steps; }

这个过程看起来是纯软件模拟,实际开销也很低。一次几万步的运动,生成表只需几毫秒,比运动本身的时间小几个数量级,完全可以接受。生成完毕后,两路DMA就位,CPU就可以去处理通信或界面逻辑了。

4.3 多轴同步启动与精准停止

最后一步是同步。8个轴必须在同一个时刻开始运动,也要在同一个时刻停止,否则轨迹首尾都会偏。启动同步我用的是主定时器事件触发所有输出通道,而不是分先后启动多个定时器。硬件上把所有通道的使能位先准备好,最后一个统一开闸。

停止同步同样依赖DMA传输完成。当DMA_T的传输计数走完,产生完成中断,在中断里同时关闭输出端口和主定时器。由于ARR表最后一项已经是减速段的最终周期,最后一个脉冲发出之后,所有轴在同一个参考脉冲上收尾,不会出现某轴多走一步的情况。这一点在有往复走位、对起点终点精度有严格要求的设备上特别重要。

5. 实操:源码架构与关键代码

5.1 模块划分

源码工程我分成了四个文件:profile生成、插补表生成、DMA和定时器驱动、运动调度。profile文件负责算加减速表和输出速度曲线;插补表文件负责算轴状态表;驱动文件负责初始化定时器和DMA、启动搬运、处理完成中断;调度文件负责把一次运动从"输入目标步数"变成"两张表加启动命令"。

这么拆的好处是替换方便。比如换一个不同主频的MCU,只需要改驱动层,profile和插补表完全不变。如果想加圆弧插补,只需要在插补表生成时改算法,驱动层不用动。这也是我建议读者参考这套源码时最值得保留的结构。

5.2 双DMA与定时器初始化要点

定时器初始化要注意初始值。建议先把ARR设成一个安全的低频值,比如1000,对应1kHz,避免启动瞬间因为ARR太大或太小产生频率异常。PWM模式下把CCR设为ARR的一半,输出占空比50%,步进电机驱动器的电平识别没有负担,脉冲宽度也足够。

void Motion_Timer_Init(void) { // 定时器时钟,按实际芯片配置分频 TIM_TimeBase_Init(168, TIM_CKD_DIV1); // 预分频168,计数频率约1MHz TIM_SetAutoreload(1000); // 初始ARR,避免启动瞬间频率异常 TIM_PWM_Config(TIM_CH1, 500); // 占空比50% TIM_DMA_Request_Enable(TIM_DMA_UPDATE); // 使能更新事件DMA请求 }

DMA配置最关键的两件事:内存地址必须指向表的首元素,传输次数必须等于表长度减一。因为初始化时定时器已经用了第一个ARR值,DMA从第二个值开始搬。轴状态DMA则指向16位状态表的首地址,次数同样对齐。

启动顺序也很有讲究。先启动两路DMA,延时几个微秒,再使能定时器的更新DMA请求。这样能确保第一个更新事件到来时,DMA已经处于就绪状态。

void Motion_SegStart(const uint16_t *arr_tbl, uint32_t arr_cnt, const uint16_t *state_tbl, uint32_t state_cnt) { DMA_Config(DMA_T, (uint32_t)arr_tbl, (uint32_t)&TIM_ARR, arr_cnt); DMA_Config(DMA_S, (uint32_t)state_tbl, (uint32_t)&MOTOR_STATE_REG, state_cnt); DMA_Enable(DMA_T); DMA_Enable(DMA_S); DelayUs(10); // 确保DMA就绪 TIM_EnableUpdateDMA(); }

5.3 一次完整运动的执行流程

完整流程走一遍,便于你对照自己的实现:

  1. 上层下发目标:8个轴各自的目标步数和方向。
  2. profile模块生成速度剖面表,得到参考总步数R。
  3. 插补表模块按DDA算法生成R条轴的输出状态。
  4. 驱动层把两张表地址分别赋给两路DMA,设置传输次数。
  5. 启动DMA,稍作延时后启动主定时器。
  6. DMA_T和DMA_S在定时器更新事件驱动下自动搬运,CPU不参与。
  7. DMA_T传输完成中断里,关闭定时器输出,通知上层运动结束。

第5步的"稍作延时"是经验之谈。DMA启动需要几个时钟周期才能稳定响应请求,如果定时器立刻启动,第一个更新事件可能抢在DMA就绪之前,导致第一拍的数据没有被装载。我实际测试时遇到过第一脉冲周期异常的情况,加了一个软延时后消失。

6. 常见问题与排查技巧

6.1 脉冲抖动问题

运行实测中,如果示波器看到脉冲间隔忽大忽小,先别急着怀疑算法。最常见的原因有三个:DMA搬运和定时器更新事件之间没有对齐、内存表跨了缓存行、或者DMA优先级被别的请求抢占。我排查的顺序是先看是否有高优先级中断抢占了DMA请求通道;其次确认DMA是内存到外设、外设地址是否正确;最后看表的数据是否有编译器优化导致的字节对齐问题。把状态表声明成16位对齐,能解决相当一部分莫名抖动。

现象可能原因排查方向
脉冲间隔忽大忽小DMA优先级被抢占检查其他外设DMA请求
第一拍频率异常DMA未就绪定时器已启动启动顺序加延时
偶发抖动表数据字节对齐问题状态表用16位对齐
运动末尾多走一步传输计数设置错误检查DMA次数是否等于表长减一

6.2 DMA完成中断丢失

另一个容易踩的坑是DMA传输完成中断被阻塞。如果两张DMA表长度正好用完,而你在完成中断里还要做下一段运动的拼接,这时候要注意:DMA完成中断在传输计数器从1减到0时产生,优先级必须高于任何可能把它堵住的任务。我曾经把完成中断优先级设低了,结果在匀速段结束时偶发抖动,查了很久才定位到是另一个外设中断长时间占用了总线。

另外,不要在一次运动结束后立刻释放DMA通道,要确认定时器已经完全停止。否则下一次启动DMA时,残留的传输请求可能会被带入新的运动,导致第一拍多出脉冲。

6.3 8轴同步性快速验证

最后分享一个不用示波器也能初步验证8轴同步的方法。在状态表里固定让第0轴每一步都动,把其他轴设成不同步数比例,然后让设备走一条很慢的直线,用千分表或者激光位移传感器测量末端轨迹。如果轨迹是一条完美的直线,说明同步是对的;如果轨迹有明显的阶梯抖动,说明某个轴的脉冲在时间上偏了,优先检查该轴对应的状态位是否和参考脉冲严格同步。

还有一个土办法:让两个轴分别接LED,观察LED亮灭的节奏是否均匀。虽然粗糙,但能快速暴露明显的失步问题。同步性测试一定要在低速、中速、高速三个档位都做一遍,很多问题只在高速段才会暴露。


这套源码方案我前后调了两三个版本,最大的体会是:多轴插补的难点往往不在数学,而在工程实现——到底怎么让硬件按预定的节奏执行。双DMA加定时器这条路线,把频率控制和空间分配都交给了硬件,是应对高频率、多轴联动非常实用的路子。

如果你也准备在项目里试,我建议先拿2轴验证整个数据流,把两张表、两路DMA的时序彻底搞明白,再扩展到8轴。另外,规划表的内存管理值得一开始就留好接口,后续加圆弧、样条插补时,只需要替换表生成算法,驱动层几乎不用动。后面我还会继续分享这套源码里的圆弧插补和前瞻速度规划部分,感兴趣的朋友可以先把这套基础架构跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询