
简介面向单片机开发与固件升级场景一个小工具可将bin文件按指定大小填充0xFF解决升级包制作时文件长度不足、需补齐对齐的问题。内含完整C源码支持通过批处理直接调用自动处理相对路径适合嵌入式开发者快速集成到构建流程中。压缩包共13个文件除.cpp/.h源文件外还附带了.bat批处理脚本、.exe可执行程序、.bin测试样例和说明文档整体仅23KB轻量易用。作者提供了可直接运行的release版程序与示例脚本下载后既可快速验证填充效果也可修改源码定制起始偏移或填充值便于二次开发。目前已有2076人学习使用对于需要批量处理bin文件对齐的单片机开发者而言是一个便捷实用的参考工具。 上个星期给板子做OTA升级包App编译出来才100多KBBootloader那边却要求固定256KB的数据包差的部分必须补齐才能过启动校验。这种需求在嵌入式里实在太常见了bin文件要按Flash容量、扇区大小或者升级分区对齐而对齐用的填充字节行业里默认就是0xFF。填充这个动作听起来简单但“为什么是0xFF而不是0x00”“填充时遇到超尺寸该不该截断”“生成之后怎么验证”这些细节真做起来一堆门道。这篇文章把我一直在用的方案完整拆开讲底层原理、工具设计思路、可直接编译的C源码、Keil/Makefile接入方法还有几个我踩过的坑。1. 为什么Flash填充非得是0xFF先说清楚底层逻辑1.1 Flash擦除后的“默认态”决定了填充字节NOR Flash的物理结构决定了它擦除之后存储单元全部处于逻辑1状态读出来就是0xFF。而写入操作的本质是把某些位从1改成0也就是只能1变0不能0变1。想再把某个位写回1就必须对整个块做擦除操作。所以整个Flash世界里“0xFF”等价于“空白”“未写入”“可擦除”。如果你往空白区域填0x00烧录器要额外编程大量位增加整片编程时间而且很多芯片和烧录器自带“空白检查”功能检测到非0xFF区域就会认为这里已经有数据容易引起后续校验和写入策略的误判。从程序执行的角度看0xFF也有独到优势。以Cortex-M为例0xFF通常会被解码成未定义指令如果PC指针因为错误跳进未初始化区域处理器会直接进入HardFault问题能立刻暴露出来。反观填0x00在某些架构上可能被解释成一串空转指令程序卡在那里但又不报错排查起来让人抓狂。1.2 bin与Hex的分工以及“对齐”为什么是刚需日常编译出来的固件有两种常见格式Hex和bin。Hex是带地址信息的ASCII文本每一行都记录了数据要写入的起始地址和校验值调试器下载很直观。bin则是纯粹的二进制数据不带任何地址信息体积小、结构简单特别适合Bootloader通过串口/网络传输也适合量产时整片烧录。两者在实际工作中的分工往往是这样的对比项Hexbin地址信息每条记录自带地址无地址烧录时要人为指定偏移文件格式ASCII文本二进制可读性文本编辑器可打开需要十六进制工具典型用途调试器在线下载OTA差分包、量产固件、Bootloader升级包既然bin不带地址它就必须以“从某个地址开始连续存放”为前提。这时候对齐就成了刚需Bootloader按块擦写Flash时要求升级包长度是扇区/块大小的整数倍做整片镜像烧录时文件长度必须等于Flash容量OTA固件按固定分区校验时每个分区的大小都是固定值编译出来的原始bin往往不够长只能在尾部补0xFF直到凑满分区长度让校验逻辑拿到一个“补全后”的完整包。2. 工具设计容量解析、内存占用与超尺寸保护2.1 命令行用法兼容十六进制和后缀这个工具我命名为binpad用法很简单binpad 输入.bin 输出.bin 目标大小目标大小支持三种写法怎么方便怎么来binpad app.bin app_full.bin 0x80000 # 十六进制写法符合Flash地址习惯 binpad app.bin app_full.bin 512K # 后缀写法直观 binpad app.bin app_full.bin 1048576 # 纯字节数第一种最常用嵌入式工程师看到0x80000就知道是512KB写多少Flash容量都不用心算换算。这也是我一开始就决定要支持的解析方式。2.2 流式复制不把几百MB文件一次性读进内存很多人写这类工具的第一版会先用fseek和ftell拿到文件大小然后malloc一个同样大小的buffer一次性读进来再一口气写出去。对付几MB的bin文件没问题但如果哪天要处理一个几百MB的SPI Flash整片dump这种写法就可能把内存吃爆在低配的编译服务器上直接崩给你看。我采用的是流式处理固定开一个4KB缓冲区从输入文件一点一点读再一点一点写整个程序的内存占用恒定在4KB一点栈空间。这个思路和操作系统做文件复制是一样的量大也不怕。2.3 尺寸检查必须前置超尺寸时绝不截断这是这个工具里最重要的一条设计原则如果输入文件本身的字节数已经大于目标大小直接打印错误并退出绝不生成输出文件更不允许“帮忙截断”。原因很现实固件超过分区容量意味着链接脚本或者分区规划出了问题正确做法是回头改代码、改Flash分区而不是靠一个填充工具把固件砍掉一截。真截了烧进设备大概率启动不了而且这个“看起来正常但实际残缺”的bin会一路流到产线等到量产阶段才发现代价就大了。3. 核心源码与关键代码段解读3.1 容量参数解析字符串到字节数的转换解析函数的核心是strtoul顺势把0x前缀和后缀K/M/G的处理一起做掉。需要注意strtoul的end参数它指向解析停止的位置用这个位置来判断后面跟的是K、M还是G非常干净static unsigned long parse_size(const char *s) { char *end; unsigned long v; if (s[0] 0 (s[1] x || s[1] X)) v strtoul(s, end, 16); else v strtoul(s, end, 10); if (end s) { fprintf(stderr, Invalid size: %s\n, s); exit(1); } if (*end K || *end k) v * 1024UL; else if (*end M || *end m) v * 1024UL * 1024UL; else if (*end G || *end g) v * 1024UL * 1024UL * 1024UL; return v; }这样就把“0x80000”“512K”“1048576”统一转成了字节数后面所有逻辑只面对一个unsigned long不用再关心用户到底怎么输入的。3.2 主流程先复制后填充主流程分三步走打开输入文件用fseek/ftell拿到字节数。这一步要放在任何写操作之前先确认输入文件是否存在、大小是否合理。把原始内容全部拷贝到输出文件。这里同样走4KB缓冲区的流式拷贝。在文件末尾循环写入0xFF直到总长度等于目标大小。填充部分的关键是预先把缓冲区memset成0xFF然后循环fwrite避免一个字节一个字节地写、拖慢速度memset(buf, 0xFF, sizeof(buf)); remaining target_size - (unsigned long) in_len; while (remaining 0) { size_t chunk remaining sizeof(buf) ? sizeof(buf) : remaining; if (fwrite(buf, 1, chunk, out) ! chunk) { fprintf(stderr, Failed to write padding data\n); fclose(in); fclose(out); return 1; } remaining - chunk; }3.3 完整源码#include stdio.h #include stdlib.h #include string.h static unsigned long parse_size(const char *s) { char *end; unsigned long v; if (s[0] 0 (s[1] x || s[1] X)) v strtoul(s, end, 16); else v strtoul(s, end, 10); if (end s) { fprintf(stderr, Invalid size: %s\n, s); exit(1); } if (*end K || *end k) v * 1024UL; else if (*end M || *end m) v * 1024UL * 1024UL; else if (*end G || *end g) v * 1024UL * 1024UL * 1024UL; return v; } int main(int argc, char *argv[]) { if (argc 4) { fprintf(stderr, Usage: binpad input.bin output.bin target_size\n); fprintf(stderr, Example: binpad app.bin app_full.bin 0x80000\n); return 1; } const char *in_path argv[1]; const char *out_path argv[2]; unsigned long target_size parse_size(argv[3]); FILE *in fopen(in_path, rb); if (in NULL) { fprintf(stderr, Cannot open input file: %s\n, in_path); return 1; } fseek(in, 0, SEEK_END); long in_len ftell(in); fseek(in, 0, SEEK_SET); if (in_len 0 || (unsigned long) in_len target_size) { fprintf(stderr, Input file %lu bytes exceeds target size %lu bytes, abort.\n, (unsigned long) in_len, target_size); fclose(in); return 1; } FILE *out fopen(out_path, wb); if (out NULL) { fprintf(stderr, Cannot create output file: %s\n, out_path); fclose(in); return 1; } unsigned char buf[4096]; unsigned long remaining (unsigned long) in_len; while (remaining 0) { size_t chunk remaining sizeof(buf) ? sizeof(buf) : remaining; if (fread(buf, 1, chunk, in) ! chunk) { fprintf(stderr, Read input file failed\n); fclose(in); fclose(out); return 1; } if (fwrite(buf, 1, chunk, out) ! chunk) { fprintf(stderr, Write output file failed\n); fclose(in); fclose(out); return 1; } remaining - chunk; } memset(buf, 0xFF, sizeof(buf)); remaining target_size - (unsigned long) in_len; while (remaining 0) { size_t chunk remaining sizeof(buf) ? sizeof(buf) : remaining; if (fwrite(buf, 1, chunk, out) ! chunk) { fprintf(stderr, Write padding data failed\n); fclose(in); fclose(out); return 1; } remaining - chunk; } fclose(in); fclose(out); printf(Output: %s\n, out_path); printf( Original size: %lu bytes\n, (unsigned long) in_len); printf( Target size : %lu bytes\n, target_size); printf( Padding bytes: %lu bytes (0xFF)\n, target_size - (unsigned long) in_len); return 0; }编译就一行命令gcc binpad.c -o binpadLinux、macOS直接编译Windows下用MinGW或者WSL都一样。代码里提示信息我用的英文避免Windows控制台中文编码造成乱码。4. 实战从Keil生成bin到烧录前的完整链路4.1 Keil里自动生成bin并调用填充工具Keil MDK默认只生成Hex要想拿到bin需要在编译完成后跑一次fromelf。打开Options for Target切到User标签页在After Build/Rebuild栏勾选Run #1填入fromelf --bin --output./Objects/app.bin ./Objects/app.axf再在Run #2填上./binpad ./Objects/app.bin ./Objects/app_full.bin 0x100000这样每次编译完Keil会自动调用fromelf生成原始bin再调用binpad生成填充后的完整包。需要注意两点一是工作目录默认是工程文件所在目录相对路径要基于工程根目录来写二是如果binpad不在系统PATH里这里要写绝对路径比如D:\tools\binpad.exe。编译后如果binpad返回非0Keil会直接标红报错等于在编译阶段就把固件超容量的风险拦住了。4.2 一个具体的填充案例假设芯片Flash是1MB即0x100000字节。Bootloader占前面64KBApp分区从0x10000开始到0xFFFFF结束分区大小是0xF0000也就是960KB。App编译出来只有120KB但OTA和量产烧录要求这个分区的bin必须固定为960KB那命令就是binpad app.bin app_full.bin 0xF0000执行完会打印Output: app_full.bin Original size: 122880 bytes Target size : 983040 bytes Padding bytes: 860160 bytes (0xFF)这里有一个容易踩的细节bin文件本身不带地址填充后的app_full.bin要烧录到App分区的起始地址0x10000这个偏移是由烧录工具或Bootloader指定的binpad不负责、也不需要知道地址它只负责把文件长度做对。4.3 烧录前验证文件大小、头部原始数据和尾部填充拿到填充后的bin我习惯在烧录前快速验证三件事# 检查文件总大小 ls -l app_full.bin # 检查头部数据是否和原始bin完全一致 cmp (head -c 122880 app_full.bin) app.bin echo HEAD MATCH # 检查尾部是否全是0xFF tail -c 16 app_full.bin | xxd头部一致说明原始代码没有被破坏尾部全FF说明填充工作正确。如果没有Linux环境也可以用一个Python一行命令来看尾部python3 -c print(open(app_full.bin,rb).read()[-16:].hex())输出应该是连续16个ff。5. 我踩过的坑和一点实用扩展5.1 填充工具绝不能做成“超了就截断”我最早一版工具确实想过要不要顺手支持一下“超过了就把多余部分丢掉”。后来在一个量产项目里亲眼见过这种截断工具的后果部门里另一个同事用了他自己的“万能对齐工具”固件超过分区容量时工具自动把末尾砍掉编译不报错、烧录不报错结果板子一到现场就随机死机排查了一整天才发现是固件本身被截短了后面一大块功能代码直接没烧进去。从那以后我的原则很明确工具职责单一填充就只做填充。输入大于目标就是配置错误必须报错终止让问题暴露在最早的环境里。5.2 保留扇区与启动配置区要格外留意如果填充的目标是“整片Flash镜像”而不是某个App分区要特别注意Flash末尾可能存在保留扇区、用户配置区、Option Bytes这类特殊区域。这些区域并不适合用盲目的0xFF去填充有些还需要写入特定的配置值乱填可能影响启动行为。更常见的场景是处理App分区分区边界之外的事情由Bootloader或烧录器负责那就没有这个顾虑。另外一个相关经验是如果固件带CRC或签名校验签名一定要在填充完成之后再对“完整文件”计算先后顺序反了升级包在目标板上必然校验失败。5.3 扩展思路填充字节可配置、objcopy替代方案有些场景下需要填充0x00而不是0xFF比如某些Bootloader的差分升级模块会拿空白区域做压缩包暂存区。我给binpad预留的扩展思路很简单加一个可选参数默认0xFF通过命令行指定其他字节。核心逻辑完全不用动只要把memset那行的0xFF改成变量就行。另外提一句GNU工具链里的arm-none-eabi-objcopy也支持类似功能arm-none-eabi-objcopy -I ihex -O binary --pad-to0x100000 --gap-fill0xff app.hex app.bin但objcopy的--pad-to描述的是“地址”而不是“长度”用的时候还得先算出基地址再换算不够直观尤其是处理按分区大小定义的升级包时很容易算错。所以我最终更偏爱按字节长度工作的独立小工具一行命令搞定还不依赖交叉编译环境。我现在的习惯是把binpad放进每个项目的scripts目录在Keil的After Build、Makefile的post build步骤里都调用它。这样每次编译完固件包就已经是可直接烧录、可传输的完整形态而不是等到烧录前才发现文件长度不对再手忙脚乱地找工具补。这种工具写一次能用好几年值得花十分钟搞定。本文还有配套的精品资源点击获取