简介:这是一份面向嵌入式与通信设备开发者的串口烧录工具工程源码,基于 Windows Forms 构建,主要用于对讲机等设备通过串行接口完成固件升级与数据写入。工程围绕串口参数配置、数据收发、固件更新协议解析、烧录进度界面与异常处理等环节展开,适合需要了解上位机烧录流程、学习串口通信编程或二次开发类似工具的中级开发者参考。压缩包共 88 个文件,约 2.21MB,以 cs 源码、exe 可执行文件、dll 依赖库、config 配置、manifest 清单及 pfx 证书等为主,另含解决方案与项目文件,目录结构完整,便于直接打开工程梳理模块划分与调用关系。目前已有 932 人学习下载。读者可从中获取串口烧录的界面组织方式、通信参数匹配思路与固件传输流程的参考实现,用于对照自身项目排查通信异常或搭建类似上位机工具。
1. 一个 WinForm 串口烧录工具,解决的是产线最后一百米的重复劳动
做嵌入式的人大概都经历过这种场面:板子焊回来,插上 USB 转串口,打开某个上位机,选 COM 口、选波特率、点连接、选固件文件、点下载、等进度条、看返回的 OK,然后拔线换下一块。一块板子两分钟,一百块板子就是一下午。更难受的是,这个流程里任何一步点错,比如选错了 COM 口或者波特率,轻则重来,重则把 bootloader 的握手时序打乱,得重新上电。这个项目就是一个用 WinForm 写的串口烧录工具,把上面这套动作压缩成「选口、选文件、点烧录」三步,适合做小批量产线烧录、实验室样机刷写,或者给不熟悉命令行的同事做一个傻瓜式入口。它不追求跨平台,也不搞花哨的协议抽象,核心就是 SerialPort 加一个自定义的帧协议,把固件按块发下去、按块等应答。下面按「这东西怎么组织的、怎么跑起来、哪里容易翻车、怎么改成你自己的」这条线拆一遍。
2. 工程结构与技术选型:一个 WinForm 壳子加一条串口协议链路
2.1 项目分层:UI、串口会话、协议编解码三段式
这类工具最容易写成一坨:按钮事件里直接SerialPort.Write,读数据也在同一个线程里ReadExisting,最后代码全挤在Form1.cs里。真到要改协议或者加个新芯片,就得在事件处理函数里翻半天。合理的做法是按职责切三层,WinForm 这边只负责「收集参数、显示状态」,串口会话层负责「打开关闭、收发字节、超时控制」,协议层负责「把固件块打包成帧、把应答帧解析成结果」。目录大概长这样:
SerialBurner/ ├── Forms/ │ ├── MainForm.cs 主窗口,控件事件绑定 │ └── MainForm.Designer.cs ├── Core/ │ ├── SerialSession.cs 串口打开/关闭/收发封装 │ ├── BurnProtocol.cs 帧格式、校验、命令字 │ └── FirmwareImage.cs 固件文件读取、分块、校验和 ├── Models/ │ ├── BurnOptions.cs 波特率、块大小、超时等参数 │ └── BurnProgress.cs 进度回调数据 └── Program.csSerialSession只暴露Open、Close、WriteFrame、ReadFrame这类方法,内部用SerialPort的DataReceived事件把字节塞进一个缓冲区,再由上层按帧头去切。BurnProtocol是纯静态类,输入字节数组输出帧,不碰串口对象,这样单元测试可以直接跑,不用插硬件。FirmwareImage负责把.bin读成字节数组、按块大小切片、算每块的 CRC。UI 层通过事件或者回调拿进度,不直接碰串口。这个分层不是为了好看,是为了后面换芯片时只改BurnProtocol里的命令字和握手序列,其余不动。
2.2 串口参数与帧格式:先定死握手,再谈速度
串口烧录的坑一大半出在参数上。常见配置是 115200 8N1,但有些 bootloader 要求先以低速握手、再切高速传数据,这个项目里我把它做成BurnOptions里的两个字段:HandshakeBaud和DataBaud。帧格式用最朴素的一种:帧头0xAA 0x55、命令字 1 字节、长度 2 字节(小端)、载荷 N 字节、CRC16 两字节。命令字至少要有CMD_SYNC(握手)、CMD_ERASE(擦除)、CMD_WRITE(写块)、CMD_VERIFY(校验)、CMD_JUMP(跳转运行)。应答帧同结构,命令字最高位置 1 表示 ACK,载荷里带状态码。
// BurnProtocol.cs 片段:组一帧写块命令 public static byte[] BuildWriteFrame(ushort seq, byte[] block) { // 载荷 = 序号(2) + 数据块 var payload = new byte[2 + block.Length]; payload[0] = (byte)(seq & 0xFF); payload[1] = (byte)(seq >> 8); Buffer.BlockCopy(block, 0, payload, 2, block.Length); var frame = new byte[6 + payload.Length + 2]; frame[0] = 0xAA; frame[1] = 0x55; // 帧头 frame[2] = CMD_WRITE; // 命令字 frame[3] = (byte)(payload.Length & 0xFF); // 长度低字节 frame[4] = (byte)(payload.Length >> 8); // 长度高字节 Buffer.BlockCopy(payload, 0, frame, 5, payload.Length); var crc = Crc16.Compute(frame, 2, 3 + payload.Length); // 从命令字算到载荷尾 frame[5 + payload.Length] = (byte)(crc & 0xFF); frame[6 + payload.Length] = (byte)(crc >> 8); return frame; }这里几个参数值得说清楚:seq是块序号,从 0 递增,bootloader 靠它判断有没有丢块,重传时序号不变;block的长度由BurnOptions.BlockSize决定,常见 256 或 1024 字节,块越大单帧开销占比越小,但串口接收缓冲和 bootloader 的 RAM 缓冲要跟得上,1024 是个比较稳的折中;CRC 的起始位置是帧头之后,也就是从命令字开始算,这一点必须和固件端严格一致,否则就是「发出去全 ACK 失败」的经典翻车。长度字段用小端,是因为多数 MCU 是小端,省得固件端再做字节序转换。
2.3 收发线程模型:别在 UI 线程里等应答
WinForm 的按钮事件跑在 UI 线程上,如果里面写Thread.Sleep等应答,界面直接假死,用户以为卡了就去点关闭,串口句柄泄漏。这个项目的做法是:点击烧录后,把整个烧录流程丢到Task.Run里,UI 线程只负责禁用按钮、更新进度条。串口接收用DataReceived事件往ConcurrentQueue<byte>或者一个带锁的List<byte>里塞,烧录线程按「发一帧、轮询缓冲区找完整应答帧、超时重试」的节奏走。
// SerialSession.cs 片段:带超时的读帧 public byte[] ReadFrame(int timeoutMs) { var deadline = Environment.TickCount + timeoutMs; while (Environment.TickCount < deadline) { lock (_rxLock) { int idx = FindHeader(_rxBuffer); // 找 0xAA 0x55 if (idx >= 0 && _rxBuffer.Count >= idx + 5) { int len = _rxBuffer[idx + 3] | (_rxBuffer[idx + 4] << 8); if (_rxBuffer.Count >= idx + 5 + len + 2) { var frame = _rxBuffer.GetRange(idx, 5 + len + 2).ToArray(); _rxBuffer.RemoveRange(0, idx + 5 + len + 2); return frame; // 交给上层校验 CRC } } } Thread.Sleep(5); // 轻量轮询,别用忙等 } return null; // 超时,上层决定重试 }timeoutMs一般给 500 到 1000 毫秒,擦除命令可能更久,单独给 3000。Thread.Sleep(5)是轮询间隔,太小空转 CPU,太大增加单帧延迟,5 毫秒在 115200 下差不多是 50 个字节的传输时间,够用。注意_rxBuffer的清理逻辑:找到帧头之前的垃圾字节要丢掉,否则一次干扰就会让后续所有帧错位,这是串口解析里最常见的「越跑越乱」来源。
3. 从零跑起来:环境、编译、第一次烧录的完整路径
3.1 环境准备与编译:.NET Framework 还是 .NET 6
WinForm 项目有两种常见起点:老一点的.NET Framework 4.7.2/4.8,新一点的.NET 6/8的net6.0-windows。这个工具用到的System.IO.Ports在 Framework 下是内置的,在 .NET 6 以上需要额外装System.IO.PortsNuGet 包。如果拿到的是 Framework 版本,用 Visual Studio 2019/2022 打开.sln,确认目标框架已安装,直接 F5 就能跑;如果是 SDK 风格项目,dotnet build也行,但 WinForm 设计器在非 VS 环境下体验一般,建议还是用 VS。
# SDK 风格项目的命令行编译(在项目根目录) dotnet restore dotnet build -c Release # 产物在 bin/Release/net6.0-windows/ 下编译前检查两件事:一是System.IO.Ports包版本和运行时匹配,二是项目属性里的「平台目标」建议设成 x86 或 AnyCPU,别设成 x64 后又去调 32 位的串口驱动 DLL,那种BadImageFormatException排查起来很浪费时间。第一次跑之前,把 USB 转串口驱动装好,设备管理器里能看到 COM 号,再打开工具。
3.2 界面操作顺序:选口、选文件、点烧录
主窗口上一般就这几个控件:串口下拉框(ComboBox,启动时枚举SerialPort.GetPortNames())、波特率下拉框、一个「打开串口」按钮、一个「选择固件」按钮加文件路径显示、一个「开始烧录」按钮、一个进度条和一个日志文本框。操作顺序是:先插板子上电,点刷新枚举串口,选中对应 COM 口,点打开串口,日志里出现「串口已打开」;然后选.bin文件,工具会读文件、算总块数、显示大小;最后点开始烧录,日志按「握手成功 → 擦除中 → 写入块 12/240 → 校验通过 → 跳转运行」滚动。
// MainForm.cs 片段:开始烧录按钮的入口 private async void btnBurn_Click(object sender, EventArgs e) { if (_session == null || !_session.IsOpen) { Log("请先打开串口"); return; } var image = FirmwareImage.Load(txtFirmwarePath.Text); // 读文件+分块 var options = new BurnOptions { BlockSize = 1024, HandshakeBaud = 115200, DataBaud = 460800, RetryCount = 3 }; btnBurn.Enabled = false; try { var progress = new Progress<BurnProgress>(p => { progressBar.Value = p.Percent; Log($"写入块 {p.CurrentBlock}/{p.TotalBlocks}"); }); await Task.Run(() => _burner.Run(image, options, progress)); Log("烧录完成"); } catch (Exception ex) { Log("烧录失败: " + ex.Message); } finally { btnBurn.Enabled = true; } }Progress<BurnProgress>会自动把回调 marshal 回 UI 线程,所以里面直接改控件是安全的,不用自己Invoke。RetryCount = 3表示单块写失败重发三次,三次还失败就整体中止,避免在坏板子上无限重试。DataBaud设成 460800 是很多 USB 转串口芯片(CH340、CP2102)能稳定跑的上限附近,再高就要看线材和板子晶振了,第一次跑建议先用 115200 全流程走通,再往上调。
3.3 用虚拟串口先验证协议:不插板子也能测
手上没有板子的时候,可以用一对虚拟串口(比如 com0com 这类工具)把工具和一个自己写的小模拟器对接,模拟器按同样的帧格式回 ACK。这样能先把「组帧、拆帧、超时重试、进度计算」这些纯软件逻辑跑通,再去接真硬件。模拟器不用复杂,一个控制台程序打开另一端串口,收到CMD_SYNC回 ACK,收到CMD_WRITE校验 CRC 后回 ACK,收到CMD_JUMP回 ACK 然后退出。
// 极简 bootloader 模拟器核心循环 var port = new SerialPort("COM11", 115200) { ReadTimeout = 1000 }; port.Open(); while (true) { var frame = ReadOneFrame(port); // 和工具端同样的拆帧逻辑 if (frame == null) continue; if (!Crc16.Check(frame)) { SendNack(port); continue; } switch (frame[2]) { case CMD_SYNC: SendAck(port, CMD_SYNC); break; case CMD_WRITE: SendAck(port, CMD_WRITE); break; case CMD_JUMP: SendAck(port, CMD_JUMP); return; } }这一步的价值在于把「协议对不对」和「硬件好不好」两个问题分开。真到接板子时如果握手失败,你就知道大概率是硬件侧波特率、接线或者 bootloader 没进下载模式,而不是自己组帧写错了。血泪经验是:先让模拟器全绿,再上真板子,能省掉一半的互相甩锅时间。
4. 核心链路拆解:一次烧录从握手到跳转都发生了什么
4.1 握手与擦除:先对齐波特率,再动 Flash
烧录流程的第一步不是发数据,是握手。工具先以HandshakeBaud打开串口,发CMD_SYNC,等 bootloader 回 ACK。有些 bootloader 要求在上电后几百毫秒内发握手,过了窗口就跳应用了,所以工具里握手要带重试,并且允许用户手动「复位后立即点烧录」。握手成功后如果DataBaud和HandshakeBaud不同,发一条切波特率命令,双方同时改SerialPort.BaudRate,再发一次CMD_SYNC确认新速率下通信正常。这一步翻车的典型现象是「切完波特率就再也收不到 ACK」,原因多半是切换命令发出后工具立刻改了本地波特率,而 bootloader 还在用旧速率回 ACK,导致那条 ACK 被当成乱码丢掉。稳妥做法是切速率后先清空接收缓冲,再发同步帧。
擦除命令CMD_ERASE的载荷里带起始地址和长度,bootloader 按页擦除。擦除耗时和 Flash 大小、页大小有关,几 MB 的片子可能要几秒,所以这条命令的超时要单独放大到 3000 到 5000 毫秒。擦除期间不要发任何其他帧,否则 bootloader 可能正在忙、直接丢帧,工具这边就会误判超时然后重试,重试又撞上还没擦完,最后报一个莫名其妙的失败。
4.2 分块写入与重传:序号和 CRC 是后悔药
写入阶段是循环:取第 N 块、组CMD_WRITE帧、发送、等 ACK、成功则 N+1,失败则重发同一块,超过RetryCount就中止。这里有两个细节决定稳定性。第一,ACK 里要带块序号,工具收到 ACK 后比对序号,防止把上一块的迟到 ACK 当成当前块的,这种「错位 ACK」在高速率下偶发,一旦发生就会导致后续全部错位。第二,每块数据本身可以再带一个块内 CRC,和帧 CRC 形成双保险,帧 CRC 防传输错误,块 CRC 防 bootloader 写 Flash 时出错。
// Burner.cs 片段:带重传的写块循环 for (ushort seq = 0; seq < image.Blocks.Count; seq++) { var block = image.Blocks[seq]; bool ok = false; for (int attempt = 0; attempt < options.RetryCount && !ok; attempt++) { _session.WriteFrame(BurnProtocol.BuildWriteFrame(seq, block)); var resp = _session.ReadFrame(800); if (resp != null && BurnProtocol.IsAck(resp, CMD_WRITE, seq)) ok = true; else Thread.Sleep(20); // 给 bootloader 一点喘息 } if (!ok) throw new IOException($"块 {seq} 写入失败,已重试 {options.RetryCount} 次"); progress.Report(new BurnProgress(seq + 1, image.Blocks.Count)); }Thread.Sleep(20)是重传前的退避,别小看这 20 毫秒,它能让 bootloader 从上一帧的解析状态里恢复出来,尤其是那些用中断收串口、主循环解析的固件,连续两帧贴太近容易丢。ReadFrame(800)的超时按块大小和波特率估算:1024 字节在 460800 下大约 22 毫秒传完,加上 bootloader 写 Flash 的时间,800 毫秒足够宽裕,又不至于让真失败等太久。
4.3 校验与跳转:最后一步别急着拔线
所有块写完后发CMD_VERIFY,bootloader 对整片区域算 CRC 或者逐块比对,返回结果。校验通过再发CMD_JUMP,让 bootloader 跳到应用区入口。跳转命令发出后,工具应该等一个短 ACK 或者干脆等 100 毫秒再关串口,因为有些 bootloader 是先回 ACK 再跳,有些是直接跳、ACK 由应用发。如果工具在跳转后立刻Close(),可能把还没发完的 ACK 截断,日志里就会少一行「跳转成功」,虽然不影响功能,但会让人怀疑是不是没跳过去。
校验失败的处理也要想清楚:是整片重烧,还是只重传出错块?简单做法是整片重烧,因为定位出错块需要 bootloader 支持回读,多数简易 bootloader 没有这个能力。整片重烧前记得重新发一次CMD_ERASE,别在没擦的区域上直接写,Flash 没擦就写会得到一堆看似成功实则数据错乱的结果,这种板子跑起来行为诡异,排查成本极高。
5. 避坑与排查:串口烧录最容易翻车的五个地方
5.1 现象:握手一直超时,换块板子又好了
原因通常不是代码,是时序和接线。USB 转串口的 TX/RX 接反、共地没接、板子没进 bootloader 模式(比如 BOOT 引脚没拉对)、或者上电后错过了握手窗口,都会表现为「发出去没回应」。解决顺序是:先用示波器或者逻辑分析仪看工具端 TX 有没有波形,有波形再看板子 RX 引脚有没有收到,收到再看 bootloader 是不是在等握手。没有仪器就退一步,把波特率降到 9600、握手重试间隔拉长到 500 毫秒,很多时候能握手成功,说明就是时序窗口太窄。
5.2 现象:前几块成功,后面开始大量 NACK
这种「越写越差」的曲线,八成是接收缓冲区没清干净或者流控没开。数据量大之后,如果 bootloader 处理速度跟不上,而工具还在猛发,串口接收溢出就会丢字节,丢字节导致帧错位,错位后 CRC 全错,于是 NACK 连片。解决方法是每块之间加一个小延时,或者启用 RTS/CTS 硬件流控(前提是板子接了这两根线)。另一个可能是_rxBuffer只增不减,垃圾字节越积越多,FindHeader每次都从很后面开始找,延迟越来越大,最后超时。检查ReadFrame里找到帧后有没有RemoveRange掉已消费的部分。
5.3 现象:切高速率后通信中断,改回 115200 又正常
这是第 4.1 节说的切换时序问题。工具发完切速率命令立刻改本地BaudRate,bootloader 的 ACK 还在旧速率上飞,被新速率解析成乱码。解决:发切换命令后先Thread.Sleep(50),再清空_rxBuffer,再改BaudRate,再发同步帧确认。如果 bootloader 固件是自己写的,更稳的做法是让它在切速率前先回一个「准备切换」的 ACK,工具收到这个 ACK 再改,双方有个明确的同步点。
5.4 现象:烧录显示成功,板子跑起来行为不对
先怀疑「没擦就写」。有些 bootloader 的CMD_WRITE内部不检查目标区域是否已擦除,直接写,Flash 的编程规则是只能把 1 写成 0,没擦的区域写进去就是旧数据和新数据按位与,结果既不是旧的也不是新的。解决:确保每次烧录前都发CMD_ERASE,并且擦除后等 ACK 再开始写。另一个可能是固件文件本身不对,比如偏移地址没算对,工具从 0 地址开始写,而应用区实际从 0x8000 开始,写进去的全错位。检查FirmwareImage里有没有BaseAddress字段,组帧时地址要带上。
5.5 现象:关掉工具再打开,串口打不开
SerialPort对象没Dispose,或者Close()时还有线程在Read,句柄没释放,下次打开就报「拒绝访问」。WinForm 里要在FormClosing事件里显式关串口、停掉烧录线程、Dispose掉SerialPort。更隐蔽的一种是异常路径:烧录中途抛异常,finally里只恢复了按钮状态,忘了关串口,用户以为关了窗口其实进程还挂着。养成习惯,串口的生命周期用using或者在一个统一的Shutdown()方法里收口,别散落在各个事件里。
6. 二次开发:把协议层换成你自己的,其余照抄
拿到这个工具想改成自己芯片的烧录器,改动量最小的是只换BurnProtocol里的命令字和帧格式,SerialSession和 UI 基本不动。具体做法:先把你的 bootloader 协议文档摊开,列出握手序列、命令字、应答格式、超时要求,然后在BurnProtocol里一一对应实现BuildSyncFrame、BuildEraseFrame、BuildWriteFrame、ParseResponse。如果协议不是「一问一答」而是「连续发、最后统一应答」,那SerialSession的ReadFrame轮询模型要改成「发完一批再收」,RetryCount的语义也要从「单块重传」变成「整批重传」。
第二个常见改造点是加芯片型号选择。不同芯片的 Flash 页大小、应用区起始地址、块大小上限都不一样,把这些做成BurnOptions的预设,界面上加一个下拉框,选型号就带出参数。别把这些常量硬编码在BurnProtocol里,否则每加一个芯片就要改一次协议类,越改越乱。
// 芯片预设:把差异收进配置,协议层只认参数 public static class ChipPresets { public static readonly Dictionary<string, BurnOptions> All = new() { ["ChipA"] = new BurnOptions { BlockSize = 1024, AppBase = 0x08008000, EraseTimeout = 3000 }, ["ChipB"] = new BurnOptions { BlockSize = 256, AppBase = 0x00004000, EraseTimeout = 5000 }, }; }验证改造是否成功,别一上来就烧整片。先烧一个只有几块的小测试固件(比如点灯程序),确认握手、擦除、写入、校验、跳转五步日志齐全,再烧完整固件。每次改完协议层,先用第 3.3 节的模拟器跑一遍,模拟器里把新协议实现一遍,两边对得上再上硬件。这个习惯是我被「改完直接上板、结果把 bootloader 擦没了、只能拆下来用编程器救」教出来的——从那以后我每次动协议层,都强制先过模拟器再碰真板子。希望帮到你。
本文还有配套的精品资源,点击获取