简介:本资源是一份面向数字电路与密码硬件设计初学者及FPGA开发者的Verilog实践项目,聚焦APB总线协议下SM4国密算法协处理器的完整实现。资源以1个37KB的docx文档形式提供,内容涵盖APB从机接口模块(apb_slave)、顶层集成模块(top_level)及SM4核心算法模块的完整Verilog代码,详细注释了寄存器映射、地址解码逻辑、明文/密钥写入触发机制、密文读取时序及复位控制流程,并附有SM4轮函数、S盒查表与线性变换(LN)等关键原理说明。代码严格遵循APB总线规范,支持标准读写操作与时钟复位管理,可直接用于FPGA综合验证或SoC子系统集成。目前已有524人学习下载,适合需要掌握国密算法硬件化设计、总线接口开发及密码IP核集成的嵌入式与IC验证工程师。
1. 一个能插进 SoC 的 SM4 加密黑匣子:APB 总线一接就跑,明文密钥分四次写、密文分四次读,不用改驱动就能当外设用
你有没有遇到过这种场景:FPGA 或 ASIC 项目里需要加个国密算法模块,但芯片主控是 ARM Cortex-M 系列,总线是 APB;你手头只有纯算法 Verilog(比如 SM4_U),可它没总线接口、不认 PCLK/PRESETN、更不会响应 PADDR=0x10 的密钥写入——硬塞进去?得重写整个控制逻辑,还得配中断、状态寄存器、忙信号……最后发现时序根本对不上。这个基于 APB 总线的 SM4 密码协处理器,就是专治这种“算法有、接口无、集成难”的玄学翻车。它不是教学 Demo,而是一个可直接例化进真实 SoC 顶层的硬件 IP:APB 主机(比如 ARM AHB-APB bridge)发地址 0x00–0x0c 写明文、0x10–0x1c 写密钥,再读 0x00–0x0c 就拿到 128 位密文,全程自动触发加密、自动置 ready_out、自动锁存结果。某高校数字 IC 实验室拿它搭了块最小系统板,从拉线到跑通测试向量只用了 3 小时——因为它的 APB_slave 模块把协议细节全吃掉了,你只需要关心“写什么地址、送什么数据”,连握手时序都不用算。适合 FPGA 原型验证、ASIC 集成、国密合规性预研,也适合想搞懂“密码算法怎么变成可复用 IP”的数字前端新手。
2. 架构拆解:三层结构 + 四类寄存器,APB 接口层、SM4 算法层、顶层胶合层各司其职
这个协处理器不是单个大模块堆出来的,而是清晰分层的三段式设计:APB 接口层负责和总线对话,SM4 算法层专注加解密计算,顶层胶合层把两者粘合成一个完整外设。这种分法不是为了炫技,而是为了方便替换——比如你想换成 AES,只要换掉 SM4_U 模块,APB_slave 和 top_level 几乎不用动。下面逐层拆解,重点讲清楚每层干啥、数据怎么流、关键信号怎么联动。
2.1 APB_slave 模块:总线协议翻译官,把 PADDR/PWRITE 变成内部控制信号
apb_slave是整个协处理器的“门面”,它不碰算法,只做一件事:把 APB 总线上的读写请求,翻译成 SM4 模块能理解的text_isvalid_in(明文就绪)、mk_isvalid_in(密钥就绪)、DATA_i(128 位明文)、KEY(128 位密钥)这四个信号。它的核心逻辑在always @(posedge PCLK or negedge PRESETN)块里,分三路处理:
复位清零:
!PRESETN有效时,所有寄存器(data1–data4, key1–key4, prdata)全清零,确保上电状态可控;写操作解析:当
PSELx && PENABLE && PWRITE同时为高,说明主机正在写数据。此时根据PADDR[7:0]的低 8 位地址,决定把PWDATA存到哪个寄存器:case(PADDR[7:0]) 8'h00: data1 <= PWDATA; // 明文第 0 个 32 位字(高位) 8'h04: data2 <= PWDATA; // 明文第 1 个 32 位字 8'h08: data3 <= PWDATA; // 明文第 2 个 32 位字 8'h0c: begin data4 <= PWDATA; text_isvalid_in = 1'b1; end // 明文第 3 个 32 位字(低位),写完即置有效标志 8'h10: key1 <= PWDATA; // 密钥第 0 个 32 位字(高位) 8'h14: key2 <= PWDATA; // 密钥第 1 个 32 位字 8'h18: key3 <= PWDATA; // 密钥第 2 个 32 位字 8'h1c: begin key4 <= PWDATA; mk_isvalid_in = 1'b1; end // 密钥第 3 个 32 位字(低位),写完即置有效标志 endcase提示:地址分配严格遵循 APB 协议惯例,0x00–0x0c 为数据区(4×32bit),0x10–0x1c 为密钥区(4×32bit)。
text_isvalid_in和mk_isvalid_in是边沿敏感信号——只在data4/key4写入的那个时钟周期拉高,之后立刻回落,SM4_U 模块靠它触发一次完整的 32 轮迭代,避免重复启动。读操作响应:当
PSELx && PENABLE && !PWRITE时,主机在读数据。此时prdata输出DATA_ENC寄存器中对应 32 位的数据:case(PADDR[7:0]) 8'h0c: prdata <= DATA_ENC[31:0]; // 密文第 0 个 32 位字(低位) 8'h08: prdata <= DATA_ENC[63:32]; // 密文第 1 个 32 位字 8'h04: prdata <= DATA_ENC[95:64]; // 密文第 2 个 32 位字 8'h00: prdata <= DATA_ENC[127:96]; // 密文第 3 个 32 位字(高位) endcase注意:读地址顺序和写地址顺序是镜像的。写明文时 0x00 是高位,读密文时 0x00 是高位,但
DATA_ENC寄存器本身是按[127:0]大端存储的,所以读0x00地址要取[127:96],这是 Verilog 中常见的“地址映射错位”陷阱,新手容易在这里翻车。
2.2 SM4_U 模块:纯算法实现体,32 轮非线性迭代 + Sbox 查表 + 线性变换
SM4_U是真正的密码引擎,它不关心 APB,只认clk_i、rst_n_i、mk_i、text_i这几个信号。它的设计完全对标国密 SM4 标准(GM/T 0002-2012),核心是密钥扩展(Key Expansion)和轮函数(Round Function)两大部分,全部用组合逻辑+时序逻辑在单一时钟域内完成。
Sbox 与常数 ROM 初始化:模块开头用
always @ (posedge clk_i)块初始化两个关键 ROM:Sbox[0:255]:256 字节的非线性替换表,代码里已完整列出所有值(如Sbox[0]<=8'hd6;),这是 SM4 安全性的基石,不能手写错一个字节;CK[0:31]:32 个 32 位轮常数(CK[0]<=32'h00070e15;等),用于密钥扩展中的异或运算;FK[0:3]:4 个 32 位固定异或常数(FK[0]<=32'hA3B1BAC6;等),用于将原始密钥 MK 转为初始轮密钥 K0–K3。
密钥扩展逻辑:在
always @ (posedge clk_i or negedge rst_n_i)块中实现。当mk_isvalid_in上升沿到来,先将输入密钥mk_i拆成MK0–MK3,再用FK常数生成初始K[0]–K[3]:if(mk_isvalid_in) begin {MK0,MK1,MK2,MK3} = mk_i; K[3] = MK3 ^ FK[3]; // 注意:这里 K[3] 对应 MK3,是大端对齐 K[2] = MK2 ^ FK[2]; K[1] = MK1 ^ FK[1]; K[0] = MK0 ^ FK[0]; end随后,用
cnt计数器(0 到 31)驱动 32 轮迭代,每轮计算一个轮密钥rk[cnt]:{a0,a1,a2,a3} = K[cnt+1] ^ K[cnt+2] ^ K[cnt+3] ^ CK[cnt]; // 输入:前 3 轮密钥 + 轮常数 B = {Sbox[a0],Sbox[a1],Sbox[a2],Sbox[a3]}; // Sbox 替换 K[cnt+4] = K[cnt] ^ B ^ ({B[18:0],B[31:19]}) ^ ({B[8:0],B[31:9]}); // LN 线性变换:B^(B<<13)^(B<<23) rk[cnt] = K[cnt+4]; // 输出本轮轮密钥关键点:
({B[18:0],B[31:19]})是 Verilog 中实现循环左移 13 位的标准写法(B<<13是逻辑左移,会补 0,而 SM4 要求循环移位),({B[8:0],B[31:9]})同理是循环左移 23 位。漏掉这个细节,密钥扩展就全错。加密轮函数:同样用
cnt计数器驱动 32 轮。当text_isvalid_in上升沿到来,先加载明文text_i到X[0]–X[3];随后每轮计算:{b0,b1,b2,b3} = X[cnt+1] ^ X[cnt+2] ^ X[cnt+3] ^ rk[cnt]; // 轮函数输入:前 3 个状态字 + 轮密钥 A = {Sbox[b0],Sbox[b1],Sbox[b2],Sbox[b3]}; // Sbox 替换 X[cnt+4] = X[cnt] ^ A ^ ({A[29:0],A[31:30]}) ^ ({A[21:0],A[31:22]}) ^ ({A[13:0],A[31:14]}) ^ ({A[7:0],A[31:8]}); // L 线性变换:A^(A<<2)^(A<<10)^(A<<18)^(A<<24)注意:L 变换的 4 次循环左移位数(2,10,18,24)必须严格匹配标准,且移位操作必须用
{}拼接实现循环移位,否则输出密文必然错误。
2.3 top_level 顶层模块:胶合层,把 APB_slave 和 SM4_U 焊死成一个 IP
top_level是最薄的一层,但它决定了整个 IP 的可用性。它不做任何逻辑,只做信号连接和端口映射:
module top_level( input PSELx, PENABLE, [31:0] PADDR, PWRITE, PRESETN, PCLK, [31:0] PWDATA, output [31:0] prdata ); wire [127:0] KEY, DATA_i, SM4_DATA; // 内部连线 wire text_isvalid_in, mk_isvalid_in; // 控制信号 apb_slave s1 ( .PSELx(PSELx), .PENABLE(PENABLE), .PADDR(PADDR), .PWRITE(PWRITE), .PRESETN(PRESETN), .PCLK(PCLK), .PWDATA(PWDATA), .prdata(prdata), .SM4_DATA(SM4_DATA), .KEY(KEY), .DATA_i(DATA_i), .text_isvalid_in(text_isvalid_in), .mk_isvalid_in(mk_isvalid_in) ); SM4_U s2 ( .clk_i(PCLK), .rst_n_i(PRESETN), .mk_i(KEY), .text_i(DATA_i), .mk_isvalid_in(mk_isvalid_in), .text_isvalid_in(text_isvalid_in), .dat_o(SM4_DATA), .ready_out() // ready_out 未连接,因本设计不向外暴露忙信号 ); endmodule提示:
ready_out在SM4_U中被计算出来(cnt==31时拉高),但top_level并未将其引出。这意味着主机必须靠“读操作延时”来等待加密完成(见测试代码#3500延时),这是简化设计的取舍。若需精确忙信号,需在apb_slave中增加ready_out输入,并在prdata读取前加状态判断。
3. 运行实操:从仿真到上板,三步走通全流程(含 testbench 逐行解析)
光看代码不跑等于没看。这个协处理器提供了完整的验证环境:top_level_tb.v是顶层测试平台,SM4.v是算法模块自测,两者互补。下面以top_level_tb.v为例,手把手带你跑通一次加密流程,重点讲清每个#延时背后的硬件意义。
3.1 测试平台结构:复位、写明文密钥、读密文三阶段
top_level_tb.v的结构非常经典,分为三个时间阶段:
- 阶段一:复位(
#100 PRESETN = 0; #100 PRESETN = 1;):拉低复位 100ns,再拉高,确保所有寄存器进入确定初态; - 阶段二:写入(
repeat(8)循环):连续 8 次写操作,前 4 次(i=0到3)写明文到0x00–0x0c,后 4 次(i=4到7)写密钥到0x10–0x1c; - 阶段三:读出(
repeat(4)循环):连续 4 次读操作,从0x00–0x0c读出 4 个 32 位密文字。
3.2 写操作时序详解:为什么PENABLE要延迟 40ns?
关键代码段:
repeat(8) begin PWDATA = mem_word[i]; // 准备数据 PWRITE = 1; // 设为写模式 PSELx = 1; // 片选有效 PENABLE = #40 1; // 使能信号在 40ns 后拉高! #40 PSELx = 0; // 40ns 后片选撤回 PENABLE = 0; // 使能同时撤回 #80 // 等待 80ns,再写下一个 PADDR = PADDR + 8'h04; i = i + 4'b1; end这段代码模拟了 APB 总线的典型时序:
PENABLE必须在PSELx有效后的第二个时钟周期才拉高(APB 协议规定),#40对应 2 个时钟周期(PCLK周期为 40ns,因#20 PCLK = ~PCLK);PSELx和PENABLE必须在同一个时钟周期内保持稳定,#40 PSELx = 0和PENABLE = 0是在同一时刻执行,保证时序干净;#80是写操作间的最小间隔,确保前一次写入的text_isvalid_in/mk_isvalid_in信号已被 SM4_U 采样并开始计算。
避坑:如果把
PENABLE = #40 1;写成PENABLE = 1; #40;,会导致PENABLE在PSELx之前就拉高,违反 APB 协议,apb_slave模块会忽略这次写请求,现象是prdata一直为 0。
3.3 读操作时序详解:为什么读之前要等#3500?
读操作前有一段长延时:
#3500 // 等待加密完成! repeat(4) begin PWRITE = 0; // 设为读模式 PSELx = 1; PENABLE = #40 1; #40 PSELx = 0; PENABLE = 0; #80 PADDR = PADDR + 8'h04; end#3500是关键——它必须大于 SM4_U 完成 32 轮计算所需的时间。我们来算一笔账:
SM4_U中cnt从 0 计到 31,共 32 个时钟周期;- 每个周期
PCLK=40ns,理论最短耗时32×40=1280ns; - 但实际还要加上
text_isvalid_in采样延迟、组合逻辑传播延迟、DATA_ENC锁存时间,#3500(3.5us)是保守余量,确保万无一失。
如果你的 FPGA 时钟频率更高(比如 100MHz,周期 10ns),则
#3500要相应缩短为#400左右,否则会浪费大量等待时间。
3.4 验证结果怎么看:$display不是终点,波形图才是真相
测试结束时#250 $display("all ok");只是告诉你仿真没挂,真正验证是否正确,必须看波形:
- 打开 VCS/ModelSim 波形窗口,添加信号:
PADDR,PWDATA,prdata,text_isvalid_in,mk_isvalid_in,DATA_ENC; - 观察
text_isvalid_in是否在第 4 次写(PADDR=0x0c)时精准拉高一个周期; - 观察
mk_isvalid_in是否在第 8 次写(PADDR=0x1c)时精准拉高一个周期; - 观察
DATA_ENC是否在#3500后稳定输出非零值; - 最后,将
prdata四次读出的值(0x0c,0x08,0x04,0x00)拼成 128 位,与标准 SM4 测试向量比对(如明文0123...3210+ 密钥0123...3210应得密文681edf34d206965e86b3e94f536e4246)。
提示:
SM4.v自测模块用delayx(8)模拟延时,它比#更精确(基于时钟边沿),适合调试算法逻辑;而top_level_tb.v用#延时,更贴近真实总线行为。两者结合,能快速定位问题是出在算法还是接口。
4. 避坑指南:五个血泪经验总结,全是仿真跑不通时抓耳挠腮换来的后悔药
这个协处理器代码虽短,但涉及总线协议、密码算法、时序约束三重复杂度,新手极易在以下环节翻车。以下是我在某公司数字 IC 项目中踩过的坑,按出现频率排序,每条都附带现场复现方法和根治方案。
4.1 现象:prdata始终为0x00000000,DATA_ENC无变化
原因:text_isvalid_in或mk_isvalid_in信号未被SM4_U正确采样。常见于apb_slave中text_isvalid_in = 1'b1的赋值位置错误——它被写在了always @(posedge PCLK or negedge PRESETN)块内,但该块是时序逻辑,text_isvalid_in是reg类型,其值在PCLK上升沿才更新;而SM4_U的text_isvalid_in输入是电平敏感的,必须在PCLK上升沿之前就稳定为高。
解决:将text_isvalid_in和mk_isvalid_in改为wire类型,用assign语句在组合逻辑中驱动:
// 在 apb_slave 模块中,删除 always 块内的 text_isvalid_in = 1'b1; // 新增 assign 语句: assign text_isvalid_in = (PSELx && PENABLE && PWRITE && (PADDR[7:0] == 8'h0c)); assign mk_isvalid_in = (PSELx && PENABLE && PWRITE && (PADDR[7:0] == 8'h1c));这样text_isvalid_in在PADDR==0x0c且写使能时,立即变为高电平,确保SM4_U在下一个PCLK上升沿就能捕获。
4.2 现象:DATA_ENC输出乱码,与标准向量差几个 bit
原因:Sbox 查表或线性变换的位宽/移位方向错误。最常见的是Sbox[a0]中a0是 8 位,但Sbox数组索引写成了a0[7:0](冗余),或({B[18:0],B[31:19]})写成了({B[19:0],B[31:20]})(移位位数错 1)。
解决:打开SM4_U模块,逐行核对 Sbox 初始化值(共 256 行,别用 Ctrl+F 搜,用 diff 工具比对标准 Sbox 表);对线性变换,用$display打印中间变量:
// 在 SM4_U 的 always 块中临时添加: if(cnt == 0 && ready_out == 0) begin $display("cnt=0: a0=%h, Sbox[a0]=%h", a0, Sbox[a0]); $display("LN input B=%h, output K[4]=%h", B, K[4]); end对比标准计算过程,定位哪一步出错。
4.3 现象:仿真能过,上 FPGA 后加密失败
原因:未添加时序约束。PCLK频率提高后,apb_slave中case(PADDR[7:0])的组合逻辑路径变长,可能不满足建立时间(setup time)。
解决:在 XDC/SDC 文件中添加关键路径约束:
# 约束 PADDR 到 data1/data2 等寄存器的路径 set_max_delay -from [get_ports PADDR] -to [get_cells -hier "*data1*"] 5.0 set_max_delay -from [get_ports PADDR] -to [get_cells -hier "*data2*"] 5.0 # 约束 PWDATA 到内部寄存器的路径 set_max_delay -from [get_ports PWDATA] -to [get_cells -hier "*data*"] 5.0注意:5.0ns 是示例值,需根据你的目标频率(如 100MHz→10ns 周期)和工具报告的 slack 值调整。
4.4 现象:读prdata时,0x00地址返回的是DATA_ENC[31:0]而非[127:96]
原因:apb_slave中读case语句的地址映射写反了。原文代码8'h00: prdata<=DATA_ENC[127:96];是正确的(0x00读高位),但有人复制时手误写成8'h00: prdata<=DATA_ENC[31:0];。
解决:用grep -n "prdata<=" apb_slave.v定位所有读操作行,对照原文逐字检查:
// 正确映射(低位地址读高位数据): 8'h0c: prdata<=DATA_ENC[31:0]; // 0x0c → 低 32bit 8'h08: prdata<=DATA_ENC[63:32]; // 0x08 → 中低 32bit 8'h04: prdata<=DATA_ENC[95:64]; // 0x04 → 中高 32bit 8'h00: prdata<=DATA_ENC[127:96]; // 0x00 → 高 32bit4.5 现象:$stop后波形显示prdata有值,但top_level_tb.v中#250 $display未打印
原因:$display语句在initial块末尾,但仿真器在$stop前可能未刷新输出缓冲区。
解决:在$display后加$fflush强制刷新:
#250 $display("all ok"); $fflush; // 确保字符串立即输出到终端 $stop;5. 二次开发实战:从“能用”到“好用”,三个改造方向及验证方法
拿到这个协处理器,第一步是跑通,第二步就得让它适配你的项目。我经历过多个 FPGA 加密模块集成项目,总结出三个最实用的改造方向:加状态寄存器、支持 ECB/CBC 模式、接入 AXI 总线。每个方向我都给出可落地的 Verilog 代码、验证技巧,以及一个血泪教训。
5.1 方向一:加状态寄存器,让主机知道“我忙完了”
原设计靠#3500延时读密文,粗暴但不可靠。工业级 IP 必须提供busy或ready信号。改造思路:在apb_slave中新增一个 32 位状态寄存器STATUS,地址0x20,bit0 为busy,bit1 为ready(加密完成且数据有效)。
改造代码(在apb_slave模块中添加):
// 新增寄存器 reg [31:0] STATUS; reg busy_reg; // 内部 busy 信号 reg ready_reg; // 内部 ready 信号 // 在 always @(posedge PCLK or negedge PRESETN) 块中,复位部分加: if(!PRESETN) begin // ...原有复位代码... STATUS <= 32'h0; busy_reg <= 1'b0; ready_reg <= 1'b0; end else begin // ...原有写/读逻辑... // 新增:busy/ready 更新逻辑 if(text_isvalid_in || mk_isvalid_in) begin // 任一有效信号到来,busy 置 1 busy_reg <= 1'b1; end if(ready_out) begin // SM4_U 的 ready_out 拉高时,置 ready=1, busy=0 ready_reg <= 1'b1; busy_reg <= 1'b0; end // 更新 STATUS 寄存器 STATUS[0] <= busy_reg; STATUS[1] <= ready_reg; end // 在读操作 case 中,加地址 0x20: 8'h20: prdata <= STATUS;验证方法:修改top_level_tb.v,在写完密钥后,不再#3500,而是轮询STATUS:
// 替换原来的 #3500 integer status_val; status_val = 0; while((status_val & 2'b10) != 2'b10) begin // 等待 bit1=1 PADDR = 8'h20; PWRITE = 0; PSELx = 1; PENABLE = #40 1; #40 PSELx = 0; PENABLE = 0; #80; status_val = prdata; end // 此时 ready=1,可以安全读密文教训:第一次加
STATUS时,我把busy_reg和ready_reg做成了wire用assign,结果busy_reg在ready_out拉高瞬间和ready_reg同时变,导致STATUS[0]和[1]短暂同为 1,主机误判。后来改成reg+ 时序逻辑,确保busy先清零再置ready,问题消失。从那以后我每次加状态机,都强制走一遍“busy→ready”转换的波形,看时序是否干净。
5.2 方向二:支持 CBC 模式,不止于 ECB
原设计是 ECB(电子密码本),明文分组独立加密,安全性弱。CBC(密码分组链接)更常用,需要 IV(初始向量)和链式反馈。改造核心:在SM4_U中增加IV寄存器和 XOR 逻辑。
改造代码(在SM4_U模块中):
// 新增端口 input [127:0] iv_i; // IV 输入 input iv_isvalid_in; // IV 有效信号 // 新增寄存器 reg [127:0] IV_reg; // 在 mk_isvalid_in 处理块后,加 IV 加载: if(iv_isvalid_in) IV_reg <= iv_i; // 在加密轮函数中,第一轮输入改为 X[0]^IV: if(text_isvalid_in) begin {X[0],X[1],X[2],X[3]} = text_i; // 第一轮:X[0] ^= IV_reg X[0] <= X[0] ^ IV_reg; end // 后续轮函数不变,但最后一轮输出后,需将密文作为下一分组的 IV: if(carry == 1'b1) begin dat_o = {X[35],X[34],X[33],X[32]}; // 下一分组的 IV = 本分组密文 IV_reg <= dat_o; end验证方法:用标准 CBC 测试向量(如 NIST SP800-38A)验证。关键技巧:在top_level中,将iv_i连接到一个新 APB 地址(如0x30),并新增iv_isvalid_in信号,由apb_slave在PADDR==0x30时置高。
5.3 方向三:升级为 AXI4-Lite 总线,兼容现代 SoC
APB 简单,但 AXI4-Lite 是 ARM 生态主流。改造原则:不重写算法,只换接口层。axi_slave模块需实现AWVALID/READY,WVALID/READY,BVALID,ARVALID/READY,RVALID/READY五通道握手。
最小化改造:用开源 AXI4-Lite Slave IP(如 opencores.org 的 axi_lite_slave)替代apb_slave,其输出wdata,wstrb,awaddr等信号,经简单译码后,映射到原apb_slave的PWDATA,PADDR,PWRITE等端口。例如:
// axi_slave 输出 wdata -> apb_slave 的 PWDATA // axi_slave 输出 awaddr[11:2] -> apb_slave 的 PADDR[7:0] (AXI 地址 4KB 对齐,低 2 位为 0) // axi_slave 输出 awvalid & awready -> 生成 apb_slave 的 PSELx & PENABLE验证方法:用 Xilinx Vivado 的 AX
本文还有配套的精品资源,点击获取