简介:基于Verilog的DES加密算法工程包,面向FPGA开发者和密码学初学者,提供在可编程逻辑器件上实现经典对称加密算法的完整设计参考。包内含134个文件,压缩后约409KB,以.v源码、.qpf/.qsf工程配置、.mif初始化数据、.cdb/.hdb工程数据库、.rpt综合报告及readme说明为主,便于还原Quartus工程环境并跟踪综合、布局布线结果。资源已有1072人学习下载,适合作为数字逻辑设计与DES算法结合的入门项目。通过对DES核心模块、顶层控制、S盒与P盒等子模块的梳理,读者可掌握硬件描述语言实现加密流程的方法,也能借助配套仿真与文本记录理解轮密钥生成、迭代运算与逆初始置换等细节。整体目录结构完整,兼顾工程复盘与学习参考,对嵌入式安全和硬件加速方向的技术提升也有帮助。
1. 基于 Verilog 的 DES 加密算法:从数据通路到上板验证
DES 放在 FPGA 上做,最费功夫的不是那十六轮迭代本身,而是所有“按位编号”的置换表怎么映射到数据通路上。用 Verilog 写 DES,翻车点往往出在第一位:E 扩展的位序写反一个下标,整个密文就面目全非,而仿真还未必报错。这套“基于 Verilog 的 DES 加密算法”资源,适合正在学 FPGA 但想拿真实算法练手的人,也适合要把 DES 当成 IP 核塞进通信链路里的从业者。它能帮你把 Feistel 结构、子密钥生成、S 盒替换这几个关键模块一次性理清,并落到可综合的代码上。整篇笔记我会按“结构选型 → 子密钥 → 轮函数 → 避坑 → 验证”的顺序展开,中间穿插可复现代码。
2. DES 的硬件设计框架:为什么 FPGA 适合跑 Feistel 结构
2.1 从算法结构看硬件映射:内外两层数据通路
DES 本质上是一个分组长度为 64 bit 的对称加密算法,密钥有效位 56 bit(加上奇偶校验位共 64 bit)。它最核心的数学结构是 Feistel 网络:每一轮把 64 bit 明文分成左右两半,右半边经过轮函数 F 后与左半边异或,再交换位置。这个结构对硬件极友好——加密和解密共用同一套轮函数,只是子密钥的使用顺序相反,这意味着我在 FPGA 里只需要实现一套 F 函数,加解密靠控制子密钥的读取方向切换。
做硬件映射时,我习惯把整个 DES 拆成三个层次来看。第一层是密钥调度模块(Key Schedule),负责把 64 bit 主密钥经过置换选择 PC-1、循环左移、置换选择 PC-2,生成 16 个 48 bit 子密钥。第二层是轮函数模块(Round Function),负责 E 扩展、异或子密钥、S 盒替换、P 置换这一串组合逻辑。第三层是顶层控制模块,负责计数 16 轮迭代、决定加解密模式、处理初始置换 IP 和逆初始置换 IP^-1。
| 设计参数 | 数值 | 说明 |
|---|---|---|
| 明文分组 | 64 bit | 每轮加密一个分组 |
| 密钥长度 | 64 bit(有效 56 bit) | 每 8 bit 中最后 1 bit 为奇偶校验位 |
| 轮数 | 16 轮 | 轮数固定,控制逻辑可复用 |
| 子密钥长度 | 48 bit | 由 56 bit 经 PC-2 压缩置换得到 |
| 扩展函数 E | 32 bit → 48 bit | 将右半部分扩展后与子密钥异或 |
对新手来说最容易忽略的一点是:Feistel 结构的加解密同构意味着解密时只需要把 16 个子密钥的读取顺序反过来,不需要额外的组合逻辑面积。很多人在做硬件实现时会下意识做两套解密逻辑,这是完全没有必要的面积浪费。
2.2 模块划分与位宽约定:让代码可读、可综合、可调试
我拿到一个 DES 的 Verilog 工程,先不看算法细节,而是看它的模块边界。一个合格的硬件 DES 设计,至少要分成 key_schedule、round_function、des_top 三个模块。位宽命名上,我强烈建议一上来就把所有信号的位宽写清楚,并且用_in、_out、_key这类后缀标注信号方向,否则十六轮迭代中间信号的位宽非常容易搞混。
在实际编码之前,我会先画一张数据流图:明文进来先过初始置换 IP,拆成 L0 和 R0;每一轮的 L[i] = R[i-1],R[i] = L[i-1] XOR F(R[i-1], K[i]);十六轮之后做逆初始置换得到密文。这张图不是白画的——它能帮你在写代码之前就先确认两个关键时序问题:第一,每一轮迭代是纯组合逻辑还是用时序逻辑;第二,如果是时序逻辑,每轮需要几个时钟周期。
2.3 纯组合逻辑一轮完成还是分拍完成:时序与面积的取舍
DES 的轮函数 F 包含 E 扩展、异或、8 个 S 盒、P 置换,这个数据通路的组合逻辑深度并不算浅。如果一切以时钟周期为代价,采用纯组合逻辑实现完整轮函数,那么在一个时钟周期内必须完成 S 盒查找和 P 置换的全部传播,这在低速率时钟下没有问题,但时钟频率一旦跑高,时序收敛就非常吃力。
另一种常见做法是把 S 盒做成寄存输出,把一轮拆成两级流水,每一级只做一部分组合逻辑。我一般会在资源允许的情况下,把轮函数设计成一个纯组合逻辑的模块,然后在顶层用计数器控制 16 轮状态,每轮占用一个时钟周期。这样做的好处是仿真和上板的行为完全一致,易于调试。注意,S 盒在 Verilog 里用 case 语句实现时,综合工具通常能把它映射成 LUT 或分布式 RAM,不必手动例化 Block RAM。
3. 子密钥生成模块:从 64 bit 主密钥到 16 个 48 bit 子密钥
3.1 密钥调度的三步拆解:PC-1 置换、循环左移、PC-2 置换
密钥调度的第一步是 PC-1 置换,它把 64 bit 主密钥里的 8 个奇偶校验位剔掉,剩下 56 bit,然后分成 C0 和 D0 两个 28 bit 寄存器。第二步是每一轮对 C 和 D 分别循环左移,左移的位数由轮数决定:第 1、2、9、16 轮左移 1 位,其余轮数左移 2 位。第三步是把循环左移后的 56 bit 做 PC-2 压缩置换,去掉 8 bit,得到 48 bit 子密钥。这个流程决定了子密钥生成不能一次性组合逻辑搞定——因为每一轮的 C、D 初始值都依赖上一轮的状态,必须用寄存器逐轮递推。
我在设计这个模块时,遇到的主要问题是左移位数的控制。很多实现是每轮直接对 C 和 D 的寄存器赋值,但更干净的写法是例化一个可复用的移位寄存器,用轮数作为选择信号控制移位量。下面是子密钥生成模块的核心代码片段:
module key_schedule ( input wire clk, input wire rst_n, input wire [63:0] master_key, input wire key_valid, output reg [47:0] subkey [0:15] ); reg [27:0] C_reg, D_reg; reg [27:0] C_next, D_next; reg [2:0] round_cnt; wire [1:0] shift_amount; // 左移位数表:第1、2、9、16轮左移1位,其余左移2位 assign shift_amount = ((round_cnt == 4'd1) || (round_cnt == 4'd2) || (round_cnt == 4'd9) || (round_cnt == 4'd16)) ? 2'd1 : 2'd2; // 循环左移:把最高位绕回到最低位 always @(*) begin case (shift_amount) 2'd1: begin C_next = {C_reg[26:0], C_reg[27]}; D_next = {D_reg[26:0], D_reg[27]}; end 2'd2: begin C_next = {C_reg[25:0], C_reg[27:26]}; D_next = {D_reg[25:0], D_reg[27:26]}; end default: begin C_next = C_reg; D_next = D_reg; end endcase end // PC-1 置换后得到初值,后续按轮递推 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin round_cnt <= 3'd0; C_reg <= 28'd0; D_reg <= 28'd0; end else if (key_valid) begin C_reg <= master_key[55:28]; // 实际工程中应替换为完整的 PC-1 位映射 D_reg <= master_key[27:0]; round_cnt <= 3'd1; end else if (round_cnt >= 3'd1 && round_cnt <= 3'd16) begin C_reg <= C_next; D_reg <= D_next; round_cnt <= round_cnt + 1'b1; // 此处应实例化 PC-2 置换,将 C_reg 和 D_reg 组合成 48 bit 子密钥 subkey[round_cnt] <= {C_next[27:22], D_next[27:22], C_next[21:16], D_next[21:16]}; end end endmodule逻辑说明:这个模块用 round_cnt 作为状态机计数器,key_valid 信号载入主密钥后,每一拍生成一个子密钥。PC-1 置换在理想情况下应该按标准置换表逐位映射,为了代码示例简洁,上面对 master_key 直接做了截取,实际工程中你需要把 PC-1 的 56 个输出位号按表填入。PC-2 同理,用拼接的方式示意,真实实现应替换成完整的位映射逻辑。
参数说明:round_cnt是 5 bit 足够容纳 1 到 16;shift_amount由轮数查表得到,注意第 16 轮之后不需要再移位,所以控制逻辑里应判断round_cnt == 16后停止递推。subkey是一个长度 16 的数组,用 reg 类型声明,综合时会展开成 16 个 48 bit 寄存器。
3.2 用查找表替代条件判断:让移位逻辑更清晰
上面代码里用 case 语句判断左移 1 位还是 2 位,关键是这个判断每一轮都要做。如果你想让代码更有工程味,更推荐预置一张移位量表,用shift_rom[round_cnt]直接取出移位量。这样在后续做时序优化时,这条路径的组合逻辑会更短。
| 轮数 | 左移位数 | 轮数 | 左移位数 |
|---|---|---|---|
| 1 | 1 | 9 | 1 |
| 2 | 1 | 10 | 2 |
| 3 | 2 | 11 | 2 |
| 4 | 2 | 12 | 2 |
| 5 | 2 | 13 | 2 |
| 6 | 2 | 14 | 2 |
| 7 | 2 | 15 | 2 |
| 8 | 2 | 16 | 1 |
3.3 子密钥生成的三个易错点:初值时刻、停止条件、密钥装载时序
第一个坑是 key_valid 与 round_cnt 的竞争。在代码里,key_valid 拉高的那个时钟上升沿,C_reg 被赋初值,同时 round_cnt 被置为 1,那么这一拍结束之后,下一个上升沿到来时才会开始算第一轮的子密钥。如果 key_valid 拉高和下一轮计数的时序没想清楚,很容易出现第一个子密钥是垃圾值的情况。
第二个坑是先把 PC-1 置换做完整再做拆分。很多人的代码习惯是先对 master_key 做完整置换得到 56 bit,然后取前 28 bit 给 C,后 28 bit 给 D。这个顺序是对的,但位数容易写错——56 bit 从 0 编号到 55,前 28 bit 是 [55:28],后 28 bit 是 [27:0],一旦写成 [27:0] 和 [55:28] 顺序颠倒,整个密钥调度的结果全错。
第三个坑是第 16 轮之后的状态处理。DES 标准规定第 16 轮之后 C 和 D 不再循环左移,但很多实现为了图省事会让移位寄存器继续转,这会导致如果你把子密钥生成模块复用到解密模式,第 17 拍以后的数据没有任何意义。正确做法是在 round_cnt 计数到 16 时拉高一个key_done信号,让顶层知道所有子密钥已经生成完毕。
4. 轮函数与 S 盒实现:数据通路从 32 bit 到 48 bit 再到 32 bit 的变换
4.1 E 扩展与白化异或:位序不能靠感觉,要靠置换表对照
轮函数 F 的输入是 32 bit 的 R[i-1],第一步先做 E 扩展,把 32 bit 扩展成 48 bit。E 扩展不是简单的复制拼接,而是按标准置换表把某些位重复出现。常见实现是把 32 bit 分成 8 组,每组 4 bit,扩展成每组 6 bit,头和尾各补一组的前一位和后一位。
E 扩展的 Verilog 写法,最直接的是用 assign 按位拼接。代码示例如下:
// E 扩展:32 bit 输入扩展为 48 bit wire [47:0] e_expand; assign e_expand[47:42] = {r_in[0], r_in[31], r_in[30], r_in[29], r_in[28], r_in[27]}; assign e_expand[41:36] = {r_in[27], r_in[26], r_in[25], r_in[24], r_in[23], r_in[22]}; assign e_expand[35:30] = {r_in[23], r_in[22], r_in[21], r_in[20], r_in[19], r_in[18]}; assign e_expand[29:24] = {r_in[19], r_in[18], r_in[17], r_in[16], r_in[15], r_in[14]}; assign e_expand[23:18] = {r_in[15], r_in[14], r_in[13], r_in[12], r_in[11], r_in[10]}; assign e_expand[17:12] = {r_in[11], r_in[10], r_in[9], r_in[8], r_in[7], r_in[6]}; assign e_expand[11:6] = {r_in[7], r_in[6], r_in[5], r_in[4], r_in[3], r_in[2]}; assign e_expand[5:0] = {r_in[3], r_in[2], r_in[1], r_in[0], r_in[31], r_in[30]};逻辑说明:E 扩展输出的第 0 位和第 47 位分别是 r_in[31] 和 r_in[0],也就是把边界位复制到扩展位。如果你用的是教材上的表,第 0 位对应输入第 32 位,写代码时下标换算很容易错一位——这类错误仿真时很难发现,因为输出的位宽没错、数值也有变化,但整个算法的结果就是不对。
参数说明:E 扩展后与子密钥 key 做异或。注意子密钥是 48 bit,扩展结果是 48 bit,异或结果也是 48 bit。异或运算本身在 Verilog 里就是一拍assign xor_out = e_expand ^ subkey;的事。关键是要确认 subkey 数组中索引的轮数与当前迭代轮数对应,千万不要在流水线设计里把子密钥和轮函数的轮次错开。
白化异或的结果接下来送入 S 盒。DES 使用 8 个不同的 S 盒,每个 S 盒输入 6 bit,输出 4 bit。S 盒的替换逻辑本质上是一个 64 进 16 出的查找表,在 Verilog 里最直接的写法是 case 语句。
4.2 S 盒的 Verilog 实现:case 语句与 ROM 两种写法的选择
我见过很多人的 S 盒代码是一长串 case 语句。对一个 S 盒来说,64 个 case 分支是必须的,8 个 S 盒就是 512 个分支,手写会非常痛苦。更常规的做法是用二维数组定义 S 盒内容,然后用索引直接取数据。对于 FPGA,我一般建议把 S 盒声明为 reg 类型,在 initial 块里初始化,这样综合工具会自动推断为分布式 RAM 或 LUT ROM。
// S1 盒的部分实现:输入 6 bit,输出 4 bit function [3:0] s1_box; input [5:0] addr; case (addr) 6'd0: s1_box = 4'd14; 6'd1: s1_box = 4'd4; 6'd2: s1_box = 4'd13; 6'd3: s1_box = 4'd1; // ... 省略中间项,完整版见资源中的 sbox.v 6'd62: s1_box = 4'd11; 6'd63: s1_box = 4'd13; default: s1_box = 4'd0; endcase endfunction逻辑说明:S 盒的行由输入的最高位和最低位拼成,列由中间 4 位决定。这是初学 DES 最容易漏掉的细节——直接拿 6 bit 做 case 索引是错的,必须拆出行列再去查表。上面的代码为了展示 case 的写法,没有把行列拆分逻辑写进去,但你实际的实现里必须先做这一步,否则结果和标准 DES 对不上。
参数说明:s1_box 函数返回 4 bit,addr是 6 bit,8 个 S 盒各自独立。每个 S 盒的输出按顺序拼接成 32 bit,作为 P 置换的输入。注意 S 盒的序号要对应清楚:第一个 S 盒处理的是异或结果的高 6 位,依次往下,最后一个处理低 6 位,序号错了密文就全错了。
4.3 轮函数顶层拼接:L、R 交换的时序陷阱
完整的轮函数架构如下:R 输入 E 扩展 → 与子密钥异或 → 8 个 S 盒替换得到 32 bit → P 置换 → 与 L 异或 → 交换。很多初学者会忘记 P 置换,或者把 L 和 R 的交换时序写错。交换不是可选的——Feistel 结构靠这个交换保证可逆性,如果代码里直接让 L 和 R 都更新,那就不是 DES 了。
// 轮函数顶层:单轮迭代组合逻辑 module round_function ( input wire [31:0] l_in, input wire [31:0] r_in, input wire [47:0] key_in, output wire [31:0] l_out, output wire [31:0] r_out ); wire [47:0] expand_out; wire [47:0] xor_out; wire [31:0] sbox_out; wire [31:0] p_out; // E 扩展 assign expand_out = e_expand(r_in); // 密钥白化 assign xor_out = expand_out ^ key_in; // S 盒替换后拼接 assign sbox_out = {s1_box(xor_out[47:42]), s2_box(xor_out[41:36]), s3_box(xor_out[35:30]), s4_box(xor_out[29:24]), s5_box(xor_out[23:18]), s6_box(xor_out[17:12]), s7_box(xor_out[11:6]), s8_box(xor_out[5:0])}; // P 置换 assign p_out = p_permute(sbox_out); // 左右交换 assign l_out = r_in; assign r_out = l_in ^ p_out; endmodule逻辑说明:这段代码假设 e_expand、p_permute 是已经封装好的 function。注意l_out = r_in、r_out = l_in ^ p_out是同时赋值的组合逻辑,不存在先后顺序。如果你把轮函数设计成时序逻辑,那么这里的赋值最好改成在时钟沿触发,否则综合出来的寄存器和组合逻辑的边界会和你预期不符。
参数说明:p_out是 32 bit,P 置换表固定。这个模块在 16 轮迭代中会被反复调用,你可以把它例化 16 次(展开成纯组合逻辑),也可以用状态机复用一个模块。前者吞吐高,后者面积小。对于学习用途,我推荐先做复用版本,也就是顶层用 round_cnt 选择当前使用的子密钥,每拍给轮函数喂上一轮的 L、R 即可。
5. 避坑与调试:DES 仿真翻车场景复盘
5.1 仿真永远是对的,上板就是不对:问题出在寄存器初始值
现象:用同一个测试向量仿真,密文和教材完全一致,但下载到板子上之后,加密结果每次复位都一样错。
原因:仿真时 reg 变量默认是 x 或 0,而 FPGA 上电后寄存器状态是随机的。DES 的轮函数是迭代结构,任何一级寄存器没有复位,都会导致整个状态错乱。很多人的代码里只复位了计数器,没有把 L、R、子密钥寄存器全部复位。
解决:写一个全局复位,把所有数据通路寄存器都拉回确定值。我习惯在复位状态下把l_reg、r_reg赋值成初始置换后的明文,把round_cnt清零,并且确保 key_schedule 的 C_reg、D_reg 在复位时也有初值,而不是等着 key_valid 来置数。
5.2 S 盒行列拆分搞错:加密结果只在某些分组上出错
现象:某些明文分组加密结果正确,换一个明文就出错,错误没有规律。
原因:S 盒索引需要把 6 bit 拆成行(最高位和最低位)和列(中间 4 位)。如果直接用 6 bit 编号查表,相当于只用了列号,行号被忽略了。DES 的 S 盒是按行列组织的,行决定用哪一行数据,列决定取哪一列,这种双索引结构不能用普通数组下标替代。
解决:写一个公共函数,先拆位再查表。
function [3:0] sbox_lookup; input [5:0] data; input [3:0] sbox_table [0:63]; integer row, col; begin row = {data[5], data[0]}; col = data[4:1]; // 根据 row 和 col 换算成 0~63 的线性地址 sbox_lookup = sbox_table[row*16 + col]; end endfunction逻辑说明:row 只有 0 到 3 四种取值,col 是 0 到 15,线性地址等于 row 乘以 16 加上 col。这样才符合 DES 标准里 S 盒的查询方式。
5.3 子密钥轮数错位:加密第一个块对,从第二个块开始全错
现象:Block RAM 或 FIFO 连续送入两个明文分组,第一组密文对,第二组以后全部错误。
原因:子密钥生成模块如果只在 key_valid 时装载一次密钥,那么连续加密多组数据时,第二个分组复用了第一组已经完成 16 轮递推后的 C_reg、D_reg 状态。正确的做法是每个新分组到来时都要重新装载主密钥、重新计数。
解决:在顶层控制器里,每次检测到明文有效的上升沿,就让 key_schedule 模块重新装载 master_key,并且把 round_cnt 复位为 0。这样每处理一个分组,子密钥都会从头生成。这里顺带说一点血泪经验:很多 IP 核的接口有key_valid和data_valid两个信号,二者之间的时序配合是最容易翻车的玄学区域,务必用状态机显式管理,不要依赖信号巧合。
5.4 初始置换和逆初始置换被当成可选项:结果和标准 DES 对不上
现象:用标准测试向量验证时,中间轮次值都对,只有最终密文差一个初始置换的距离。
原因:DES 标准里明文先做 IP 初始置换,最后做 IP^-1 逆初始置换。有些参考代码为了简化,把 IP 和 IP^-1 去掉了,直接以 L0、R0 为初始输入、最后按 R16L16 输出。这种做法能跑通自己的仿真,但和其他 DES 实现互操作时必挂。
解决:严格按照标准流程实现,IP 和 IP^-1 的置换表在资源中已经给出,直接用 assign 拼位即可。注意,IP^-1 不是 IP 的逆运算代码,它是另一张独立的置换表,不要试图用同一个模块翻转实现。
5.5 级联流水线里 S 盒用了时序逻辑:吞吐是上去了,但数据全乱了
现象:想把轮函数做成流水线提高吞吐,每级 S 盒输出都打了一拍寄存器,结果前几级数据全错位。
原因:S 盒 case 语句本身是组合逻辑,如果你在 S 盒输出后面加了寄存器,那么整个轮函数的数据通路多了一拍延迟。流水线设计需要把每一级的延迟都计算清楚,主密钥调度、轮函数、数据分配三者的节拍必须完全对齐。
解决:要么全部走组合逻辑只打最后一级寄存器,要么统一改成每轮一拍的全时序设计,不要混用。混用的话,调试的难度会指数级上升——你看到的中间值永远是错拍的,根本没法判断是逻辑错还是时序错。
6. 用标准测试向量做全链路自检:最后一公里验证技巧
拿到代码后的第一件事,永远是跑标准测试向量。DES 的验证有固定的套路:用已知明文和已知密钥,算出标准密文,然后让 testbench 自动比对。我习惯把测试向量直接写成一个自检任务,把三组以上的向量塞进去,复位后依次跑完,任何一组不一致就拉高错误标志。
所谓自检,本质上就是把人的判断换成机器的判断。比如标准向量 1:明文0123456789ABCDEF,密钥133457799BBCDFF1,加密结果是85E813540F0AB405。你的 testbench 里应该这么写:
task check_des; input [63:0] plaintext; input [63:0] ciphertext_expected; begin @(posedge clk); // 驱动明文输入并等待 16 轮完成 // 比较 des_out 与 ciphertext_expected if (des_out !== ciphertext_expected) $display("TEST FAILED: plaintext %h, got %h, expected %h", plaintext, des_out, ciphertext_expected); else $display("TEST PASSED: %h -> %h", plaintext, des_out); end endtask逻辑说明:这个任务封装了一个完整的检查流程,你只需要在 initial 块里依次调用三次,每次填入不同的向量。跑完所有向量后,如果三条 TEST PASSED,那这个 DES 核的正确性就基本有保证了;如果 FAILED,再结合第 5 章的方法逐级排查。注意这里用的是非阻塞赋值,des_out是顶层模块寄存输出,比较时用!==能同时检测 x 和高阻态。
参数说明:每调用一次 check_des 任务,实际上是一次完整的 16 轮加密,因此必须等done信号拉高后再比较结果。如果你把任务写在 initial 块里连续执行,务必在每次调用之间留出足够的时钟周期等待加密完成。
从那以后我每次做 DES 相关的设计,都强制走一遍“全向量自检 + 全寄存器复位检查”的流程,才敢把代码交给下一步仿真或上板。一套能在初期就发现问题、把错误定位到具体模块的可复现验证环境,比多写几百行代码更有价值,希望帮到你。
本文还有配套的精品资源,点击获取