ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA测控程序架构设计:从分层框架到数据流的工程实践

2026/9/8 18:04:22 拓冰建站 浏览量
FPGA测控程序架构设计:从分层框架到数据流的工程实践 做FPGA的工程师基本都逃不过测控程序这类需求。我这里说的测控程序不是视频流或者高速基带那种一路流水处理而是指要跟外部设备握手、听上位机使唤、把测量结果和控制反馈按节奏送出去的一套逻辑工程。代码量往往不大可一旦命令多、模式多、模块多框架、模块和数据流没理顺后面就全是返工。今天我把这些年搭FPGA测控程序的思路从头捋一遍适合正在做采集卡、电机控制、编码器反馈、仪器仪表这类方向的人参考。你会看到我反复强调同一件事在写具体模块之前先花半天把分层和数据流画清楚这比拿到一份现成代码直接改更重要。我见过不少同事拿到需求就在顶层例化一堆模块代码也能跑可项目迭代到第三四个版本时加一个通道就像在大脑里做手术。后来我逐渐把测控程序稳定成一套自己的写法框架按职责切层模块按外部接口边界切数据流单独作为一等公民来设计。这篇文章就把这三块从头到尾讲透。1. 测控程序不是状态机编程是架构问题1.1 我见过的“失控测控代码”都有同一个毛病很多测控项目的代码失控不是从第一天开始的。最初只有一个命令控制一个DA输出顺便读一个ADC电压值代码就三四个always块谁都能hold住。问题出在第二个人接手之后开始加需求今天加一个编码器位置回传明天加一个采集触发模式后天再支持连续采集和单次采集切换。这时候最初的“简洁代码”开始长出各种旁路逻辑状态机里套状态机命令解析散落在好几个模块里原本应该是公共逻辑的“串口命令”却出现在AD采集模块内部。这种代码有一个标志性症状改一个功能时你会发现你并不是只改一个文件而是在五六个模块里寻找同一份逻辑的“分身”。比如上位机下发一个“开始采集”命令AD模块里判断一次回传打包模块里又判断一次最后控制状态机里还要再判断一次。三处判断的逻辑只要有一处没同步改程序就会表现出“命令有时候生效、有时候不生效”的玄学问题。还有一类失控是模块间的触发关系建立在偶然时序上。有人喜欢把某个信号的下降沿或计数器的第N个周期作为另一个模块的启动条件一旦涉及跨时钟域或复位顺序变化模块启动就会晚几拍甚至完全不启动。测控程序最怕这种“没有显式握手”的连接方式因为外部环境一变触发条件失效时你根本不会第一时间想到是启动逻辑出了问题。1.2 测控类FPGA项目对程序结构有三个特殊要求测控程序为什么不能简单套用数字信号处理或图像处理那套“输入数据流流水线处理输出数据流”思路因为它不是单向流水而是典型的闭环交互系统。对照我做过的一堆采集控制板卡这类程序普遍有三个特殊要求。第一个要求是命令响应要有明确的开始和结束。上位机随时可能下发命令但外部设备不会瞬间准备好。ADC转换要等busy信号拉低编码器位置读取要有通信时序电机控制要等当前动作安全结束。所以测控程序里天然有一堆状态机每个状态机都在等待某个外部事件这和数据处理逻辑中那种“数据一直往前推”的思路完全不同。第二个要求是工作模式经常是周期性和事件性混合的。系统既要以固定采样率连续采集又要响应突变事件比如外部硬触发、FIFO快满、设备超时。这些模式要被同一套框架管理却不能互相干扰。第三个要求是错误处理不是可选功能。测控系统面向真实物理世界设备可能没接好线缆可能松动通信可能超时。程序里如果没有超时退出、错误上报、状态恢复机制一个偶发异常就能让整个设备卡死到下次上电。我甚至认为一个测控框架的健壮性主要看它对异常状态的处理是否完备而不是看正常流程写得多顺。1.3 想清楚框架再动手到底让你少改什么框架的价值不是让代码看起来规整而是把“变化点”限制在一个小区间里。我说一个自己印象很深的例子。某块板卡第一次设计时只支持单通道AD采集和串口命令我偷懒把命令解析直接写在AD采集模块里。后来要做多通道扫描上位机命令格式变了我以为只要改AD模块就行结果回传打包模块里也解析了一遍命令控制总线那块还缓存了旧的地址映射。那个功能前后改了两个通宵不是逻辑难是同样的逻辑在三个地方各有一份必须同时找到并保持一致。整个项目重构之后我把“命令解析”收敛到接口桥接层AD模块只认寄存器堆给出的控制字。后来再改命令格式我只动一个文件半小时就完成了。所以说先搭框架不是流程上的形式主义。它给你的是两样东西改需求时知道该动哪个模块出问题时知道把调试探针插到哪个信号。测控程序里最贵的时间从来不是写代码而是排查“到底是谁多等了一个时钟、谁少清了一个标志”这类问题。结构清楚了这类问题往往一眼就能圈定范围。2. 顶层框架设计接口层、寄存器层、任务层、数据通道各管什么2.1 四层划分和各自的职责边界做测控FPGA程序我习惯把整个工程分成四个职责边界清晰的部分这里讲的“层”不是软件分层那种带抽象开销的概念而是RTL模块归属的划分原则。每层只做一类事情。接口桥接层负责把所有外部接口统一成内部事务。如果是串口它只解析帧头、长度、校验、地址和读写数据如果是以太网它只处理MAC和IP层的收发把有效载荷提取出来。这层不关心地址对应的是什么寄存器更不理解“启动采集”是什么意思。它只负责把外部发来的数据变成一条内部请求并接收内部返回的数据组装成响应帧。寄存器管理层是整台设备的大脑入口。它维护一张地址映射表里面定义了每一个寄存器的读写属性和用途。上层发来的地址写数据在这里变成可读可写的寄存器值各功能模块需要配置的参数也通过这里取出去。只要所有外部访问都走这张表上位机软件就能用一套统一接口操作底层的各种外设。任务控制层是干活的人。它包含若干状态机例如采样触发状态机、DA输出状态机、编码器归零状态机。这些状态机不直接处理串口协议也不直接处理数据回传而是根据寄存器中的启动位、停止位、模式选择位去驱动对应的功能模块完成一次动作并把完成状态和执行结果回报到寄存器状态位里。数据通道层负责搬运采样数据。AD采集的数据、编码器位置、DA回读值凡是需要给上位机的数据最终都要汇聚到这里。这一层里有FIFO、数据打包模块、发送状态机它的核心任务是保证数据不丢、不乱、不堵并且把数据包的格式统一好。控制命令和回传数据走完全不同的通路这在框架上是最重要的一点。2.2 寄存器访问总线选型AXI-Lite还是自写总线测控程序内部模块之间怎么连接最常见的选择是用一个统一的寄存器访问总线。如果你使用Xilinx或Intel的SoC平台自然可以选AXI-Lite或Avalon-MM这类标准总线。AXI-Lite的好处不用多说所有Xilinx IP都支持地址译码由工具自动生成接入DMA、DDR等IP时非常省事。但AXI-Lite也有代价通道比较多AW、W、B、AR、R五个通道即使你的测控逻辑只需要写几个寄存器也要维护一堆握手信号。对于纯FPGA接外部MCU或者板上自己跑一个小软核的场合这些握手有时反而成了累赘。我更常用的方式是自写一个极简寄存器总线。它不需要像AXI那么复杂只需要四类信号地址、写数据、写使能、读数据再加一个读写有效脉冲。一次读写在一个时钟周期内完成没有burst没有等待。小型测控程序里的寄存器访问本来就是低频操作完全没有必要上复杂的总线协议。拿我常用的格式打比方wire bus_cmd_valid; // 总线请求有效 wire bus_we; // 1写0读 wire [7:0] bus_addr; wire [15:0] bus_wdata; wire [15:0] bus_rdata;接口桥接层把串口或以太网收到的一帧命令解析成上面的字段寄存器堆模块根据地址和读写方向完成操作。这种方式的好处是时序极简单任何能写Verilog的人都能看懂而且综合后资源占用也很小。如果你要接Xilinx自带IP建议做一个简单的总线转换桥把内部总线转成AXI-Lite的从接口而不是把所有寄存器都搬到AXI通道上。2.3 一个可扩展的顶层例化结构把四层落到顶层代码结构大致长这样。下面只是示意重点看模块归属和信号方向module mea_ctrl_top ( input wire clk_sys, input wire clk_eth, input wire rst_n, input wire uart_rx, output wire uart_tx, output wire adc_convst, input wire adc_busy, input wire [15:0] adc_data, output wire dac_sync_n ); // 内部总线 wire bus_cmd_valid; wire bus_we; wire [7:0] bus_addr; wire [15:0] bus_wdata; wire [15:0] bus_rdata; // 寄存器管理层的输出控制字 wire [15:0] ctrl_word; wire [15:0] mode_word; wire cmd_start_pulse; wire cmd_stop_pulse; // 任务控制层给数据通道层的启动信号 wire adc_sample_req; wire adc_busy_from_task; // 接口桥接层 uart_bridge #(.CLK_DIV(16d87)) u_uart_bridge ( .clk (clk_eth), .rst_n (rst_n), .uart_rx (uart_rx), .uart_tx (uart_tx), .bus_cmd_valid(bus_cmd_valid), .bus_we (bus_we), .bus_addr (bus_addr), .bus_wdata (bus_wdata), .bus_rdata (bus_rdata) ); // 寄存器管理层 reg_file u_reg_file ( .clk (clk_eth), .rst_n (rst_n), .bus_cmd_valid (bus_cmd_valid), .bus_we (bus_we), .bus_addr (bus_addr), .bus_wdata (bus_wdata), .bus_rdata (bus_rdata), .ctrl_word (ctrl_word), .mode_word (mode_word), .cmd_start_pulse(cmd_start_pulse), .cmd_stop_pulse (cmd_stop_pulse) ); // 任务控制层 sample_task_sm u_sample_task ( .clk (clk_sys), .rst_n (rst_n), .cmd_start (cmd_start_pulse), .cmd_stop (cmd_stop_pulse), .mode_word (mode_word), .adc_sample_req(adc_sample_req) ); // 接口封装层中的AD驱动只负责时序和采样值 adc_driver u_adc_driver ( .clk (clk_sys), .rst_n (rst_n), .sample_req (adc_sample_req), .adc_convst (adc_convst), .adc_busy (adc_busy), .adc_data (adc_data), .data_valid (sample_valid), .sample_data(sample_data) ); endmodule看到这里你会发现顶层例化最需要注意的是不同模块工作在不同时钟域clk_eth、clk_sys、甚至AD自己的采样时钟可能都不是同一个来源。这恰恰是下一节要展开的模块设计问题。框架只是给出职责归属真正决定框架能不能跑稳的是模块之间的边界定义。3. 模块拆分的实际边界采集、控制、通信、监测都不是越独立越好3.1 模块划分的三条依据很多初学者划分模块时习惯按“功能名字”来写一个uart_control模块再写一个adc_control模块再写一个pwm_control模块。这种划分看着很直观但它把“通信协议”和“控制策略”耦合在了一起。真正的划分依据不是功能名字而是外部接口边界和状态变更边界。我总结出三条依据。第一看这个模块是否有独立的外部时序接口。比如ADC芯片可能有busy信号、并行数据线、转换触发时序这些时序必须封装在一个模块里其他地方不能直接操作这些IO。BISS-C编码器有单线双向通信时序SPI设备有时钟极性和相位配置都应该封装成独立模块对外提供服务。第二看这个模块的内部状态是否是内聚的。一个状态机只应该管理一种类型的事务。如果你看到一个always块的状态变量里既有“等待串口字节”的状态又有“等待ADC转换完成”的状态那这个状态机已经同时干了通信和采集两件事必须拆开。否则每加一个外部设备状态编码都要爆掉一次。第三看这个模块是否方便单独测试。好的模块边界应该让你能造一个简单的testbench喂测试向量进去观察输出是否符合预期。如果模块内部大量依赖外部时钟相位和触发条件testbench就很难写仿真时也难定位问题。3.2 把外设时序封装成“专业模块”以ADC为例一个合理的ADC封装模块对外接口会做成这样module adc_driver ( input wire clk, input wire rst_n, input wire sample_req, // 单周期高电平请求一次转换 output reg adc_convst, // 到ADC芯片的转换启动信号 input wire adc_busy, // ADC忙信号 input wire [15:0] adc_data, // ADC并行数据 output reg data_valid, // 单周期高电平数据有效 output reg [15:0] data_out );调用者不需要知道ADC是忙信号下降沿有效还是上升沿有效也不需要知道转换启动后要等多少周期只要拉高一次sample_req然后等待data_valid回来。这样任务控制层的状态机就能完全脱离具体器件时序。不同ADC需要等待的时间不一样只需要改这一个模块内部的计数器参数。再比如处理BISS-C编码器这类带通信协议的位置反馈器件就更应该封装成独立模块。BISS-C本质上是主站发起时钟、从站回传数据的单向时序链路但工程上还要处理Start位、Control位、Position数据、CRC校验、超时判断。如果在任务控制层里裸写这段时序代码会迅速变得非常难看。正确做法是编码器驱动模块对外只输出位置值和状态标志内部把寄存器配置、通信时序、CRC校验全部吃掉这样上层状态机只是一套简单的“请求位置-得到位置”的握手。3.3 模块对外接口规范pulse、valid/ready、寄存器三件套模块定义好了以后模块之间的信号连接要有一个统一规范否则框架难以为继。我习惯把所有模块对外接口归纳成三类信号。第一类是pulse信号用于事件触发。比如start、stop、done、error。这些信号是单周期高电平脉冲而不是电平。用脉冲触发状态机最大的好处是状态机不会因为某个控制位一直为高而重复进入动作分支。这个细节在测控程序里特别重要如果start命令是寄存器电平信号状态机会不断重新启动任务而脉冲信号天然只触发一次省掉了设计上升沿检测逻辑的痛苦。第二类是valid/ready数据流信号。任何模块之间的数据传递都要有一对握手信号。valid表示数据线上的数据有效ready表示接收方可以接收。当valid和ready同时为高时一个数据完成传输。这个规范和AXI-Stream类似只不过我的带宽要求不高时可以不严格等待ready但保留它的好处是能统一接口。第三类是寄存器配置接口也就是2.2节提到的那组bus_addr、bus_wdata、bus_we信号。配置不外乎三种情况需要用户给定参数的寄存器、需要上报状态的只读寄存器、需要反映模块执行状态的命令字。模块内部译码后把对应字段变成内部wire/reg。对于带多种工作模式的模块建议把模式字段单独提取出来不要把所有寄存器值都拼接成一个大位宽控制字段再靠bit位置理解。4. 数据流设计上行采样、下行控制与跨时钟域的那些账4.1 测控程序里真正存在的三条数据流数据流是测控程序最容易想当然的部分很多人默认数据流就是从ADC采样然后发给上位机。实际上一个完整的测控程序存在三条独立的数据流。第一条是上行采样数据流即外部采集数据进入FPGA再缓冲打包回传上位机。这条路的特点是持续流式、带宽可计算、对乱序和丢失敏感。第二条是下行控制数据流包括上位机来的命令、参数、模式切换这条路的特点是低频突发、要求可靠到达并立即生效。第三条是内部控制数据流包括状态上报、错误标志、FIFO水位、超时告警。它在程序内部传输不进FIFO通常通过寄存器状态位体现。把三条数据流分开考虑设计起来就不会乱。比如很多项目把控制命令和采样数据放在同一条UART链路上这就必须设计一个协议优先级机制。常见做法是让命令响应和采样数据共用发送物理链路但用不同的帧类型区分发送仲裁时命令帧优先插入。如果采样数据一直占着FIFO发送通道命令帧迟迟发不出去上位机就会以为设备失联了。具体实现时打包模块每发一包数据前检查一下命令发送请求队列若有命令等待则先发送命令响应再继续数据帧这个逻辑虽然很小但能让整个系统响应手感和连续数据吞吐达到一个合理的平衡。4.2 FIFO深度不是拍脑袋一个1MSPS采样的带宽账数据流设计中最常被问起的是FIFO深度怎么选。很多人喜欢随手填一个4096然后祈祷不出问题。FIFO容量应该由两个量决定瞬时写入速率和读取端不发生阻塞的最长时间。这里特别要强调FIFO只能吸收瞬时速率波动无法解决长时间的平均带宽不匹配。拿一个具体例子算一算。ADC以1MSPS采样16bit持续采样产生约2MB/s的数据。如果回传链路是UART最高波特率就算到921600有效数据率不到100KB/s平均带宽差了二十倍。这种情况下无论FIFO开多大最终都会溢出。正确思路是要么降低采样率要么改用USB、以太网、PCIe这类高速链路。FIFO在这里不是用来“把两兆数据攒下来慢慢发”的它解决的只是发送端暂停期间缓冲几十毫秒的数据。所以更合理的估算法是先算发送端最坏情况下暂停多长时间不读FIFO再用暂停时间乘以写入速率。比如千兆以太网发送一包需要处理IP分片、MAC重传或上层调度最坏可能有几百微秒到几毫秒的调度延迟。假设最坏延迟是2ms上游写入速率是2MB/s那这段时间内会写入4KB左右。为了给异常情况留余量FIFO深度翻倍到8KB比较稳妥。如果一包数据最大的有效载荷是1472字节那8KB也足够缓存好几包数据。这样算出来的深度比盲选4096更靠谱也更容易在评审时说明理由。更恶劣的情况是发送链路长期处于半阻塞状态比如以太网对端处理慢导致FIFO只出不进。这时再深的FIFO也会被灌满程序里必须设计溢出保护逻辑。溢出不代表只能丢弃数据更好的做法是丢弃旧数据保留最新数据或者干脆停止产生新数据并及时通过寄存器状态告诉上位机当前发生了溢出。静默丢数据是测控程序最大的忌讳因为上位机看到的会是“数据开始变得不连续”而很难判断是链路问题还是FPGA丢帧。4.3 回传帧格式里的防错细节帧序号、长度、CRC、溢出标志数据打包上行的帧格式决定了调试时能不能快速定位问题。我习惯在每一帧里放五样东西帧头、数据长度、帧序号、数据区、CRC校验。帧头用于帧同步长度字段用于定界帧序号用于检测丢包CRC校验用于检查内容是否被破坏。很多人会省略帧序号认为链路质量好、FIFO不会丢包就不用。但实际上帧序号在测控程序里有不可替代的价值当数据被某种异常挤掉了半包时上位机通过检测序号跳变能立刻定位丢帧位置而不是等到发现整段数据缺失才迷惑。帧序号本身要在一个长计数器上取FPGA里就用普通计数器加到某个值回绕上位机需要处理回绕场景一般连续比较差值即可。CRC多项式可以根据数据长度选常见的有CRC16和CRC32。如果数据量不大每帧不超过一两百字节CRC16足够如果走千兆以太网传大包建议CRC32。CRC计算可以用LFSR一次性算完也可以在打包状态机中逐字节累加关键是要保证帧内容发送的最后一个字节跟CRC字段顺序一致否则上位机验不过。我踩过一次坑CRC算的是整个数据区的复合值结果发送时把它按小端先发了低字节上位机按大端组装怎么都对不上后来统一了发送和计算的字节序才解决。还要在帧头中带上一个溢出标志位。如果FIFO曾经发生溢出即使这次送出的包本身是完整的也需要把这个标志位置为1。上位机看到的是数据完整但有溢出标记它就知道这段数据中间曾有丢弃