如果你的串口调试助手一次能收全一帧,但现场设备就是偶发超时;如果你用示波器抓RS-485波形,发现不是教科书上那种漂亮的方波;如果你把Modbus RTU的CRC加上后发现主站和从站永远都对不上——恭喜,你大概率也进了这个坑。Modbus RTU是工业现场最常用的串行通信协议之一,核心就三个词:一主多从、电报格式、CRC校验。换成大白话就是:主站发问、从站回答,靠地址区分谁该回,靠CRC判断问的是不是同一套内容。协议本身并不复杂,复杂的是它跑在RS-485半双工总线上,物理层、链路层和你的定时器代码会一起出幺蛾子。
这篇笔记是给两类人写的。一类是刚接触Modbus RTU、正在拿STM32或PLC和一个从站设备联调的人,你需要知道波形和时序到底在说什么;另一类是已经能跑通基本通信、但遇到偶发超时和CRC错误的人,我会把实际的排查过程和判断标准放出来。内容会围绕三个主题展开:用示波器和逻辑分析仪读RS-485波形、用t3.5/t1.5把帧边界算明白、用CRC16-Modbus把错误挡在门外。
1. 动手之前,先把Modbus RTU的“三层画像”画清楚
1.1 一主多从、半双工、地址轮询
先理清关系。Modbus RTU只能是“一对一回答”式的半双工模式:总线上有一台主站,若干台从站,每个从站一个地址。主站发出请求帧,帧头第一个字节就是从站地址,只有地址匹配的从站才会响应。主站不发问,从站不能主动发言。这有点像班主任点名:班主任点名“二号”,二号同学站起来回答,其他同学保持安静。在RS-485总线上,所有设备都挂在同一对差分线上,如果两个从站同时应答,总线直接短路冲突,帧就会变成乱码。这也是为什么“一主多从、地址轮询”不是可选项,而是协议的地基。
理解了这一点,你也就理解了很多常见故障的根源。比如某个从站明明地址对、功能码对,但就是“间歇性失联”,很多时候不是它坏了,而是它响应过慢占用了总线,或者另一个从站误收到请求后抢先应答。调试时先确认总线上只挂一台从站,往往能最快缩小问题范围。
1.2 报文:地址、功能码、数据、CRC的低字节顺序
一个完整的RTU请求/响应帧由四段组成:从站地址、功能码、数据区、CRC校验。地址1字节,功能码1字节,CRC占2字节。Modbus功能码里最常用的一个是03(读保持寄存器),另一个是06(写单个寄存器)。以读两个保持寄存器为例,主站发出的典型帧是:
01 03 00 00 00 02 C4 0B
01是地址,03是功能码,00 00是寄存器起始地址,00 02是要读的寄存器数量,C4 0B是CRC的低字节和高字节。注意这里和很多人直觉相反:协议规定CRC先发低字节,再发高字节。所以你看到的帧里是CRC_LO在前,CRC_HI在后。这个顺序坑过不少人,后面第4章会专门讲。
这里还想多说一句:Modbus RTU之所以叫“电报格式”,是因为它的帧结构非常紧凑,没有多余的ASCII字符或换行符,一个请求就是一连串二进制字节。你在串口调试助手里看到的十六进制报文,其实已经接近物理层实际发送的东西了。因此,解析时不能按“读到换行符才算一帧”的思路,必须按长度、地址、功能码和CRC去判断帧完整性。
1.3 分层:UART只负责字节,帧和总线由你负责
再往下分一层:Modbus RTU跑在串口UART之上,但UART只负责把一个字节变成串行位流,它不区分“这是一帧的结束”和“这是一帧中间的空隙”。RTU模式没有起始标识和结束标识,只能靠“静默时间”来切帧:帧内字节间隔不能超过1.5个字符时间,帧与帧之间的静默至少3.5个字符时间。
这个定义有非常大的工程后果:如果主站在发完一个请求后,过了t3.5还没收到从站响应,你就要进入超时处理;如果从站在接收过程中发现某个字节间隔超过了t1.5,就应该丢弃前面收到的半截帧。很多偶发“报文错位”“断帧粘连”都是这两条线没划清楚。换句话说,你在示波器和逻辑分析仪上看到的那些“波形间隔”,不只是在验证信号质量,而是在直接决定协议能不能把一帧数据正确切出来。
2. 波形:用示波器和逻辑分析仪把RS-485抓明白
2.1 示波器接线与差分测量
抓RS-485波形,第一件事是别图省事。单端探头只接A、地接GND顺着量,看到的是一个点位对地的电压,既不准确也不直观,因为RS-485是差分信号,真实的信息在A和B的差值里。我在调试现场最常用的是两路单端探头:CH1接A,CH2接B,示波器Math通道做CH1-CH2,时基放到2ms一格,触发电平设在0V附近,触发模式选下降沿。这样A-B的差分波形会稳定显示在屏幕上。如果你手头有高阻差分探头,直接一根探头接A/B也行,很多便携示波器没有差分通道时,用Math相减是通用做法。
这里有一个基于现场经验的提醒:示波器探头一定要共地,最好使用同一卷线缆的地线夹。否则两台探头地电位不一致,Math通道算出来的差值会带直流偏置,波形看着像正确,但实际上已经失真。另外,抓RS-485波形时示波器带宽不用很高,50MHz带宽对9600波特率绰绰有余。真正要关注的是上升沿、下降沿附近的振荡,以及一帧结束后的空闲电平恢复时间,这些决定信号能否被远端接收器稳定采样。
2.2 正确波形:空闲偏置、起始位和翻转
用上述接法抓到一帧正常波形,你应该看到这样的画面:总线上没有数据时,A相对B是高电平,差分输出稳定在200mV以上,这是RS-485接收器的有效电平区间;一旦UART发送一个起始位,A被拉低、B被拉高,差分电压翻转到负方向,之后每个数据位都会按字节内容翻转。整个字节呈现出标准“起始位+8数据位+校验/停止位”形状,发送结束后总线又回到空闲偏置。这就是网上常说的“rs485的AB波形哪种才是正确的”——正确波形不只要看总线上有没有方波,还要看空闲状态有没有偏置电平。
判断A/B是否接反,土办法也很有效:当总线没有数据时量A和B之间的电压,如果接近0甚至反压,说明输出级没抬起电平,先别急着改协议栈。如果总线正在通信,A-B差分波形和UART原始逻辑正好相反,那大概率是这个节点上A/B线序接错了。不同厂商对A/B的丝印标注不一定一致,有的叫A+,有的叫B-,不要只看标注,要用实际电压电平确认。
2.3 常见畸形波形和处理
第一类畸形是回波振荡。总线很长或没有终端电阻时,帧末和字节切换处会出现明显的过冲和振铃,像是方波边缘长了毛刺。这种毛刺可能在接收端被错误采样成附加数据,造成CRC错或收发字节错位。处理办法是在总线两端各并联一个120Ω终端电阻,物理原理是阻抗匹配,让反射波被吸收,而不是反复弹射。
第二类是波形不对称,边沿很缓、上升时间过长。这通常是波特率配置不匹配,或者传输线过长、驱动器驱动能力不足。可以用逻辑分析仪加UART解码器直接看解码结果,如果波形明明存在但解码乱码,优先检查波特率和数据位。
第三类是帧中某一段低电平时间明显过长,看着不像正常字节周期。这往往不是协议问题,而是DE换向时总线进入高阻态,或主站发送与从站应答之间的换向窗口没处理好。解决这类问题,靠的不是加大波特率,而是把收发器控制时序理清楚。总之,抓波形时先看“空闲/起始/停止”,再看“边沿质量”,最后才是“数据内容”。
3. 时序:帧间隔、字符间隔和从机响应是三条线
3.1 t3.5与t1.5从哪里来
RTU的帧切分完全依赖间隔时间,所以算准t3.5和t1.5是通信可靠性的前提。一个字符时间不是直观的“1字节的时间”,而是包含起始位、数据位、校验位、停止位的整个UART字符长度。Modbus规约按11位字符计算:1位起始,8位数据,1位校验,1位停止。没有校验位时则使用两个停止位,同样是11位。t3.5等于3.5个字符时间,也就是38.5个位时间;t1.5等于1.5个字符时间,也就是16.5个位时间。
我之前见过不少代码直接按10位算,在9600下偏差只有几百微秒,似乎“还能用”,但在19200以上或者现场有干扰时,这种偏差可能造成临界状态,时好时坏。按11位算并不是教条,而是协议明文规定的字符长度。有些设备对帧间隙要求宽松,但你不能赌每个厂商都宽松。
3.2 波特率速查表
为了方便现场估算,我做了一张按11位字符计算的速查表。用的时候直接按波特率查t3.5和t1.5,比现场掏出计算器快得多。
| 波特率 | 1字符时间 | t1.5 | t3.5 |
|---|---|---|---|
| 1200 | 9.167ms | 13.75ms | 32.08ms |
| 2400 | 4.583ms | 6.875ms | 16.04ms |
| 4800 | 2.292ms | 3.438ms | 8.021ms |
| 9600 | 1.146ms | 1.719ms | 4.010ms |
| 19200 | 0.573ms | 0.859ms | 2.005ms |
| 38400 | 0.286ms | 0.430ms | 1.003ms |
这张表对主从双方都适用。主站用来判断“收到完整帧后能不能处理”,从站用来判断“半截帧要不要丢弃”。以9600为例,主站发完请求后,如果4ms左右还没收到从站任何字节,就可以判超时;从站如果发现两个字节间隔超过1.7ms,说明前一帧已经断了,需要清空缓冲区。
3.3 代码里怎么把t3.5算准
嵌入式的串口接收有两种常见做法。第一种是依赖UART的IDLE中断,收到空闲标志就认为一帧结束。这种方法简单,但很多芯片的IDLE中断是在1个字符时间空闲后触发,接近t1.5而不是t3.5,遇到慢响应设备会误判。更稳妥的是用自由运行定时器记录“最后一次收到字节的时间戳”,在主循环或接收中断里判断间隔。
一个伪代码示例如下:
// 假设1MHz自由运行定时器 const uint32_t t35_us = 4008; // 9600bps, 11-bit字符 extern volatile uint32_t g_tick_1us; volatile uint32_t last_byte_time; volatile uint8_t rx_frame[256]; volatile uint16_t rx_len; void UART_RxCpltCallback(void) { rx_frame[rx_len++] = uart_receive_byte(); last_byte_time = g_tick_1us; } void main_loop(void) { if ((uint32_t)(g_tick_1us - last_byte_time) > t35_us) { if (rx_len > 0) { process_modbus_frame(rx_frame, rx_len); rx_len = 0; } } }这段代码利用了无符号整数减法处理溢出,细节上很省事。只要保证定时器频率稳定,t3.5判断就非常准。这里的4008就是上表里9600波特率的t3.5微秒数。如果你用的是Cortex-M系列,可以直接用SysTick或TIM4做微秒级计时,效果都很好。
3.4 总线换向和从机响应时间的坑
RS-485半双工,收发器有一个DE引脚,高电平发送,低电平接收。主站发完最后一字节后,DE不能立刻撤,否则最后一个停止位可能被截断;也不能太晚撤,否则总线一直被你占着,从站有EPS也不能发。经验做法是在发送完成标志置位后,延时一个位时间再拉低DE,或者根据收发器的驱动关闭时间决定。具体延时建议看收发器数据手册,有的芯片关闭时间在50ns级,有的在几百ns,按最糟糕情况留余量。
从站响应时间同样关键。从站收到一帧完整请求后,需要先校验CRC、解析命令、读取寄存器或执行写操作,然后才能把响应帧放到总线上。这段时间属于“静默窗口”,主站必须容忍。我在现场给9600波特率下的单从站响应超时保守设定为200ms,如果总线上从站多,主站轮询周期还要按从站数量放大。千万不要把超时设成和t3.5一样,否则从站稍微慢一点,主站就急着重发,两边节奏一乱,整个总线会被重发风暴占掉。
4. CRC16-Modbus:查表不是终点,理解才是
4.1 多项式、初值、反射:CRC16-Modbus的定义
CRC16-Modbus是一类带固定参数的CRC算法,不是随便一代“查表CRC都可以用”。它的标准定义是:多项式为0x8005,初值为0xFFFF,结果不反转异或,算法采用右移反射形式。更准确地说,因为大端多项式0x8005做位反射后就是0xA001,所以用右移算法时异或的是0xA001。
如果你在网上抄CRC函数,先看这几个参数是否对上:initial是FFFF,poly是A001(右移)或8005(左移),最后没有xout。很多通用库里的CRC-16/IBM初值是0000,多项式虽然也是A001,但结果完全不同。这解释了为什么“同一个CRC函数,有人用得好好的,我用了就是不对”——大概率是参数模板选错,或者在移植时只改了名字没改参数。
4.2 一步步手算与C语言实现
手算流程可以这样走:CRC寄存器初始为0xFFFF;每来一个字节,先和CRC寄存器做异或;然后循环8次:如果最低位是1,右移一位再异或0xA001;如果最低位是0,只右移一位。以经典请求01 03 00 00 00 02为例,按这个流程算出来的CRC是0x0BC4,发送时先低字节后高字节,就是你在帧里看到的C4 0B。这句话可以当自检样例,能对上说明你的算法框架是对的。
C语言实现很简洁:
uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *data++; for (uint8_t i = 0; i < 8; i++) { if (crc & 1) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }调用时传入从站地址到数据区结束的所有字节,不包含CRC本身。返回的0x0BC4要拆成两个字节发送:低位0xC4先发,高位0x0B后发。很多从站“偶尔能通、经常CRC错误”,问题往往就出在这一层:你算出的CRC是对的,但发送顺序反了。
4.3 查表法实现与内存权衡
逐位算法在低主频单片机上计算CRC会比较耗时。如果系统每毫秒要处理多帧,我建议用查表法。原理是把“当前字节异或到CRC低字节后,再经过8次右移运算”的结果提前算成一张256项的表格,运行时只用两次移位和一次异或。
查表代码骨架如下:
static const uint16_t crc_table[256] = { /* 生成后预置 */ }; uint16_t crc16_modbus_table(const uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { uint8_t index = (uint8_t)((crc ^ *data++) & 0xFF); crc = (crc >> 8) ^ crc_table[index]; } return crc; }表格生成逻辑我不建议手工算,可以写一个初始化函数,在启动时填充:
void init_crc_table(uint16_t *table) { for (uint16_t i = 0; i < 256; i++) { uint16_t crc = i; for (uint8_t j = 0; j < 8; j++) { if (crc & 1) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } table[i] = crc; } }查表法和逐位法结果完全一致。如果项目里已经有硬件CRC外设,也可以用硬件做CRC,但必须确认硬件是否能配置成Modbus参数。很多MCU内置CRC引擎用的是CRC-32,或者初值固定为全0,并不适合直接用在Modbus RTU里,硬套反而会踩坑。
4.4 校验不对时的排查清单
遇到“CRC永远不对”,按下面顺序排查,大多数能在十分钟内定位:
- 字节顺序:发送时CRC低字节在前,高字节在后。如果帧尾两个字节和计算值高低反了,从站一定报错。
- 参数初值:确认CRC初始化是0xFFFF,不是0x0000。0x0000是CRC-16/IBM的参数。
- 多项式:右移算法用0xA001,左移算法用0x8005。两者混用会得到一个看似“差不多”但实际错误的值。
- 包含范围:CRC计算范围是从地址字节开始,一直到数据区最后一位,不包含CRC本身。如果把从站地址漏掉,或者把前一帧的残留字节带进来,算出来必错。
- 接收端验证技巧:从站收到完整帧后,把地址、功能码、数据区和CRC两个字节全部送入同一个CRC函数,计算结果是0x0000才是合法帧。这个技巧能同时验证发送顺序和算法参数。
5. 联调实录:把波形、时序、CRC放在一起排查
5.1 案例一:CRC永远不对,波形却“很完美”
曾经有一个从站板子,主站用Modbus Poll模拟器发请求,从站一直在回异常码。示波器抓总线波形,A-B差分方波非常干净,帧间隙也正常,地址和功能码看起来都没问题。但CRC每次都是错的。后来我把主站和从站的串口参数逐一比对,发现主站工具配置的是“无校验位、1位停止位”,而从站初始化成了“偶校验、1位停止位”。
UART在配置不一致时,每个字节的位流长度和校验位含义都变了,波形看着还是“一字节一字节”,但接收端采样出来的数据已经错位。CRC是整帧的指纹,任一位被破坏,计算值就会对不上。这个案例说明,CRC报错不一定是CRC函数本身,UART层的校验位、数据位、停止位必须严格一致。Modbus RTU最常见的配置是8位数据、无校验、2位停止,或者8位数据、偶校验、1位停止,两种都符合11位字符长度。
5.2 案例二:距离一远就超时,问题出在终端电阻
另一个项目:总线长度约150m,在实验室短距离调试时一切正常,搬到现场后开始偶发超时,请求重发一次又能通。用示波器看,长线上波形的边沿出现了明显回弹,信号不是平稳翻转,而是像钟摆一样来回振荡。这种情况接收器有可能在错误的时间点采到多余电平,导致字节错或CRC错。
排查后发现问题很直接:总线两端都没有120Ω终端电阻。我在两端各加了一个120Ω电阻后,再用示波器看,波形边沿干净了很多。这里多说一句,终端电阻不是“加上去就好”,位置有讲究。典型RS-485总线应该是主干线两端各一个120Ω,所有从站用短分支接入主干线。分支线如果太长,相当于在总线上制造了额外的阻抗不连续点,信号又会在分支末端反射。预算允许时,分支线尽量控制在1m以内。
如果现场不方便拆线,可以用万用表断电量A-B之间的直流阻抗。总线两端都接120Ω时,量到的是两个120Ω并联值,大约60Ω。如果你量到120Ω,说明只有一端有终端电阻;如果量到接近几百欧甚至开路,说明两端都没接。这个测法很土,但特别实用。
5.3 案例三:A/B接反导致某个从站“失联”
还有一个典型场景:总线上挂了多个从站,中间有一台设备怎么都ping不到。示波器抓总线波形,整体通信在跑,主站都在正常轮询其他从站。排查发现,这条失败设备的分支线上A和B接反了。RS-485是差分对,某个节点接反之后,它看到的空闲电平和数据电平全是反的,自然收不到主站帧,更不可能正确应答。
用示波器在端子排上量这个节点的A-B电压,会看到差分信号一直为负,而正常节点的差分信号为正。把A/B纠正后,设备马上就能通信。这里想提醒一句:A/B的丝印标注在不同厂家之间并不统一,有的标A+、B-,有的标A-、B+。不要只看外壳印刷,要以实际电压电平和线缆颜色为准。最简单的方式是在通电后、无数据时量差分电压,正电压对应的那个端子,通常就是协议意义上的A。
5.4 调试顺序和速查表
如果你现在正卡在某个环节,我建议先把调试顺序固定下来,不要一上来就改程序。我自己的固定顺序是:先用示波器看空闲差分电压,确认偏置和线序;然后看起始位、停止位,确认波特率和UART帧格式;接着量t3.5/t1.5,确认帧切分逻辑;再用逻辑分析仪或串口抓包工具核对地址、功能码、数据区;最后计算CRC,确认帧尾字节顺序。物理层通,链路层再通,CRC才轮得到出场。
为了方便现场低头排查,我整理了一张速查表:
| 现象 | 优先检查 | 常见原因 |
|---|---|---|
| 波形边沿有毛刺 | 终端电阻、主干部线长度 | 总线缺少120Ω匹配电阻 |
| 空闲差分电压接近0 | 偏置电路、收发器DE/RE | 没有偏置电阻或控制引脚反了 |
| 某个节点A-B电压反向 | 该节点线序 | A/B接反 |
| CRC总是错 | UART参数、CRC字节序 | 校验位不一致、CRC高低字节颠倒 |
| 偶发超时 | t3.5计时、从站响应时间 | 定时器按10位算、超时时间过短 |
| 从站完全不响应 | 地址、波特率、电源 | 从站地址未配置或总线故障 |
遇到问题不要一次改多个变量。比如CRC错,先把主站和从站的UART参数拍个照,再换一个已知完好的从站去验证。一次只改一个参数,才能确认到底是谁引起的。我自己在后来做每一套Modbus RTU项目时,都会先写一个不依赖从站的自检脚本:主站自发自收,把请求帧的CRC用同一套函数算出来再校验,至少能确认协议栈最底层没问题。这样再接到真实从站上,剩下要排查的就只剩波形、时序和物理链路了。