搞嵌入式Linux开发的,十有八九会跟Modbus打交道。不管是接个温湿度传感器、读个电表,还是跟PLC对数据,Modbus RTU基本就是工业现场默认的“普通话”。这篇东西想把整个流程完整串一遍:从Linux下串口设备怎么配、termios参数怎么调,到Modbus RTU报文怎么组帧、CRC怎么算、传感器数据怎么读回来,再到代码架构怎么设计才能稳定轮询——全是我实际碰过的问题和踩过的坑。
这个项目适合刚接触嵌入式Linux不久、想用C语言在板子上写一个Modbus主站去读从站传感器的朋友,也适合那些已经在用第三方库、但遇到稀奇古怪问题不知道怎么排查的人。我会尽量说清楚每一步为什么要这么干,而不是只丢一段能跑能用的代码。
1. 项目思路与方案选型:为什么说Modbus RTU是嵌入式Linux的“必修课”
1.1 Modbus到底解决什么问题
Modbus是1979年莫迪康(Modicon,现在属于施耐德)搞出来的一个应用层串行通信协议。它最核心的设计思想就两个:主从(Master/Slave)模型,以及查询—响应(Request/Response)机制。总线上只有主机能主动发请求,从机收到请求以后回一个响应,同一时刻只允许一个主机存在。这么设计放到今天看确实显得简陋,但也正是这份简陋让它在工业现场整整活了四十多年——稳定、简单、透明,几乎没有解析成本。
RTU(Remote Terminal Unit)是Modbus在串行链路上的一个帧编码方式,数据用二进制传输,每一帧包含地址码、功能码、数据区和CRC校验。跟ASC-II模式相比,同样功能码和寄存器数量的报文,RTU模式起码少传一半字节,在9600波特率这种公用频段上优势非常明显。所以工业传感器、PLC、变频器、电表,默认情况下绝大多数都支持RTU协议,这也是嵌入式Linux端最常遇到的一个通信需求。
还有一个很多人纠结的问题:工业现场Modbus TCP也很多,为什么偏要用RTU?其实很简单——你拿不到网线,只能走RS485两根线。我做过一个采集项目,现场几十个温湿度传感器用RS485手拉手串成总线,一个板子就能把所有数据轮询回来,如果硬要上TCP就得给节点全部重新拉网线,成本完全不是一个量级。
1.2 硬件怎么选:串口、USB转485、还是板载UART?
做嵌入式Linux,第一步就得在硬件上分清你手上到底有哪些串口资源。这里我按实际经验给个选型结论:
- 如果只是开发调试阶段:用USB转RS485的线是最快的方案,也就是ttyUSB0节点,插上就能用,缺点是有偶发的掉线风险,长期跑项目不建议。
- 如果是小批量产:强烈建议用板载UART配合SP3485、MAX3485这类收发器芯片,对应设备节点一般是ttyS0、ttymxc1、ttymxc2这类。板载串口的稳定性和中断响应在可靠性和实时性上比USB转出来的要好很多。
- 如果需要长期高波特率跑大流量:尽量选支持FIFO深度大一点的串口控制器,像i.MX的UART和全志的UART基本都带64字节FIFO,默认配置可能只开16字节触发中断,这个后面还要在驱动层调,不过那是另一个话题了。
我自己的习惯是开发时先用USB转485接从站设备,代码跑通以后再切到板载UART,中间只要把设备节点名改一下,其他代码完全复用。这篇后面所有的示例,我都以Linux通用串口设备节点来写,具体是ttyS0还是ttyUSB0,自己对应改一下就行。
1.3 软件架构划分
在动手写代码之前,先把整个软件分成三块,这个问题就清楚多了:
- 串口抽象层:负责打开设备、配置termios参数、读写字节流。这一层不应该知道Modbus是什么,它只负责可靠地收发字节。
- Modbus协议层:负责组帧、解帧、CRC校验、功能码处理、超时重试。这一层是一台Modbus主机的核心。
- 业务应用层:负责配置轮询列表、处理传感器数据的单位换算、浮点数拼接、脏数据过滤、日志输出等等。
这个分层的好处是,以后如果你要把RTU换成TCP,只需要把串口抽象层换成socket收发,协议层基本不用动。我自己做项目的时候,一开始图省事把组帧和串口收发写在一个文件里,后来要支持第二个传感器型号时改得一塌糊涂,逼着我重构了一遍。所以不管项目多小,分层这个习惯值得从一开始就养成。
2. 动手前必须吃透的Modbus RTU协议细节
2.1 帧结构:地址、功能码、数据、CRC
Modbus RTU的一帧报文大概长这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| 地址码 | 1字节 | 从站地址,0x01~0xF7,0x00是广播地址 |
| 功能码 | 1字节 | 告诉从站要干什么 |
| 数据区 | 0~252字节 | 寄存器地址、数量、具体数据 |
| CRC16 | 2字节 | 低字节在前,高字节在后 |
举个例子,读1号从站的保持寄存器,从寄存器0x0000开始读2个寄存器,主机发出的请求是:
01 03 00 00 00 02 C4 0B拆开看:01是从站地址,03是功能码(读保持寄存器),00 00是起始寄存器地址,00 02是寄存器数量,C4 0B是CRC16。
从机正常响应则是:
01 03 04 00 63 00 64 7D 5701地址、03功能码、04字节数,后面跟4个字节数据,最后CRC。如果出错了,从机会回一个功能码高位加1的异常帧,比如03变成0x83,并且在数据区带回一个异常码:01表示非法功能码,02表示非法寄存器地址,03表示非法数据值,04表示从站设备故障。我在调试的时候经常一开始没看异常码,反复给对方厂家打电话,结果发现是自己寄存器地址写错了,这个教训实在太多。
2.2 数据模型与三种常用功能码
Modbus给设备定义了几种不同的数据区:线圈(Coil,可读可写,按位操作)、离散输入(Discrete Input,只读,按位操作)、输入寄存器(Input Register,只读,按16位操作)、保持寄存器(Holding Register,可读可写,按16位操作)。
实际做传感器采集,用到最多的就三个功能码:
- 0x03:读保持寄存器,读温湿度传感器的校准参数或者某些设备的设定值。
- 0x04:读输入寄存器,大量传感器设备出厂就把测量的实时值放在输入寄存器里。
- 0x06:写单个寄存器,偶尔用来下发传感器修改地址或恢复出厂设置的命令。
这里有一个特别容易踩的坑:0x03和0x04读回来的数据,在大多数设备里都是大端序的,也就是高字节在前。但是也有个别国产传感器厂子二话不说按小端序出,导致你读回来的温度值变成了几百倍几十倍。调试的时候如果读数明显离谱,先别怀疑代码,拿Modbus Poll之类的工具直接读一次原始寄存器值,看看AB/CD到底哪个方向对,然后再决定要不要做字节序交换。
2.3 CRC16计算:算法推导与C语言实现
Modbus RTU用的CRC16是CRC-16/MODBUS,多项式是0x8005,初始值是0xFFFF,输出的时候低字节在前。它跟常见的CRC-16/IBM不是一回事,千万别混用,否则组出来的帧怎么对都对不上。
实际C语言里更常见的写法是右移查找表法,但其实直接按位右移的算法也就几行代码,性能在嵌入式Linux完全没问题:
static uint16_t modbus_crc16(const uint8_t *buf, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; i++) { crc ^= buf[i]; for (int bit = 0; bit < 8; bit++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }发送的时候,需要注意把CRC的低字节先发出去,再发高字节。比如上面那个读寄存器的例子,CRC算出来是0x0BC4,那么发送顺序就是C4 0B。这个坑我只能说几乎每个人都会踩一回,我最初写主站时直接按内存顺序memcpy发出去了,从站完全不回包,对着协议看了老半天才反应过来字节序错了。
2.4 3.5字符间隔:为什么帧间隔在Linux上要自己care
RTU模式有一个硬性要求:两个帧之间至少要隔3.5个字符的静默时间,否则接收方会把两帧当成一帧;一帧内部相邻字节之间间隔不能超过1.5个字符时间,否则接收方判定帧不完整,直接丢掉。
3.5个字符时间怎么算?以9600波特率、8位数据位、1位停止位、无校验为例,一个字符是11位(1起始位+8数据位+1停止位+1额外开销,严谨点说是10位或11位),一位时间是1/9600秒约104微秒,一个字符约1.04毫秒,3.5个字符大概就是3.65毫秒。
在Linux应用层,你没有硬件级的帧中断去精确卡这个时间。一般有两类做法:
- 接收方:靠read函数加上termios的VTIME超时来定帧尾。这个后面第三章会细说。
- 发送方:在连续发送下一帧之前,确保串口已经完全把上一帧发完了,调用tcdrain或者tcdrain之后加一个3.5字符时间的延时。
实际项目里我通常是“两帧间隔最小5ms”这种拍脑袋值,因为Linux应用层的调度延迟本身就不小,与其卡3.65毫秒这种理论值,不如干脆留足余量,牺牲一丁点吞吐量,换来稳定。当然如果你跑的是工业级高速轮询,每秒要刷几百个点,那就另外一回事,需要上线程调度和更精细的时序控制。
3. Linux串口配置实操:从设备节点到termios
3.1 找到并验证串口设备节点
Linux下串口设备文件一般是/dev/ttyS0(板载串口)、/dev/ttyUSB0(USB转串口)、还有i.MX平台的/dev/ttymxc0、全志平台的/dev/ttyS1等。拿到板子第一件事是把设备节点搞清楚:
ls -l /dev/tty* dmesg | grep tty插上USB转485线以后,dmesg会打印类似“usb 1-1: FTDI USB Serial Device converter now attached to ttyUSB0”的日志。确认好节点名以后,先用一个最简单的回环测试验证串口好坏:用杜邦线把开发板的TXD和RXD短接,然后执行:
echo "hello" > /dev/ttyUSB0 cat /dev/ttyUSB0能读到hello说明这个串口驱动基本正常。如果这一步都过不了,后面Modbus就更不用谈了。
这里有一个Linux老手都会提一句的权限坑:非root用户访问串口权限不足。很多时候程序open设备节点返回Permission denied,直接在后面加sudo就跑通了,然后也想不起来去修复权限。推荐长期方案是把用户加到dialout组里:
sudo usermod -aG dialout $USER然后重新登录一下用户,一劳永逸。
3.2 termios参数配置解析
Linux对串口的行为控制完全是靠termios结构体完成的。写串口配置的时候,最容易搞错的就是“规范模式”和“非规范模式”的区别。规范模式下,内核会对输入做行编辑处理,读到换行符才返回给应用,这对Modbus这种二进制帧来说是灾难——因为你不知道帧里哪个字节恰好是换行符。
所以第一件事就是用cfmakeraw把串口设置成原始模式,让内核不加工任何字节、不解析任何特殊字符,应用程序收到什么就是什么。
一个我自己验证过的完整配置函数:
int uart_setup(int fd, int baudrate) { struct termios tty; if (tcgetattr(fd, &tty) != 0) { perror("tcgetattr"); return -1; } cfmakeraw(&tty); /* 使能接收和本地回环抑制 */ tty.c_cflag |= CLOCAL | CREAD; /* 波特率 */ cfsetispeed(&tty, baudrate); cfsetospeed(&tty, baudrate); /* 8N1:8数据位、无校验、1停止位 */ tty.c_cflag &= ~CSIZE; tty.c_cflag |= CS8; tty.c_cflag &= ~PARENB; tty.c_cflag &= ~CSTOPB; /* 非规范模式,不启用流控 */ tty.c_iflag = 0; tty.c_oflag = 0; tty.c_lflag = 0; tty.c_cflag &= ~CRTSCTS; /* 按字节返回,超时100ms */ tty.c_cc[VMIN] = 1; tty.c_cc[VTIME] = 10; tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, &tty) != 0) { perror("tcsetattr"); return -1; } return 0; }这里稍微解释一下,免得你抄完代码还是一脑袋雾水:
CLOCAL:忽略调制解调器控制线,保证即使DCD线没接也能读数据。CREAD:使能接收,这个忘了开的话发出去倒是正常,读回来永远是0字节。PARENB:关闭校验位,Modbus RTU常用的是8N1;如果设备是8E1或8O1,需要把校验位打开并设置PARODD。CSTOPB:停止位,8N1就清掉这个位;2停止位就置上。CRTSCTS:这是RTS/CTS硬件流控,Modbus主站和从站之间通常不开硬件流控,要关掉。VMIN和VTIME这两个配合起来定义read的阻塞行为:VMIN=1表示至少读到一个字节才返回,VTIME=10表示每字节之间的等待超时为1秒(单位是0.1秒,10就是1秒)。我推荐这个组合,既能确保不会永远死等,又能把不定长的Modbus响应包快点收完。
3.3 打开串口与常用读写模式
打开串口要用open函数,注意不要漏了O_NOCTTY和O_NDELAY:
int fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open"); return -1; }O_NOCTTY的意思是如果这个设备是终端,不要把它设为控制终端;O_NDELAY表示打开的时候不阻塞等待设备准备好。如果你是接RS485芯片没收到数据,还有一个容易忽略的地方:有些开发板的TTL串口和RS485收发器之间的方向切换是有延时的,打开设备后建议先tcflush(fd, TCIOFLUSH)把缓冲区清一遍,然后发送完每一帧后调用tcdrain(fd)等待数据全部从驱动缓冲区真正写到硬件上,再进行方向切换。
读数据的时候,因为Modbus响应帧长度不确定,常规做法是先按功能码预判响应长度,然后循环read,拼到缓冲区里:
uint8_t buf[256]; size_t pos = 0; while (pos < need_len) { int n = read(fd, buf + pos, need_len - pos); if (n < 0) { perror("read"); break; } else if (n == 0) { /* 超时未收到数据 */ break; } pos += n; }read返回0只有一种情况:VMIN=1但VTIME超时了都没等到一个字节。这时候上层就可以判定从站没响应。
3.4 权限、开机自启与常见配置坑
权限问题上面已经说过了,这里再补两个我实际踩过坑的小细节:
第一个是“波特率写错但看起来没问题”。很多USB转串口芯片在驱动里默认猜测波特率,你实际想配9600,不小心传了19200,结果设备也能收到乱七八糟的字节,但就是不出正确数据。排查的时候先用stty -F /dev/ttyUSB0看当前配置,能省不少时间。第二个是cfmakeraw会把输出处理OPOST清掉,这本来是对的,但如果有人手贱在后面重新把OPOST置位,你会看到发出去的字节在某些串口上多了回车或换行转换,帧就彻底废了。所以配置完以后,重点关注c_iflag和c_oflag是不是都是0。
4. Modbus RTU主站程序实现
4.1 查询-响应模型和轮询状态机
Modbus RTU通信是典型的单主机轮询模型。单片机时代写主站,经常就是一个while死循环,一次一次地阻塞收发;但是到了嵌入式Linux上,如果一个传感器响应特别慢,而你还有其他传感器要轮询,那就得设计一个非阻塞的轮询调度器,否则整个CPU就被卡死了。
我的做法是给每个从站地址维护一个结构体:
typedef struct { uint8_t addr; uint8_t func; uint16_t reg_addr; uint16_t reg_cnt; uint8_t *data; uint8_t state; /* 0:idle, 1:wait_response, 2:success, 3:error */ uint8_t retry; struct timespec sent_time; } modbus_slave_t;然后主循环每隔固定时间(比如100ms)遍历一遍轮询列表,找到状态为idle的从站发下一帧请求,并把状态置为wait_response。后续每次read到数据,就根据地址去匹配对应的从站,收到完整一帧后置为success,等待下一次调度。这样即使某个从站掉线了,也只会影响它自己的轮询间隔,不会把整个采集系统拖垮。
等待响应的超时用poll定时器或者select都行,不要用sleep阻塞,理由很简单——sleep没法在收到数据以后立刻返回,会白白浪费几毫秒甚至几十毫秒。Linux上最省事的是poll(fd, 200),200ms超时,没数据就返回0,然后你决定重试还是放弃。
4.2 报文拼装与CRC实现
拼一个Modbus RTU请求帧就是个搬砖活,我用一个通用函数处理:
int modbus_build_request(uint8_t addr, uint8_t func, uint16_t reg_addr, uint16_t reg_cnt, uint8_t *out) { int len = 0; out[len++] = addr; out[len++] = func; out[len++] = (reg_addr >> 8) & 0xFF; out[len++] = reg_addr & 0xFF; out[len++] = (reg_cnt >> 8) & 0xFF; out[len++] = reg_cnt & 0xFF; uint16_t crc = modbus_crc16(out, len); out[len++] = crc & 0xFF; /* 低字节在前 */ out[len++] = (crc >> 8) & 0xFF; return len; }响应帧的处理比请求稍微麻烦一点,因为如果是异常响应,数据区格式完全不同。所以解帧时我建议先校验CRC,再判断地址和功能码,最后判断功能码是不是异常功能码(正常功能码高位bit7会是0,异常时bit7置1)。顺序千万别搞反,否则你拿一个CRC错误的帧去解析地址,很容易被脏数据误导。
4.3 超时、重试与错误恢复
工业现场没有不丢包的通信链路,RS485总线虽然稳定性不错,但也挡不住强电干扰、接线松脱、设备死机。超时重试是必须实现的,但重试策略要有设计感,不能无脑循环。
我推荐的做法是“3次重试+指数退避”:第一次请求失败以后,等100ms重试;第二次失败,等200ms;第三次失败,等400ms;三次都失败就把这个从站标记为离线,等下一轮轮询周期再试。这么做的好处是既能容忍偶发干扰,又不会在从站彻底掉线的时候疯狂发报文把总线打爆。
还有一个小细节:每次重试之前,一定要把串口收缓冲区清干净——tcflush(fd, TCIOFLUSH)。否则上一次响应虽然超时了,但数据可能晚到一步,被下一次请求的read函数当成新响应读出来,你得到的就是一个错位的帧,或者一个地址不匹配的帧。这个问题不解决,就算你不重试,通信也会越来越乱。
4.4 试运行时的轮询节奏
轮询间隔到底设多少合适?我的经验是,先读传感器的数据手册,看厂商标称的响应时间。很多传感器RTU响应时间是几十到几百毫秒,如果你把轮询间隔设成跟响应时间差不多的值,那整条总线基本就在冲突和重试中度过了。稳妥的做法是:发送请求后留出至少“最大响应时间 × 2 + 10ms”的等待窗口。比如某传感器最大响应时间200ms,那么一次完整轮询周期至少给400ms,加上串口收发和调度开销,500ms一个周期比较靠谱。
如果有多台从站,轮询总周期就是每台从站的等待窗口之和。这时候可以考虑提升波特率来压缩响应时间,从9600升到57600,理论传输时间能缩短将近6倍,但注意总线上所有设备的波特率都要一致,而且要确认距离和线缆质量足够好。
5. 传感器数据读写实战:温湿度寄存器解析
5.1 联调工具:ModbusPoll和Modbus Slave的用法
我在写Linux端主站代码前,一定会先用PC上的Modbus调试工具把从站的协议看清楚,把寄存器地址、数据长度、数值换算公式摸透,再回来写代码。两大利器是ModbusPoll(主机模拟)和Modbus Slave(从站模拟)。
ModbusPoll配置起来很简单:选串口端口、波特率、数据位、停止位、校验位,设置从站地址和功能码,然后点连接,它就会定时轮询,并把寄存器值用表格列出来。它有“密钥”的说法网上很多,但正规使用直接去官网下载试用版就行,功能足够做调试。我用它做的第一件事就是读一遍传感器所有寄存器,把温度和湿度原始值记下来,和Modbus Slave里我预先填的测试值对比,确认字节序没有问题。
Modbus Slave则可以用来模拟一台虚拟从站。当Linux端代码还没连到真设备时,先用Modbus Slave在PC上开一个从站,这样协议层和串口层都能先用虚拟设备验证一遍,再上现场不会手忙脚乱。联调时有个非常坑的地方:PC端Modbus工具和Linux开发板同时开着、都连到总线上,会造成地址冲突——因为总线上有两个同样地址的主站在抢发请求。调试时一定要保证总线上同时只有一台设备处于“主站”模式。
5.2 实际读取流程:从轮询到数据落盘
我做的那个项目用的是一款支持Modbus RTU的温湿度传感器,从站地址1,温度在输入寄存器0x0001,湿度在0x0002,都是32位浮点数。
第一步,用ModbusPoll读到原始值,假设温度寄存器4字节是44 32 8F 5C,湿度是43 7A 00 00,然后用IEEE浮点数解析工具转换,得到25.65℃和62.0%RH。第二步,确认这个值是真实传感器测量值之后,就开始在Linux板上写轮询代码。代码逻辑跟前面第4章一致,区别只在于响应数据的解析:
static void parse_sensor_frame(const uint8_t *buf, int len) { /* buf[0]=addr buf[1]=func buf[2]=byte_count */ if (buf[2] == 4) { float temp = bytes_to_float(buf[3], buf[4], buf[5], buf[6]); printf("temp=%.2f\n", temp); } }最后把读回来的值格式化以后写到一个环形缓冲区,由另一个线程负责上传到MQTT或者用SQLite落盘。这一块不是本项目的核心,但一个稳定的采集程序总得有地方放数据,我建议至少先加一个带时间戳的日志函数,这样排查问题的时候才有据可查。
5.3 IEEE 754浮点数还原的坑
Modbus寄存器和浮点数之间的转换是最容易翻车的地方。一个32位浮点数占用2个寄存器,16位一个。Modbus协议本身规定寄存器传输时高字节在前,但不同厂家的传感器在寄存器排列上却有两种习惯:
- 方案A:高16位在前,低16位在后,就是标准大端序。
- 方案B:低16位在前,高16位在后。
同样,4个字节内部的字节序也可能有大小端差别。我提供一个通用的解析函数,内部可以灵活调整字节顺序:
static float bytes_to_float(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) { uint32_t u = ((uint32_t)b0 << 24) | ((uint32_t)b1 << 16) | ((uint32_t)b2 << 8) | b3; float f; memcpy(&f, &u, sizeof(f)); return f; }实际拿到传感器数据以后,先把原始寄存器值打出来,然后在PC上手动试几种顺序,看哪个最像真实物理量。千万不要凭感觉猜。我一个同行做过一个项目,因为浮点数高低字节顺序没对,读回来的温度常年相差2℃,整整调了三天,最后发现是传感器手册里寄存器字节序写的是“float little endian”,跟常规做法恰好相反。
5.4 数据清洗与上层应用对接
工业传感器读回来的原始数据不能直接放心用,要想清楚“哪些数据是可信的”。我做数据清洗时有几条固定规则:
- 超出合理物理范围的数据直接丢弃。比如温度-40~85℃,超出这个范围不管是什么原因,都不能上报。
- 连续两次采集值跳变超过阈值时,多读一次确认。比如1秒内从25℃跳到78℃,不是传感器坏了就是干扰。
- 数据带上时戳,写入队列后由消费者统一处理。这样即使某次轮询失败,也不影响历史数据的连续性和可追溯性。
上层对接一般有两种方向:一种是供本地GUI或HMI显示,把解析好的数据结构体传给UI线程即可;另一种是走网络上传,把数据封装成JSON或MQTT报文发到云端。无论哪种,Modbus开发本身都只是“取得可靠数据”这一步,后面的数据价值和业务逻辑都建立在数据正确之上,所以数据清洗这一节千万别省。
6. 常见问题与排查技巧实录
6.1 设备无响应怎么查
这个问题是新手问得最多的,我也碰到过无数次。我总结了一个排查顺序,按这个顺序走,九成问题都能解决:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| open返回Permission denied | 用户无权限 | 加入dialout组或用sudo |
| 发数据后无任何回包 | 波特率不对 | stty看实际配置,核对8N1 |
| 发数据后无回包 | TX/RX接反 | 两根线对调再试 |
| 发数据后无回包 | 地址不对 | 用ModbusPoll扫1~247地址 |
| 收到数据但CRC总是错 | 干扰或波特率偏差 | 示波器看波形,适当降低波特率 |
| 收到数据但解析不对 | 从站是异常响应 | 看功能码bit7是否为1 |
还有一个最隐蔽的问题:RS485总线是半双工的,如果你在总线上串了一个全双工的USB转TTL线,然后收发各用一根线,数据往总线上一发,A/B线自己就把信号短路了,自然没有回包。我给个最简单的判断方法:先用PC加一个USB转485工具直接接从站设备,如果PC能正常读写,而你Linux板不行,那么问题基本出在Linux板的接线或配置上;如果PC也不通,那肯定是总线或者从站设备本身的问题。
6.2 数据偶发错乱:电气噪声与接地
数据错乱比完全无响应更难排查,因为它是“偶发”的。经验告诉我,串口通信数据偶尔错一两个字节,大多数情况是信号质量问题,而不是程序逻辑问题。RS485是差分信号,抗干扰能力本身不弱,但有两个使用禁忌:一是总线没有终端电阻,二是总线没有共地。
长距离传输时,RS485总线两端需要各接一个120欧终端电阻,否则信号在末端反射,波形畸变,数据自然错。另外,现场两个设备的电源不共地,A/B线之间就容易存在地电位差,轻则数据错乱,重则烧毁芯片。我刚入行时有一次去现场调试,发现数据每隔几分钟就跳一下乱码,最后工程师傅拿万用表量了下,两块板子的GND电压差了2V多,瞬间明白了。解决办法是RS485线用双绞屏蔽线,屏蔽层单点接地。
6.3 RS485方向切换的两种方案
RS485是半双工通信,需要一个方向控制信号来决定当前是发送还是接收。在嵌入式Linux上,方向切换通常有两种做法:
- 硬件自动方向型:很多新式RS485收发器芯片(比如MAX13487)内置自动方向控制,直接接TTL串口的TXD就能工作,不用额外控制,这个是最省心的。
- 软件RTS切换型:老式芯片需要程序控制RTS引脚。Linux提供了
TIOCSRS485这个ioctl来配置串口的RS485模式,配置好以后驱动会在发送前自动拉高RTS,发送完自动拉低。
如果驱动不支持TIOCSRS485,那就只能在应用层用GPIO库手动切换。发送前拉高方向,发送后用tcdrain(fd)确保所有字节都已经从硬件发出去了,再拉低方向。这个时序非常关键,如果拉得太早,后半个帧还没发完,总线就被切回接收方向,从站收的数据就是残帧;拉得太晚,从站开始回数据时方向还没切回来,你又把自己的发送端和数据回线短接了。我遇到过一个很诡异的现场:偶尔能通信,偶尔不行,排查到最后就是方向切换早了0.5毫秒。加一个usleep(1000)或利用tcdrain彻底等发送完成,问题就解决了。
6.4 调试工具推荐:从命令行到逻辑分析仪
遇到疑难杂症,光靠printf太慢了,这里分享几个我从常用的调试手段:
- strace:Linux下看程序的系统调用,可以确认read返回了多少字节、耗时多少。
strace -f -e trace=read,write,ioctl ./modbus_app,查“是否发出去了”、“内核有没有收到数据”特别管用。 - socat:串口调试神器,
socat -v -x /dev/ttyUSB0,raw,echo=0 pty,raw,echo=0,可以把你需要的串口重定向到一个虚拟串口,还能打印所有经过的字节,适合在PC上模拟一套虚拟串口对来测试协议代码。 - 逻辑分析仪:我强烈建议手头常备一台便宜的8通道逻辑分析仪,专门用来抓UART波形。因为Linux应用层看到的数据是经过驱动处理的,逻辑分析仪直接看RS485芯片TTL侧的波形,才能确认硬件到底收发了什么。很多“程序没收到数据”的问题,一抓波形就知道是根本没发出来还是压根没进来。
- ModbusPoll / Modbus Slave:前面提过的主从模拟工具,联调和协议确认阶段必备。
还有一个小技巧:写程序时把原始收发帧十六进制打印出来,排错效率能翻倍。很多问题你看打印的报文,一眼就明白是地址错了还是CRC错了,比debugger还直观。排查完记得去掉或者做成日志开关,不然后面跑业务的时候日志刷得飞快。
做Modbus开发这几年,我最深的体会是:Modbus这个协议本身简单到几乎没什么可讲的,真正的难点全在它外面那一圈——串口驱动、时序控制、总线电气特性、超时重试策略。你把这圈基本功练扎实了,写个主站读传感器就是水到渠成的事。最后再分享一个我一直保留的习惯:每次接新设备,先用PC把协议摸清楚,再动板子写代码;遇到问题先抓原始帧,再改代码,这能帮你省掉一大半无意义的加班。