GRBL速度前瞻算法拆解:反向规划与正向规划如何协同运作
搞运动控制的人多少都遇到过这种场景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 段直接转入第 N1 段拐角处的向心加速度会瞬间施加到机械结构上轻则丢步重则崩刀。所以段间速度不能只考虑“能不能在段末停下”还要考虑“这个夹角允许以多快的速度转过去”。这两条约束合在一起就引出了速度前瞻必须在执行某段之前提前向后多看几段统一规划出一个速度剖面让整个运动链在满足加速度和拐角限制的前提下尽量快。GRBL 把它拆成两个 pass 来做也就是标题里的反向规划和正向规划。1.3 GRBL 规划器的工程约束GRBL 跑在 Arduino 这类 MCU 上主频 16 MHzSRAM 只有 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_countstep主轴最大步数等效段长accelerationsteps/min²该段加速度规划域单位nominal_speedsteps/min该段最大允许速度entry_speed_sqr(steps/min)²入口速度平方规划器输出max_entry_speed_sqr(steps/min)²拐角限制的最大入口速度平方recalculate_flagbool反向修改标记正向消费direction_bitsbitmask各轴方向注意单位。源码里块的长度用 step_event_count速度和加速度全部用步进域单位 steps/min 和 steps/min²。你在 $ 参数里设置的 max rate 是 mm/minacceleration 是 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 的顺序和标记位逻辑画在纸上再对照源码读一遍收获会比单纯刷代码大得多。如果在移植过程中遇到“速度曲线震荡”或“长段提速慢”的问题回头检查单位换算和正向规划是否完整多半就能找到答案。