简介:面向FPGA开发人员的Modbus-RTU协议工程包,基于VHDL实现完整主从站通信逻辑,适合工业自动化和嵌入式联网项目中需要自行部署Modbus硬件协议栈的场景。压缩包包含2211个文件,总大小37.13MB,核心源码以vhd/tdf形式组织,另有qsf/qpf工程配置、bsf/qip模块封装、sof/pof比特流文件以及vwf仿真波形;大量rpt、summary、qmsg等编译报告可辅助排查综合和时序问题。已有1358人浏览学习,非常适合具备基础数字电路知识、希望移植或参考Modbus-RTU实现的开发者。工程内划分了Modbus_slave、Modbus_interface、RECEIVER、Mast_Contrl等模块,清晰呈现帧接收解析、CRC校验、主从应答与寄存器读写等关键环节,方便二次开发和功能裁剪。
1. FPGA跑modbus-RTU为什么值得用:这份VHDL源码能解决什么
工控现场最不缺的就是串口设备,PLC、仪表、变频器几乎都认modbus-RTU。很多人的第一反应是用单片机跑协议栈,数据一多、中断一多,丢帧、响应超时的玄学问题就开始冒头。这份基于FPGA的modbus-RTU协议VHDL源代码,把从站协议栈完整落进硬件逻辑:UART收发、CRC16校验、功能码解析、寄存器读写全部由状态机驱动,不依赖操作系统,时钟稳了就能长期稳定跑。适合做PLC通信模块、电力仪表采集单元或串口网关的fpga应用工程师,也适合想拿完整串口协议源码练手的fpga项目实战党。下文先把协议层拆透,再给可直接照抄的VHDL代码和验证步骤。
2. Modbus-RTU协议层设计:帧格式、寄存器映射和从站状态机怎么组织
2.1 帧结构先定死:六个字段一处都不能错
Modbus-RTU 的帧结构非常简洁,但字节序、位数、帧间隔这些细节决定了协议栈能不能过一致性测试。一帧完整的请求或响应包含四个部分:地址码、功能码、数据域、CRC16,在总线上展开就是下面的字段表。
| 字段 | 长度 | 说明 |
|---|---|---|
| 地址码 | 1 字节 | 从站地址 1~247,0x00 是广播地址,广播帧不要求回包 |
| 功能码 | 1 字节 | 读还是写,常见 0x03 / 0x06 / 0x10 |
| 数据域 | N 字节 | 寄存器地址、数量、数据,按功能码不同长度不同 |
| CRC16 | 2 字节 | 对前面所有字节做 CRC16-Modbus 计算,先发低字节 |
功能码决定数据域长度,也决定从站 FSM 要收多少字节。这套源码里最核心的功能码有三个:03 读保持寄存器、06 写单个寄存器、16 写多个寄存器。03 请求的数据域固定 4 字节(起始地址 2 字节 + 数量 2 字节),06 固定 2 字节地址 + 2 字节数据,16 会多一个字节计数字段。如果工程里还要支持线圈读写,可以在同一套框架下扩展,但线圈是按位打包的,和寄存器按字处理完全两个套路,别混在一个解析状态里。
功能码的具体约定:
| 功能码 | 含义 | 请求数据域 | 典型用途 |
|---|---|---|---|
| 0x01 | 读线圈 | 地址 2 字节 + 数量 2 字节 | 开关量输出 |
| 0x03 | 读保持寄存器 | 地址 2 字节 + 数量 2 字节 | 模拟量、运行参数 |
| 0x06 | 写单个寄存器 | 地址 2 字节 + 值 2 字节 | 单个参数下装 |
| 0x10 | 写多个寄存器 | 地址 2 字节 + 数量 2 字节 + 字节数 1 字节 + 数据 N 字节 | 批量配置 |
除了结构,帧与帧之间的静默时间也是协议的一部分。RTU 规定两帧间隔不能小于 3.5 个字符时间,帧内字节间隔不能大于 1.5 个字符时间。9600 波特率、8N1 格式下,一个字符是 10 个 bit,约 1.04ms,3.5 字符就是 3.65ms。这两个参数直接影响空闲检测逻辑:收字节时如果间隔超过 1.5 字符,就要怀疑帧被劈断了;两帧之间不足 3.5 字符,主站会把回包错认为残留数据。
2.2 从站状态机:七拍走完一帧请求
从站侧的核心是一个接收状态机,把整帧当成字节流逐拍处理。状态设计基本就是:空闲等待 → 收地址 → 收功能码 → 按功能码收数据 → 收 CRC → 校验并执行 → 组回包发送。每一步都要处理超时回到空闲,否则一帧坏数据会把协议栈卡死。
| 状态 | 动作 | 出口条件 |
|---|---|---|
| IDLE | 等待 rx_done 脉冲 | 收到地址字节 |
| RX_ADDR | 比对从站地址 | 匹配则进 RX_FUNC,不匹配丢弃并回 IDLE |
| RX_FUNC | 锁存功能码,确认数据域长度 | 按功能码分流进 RX_DATA |
| RX_DATA | 按固定长度收数据域 | 收满进 RX_CRC |
| RX_CRC | 收 2 字节 CRC,调用 crc16 核验 | 通过进 EXEC,失败回 IDLE |
| EXEC | 执行寄存器读写或异常响应 | 组好回包进 TX_REPLY |
| TX_REPLY | 逐字节发送回包 | 发完回 IDLE |
两个从站必踩的点:一是地址匹配,地址不是本机就直接丢,连 CRC 都不用收,省资源也避免误回包;二是广播地址 0x00 要执行写操作但不回包。寄存器文件用双口 RAM 或一组寄存器数组实现,读请求走读口,写请求走写口,注意读写冲突。对于不认得的寄存器地址或非法功能码,按 Modbus 规范回异常响应,功能码最高位置 1,再跟一个异常码,比如 0x83 对应 03 读请求的异常回包。
提示:寄存器编号和协议地址差 1,这是 Modbus 协议本身的约定,仪表手册里经常写“寄存器 1”而帧里地址是 0x0000。这个偏移统一在寄存器文件这一层做掉,不要让 FSM 去背,否则后面调功能码 03 时会整体错位。
3. 核心模块代码拆解:UART收发、CRC16校验与指令解析的落地写法
3.1 UART 模块:波特率分频、起始位检测和中心采样
FPGA 开发里 UART 和 CRC 是两个高频模块,UART 的关键是把采样点放在每个 bit 的中心而不是边沿。50MHz 时钟跑 9600 波特率,单个 bit 是 5208 个时钟周期,分频常数就是50000000 / 9600取整 5208。误差只有 0.006%,一帧 11 个 bit 累计误差远小于半 bit,可以忽略。
-- uart_rx.vhd:8N1 格式接收,9600bps,采样点对齐 bit 中心 library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity uart_rx is generic ( CLK_FREQ : integer := 50_000_000; BAUD : integer := 9600 ); port ( clk_i : in std_logic; -- 系统时钟 rst_n_i : in std_logic; rx_i : in std_logic; -- 串口接收脚 rx_data : out std_logic_vector(7 downto 0); -- 收到的字节 rx_done : out std_logic -- 字节接收完成脉冲 ); end uart_rx; architecture rtl of uart_rx is constant BIT_CNT : integer := CLK_FREQ / BAUD; -- 每 bit 的时钟数 5208 constant HALF_BIT : integer := BIT_CNT / 2; -- 半 bit,对齐采样 type state_t is (IDLE, START, DATA, STOP); signal state : state_t := IDLE; signal clk_cnt : integer range 0 to BIT_CNT - 1 := 0; signal bit_idx : integer range 0 to 7 := 0; signal shift : std_logic_vector(7 downto 0) := (others => '0'); begin process(clk_i, rst_n_i) begin if rst_n_i = '0' then state <= IDLE; rx_done <= '0'; shift <= (others => '0'); elsif rising_edge(clk_i) then rx_done <= '0'; -- 默认拉低,脉冲输出 case state is when IDLE => if rx_i = '0' then -- 检测到起始位下降沿 state <= START; clk_cnt <= 0; end if; when START => if clk_cnt = HALF_BIT then -- 已到起始位中心 clk_cnt <= 0; bit_idx <= 0; state <= DATA; else clk_cnt <= clk_cnt + 1; end if; when DATA => if clk_cnt = BIT_CNT - 1 then -- 每个数据位中心采样 clk_cnt <= 0; shift(bit_idx) <= rx_i; -- LSB 先收进 shift if bit_idx = 7 then state <= STOP; else bit_idx <= bit_idx + 1; end if; else clk_cnt <= clk_cnt + 1; end if; when STOP => if clk_cnt = HALF_BIT then rx_done <= '1'; -- 整字节收完,给出脉冲 rx_data <= shift; state <= IDLE; else clk_cnt <= clk_cnt + 1; end if; end case; end if; end process; end rtl;逻辑说明:IDLE 状态检测到 rx_i 拉低,认为起始位来了;START 状态数到半 bit 再确认一次,能滤掉窄毛刺;DATA 状态每个 bit 周期在中心采样一次,LSB 先进入 shift 寄存器,8 位收齐进 STOP;STOP 状态等半 bit 后输出 rx_done 单周期脉冲,同时把 shift 锁到 rx_data。rx_done 是单周期脉冲,modbus_core 的 FSM 靠它推动后续接收。
参数说明:CLK_FREQ 和 BAUD 是 generic,换时钟换波特率只改这两个数。如果跑 115200,50MHz 下 BIT_CNT 变成 434,误码率仍然可接受。注意 BIT_CNT 是整数除法,遇到除不尽时误差会累积,尽量选能被整除的时钟,或者用 PLL 生成精确波特率时钟。发送侧 uart_tx 思路完全对称:idle 时保持高电平,收到 tx_en 先拉低发起始位,然后从 data(0) 开始逐位移出,最后拉高停止位,tx_busy 在整个发送窗口保持高电平。
3.2 CRC16 校验模块:逐位移位算法,不要一上来就查表
CRC16-Modbus 的多项式是 0x8005,反射到软件实现里就是 0xA001,初值 0xFFFF。FPGA 上最省事的是逐位反馈移位,一帧最多 256 字节,逐位算完全来得及。查表法适合软件轮询,硬件里查表反而要一块 256×16 的常量 ROM,没必要。
-- crc16.vhd:逐位算法,LSB 先算,多项式 0xA001 library ieee; use ieee.std_logic_1164.all; entity crc16 is port ( clk_i : in std_logic; rst_n_i : in std_logic; crc_rst : in std_logic; -- 帧开始拉高一次复位初值 crc_en : in std_logic; -- 每来一个 bit 使能一次 data_bit : in std_logic; -- 待校验的 bit crc_out : out std_logic_vector(15 downto 0) ); end crc16; architecture rtl of crc16 is signal crc_reg : std_logic_vector(15 downto 0) := x"FFFF"; begin process(clk_i, rst_n_i) begin if rst_n_i = '0' then crc_reg <= x"FFFF"; elsif rising_edge(clk_i) then if crc_rst = '1' then crc_reg <= x"FFFF"; -- 每帧复位到初值 elsif crc_en = '1' then if data_bit = crc_reg(0) then crc_reg <= '0' & crc_reg(15 downto 1); -- 异或结果为 0 时单纯右移 else crc_reg <= ('0' & crc_reg(15 downto 1)) xor x"A001"; end if; end if; end if; end process; crc_out <= crc_reg; end rtl;逻辑说明:data_bit 与 crc_reg 最低位做异或判断,相同就右移,不同就右移后再和 0xA001 异或。这就是 CRC 的线性反馈移位寄存器本质,反射多项式决定了从 LSB 开始处理。全部字节喂完后,crc_reg 里的值就是要发或要校验的 CRC16。
参数说明:crc_rst 必须在每一帧第一个字节之前拉高至少一个周期,把初值恢复到 0xFFFF。这个细节容易翻车:复位晚了一个 bit,或者帧中间误触发复位,结果全错。发送回包时记得先发 crc_reg 的低 8 位,再发高 8 位,这是 Modbus-RTU 的固定字节序。
这里有一个验证捷径:串口调试助手和在线 CRC 工具大多支持 Modbus 算法,用测试帧01 03 00 00 00 01算出来是 0xD844,低字节 0xD8 先上总线,整帧就是01 03 00 00 00 01 D8 44。仿真里拿这条参考帧直接对拍,比看波形猜快得多。
3.3 功能码解析与寄存器读写通路:数据从总线到寄存器文件怎么走
收到完整请求并校验 CRC 通过后,FSM 进入执行阶段。以 03 功能码为例,请求的数据域四字节解析成起始地址 0x0000 和数量 0x0001,然后从寄存器文件读出对应字,组织回包:地址码、功能码、字节数 0x02、数据高字节、数据低字节,最后补 CRC。回包的 CRC 同样走 crc16 模块,发完最后一个数据字节后,把 crc_reg 的两个字节按低在前发出去。
-- modbus_core.vhd 片段:03 功能码回包组装 -- reg_data 是刚从寄存器文件读出的 16bit 数据 -- tx_fifo_wr 每拍写一个字节到发送模块 when EXEC => case func_code is when x"03" => tx_pending <= '1'; tx_byte <= ADDR_SELF; -- 第 1 字节:从站地址 tx_fifo_wr <= '1'; state <= TX_BYTE_FUNC; when others => state <= TX_EXCEPTION; -- 非法功能码走异常回包 end case;之后的 TX_BYTE_FUNC、TX_BYTE_CNT 状态分别发功能码 0x03 和字节计数 0x02,再经过两个状态依次发数据高字节、数据低字节,最后两个状态发 CRC 低字节和高字节。CRC 是在发送的同时用同一个 crc16 模块实时算的:每个字节按位喂进去,等最后一个数据字节发完,crc_reg 里的值正好是整帧的校验值,直接进发送移位器,不用二次计算。
寄存器文件这层建议单独成模块,保持寄存器和协议解耦。写操作(06/16)解析时注意方向:请求帧里寄存器数据是高字节在前,收到后要先拼成 16bit 再写入寄存器数组;读操作则反过来。大小端统一在这一层处理,后面避坑章节会细说为什么不能在 FSM 里打补丁。
4. 仿真验证与板级调试:Testbench对拍和波形定位技巧
4.1 Testbench:模拟主站发一帧03读取请求
拿到这份 VHDL 源码,第一件事不是上板,是把 03 读保持寄存器的完整链路在 ModelSim 里跑通。写 Testbench 时复用上面的参考帧01 03 00 00 00 01 D8 44,把主站的串口发送行为模拟出来。
-- tb_modbus_slave.vhd:模拟主站发送 03 读保持寄存器请求 library ieee; use ieee.std_logic_1164.all; entity tb_modbus_slave is end tb_modbus_slave; architecture sim of tb_modbus_slave is signal clk : std_logic := '0'; signal rst_n : std_logic := '1'; signal uart_rx : std_logic := '1'; -- 主站发出的串口线,接从站 RX signal uart_tx : std_logic; -- 从站回包,接主站 RX constant CLK_PERIOD : time := 20 ns; -- 50MHz constant BIT_TIME : time := 104.167 us; -- 9600 波特率单 bit 时间 -- 模拟 UART 发送一个字节:起始位 + 8 数据位 LSB 先发 + 停止位 procedure uart_send_byte( signal txd : out std_logic; data : in std_logic_vector(7 downto 0)) is begin txd <= '0'; -- 起始位 wait for BIT_TIME; for i in 0 to 7 loop txd <= data(i); -- LSB first wait for BIT_TIME; end loop; txd <= '1'; -- 停止位 wait for BIT_TIME; end procedure; begin clk <= not clk after CLK_PERIOD / 2; dut : entity work.modbus_slave_top port map ( clk_i => clk, rst_n_i => rst_n, rx_i => uart_rx, tx_o => uart_tx ); stim_proc : process begin rst_n <= '0'; wait for 200 ns; rst_n <= '1'; wait for 1 ms; -- 03 请求:地址 01,功能码 03,起始地址 0000,数量 0001,CRC D8 44 uart_send_byte(uart_rx, x"01"); uart_send_byte(uart_rx, x"03"); uart_send_byte(uart_rx, x"00"); uart_send_byte(uart_rx, x"00"); uart_send_byte(uart_rx, x"00"); uart_send_byte(uart_rx, x"01"); uart_send_byte(uart_rx, x"D8"); uart_send_byte(uart_rx, x"44"); wait; end process; end sim;逻辑说明:uart_send_byte 过程完整模拟 8N1 帧,起始位、8 个数据位(LSB 先发)、停止位。等待 1ms 是为了让复位完全释放,也顺便把 t3.5 的空闲期做足。仿真跑完可以在波形里核对三条信号:rx_done 应该出现 8 个脉冲,modbus_core 状态机应该依次走到 EXEC,uart_tx 上对应回包01 03 02 00 01 79 84。
参数说明:BIT_TIME 用 104.167us,这是 9600 波特率的精确周期,不要图省事写成 104us,长时间仿真累计误差会让停止位采样点偏移。dut 的端口名要和顶层模块对上,不同工程命名差异很大,建议在 Testbench 顶部把端口映射写清楚,换工程时只改这一段。
4.2 板级调试:状态机波形触发和 rx_done 计数
仿真通过之后上板,常出现仿真全绿、板子就是不通的反差。这时候别急着怀疑协议栈,先把回环跑起来:把 FPGA 的 TX 直接接自己的 RX,或者通过 USB 转串口自环,用串口调试助手发参考帧,观察回包。自环测试能一次性排除时钟、复位、引脚约束三类问题。
自环能通、接真主站不通,重点查两个位置:一是空闲检测的 t3.5 计数器有没有被波特率配置带偏,二是 RX 引脚电气特性。板级调试我习惯用 Quartus 的 SignalTap 或者 Vivado 的 ILA,触发条件设成 modbus_core 的 state = EXEC,抓一拍完整帧波形。相比仿真,板级波形能看到真实引脚时序,RX 线上的毛刺和边沿抖动都在图里,一眼就能看出是电平问题还是逻辑问题。
提示:SignalTap 采样时钟用系统时钟而不是波特率时钟,抓串口信号时采样率越高看得越清楚,但存储深度占用也大。条件有限就只抓 state、rx_done、crc_en、tx_busy 这几个信号,足够定位绝大多数问题。
5. 避坑指南:modbus-RTU常见问题与五个实测翻车记录
5.1 CRC校验总是不通过
现象:仿真对拍都正常,一到串口调试助手就报 CRC error;或者主站报错,从站却不回异常码。
原因:九成是字节序问题。Modbus-RTU 要求 CRC 低字节先发,从单片机转过来的工程师习惯把 crc_reg 整体按高字节先发,主站按低字节先收,两边就错位了。另外初值复位时机不对也常见,crc_rst 没在帧头之前拉高,把前一帧的残留值算进来了。
解决:用参考帧01 03 00 00 00 01先锁死算法,确认算出 0xD844 再上板。发送侧固定成 low_byte → high_byte,接收侧校验同理,低字节先入校验器。复位信号必须发生在地址字节之前,至少提前一个时钟周期。
5.2 偶发乱码与起始位误判
现象:跑几个小时偶尔丢一个字节,或者起始位被提前触发,整帧往后错一位,然后 CRC 报错。
原因:两个因素叠加。一是分频误差,时钟不是 9600 的整倍数时,累计误差会让停止位采样点跑偏;二是 RX 引脚没有施密特触发,线缆长、电平爬坡慢,边沿抖动直接把起始位采成噪声。FPGA 的普通 IO 在输入模式下对慢边沿很敏感,长线 RS485 上尤其致命。
解决:优先用支持 hysteresis input mode 的 IO,Quartus 的 Assignment Editor 里给 rx_i 打开施密特触发送项;采样点做成可配置,把 HALF_BIT 和 BIT_CNT 设成 generic 方便换板子微调。接收端在 START 状态过半 bit 时再确认一次低电平,窄毛刺就不会误触发。
5.3 03读回的寄存器值整体错位
现象:发 03 读寄存器 1,回包里数据却是寄存器 2 的内容,读什么都差一个。
原因:Modbus 协议本身寄存器编号从 1 开始,帧内地址从 0 开始。仪表手册的寄存器表通常写“40001、40002”,对应帧地址 0x0000、0x0001。代码直接拿帧地址当下标索引寄存器数组,把 0 当成 1,整体错位。这类错最隐蔽,单帧读写看起来完全正常,对照手册才发现。
解决:在寄存器文件这一层做偏移,reg_index <= frame_addr + 1;或者寄存器数组下标 0 空置不用。关键是全工程只在一个地方做这个偏移,FSM 和 UART 层都不要碰。
5.4 主站报帧间隔错误或响应超时
现象:回包数据是对的,但主站要么报 CRC error,要么把回包拆成两帧,要么直接超时。
原因:RTU 要求帧间静默至少 3.5 字符时间。从站刚收到请求立刻回包,主站自己的接收空闲窗口还没关闭,就会把从站第一个字节当成上一帧残留;反过来,从站回包太慢超过主站超时,也会判定失败。还有一类是从站空闲计数器用固定值,换了波特率没重新算,导致 t3.5 阈值失效。
解决:从站回包前插入至少 4 个字符时间的空转,9600 波特率下就是 4ms 左右;接收侧空闲超时按 1.5 字符时间复位字节计数器、3.5 字符时间判定一帧结束,这两个阈值做成参数,换波特率跟着变。
5.5 回包数据高低字节颠倒
现象:串口助手能收到响应,数值也对区间,但 0x1234 回成 0x3412,主站解析出来完全不对。
原因:大小端处理不一致。寄存器文件里存的是 16bit 字,03 回包要求高字节先发,06/16 写请求则要求先收高字节再拼字。代码如果没统一,读走一遍、写走一遍,出来的顺序就反了。
解决:寄存器文件读写口统一做大端协定,读时 high_byte 先出,写时 high_byte 先收。不要在 modbus_core 的状态机里临时 swap,那是最容易漏的补丁式写法,换一个功能码就翻车。
6. 进阶改造:从站转主站与多机轮询调度怎么落地
6.1 把回包FSM反转成发送FSM
这套代码的接收路径、CRC 路径、寄存器文件都是现成的,转主站的改动量没有想象中大。核心是把状态机从“等请求 → 组回包”改成“组请求 → 发帧 → 等响应”:新增一个 POLL_SEND 状态,在空闲时从轮询表取出下一项,先用 crc16 模块对地址、功能码、寄存器地址、数量逐字节算 CRC,算完把整帧按字节灌进 uart_tx,然后进 WAIT_RESP 状态等从站应答。
6.2 轮询表用 ROM 存,间隔用计数器
多从站场景建议把轮询内容固化成一个二维数组或 ROM 表,每行一个任务。
| 列 | 含义 | 示例 |
|---|---|---|
| from_addr | 从站地址 | 0x01 |
| func | 功能码 | 0x03 |
| reg_start | 起始寄存器 | 0x0000 |
| reg_qty | 数量 | 0x0001 |
| interval | 轮询间隔 | 100ms |
FSM 每完成一轮跳到表的下一行,表尾回到表头。间隔用系统时钟计数器实现,计数到 interval 才发起下一轮。这样加设备只改 ROM 内容,逻辑一行不用动。验证方法和第 4 章 Testbench 同一套思路,只是把激励从“主站发请求”换成“从站回响应”,对拍响应帧的 CRC 和寄存器值。
从那以后,我每次移植这套基于FPGA的modbus-RTU协议VHDL源代码,第一件事就是先跑一遍带参考 CRC 帧的 Testbench,帧例过了才敢上板调电平。这个习惯帮我筛掉了至少一半“看着像硬件问题”的软件 bug。希望帮到你。
本文还有配套的精品资源,点击获取