简介:适用于Windows、Mac和Linux的MatterControl 3D打印软件完整工程包,内含C#源码与G-code相关模块,面向3D打印开发者、创客及希望深入理解切片与控制流程的中高级用户。资源共1990个文件,以953个C#源码文件为核心,辅以308个PNG界面资源、270个printer设备配置、239个txt说明文档,以及STL模型、material材料配置、JSON配置等,整体约41.3MB,目录结构清晰完整。已有319人学习下载。通过阅读源码可系统掌握MatterControl跨平台架构、插件API与打印工作流,并借助G-code生成与编辑逻辑,剖析切片参数如何转换为底层打印机指令。资源同时包含AMF模型样例、工程解决方案与多类配置文件,方便直接编译运行与二次开发,适合功能定制、教学研究及桌面级3D打印机配套软件改造实践。
1. 跨平台3D打印软件为什么值得用C#重写一遍
3D打印机的软件栈通常被切成两半:一边是PrusaSlicer、Cura这类切片工具负责把模型转成G-code,另一边是Pronterface、OctoPrint这类控制端负责把G-code逐行发给打印机。很多时候你想要的并不是再装一个封闭的切片器,而是一个能同时在Windows、Mac、Linux上运行、能用C#直接改逻辑的上位机:解析G-code、模拟坐标、发送指令、读取回温。标题里的项目名称把这几点全点了出来。它能解决的是团队里有人用Mac、有人用Linux、固件工程师又想让协议逻辑完全可控的场景。这篇文章就顺着这个需求,把C#在G-code解析、串口通信、三端打包和排错上的落地路径完整拆开。适合嵌入式上位机开发者、3D打印创客,以及想把.NET知识迁移到机器控制领域的后端工程师。
2. 用C#定义G-code模型:从字符串到可执行指令
2.1 选C#不是情怀,是跨平台和嵌入式之间的半步
3D打印软件常用的技术栈是Python + PyQt、Electron + Node,或者C++ + Qt。Python写起来快,但打包成macOS和Linux原生应用时依赖管理很麻烦,pyserial在高频率G-code流下也不够利落;Electron包体积大,和底层串口交互还要走Node桥接,遇到实时温控曲线时会多一层不必要的调度延迟;C++性能没问题,但开发效率低,团队里如果还有做切片算法的人一起改代码,沟通成本会明显抬高。C#在.NET 6之后Runtime API全面统一,System.IO.Ports、Socket、异步任务、值类型性能都足够,一套代码可以直接编译到Windows、macOS和Linux三个桌面平台。
另一个点是团队协作。3D打印软件往往同时涉及固件协议、文件解析、UI界面三层。C#的UI层可以选Avalonia或GTK#,而G-code解析层则可以完全独立成不依赖UI的类库。这样命令行工具、桌面端、甚至将来把解析服务丢到本地WebAPI里都能复用同一套逻辑。官方模板给的是桌面壳,真正值钱的是中间这部分纯C#的协议内核。标题里的“C#”落点就在这里:不是用WinForms画几个按钮,而是用C#做协议解析、坐标状态机、串口握手和打印进度估算。
2.2 G-code行模型:先定数据结构,再写解析器
常见的做法是先把G-code行抽象成不可变记录类型。G-code每行看起来简单,比如G1 X100 Y50 E2.5 F1800,但实际上有三种内容混在一起:注释、参数、命令。如果一开始就做正则或字符串Split,后面处理坐标继承、注释过滤、固件应答时会越改越乱。一个稳妥的做法是先定义行模型:
public readonly record struct GcodeLine { public string Command { get; init; } // 如 "G1"、"M104"、"T0" public Dictionary<char, double> Params { get; init; } public string Comment { get; init; } // 分号后面的内容 public int LineNumber { get; init; } public bool IsEmpty => Command.Length == 0; }这里用record struct而不是class,是因为G-code文件动辄几十万行,值类型能减少堆分配压力。Command只存“字母+数字”的整体,比如G1、M104、T0;Params保存X、Y、Z、E、F、S、P这些字母对应的数值。注意double精度足够,坐标数值一般到小数点后5位,浮点误差在3D打印场景可以接受,但如果你做的是激光雕刻或者高精度图像映射,建议改用decimal或者干脆以微步整数存储。
解析函数保持纯净,不要在这里做日志和状态修改:
public static GcodeLine Parse(string rawLine, int lineNumber) { ReadOnlySpan<char> line = rawLine.AsSpan(); int commentIdx = line.IndexOf(';'); if (commentIdx >= 0) line = line.Slice(0, commentIdx); line = line.Trim(); if (line.Length == 0) return new GcodeLine { LineNumber = lineNumber }; int cmdEnd = 0; while (cmdEnd < line.Length && (char.IsLetter(line[cmdEnd]) || (cmdEnd > 0 && char.IsDigit(line[cmdEnd])))) cmdEnd++; string command = line.Slice(0, cmdEnd).ToString(); var dict = new Dictionary<char, double>(); ReadOnlySpan<char> rest = line.Slice(cmdEnd); while (rest.Length > 0) { rest = rest.TrimStart(); if (rest.Length == 0) break; char key = rest[0]; int valStart = 1; while (valStart < rest.Length && (char.IsDigit(rest[valStart]) || rest[valStart] is '.' or '-' or '+')) valStart++; if (valStart > 1) dict[key] = double.Parse(rest.Slice(1, valStart - 1), CultureInfo.InvariantCulture); rest = rest.Slice(valStart); } return new GcodeLine { Command = command, Params = dict, LineNumber = lineNumber }; }关键点是先用AsSpan切掉注释,再按“字母+数字”的边界切出命令,最后循环读取“字母+数值”对。这里没有用正则,是因为大文件逐行解析时,Span + 手写循环比正则快一个量级。分号注释是最通用的,但Marlin老固件也支持括号注释,如果你要兼容老版本,可以再加一层(和)的切片判断。double.Parse必须传CultureInfo.InvariantCulture,否则在德语等区域设置下小数点会被当成逗号,整条G-code的坐标就全错了。
2.3 坐标追踪:G-code状态机的最小实现
解析出G-code后,要回答一个实际问题:“执行完这一行,喷头在哪?” 这不是简单取最后一个X值。G1 Y50没有X,X要继承上一条的值;G92会重置坐标系;G28归位后坐标清零。一个可用的坐标追踪器应该记录每个轴的值,并且只响应运动指令和坐标系指令:
public sealed class GcodePositionTracker { public double X { get; private set; } public double Y { get; private set; } public double Z { get; private set; } public double E { get; private set; } public void Apply(GcodeLine g) { if (g.Command == "G1" || g.Command == "G0") { if (g.Params.TryGetValue('X', out var x)) X = x; if (g.Params.TryGetValue('Y', out var y)) Y = y; if (g.Params.TryGetValue('Z', out var z)) Z = z; if (g.Params.TryGetValue('E', out var e)) E = e; } else if (g.Command == "G92") { if (g.Params.TryGetValue('X', out var x)) X = x; if (g.Params.TryGetValue('Y', out var y)) Y = y; if (g.Params.TryGetValue('Z', out var z)) Z = z; if (g.Params.TryGetValue('E', out var e)) E = e; } else if (g.Command == "G28") { if (!g.Params.ContainsKey('X') && !g.Params.ContainsKey('Y') && !g.Params.ContainsKey('Z')) { X = 0; Y = 0; Z = 0; } else { if (g.Params.ContainsKey('X')) X = 0; if (g.Params.ContainsKey('Y')) Y = 0; if (g.Params.ContainsKey('Z')) Z = 0; } } } }注意G28的行为在不同固件里不一致,有些固件归位后不是清零而是设置到软件限位偏移,所以这个类最好支持一个HomeResetMode配置项。坐标追踪最大的用处有两个:一是打印之前做模拟预览,估算打印时间和材料用量;二是在串口通信中断后,能从最后已知位置继续发包,而不是让打印机误以为你在从原点发指令。
| G-code类别 | 典型命令 | 作用 | 是否影响坐标状态 |
|---|---|---|---|
| 运动 | G0/G1 | 快速移动/挤出移动 | 是 |
| 坐标系 | G54-G59 | 切换工作坐标系 | 是 |
| 归位 | G28/G29 | 回原点/自动调平 | 是 |
| 设备控制 | M104/M140 | 设置喷头/热床温度 | 否 |
| 等待 | M109/M190 | 等待到达目标温度 | 否 |
| 查询 | M105 | 读取当前温度 | 否 |
判断“是否影响坐标状态”的意义在于,你可以把不改变状态的指令直接透传,不进入状态机,节省CPU;而所有运动指令和坐标系切换指令都必须进入这个类。比如G54虽然不直接改变坐标,但它改变了后续G1的参考坐标系,如果不处理,你追踪到的位置会和固件内部的位置差一个偏移量。
2.4 用层标记做打印进度快照
如果你要在UI上显示“当前第几层”和“剩余时间”,不能靠数Z值变化实现。螺旋花瓶模型每层Z连续增加,普通模型偶尔也有回抽移动,数Z变化会把一整层当成几十层。常见切片器的做法是在层起始处写注释:
;LAYER_COUNT:36 ;LAYER:0解析时遇到;LAYER:就推进一层,同时把当前坐标状态机的快照存下来。这一层逻辑建议单独放在LayerTracker类里,不要塞进解析器,因为它负责的是“从线性流里提炼打印进度”,与语法解析解耦。事件或回调把层变更抛出去,Avalonia的UI订阅后更新进度条即可。
3. 串口发送与G-code流控制:跨平台下的收发实现
3.1 串口参数与端口识别
3D打印主板通常用115200或250000波特率,数据位8、无校验、1停止位。坑不在这个参数组合,而在三端对串口名称的处理完全不同:Windows上是COM3,macOS上是/dev/tty.usbserial-xxx,Linux上是/dev/ttyUSB0或/dev/ttyACM0。代码里不能硬编码端口名,要做两个配合:一是枚举端口让用户选择,二是读取USB设备描述显示友好名称。
var portNames = SerialPort.GetPortNames(); foreach (var name in portNames) { // Linux/macOS 下可以通过 /sys/class/tty/ttyUSB0/device/interface 读取设备名 Console.WriteLine(name); }在Linux上,/sys/class/tty/ttyUSB0/device/interface通常存着“USB-Serial Controller”这类描述。过滤掉ttyS0和蓝牙设备后,剩下的才是真正要用的打印机串口。另外,打开串口后建议固定设置DtrEnable和RtsEnable为true,然后延时500ms再发数据。有些主板在串口打开瞬间会复位,如果立即发送,复位后的bootloader会漏掉第一行指令。
3.2 异步读行循环:避免DataReceived跨平台差异
System.IO.Ports的DataReceived事件在Windows上很可靠,但在Linux和macOS上触发时机有差异,且ReadLine()在流没有结束时会阻塞事件线程,导致后续数据进不来。一个更稳的写法是放弃事件,直接基于BaseStream.ReadAsync做读行循环:
public async Task ReadLoopAsync(CancellationToken ct) { var buffer = new byte[4096]; var pending = new StringBuilder(); while (!ct.IsCancellationRequested) { int read = await _serial.BaseStream.ReadAsync(buffer, ct); if (read <= 0) continue; pending.Append(Encoding.UTF8.GetString(buffer, 0, read)); int newlineIdx; while ((newlineIdx = pending.ToString().IndexOf('\n')) >= 0) { string rawLine = pending.ToString(0, newlineIdx).TrimEnd('\r'); pending.Remove(0, newlineIdx + 1); OnLineReceived(rawLine); } } }这个循环把接收字节流拆成行,兼容\r\n和\n两种换行。IndexOf每次都把StringBuilder转成字符串,性能上不是最优,但串口数据量不大,稳定性优先。每收到一行就交给OnLineReceived,你可以在那里判断是不是ok,是不是wait,或者是不是M105温度回执。不要在这个方法里做UI更新,丢给后台任务汇总。
3.3 带ok握手的发送队列
Marlin固件的缓冲机制是:每收到一行G-code,执行完后回复ok。如果你连续发送几十行,缓冲区被填满后就会丢指令。常见做法是维护一个发送窗口:允许同时有N条指令在飞行,收到一个ok就补一条。
public sealed class GcodeSender { private readonly SerialPort _serial; private readonly Queue<string> _pendingQueue = new(); private readonly SemaphoreSlim _okSignal = new(0); private int _inFlight; private readonly int _maxInFlight; public GcodeSender(SerialPort serial, int maxInFlight = 4) { _serial = serial; _maxInFlight = maxInFlight; } public void HandleLine(string line) { if (line == "ok" || line.StartsWith("ok ")) { Interlocked.Decrement(ref _inFlight); _okSignal.Release(); } else if (line.StartsWith("busy:")) { // 固件忙,等待一段时间后再继续 Thread.Sleep(200); } } public async Task SendAsync(string gcodeLine, CancellationToken ct) { while (Volatile.Read(ref _inFlight) >= _maxInFlight) await _okSignal.WaitAsync(ct); _serial.BaseStream.Write(Encoding.UTF8.GetBytes(gcodeLine + "\n")); Interlocked.Increment(ref _inFlight); } }maxInFlight一般取4到16。如果主板固件的串口缓冲区较大可以调高,保守一点4就够。注意发送时手动拼\n,不要用WriteLine,因为WriteLine在不同平台上写入的是Environment.NewLine,Windows上会变成\r\n,有些固件对\r很敏感。busy:响应表示固件内部正在处理长时间任务,比如写SD卡,此时要等它恢复ok才能继续发下一批,否则指令会积压。
3.4 温度轮询解析
打印过程中需要周期性发送M105查询温度。M105的响应格式是ok T:200.0 /205.0 B:55.0 /60.0,解析时不能干扰ok握手的计数,所以它在HandleLine里除了匹配ok以外,还要额外提取温度:
private static (double hotend, double bed)? TryParseTemp(string line) { var match = Regex.Match(line, @"T:([\d.]+).*?B:([\d.]+)"); if (!match.Success) return null; return (double.Parse(match.Groups[1].Value), double.Parse(match.Groups[2].Value)); }Marlin不同版本输出精度不同,有的输出T:200.0,有的输出T:200,正则里用[\d.]+可以兼容整数和小数。温度解析不要阻塞接收线程,把结果存成一个字段,UI用一个Timer定时读取即可。
4. 一套代码发布到Windows、Mac、Linux:打包参数详解
4.1 发布方式选择:自包含还是框架依赖
自包含发布会把.NET运行时一起打包,目标机器上不需要预装任何环境,但体积会从几十MB涨到一百多MB。框架依赖发布体积小,但用户机器必须装对应版本的.NET Desktop Runtime,这对很多创客并不现实。3D打印软件通常分发给动手能力强的人,机器环境乱七八糟,自包含是更省心的选择。唯一的代价是如果程序里引用了像SQLite这样的本机库,发布目录里会多出几个.so、.dylib、.dll文件,打包时不要漏掉。
4.2 dotnet publish三端命令与RID
常用做法是在CI里分别跑三条发布命令,然后各自压缩成zip,对应标题里“下载.zip”的形态:
# Windows x64 dotnet publish -c Release -r win-x64 --self-contained true \ -p:PublishSingleFile=true -o build/win-x64 # macOS Intel 和 Apple Silicon dotnet publish -c Release -r osx-x64 --self-contained true \ -p:PublishSingleFile=true -o build/osx-x64 dotnet publish -c Release -r osx-arm64 --self-contained true \ -p:PublishSingleFile=true -o build/osx-arm64 # Linux x64 dotnet publish -c Release -r linux-x64 --self-contained true \ -p:PublishSingleFile=true -o build/linux-x64每条命令的-r参数指定Runtime Identifier。注意PublishSingleFile=true并不是AOT,它只是把托管程序集合并进一个可执行文件,运行时还是要JIT编译。如果程序里用了System.IO.Ports,单文件发布后这个库在Linux上仍然依赖libdl和libc,CentOS 7这类老系统上可能缺少高版本GLIBC,最简单的方式是在Docker容器里用mcr.microsoft.com/dotnet/sdk:8.0-jammy构建,这样可以避免链接到当前开发机过新的glibc。
4.3 单文件压缩与Globalization开关
发布参数里最容易被忽视的是下面这三个:
| 参数 | 作用 | 建议值 | 注意事项 |
|---|---|---|---|
| InvariantGlobalization | 关闭全球化数据 | true | 省约20MB体积,但CultureInfo需要全部固定用InvariantCulture |
| UseSystemResourceKeys | 系统错误消息简化 | true | 减少未处理异常信息体积 |
| EnableCompressionInSingleFile | 单文件压缩 | true | 第一次启动会解压,启动略有延迟 |
在csproj里设置:
<PropertyGroup> <InvariantGlobalization>true</InvariantGlobalization> <UseSystemResourceKeys>true</UseSystemResourceKeys> <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> </PropertyGroup>InvariantGlobalization开启后,所有区域相关的日期、数字格式化行为都会被固定,G-code解析里必须用CultureInfo.InvariantCulture这一点就成了硬约束,写代码时漏掉一个double.Parse在后续测试里会很难现。如果软件计划做多语言界面,这个开关就先不要开,改用裁剪后的ICU。3D打印软件通常单语言,开它没有负担。
4.4 macOS签名与Universal二进制
Apple Silicon和Intel的mac不能只发一个x64包。先用两条dotnet publish分别产出osx-arm64和osx-x64,再用lipo合并且通用二进制:
lipo -create -output build/3DPrinterApp \ build/osx-x64/3DPrinterApp \ build/osx-arm64/3DPrinterApplipo合并的是可执行文件本身。如果应用包结构里有.app/Contents/MacOS/,把合并后的文件替换进去即可。内部分发的mac应用没有Developer ID证书时,用户在首次打开时会碰到Gatekeeper拦截,常见做法是让用户执行一次xattr -dr com.apple.quarantine /Applications/YourApp.app。这不是软件的bug,只影响分发给非技术人群时的体验。
4.5 Linux下的串口权限与zip包目录组织
Linux用户经常会遇到“打开串口提示Permission denied”而不是程序逻辑问题。原因是当前用户不在dialout组里。常见分发包里会附带一个udev规则:
# /etc/udev/rules.d/50-3dprinter.rules SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", GROUP="dialout", MODE="0660"CH340/CH341芯片的设备ID是1a86:7523,FTDI芯片是0403:6001。规则写完后执行sudo udevadm control --reload-rules && sudo udevadm trigger。zip包结构建议这样放:
3dapp-linux-x64.zip ├── 3dapp ├── 50-3dprinter.rules └── README-Linux.txtWindows、Mac、Linux三个zip各自独立,README里写清运行前提。这个结构虽然简单,但在现场部署时比让用户自己去找驱动路径可靠得多。
5. 现场排错:三端打印软件最容易翻车的四个问题
5.1 打印机无响应,先查物理链路和波特率
遇到打印机完全不回ok,不要急着改代码,先确认三件事:串口线是数据线还是只能充电的线?用的是正确的tty设备还是蓝牙设备?波特率是否和固件匹配?最常见的坑是CH340驱动在Windows 11下没有自动安装,设备管理器里显示感叹号;在Linux下则可能是modemmanager抢占了ttyUSB0。临时停用modemmanager的命令是:
sudo systemctl stop ModemManager停掉后串口就能被应用打开。如果仍然无响应,用手头最简单的方式验证:断开和打印机的连接,用跳线短接串口模块的TX和RX,然后在软件里发一行M105。如果能看到自己发出去的内容回显,说明串口链路是通的,问题在打印机的固件或主板接线。
5.2 乱码不一定是波特率,先看换行和流控
打印过程中出现乱码,大多数人第一反应是波特率设错了,但不全对。波特率不一致时乱码通常每行都乱,不可能偶尔有一行正常解析。如果运行一段时间后才出现乱码,更可能是发送端和接收端换行不一致。Windows上\r\n入串口,Marlin某些版本会把\r当成指令的一部分,导致ok后延迟变长,后续指令时间一长就堆积。解决办法前面已经提到:发送时统一追加\n,接收时统一TrimEnd('\r')。
另外检查流控设置。很多USB转串口芯片在开启硬件流控后,如果CTS引脚悬空,数据不会真正发出去。建议代码里强制关闭硬件流控:_serial.Handshake = Handshake.None。Dtr/Rts单独控制,不要和Handshake混在一起。
5.3 用环回测试验证数据链路是否通
在复杂的排错现场,可以直接用一个小命令做串口环回验证,不用额外装工具:
serial.BaseStream.Write(Encoding.UTF8.GetBytes("M105\n")); await Task.Delay(200); string resp = await ReadLineWithTimeoutAsync(serial, 500);把串口的TX和RX短接后发送M105,能读到一模一样的M105\n,说明操作系统到串口驱动这一段完全正常。此时如果接上打印机不发回执,问题就锁定在主板固件和供电上。读完这一段,你已经把从G-code解析到三端交付再到现场排错的链路完整跑了一遍,剩下的具体固件差异,交给Marlin的配置页去处理。
本文还有配套的精品资源,点击获取