1. 项目背景与整体设计思路1.1 从需求说起为什么要做嵌入式Linux端Modbus RTU做嵌入式Linux开发这些年遇到最多的一类需求就是“把传感器数据读到板子上”。工业现场里温度、湿度、压力、液位、流量这些传感器十有八九走的是Modbus协议而且以RTU模式为主。为什么因为RS485总线抗干扰能力强、传输距离能到1000米以上一条总线还能挂几十个设备成本也低工业现场几十年攒下来的存量设备基本都是这套东西。我之前接过一个项目一块跑嵌入式Linux的ARM板子需要通过RS485总线挂一排水文监测传感器采集水位、水温、雨量数据。传感器是标准Modbus RTU从机板子做主机。整个链路看起来很简单但真正动手做的时候发现串口配置、报文封装、CRC校验、超时处理、断帧粘包每一环都有坑。这篇文章就把整个开发过程捋一遍从串口初始化到RTU报文读写给出可以直接用的C语言代码和调试方法。适合刚接触Modbus的嵌入式Linux开发者也适合从单片机转过来的朋友——因为Linux下的串口编程和裸机操作差别很大不是简单往寄存器里写数据就能完成的。1.2 核心方案选型RTU、RS485和主机从机角色开发前先把角色和链路定清楚。这个项目里嵌入式Linux板卡是Modbus主机传感器是从机。主机主动发起请求从机收到后回复响应一问一答干脆利落。从机不会主动上报数据所以主机必须不停轮询。硬件链路上最常用的物理层是RS485。嵌入式Linux板卡通常自带UART口只不过电平是TTL的需要外接一个RS485收发芯片比如MAX485/SP3485把TTL电平转成差分信号。有些工业级板卡直接板载了RS485接口那就省事了。连接时要注意A、B线不要接反共地也必须做好——这几个小问题能排查到怀疑人生。Modbus RTU协议走的是串口帧格式一个起始位、8个数据位、一个可选校验位、一个停止位。最常见配置是8N1即8数据位、无校验、1停止位波特率根据传感器手册来常见的有9600、19200、38400等。协议本身不限制波特率主从双方约定好就行。1.3 为什么不在Linux里直接用应用层现成库这个问题我每次都要回答一遍。Linux下其实有libmodbus这种成熟的Modbus库装上去就能用。但对嵌入式开发来说我个人强烈建议至少自己完整实现一遍裸协议再考虑用库。原因有三个。第一嵌入式板卡的交叉编译链环境往往比较老libmodbus依赖的线程库、socket库版本不一定匹配折腾编译依赖的时间比写一个还长。第二工业现场的设备地址、寄存器表、异常码各不相同你最后大概率要定制报文用库有时反而被封装层挡住出了问题不好排查。第三也是最重要的一点Modbus RTU协议本身并不复杂核心就是CRC16计算和报文拼装自己实现一次排查问题时能直接通过串口抓包看懂每一帧而不是对着库的内部函数发呆。所以这篇文章走的是“手写轮子”路线。代码量不大但每一步都讲清楚为什么这么做。2. 串口配置实战Linux下termios到底怎么配才不坑2.1 打开串口不是open就完事了Linux下串口设备文件一般是/dev/ttyS0、/dev/ttyUSB0或者/dev/ttymxc0这类的路径具体取决于板卡串口硬件和驱动。RTOS或裸机开发习惯了的人会以为open之后就能直接读写实际上Linux的串口默认有很多历史包袱——默认可能是echo模式、行缓冲模式收到数据会回显还会被特殊字符干扰。所以open之后第一件事是立刻用tcgetattr拿到当前termios配置然后逐项修改。注意open的参数需要加上O_NOCTTY和O_NDELAYO_NOCTTY防止串口成为控制终端避免键盘信号干扰串口。O_NDELAY让open操作即使没有检测到载波信号也立即返回避免阻塞。这个细节对工业场景很重要因为现场串口设备上电时序不固定如果open因为等信号而阻塞整个程序启动就卡死了。2.2 termios关键配置项raw模式的完整姿势直接上代码这是我在项目中验证过的一套配置关键地方都有注释#include termios.h #include fcntl.h #include unistd.h #include string.h #include errno.h int uart_init(const char *dev, int baudrate) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { printf(open %s failed: %s\n, dev, strerror(errno)); return -1; } struct termios opt; memset(opt, 0, sizeof(opt)); tcgetattr(fd, opt); /* 设置波特率 */ speed_t speed; switch (baudrate) { case 9600: speed B9600; break; case 19200: speed B19200; break; case 38400: speed B38400; break; case 115200: speed B115200; break; default: speed B9600; break; } cfsetispeed(opt, speed); cfsetospeed(opt, speed); /* 原始模式关掉所有输入/输出/规范模式处理 */ opt.c_cflag ~CSIZE; opt.c_cflag | CS8; /* 8数据位 */ opt.c_cflag ~PARENB; /* 无校验位 */ opt.c_cflag ~CSTOPB; /* 1停止位 */ opt.c_cflag ~CRTSCTS; /* 禁用硬件流控 */ opt.c_cflag | (CLOCAL | CREAD); /* 忽略调制解调器状态使能接收 */ opt.c_iflag ~(IXON | IXOFF | IXANY | ICRNL | INLCR | IGNCR | INPCK | ISTRIP); opt.c_iflag | (IGNPAR | IGNPAR); /* 忽略校验错误防止死锁 */ opt.c_oflag ~OPOST; /* 原始输出 */ opt.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); /* 关闭规范模式和回显 */ /* 关键字符超时和最少接收字节 */ opt.c_cc[VTIME] 1; /* 每1个字符的超时时间单位0.1秒 */ opt.c_cc[VMIN] 0; /* 非阻塞读有数据就返回没数据也返回 */ tcflush(fd, TCIOFLUSH); /* 清空缓冲区丢弃历史脏数据 */ if (tcsetattr(fd, TCSANOW, opt) 0) { printf(tcsetattr failed\n); close(fd); return -1; } return fd; }这套配置里最核心的就是VTIME和VMIN。Linux的串口read行为完全由这两个参数决定VMIN0, VTIMEx非阻塞方式。read会立即返回最多等x个0.1秒。如果没有数据返回0有数据就返回实际读到的字节数。VMIN1, VTIME0阻塞方式。一直等到至少读到1个字节才返回。VMINn, VTIME0一定要等够n个字节才返回。Modbus RTU主从一问一答响应长度不定从机偶发超时所以用VMIN0, VTIME1最合适。read不会在等待时卡死线程超时控制交由上层逻辑处理。2.3 发送数据记得清掉缓冲区配置完成后每次发送请求帧之前我强烈建议先tcflush(fd, TCIOFLUSH)。为什么因为RS485是半双工总线从机响应可能有延迟如果前一次读取超时了接收缓冲区里可能残留了半帧数据。不清掉的话下一次发送请求后立刻read很可能读到的是上一次的残留帧导致解析错乱。这个坑在轮询多个传感器的时候尤其致命。我见过有人排查了一天问题最后发现是残留帧导致所有应答都错位。发送本身用write就行int uart_send(int fd, const unsigned char *buf, int len) { int ret write(fd, buf, len); tcdrain(fd); /* 等数据全部发送完毕 */ return ret; }tcdrain有必要加因为write返回时数据可能还在驱动缓冲区里。在RS485切换方向的应用场景下如果驱动里没有自动控制DE/RE引脚你必须在发送完成之后再切换方向否则会把数据的后半截截断。3. Modbus RTU协议核心报文结构、寄存器模型与功能码3.1 把Modbus RTU看成“密码本”式的一问一答Modbus RTU的报文结构很简单。每一帧都是从机地址 功能码 数据 CRC16校验。从机地址是0到2471到247是可用的从机地址0是广播地址所有从机都会接收但不会回复。主机发什么从机必须原样按格式回复就像拿着同一本密码本对暗号对上了才回话。RTU报文里有个容易忽略的通信规则帧与帧之间必须有至少3.5个字符时间的静默间隔。在9600波特率下一个字符约1.04ms3.5个字符时间大约3.6ms。这个间隔的作用是让双方能分辨“这是上一帧的结尾”和“这是新一帧的开始”。Linux下用select或poll来等待数据配合延长一点超时时间实际上可以规避这个问题。但如果你在写一个严格要求时序的网关程序就需要用一个定时器来辅助断帧判断。3.2 寄存器模型和功能码读什么、写什么全看这张表Modbus定义了四类寄存器每一类的读写权限不一样寄存器类型含义读功能码写功能码数据宽度线圈Coil布尔量可读可写01H05H单、0FH多1位离散输入Discrete Input布尔量只读02H无1位保持寄存器Holding Register16位数据可读可写03H06H单、10H多16位输入寄存器Input Register16位数据只读04H无16位传感器行业里绝大多数数据走的是04H输入寄存器因为传感器数据对主机来说天然是只读的。但也有一些厂家用03H保持寄存器来存储校准参数和实时数据所以调试前务必翻设备手册确认到底用哪个功能码。我见过有人上来就用04H读设备结果设备一直不回换到03H立刻通了。3.3 CRC16 Modbus算法必须手写一次Modbus RTU的差错校验用的是CRC16多项式是0x8005初值0xFFFF。算法本身不复杂一张256项的查表就能搞定。嵌入式场景下查表法速度最快代码也最简单。static unsigned short crc16_modbus(const unsigned char *data, int len) { unsigned short crc 0xFFFF; for (int i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }注意CRC计算范围是从机地址开始到数据域结束CRC本身不参与计算。发送时先发低字节再发高字节这个顺序千万别搞反——我见过不少人在这个细节上翻车报文看着像模像样从机就是不认。自己写一遍CRC最大的好处是以后遇到任何Modbus变种比如某些设备自定义了寄存器映射都能从容应对。4. 传感器数据读写实现完整代码与关键环节拆解4.1 构造读请求帧03H与04H功能码的报文格式读输入寄存器04H的请求帧格式是这样的字节位置内容示例值0从机地址0x011功能码0x042-3起始寄存器地址0x00004-5读取寄存器数量0x00026-7CRC16校验低字节在前按需计算对应的代码int modbus_read_registers(int fd, unsigned char slave_addr, unsigned char func, unsigned short start_addr, unsigned short reg_num, unsigned char *rx_buf, int rx_buf_size, int timeout_ms) { unsigned char tx[8]; tx[0] slave_addr; tx[1] func; tx[2] (start_addr 8) 0xFF; tx[3] start_addr 0xFF; tx[4] (reg_num 8) 0xFF; tx[5] reg_num 0xFF; unsigned short crc crc16_modbus(tx, 6); tx[6] crc 0xFF; tx[7] (crc 8) 0xFF; tcflush(fd, TCIOFLUSH); int ret uart_send(fd, tx, 8); if (ret 0) return -1; /* 等待并读取响应 */ int len wait_read(fd, rx_buf, rx_buf_size, timeout_ms); return len; }响应帧格式为从机地址 功能码 返回字节数 数据 CRC16。唤醒响应后第一件事就是校验从机地址、功能码和CRC三项全部通过后才开始解析数据。4.2 解析传感器数据大端字节序和IEEE 754浮点数Modbus协议规定寄存器数据是big-endian高字节在前。一个16位整数读取时要把第一个字节左移8位再和第二个字节或起来。工业传感器常见的数据类型有16位无符号整数、16位有符号整数、32位整型、32位浮点IEEE 754。32位浮点用两个寄存器存储顺序通常有两种一种是一般意义的“ABCD”顺序即寄存器N存高16位、寄存器N1存低16位但部分小厂设备会把顺序反过来变成“CDAB”。设备手册里一般会写明。遇到读出来的浮点数非常离谱的情况优先怀疑字节序。void parse_float_abcd(const unsigned char *buf) { unsigned int raw (buf[0] 24) | (buf[1] 16) | (buf[2] 8) | buf[3]; float val; memcpy(val, raw, sizeof(float)); printf(float value: %f\n, val); }注意强制类型转换有对齐风险和未定义行为这里用memcpy最稳妥。嵌入式平台有的不支持浮点硬件但Modbus从机回传的浮点数据在主机侧做软件解析没有性能压力。4.3 轮询读取与超时控制from机不回复也不能卡死Modbus通信里从机可能因为总线冲突、设备故障、电缆松动等原因不回复。所以超时控制必须做而且超时时间要按波特率合理设置。简单的经验值是9600波特率下一帧响应大约需要3到5ms超时设100到200ms足够115200波特率下可以缩短到50ms。如果总线上挂了多个从机每个从机都要单独计算超时不能用一个固定值套所有设备——总线上设备多时从机响应时间会有明显差异。wait_read函数我推荐用select实现超时精度高、代码简洁int wait_read(int fd, unsigned char *buf, int buf_size, int timeout_ms) { fd_set rfds; struct timeval tv; FD_ZERO(rfds); FD_SET(fd, rfds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int ret select(fd 1, rfds, NULL, NULL, tv); if (ret 0) return -1; /* 超时或错误 */ ret read(fd, buf, buf_size); return ret; }4.4 多从机轮询策略顺序读、不漏读、不忙等当总线上挂了多个传感器时轮询顺序需要考虑。最简单的方案是for循环按地址顺序轮询但这有个问题如果某个从机一直无响应每次都等满超时时间整个周期会被拖得很长。更合理的做法是对每个从机维护一个健康状态计数连续多次超时的设备降低轮询频率正常设备保持高频率。这个策略在设备数量多、通信质量不稳定的现场非常实用。另一个细节是主机问完一个从机必须等它的响应结束之后才能发下一帧。有些开发者贪快不等响应就发第二帧这在RS485上会直接造成总线冲突两个从机同时回数据总线数据直接乱掉。5. 实战中的疑难故障排查与调试技巧5.1 最经典的坑主机从机单独测都正常连在一起就不通这是Modbus调试里高频出现的问题出现的概率高到可以写进教材。主机和从机分开测试时都正常一接起来数据就错、乱码或者完全无响应。我实际排查过这种问题。通常原因有几类一是A、B线接反。RS485的A和B是差分对接反了信号电平全部反相接收端解调出来的数据全错。关键是很多DB9转接头、压线端子上面标注不清容易搞混。二是共地问题。RS485虽然传输只需要两根线但收发双方必须有一个共同的参考地。如果双方都是隔离供电又不拉地线共模电压差可能达到几十伏轻则数据错乱重则烧芯片。三是终端电阻缺失或者接多了。RS485总线两端需要各接一个120欧姆终端电阻用来匹配阻抗、减少反射。如果总线上设备多但一个终端电阻都没接长线传输时信号反射会让波形严重畸变。四是波特率、数据位、校验位有一边配置不一致。这种问题最隐蔽因为单测时各自正常一对接就完全不通。调试时用逻辑分析仪或示波器看波形是最直接的方式。如果没有仪器就先从最简单的拓扑排查一根线直连主机和从机A接A、B接B、GND接GND中间不经过端子排先跑通再往外延伸。5.2 CRC校验失败一半是字节序问题一半是脏数据问题CRC校验失败的报错在处理Modbus数据时非常常见。这里有一个我总结出来的经验先用串口抓包工具把原始收到的十六进制打印出来肉眼比对一下请求和响应前几个字节。如果响应帧前两字节和请求帧几乎一样地址相同、功能码相同但CRC不对重点检查CRC计算范围对不对。CRC只算到数据域结束那一字节不包含CRC本身。有人用全部报文算CRC那永远对不上。如果CRC算出来正确但解析出来的数据不对优先怀疑粘包或断帧。Linux下read可能一次只读到部分数据也可能一次读到两帧数据所以在解析前必须做帧头帧尾判定和长度校验。一个实用技巧是把read到的数据追加到环形缓冲区然后按帧格式逐字节解析CRC校验通过后再消费这帧数据。5.3 现场调试常用工具和方法调试Modbus RTU手头必不可少的几样东西USB转RS485调试器电脑上接一个配合串口工具直接监听总线。注意要用“只监不发”的模式防止电脑发出的调试数据干扰真实主从通信。十六进制收发工具可以手动发一帧原生的Modbus请求用来验证从机是否响应、响应内容是什么。一开始建议用这种最原始的方式确认协议细节而不是直接跑程序看结果。逻辑分析仪或示波器排查物理层信号问题。看波形是否完整、毛刺多不多、电平幅值是否正常。straceLinux调试利器。程序运行时用strace -e read,write -p 进程号来看应用层和驱动之间实际读写了多少字节能快速定位问题出在上层还是下层。下面这张表是实际工作中最常见的几个故障现象和排查方向故障现象可能原因排查手段完全无响应接线错误、地址不对、波特率不符USB转485工具手动发包验证响应乱码波特率不一致、A/B接反、共地不良示波器看波形检查接线CRC一直失败字节序反了、计算范围不对、粘包打印hex帧对比修改计算逻辑偶尔超时干扰噪声、终端电阻缺失、线长过长降低波特率加屏蔽双绞线调整终端电阻数据对但数值离谱字节序错、寄存器地址错、数据类型错查设备手册交换高低字节验证5.4 Modbus Poll / Modbus Slave为什么调试时一定要用Modbus Poll是PC端的主机调试工具Modbus Slave是从机模拟工具这两个是我电脑上必装的软件。开发过程中用它们做交叉验证可以快速区分“是设备问题还是代码问题”。流程是这样的先用Modbus Poll连接实际传感器确认传感器返回的数据。再把传感器断开用Modbus Slave模拟一个假从机跑自己的Linux主机代码去读它。两边数据一致说明你的代码没问题问题出在物理链路或传感器本身。如果自己的代码连模拟从机都读不对那就老老实实从头查报文格式。环境里没有真实设备时用Modbus Slave配几个测试寄存器就能把整套主机代码跑通开发效率高很多。我记得之前等硬件样机的时候就是靠这种方式把协议栈代码全部验证完毕样机到了直接上电联调省了整整一个开发周期。6. 从RTU到TCP以及整个方案还能怎么扩展6.1 Modbus RTU和Modbus TCP的关系Modbus RTU和Modbus TCP的寄存器模型、功能码完全一致可以理解为同一个协议的不同“马车”。RTU跑在串行总线上TCP跑在以太网上。区别在于TCP模式没有CRC校验因为TCP/IP协议栈本身已经做了可靠传输。TCP模式多了6个字节的MBAP报文头其中包含事务处理标识符和单元标识符用来匹配请求与响应。TCP模式的默认端口是502。现在很多工业网关做的事就是把底层的RTU报文原封不动打包成TCP上行或者把上位机的TCP请求拆成RTU下行到设备。理解了RTUTCP就是一个信封包装的差别。嵌入式Linux板卡如果带网口通过Modbus TCP和上位机通信是非常自然的扩展方向。底层传感器采集逻辑不用改只需要增加一层TCP服务器把采集到的数据按MBAP格式回传给上位机即可。6.2 传感器数据多路轮询时如何管理设备表项目里传感器数量多了以后管理各自的从机地址、寄存器地址、数据类型就成了一项不容忽视的工作。建议定义一个全局设备表用结构体统一描述每个传感器的属性typedef struct { unsigned char slave_addr; unsigned char func; unsigned short start_reg; unsigned short reg_count; unsigned char data_type; /* 0: u16, 1: i16, 2: f32 */ float current_value; int error_count; } sensor_node_t;轮询程序遍历这个设备表对每个节点调用modbus_read_registers再根据data_type解析数据。新增传感器就加一条记录代码一行不用改。这个方案的灵活性和可维护性远超把每个传感器的读取逻辑写成独立函数的做法。6.3 日志记录与数据上云最后说一个容易被忽视的环节日志。嵌入式Linux不像单片机那样只有一个串口输出系统日志、业务日志都可以落到文件里。把每次Modbus通信的请求帧、响应帧、CRC结果、解析值都记录下来后期排查问题有据可查。日志量会比较大生产环境建议循环覆盖只保留最近一段时间的记录。最省事的做法是写一个简单的文件轮转文件大小超过阈值就重命名保留最近两三个文件。不要小看这个功能现场问题往往只出现一次没有日志就只能干瞪眼。数据上云的话Linux平台直接通过MQTT、HTTP等方式上报即可。解析好的传感器数据已经在你手里了剩下的事情就顺理成章了。我在实际项目中最大的体会是Modbus RTU这个协议不吃资源、不靠平台只要把串口和CRC吃透后面所有的事情都是水到渠成。拿到一个陌生的传感器花十分钟用Modbus Poll测一遍再对照报文格式写代码基本一次就能跑通。把这套基本功练扎实嵌入式Linux下的工业通信开发就成功了一大半。