
前阵子手头有个安全相关项目需要高速计算国密SM3哈希。设备端原本用的是ARM核软件实现每秒只能处理大约几十MB数据随着业务并发量上来这个吞吐率成了明显的瓶颈。当时我们团队里正好有几块FPGA板卡闲置就顺手做了个用FPGA硬件加速SM3的完整方案从RTL设计、仿真验证到上板实测都跑了一遍。这篇文章就复盘整个过程中的架构设计思路、关键代码细节以及踩过的坑给准备在FPGA上做国密算法加速的朋友一个可参考的落地案例。1. 为什么要把SM3搬到FPGA上需求分析与方案选型1.1 SM3算法到底卡在哪里先说SM3本身。它是国家标准密码算法输出256位哈希值处理流程分成消息填充、消息扩展、压缩函数三个大块。其中压缩函数要迭代64轮每轮包含多次32位加法、循环左移、异或和逻辑函数运算软件实现通常一个分组512bit需要几百到上千个时钟周期并且还依赖CPU的乱序执行和流水线并行能力。在高并发签名验签、大数据完整性校验、区块链节点同步等场景下纯软件方案的CPU占用率会迅速拉满这时把计算密集部分下沉到硬件就显得非常有必要。FPGA在这类场景里的优势在于它可以用硬件描述语言把SM3的每一轮运算映射成组合逻辑和寄存器用16个周期加载完分组后最快每个时钟周期就能完成一轮压缩计算。也就是说理论上一个512bit分组的哈希计算可以在百来个周期内完成配合100MHz以上的时钟单引擎就能跑到每秒数Gbps的吞吐率整个芯片的CPU可以彻底腾出来做业务逻辑。1.2 硬件加速方案的横向对比做硬件加速摆在桌面上的选择无非是这么几条专用ASIC芯片、GPU通用计算、FPGA可编程逻辑。放在SM3这个具体算法上我做了个简单对比方案开发周期灵活性峰值性能功耗适用场景ASIC很长流片成本高几乎没有最高可做全流水最低海量量产、算法不经常变化GPU较短CUDA/OpenCL较强高但PCIe传输有瓶颈较高大批量并行计算服务器场景FPGA较短RTL/HLS强可改可重配置中等偏高较低中等批量、算法需演进、低延时场景我们的项目有几个特点一是设备端需要嵌入式中等的计算能力不能像服务器一样直接塞GPU二是SM3标准本身已经固定短期内大概率不会改版但也不能完全排除未来要增加新算法或者做协议修改的可能三是延期风险要控制住。基于这三点FPGA几乎是最合适的选项。它不需要像ASIC那样承担高昂的NRE成本又能通过改写RTL在几小时内完成算法升级。1.3 我做架构选型时的考量真正开始动手前我先明确了一个原则FPGA加速不能理解成“把C代码一句句翻译成Verilog”那做出来的东西大概率又慢又费资源。面向硬件做SM3加速核心思路是用并行性和流水线来降低单个分组的处理时延同时尽可能复用数据通路来节省LE/LUT资源。具体来说我做了三方面的架构决策。第一消息扩展和压缩函数并行运行不搞“先算完所有W再进压缩”的串行模式这样能省下至少64个周期。第二压缩函数内部采用每周期算两轮的展开方式在时延和组合逻辑深度之间找到平衡避免单轮周期频率上不去。第三数据接口按块缓存的方式设计搭配简单的握手信号方便后续接入AXI总线或自定义高速接口。这样的架构在单个引擎上吞吐率可超过1Gbps如果有更高需求直接例化多个引擎并行就可以线性扩展。2. 吃透SM3算法硬件实现前必须拆解的细节2.1 搞懂消息填充和分组规则SM3先要把任意长度的消息填充成512bit分组的整数倍填充规则是在消息末尾追加一个“1”再补若干个“0”最后用64bit表示原始消息的比特长度。规则本身不复杂但硬件实现时边界情况非常容易出错比如原始消息长度刚好是448mod512时需要额外多填一个完整分组因为一个分组里装不下“1064bit长度”这么多东西。我做填充模块时把它设计成两种工作模式如果输入数据已经凑满一个512bit分组且不是最后一个分组就直接透传给后续运算如果是最后一个分组就要判断当前最后一组剩余字节数够不够放填充字段。这里的核心代码可以这样写module sm3_padding ( input wire clk, input wire rst_n, input wire in_valid, // 输入数据有效 input wire [511:0] in_data, // 512bit输入分组 input wire [63:0] in_len, // 当前已处理的比特长度 input wire in_last, // 当前是否为最后一组 output reg out_valid, // 输出有效 output reg [511:0] out_block, // 填充后的512bit分组 output reg out_last // 输出是否为最后一个分组 ); // 内部状态0表示直接透传1表示需要填充2表示需要输出第二个分组 reg [1:0] state; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state 2b00; out_valid 1b0; out_last 1b0; out_block 512b0; end else if (in_valid) begin if (!in_last) begin // 中间分组不需要填充直接输出 state 2b00; out_valid 1b1; out_block in_data; out_last 1b0; end else if (in_len[8:0] 9d0) begin // 最后一组恰好512bit没有留任何填充空间要输出两个分组 state 2b10; out_valid 1b1; out_block in_data; // 第一个分组不填充直接输出 out_last 1b0; end else begin // 正常情况在最后一组内完成填充 state 2b00; out_valid 1b1; out_block in_data | padding_mask; // 按长度决定填充字节位置 out_last 1b1; end end end上面代码中padding_mask要根据in_len实时生成把正确的bit位置置1或置0。这里有个我调试时花了不少时间的地方长度字段必须以比特为单位而不是字节。如果你在设计总线接口时只传递了字节数记得在进入核心前左移3位否则计算出来的哈希值永远是错的。2.2 消息扩展与压缩函数的关系SM3的消息扩展把一个512bit的消息分组扩展成132个32bit字W[0..67]和W[0..63]。压缩函数每轮使用W[j]和W[j]参与运算。软件实现里大多数参考代码都会先把W全部算好再进入64轮循环因为软件循环访问数组的成本很低。但硬件里这么做就浪费了宝贵的周期——W[j]的生成和压缩函数的第j轮可以完全重叠前者只比后者提前几个周期即可。具体来说W[0..15]直接来自消息分组从W[16]开始每个W[j]是由W[j-16]、W[j-9]、W[j-3]、W[j-13]、W[j-6]这五个历史字混合生成。你会发现第j轮压缩需要W[j] W[j] ^ W[j4]也就是说当你在做第j轮压缩时W[j4]必须已经算出来了这意味着消息扩展只能比压缩函数提前4轮运行。硬件里实现的办法是做一个深度为68的寄存器阵列每周期往里面写入一个新的W[j]同时读出当前轮压缩需要的W[j]和W[j4]。这个设计在RTL里很直观还能避免使用BRAM的读写冲突。扩展模块的核心逻辑可以这样表达// W[j] P1(W[j-16] ^ W[j-9] ^ ROL(W[j-3], 15)) // ^ ROL(W[j-13], 7) ^ W[j-6] // W[j] W[j] ^ W[j4] reg [31:0] w_mem [0:67]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位所有W寄存器为0 end else if (start) begin // 前16拍从block载入W[0..15] if (cnt 16) w_mem[cnt] block[(15-cnt)*32 : 32]; else if (cnt 68) begin w_mem[cnt] p1(w_mem[cnt-16] ^ w_mem[cnt-9] ^ rol(w_mem[cnt-3], 15)) ^ rol(w_mem[cnt-13], 7) ^ w_mem[cnt-6]; end end end注意上面只是扩展模块的部分逻辑实际工程中还需要把W[j]的生成同步放到压缩模块的输入侧或者干脆在压缩模块内部用w ^ w_mem[j4]组合出来避免多打一拍寄存器。2.3 硬件设计中容易忽略的两个边界很多人在RTL实现SM3时顺序是按照标准文档的公式一步步搬表面上看起来没问题仿真也能过但综合后要么频率上不去要么资源翻倍。我复盘下来有两个边界问题是最高频的坑。第一个是T函数的双区间切换。SM3压缩函数里的常量T_j在前16轮是0x79CC4519后48轮是0x7A879D8A另外FF和GG两个布尔函数也在第16轮前后换了定义。初学者容易用case语句去判断j 16综合后就会在关键路径上多出比较器和多路选择器。更聪明的做法是让计数器j的高位直接做选择信号因为j 16等价于j[4] 0根本不需要比较器。我把这些常数和函数选择优化成纯组合逻辑后压缩轮的关键路径直接缩短了大概0.5ns。第二个是加法器的进位链优化。SM3一轮里面有SS1、TT1、TT2等多个多操作数加法真实组合路径是A E T_j这种三输入加法Vivado默认会综合成两级加法器。在200MHz以上时序比较紧张时建议用CSA进位保存加法器把三输入加法先压成两个数再做一次完整进位传播或者直接用DSP48的加法模式来分担LUT压力。这部分我放到后面的时序优化章节展开细说。3. 架构设计从软件实现到硬件流水线的思维转变3.1 顶层架构与模块划分整个SM3加速IP我划分成了几个模块输入缓存与协议解析、消息填充、消息扩展、压缩函数、输出寄存与摘要缓存。它们的关系是外部数据先进入输入缓存解析出分组边界后送给消息填充模块填充完成后产生一个有效信号消息扩展模块和压缩函数模块同时启动压缩函数每周期接收来自扩展模块的W和W执行完64轮后与初始向量IV相加得到最终摘要。这样做的好处是模块间耦合度很低调试时可以单独对每个模块打测试激励。我在实际项目中就是先分别验证了填充模块和压缩模块才最终联调的后面排查问题会轻松很多。如果你一上来就直接把代码堆在一起写出问题时定位起来非常痛苦这一点一定是经验的教训。3.2 压缩轮函数的设计从单轮到多轮并行展开压缩函数的64轮迭代在硬件上有一个天然的矛盾如果每个周期做1轮控制逻辑最简单但吞吐率受限如果每个周期做64轮理论上一个周期就能出一个分组但组合逻辑深度巨大工作频率可能连10MHz都到不了反而得不偿失。最稳妥的方案是取中间值我选择了每周期两轮展开。每个时钟周期内顺序计算第j轮和j1轮状态变量在下一个时钟边沿同时更新。写成RTL时你需要注意的是第二轮的输入必须使用第一轮算出来的中间结果而不是当前寄存器值否则结果就会串位。核心代码如下// 每周期计算两轮 wire [31:0] ss1_1 rol(rol(A,12) E rol(Tj(j), j), 7); wire [31:0] ss2_1 ss1_1 ^ rol(A,12); wire [31:0] tt1_1 ff(A,B,C,j) D ss2_1 w1_cur; wire [31:0] tt2_1 gg(E,F,G,j) H ss1_1 w_cur; // 第二轮使用第一轮计算后的临时变量 wire [31:0] A_tmp tt1_1; wire [31:0] E_tmp p0(tt2_1); wire [31:0] B_tmp rol(B, 9); wire [31:0] F_tmp rol(F, 19); wire [31:0] ss1_2 rol(rol(A_tmp,12) E_tmp rol(Tj(j1), j1), 7); wire [31:0] ss2_2 ss1_2 ^ rol(A_tmp,12); wire [31:0] tt1_2 ff(A_tmp,B_tmp,C,j1) D ss2_2 w1_next; wire [31:0] tt2_2 gg(E_tmp,F_tmp,G,j1) H ss1_2 w_next; always (posedge clk) begin if (start) begin // 装载初始IV end else if (valid) begin A tt1_2; B rol(B_tmp, 9); C B_tmp; D C; E p0(tt2_2); F rol(F_tmp, 19); G F_tmp; H G; j j 2; end end我在这段代码里用了Tj(j)和Tj(j1)这样的函数表示法实际工程中建议直接把两轮的常数根据j的最高位选择好或者用查表法生成。采用两轮并行展开后64轮迭代只需要32个时钟周期而单轮方案需要64个周期吞吐率几乎翻倍代价是组合逻辑路径变长了一些对于100~150MHz的时钟来说完全可接受。3.3 关键路径在哪里加法器和循环移位在FPGA上做SM3对时序影响最大的两大块一个是多操作数加法链另一个是循环移位造成的布线延迟。循环移位ROTL在verilog里用拼接运算符写出来看起来很简单但最后会通过MUX实现当移位数是变量时一个32位的可变循环移位需要5级LUT级联这条路径不能小看。我处理循环移位的方法是对固定移位比如A的12位循环移位、B的9位、F的19位直接写成{A[19:0], A[31:20]}这种拼接形式综合器会把它优化成纯布线不消耗任何LUT。只有那些移位数随j变化的比如ROL(T_j, j)才需要仔细考虑。我这里的做法是把ROL(Tj, j)做成一个独热码选择因为j是由计数器产生的每一拍都只会有一个具体值综合器通常能优化得不错。实在不行也可以在控制状态机里预先算好这个值存到寄存器中再送给压缩函数直接把这段组合逻辑从关键路径里挪走。加法器方面我建议将SS1计算拆成tmp rol(A,12) E再用tmp rol(Tj, j)组合。这样看似多了一步实际上能利用FPGA内部的快速进位链carry chain来做两级进位传播比整体写成一个三输入加法表达式更容易让工具布局。对于追求极高频率的场景可以用CSA把三个数压缩成两个再相加能有效缩短进位传播链的长度。3.4 为什么不用BRAM存储W数组我见过不少实现把消息扩展的W[0..67]放在BRAM里理由是BRAM是现成资源不用占用LUT。但在SM3这个应用里我强烈不建议这么做。原因有两个一是BRAM只有一个读端口和一个写端口你每个周期既要写新的W[j]又要读W[j]和W[j4]给压缩函数用端口冲突非常严重要么双端口BRAM要么分时读写都是麻烦事二是BRAM的读写有一个周期的延迟会打断消息扩展和压缩函数之间的紧密配合。正确做法是用寄存器阵列。68个32bit寄存器在FPGA里大约占用68x32/4 544个LUT实际可能有出入相对于整个SM3引擎动辄几千LUT的资源占用来说完全是可以接受的但换来的是无冲突的多端口读取和零延迟访问。我在Artix-7上测试过寄存器阵列方案比BRAM方案时序至少好10%以上。4. 工程实践编码、仿真、综合与上板验证4.1 开发环境与工程搭建我这次用的是Xilinx的Artix-7系列板卡开发工具是Vivado 2022.2。Artix-7资源不算多但做单引擎SM3绰绰有余整体逻辑资源占用大概在LUT 3000左右、FF 2000左右随便一个入门级板子都能跑。如果你用的是Intel Cyclone系列也完全可以逻辑资源是溢出足够的。工程创建时我开设了三个目录分别放RTL、仿真和约束。RTL目录下是所有源文件仿真目录下是testbench和数据集脚本约束目录下是时钟约束和引脚约束。这个小习惯在大项目中会省很多事。Vivado在综合后如果发现某个文件的信号被优化掉了往往是因为没有正确加约束或者模块端口悬空独立的目录结构能让你快速定位问题。4.2 数据通路与握手信号的注意事项SM3加速IP对外接口我设计了三个信号in_valid、in_ready、in_data。in_valid表示上游数据有效in_ready表示IP内部可以接收数据两者同时拉高时完成一次数据装载。这种简单的valid-ready握手协议比AXI4-Stream的完整总线更容易理解和调试后续如果要转成AXI4-Stream只差一层转换逻辑。内部状态机分为几个阶段IDLE、LOAD、PADDING、EXPAND_COMPRESS、OUTPUT。这里最容易出问题的是LOAD阶段的边界条件。比如上游一次只给1字节或者16字节而不是标准的64字节那么输入缓存至少要能拼装出完整的512bit分组。我的做法是把输入侧做成一个小型FIFO每次满了64字节就自动提取为一组并附带上当前的字节计数和结束标志。4.3 仿真验证用标准测试向量校核做算法类IP仿真验证一定要用标准测试向量“锚”住正确性否则后面每次改一个优化都会提心吊胆。SM3国标文件里提供了两个最基础的测试向量输入“abc”得到摘要66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0输入“abcd”重复16次得到摘要debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732。这两个向量长度都很短适合做功能验证。我的testbench流程是把要测试的输入数据拼接成标准格式通过任务task喂给DUT然后等待摘要输出再用$display打印十六进制形式与预期向量比对。如果第一次跑不一致可以用Vivado的波形窗口直接观察各轮的中间变量尤其是消息展开W是否与软件参考一致。这里提供一个自查技巧写一个简单的Python脚本用gmssl库计算SM3中间轮的状态然后和仿真波形逐比特对比这样可以把错误定位到具体某一轮。我还额外补充了长度边界测试输入0bit、448bit、449bit、512bit、513bit确保填充模块在这些边界值上都能正确处理。这类边界值往往比标准向量更容易暴露隐藏bug。4.4 综合与时序收敛布局布线的那些事我的设计目标时钟是100MHz但后几轮优化时我尝试冲击150MHz。综合跑完看时序报告发现关键路径还是出在压缩函数的SS1加法链上。这时我用了一个非常有效的例行优化先用report_timing_summary看最差路径的起点和终点然后在RTL里找到对应逻辑手工调整加法结构。有一种很典型的情况是综合工具会把多个加法器串成一条长长的进位链。比如TT1 FF D SS2 w1这样的四输入加法Verilog写出来看似一行工具实际会生成三级加法器。我的处理方式是改用DW02_sum风格或者干脆手动分组wire [31:0] t1 ff_res D; // 第一级加法 wire [31:0] t2 SS2 w1; // 第一级加法和t1并行 wire [31:0] TT1 t1 t2; // 第二级加法这样用括号明确告诉综合器哪些加法是并行执行的可以让关键路径从三级缩到两级。原理很简单就像你算四位数相加时先两两相加再汇总比从右往左依次进位快得多。FPGA里的进位链是串行资源尽量减少链上串联的加法器个数是提高SM3工作频率最有效的办法。布局和布线是另一个容易踩坑的环节。综合synthesis只是把RTL映射成LUT、FF、DSP这些底层单元而布局布线PR才决定这些单元实际放在芯片的哪个位置以及怎么连线。很多初学者以为综合后时序没问题就万事大吉甚至在RTL里看到两个模块信号连在一起就以为物理上也连在一起实际上如果约束没写对布局布线工具可能把两个关键模块放得很远导致布线延迟直接让时序崩溃。这就是为什么一定要正确添加create_clock约束并且尽量让顶层模块内部的逻辑在物理上有合理的排列。4.5 上板实测结果仿真全部通过后我做了上板实测。测试方式是用FPGA内部的测试逻辑生成一段固定数据比如不断递增的计数器拼接送入SM3加速IP同时用JTAG把摘要读出和PC端软件跑出的结果比对。结果完全一致。性能数据方面在100MHz时钟下单次处理一个512bit分组大约耗时60个周期包含16拍数据加载、填充、32拍两轮展开压缩、以及输出延迟折合时间0.6微秒纯吞吐率约850Mbps。在150MHz时钟下吞吐率可以做到约1.2Gbps。如果例化4个SM3引擎并行处理不同分组的独立消息总吞吐率能轻松突破4Gbps已经能满足绝大多数嵌入式场景需求。5. 调试实录我踩过的五个坑5.1 消息分组少算了一组第一次把整个链路跑通时标准向量“abc”居然算出来完全不对。打了个全波形追踪发现填充模块在原始数据为0到447bit之间时逻辑正确但一旦输入长度超过448bit输出到压缩模块的分组数就少了一个。原因是我在填充状态机里默认“最后一组数据一定能在本组内完成填充”没有考虑到当最后一组数据为448bit时填充字段确实还能塞进去但当数据为449bit时本组只能放“1和31个0”64bit长度放不下必须输出两个分组。修复方案就是我在2.1节写的那样状态机加一个state 2处理需要额外输出一个分组的情况。这类边界条件如果不通过随机数据测试真的很难发现。5.2 字节序不一致CPU端算出的结果和FPGA完全不同系统联调时上位机软件通过串口把一串数据发给FPGAFPGA算出的SM3摘要和上位机用软件库算的完全对不上。排查了很久最后发现是端序问题上位机按小端序逐字节发送而SM3标准内部要求按大端序把字节组装成32bit字。我在RTL里直接把收到的前4字节拼成一个32bit字结果等于把字节序列倒过来了。解决办法是在进入SM3核心前先把每个32bit字做一次字节序转换具体就是word {byte[0], byte[1], byte[2], byte[3]}和{byte[3], byte[2], byte[1], byte[0]}之间的切换。如果你的数据来自PC的memcpy通常就会是这种坑。建议统一约定好接口端序并且在验证阶段就加一个端序转换的testbench测试。5.3 复位信号亚稳态问题SM3控制状态机一开始用的是异步复位而且复位信号来自板卡上的拨码开关没有做任何同步处理。仿真没问题上板后偶尔会出现状态机跳飞的情况表现为哈希结果时对时错。后来定位到是复位信号释放时和时钟上升沿发生了冲突导致部分寄存器复位成功、部分没有复位成功进入了非法状态。在FPGA里做设计复位信号不能想当然随便用。我的经验是状态机这类逻辑尽量用同步复位rst_n先打两拍同步化再进控制逻辑复位释放时间必须满足寄存器的recovery和removal时间否则就会出现亚稳态。如果你看到上板后偶尔“抽风”的问题优先检查复位树。5.4 综合器把“该有的逻辑”优化掉了排查一个数据通道问题时发现扩展模块里某个中间变量在Vivado综合报告里显示被优化掉了导致仿真行为与综合后行为不一致。原因是我写了一个没有实际影响的逻辑某段代码里对一个寄存器赋值后又立刻被后续赋值覆盖了综合工具认为是冗余逻辑直接删除。这种情况在波形仿真时看不出来因为仿真器忠实执行各条语句但综合后的网表里已经没有了。经验是仿真通过后务必跑一次综合出来后的功能仿真post-synthesis functional simulation特别是在遇到行为不一致问题的时候。如果综合工具报了很多“unused signal”或者优化警告不要忽略逐个确认到底是不是你自己代码的问题。5.5 多引擎并行时的输出乱序最后做多引擎扩展时4个SM3引擎各自独立计算不同消息结果输出端出现乱序——先送进去的消息反而后出来。这在高并发场景里是个大问题因为上层协议往往期望按发送顺序拿到对应的摘要。解决方法是在每个消息进入引擎前分配一个递增的序号引擎计算完成后把序号一路随数据传递最终在输出缓存里按序号做重排。用一个小型FIFO和比较器组成重排序模块严格按照序号输出这样即使不同引擎计算延迟不同输出顺序也是确定的。写在最后的工程体会整个过程下来最深的感受是做FPGA算法加速真正的瓶颈往往不是硬件资源而是对算法本身的理解深度。SM3虽然只有64轮但每轮里加法、循环移位、逻辑函数交织在一起软件上看似简单的操作映射到硬件时都要重新审视数据依赖和关键路径。不要指望把C代码“翻译”成Verilog就能拿到性能你必须从并行度、流水线、时序约束这些角度重新设计架构才能发挥出FPGA应有的加速效果。另一个体会是调试算法IP要有系统的验证方法。标准测试向量只能验证功能正确性边界条件和随机测试才是逼出隐蔽bug的关键。我后来写了一套随机长度的自动测试脚本每次修改RTL后都用它回归省下了大量手测的时间。启动新项目之前先把验证环境搭好比什么都重要。