十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言图像读写实战:BMP文件格式解析与代码实现

C语言图像读写实战:BMP文件格式解析与代码实现 简介面向C语言初学者的图像读写示例代码以纯C实现BMP/JPEG等常用格式的读取与写入适合游戏开发、计算机视觉入门者理解底层像素操作与二进制文件处理。压缩包共47个文件大小592KB核心代码集中在3个cpp与5个h文件中另包含Visual Studio工程配置vcxproj、sln、编译中间文件tlog、obj以及测试图片和说明文档目录结构清晰便于直接打开工程运行调试。已有1619人学习下载。资源不依赖OpenCV等第三方库完全使用stdio.h中的fopen、fread、fwrite完成磁盘交互代码轻量让初学者从零理解图像文件头部解析、像素数据解码与内存操作。通过研究Trent_ImageRWDemo主程序可掌握图像的读取流程、像素矩阵的遍历与修改以及将处理结果写回文件的方法为后续学习更复杂的图像处理算法打下坚实基础。 最近在整理资料时看到一份名为“C图像读写源代码.zip”的资源包让我想起很多初学C语言的朋友都卡在这一步语法看了一遍又一遍指针、结构体、文件操作背得滚瓜烂熟可真要上手写一个能读写文件的项目却不知道从哪里下手。图像读写正好是一个合适的练手场景——它不依赖第三方库又能把C语言的几块核心硬功夫一次全用上。这篇文章我会以BMP图像为例把C语言图像读写的完整思路、实现步骤和容易踩的坑一次讲清楚适合学完C语言基础、想找实战项目练手的读者也适合正在做课程设计的朋友参考。1. 为什么我推荐用BMP文件作为C语言图像读写的突破口1.1 文件结构简单到可以逐字节讲清楚BMPBitmap是一种无压缩的位图格式它的核心特征就是“原始”。每个像素的颜色直接按顺序存储在文件里不像JPEG、PNG那样经过离散余弦变换或zlib压缩存储前还要处理各种编码表和解码状态机。对刚接触文件读写的C语言初学者来说BMP是唯一能在一页纸内讲清楚结构、用二十行代码读完的文件格式。我用一张常见图片的BMP文件对比一下同样是图像文件JPEG开头是FF D8 FF E0这样的标记段中间嵌着一堆哈夫曼表、量化表要读出一个像素的颜色得先解压缩而BMP文件开头就是两个字节的BM标记后面紧跟文件大小、像素数据偏移量再往后就是纯粹的BGR颜色值。这种“所见即所得”的特性决定了它特别适合用来理解“文件在磁盘上到底长什么样”。1.2 这个项目能一次性练到C语言的几块核心硬功夫图像读写表面上是文件操作实际上覆盖了C语言几个最容易被忽视的知识点文件指针操作fopen、fread、fseek、fwrite全部要上阵还得会判断返回值。结构体与内存布局BMP文件的头信息非常适合用结构体来映射但怎么正确处理字节对齐这就避不开#pragma pack。指针与动态内存分配像素数据大小取决于图片宽高运行前不确定要用malloc按需分配。数组与指针运算像素数组的遍历、行对齐的跳行处理本质是二维数组在一维内存中的寻址。调试能力写完代码后图片显示不对是二进制读错了还是坐标算错了这就要学会用十六进制工具对照文件内容排查。很多培训班把图像处理当作进阶课程我不太认同。BMP读写并不需要高深的算法知识它需要的恰恰是C语言最基础的那几板斧刷再多的语法题都不如完整读、写一张图片实战来得深刻。2. 动手前必须先看懂的BMP文件结构2.1 14字节文件头里藏着哪些信息BMP文件开始是14字节的文件头BITMAPFILEHEADER它描述了文件的类型、大小和数据偏移。我整理了一张表写代码之前最好先背下来字段名大小偏移含义bfType2字节0固定为BM十六进制是0x4D42bfSize4字节2整个文件大小字节数bfReserved12字节6保留字段常为0bfReserved22字节8保留字段常为0bfOffBits4字节10像素数据起始位置的偏移量最容易出错的是bfType。用十六进制编辑器打开BMP文件时前两个字节实际是42 4DASCII码的B和M但在小端机器上以uint16_t读取后得到的是0x4D42判断时别写反。bfOffBits这个字段很关键——它不是固定等于文件头的14字节。24位图通常正好是54但如果图片带调色板或文件头信息头版本不同这个偏移会变。读取像素数据前一定要通过这个字段定位不能死记硬算。2.2 40字节信息头为什么比文件头更关键紧接文件头的是至少40字节的信息头BITMAPINFOHEADER它才是真正告诉代码“图片有多大、用什么格式存”的部分。核心字段如下字段名大小含义biSize4字节本结构体大小常为40biWidth4字节图像宽度像素biHeight4字节图像高度像素正数表示自底向上存储负数表示自顶向下biPlanes2字节颜色平面数必须为1biBitCount2字节每像素位数常见1/4/8/16/24/32biCompression4字节压缩类型0表示BI_RGB不压缩biSizeImage4字节像素数据大小不压缩时可为0biClrUsed4字节使用的颜色数0表示使用全部在动手写代码之前把biBitCount理解透彻很重要。24位真彩色是最容易上手的情况每个像素用3个字节表示按蓝、绿、红的顺序存放。8位及以下的BMP文件带有调色板每像素只是一个调色板索引解析逻辑完全不同。初学者若是拿到一个调色板BMP直接用24位的思路去读读出来的图肯定是花的。2.3 像素区的排列规则BGR顺序与行对齐像素数据部分有两个特别容易踩的规则第一是颜色顺序。像素不是按常说的RGB顺序存而是BGR也就是说文件里第一个字节是蓝色分量第二个是绿色分量第三个是红色分量。早期不少教程在这里吃过亏写出图片后发现红色和蓝色完全对调了。第二是行对齐。BMP要求每行像素数据的字节数必须是4的倍数不足4的倍数要补齐。计算公式是rowSize ((biWidth * biBitCount 31) / 32) * 4以常见的1920x1080的24位图为例每行本来是1920×35760字节5760刚好是4的倍数所以不需要补位但如果宽度是100像素100×3300字节300除以4余数为0实际rowSize还是300。再看99像素的宽度99×3297字节297不是4的倍数要补齐成4×75300字节每行多出3个无意义的填充字节。这些填充字节必须原样保留或清除否则图像会出现一条明显的斜向错位。3. C代码读取BMP图像的完整实现3.1 用一个紧凑结构体直接映射文件布局读取BMP最直接的方式是把文件头和信息头定义成结构体然后一次fread把整块头数据读进内存。但这里有一个常见的坑编译器默认会对结构体做内存对齐比如uint16_t后面跟uint32_t中间很可能会被插入2个填充字节导致结构体实际大小不是14字节或40字节而fread是按结构体大小读取的这样读出来的文件头全是错的。解决方法是告诉编译器不要做任何对齐按紧凑方式排列#include stdio.h #include stdlib.h #include stdint.h #include string.h #pragma pack(push, 1) typedef struct { uint16_t bfType; uint32_t bfSize; uint16_t bfReserved1; uint16_t bfReserved2; uint32_t bfOffBits; } BMPFileHeader; typedef struct { uint32_t biSize; int32_t biWidth; int32_t biHeight; uint16_t biPlanes; uint16_t biBitCount; uint32_t biCompression; uint32_t biSizeImage; int32_t biXPelsPerMeter; int32_t biYPelsPerMeter; uint32_t biClrUsed; uint32_t biClrImportant; } BMPInfoHeader; #pragma pack(pop)用了#pragma pack(push, 1)之后sizeof(BMPFileHeader)就是14sizeof(BMPInfoHeader)就是40。在Windows和Linux的GCC/Clang编译器下都支持跨平台也没问题。3.2 读取函数打开、校验、分配、装载四步走读取一张BMP图片我习惯按四个步骤组织代码打开文件检查fopen返回。读取文件头和信息头校验魔数bfType和压缩类型。根据宽高和位数计算像素区大小用malloc分配内存。用fseek定位到bfOffBits一次性读入像素数据。typedef struct { BMPFileHeader fileHeader; BMPInfoHeader infoHeader; uint8_t *pixels; uint32_t rowSize; } BMPImage; int readBMP(const char *path, BMPImage *img) { FILE *fp fopen(path, rb); if (!fp) { perror(打开文件失败); return -1; } if (fread(img-fileHeader, sizeof(BMPFileHeader), 1, fp) ! 1) { printf(读取文件头失败\n); fclose(fp); return -2; } if (img-fileHeader.bfType ! 0x4D42) { printf(不是合法的BMP文件\n); fclose(fp); return -3; } if (fread(img-infoHeader, sizeof(BMPInfoHeader), 1, fp) ! 1) { printf(读取信息头失败\n); fclose(fp); return -4; } if (img-infoHeader.biCompression ! 0) { printf(暂不支持压缩BMP仅支持BI_RGB\n); fclose(fp); return -5; } if (img-infoHeader.biBitCount ! 24) { printf(当前示例仅支持24位真彩色BMP\n); fclose(fp); return -6; } int width img-infoHeader.biWidth; int height abs(img-infoHeader.biHeight); int bytesPerPixel 3; img-rowSize ((width * bytesPerPixel 3) / 4) * 4; uint32_t pixelDataSize img-rowSize * height; img-pixels (uint8_t *)malloc(pixelDataSize); if (!img-pixels) { printf(内存分配失败\n); fclose(fp); return -7; } fseek(fp, img-fileHeader.bfOffBits, SEEK_SET); if (fread(img-pixels, 1, pixelDataSize, fp) ! pixelDataSize) { printf(读取像素数据失败文件可能不完整\n); free(img-pixels); img-pixels NULL; fclose(fp); return -8; } fclose(fp); return 0; }这段代码里我用abs(img-infoHeader.biHeight)来计算高度因为biHeight可能是负数负号表示图像行序是自顶向下存放计算数据总量时取绝对值即可。fseek到bfOffBits而不是简单跳过文件头是为了兼容那些带有调色板或者信息头版本不同的BMP。3.3 别忘了校验文件末尾的像素数据完整性fread的返回值一定要和期望读取的字节数比较不能只判断“非空”。如果文件在下载或拷贝过程中被截断fread可能只读回部分数据而这时图片依然能“打开”只是底部变成黑色或者直接花掉。我遇到过不止一次这种问题所以if (fread(...) ! pixelDataSize)这个判断务必要写。另外malloc出来的内存用完记得free。图像处理往往要连续处理很多张图片哪怕每次只泄漏几兆字节跑一轮批量处理下来内存占用也会非常难看。一份健壮的BMP读取工具回收内存和检查错误返回值是基本素养。4. 实现图像写入从内存数组到磁盘文件4.1 写出一张纯色图片验证基本逻辑读取做完了接下来写写入。写入比读取多一个任务你需要自己填充文件头和信息头。这里可以封装一个独立的写入函数把宽、高、位数、像素数据作为参数传入函数内部自动计算对齐、生成头部并写出int writeBMP(const char *path, int width, int height, int bitCount, const uint8_t *pixels) { uint32_t bytesPerPixel bitCount / 8; uint32_t rowSize ((width * bitCount 31) / 32) * 4; uint32_t pixelDataSize rowSize * height; BMPFileHeader fh; BMPInfoHeader ih; memset(fh, 0, sizeof(fh)); memset(ih, 0, sizeof(ih)); fh.bfType 0x4D42; fh.bfSize 54 pixelDataSize; fh.bfOffBits 54; ih.biSize 40; ih.biWidth width; ih.biHeight height; ih.biPlanes 1; ih.biBitCount bitCount; ih.biSizeImage pixelDataSize; FILE *fp fopen(path, wb); if (!fp) { perror(创建文件失败); return -1; } fwrite(fh, sizeof(BMPFileHeader), 1, fp); fwrite(ih, sizeof(BMPInfoHeader), 1, fp); for (int y 0; y height; y) { fwrite(pixels y * rowSize, 1, rowSize, fp); } fclose(fp); return 0; }这个写入函数里我按行fwrite像素数据而不是一次性写整个像素数组。原因很简单如果调用者传入的像素数组没有预留行尾的填充字节按行写入时我可以方便地在每行末尾补0保证对齐规则不出错。如果你确定像素数组已经按rowSize对齐一次性fwrite也行但按行写更稳妥。测试写入逻辑最简单的方法是生成一张纯红色图片。填入像素数据时每行把填充字节清0每个像素的B分量置0、G分量置0、R分量置255int main() { int w 100, h 80; uint32_t rowSize ((w * 3 3) / 4) * 4; uint32_t dataSize rowSize * h; uint8_t *pixels (uint8_t *)malloc(dataSize); memset(pixels, 0, dataSize); for (int y 0; y h; y) { uint8_t *row pixels y * rowSize; for (int x 0; x w; x) { row[x * 3 0] 0; // B row[x * 3 1] 0; // G row[x * 3 2] 255; // R } } writeBMP(red.bmp, w, h, 24, pixels); free(pixels); printf(已生成 red.bmp\n); return 0; }运行后打开red.bmp能看到一张纯红色的图说明头部写入和像素写出的基本流程都通了。4.2 读取后修改像素再写回形成一个可运行的小工具真正有实用价值的代码是“读进来、改一改、写出去”的全链路。我常用一个颜色反转的例子来验证读写函数的完整性把图片每个像素的R、G、B三个分量都取反即255减去原值。这样处理后图片能立刻看出效果蝴蝶兰会变成青色红色汽车会变成蓝色非常直观。void invertPixels(BMPImage *img) { int width img-infoHeader.biWidth; int height abs(img-infoHeader.biHeight); for (int y 0; y height; y) { uint8_t *row img-pixels y * img-rowSize; for (int x 0; x width; x) { row[x * 3 0] 255 - row[x * 3 0]; row[x * 3 1] 255 - row[x * 3 1]; row[x * 3 2] 255 - row[x * 3 2]; } } } void freeBMP(BMPImage *img) { free(img-pixels); img-pixels NULL; }在main里把这三段串起来readBMP读入原始图、invertPixels做反转、writeBMP写回新文件中间注意备份文件头信息头的字段。这里有一个细节invertPixels按行遍历时rowSize可能包含了填充字节填充字节不参与颜色计算所以内层循环只处理width个像素写完后填充字节保持原样或者清0都行。这就是我在数据结构中单独保存rowSize的原因不然处理到非4倍数的宽度时很容易多算或少算。5. 实测中最容易踩的几个深坑5.1 结构体字节对齐引发的头信息错乱这是初学者报错率最高的一个问题。默认情况下GCC在64位Linux上会把结构体按4字节或8字节对齐导致sizeof(BMPFileHeader)不是14而是16fread读入后bfOffBits的值整个错位后续所有内容全是乱的。症状往往是读出来的图片宽度是个天文数字每行像素大小完全对不上。这个问题排查起来很有迷惑性因为打印文件头信息头时字段看起来都有值但就是不对。我建议在写完结构体后立即用printf(sizeof %zu\n, sizeof(BMPFileHeader))打印确认一下14和40这两个数必须严格对上。加上#pragma pack(push, 1)是最省事的做法比逐字段手工读取更不容易漏。5.2 行对齐规则被忽略导致图像斜切当图片宽度乘以3不是4的倍数时如果读取时没把填充字节计算在内整张图会出现明显的斜向“撕裂”效果——每一行都比实际短一点后一行就往前错位一点。比如99像素宽的图按rowSize297分配并读取实际文件里每行是300字节读取结果就是越到下面偏差越严重。处理方案是严格按“每行字节数向上取整到4的倍数”来分配和读取。我遇到那种手写的图片生成代码很多人喜欢直接dataSize width * height * 3在BMP里这是不对的代价就是图片显示成斜条纹。建议所有涉及BMP尺寸计算的地方统一用一个宏或函数uint32_t bmpRowSize(int width, int bitCount) { return ((width * bitCount 31) / 32) * 4; }5.3 负高度代表自顶向下的存储顺序biHeight可以是负数这一点比行对齐还容易忽略。负值意味着像素数据的第一行对应图像的最上面一行正数则是对应最下面一行。Windows截图工具保存的BMPbiHeight通常为正数也就是自底向上存储而很多图形库生成的BMP可能直接写负数表示自顶向下。如果代码统一按自底向上来处理遇到负高度的图片时读出来的图会上下颠倒。处理方式有两种一种是把所有像素行逆序后再处理另一种是始终保持原序、只在显示时判断方向。对于一个通用的工具函数我建议在内部把高度取绝对值并且保留原始biHeight符号信息给调用者这样读取和写入都能保持与原图一致的方向。5.4 24位与8位BMP在调色板上的巨大差异网上很多基础的BMP教程只讲24位真彩色但如果拿到一个8位灰度BMP用24位的逻辑去读读出来的颜色会完全错乱——因为8位BMP的像素数据是一个个调色板索引真正颜色在调色板里不处理调色板等于没读。8位BMP的结构比24位多一个调色板区调色板紧跟在信息头之后每项4字节蓝、绿、红、保留共256项也就是1024字节。读取时要先跳过或读取调色板再根据索引查表得到RGB值。我建议初学者第一阶段只处理24位图等技术熟练后再扩展8位灰度图这样不会一上来就被调色板搞晕。5.5 文件偏移字段比“固定54字节”更可靠有人为了省事读取像素数据时直接fseek(fp, 54, SEEK_SET)这在大多数24位无调色板BMP上没问题但一旦遇到带调色板的文件或者信息头版本不同的文件就会出错。正确的做法是始终使用bfOffBits字段定位像素数据起点始终用biSize判断信息头实际长度。写代码时遵守“所有偏移都以文件头为准”的原则能帮你躲过各种非标准BMP文件的坑。6. 从读写走向图像处理三个随手可做的进阶扩展6.1 灰度化BGR转亮度公式BMP读写跑通之后第一个值得做的小扩展是图片灰度化。灰度化并不是简单地把RGB三个分量取平均值因为人眼对绿色最敏感、对蓝色最迟钝标准的亮度公式是gray 0.299 * R 0.587 * G 0.114 * B转换成整数运算可以用(77 * R 150 * G 29 * B) 8避免浮点运算速度更快。灰度化实现的本质就是把每个像素的三个分量都替换成同一个灰度值。跑通这个功能你对“像素级操作”的感觉会完全不一样。6.2 颜色反转最简单又最能验证正确性的操作我在前面已经演示过颜色反转的代码。它虽然简单但非常适合用来验证读写逻辑是否正确——因为反转后的效果肉眼可见任何一行的错位、任何一个通道的顺序错误都会立刻暴露。建议你的第一个BMP小工具就做颜色反转跑通后直接在系统自带图片查看器里打开检查红色变成青色、蓝天变成土黄色就能确认BGR通道顺序和行对齐都没问题。6.3 裁剪与拼接把读写能力变成真正的工具再往前一步可以写一个裁剪函数给定左上角坐标和裁剪宽高从原像素数组中按行拷贝目标区域同时重新生成新的文件头信息头。裁剪的代码核心是源区域的行首地址计算srcRow pixels (srcY y) * rowSize srcX * 3目标区域的每行直接memcpy。做完裁剪可以接着做图片拼接——把两张宽度相同的图片垂直拼成一张其实就是把两个像素数组依次写入同一个输出文件。这两个功能练完后你已经能处理项目中绝大多数的BMP图像操作需求。我在实际处理图像时还有个心得每次读写BMP都在关键位置加日志打印宽、高、rowSize、像素数据大小所有问题都藏不住。别看打印日志这步简单它能帮你快速区分是头部解析问题还是像素操作问题排查效率至少翻一倍。先把BMP这一套玩扎实以后接触PNG的过滤算法、JPEG的离散余弦变换你都会感谢这会儿打下的文件操作和内存管理基本功。本文还有配套的精品资源点击获取
返回列表