恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
FPGA实现8x8二维DCT:从算法选型到硬件架构全复盘
首页
资讯中心
/
FPGA实现8x8二维DCT:从算法选型到硬件架构全复盘
FPGA实现8x8二维DCT:从算法选型到硬件架构全复盘
发布时间:2026/10/15 23:46:59
最近刚把一个二维离散余弦变换DCT的FPGA实现完整跑通8x8块行变换-转置-列变换两级流水行为仿真和上板实测结果一致误差控制在允许范围内。DCT这东西教科书上几行公式就能写完可真要在FPGA上把它做出来从算法选型到位宽规划、从数据调度到时序收敛每一步都有不少讲究。这篇就把整个项目从零到落地的过程完整复盘一遍给正在做图像/视频压缩硬件加速或者想拿DCT练手FPGA信号处理的同学一条可以直接照着走的路线。DCT在JPEG、HEVC这类压缩标准里承担的是“去相关”的活把图像块从像素域变换到频率域把能量集中到少数低频系数上。FPGA做这件事的优势是数据流规则、乘加密集、天然适合流水线和并行化。但真正设计时会发现难点不在DCT本身而在怎么把数学公式映射成可综合、可收敛、精度够用的硬件结构。这篇文章我会按“算法选型 - 硬件架构 - 定点化 - 核心代码 - 调试经验”这条主线来讲每一步都会解释为什么这么选以及实测中踩过的坑。1. 项目拆解DCT在FPGA上到底要解决什么问题1.1 压缩链路里的DCT能量集中与去相关先捋清DCT在压缩算法里的位置。以JPEG为例流程是RGB转YCbCr - 分块8x8- DCT变换 - 量化 - Z形扫描 - 熵编码。DCT之后的量化才是真正丢信息的地方而DCT本身是有损可逆的在浮点精度下。DCT的核心作用有两个能量集中和去相关。图像像素之间相关性很强直接熵编码压不动DCT把大部分能量压到左上角低频系数高频系数大量趋近于零量化后零串特别多后续编码效率就高了。做硬件时我们关心的是一个8x8块经过二维DCT后输出64个系数其中只有少数非零这块逻辑必须够快、够稳不能成为整条编码链路的瓶颈。FPGA上做DCT本质上就是做一个“像素块输入 - 系数块输出”的组合变换模块。对外接口很简单8x8像素进64个DCT系数出中间不关心你用什么算法。但在内部不同的算法选择直接决定了乘法器数量、流水级数和时序表现。1.2 为什么选FPGA而不是CPU或专用ASIC这个项目选FPGA不是拍脑袋。CPU做DCT当然可行但处理一帧1080p图像仅DCT就有几十万次变换CPU串行执行时延高、功耗大不适合嵌入到实时视频处理链路里。专用ASIC性能最强但开发周期长、成本高只做原型验证根本划不来。FPGA的定位正好卡在中间有可编程逻辑资源能做深度流水线片内乘法器和块RAM足够应付DCT这种规则运算开发周期以周为单位。实际做下来一个主频150MHz、一维DCT核复用的设计能轻松处理4K30帧的实时DCT运算这在通用处理器上很难凭空路段的预算内做到。核心权衡点在于“并行度换吞吐率”。FPGA上同一个DCT模块可以复制多份也可以分时复用编译器的抽象程度高。论文里那些16路并行DCT的架构很唬人但实际工程中要综合考虑资源和吞吐一味堆并行度只会把时序和布线搞崩。1.3 项目目标一个可复用、精度可控的8x8 DCT IP核这个项目最终定义的目标很清晰做一个8x8二维DCT的IP核输入8bit无符号像素输出16bit有符号系数支持块级流水处理时序收敛在150MHz以上占用资源不超过中等规模FPGA的15%。这样的目标适合做独立IP。不跟量化、熵编码绑在一起方便后面接入更大的编码器工程。从设计规范角度看接口要有帧同步信号和数据有效信号核心逻辑用标准的AXI-Stream握手风格方便IP化复用。2. 算法选型从二维公式到一维硬件结构2.1 二维DCT的可分离性把大问题拆成两段二维8x8 DCT公式长这样F(u,v) (1/4) * C(u)C(v) * Σ x[m][n] * cos((2m1)uπ/16) * cos((2n1)vπ/16)直接按这个公式综合会得到一个巨大的组合逻辑阵列64输入到64输出中间全是乘法器。硬件上没人这么干因为二维DCT具备“可分离性”也就是F(u,v) A * X * A^T其中A是8x8余弦变换矩阵。这意味着可以先对每一行做一维DCT得到一个中间结果再对中间结果的每一列做一维DCT最终得到二维变换结果。计算量从O(N^4)降成两轮O(N^3)硬件结构也变成规整的“行DCT - 转置存储 - 列DCT”。所以硬件架构的第一板斧就是“行列分解”。一维DCT核只需要处理8个点输入输出都是8个值整个系统的核心变成一个精心设计的一维8点DCT模块。2.2 一维8点DCT的三种实现路线矩阵乘法、AAN、递归分解一维8点DCT最笨的办法是直接做矩阵乘法8x8系数矩阵乘8x1输入向量需要64次乘法和56次加法。FPGA里做64个乘法不是做不了但会占用大量DSP单元而且乘法路径长时序不好收敛。实际工程中常用的快速算法有两个AAN算法和Loeffler算法。AANArai-Agui-Nakajima把8点DCT分解成蝶形运算加旋转因子乘法次数从64降到11个左右加法约29次是JPEG开源实现中最常见的算法。Loeffler算法有类似的结构乘法次数13个左右但系数更规整在某些量化精度要求高的场景下数值特性更好。两种算法在硬件上都是“蝶形旋转”的结构差别只是中间系数和连线顺序。从实现角度看AAN的乘法次数更少、结构更紧凑我最后选的就是AAN。但要提醒AAN的系数不是传统DCT矩阵系数输出需要额外乘一个缩放因子这个因子可以和后续量化合并掉千万别在硬件里做两次缩放。一维DCT的3种方案快速对比方案乘法次数加法次数硬件特点直接矩阵乘法6456结构简单但乘法暴多DSP容易吃满AAN快速算法~11~29蝶形旋转乘法少最常用Loeffler算法1329系数规整误差特性好一点2.3 蝶形运算与旋转因子的硬件含义无论选哪个快速算法最终映射到硬件的基本单元就两种加减法和常数乘法。蝶形运算就是一对输入做加法和减法比如y0 a b, y1 a - b。硬件上就是两个加法器没别的。旋转因子稍微复杂一点比如y0 a * C b * Sy1 -a * S b * C需要4个乘法器和2个加法器。FPGA里可以用DSP乘法器直接做也可以用移位加法器代替后面章节细说。从数据路径角度看算法结构是固定的每个节点只依赖前一级的两个输入天然适合流水线切分。综合工具可以自动在这些节点之间插寄存器关键是设计时留好中间信号的位宽余量否则综合出来的资源会虚高。3. 硬件架构与数据流组织行-转置-列三段式3.1 整体架构从像素块到系数块的一条流水线虽然只是做8x8 DCT但完整硬件架构包含五个部分输入缓冲、行DCT模块、转置存储、列DCT模块、输出缓冲。数据流是这样的先把8x8像素块按行写入输入缓冲行DCT模块依次读入8行数据每行做一维DCT每行的8个变换结果写入转置存储的对应行8行全部处理完后按列读取转置存储列DCT模块对每一列做一维DCT最终输出按行排列的64个二维DCT系数。这里最关键的决策是行DCT和列DCT各用几个核。最省资源的做法是只做一个一维DCT核先做8次行变换再做8次列变换共16次调用吞吐率低但资源少。如果对吞吐率有要求可以放两个一维DCT核行、列各一个这样行DCT算完第一行的中间结果就可以立刻开始列DCT只需要保证转置存储的读写端口不冲突。我的设计选择了两个核的方案中间用乒乓RAM做缓冲吞吐率翻了近一倍代价是多占了一些块RAM和少量LUT特别值得。3.2 转置存储双口RAM和寄存器堆怎么选行列分解实现不难转置才是硬件里最容易出错的地方。转置的本质是写入时按行地址排布读出时按列地址排布。具体做法有两种第一种是用标准双口RAM。一个端口写、一个端口读写地址用行号和列号拼成读地址也用行号和列号拼成读出时只要把行列号交换就能从行写入变成列读出。这是最省资源的方案一个8x8的16bit双口RAM只需要18Kbit左右的块RAM能力。第二种是用寄存器堆也就是64个寄存器加读译码逻辑。好处是读写可以真正做到无阻塞同一拍任何行列都能访问不依赖RAM的端口限制缺点是资源消耗大64个16bit寄存器加上大量多路选择逻辑中等规模FPGA这么干有点奢侈。实际工程我建议优先用双口RAM只在转置模块需要同时读两列或写两行时才考虑寄存器堆。如果用了块RAM有一个细节必须注意读数据有一拍输出延迟状态机在做列读时序时要补这一拍不然DCT系数会整体错位一拍仿真时很难看出来。3.3 乒乓RAM与块间重叠处理如果不做缓冲优化一个块的处理流程是行DCT写完全部8行 - 列DCT读完全部64个数 - 输出 - 开始下一个块。这中间留了一段空闲时间也就是说DCT核心和列DCT核心有一部分时间在空转。解决办法是乒乓缓冲。准备两块转置RAM块A的行DCT结果写入RAM0同时块B的列DCT从RAM1读取数据。下一个块到来时角色互换。这样行DCT和列DCT两个模块可以一直不停地把数据喂在流水线上整个设计变成了真正的块级流水。乒乓缓冲要注意RAM状态切换的同步。块RAM写完成后必须等当前列DCT把数据全部读完才能开始往这块RAM写入新块数据。状态机里要有一个“busy”信号写端在切换RAM时检查对方是否空闲。3.4 控制状态机让数据流按节奏走整个DCT IP的控制逻辑非常简单就是一个几步状态机IDLE等待帧同步信号LOAD_ROW按行读入8x8像素块同时行DCT开始计算WR_TRANS把行DCT结果按行写入转置RAMRD_TRANS按列读出转置RAMCOL_DCT列DCT计算输出第二个8个系数OUT输出64个系数的完整数据块回到IDLE或直接进入下一个块。状态机虽然简单但和DCT流水线之间的握手很关键。我建议把控制信号分成“块级”和“像素级”。块级信号管帧同步和缓冲切换像素级信号管每行8个数据的有效脉冲。两者只有对齐时数据才有效可以有效避免前后两个块的数据互相串扰。4. 位宽、定点化与精度控制硬件里最容易被低估的部分4.1 从8bit像素到16bit系数每级位宽怎么定浮点DCT算完可以保证任意动态范围但FPGA里一切都是有符号定点数。位宽定少了会溢出丢数据定多了浪费资源还拖慢时序。实际项目里我用的是这样一套位宽规划处理阶段输入位宽输出位宽说明输入像素8bit无符号9bit有符号像素要先减128消除直流偏置第一级蝶形运算9bit有符号10bit有符号加减法最多扩1bit旋转运算10bit有符号14bit有符号常数乘法放大系数行DCT输出14bit有符号14bit有符号可以在此截断转置RAM存储14bit有符号14bit有符号保持精度不截断列DCT输出14bit有符号16bit有符号二次变换扩位最终输出16bit有符号16bit有符号与量化模块对齐这套位宽规划的依据是一维DCT的增益最多到8倍DC项再加上输入饱和范围14bit足够二维后再扩2~3bit16bit稳够。实际用仿真验证时同一组数据对比浮点参考16bit输出实际误差不超过1个LSB这个精度对图像处理完全够用了。4.2 常数系数的定点表示量化误差要控制住快速DCT里的旋转因子和余弦系数都是无限小数比如cos(π/4)≈0.7071、cos(π/8)≈0.9239。在FPGA里只能取有限位宽的定点数。常见做法是把系数放大到2^12或2^14取整后存成整数计算完再右移回去。选择系数位宽要权衡两个东西系数量化误差和乘法器位宽。系数用12bit量化误差约0.024%远小于图像编码阶段量化表带来的误差完全够用。系数提取可以直接用脚本生成把每个旋转因子的整数值打出来转成Verilog参数或存入ROM这样人工手算错符号的概率就降下来了。这里有个关键的工程细节AAN算法里输出的缩放因子不要单独再做乘法把它合并到后续的量化表中。我的设计里在DCT核外部直接输出未定标的系数由量化模块统一缩放这样DCT核内部的乘法器可以做到最少也不影响精度。4.3 移位加法替代乘法器省资源还是省事快速DCT的常数乘法可以拆成移位和加法组合。比如乘0.7071可以拆成乘181/256181就是0.7071*256四舍五入得到的整数。181/256用二进制的移位加法表示就是y (x * 181) 8进一步拆成移位加181 128 32 16 4 1也就是y (x7) (x5) (x4) (x2) x最后算术右移8位这样处理的好处是完全不用DSP乘法器全部走LUT和布线对DSP资源紧张的项目很重要。坏处是路径变长一个常数乘法器变成5个移位器加4个加法器时序上需要插入寄存器或者作为流水级处理。如果目标器件DSP156充足我更建议直接用DSP乘法器做旋转因子开发效率高很多时序也更可控。移位加法适合在LUT资源宽裕、想省DSP给后续量化模块用的场景下采用。二者的选择本质上是一个“面积换时序”的决策没有绝对优劣。4.4 截断还是舍入不要无脑右移截断定点数每级右移都会丢低位怎么丢大有讲究。直接截断truncation会把误差往负方向推连续几级截断后直流分量会偏图像上就是亮度偏移肉眼可见。我采用的是“加半舍入”round-to-nearest策略右移k位前先加上1(k-1)再右移。硬件上就是多一个加法器成本极低效果却是质的提升。实测对比直接截断的PSNR比浮点参考低了大约2.5dB加半舍入后差距缩小到0.1dB以内这个差距在图像链路里很关键。舍入之后还有符号问题。对有符号数加半舍入要把符号位和数值位一起处理最简单的方法是把负数绝对值化再舍入、最后还原符号但这样会有进位链。更优雅的办法是用有符号加法器加偏移同步处理正负不会产生额外偏差。5. 核心模块的Verilog实现骨架5.1 蝶形运算单元加减法也要注意位宽有了前面的选型一维DCT的硬件结构就是一组蝶形运算和旋转因子组合起来。蝶形单元本身毫无技术含量就是加法和减法但写代码时有个很容易忽略的点输入位宽是8bit时两个输入相加最大会到9bit输出绝对值要扩到10bit才能防溢出。下面的代码示例里把x扩展到了符号位再运算避免进位丢失。// 蝶形运算级y0 a b, y1 a - b // 输入有符号输出扩2bit防止溢出 module butterfly #( parameter W 9 )( input wire signed [W-1:0] a, input wire signed [W-1:0] b, output wire signed [W:0] sum, output wire signed [W:0] diff ); assign sum a b; assign diff a - b; endmodule这段代码看着简单但实际项目中所有蝶形级加起来有十几个每个的输入输出位宽都要精确规划好差1bit会在后续旋转级被放大好几倍最后的系数误差会超过容忍范围。5.2 常数乘法器移位加法的两种写法常数乘法器实现我分别试过DSP和移位加法这里给出移位加法的参考代码。注意代码里要用算术左移和算术右移即Verilog里的和强调这是有符号数运算普通逻辑移位会丢符号位。// 常数乘法器y x * 181 / 256 x * 0.70703125 ≈ x * cos(pi/4) // 181 128 32 16 4 1 module cmul_c4 #( parameter W 12 )( input wire signed [W-1:0] x, output wire signed [W8-1:0] y // 做完整精度输出 ); wire signed [W8-1:0] t1, t2, t3, t4, t5; assign t1 { {8{x[W-1]}}, x } 7; // 算术左移扩展符号 assign t2 { {8{x[W-1]}}, x } 5; assign t3 { {8{x[W-1]}}, x } 4; assign t4 { {8{x[W-1]}}, x } 2; assign t5 { {8{x[W-1]}}, x }; assign y t1 t2 t3 t4 t5; endmodule这段代码把5个移位项合并最后的右移8位如果放到后续流水级做可以进一步缩短关键路径。实际综合时可以把右移拆到输出端甚至和下一级旋转因子合并处理减少一次右移运算。5.3 转置存储模块RAM地址拼接就是转置转置模块是行列分解的关键用双口RAM实现写地址和读地址都是6bit。写入时高3位是行号、低3位是列号读出时把行号和列号的拼接顺序颠倒一下自然就完成了矩阵转置。// 8x8转置存储写入按行读出按列 module transpose_ram #( parameter W 14 )( input wire clk, input wire we, input wire [2:0] wrow, input wire [2:0] wcol, input wire signed [W-1:0] wdata, input wire [2:0] rrow, input wire [2:0] rcol, output reg signed [W-1:0] rdata ); reg signed [W-1:0] mem [0:63]; always (posedge clk) begin if (we) mem[{wrow, wcol}] wdata; // 6bit地址 行号*8 列号 rdata mem[{rrow, rcol}]; end endmodule读写地址都用的{row, col}拼接所以当状态机加载新块的行DCT结果时用wrow递增即可当列DCT读取时固定rcol的值、让rrow从0递增到7读出的序列就是一列数据。RAM的读延迟一拍在状态机里处理即可。5.4 顶层状态机流水线握手和乒乓切换顶层控制逻辑用状态机把所有模块串起来核心是保证每一拍的使能信号不乱发。下面是一段可综合的状态机骨架省略了乒乓RAM切换细节但展示了数据路径上的控制流。// 4状态状态机IDLE - ROW_DCT - TRANSPOSE - COL_DCT localparam IDLE 2d0; localparam ROW_DCT 2d1; localparam TRANSPOSE 2d2; localparam COL_DCT 2d3; reg [1:0] state, next_state; reg [2:0] row_cnt, col_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: if (frame_vld) next_state ROW_DCT; ROW_DCT: if (row_cnt 3d7) next_state TRANSPOSE; TRANSPOSE: if (col_cnt 3d7) next_state COL_DCT; COL_DCT: if (col_cnt 3d7) next_state IDLE; default: next_state IDLE; endcase end状态机最好用三段式现态、次态、输出编写使能信号在状态切换的前一拍就准备好避免组合逻辑毛刺。乒乓RAM切换信号建议放在TRANSPOSE状态结束时置位确保列DCT读完旧RAM后才写新RAM。这个顺序一旦颠倒会出现数据错乱且难以定位。6. 仿真验证与常见调试实录6.1 怎么建立可信的参考模型DCT做的对不对不能靠肉眼盯着波形猜。我的做法是建三重验证环境第一步用脚本Python或你熟悉的任何科学计算工具生成100组随机8x8像素块同时用浮点公式算出对应的DCT系数作为golden reference。第二步把同样的输入数据转成十六进制TXT文件喂给RTL仿真环境跑完行为仿真后把输出延迟对齐用脚本对比RTL输出和浮点参考的差值。第三步对真实图像取若干8x8块做上板测试把FPGA输出的系数通过串口回传和软件端同一个像素块的DCT结果对比。这一步能覆盖前面所有环节的隐患包括时钟、复位、跨时钟域问题。验证通过的标准我定得很严格每路系数和参考值的绝对误差不超过2个LSB。如果误差超过这个数基本可以断定是位宽截断或系数定点化精度不足需要回头查。6.2 那几次让人怀疑人生的调试经历第一次跑仿真输出全零。查了半天发现状态机复位后卡在IDLE因为frame_vld信号和像素数据没有对齐使能信号一直没来。从此养成了习惯先加班让信号波形显示出来再看数据。第二次是DC系数偏大。DCT的第一个系数理论上是像素均值的8倍偏偏只大了一截。查到最后是输入像素没做减128处理。8bit无符号像素直接参与有符号运算直流分量实际带了个128的偏置这个偏置在DCT里非但没消掉反而被放大。正确的做法是在输入端把像素减128转成有符号数再进DCTDCT核的输入永远是带符号数。第三次最隐蔽转置RAM读出的数据序列错乱。现象是列DCT输出系数时前几个数对、后几个数完全不对。原因是同步RAM读有一拍延迟状态机发读地址后立即用读数据没有打拍对齐。修正方案是在读数据输出侧打一拍寄存器同时把状态机的读地址递增时序整体推迟一拍。6.3 时序收敛关键路径与优化三板斧综合工具跑完时序报告里最亮红灯的往往是两个地方常数乘法器尤其是移位加法实现和转置RAM的地址译码逻辑。第一版设计在主频150MHz约束下时序违例负的裕量有几百ps根本没法交付。优化三板斧依次是加流水级、换DSP乘法器、调整结构。我在每个旋转因子输出端插了一级流水寄存器把移位加法的长链条切开转置RAM读地址直接由状态机寄存器输出不在组合逻辑里生成DSP乘法器代替部分移位加法器后关键路径缩短到原来的60%。如果加完流水级还是不够可以考虑把一维DCT核内部的旋转级做两拍延迟吞吐率不变因为流水线是连续的只是单块延迟增加几拍。这种代价在图像处理里完全可以接受。6.4 常见问题速查表现象可能原因解决思路输出全零状态机卡在IDLE、frame_vld未对齐检查输入使能时序DC系数偏大输入没减128直流偏置未消除输入转有符号并减128系数错位同步RAM读延迟未补偿读数据打一拍、调整状态机节奏高频系数符号错乱旋转因子符号搞反用脚本生成系数别手算上板和仿真不一致复位释放不同步统一异步复位同步释放时序难收敛移位加法链条太长流水级切开或用DSP乘法器6.5 资源与吞吐率的平衡建议如果目标FPGA的DSP很紧张就尽量用移位加法实现旋转因子LUT和FF资源一般足够如果主频要求高、时序压力大优先用DSP乘法器省下来的时间够你调好几轮优化。从吞吐率角度看如果一个视频帧只有几十毫秒的处理预算8x8 DCT模块主频150MHz、每16个时钟处理一个块就能轻松做到1080p30实时。如果做成全并行8路一维DCT架构吞吐还能翻倍但功耗和面积代价不小。我建议第一版先做“双核加乒乓RAM”的经典结构把整个流程跑通再说优化。最后再聊几句做这个小项目最大的体会是算法公式只是起点硬件实现才是真正的考验。DCT核心就那么几十行逻辑但位宽规划、数据调度、时序收敛、验证闭环每一步都可能在最意想不到的地方卡住你。我最后想说的是如果你也打算做一个类似的变换级IP一定别急着写代码先把算法结构、位宽规划和验证方案在纸上理清楚这些东西想明白了写Verilog反而很快。另外仿真通过不等于上板通过建议尽早准备一块开发板把真实图像数据跑一遍很多隐蔽问题会提前暴露出来。这个DCT IP做扎实之后后面再接量化、Z形扫描、熵编码就有了一根可靠的地基。