简介:面向工业自动化的FPGA通信协议实现方案,围绕Modbus-RTU从站与主站设计提供完整VHDL源代码。资源包含从站全局配置模块、主站控制模块、接口层、接收机与写数据单元等多个可复用功能块,源码层次分明,便于二次开发;搭配工程配置文件、原理图与文本设计文件、仿真波形以及编译报告,可在Quartus环境下直接打开并复现协议交互流程。压缩包内共2211个文件,总大小37.13MB,其中93个vhd文件为主逻辑,其余为建工程所需的辅助文件、日志和说明文档,结构归类清晰。已有1358人学习,资料对FPGA开发者尤其是工业通信方向的学习者有较高参考价值。通过研读代码,可以深入理解Modbus-RTU的帧解析、地址映射、读写请求处理和CRC校验的硬件实现方式,也可将状态机与收发控制逻辑迁移至其他FPGA项目,是一套实用性较强的底层协议参考设计。
1. 基于FPGA的Modbus-RTU:从串口到寄存器的一条命令链路
先把问题摆在台面上:一个modbus-RTU从站,核心职责就是接收主站发来的一帧RTU报文,校验CRC,按功能码去读或写寄存器,再回一帧。这条链路放到FPGA上,本质是“串口收发 + 帧状态机 + 寄存器堆”三件事,而不是CPU式的逐条指令执行。VHDL写这套源代码,难点不在语法,在于把协议时序翻译成状态与计数器。如果你正在做fpga项目实战里的modbus主从通讯,或者想交一套能直接落地的VHDL源代码,这篇文章就把我常用的实现架构、参数和坑位拆开讲。先给你结论:纯逻辑实现Modbus-RTU完全可行,比软核方案在回帧延迟上更可控;但帧间隔、CRC位序、波特率分频这三个点没拿捏住,仿真再漂亮,上板一样翻车。
2. 先把RTU帧和CRC16焊死在逻辑里:VHDL实现与参数位宽
Modbus-RTU的帧格式就那几样:地址、功能码、数据、CRC16。难点在于多字节字段的字节序,以及CRC自身的位序。VHDL实现之前,我习惯先把协议在纸上画清楚,再动手写代码。
2.1 RTU帧的字节布局和两个“低位在前”
读保持寄存器请求(功能码03)的帧长固定为8字节:地址1字节、功能码1字节、起始地址2字节、寄存器数量2字节、CRC2字节。这里的“低位在前”指的是多字节字段的低字节先发,比如起始地址0x0100,线上先发0x00再发0x01。很多人第一次写,把这几个字节按大端方式发出去,主站直接回异常码。
帧里两个字段的位序方向不一样,最容易混:地址、功能码、数据是按字节流顺序发送;而CRC本身是按LSB-first逐位计算的。这意味着你在做数据打包时要分两套思路去对待。下面用一个请求帧实例表格来说明字节布局:
| 字节偏移 | 内容 | 十六进制示例 | 说明 |
|---|---|---|---|
| 0 | 从站地址 | 0x01 | 目标从站 |
| 1 | 功能码 | 0x03 | 读保持寄存器 |
| 2 | 起始地址高字节 | 0x00 | 寄存器地址0x0000 |
| 3 | 起始地址低字节 | 0x00 | 先发高字节,再发低字节 |
| 4 | 寄存器数量高字节 | 0x00 | 读1个寄存器 |
| 5 | 寄存器数量低字节 | 0x01 | 数量范围1~125 |
| 6 | CRC低字节 | 0x84 | CRC由前面6字节算出 |
| 7 | CRC高字节 | 0x0A | 低字节先发 |
回帧的结构稍有不同:地址、功能码0x03、数据字节数、数据、CRC。读N个寄存器时,数据字节数固定是2N,回帧总长就是3 + 2N。这里要特别注意每个寄存器的16位数据也是高字节先发,和请求帧里地址字段的字节序方向保持一致。
2.2 逐bit CRC16的VHDL实现:初值0xFFFF,反馈0xA001
CRC16是整套协议里在VHDL中最容易写错的部分。Modbus用的是CRC16-IBM标准,多项式是0x8005,但标准写法是左移,而Modbus实际传输是低位在前,所以工程实现要用反射后的多项式0xA001。初值固定0xFFFF,不是0x0000。
逐bit算法在VHDL里可以写成一段非常紧凑的代码:每个时钟移入一个数据bit,反馈位由移位寄存器最低位和输入异或得到。反馈为1时异或0xA001,否则保持。以字节为处理单位时,必须先从LSB开始,逐个bit送入。
signal crc_reg : std_logic_vector(15 downto 0) := x"FFFF"; process(clk, rst_n) begin if rst_n = '0' then crc_reg <= x"FFFF"; elsif rising_edge(clk) then if crc_en = '1' then -- 反馈位:当前CRC最低位 与 输入bit 异或 fb := crc_reg(0) xor data_bit; -- 右移一位,最高位补0 crc_reg <= '0' & crc_reg(15 downto 1); if fb = '1' then crc_reg <= crc_reg xor x"A001"; end if; end if; end if; end process;这里的data_bit是当前要处理的单个bit,不是完整字节。处理一个字节需要8个时钟周期,每周期从字节的最低位开始送入。参数位宽上,CRC寄存器固定16位,数据位宽8位就够了。不要试图把CRC截成8位来省逻辑,Modbus主站校验是全16位的,截断后无论你怎么补位都补不回来。
很多fpga入门阶段的人卡在这一步:仿真里CRC算出来了,但把字节按bit从高位往低位送,或者反馈位取了输入bit而不是异或结果,算出来的值和正确值总差那么几个数字。避免翻车的办法很简单:找一个已知正确帧的CRC值作为测试向量,先用Python或在线工具算出标准CRC,再和RTL仿真波形逐位比对。
2.3 查表还是逐bit、资源消耗与位宽
逐bit方式是面积换时间,8个clk算一个字节。在9600、19200这类波特率下,一个字节还没收完,CRC早就算完了,完全没有性能压力。查表法则需要一张256×16的常量表,VHDL里写成case语句或者ROM初始化文件,代码会长出一截,但每个字节只需2个时钟周期左右。
如果系统时钟在50MHz以下、逻辑资源紧张,我一般用逐bit方式。如果资源宽裕、数码逻辑里还有其他CRC要做,才考虑查表。两者的计算结果完全一致,差异只在延迟和代码可维护性。在fpga项目实战里,我倾向于把CRC做成一个独立组件,输入时钟、数据使能、数据bit,输出crc_done。收一个字节就使能一次,这样后续调功能码时完全不用碰CRC逻辑。
资源本身不是大头。一次完整Modbus-RTU从站的逻辑大概消耗几百个LUT,这在现在的FPGA上几乎是零头。modbus这种控制协议和fpga图像处理里的高速数据流完全是两种范式,前者的资源大头在状态机和寄存器堆,不在运算单元。所以写这套代码时,优先保证状态清晰,不要为了省几个触发器去压缩状态编码。
2.4 帧长与字节计数:没有长度字段怎么办
RTU帧没有显式的长度字段,从站靠“总线空闲超过3.5字符时间”来判断帧结束。VHDL里不能写阻塞式延迟,必须把“当前收到几个字节、停止接收多久了”变成计数器。
字节计数器的作用有两层:一是记录已收字节数;二是限制最大帧长。Modbus规范里,单帧最大字节数和功能码有关。读保持寄存器最多一次性读125个寄存器,响应帧长度上限是255字节左右。在VHDL里,最大帧长常量建议设成256,收到超过这个数就强制报错并丢弃。
constant BIT_TIMES_9600 : integer := 5208; -- 50MHz / 9600 constant CHAR_TIME : integer := 11 * BIT_TIMES_9600; -- 含起始位停止位的一个字符 constant FRAME_GAP : integer := 40 * BIT_TIMES_9600; -- 3.5字符时间放宽到4字 constant MAX_FRAME_BYTES : integer := 256;帧空闲阈值的设置不要硬编码成精确的3.5字符时间。上位机在帧内连续字节之间几乎不会插入额外延迟,但从站自己的处理可能让下一个字节晚到几个时钟周期。放宽到40个bit时长,既能稳妥识别帧结束,又不会把相邻帧错误拼在一起。
3. UART收发与3.5字符空闲检测:Modbus帧边界的正确姿势
Modbus-RTU下位机的物理层就是UART。这里不讲通用UART教程,重点讲为Modbus服务的UART和普通串口收发差在哪。
3.1 采样点做对:波特率分频的余量和半bit定位
系统时钟50MHz、波特率9600时,分频系数是50_000_000/9600,约等于5208.33。VHDL里只能取5208,误差约0.006%,对上板传输几乎没有影响。真正让采样点跑偏的,是接收端检测到起始位下降沿之后,从“半bit位置”开始采样的那一拍。
标准做法是:检测到起始位下降沿后,先等半个bit时长再开始采样,之后每隔一个完整bit时长采一次点。这样采样点落在每个bit的中间位置,对边沿抖动最不敏感。如果起始位沿检测晚了一两个时钟周期,再累加8个bit的取整误差,最后一个数据位的采样点就可能往边界偏移。
我习惯把采样点从bit中线往后偏一两拍,也就是放在约60%位置。这样即使收发两端时钟存在微小偏差,采样点也会偏向bit后段而不是边缘。下面是一个接收骨架,重点看半bit初值和重载方式:
type rx_state_t is (IDLE, RECV, STOP); signal rx_state : rx_state_t; signal bit_cnt : integer range 0 to 10; signal clk_cnt : integer range 0 to 5208; process(clk, rst_n) begin if rst_n = '0' then rx_state <= IDLE; bit_cnt <= 0; clk_cnt <= 0; elsif rising_edge(clk) then case rx_state is when IDLE => if rx_din = '0' then -- 检测到起始位下降沿 clk_cnt <= BIT_TIMES_9600 / 2; -- 半bit定位 rx_state <= RECV; end if; when RECV => if clk_cnt = 0 then if bit_cnt >= 1 and bit_cnt <= 8 then -- 数据位从LSB开始进入移位寄存器 rx_shift_reg <= rx_din & rx_shift_reg(7 downto 1); end if; bit_cnt <= bit_cnt + 1; clk_cnt <= BIT_TIMES_9600 - 1; if bit_cnt = 9 then rx_state <= STOP; end if; else clk_cnt <= clk_cnt - 1; end if; when STOP => -- 停止位采样,可拉高rx_byte_valid rx_state <= IDLE; end case; end if; end process;参数BIT_TIMES_9600在上一章已经定义,接收和发送共用同一个常量即可。如果波特率要支持切换,就把这个常量做成寄存器,由配置接口改写。寄存器位宽13位足够计数到5208,不需要更多。
3.2 从字节到帧:3.5字符空闲检测的状态机
帧结束判定有两种常见方案。第一种是“字节间超时”:每收到一个字节就清零计数器,计数达到阈值就报帧结束。这种方式和字节边界耦合较紧,如果上位机在帧内字节之间插入了一点点延迟,从站可能把一帧截断成两帧,导致错误响应。
第二种是“总线空闲检测”:不管字节信封,只要接收总线上空闲满3.5字符时间,就把已经收到的字节包交给解析状态机。这种方式更符合协议本意,也对真实工业上位机更友好。我推荐第二种,把空闲检测放在接收字节层外面一层,不与字节边界绑死。
process(clk, rst_n) begin if rst_n = '0' then frame_gap_cnt <= 0; frame_end <= '0'; elsif rising_edge(clk) then if rx_byte_valid = '1' then frame_gap_cnt <= 0; -- 新字节到达,空闲计时清零 frame_end <= '0'; elsif frame_gap_cnt >= FRAME_GAP then frame_end <= '1'; -- 总线空闲超过阈值,当前帧结束 else frame_gap_cnt <= frame_gap_cnt + 1; end if; end if; end process;FRAME_GAP按50MHz、9600波特率算是40×5208,约208320个时钟周期,18位计数器可以覆盖。这个写法和fpga实现频率测量里的计数思路是同一套路:把时间换成clk个数,再和阈值比较。唯一差别是这里的清零条件不是外部gate信号,而是接收字节有效脉冲。
frame_end信号只能维持一个时钟周期,否则主状态机会重复解析同一帧。如果发现从站一帧回了两次响应,优先查这个信号是不是被直接接到状态机的case条件上而没有打成单周期脉冲。
3.3 发送器、RS485方向脚和io上拉:物理层三件套
回帧发送比接收简单:把要发的字节先放进FIFO或移位寄存器,然后按波特率逐bit移出。发送器需要一个tx_busy状态,主状态机在发送期间不能接收新帧,否则会丢数据。Modbus从站挂在RS485总线上,发送和接收共用一对差分线,必须用方向脚控制收发切换。
io_bus : inout std_logic; signal bus_dir : std_logic; -- '1'为发送,'0'为接收 signal bus_out : std_logic; io_bus <= bus_out when bus_dir = '1' else 'Z'; rx_din <= io_bus;这里有个很多新手在仿真里看不见的坑:方向脚从接收切换到发送时,如果立刻把数据驱到总线上,RS485收发器可能还在建立使能状态,导致帧头被吃掉。我一般会在bus_dir拉高后空等至少一个字节时长再发数据;发完最后一bit,bus_dir也要延迟几个时钟再拉低。也就是让RS485驱动器的使能信号有足够时间稳定。收发转换中间插入这段空档,是实机联调能稳定通信的细节之一。
另外,FPGA的io脚在接收状态下是输入,如果外部RS485芯片输出为高阻态,引脚电平会悬空。rx_din读到不定值就可能持续误触发接收。处理办法是配置引脚内部上拉,或者在PCB上给接收输入加一个10kΩ上拉到3.3V。如果目标FPGA的io支持迟滞输入模式,打开施密特触发,能吸收长线反射毛刺,这对现场工业环境很有帮助。
4. 功能码03/06/16与寄存器堆:从指令到回帧的完整状态机
前一章把收帧和CRC解决后,这一章要把协议翻译成具体动作。modbus-RTU应用层最常用的三个功能码是03读保持寄存器、06写单一寄存器、16写多个寄存器。把这三个做全,一套从站源代码也就齐了。
4.1 寄存器堆的地址规划与双端口RAM
从站的寄存器堆本质是一块地址空间。FPGA里最稳的做法是例化双端口RAM,端口A给应用逻辑读,端口B给Modbus协议读写。数据宽度16位、深度256个地址,基本覆盖一台设备的全部参数。
寄存器类型分三类:只读区、读写区、只写区。只读区放设备地址、固件版本、状态寄存器,Modbus写这些地址应回异常码0x02;读写区放可配置参数和控制字;只写区放命令触发寄存器,写后自动清零,读回0。VHDL里地址范围判断写成组合逻辑即可:
process(reg_addr) begin if reg_addr >= x"0000" and reg_addr <= x"000F" then region_type <= REG_RW; elsif reg_addr >= x"0010" and reg_addr <= x"001F" then region_type <= REG_RO; else region_type <= REG_INVALID; end if; end process;我一般不用二维数组实现寄存器,因为综合后每个元素都变成独立触发器,地址稍微宽一点资源会暴涨。双端口RAM IP可以省大量资源,而且读写端口独立,Modbus写数据时应用逻辑还能同时读状态,两边不冲突。
4.2 03读保持寄存器:报文的长度与字节序检查
03指令的请求帧固定是8字节。收到完整帧并CRC校验通过后,依次检查从站地址是否匹配、寄存器起始地址是否合法、寄存器数量是否为0、地址加数量是否越界。任何一项不满足都要回异常码,而不是直接回空数据。
03回帧格式是:从站地址、功能码0x03、数据字节数、数据。数据字节数等于寄存器数量乘以2。这里的字节序和请求帧方向一致,都是高字节先发。很多人在这一步前功尽弃:CRC算对了,寄存器值也对,但字节序反了就出现数值错乱,而主站侧看CRC又是正确的,这种问题最难排查。
-- 03回帧组装核心逻辑 resp_frame(0) <= slave_addr; resp_frame(1) <= x"03"; resp_frame(2) <= std_logic_vector(to_unsigned(reg_count * 2, 8)); for i in 0 to reg_count - 1 loop resp_frame(3 + 2*i) <= reg_value(i)(15 downto 8); -- 高字节 resp_frame(4 + 2*i) <= reg_value(i)(7 downto 0); -- 低字节 end loop;数据字节数位宽是8位,reg_count上限125,数据总长250字节,不会溢出。在VHDL的for循环里要注意,综合工具需要循环上界是常数,所以reg_count在解析完成后要先打拍存到一个固定宽度信号里,不要直接把可变变量用在循环边界上。
4.3 06与16写指令:把“数据字节数”吃进去
06写单一寄存器请求也是8字节,请求和回帧完全一致:地址、功能码0x06、寄存器地址2字节、寄存器值2字节、CRC2字节。实现最简单,校验通过后直接把寄存器地址和值写入RAM。
16写多个寄存器则要复杂一截。请求帧长度不固定,结构是:地址、功能码0x10、起始地址2字节、寄存器数量2字节、数据字节数1字节、数据N字节、CRC2字节。解析状态机收完前面的6字节后,必须先去读第7个字节,得到后面的数据总长度,再继续接收N字节。
VHDL状态机里处理变长帧,最忌讳的是“边收边解析”。我习惯先把整帧收进一个以太网风格的接收FIFO,帧结束信号到达后再做解析。这样状态机逻辑干净,也便于回帧时复用缓冲。实际代码里,FIFO深度256字节,每个字节8位,一块FPGA中很小的RAM资源就够。
写多个寄存器的回帧是固定的8字节,内容为请求帧的前6字节加CRC,不返回数据体。这里要注意一个细节:数据字节数是否等于寄存器数量乘以2。如果不相等,应回异常码0x03非法数据值,而不是强行按数量解析。
4.4 主状态机与异常响应:对不合理的请求说不
整体主状态机框架是:IDLE → RECV_WAIT → FRAME_PARSE → EXECUTE → BUILD_RESP → SEND_RESP,最后回到IDLE。异常响应不需要单独的状态机,在FRAME_PARSE阶段发现错误,直接跳到BUILD_RESP去组异常帧。
异常帧只有5字节:从站地址、功能码最高位置1、异常码、CRC。异常码0x01表示功能码不支持,0x02表示寄存器地址非法,0x03表示数据值非法。判断顺序上,先查从站地址和功能码,再查地址范围,最后查数据值。顺序反过来会掩盖真实错误原因。
状态机的编码方面,FPGA里case用独热码是常见又稳妥的选择。FPGA寄存器资源比组合逻辑资源便宜,独热码的状态译码省组合逻辑,也不容易出现毛刺;二进制编码能省几个触发器,但在状态跳转频繁的协议解析里不如独热码直观。无论用哪种编码,case最后都要带when others语句,否则一旦状态被打入非法值,状态机就卡死了。
关于广播地址0xFF有一点要提前规划:Modbus协议允许主站向0xFF广播,但广播帧从站不回响应。实现上,收到地址0xFF的03请求后要执行读操作但不回帧;写指令也是。这个分支在主状态机的SEND_RESP入口处理,其他地方不要分散判断,否则容易漏。
5. Modbus-RTU调不通的五个常见问题:仿真过了照样踩坑
仿真环境里把testbench跑绿了,不代表上板就能通。下面五条都是我在fpga开发过程中实际撞过的墙,按“现象、原因、解决”写出来,你照着排查能省不少时间。
5.1 回帧CRC是对的,主站就是不认
现象:用Modbus Poll类主站工具连从站,请求发过去就报超时。逻辑分析仪抓总线,看到从站回了帧,字节内容看着也正常。
原因:CRC16初值或多项式用错,也可能是CRC字节发送顺序反了。最常见的两个错:初值写成了0x0000,多项式用了0x8005而不是反射后的0xA001;第二个错是校验码算完以后,先发高字节再发低字节,和协议要求的低字节在前正好相反。
解决:把请求帧和回帧都放进测试向量表,先算一个已知帧的标准CRC值。比如地址01、功能码03、起始地址00 00、寄存器数量00 01,前面6字节的CRC应是0x840A。用这个值验证仿真波形,上板后用示波器抓串口Tx脚对比。三位一体能对上了,再排查其他。
5.2 帧空闲计时做成了字节空闲计时
现象:上位机循环读写时偶发丢包,换一个主站软件或把轮询周期调大就好了,但问题始终断断续续。
原因:把“帧结束判定”做成了“字节间隔判定”,任何两个字节之间如果超过3.5字符时间就会被判为帧结束。上位机在帧内字节间插入了一点处理延迟,比如几十微秒,从站就把帧截断了。
解决:改成总线空闲检测,用前面第3.2节那种计数器,在rx_byte_valid来临时清零,否则持续累加。阈值从35个bit时长放宽到40个左右。记住,主站对帧间隔的容忍度通常比从站大,从站把阈值放宽不影响协议合规性。
5.3 仿真对,上板只有第一帧能通
现象:上电后第一次请求能正常回帧,第二次开始全部超时,复位后又能通一次。
原因:tx_busy信号在发送完成后没有拉低,主状态机看到busy就一直停在IDLE;或者frame_end只拉高了一个周期,但状态机在RECV_WAIT阶段错过了这个脉冲。这两种都会造成“只能通一次”的假象。
解决:把发送完成信号和帧结束脉冲都用单周期脉冲逻辑处理,不能依赖电平。如果使用了FIFO回帧,检查发送状态机的空标记是否在FIFO读空后正确拉高。波形里看tx_busy是不是在最后一个停止位结束后延迟一两个周期就释放。
5.4 115200波特率下不断重发
现象:9600波特率下稳如泰山,调到115200后丢帧严重,主站不断重试。
原因:分频系数取整误差在高速率下被放大。50MHz对115200的分频是434.03,取434后单bit误差很小,但串口接收端从起始位开始连续采样9个bit,误差会累积到采样点偏移。加上RS485线缆上的反射,信号边沿抖动让问题更明显。
解决:系统时钟改用PLL生成一个能被波特率整除的时钟,比如125MHz下115200的分频是1085.07,取整误差更小;或者降低采样率、在每个bit的60%位置采样。把FPGA io的迟滞输入模式打开,也能滤掉一部分边沿毛刺。如果板上晶振不能变,可以考虑把收发器换成驱动能力更强的型号。
5.5 双向io在综合后方向错乱
现象:仿真里inout端口功能正常,综合布线后总线冲突,电平明显不对,甚至引起FPGA io脚发热。
原因:inout引脚的三态控制逻辑有误。常见错误是在bus_dir为接收时用io_bus输出定值,而不是置为高阻Z;另一个错误是收发方向切换没有空档,导致RS485收发器输出和外部驱动器同时对总线驱动。
解决:按第3.3节的三态结构,接收方向必须让io_bus为Z;发送方向才驱出bus_out。方向切换之间插入若干时钟周期的保持时间,并确认RS485收发器的DE/RE脚极性。上板后用示波器量总线电平,方向切换瞬间不应出现明显的毛刺台阶。
6. 联调验证三板斧:抓包、示波器探针和CRC边界测试
联调时我通常按三板斧走,不靠猜。第一板斧是模拟主站抓包:用Modbus Poll这类工具轮询从站,每50ms读一次寄存器,观察响应时间和异常码。第二板斧是示波器或逻辑分析仪挂在RS485差分线上,触发条件设为功能码0x03,直接看帧波形、停止位长度和字节间隔。第三板斧是CRC边界测试:构造一批畸形帧,包括CRC错、地址不匹配、长度非法等,逐一确认从站不回帧或回正确异常码。
针对从站调试,还有一个实用技巧:把内部状态机、寄存器当前地址、最后收到的字节数映射到一个调试寄存器组,再通过第二路UART主动输出ascii字符串。fpga实现串口发送ascii字符串并不复杂,把调试寄存器的值按十六进制转成字符,用一条额外的串口线接上位机看,比反复读总线抓包快得多。这套调试口在原型阶段特别值,等协议稳定了再删掉。
我最早一次拿FPGA去联PLC,主站一直报超时,我盯着波形看到回帧明明有,却不知道从站内部停在哪个状态。后来加了状态寄存器映射到串口,才发现写完寄存器后FIFO空标记没有正确产生。全程抓包软件帮不上的原因,就是问题出在逻辑内部而不是协议线上。这个习惯我一直留到现在:先让内部状态可见,再谈调参数。
如果你正要把这套代码收尾,建议把testbench和仿真脚本按目录管理好,RTL、仿真、约束分开放。这样下次维护时换一块板子或加功能码,直接能在原工程上续作,不用重新熟悉自己的旧代码。希望帮到你。
本文还有配套的精品资源,点击获取