简介这是一份面向单片机应用开发者的Hex转Bin小工具附带完整的C#项目源码。在嵌入式开发中Hex文件常用于直接烧录但需要与其他系统交互或固化到特定介质时Bin格式往往更通用该工具能将Hex文件转换为更通用的Bin格式并会对输入文件进行校验避免转换过程出现数据丢失或损坏。工具基于Visual Studio 2022开发源码注释详细便于用户阅读、理解和修改也支持在生成的Bin文件中加入校验码或加密信息为后续扩展提供较大灵活性。资源包整体体积很小压缩包约452KB共97个文件核心文件包括C#源文件cs、工程文件sln、csproj、可执行程序exe、依赖库dll和说明文档txt等整体目录结构清晰方便直接定位源码与工具。目前已有433人学习下载适合具备一定C#基础、希望在Hex/Bin转换基础上进行定制开发的嵌入式开发者使用。 做嵌入式开发这些年跟 Hex、Bin 这两种文件格式打交道是家常便饭。早年间用 STC 单片机串口下载要的是 Hex后来用 STM32J-Flash、ST-Link Utility 烧录又经常要 Bin再后来做 OTA 升级固件分包下发基本只认 Bin因为 Bin 是纯粹的内存镜像程序跑到哪个地址就往哪个地址写简单直接。问题是很多编译工具链默认只生成 Hex或者拿到手的第三方固件是 Hex 格式要转成 Bin 得去装一个专门的转换软件运气不好还得翻IDE菜单找半天。自己用 C# 写一个转换小工具既能彻底搞懂两种格式的底层逻辑又能顺手解决工作中反复出现的格式转换需求还能把源码留着以后想加批量处理、地址偏移、校验功能都能直接改。这篇文章就把我实际开发这个小工具的思路、代码结构、踩过的坑一次讲清楚做单片机、做上位机、做嵌入式软件的朋友都可以参考跟着操作下来你也能拥有一套自己随时改着用的 Hex/Bin 转换工具。1. 为什么需要自己写一个 Hex 转 Bin 工具在动手写代码之前先得把需求理清楚。市面上不缺转换工具但真正干活的时候你会发现通用工具总有几个让你难受的地方。1.1 Hex 和 Bin 到底差在哪很多人一开始搞不清这两个格式的区别。Bin 文件最简单它就是目标设备内存的原始映像文件里的第一个字节就是起始地址处存放的内容没有任何附加信息烧录器拿到 Bin 之后直接按顺序往 Flash 里搬就行。Hex 文件则完全不是这个逻辑。Intel Hex 本质上是文本文件每一行都是 ASCII 字符记录了地址、数据、校验信息。它的优势是可读性好、能表达非连续地址段、每条记录还有校验和传输过程中出错能及时发现。缺点是解析起来要花功夫烧录器、下载器、上位机软件不能直接拿到数据就写得先解析。这里有个很关键的点Hex 文件里可以包含多个不同的地址段而且地址是显式标出来的Bin 文件没有地址信息它默认就是从烧录起始地址开始连续排列的。所以转换的时候如果没有额外的地址信息一般默认起始地址是 0或者需要用户指定一个起始地址然后根据 Hex 里记录的最大地址和最小地址来确定 Bin 文件的长度。1.2 现成工具的常见痛点我试过几类现成方案都有槽点。第一个是 IDE 自带的转换功能。Keil MDK 里可以通过配置 fromelf 命令行生成 Bin但每次都要改工程配置而且只对当前工程有效临时拿来转换一下别人的 Hex 就完全没法用。CCS 也可以用 hex2000 之类的工具但配置路径、参数同样麻烦。第二个是命令行小工具比如 srec_cat、objcopy。功能确实强大参数也极其复杂问题是平时我们就在 Windows 环境下做开发装一个 GNU 工具链就为了转个文件性价比太低。而且这些工具对新手不友好参数抄错一个就默默报错。第三个是在线转换网站。先不说把固件上传到第三方服务器有多大的安全隐患单说文件太大、网速不稳、转换格式不对这些事就够烦了。我做产品固件的时候公司明文规定禁止把未发布的固件传外网这种场景下有个本地的小工具是刚需。自己用 C# 写一个一方面可以完全掌控解析逻辑想怎么改就怎么改另一方面可以按自己的使用习惯加功能比如拖拽文件到窗口直接转、批量转换、自动识别 Hex 的起始地址。我觉得这一步投入的时间远比以后每次手工找工具、对着参数头疼要划算。2. Intel Hex 格式解析核心原理先行既然要写转换工具就必须把 Intel Hex 的格式吃透。这个格式本身不复杂一共就几种记录类型但细节里全是坑。2.1 Hex 行结构拆解Intel Hex 的每一行都遵循同样的结构位置长度含义起始1 字节冒号:行开始的标志数据长度1 字节本条记录中实际数据字节数范围 0x00~0xFF一般记录为 0x1016字节地址2 字节本条记录数据在内存中的起始地址注意这里是 16 位地址记录类型1 字节00 数据记录、01 文件结束、02 扩展段地址、03 起始段地址、04 扩展线性地址、05 起始线性地址数据N 字节实际的有效数据长度由前面的数据长度字段决定校验和1 字节校验算法往下看举个例子一行典型的 Hex:10010000214601360121470136007EFE09D2190140拆开来看10数据区长度是 16 字节0100地址是 0x010000数据记录类型214601360121470136007EFE09D219014016 字节数据40校验和校验和的计算逻辑是把长度、地址、类型、数据区所有字节累加起来取累加和的低 8 位用 256 减去这个低 8 位得到的值就是校验字节。换句话说所有字节含校验和累加后低 8 位必须等于 0。这个性质可以作为解析时的正确性验证手段。2.2 五种记录类型的处理逻辑记录类型是整个转换的核心每种类型处理方式不一样我一个个说。类型 00数据记录这是最常见的一条里面是真正的固件数据。处理逻辑是把地址和数据存到缓冲区里。注意这里的地址是 16 位的但在大容量单片机时代这个地址往往不是完整的物理地址而是要跟前面扩展地址记录配合使用。类型 01文件结束记录表示文件到此结束正常解析到这一条后就不需要继续处理了。格式固定是:00000001FF数据长度是 0地址是 0类型是 01校验是 FF。类型 02扩展段地址记录这个是比较老式的寻址扩展方式用于 8086 那种分段寻址的模式。它的数据区固定是 2 字节表示段基址实际物理地址要左移 4 位再加偏移。现在大多数工具链默认用扩展线性地址所以这个类型在解析时处理一下就好不是重点。类型 04扩展线性地址记录这是最常用的一种数据区是 2 字节表示后续数据记录地址的高 16 位。比如:020000040800F2表示当前扩展地址是 0x0800那么后面数据记录的实际物理地址就是0x0800 16 | 16位地址。对于 STM32 这类从 0x08000000 开始存放程序的 MCUHex 文件开头必然会有这么一条记录。类型 03 和类型 05起始地址记录分别对应 CS:IP 和 EIP 寄存器初值主要给调试器定位程序入口用的。转换 Bin 的时候直接忽略掉不影响烧录结果。2.3 地址映射与数据填充的关键设计解析完整份 Hex 之后怎么把这些分散的数据拼成一份连续的 Bin我的做法是分三步走。第一步遍历一遍整个文件找到所有数据记录中的最小物理地址和最大物理地址。注意这里的物理地址是扩展地址加偏移之后的结果不是记录里那 16 位地址字段。第二步根据最小地址和最大地址计算出 Bin 文件的长度。比如最小地址是 0x08000000最大地址是 0x08000FFF那 Bin 长度就是 0x1000 字节。第三步分配一个长度合适的字节数组把所有地址统一减去最小地址作为偏移然后把数据填进去。这样无论 Hex 内部的地址段有多分散生成出来的 Bin 都是从最小地址连续排列的。这里有一个需要决策的点如果 Hex 文件里的地址段之间有大段的空洞这些空洞在 Bin 里就只能填 0xFF。因为我用的是字节数组初始化默认值 0所以需要显式把数组全部填充为 0xFF。对于 Flash 来说擦除后的状态就是全 1也就是 0xFF这个处理符合烧录的要求。3. C# 项目结构与核心代码实现工具的开发环境我用的是 .NET 6 WinForms选这个组合是因为 WinForms 写桌面小工具开发速度快、拖拽控件方便而且 .NET 6 以后发布的单文件程序在目标机器上不需要额外装运行时对工具类软件来说发布体验很重要。如果你的环境是 .NET Framework 4.x 也可以代码逻辑完全通用只是项目文件格式会略有差别。3.1 项目文件结构和界面布局整个解决方案里只有一个项目结构很简单按功能拆分文件和类HexToBin/ ├── Program.cs // 入口 ├── MainForm.cs // 主窗体拖拽、按钮 ├── HexParser.cs // Hex 文件解析核心类 ├── HexRecord.cs // Hex 记录的数据模型 └── HexToBin.csproj // 项目文件界面我做得非常克制就三个核心元素一个 TextBox 显示源文件路径支持从资源管理器拖拽文件到窗口一个“转换”按钮一个多行 TextBox 显示日志信息把解析到的地址范围、记录条数、校验状态等关键信息都打出来工具有个毛病就是界面花里胡哨但功能不实用我这里刻意保持简单能用就行。3.2 HexParser 核心解析代码解析类的核心方法是ParseFile输入是 Hex 文件路径输出是解析结果对象里面包含数据缓冲区和一些统计信息。整个解析过程是逐行处理的每一步都有校验。public class HexParser { public byte[] DataBuffer { get; private set; } public int StartAddress { get; private set; } public int EndAddress { get; private set; } public int RecordCount { get; private set; } private List(int Address, byte[] Data) records new List(int, byte[])(); public void ParseFile(string filePath) { string[] lines File.ReadAllLines(filePath); int extendedAddress 0; int minAddr int.MaxValue; int maxAddr 0; bool sawEndRecord false; foreach (string line in lines) { string trimmed line.Trim(); if (string.IsNullOrEmpty(trimmed)) continue; if (!trimmed.StartsWith(:)) throw new FormatException($非法Hex行缺少冒号: {trimmed}); // 每行字符串长度固定为 1 2 4 2 N*2 2 // 即冒号 长度 地址 类型 数据 校验 int byteCount (trimmed.Length - 1) / 2; byte[] rawBytes new byte[byteCount]; for (int i 0; i byteCount; i) { rawBytes[i] Convert.ToByte(trimmed.Substring(1 i * 2, 2), 16); } // 校验和验证 byte sum 0; foreach (byte b in rawBytes) sum b; if (sum ! 0) throw new FormatException($校验和不正确: {trimmed}); int dataLength rawBytes[0]; int address (rawBytes[1] 8) | rawBytes[2]; int recordType rawBytes[3]; switch (recordType) { case 0x00: // 数据记录 int physicalAddr (extendedAddress 16) | address; byte[] data new byte[dataLength]; Array.Copy(rawBytes, 4, data, 0, dataLength); records.Add((physicalAddr, data)); minAddr Math.Min(minAddr, physicalAddr); maxAddr Math.Max(maxAddr, physicalAddr dataLength - 1); break; case 0x01: // 文件结束 sawEndRecord true; break; case 0x02: // 扩展段地址 extendedAddress (rawBytes[4] 12) | (rawBytes[5] 4); break; case 0x04: // 扩展线性地址 extendedAddress (rawBytes[4] 8) | rawBytes[5]; break; case 0x03: // 起始段地址调试用忽略 case 0x05: // 起始线性地址调试用忽略 break; default: throw new FormatException($未知记录类型: 0x{recordType:X2}); } } if (!sawEndRecord) throw new FormatException(Hex文件缺少结束记录(类型01)); if (records.Count 0) throw new FormatException(Hex文件中没有任何数据记录); // 分配缓冲区并填充数据 int totalLength maxAddr - minAddr 1; DataBuffer new byte[totalLength]; // Flash默认全0xFF for (int i 0; i totalLength; i) DataBuffer[i] 0xFF; foreach (var rec in records) { int offset rec.Address - minAddr; Array.Copy(rec.Data, 0, DataBuffer, offset, rec.Data.Length); } StartAddress minAddr; EndAddress maxAddr; RecordCount records.Count; } }几个容易出错的细节我提一下。第一个是扩展地址左移的位数类型 04 的记录扩展地址是 16 位左移 16 位类型 02 的记录扩展地址是段地址左移 4 位。搞混了地址就全错了。第二个是校验和的验证方式直接把整行所有字节累加判断低 8 位是否为零比单独计算再比对要简洁得多。第三个是数据记录里可能没有数据比如:00000000这种空记录转换时要注意长度为零的情况。这些细节在实际工作中踩一次就要折腾半天写进代码里一次搞定。3.3 转换过程与日志输出解析完成之后写入 Bin 文件就非常简单了。一个File.WriteAllBytes就搞定。我比较在意的是日志输出的信息这些信息在排查问题的时候特别有用。private void btnConvert_Click(object sender, EventArgs e) { try { string hexFile txtSource.Text.Trim(); if (!File.Exists(hexFile)) { MessageBox.Show(源文件不存在); return; } HexParser parser new HexParser(); parser.ParseFile(hexFile); string binFile Path.ChangeExtension(hexFile, .bin); File.WriteAllBytes(binFile, parser.DataBuffer); txtLog.AppendText($解析成功共 {parser.RecordCount} 条数据记录。\r\n); txtLog.AppendText($起始地址: 0x{parser.StartAddress:X8}\r\n); txtLog.AppendText($结束地址: 0x{parser.EndAddress:X8}\r\n); txtLog.AppendText($数据大小: {parser.DataBuffer.Length} 字节 (0x{parser.DataBuffer.Length:X})\r\n); txtLog.AppendText($Bin 文件已生成: {binFile}\r\n); } catch (Exception ex) { txtLog.AppendText($转换失败: {ex.Message}\r\n); } }这里有个设计决策默认生成的 Bin 文件跟 Hex 文件同名、同目录后缀改成.bin。这是最符合使用直觉的做法不想覆盖现有文件的话也可以加一个“另存为”按钮来判断。我做的时候默认直接覆盖毕竟大多数情况下转换出来就是要立刻拿去烧录或者打包的。4. 实操流程从源码到独立小工具下面我把完整的实操步骤走一遍分两个场景一个是在 Visual Studio 里调试运行另一个是发布成独立的 exe 工具方便以后拿给同事用。4.1 Visual Studio 建项目、写代码、调试打开 Visual Studio 2022新建项目选择“Windows 窗体应用”框架选 .NET 6 或 .NET 8 都行。项目名称就叫HexToBin。建好之后按上面的结构添加HexParser.cs和HexRecord.cs两个文件复制对应代码。窗体的设计就三步从工具箱拖一个 Button 到窗体底部拖两个 TextBox一个设为只读用来显示路径一个设为多行用来显示日志。在窗体的AllowDrop属性设为 true注册DragEnter和DragDrop事件实现拖拽打开文件。private void MainForm_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.FileDrop)) e.Effect DragDropEffects.Copy; } private void MainForm_DragDrop(object sender, DragEventArgs e) { string[] files (string[])e.Data.GetData(DataFormats.FileDrop); if (files.Length 0 Path.GetExtension(files[0]).ToLower() .hex) { txtSource.Text files[0]; btnConvert_Click(sender, e); } }注意这段代码里有个细节拖拽事件里如果文件名后缀不是.hex我不做任何反应。这是故意的避免误操作。后面如果你想支持.i后缀有些工具链生成的是.i格式的 Intel Hex把扩展名判断数组化就好。调试的时候找一个真实的 Hex 文件直接从资源管理器拖到窗口上看日志输出。我拿了一块 STM32F103 的开发板编译出的测试固件测试日志正确打印出起始地址0x08000000、结束地址0x08002FFF、大小0x3000字节跟工程的链接脚本完全一致。4.2 发布单文件工具的配置工具开发完自己用没问题但要给部门同事分发还得走发布流程。我用的发布配置是这样的在项目文件右击 → 发布 → 选择目标文件夹然后在配置文件里设置PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet6.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms Nullableenable/Nullable PublishSingleFiletrue/PublishSingleFile SelfContainedfalse/SelfContained RuntimeIdentifierwin-x64/RuntimeIdentifier EnableCompressionInSingleFiletrue/EnableCompressionInSingleFile IncludeNativeLibrariesForSelfExtracttrue/IncludeNativeLibrariesForSelfExtract /PropertyGroup这里SelfContained设为false意味着生成的 exe 不包含 .NET 运行时体积小很多大概只有几百 KB但目标机器需要安装 .NET 6 Desktop Runtime。SelfContained设为true的话exe 有几十 MB但任何 Windows 10/11 机器都能直接运行。我个人倾向于false因为做嵌入式开发的同事电脑上普遍装了 VS 或者运行库而且几百 KB 的工具有问题发邮件传起来也方便。用到IncludeNativeLibrariesForSelfExtract是因为 WinForms 有原生依赖不设这个选项的话单文件发布之后可能在本机运行正常、换台机器提示缺少Microsoft.WindowsDesktop.App。这个坑我踩过一次设置之后一切正常。到这里你的HexToBin.exe就可以独立运行了。拖一个 Hex 进去秒出结果日志打印清晰够用。5. 实际使用中的常见问题与排查工具做完不等于万事大吉实际使用中总有一些刁钻情况。我把这一年多用下来遇到的高频问题整理成一份速查表新写代码的朋友可以直接跳过这些坑。5.1 常见问题速查表现象原因解决办法生成的 Bin 文件开头有一大堆 0xFFHex 文件的最小地址不是从 0 开始中间有大段空洞这是正常行为。Bin 是从最小地址开始的连续镜像空洞填充 0xFF。烧录时注意偏移量即可报错“校验和不正确”Hex 文件在复制、传输过程中损坏或文件本身不是标准 Intel Hex用文本编辑器打开看尾部行是否完整重新导出一份 Hex解析时遇到“未知记录类型”用到了比较老的记录类型或者文件根本不是 Hex检查文件扩展名和开头有些.hex文件可能是摩托罗拉 S19 格式那需要另一套解析逻辑生成的 Bin 跟预期大小不符数据记录里存在跨越大段地址的数据比如 bootloader app 分成两段这种情况 Bin 会把两段地址之间的空洞全填 0xFF导致文件偏大。建议先把地址区间确认清楚转换出来的 Bin 烧录后程序不跑地址偏移问题。Bin 没有地址信息烧录器默认从 0 地址开始在烧录工具里设置正确的起始地址参考日志输出里的起始地址。比如日志是 0x08000000烧录时就把地址设为 0x08000000第一行说的情况以前经常误导新手。比如 Hex 文件里的地址是从 0x08000000 到 0x08003FFF但中间有一段没代码Bin 文件就会在对应位置填充 0xFF。这没问题Flash 上没写到的区域本来也是 0xFF。关键是烧录的时候地址要跟日志里的起始地址保持一致。5.2 两个容易忽略的场景第一个场景是非常大的 Hex 文件比如包含蓝牙协议栈的固件Hex 可能有好几 MB。解析时要用File.ReadAllLines的话会把所有行加载到内存在资源受限的电脑上有点压力。虽然实际测试中几 MB 的文件完全没问题但你要是想做得更优雅可以改成用StreamReader逐行读取内存占用会小很多。第二个场景是 BLE 固件升级的场景固件分包下发时往往需要把 Bin 按 16 字节、32 字节或者其他对齐单位切割成固定大小的包并且每包要加序列号和 CRC 校验。这种情况下你可以在 Hex 转 Bin 完成之后在同一个程序里继续对DataBuffer做分包处理直接生成升级包省去中间文件环节。我就是在这个小工具的基础上迭代出了一个固件打包器这才是写自己的工具最大的价值。5.3 关于校验值计算的一点经验有个细节值得单独拿出来说就是校验和计算的验证方式。网上很多版本的代码是先计算一遍校验值然后跟文件里的校验字节比对。这个逻辑没问题但代码要多写几行。我推荐的方式是把整行记录的所有字节包括文件里自带的那个校验字节全部累加最后结果低 8 位是 0说明校验通过。为什么这个方式靠谱因为校验字节本身就是按照“让整行累加和为 0”这个规则生成的所以把文件里的校验字节代入之后数学上天然成立。一行代码一个循环就完成了校验。当然这里要留个心眼Hex 文件里每一行的地址都是大端序也就是高字节在前、低字节在后。解析地址字段的时候要先取高位再取低位。这个顺序搞反了轻则地址错乱重则解析直接失败。C# 里(rawBytes[1] 8) | rawBytes[2]就对了写成(rawBytes[2] 8) | rawBytes[1]就完蛋。这类细节正是自己写解析工具的价值所在——你只有亲手处理过这些数据才会对固件文件的底层结构有真正的理解。6. 这个工具还能怎么扩展最后聊一下扩展方向。一个工具写到能用只是起点觉得好用、顺手才会不断往里加功能。我目前已经加进去的功能包括命令行模式方便在批处理脚本里调用。如果编译脚本里直接调HexToBin.exe input.hex -o output.bin自动化流程会顺畅很多。批量转换把多个 Hex 文件拖进来一键全部转成 Bin。指定填充值有的场景需要空洞填 0x00 而不是 0xFF这是做差分升级包时的常见需求。后续我还想加一个“Bin 转 Hex”的反向功能毕竟打包给工厂生产时有些产测工具只认 Hex。代码架构上HexParser和未来的BinParser是对称的加上HexWriter就完成了闭环。我的体会是这种小工具最大的价值不在于代码量多大、算法多巧妙而在于它把日常工作里琐碎枯燥的部分自动化了同时让你对固件的底层格式有了第一手的认知。自己写、自己用、自己改整个过程比单纯下载一个工具敲个命令学到的多得多。如果你也在做嵌入式或者上位机开发强烈建议照着上面的思路自己写一份源码结构不复杂半天时间就能搞定。本文还有配套的精品资源点击获取
