简介:面向嵌入式开发者的STM32F407ZGT6 Modbus RTU实现资源,完整演示通过UART与智能电表通信,并采用CRC-16循环冗余校验确保数据可靠传输。压缩包约33.57MB,内部包含工程源码的Inc与Src目录、HAL库驱动程序、IAR和Keil两类开发环境的工程配置文件,还提供Modbus协议中文版PDF手册,方便对照文档理解帧结构与寄存器操作。已有1045人学习下载,适合具备一定单片机基础、正在学习工业通信协议或希望快速实现Modbus读表的软硬件工程师。资源从串口参数配置到中断收发,再到CRC计算与异常处理均有覆盖,读者可据此掌握Modbus RTU帧的封装解析技巧,并可直接将HAL库相关API应用到实际电表采集项目中,为多设备组网和云平台数据接入打下基础。同时,超时、重发等边界情况的处理思路也一并在工程中呈现,有助于规避调试中的典型问题。
1. 先搞清楚Modbus RTU是怎么说话的
做智能电表读取,第一步不是写代码,而是先把Modbus RTU这个“语言规则”弄明白。市面上绝大多数智能电表都支持Modbus RTU协议,挂在RS485总线上,主站发请求,从站(电表)回响应,一问一答,规矩非常清晰。
Modbus RTU的消息帧格式其实就五个部分:从站地址、功能码、数据区、CRC校验、结束。帧没有固定的起始和停止字节,靠的是“静默时间”来切分——一条帧发完,总线空闲至少3.5个字符时间,下一条帧才能开始。这个细节很多人第一次写的时候会忽略,导致收帧拆帧乱套。
读电表数据,最常用的功能码是03(读保持寄存器)。为什么是03而不是04?因为电表的电压、电流、功率、电能这些数据,绝大多数厂商都映射到了保持寄存器区(协议地址40001~49999),对应Modbus协议里的“保持寄存器”,用03功能码去读。也有部分电表用04读输入寄存器,但一般看厂家手册就能确认,DL/T 645转Modbus的设备多半也是走03。
这里要先记住一个最坑的换算关系:协议地址和数据地址差1。程序里填的寄存器地址是协议地址,比如要读“40001”,代码里填的偏移量是0x0000;要读40003,代码填0x0002。当年我第一次调电表,直接填40001进去,结果电表回了异常码0x02,查了半天才发现是地址偏移搞错了。这个换算不懂,后面全白搭。
再一个是寄存器宽度。电表的电压、电流这类数据,一般占2个寄存器(32位),比如电压是0.1V分辨率,那就得把两个寄存器的值拼起来再除以10。不同电表的分辨率还不一样,这块必须以厂家协议手册为准,不能想当然。
搞清楚帧格式和寄存器地址规则,后面写代码才有底气。接下来把CRC校验单独拎出来讲,因为这是Modbus RTU帧能不能被正确解析的命门。
2. CRC校验:电表通信的最后一道保险
Modbus RTU的CRC校验用的是CRC-16/MODBUS算法,多项式是0x8005(反映形式0xA001),初始值0xFFFF,输出时低字节在前、高字节在后。和常见的CRC-16/CCITT、CRC-16/XMODEM都不一样,千万别套错,套错了电表会一直不回包,或者回了包你这边校验不过。
CRC计算的原理不复杂:把要校验的数据看作一个二进制大数,除以生成多项式,余数就是CRC值。Modbus的实现是逐字节、逐位右移的算法,每次右移一位,如果移出的位是1,就和0xA001异或。这个算法可以用查表法加速,也可以逐位硬算。单片机跑逐位法,一帧也就8~16个字节,耗时完全无所谓,写起来还直观。
我直接给一版逐位计算的实现,实测在STM32F407上跑一遍8字节的帧,耗时微秒级,完全够用:
uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc = crc >> 1; } } } return crc; }调用的时候注意,返回的crc是16位的,发送时先发低字节,再发高字节。比如算出来0xC50B,线上先发0x0B,再发0xC5。这个字节序错了,接收方CRC校验必然失败。
查表法其实也值得了解,它把256个字节对应的CRC值提前算好存成表,计算时一个字节一次查表异或,速度比逐位快8倍。F407主频168MHz,逐位法已经绰绰有余,我建议初学先吃透逐位法,理解到位再换查表。查表法的核心思想是“用空间换时间”,一张512字节的表,换来的是每次计算少跑8轮循环。
CRC校验失败时,处理策略很简单:整帧丢弃,不解析,等待下一个静默周期重新收帧。千万不要“半信半疑”地用错误帧里的数据,电表数据错一位可能就是电压变成几千伏,这在真实项目里是要出事故的。
我个人的习惯是,在串口中断里只收字节、不解析,收满一帧后在做CRC校验和应用层解析。这样ISR保持极短,不容易丢字节。F407的串口带FIFO和DMA,其实还能更省心,但如果你刚开始调,先用普通中断方式把逻辑跑通,后面再优化也不迟。
3. 核心代码实现:从串口收字节到完整报文
帧格式懂了、CRC有了,接下来就是在STM32F407上把它变成能跑的代码。先说整体思路:主站(STM32)主动发查询帧,然后等电表回响应帧。中间这帧的切分、校验、解析,是全部工作的核心。
先看关键的串口配置。F407的USART1挂APB2,USART2/3挂APB1,波特率设置时注意时钟源不同。这里串口接收我建议用中断方式,每个字节进一次中断,把字节推入环形缓冲区或状态机缓存。波特率根据电表手册来,常见的是9600和2400,个别电表支持19200。波特率对应到USART的BRR寄存器计算,写法如下:
void uart_init(uint32_t baudrate) { // 以USART1为例,时钟来自APB2,频率84MHz RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); USART_InitTypeDef usart; usart.USART_BaudRate = baudrate; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_Mode = USART_Mode_RX | USART_Mode_TX; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &usart); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); }注意Modbus RTU要求8数据位、无校验、1停止位(8N1),这是默认配置。少数电表支持偶校验(8E1),但出场默认绝大多数是8N1,不匹配的话收不到任何正常响应。
接收帧的切分,核心就是“超时判断”。Modbus规定两个字节之间间隔超过1.5个字符时间就算帧内错误,超过3.5个字符时间就认为一帧结束。在代码里,我用这种方式实现:收到第一个字节后启动一个定时器,比如用一个1ms的Tick计数器,持续检测距上次收到字节的时间,超过3.5个字符时间(9600波特率下约4ms)就认为帧收完了。网上的做法五花八门,有的在串口中断里直接来个for循环等待,这在高波特率下容易卡死主循环,不推荐。
更实用的是状态机缓存方式。把所有字节存到一个数组里,同时记录长度,静默超时后再统一处理:
#define FRAME_BUFFER_SIZE 64 volatile uint8_t rx_buffer[FRAME_BUFFER_SIZE]; volatile uint16_t rx_len = 0; volatile uint8_t frame_ready = 0; volatile uint32_t last_rx_tick = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); if (rx_len < FRAME_BUFFER_SIZE) { rx_buffer[rx_len++] = ch; } last_rx_tick = systick_ms; // 记录最后收字节时间 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } void modbus_poll(void) { // 每毫秒调用一次,检查帧是否收完 if (rx_len > 0 && !frame_ready) { if (systick_ms - last_rx_tick >= 4) { // 9600下3.5字符约4ms frame_ready = 1; } } }有人质疑,表达式数据里如果本身包含连续0x00,会不会被误判?没关系,我们判断的是时间间隔,不是字节值。
组装查询帧时,核心是用03功能码读N个寄存器:
void modbus_read_registers(uint8_t slave_addr, uint16_t start_reg, uint16_t reg_count, uint8_t *frame) { frame[0] = slave_addr; frame[1] = 0x03; frame[2] = (start_reg >> 8) & 0xFF; frame[3] = start_reg & 0xFF; frame[4] = (reg_count >> 8) & 0xFF; frame[5] = reg_count & 0xFF; uint16_t crc = modbus_crc16(frame, 6); frame[6] = crc & 0xFF; frame[7] = (crc >> 8) & 0xFF; }发送后进入等待状态,收到完整响应帧后,先做CRC校验,再检查从站地址和功能码,最后解析数据区。响应帧的格式是:从站地址、功能码、字节数、数据、CRC。比如读2个寄存器成功,响应是“01 03 04 数据4字节 CRC低 CRC高”。
解析时有个容易踩的坑:如果功能码最高位是1(比如0x83),说明电表返回的是异常码,后面那个字节是异常码(01非法功能、02非法数据地址、03非法数据值),这时候就别当正常数据解析了。
4. 读电表实操:从接线到出数据
硬件接线这块,很多人翻车。STM32的串口TTL电平,要经过RS485收发器转成差分信号再接电表。常见方案是用一块SP3485或MAX3485做的RS485转TTL模块,STM32的TX/RX分别接模块的RXD/TXD,再拉一个GPIO控制DE/RE方向。收发切换必须留出稳定时间,发完查询帧后延时至少1ms再切接收,不然电表响应回来时方向还没切过来,前半段字节直接丢了。
A/B端子:A接A,B接B,不要接反。接反了的现象很典型:发送正常但完全收不到响应,或者收到的全是干扰乱码。总线两端记得接120Ω终端电阻,短距离(几米)不接也能跑,但稍微长一点或现场干扰大,丢帧率会明显上升。另外尽量和电表共地,RS485虽然差分传输,但共模电压过高会把收发器打坏,这个隐患在现场项目里遇到过好几次。
STM32那边的接线示意大概是这样:
- STM32 TXD(PA9,USART1)→ RS485模块 RXD
- STM32 RXD(PA10,USART1)→ RS485模块 TXD
- STM32 任意GPIO(比如PA8)→ RS485模块 DE/RE(合并控制)
- RS485模块 A → 电表 RS485 A
- RS485模块 B → 电表 RS485 B
我给一个实际跑通过的主流程伪代码,还是用轮询方式,不搞复杂操作系统,逻辑清晰:
int main(void) { systick_init(); uart_init(9600); gpio_init_rs485_dir(); uint8_t query[8]; uint8_t resp[64]; modbus_read_registers(0x01, 0x0000, 2, query); // 电表地址1,读寄存器40001 while (1) { rs485_dir_tx(); uart_send_bytes(query, 8); delay_ms(1); rs485_dir_rx(); // 等待接收,超时200ms uint32_t timeout = systick_ms + 200; while (systick_ms < timeout) { modbus_poll(); if (frame_ready) break; } if (frame_ready) { // 1. CRC校验,通过再继续 uint16_t crc_calc = modbus_crc16((uint8_t*)rx_buffer, rx_len - 2); uint16_t crc_recv = rx_buffer[rx_len - 2] | (rx_buffer[rx_len - 1] << 8); if (crc_calc == crc_recv) { // 2. 解析响应,提取电压值 uint32_t raw = (rx_buffer[3] << 8) | rx_buffer[4]; voltage = raw / 10.0f; // 按0.1V分辨率换算 } rx_len = 0; frame_ready = 0; } delay_ms(1000); // 每秒读一次 } }解析响应帧时,注意数据区里多个寄存器的顺序。比如读2个寄存器返回4字节,通常是高字节在前(大端)。电压这种32位数据,可能是“寄存器1高字节、寄存器1低字节、寄存器2高字节、寄存器2低字节”,拼接时要按大端处理。具体分辨率比如0.1V还是0.01V,看电表协议书。
电表地址(从站地址)出厂一般是1,个别是2或其他,可以在电表面板或厂家软件里设置。地址不对,返回的响应帧直接是超时,CRC根本就不用验了。
5. 踩坑实录与排查速查表
这一步把我在多个项目里实际遇到的问题汇总成一张表,基本覆盖了90%的新手故障:
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| 完全无响应 | 接线错误、从站地址不对、波特率不匹配 | 用USB转485模块接电脑串口助手测,确认帧格式和地址 |
| 有响应但CRC老错 | 字节序填反、数据被干扰、奇偶校验不匹配 | 核对CRC高低字节发送顺序,对比串口助手抓包 |
| 响应异常码0x02 | 寄存器地址填错,偏移量没减1 | 确认读40001时填0x0000 |
| 数据值明显不对 | 寄存器数量错、分辨率算错、字节序拼错 | 对照手册确认数据宽度和缩放系数,逐字节打印定位 |
| 收发切换丢字节 | RS485方向切换没延时 | 发送结束后延时1~5ms再切接收 |
| 偶发乱码 | 共地不良、没接终端电阻、线缆过长 | 加120Ω终端电阻,确保两边共地,缩短线缆距离 |
调试工具是最值钱的投资。强烈建议第一步用USB转RS485模块加串口调试助手,在电脑上先把电表读通,抓一帧正确的响应数据,再拿这帧去对比STM32收到的内容。这样能快速区分是“协议问题”还是“代码问题”。别一上来就STM32直接连电表,否则出错时你不知道问题是出在硬件还是软件。
还有一个独家技巧:在STM32代码里把收到的原始帧通过另一个串口(比如USART2)打印到PC串口助手,格式用十六进制。这样你不仅能看连续通信的空闲间隔,还能验证T35超时切帧是否正常。我调试时经常同时开两个串口调试窗口,一个看原帧,一个看解析后的数据,定位问题效率能提升一倍。
关于T35超时的计算,我再补充一个细节。3.5个字符时间 = 3.5 × 11位(起始位1 + 数据位8 + 校验0 + 停止位1)÷ 波特率。9600波特率下约4ms,115200下约0.33ms。F407的主频很高,系统Tick用1ms粒度在9600波特率下刚刚够用;如果换更高波特率,建议把Tick降到0.1ms粒度,或者直接用定时器捕获的方式来判断帧间隔,否则会产生误切帧的隐患。
另外,DMA接收也是一个值得尝试的优化方向。串口DMA接收+空闲中断(IDLE Line)可以自动把一帧搬到内存里,CPU几乎零负担。F407的USART支持空闲中断,配合DMA,收到一整帧后触发一次中断,省去逐字节进中断的消耗。这个方案在需要长时间轮询多块电表的生产环境里非常香,等基础版跑通后可以进阶试试。
最后一个建议:写一个modbus_parse_response函数,把解析逻辑和发送逻辑彻底分开。发送负责组装帧、控制RS485方向,接收负责收帧、校验、切帧,解析负责从已校验的数据区提取有意义的值。这样三层解耦后,后续换电表型号、加功能码、扩展多从站轮询,都只需要改很小一部分代码,不用推倒重来。我自己的经验是,凡是Modbus主站代码写成一坨的,后面维护绝对想骂人。
读智能电表这件事,说到底就是“会说话、说得对、守规矩”。把帧结构、CRC、时序这三样吃透,STM32上实现Modbus RTU主站读电表就是水到渠成的事。我在实际项目中用这套代码连续跑过几个月,掉线和误码率都在可接受范围内,可靠性关键在于严格按照Modbus的时序来,不偷工减料。如果你在调试时碰到诡异问题,别急着怀疑硬件,先抓原始帧对照协议抠细节,大多数坑都在协议层面。
本文还有配套的精品资源,点击获取