简介:基于Verilog的DES加密算法FPGA工程包,面向数字逻辑设计与信息安全方向的开发者,用于学习对称加密算法的硬件实现、Quartus工程综合与仿真验证流程。包内共有134个文件,压缩后仅约409KB,核心内容包含8个Verilog源文件与8个MIF初始化文件,覆盖DES核心模块、顶层控制与仿真测试平台;同时附带Quartus工程配置文件(qpf/qsf)、编译过程生成的数据库与报告文件(cdb/rdb/rpt等)及readme说明,便于对照分析从RTL设计到综合适配的完整过程。已有1072人下载学习。通过该项目可掌握用Verilog描述初始置换、轮函数、S盒等DES关键步骤的方法,并通过仿真比对验证加密正确性。由于工程结构紧凑、源码完整,非常适合FPGA密码算法入门、硬件安全课程设计或作为AES等其他加密算法实现的参考模板。
1. DES加密的FPGA实现:为什么Verilog更适合做密码学项目的起点
我见过不少想做硬件加密的同学,一上来就去抄软件C代码,折腾两个星期还在用状态机模拟软件流程。真正落地的做法,是直接回到Verilog,把DES当成一组置换表和一轮Feistel网络的连线来写。基于Verilog的DES加密算法,把这个经典密码算法完整落到FPGA上:从初始置换到16轮加密、从子密钥生成到S盒替换,全部用可综合的RTL描述,仿真通过之后可以直接上板,也能拿去做3DES、AES的改造原型。对想用FPGA做密码学入门的人,还有想快速拿到一个加密IP核做数据加解密的开发者,这份工程都能当起点。讲实话,代码量不大,但坑都在S盒、位宽和时序上。
2. 系统架构:DES加密算法的硬件模块划分与Verilog实现方案
2.1 DES算法流程回顾:从明文分组到Feistel网络的硬件映射
DES处理的是64位分组,核心是16轮Feistel结构。每一轮做的事情高度一致:右侧32位R先做E扩展变成48位,和48位轮密钥异或,接着进入8个S盒压缩回32位,再做P置换,最后和左侧32位L异或,得到下一轮R。下一轮L就是本轮R。用Verilog描述这个结构非常直观,因为每轮全是组合逻辑,只需要在每个轮次边界加一组寄存器。
整个算法从上到下可以拆成四条数据通路:初始置换IP、16轮迭代、左右交换、逆初始置换IP^-1。硬件实现时,IP置换和逆置换是固定连线,不占用任何逻辑单元;真正消耗资源的是8个S盒查表、密钥调度的循环移位和P置换。综合工具拿到这些代码后,会把E扩展、P置换优化成纯布线,实际LUT消耗集中在S盒和异或逻辑上。
理解这个流程是编写代码的前提。建议先把下面这个映射表写死在注释里,再开始写RTL:
- 输入:64位明文,64位密钥(含8位奇偶校验位)
- 初始置换:IP表重排位,输出L0[31:0]、R0[31:0]
- 第r轮:Lr+1 = Rr,Rr+1 = Lr + F(Rr, Kr)
- 最终输出:经过16轮后,交换左右,再过IP^-1置换
这个结构和MD5、SHA那种填充分组不一样,DES对字节顺序极其敏感,很多新手第一次跑出错误结果,都是因为把明文位序写反了。
2.2 顶层模块:接口定义与主状态机设计
顶层模块我习惯命名为des_cipher_top,对外暴露最小接口:时钟、复位、启动信号、明文、密钥、密文输出和完成标志。这样设计的好处是方便在不同FPGA工程之间复用,也容易往CBC、CTR模式扩展。
模块端口定义如下:
| 信号名 | 位宽 | 方向 | 说明 |
|---|---|---|---|
| clk | 1 | input | 系统时钟,上板后由外部晶振提供 |
| rst_n | 1 | input | 异步复位,低有效 |
| start | 1 | input | 拉高一拍启动加密 |
| data_in | 64 | input | 待加密明文 |
| key_in | 64 | input | 加密密钥,含奇偶校验位 |
| data_out | 64 | output | 加密结果 |
| done | 1 | output | 加密完成后拉高一个周期 |
状态机采用三段式风格,分为IDLE、ROUND、FINISH三个状态。IDLE状态下检测start,锁存明文和密钥;ROUND状态下迭代16轮;最后一轮输出密文并拉高done。这里有一个容易忽略的点:IP置换要在start有效的那一拍完成,再锁存到内部寄存器里,不能在ROUND里反复做。
顶层状态机的核心代码结构如下:
reg [4:0] round_cnt; reg [63:0] block; reg [63:0] key_reg; reg [1:0] state, nstate; localparam IDLE = 2'd0; localparam ROUND = 2'd1; localparam FINISH = 2'd2; // 一轮迭代组合逻辑 wire [63:0] round_out; des_round u_round ( .L_in (block[63:32]), .R_in (block[31:0]), .K (subkey), .L_out(round_out[63:32]), .R_out(round_out[31:0]) ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; round_cnt <= 5'd0; done <= 1'b0; end else begin state <= nstate; case (state) IDLE: begin if (start) begin block <= des_ip_perm(data_in); key_reg <= key_in; round_cnt <= 5'd0; done <= 1'b0; end end ROUND: begin if (round_cnt == 5'd15) begin data_out <= des_ipinv_perm( {round_out[31:0], round_out[63:32]} ); done <= 1'b1; end else begin block <= round_out; round_cnt <= round_cnt + 1'b1; end end FINISH: done <= 1'b0; endcase end end这段代码里,des_ip_perm和des_ipinv_perm是函数,内部查标准置换表。注意最后一轮的处理:Feistel结构要求16轮之后交换左右两个32位,再执行逆初始置换,所以代码里用拼接操作把round_out的L、R对调。这里需要特别小心,拼接顺序一旦写反,密文输出就会整体错位,而且仿真不容易发现。
subkey信号由密钥调度模块产生,下一小节单独讲。顶层状态机没有把密钥调度做成独立时序模块,而是做成组合逻辑,round_cnt一变,subkey立即更新,这样每轮迭代只花一个时钟周期,16轮加密总共16拍加上启动和收尾,硬件开销非常小。
2.3 密钥调度模块:从64位密钥到16轮48位子密钥
密钥调度是DES实现里最容易出错的部分。原始密钥是64位,先经过PC-1置换去掉8个奇偶校验位,得到56位。然后分成C0和D0两个28位寄存器,每轮按固定位数循环左移,最后把移位后的56位送入PC-2置换,压缩成48位轮密钥。
硬件上,我推荐把密钥调度做成纯组合逻辑模块,输入原始密钥和当前轮号,输出对应子密钥。这种方式不用额外状态机,资源消耗也很低。
module des_key_schedule ( input wire [63:0] key, input wire [4:0] round, output reg [47:0] subkey ); // PC-1置换:64位到56位 wire [55:0] key56 = pc1_perm(key); // 每轮循环左移位数表:1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1 reg [5:0] shift_bits; always @(*) begin case (round) 5'd0, 5'd1, 5'd8, 5'd15: shift_bits = 6'd1; default: shift_bits = 6'd2; endcase end // 56位循环左移 reg [55:0] rotated; always @(*) begin case (shift_bits) 6'd1: rotated = {key56[54:0], key56[55]}; 6'd2: rotated = {key56[53:0], key56[55:54]}; default: rotated = key56; endcase end // PC-2置换:56位到48位 always @(*) begin subkey = pc2_perm(rotated); end endmodule这里最关键的是循环左移的拼接方式。56位寄存器左移1位,最高位要回填到最低位:{key56[54:0], key56[55]}。左移2位则是{key56[53:0], key56[55:54]}。很多工程翻车就翻在这里,有人直接写了key56 << 1,语法没问题,但最高位移丢后没有回填,导致子密钥从第2轮开始全部错误。
PC-1和PC-2置换表是固定查找表。实际工程中建议单独建一个des_tables.v文件,用function实现这些表。不要一上来就直接写code,先把标准表从文档里复制出来,逐个核对。网上流传的表有些是程序生成索引,位序和硬件描述里的bit编号正好差一位,这种低级错误很难排查。
2.4 迭代架构与流水线展开的取舍
这个工程默认采用迭代架构,也就是16轮共用同一个des_round组合逻辑,通过状态机循环使用。它的优点是面积小,LUT占用低,适合入门级FPGA。缺点是一个周期只能做一轮,加密64位数据需要16个周期,吞吐率受限。
如果你追求高吞吐,可以在迭代架构基础上做部分展开。比如每轮之间插入寄存器,形成两级流水,或者直接把16轮展开成16份组合逻辑。展开后延迟变小,但面积会增大到原来的十几倍。对DES这种每轮逻辑完全一致的结构,展开后综合工具能自动共享部分S盒资源,实际面积增长没那么夸张。
我自己的习惯是先做迭代版本,跑通功能后再调成流水线。或者反过来,用迭代版本做参考模型,流水线版本每一轮用相同的子模块,方便逐级比对中间结果。这个工程里给出的就是迭代版本,代码短,适合改造成各种模式。
3. 核心模块代码实现:加密轮与S盒的Verilog细节
3.1 加密轮模块des_round:E扩展与轮密钥异或
加密轮模块是DES的核心数据通路。输入左侧32位L、右侧32位R、48位轮密钥K,输出下一轮的L和R。内部由扩展置换、异或、S盒、P置换四级逻辑串联。
E扩展不是简单地把32位补成48位,而是按E表重新排列并复制某些位。比如R的第1、2、4、5位会被复制到扩展输出的多个位置。用Verilog实现我习惯写成wire赋值,让综合工具自动处理布线:
module des_round ( input wire [31:0] L_in, input wire [31:0] R_in, input wire [47:0] K, output wire [31:0] L_out, output wire [31:0] R_out ); // E扩展置换:32位 -> 48位 wire [47:0] exp_out = des_e_perm(R_in); // 与轮密钥异或 wire [47:0] xored = exp_out ^ K; // S盒压缩:48位 -> 32位 wire [31:0] sbox_out; des_sboxes u_sboxes ( .data_in(xored), .data_out(sbox_out) ); // P置换 wire [31:0] f_out = des_p_perm(sbox_out); assign L_out = R_in; assign R_out = L_in ^ f_out; endmodule这段代码直接把Feistel结构映射成了连线。R_in经过F函数得到f_out,R_out由L_in异或f_out得到,L_out就是R_in本身。注意这里使用的des_e_perm和des_p_perm是function,内部查表完成位重排。function在综合时会展开成纯组合逻辑,不会生成额外寄存器。
扩展置换的实现有人在always块里用case逐位赋值,那样代码会很长。我更推荐用function配合循环:
function [47:0] des_e_perm; input [31:0] din; integer i; begin for (i = 0; i < 48; i = i + 1) begin des_e_perm[i] = din[E_TABLE[i]]; end end endfunctionE_TABLE是一个reg数组,在模块头部用initial块初始化。这种方法把表和代码分离,后续换算法只需要替换表内容。综合工具能把这种for循环完全展开,不会生成真正的循环硬件。
3.2 S盒的Verilog实现:查找表与位宽陷阱
8个S盒是整个DES里唯一引入非线性变换的地方,也是资源消耗最大的部分。每个S盒输入6位,输出4位。6位输入的首尾两位拼成行号(0~3),中间4位拼成列号(0~15),查表得到4位输出。
S盒实现我分成两层:每个S盒内部用case语句描述16行4位的查找表,外层用generate或function根据盒序号选择对应S盒。由于8个S盒的表内容完全不同,不能直接共用一份case。
下面是单个S盒的完整结构示意:
function [3:0] sbox_lookup; input [2:0] box_id; input [5:0] sbox_in; begin case (box_id) 3'd0: begin case ({sbox_in[5], sbox_in[0]}) // 行号由首尾两位决定 2'd0: case (sbox_in[4:1]) 4'd0: sbox_lookup = 4'hE; 4'd1: sbox_lookup = 4'h4; // 完整16项省略,工程内见des_tables.v default: sbox_lookup = 4'h0; endcase // 其他行号省略 endcase end // 其他7个S盒省略 endcase end endfunction这里有个新手最容易踩的坑:行号是sbox_in[5]和sbox_in[0]拼接,不是sbox_in[5:4]。列号是中间4位sbox_in[4:1],不是sbox_in[4:0]。因为输入总共6位,首尾两位被拿去定行,剩下4位定列。把索引算错,结果就是S盒输出全错,但仿真波形看起来还是有值变化,很难发现。
从资源角度讲,case语句实现的S盒在综合后一般会映射成LUT。如果目标是更高吞吐,也可以把S盒改造成分布式RAM或BRAM,但这会引入时钟周期访问延迟,需要调整流水线设计。目前这个工程直接用case,最简单也最容易验证。
3.3 P置换与F函数组合:输出完整加密轮
P置换是最后一层32位到32位的置换,把S盒输出重新打散。实现方式和E扩展一样,用function查表。F函数就是E扩展、异或、S盒、P置换这条链路的整体。des_round模块里的f_out就是完整的F函数输出。
从综合角度看,P置换和前面的E扩展一样,都是纯布线优化,不会消耗LUT。真正的硬件成本是S盒case语句展开后的查找表逻辑。如果FPGA的LUT是6输入结构,一个6位输入、4位输出的S盒大约需要两三个LUT,8个S盒大概二十多个LUT,整个DES核心的资源消耗大头就在这里。
加密轮全部是组合逻辑,因此时序路径就是从R_in经过E扩展、S盒、P置换到R_out这条路径。S盒的case嵌套比较深,综合工具可能会在一级case里生成较长的级联MUX,导致Fmax上不去。后面第四章会专门讲这个问题的排查和优化。
3.4 Testbench与标准向量验证:让代码跑起来的第一步
写完RTL不要直接上板,先用一个标准测试向量把功能验证通过。DES标准里有一组公开测试向量,明文0x0123456789ABCDEF,密钥0x133457799BBCDFF1,加密结果固定为0x85E813540F0AB405。这个向量在网上很容易查到,适合作为Testbench的基准。
Testbench我一般包含两层验证:第一层只验证顶层加密结果,第二层逐轮打印中间值。因为DES的中间结果在标准文档里也能查到,一旦发现最终结果不对,对照中间L、R寄存器就能定位是哪一轮、哪个模块出了问题。
module des_tb; reg clk = 1'b0; reg rst_n = 1'b0; reg start = 1'b0; reg [63:0] data_in = 64'h0123456789ABCDEF; reg [63:0] key_in = 64'h133457799BBCDFF1; wire [63:0] data_out; wire done; des_cipher_top dut ( .clk(clk), .rst_n(rst_n), .start(start), .data_in(data_in), .key_in(key_in), .data_out(data_out), .done(done) ); always #10 clk = ~clk; initial begin rst_n = 1'b0; #30 rst_n = 1'b1; #20 start = 1'b1; #20 start = 1'b0; wait (done == 1'b1); if (data_out === 64'h85E813540F0AB405) $display("TEST PASS"); else $display("TEST FAIL: %h", data_out); $finish; end endmodule这段代码里,等待done信号用wait语句,避免自己数周期。数据比较用全等符===,否则如果data_out里有X态,普通比较会返回假。整个仿真跑下来,迭代架构是16个周期出结果,加上复位和启动,仿真时间几十纳秒就能结束。
如果第一次跑不通过,我的排查顺序是:先看初始置换后的L0、R0是否正确,再看第一轮子密钥是否正确,然后单独测一个S盒的输入输出。这三处都对了,后面基本只剩位宽和拼接问题。
4. 常见问题与排查:仿真与FPGA实现中的高频踩坑记录
4.1 加密结果中间过程正确,后几轮突然乱码
现象:前三轮中间值能和标准向量对上,从第四轮开始完全不对,最终密文全是错位数据。
原因:这是典型的密钥调度循环左移位数错误。DES的移位序列是1、1、2、2、2、2、2、2、1、2、2、2、2、2、2、1,第0轮和第1轮只移1位,第8轮移1位,其余都是移2位。如果脑子里记成“前两轮移1位”,到第3轮就开始错,而且越错越离谱。
解决:把shift_bits的判断改成显式case,不要用if去判断round的奇偶。我当时是直接把标准表抄进注释,每轮用$display打印subkey,和标准文档手动比对。确认错在移位后,重新写case,一次就修好了。
4.2 S盒输出全为0,但E扩展和异或结果正确
现象:中间节点检查发现xored信号正常,S盒输出sbox_out始终是32位全0,排除硬件故障,仿真就是全0。
原因:S盒的case分支里default写成了4'h0,而实际输入的组合可能没覆盖到所有分支。比如行号计算用了sbox_in[5:4],而不是sbox_in[5]和sbox_in[0],导致行号永远落在0或1,其他行被default兜底输出0。这个错误非常隐蔽,因为默认值让仿真不会报X态。
解决:把行号改成{sbox_in[5], sbox_in[0]},然后写一个小的验证任务,遍历0到63的所有S盒输入,确认输出和标准S盒表一致。从那以后我再写S盒,都强制跑一遍全输入遍历测试,花不了几个时钟周期,能挡住大部分索引错误。
4.3 仿真通过,综合后时序不收敛
现象:功能仿真结果全对,但综合实现后时序报告中出现大量setup违例,Fmax只能跑到设计目标的一半。
原因:加密轮组合逻辑路径太长。E扩展、异或、S盒case、P置换串在一起,其中S盒的嵌套case被综合成多级MUX,路径延迟非常大。迭代架构的寄存器之间塞了整个加密轮,时序自然紧张。
解决:最直接的办法是在F函数中间插入一级流水寄存器,把S盒前后拆成两级。这样一帧数据多一拍延迟,换来Fmax大幅提升。如果你不想引入流水,也可以把S盒case改成更扁平的结构,用独立的wire为每个输出位写逻辑表达式,避免深嵌套MUX。但后者写起来很痛苦,我一般直接选流水。
4.4 上板结果和仿真不一致,仿真却完全正常
现象:Testbench里加密结果正确,烧进FPGA后用串口把密文读回来,发现每隔一段时间结果会闪一下错误值。
原因:大概率是复位信号没有做同步处理。外部按键或上电复位在FPGA内部属于异步信号,直接接在寄存器异步复位端,可能出现在时钟有效沿附近变化,导致部分寄存器复位、部分没有复位,状态机进入错误分支。
解决:把外部复位先经过两级触发器同步,再送给所有模块。代码里写成:
reg rst_n_sync1, rst_n_sync2; always @(posedge clk) begin rst_n_sync1 <= rst_n; rst_n_sync2 <= rst_n_sync1; end然后把复位信号换成rst_n_sync2。这个教训很经典,仿真时model里复位和时钟是理想对齐的,上板后真实信号有毛刺,同步就出问题了。从那以后我所有FPGA工程的复位都走这个两级同步结构。
4.5 仿真出现大量X态,波形一片红
现象:启动后多个信号显示为X,加密结果完全无法判断。
原因:寄存器没有初始化。FPGA上电后寄存器有确定初值,但仿真模型里默认是X,除非在initial块里赋值或复位。如果状态机等信号依赖某个未复位寄存器,X就会扩散。
解决:检查所有always块,确保每个寄存器都在复位分支里赋了初值。尤其要注意组合逻辑产生的wire,如果always @(*)里没有覆盖全部输入组合,也可能输出X。验证方法是在Testbench里复位后先等几个周期,再拉高start,观察所有中间信号是否已退出X态。
5. FPGA板级调试:从仿真到比特流上板全流程
5.1 开发流程与约束设置
仿真通过后进入实际FPGA工程流程。这里的开发环境不限定具体厂商,大体流程都是RTL综合、布局布线、生成比特流、下载。
综合之前先写好时序约束。主时钟直接约束为你的板载晶振频率,比如50MHz就写周期20ns。对data_in、key_in这些外部输入信号,加一个input delay约束,告诉工具数据相对于时钟到达的窗口。对done、data_out这类输出,加output delay。这些约束直接影响布局布线质量,偷懒不写,工具会按默认值处理,时序可能崩。
复位信号在约束里建议标记为false path或async_reg。因为经过两级同步后的复位内部信号,其实已经和时钟域对齐,不需要再做时序检查。这里有一个细节:两级同步器里的两个触发器要加ASYNC_REG属性,让工具把它们放得尽量靠近,避免亚稳态传播。
5.2 板级调试:用片上逻辑分析仪抓中间信号
上板调试最怕的就是内部信号看不见。我的习惯是综合前先决定要观察哪些信号,把它们加为调试探针。加密核里的round_cnt、block、subkey、done都是很好的观察对象。
在综合设置里,给探针信号加上保持属性,防止被综合工具优化掉。比如在Verilog里写:
(* MARK_DEBUG = "true" *) reg [4:0] round_cnt; (* MARK_DEBUG = "true" *) reg [63:0] block;如果你不想让信号被优化掉,也可以加综合属性keep。上板后,触发条件可以设置在done拉高时抓取,或者round_cnt等于某个值时抓取中间block。这样在调试工具里就能看到每一轮的明文状态,和仿真波形对比,误差代立马暴露。
调试时要注意一个细节:加了探针信号后,布局布线资源会增加,Fmax可能下降。调试完记得去掉探针再重新综合一版做最终测试,不要拿着带探针的版本去量性能。
5.3 资源占用与性能评估
迭代式DES核心的资源占用非常小。以我组装过的典型工程为例,8个S盒用case实现,在主流低端FPGA上大约消耗不到两千个LUT,FF数量主要看状态机、round_cnt和各寄存器位宽,整体不超过几百个。因为E扩展和P置换是纯布线,LUT基本全花在S盒和异或逻辑上。
性能方面,迭代架构完成一个64位分组需要16轮加启动和收尾,效率约1Gbps以内。如果只追求功能验证,50MHz时钟足够。要做更高吞吐,常见的调整方向是把一轮拆成两级流水,或者把16轮展开,让数据每周期流过一个组合轮。展开后面积翻倍但吞吐接近原来的16倍,代价是时序路径变长,需要精细约束。
我一般评估一个密码核能不能用,先看三点:资源占用是否超过FPGA总LUT的一半、Fmax是否高于系统需求的两倍、接口是否方便后续协议模块接入。这三点过了,才敢把它集成到大的系统里。
6. 二次开发:基于此DES核的改造思路与扩展技巧
6.1 从ECB模式扩展到CBC/CTR模式
当前设计是单块加密,也就是ECB模式。实际使用中CBC模式更多。改造思路很简单:在顶层增加一个64位的反馈寄存器和一级异或逻辑。加密时,先把上一组密文和当前明文异或,再送入des_cipher_top。密文输出同时写入反馈寄存器。CBC模式下解密用的是解密核,注意解密时是密文先解密再和上一组密文异或,顺序不要搞反。
CTR模式更简单,把计数器值当明文加密,输出和真实明文异或。改造时只需要增加一个计数器模块,des_cipher_top原封不动复用。这也是当初把顶层接口设计成64位数据进来的好处,扩展模式不会动核心轮模块。
6.2 向3DES演进
3DES本质上是把DES运行三次。两种主流做法是2-key和3-key。2-key模式用K1加密、K2解密、K1再加密;3-key模式用K1、K2、K3依次加密。改造时可以直接例化三份des_cipher_top,数据依次流过,密钥分别接三组寄存器。面积是原来的三倍,但模块之间没有共享逻辑,综合工具也能优化得很好。
这里有个工程细节:解密核和加密核不是同一个模块。DES解密用的子密钥顺序和加密相反,其它逻辑完全一样。如果你希望能加能解,最简单的办法是在密钥调度模块里加一个方向信号,解密时round_cnt从15跑到0,或者把C、D寄存器的循环左移改成右移。不要复制两份des_round,维护起来很痛苦。
6.3 性能优化方向和验证方法
想提升Fmax,优先拆S盒前后的流水级。把E扩展和异或放第一级,S盒和P置换放第二级,每轮变成两个周期。想要提升吞吐,就把16轮展开成16份组合逻辑,中间插寄存器,形成流水线。这两种方案是可以叠加的:展开后用每份组合逻辑只有半个轮子的深度,Fmax能冲很高。
改造后验证方法不变,标准向量仍是最终依据。 我建议在Testbench里先用软件模型生成几百组随机明文密钥,和RTL仿真结果逐一比对。这个工作一次性投入,之后每次改动都能自动回归。
我记得第一次自己做DES改造时,犯了一个很低级的错误——IP置换表是从网上直接复制过来的,前几行不是标准表,结果标准向量第一轮就错。从那以后我每次拿到任何密码算法工程,都会先写一个顶层testbench,把IP、E扩展、S盒、密钥扩展逐级打印出来和标准文档比对,跑完才敢做综合。这个习惯替我挡了不少坑,希望帮到你。
本文还有配套的精品资源,点击获取