简介:面向FPGA开发者和密码学学习者,这份资源是一套基于Verilog的AES-128加密算法硬件实现完整工程。代码将字节替换、行移位、列混淆与密钥扩展等核心环节拆分为独立功能模块,并附带testbench测试平台,便于在仿真环境中对照算法流程逐级验证。工程整体共230个文件,压缩包约119.72MB,除Verilog源码与VHDL文件外,还包含Quartus工程配置、ModelSim仿真波形和库文件、备份源文件及readme说明,目录结构完整,可直接打开进行综合、仿真与调试。目前已有1202人学习下载,适合硬件安全入门者、数字IC设计学生和FPGA项目开发者参考,既能加深对AES标准流程的理解,也可作为实际工程的移植起点。
1. FPGA上的AES-128硬加密:一个能直接跑通全流程的Verilog工程
当你的主控芯片跑软件AES加密每秒只能处理几十KB,而同样的算法放到FPGA里用Verilog实现,吞吐可以轻松上Gbps时,你会理解为什么很多通信板卡和存储设备都在用硬件加密。这套代码不是教学里的片段截图,而是一个完整的AES-128加密核:顶层状态机控制10轮迭代,S盒用查表实现,密钥扩展一次生成全部轮密钥,配套行为级仿真的Testbench,导入Vivado或ModelSim就能跑出与标准向量一致的密文。适合正在做数据链路加密的嵌入式或FPGA工程师,也适合想搞懂密码算法硬件表达方式的学生。下面按架构、代码、仿真、避坑、改造的顺序,逐步拆解。
2. 系统架构:一个128位数据通路是怎么组织起来的
2.1 顶层接口设计:状态机如何调度10轮迭代
一个AES-128加密核的顶层接口说复杂也复杂,因为要处理握手信号;说简单也简单,因为数据位宽固定是128位。我习惯把顶层接口分成三组:时钟复位、输入握手、输出握手。
module aes_top ( input wire clk, // 工作时钟,建议100MHz以上 input wire rst_n, // 同步低电平复位 input wire start, // 启动加密,单周期高电平脉冲 input wire [127:0] plaintext, // 128位明文输入 input wire [127:0] key, // 128位主密钥 output reg ready, // 高电平表示可接收新任务 output reg done, // 加密完成标志 output reg [127:0] ciphertext // 128位密文输出 );这里关键是ready和done这对握手信号。ready拉高表示内核空闲,可以接受新的输入;start拉高后内核锁存明文和密钥,ready拉低进入状态机流转;加密完成后done拉高一个周期,ciphertext在该周期稳定出现在总线上。整套控制用一段式状态机实现,状态转移如下:
localparam IDLE = 3'd0; localparam INIT = 3'd1; localparam ROUND = 3'd2; localparam DONE_S = 3'd3; reg [2:0] state; reg [3:0] round_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; round_cnt <= 4'd0; ready <= 1'b1; done <= 1'b0; end else begin case (state) IDLE: begin if (start) begin state <= INIT; ready <= 1'b0; end end INIT: begin // 第0轮密钥加(AddRoundKey) state <= ROUND; round_cnt <= 4'd1; end ROUND: begin if (round_cnt == 4'd10) begin state <= DONE_S; done <= 1'b1; end else begin round_cnt <= round_cnt + 1'b1; end end DONE_S: begin done <= 1'b0; ready <= 1'b1; state <= IDLE; end endcase end endround_cnt的含义是「当前正在进入的轮数」,从1数到10。第10轮和前面9轮的关键区别是:第10轮不执行MixColumns,所以轮函数内部要根据round_cnt决定是否走列混合路径。这个判断一般放在round_function模块里,用信号wire skip_mix = (round_cnt == 4'd10)传入。
再说输入锁存。start拉高那一拍,明文和密钥必须已经稳定在端口上,否则锁存的就是中间态。新手最容易踩这个时序点:前一拍数据刚赋值,后一拍就拉start,综合出来可能没问题,但仿真里由于非阻塞赋值的延迟,采样到的就是旧值。后面避坑章节会专门展开。
2.2 模块拆分:S盒、密钥扩展、轮函数怎么分工
一个完整AES-128加密核至少需要四个子模块:
- sbox:256字节的S盒查找表,输入一个字节输出一个字节,纯组合逻辑。
- key_expansion:从128位主密钥生成11个128位轮密钥。
- round_function:把状态矩阵依次做SubBytes、ShiftRows、MixColumns、AddRoundKey。
- aes_top:调度以上模块,维护状态机和轮计数。
四个模块的连接关系是这样的:明文先与第0轮密钥异或,然后进入round_function循环10次,最后一次跳过MixColumns,最终输出密文。密钥扩展模块在主密钥valid时一次性算完所有轮密钥,存进寄存器数组,后续每轮只需按索引取出对应密钥,不再占用运算周期。
有人会问:为什么不逐轮生成轮密钥?这里有个面积和时序的权衡。密钥扩展一次性展开的代价是11个128位寄存器,换来的是状态机极简、关键路径短;逐轮生成的代价是面积小,但每一轮都要等密钥扩展的组合逻辑稳定,占用路径变长。作为入门级的可综合实现,我倾向于前者。时序好收敛,代码也好Debug——加密结果不对时,先在波形里检查11个轮密钥的值与FIPS-197标准附录是否一致,就能省掉一半排查时间。
2.3 工程目录与文件清单
下载解压后是这样一个标准RTL工程布局:
aes128_fpga/ ├── rtl/ │ ├── aes_top.v // 顶层状态机与数据通路 │ ├── sbox.v // S盒查找表,256字节case展开 │ ├── key_expansion.v // 密钥扩展,生成11个轮密钥 │ ├── round_function.v // 轮函数:SubBytes+ShiftRows+MixColumns │ └── mix_columns.v // MixColumns专用模块 ├── tb/ │ └── aes_tb.v // FIPS-197标准向量测试平台 ├── scripts/ │ ├── sim.do // ModelSim/Questa仿真脚本 │ └── vivado_sim.tcl // Vivado行为仿真脚本 └── doc/ └── design_note.md // 设计说明与字节序约定rtl目录下5个文件就是整个加密核的全部代码,tb目录是自检平台,doc里的design_note记录了字节序约定——这个文件别看小,后面排查密文对不上问题全靠它。design_note里写了三条硬性约定:明文按FIPS-197的字节顺序排列,即明文第0字节对应plaintext[127:120];轮密钥数组中第0个元素为主密钥本身;所有模块只允许在时钟上升沿更新状态,S盒与MixColumns为纯组合逻辑。
这三个约定贯穿整个工程。任何一次改动若打破了约定,密文错乱几乎是必然的,而定位问题的时间成本远大于遵守约定的成本。
3. 核心模块代码讲解:S盒、密钥扩展与轮函数的实现细节
3.1 S盒查表模块:用查表把非线性变换变成纯数据通路
AES的S盒是一个256字节的非线性映射表。实现方式五花八门,但在入门工程里查表是最稳的。这里用case语句展开,既能在仿真里看到完整表项,也能被综合器映射成LUT-ROM。
module sbox ( input wire [7:0] addr, output reg [7:0] data ); always @(*) begin case (addr) 8'h00: data = 8'h63; 8'h01: data = 8'h7c; 8'h02: data = 8'h77; 8'h03: data = 8'h7b; 8'h04: data = 8'hf2; 8'h05: data = 8'h6b; 8'h06: data = 8'h6f; 8'h07: data = 8'hc5; 8'h08: data = 8'h30; 8'h09: data = 8'h01; 8'h0a: data = 8'h67; 8'h0b: data = 8'h2b; // 其余244项在源码sbox.v中完整给出,此处省略 default: data = 8'h00; endcase end endmodule参数说明:addr是待替换的字节,data是替换结果。整个模块是纯组合逻辑,不消耗时钟资源。用case而不是function的原因很简单:case在模块边界上更清晰,仿真时可以单独对sbox加激励验证,综合时也可以给sbox单独加综合属性。
sbox的输入、输出数据位宽都是8位,与其他模块的连接方式是为每个状态矩阵字节实例化一次sbox模块。例如在round_function中,用16个sbox实例并行完成所有字节的替换。这是AES硬件实现的基本手法:面积换并行度。如果只用1个sbox串行处理16个字节,吞吐会降到十六分之一,但控制逻辑复杂不少。在这个工程里选择16个并行实例,数据通路整齐,时序也好估算。
3.2 密钥扩展:一次性生成44个32位字的核心逻辑
密钥扩展的输入是128位主密钥,输出是11个128位轮密钥。标准算法把密钥看成4个32位字w[0]到w[3],之后每个新字w[i]依赖前一个字和前4个字。生成规则是:当索引i能被4整除时,w[i] = w[i-4] ^ SubWord(RotWord(w[i-1])) ^ Rcon[i/4];其余情况,w[i] = w[i-4] ^ w[i-1]。
// 密钥扩展函数:RotWord是循环左移8位,SubWord是对4个字节查S盒 function [31:0] rotword; input [31:0] w; begin rotword = {w[23:0], w[31:24]}; end endfunction // Rcon常量表,用于消除对称性 reg [7:0] rcon [0:10]; initial begin rcon[0] = 8'h00; rcon[1] = 8'h01; rcon[2] = 8'h02; rcon[3] = 8'h04; rcon[4] = 8'h08; rcon[5] = 8'h10; rcon[6] = 8'h20; rcon[7] = 8'h40; rcon[8] = 8'h80; rcon[9] = 8'h1b; rcon[10] = 8'h36; end在实际的key_expansion.v里,w[0]到w[43]这44个32位字用generate块展开成寄存器数组,综合后就是11个128位轮密钥寄存器。generate块的好处是代码量小、参数化程度高,改动轮数只需调整参数;坏处是仿真时单步调试不太直观,所以design_note里建议先用标准向量验证整体结果,确认不对再回到generate块里查具体某一轮的字值。
参数说明:rcon是轮常量,只在i能被4整除时用到。之所以需要rcon,是为了避免4个32位字在轮间形成对称性,防止密钥相关性攻击。这个表是AES标准写死的,不能随意改动。初次做这个工程的人最容易犯的错误,是把rcon[i]写成了i本身——结果自然是除了第一轮,后面每一轮密钥都对不上。
3.3 轮函数组合逻辑:SubBytes、ShiftRows、MixColumns、AddRoundKey的串联
轮函数是AES的核心运算,每轮按顺序执行四个变换。SubBytes是逐字节S盒替换;ShiftRows是把状态矩阵的第0行左移0位、第1行左移1位、第2行左移2位、第3行左移3位;MixColumns是对每一列做GF(2^8)域上的矩阵乘法;AddRoundKey是把状态与轮密钥做按位异或。
module round_function ( input wire [127:0] state_in, input wire [127:0] round_key, input wire skip_mix, // 1表示跳过MixColumns,用于最后一轮 output wire [127:0] state_out ); // 第一步:SubBytes,16字节并行替换 wire [127:0] sub_out; genvar i; generate for (i = 0; i < 16; i = i + 1) begin : sbox_inst sbox u_sbox ( .addr(state_in[127 - 8*i -: 8]), .data(sub_out[127 - 8*i -: 8]) ); end endgenerate // 第二步:ShiftRows,按行循环左移 wire [127:0] shift_out; // 行0:字节0,1,2,3不移位 // 行1:字节4,5,6,7左移1位 -> 变为 5,6,7,4 // 行2:字节8,9,10,11左移2位 -> 变为 10,11,8,9 // 行3:字节12,13,14,15左移3位 -> 变为 15,12,13,14 assign shift_out[127:96] = sub_out[127:96]; assign shift_out[95:64] = {sub_out[87:80], sub_out[95:88]}; assign shift_out[63:32] = {sub_out[47:40], sub_out[63:56]}; assign shift_out[31:0] = {sub_out[31:24], sub_out[63:32]}; // 第三步:MixColumns,按列混合 wire [127:0] mix_out; mix_columns u_mix ( .state_in(shift_out), .state_out(mix_out) ); // 第四步:AddRoundKey assign state_out = (skip_mix ? shift_out : mix_out) ^ round_key; endmodule这里有个设计取舍值得说。如果不管最后一轮的特殊性,直接把mix_out和round_key异或,最后一轮结果必然错误。所以用skip_mix信号在MixColumns和ShiftRows的输出之间做了选择。综合工具会把这个选择器优化成多路选择器,面积开销不超过几个LUT,但逻辑表达上清晰很多。
MixColumns的域乘法是另一个容易踩坑的地方。GF(2^8)上的乘法不能简单用Verilog的乘法运算符,必须用xtime算法实现:乘2等价于左移一位后按不可约多项式0x1b规约。
// GF(2^8)域上乘以2的xtime函数 function [7:0] xtime; input [7:0] b; begin xtime = {b[6:0], 1'b0} ^ (b[7] ? 8'h1b : 8'h00); end endfunction // 列混合的核心公式:对每一列4个字节做线性组合 // out0 = 2*in0 ^ 3*in1 ^ in2 ^ in3 // out1 = in0 ^ 2*in1 ^ 3*in2 ^ in3 // out2 = in0 ^ in1 ^ 2*in2 ^ 3*in3 // out3 = 3*in0 ^ in1 ^ in2 ^ 2*in33乘以某个字节可以表示为xtime(b) ^ b,因为GF(2^8)中乘以3等于乘以2再加自身。这样整个MixColumns就不需要真正的乘法器,只需要移位、异或和条件判断,在FPGA里映射成少量LUT。很多第一次接触这个算法的人会直接用乘法运算符,综合器大概率给你生成乘法器IP核,占用DSP资源且时序不可控——这是完全没必要的浪费。
4. 仿真验证与工程运行:从Testbench到波形确认
4.1 Testbench怎么写:用FIPS-197标准向量做输入激励
FIPS-197附录C提供了一个完整的AES-128测试向量,这是验证AES实现正确性的黄金标准。密钥是000102030405060708090a0b0c0d0e0f,明文是00112233445566778899aabbccddeeff,对应的密文是69c4e0d86a7b0430d8cdb78070b4c55a。任何AES实现的仿真如果在这个向量上输出不一致,可以断定代码有错误。
module aes_tb; reg clk; reg rst_n; reg start; reg [127:0] plaintext; reg [127:0] key; wire ready; wire done; wire [127:0] ciphertext; aes_top uut ( .clk(clk), .rst_n(rst_n), .start(start), .plaintext(plaintext), .key(key), .ready(ready), .done(done), .ciphertext(ciphertext) ); initial begin clk = 0; forever #5 clk = ~clk; // 10ns周期,100MHz end initial begin rst_n = 0; #20 rst_n = 1; // 加载FIPS-197标准向量 plaintext = 128'h00112233445566778899aabbccddeeff; key = 128'h000102030405060708090a0b0c0d0e0f; @(posedge clk); start = 1; @(posedge clk); start = 0; // 等待done信号 wait(done == 1); // 检查密文 if (ciphertext !== 128'h69c4e0d86a7b0430d8cdb78070b4c55a) $display("TEST FAILED: %h", ciphertext); else $display("TEST PASSED"); $finish; end initial begin $dumpfile("aes_tb.vcd"); $dumpvars(0, aes_tb); end endmodule这个Testbench的时序很关键。start信号在时钟上升沿后被拉高,然后在下一个时钟上升沿拉低,形成一个单周期脉冲。这样做的原因是:在真实硬件里start通常来自上游的状态机,脉冲宽度必须稳定为一个时钟周期,过长会触发多次启动。逻辑说明上,这里用@(posedge clk)与wait(done)的组合,确保所有检查都发生在时钟沿之后、寄存器输出稳定的窗口内。
参数说明:时钟周期是10ns,仿真时间约200ns就能跑完一轮完整加密。如果使用$dumpfile生成VCD文件,还能用GTKWave打开波形做后续分析。
4.2 两种主流仿真流程:Vivado工程脚本与ModelSim批处理
拿到源码后,跑行为仿真有两种常见路径。如果你用的是Vivado,打开行为仿真界面,把rtl目录下5个.v文件和tb目录下1个.v文件全部添加到工程,选择aes_tb为顶层仿真模块,运行即可。脚本方式更省事,源码里自带的vivado_sim.tcl做了这件事:
create_project -in_memory aes_sim add_files -norecurse rtl/aes_top.v rtl/sbox.v rtl/key_expansion.v \ rtl/round_function.v rtl/mix_columns.v add_files -norecurse tb/aes_tb.v set_property top aes_tb [get_filesets sim_1] launch_simulation -mode behavioral如果你习惯用ModelSim,直接跑sim.do脚本:
vlib work vlog rtl/*.v tb/aes_tb.v vsim -novopt work.aes_tb add wave -hex /aes_tb/clk /aes_tb/start /aes_tb/done /aes_tb/ciphertext run -all这两条路线跑出来的结果应该完全一致。我的经验是:库里有一大批验证脚本时,ModelSim这种批处理方式更适合回归;而Vivado的优势在于波形界面和综合网表联仿方便。不管用哪种,第一次跑仿真前先确认所有文件都编译通过,再看transcript窗口里有没有打印TEST PASSED。
4.3 波形怎么读:从start到done的关键时间点
仿真波形里最有价值的信号就是start、ready、done和ciphertext。正确的时序应该是:start拉高的下一个时钟沿,ready从1拉低;之后经过11轮运算,done拉高一个时钟周期,同时ciphertext输出正确密文;done拉低后,ready重新拉高。
这段波形读起来有个小技巧:不要盯着ciphertext总线的十六进制值看到底,而是把sbox的输出、shift_rows的输出分别加到波形窗口里,按10轮迭代逐级追踪。如果某一轮的中间值和FIPS-197标准附录里列出的中间状态不一致,问题大概率出在那一段组合逻辑上。
还有一个容易被忽略的细节:done信号拉高时ciphertext是不是稳定有效。有些实现会在done拉高前一个周期就提前改变了ciphertext,这会导致外部电路采样到错误的密文。正确做法是ciphertext在done拉高前至少一个时钟周期就稳定下来,并在done拉低后才允许改变。这个时序要求在Testbench里可以用如下断言辅助检查:
always @(negedge done) begin if (ciphertext !== 128'h69c4e0d86a7b0430d8cdb78070b4c55a) $display("ERROR: ciphertext not stable at done deassertion"); end5. 常见问题与避坑指南:RTL实现AES的五个血泪教训
5.1 现象:S盒综合后路径过长,频率疯狂下跌
一上来直接综合,时序报告里S盒到MixColumns的关键路径有十几纳秒,100MHz跑不住。原因是256项case语句被综合成了多级逻辑,并且和后续的MixColumns组合逻辑串联在一起。解决思路是把S盒的实现方式改一下:在Vivado里把sbox模块加上(* rom_style = "distributed" *)综合属性,或者在源代码里手动声明为reg数组并用initial赋值,让综合器推断成分布式RAM。再一个办法是在S盒输出和MixColumns输入之间插一级寄存器,把组合路径切开。代价是延后一个时钟周期出结果,换来的是频率可能从80MHz提升到150MHz以上。
5.2 现象:密文输出前几轮正常,后面全错
这种问题九成出在密钥扩展上。现象是前3轮的轮密钥值对得上,从第4轮开始和FIPS-197附录里的展开密钥不一致。原因通常有两种:一种是rcon常量表写错,比如rcon[4]写的0x10而不是标准值0x08;另一种是密钥扩展的generate块索引范围写错,导致某几轮读到了未初始化的寄存器值。排查方法是在波形里把round_keys数组按轮打印出来,和FIPS-197附录A里给的密钥展开值逐字节比对,第一处不同的位置就是问题所在。
5.3 现象:仿真波形完全正确,下载到FPGA后输出乱码
仿真过了、上板挂了,这类玄学问题在FPGA工程里太常见了。最典型的原因是S盒用initial块初始化了reg数组,这种写法在仿真里完全没问题,但被综合成硬件时初始化逻辑可能被优化掉,或者存在上电时序问题。解决方法是把S盒改成case语句的纯组合逻辑实现,确保综合后是LUT而不是依赖初始值的RAM。另外把复位方式从异步复位改成同步复位,并且把复位信号从外部引入后先做一次同步打拍,也能减少上板后的不确定行为。
5.4 现象:外部启动信号偶发抖动,导致加密结果偶发出错
外部给过来的start信号和FPGA内部时钟不是同一个时钟域的,直接接进状态机可能会采到亚稳态。现象是测试100次有1到2次密文错误。解决思路是给所有外部输入的异步信号加两级同步打拍后再进入状态机,代码就是两个级联的触发器:
reg start_sync1, start_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin start_sync1 <= 1'b0; start_sync2 <= 1'b0; end else begin start_sync1 <= start; start_sync2 <= start_sync1; end end wire start_synced = start_sync2;参数说明:两级同步器能把亚稳态的发生概率降低到可以忽略的程度,但会增加两个时钟周期的延迟。在低速控制信号上这个延迟完全可接受,但如果要同步的是数据总线,就需要改用异步FIFO或者握手协议,不能简单用打拍方式。这条经验对所有跨时钟域控制信号都适用,不只是AES工程。
5.5 现象:密文结果和参考向量对比,字节序完全反转
这个问题最坑,因为代码逻辑层面找不出错,只能从数据流逐级比对。现象是密文的前16个字节按字节反转后恰好等于参考向量。原因在于明文输入、轮密钥输出、S盒索引这三个环节的字节序没有统一。比如明文第0字节被放到了plaintext[7:0],而S盒第一个处理的是plaintext[127:120],这样在数据通路上就自然产生了完全反向的密文。解决方法是严格按design_note里的约定,把明文第0字节放在最高字节位置,并且在代码里搜索所有[127:120]和[7:0]的位宽切片,确保全部切片方向一致。从那以后我每次拿到新的AES代码,第一件事不是跑仿真,而是通读一遍所有位切片的方向,把索引方向统一好再动手改逻辑,能省一晚上调试时间。
6. 二次开发:从单一加密核到可复用的加解密IP
6.1 加解密一体:逆S盒与逆轮函数的复用策略
AES解密是加密的逆过程,轮函数里四个变换都要反过来执行。最省面积的实现方式是加密解密共用一套S盒,通过加一个控制信号选择正向还是逆向查找表。逆S盒需要单独写一份256字节的表,面积翻倍但逻辑清晰。我一般会把sbox模块增加一个inv_mode端口,内部用两个case块分别输出正向和逆向结果。解密轮函数要用逆MixColumns,它的域乘法系数从[2,3,1,1]变成了[14,11,13,9],实现仍然可以用xtime函数组合出来,但代码量会比加密方向多一些。
6.2 吞吐优化:把迭代架构改成全流水架构
当前这个工程是迭代式架构,一份轮函数硬件被使用10次,吞吐是每10个时钟周期一个128位分组。改成全流水架构后,每个时钟周期都进入一个新的128位明文分组,10级流水线各处理不同轮次的数据,吞吐提升接近10倍。代价是S盒、MixColumns等组合逻辑需要复制10份,面积大约增加8到10倍。改造的关键步骤是:先把轮函数内部组合逻辑的输入输出全部用寄存器对齐,再把密钥扩展改成提前一拍输出对应轮密钥。验证方式还是同一个FIPS-197向量,但需要在连续输入多个分组后检查每一组的密文输出顺序。
6.3 协议标准化:封装成标准总线接口
如果这个核要集成到更大的SoC或DMA链路里,建议把顶层接口从手写握手信号换成通用总线协议。比如封装成AXI-Stream从设备,把start信号映射为tvalid,ready映射为tready,done映射为tlast,这样外部的主设备可以直接用标准驱动读写加密核,链路中还能串接FIFO做数据缓存。封装时要注意AXI-Stream的数据字节序和AES约定是否一致,通常需要在小端还是大端之间做一次转换。我一般会在封装层放一个字节重排模块,把转换逻辑集中在一个地方,避免在多个模块里各自改切片方向。
这套代码的价值不在于算法本身——AES的数学公式很多教科书都有,而在于它把算法落地成了一份能综合、能仿真、能上板的RTL工程。遇到问题时优先怀疑字节序和握手时序,其次检查密钥扩展的rcon和generate索引,按这个顺序排查,大部分坑都能在两小时内定位。希望帮到你。
本文还有配套的精品资源,点击获取