C++与Qt实现G代码解析:从词法扫描到Homing回零
2026/9/11 17:03:01 网站建设 项目流程

简介:这是一套使用C++语言编写的G代码解析与CNC控制参考实现,压缩包内包含完整的PSCNC工程源码、可执行程序以及动态链接库,适合数控爱好者、嵌入式开发者以及正在学习RS-274指令解析的初学者。资源共62个文件,主体为18个头文件与8个CPP源文件,并搭配dfm窗体文件、lib静态库、dll动态库和exe示例程序,整体仅761KB,结构紧凑,可直接用Qt或C++ Builder工程打开研究;头文件声明接口,CPP实现解析逻辑,lib与dll完成底层通信封装,exe便于快速验证功能。核心代码覆盖词法分析、语法分析、Home归零流程、串口通信等模块,同时附有PDF说明文档,可帮助梳理G代码从读入、解析到执行的完整链路。另外,资源中还保留了MAK、DSW、DSP等工程配置文件,便于按模块逐个编译和调试。已有1035人学习使用,对照源码能掌握解析器架构与PSCNC界面调用方式,适合用于二次开发或理解CNC系统底层逻辑。

1. 在 C++ 与 Qt 之间,G 代码解析真正该解决的是什么

Gcode.rar这类压缩包出现在 CNC 上位机项目里,文件名同时写清楚了需要什么:GCODE 程序、gcode c lib、homing 和 qt gcode。它们拼起来是一条完整链路:C++ 解析 G 代码,Qt 做界面,运动控制执行 homing 回零。G 代码解析的难点在于它是一套方言很多的文本协议,G0 X10 Y20 F300里混合了动作、坐标和进给率,注释还要单独处理;切分行没做好,后面运动队列都会偏。

真正要的,是一个能长期维护的解析库:单测里能复现、UI 线程能调用、控制层拿到 G28 能生成回零序列。所以下文按“词法扫描 → 指令状态机 → homing 逻辑 → Qt 接入”的顺序,把 C++ 实现 gcode 解析的参数和坑位讲清楚。老手可以直接跳到模态状态机那一节,前面的词法部分虽然基础,但错误码设计值得顺手核对一遍。

2. G 代码行级词法解析:把 G0 X10 Y20 拆成可控的最小单元

2.1 先定义块结构:地址字、行号、注释和不能丢的小数点

G 代码的每一行在 NIST RS274NGC 里被称为一个块(block),块由一个或多个“地址字 + 数值”组成。地址字是单个大写字母,数值可以带正负号和小数点,例如X-12.5。最常见的地址字有运动准备功能 G、辅助功能 M、坐标字 X/Y/Z、圆弧参数 I/J/K、进给率 F、主轴转速 S、刀具号 T 和延时参数 P。有些文件里会出现N10这样的行号,解析器可以忽略它,也可以把它放进元数据用于错误上报,我的习惯是保留但不参与动作。

注释方面,()包裹块内注释,分号;从当前位置到行尾都是注释。分号注释必然截断;括号注释在多数控制器里也截断,但少数 CAM 后处理器会在括号之后继续输出指令。解析库的默认策略是“见到就终止”,把(M3 主轴)这样的内容留在原始文本里不输出;如果项目确实需要保留括号后指令,再单独开一个选项,不要全局放宽,否则容易接受本不该接受的文件。

常见地址字不复杂,但字段语义差异很大,列一个表可以避免解析器把坐标和参数混在一起:

地址字类型解析后的字段
G准备功能运动方式或坐标系状态
M辅助功能主轴、冷却、暂停命令
X Y Z坐标字目标位置
A B C旋转坐标绕 X/Y/Z 轴的旋转角
I J K圆弧中心圆心相对起点的偏移
F进给率单位毫米/分钟或英寸/分钟
S主轴转速主轴转数
T刀具号换刀编号
P延时参数毫秒或主轴定位角度

2.2 手写地址字扫描器:C++ 解析 G 代码不该首选正则

我见过不少解析器第一反应是正则,([A-Z])\s*(-?\d+\.?\d*)确实一句话就能提取地址字。但有两个问题:一是大文件逐行解析时正则分配的临时对象多,二是拿不到错误位置,遇到不合法字符只能知道“没匹配上”,不知道错在第几个字节。手写扫描器可以把错误位置精确到字节下标,也可以顺手把容错行为定义成规则。

下面的scanGcodeLine是这类 gcode c lib 里最常用的扫描核心,不依赖 Qt,也不依赖正则库:

#include <vector> #include <string> #include <optional> #include <cctype> struct GcodeWord { char addr = 0; std::optional<double> value; }; std::vector<GcodeWord> scanGcodeLine(const std::string& raw) { std::vector<GcodeWord> result; size_t i = 0; const size_t n = raw.size(); while (i < n) { unsigned char c = static_cast<unsigned char>(raw[i]); // 分号和圆括号注释都视为本行解析终点 if (c == '(' || c == ';') break; if (std::isspace(c)) { ++i; continue; } if (std::isalpha(c)) { GcodeWord w; w.addr = static_cast<char>(std::toupper(c)); ++i; // 允许地址字和数值之间有空格,例如 "G 90" while (i < n && std::isspace(static_cast<unsigned char>(raw[i]))) { ++i; } int sign = 1; if (i < n && (raw[i] == '+' || raw[i] == '-')) { if (raw[i] == '-') sign = -1; ++i; } double intPart = 0.0; double fracPart = 0.0; bool found = false; while (i < n && std::isdigit(static_cast<unsigned char>(raw[i]))) { intPart = intPart * 10.0 + (raw[i] - '0'); found = true; ++i; } if (i < n && raw[i] == '.') { ++i; double scale = 0.1; while (i < n && std::isdigit(static_cast<unsigned char>(raw[i]))) { fracPart += (raw[i] - '0') * scale; scale *= 0.1; found = true; ++i; } } if (found) { w.value = sign * (intPart + fracPart); } result.push_back(w); } else { // 非字母、非空白的异常字符,跳过,保留后续解析 ++i; } } return result; }

这段代码有几个值得注意的决策。optional<double>区分“地址字存在但没有数值”和“地址字存在且数值为 0”,这个区分很关键:G0 XG0 X0在某些控制器里语义不同,前者应该报参数缺失,后者是合法的快速定位。坐标用double而不是float,因为运动学换算在大行程机床上容易累积误差,单精度到后面会看到明显的坐标漂移。注释判断直接用break,也就是说解析器把注释以后的内容全部丢弃,这种做法对“控制器侧”更安全,对“CAM 显示侧”则需要单独保留原文。

2.3 错误返回设计:ParseError 与容错等级

解析器不建议用 C++ 异常往外抛,因为运动控制程序往往多线程运行,异常跨线程传播容易把状态机打到不可预测的位置。更好的做法是返回错误码,同时把出错位置带出来。下面这个结构体可以放在gcode_errors.h里:

enum class ParseError { None, InvalidCharacter, MissingValue, LineTooLong, TooFewWords, }; struct ScanOutcome { std::vector<GcodeWord> words; ParseError error = ParseError::None; size_t errorPos = 0; }; static const size_t kMaxGcodeLineBytes = 200;

把错误位置做到字节级以后,Qt 端显示错误时可以直接定位到行内第几列;串口调试时也能把这段文本回传给上位机。容错等级的设置一般有两个挡位:严格模式遇到MissingValue就中止文件;兼容模式只记录错误并继续解析。我给运动控制项目做库时默认用兼容模式,因为真实加工文件里出现G0 X这类问题往往来自手工改参数,直接中止会让用户觉得是软件崩溃,先跳过再提示更容易排查。

3. G 代码解析核心状态机与 gcode c lib 的 C 接口导出

3.1 从词法结果到指令结构体:GcodeLine 的字段设计

词法扫描完成以后,下一步是把std::vector<GcodeWord>翻译成运动控制层可以直接消费的数据结构。gcode c lib 这个说法里最重要的一点,是库的边界要设计成 C 接口,内部可以用 C++ 类,但暴露给 UI 和控制层的头文件必须没有std::stringstd::vector这类会破坏 ABI 的类型。对应的导出结构体长这样:

extern "C" { typedef struct GcodeLine { int lineNo; int g; // 这一行的最后一个 G 指令值 int m; // 这一行的最后一个 M 指令值 double x, y, z; unsigned char hasX, hasY, hasZ; double i, j, k; // 圆弧中心或螺纹导程参数 double f; // 进给率 double s; // 主轴转速 double p; // 延时时间等参数 double t; // 刀具号 unsigned char hasF, hasS, hasP; } GcodeLine; typedef struct GcodeParser GcodeParser; GcodeParser* gcode_parser_new(void); void gcode_parser_free(GcodeParser* parser); int gcode_parser_parse_line( GcodeParser* parser, const char* line, int lineNo, GcodeLine* out); }

每个字段后面挂一个hasX这样的标志位,是为了保留“原文件里有没有写这个参数”。很多人写解析器只保存数值,后续就会把“没写 X”和“X 为 0”混在一起;在绝对坐标模式下两者可能刚好相同,一旦切换到相对坐标模式,就会多走一段莫名其妙的距离。gm字段只保存最后一个 G 值和最后一个 M 值,同一个块内出现多个 G 指令时,后面的覆盖前面的,这是兼容多数 CAM 输出的做法。

C 接口的内部实现可以非常简单:申请一个类对象,把模态状态放进类成员里,外部拿到的只是一个不透明指针。这样既满足了“gcode c lib”的 C 导出需求,也让 Python ctypes 或 Rust FFI 绑定成为可能。

3.2 模态指令的状态表:G90/G91、G20/G21 和进给率继承

模态是 G 代码解析和普通文本解析最大的区别。所谓模态,是指某些指令设置一次以后,会持续作用于后续所有代码,直到同模态组里另一条指令出现。下面是运动控制里最常见的几组:

模态组指令保持方式
运动模式G0 / G1 / G2 / G3组内互斥,保留最后一条
单位制G20 / G21英寸 / 公制切换后持续生效
坐标系G90 / G91绝对 / 增量切换后持续生效
进给模式G93 / G94逆时间 / 每分钟进给
刀具长度补偿G43 / G49开启或取消

模态状态对象的 C++ 实现一般就是一个结构体,解析器每解析一行就更新它:

enum class UnitMode { Metric, Inch }; enum class DistanceMode { Absolute, Incremental }; struct ModalState { UnitMode unit = UnitMode::Metric; DistanceMode distance = DistanceMode::Absolute; int plane = 17; // G17 表示 XY 平面 double feedrate = 0.0; };

解析器内部持有ModalStategcode_parser_parse_line处理完当前行,会把这个状态传递到下一条命令。最容易错的是进给率 F:F 不是当前行用完就失效的字段,它在 G1 加工中会一路保持,直到新的 F 出现。所以结构体里也要有hasF,否则控制层不知道 F 是没给还是给了一个合法值。

提示:一个块里同时出现 G90 和 G91 属于非法写法,但真实 CAM 文件确实会出现“先 G90 后 G91”的极端情况。按 RS274NGC 标准应当报错,实际项目里多数解析器采用“后者覆盖前者”,只在日志里记 warning。如果你的控制内核没有明确的报警机制,建议同样采用覆盖策略。

3.3 单位换算与预读缓冲:解析器不要自己做归一化

单位制 G20/G21 影响的是后续所有坐标值和进给率。很多解析器喜欢在解析阶段直接把英寸换算成毫米,让控制层拿到统一单位。这个做法在单机程序里没问题,但放到带 UI 的上位机里反而添乱:界面要显示原始 G 代码行,控制器要使用公制值,解析器如果已经做过归一化,界面就得多存一份原文。更好的切分是解析器只记录当前单位模式,控制层在入队前自己调用一个gcode_to_metric函数完成换算。

预读缓冲同样不属于解析器的职责。控制器需要预读几十行来做速度规划,但 UI 读取文件只需要逐行解析并在表格里显示。常见的工程方案是解析器提供单行入口,外层在while循环里逐行调用;如果文件很大,再用std::queue<GcodeLine>或者 Qt 的QQueue做缓冲。解析器自身保留状态的同时不持有队列,可以避免多线程时锁粒度变大。

4. Homing 回零与运动前扩展:让 G28 变成可执行序列

4.1 G28、G92、$H 在解析器中的角色不同

Homing 在 G 代码方言里并不是一个统一指令。标准 G 代码常用G28表示返回第一参考点,GRBL 风格的文件用$H命令做全轴回零,增材设备常直接发G28 X Y Z,但 XYZ 后面的参数表示“回零的轴”,不是目标位置。解析器要在两种场景里分清:G28是标准地址字,而$H是控制器命令行,不属于 G 代码块。比较稳的方案是两个入口:gcode_parser_parse_line处理标准 G 代码,gcode_parser_parse_command处理$H这类控制命令,两者最后都输出同一个HomeRequest结构,上层不用关心来源。

下表把回零相关的主要指令归类说明:

来源指令解析器输出
标准 G 代码G28 X0 Y0HomeRequest(axes=X,Y)
标准 G 代码G28.1RefPointRecord
标准 G 代码G92 X0 Y0SetCoordinateOffset
控制器命令$HHomeRequest(axes=all)

G92 本身不是回零动作,但回零完成后通常会用一条G92 X0 Y0把当前机械坐标清零,建立工件原点。解析器不能把它当成 Home 处理,我见过一个项目把 G92 错判成运动指令,结果界面显示坐标不断跳动,控制器的位置却停在原地,排查了很久才发现是 CommandKind 分类错误。

4.2 用 C++ 状态机描述轴回零:快速趋近、慢速找沿、反向脱离

多轴机器执行 HomeRequest 时不会把所有轴同时撞过去,而是按顺序进行。笛卡尔机一般先回 Z 轴,避免在 X/Y 轴回零时撞到工件;CoreXY 和三角洲各有自己的轴顺序。解析器不需要知道具体顺序,但可以提供一个HomePlanner,把“轴顺序、回零速度、脱离距离”组装成目标序列。

下面的状态机是回零逻辑里最经典的三段式:快速接近限位开关,触发后慢速反向找开关边沿,脱离后再确认开关释放:

enum class HomePhase { ApproachFast, // 快速向机械原点移动 SlowSearch, // 慢速反向寻找开关边沿 ReverseEsc, // 反向脱离触发区 Complete }; struct HomeAxis { HomePhase phase = HomePhase::ApproachFast; double fastFeed = 1500.0; // 快速接近速度,单位 mm/min double slowFeed = 80.0; // 慢速找边沿速度 double escapeFeed = 200.0; // 反向脱离速度 double switchOffset = 0.0; // 开关触发点与机械零点的偏差 }; struct HomePlanner { std::vector<HomeAxis> axes; size_t currentAxis = 0; bool tick(bool limitHit) { HomeAxis& a = axes[currentAxis]; switch (a.phase) { case HomePhase::ApproachFast: if (limitHit) a.phase = HomePhase::SlowSearch; return false; case HomePhase::SlowSearch: if (!limitHit) { a.phase = HomePhase::ReverseEsc; } return false; case HomePhase::ReverseEsc: if (limitHit) { a.phase = HomePhase::Complete; currentAxis++; if (currentAxis >= axes.size()) return true; } return false; default: return false; } } };

这段代码里limitHit是底层控制器已经做过极性处理和消抖之后的信号。不同机床的限位开关接线不同,常闭触点断开才算触发,常开触点吸合才算触发;如果直接在解析层判断电平,回零会表现得像“根本没反应”或者“碰一下就停”。所以开关极性配置是HomePlanner的输入参数,不是状态机内部写死的。

慢速找沿这一步也涉及方向问题。有的系统碰到限位后反向退出,用开关“释放”的那个点作为机械原点;有的系统反向退出后再次慢速接近,用开关“吸合”的点作为原点。两种策略都能跑,但计算switchOffset时要注意,开关释放点与吸合点之间通常存在机械死区,offset 取负值还是正值要跟实际标定结果一致。

4.3 回零参数表与软限位冲突的工程解法

回零最容易出问题的不是状态机本身,而是参数没有给足。整理一张参数表,可以对照着检查自己的库:

参数常见陷阱建议值
快速接近速度太快会导致过冲,反复撞坏限位开关不超过机床最大速度的 60%
慢速找沿速度太快会导致每次零点不一致20~80 mm/min
反向脱离距离为 0 时开关一直处于触发状态,无法脱离至少大于开关触发宽度 1mm
软限位开关未回零前坐标未知,软限位会误报警homing 阶段强制禁用,完成后恢复

软限位(soft limit)是很多项目会漏掉的一环。解析器在输出 HomeRequest 时,最好同时输出一个swLimitEnable=0的标志位,让控制器在回零流程里临时禁用软限位。原因很好理解:机床没有回零之前,机械坐标是未知的,如果系统按上一次残留坐标来判断软限位,会把回零动作直接判定为超行程。LinuxCNC 这类系统会在配置里规定 soft limit,不回零就报警;GRBL 的习惯则是靠$H命令自带回零流程,不让普通 G 代码绕过。

5. Qt 前端验证 gcode 解析器:CMake、逐行读文件与调试技巧

5.1 在 Qt 5.15 + C++17 里接入解析库的最小工程

解析器本身应该是纯 C++ 标准库实现,不依赖 Qt,这样单元测试可以脱离界面单独跑。真正花时间的往往是 Qt 工程链接阶段。把工程拆成gcode_core静态库和gcode_ui可执行文件,Qt 版本从 5.15 升到 6.x 时核心库不用动。最小的 CMake 配置如下:

cmake_minimum_required(VERSION 3.16) project(gcode_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) add_library(gcode_core STATIC gcode_scan.cpp gcode_parser.cpp gcode_home.cpp ) target_include_directories(gcode_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) add_executable(gcode_ui main.cpp MainWindow.cpp ) target_link_libraries(gcode_ui PRIVATE Qt5::Widgets gcode_core)

在 Visual Studio Code 里配置 C/C++ 环境时,只要把 Qt 安装目录里的 include 路径加进c_cpp_properties.json,再让 CMake Tools 接管编译即可。Qt 安装后偶尔会遇到qt_qpa_platform_plugin_pathd:\qt\5.15.2\msvc2019_64这样的报错,这不是解析器的问题,而是QT_QPA_PLATFORM_PLUGIN_PATH环境变量没指到plugins\platforms目录。把它跟解析器错误分开排查,能省掉几个小时的胡乱试探。

5.2 用 QTextStream 逐行喂解析器,并同步显示解析结果

Qt 端读文件使用QTextStream逐行读取,比一次性readAll更不容易卡界面,也方便在文件很大时显示进度。下面的代码把每一行交给解析器,成功就插入表格,失败就写日志:

void MainWindow::loadGcode(const QString& path) { QFile file(path); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return; QTextStream in(&file); gcode_parser_reset(parser_); ui->table->setRowCount(0); int fileLine = 0; while (!in.atEnd()) { const QString line = in.readLine(); ++fileLine; if (line.trimmed().isEmpty()) continue; GcodeLine out; int rc = gcode_parser_parse_line( parser_, line.toStdString().c_str(), fileLine, &out); if (rc != 0) { ui->log->append( QString("行 %1: %2").arg(fileLine).arg(line)); continue; } insertRow(fileLine, line, out); } }

这里要求文件必须按原始顺序逐行喂入,不能排序,也不能跳过空行后再单独处理,因为解析器内部的模态状态是连续累加的。表格很大时不要每行都更新一次界面,QTableWidget在这种场景下开销较高;可以每 500 行调用一次setUpdatesEnabled,或者改用QAbstractTableModelQTableView。日志里只记解析失败行,成功行不写,否则上千行的文件会把控件拖慢。

当解析到HomeRequest时,界面状态要单独显示。我一般会让表格的“动作”列显示homing,坐标列保持空白,这样操作员能立刻看出当前行不是普通加工运动。

5.3 三个快速验证点:行号、模态、坐标换算

在接复杂功能之前,先让解析器跑一个最小的“对角线测试”。喂入下面三行,观察输出结果:

G21 G90 G0 X10 Y0 G1 X0 Y10 F300 G2 X-10 Y0 I0 J-10

第一行应该让单位进入公制、位置模式进入绝对值;第二行应该从 G0 切换到 G1,同时继承第一行没有出现的 F 参数,这里 F 继承的是第二行自己给的 300;第三行是圆弧,I/J 必须原样保留。如果第三行把 I/J 误当成 X/Y 坐标处理,说明结构体里 I/J/K 字段被挤掉了,或者地址字扫描阶段没有把 I 归入参数类别。

三个验证点对应的排查方向如下:

验证点期望值失败线索
行号回显一致文件行号与解析器保存的 N 值差为 0文件混入 CR/LF 或 UTF-8 BOM
模态继承第二行没写 G1 前的 G0,运动模式仍是线性每行解析后重置了 ModalState
圆弧参数I/J 原样输出,不受 G20/G21 换算影响扫描 I 时进入了坐标换算分支
homing 标志G28 X 输出 HomeRequest,不是 G1 运动把回零指令当普通运动指令处理

上述验证里最容易出问题的是第三个。很多解析器在扫描到 I/J/K 时会顺手做单位换算,但 I/J/K 表示的是圆心相对起点的偏移,它的坐标系和当前单位应当一致,但单位换算必须乘上同一个系数;如果只换算 X/Y,不换算 I/J,圆弧会画出完全不同的圆心位置。更隐蔽的情况是 G90/G91 对 I/J 的语义影响,增量模式下 I/J 是相对起点偏移,绝对模式下部分控制器会把它解释成绝对圆心,解析器至少要保留原始值并标记当前模式,不要在设计阶段就把圆弧参数的特殊性抹掉。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询