☰
FPGA JPEG编码器Verilog实现:从DCT到Huffman的硬件设计指南
2026/10/13 20:38:20 网站建设 项目流程

简介:基于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:0,MCU(最小编码单元)不只一个 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_{n=0..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] = 12'sd512; coef[0][1] = 12'sd512; coef[0][2] = 12'sd512; coef[0][3] = 12'sd512; coef[1][0] = 12'sd502; coef[1][1] = 12'sd426; coef[1][2] = 12'sd284; coef[1][3] = 12'sd100; // 其余行按JPEG标准DCT-II查表补齐 end reg [2:0] idx; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin idx <= 3'd0; done <= 1'b0; end else if (start) begin idx <= 3'd1; done <= 1'b0; end else if (idx == 3'd7) begin idx <= 3'd0; done <= 1'b1; end else if (idx != 3'd0) begin idx <= idx + 1'b1; done <= 1'b0; 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 <= 16'sd0; end else if (idx == 3'd1) 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,否则写的时候没法读。标准做法是用双 bank:bank 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减上一块DC,signed 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 = 4'd0; dc_huff_code = 12'h0; // 00 dc_huff_len = 4'd2; end else if (diff >= -1 && diff <= 1) begin dc_cat = 4'd1; dc_huff_code = 12'h2; // 010 dc_huff_len = 4'd3; end // 其余category按标准表继续补全 end

JPEG 标准里亮度 DC 表从 category 0 到 11,码长从 2bit 到 9bit,查表用 case 写最直接。注意我代码里12'h0是先把码字右对齐存进 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 解释,自然全错。

我用的可靠做法是:先把码字左对齐存放,再按码长逐位移进移位寄存器。比如当前码字3'b110,存在 12 位寄存器里写作12'b110_0000_0000,码长 3。拼接时把寄存器右移 3 位再和已有内容做或。这样不管码字长短,bit 顺序天然正确。也有人反过来,把 bitstream 缓冲倒着用,每拼一码先左移再或,效果一样,但必须整条链路从头到尾保持同一个约定。

最终输出时要凑齐整字节。JPEG 压缩数据按字节流输出,但 Huffman 码是变长的,所以需要一个桶式移位器,把待输出的 bit 攒到至少 8 个时吐一个字节。桶式移位器用组合逻辑写表,不要用 for 循环一位位移,否则时序容易挂。

4.4 字节填充与文件头:熵编码只是中间产物

JPEG 文件不是只放压缩数据,前面还要有 SOI、APP0、DQT、SOF0、DHT、SOS 等标记。解码器靠这些标记识别量化表、Huffman 表、图像尺寸和采样格式。硬件里这些头可以固化在 ROM 中,需要变化的只有宽高和量化表,用寄存器在启动时写入。

压缩数据流本身有一个强制规则:每输出 0xFF 字节,后面紧跟一个 0x00,这是为了防止和解码器的标记字节冲突。这个填充必须硬件自动完成,不能软件事后补,因为补字节会影响后面的字节对齐。实现上很简单:检测到输出字节等于 8'hFF 时,额外插入一个时钟周期输出 8'h00。

头文件部分最容易踩的坑是 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 编码器,建议先按上面的坑排查一遍,再考虑并行优化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询