简介:面向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 文件结构简单到可以逐字节讲清楚
BMP(Bitmap)是一种无压缩的位图格式,它的核心特征就是“原始”。每个像素的颜色直接按顺序存储在文件里,不像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),它描述了文件的类型、大小和数据偏移。我整理了一张表,写代码之前最好先背下来:
| 字段名 | 大小 | 偏移 | 含义 |
|---|---|---|---|
| bfType | 2字节 | 0 | 固定为BM,十六进制是0x4D42 |
| bfSize | 4字节 | 2 | 整个文件大小(字节数) |
| bfReserved1 | 2字节 | 6 | 保留字段,常为0 |
| bfReserved2 | 2字节 | 8 | 保留字段,常为0 |
| bfOffBits | 4字节 | 10 | 像素数据起始位置的偏移量 |
最容易出错的是bfType。用十六进制编辑器打开BMP文件时,前两个字节实际是42 4D(ASCII码的B和M),但在小端机器上以uint16_t读取后得到的是0x4D42,判断时别写反。
bfOffBits这个字段很关键——它不是固定等于文件头的14字节。24位图通常正好是54,但如果图片带调色板或文件头信息头版本不同,这个偏移会变。读取像素数据前一定要通过这个字段定位,不能死记硬算。
2.2 40字节信息头为什么比文件头更关键
紧接文件头的是至少40字节的信息头(BITMAPINFOHEADER),它才是真正告诉代码“图片有多大、用什么格式存”的部分。核心字段如下:
| 字段名 | 大小 | 含义 |
|---|---|---|
| biSize | 4字节 | 本结构体大小,常为40 |
| biWidth | 4字节 | 图像宽度(像素) |
| biHeight | 4字节 | 图像高度(像素),正数表示自底向上存储,负数表示自顶向下 |
| biPlanes | 2字节 | 颜色平面数,必须为1 |
| biBitCount | 2字节 | 每像素位数,常见1/4/8/16/24/32 |
| biCompression | 4字节 | 压缩类型,0表示BI_RGB不压缩 |
| biSizeImage | 4字节 | 像素数据大小,不压缩时可为0 |
| biClrUsed | 4字节 | 使用的颜色数,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×3=5760字节,5760刚好是4的倍数,所以不需要补位;但如果宽度是100像素,100×3=300字节,300除以4余数为0,实际rowSize还是300。再看99像素的宽度:99×3=297字节,297不是4的倍数,要补齐成4×75=300字节,每行多出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)就是14,sizeof(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分量置255:
int 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而是16,fread读入后bfOffBits的值整个错位,后续所有内容全是乱的。症状往往是:读出来的图片宽度是个天文数字,每行像素大小完全对不上。
这个问题排查起来很有迷惑性,因为打印文件头信息头时字段看起来都有值,但就是不对。我建议在写完结构体后,立即用printf("sizeof = %zu\n", sizeof(BMPFileHeader))打印确认一下,14和40这两个数必须严格对上。加上#pragma pack(push, 1)是最省事的做法,比逐字段手工读取更不容易漏。
5.2 行对齐规则被忽略导致图像斜切
当图片宽度乘以3不是4的倍数时,如果读取时没把填充字节计算在内,整张图会出现明显的斜向“撕裂”效果——每一行都比实际短一点,后一行就往前错位一点。比如99像素宽的图,按rowSize=297分配并读取,实际文件里每行是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截图工具保存的BMP,biHeight通常为正数,也就是自底向上存储;而很多图形库生成的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的离散余弦变换,你都会感谢这会儿打下的文件操作和内存管理基本功。
本文还有配套的精品资源,点击获取