简介:这份C图像读写源代码包,定位为面向C语言初学者与图像处理入门者的演示工程,核心目标是不依赖OpenCV等任何第三方库,仅借助标准文件I/O和基本位操作,完成常见图像格式的读取、解析、像素访问与写回。源码结构清晰,围绕二进制文件流、图像头部信息和二维像素数组展开,适合用来理解底层二进制数据处理、图像编码布局,以及内存中像素的基本操作方式,为后续学习BMP、JPEG、PNG等格式打下直观基础。
包内共47个文件,整体约592KB,主要包含cpp/h源码、Visual Studio工程配置、tlog构建日志、obj中间文件、pdb调试符号,以及一张测试图片和说明文档。工程文件齐全,便于直接打开、编译和对照学习;虽然没有引入OpenCV这类重量级库,但反而能更清楚地看到图像读写底层的实现路径。
目前已有1624人学习下载,热度不错。对希望从零开始理解图像读写机制、再逐步过渡到计算机视觉或复杂图像算法的开发者而言,这是一份轻量且完整的起步样例,既能动手验证关键概念,也可作为扩展自定义格式读写的参考起点。
1. 看这份 C 图像读写源代码前,先搞清楚它到底能干什么
打开这个压缩包的开发者,多半跟我当初一样:不想为了读一张图就拽进一个几百兆的第三方库,或者单纯想弄明白一张图片在磁盘上到底存了什么字节。这份用 C 语言从零实现的图像读写代码,核心对象是 BMP 和 PPM 这两类最不藏私的格式——它们基本不做压缩,像素在文件里和内存里几乎一一对应。它的价值不在性能,而在透明:你能在几十行代码里看清文件头怎么解析、像素怎么对齐、字节序怎么处理,这些认知在接任何图像库、任何跨平台项目时都能复用。适合三类人:刚接触 C 文件 IO 的初学者、需要在嵌入式或极简环境里做图像落盘的从业者、以及被某个图像库黑匣子坑过、想自己掌握底层的开发者。
2. 动手前先吃透三件事:格式选型、文件结构、接口设计
2.1 为什么代码包选 BMP 和 PPM 作为读写对象
读这份代码之前,先明白它为什么拿 BMP 和 PPM 开刀。这两个格式的共同点是数据不经过压缩,PPM 甚至头部都是纯文本:第一行写P6,第二行写宽和高,第三行写最大灰度值255,之后就是纯粹的 RGB 字节流。BMP 稍微复杂,有 14 字节文件头和 40 字节 DIB 头,并且每一行像素要按 4 字节对齐补零,但它依然是裸数据。正因如此,它们最适合做「从零手写图像读写」的载体——所有该出现的坑都会出现,且没有压缩算法干扰排查。
我见过不少人拿到这类代码包后直接跳到读图函数,结果被文件头的各种偏移量搞晕。正确的打开方式是先建立一个认知:文件里的像素布局和内存里的布局是不同的。BMP 在磁盘上按 BGR 三通道、行尾补零排列;而代码包内的内存结构体通常是紧凑的 RGB 行优先连续块。两者之间的转换,才是这套代码真正的工作内容。后面所有读写函数,本质上都在做这一件事——布局转换。
2.2 文件头结构:从磁盘字节到内存像素的映射
先看代码包主文件里最核心的结构体定义,这也是理解一切的入口:
/* image.h —— 核心数据结构与接口声明 */ typedef struct { int width; int height; int channels; /* 3 = RGB,4 = RGBA */ unsigned char *data; /* 像素数据,行优先,无行填充 */ } image_t; /* 返回值约定:0 成功,负数对应不同错误码 */ int image_load(image_t *img, const char *path); int image_save(const image_t *img, const char *path); void image_free(image_t *img);data的实际长度是width * height * channels,注意它已经去掉了 BMP 的行填充。代码在解析 BMP 时会把每行末尾的对齐字节跳过,只把有效像素拷贝进来。这个设计决定了后续一切读写逻辑的走向:文件里的行有填充、内存里没有,解析时必须逐行处理,不能整个文件一次性fread。
BMP 的 DIB 头里几个关键字段的偏移量要背下来:偏移 18 处是宽度(4 字节小端)、偏移 22 处是高度(小端,负数表示自顶向下存储)、偏移 28 处是像素位数(2 字节,常见 24 或 32)、偏移 30 处是压缩标记(0 表示 BI_RGB,不压缩)。代码包里读取 BMP 的函数基本就是对着这几个偏移量做fseek加fread,多一个字段都不读——这也是它比直接读整个文件头结构体更稳的原因。
2.3 接口设计:读、写、查错三件套
这套接口设计有个值得学的点:错误码不是布尔值,而是分层负数。-1表示文件打不开,-2表示文件头不合法,-3表示像素数据读取失败。这种分层在排查时非常有用,比返回NULL之后靠errno猜原因直观得多。你在自己的项目里复用它时,建议保留这套约定,别为了省事改成0/1。
写文件的流程是反过来的:先填头部,再写像素。PPM 用fprintf写三行文本头就行;BMP 要用fwrite写满 54 字节头部,再逐行补零写像素。写 BMP 时有个新手必踩的坑:头部里填入的文件大小和像素偏移必须与实际写入完全一致,否则任何看图软件都会直接报「文件损坏」。代码包里通常会在头部字段用位运算逐字节拼小端整数,这比直接给结构体指针赋值要可移植得多。
3. 把源码跑通:编译、读图、写图的最小可复现路径
3.1 目录结构与编译命令
解压后的典型布局是image.h、image_bmp.c、image_ppm.c加一个main.c测试入口。编译只需要一条命令,不需要链接任何第三方库:
# 把各源文件一起编译,-std=c99 保证整数类型定义可用 gcc -std=c99 -Wall -O2 main.c image_bmp.c image_ppm.c -o image_tool-Wall一定要开,这种纯手工解析字节的代码最怕隐式类型转换和未初始化变量。-O2对这类代码影响不大,但开起来能顺手处理掉一些未定义行为。如果编译报'uint8_t' undeclared,就在image.h顶部加一行#include <stdint.h>,这是 32 位和 64 位平台上最常见的头文件遗漏。链接阶段如果报重复定义,检查是不是把image_bmp.c编译了两次,或者头文件里定义了全局变量而不是用extern声明。
3.2 读图最小路径:从 PPM 开始
我一般建议先只用 PPM 验证环境,因为它最简单。核心读图代码可以拆成这样:
/* image_ppm.c —— 读取 P6 格式 PPM 的核心代码 */ int image_read_ppm(const char *path, image_t *img) { FILE *fp = fopen(path, "rb"); if (!fp) return -1; char magic[3]; if (fscanf(fp, "%2s", magic) != 1 || magic[0] != 'P' || magic[1] != '6') { fclose(fp); return -2; /* 文件头不合法 */ } /* fscanf 天然跳过头部文本里的空格和换行,省去手写跳过逻辑 */ if (fscanf(fp, "%d %d %d", &img->width, &img->height, &img->maxval) != 3) { fclose(fp); return -2; } img->channels = 3; /* PPM 只有 RGB 三通道 */ img->data = malloc((size_t)img->width * img->height * img->channels); if (!img->data) { fclose(fp); return -3; } /* 跳过头部到像素数据之间的一个空白字符,然后 fread 一次读完 */ fgetc(fp); if (fread(img->data, (size_t)img->width * img->height * img->channels, 1, fp) != 1) { free(img->data); fclose(fp); return -3; } fclose(fp); return 0; }这里有两个参数细节要重点说。第一个是fscanf的%d,它自动跳过空白,比手写strtok解析健壮得多;唯一的坑是它不跳过#注释行,如果真实环境遇到带注释的 PPM 文件,要在解析前补一个跳过注释的逻辑。第二个是fgetc(fp)那一行,PPM 规范要求像素数据前有一个空白字符,很多文件里是换行符。如果直接fread,这个字符会被读进像素区,导致整张图第一行偏移一个字节——视觉上就是图像被横向撕开,且只偏一行,极难察觉。
3.3 写图最小路径:BMP 的行对齐
写 BMP 是这套代码里最容易出错的部分,核心在行对齐:
/* image_bmp.c —— 写 24 位 BMP 的关键代码段 */ int image_write_bmp(const char *path, const image_t *img) { if (!img->data || img->channels != 3) return -4; FILE *fp = fopen(path, "wb"); if (!fp) return -1; int row_size = img->width * 3; int padded_size = (row_size + 3) & ~3; /* 每行按 4 字节对齐 */ int data_size = padded_size * img->height; int file_size = 54 + data_size; unsigned char header[54] = {0}; header[0] = 'B'; header[1] = 'M'; /* 以下按小端逐字节写入,跨平台不依赖本机字节序 */ header[2] = (unsigned char)(file_size & 0xff); header[3] = (unsigned char)((file_size >> 8) & 0xff); header[4] = (unsigned char)((file_size >> 16) & 0xff); header[5] = (unsigned char)((file_size >> 24) & 0xff); header[10] = 54; /* 像素数据偏移 */ header[14] = 40; /* DIB 头大小 */ /* 宽度、高度、bpp=24、压缩=0 等字段按同样方式填充 */ fwrite(header, 1, 54, fp); /* 逐行拷贝像素并补零 */ unsigned char pad[3] = {0, 0, 0}; int pad_bytes = padded_size - row_size; for (int y = 0; y < img->height; y++) { fwrite(img->data + (size_t)y * row_size, 1, row_size, fp); if (pad_bytes) fwrite(pad, 1, pad_bytes, fp); } fclose(fp); return 0; }(row_size + 3) & ~3这个表达式是整个 BMP 写入的核心,它把行字节数向上取整到 4 的倍数。比如宽 5 像素、24 位时行字节数是 15,补到 16。忘记补零的后果是文件仍能被打开,但图像底部会出现斜向的彩色条纹,因为后续每一行整体错位了pad_bytes个字节。这也是本章标题里「血泪经验」最集中的一处——写 BMP 不补对齐,十个新手九个翻车。
4. 图像读写避坑指南:四个高频翻车点与排查方法
4.1 行对齐:读图时忘了跳填充字节
现象:用代码读一张正常的 BMP,宽度不是 4 的倍数时(比如 101 宽),图像出现整体斜切,右侧边缘有彩色残影,但头部信息宽高完全正确。
原因:写 BMP 的人按规范做了行尾补零,但读代码只按width * 3去fread,导致每行末尾的 1 到 3 个对齐字节被当成了下一行开头的像素,每一行都向后偏移pad_bytes个字节。偏移量随行数线性积累,看起来就是斜切。
解决:读图时先算padded_size,每读完一行就跳过填充字节。修改点通常在image_bmp.c的读函数里,把原来的一次性大块fread改成逐行处理:
/* 读取 BMP 像素区域:逐行读并跳过行尾填充 */ int pad_bytes = padded_size - row_size; for (int y = 0; y < img->height; y++) { if (fread(img->data + (size_t)y * row_size, 1, row_size, fp) != (size_t)row_size) { free(img->data); fclose(fp); return -3; } if (pad_bytes) fseek(fp, pad_bytes, SEEK_CUR); /* 跳过对齐字节 */ }注意这里用的是fseek而不是fread到临时缓冲,因为填充字节内容没有意义,直接跳过即可。改完后用 101、102、103 三种宽度的测试图各跑一遍,确认不斜切才算过。
4.2 通道顺序:RGB 和 BGR 的玄学
现象:读出来的图色彩完全颠倒,红蓝互换、绿不变,但轮廓清晰。写出去的图在 Windows 画图里看是正常的,在某个视觉库里看又是反的。
原因:BMP 在磁盘上的像素顺序是 BGR,而内存结构体image_t约定的是 RGB。纯手写解析时,最容易漏掉的就是拷贝过程中的通道交换。很多人第一次遇到这个现象会怀疑是显示器或库的问题,实际上是换通道的问题。
解决:在把像素拷进img->data时做一次三通道交换:
/* 读 BMP 时逐像素交换 BGR -> RGB */ for (size_t i = 0; i < (size_t)img->width * img->height; i++) { unsigned char *p = img->data + i * 3; unsigned char t = p[0]; p[0] = p[2]; p[2] = t; }但这里有个边界要拎清楚:如果后续要把图像直接送到 GPU 纹理或某些视觉库,它们可能本来就要 BGR,这时候就不该交换,而是透传。先确认下游的通道约定再决定要不要做这一步,否则你会在「交换了又不对、不交换也不对」之间反复横跳。
4.3 内存:读失败后忘了释放
现象:程序连续读几千张图后内存缓慢上涨;或者读一个头部合法但像素数据截断的损坏文件时直接崩溃。
原因:malloc之后如果fread失败,直接return就漏了free。更隐蔽的是外层调用者先对img->data做了释放,image_load内部又释放了一次,造成 double free。这种问题在单张图场景下根本看不出来,一旦跑批量处理就暴露。
解决:给这套代码定一条铁律:谁分配谁释放。image_load内部负责清理自己分配失败后的残留;外层调用者只对成功返回的img调用一次image_free。排查时用valgrind跑一个读图循环,看到definitely lost报告的位置就是漏释放的出口。另外可以在image_free里加一行img->data = NULL,防止释放后被再次释放。
4.4 格式误判:靠扩展名辨格式靠不住
现象:把一张 PNG 直接改名成.bmp,调用读图函数返回-2但没崩溃;某些实现却按 BMP 去解析,读出了花屏数据还返回成功。
原因:代码包里的image_load如果只是按扩展名分发解析函数,就拦不住改名文件。PNG 的头八个字节是固定的签名,BMP 必须是BM,PPM 是P6,不看内容只看扩展名早晚踩坑。
解决:在image_load里先读文件头 8 字节做魔数判断,再按魔数分发:
/* image_load 入口:按魔数分发而非扩展名 */ unsigned char magic[8] = {0}; fread(magic, 1, 8, fp); rewind(fp); /* 读回文件头,交给具体解析函数 */ if (magic[0] == 'B' && magic[1] == 'M') return read_bmp(fp, img); if (magic[0] == 'P' && magic[1] == '6') return read_ppm(fp, img); return -2; /* 未知格式 */这样即使扩展名错了,也能明确返回错误码而不是花屏数据。这个习惯在真实项目里能省掉大量联调时间,尤其是当上游交付的文件命名混乱时。
5. 把它改造成自己的工具:扩展格式前的三个检查点
代码跑通之后,下一步通常是想加 PNG 或 JPEG 支持。别急着写解码器,先过三个检查点。
第一个检查点:确认你要读的文件是不是真需要完整解码。很多场景只需要尺寸、宽高比或者一张缩略图——PNG 的 IHDR 块和 JPEG 的 SOF 段都能在不解全部像素的情况下读出宽高。花半天写一个「只解析头部」的轻量函数,比引入一个完整解码库划算得多,内存占用也小一个量级。
第二个检查点:把读写路径做成严格对称的。也就是说,image_save写出来的文件必须能被image_load原样读回,像素值逐字节相等。这个回环测试是检验这整套代码正确性最快的土办法。我习惯在main.c里加一个自检模式:生成一张带渐变和固定色块的测试图,写出去再读回来,逐字节比对,任何不一致就打印出错的行列坐标。这种做法能在一分钟内揪出通道顺序、行对齐、文件头长度三类问题。
第三个检查点:想清楚数据要往哪里去。如果读取结果要交给图像处理算法,保持行优先、无填充的连续内存最省事;如果要落盘给 Windows 生态看图,就得保留 4 字节行对齐。这两个目标冲突时,好的做法是在image_t里加一个row_stride字段,内存布局跟着它走,而不是死守width * channels的紧凑模式。所有读写函数统一按row_stride计算偏移,上层算法只管像素、不管布局。
我当年在模拟项目X里就是因为没做这第三个检查点,嵌入式端要求紧凑内存、PC 端要求对齐存储,各改一版后联调对不上。后来把row_stride加进结构体,所有读写函数统一按它计算偏移,一天就收完了。这份 C 图像读写代码的价值就在于此——它不是终点,但把文件层和内存层拆清楚之后,上面接算法、下面接直写的逻辑都顺了。希望帮到你。
本文还有配套的精品资源,点击获取