
简介一份用C语言实现的JPEG解码器完整源代码适合图像处理初学者、嵌入式开发者及需要移植解码逻辑的工程师。代码从JPEG二进制流读取、SOI/EOI/SOF/DQT/DHT等标记解析到DC/AC系数解码、反量化、IDCT及YCbCr转RGB全流程均有清晰注释可对照源码逐模块调试理解霍夫曼表构建、量化表应用与色度抽样等难点尤其适合逐步跟踪8x8块的DC预测与AC游程解码过程资源虽标记C#核心解码仍是C实现便于在界面层或二次集成中调用。压缩包共75个文件包含54个C源文件与14个头文件构成完整解码工程另附测试图片、BMP输出示例及Visual C 6.0工程文件.dsp/.dsw等包体仅378KB目录结构清晰头文件与实现分离便于定位关键函数。已有271人学习浏览。研读后既能系统掌握JPEG有损压缩标准也可为自研图像格式或优化解码性能提供可移植的C语言参考无论是课程设计还是工程落地均有直接价值。1. JPEG解码的难点从来不在解码本身手机上双击一张照片系统在几十毫秒内把一张 4000×3000 的 JPEG 显示出来很多人觉得这是理所当然的。但真到自己动手写一个解码器就会发现坑全在那些看不见的边界条件里碰到不完整的码流、非标准的量化表、4:2:0 与 4:2:2 混用的扫描数据解码器能不能稳稳地出图完全是另一回事。这套用 C 语言实现的 JPEG 解码器源码文件命名沿用了 IJGIndependent JPEG Grouplibjpeg 的惯例从 jdmarker.c 的标记解析、jdhuff.c 的霍夫曼解码到 jidctfst.c 的快速逆离散余弦变换、jdcolor.c 的颜色空间还原一条完整的解码链路都在里面。对刚接触图像编码的人它是理解 JPEG 规范最好的活教材对有几年经验的人它展示了如何用纯 C 写出可移植、可裁剪、能在嵌入式环境里跑的解码模块以及 C# 这类上层语言如何通过本地接口把它接进自己的图像处理管线。2. 标记扫描JPEG 码流的骨架与 jdmarker.c 的解析顺序JPEG 文件本质是一串由标记Marker分隔的数据段。解码器拿到文件后第一步不是急着解像素而是沿着标记把量化表、霍夫曼表、帧参数全部收集齐直到遇见 SOSStart of Scan才开始真正的熵解码。这个阶段的代码在源码里对应 jdmarker.c 和 jdapimin.c理解它的核心是哪些标记必须处理哪些可以跳过以及某些标记缺失时解码器该如何降级。2.1 标记结构从 SOI 到 EOI哪些字节值得停下来JPEG 的标记由0xFF加一个字节的编号组成位于段的开头。解码器逐字节扫描文件遇到0xFF后如果是0x00说明这是一个被填充的数据字节不是标记需要跳过如果0xFF后紧跟0xD8SOI或0xD9EOI这两个是单字节标记没有长度字段其余标记后面都跟着两个字节的段长度包含长度字段自身。这也是为什么 JPEG 文件头总是FF D8 FF E0看到FF D8就能确认这是一张 JPEG 图。源码里 jdmarker.c 维护了一个标记分发表把每个标记映射到对应的处理函数。下表列出的标记对解码器是必须处理的标记名称作用缺失时的表现FF D8SOI图像开始文件非法直接拒绝FF E0APP0JFIF 信息含分辨率部分解码器会忽略FF DBDQT定义量化表无法反量化FF C0SOF0基线帧参数不知道尺寸和采样FF C4DHT定义霍夫曼表无法熵解码FF DASOS扫描开始没有像素数据FF D9EOI图像结束码流不完整2.2 用 jpeg_read_header 模拟一遍标记遍历libjpeg 风格的解码器通常会先调用jpeg_read_header()它的内部逻辑就是循环读标记、分发、处理直到遇到 SOS。写一个简化版的标记遍历可以帮助理解 jdmarker.c 的工作方式static int consume_marker(JFILE *fp, JPEG_MARKER *m) { int c; // 跳过填充字节0xFF 后可能连续出现多个 0xFF do { c read_byte(fp); } while (c ! 0xFF); // 0xFF 后跟 0x00 是数据填充不是标记 do { m-code read_byte(fp); } while (m-code 0x00); if (m-code 0xD8 || m-code 0xD9) return 0; // 单字节标记无长度字段 m-length read_word(fp); // 大端序读取段长度 return m-length - 2; // 减去长度字段自身的 2 字节 } int jpeg_read_header(JFILE *fp, JPEG_HEADER *hdr) { JPEG_MARKER m; while (1) { int remain consume_marker(fp, m); if (remain 0) return JPEG_ERR; switch (m.code) { case 0xDB: // DQT读取量化表存到 hdr-quant_tbl parse_dqt(fp, remain, hdr); break; case 0xC0: // SOF0读取宽高、分量数、采样因子 parse_sof(fp, remain, hdr); break; case 0xC4: // DHT读取霍夫曼表 parse_dht(fp, remain, hdr); break; case 0xDA: // SOS进入扫描解码阶段 return JPEG_OK; default: skip_bytes(fp, remain); // 不认识的标记直接跳过 break; } } }consume_marker()里的两个循环是有讲究的第一个循环用来跳过连续出现的0xFF第二个循环把0x00当作转义字节处理这样能保证熵编码阶段里表示值 255 的字节不会被误判成标记。JPEG 规范要求编码器在数据中出现0xFF时后面必须补一个0x00解码器看到0xFF 0x00就把0x00丢掉恢复出原始数据。如果你在解一个自己写的 JPEG 编码器产出的文件这段逻辑大概率就是 bug 高发区——漏掉转义解码器会提前结束图像数据。注意并非所有 JPEG 文件都有 APP0JFIF段。有些相机输出的文件用 APP1Exif有些干脆只保留 SOI、SOF、DHT、SOS、EOI。解码器不能假设 JFIF 字段一定存在尺寸和颜色信息要从 SOF 里读。3. Huffman 解码熵编码阶段差在哪一个比特JPEG 的熵编码不是简单的霍夫曼编码它在霍夫曼之前还有一道预处理对 DC 系数做差分编码对 AC 系数做游程编码。所以解码器看到的 symbol 不是直接的系数值而是(游程长度, 二进制位数)的组合。源码里 jdhuff.c 维护着 DC 和 AC 两套霍夫曼表实际解码时色彩分量各用一个 DC 表和一个 AC 表。3.1 DC 差分与 AC 游程解码器面对的不是像素而是系数每个 8x8 块经过 DCT 后第一个系数是 DC直流分量其余 63 个是 AC交流分量。为了压缩JPEG 对相邻块的 DC 做了差分编码——当前块的 DC 只保存与上一个块 DC 的差值AC 系数则按照 Zig-Zag 顺序扫成一个一维数组相邻的零值被合并成游程。解码时DC 部分需要维护一个预测器last_dc_value每解出一个差值就累加进去AC 部分则要按(zero_run_length, size)逐段恢复非零系数把零填到对应位置。这样设计的结果是霍夫曼表里的符号取值范围不大DC 差值和 AC 系数位宽最多 15 bit表规模可以控制在几百个条目内解码速度远快于直接用大数值建表。理解这一层你就能明白为什么 jdhuff.c 里的解码循环一直在做位读取、查表、判断符号位这三件事。3.2 用 jdhuff.c 的思路构建解码表DHT 标记里存放的是每个符号的码长分布解码器要先把它转换成可直接查表的格式。下面是简化版的建表逻辑#define HUFF_MAX_TABLES 4 #define HUFF_LOOKAHEAD 8 typedef struct { int mincode[17]; // 每个码长对应的最小码值 int maxcode[17]; // 每个码长对应的最大码值 int valptr[17]; // 每个码长在符号表中的起始位置 UINT8 val[256]; // 符号表按码长顺序排列的符号值 } huff_table; int huff_make_table(huff_table *t, const UINT8 *bits, const UINT8 *huffval) { int p 0; // huffval 中的当前下标 for (int i 1; i 16; i) { if (bits[i] 0) { t-mincode[i] -1; t-maxcode[i] -1; t-valptr[i] -1; } else { if (p bits[i] 256) return JPEG_ERR_TABLE; // 表数据超过符号表上限 t-valptr[i] p; // 同一码长下符号按 huffval 顺序排列 // mincode 是该码长下的第一个码值 t-mincode[i] (i 1) ? 0 : (t-mincode[i-1] 1) 1; // 推导逻辑码长 i 的最小码值由码长 i-1 的最大码值 1 再左移 1 位 t-maxcode[i] t-mincode[i] bits[i] - 1; p bits[i]; } } memcpy(t-val, huffval, p); return p; }这里mincode的推导是理解整张表的关键。霍夫曼编码满足短码不能是长码的前缀这一约束所以当码长为n的全部码值用完后下一个码长的最小码值等于(当前最大码值 1) 1。源码中的 jdhuff.c 用了查表法加速解码时先读一个字节查lookahead表拿到符号和码长不行再逐位读。实际解码一个系数时流程是从比特流里读入足够的位维护一个get_buffer和一个bits_left计数器对 DC 系数查 DC 表得到size二进制位数再读size位原始值若最高位为 0 则减去(1 size) - 1得到差值对 AC 系数查 AC 表得到(run, size)若size为 0 且run为 0表示块结束EOB。提示如果查表时读到的码字超过maxcode[16]基本可以判定是霍夫曼表出错或比特流损坏。调试时可以打印get_buffer的十六进制值和当前bits_left对照 DHT 段的原始数据检查建表是否正确。3.3 碰到坏码流时怎么定位解码器在实际工程里面对的文件一半以上不是教科书级的标准 JPEG。常见的异常有DHT 段的bits数组总和不足 256导致符号表不完整SOS 段的颜色分量与 SOF 不一致以及扫描数据中间混入填充字节。定位思路是先把jdmarker.c里的标记解析跑通确认 DHT 数据能完整读入再单独验证建表结果——取出val[0]到val[p-1]和编码端写入的huffval做一遍比对。大多数解码出来是花屏的问题根因都出在这一层而不是后面的 IDCT。4. IDCT 与色彩还原从频率域回到屏幕像素熵解码拿到的是量化后的 DCT 系数要显示图像还需要做反量化、逆 DCT 和颜色空间转换。这三步在源码里分别由 jdcoefct.c、jidctfst.c / jidctint.c 和 jdcolor.c 负责。如果说前两章是把压缩数据还原成系数这一章就是把系数还原成人眼能感知的像素。4.1 反量化量化表是压缩率的真正开关量化表的 64 个值存放在 DQT 标记中每个系数在解码时乘以对应的量化步长即可恢复。之所以说恢复而不是还原是因为量化本身不可逆高频系数在编码时已经被四舍五入过了。解码端的反量化是纯乘操作void dequantize_block(short *coeff, const UINT16 *quant_tbl, short *out, int size) { for (int i 0; i size; i) { // coeff 是熵解码得到的量化系数 // quant_tbl[i] 是量化步长 // 反量化乘以步长得到 DCT 域的近似值 int val coeff[i] * quant_tbl[i]; // 钳位到 JPEG 合法的系数范围 [-2048, 2047] if (val 2047) val 2047; if (val -2048) val -2048; out[i] (short) val; } }量化表的值越大压缩率越高恢复出来的系数越粗糙。标准量化表通常由编码器根据质量因子生成解码器不需要关心它的合理性但要注意DQT 标记里的表数据可能长度为 64 或 128前者是 8 位精度后者是 16 位精度jpegint.h 里的JQUANT_TBL结构体会统一转换成 16 位存储解码时直接按 16 位处理即可。4.2 快速 IDCT 选型jfdctfst 与 jidctfst 的对称逻辑JPEG 标准只规定 DCT 和 IDCT 的数学定义没有规定实现方式。源码里同时给出了浮点版和定点整数版jidctint.c是逐行的整数 AAN 算法jidctfst.c是快速整数算法jidctflt.c是浮点版本。三者在输出质量上几乎没有肉眼可见的差别差别在性能和代码复杂度上。推荐的做法是先用浮点版验证整条解码链路的正确性再换成整数版提速。浮点 IDCT 的核心代码如下#define PI 3.14159265358979323846 void idct_float(const float *in, float *out) { float tmp[64]; // 先对 8 行分别做一维 IDCT for (int row 0; row 8; row) { const float *ip in row * 8; float *op tmp row * 8; for (int col 0; col 8; col) { float sum 0.0f; for (int k 0; k 8; k) { // C(u) 归一化因子u0 时为 1/sqrt(2)其余为 1 float cu (k 0) ? 0.7071067811865476f : 1.0f; // cos 项cos((2*row1)*k*PI/16) float basis cu * cosf((2 * row 1) * k * PI / 16.0f); sum cu * ip[k] * basis; } op[col] 0.5f * sum; } } // 再对 8 列做一维 IDCTout 即为空间域像素 for (int col 0; col 8; col) { for (int row 0; row 8; row) { float sum 0.0f; for (int k 0; k 8; k) { float cv (k 0) ? 0.7071067811865476f : 1.0f; float basis cv * cosf((2 * row 1) * k * PI / 16.0f); sum cv * tmp[k * 8 col] * basis; } out[row * 8 col] 0.5f * sum; } } }公式里0.5f是 JPEG 标准里 DCT 正变换和逆变换共享的缩放因子少乘这一下整张图会亮一倍。实际调试时如果发现输出的图像明显偏亮或偏暗优先检查这处缩放比检查查表逻辑更省时间。整数版 jidctfst.c 通过拆分乘加运算避免浮点误差速度能快 3 到 5 倍适合嵌入式场景移植时要注意中间变量的位宽源码里用INT32累加是底线降到short会导致数据溢出后出现条纹状伪影。4.3 YCbCr 到 RGB 的转换与色度抽样JPEG 里存的是 YCbCr显示时要用到下面的转换公式int clamp8(int x) { // 输出钳位到 [0, 255] return (x 0) ? 0 : (x 255) ? 255 : x; } void ycbcr_to_rgb(int y, int cb, int cr, int *r, int *g, int *b) { // 从 YCbCr 转到 RGB系数对应 CCIR 601 标准 *r clamp8((y 16) 91881 * (cr - 128) 16); *g clamp8((y 16) - 22554 * (cb - 128) - 46802 * (cr - 128) 16); *b clamp8((y 16) 116130 * (cb - 128) 16); }公式里的 91881、22554、46802、116130 是浮点系数分别约等于 1.402、0.344、0.714、1.772左移 16 位后的整数表示用移位代替浮点乘法以适配无 FPU 的嵌入式平台。需要注意YCbCr 的Cb和Cr是带符号的范围在 [-128, 127]所以在计算前要先减 128。还有一个容易忽略的点是色度抽样。常见的 4:2:0 格式里Cb、Cr 分量的宽高是 Y 分量的一半解码器拿到的是先按分量各自解码、再合并输出的结构。源码中的 jdcoefct.c 负责把三个分量的 MCU 数据按采样因子交错处理保证最终输出的图像尺寸正确。如果你直接把三个分量按原始尺寸解码然后叠加会得到一个色彩错位的图。5. 把解码器塞进真实工程djpeg 的结构与 C# 互操作思路源码包里的 djpeg.c 是一个完整的命令行解码器它演示了如何把前面几个模块组装起来读取文件头、初始化解码状态、循环读取扫描数据、逐 MCU 解码并输出。对实际工程来说你可以抛开控制台逻辑只保留解码核心把它封装成一个纯函数让外部传入文件路径或内存缓冲区。常见做法是定义一个解码上下文结构体把量化表、霍夫曼表、帧参数全部挂在上下文上每次解码一帧时重置状态。C# 侧要接入的话不推荐把整套源码用 C# 重写而是通过 P/Invoke 调用 C 编译出的原生库[DllImport(jpegdecoder.dll, CallingConvention CallingConvention.Cdecl)] private static extern IntPtr jpeg_decode_buffer( byte[] src, int srcLen, out int width, out int height, out int channels); [DllImport(jpegdecoder.dll, CallingConvention CallingConvention.Cdecl)] private static extern void jpeg_free_buffer(IntPtr buf); public static byte[] DecodeJpeg(byte[] jpegData, out int w, out int h) { IntPtr buf jpeg_decode_buffer(jpegData, jpegData.Length, out w, out h, out int ch); byte[] result new byte[w * h * ch]; Marshal.Copy(buf, result, 0, result.Length); jpeg_free_buffer(buf); return result; }互操作时要特别注意内存所有权C 侧分配的解码输出缓冲区一定要由 C 侧释放否则每次解码都会泄漏一块非托管内存。字节序问题同样重要——JPEG 里所有多字节字段都是大端序而 x86 和 ARM 平台默认是小端序C 侧用WORD类型读取时要手动做字节交换或者直接用(datas[0] 8) | datas[1]的方式读取。性能调优方面解码器最耗时的三个位置是熵解码、IDCT 和色彩空间转换。IDCT 可以用多线程按行或按块并行色彩转换适合用 SIMD 指令优化。一个有效的验证手段是用源码包里自带的 djpeg 程序先把kitty.jpg解成 BMP 或 PPM再用自己的解码器输出同样的格式逐像素比较误差超过 1 表示 IDCT 或反量化阶段存在精度问题。源码包里的kitty.jpg正好可以作为测试基准跑通它再换真实场景的图片能过滤掉大多数低级错误。本文还有配套的精品资源点击获取