简介:这是一套基于STM32与RS485的Modbus-RTU双机通信工程源码,面向嵌入式开发者和工业控制学习者,解决多从机轮询与主从切换的实际需求。工程默认以主机模式运行,自动寻址01号从机读取数据;通过四个按键可分别查询01~03号从机,并支持一键切换为地址0x02的从机模式,附带不同LED状态指示,便于观察运行逻辑。压缩包共163个文件,体积3.02MB,以h/c源文件、uvprojx工程文件、hex烧录文件以及o/crf编译中间文件为主,同时包含调试备份、清理脚本和使用说明文本,适合直接打开工程或参考编码思路。已有515人学习下载,代码注释与结构清晰,可帮助用户理解串口、定时器在Modbus-RTU超时与帧间隔处理中的实际应用,以及非DMA方式下主机读取多从机寄存器数据的实现方法,可直接烧录验证。
1. 为什么 STM32、RS485 和 Modbus-RTU 会凑到一起:先讲清楚这套东西解决什么
STM32、RS485 和 Modbus-RTU 三个词放在一起,基本就是一套低成本一主多从工业通信方案的代名词:RS485 解决物理层的长距离和抗干扰,Modbus-RTU 解决应用层谁先说话、数据按什么字节序摆,STM32 则用串口加定时器把这套协议跑起来。这篇文章写给要接传感器、电表、变频器,或者自己做采集板、控制器从站的工程师,目标是让你手里的主机和从机代码在两天内跑通,也知道哪些参数不能盲抄。中间会给出定时器判帧、CRC16、状态机的可复现代码,以及几条现场才会遇到的翻车记录,读完能直接对着硬件动手。
2. Modbus-RTU 的帧格式与关键参数:地址、功能码、CRC16 和 3.5 字符时序
2.1 一帧请求的字节顺序:从地址到 CRC 每位都说清楚
Modbus-RTU 没有帧头也没有帧尾,它靠空闲时间分帧。一帧完整的请求或者应答,在字节层面就这四段:地址码 1 字节、功能码 1 字节、数据区 n 字节、CRC16 低字节在前。主机读从机地址 0x01 的保持寄存器,起始地址 0x0000,读 2 个寄存器,线上报文是01 03 00 00 00 02 C4 0B。这里C4 0B是01 03 00 00 00 02这 5 个字节算出来的 CRC16 校验码,发送时先发低字节 C4,再发高字节 0B。应答帧也是同样格式,从机把数据塞进数据区,再补 CRC。这个帧格式里最容易忽略的是地址 0x00,它是广播地址,从机收到后要执行但不应答,很多从机代码把 0x00 当成非法地址直接丢弃,广播功能就废了。
功能码决定数据区怎么解释,常用的是 03 读保持寄存器、06 写单个保持寄存器、16(0x10)写多个保持寄存器。无论哪个功能码,寄存器地址和数据值在线上都按大端序传输,也就是寄存器地址 0x1234 在报文里是12 34,写值 0x0064 在报文里是00 64。实际项目里把这套字段拼错的情况非常常见,尤其写 16 功能码时,数据区里要带一个字节计数字段,它的值是寄存器数量乘以 2,很多人直接把寄存器数量填进去,导致从机解析错位。字节序和字段长度这两个点,是所有 Modbus-RTU 代码最先要核对的地方。
CRC 校验本身也有一堆细节。CRC16-Modbus 的生成多项式是 0x8005 的反转形式 0xA001,初值固定 0xFFFF。注意这个值和常见 CRC16/IBM 不一样,不能通用。校验范围是地址、功能码、数据区,不包括 CRC 本身。主机发请求时要计算一次并追加;从机收到请求时要计算一次判断是否接收;从机回应答时又要算一次。三个环节任何一处算法或者字节序写错,对端都会静默丢帧,表现出来就是主机一直超时重试,总线上一片请求却没人应答。
2.2 3.5 字符空闲时间:主从两边都靠它切帧
Modbus-RTU 的帧边界不是靠字节数,而是靠空闲时间。协议里定义了两条时间线:字符间超时 1.5 个字符时间,帧间超时 3.5 个字符时间。前者用于从机判断一帧内部数据是否残缺,后者用于主机和从机共同判断一帧是否结束。字符时间不是 1 个二进制位的时间,而是 1 个完整字节在串行线上传输的时间。常见格式是 1 起始位 + 8 数据位 + 无校验 + 1 停止位,总共 11 个位时间,所以 3.5 字符时间等于 38.5 个位时间。9600 波特率下大约是 4.01ms,19200 波特率约 2.00ms,115200 波特率约 334us。
这个时间直接影响整个协议栈的设计。接收侧判帧不能用 1 个字符时间,太短会把连续上传的字节拆散;也不能用 10 个字符时间,太长会让主机等待应答的超时时间跟着被拉大,整条总线的轮询周期变慢。我一般把接收侧的超时定在 3.5 到 4.5 字符之间,宁长勿短。因为 STM32 的中断处理、RS485 方向切换、串口 DMA 搬运都有微秒级延迟,卡着 3.5 字符算,波特率一高就容易在边界抖动,实际问题表现为从机偶尔把一帧拆成两帧。主机在发完请求后等待应答的超时时间,则要远大于 3.5 字符时间,一般取 50ms 到 200ms,具体看从机处理逻辑的快慢。如果从机内部有 EEPROM 写入这类耗时操作,100ms 以上更稳。
以下是常用波特率下字符时间和定时器初值的对应参考,定时器按 1MHz 计数频率计算:
| 波特率 | 单字符时间 | 3.5 字符时间 | 定时器初值(1MHz) |
|---|---|---|---|
| 2400 | 4.583 ms | 16.04 ms | 16042 |
| 9600 | 1.146 ms | 4.01 ms | 4010 |
| 19200 | 0.573 ms | 2.00 ms | 2005 |
| 115200 | 95.5 us | 334 us | 334 |
上面这组值是协议层的理论下限,实际写代码我会加一点余量,例如 9600 波特率把定时器初值配到 4200 左右,给中断响应留余量。波特率越高余量要越小,否则帧间隔过了头,接收侧会把下一帧的开头几个字节当成残帧处理。
2.3 03/06/16 功能码:寄存器读写场景怎么分工
03 读保持寄存器是项目里最常用的,温湿度、压力、电流、电压这类模拟量都走它。请求格式固定为地址、功能码、起始寄存器地址 2 字节、寄存器数量 2 字节、CRC。从机应答则是地址、功能码、字节数、数据、CRC。比如从机回01 03 04 02 1B 00 C8 CRCL CRCH,其中04表示后面数据共 4 字节,即 2 个寄存器;02 1B是第一个寄存器值,解析为 0x021B 等于 539,除以 10 就是 53.9 度。
06 写单个寄存器多用于控制量,启停、设定值、工作模式切换。请求数据区就是寄存器地址 2 字节加寄存器值 2 字节。从机应答时原样回显请求帧,所以调试 06 功能码时逻辑最简单:只要请求和应答逐字节一致,基本可以确定链路是通的,分歧只会出在 CRC 或字节序。这里有个常见错误:主机写值 1 表示启动,从机手册里却写明写 0x0100 才是启动,这属于设备制造商自定义,和协议无关,接新设备前一定要查手册。
16 写多个寄存器适合一次下发一组参数,比如 PID 参数、校准系数、批量设定。请求数据区是起始地址、寄存器数量、字节数、数据区、CRC。这个功能码的坑在字节计数字段,它是数据区字节数而不是寄存器数。主机端自己拼过一遍就明白,从机端解析时同样要按字节数跳指针。寄存器数量为 0 或超过从机地址范围时,从机要回异常码 0x02,而不是保持沉默,这样主机才能快速定位是地址错了还是功能码不受支持。
3. STM32 串口接收 Modbus 帧:定时器超时判帧与串口空闲中断+DMA
3.1 三种接收方案的取舍:定长缓冲、串口空闲中断、定时器超时
写 STM32 串口代码,第一反应往往是定长接收:收满固定字节数就认为一帧到齐。Modbus-RTU 帧长根本不固定,03 应答是 3+寄存器数*2,16 请求还要加字节计数和数据区,同一总线上不同从机帧长可能差很多。定长缓冲只适用于能够把所有从机最大帧长限制死的私有协议,在 Modbus 上不通用,这是第一个要排除的方案。
第二种是串口空闲中断,也就是 USART 的 IDLE LINE 中断。串口收到完整一帧后,总线上出现空闲,硬件自动拉高 IDLE 标志,配合 DMA 环形缓冲可以直接得出帧长度。这个方案中断次数少,CPU 占用低,处理不定长帧非常自然,是目前 STM32 项目的主流做法。缺点是它依赖硬件特性,F1 系列到 G0 系列都有 IDLE 中断,但如果换到 51 或者一些国产单片机,只能自己想办法。
第三种是定时器超时法,每收到一个字节就清零定时器,超过 3.5 字符时间没有新字节到达,定时器溢出中断里判定一帧结束。它不挑单片机平台,51、STM32、新唐、GD32 都能用,也最贴近 Modbus-RTU 协议本身的时序定义。缺点是每个字节都要进串口中断,再加一次定时器访问,波特率到 115200 以上时 CPU 中断开销明显偏大。我的实际建议是:STM32 上优先用串口空闲中断加 DMA,代码量小,抗中断抖动能力强;老平台或者希望协议栈跨平台复用,就写定时器超时法。两种方案我都会在后面给出可直接抄的代码结构。
3.2 用基本定时器做 3.5 字符超时判帧:代码与定时器初值计算
以 STM32F103 的 USART1 加基本定时器 TIM6 为例。系统时钟 72MHz,TIM6 挂在 APB1 总线上,把预分频器设为 71,得到 1MHz 计数频率,计数一个单位就是 1us。定时器初值按照上一节的表来算,9600 波特率用 4200 左右,19200 波特率用 2200 左右,115200 波特率用 400 左右。串口配置为接收单字节中断,每收一个字节就把计数器清零并重新使能定时器,定时器溢出说明超过 3.5 字符时间没有新字节,判定当前帧结束。
/* 串口单字节接收中断:每收一个字节喂一次定时器 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_TIM_SET_COUNTER(&htim6, 0); __HAL_TIM_ENABLE(&htim6); rx_buf[rx_len++] = (uint8_t)(USART1->DR & 0xFF); if (rx_len >= RX_BUF_LEN) { rx_len = 0; } HAL_UART_Receive_IT(huart, &rx_byte, 1); } } /* TIM6 溢出中断:超过 3.5 字符时间没收到新字节,判定一帧完成 */ void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); __HAL_TIM_DISABLE(&htim6); frame_ready = 1; } }这段代码里有个逻辑要先理清:HAL_UART_Receive_IT每收一个字节会进入一次回调,回调里把定时器清零,所以只要字节连续到达,定时器永远不溢出。当最后一个字节到达后,定时器开始计数,经过 3.5 字符时间没有新字节,TIM6 溢出,frame_ready置 1,主循环检测到后马上处理。定时器放在中断里禁止做 CRC 解析和寄存器操作,只置标志,真正解析拿到主循环。否则 115200 波特率下两个字节间隔只有 80 多微秒,中断服务时间稍微长一点,串口硬件 FIFO 就会溢出丢字节,现场表现为主机偶发超时,排查起来非常隐蔽。
还要注意一个细节:如果从机是广播地址 0x00,主机发出广播帧后本身不应答,但所有从机都会执行这条命令。从机的定时器判帧逻辑和普通帧完全一样,只是处理完后不回帧。另外 STM32 进入低功耗 Stop 模式后,TIM6 这类普通定时器会停掉,只有 LPTIM 还能工作,如果项目要做低功耗总线监听,定时器判帧方案在 Stop 模式下会失效,需要额外安排唤醒机制。
3.3 串口空闲中断+DMA 收不定长帧:HAL 配置与代码
串口空闲中断加 DMA 的搭配,重点在于 DMA 配置成循环模式。在 CubeMX 里把 USART1 配成异步收发,DMA 接收选择 Circular,数据宽度都是 Byte。然后在代码里使能 USART1 的 IDLE 中断。DMA 会自动把串口收到的字节搬到缓冲数组里,整个搬运过程不需要 CPU 介入。当总线上出现空闲,USART 硬件置 IDLE 标志,在中断里读当前 DMA 剩余计数,缓冲长度减去剩余计数就是完整一帧的长度。
/* 串口空闲中断里取 DMA 剩余计数,算出一帧长度 */ void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx); uint16_t frame_len = RX_BUF_LEN - remain; if (frame_len > 0 && frame_len < RX_BUF_LEN) { process_modbus_frame(rx_dma_buf, frame_len); } } HAL_UART_IRQHandler(&huart1); }这段代码比定时器法简洁很多,但有几个隐患要注意。第一个是 DMA 必须配成 Circular,配成 Normal 的话 DMA 收满缓冲就停,空闲中断触发时拿到的remain永远是 0,frame_len等于整个缓冲长度,会把缓冲里的陈旧数据当成一帧处理。第二个是处理完一帧后不需要重新初始化 DMA,Circular 模式会自动继续接收,但新数据会覆盖旧数据,所以主循环或中断里必须尽快把缓冲数据解析掉。第三个是如果总线上连续有多个从机节点回数据,DMA 缓冲里的数据可能是拼帧或者半帧,Modbus-RTU 主从结构里同时只有一个节点发送,正常情况不会出现,一旦出现要先查 RS485 方向控制是不是有节点同时占用了总线。
还有一个实际经验:空闲中断里直接处理帧,和置标志后丢给主循环处理,两者差距不大。我习惯在空闲中断里只做长度计算和标志置位,把process_modbus_frame放到主循环的协议栈里执行,因为 CRC 计算在 9600 波特率下耗时接近 1ms,如果放在中断里,下一个字节进来就在排队,总线利用率越高越容易出问题。对程序结构有洁癖的可以把process_modbus_frame放在主循环,让中断体积保持最小。
4. 主机代码:轮询状态机、CRC16 与 RS485 收发方向
4.1 主机状态机:轮询表、等待应答、重试
Modbus 主机本质是一个循环轮询器,状态机通常只有四五个状态:空闲、发送请求、等待应答、校验应答、切换下一个从机。工程上我先建一张轮询表,每个成员包含从机地址、功能码、寄存器地址、寄存器数量、超时时间和剩余重试次数。每次主循环按表逐项执行,发送一帧请求后停住等待应答,收到合法应答才继续下一项,超时则按重试次数重新发送,重试耗尽就标记该从机离线。
typedef struct { uint8_t dev_addr; uint8_t func; uint16_t reg_addr; uint16_t reg_num; uint16_t timeout_ms; uint8_t retry_left; } poll_item_t; #define POLL_TABLE_LEN 10 poll_item_t poll_table[POLL_TABLE_LEN]; uint8_t master_state = 0; uint8_t poll_index = 0; uint32_t tx_tick = 0; void modbus_master_poll(void) { switch (master_state) { case 0: /* IDLE:确保帧间隔后再发 */ if (get_tick() - tx_tick >= 5) { build_request(&poll_table[poll_index]); rs485_dir(1); /* 切换为发送 */ uart_send_frame(tx_buf, tx_len); tx_tick = get_tick(); master_state = 1; } break; case 1: /* WAIT_RESP */ if (rx_frame_ready) { if (check_crc16(rx_buf, rx_len) && (rx_buf[0] == poll_table[poll_index].dev_addr)) { handle_response(&poll_table[poll_index]); poll_index = (poll_index + 1) % POLL_TABLE_LEN; master_state = 0; } else { goto retry; } } else if (get_tick() - tx_tick >= poll_table[poll_index].timeout_ms) { retry: if (poll_table[poll_index].retry_left-- > 0) { master_state = 0; /* 重新走 IDLE,保证帧间隔 */ } else { mark_slave_offline(poll_index); poll_index = (poll_index + 1) % POLL_TABLE_LEN; master_state = 0; } } break; } }这段状态机里最容易抄错的地方是重试后不能立刻重发。从goto retry回到case 0时,tx_tick已经更新过,必须等 5ms 以上的帧间隔计时结束再组织下一帧。如果重试逻辑直接在当前 case 里调用build_request和uart_send_frame,连续两帧之间的间隔会小于 3.5 字符时间,从机可能把两帧粘在一起,CRC 校验自然全错。
超时时间和轮询周期是两个不同概念。应答超时是从发完请求到收到应答的最大等待时间,我一般把它和从机功能码挂钩,读寄存器给 100ms,写 EEPROM 的从机给 200ms。轮询周期是所有从机扫一圈的总时长,比如 10 个从机、每个超时 100ms、全部正常应答后,一个完整周期大约是 1 秒左右。如果现场要求响应更快,先缩短从机处理时间,而不是粗暴减小超时,超时设小了现场稍有干扰就误报离线。
4.2 CRC16 Modbus 查表法:代码与公共字节序坑
CRC16-Modbus 的标准初值是 0xFFFF,多项式是 0x8005 逐位反转后的 0xA001。最不容易出错的实现是按位计算,一次处理一个字节,每字节移位 8 次,代码可读性最好,适合移植和调试。查表法适合运行效率要求高的场合,本质是把 256 个字节对应的 CRC 结果预先算好,运行时一次查表加几次异或,省掉内层 8 次循环。两种算法结果完全一致,工程上我先把按位版本跑通,再决定要不要优化成查表。
/* CRC16-Modbus 计算:返回完整的 16 位 CRC */ uint16_t crc16_modbus(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; } /* 往发送缓冲区追加低字节在前 CRC,并更新帧长度 */ void crc16_modbus_append(uint8_t *buf, uint16_t *len) { uint16_t crc = crc16_modbus(buf, *len); buf[(*len)++] = crc & 0xFF; buf[(*len)++] = (crc >> 8) & 0xFF; }CRC 相关的坑集中在三处。第一是多项式方向用反,标准正序写法是多项式 0x8005 左移计算,反转写法是 0xA001 右移计算,两者不能混用。第二是初值必须是 0xFFFF,有的移植代码把初值顺手改成 0x0000,结果所有报文 CRC 全部对不上。第三是发送顺序,Modbus-RTU 规定 CRC 低字节在前,代码里crc & 0xFF先发,如果改成先发高字节,对端计算时会判 CRC 错误并静默丢帧,主机表现为超时重试,整个总线看起来完全没有回应。
接收侧校验时也要小心字节序。接收帧里最后两个字节的排列是[CRC低][CRC高],做比较时要拼成(rx_buf[len-1] << 8) | rx_buf[len-2]再和本地计算结果比较。很多人在主机代码里直接memcmp两个 16 位整数,高位对齐方式不同,永远相等不了,这种问题靠看串口调试助手的十六进制显示就能一眼发现,因为报文里 CRC 尾字节的排列方式和一个正常的整数内存排列清清楚楚不一致。
4.3 RS485 方向控制:GPIO 翻转与硬件自动方向电路的边界
ST 的 485 芯片如 MAX485、SP3485,DE 和 RE 通常合并成一个方向脚,高电平发送,低电平接收。最简单的方案是拿一个 GPIO 直接控制这一个脚。发送前拉高,发送完成后拉低。关键是拉低的时机要在数据帧最后一位真正从串口发完以后。用阻塞发送时HAL_UART_Transmit返回代表 SR 的 TXE 和 TC 都已经完成或者处于完成状态,此时拉低安全;用 DMA 发送则必须在发送完成回调里拉低,否则 DMA 把数据交给串口后立刻返回,这时候最后一个字节还在移位寄存器里,方向脚过早拉低会截断停止位。
硬件自动收发电路是另一个常见方向,用 MOS 管或三极管根据 TXD 电平自动切换。TXD 空闲高电平时 485 芯片处于接收,TXD 发送低电平时自动转发送。这类电路看起来省掉一个 GPIO,实际影响在高速下暴露得特别明显。我做过的板卡上,MOS 管栅极经过电阻接 TXD,栅极电容和偏置电阻构成 RC 延时,把数据沿拖成了梯形,9600 波特率完全没问题,115200 已经能看出波形变钝,到 230400 波特率时误码率显著上升。如果项目明确要求 115200 以上,优先用 GPIO 方向控制,或者换带自动方向逻辑的专用芯片,不要省这个 IO。
RS485 的方向不仅仅影响发送截断,还影响整个总线的空闲电平。方向脚拉低后,如果 A、B 线之间没有加偏置电阻,总线可能处于不确定状态,表现为主机偶尔收到 0xFF 或 0x00 的杂波。这种杂波在从机代码里会被当成一个错误的起始字节,帧长度直接乱掉。解决的常见做法是在 A、B 线上加偏置电阻,让没设备发送时 A 线高于 B 线。偏置电阻的取值和终端 120Ω 电阻相互影响,计算偏置电流时要把总线上的终端电阻并联值算进去,否则高波特率下偏置不足,照样会出现线上的杂散帧。
5. 从机代码与常见问题排查:地址识别、功能码分发、5 个高频翻车点
5.1 从机主循环与帧解析状态机
从机端代码的核心是三层过滤:地址匹配、CRC 校验、功能码分发。接收部分无论用定时器法还是空闲中断法,最终都拿到一个完整帧缓冲和帧长度。主循环不断检查帧完成标志,帧到达后先看地址是不是自己,再看 CRC 是否正确,最后按功能码分发。地址不匹配或 CRC 错误时都保持沉默,不回任何字节,这是 Modbus-RTU 的抗干扰设计,擅自回异常帧会把总线搅乱。
void modbus_slave_loop(void) { if (!frame_ready) return; frame_ready = 0; if (rx_buf[0] != slave_addr && rx_buf[0] != 0x00) { return; /* 不是发给自己的帧,静默丢弃 */ } if (!crc16_modbus_check(rx_buf, rx_len)) { return; /* CRC 错,静默丢弃 */ } switch (rx_buf[1]) { case 0x03: handle_read_holding(rx_buf, rx_len); break; case 0x06: handle_write_single(rx_buf, rx_len); break; case 0x10: handle_write_multi(rx_buf, rx_len); break; default: build_exception(rx_buf[1] | 0x80, 0x01); break; } }这个主循环虽然短,但几个细节要在真实代码里落地。第一是frame_ready清零的时机,必须在进入处理之前清,否则处理过程中又来了新帧,标志位被置位,当前解析就会被打断。第二是地址和 CRC 的顺序,只有地址匹配才需要继续算 CRC,不是自己地址的帧直接丢弃,省下大量 CPU 时间。第三是异常帧的构造,功能码最高位置 1 表示这是异常响应,后面跟 1 字节异常码,0x01 表示不支持的功能码,0x02 表示寄存器地址越界,0x03 表示数据值非法。
Modbus 从机的真实工程结构和上面这段主循环有些距离。实际产品一般会在主循环外面套一个定时调度,比如每 10ms 扫描一次是否有帧完成,每 100ms 做一次传感器采集,每 500ms 刷新一次 LED。帧处理函数里不应有耗时超过 1ms 的操作,尤其是 EEPROM 写入,要放到专门的任务切片里,先回应答再执行写入。有的从机写寄存器后需要掉电保存,如果保存动作在应答之后再触发,主机不会等到保存完成,下一次读回来的可能还是旧值,这种问题要靠寄存器里增加写确认标志或者状态寄存器来兜底,而不是延长应答时间。
5.2 寄存器映射表:保持寄存器、输入寄存器、线圈
从机内部维护一张寄存器表,我用结构体数组来管理,每个成员包含寄存器地址和值。地址 0x0000 对应设备手册里的 40001 编号,0x0001 对应 40002。用户手册写 40001 时,报文里的寄存器地址字段填 0,不要在主程序里出现reg_addr - 40001这种硬减操作。
typedef struct { uint16_t addr; uint16_t value; } reg_item_t; static reg_item_t holding_regs[] = { {0x0000, 0x021B}, /* 温度,放大 10 倍,53.9 度 */ {0x0001, 0x00C8}, /* 湿度,放大 10 倍,20.0 %RH */ {0x0002, 0x0001}, /* 工作模式:0 待机,1 运行 */ {0x0003, 0x0000}, /* 预留 */ }; #define HOLDING_REGS_NUM (sizeof(holding_regs) / sizeof(holding_regs[0]))03 读保持寄存器直接查这张表,按请求的起始地址和数量逐个回填数据。06 写单个寄存器则是先找到对应地址,再把 value 字段更新。16 写多个寄存器要处理批量数据,流程是先校验地址范围,再逐个写入,任何一个地址越界就回异常码 0x02,并且已经写入的部分通常不要求回滚,这一点和现场人员的直觉不一样但协议里没有事务机制,主机侧要靠自己的重试逻辑保证一致性。
输入寄存器和线圈在协议上更偏门。输入寄存器由 04 功能码读,通常映射到只读的采集值。线圈由 01 读、05 写单个、15 写多个,每个 bit 代表一个开关量。做线圈映射时要注意位寻址和字节寻址的换算,地址 0 到 7 在第一个字节的 bit0 到 bit7,主机下发时会把多个线圈的位打包成字节,低地址对应低 bit,这个位序和很多人的直觉还不一样。如果项目里暂时用不到开关量,先把 03/06/16 跑通就够覆盖绝大多数工业设备。
5.3 现象 → 原因 → 解决:5 个高频排查记录
现象一:从机回包 CRC 校验错误,串口调试助手显示请求和应答对不上。原因大概率是 CRC 字节序反了,或者应答帧构造时忘了在数据区后面追加 CRC。少数情况是 RS485 方向切换太早,应答帧最后一个字节被截断。解决方法是先用串口调试助手手动发请求帧,用带 CRC 计算功能的十六进制工具重新算应答帧,再把从机代码里的方向控制改到发送完成回调里拉低,逐字节对照,一次能定位。
现象二:单块从机用 USB 转 RS485 直接调没问题,挂到总线上就间歇性丢包。原因是总线没有按菊花链串联而是星型接法,反射导致信号质量差,或者主干两端缺终端电阻。解决方法是把 RS485 布线改成总线型串联,设备从总线主干上引出尽量短的分支,主干两端各接一个 120Ω 电阻。如果现场已经无法改线,先把波特率降到 9600,反射对低波特率影响小很多,再考虑加偏置电阻把空闲电平固定住。
现象三:自动收发电路在 115200 以下都正常,换到 230400 就大量误码。原因是 MOS 管栅极电容和偏置电阻形成了 RC 延时,TXD 的边沿被拉缓,数据位判定点偏移,A/B 波形变成梯形。解决方法是把 RS485 方向改成 GPIO 控制,或者换专用自动方向芯片。若坚持用当前电路,只能用 9600 波特率跑,别拿波形赌稳定性。
现象四:主机一直超时,但用串口助手手动发请求从机又能回。原因是主机状态机在发送请求后没有把 RS485 方向切回接收,或者请求帧间隔不足 3.5 字符,从机把两帧粘成一条。解决方法是在状态机里检查方向脚时序,并在 IDLE 状态加帧间隔计时,再通过虚拟串口软件截取总线数据,看两条主机请求之间是否留足空白时间。
现象五:串口调试助手收到的帧看起来都正确,主机代码一跑就 CRC 错。原因是电脑端 USB 转串口的驱动存在缓冲和延迟,助手显示的是驱动处理后的结果,不是真实总线时序;主机端中断响应抖动较大时,字节间间隔会小于 1.5 字符,从机判帧逻辑直接拆了帧。解决方法是把主机串口发送改成 DMA 或阻塞发送并减少高优先级中断抢占,用示波器看 A/B 波形,确认字节间隔确实大于 1.5 字符时间。
6. 进阶验证路径:用串口调试助手先把从机测通,再接主机看轮询
6.1 从机自测:串口调试助手手动发帧
拿到一块从机板卡后,我不急着写主机代码。先用 USB 转 RS485 把电脑和从机接起来,装好 CH340 或者 FTDI 的串口驱动,打开串口调试助手,波特率 9600,数据位 8,无校验,停止位 1。手动输入01 03 00 00 00 02 C4 0B并发送,如果从机回01 03 04 XX XX YY YY CRCL CRCH,说明从机的地址、CRC、寄存器映射全通了。没有反应就先检查 A/B 是否接反,这是 RS485 调试最常见的首坑。
6.2 主机与从机联调:两个 USB 转 RS485 的最小验证
把主机板卡也接到同一条总线,我习惯用两个 USB 转 RS485 模块同时在电脑上监听,一个看主机发出的请求,一个看从机应答。先用串口调试助手分别验证两个节点的收发,再把主机跑起来,观察总线上是否有重叠发送。正常情况应该是主机发完请求后从机立刻应答,主机收到后等待下一个轮询周期。如果总线波形在示波器上看到两个节点同时拉低,说明有一个节点的方向控制没切换干净,这是半双工总线最容易造成数据损坏的问题。
6.3 选型和参数不盲抄的边界
最后提醒几个容易在后面项目里翻车的边界条件。通信距离超过 300 米时,我从 9600 起步,不追求高波特率;距离近但要求吞吐量,再上 115200 并用 DMA 加空闲中断。低功耗场景里要注意 Stop 模式会停掉常规定时器,只有 LPTIM 还活着,定时器判帧方案在 Stop 模式下失效。寄存器地址偏移也不能照抄别人工程,每个从机手册定义的 40001 到底对应报文地址 0 还是 1,用串口调试助手发一帧读请求就能确认。这些参数我踩过不止一次,每次换新设备都会先把时序表和寄存器表画在纸上再写代码,这个习惯帮我省了很多现场调试点。希望帮到你。
本文还有配套的精品资源,点击获取