简介:面向初学者的C语言图像读写演示工程,基于Visual Studio开发,全程不依赖任何第三方图像库。源码围绕图像文件读取、像素解析与写回展开,能让读者直接接触二进制文件I/O、文件头部信息解析以及内存中RGB/灰度像素的读写方法,适合希望从零理解图像处理底层原理的开发者。压缩包共47个文件,以cpp、h源码和Visual Studio工程配置(sln、vcxproj、filters)为主,另有编译产生的obj、tlog、pch等中间文件、资源描述文件以及一张用于测试的jpg图片,整体仅592KB,结构简洁又不失典型性。工程含ReadMe说明和可直接打开的解决方案,调试时可跟随源码逐段观察图像数据从磁盘到内存、再写回文件的完整流程,既能巩固C语言文件操作基本功,也为后续学习OpenCV等高级图像处理奠定基础。目前已有1619人学习,适合初学图像处理与C/C++工程实践的读者下载参考。 看到这个 “C图像读写源代码.zip” 的标题,我的第一反应是:这名字也太朴素了,但里面的东西恰恰是 C 语言学习者最容易忽略、同时也最应该搞透的一个练手项目。图像读写听起来没什么技术含量,可一旦你真的要用 C 语言把一个 BMP 格式的图片文件读进内存、再原样写出来,或者做点像素级的调整操作,你就会发现这里面的坑一点不比写业务代码少:字节对齐、行跨步、内存分配、文件指针偏移,每一样都能让新手摔一跤。
这套代码核心功能很明确:用 C 语言实现图像文件的读取与写入,重点放在 BMP 这种无损位图格式上,解析出图像的宽、高、色深以及像素数据区,再按规范把处理后的数据写回文件。它适合三类人:正在学 C 语言、想找一个综合项目把指针、结构体、文件操作串起来练手的人;刚接触图像处理、想搞清楚“图片在内存里到底是什么形态”的人;以及搞嵌入式开发、需要在单片机或小屏设备上显示图像的人,因为那类场景里 C 和 BMP 的组合出现频率极高。
1. 项目整体设计与思路拆解
1.1 为什么用 C 语言做图像读写,而不是直接用现成库
现在随便一个 Python 脚本,三行代码就能读图、写图、转格式。那为什么还要用 C 语言去折腾这件事?我个人的看法是:正因为处理图像这件事在 C 语言里没有现成的舒适区,你才必须亲手把文件的每一个字节、像素的每一个分量管理好,这个过程逼着你把 C 语言最核心的几个能力全部串起来,包括文件 I/O、指针操作、内存管理、结构体在内存中的布局。
如果你只是想给图片加个滤镜,那确实没必要用 C。但如果你的目标是理解“图片数据到底长什么样”,或者要在没有操作系统依赖的嵌入式环境里做图像显示,那么老老实实把 BMP 读写啃下来,比背诵一百遍指针语法都管用。这套源码的价值不在于“实现了读图”,而在于“用最底层的方式实现了读图”,它把高级语言里被封装掉的那层东西重新暴露在你面前。
1.2 为什么单选 BMP 格式
图像格式那么多,JPEG 有离散余弦变换、PNG 有 zlib 压缩和扫描线过滤、GIF 有 LZW 编码,全都不适合新手从零实现。唯独 BMP(Windows Bitmap)是“裸”的数据格式:大多数 BMP 文件是不压缩的,文件头和信息头结构固定,像素数据按行存储,每行按 4 字节对齐。你用十六进制编辑器打开一张 BMP,能直接看到从文件头到像素数据的完整布局,每一块代表什么含义清清楚楚。
这种“透明”特性让它成为教学和底层开发的绝佳选择。我见过不少新手一上来就写 JPEG 解码器,那是给自己挖坑,JPEG 涉及量化表、霍夫曼编码、色彩空间转换,随便一环出错整张图全是花屏。BMP 没有这个负担,它把重点完整地保留在“文件结构 + 内存管理”上,而这恰好是 C 语言最需要练的基本功。
1.3 整体功能架构怎么搭
拿到这类源代码,建议先别急着编译,先把整体功能脑图整理出来。这个项目的核心思路可以拆成三层,每一层各司其职、互不干扰:
- 文件 I/O 层:负责打开文件、读取文件头和信息头、按偏移读取像素数据、写入处理后的结果,使用标准库的 fopen / fread / fwrite / fseek。
- 格式解析层:负责把 BMP 文件里的字节流翻译成结构体,涉及 C 语言的结构体定义、字节对齐处理,以及把小端序字节组装成整数。
- 像素操作层:在拿到像素缓冲区之后,对每个像素的 RGB 分量做处理,比如灰度化、颜色反转、亮度调整,这一层是后续一切图像算法的基础。
这个分层的思路非常关键。它把“文件怎么存”和“像素怎么处理”解耦开,之后想扩展功能,比如加一个裁剪或者缩放,只需要动第三层,前面两层基本不用改动。很多成熟的图像库也是按这个逻辑来组织的,只是外面包了一层更抽象的统一接口。
2. BMP 格式核心细节与数据结构设计
2.1 BMP 文件由哪几部分组成
如果这个细节没讲透,代码写得再漂亮也是空中楼阁。BMP 文件从结构上分为四块,我对照代码逐一说明。
第一块是BITMAPFILEHEADER,共 14 字节,关键字段有bfType(两字节,必须是字符 'B' 和 'M',即十六进制 0x4D42)、bfSize(整个文件的字节数)、bfOffBits(从文件头到像素数据区的偏移量)。
第二块是BITMAPINFOHEADER,通常为 40 字节,关键字段有biWidth(图像宽度,单位像素)、biHeight(图像高度,单位像素)、biBitCount(每像素位数,常见值为 24 和 32)、biCompression(压缩方式,0 表示不压缩)、biSizeImage(像素数据大小)。
第三块是调色板,只有biBitCount等于 8 或更低时才存在,24 位真彩图没有这一块,直接从信息头跳到像素数据区。
第四块是像素数据区,从bfOffBits指向的位置开始。这里有一个新手最容易忽略的重点:BMP 的像素数据按从下往上、从左往右的顺序存储,也就是说文件里的第一行像素对应的是图像的最底部一行。另外每行的字节数必须是 4 的倍数,不足的要补齐。
2.2 结构体定义与字节对齐的坑
定义结构体来接收文件头是基本操作,但这里有一个大坑:C 语言的 struct 默认带内存对齐,编译器会在成员之间自动填充字节,而磁盘上的 BMP 文件头是紧凑排列的,没有任何填充。如果你直接用默认对齐的 struct 去 fread,读出来的字段会发生错位,bfSize可能变成乱码,后面的像素数据更加无从谈起。
解决办法是使用#pragma pack(push, 1)和#pragma pack(pop),让结构体按 1 字节对齐,保证内存布局和磁盘上的字节流完全一致。代码里通常长这样:
#pragma pack(push, 1) typedef struct { uint16_t bfType; uint32_t bfSize; uint16_t bfReserved1; uint16_t bfReserved2; uint32_t bfOffBits; } BITMAPFILEHEADER; #pragma pack(pop)这个细节是新手最容易翻车的地方,没有pack(1),你读取一个正常 BMP 文件,文件头字段全是错位的,后面怎么调都白搭。而且这个问题在 Visual Studio 和 GCC 下的表现还不一样,有些编译器默认对齐宽度不同,更让人头疼。
2.3 行对齐与跨步计算
BMP 要求每一行像素所占的字节数必须是 4 的倍数。举个例子,一张宽为 41 像素、24 位色深的图片,一行原始的字节数是 41 乘以 3,结果是 123 字节,而 123 不是 4 的倍数,所以要补齐到 124 字节,多出来的 1 个字节是填充数据,不存储任何像素信息。
行字节数的计算公式如下:
int lineBytes = ((width * biBitCount + 31) / 32) * 4;这个公式实质上就是:先计算一行的总位数,即width * biBitCount,加上 31 后除以 32,向上取整到 32 位,也就是 4 字节的倍数,最后再乘以 4 转成字节数。
很多人读像素数据时直接按width * 3去读,结果每一行都错位,图像看起来是歪的,还误以为是自己的算法写错了。其实问题就出在“没有按对齐后的 lineBytes 去跳行”,这个变量在代码里一定要单独计算并保存下来,后续循环取像素、图像缩放都离不开它。
3. 核心读写代码实现与逐步讲解
3.1 读取并验证文件头
读写的第一步是打开文件,然后按bfType == 0x4D42验证它到底是不是 BMP 文件。这一步非常值得做,因为用户传进来的文件可能是 JPG 或 PNG,只是扩展名被改成了.bmp,如果你不做校验,后面读出来的数据全是乱的。
FILE* fp = fopen(path, "rb"); if (!fp) { printf("文件打开失败\n"); return -1; } BITMAPFILEHEADER fileHeader; fread(&fileHeader, sizeof(BITMAPFILEHEADER), 1, fp); if (fileHeader.bfType != 0x4D42) { printf("不是合法的BMP文件\n"); fclose(fp); return -1; }这里有个实际经验:如果 fopen 路径里带有中文,在 Windows 上可能因为编码问题导致打不开,稳妥的做法是使用fopen_s,或者索性把测试文件路径改成纯英文再调试。别小看这个细节,我见过不少人因为文件名叫“测试图像.bmp”而排查了半天,最后发现其实是编码问题。
3.2 读信息头并分配像素缓冲区
文件头确认无误后,接着读取信息头,然后根据宽、高、色深信息分配像素缓冲区。分配内存时,宽度方向要使用前面计算好的对齐后的lineBytes,高度方向要取biHeight的绝对值,因为 BMP 允许用负数高度表示自顶向下存储,但绝大多数文件是正整数。
BITMAPINFOHEADER infoHeader; fread(&infoHeader, sizeof(BITMAPINFOHEADER), 1, fp); int width = infoHeader.biWidth; int height = infoHeader.biHeight < 0 ? -infoHeader.biHeight : infoHeader.biHeight; int lineBytes = ((width * infoHeader.biBitCount + 31) / 32) * 4; int dataSize = lineBytes * height; uint8_t* pixels = (uint8_t*)malloc(dataSize); if (!pixels) { printf("内存分配失败\n"); fclose(fp); return -1; } fseek(fp, fileHeader.bfOffBits, SEEK_SET); fread(pixels, 1, dataSize, fp); fclose(fp);顺序上要特别注意:必须先验证文件有效性再分配内存,不要先malloc再判断文件是否存在,那样白白分配了一块内存又得释放,纯属浪费还容易泄漏。另外,fseek的偏移量要使用文件头里保存的bfOffBits,而不是自己硬编码 54,因为有些 BMP 文件在信息头和像素数据之间还有调色板存在。
3.3 像素处理:灰度化与颜色反转
拿到pixels缓冲区后,就可以对像素做各种操作了。这一块是整个项目里最有“图像处理”味道的部分。以 24 位 BMP 为例,每个像素占 3 个字节,顺序是 B、G、R,不是我们习惯的 RGB 顺序,这一点踩坑率极高。
灰度化的核心思路是让 R、G、B 三个分量相等,都等于亮度值,常用的加权公式是:
uint8_t gray = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);实际代码中就是对每个像素执行同样的操作:
for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int index = y * lineBytes + x * 3; uint8_t b = pixels[index + 0]; uint8_t g = pixels[index + 1]; uint8_t r = pixels[index + 2]; uint8_t gray = (uint8_t)(0.299f * r + 0.587f * g + 0.114f * b); pixels[index + 0] = gray; pixels[index + 1] = gray; pixels[index + 2] = gray; } }注意这里像素在行内的步进是按 3 字节,但行尾跳到下一行时必须使用lineBytes而不是width * 3,一旦中间存在填充字节,就必须用它跳过。颜色反转更简单,对每个分量执行255 - 原值即可,代码模式完全一样,只是把灰度公式换成了反转公式。
3.4 写回 BMP 文件
写文件是读文件的逆向操作,但有一个细节不同:如果是在原图基础上处理,可以直接复用原始的文件头和信息头,只替换像素数据区的内容;如果是新建一张图,需要先手工填充两个头结构,再写入像素数据。
FILE* out = fopen("output.bmp", "wb"); if (!out) return -1; fwrite(&fileHeader, sizeof(BITMAPFILEHEADER), 1, out); fwrite(&infoHeader, sizeof(BITMAPINFOHEADER), 1, out); // 典型情况 bfOffBits = 54,文件头加信息头,中间没有调色板 fwrite(pixels, 1, dataSize, out); fclose(out);文件头和像素数据的衔接,有时候会让新手困惑:文件头加信息头共 54 字节,如果bfOffBits正好是 54,说明中间没有调色板,直接接着写像素数据即可。如果bfOffBits大于 54,中间可能有调色板或预留区域,需要在写入时把那段也跳过或者同步写一份,但常规应用里很少见到,可以暂时忽略。
3.5 内存释放与资源管理
代码的最后少不了释放malloc出来的内存。这里有一个小习惯:先fclose文件,再free像素缓冲区,顺序颠倒了可能造成悬垂指针。更稳妥的写法是无论成功还是失败,都走统一的错误处理分支,只在一个出口处释放资源。
free(pixels); pixels = NULL;把指针置为 NULL 的目的是防止野指针,虽然程序马上退出时可加可不加,但在大项目里这是防止 double-free 的基本素养。另外fwrite之后最好检查一下返回值,确认实际写入的字节数和期望一致,否则可能磁盘满了导致写入不完整,而程序却没有任何报错。
4. 常见问题与排查技巧实录
4.1 读出来的图像整体倾斜、每行错位
这个问题的根源几乎永远是行对齐没算对。很多新手直接按width * 3去跳行,碰到宽度不是 4 倍数的图片就全部错位,图像看起来像是被斜切过。排查方式很直接:用一张宽度为 41 的 BMP 转灰度图,如果看到斜条纹,那基本就是lineBytes写错了。修正方法就是前面给的那个公式,这里再强调一遍:lineBytes是((width * biBitCount + 31) / 32) * 4,不是简单乘个 3。
4.2 读出来的图像颜色不对,整体发蓝或发红
如果你看到的画面偏蓝或偏红,大概率是 BGR 和 RGB 顺序搞反了。BMP 在磁盘上按 B、G、R 顺序存储,如果直接用 RGB 顺序去取分量,画面颜色就会错乱。解决办法是在解析像素时按 B、G、R 的顺序读取,在写入时也保持同样的顺序。这类问题用一张纯红色测试图排查最方便,如果读出来变成蓝色,那基本就是顺序问题。
4.3 fopen 中文路径在 Windows 下打不开
Windows 下fopen默认使用 ANSI 编码,如果路径里带中文,在简体中文系统上可能因为编码转换问题打不开。常见的解法是在 Windows 下用fopen_s,或者将路径转为宽字符串后用_wfopen。这类问题在 Linux 下基本不存在,因为 Linux 的文件名本身就是 UTF-8 字节流,直接传给 fopen 不会出错。
4.4 处理完的 BMP 文件被其他软件拒绝打开
如果生成的文件用 Windows 自带的照片查看器打不开,首先要检查bfOffBits、bfSize这些头部字段是否填写正确。尤其是bfSize,它应该是文件头加信息头加像素数据的总字节数,哪怕差一个字节都会被某些严格解析的软件判定为无效文件。另一个常见原因是像素数据区的字节数没有对齐到 4 的倍数,文件头里声称的biSizeImage和实际写入的数据量不一致,也会导致文件损坏。
5. 扩展与应用方向
5.1 从 DEMO 走向实用:封装成统一接口
既然是“源代码”,拿到手之后建议先跑通,再把关键步骤封装成函数接口,比如:
int bmp_load(const char* path, BMPImage* img); int bmp_save(const char* path, const BMPImage* img); void bmp_destroy(BMPImage* img);用结构体统一保存宽、高、位深、像素缓冲区和行字节数,后续再做图像处理算法时,传参和复用都方便得多。这个封装过程本身也是 C 语言抽象思维的绝佳训练,把零散代码整合成有清晰边界的模块,才算真正把代码变成了自己的东西。
5.2 结合嵌入式场景:输出 RGB565 格式
如果你在 STM32 这类单片机上做 LCD 显示,BMP 常常需要转换成 RGB565 裸数据。方法很简单,遍历 24 位像素,取 R 的高 5 位、G 的高 6 位、B 的高 5 位,拼成一个 16 位数写入文件或直接填充到显存。这一步可以把“图像读写”这个 DEMO 技能平滑地迁移到真实的嵌入式项目里,配合 ILI9341 这种屏幕驱动,效果会非常直观。
写在后面
这套源代码我当年也完整练过,踩过的坑基本都写在上面了。刚开始跑通时,看到一张照片被 C 语言程序读进来、处理完再写出去,那种“原来图片就是一堆字节”的顿悟感,到现在都记得很清楚。它并不炫酷,不涉及神经网络、目标检测这些热门概念,但它把 C 语言最扎实的基本功变成了有实物反馈的成果。
如果你也在学 C 语言,或者正打算做嵌入式的图像显示,找一套这样的代码从头到尾读一遍、改一遍、再重写一遍,比刷几十道填空题的价值大得多。最后分享一个小建议:拿到源码不要急着整体编译,先只写一个读取文件头并打印宽高、色深、偏移量的小程序,确认基础解析没问题后,再逐步加上像素处理和写文件功能,这样出问题时你永远知道是哪个环节出了问题。
本文还有配套的精品资源,点击获取