从Intel HEX到二进制BIN:嵌入式固件格式转换原理与C#实现

张开发
2026/6/6 14:18:55 15 分钟阅读

分享文章

从Intel HEX到二进制BIN:嵌入式固件格式转换原理与C#实现
1. 项目缘起与需求拆解最近在折腾AVR单片机的Bootloader程序主体已经用C写得差不多了参考了网上开源的方案用的是经典的XMODEM协议配合Windows XP时代就有的“超级终端”进行数据传输。东西跑是能跑起来但卡在了一个看似不起眼却非常关键的文件格式问题上XMODEM协议传输的是纯粹的二进制.bin文件而我的编译器AVR-GCC默认生成的是Intel HEX格式.hex文件。这就好比你想给朋友传一份Word文档但对方的老式打印机只认纯文本.txt中间缺了个转换环节。当然有现成的路可以走。比如直接修改GCC的Makefile让它在编译链接后直接输出.bin文件。这个方法简单直接一次配置终身受用。但我琢磨了一下觉得这事儿没那么简单。首先我计划后续要自己写一个Bootloader的上位机程序这个程序需要能灵活处理.hex和.bin两种格式总不能每次都依赖外部转换工具吧其次作为一个嵌入式开发者经常需要分析、烧录、比对各种固件手头有一个自己写的、知根知底的格式转换工具会方便很多。最后也是最重要的一点我正好在学C#想找个实际的小项目练练手。用新学的语言解决一个真实的工作痛点这学习动力和成就感直接拉满。所以这个“Hex转Bin”的小程序它不仅仅是一个格式转换器。它是我理解两种文件格式本质差异的实践是串联起编译、烧录、调试工作流的一个自制小工具也是我踏入C#桌面应用开发领域的第一块敲门砖。2. 核心原理深入理解HEX与BIN在动手写代码之前必须把HEX和BIN这两种格式的“底裤”扒清楚。这决定了我们转换算法的核心逻辑。2.1 Intel HEX格式带地图的包裹Intel HEX文件你可以把它想象成一个精心包装、贴满了标签的快递包裹。它里面的“货物”确实是机器码但它用ASCII字符来记录一切信息为的是让人和机器都能方便地阅读和处理。观察一个典型的HEX文件行:10000000B80F0020191500201D15002021150020A3这一行字符串就是一条完整的“记录”。它的结构是严格定义的遵循:CCAAAARR…DDZZ的格式。我们来拆解这一行: 每条记录的开头标志。CC 数据字节长度。这里是10十六进制表示这条记录包含16个字节的数据。AAAA 本条数据在内存中的起始地址。这里是0000表示这16个字节的数据应该被放置在地址0x0000开始的地方。RR 记录类型。这里是00代表这是数据记录。这是最常见、承载实际程序代码的类型。还有其他类型比如01文件结束、04扩展线性地址记录用于突破64KB寻址限制这些在转换时都需要特别处理。…DD 数据域。这里从第9个字符开始是B80F002019150020...这就是实际的16字节机器码但每个字节是用两个十六进制ASCII字符表示的。所以B8对应一个字节0xB8。ZZ 校验和。这里是A3。它的计算规则是从CC到最后一个数据字节DD注意是它们的二进制值不是ASCII字符将所有字节相加然后取和的低8位再计算其二进制补码即0x100减去这个低8位和。校验和用于验证该行数据在传输或存储过程中没有出错。HEX文件的精髓在于地址信息。它允许数据非连续存放。比如你的程序代码可能在0x0000-0x0FFF而中断向量表在0x2000-0x200F。一个HEX文件可以轻松地用多条记录描述这种不连续的内存映像。2.2 BIN格式纯粹的数据流BIN文件就简单粗暴多了。它就是一个纯粹的二进制流没有任何格式、地址、校验和等元数据。它相当于把HEX文件中所有数据记录RR00里的数据域DD按顺序拼接起来从地址0开始一个字节接一个字节地写入文件。这里有一个至关重要的陷阱BIN文件默认是从地址0开始的连续映像。如果HEX文件中的数据不是从0开始或者地址不连续直接拼接就会导致生成的BIN文件在物理地址上出现“空洞”。比如HEX中有一段数据在地址0x1000直接转成BIN后这部分数据会被放在BIN文件的偏移0x1000处而它前面的0x0000到0x0FFF全部是0或未初始化值。这会使得BIN文件变得巨大且包含大量无效数据。2.3 转换的核心逻辑与挑战因此一个健壮的Hex2Bin转换器其核心算法是逐行解析HEX文件。识别记录类型对于数据记录00提取其起始地址AAAA和数据DD。构建内存映像在内存中模拟一个足够大的字节数组或字典根据提取的地址将数据准确地“放置”到对应的位置。处理地址不连续这是关键。需要决定如何对待地址之间的“空洞”。通常有两种策略策略A紧凑模式忽略空洞只输出有数据的连续块。但这需要记录多个数据块及其起始地址生成的BIN可能不唯一且烧录时需要指定偏移量。策略B填充模式用特定值通常是0xFF或0x00取决于芯片的擦除状态填充空洞生成一个从最低地址到最高地址的、连续的完整映像。这是最常用、最通用的方式因为大多数烧录工具期望一个完整的、连续的二进制文件。处理扩展地址当遇到04类型记录时它提供了高16位地址。后续的数据记录地址需要与此结合形成完整的32位地址。这对于现代32位MCU如STM32的HEX文件至关重要。写入BIN文件将构建好的完整内存映像字节数组从头到尾写入一个新的.bin文件。3. 实战用C#打造转换工具理解了原理我们就可以用C#动手实现了。C#的System.IO和字符串处理能力让这个任务变得相当轻松。3.1 项目结构与界面设计我使用Visual Studio Community版本创建一个Windows窗体应用.NET Framework 或 .NET Core/WinForms均可。主界面设计非常简单直观两个TextBox一个用于显示或输入HEX文件路径另一个用于显示或输入要保存的BIN文件路径。两个Button分别对应“浏览HEX文件”和“浏览BIN保存位置”。一个Button“转换”按钮核心功能触发点。一个ProgressBar用于显示转换进度增强用户体验。一个RichTextBox或ListBox用于输出转换过程中的日志信息如成功解析行数、遇到的数据范围、填充情况等方便调试和查看结果。界面布局力求清晰让用户一眼就知道该如何操作。3.2 核心转换算法实现以下是转换器核心类的简化代码包含了详细的注释using System; using System.Collections.Generic; using System.IO; using System.Text; namespace HexToBinConverter { public class HexFileConverter { // 用于存储内存映像。使用字典可以高效处理非连续地址。 private Dictionaryuint, byte _memoryImage new Dictionaryuint, byte(); private uint _startAddress 0xFFFFFFFF; // 记录遇到的最小地址 private uint _endAddress 0; // 记录遇到的最大地址 private uint _upperAddressBase 0; // 用于处理扩展线性地址0x04记录 /// summary /// 将Intel HEX文件转换为二进制BIN文件。 /// /summary /// param namehexFilePath输入的HEX文件路径。/param /// param namebinFilePath输出的BIN文件路径。/param /// param namefillValue用于填充地址空洞的值默认为0xFF。/param /// returns转换是否成功以及相关信息。/returns public (bool Success, string Message) Convert(string hexFilePath, string binFilePath, byte fillValue 0xFF) { _memoryImage.Clear(); _startAddress 0xFFFFFFFF; _endAddress 0; _upperAddressBase 0; try { string[] lines File.ReadAllLines(hexFilePath); int lineNumber 0; foreach (var line in lines) { lineNumber; string trimmedLine line.Trim(); if (string.IsNullOrEmpty(trimmedLine) || !trimmedLine.StartsWith(:)) { continue; // 跳过空行和非HEX记录行 } if (!ParseHexRecord(trimmedLine, lineNumber)) { return (false, $解析错误第 {lineNumber} 行: {trimmedLine}); } } // 检查是否至少解析到一些数据 if (_memoryImage.Count 0) { return (false, 未在HEX文件中找到有效数据记录。); } // 生成连续的二进制数据 byte[] binData GenerateContinuousBinaryData(fillValue); // 写入BIN文件 File.WriteAllBytes(binFilePath, binData); return (true, $转换成功\n数据范围: 0x{_startAddress:X8} - 0x{_endAddress:X8}\n生成文件大小: {binData.Length} 字节); } catch (Exception ex) { return (false, $转换过程中发生异常: {ex.Message}); } } /// summary /// 解析单条HEX记录。 /// /summary private bool ParseHexRecord(string record, int lineNum) { // 移除冒号 string data record.Substring(1); // 计算字节数每两个字符一个字节 if (data.Length % 2 ! 0 || data.Length 10) // 至少要有长度(2)地址(4)类型(2)8字符再加数据和校验和 { return false; } byte[] bytes HexStringToByteArray(data); int byteCount bytes[0]; uint address (uint)((bytes[1] 8) | bytes[2]); byte recordType bytes[3]; // 验证数据长度 if (bytes.Length ! byteCount 5) // 5 长度(1)地址(2)类型(1)校验和(1) { return false; } // 计算校验和 byte checksum 0; for (int i 0; i bytes.Length - 1; i) { checksum bytes[i]; } checksum (byte)(~checksum 1); // 取补码 if (checksum ! bytes[bytes.Length - 1]) { // 校验和错误但有时可以继续根据需求决定 // 这里选择记录警告或直接报错。为严谨起见报错。 // Console.WriteLine($警告第{lineNum}行校验和错误。); // return false; } switch (recordType) { case 0x00: // 数据记录 address | _upperAddressBase; // 结合高地址 for (int i 0; i byteCount; i) { uint fullAddr address (uint)i; _memoryImage[fullAddr] bytes[4 i]; // 数据从索引4开始 // 更新地址范围 if (fullAddr _startAddress) _startAddress fullAddr; if (fullAddr _endAddress) _endAddress fullAddr; } break; case 0x01: // 文件结束记录 // 什么都不做正常遇到即结束 break; case 0x04: // 扩展线性地址记录 // 高16位地址左移16位后后续的数据记录地址要与之相加 _upperAddressBase (uint)((bytes[4] 24) | (bytes[5] 16)); break; case 0x02: // 已过时的段地址记录一般不用 case 0x03: // 开始段地址记录一般不用 default: // 对于不处理的记录类型可以选择忽略或记录日志 // Console.WriteLine($信息忽略第{lineNum}行的记录类型 0x{recordType:X2}); break; } return true; } /// summary /// 将十六进制字符串转换为字节数组。 /// /summary private byte[] HexStringToByteArray(string hex) { int numberChars hex.Length; byte[] bytes new byte[numberChars / 2]; for (int i 0; i numberChars; i 2) { bytes[i / 2] Convert.ToByte(hex.Substring(i, 2), 16); } return bytes; } /// summary /// 生成连续的二进制数据用指定值填充空洞。 /// /summary private byte[] GenerateContinuousBinaryData(byte fillValue) { if (_startAddress _endAddress) return new byte[0]; uint length _endAddress - _startAddress 1; byte[] result new byte[length]; // 先用填充值初始化整个数组 for (int i 0; i result.Length; i) { result[i] fillValue; } // 将内存映像中的数据复制到对应位置 foreach (var kvp in _memoryImage) { if (kvp.Key _startAddress kvp.Key _endAddress) { result[kvp.Key - _startAddress] kvp.Value; } } return result; } } }3.3 界面与逻辑的绑定在窗体的“转换”按钮点击事件中调用上述转换器private void btnConvert_Click(object sender, EventArgs e) { string hexFile txtHexPath.Text; string binFile txtBinPath.Text; if (!File.Exists(hexFile)) { MessageBox.Show(HEX文件不存在, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); return; } if (string.IsNullOrEmpty(binFile)) { MessageBox.Show(请指定BIN文件保存路径, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); return; } // 显示进度条 progressBar1.Style ProgressBarStyle.Marquee; progressBar1.Visible true; btnConvert.Enabled false; // 使用Task避免界面卡顿 Task.Run(() { var converter new HexFileConverter(); var result converter.Convert(hexFile, binFile, 0xFF); // 使用0xFF填充这是Flash的擦除状态 // 回到UI线程更新结果 this.Invoke(new Action(() { progressBar1.Visible false; btnConvert.Enabled true; if (result.Success) { MessageBox.Show(result.Message, 转换成功, MessageBoxButtons.OK, MessageBoxIcon.Information); // 在日志框中添加成功信息 rtxtLog.AppendText($[{DateTime.Now:HH:mm:ss}] 转换成功。{result.Message}\n); } else { MessageBox.Show(result.Message, 转换失败, MessageBoxButtons.OK, MessageBoxIcon.Error); rtxtLog.AppendText($[{DateTime.Now:HH:mm:ss}] 转换失败。{result.Message}\n); } })); }); }4. 开发中的坑与实战经验这个小程序虽然逻辑不复杂但在开发和后续使用中还是遇到了不少值得分享的“坑”。4.1 地址对齐与空洞处理问题最初我简单地按行顺序拼接数据完全忽略了地址。结果转换出来的BIN文件用烧录工具烧进芯片后程序完全跑飞。用反汇编工具一看代码段、数据段全部错位。解决必须引入“内存映像”的概念。在内存中维护一个从_startAddress到_endAddress的缓冲区。解析每一条数据记录时根据其完整地址基础地址偏移将数据存入缓冲区的对应位置。对于缓冲区中没有被HEX文件覆盖的位置必须填充。填充值的选择有讲究0xFF对于大多数NOR Flash存储器擦除后的状态就是0xFF。用这个值填充相当于标记这些区域为“未使用/已擦除”是最安全、最通用的选择。0x00有些OTP一次可编程存储器或特定架构下可能用0x00。需要根据目标芯片的存储器特性决定。注意务必在转换完成后在日志或界面中明确输出数据的起始地址、结束地址和文件大小。这在你后续使用烧录工具时非常重要因为烧录时需要指定“偏移量”Offset这个偏移量通常就是_startAddress。4.2 扩展地址记录0x04的处理问题在转换一个STM32F103的HEX文件时转换过程没有报错但生成的BIN文件大小只有几KB明显不对。而原HEX文件有几十KB。排查打开HEX文件查看发现前面几行之后出现了这样的记录:020000040800F2。这就是0x04扩展线性地址记录。它告诉解析器后面数据记录的地址高16位是0x0800。我最初的代码没有处理这个类型导致后面所有数据的地址都被错误地计算在0x0000xxxx范围内大量数据因为地址重叠而被覆盖最终只保留了最早的一小部分数据。解决在ParseHexRecord方法中增加对recordType 0x04的处理。当遇到此类型时解析其数据域两个字节左移16位后赋值给一个类变量_upperAddressBase。在解析后续的数据记录0x00时需要将_upperAddressBase与记录中的低16位地址相加得到完整的32位地址。这样就能正确转换大于64KB地址空间的程序了。4.3 校验和的计算与处理问题网上有些HEX文件可能因为编辑或传输问题存在校验和错误。严格的转换器应该校验每一行。实现如上述代码所示校验和的计算规则是将该记录中除起始冒号和校验和字节本身之外的所有字节的二进制值相加取和的低8位然后计算其二进制补码。计算出的值应与记录最后的校验和字节相等。策略在代码中我实现了严格的校验和验证。一旦发现不符立即返回错误。但在某些调试场景你可能想忽略校验和错误继续转换比如你知道文件只是末尾注释有损。这时可以将其改为警告并提供一个“忽略校验和错误”的复选框给用户选择。对于生产环境或烧录关键固件强烈建议开启严格校验。4.4 性能与内存考量问题当转换一个几十MB的大型HEX文件比如包含字库的嵌入式系统固件时程序可能会卡顿甚至内存溢出。优化流式处理最初的File.ReadAllLines会一次性读入所有行对于大文件不友好。可以改为使用StreamReader逐行读取和处理。内存优化使用Dictionaryuint, byte存储映像对于极度稀疏的数据地址非常分散很高效但如果数据基本连续用一个大byte[]数组在内存利用率上可能更好。可以在解析前先快速扫描一遍文件确定地址范围再分配数组。进度反馈对于大文件进度条不能再用简单的Marquee动画。需要在解析过程中根据已处理的行数占总行数的比例来更新进度条的值给用户明确的反馈。5. 进阶功能与扩展思路一个基础的转换工具完成后可以考虑添加一些实用功能让它变得更强大。BIN转HEX反向转换这个需求也常见。比如你只有一个BIN文件但想用某些只支持HEX的仿真器进行分析。反向转换需要知道BIN文件在目标芯片中的起始地址然后按固定长度如16字节/行分割数据生成带地址和校验和的HEX记录。分段提取与合并有时我们只想提取HEX文件中的某一段如Bootloader区、应用程序区或者将多个BIN文件合并成一个并指定各自的偏移地址。这需要更灵活的地址范围选择和文件操作。填充模式选择提供0xFF、0x00甚至用户自定义填充值的选项。文件比较Diff集成一个简单的二进制比较功能快速对比转换前后的BIN文件与原HEX文件解析出的映像是否一致或者比较两个不同版本的BIN文件差异。集成到右键菜单通过修改Windows注册表将工具添加到文件的右键菜单中实现“右键-转换为BIN”的快捷操作效率提升巨大。命令行支持为工具添加命令行接口例如Hex2Bin.exe input.hex output.bin -fill 0xFF。这样可以方便地集成到自动化构建脚本如Jenkins, GitHub Actions中在编译完成后自动执行格式转换。这个小工具从最初为了解决Bootloader文件传输问题而写到现在已经成为我嵌入式开发工具箱里的常客。它让我对固件文件格式的理解从“知其然”到了“知其所以然”。用C#实现的过程也非常愉快Windows Forms快速构建界面的能力加上C#强大的类库让这种工具类软件的开发效率很高。如果你也在学习C#或者经常和嵌入式固件打交道强烈建议你亲手实现一遍过程中遇到的每一个问题都会让你对底层细节有更深的认识。

更多文章