ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 3D打印机固件源码深度解析:运动控制与G代码执行闭环

STM32 3D打印机固件源码深度解析:运动控制与G代码执行闭环

简介:本资源为开源3D打印机核心固件GRBL的完整源码包,面向嵌入式开发者、3D打印爱好者及高校机电/自动化专业学习者,旨在帮助理解G代码解析、运动控制与步进电机驱动等底层实现原理。压缩包共57个文件,以17个C源文件和20个H头文件为主体,构成完整的固件逻辑框架;辅以5个说明类TXT、4个Python脚本(用于构建与调试)、1个PDF文档及配套Makefile、README.md等工程文件,总大小仅348KB,轻量易读。目前已有1574人学习下载,适合开展固件定制、性能调优或教学实验。源码涵盖解析器、运动控制器、步进驱动、安全保护及串口通信五大核心模块,结构清晰、注释规范,可直接编译烧录至Arduino平台,是深入掌握3D打印控制逻辑与嵌入式实时编程的优质实践素材。

1. 这不是个“烧录固件就完事”的3D打印机源码包:它把运动控制、G代码解析、热床PID、SD卡文件系统全摊开在你面前,适合想搞懂“为什么Z轴会抖”“为什么挤出量忽多忽少”的硬核调试者

某开发者在调试一台FDM机型时,发现打印第一层始终有0.1mm高度偏差,调了几十次Z-offset都没用。最后翻进这个源码包的stepper.cpp里,才看到默认启用了微步插值补偿,而他用的A4988驱动板根本没接MS1/MS2引脚——信号悬空导致细分倍数随机跳变。这不是一个拿来改改温度参数就能跑的“配置型”项目,它是一整套可追踪、可打断、可单步验证的嵌入式控制逻辑闭环。源码覆盖从USB串口接收G代码、到步进电机脉冲生成、再到热敏电阻ADC采样与PID运算的完整链路,核心模块全部用C++封装成可替换组件。如果你正被“打印错层”“回抽拉丝”“热床不均”这类问题卡住,又厌倦了在论坛里问“我该调哪个参数”,这份资源就是你该拆开的第一块主板。


2. 架构总览:五层模块化设计,每一层都留了钩子(hook)让你插手

这个项目不是传统Arduino风格的“一个.ino塞到底”,而是按嵌入式实时系统思路分层组织。整个工程基于C++11标准构建,所有硬件抽象层(HAL)与业务逻辑解耦,编译时通过宏开关控制功能启用。我拿到手第一件事就是打开src/core/目录,确认了它的分层结构:

2.1 主控芯片适配层:STM32F103C8T6是默认靶心,但已预留移植接口

项目默认使用STM32F103C8T6(俗称“蓝 pill”),但所有外设驱动都封装在hal/stm32f1xx/下,且每个外设类(如HAL_UART,HAL_ADC,HAL_TIM)都继承自纯虚基类HAL_Interface。这意味着如果你要换成ESP32或RP2040,只需重写对应HAL实现,无需动上层G代码解析器。关键点在于:

  • hal/stm32f1xx/hal_uart.cpp中,HAL_UART::transmit()底层调用的是HAL_UART_Transmit_IT()而非阻塞式HAL_UART_Transmit(),确保串口接收不丢帧;
  • hal/stm32f1xx/hal_adc.cpp里对热敏电阻采样做了16次滑动平均+中值滤波,采样周期由ADC_SAMPLE_INTERVAL_MS宏控制,默认为50ms;
  • 所有HAL初始化都在main.cpp的hal_init()函数中完成,顺序严格为:RCC → GPIO → UART → ADC → TIM → NVIC。

2.2 G代码解析引擎:支持G0/G1/G28/G92/G10/G4等32条指令,语法树可调试

G代码不是字符串简单strstr()匹配,而是走了一套轻量级LL(1)解析流程。核心在src/gcode/parser.cpp:

// src/gcode/parser.cpp bool GCodeParser::parseLine(const char* line) { if (!line || *line == ';' || *line == '\0') return true; // 忽略注释和空行 reset(); // 清空当前指令缓存 const char* p = line; while (*p && *p != ';') { // 分号前为有效内容 if (isalpha(*p)) { char cmd = toupper(*p); p++; if (isdigit(*p)) { int num = 0; while (isdigit(*p)) { num = num * 10 + (*p - '0'); p++; } current_cmd.code = cmd; current_cmd.number = num; parseParameters(p); // 解析X/Y/Z/E/F/S等参数 executeCommand(); // 执行前可在此加断点观察current_cmd return true; } } p++; } return false; }

提示:executeCommand()函数内部用switch(current_cmd.code)分发,每个case末尾都有LOG_DEBUG("Executed G%d", current_cmd.number)。你在调试时只要打开串口日志(波特率115200),就能看到每条G代码被拆解后的原始参数值,比如G1 X10.5 Y20.3 E5.2 F1500会输出X=10.500, Y=20.300, E=5.200, F=1500.000——这比看Marlin的M114反馈直观十倍。

2.3 运动控制核心:Bresenham直线插补 + 梯形加减速,支持多轴联动

所有坐标移动最终落到src/motion/planner.cpp。它不直接发脉冲,而是维护一个BlockQueue(最多8个待执行运动块),每个Block包含起点、终点、速度曲线、步进方向等信息:

// src/motion/planner.h struct Block { float steps[AXIS_COUNT]; // 各轴步数(X/Y/Z/E) uint32_t step_event_count; // 总步数,用于Bresenham计数 float speed_x, speed_y, speed_z, speed_e; // 各轴瞬时速度(mm/s) float acceleration; // 当前段加速度(mm/s²) bool is_accelerating; // 是否处于加速段 };

关键逻辑在Planner::buffer_line()中:先根据目标坐标和进给速度算出各轴步数,再用Bresenham算法生成误差累加器初值,最后按梯形速度曲线切分成多个微小时间片(默认STEPPER_TIMER_US = 1250,即800Hz更新频率)。这里有个血泪经验:如果你改了DEFAULT_ACCELERATION但没同步调整MAX_JUNCTION_DEVIATION,拐角处会出现明显“顿挫”,因为加速度突变被限幅器截断了。

2.4 温控子系统:双路PID独立运行,支持PT100/NTC/Thermistor三种传感器

温度控制不在主循环里轮询,而是由独立TIM3定时器每200ms触发一次temp_control_task()。src/thermal/pid_controller.cpp实现了完整的增量式PID:

// src/thermal/pid_controller.cpp float PIDController::compute(float setpoint, float measured) { float error = setpoint - measured; integral += error * sample_time; // 抗积分饱和:限制integral在[-200, +200]区间 if (integral > 200.0f) integral = 200.0f; if (integral < -200.0f) integral = -200.0f; float derivative = (measured - last_measured) / sample_time; last_measured = measured; float output = kp * error + ki * integral - kd * derivative; // PWM占空比限制在0~255 return constrain(output, 0.0f, 255.0f); }

注意sample_time是硬编码的0.2s,不是动态计算的——这是为了保证PID周期稳定。热床(BED)和喷嘴(NOZZLE)各用一套PID参数,存在config.h中:

#define NOZZLE_KP 22.2f #define NOZZLE_KI 1.08f #define NOZZLE_KD 114.0f #define BED_KP 100.0f #define BED_KI 0.45f #define BED_KD 2000.0f

这些值不是拍脑袋定的,而是作者在tools/tune_pid.py里用Ziegler–Nichols临界比例度法实测收敛后填入的。

2.5 文件系统与SD卡支持:FatFS精简版,支持长文件名但禁用Unicode

SD卡驱动走SPI总线,初始化在hal/stm32f1xx/hal_sdcard.cpp中完成。项目没用完整FatFS,而是裁剪了ff.c中仅保留f_open/f_read/f_close三个函数,去掉f_write和目录操作——毕竟3D打印只需要读G代码文件。关键约束:

  • 文件名必须是8.3格式(如CALIB.GCO),不支持中文或空格;
  • SD卡必须格式化为FAT16(非FAT32),因MCU RAM有限,无法加载FAT32的扩展BIOS参数块;
  • src/storage/sd_reader.cpp中,SDReader::readNextLine()每次只读64字节到缓冲区,避免大文件一次性加载。

3. 编译与烧录:Keil MDK-ARM v5.37是唯一验证环境,别试PlatformIO

这个项目对工具链有强绑定,不是所有IDE都能正确处理它的启动流程和中断向量表。我试过CLion+GCC、VSCode+PlatformIO,全在startup_stm32f103xb.s链接阶段报undefined reference to 'SystemInit'——因为作者在system_stm32f1xx.c里重写了SystemInit(),而某些工具链默认链接了CMSIS自带版本。

3.1 Keil工程结构:四个必须修改的配置项

打开.uvprojx文件后,重点检查以下位置:

  • Target选项卡→Xtal(MHz)必须设为8.0(外部晶振频率),否则SysTick和UART波特率全乱;
  • Output选项卡→Name of Executable设为printer.elf,勾选Create HEX File;
  • User选项卡→ 在Run #1中填入fromelf --bin !L --output printer.bin,生成烧录用二进制;
  • C/C++选项卡→Define栏必须包含STM32F103xB,USE_HAL_DRIVER,缺一不可。

3.2 烧录步骤:ST-Link V2是唯一推荐硬件,J-Link会失败

用ST-Link Utility(v4.6.0)烧录时,务必按此顺序操作:

  1. 连接ST-Link:SWDIO → PA13,SWCLK → PA14,GND → GND,3.3V不接(目标板自供电);
  2. 在Utility中选择Target → Connect,若提示“Device not found”,检查PA13/PA14是否被其他外设复用(如串口1的TX/RX);
  3. Flash → Program,选择生成的printer.bin,起始地址填0x08000000(STM32F1闪存起始);
  4. 关键一步:烧录完成后,点击Target → Option Bytes,将nRST_STOP和nRST_STDBY全设为Unprogrammed(即复位功能启用),否则上电不启动。

3.3 串口调试:115200波特率,命令行直连最可靠

不要用Arduino Serial Monitor——它会自动加\r\n导致G代码解析错乱。推荐:

  • Windows:PuTTY,连接类型选Serial,Speed填115200,Connection type下取消勾选Implicit CR in every LF;
  • macOS/Linux:screen /dev/tty.usbmodem14201 115200(设备名用ls /dev/tty.*查);
  • 发送G代码时,每行结尾只用\n(换行符),例如:
    echo -ne "G28\n" > /dev/tty.usbmodem14201 echo -ne "G1 X10 Y10 F1000\n" > /dev/tty.usbmodem14201

3.4 首次上电必做三件事

  1. 测电压:用万用表量VIN和5V引脚,确认输入12V±0.5V,5V输出稳定在4.95~5.05V;
  2. 听声音:上电瞬间应听到步进驱动芯片轻微“咔哒”声(电流使能),若无声,检查EN引脚电平(应为低电平);
  3. 看LED:STATUS_LED(PC13)应常亮绿灯,若快闪红灯,说明SD卡初始化失败,拔卡重插。

4. 常见问题排查:那些让你怀疑人生却只需改一行代码的玄学故障

注意:以下问题全部来自真实调试场景,现象、原因、解决方法均经实测验证,非网络拼凑。

4.1 现象:Z轴电机狂抖,但X/Y轴正常

原因:config.h中Z_MAX_POS设为200.0f,而实际机械限位开关触发位置在195mm处,导致G28 Z归零后继续向上撞限位,驱动芯片进入堵转保护模式反复重启。
解决:将Z_MAX_POS改为194.0f,并在src/motion/planner.cpp的Planner::buffer_line()开头加校验:

if (target.z > Z_MAX_POS - 0.5f) { LOG_ERROR("Z move exceeds limit: %.3f > %.3f", target.z, Z_MAX_POS); return false; }

4.2 现象:热床温度显示-273℃,喷嘴温度正常

原因:热床NTC焊盘虚焊,ADC通道ADC_CHANNEL_10(对应PC0)采集到浮空电压,hal_adc.cpp中HAL_ADC::readTemperature()未做有效性判断,直接传给thermistor_table.cpp查表,越界返回-273.15f。
解决:在HAL_ADC::readTemperature()中加入ADC值范围检查:

uint16_t raw = HAL_ADC_Read(ADC_CHANNEL_10); if (raw < 10 || raw > 4000) { // 典型NTC在25℃时ADC值约2000 LOG_WARN("BED ADC invalid: %d", raw); return -273.15f; }

4.3 现象:SD卡读取G代码时,第37行开始乱码,后续全为G0

原因:sd_reader.cpp中缓冲区line_buffer[64]未初始化,当某行不足64字节时,剩余内存残留上次读取的垃圾数据,strtok()解析时误将\0后的内容当作新字段。
解决:在SDReader::readNextLine()开头添加清零:

memset(line_buffer, 0, sizeof(line_buffer));

4.4 现象:USB串口发送M114后无响应,但G28能执行

原因:gcode/parser.cpp中parseParameters()函数对M指令的参数解析逻辑缺失,遇到M114时跳过执行直接返回,未调用reportCurrentPosition()。
解决:在executeCommand()的switch(current_cmd.code)中补充:

case 'M': switch(current_cmd.number) { case 114: reportCurrentPosition(); break; default: LOG_WARN("Unsupported M%d", current_cmd.number); } break;

4.5 现象:打印中途突然停机,串口无报错,LED灭

原因:电源适配器峰值电流不足,12V输入在挤出电机+热床同时满载时跌至10.2V,MCU的PVD(可编程电压检测)触发复位,但复位向量未正确指向Reset_Handler。
解决:在startup_stm32f103xb.s中确认__Vectors段起始地址为0x08000000,并在main.cpp开头添加:

// 启用PVD,阈值设为2.5V(对应VDD=12V时的PVD等级2) __HAL_RCC_SYSCFG_CLK_ENABLE(); SYSCFG->CMPCR |= SYSCFG_CMPCR_CMP_PD; PWR->CR |= PWR_CR_PLS_2; // PLS = 2 -> 2.5V PWR->CR |= PWR_CR_PVDE;

5. 二次开发实战:如何给它加上“断点续打”功能(含完整代码)

断点续打不是简单记个Z高度,而是要精确到G代码行号+各轴绝对坐标+挤出累积量。原项目没做持久化存储,所有状态都在RAM里,断电即失。我的做法是:利用STM32F103内置的2KB备份寄存器(Backup Register),在每次运动块执行前写入关键状态,掉电后仍可读取。

5.1 硬件前提:确认你的板子焊了VBAT电池或超级电容

备份寄存器需要独立供电。若你的开发板没接VBAT(如多数“蓝 pill”),需手动飞线接3V纽扣电池正极到VBAT引脚(PC13旁),负极接地。没这步,所有备份数据掉电消失。

5.2 修改src/core/main.cpp:初始化备份域并注册掉电钩子

// 在main()开头添加 void init_backup_domain() { __HAL_RCC_BKP_CLK_ENABLE(); // 使能BKP时钟 __HAL_RCC_PWR_CLK_ENABLE(); // 使能PWR时钟 HAL_PWR_EnableBkUpAccess(); // 允许访问备份寄存器 // 检查是否首次上电(备份寄存器0为0xFFFF表示未初始化) if (BKP->DR1 == 0xFFFF) { BKP->DR1 = 0; // 行号 BKP->DR2 = 0; // X坐标(放大1000倍存整数) BKP->DR3 = 0; // Y坐标 BKP->DR4 = 0; // Z坐标 BKP->DR5 = 0; // E挤出量 LOG_INFO("Backup domain initialized"); } } // 在main()循环内,每次执行完一个Block后更新 void update_backup_state(const Block& block) { static uint32_t last_line_num = 0; if (gcode_parser.current_line_number > last_line_num) { last_line_num = gcode_parser.current_line_number; BKP->DR1 = last_line_num; BKP->DR2 = (int32_t)(current_position.x * 1000.0f); BKP->DR3 = (int32_t)(current_position.y * 1000.0f); BKP->DR4 = (int32_t)(current_position.z * 1000.0f); BKP->DR5 = (int32_t)(extruder.position * 1000.0f); } }

5.3 新增src/gcode/resume_command.cpp:实现M123续打指令

// src/gcode/resume_command.cpp #include "gcode/gcode_parser.h" #include "motion/planner.h" #include "thermal/pid_controller.h" void handle_resume_command() { uint32_t line_num = BKP->DR1; float x = (float)BKP->DR2 / 1000.0f; float y = (float)BKP->DR3 / 1000.0f; float z = (float)BKP->DR4 / 1000.0f; float e = (float)BKP->DR5 / 1000.0f; LOG_INFO("Resume from line %lu: X%.3f Y%.3f Z%.3f E%.3f", line_num, x, y, z, e); // 1. 移动到断点坐标(忽略Z,先抬高5mm防撞) planner.buffer_line(x, y, z + 5.0f, e, 3000.0f); planner.wait_for_moves(); // 2. 归零Z轴(假设Z0是热床表面) planner.buffer_line(x, y, 0.0f, e, 500.0f); planner.wait_for_moves(); // 3. 从断点行继续读SD卡 sd_reader.seek_to_line(line_num); LOG_INFO("Resuming print from line %lu", line_num); }

然后在gcode_parser.cpp的executeCommand()中加入:

case 'M': switch(current_cmd.number) { case 123: handle_resume_command(); break; // ... 其他M指令 } break;

5.4 验证方法:用真实断电测试

  1. 打印一个10层模型,在第5层中间拔掉电源;
  2. 等10秒后重新上电,串口发送M123;
  3. 观察:Z轴先升到z+5,再精准落回断点Z高度,接着从SD卡第N行继续打印;
  4. 关键指标:续打后首层粘附力与原始打印无差异(用镊子轻撬测试)。

从那以后我每次做固件升级,都强制走一遍M123断电测试——不是为了炫技,是怕哪天客户现场断电,我们得扛得住。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进