简介:这是一份面向单片机应用开发者的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;后来用 STM32,J-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,一般记录为 0x10(16字节) |
| 地址 | 2 字节 | 本条记录数据在内存中的起始地址,注意这里是 16 位地址 |
| 记录类型 | 1 字节 | 00 数据记录、01 文件结束、02 扩展段地址、03 起始段地址、04 扩展线性地址、05 起始线性地址 |
| 数据 | N 字节 | 实际的有效数据,长度由前面的数据长度字段决定 |
| 校验和 | 1 字节 | 校验算法往下看 |
举个例子,一行典型的 Hex:
:10010000214601360121470136007EFE09D2190140拆开来看:
10:数据区长度是 16 字节0100:地址是 0x010000:数据记录类型214601360121470136007EFE09D2190140:16 字节数据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 开始存放程序的 MCU,Hex 文件开头必然会有这么一条记录。
类型 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> <OutputType>WinExe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</Nullable> <PublishSingleFile>true</PublishSingleFile> <SelfContained>false</SelfContained> <RuntimeIdentifier>win-x64</RuntimeIdentifier> <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> <IncludeNativeLibrariesForSelfExtract>true</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 文件开头有一大堆 0xFF | Hex 文件的最小地址不是从 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就完成了闭环。
我的体会是,这种小工具最大的价值不在于代码量多大、算法多巧妙,而在于它把日常工作里琐碎枯燥的部分自动化了,同时让你对固件的底层格式有了第一手的认知。自己写、自己用、自己改,整个过程比单纯下载一个工具敲个命令学到的多得多。如果你也在做嵌入式或者上位机开发,强烈建议照着上面的思路自己写一份,源码结构不复杂,半天时间就能搞定。
本文还有配套的精品资源,点击获取