ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32实战:MODBUS-RTU主机从机完整实现与RS485总线详解

STM32实战:MODBUS-RTU主机从机完整实现与RS485总线详解

简介:一套基于STM32与RS485总线实现的Modbus-RTU主从机通信工程,面向嵌入式开发初学者、自动化专业学生,以及需要快速完成串口主从机通信的工程师。设备默认上电为主机,自动寻址地址01的从机读取数据;通过按键1~3可切换查询01~03号从机,按键4则切换为地址0x02的从机,并用不同LED闪烁指示当前状态,全面覆盖串口收发、定时器超时处理、Modbus报文解析与应答等核心环节。压缩包共162个文件,以C源文件(.c)、头文件(.h)和Keil工程配置为主,同时附带hex/axf可执行文件及map/lst等编译辅助文件,整体大小约3.02MB,可直接打开工程研读或烧录验证。已有16231人学习下载,适合作为STM32+Modbus-RTU主从机代码的参考模板加以复用。

1. 从一次现场联调说起:RS485总线上为什么需要MODBUS主机

做嵌入式的人迟早会遇到一件事:把几台RS485设备挂到同一根总线上,让STM32轮流读取数据。我最初的做法很天真——串口发一串字节、延时100ms、再读返回值,结果在现场联调时翻车很惨:设备A回了、设备B没回,设备C回了半帧,设备D干脆把总线拉死。这套资源解决的就是这件事,在STM32上实现一套完整的MODBUS-RTU主机和从机代码,串口负责收发、定时器负责判断帧边界、GPIO负责RS485半双工换向。既能当主机轮询多台从机,也能让设备本身当从机被别人读取。适合正在写RS485采集、传感器联网、PLC对接的嵌入式开发。

2. 架构与目录:主机和从机复用同一套串口接收框架

2.1 整体架构:一主多从的分层设计

这套代码没有把主机和从机拆成两个独立工程,而是把通信链路分成三层。物理层是RS485差分总线,多台设备并联在A、B两根线上;链路层是STM32的USART串口,负责字节流收发;应用层是MODBUS-RTU协议,定义请求帧、应答帧、CRC校验和功能码。主机和从机共用同一个串口接收中断、同一个定时器帧超时判断,差异只在帧完整到达后的分发逻辑里。

RS485是半双工总线,同一时刻只能有一台设备驱动线路。整套系统是一主多从结构,主机主动发请求,从机收到地址匹配且CRC正确的帧后被动应答。为什么不靠硬件仲裁?因为MODBUS-RTU本身没有链路层冲突检测,必须靠协议上的地址过滤和主从时序来避免总线碰撞。应用层的差异集中在两点:主机侧是轮询调度和超时重试,从机侧是寄存器表和功能码分发,其余全部复用。

2.2 目录结构与文件职责

工程按CubeMX生成的标准目录组织,协议代码集中在Application层,便于移植到F103、F407或G0系列。

文件路径职责
Core/Src/main.c主循环,调度主机轮询或从机查询帧标志
Core/Src/modbus_master.c主机状态机、请求帧拼装、超时重试
Core/Src/modbus_slave.c从机地址过滤、功能码处理、寄存器表
Core/Src/crc16.cCRC16-MODBUS计算,收发共用
Core/Src/uart.c串口1初始化、单字节中断接收
Core/Src/timer.c定时器6初始化,实现RTU帧超时判定

这里有个刻意的安排:crc16.c、uart.c、timer.c是主机和从机复用的,modbus_master.c和modbus_slave.c按角色编译。实测在主循环里同时跑两个角色也可以——同一份代码既响应别人读取自己的寄存器,也主动去轮询别的从机,前提是两边用不同的串口或不同地址。这在写联调测试板时很有用,一个固件既能当主机又能当从机,省一套硬件。

2.3 硬件接线与初始化参数

RS485部分我一般用MAX3485,3.3V供电,和STM32电平直接匹配。经典接法如下:

STM32引脚MAX3485引脚说明
PA9 (USART1_TX)DI发送数据
PA10 (USART1_RX)RO接收数据
PA8 (GPIO输出)DE/RE方向控制,高电平发送、低电平接收
3.3V / GNDVCC / GND供电
A / B总线差分对手拉手并联

总线两端各并一个120欧终端电阻,匹配特征阻抗,防止信号反射。距离超过几十米或者现场有变频器、电机时,这个电阻不能省,否则偶发乱码会折磨你好几天。

定时器用的是TIM6,基本定时器专做帧间隔超时判断,不占用通用定时器的PWM功能。以STM32F103的72MHz主频为例,关键参数计算如下:

/* 定时器参数:9600 波特率、8 数据位 1 停止位 */ /* 1 个字符 = 10 bit,9600bps 下 1 字符约 1.04ms */ /* MODBUS-RTU 规定 3.5 个字符时间判帧结束,即约 3.65ms */ htim6.Init.Prescaler = 72 - 1; /* 72MHz / 72 = 1MHz,计数周期 1us */ htim6.Init.Period = 4000 - 1; /* 溢出时间 4000us = 4ms,略大于 3.5T */

说明:预分频72后定时器计数频率是1MHz,每计一次就是1微秒。Period取4000表示计数4000次溢出,正好4ms。为什么取4ms而不是精确的3.65ms?因为现场波特率本身存在误差,UART时钟源还有分频误差,留一点余量反而稳。如果改19200波特率,Period缩小到2000;改115200则取350,对应约350us。换固件时注意强制约定主机和从机波特率完全一致,稍有偏差尤其在长帧时就会偶发断帧。

提示:如果发现一帧数据被拆成两帧,检查定时器溢出值是否小于一帧中两个相邻字节的正常间隔。串口逐字节接收时,正常情况下字节之间没有间隔,如果定时器周期太小,中间某次间隙超过阈值就会误判。

3. 核心代码拆解:从机从收包到应答的完整链路

3.1 串口逐字节中断接收与帧缓冲

接收方案的选型很关键。常规做法有两种:DMA加空闲中断,或者逐字节中断加定时器。逐字节中断方案代码直观、依赖少,这套代码选的就是它。每次启动接收一个字节,进入回调后把字节写入缓冲区并复位定时器,定时器溢出则在另一个中断里置帧完成标志。

/* 全局帧缓冲,放在 .bss 段 */ uint8_t g_rx_buf[256]; /* 一帧最大256字节,够绝大多数场景 */ uint16_t g_rx_len; /* 当前已收字节数 */ uint8_t g_rx_byte; /* HAL库单字节接收缓存 */ volatile uint8_t g_frame_ready; /* 一帧接收完成的标志 */ /* 主循环开始前启动第一次接收 */ HAL_UART_Receive_IT(&huart1, &g_rx_byte, 1); /* 每收到一个字节,HAL 库回调一次 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { g_rx_buf[g_rx_len++] = g_rx_byte; if (g_rx_len >= sizeof(g_rx_buf)) { g_rx_len = 0; /* 溢出保护,丢弃本帧重新攒 */ } __HAL_TIM_SET_COUNTER(&htim6, 0); /* 新字节到达,重置帧超时计时 */ HAL_UART_Receive_IT(&huart1, &g_rx_byte, 1); /* 继续接收下一字节 */ } }

这段代码有三个关键点。第一,__HAL_TIM_SET_COUNTER(&htim6, 0)是核心动作,每收到一个字节都把定时器计数清零,定时器永远从0开始数,只有超过4ms没有新字节才溢出。第二,缓冲区尾部要处理,256字节对MODBUS-RTU常规帧够用,如果寄存器数量较多,把数组改成512。第三,HAL库单字节接收模式必须每次回调后重新enable,漏了这行,机器跑着跑着通信就卡死,我在某个远程采集项目里踩过一次,排查了整整一天。

定时器溢出回调接在后面:

/* TIM6 溢出中断:4ms 无新字节,判定一帧结束 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { if (g_rx_len > 0) { g_frame_ready = 1; /* 置位后主循环去处理 */ } } }

逻辑说明:这里不清计数、也不停定时器,让TIM6一直自由运行,新字节到了自然会被SET_COUNTER清零。g_frame_ready置位后,主循环处理完毕要手动清g_rx_len和g_frame_ready,否则下一帧会拼在上一帧后面。问什么把这步放在主循环而不是中断里?因为协议解析、CRC计算都比较耗时,放在中断里会拖慢串口响应,可能丢下一个字节。

3.2 定时器参数:3.5T怎么算才不丢帧

MODBUS-RTU的帧边界规则:一帧内相邻两个字符间隔不能超过1.5T,帧与帧之间空闲至少3.5T。工程上用"超过3.5T无新字节判帧结束"最简单,但参数取多少直接决定通信稳不稳。按9600、8N1计算,一字符10位,1字符时间1.04ms,3.5字符约3.65ms,定时器取4ms。这个值比理论值略大,因为UART中断响应本身有几微秒到几十微秒延迟,字节到达是离散的,留0.3ms余量很有必要。

波特率1字符时间(8N1)3.5T理论值定时器Period建议
48002.08ms7.29ms8ms
96001.04ms3.65ms4ms
192000.52ms1.82ms2ms
384000.26ms0.91ms1ms
1152000.087ms0.30ms0.5ms

实测建议别把Period压到理论值边缘,宁可偏大一点,后果只是主机收到响应的判定晚几百微秒;偏小的代价是大量偶发断帧,排查起来的痛苦程度完全不在一个量级。

3.3 CRC16校验与从机地址过滤

CRC16-MODBUS是整个协议里最容易写错的部分。多项式0xA001,初始值0xFFFF,逐位计算。下面这个软件位运算版不查表,代码短、易理解,换任何编译环境都能跑:

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 bit = 0; bit < 8; bit++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; /* 最低位为1时异或多项式 */ } else { crc >>= 1; } } } return crc; }

参数说明:第一个参数是参与校验的数据指针,第二个是长度。主机请求帧、从机应答帧都要算CRC,校验范围是"从地址字节到最后一个数据字节",不包括CRC本身那两字节。初值0xFFFF不能改,多项式0xA001是MODBUS标准要求,改成0x8005就是CRC-16/CCITT,设备之间会直接对不上。

从机收到一帧后的处理顺序:先看地址,不是自己的直接丢;再看长度下限;最后算CRC。地址过滤放CRC前面是刻意的,地址不匹配说明这帧是发给其他设备的,没必要浪费几十微秒算CRC。

uint8_t modbus_slave_check(void) { uint16_t calc_crc, recv_crc; if (g_rx_buf[0] != SLAVE_ADDR) /* SLAVE_ADDR 在 modbus_config.h 配置 */ return 0; if (g_rx_len < 8) /* 帧长度下限:地址+功能码+数据+CRC */ return 0; calc_crc = modbus_crc16(g_rx_buf, g_rx_len - 2); recv_crc = (g_rx_buf[g_rx_len - 2]) | /* MODBUS帧内CRC低字节在前 */ (g_rx_buf[g_rx_len - 1] << 8); return (calc_crc == recv_crc) ? 1 : 0; }

注意这里接收CRC的拼法,很多初学在这里翻车:MODBUS-RTU线上先传CRC低字节、再传高字节,所以帧尾第len-2个字节是低字节,直接左移8位就拼反了。正确写法是buf[len-2] | (buf[len-1] << 8)。

3.4 功能码03读保持寄存器与06写单个寄存器

从机侧核心是功能码分发。03读保持寄存器是最常用的查询类功能码,06写单个寄存器是控制类。下面是03的完整应答拼装逻辑:

/* 保持寄存器表,地址从0开始,数量可配 */ uint16_t reg_table[64]; void modbus_slave_task(void) { uint8_t i; uint16_t reg_addr, reg_count; if (!g_frame_ready) return; g_frame_ready = 0; if (!modbus_slave_check()) { /* 地址或CRC不过,丢弃 */ g_rx_len = 0; return; } switch (g_rx_buf[1]) { /* 第二个字节是功能码 */ case 0x03: /* 读保持寄存器 */ reg_addr = (g_rx_buf[2] << 8) | g_rx_buf[3]; reg_count = (g_rx_buf[4] << 8) | g_rx_buf[5]; g_tx_len = 0; g_tx_buf[g_tx_len++] = SLAVE_ADDR; g_tx_buf[g_tx_len++] = 0x03; g_tx_buf[g_tx_len++] = reg_count * 2; /* 寄存器数×2 才是字节数 */ for (i = 0; i < reg_count; i++) { g_tx_buf[g_tx_len++] = (uint8_t)(reg_table[reg_addr + i] >> 8); g_tx_buf[g_tx_len++] = (uint8_t)(reg_table[reg_addr + i] & 0xFF); } modbus_send(g_tx_buf, g_tx_len); break; case 0x06: /* 写单个保持寄存器 */ reg_addr = (g_rx_buf[2] << 8) | g_rx_buf[3]; reg_table[reg_addr] = (g_rx_buf[4] << 8) | g_rx_buf[5]; modbus_send(g_rx_buf, g_rx_len); /* 原样回显请求帧 */ break; } g_rx_len = 0; }

逻辑说明:03请求帧的数据段是"起始地址2字节+寄存器数量2字节",响应先给字节数再给原始数据,寄存器值高字节在前。06响应不是自己构造,而是把请求原样回发,这是MODBUS标准规定,上位机靠比对请求和响应的一致性确认写入成功。reg_table就是设备的"内存映射",实际项目里它通常不是简单数组,而是结构体或函数指针表,后面二次开发章节再展开。

发送侧的重点是RS485半双工换向,下面是踩过坑后的写法:

void modbus_send(uint8_t *buf, uint16_t len) { RS485_DE_DIR(1); /* PA8 拉高,DE/RE 进入发送模式 */ HAL_UART_Transmit(&huart1, buf, len, 100); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET); /* 等移位寄存器完全送出 */ RS485_DE_DIR(0); /* 再切回接收模式 */ }

这里最容易出问题的是注释标的那一步。HAL_UART_Transmit返回时,数据只是从软件缓冲搬到了发送寄存器,最后一个字节可能还在移位寄存器里往外移,此时把DE拉低,发送被截断,从机收到的帧尾少一个字节。加一个while等TC标志是必须的。另外MAX3485的DE和RE在同一个引脚上——DE为高是发送、RE为低是接收,所以用一个GPIO能同时控制两个功能。

4. 主机侧实现:状态机轮询与超时重试

4.1 主机轮询表与状态机设计

主机的难点是无阻塞。发完一帧请求后如果死等响应,整个主循环被卡住,其他任务全部停摆。MODBUS轮询的常规做法是状态机加超时计时器——发请求、切状态、继续跑主循环,定时器到点再回来检查有没有收到应答。

轮询表是配置驱动的核心,新增一台从机就是加一行:

typedef struct { uint8_t slave_addr; /* 从机地址 */ uint8_t func; /* 功能码,0x03读/0x06写 */ uint16_t reg_addr; /* 起始寄存器地址 */ uint16_t reg_count; /* 寄存器数量 */ uint16_t timeout_ms; /* 单次等待应答的时长 */ uint8_t retry_cnt; /* 超时重试次数 */ } poll_item_t; static const poll_item_t poll_table[] = { {0x01, 0x03, 0x0000, 10, 200, 3}, /* 1号从机:读 0x0000 起的 10 个寄存器 */ {0x02, 0x03, 0x0000, 5, 200, 3}, /* 2号从机:读 5 个寄存器 */ {0x03, 0x06, 0x0100, 1, 200, 3}, /* 3号从机:写 1 个寄存器 */ };

参数说明:timeout_ms是等待从机响应的最长时间,按经验取从机内部处理时间的两倍以上。常见从机响应在10~100ms,取200ms基本够用,但带屏显的仪表内部刷新可能到500ms,这个值就得加大。retry_cnt我一般设3次,3次都失败就把这台从机标记为离线,继续轮询下一台,不卡死在一台上。注意地址0xFF是MODBUS广播地址,广播帧不期望任何从机应答,别把它放进轮询表。

状态机定义如下:

typedef enum { MS_IDLE, /* 空闲,等待下一个轮询周期 */ MS_SEND_REQ, /* 发送请求帧 */ MS_WAIT_RESP, /* 等待应答,定时器倒计时 */ MS_HANDLE_RESP, /* 应答已收到,解析并保存 */ MS_NEXT_ITEM /* 跳到下一个轮询项 */ } master_state_t;

这个状态机在主循环里每轮都执行一次master_poll(),不阻塞、不延时,吞吐完全由轮询表大小和timeout决定。

4.2 请求帧拼装与RS485换向时序

主机发送请求帧和从机发送应答帧用的是同一个modbus_send函数,差别只在数据内容。下面是主机专用的拼帧代码:

void master_send_request(const poll_item_t *item) { uint16_t crc; g_tx_len = 0; g_tx_buf[g_tx_len++] = item->slave_addr; g_tx_buf[g_tx_len++] = item->func; g_tx_buf[g_tx_len++] = (uint8_t)(item->reg_addr >> 8); g_tx_buf[g_tx_len++] = (uint8_t)(item->reg_addr & 0xFF); g_tx_buf[g_tx_len++] = (uint8_t)(item->reg_count >> 8); g_tx_buf[g_tx_len++] = (uint8_t)(item->reg_count & 0xFF); crc = modbus_crc16(g_tx_buf, g_tx_len); g_tx_buf[g_tx_len++] = (uint8_t)(crc & 0xFF); /* CRC低字节先发 */ g_tx_buf[g_tx_len++] = (uint8_t)(crc >> 8); modbus_send(g_tx_buf, g_tx_len); current_item = item; /* 记住当前轮询项,等应答回来核对 */ resp_timeout_cnt = item->timeout_ms; /* 启动应答超时倒计时 */ retry_left = item->retry_cnt; master_state = MS_WAIT_RESP; }

逻辑说明:注意CRC低字节先发是MODBUS-RTU的硬性要求,发送顺序错了从机直接丢弃。请求帧结构是"地址、功能码、起始地址高低字节、数量高低字节、CRC低高字节",共8字节,与从机接收解析严格对应。换向时序和从机完全一样,先拉高DE,发完后等TC,再拉低DE,这个等待不能省。

4.3 应答校验、超时重试与错误统计

主机收到一帧数据后,处理函数的核心逻辑如下:

void master_handle_response(void) { uint16_t calc_crc, recv_crc; if (g_rx_buf[0] != current_item->slave_addr) return; /* 不是当前请求的从机发来的帧,忽略 */ if (g_rx_len < 4) return; calc_crc = modbus_crc16(g_rx_buf, g_rx_len - 2); recv_crc = g_rx_buf[g_rx_len - 2] | (g_rx_buf[g_rx_len - 1] << 8); if (calc_crc != recv_crc) return; /* CRC错误,当没收到,等超时重试 */ if (g_rx_buf[1] == 0x03) { uint8_t byte_cnt = g_rx_buf[2]; /* 将寄存器数据按序存入全局数据区,供业务逻辑使用 */ for (uint8_t i = 0; i < byte_cnt / 2; i++) { master_reg_data[current_item->reg_addr + i] = (g_rx_buf[3 + i * 2] << 8) | g_rx_buf[4 + i * 2]; } } master_state = MS_NEXT_ITEM; /* 一个轮询项完成 */ g_rx_len = 0; }

提示:总线上混着多个从机时,它们的响应帧只会在各自被点名后出现,一般不会碰撞,但偶发的重复帧或掉线后的乱响应是存在的。主机侧按"当前请求的从机地址"过滤每一帧,地址不符的直接忽略,不进CRC流程。这个过滤虽然简单,能避免不少现场执拗的故障。

超时重试在主循环里是这样推进的:

void master_poll_tick(void) { if (master_state != MS_WAIT_RESP) return; if (--resp_timeout_cnt == 0) { if (retry_left-- > 0) { master_send_request(current_item); /* 重发同一请求 */ } else { slave_offline_cnt++; /* 这台从机本轮离线 */ master_state = MS_NEXT_ITEM; } } }

逻辑说明:resp_timeout_cnt在master_send_request里初始化为timeout_ms,由主循环每隔1ms调用一次master_poll_tick递减。倒计时归零说明从机没响应,优先重发;重试次数耗尽则记录离线并跳到下一个轮询项。这里有组经验值:如果某台从机响应特别慢,超时500ms、重试2次,可以在保证现场人员感知到"设备正在恢复通信"的前提下,不拖垮整条总线。slave_offline_cnt是很重要的诊断信息,配合调试串口打印能快速定位哪台掉线。

5. 常见问题排查:这四个坑让我翻过车

5.1 现象:通信完全无响应,示波器看不到任何波形

第一次用RS485调MODBUS,最常见的就是"发出去没有回、A/B线上看不到波形"。原因通常有四个:一是DE/RE悬空或初始状态不对,MAX3485的DE引脚没有默认上下拉,上电后电平不定,芯片可能一直锁在接收模式;二是A、B线接反,发出的差分信号方向完全反了;三是总线两端没共地,设备间GND电位差过大,差分信号被拉偏;四是终端电阻接错,该接120欧的地方没接,不该接的地方多接了一个。

排查顺序我一般这样走:先用万用表测PA8到MAX3485 DE引脚的电压,发送时应为3.3V,否则GPIO初始化有问题;再交换A/B两根线试一次;然后在两端设备之间补一根共地线,很多"玄学故障"到这里就消失了。最后确认终端电阻只在总线两端各一个,中间设备尽量不加。

5.2 现象:每次发请求都有响应,但CRC校验总失败

这类故障的典型表现是:示波器看波形完整、字节数也对,程序里modbus_slave_check却永远返回0。最常见原因是CRC计算范围错了——把帧末尾的CRC两字节也算进校验范围,或者接收时高低字节拼反。其次是缓冲区长度问题,g_rx_len在别处被截断成uint8_t,长度超过255后回绕,CRC自然对不上。

解决思路:先在线跟踪g_rx_len,对比请求帧实际字节数。如果总是多两字节,说明CRC也被收进缓冲,检查接收循环的位置;如果总数正确但校验失败,打印recv_crc和calc_crc逐字节对比,再核对拼装顺序。MODBUS的CRC在线序是低字节在前,我在3.3节强调过,这是最容易看走眼的一步。

5.3 现象:从机明明回了数据,主机却判断超时

示波器上能看到完整响应帧,但主机还是走了重试分支,问题大概率出在发送后的RS485换向时机。HAL_UART_Transmit返回不等于数据发完了,最后一个字节的停止位可能还在移位寄存器里。此时如果立刻拉低DE,芯片切回接收模式,发送引脚被置为高阻,帧尾直接截断。这是半双工总线特有的故障,全双工的UART根本遇不到。

解决方法是发送完成后循环等待UART_FLAG_TC标志置位,再切DE方向,3.4节的modbus_send里已经写了这个while循环。还有一个隐蔽点:如果使用DMA发送,要等HAL_UART_GetState回到READY才能切方向,DMA模式下的TC标志时机和普通模式不同。

5.4 现象:多数设备正常,某一台从机偶发丢帧

这种问题排查起来最折腾。现场特征是总线挂了好几台从机,大部分时间通信正常,某台从机隔一段时间丢一次响应。往深里查,多半是三种原因:一是该从机响应时间偏长,主机timeout设得太短,正常响应刚好卡在超时线附近,偶发超时;二是主机在上一帧响应结束前就发出了下一帧请求,两个设备的发送窗口重叠,总线数据碰撞;三是定时器周期太小,从机把正常帧误判成两段,后半段被当成新帧丢弃。

解决套路是:给主机轮询之间加一个2~5ms的空闲间隔,用状态机里的MS_IDLE状态保证帧间有足够间隙;然后按最慢从机的实际响应时间重新配timeout,宁慢勿丢;最后对容易断帧的那台设备单独降波特率验证。现场测试时,把丢帧计数和离线计数通过调试串口打出来,比在示波器上抓偶发信号直观得多。

6. 二次开发思路:从采集温湿度到整张设备网

6.1 新增功能码

最常扩展的是04读输入寄存器和10写多个寄存器。实现上就是加一个case,真正要动脑筋的是寄存器表的组织。我一般把reg_table改成带读写属性的结构体数组:

typedef struct { uint16_t addr; /* 寄存器地址 */ uint16_t value; /* 当前值 */ uint8_t access; /* RD_ONLY / WR_ONLY / RD_WR */ int16_t min, max; /* 上下限,写操作时校验 */ } modbus_reg_t;

这样06功能码写入前先做范围校验,越界直接回异常帧,比裸数组稳妥得多,也方便后续接PID参数、报警阈值这些业务字段。

6.2 波特率与从机数量扩展

改波特率要同步改三处:串口初始化、定时器Period、主机的timeout_ms。三处不一致是最常见的改版翻车点。从机数量扩展则只需要往poll_table加行条目,注意同一时刻总线上只有一个主机,如果现场已有上位机在当主机,设备侧固件要切到纯从机模式。

6.3 验证方法

验证从机:PC端串口助手发03读请求帧01 03 00 00 00 0A C5 CD,看返回是否是11个字节且CRC正确。验证主机:用MODBUS调试软件模拟从机,主机发帧后调软件回帧,观察重试逻辑和离线计数。两端都过了再上总线联调,能省一半现场时间。

这套代码值钱的不是某一行写法,而是把"串口收包、定时器定界、RS485换向、协议解析、轮询容错"这几件事的边界分清楚了,改任何一个环节都不动其他模块。从那以后,我每次改完一套协议固件,都强制自己用PC端的MODBUS调试工具把主机和从机各跑一遍完整轮询再交到现场,别嫌麻烦,这套流程救过我很多次。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进