FPGA JPEG编码器Verilog实现:从DCT到Huffman的硬件设计指南
发布时间:2026/10/13 20:38:25 锦皓数字建站

简介基于Verilog的FPGA JPEG编码器源代码完整实现了JPEG压缩流程适合作FPGA开发、嵌入式系统及硬件图像处理方向的学习与工程参考。源码覆盖颜色空间转换、8x8分块、DCT变换、量化、zigzag扫描与哈夫曼熵编码等关键环节展示了如何以Verilog模块化构建高性能并行流水线各子模块提供独立接口和清晰注释便于移植复用可有效缩短开发周期。压缩包共52个文件以38个.v源文件为主辅以仿真工程、文本说明及chm帮助文档整体仅166KB目录结构清晰便于按模块研读。据统计已有591人学习这份代码可当作理解硬件JPEG算法和FPGA设计流程的入门样例。通过阅读源码与自带仿真测试平台能直观理解从图像输入到压缩码流输出的完整数据通路掌握基于FPGA实现实时图像压缩的硬件设计方法对提升算法落地能力很有帮助。1. 为什么我在拿到 FPGA JPEG Verilog Source code 源代码后还是折腾了两周如果你搜过“FPGA JPEG Verilog Source code 源代码”这个组合大概率不是想要一份理论手册而是想让一块 FPGA 把摄像头送进来的画面压缩成 JPEG再通过网口或 PCIe 丢给上位机。我当年第一次拿到这套源码时也以为解压就能用结果光是搞懂 dct、quant 和 huffman 三个模块怎么对接就花掉了两个完整的周末。这个标题背后其实是一整条视频压缩链路RGB 转 YCbCr、8x8 分块、二维 DCT、量化、Zigzag、游程编码、Huffman 查表、最后再拼成标准 JPEG 文件头。适合谁适合要自己做图像采集卡、高速传输、或嵌入式存储的开发者不适合只想看 CPU 上跑 OpenCV 那种软编码的人。这篇就按我做过的方案把这条链路拆开讲清楚。2. JPEG 编码链路拆解从像素到 bitstream 的模块边界与数据流2.1 五个边界清晰的子模块JPEG 基线编码器在 FPGA 上一般切成五块颜色空间转换、二维 DCT、量化与 Zigzag、熵编码、bitstream 打包。每个模块之间用 valid/ready 握手或者用同步 FIFO 接。我自己习惯把颜色转换放在最前面因为它消耗的乘法器不多但能大大减轻后面 DCT 的动态范围压力如果输入源已经是 YCbCr这一整块可以直接旁路。模块划分时最容易犯的错是把量化放在 DCT 前面。DCT 后的系数分布在 -1024 到 1023 之间量化表里每个元素只有 8 bit能直接映射成查表但 DCT 前的像素是 8 bit 无符号数量化毫无意义。所以数据流一定是pixel - DCT - 量化 - Zigzag - 熵编码这个顺序不能乱。硬件上每个模块吃一个 8 像素的块输出一个编码后的变长比特段。模块接口上我一般统一用 AXI-Stream 风格tvalid、tready、tlast外加一个 sideband 的像素坐标或者块序号。位宽不用太宽像素输入 8bit 够用中间 DCT 输出 12bit 有符号量化后回到 8bit 左右。tlast 的作用是标记一帧结束这个信号非常关键后面避坑章会专门讲。2.2 帧、段、块三级同步关系JPEG 编码不是逐像素独立完成的而是以 8x8 块为原子单位。一帧图像要先按 8 对齐切块如果宽高不是 8 的倍数边缘要用像素复制或者填 0 补齐。这个补边逻辑必须在进入 DCT 之前做否则输出的 JPEG 解码后会看到边缘有一条硬边。硬件里实现方式很简单行计数器到边界时把最后一个有效像素值保持住再打几个周期。同步设计我分成三层帧级frame_start/frame_end、块级block_start/block_end、像素级pixel_valid。DCT 模块只认块级信号熵编码模块要额外知道当前是 DC 还是 AC 系数所以还需要一个系数序号计数器。三层信号用很小的状态机就能管理别为这个引入复杂的总线协议。像素级 valid 在颜色转换模块处产生之后每个模块都把自己的 valid 向后传这样链路天然流水。有个容易忽略的点YCbCr 采样格式。如果你的输入是 4:2:2 甚至 4:2:0MCU最小编码单元不只一个 8x8 块而是多个块按照固定顺序排列。大部分 Verilog 源码默认处理 4:4:4 或者单分量灰度图改成 4:2:2 时Y、Cb、Cr 三路数据要分时复用同一套 DCT 和熵编码模块这时候块级计数器要做成能区分“当前块属于哪个分量”的模式。改起来不复杂但接口必须提前留好分量号这个字段。2.3 数据位宽选择8 位进、12 位中间、8 位量化出我把数据位宽的选择做成一张参数表方便你在移植时直接抄位置位宽格式说明像素输入8 bit无符号0~255如果是 10bit 摄像头先截断或缩放颜色转换后9 bit有符号YCbCr 中 Cb/Cr 可能是负数DCT 输出12 bit有符号量化前最大可以到 -1024~1023量化后9 bit有符号除以量化表步长后范围明显变小Huffman 输入6/8 bit无符号查表地址用DCT 输出选 12 位是有讲究的。8x8 DCT 变换的本质是矩阵乘法对 8bit 输入理论最大值约 1024加一点裕量用 12 位有符号刚好不溢出。有人为了省寄存器用 10 位结果高对比度图像上出现明显块效应这是最常见的翻车点。中间累加器我在第 3 章细说原则是“宁可多给 2 bit不要省出花屏”。3. 二维 DCT 的 FPGA 实现行列分解、转置缓冲与定点系数3.1 为什么先做行变换再做列变换二维 DCT 直接做是一个 8x8 矩阵乘 8x8 像素矩阵数学上等价于先对每一行做一维 DCT得到中间结果再对每一列做一维 DCT。这种行列分解最大的好处是硬件复用只需要实现一个 8 点一维 DCT 核行变换和列变换共用它中间加一块转置缓冲。如果直接做二维矩阵乘需要 64 个乘法器同时工作行列分解只需要 16 个左右代价是目标时钟频率能轻松跑到 200MHz 以上资源却节省一大块。我在某厂商中端 FPGA 上估过资源一个 8 点一维 DCT 核大概 800 个 LUT 加 8 个 DSP行列分解加转置缓冲总共不到 2500 LUT、16 个 DSP。如果用直接矩阵乘DSP 直接飙到 64 个布线压力立刻上来。对于 JPEG 这种对实时性要求高但不追求单块极致速度的场景行列分解是绝对主流。实现上行的处理流程是先把一行的 8 个像素拼接成 64bit 向量送入一维 DCT 核得到 8 个频域系数写入转置缓冲的一行。等 8 行全部写完转置缓冲按列读出再送进同一个一维 DCT 核输出的就是二维 DCT 结果。这里最容易被忽视的是时序行变换和列变换不能同时占用同一个核所以转置缓冲要设计成乒乓结构一块在写、一块在读。3.2 一维 DCT 的 Verilog 实现系数 ROM 加乘累加8 点一维 DCT 公式里带 cos 项FPGA 上不能直接算浮点常规做法是把 cos 系数乘以一个缩放因子转成定点整数存成 ROM。下面是 8 点一维 DCT 核心代码的简化版完整源码里会有流水线控制这里保留了最容易理解的结构// 8点一维DCT核心Y[k] sum_{n0..7} X[n] * C[k][n] // 系数放大 2^10 倍最终结果右移 10 位 舍入 module dct_1d_8 #( parameter DATA_W 9, // 输入像素位宽带符号 parameter COEF_W 12 // 定点系数位宽 )( input wire clk, input wire rst_n, input wire start, // 单周期脉冲表示一组8个像素开始 input wire signed [DATA_W-1:0] p0, p1, p2, p3, p4, p5, p6, p7, output reg signed [15:0] y0, y1, y2, y3, y4, y5, y6, y7, output reg done ); // 系数ROM只列前四个系数实际8x8完整表按列补齐 reg signed [COEF_W-1:0] coef [0:7][0:7]; initial begin coef[0][0] 12sd512; coef[0][1] 12sd512; coef[0][2] 12sd512; coef[0][3] 12sd512; coef[1][0] 12sd502; coef[1][1] 12sd426; coef[1][2] 12sd284; coef[1][3] 12sd100; // 其余行按JPEG标准DCT-II查表补齐 end reg [2:0] idx; always (posedge clk or negedge rst_n) begin if (!rst_n) begin idx 3d0; done 1b0; end else if (start) begin idx 3d1; done 1b0; end else if (idx 3d7) begin idx 3d0; done 1b1; end else if (idx ! 3d0) begin idx idx 1b1; done 1b0; end end // Y[k] p0*C[k][0] p1*C[k][1] ... p7*C[k][7] always (posedge clk or negedge rst_n) begin if (!rst_n) begin y0 16sd0; end else if (idx 3d1) begin y0 p0 * coef[0][0] p1 * coef[0][1] p2 * coef[0][2] p3 * coef[0][3] p4 * coef[0][4] p5 * coef[0][5] p6 * coef[0][6] p7 * coef[0][7]; end // y1~y7结构相同分别使用coef[1]~coef[7] end endmodule代码逻辑上并不难start 之后我让 idx 从 1 走到 7每个周期算一个频域系数8 个周期后一组 8 个像素全部变换完成。注意这里用了 idx 等于某值才计算是因为乘法器和加法器通过时序复用避免组合逻辑在一拍内堆 8 个 12x9 乘法器导致时钟跑不上去。综合工具会把这种固定系数的乘法自动优化成移位加加法但前提是系数 ROM 用常量写死不要用变量地址去读。DSP 位宽这块乘法器输入是 9bit 像素和 12bit 系数乘积 21bit。累加 8 项最大需要 24bit 才安全。我上面的输出只给了 16bit这是做了截断实际工程建议把累加器做到 22bit 以上最后输出前再截到 12 位。截断时要做有符号舍入直接截掉低位的后果会在第 5 章讲。3.3 转置缓冲的双 bank 设计转置缓冲是行变换到列变换之间的桥梁。行变换写完 8 行后列变换才能开始读所以不能只有一块 BRAM否则写的时候没法读。标准做法是用双 bankbank A 在写第 N 个块的行数据时bank B 在读第 N-1 个块的列数据。这样两轮变换之间没有空档吞吐量直接翻倍。BRAM 的读写位宽要仔细配。8 个系数各 12bit一行 96bit。如果 BRAM 位宽只有 32bit需要拆成 3 个地址存一行转置读出来时要重组逻辑会多几十行。我一般配 96bit 位宽、8 深度这样一个块正好占用一块 BRAM。写入地址用行号读出地址用列号读写地址的映射正好交叉就是转置操作本身。这里的边界坑DCT 的直流系数 y0 数值显著大于交流系数对压缩效果影响最大。有些源码为了省资源把转置缓冲的存储位宽从 12bit 压到 10bit主观画质在平滑区域会看到横条纹。便宜的代价是每个块多花几百个 LUT 做饱和处理但换来了所有 bit 都保留我认为这笔交易相当划算。4. 熵编码硬件化DC差分、AC游程、Huffman查表与bitstream打包4.1 DC 差分分类的查表实现JPEG 熵编码分两部分DC 系数用差分编码AC 系数用游程编码最后都查 Huffman 表。DC 系数是每个 8x8 块左上角那个值它和上一个块的 DC 值做差这个差值通常很小。Huffman 表只对差值的分类号category编码差值本身的二进制补码再原样跟在后面。所以 DC 编码分成两步算差值、查分类号、查 Huffman 码、拼上差值位。硬件上差分编码需要一个寄存器保存上一块的 DC 值每来一个新块就做一次减法。分类号就是差值最高位所在位置可以建模成 case 语句// 亮度DC差值分类表取自JPEG基线标准 // diff是当前块DC减上一块DCsigned reg [3:0] dc_cat; reg [11:0] dc_huff_code; reg [3:0] dc_huff_len; always (*) begin if (diff 0) begin dc_cat 4d0; dc_huff_code 12h0; // 00 dc_huff_len 4d2; end else if (diff -1 diff 1) begin dc_cat 4d1; dc_huff_code 12h2; // 010 dc_huff_len 4d3; end // 其余category按标准表继续补全 endJPEG 标准里亮度 DC 表从 category 0 到 11码长从 2bit 到 9bit查表用 case 写最直接。注意我代码里12h0是先把码字右对齐存进 12 位寄存器后面拼 bitstream 时再左移补位避免在 case 里直接写一堆带前导零的二进制串容易数错长度。category 对应的实际补码位数等于 category 值比如 category 5 的差值要输出 5 位有效位这 5 位要跟在 Huffman 码后面。4.2 AC 游程编码与 Zigzag 地址映射AC 系数有 63 个除了少量低频值较大大部分是 0。编码方式是按 Zigzag 顺序扫描统计连续 0 的个数run遇到非零系数时输出一个 (run, size) 组合size 又是该系数分类号后面跟系数补码。如果从某个位置开始后面全是 0就输出一个特殊的块结束标记 EOB。硬件实现就是一个状态机状态里保存 run 计数器和当前扫描到的 Zigzag 序号。Zigzag 扫描顺序不能靠嵌套 for 循环8x8 的映射直接写成一个常量数组。我习惯把地址映射表拆成两个 64 深度的 ROM一个存行号一个存列号这样从自然顺序索引到 Zigzag 顺序就是一次查表。这里顺便解释一个常见误解JPEG 源码里常见的“Zigzag table”并不是熵编码表它只是重新排列 DCT 系数的顺序。真正查 Huffman 表时用的是 (run, size) 组合而不是系数本身。4.3 Huffman 码的最短码长与位序陷阱Huffman 编码最大的坑不是查表而是位序。JPEG 标准规定码流先传 MSB也就是说一个 Huffman 码字110在 bitstream 里就是1 1 0。但你在 Verilog 里拼接时如果直接从右向左填 buffer很容易填成0 1 1。解码端拿到后按标准 MSB 解释自然全错。我用的可靠做法是先把码字左对齐存放再按码长逐位移进移位寄存器。比如当前码字3b110存在 12 位寄存器里写作12b110_0000_0000码长 3。拼接时把寄存器右移 3 位再和已有内容做或。这样不管码字长短bit 顺序天然正确。也有人反过来把 bitstream 缓冲倒着用每拼一码先左移再或效果一样但必须整条链路从头到尾保持同一个约定。最终输出时要凑齐整字节。JPEG 压缩数据按字节流输出但 Huffman 码是变长的所以需要一个桶式移位器把待输出的 bit 攒到至少 8 个时吐一个字节。桶式移位器用组合逻辑写表不要用 for 循环一位位移否则时序容易挂。4.4 字节填充与文件头熵编码只是中间产物JPEG 文件不是只放压缩数据前面还要有 SOI、APP0、DQT、SOF0、DHT、SOS 等标记。解码器靠这些标记识别量化表、Huffman 表、图像尺寸和采样格式。硬件里这些头可以固化在 ROM 中需要变化的只有宽高和量化表用寄存器在启动时写入。压缩数据流本身有一个强制规则每输出 0xFF 字节后面紧跟一个 0x00这是为了防止和解码器的标记字节冲突。这个填充必须硬件自动完成不能软件事后补因为补字节会影响后面的字节对齐。实现上很简单检测到输出字节等于 8hFF 时额外插入一个时钟周期输出 8h00。头文件部分最容易踩的坑是 DHT 和 SOS 之间的顺序。JPEG 标准要求所有 DHT 标记必须出现在 SOS 之前解码器才认为表是完整的。如果你的工程把 Huffman 表分块放在压缩数据后面很多严格解码器会直接报错。所以我的固定习惯是编码器上电后先把量化表和 Huffman 表从头文件 ROM 里按序发完再开始送图像压缩数据。5. 上板前必查的5个坑从仿真全绿到图像正常显示的排查清单5.1 仿真波形好看上板满屏花块现象Modelsim 里跑出来的 JPEG 文件用电脑解码器打开完全正常一旦烧到 FPGA 上同一个输入图像变成满屏花块颜色还是乱的。原因仿真模型里像素数据是理想 8bit但上板后摄像头输入是 10bit 或者 12bit送到 DCT 前被截断了低 2 位截断时又没有加括号Verilog 直接丢弃了高符号位。DCT 对输入端的微小误差不敏感但符号位错了就是大错。解决在像素入口统一做一次有符号扩展把 10bit 先左移 2 位再截成 8bit或者把 DCT 输入位宽参数改成 10bit。两种方式都行关键是入口处用一个独立模块处理位宽不要让后面每个模块各自猜输入格式。5.2 Huffman 码全对但解码器报错“extra bytes”现象生成的 JPEG 用图像软件能打开但严格解码器提示压缩数据后面多出字节文件大小不对。原因bitstream 的最后一段如果不满一个字节剩余位用 1 填充。这个填充本身是标准允许的但如果最后一个字节恰好是 0xFF就没有做 0x00 插入解码器把 0xFF 当成了新的标记开始位置。解决在输出侧判断如果最后一个填充字节等于 0xFF主动改成 0xFF 0x00并让文件长度加 1。这个检查要放在整帧压缩数据全部结束之后而不是每 8bit 拼完立即判断否则会误伤正常数据里的 0xFF。5.3 tlast 信号保持太久解码器丢帧现象FPGA 输出的 JPEG 流接到 DMA 控制器后上位机收到的文件总是少一截但仿真时明明看到 tlast 正确拉高。原因tlast 被设计成了持续一整行或者持续到下一帧开始才拉低。AXI-Stream 规则里 tlast 只应持续一个周期表示当前传输的最后一笔数据。DMA 控制器看到 tlast 就会结束这次传输持续高电平会让它把下一帧的起始数据也吞进来或直接丢弃。解决tlast 用组合逻辑产生条件是“当前块序号 帧内块总数 - 1”并且只允许一个时钟周期随后被状态机强制拉低。我后来专门加了一句axis_tlast (last_block axis_tready)保证只有当对端 ready 时才产生防止握手期间丢标记。5.4 图像偏色暗部发绿现象编码结果能解码但整幅图暗部偏绿亮部正常灰度图测试没有问题。原因JPEG 颜色转换模块输出的 Cb、Cr 分量范围不对称量化表又直接用了亮度量化表。色度量化表步长通常比亮度大直接套用会让色度高频分量过度保留或过度丢失暗部色差信号被错误放大。解决准备两套量化表Y 用亮度表Cb/Cr 用色度表量化表地址用分量号选择。这个坑不到 30 行代码就能修但必须在一开始接口设计时就留出表选择信号。如果你只做灰度 JPEG可以跳过但彩色编码器一定不能省。5.5 异步 FIFO 深度不够突发图像丢数据现象低速时钟域的摄像头数据送入高速编码器时钟域偶尔丢一块频率不高但很随机。原因编码时钟和像素时钟不同源跨时钟域用了同步 FIFO深度只设了 16而 DCT 和熵编码在工作过程中对输入并不是均匀消费的。Huffman 编码遇到高频信息丰富的块时输出码长暴涨输入缓冲被占满FIFO 写满后直接把新像素丢掉了。解决像素入口的异步 FIFO 深度按照“最大突发量加余量”计算。我一般取能够缓存 4 行 1080p 图像的大小也就是差不多 8192 x 16bit同时用 almost_full 信号提前反压摄像头端。与其赌 FIFO 不会满不如给足余量再配一个半满水位监测反正 BRAM 在这个设计里基本用不满。6. 进阶把编码吞吐量再翻一倍的三个技巧如果上面的链路你已经跑通接下来瓶颈一般会出现在两处转置缓冲的读写等待以及 Huffman 桶式移位器的每周期移位次数。第一个技巧是把转置缓冲从单 bank 改成双 bank 乒乓让行变换和列变换并行工作。这个改动不碰 DCT 核只改读写地址切换和块号计数逻辑吞吐量却能提升接近一倍因为列变换不再等行变换完全停摆。第二个技巧是 Huffman 输出端不要用“每次右移 1bit”的移位寄存器。改成桶式移位器用优先编码器算出当前有效 bit 数一次移出多个 bit然后在一个周期内完成字节组装。比如缓冲区里攒了 16bit当前码字 5bit计算出发送后还剩 13bit下一拍直接按 13bit 对齐不需要逐 bit 循环。这个优化对时钟频率的影响很小但能显著降低长码字序列下输出端的阻塞概率。第三个技巧是在编码器输出再接一个小型缓存 FIFO配合 DMA 的 burst 传输。上位机读取 JPEG 数据时一个 burst 最好读 64 字节而不是每来 1 字节就中断一次。缓存 FIFO 深度做到 256 字节DMA 一次 burst 读完CPU 中断频率能降一个数量级。这三个技巧我已经反复用在帧率不够的项目里效果比盲目堆 FPGA 时钟频率明显得多。我自己第一次调这个编码器时最深的教训就是“一位一位右移”这种写法写起来轻松、跑起来难受。有 200MHz 时钟资源的时候一定要舍得把它换成桶式移位器。后来我把这套架构优化完同样的 FPGA 从 720p30 提到了 720p60代价只是多了几百个 LUT。如果你也在调 JPEG 编码器建议先按上面的坑排查一遍再考虑并行优化。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。