Marlin 固件拆解:一条 G1 命令从串口到定时器的完整旅程,5 层讲清楚
【免费下载链接】MarlinMarlin is a firmware for RepRap 3D printers optimized for both 8 and 32 bit microcontrollers. Marlin supports all common platforms. Many commercial 3D printers come with Marlin installed. Check with your vendor if you need source code for your specific machine.项目地址: https://gitcode.com/GitHub_Trending/ma/Marlin
切片软件导出一份几万行的 G 代码文件,你盯着首层打印成型,热端温度抖一下又稳回来,机器没有出任何幺蛾子。这背后,Marlin 固件在一颗几兆主频的单片机上,把这些行字符一个一个吞进去、排好队、算好速度,再吐成步进脉冲——全程没有操作系统,就是一个主循环加几个中断。
这条链路值得拆开看。我们跟着一行G1 X100 Y50 F6000走一遍:它先过命令队列,再被速度规划器铺成曲线,最后由定时器中断一格一格兑现成脉冲;而加热、屏幕、温度自动上报这些"后台活",全都挤在主循环的缝隙里。下面由外向内,一层一层剥。
一行指令落进串口:先过关行号和校验和
串口送来的是一串没有边界的字节,Marlin 不急着执行,先把它攒成完整的一行。这个工作发生在 queue.cpp 里的get_serial_commands():一个按字符跑的输入状态机,处理分号行尾注释、括号内联注释、引号转义,直到读到换行才把这一行收进固定长度的环形缓冲(就是个首尾相接的定长队列,写指针和读指针绕圈走)。
但如果这一行带了主机协议要求的 N 行号,后面还有两个硬检查:行号必须等于上一条加一,尾部*后面的校验和必须等于前面所有字符的异或。源码里这段逻辑不长:
char *apos = strrchr(command, '*'); if (apos) { uint8_t checksum = 0, count = uint8_t(apos - command); while (count) checksum ^= command[--count]; if (strtol(apos + 1, nullptr, 10) != checksum) { gcode_line_error(F(STR_ERR_CHECKSUM_MISMATCH), p); break; } } else { gcode_line_error(F(STR_ERR_NO_CHECKSUM), p); break; }这段在干嘛:一行命令进队列之前,先把它的完整性验一遍,验不过就地打回。
打回的方式是gcode_line_error()里那句经典的Resend: n——清空接收缓冲,让主机把第 n 行重发一遍。说白了,Marlin 承认串口会丢字节,所以宁可重传也不赌。行号对不上时还有个约定俗成的解法:主机发 M110 重置行号计数器。
这里有个坑很多人没注意:接收阶段就有几条命令是"插队"处理的。M112(kill 停机)这类字在字节流里一出现就直接执行,不等自己在环形缓冲里排队——紧急停机命令要是排队,那它排队的每一毫秒都是事故。
进队之后固件回一个ok给主机;开了 ADVANCED_OK 的话,回包里还捎带着规划器剩余空位 P 和队列剩余空位 B。主机的流控就是靠这两个数字决定要不要发下一行,整个打印过程由此变成"你追我赶但谁也不越界"的握手。
字节流怎么变成一条命令
状态机只有几个状态:正常接收、行尾注释、内联注释、引号串、转义字符。每来一个字符推一次状态,越界就丢弃剩余部分。没有动态内存分配,一行命令最多 MAX_CMD_SIZE 字节,全部走静态数组——这是 8 位机和 32 位机通吃的关键。
丢字节了怎么办
就是前面那套:行号断档或校验和不对,回Resend,主机重发。配合ok里带出的 P/B 余量,主机端(Cura、PrusaSlicer 的发送层)都能据此调节发送节奏。
第一个脉冲还没发出,速度规划器已经算好了整条曲线
队列每转一圈,advance()从队头取出一条命令交给解析器,解析器把参数拆开,移动类命令最终在 planner.h 里变成一个 block。注意:此刻电机纹丝不动,只是入队了一个"移动段"。
每个移动段长这样(节选):
float nominal_speed, // 本段公称速度 (mm/s) entry_speed_sqr, // 上一交接点实际入口速度,平方存 (mm/s)^2 min_entry_speed_sqr, // 该交接点允许的最小入口速度 max_entry_speed_sqr, // 该交接点允许的最大入口速度 millimeters, // 本段总行程 (mm) acceleration; // 加速度 (mm/s^2) uint32_t step_event_count; // 走完本段需要的总脉冲事件数 uint32_t accelerate_before, // 从第几个脉冲事件开始进入匀速 decelerate_start; // 从第几个脉冲事件开始减速这段结构体在干嘛:把一段 G1 移动压扁成几个数,规划的全部结果都落在里面。
速度规划用的是梯形规划——把"先加速、匀速、再减速"算成一段三段直线拼出的速度曲线,这套算法是从 grbl 那里继承来的(planner.h 文件头注释写得很明白)。每个相邻移动段的交界处(junction)都有一个允许速度范围,规划器要解一道约束:本段入口速度受本段出口减速约束和上一段加速约束双向夹击,所以它先正向扫一遍再反向扫一遍,直到所有交接点自洽。转角太急的交接点会额外受 junction_deviation(拐角偏差,单位是毫米级的路径偏离量)压制,转急弯必须降速,不然轨迹直接划出轮廓。
规划完的产物就是那两个索引:第 0 个脉冲事件起按加速度爬升,decelerate_start起开始收速度,中间全是公称速度。规划器把整条路线铺完,后面的执行层只负责照单跑腿。
另外绕不开的一点:规划器缓冲也是定长的。它满了,ok里的 P 值就归零,主机停发。串口缓冲和规划缓冲这两级定长队列,是整条流水线的背压源头。
一个移动段怎么装进 block
字段就是上面那堆:速度、距离、步数、两个转折点索引。存速度的平方而不是速度本身,是因为约束方程里全是二次项,少做几次开方。
两个脉冲索引定生死
accelerate_before和decelerate_start把"什么时候匀速、什么时候该收"钉死在脉冲序号上。执行层不思考,它只是数数。
运动的最后一微米,由定时器中断数出来
到了执行层,毫米已经没意义了,只有脉冲数。stepper.cpp 的中断服务程序被一个硬件定时器按固定频率反复唤醒:每响一次,给本 block 每个轴的剩余步数各减一次,哪个轴先减到零,就发出那个轴的 STEP 脉冲并置好方向位——多条轴同时插补就是这么来的,本质上是 grbl 一脉的计数器法,脉冲节拍里还嵌着 Bresenham 步进(一种决定每个脉冲该迈哪个轴的整数直线插值算法)的思路。当前 block 所有轴的步数都走完,就加载下一个 block,把新的accelerate_before/decelerate_start搬进工作寄存器。
在 AVR 这种没有硬件乘法器的老芯片上,脉冲间的速度换算走的是speed_lookuptable.h里的定点查找表——stepper.h 顶部那个#ifdef __AVR__的 include 就是为此准备的。乘法贵,查表便宜,嵌入式老手法。
顺带一提,stepper.h 里的轴位定义能开满 9 个运动轴(X/Y/Z 外加 I/J/K/U/V/W),挤出机轴最多 8 个,所以这台"引擎"本身是能支撑多轴联动和 8 挤出机的,缺的只是主板引脚。
每次中断进来干了什么
读剩余步数、判断哪个轴到点、拉 STEP、方向已提前设好、block 走完就换。全程几十条指令级别,不允许任何阻塞——所以规划(慢,可以算几百个周期)和执行(快,必须按时)分家,是整条流水线最重要的一个分工。
同一份代码为什么能编译出三十多种芯片的固件
从 AVR 到 STM32、ESP32、RP2040,Marlin 兼容的 MCU 有三十多种。差异被封在 src/HAL/ 目录里:每颗芯片一个子目录,各自实现 fastio(直接操作寄存器读写引脚)、定时器初始化、SPI 这些底层动作。规划器、队列、温度管理这些业务代码从来不碰寄存器,它们只调 HAL 暴露的接口。换芯片等于换一套"插座",上层一行不改。代价是 HAL 目录本身很厚——每个平台的定时器编号、中断向量、引脚映射都得各写各的,这部分代码量基本就是平台数量乘以一套底层驱动。
运动不停,加热也没停:主循环怎么干杂活
上面讲的都是"被命令驱动的活"。但一台打印机的多数时间,CPU 在干没人指挥的活:盯温度、刷屏幕、回温度查询。MarlinCore.cpp 的主循环短得有点朴素:
void loop() { do { marlin.idle(); // 加热、UI、自动上报…… if (card.flag.abort_sd_printing) abortSDPrinting(); queue.advance(); // 取一条队列命令执行 endstops.event_handler(); // …断电延时、LVGL 刷新等 } while (true); }这个循环在干嘛:所有事情都是轮询式的,一轮之内该干嘛干嘛,没有任何时间片切换,也没有任务优先级——它是协作式调度。
idle()就是那个大杂烩任务列表:
void Marlin::idle(const bool no_stepper_sleep) { manage_inactivity(no_stepper_sleep); // 看门狗喂狗、步进休眠等 thermalManager.task(); // 加热,每轮必做 ... hal.idletask(); ... ui.update(); // 读按键、刷新屏幕 }注意顺序:thermalManager.task()在每次 idle 里无条件跑一轮。temperature.cpp 负责读温度 ADC、做平滑、跑 PID 算出软 PWM 占空比。安全逻辑也在这条链路上:目标温度开着、加热输出着,温度却长时间不升,或者升温速率低于阈值,热漂移(thermal runaway)判定就触发kill()——所有加热器断电、电机停、机器锁死等复位。热敏电阻断了读出来是冰点,会先撞 maxtemp/mintemp 范围检查;传感器虚焊在冷端,走的是 runaway 那条路。两条都堵上,"加热丝空烧"才算真正防住。
manage_inactivity()里还有看门狗(watchdog,硬件定时器,代码不按时喂它就复位芯片)和步进休眠:长时间不动的轴自动断电,防止电机堵转发热。
加热放在 idle 里,热漂移判定的是"不升"
PID 的每一项都不复杂,复杂的是判定逻辑:它比的不是"温度对不对",而是"温度该动的时候动不动"。这也是为什么固件里热漂移报错比超温报错更常见——多数故障是传感器链路的问题,不是加热丝的问题。
刷屏和温度上报,也挤在同一个主循环
ui.update()读编码器按键并刷新 LCD;自动上报(M155 温度、M154 位置这类)是一组带节拍的 tick,到点了往串口塞一行数据。所以打印时你每 2 秒收到一条ok T:xx /xx B:xx /xx,不是谁在专门发,而是主循环转到了那一格。
上面这张是 Marlin 固件在 TFT 屏上的启动界面,上电自检和版本号都在这一步给出,也是判断固件有没有正常跑起来的第一个依据。
要动 Marlin 源码,先看这几处
如果你读到这里想上手,按这个顺序看,少走弯路:
- 命令生命周期的主干:
loop()在 MarlinCore.cpp 底部,advance()在 gcode/queue.cpp,一出一进,先把这条线走通,再谈别的。 - 速度算法的入口:planner.cpp 里梯形计算那一段,输入是 junction_deviation 和加速度配置,输出是上面那两个转折点索引。想改运动手感,变量在这里。
- 加一条 G 代码:gcode.cpp 里是一张命令到函数的表,表本身就是全局地图,查表比读代码快。
- 支持一块新主板:别碰业务代码,src/HAL/ 下照着一个现有平台抄目录结构,实现 fastio 和定时器,上层一行不改。
- 所有"行为开关":Configuration.h 和 Configuration_adv.h 是入口,但真正生效的判断散在各文件的 ENABLED 宏里,改之前先全局搜一遍宏名,确认它只影响你以为影响的地方。
这条从串口字节到脉冲计数器的链路,就是 Marlin 固件的全部骨架。它老、它直接、它把每一个工程权衡都摊在台面上——这正是三十多种芯片、几百家整机厂还在用它的原因。
【免费下载链接】MarlinMarlin is a firmware for RepRap 3D printers optimized for both 8 and 32 bit microcontrollers. Marlin supports all common platforms. Many commercial 3D printers come with Marlin installed. Check with your vendor if you need source code for your specific machine.项目地址: https://gitcode.com/GitHub_Trending/ma/Marlin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考