简介2023年FPGA创新大赛AMD赛道参赛工程资料包聚焦基于FPGA的图神经网络传感器系统设计与实现适合FPGA竞赛参赛者、嵌入式开发工程师及神经网络硬件加速方向的学习者。项目以温控传感器为切入点主要完成ADC多路信号采集、传感器群的图模型构建、图神经网络频域分析与实时状态评估设计目标覆盖农业环境监测、工业机械臂运行状态判断、太空基地结构健康检测等场景。包内共1600个文件约100.42MB硬件描述源码以v/vhd为主配合Vivado工程文件xpr、xdc、xci、C/H辅助代码及tcl/sh/do等构建与仿真脚本并保留大量综合、布局布线中间产物便于对照开发流程。已有393人学习下载。资料适合复盘赛题从建模到FPGA实现的关键步骤可作为FPGA图神经网络方向的进阶参考尤其有助于理解图模型构建、串行通信协议设计与实时评估模块的落地细节。1. 把图神经网络塞进 FPGA一份能跑的传感器推理工程产线上一套六轴姿态传感器网络每秒产出上千条数据故障特征往往藏在多个传感器的联动关系里。传统做法是把数据打上时间戳送回服务器跑 GNN一个来回几十毫秒碰上机械臂急停这种工况根本来不及。拿到这份 2023 年 FPGA 创新大赛赛道留下的工程我从头到尾拆了一遍它的做法正好反过来——把 GNN 推理直接装进 FPGA在板卡上完成从传感器采集、图构建到推理输出的完整链路单次推理延迟拉到微秒级。项目适合正在做 fpga 开发、神经网络边缘化部署的开发者或者想在嵌入式场景里给传感器数据加一层「关系推理」的人。工程标注了未完待续正好留了几个模块接口让读者接着填下文会逐个说清楚哪些能直接跑、哪些要自己补。2. 系统架构拆解从传感器窗口到图推理的完整数据流2.1 为什么传感器网络要用 GNN 而不是普通分类器单点阈值报警只能抓幅度异常抓不住「两个传感器之间的差值趋势」这类关系特征。举一个例子管道堵塞时末端温度会缓慢上升但入口温度不变两者之间的温差才是有效特征用 1D-CNN 滑窗也能看到部分趋势但窗口长度有限传感器数量一多排列组合的关系数量会爆炸CNN 做不了这种结构化的关系建模。GNN 的输入是一张图节点是传感器边是传感器之间的关联每一层卷积通过消息传递把邻居节点的特征聚合到自身天然适配这种多节点联动场景。这套系统的目标场景是设备健康监测传感器节点数量在 8 到 32 个之间每个节点采温度、振动、电流三类特征图结构相对固定不需要在线动态改图。这决定了硬件设计不必追求通用图计算可以针对固定邻接矩阵做深度优化。和跑在 GPU 上的训练不同FPGA 侧只做推理所以网络结构、权重、邻接矩阵都是编译期确定的这是后面能做定点量化、能做流水线切割的前提。2.2 整体链路与模块划分从 ADC 采出来的原始数据到最终推理结果完整链路是多通道传感器信号经过 ADC 控制器进入环形缓冲打时间戳后按固定窗口长度切包每个窗口生成一个节点的特征向量特征向量汇总后送进图构建模块生成邻接矩阵和输入特征矩阵接着 GNN 推理引擎逐层做聚合与变换最后结果写入寄存器由 DMA 回传上位机。下面是顶层模块的 Verilog 例化示意。module top_sensor_gnn #( parameter NODE_NUM 8, // 传感器节点数量 parameter FEAT_IN 3, // 每个节点的输入特征维度 parameter FEAT_HIDE 16, // 隐藏层维度 parameter ADDR_W 16, // BRAM 地址位宽 parameter HIDDEN_LAY 2 // 隐藏层层数 )( input logic clk, input logic rst_n, // 来自 ADC 控制器的采样数据接口 input logic [15:0] adc_data, input logic adc_valid, // DMA 回传接口 output logic [31:0] dma_data, output logic dma_valid, input logic dma_ready, // 权重加载接口 input logic weight_ld_en, input logic [7:0] weight_idx, input logic [15:0] weight_val ); logic [15:0] feat_buf [NODE_NUM][FEAT_IN]; // 节点输入特征缓存 logic adj_mat [NODE_NUM][NODE_NUM]; // 二值邻接矩阵 logic [31:0] acc_buf [NODE_NUM][FEAT_HIDE]; // 聚合累加缓存 sensor_window_collector u_window ( .clk(clk), .rst_n(rst_n), .adc_data(adc_data), .adc_valid(adc_valid), .feat_buf(feat_buf) ); graph_builder u_graph ( .clk(clk), .rst_n(rst_n), .feat_buf(feat_buf), .adj_mat(adj_mat) ); gnn_engine #( .NODE_NUM(NODE_NUM), .FEAT_IN(FEAT_IN), .FEAT_HIDE(FEAT_HIDE) ) u_gnn ( .clk(clk), .rst_n(rst_n), .adj_mat(adj_mat), .feat_buf(feat_buf), .acc_buf(acc_buf) ); result_dma u_dma ( .clk(clk), .rst_n(rst_n), .acc_buf(acc_buf), .dma_data(dma_data), .dma_valid(dma_valid), .dma_ready(dma_ready) ); endmodule这个顶层设计把系统拆成四个相对独立的数据通路模块采集窗口负责把 ADC 流切成长度固定的时序窗口图构建模块负责从特征窗口生成邻接矩阵GNN 引擎是核心计算单元做多层消息传递DMA 负责把结果搬到 PS 端或上位机。每个模块之间用简单的 valid/ready 握手没有引入 AXI 总线依赖方便单独仿真验证。值得注意的点是feat_buf被设计成二维数组第一维是节点编号第二维是特征维度。这个布局直接对应后续 GNN 引擎的读取模式——聚合邻居节点时按行读特征变换时按列读两种访问模式都能在一个时钟周期内取出完整的一行数据。如果用一维数组压平存储地址计算会引入额外组合逻辑时序容易跑不高。参数里NODE_NUM设成 8 是因为目标平台的 BRAM 容量有限邻居聚合需要的中间缓存正好能塞进两块 BRAM节点数增加到 16 以上时acc_buf的位宽和存储占用会翻倍硬件规模会明显变大。2.3 PL/PS 分工与模型训练流程整个系统采用异构架构PL 侧跑数据通路和 GNN 推理PS 侧跑 Linux 或裸机程序负责传感器总线的寄存器配置、采样开关控制、以及通过 DMA 把推理结果读走。这么分的原因是 PL 擅长定长流水和并行计算而传感器初始化和时间戳管理这类控制逻辑用 C 写更省事不必在状态机里折腾。模型训练完全在 PC 端完成训练框架用的是 PyTorch网络结构固定为两层的图卷积网络激活函数用 ReLU最后的输出层接全连接。训练好后把权重和邻接矩阵导出成二进制文件通过$readmemh或 PS 端的加载接口写入 BRAM。导出时有几个细节需要注意权重一定要做定点量化训练阶段用的 FP32 值直接拿去 FPGA 用会损失精度而且 BRAM 位宽固定塞不下浮点数。模型训练时我会先用传感器采集一段真实数据生成图数据集把每个时间窗口的特征矩阵和对应的健康标签存成样本。由于「未完待续」的工程里并没有附带完整训练代码我在本地用同样的网络结构、固定随机种子复现了一份可用的权重。训练完成后做一次推理对比把同一组测试数据分别输入 PyTorch 模型和 RTL 仿真逐层比较中间结果误差控制在 1% 以内再考虑上板。这个流程是边缘 AI 项目的通用做法能避免板卡上出了问题还要区分是训练问题还是硬件问题。3. 核心模块实现GNN 推理引擎的逐层硬件化3.1 GCN 层实现把邻接矩阵乘法拆成三步图卷积层的公式是 H σ(·H·W) 是加了自环和归一化的邻接矩阵H 是输入特征W 是本层权重。硬件实现不直接做矩阵乘法而是拆成三步第一步把每个节点的邻居特征累加起来第二步乘以权重矩阵第三步做 ReLU 激活。拆开的好处是第一步可以用纯加法树实现不需要乘法器节省大量 DSP 资源。邻居聚合的 Verilog 状态机实现如下module neighbor_aggregate #( parameter NODE_NUM 8, parameter FEAT_HIDE 16, parameter ACC_WIDTH 32 )( input logic clk, input logic rst_n, input logic start, input logic [NODE_NUM-1:0] adj_row, // 当前节点的邻居掩码 input logic [FEAT_HIDE*NODE_NUM-1:0] feat_matrix, // 所有节点特征展平输入 output logic [ACC_WIDTH-1:0] agg_result [FEAT_HIDE], // 聚合结果 output logic done ); typedef enum logic [1:0] {IDLE, AGG_LOOP, ACC_WRITE, DONE_ST} state_t; state_t state, next_state; logic [3:0] node_idx; // 遍历节点编号 logic [3:0] feat_idx; // 遍历特征维度 logic [ACC_WIDTH-1:0] acc_reg [FEAT_HIDE]; // 按邻居列表逐节点累加每周期处理一个邻居 always_ff (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; end else begin state next_state; end end always_comb begin next_state state; done 1b0; case (state) IDLE: if (start) next_state AGG_LOOP; AGG_LOOP: begin if (node_idx NODE_NUM - 1) next_state ACC_WRITE; end ACC_WRITE: next_state DONE_ST; DONE_ST: begin done 1b1; next_state IDLE; end endcase end // 累加逻辑命中邻居掩码时把对应节点的特征加到累加器 always_ff (posedge clk) begin if (state IDLE) begin for (int f 0; f FEAT_HIDE; f) acc_reg[f] 0; node_idx 0; end else if (state AGG_LOOP) begin if (adj_row[node_idx]) begin for (int f 0; f FEAT_HIDE; f) begin acc_reg[f] acc_reg[f] feat_matrix[node_idx*FEAT_HIDE f]; end end node_idx node_idx 1b1; end end assign agg_result acc_reg; endmodule这段代码的状态机分四拍IDLE 清空累加器AGG_LOOP 遍历全部节点每周期读一个节点的邻居掩码命中就把它的特征向量累加进来ACC_WRITE 把结果锁存到输出DONE_ST 拉高 done 信号。参数里ACC_WIDTH设成 32是为了给累加留足余量输入特征 8bit、最多 8 个节点相加理论最大需要 8311 bit 就能不溢出但后续还要乘权重权重的量化系数可能会放大数值范围32 bit 更稳妥。这套状态机最怕的就是adj_row和feat_matrix的位宽写错。feat_matrix是把所有节点的特征按节点编号×特征维度 特征编号的顺序展平成一维向量NODE_NUM8、FEAT_HIDE16时就是 128bit。如果代码里把两个参数弄混索引就会越界读出来的邻居特征全是错的而且仿真阶段很难看出来——因为只是数值不对不会报 X 态。3.2 定点量化与位宽选择精度和面积的取舍GNN 模型在训练阶段用的是 FP32FPGA 上直接算浮点会消耗大量 DSP 和寄存器资源。这套系统采用定点量化方案输入特征用 8bit 有符号定点权重用 8bit 有符号定点累加器用 32bit激活后的中间结果截断回 8bit。数据对象位宽格式量化方式输入特征8 bitQ4.3有符号训练数据按最大值归一化到 [-1, 1)权重8 bitQ1.6有符号逐层统计权重的 min/max 后线性量化邻接矩阵1 bit二值0/1不参与量化聚合累加器32 bit纯整数不做截断最终输出前再统一处理激活后输出特征8 bitQ4.3有符号饱和截断负值归零ReLU量化参数的选择直接影响推理精度。我习惯的做法是用 PyTorch 的fake_quantize在训练时模拟量化误差再把权重导成二进制。如果训练时不带量化模拟上板之后精度骤降是必然的。Q4.3 的含义是 4 bit 整数位、3 bit 小数位符号位占 1 bit能表示的范围是 [-16, 15.875]最小分辨率 0.125。对于归一化后的传感器特征这个分辨率够用如果采集到的原始数据噪声较大建议先做滑动平均滤波再量化。ReLU 激活的硬件实现比想象中简单不需要真的判断符号位——只要把符号位为负的中间结果直接置成 0 就行。更高效的做法是压根不写判断逻辑直接把 32bit 累加器的第 31 位符号位接到一个使能信号上符号位为 1 时把输出寄存器清零这样整体可以放进流水线。这里有一个坑如果用的是无符号位宽ReLU 永远触发不了输出恒为 0。3.3 片上存储布局BRAM 怎么分才能不打架GNN 推理的数据访问模式很特殊邻居聚合阶段要同时读多个节点的特征矩阵乘法阶段又要反复读同一份权重。为了不让两个阶段互相踩内存我把片上存储拆成了三块独立区域。存储区域存放内容容量NODE8, FEAT_HIDE16端口配置区域 A输入特征矩阵8×16×8 bit 1 Kbit单端口聚合阶段读区域 B权重矩阵两层3×16×8 bit 16×16×8 bit ≈ 1.2 Kbit单端口变换阶段读区域 C邻接矩阵8×8 bit 64 bit直接分布式寄存器不用 BRAM区域 C 之所以用分布式寄存器而不是 BRAM是因为邻居聚合时每个周期都要读一整行邻接掩码BRAM 的随机访问延迟会拖慢状态机节奏而且 64 bit 连一块 BRAM 的最低容量都填不满纯属浪费。区域 A 和区域 B 分属两个物理 BRAM 块这样聚合阶段和矩阵变换阶段可以乒乓操作——聚合读到一半时矩阵变换模块已经拿到上一层的输出开始算了两个 BRAM 互不干扰能直接做层间流水。3.4 顶层调度状态机与 AXI 握手整个推理引擎的调度状态机分为五步IDLE 等待触发LOAD 从 BRAM 加载权重和邻接矩阵CONV 执行 GCN 层的前向计算POOL 做全局平均池化最后 OUT 拉高输出有效信号。和外部 DMA 的接口沿用了 AXI-Stream 的 valid/ready 握手规范发送方拉高 valid 表示数据有效接收方拉高 ready 表示可以接收两边同时为高时一拍完成传输。我建议把握手信号单独封装成一个小模块不要裸写在顶层。原因很实际AXI 握手协议要求valid一旦拉高就不能中途撤下直到握手成功新手很容易写成「valid 拉高一个周期后自己降下来」这在仿真里看着没问题因为 testbench 的ready恒为高但接上真实 DMA 后数据会随机丢包。这是 FPGA 项目里典型的「仿真过了但板上翻车」场景后面避坑章细说。4. 在板上跑通全流程仿真、综合到数据回读4.1 工程目录结构与启动顺序解压这份资源后它的目录结构是这样的。这里把关键目录和各自职责列出来方便你对照着看。路径内容/rtl顶层 HDL 代码按模块分文件存放/tb仿真 testbench包含激励生成和结果比对/script综合、布局布线、仿真运行脚本/modelPyTorch 训练脚本、权重导出脚本、量化配置/data传感器采集样例数据、生成的测试向量/doc项目设计文档含模块接口说明和时序图我的启动顺序是先跑仿真再看综合报告最后才碰开发板。直接打开工程就综合是新手最容易犯的错——综合能过不代表功能正确功能正确不代表时序收敛。跑仿真的命令依赖具体的仿真工具工程里自带的 Makefile 可以一键跑完 testbench 并生成波形我把仿真结果和 PyTorch 参考输出比对两边误差小于 1% 再继续。4.2 用 Python 造激励数据testbench 里最麻烦的部分是构造有意义的输入。手写一堆随机数喂进去仿真波形看起来热闹但根本无法验证推理正确性。正确做法是用 Python 脚本生成传感器数据经过训练好的模型得到参考输出再把输入数据格式化成$readmemh能读的文本文件。import numpy as np # 生成 8 个传感器节点的模拟采样数据每个节点 3 路特征 node_num 8 feat_dim 3 window_len 64 for node in range(node_num): samples np.zeros(window_len) for feat in range(feat_dim): # 基础正弦波叠加噪声模拟真实传感器信号 t np.arange(window_len) signal 0.5 * np.sin(2 * np.pi * (node 1) * t / 50) noise 0.05 * np.random.randn(window_len) samples signal noise # 量化到 8bit 定点范围 [-1, 1) scaled np.clip(samples, -1, 1) * 128 quantized scaled.astype(np.int8) # 按 Q4.3 格式转成二进制补码写到文本文件 with open(f../data/feat_node{node}_{feat}.txt, w) as f: for val in quantized: f.write(f{val 0xFF:02x}\n) # 生成邻接矩阵相邻节点之间互为邻居 adj np.zeros((node_num, node_num), dtypeint) for i in range(node_num - 1): adj[i][i1] 1 adj[i1][i] 1 np.savetxt(../data/adj.txt, adj, fmt%d)生成的文件里每行是一个字节的十六进制数。0xFF val这一步很关键Python 的np.int8是补码存储如果直接写val负数会变成带负号的十进制数$readmemh根本读不了。这个脚本在仿真阶段和上板阶段都能复用——上板时把同样的数据文件通过 PS 端加载进 BRAM就能和仿真结果做逐位对比。实际中我发现单纯看 RTL 仿真通过还不够最好把同一份数据在 C 语言模型里再过一遍比较三个结果PyTorch、RTL、板上的一致性能把定位问题的范围缩小很多。4.3 综合实现与上板调试综合前先确认几项关键约束。时钟约束按板上输入时钟频率来如果板载时钟是 100MHz但推理引擎内部的瓶颈模块只能跑到 80MHz就要在约束里做异步时钟域划分不能强行把整个设计跑在 100MHz 下。引脚约束必须查开发板的原理图把传感器接口的引脚号一一对应填进去错一个脚就白烧一次 bit 文件。上板调试的通用做法是在 GNN 引擎的输出端口挂一个 ILA集成逻辑分析仪核触发条件设成done信号上升沿采样窗口设成 1024。ILA 可以抓到推理完成后写进 DMA 前的最终结果和仿真输出比对。如果发现 ILA 抓到的数据和仿真不一致优先怀疑两个地方一是 BRAM 的初始化文件在综合时没有正确加载工程脚本里忘了加-init_file选项二是跨时钟域的握手信号没同步产生亚稳态导致数据错位。调试时我会把 GNN 引擎的done信号单独引到 LED 上每推理一次 LED 闪一下先确认硬件跑到了底再查数据对不对——这是排查「卡死」和「算错」最快的分流方法。5. 避坑记录推理 NaN、时序收敛与握手时序的四个翻车现场这章写的是我在搭建这套系统时实际踩过的坑。每个坑都按「现象 → 原因 → 解决」的顺序展开都是我回头翻仿真波形、一字一句对代码才定位到的属于花过时间的血泪经验。5.1 推理结果溢出变符号位翻转现象仿真跑到第二层 GCN 时输出特征中偶尔出现负数。输入数据和权重都是正负混合ReLU 应该在激活阶段把所有负值归零但激活前的中间结果就出现了错误的大负数。原因第一层 GCN 输出的特征范围超过了 8bit 能表示的上限。输入特征两个节点相加的结果再乘以权重放大了数值范围中间结果本该在 32bit 累加器里保持但激活后我直接截断回了 8bit溢出导致符号位翻转正数变负数。解决把激活后的截断改成饱和截断而不是直接截断低位。Verilog 里写一个比较器判断累加值是否大于 127是就输出 127小于 -128 就输出 -128在范围内才正常截断。改完后整个网络输出再也没有出现过异常大负数。5.2 板上结果比仿真慢一个周期现象同样的测试数据RTL 仿真输出的是预期结果上板用 ILA 抓到的结果却错开一个数据看起来像「上一次的推理结果」。原因DMA 的ready信号在仿真里始终拉高但真实 DMA 控制器在数据没被搬走之前会把ready拉低一拍。我的输出状态机在done拉高后直接跳回 IDLE没有等握手完成结果 DMA 还没来得及采数据状态机就重新开始计算了。解决在输出状态增加一个等待valid ready的跳变条件握手成功后才回到 IDLE。从那以后我在所有 valid/ready 接口后面都强制加一个状态位专门记录上一次握手是否完成防止 Level 触发误判。5.3 综合后时序收敛不了DSP 乘法级联太长现象综合报告显示关键路径到达 120MHz 以上超出目标时钟约束布局布线永远跑不满。打开原理图视图发现一条数据通路上串联了 3 个乘法器和 1 个加法器全部落在同一拍。原因矩阵乘法模块为了图省事把「聚合 × 权重」在同一个always块里用一行表达式写完综合器没有办法自动插入寄存器只能把 4 级运算全部串在组合逻辑里。解决插入两级流水寄存器把乘法和加法分开。第一拍做乘法结果暂存到 32bit 寄存器第二拍做加法。代价是推理延迟多了一个周期但换来的是关键路径砍半。加完整后综合频率从 80MHz 提到了 120MHz时序余量从负值变成了正值。综合后如果看到关键路径集中在一个长乘法链首先考虑的不是换更快的器件而是把运算拆开。5.4 BRAM 数据全是 X 态负数初始化文件读不进去现象仿真到 200ns 时feat_buf和weight_buf里的数据开始出现 X 态后续推理结果全部无效。原因权重导出脚本用 Python 的struct.pack把负浮点数转成字节串时默认用了带符号的b格式写出来的文本文件里带着-5、-31这样的负号。Verilog 的$readmemh只认十六进制数字不认负号加载时直接把整行忽略BRAM 的内容就成了未初始化状态。解决导出时统一用补码转换val 0xFF得到的无符号字节再去写文本文件。同时我在 testbench 里加了一个断言初始化完成后检查weight_buf[0]是否等于期望值一旦发现 X 态立刻停止仿真不用等到结果算出来才发现数据源就是坏的。6. 二次开发把「未完待续」的模块接着往下做这份资源标了「未完待续」恰恰是它最有价值的地方——留白处正好当改造练习。我建议从三个方向入手。第一个方向是把图构建模块从「固定邻接矩阵」升级成「动态邻接矩阵」。现在工程里的邻接矩阵是训练时写死的传感器节点之间的连接关系一旦改变比如某个传感器被拔掉必须重新生成权重文件再烧 BRAM。改成动态更新的做法是在graph_builder模块里加一个配置接口PS 端通过寄存器写入新的邻接行GNN 引擎在每轮推理前重新加载。接口设计可以复用现有的weight_ld_en和weight_idx信号加一个adj_upd_en使能位对已有模块的改动量很小。第二个方向是支持多类型传感器混合输入。现在的FEAT_IN 3把三类特征等同看待但振动数据的频率成分和温度数据的变化速率不在一个量级。改造时先对每个特征维度做独立的归一化缩放系数作为可配置参数存入寄存器更进一步可以给每个节点赋予不同的特征位宽用「节点编号查表决定位宽」实现异构特征这就绕开了定点量化里「全局统一 Q 格式」的精度瓶颈。第三个方向是最实用的——把推理结果接一个串口上位机可视化。工程里 DMA 已经把推理结果送到了 PS 端但 PS 端的裸机程序还没实现结果解析这就是「未完待续」的主要缺口之一。我补了一个简单的串口打印程序把每个节点的健康标签按固定帧格式发送帧头0xAA、节点编号、预测类别、校验和上位机用 Python 的pyserial接收后绘制成拓扑图能看到哪些传感器节点状态异常。这样整套系统就从「能跑」变成了「能演示」。这些改造共同指向一个原则FPGA 上手改动时优先在现有模块的接口上做扩展而不是推翻重写。我每次拿到「未完待续」的工程都会先花半天把接口信号和状态机画成时序图改完一处就全量跑一遍仿真回归把新增逻辑和旧逻辑的边界测一遍。上板之前再强制走一遍「导出权重 → RTL 仿真 → 板上对比」的流程宁可慢一点也不要在一个半通不通的工程上堆代码。希望这些拆解和改法能帮你把这块板子真正用起来跑出你自己的边缘推理系统。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?