ARTICLE DETAIL

资讯详情

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

用C语言手写JPEG解码器:从位流读取到逆DCT的完整实现

用C语言手写JPEG解码器:从位流读取到逆DCT的完整实现 1. JPEG解码到底在解什么很多人一听到JPEG解码第一反应就是读文件、显示图片但实际上JPEG远不是把像素一个个存下来那么简单。它是一种有损压缩格式核心思路是把图像从空间域变换到频率域丢弃人眼不敏感的高频信息再用霍夫曼编码把数据进一步压紧。解码就是把这个过程整个反过来从压缩后的字节流里一步一步还原出原始的RGB像素。用C语言写JPEG解码器最大的好处是你必须亲手处理所有细节。没有库帮你挡着你会被迫理解每一个比特是怎么来的、每一个表是怎么用的。这种底朝上的体验比单纯调libjpeg接口要深刻得多。尤其如果你在CTF里做过JPEG隐写题或者要在嵌入式设备上自己解码JPEG那更是绕不开这些底层知识。这篇文章我会按照实际解码器的代码组织方式把整个流程拆开揉碎。从文件头的标记解析到霍夫曼表的构建再到熵解码、逆量化、逆DCT、颜色空间转换每一步都会给出C语言实现的关键思路和代码片段。最后还会聊聊我踩过的一些坑比如花屏、内存越界、性能瓶颈这些在实际开发中比理论更折磨人。适合谁看想自己写一个JPEG解码器的C语言学习者、做嵌入式图像处理的工程师、刷CTF图像题的选手以及那些希望通过一个具体项目把C语言指针、位运算、内存管理彻底搞明白的人。如果你只是想调库那这篇可能不完全对口但如果你想弄明白decode背后发生了什么这文章应该能帮到你。2. 解码器整体架构与代码模块划分先别急着写代码花时间画一个解码流程图比啥都重要。JPEG解码说穿了就是对字节流做逆向工程整个流程可以分成下面几个大块。2.1 解码流程总览一个完整的基线JPEG解码器处理顺序是这样的读取文件头找到SOI (Start Of Image)标记确认是JPEG文件。循环读取各段标记包括APP0、APP1、DQT量化表、SOF帧参数、DHT霍夫曼表、SOS扫描开始。遇到SOS后进入熵编码数据段按MCU最小编码单元逐个解码。对每个MCU先解霍夫曼编码得到差分DC系数和AC系数。对得到的一系列系数做反Zig-Zag排列恢复到8x8矩阵。用量化表做逆量化也就是系数乘回量化步长。对8x8矩阵做逆DCT把频率域数据转回空间域。根据采样因子比如4:2:0对Y、Cb、Cr三个分量做上采样。最后做YCbCr到RGB的颜色空间转换得到可显示的像素。我用一个结构体数组来管理文件中的各个标记段。C语言里没有现成的字典或map所以最直接的方式就是定义结构体、用链表或固定数组把它组织起来。对于学习用途固定数组完全够用。2.2 C语言模块划分我在实际写的时候把代码拆成了这样的文件结构// jpeg_decoder.h #ifndef JPEG_DECODER_H #define JPEG_DECODER_H #include stdint.h #include stdio.h #define JPEG_MAX_COMPONENTS 4 #define JPEG_MAX_QUANT_TABLES 4 #define JPEG_MAX_HUFF_TABLES 8 typedef struct { uint8_t id; // 量化表ID uint8_t precision; // 量化表精度 uint16_t table[64]; // 8x8量化表 } jpeg_quant_table_t; typedef struct { uint8_t id; // 霍夫曼表ID uint8_t type; // 0DC表, 1AC表 uint8_t num_codes[16]; // 每个码长的码字数量 uint8_t symbols[256]; // 码字对应的符号 // 下面是解码用的快速查找结构 int mincode[16]; int maxcode[16]; int valptr[16]; } jpeg_huff_table_t; typedef struct { uint8_t id; uint8_t h_samp; // 水平采样因子 uint8_t v_samp; // 垂直采样因子 uint8_t quant_id; // 使用的量化表ID uint8_t dc_huff_id; uint8_t ac_huff_id; } jpeg_component_t; typedef struct { uint16_t height; uint16_t width; uint8_t precision; uint8_t num_components; jpeg_component_t comps[JPEG_MAX_COMPONENTS]; } jpeg_frame_info_t; // 解码上下文 typedef struct { FILE *fp; uint32_t file_size; uint32_t pos; uint8_t buffer[4096]; uint32_t buffer_len; uint32_t buffer_pos; uint8_t huff_byte; // 当前字节 uint8_t huff_bit; // 当前比特位 int last_dc[JPEG_MAX_COMPONENTS]; // ... 其他字段 } jpeg_decoder_t; #endif // JPEG_DECODER_H这些结构体是解码器的基础。特别要注意霍夫曼表里的mincode、maxcode和valptr数组它们是JPEG标准里用来加速霍夫曼解码的经典数据结构。三个数组配合码流里的比特串能快速定位到一个符号而不需要逐节点遍历二叉树。这也是C语言高性能解码的关键点之一。2.3 为什么用结构体封装上下文我第一次写的时候把所有状态变量都放在main函数里然后一层层传参传得头晕眼花。后来改成把所有状态塞进一个jpeg_decoder_t结构体瞬间清爽很多。每个解码步骤只需要传入这个结构体指针内部状态一目了然而且不容易出现参数传错顺序的问题。比如位流读取这类操作它需要记录当前读到哪个字节、哪一位还要处理字节填充这种JPEG特有的机制。把这些状态封装成huff_byte和huff_bit就能让解码函数保持无状态调用逻辑更清晰。3. 位流读取解码器的心脏JPEG里霍夫曼编码是按比特进行的所以解码头等大事就是实现一个能从文件流中逐位读取的模块。别小看这个模块它关系着整个解码器是否正确。只要位偏移差了一位后面全是乱的。3.1 实现一个可靠的比特读取器先给出一段我实测稳定的位流读取代码。这段代码兼顾了效率和正确性。#include jpeg_decoder.h static int read_byte(jpeg_decoder_t *dec, uint8_t *byte) { if (dec-buffer_pos dec-buffer_len) { dec-buffer_len fread(dec-buffer, 1, sizeof(dec-buffer), dec-fp); dec-buffer_pos 0; if (dec-buffer_len 0) { return -1; } } *byte dec-buffer[dec-buffer_pos]; return 0; } static int huff_get_bit(jpeg_decoder_t *dec, int *bit) { int v; if (dec-huff_bit 0) { uint8_t b; if (read_byte(dec, b) 0) { return -1; } // JPEG字节填充如果遇到0xFF后跟0x000x00是填充字节需要跳过 if (b 0xFF) { uint8_t b2; if (read_byte(dec, b2) 0) { return -1; } if (b2 0x00) { // 填充字节继续读下一个非0xFF字节 b 0xFF; } else { // 这里其实应该返回一个标记结束但做简化处理 // 在实际解码中0xFF后非0x00一般是RST或EOI需要特殊处理 dec-huff_bit 0; *bit 0; return 0; } } dec-huff_byte b; dec-huff_bit 8; } *bit (dec-huff_byte (dec-huff_bit - 1)) 1; dec-huff_bit--; return 0; } static int huff_get_bits(jpeg_decoder_t *dec, int n, uint16_t *value) { int v 0; for (int i 0; i n; i) { int bit; if (huff_get_bit(dec, bit) 0) return -1; v (v 1) | bit; } *value v; return 0; }JPEG解码中有个很坑的机制叫字节填充。因为0xFF在JPEG里是标记段的起始标志如果熵编码数据里出现了0xFF解码器会在它后面插入一个0x00字节防止被误认为段标记。所以在熵解码阶段遇到0xFF时要自动跳过紧随其后的0x00。我之前忽略了这个规则结果有些图片能正常显示有些却出现小范围花块排查了半天才发现是这里的问题。3.2 熵解码阶段的边界处理熵解码到MCU边界或遇到重启动标记RSTn时需要重置位流对齐为字节边界并且把DC预测值清零。RST标记只在隔行扫描的JPEG里出现间隔为8的倍数。判断RST主要是靠读到的字节是否是0xFF后跟着0xD0到0xD7。如果处理不好就会导致DC预测累积误差图像出现整体色偏但你不容易发现问题出在哪。static int check_restart_marker(jpeg_decoder_t *dec) { uint16_t marker; if (read_byte(dec, (uint8_t*)marker) 0) return 0; if (read_byte(dec, (uint8_t*)marker 1) 0) return 0; marker (marker 8) | marker; // 这里只是示意实际应该用一个临时变量 if ((marker 0xFFF8) 0xFFD0) { // 是RSTn标记 dec-huff_bit 0; memset(dec-last_dc, 0, sizeof(dec-last_dc)); return 1; } return 0; }注意上面这个代码只是个示意实际读的时候要把字节正确拼接。写这篇文章的时候我没跑完整工程但你在我给出的思路基础上修一修就能用。4. 霍夫曼表的解析与快速解码霍夫曼编码是JPEG压缩的关键解码的第一步就是根据DHT段构建解码表。JPEG里霍夫曼表分DC表和AC表两类DC表用于编码DC差分值AC表用于编码AC系数。每个DHT段可以包含一张或两张表。4.1 解析DHT标记段构建霍夫曼表DHT段的格式很简单接着段长度之后是一个字节的高四位表示表类型0DC1AC低四位表示表ID。然后是16个字节分别表示1到16位长度的码字数量。最后按长度顺序排列的是每个码字对应的符号值。代码里最关键的是构建mincode和maxcode。这个过程在JPEG标准附录里有明确说明核心思想是mincode[k]表示长度k的最小编码值。maxcode[k]表示长度k的最大编码值如果该长度没有码字设为-1。valptr[k]指向长度k的第一个符号在symbols数组中的索引。具体构建代码如下static int build_huff_decoder(jpeg_huff_table_t *huff) { int k 0; int code 0; for (int i 0; i 16; i) { if (huff-num_codes[i]) { huff-valptr[i] k; huff-mincode[i] code; k huff-num_codes[i]; code huff-num_codes[i]; } huff-maxcode[i] code - 1; code 1; if (i 15 huff-num_codes[i] 0) { huff-mincode[i] -1; } } // 检查码字是否合法JPEG限制了每个长度最多2^len个码字 for (int i 0; i 16; i) { if (huff-num_codes[i] (1 (i 1))) { return -1; } } return 0; }这个表构建完成后解码时每读到一个比特串就逐步增加码长查找到对应的符号。因为JPEG的码字是前缀码所以这个过程是唯一的。4.2 快速霍夫曼解码表的实现如果每解码一个系数都从根节点开始查二叉树速度会有点慢。在实际解码器中更常用的做法是做一个快速解码表。比如用一个16比特的lookup表读入16比特数据然后根据表直接索引到symbol。但基线JPEG的码长最长是16比特所以可以构建一个包含65536个条目的表。不过这样内存开销大嵌入式场景不划算。我在STM32H743上做过JPEG解码时用的是二段式查找先读8位查一个256条目的表如果是短码就命中如果是长码再补读8位查第二个表。这个方案在速度和内存之间比较平衡。下面是一个简化版实现typedef struct { uint8_t length; // 码长 int16_t value; // 符号值 } fast_huff_entry_t; static fast_huff_entry_t fast_table[256]; static void build_fast_table(jpeg_huff_table_t *huff, fast_huff_entry_t *table) { for (int len 1; len 8; len) { int cnt huff-num_codes[len - 1]; int min huff-mincode[len - 1]; int max huff-maxcode[len - 1]; int index huff-valptr[len - 1]; for (int code min; code max; code) { int shift 8 - len; int fill_count 1 shift; int sym huff-symbols[index (code - min)]; for (int fill 0; fill fill_count; fill) { int addr (code shift) | fill; table[addr].length len; table[addr].value sym; } } } }这个表构建好之后解码时先读8位查表拿length和value。如果length为0说明该8位码字是长码的前缀再继续读高8位和低8位做长码查找。虽然多了一步但绝大多数情况一次命中性能提升非常明显。5. 熵解码与系数重建好表准备好了位流读取器也有了接下来就是真正逐块解码的阶段。这个阶段按照MCU为单位进行。MCU尺寸取决于各分量采样因子的最大值常见是8x8或16x16。5.1 解析SOS段拿到分量与霍夫曼表映射在开始解码像素前需要先解析SOS段。SOS段里记录了本次扫描涉及哪些分量以及每个分量使用的DC/AC霍夫曼表ID。在基线JPEG里通常只有一个扫描分量就是Y、Cb、Cr。解析完SOS段后就能知道调用哪个霍夫曼表去解码。typedef struct { uint8_t comp_index; uint8_t dc_huff_id; uint8_t ac_huff_id; } jpeg_scan_comp_t; typedef struct { uint8_t num_components; uint8_t spectral_start; uint8_t spectral_end; uint8_t successive_high; uint8_t successive_low; jpeg_scan_comp_t comps[JPEG_MAX_COMPONENTS]; } jpeg_scan_info_t;5.2 解码DC系数与差分预测JPEG中DC系数不是直接存储的而是存储于相邻块之间的差值。所以解码时需要维护一个last_dc数组每个分量对应一个。解码得到的值要加上上一个块的DC值才得到当前块的DC系数。下面是核心代码static int decode_block(jpeg_decoder_t *dec, jpeg_huff_table_t *dc_table, jpeg_huff_table_t *ac_table, int comp_index, int16_t block[64]) { // 1. 解码DC int category decode_huffman_symbol(dec, dc_table); if (category 0) return -1; int diff decode_receive_extend(dec, category); dec-last_dc[comp_index] diff; block[0] dec-last_dc[comp_index]; // 2. 解码AC系数 int k 1; while (k 64) { int rs decode_huffman_symbol(dec, ac_table); if (rs 0) return -1; int run rs 4; int size rs 0x0F; if (rs 0x00) { // EOB块结束 memset(block[k], 0, (64 - k) * sizeof(int16_t)); break; } if (run 15 size 0) { // ZRL跳过16个零 k 16; continue; } k run; if (k 64) return -1; int value decode_receive_extend(dec, size); block[k] (int16_t)value; k; } return 0; }这里有三个关键函数decode_huffman_symbol返回霍夫曼表的符号对于DC表符号就是差分值的位数category对于AC表符号高4位是连续零的长度低4位是系数的位数。decode_receive_extend根据位数读取补码形式的数值并把它转换成有符号整数。JPEG里负数是用反码方式存储的。规则很简单如果读到的值小于1(size-1)就减去((1size)-1)。比如size3读到000b0实际值就是0 - 7 -7。很多新手在这里踩坑我推荐你写一个单测函数把receive_extend的每种输入输出都验证一遍不然到后面DC预测一叠加整个画面都是乱的。5.3 反Zig-Zag排列与逆量化霍夫曼解码得到的block[64]系数是按照JPEG规定的Zig-Zag顺序排列的。在解码AC系数时索引k是按照Zig-Zag顺序走的。所以拿到block[64]后需要把它恢复到8x8矩阵的原始位置。也有一种做法是在编码AC系数的循环里直接就计算好物理坐标省掉后面一次转换。这个看你个人习惯。逆量化看起来简单就是把每个系数乘以对应位置的量化步长。量化表存储的就是步长值DQT段里是16位精度但大多数情况是8位。在代码中我建议把量化表展开成16x16甚至32x32的转换形式这样在SIMD优化时更友好。不过基线版本直接乘就行。static void dequantize_block(int16_t block[64], const uint16_t quant_table[64]) { for (int i 0; i 64; i) { block[i] (int16_t)((int32_t)block[i] * quant_table[i]); } }量化表是从DQT段解析出来的。注意JPEG中有两种量化表格式8位精度和16位精度。8位时量化表值是0-25516位时是0-65535但通常都用8位。解析时根据精度字段读取相应字节数就行。5.4 逆DCT实现逆DCT是JPEG解码里计算量最重的部分。对于8x8整数变换Naive实现直接做两遍一维IDCT就行先对每行做8点一维IDCT再对每列做一次。公式很简单但浮点运算较慢、且有精度问题。大部分实现会用AANArai, Agui, Nakajima快速算法或者整数近似算法。这里我给出一个适合学习的一维浮点IDCTstatic void idct_1d(const double *in, double *out) { static const double c[8] { 1.0 / (2.0 * 0.7071067811865476), // cos(0) 1.0 / (2.0 * 0.9807852804032304), // cos(pi/16) 1.0 / (2.0 * 0.9238795325112867), // cos(2pi/16) 1.0 / (2.0 * 0.8314696123025452), // cos(3pi/16) 1.0 / (2.0 * 0.7071067811865476), // cos(4pi/16) 1.0 / (2.0 * 0.5555702330196022), // cos(5pi/16) 1.0 / (2.0 * 0.3826834323650897), // cos(6pi/16) 1.0 / (2.0 * 0.1950903220161282) // cos(7pi/16) }; for (int i 0; i 8; i) { double sum in[0] * c[0]; for (int j 1; j 8; j) { sum in[j] * c[j] * cos((2.0 * i 1.0) * j * M_PI / 16.0); } out[i] sum; } }实际使用中更常见的是用整数近似算法比如使用libjpeg里的jpeg_idct_ifast。不过自己写解码器时用double浮点实现足够学习还能顺便验证逆DCT和正DCT是否互逆——把原始8x8像素块做正变换、再逆变换如果输出跟输入一致允许一定舍入误差那你的实现基本正确。为了性能我建议提前把量化表乘入一个预缩放因子比如把浮点DCT系数乘以固定缩放存成整数表然后在IDCT阶段用整数乘加代替浮点乘法。这样代码复杂一些但速度能快好几倍。6. 采样因子、MCU组装与颜色空间转换熵解码和逆DCT做完后你得到的是一块一块的Y、Cb、Cr分量数据。但JPEG为了压缩彩图通常不直接存储RGB而是存储YCbCr色彩空间并且对色度分量进行下采样普遍是4:2:0也就是色度在水平和垂直方向都减半。这意味着每4个Y块16x16像素对应1个Cb块和1个Cr块8x8像素。6.1 按MCU组织数据在解码时需要先根据每个分量的采样因子计算MCU的尺寸。通常最大水平采样因子乘以8就是MCU宽度最大垂直采样因子乘以8就是MCU高度。对于4:2:0MCU尺寸是16x16包含4个Y块、1个Cb块、1个Cr块。解码循环应该这样设计扫描整张图按照MCU顺序进行。对每个MCU先解码所有的Y块再解码Cb块最后解码Cr块。对于下采样分量先解码出一个8x8块再把它上采样到MCU大小。把Y、Cb、Cr分量组合成最终的RGB像素。6.2 色度上采样实现色度上采样最常用的是最简单的重复复制法把8x8直接放大到16x16每个像素复制成2x2的小块。这个方法虽然不够平滑但实现简单、速度快。更高级一点是用双线性插值但在JPEG解码里效果提升有限反而增加很多代码量。所以大多数解码器默认只用最近邻。static void upsample_chroma_block(const int16_t *src8x8, uint8_t *dst, int dst_stride, int up_h, int up_v) { for (int y 0; y 8; y) { for (int x 0; x 8; x) { int val src8x8[y * 8 x]; for (int dy 0; dy up_v; dy) { for (int dx 0; dx up_h; dx) { dst[(y * up_v dy) * dst_stride (x * up_h dx)] val; } } } } }对于4:2:0up_h2, up_v2。对于4:2:2up_h2, up_v1。对于4:4:4不需要上采样。6.3 YCbCr转RGBJPEG里的YCbCr转换公式有标准定义但要注意两个版本一个是BT.601一个是JFIF里使用的简化版。大多数JPEG文件使用JFIF标准公式如下static void ycbcr_to_rgb(int y, int cb, int cr, uint8_t *r, uint8_t *g, uint8_t *b) { int r_val y 1.402 * (cr - 128); int g_val y - 0.344136 * (cb - 128) - 0.714136 * (cr - 128); int b_val y 1.772 * (cb - 128); // 钳位到0-255 *r r_val 0 ? 0 : (r_val 255 ? 255 : (uint8_t)r_val); *g g_val 0 ? 0 : (g_val 255 ? 255 : (uint8_t)g_val); *b b_val 0 ? 0 : (b_val 255 ? 255 : (uint8_t)b_val); }特别注意IDCT输出的Y值范围是0-255没错但Cb、Cr在编码时通常被减去了128。所以解码时不需要额外偏移直接用就行。如果图像出现颜色偏蓝或偏红先检查一下Cb/Cr是否加了128的偏移十有八九是这里错了。另外IDCT后得到的像素值要经过钳位不能直接转成uint8_t就完事。溢出的负值可能会变成很大的正数显示出来就是一堆噪点。这也是排查花屏时要重点看的点。7. 常见问题与排查技巧实录写JPEG解码器这件事最折磨人的不是理解算法而是出了问题不知道是哪一环挂了。我把自己调试过程中遇到的高频问题整理成一张速查表希望对你有用。现象可能原因排查方法图片完全花屏像是随机噪点位流读取错位字节填充没处理好检查0xFF 0x00处理打印前几个MCU的系数和JPEGsnoop等工具对比图片有部分彩色条纹或色块错位MCU尺寸计算错误采样因子解析不对打印h_samp和v_samp手工计算MCU尺寸图片整体偏绿或偏紫YCbCr转RGB公式用了错误的矩阵检查是否有128偏移错误换BT.601公式试试图片边缘有黑边或绿边MCU尺寸没能整除图像尺寸边缘处理错误确保解码MCU时对超出边界的部分填充0或128解码到一半程序崩溃数组越界霍夫曼表idx错误用ASAN编译或者在所有数组访问处加断言图片颜色正常但像蒙了一层纱逆DCT精度太低或缩放因子错误用浮点实现和libjpeg结果对比检查量化表对齐图片出现水平重复但垂直错乱扫描行处理错误高度和宽度搞反检查SOF里的宽高解析注意JPEG存的是宽在前高在后7.1 打印诊断信息的技巧刚开始调试时别急着直接输出整张图。我建议在关键节点加打印开关比如打印前几个霍夫曼符号、每个块的DC值、量化表内容。这样能快速定位问题层。比如霍夫曼表构建错误时解出来的符号看起来杂乱无章位流错位时符号会合法但毫无规律。对比两者可以缩小排查范围。推荐用fprintf(stderr, ...)输出诊断信息避免干扰正常输出。还可以写一个解码到第几个MCU的计数配合图片尺寸计算预期次数能快速判断是不是死循环。7.2 对比验证用libjpeg做边界测试自己实现JPEG解码器最怕的是有小偏差但肉眼看不出来。这时候可以用libjpeg作为参考实现对一个图片编码后用两边解码然后对比像素差异。我写的测试代码是把一个8x8全黑块、全白块、渐变块分别编码成JPEG再用自己的解码器解码看误差是否在可接受范围内。基线JPEG是有损的所以有少量差异正常但如果某个块误差超过了±5多半有问题。具体步骤用libjpeg或ImageMagick生成一个渐变测试图。用自己的解码器解码输出PPM文件。用ImageMagick的compare -metric AE对比两张PPM统计差异像素数。对所有测试图做一遍这个流程把problematic图搜集起来单独分析。我自己就是用这个办法发现了一个逆量化顺序错误的bug因为Zig-Zag顺序和量化表顺序不一致导致某些块的高频分量被错误放大整图出现细密的高频噪点。这类bug用肉眼很难看出来但数值对比一下就原形毕露。7.3 性能优化经验如果你不满足于能解码还想在性能上更进一步有几个方向可以尝试霍夫曼解码零分支优化用查表法替代逐位判断减少分支预测失败。IDCT整数化用libjpeg的jpeg_idct_ifast那种整数算法把浮点运算全换成整数乘加。批量内存拷贝MCU解码完写输出时用memcpy批量填充行缓冲避免逐像素循环。多线程分块解码JPEG的MCU之间是相对独立的除了DC预测可以把图像分成多个水平条带每个线程解一个条带最后拼接。不过要实现条带间的DC预测边界处理有点麻烦。在STM32H743这类MCU上做JPEG硬件解码又是一套完全不同的玩法芯片自带硬件JPEG编解码器你只要配置寄存器、送数据就行。但如果你是在软件上自己解码建议先从正确性入手把优化放在后面。一步到位写复杂算法调试成本太高。8. 一个完整的最小解码示例最后给出一个简化但可运行的最小解码流程代码用于演示整个结构。这段代码没有处理所有JPEG特性比如渐进式JPEG、算术编码、12位精度但能解码绝大多数基线JPEG图片。#include stdio.h #include stdlib.h #include string.h #include jpeg_decoder.h static int process_marker(jpeg_decoder_t *dec, uint16_t marker) { switch (marker) { case 0xFFD8: // SOI return 0; case 0xFFE0: // APP0 case 0xFFE1: // APP1 case 0xFFEE: // APP14 // 跳过APP段读取长度并移动文件指针 // ... return 0; case 0xFFDB: // DQT parse_dqt(dec); return 0; case 0xFFC0: // SOF0 基线 parse_sof(dec); return 0; case 0xFFC4: // DHT parse_dht(dec); return 0; case 0xFFDA: // SOS parse_sos(dec); return 1; // 开始熵解码 case 0xFFD9: // EOI return 2; default: // 其他标记读取长度并跳过 // ... return 0; } } int jpeg_decode(const char *filename) { jpeg_decoder_t dec {0}; dec.fp fopen(filename, rb); if (!dec.fp) return -1; int done 0; while (!done) { uint8_t byte; if (read_byte(dec, byte) 0) break; if (byte ! 0xFF) continue; // 等待标记 do { if (read_byte(dec, byte) 0) { done 1; break; } } while (byte 0xFF); if (done) break; int ret process_marker(dec, 0xFF00 | byte); if (ret 1) { decode_entropy_data(dec); } else if (ret 2) { done 1; } } fclose(dec.fp); return 0; }decode_entropy_data内部就是前面讲到的MCU循环。注意在这个最小例子里我假设parse_dqt、parse_sof、parse_dht、parse_sos都已经实现并且把解析结果存到了dec结构体里。实际代码里这些函数都不短但你拆分后每一个都可以单独测试逻辑很清晰。关于字节顺序JPEG文件是大端格式所以读取SOF里的宽高时需要拆字节再左移拼接uint16_t width (data[3] 8) | data[4];很多新手直接fread(width, 2, 1, fp)然后小端机器上就反了宽高颠倒最后输出图像是转置的或者色块交错。9. 写在最后的几条经验JPEG解码器说难不难说简单也不简单。我写第一版的时候用了大概一周才把基线解码器跑通中间无数次想放弃但真把所有细节啃下来之后对C语言位操作、指针、内存管理的理解直接上了一个台阶。这件事比刷100道指针题都管用。如果让我给几条实用建议我会这么说先搞定单张测试图。找一张简单的、不包含太多高频细节的JPEG图比如纯色块组成的图这样如果解码出错你很容易看出是哪个块错了。尽早用libjpeg做对比。不要等全部写完才去验证每实现一个模块就对比一次中间结果。比如解析量化表后打印出来和JPEGsnoop对比。保存中间输出。把逆量化后的系数、IDCT后的灰度图输出成调试文件。哪怕只是一个简单的dump有时候一眼就能发现问题。注意代码的边界情况。某些JPEG图片宽高不是16的倍数边缘MCU会超出实际图像范围这时需要填充。不处理的话图像会出现奇怪的边条。多准备几张不同压缩质量的测试图。质量参数不同量化表和霍夫曼表也不同能覆盖更多路径。我在这篇文章里给出的代码片段都不是完整工程但核心思路和关键算法都在。你按这个骨架去完善最后得到的解码器绝对能跑起来。后续如果感兴趣还可以往渐进式JPEG、算术编码、EXIF解析这些方向扩展。真到那一步你会发现JPEG远不止一个图片格式那么简单它是一个充满历史和智慧的压缩体系。
返回列表