19、二进制文件操作实战:图像文件读写、结构体序列化、大端小端问题
各位同学,今天我们来聊聊二进制文件操作。说实话,很多初学者对二进制文件有点怵,总觉得它不如文本文件那么直观。但我要告诉你,在嵌入式系统里,二进制文件才是真正的主角。你想想看,一张图片、一个编译好的固件、一个序列化的配置结构体——哪个不是二进制格式?
我个人习惯把二进制文件操作分成三个层次:裸字节读写、结构体序列化、跨平台兼容。今天我们就用一张BMP图像文件的读写作为主线,把这三点串起来讲透。
核心知识点速览
- fread / fwrite 的底层逻辑
- 结构体直接序列化到文件
- 大端小端问题的根源与解决方案
- BMP文件头解析实战
19.1 从BMP文件头说起
BMP格式是Windows上最基础的图像格式,它的文件头结构非常清晰。我当年第一次做图像采集项目时,就是靠解析BMP头来验证数据是否正确。说白了,BMP文件头就是一个54字节的固定结构体,里面包含了文件大小、图像宽度高度、像素位数等信息。
我们来看一下BMP文件头的标准定义:
// BMP文件头(14字节)
#pragma pack(push, 1) // 强制1字节对齐,这点非常重要!
typedef struct {
uint16_t bfType; // 固定为0x4D42,即"BM"
uint32_t bfSize; // 整个文件大小
uint16_t bfReserved1; // 保留,必须为0
uint16_t bfReserved2; // 保留,必须为0
uint32_t bfOffBits; // 从文件头到像素数据的偏移量
} BMPFILEHEADER;
// BMP信息头(40字节)
typedef struct {
uint32_t biSize; // 本结构体大小,固定40
int32_t biWidth; // 图像宽度(像素)
int32_t biHeight; // 图像高度(像素),正数为正向
uint16_t biPlanes; // 固定为1
uint16_t biBitCount; // 每像素位数:1/4/8/16/24/32
uint32_t biCompression; // 压缩方式,0为不压缩
uint32_t biSizeImage; // 像素数据大小
int32_t biXPelsPerMeter; // 水平分辨率
int32_t biYPelsPerMeter; // 垂直分辨率
uint32_t biClrUsed; // 实际使用的颜色数
uint32_t biClrImportant; // 重要颜色数
} BMPINFOHEADER;
#pragma pack(pop)
⚠️ 对齐陷阱
这里我用了 #pragma pack(push, 1),为什么?因为结构体默认会对齐到4字节边界,但BMP文件头是紧凑排列的。如果不强制1字节对齐,你读出来的bfOffBits可能就错位了。我曾经在一个项目里因为这个bug排查了整整一个下午——结构体成员对不上,读出来的图像全是花的。
19.2 用fread/fwrite读写BMP文件
好,结构体定义好了,接下来就是读写操作。二进制文件的读写核心就是两个函数:fread 和 fwrite。它们的原型很简单:
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
说白了,就是一次读/写 count 个大小为 size 的数据块。对于结构体来说,我们通常把 size 设为 sizeof(结构体),count 设为1。
下面是一个完整的BMP文件读取示例:
int read_bmp(const char *filename) {
FILE *fp = fopen(filename, "rb"); // "rb" 表示二进制读模式
if (!fp) {
perror("打开文件失败");
return -1;
}
BMPFILEHEADER fileHeader;
BMPINFOHEADER infoHeader;
// 读取文件头
if (fread(&fileHeader, sizeof(BMPFILEHEADER), 1, fp) != 1) {
fprintf(stderr, "读取文件头失败\n");
fclose(fp);
return -1;
}
// 验证BMP标志
if (fileHeader.bfType != 0x4D42) { // 'B'=0x42, 'M'=0x4D
fprintf(stderr, "不是有效的BMP文件\n");
fclose(fp);
return -1;
}
// 读取信息头
if (fread(&infoHeader, sizeof(BMPINFOHEADER), 1, fp) != 1) {
fprintf(stderr, "读取信息头失败\n");
fclose(fp);
return -1;
}
printf("图像尺寸: %d x %d, 位深度: %d\n",
infoHeader.biWidth, infoHeader.biHeight, infoHeader.biBitCount);
// 定位到像素数据
fseek(fp, fileHeader.bfOffBits, SEEK_SET);
// 分配内存并读取像素数据
size_t pixelDataSize = infoHeader.biSizeImage;
unsigned char *pixelData = (unsigned char *)malloc(pixelDataSize);
if (!pixelData) {
fprintf(stderr, "内存分配失败\n");
fclose(fp);
return -1;
}
size_t bytesRead = fread(pixelData, 1, pixelDataSize, fp);
if (bytesRead != pixelDataSize) {
fprintf(stderr, "读取像素数据不完整: 期望%zu, 实际%zu\n",
pixelDataSize, bytesRead);
free(pixelData);
fclose(fp);
return -1;
}
// 处理像素数据...
free(pixelData);
fclose(fp);
return 0;
}
💡 个人经验
我建议你在读取任何二进制文件时,都先验证文件头中的魔数(Magic Number)。BMP的魔数是0x4D42,PNG是0x89504E47,JPEG是0xFFD8FFE0。这个习惯能帮你过滤掉大量无效文件,避免后续解析时崩溃。
19.3 结构体序列化:把内存对象存到磁盘
结构体序列化,说白了就是把内存中的结构体变量原封不动地写到文件里。这在嵌入式系统中太常见了——保存配置参数、记录传感器校准数据、存储设备状态快照。
来看一个实际例子:假设我们要保存一个传感器的配置参数:
typedef struct {
uint32_t sampleRate; // 采样率,单位Hz
uint16_t gain; // 增益,0-1023
uint8_t filterType; // 滤波器类型:0=低通, 1=高通, 2=带通
uint8_t enableLogging; // 是否启用日志记录
float threshold; // 触发阈值
char deviceName[16]; // 设备名称
} SensorConfig;
// 序列化:写入文件
int save_config(const char *filename, const SensorConfig *config) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
// 直接写入整个结构体
size_t written = fwrite(config, sizeof(SensorConfig), 1, fp);
fclose(fp);
return (written == 1) ? 0 : -1;
}
// 反序列化:从文件读取
int load_config(const char *filename, SensorConfig *config) {
FILE *fp = fopen(filename, "rb");
if (!fp) return -1;
size_t read = fread(config, sizeof(SensorConfig), 1, fp);
fclose(fp);
return (read == 1) ? 0 : -1;
}
你看,代码就这么简单。但这里有个大坑——大端小端问题。
19.4 大端小端:跨平台的噩梦
为什么会这样?因为不同的CPU架构对多字节数据的存储顺序不一样。x86和ARM默认是小端(Little-Endian),而PowerPC、网络协议(TCP/IP)默认是大端(Big-Endian)。
举个例子,一个32位整数 0x12345678:
| 内存地址 | 小端存储 | 大端存储 |
|---|---|---|
| 0x00 | 0x78 | 0x12 |
| 0x01 | 0x56 | 0x34 |
| 0x02 | 0x34 | 0x56 |
| 0x03 | 0x12 | 0x78 |
如果你在小端机器上序列化了一个结构体,然后把这个文件拿到大端机器上反序列化——嗯,读出来的数据全是乱的。我当年做一个跨平台数据采集系统时就踩过这个坑,两台设备对同一份配置文件的解析结果完全不同,排查了好久才发现是字节序的问题。
解决方案:定义统一的网络字节序
标准做法是:在序列化时,把所有多字节字段都转换成网络字节序(大端);在反序列化时,再转换回主机字节序。C标准库提供了四个函数:
htons():Host TO Network Short (16位)htonl():Host TO Network Long (32位)ntohs():Network TO Host Short (16位)ntohl():Network TO Host Long (32位)
改进后的序列化函数:
// 序列化时,每个字段都转成网络字节序
int save_config_portable(const char *filename, const SensorConfig *config) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
SensorConfig netConfig;
// 逐个字段转换
netConfig.sampleRate = htonl(config->sampleRate);
netConfig.gain = htons(config->gain);
netConfig.filterType = config->filterType; // 单字节,无需转换
netConfig.enableLogging = config->enableLogging; // 单字节,无需转换
netConfig.threshold = config->threshold; // float需要特殊处理
memcpy(netConfig.deviceName, config->deviceName, 16); // 字符串,无需转换
// float的字节序转换:先转成uint32_t再调用htonl
uint32_t temp;
memcpy(&temp, &config->threshold, sizeof(float));
temp = htonl(temp);
memcpy(&netConfig.threshold, &temp, sizeof(float));
size_t written = fwrite(&netConfig, sizeof(SensorConfig), 1, fp);
fclose(fp);
return (written == 1) ? 0 : -1;
}
⚠️ float的字节序处理
注意,标准库的htonl/ntohl只支持整数类型。对于float,你需要先把它当作4字节的uint32_t来转换。具体做法是用memcpy做类型双关(type punning),不要用强制类型转换,因为那会触发未定义行为。
19.5 实战:完整的BMP图像读写流程
最后,我们把所有知识点串起来,实现一个完整的BMP图像读写流程。这个流程在嵌入式图像采集系统中非常典型:
// 写入BMP文件
int write_bmp(const char *filename, int width, int height,
const unsigned char *pixelData) {
FILE *fp = fopen(filename, "wb");
if (!fp) return -1;
// 计算行对齐:BMP要求每行字节数是4的倍数
int rowSize = ((width * 3 + 3) / 4) * 4; // 24位BMP,每像素3字节
int pixelArraySize = rowSize * height;
BMPFILEHEADER fileHeader;
BMPINFOHEADER infoHeader;
// 填充文件头
fileHeader.bfType = 0x4D42;
fileHeader.bfSize = sizeof(BMPFILEHEADER) + sizeof(BMPINFOHEADER) + pixelArraySize;
fileHeader.bfReserved1 = 0;
fileHeader.bfReserved2 = 0;
fileHeader.bfOffBits = sizeof(BMPFILEHEADER) + sizeof(BMPINFOHEADER);
// 填充信息头
infoHeader.biSize = sizeof(BMPINFOHEADER);
infoHeader.biWidth = width;
infoHeader.biHeight = height;
infoHeader.biPlanes = 1;
infoHeader.biBitCount = 24;
infoHeader.biCompression = 0;
infoHeader.biSizeImage = pixelArraySize;
infoHeader.biXPelsPerMeter = 2835; // 72 DPI
infoHeader.biYPelsPerMeter = 2835;
infoHeader.biClrUsed = 0;
infoHeader.biClrImportant = 0;
// 写入文件头和信息头
fwrite(&fileHeader, sizeof(BMPFILEHEADER), 1, fp);
fwrite(&infoHeader, sizeof(BMPINFOHEADER), 1, fp);
// 写入像素数据(注意BMP是BGR顺序,且从底部开始)
unsigned char padding[3] = {0, 0, 0};
int paddingSize = rowSize - width * 3;
for (int y = height - 1; y >= 0; y--) {
for (int x = 0; x < width; x++) {
int idx = (y * width + x) * 3;
// BMP存储顺序是BGR,而我们的数据可能是RGB
unsigned char b = pixelData[idx + 2]; // Blue
unsigned char g = pixelData[idx + 1]; // Green
unsigned char r = pixelData[idx]; // Red
fwrite(&b, 1, 1, fp);
fwrite(&g, 1, 1, fp);
fwrite(&r, 1, 1, fp);
}
// 写入行对齐填充字节
if (paddingSize > 0) {
fwrite(padding, 1, paddingSize, fp);
}
}
fclose(fp);
return 0;
}
💡 避坑指南
我曾经在写BMP时忘记处理行对齐,结果生成的图像在Windows上打开全是斜条纹。BMP规范要求每行像素数据的字节数必须是4的倍数,不足的部分用0填充。这个细节很容易被忽略,但一旦出错,图像就完全没法看。
好了,关于二进制文件操作的核心内容就这些。从BMP文件头解析,到结构体序列化,再到大端小端问题的处理——这三块知识在嵌入式开发中几乎天天用到。你想想看,无论是保存传感器校准参数、记录日志数据,还是处理网络协议包,本质上都是在做同一件事:把内存中的二进制数据可靠地写到文件里,再可靠地读回来。
下次当你遇到跨平台数据兼容性问题时,记得先检查字节序。很多时候,问题就出在这个看似不起眼的细节上。