搞运动控制的人多少都遇到过这种场景:GRBL 驱动机床走 CAM 出来的密集小线段,速度忽快忽慢,走到尖角处一顿一顿,严重时还会“过冲”撞上限位。我一开始也以为是电机力矩不够,后来把逻辑分析仪挂在 STEP 引脚上抓脉冲,才意识到问题出在速度前瞻没有吃透。GRBL 的速度前瞻算法核心就两个 pass:反向规划和正向规划,int 这个东西到底在改什么、为什么缺一个都不行,是理解整个运动规划器(planner)的关键。这篇文章我想结合 GRBL 源码,把这两个 pass 的逻辑逐步拆开讲清楚。
适合看这篇的人:改过 GRBL 想自己调前瞻效果却无从下手的;想从 GRBL 移植运动规划到其他 MCU 平台的;以及被“入口速度、出口速度、recalculate_flag”这些概念绕晕的初学者。源码版本以 GRBL 1.1 的 planner.c 为主要参考,老版本 0.9 逻辑基本一致,只有个别命名差异。
1. 速度前瞻到底在解决什么物理问题
1.1 每一段运动内部的“三角形/梯形速度曲线”
先回到最基本的问题:一段直线运动,从起点走到终点,能不能以恒定最高速度从头跑到尾?当然不行,因为速度不能突变,有加速度上限。所以每段运动的真实速度轮廓要么是“加速-匀速-减速”的梯形,要么是“加速立刻减速”的三角形。
速度、加速度、位移之间的关系就一条初中物理公式:
V² = V₀² + 2·a·S
其中 V₀ 是段起点速度,V 是段终点速度,a 是加速度,S 是段长度。GRBL 里所有规划计算都是围绕这个公式展开的,只是它做了一点变形:已知本段终点速度(下一段的入口速度)、加速度、段长,反推“本段入口速度最大能是多少”。
举个例子。加速度设 1000 mm/s²,目标进给速度 5000 mm/min(约 83.3 mm/s),段长 100 mm。从零加速到目标需要 83.3² / (2×1000) ≈ 3.47 mm,减速段同理也是 3.47 mm,加起来 6.94 mm < 100 mm,所以有一段匀速。但如果段长只有 2 mm,加速段 3.47 mm 已经超过整段长度,这时就达不到目标速度,只能走一个“尖顶三角形”,段中点速度被限制为 sqrt(a·S) = sqrt(1000×2) ≈ 44.7 mm/s。
这段运动本体的规划是“单段问题”,很多人改 GRBL 时只盯着加速度和最高速度调,却没意识到真正影响整体效率的是段与段之间怎么衔接。
1.2 连续多段运动的衔接难题
CNC 加工路径是一长串 G 代码直线段,段长可能只有 0.5 mm,而目标进给速度高达几千 mm/min。如果每段都独立按“从零启动、末端停止”来规划,整个加工过程就变成不停加减速,实际平均速度可能只有设定值的 20%。
更麻烦的是,相邻两段之间通常有夹角。以全速从第 N 段直接转入第 N+1 段,拐角处的向心加速度会瞬间施加到机械结构上,轻则丢步,重则崩刀。所以段间速度不能只考虑“能不能在段末停下”,还要考虑“这个夹角允许以多快的速度转过去”。
这两条约束合在一起,就引出了速度前瞻:必须在执行某段之前,提前向后多看几段,统一规划出一个速度剖面,让整个运动链在满足加速度和拐角限制的前提下尽量快。GRBL 把它拆成两个 pass 来做,也就是标题里的反向规划和正向规划。
1.3 GRBL 规划器的工程约束
GRBL 跑在 Arduino 这类 MCU 上,主频 16 MHz,SRAM 只有 2KB 左右。它不可能像 PC 端 CAM 软件那样把整条路径加载进来做全局最优规划,只能开一个很小的环形缓冲区,默认 15 个 block,每次新指令入队后对缓冲区内的段做一次局部重规划。
这个局部重规划就是“两步走”:
- 反向规划:从缓冲区尾部往前,给每个运动段的入口速度套上“减速可行性”上限。
- 正向规划:从缓冲区头部往后,再给每个运动段的入口速度套上“加速可行性”上限。
两个 pass 都用同一公式 V² = V₀² + 2·a·S,只是方向相反、目标不同。
2. 反向规划在源码里究竟做了什么
2.1 反向规划的本质:保证任何一段都刹得住
先讲直觉。假设你开车,前面是一个弯道或者路口,你必须以不超过 40 km/h 的速度通过。你提前多远开始踩刹车?取决于你当前车速和刹车减速度。运动规划里同样:如果下一段的入口速度被限定为 V_next,那么本段末端速度就等于 V_next,本段入口速度 V_entry 必须满足:
V_entry² ≤ V_next² + 2·a·S
大于这个值,本段距离不够把速度降到 V_next。这就是反向规划唯一在做的事:从队列尾部开始,把每一段的入口速度上限逐级向前推导。尾部没有下一段,等效于“终点后速度为零的虚拟段”,所以最后一个真实段必须先满足“能减速到 0”。
为什么叫“反向”?因为推导方向和运动执行方向相反,从最后的段倒推到最前面的段。它回答的问题是:按当前各段的长度和加速度,每段最大能以多快的速度进来,才能保证后面不会刹不住。
2.2 planner_reverse_pass 核心逻辑拆解
在 GRBL 1.1 的 planner.c 里,反向规划是静态函数 planner_reverse_pass。我把核心循环用近似源码的伪代码列出来,方便对照理解:
// 反向规划:从缓冲区尾部向前扫 static void planner_reverse_pass() { uint8_t block_index = block_buffer_head; block_t *block[3]; // 等效在队列尾部放一个速度为零的虚拟段 // 所以最后一个真实块的入口速度平方被强制修正为 0 block[0] = &block_buffer[prev_block_index(block_index)]; block[0]->entry_speed_sqr = 0.0; block[0]->recalculate_flag = true; // 从尾向前逐个窗口滑动 while (block_index != block_buffer_head) { block[2] = block[1]; block[1] = block[0]; block_index = prev_block_index(block_index); block[0] = &block_buffer[block_index]; // 当前块的入口速度如果已经小于等于下一段要求,说明它天然刹得住 if (block[0]->entry_speed_sqr <= block[1]->entry_speed_sqr) { continue; } // 用“下一段入口速度 + 本段减速距离”反推本段最大入口速度 float entry_speed_max = sqrt( block[1]->entry_speed_sqr + 2.0f * block[0]->acceleration * block[0]->step_event_count ); // 只做收紧,不做放松 if (block[0]->entry_speed_sqr > entry_speed_max) { block[0]->entry_speed_sqr = entry_speed_max; block[0]->recalculate_flag = true; // 标记这个段需要正向规划复核 } } }注意这里 step_event_count 就是本段主轴方向的总步数,也就是位移 S 的步进域表达。acceleration 是块内加速度,单位在 GRBL 内部已经换算成 steps/min²。block[1]->entry_speed_sqr 可以理解为“下一段的入口速度”,由于段之间速度连续,它同时是当前段的出口速度。
这个循环让我最初看源码时困惑了很久:为什么只修改 entry_speed_sqr,不直接改速度?因为 GRBL 刻意用速度的平方参与比较和计算,好处有两个:一是免去大量 sqrt 运算,MCU 上开根号很贵;二是当速度接近零时平方值下降很快,能更敏感地捕捉到低速状态。只在最后需要真正输出步进速率时才开根号。
2.3 “最后一段被限制成从零启动”这个设计细节
很多初次读源码的人会问:为什么反向规划里直接把最后一个真实块的 entry_speed_sqr 设为 0?如果最后一段很长,明明可以高速进段再减速到 0,强行设成 0 不是浪费吗?
这是 GRBL 一个偏保守但安全的设计决策。它的含义是:缓冲区收敛到“没有下一条指令”时,最后一个 block 被当做一个“从静止启动、到静止结束”的独立运动段。这样无论前方指令何时断掉,机器都不会在段中间以高速状态戛然而止,也大大简化了规划器的状态管理。
我在自己移植的固件里试过“让最后一段的入口根据段长自适应提高”,即优化成 V_entry_max = sqrt(2·a·S),效果上确实能提高队列尾部附近的速度利用率,但代价是停止逻辑变复杂:一旦缓冲区又被填满,这些优化过的尾部段可能干扰新一轮规划。所以 GRBL 官方选择“尾部强制零速启动”,在实际雕刻中代价很小,换来的是逻辑非常鲁棒。这算是一个基于常见实践的补充判断,不是源码注释里写的,但顺着代码看下来你会认同这个取舍。
3. 正向规划:从头部往后修正“够不够力”
3.1 反向压完速度,还有一个反方向的问题
反向规划保证了“刹得住”,但它有一个盲区:它用下一段的入口速度来限制本段入口,却完全没考虑本段入口速度能不能被前一段真正提供。举个例子:前一段特别短,加速度又不大,前一段还没把速度提起来就到了终点;但反向规划基于“后段较长”给当前段留了一个较高的入口速度上限。结果就是:当前段按这个入口速度规划,可前一段实际根本给不了这么高的出口速度,速度曲线在衔接处出现“倒挂”。
正向规划就是来解决这个“够不够力”的问题。它从缓冲区头部(正在执行或即将执行的段)开始,沿着运动方向往后扫,用前一段的入口速度、加速度、段长,重新计算本段实际能达到的最大入口速度,只做向下修正。
3.2 正向规划到底在计算什么
正向规划使用的物理公式和反向完全相同,只是方向反了:
V_entry² ≤ V_prev_entry² + 2·a_prev·S_prev
也就是:本段入口速度不可能超过“前一段从入口速度经过前一段全长的加速后”能达到的速度。如果本段原有的 entry_speed_sqr 比这个可达速度还高,就必须降下来。
对应到 GRBL 源码,正向规划在 planner_recalculate 中完成。它还有一个工具位叫 recalculate_flag,这个标记是反向规划打上的,表示“这个 block 的入口速度被反向修改过,需要正向重新核算”。正向规划扫描时如果发现某段带 flag,就立刻用前一段的数据做一次可行性校验:
// 正向规划核心循环(伪代码,逻辑等价于 GRBL 1.1 planner_recalculate) static void planner_recalculate() { // 从正在执行的块(buffer head)开始 // block[0] 是当前执行块,block[1] 是下一个待执行块 block[0] = &block_buffer[block_buffer_head]; block[0]->recalculate_flag = false; block_index = next_block_index(block_buffer_head); block[1] = &block_buffer[block_index]; while (block_index != block_buffer_tail) { // 找出带标记的块 if (block[1]->recalculate_flag) { // 前一段的入口速度 + 前一段的加速能力,决定当前段入口上限 float max_entry_speed = sqrt( block[0]->entry_speed_sqr + 2.0f * block[0]->acceleration * block[0]->step_event_count ); if (block[1]->entry_speed_sqr > max_entry_speed) { block[1]->entry_speed_sqr = max_entry_speed; } // 清标记,防止重复计算 block[1]->recalculate_flag = false; } // 窗口往前滑一格 block[0] = block[1]; block_index = next_block_index(block_index); block[1] = &block_buffer[block_index]; } }看明白没有?正向规划的“前一段”其实用的是 block[0] 的 entry_speed_sqr,也就是前一段自己的入口速度。这里有一个隐藏假设:某段达到最大吞吐时,段的出口速度近似等于入口速度(或者更准确说,段内部是否能加速到更高速度由本段 nominal_speed 决定,正向规划只解决“从上一段能拿到多少”)。这个近似在 GRBL 中已经够用,因为 nominal_speed 那一层还有钳位在兜底。
3.3 两个 pass 配合,最终速度剖面如何收敛
现在可以串起来看了。一次完整的前瞻重算流程是:
- 新 block 入队,带着初始 entry_speed_sqr(由拐角限速或前人速度决定)。
- 反向规划从尾部往前扫,凡是“刹不住”的入口速度一律收紧,并打上 recalculate_flag。
- 正向规划从头部往后扫,凡是“前段给不了”的入口速度再收紧一层,同时把标记清掉。
- 反向后可能会有部分段因为前段被修正得更低,需要再次收紧,所以这两个 pass 在源码中经常配合成一个公共入口 planner_recalculate 被反复调用。
收敛性不用担心,因为每个 pass 都只做“向下修正”,速度约束是单调递减的。最坏情况就是减速信号从尾部一路传递到头部,缓冲区多长就传多远,反正 15 个 block 的循环很快就跑完。在 MCU 上整个过程耗时通常不过几十微秒,相比步进中断周期可以忽略。
这里我觉得特别值得留意的是:反向规划是主规划,正向规划是修正。如果你只看到反向规划的代码,可能会觉得正向规划是多余的,实际跑过密集小线段后就明白,缺了正向规划,速度曲线会在短-长-短这种交替段型上出现明显的跳动。这正是很多魔改 GRBL“反向正常,正向乱抖”问题的来源。
4. 前瞻缓冲区怎么转起来:block 结构和重算时机
4.1 block_t 到底存了什么
要彻底看懂上面两个函数,必须熟悉 block_t 的字段。GRBL 1.1 中 block_t 是规划器和步进中断之间的唯一桥梁,每次运动入队都会生成一个 block。我把核心字段整理成表格,方便查:
| 字段 | 单位 | 含义 |
|---|---|---|
| steps[N_AXIS] | step | 该段各轴步数 |
| step_event_count | step | 主轴最大步数,等效段长 |
| acceleration | steps/min² | 该段加速度,规划域单位 |
| nominal_speed | steps/min | 该段最大允许速度 |
| entry_speed_sqr | (steps/min)² | 入口速度平方,规划器输出 |
| max_entry_speed_sqr | (steps/min)² | 拐角限制的最大入口速度平方 |
| recalculate_flag | bool | 反向修改标记,正向消费 |
| direction_bits | bitmask | 各轴方向 |
注意单位。源码里块的长度用 step_event_count,速度和加速度全部用步进域单位 steps/min 和 steps/min²。你在 $ 参数里设置的 max rate 是 mm/min,acceleration 是 mm/sec²,进入 planner 前会被换算成步进域单位。这也是后面我踩坑的地方:直接用毫米做反向规划公式的计算,结果差了整整一个每毫米步数系数的平方。
4.2 新指令入队后的完整触发链
每一次 plan_buffer_line 调用,都会经历:解析坐标差值、计算步数、设置 max_entry_speed_sqr(拐角限速在这里算)、写入环形缓冲区、更新 head 指针、最后触发一次完整重算。
重算顺序在不同版本里有细微差异:有些版本把反向和正向都收在 planner_recalculate 这个公共函数里,先反向再正向(或先用反向再做正向,再在函数尾部做一次反向收尾)。我查过 1.1 的源码,planner_recalculate 内部确实同时包含了正向段和反向段。不同 fork 可能会调整顺序,但核心不变:每次入队都重新算一遍缓冲区里所有未执行段的速度约束。
4.3 每执行完一个 block 也要重算
很多人忽略了 plan_execute 这个入口。每完成一个 block,步进中断会回调 plan_execute,把 block_buffer_tail 向前推进一格,然后再次执行 planner_recalculate。
为什么执行完也要重算?因为执行端消耗了缓冲区头部,原来被反向规划“从尾部压过来”的速度约束链等于被砍掉一截,剩下的段理论上可以提高速度。虽然表面逻辑是“只做向下修正”,但当前执行段的移除会让“前一段可提供速度”发生变化,所以新的正向计算实际上允许后续段恢复到更高的入口速度。这也是 GRBL 在连续运动中能逐渐把速度拉上去的原因:随着执行推进,约束链释放,速度自然爬升。
我自己在调试时最喜欢在 plan_execute 里加一个计数,看每个 block 从入队到执行完成之间被重算了几次。数据非常直观:缓冲区越满、线段越短,重算次数越多,速度曲线越“碎”。如果重算次数反复超过 5-6 次仍然收敛不了,几乎可以断定是加速度设置过大或者封锁条件互相矛盾。
5. 拐角限速:max_entry_speed_sqr 的来源
5.1 junction deviation 在限制什么
前面讲了两个 pass 都在处理“段内加减速”,但还有一个更隐蔽的约束:段与段之间的拐角。GRBL 没有真正的圆弧过渡,它用 junction deviation(交汇偏差)来估算“这个尖角允许以多快的速度转过去”。
想象两条直线段在 P 点相交,交点处允许路径出现一个直径为 junction deviation 的小圆弧偏差,也就是机床可以“切掉”尖角的一点点。这个偏差对应一个圆弧半径 R,而向心加速度 a = V² / R。给定允许偏差和最大加速度,就能反推最大过弯速度。
GRBL 源码里用夹角余弦来算:
// 两段单位方向向量的负点积,就是夹角的余弦值 float junction_cos_theta = -( unit_vec[0] * prev_unit_vec[0] + unit_vec[1] * prev_unit_vec[1] + unit_vec[2] * prev_unit_vec[2] ); if (junction_cos_theta > 0.999999f) { // 近似直线,不做限速 junction_velocity = max_velocity; } else { float sin_theta_d2 = sqrt(0.5f * (1.0f - junction_cos_theta)); junction_velocity = sqrt( (junction_acceleration * junction_deviation * (1.0f + junction_cos_theta)) / sin_theta_d2 ); }公式不用死记,核心是:夹角越尖锐,cos 越小,sin(θ/2) 越大,允许速度越低。junction deviation 越大,允许过的偏量越大,速度越高,但轮廓误差也越大。
5.2 junction deviation 和两个 pass 的关系
这个 junction_velocity 会换算成 max_entry_speed_sqr,存进块的入口速度上限。反向规划和正向规划都遵守这个上限——它们只会把它压得更低,绝不会抬高。所以你可以这样理解整个速度前瞻:
每个 block 的入口速度 = min(段内减速约束, 段前加速约束, 拐角速度约束, 指令设定速度)
四个约束层层收紧,两个 pass 负责其中两个,拐角限速在入队时已经算好。我之前调试时一直以为速度上不去是加速度太小,后来打印才发现瓶颈全在 junction deviation。默认 0.01 mm 对于做粗加工来说太保守,放大到 0.05 mm 效果立竿见影,代价是轮廓圆角明显一点。这个参数属于“加工质量和效率的权衡”,没有绝对的正确答案。
5.3 用数据观察速度剖面
调参最怕“凭感觉”。我的建议是在源码里加一个调试函数,当串口收到特定指令时,把当前缓冲区每个 block 的 entry_speed_sqr、nominal_speed、step_event_count、acceleration 全部打印出来。格式类似:
idx entry_sqr nominal_speed steps accel 0 0.000 4500.0 320 120000.0 1 120.5 4500.0 64 120000.0 2 310.2 4500.0 128 120000.0看 entry_speed_sqr 从尾部到头部是否单调不增(反向约束)、从头部到尾部是否能逐步恢复(正向约束)。如果出现某一段 entry_speed_sqr 明显低于两侧的“凹陷”,那多半是拐角限速卡的,不是加速度卡。这个打印技巧在分析复杂路径时比任何仿真都有效。
6. 我在移植和调参时踩过的坑
6.1 单位不一致导致的“怪病”
第一次把 GRBL 的速度前瞻逻辑搬到我自己的 STM32 固件时,所有公式都照抄,结果速度曲线完全不对:极短段反而全速冲,长段却龟速爬。排查到最后发现是 acceleration 单位错了。GRBL 内部 block->acceleration 是 steps/min²,而我在移植时图省事直接用 mm/sec² 代进公式,忽略了换算系数。
换算关系是:
steps/min² = (mm/sec²) × (60²) × steps_per_mm
如果每毫米 80 步,1 mm/sec² 的加速度换算成 steps/min² 就是 1 × 3600 × 80 = 288000。差一个数量级,规划结果完全不可信。这也是 GRBL 源码里代码之所以全部用平方速度、步进域单位的根本原因:在规划域提前完成所有单位换算,避免实时计算时反复转换。
6.2 BLOCK_BUFFER_SIZE 太小导致前瞻失效
默认 BLOCK_BUFFER_SIZE 是 15。如果加工的线段特别密、缓冲区两下就填满,而 15 个 block 覆盖的实际物理长度远小于减速距离,那前瞻就形同虚设——每个 block 反向规划时都只能看到“身后”一小段,速度还没提起来就开始为后面减速。
我试过把 BLOCK_BUFFER_SIZE 从 15 加到 32,短线段密集的路径平均速度提升非常明显。但代价也很大:Uno 的 SRAM 才 2KB,一个 block_t 大概 60-100 字节,32 个 block 可能直接让内存爆掉。所以这个参数不是越大越好,要结合 MCU 内存和实际段长来权衡。我的经验是:先统计 CAM 输出最常见的段长,让它乘以 15 大于你的减速距离,基本就够用了。
6.3 只调反向不调正向的误区
有些魔改版本为了“提速”,把所有加速度相关的反向限制都放宽,却不动正向规划。结果就是速度剖面在短段处出现明显的“过冲预判”:反向允许它高速进段,正向却因为前段太短给不了,两项叠加导致速度曲线震荡,步进电机会发出一种很奇怪的“嘶嘶”声,同时机身震动明显加大。
正确做法是:想提速,先看是哪个约束在卡。打印出每个 block 的 entry_speed_sqr 和 max_entry_speed_sqr,如果全是 max_entry_speed_sqr 在起作用,说明是拐角限速,调 junction deviation;如果是反向公式在起作用,说明是加速/减速能力不够,调加速度;如果速度曲线震荡,先检查正向公式有没有正确消费 recalculate_flag。
6.4 临界区保护的坑
plan_buffer_line 运行在主循环上下文,而 step 中断也会读取 block 数据。两个 pass 在修改 entry_speed_sqr 时,如果正好碰到步进中断在读同一个 block,可能出现“读到一半被改写”的脏数据。GRBL 原始代码用 cli/sei 或 AVR 的原子操作保护关键区,移植到 RTOS 环境时一定要记得加互斥锁或暂停中断。
这个坑非常隐蔽,因为它不是每次都会复现,只有在缓冲区快满、中断频率很高时才会偶发。我排查了整整一个下午,最后在 planner_recalculate 前后加了一个 GPIO 翻转,用逻辑分析仪发现确实有中断穿插,才定位到问题。做移植的朋友,这段临界区保护一定不要省。
最后说一点个人体会
把 GRBL 的速度前瞻彻底看懂之后,再看其他运动控制方案(比如 Marlin 的 junction deviation 算法、 TinyG 的多轴速度规划)会轻松很多,因为核心思想都是同一套物理约束:V² = V₀² + 2aS 在两个方向上的反复套用。我自己后来做闭环控制固件时,也沿用了“反向定刹车上限、正向定加速下限、拐角单独限速”的框架,只是把公式换成闭环可用的加速度模型。建议想深入的朋友,先把这篇文章里两个 pass 的顺序和标记位逻辑画在纸上,再对照源码读一遍,收获会比单纯刷代码大得多。如果在移植过程中遇到“速度曲线震荡”或“长段提速慢”的问题,回头检查单位换算和正向规划是否完整,多半就能找到答案。