
简介基于C构建的CNC控制系统源码包定位为开源工业级数控解决方案适合嵌入式开发者、自动化专业学生以及对运动控制二次开发有需求的工程师学习与参考。项目通过硬件抽象层HAL屏蔽不同主控差异可运行于AVR、ARM等微控制器平台并兼容GRBL、TinyG等主流控制器通信接口核心代码覆盖G/M代码解释、直线/圆弧插补、固定循环钻孔、攻丝及刀具补偿等关键环节读者可借此理解工业数控系统的分层架构与算法实现。压缩包内共241个文件以.h与.cpp源码为主另有少量.c辅以vcxproj、xml等工程配置、ld链接脚本以及部分C#辅助工程和RTF文档整体约535KB同时包含Talos_c AVR与ARM两套sln工程可直接查看NGC解释器、刀具补偿等核心模块代码目录结构清晰便于按模块定位运动控制、插补与固定循环实现。目前已有183人学习下载适合需要上手复杂嵌入式项目的开发者深度阅读。1. 让G代码变成步进脉冲Talos CNC控制系统的代码脉络拿到这份(源码)基于C的CNC控制系统.zip时最吸引我的不是某一个算法而是解压后同时出现了Talos_c AVR.atsln和Talos_c ARM.atsln两份工程——同一套 C 运动控制源码分别铺好了 AVR 和 ARM 的构建脚手架。这种“一套逻辑、双平台落地”的结构在开源 CNC 项目中比 GRBL 单线版更接近工业级控制器的做法。系统里NGC_Interpreter.cpp负责 G 代码解析c_cutter_comp.cpp负责刀具半径补偿startup_sam3xa.c、system_sam3xa.c把应用逻辑焊到 MCU 最底层。如果你不满足于只烧固件想改解析、改插补、移植到自己的板卡这套源码就是一条很完整的研究路径。2. NGC_Interpreter.cppG代码解析状态机与固定循环实现2.1 从“逐行执行”到“模态状态机”NGC_Interpreter.cpp在 Talos 项目里的角色相当于 GRBL 的gcode.c和运动控制层之间的翻译器。很多人第一次读 G 代码解析器时会觉得无非是把一行字符串拆成 N、G、M、X、Y、Z、F、S、T 这些字然后逐个执行。实际工程里完全不是这样因为 CNC 程序天然依赖前文状态写了G90之后的所有坐标都按绝对方式解释写过G54后所有位置都要叠加上该坐标系的零点偏移而G81一旦出现后续只给X、Y的行也要自动重复钻孔循环。Talos 用一套显式的模态状态机来处理这种耦合。解析器维护一个类似下面的上下文结构体// 核心模态状态对应 NGC_Interpreter.cpp 中的内部解析上下文 typedef struct { uint8_t motion_mode; // 0G0 定位, 1G1 直线, 2G2 顺圆, 3G3 逆圆 uint8_t plane; // G17: XY 平面, G18: ZX, G19: YZ uint8_t units; // G21 公制, G20 英制 uint8_t distance_mode; // G90 绝对坐标, G91 增量坐标 uint8_t cutter_comp; // G40 取消, G41 左补偿, G42 右补偿 float feed_rate; // F 字mm/min float axis_offset[3]; // G54~G59 坐标系偏移量 } gc_modal_state_t; static gc_modal_state_t gc;参数含义在注释里已经标了一部分还有三个容易被忽略的点。cutter_comp不会在每次运动指令后清零必须等到G40或程序结束才取消这为刀具补偿跨多段路径连续生效提供了前提。units的转换必须在所有轴字进入运动缓冲前完成否则后续补偿和插补阶段会出现英制毫米混用表现为某个轴整体偏移一个固定量。distance_mode直接影响增量圆弧指令的终点计算G91 模式下X、Y、I、J都会被解释成相对量。状态机把“每次解析都从头理解上下文”的成本提前分摊到一次状态初始化上。2.2 两遍扫描的执行顺序解析一行代码Talos 采用两遍扫描而不是一遍完成。第一遍收集G和M字第二遍才处理轴字。这样做有一个很实际的好处同一行里如果同时出现G54和G0必须先把坐标系偏移更新到位再计算目标点坐标否则第二个字段引用的还是旧坐标系。uint8_t gc_execute_line(char *line) { // 第一遍只收集 G/M 字确定这条指令的模态类型 char *word gc_next_word(line); while (word ! NULL) { if (word[0] G || word[0] M) { gc_collect_modal_word(word); } word gc_next_word(NULL); } // 第二遍解析 XYZ IJ K R F S T 等参数 // 并在此处做单位换算英制全部转内部毫米制 gc_parse_coordinate_word(line); // M 指令优先于 G 运动指令执行避免主轴状态和运动顺序错乱 if (gc_has_pending_m_word()) { gc_execute_m_word(); } // 运动指令最后触发此时模态上下文已经是当前行生效后的值 if (gc_has_motion_command()) { gc_execute_motion_block(); } return INTERP_OK; }这个小片段体现了几个工程判定。M 字优先是因为实际机床里开主轴和运动往往在同一条程序行里例如M3 S12000 G0 X10 Y20如果先走 G0 再开主轴刀还没接触工件倒不影响但如果换刀后主轴没开启就执行切削就是严重事故。先把轴字全部收集完再执行运动可以让人工检查这一行是否存在“缺少轴字但带有进给”的隐患也可以为固定循环的重复执行准备好完整参数集。gc_has_pending_m_word()这类接口的存在也让上层单元测试可以逐层剥离依赖。2.3 固定循环G81、G83 的循环体展开固定循环是解析器里最容易写错的模块。G81、G83、G84 等指令的核心特征是它们是模态的只要不取消后续每一行只要给出新的X、Y解释器就自动把上次保存的R、Z、F参数再执行一遍。Talos 把它们保存为“固定循环参数组”并在运动块阶段插入循环体。G 代码固定循环名称每行展开动作关键派生参数G81普通钻孔/点孔快速定位 → 工进到 Z → 快速退回 R 面R参考平面、Z孔底、F工进速度G83啄钻分段工进每段后抬刀回 R 面排屑再快速回位继续下一段Q每段切深、R、Z、FG84刚性攻丝工进 Z 过程中主轴同步旋转到达孔底后主轴反转退刀R、Z、F、S主轴转速G85铰孔工进到 Z 孔底然后以进给速度退回 R 面R、Z、FG83 很少像 G81 那样一次到底原因是深孔加工中切屑不及时排出会缠绕刀具或刮伤孔壁。Talos 在展开 G83 时会按 Q 值逐步下探每走一段就快速抬刀代码可以简化为// 按 Q 分段执行啄钻循环 static void gc_execute_g83(float r_plane, float z_target, float q_depth) { float z_step r_plane; // 每段进给深度为 q_depth但最后一段按剩余深度处理 while (z_step - q_depth z_target) { z_step - q_depth; mc_line(z_step, PLUNGE_FEED_RATE); // 工进到当前段孔底 mc_rapid(r_plane); // 快速抬回安全平面 mc_rapid(z_step); // 快速回到上次孔底上方 } // 最后一段直接进给到最终孔底 mc_line(z_target, PLUNGE_FEED_RATE); mc_rapid(r_plane); }PLUNGE_FEED_RATE在这里复用F字还是单独配置取决于 HAL 层是否实现了主轴倍率联动。这段代码里的三次运动必须严格区分快速和工进抬刀用mc_rapid是为了排屑时不切削回位用mc_rapid是为了减少非切削时间最后一段用mc_line是为了保证进给速度可控、不折断钻头。参数q_depth要能被主轴每转进给量整除最好否则每段多出一点残量实际切深会略大于设定值。2.4 解析层最容易踩的边界条件我在调试解析器时遇到过几个固定坑。第一只给轴字不给模态时刀具不能自动退回到原R平面必须检查上一行是否已经建立固定循环。第二圆弧指令I、J使用相对起点还是绝对坐标取决于控制器设计Talos 内部统一按相对方式处理所以用 CAM 导出时要注意后处理器选项。第三G92 坐标系偏移和 G54 偏移同时出现时必须明确叠加顺序否则工件原点会跳变。3. c_cutter_comp.cpp刀具半径补偿的几何模型与转角处理3.1 为什么补偿必须在插补之前完成c_cutter_comp.cpp是整套源码里几何计算最密集的文件。它的存在原因很直观CAM 生成刀路时通常按工件轮廓编程铣刀中心并不应该走在轮廓线上而应偏移刀具半径。如果不做补偿直径为 8mm 的立铣刀铣内轮廓时会直接吃掉 4mm 的余量外轮廓则少切 4mm。补偿的时机必须选在插补之前因为插补器的输入是“刀心轨迹”它没有工件轮廓的概念如果补偿放到插补之后每段插补出的微小线段会首尾错位加工表面留下折痕。G41 和 G42 决定偏移方向。沿着刀具运动方向看G41 让刀心偏在轮廓左侧G42 偏在右侧。逆铣和顺铣的选择通常由 CAM 完成但控制器必须用正确的符号去处理法线方向。3.2 偏移量的向量算法单段直线的补偿非常简单设当前点为 A目标点为 B运动方向向量为 AB把 B 点沿法线方向平移一个半径即可。法线方向在 XY 平面内通过旋转运动向量 90 度获得// 对目标点做刀具半径补偿current 为当前刀心位置 static int cutter_offset_point(float *target, const float *current, float tool_r) { float vx target[0] - current[0]; float vy target[1] - current[1]; float len sqrtf(vx * vx vy * vy); // 两轴坐标重合时方向无法定义直接拒绝补偿 if (len 1e-6f) return -1; // 运动方向的左法线为 (-vy, vx)对应 G41 左补偿 float nx -vy / len; float ny vx / len; target[0] nx * tool_r; target[1] ny * tool_r; return 0; }这里tool_r是刀具半径它应该来自刀具表而不是每次从 G 代码里读。len归一化保证偏移量恒定为一个半径不随目标距离变化。这个函数只是最底层的偏移原语真正困难的是它用在折线连续加工时相邻两段直线各自偏出一条线两条偏置线之间怎么衔接。3.3 内角、外角与缩短/插入策略转角处理是刀具补偿的核心难点。两条轮廓线夹角不同偏置线的几何关系也不同用错策略会直接导致过切。转角类型偏置线几何状态处理策略适用场景外角两条偏置线不相交中间有空隙插入刀具半径圆弧连接外轮廓拐角、凸台内角两条偏置线在轮廓内侧相交截断到交点不额外插弧内腔转角、凹槽反向角运动方向突变超过 90°报错或强制等待编程圆弧过渡防过切兜底外角处理最典型的做法是在两条偏置线之间构造半径为tool_r的过渡圆弧。这样刀具在拐角处沿圆弧切过既不会留下尖角残留也不会切入工件。内角则完全不同两条偏置线的交点就是刀心实际应到达的位置直接裁剪掉超出交点的部分否则刀心会偏向加工余量区域造成过切。判断一个角是内角还是外角常用向量叉积的符号。假设两段运动方向的单位向量为u1和u2叉积大于 0 时在 XY 平面上表现为左转左补偿 G41 时是外角右补偿 G42 时是内角。符号一变处理逻辑就要反转// 通过叉积判断转角方向 float cross u1x * u2y - u1y * u2x; int is_left_turn (cross 0.0f); // G41 时左转为外角插入圆弧G42 时左转为内角直接求交 if (gc.cutter_comp COMP_LEFT) { if (is_left_turn) insert_transition_arc(tool_r); else intersect_offset_lines(); } else { if (is_left_turn) intersect_offset_lines(); else insert_transition_arc(tool_r); }这里insert_transition_arc生成的是一段与前后偏置线都相切的圆弧它的圆心角等于两运动方向夹角半径就是tool_r。intersect_offset_lines则要解得两条二维线段方程的交点同时要考虑交点是否超出当前段的有效范围超出时取端点避免补偿后刀心路径出现倒走。3.4 防过切的边界条件补偿中最容易出事故的边界条件是轮廓内腔宽度小于刀具直径。比如用直径 10mm 的刀铣一个 8mm 宽的槽G41 补偿后刀心根本无法进入内腔如果程序继续执行插补器会把刀心强行压向内侧结果就是过切或撞刀。健康的控制器应该在补偿阶段就检测到这种几何矛盾而不是等到插补段才发现坐标越界。此外第一段开始补偿时必须用“预读两段进行过渡”最后一段取消补偿时也要保证退刀点与轮廓保持安全距离这两处都需要在c_cutter_comp.cpp内维护一个小的轨迹缓冲不能只偏移当前段就立即输出。4. startup_sam3xa.c 与 HAL 层AVR/ARM 板卡的移植与脉冲生成4.1 启动文件在 CNC 系统里的角色startup_sam3xa.c、system_sam3xa.c这两颗文件在绝大多数应用层代码里不会直接被调用但移植到新板卡时它们决定了程序能不能正常跑起来。startup_sam3xa.c做的事情是定义中断向量表把.data段从 Flash 复制到 RAM清零.bss段然后跳进main()。对于 CNC 系统来说这里有个特殊要求——运动控制中断优先级必须在系统初始化阶段就定好否则当串口中断和步进定时器中断同时到达时一个不可重入的插补状态会被撕裂。void Reset_Handler(void) { extern uint32_t _data_load, _data_start, _data_end; extern uint32_t _bss_start, _bss_end; // 将已初始化全局变量从 Flash 装载到 RAM memcpy(_data_start, _data_load, _data_end - _data_start); // 清零未初始化段保证静态变量初值为 0 memset(_bss_start, 0, _bss_end - _bss_start); SystemInit(); // 在 system_sam3xa.c 中配置时钟和 Flash 等待周期 main(); }这段代码本身没有 CNC 特有逻辑但_data_load的地址如果和 Flash 编程边界不对齐大数组如插补缓冲区会被错误初始化表现为运动轨迹随机跳变。移植到 SAM3X8E 这类 Cortex-M3 时还需要检查链接脚本里_data段对齐是否满足 4 字节否则memcpy使用 LDR/STR 指令时会产生 HardFault。4.2 system_sam3xa.c 的时钟树配置system_sam3xa.c的利益体现在主频上。SAM3X8E 默认使用内部慢速时钟刷新一次内核节拍和步进脉冲定时器的误差会被放大到难以忍受的程度。实际项目中我见过两个不同板子一个用 12MHz 外部晶振经 PLL 锁相到 84MHz另一个直接用内部 RC加工速度一高步进电机就丢步最后排查到根因是主频不准导致分频后脉冲频率漂移。正确做法是把 MPLL 配置清晰并让SystemCoreClock变量与真实主频一致void SystemInit(void) { // 启动外部晶振并等待就绪 // 配置 PLLA 输出 84MHz // 设置 Flash 等待周期 SystemCoreClock 84000000UL; }这里的SystemCoreClock会被定时器分频代码引用计算每微秒需要多少个计数周期。如果这个变量不更新HAL 层的时钟换算全错进给速度会整体偏快或偏慢。4.3 步进脉冲与 Bresenham 三轴联动走下解释器之后运动指令最终要变成电机的 STEP/DIR 引脚脉冲。Talos 的 HAL 层在定时器中断里运行插补器常见实现是 Bresenham 直线插值。它的原理是把累计误差分布到每个时间片脉冲频率最高、距离最长的轴作为主轴其余轴按比例分得脉冲。3 轴联动代码可简化为// 定时器中断里执行一次脉冲分配 void TIMER_ISR(void) { // 每收到一个定时器 tick累加计数 acc_x step_inc_x; if (acc_x 0x10000UL) { acc_x - 0x10000UL; STEP_X_PORT | STEP_X_PIN; // 产生一个步进脉冲前沿 } acc_y step_inc_y; if (acc_y 0x10000UL) { acc_y - 0x10000UL; STEP_Y_PORT | STEP_Y_PIN; } acc_z step_inc_z; if (acc_z 0x10000UL) { acc_z - 0x10000UL; STEP_Z_PORT | STEP_Z_PIN; } // 短暂延时后清空所有 STEP 脚形成完整脉冲 STEP_X_PORT ~STEP_X_PIN; STEP_Y_PORT ~STEP_Y_PIN; STEP_Z_PORT ~STEP_Z_PIN; }step_inc_x是每个中断周期里 X 轴的平均脉冲密度0x10000是 16 位整数域的溢出阈值。所有轴共用同一个定时器节拍因此三轴联动不会出现相位错位。清脉冲的操作必须放在同一中断函数里不能在运动控制主循环里补否则脉冲宽度不稳定步进驱动器会误判。AVR 版与 ARM 版的差异主要在于中断入口名称和定时器精度AVR 用 8 位计数器时步进脉冲频率上限受限ARM 版可以直接用 PIT 或 TC0 做高精度定时器。4.4 AVR 和 ARM 工程共存的编译环境Talos_c AVR.atsln和Talos_c ARM.atsln共用一个源码根但硬件抽象层文件不同。编译时建议在 Visual Studio 2019 或带有 Visual C 工具链的 VS Code 环境中分别打开两个解决方案AVR 工程用 WinAVR 或 GCC AVR 工具链ARM 工程用 arm-none-eabi-gcc。有几点很容易踩syscalls.c里的_sbrk、_write等 stub 函数在不同工具链里符号名不同ARM 工程里如果你的 HAL 层用了printf至少要实现_write重定向到串口否则链接器直接报未定义引用而 AVR 工程里 printf 库默认较小浮点格式化插件要额外打开否则进给率打印出来的全是 0.00。5. 固定循环调优与运动学回放验证5.1 用回放模式验证解释器输出调整固定循环时最怕直接上机验证因为一旦参数错误撞刀和过切都会造成实际损失。更稳妥的办法是给解释器加一个回放模式让NGC_Interpreter.cpp解析完 G 代码后不执行mc_line而是把输出坐标写到串口或一个 CSV 采集缓冲区再由脚本绘制刀心轨迹。这样 G81 的 R 平面高度、G83 的每段啄深 Q 值都能在屏幕上直接看到不必开机验证。# 配合串口打印格式$X, $Y, $Z, $F minicom -D /dev/ttyUSB0 | tee motion_log.txt采集之后可以用 Python 读回数据单独画某个固定循环孔位的 Z 轴变化确认抬刀回撤高度和最终孔底深度符合预期。5.2 从实际切削结果反推步进校准即使解释器输出正确机械传动误差依然存在。常见做法是让控制器执行 G1 X100 F300用卡尺实测实际移动距离再反推步进参数。Talos 的 HAL 层里运动参数用steps_per_mm归一化修改这个值比逐轴补偿更直接。校准公式为// 实际步距 设定距离 / 实测距离 * 原步距 steps_per_mm steps_per_mm * (100.0f / measured_distance);同理圆弧插补的半径误差多来自 XY 轴垂直度这个无法在软件里完全补偿最好的缓解手段是固定使用同一象限走整圆验证避免在机床坐标系轴交叉位置做圆弧。5.3 移植到自有板卡时最值得先改的三处第一处是syscalls.c确认_read和_write是否映射到调试串口否则无法看到解析器打印第二处是 HAL 层步进脉冲极性很多步进驱动器是高电平触发步进输入有些是低电平STEP_X_PORT | STEP_X_PIN和清脉冲的代码要配合驱动器手册做反向第三处是定时器频率和steps_per_mm的匹配建议在板卡启动时把两个变量的值通过串口打出来校验而不是等上机后才发现进给率差了一倍。这些改动都不涉及解释器逻辑却决定了整个系统在真实机台上能否稳定运行。本文还有配套的精品资源点击获取