1. 从一个真实需求说起为什么要在嵌入式设备上跑 Modbus RTU搞工业自动化和边缘计算的朋友对 Modbus 这三个字肯定不陌生。它诞生于上世纪七十年代至今仍然是工控现场最主流的通信协议之一。原因很简单简单、开放、稳定、生态成熟。而 RTU 模式作为 Modbus 在串行链路上的实现方式凭借紧凑的二进制帧结构和 CRC 校验机制在 RS485 总线上跑了几十年依然是传感器、PLC、变频器、仪表这些设备之间对话的“普通话”。这次要聊的项目是在一块叫 Express Pi 的嵌入式开发板上实现一个完整的 Modbus RTU 通信实例。Express Pi 这类板子通常运行精简版 Linux 系统带原生串口或者 USB 转串口能力功耗低、体积小、成本可控非常适合做工业现场的数据采集网关或者协议转换节点。换句话说你手里有一台跑 Linux 的小电脑它有一根 RS485 线连着若干从站设备你要让它作为主站去轮询这些设备把数据读回来或者把控制指令写下去。这个实例能解决什么问题最典型的场景就是现场有一堆支持 Modbus RTU 的传感器和执行器你需要用一个可编程的网关把它们的数据统一采集上来做本地逻辑判断或者上传到上层系统。适合谁来参考嵌入式 Linux 开发者、工业物联网方向的工程师、做数据采集网关的团队以及那些刚接触 Modbus 协议、想找一个能直接跑通的完整例子的朋友。下面我会从整体设计思路开始一步步拆到代码级别把踩过的坑和实测有效的方案都摊开来讲。2. 整体方案设计与核心思路拆解2.1 为什么选 Modbus RTU 而不是 TCP 或 ASCIIModbus 有三个常见的变体RTU、ASCII 和 TCP。TCP 跑在以太网上适合局域网内设备通信帧头有 MBAP 报文头不需要 CRC 校验解析起来相对省事。ASCII 模式用可读字符传输调试方便但效率低一个字节要用两个 ASCII 字符表示开销翻倍。RTU 则是二进制传输同样数据量下帧最短效率最高而且 CRC 校验对数据完整性有保障。在 Express Pi 这种资源受限的嵌入式设备上RTU 是首选。原因有三第一现场大量存量设备只支持 RS485 接口和 RTU 协议你没法要求它们换协议第二RTU 帧结构紧凑在 9600 甚至 4800 波特率下也能有可接受的轮询周期第三Linux 对串口的支持非常成熟termios这套接口稳定可靠不需要额外依赖复杂的网络协议栈。注意选 RTU 的前提是你的物理层是 RS485 或者 RS232。Express Pi 上如果只有 TTL 串口需要外接一个 TTL 转 RS485 模块比如常见的 MAX485 芯片方案。接线时 A 接 A、B 接 B终端电阻在长距离或高波特率时记得加上。2.2 主从架构与轮询策略的确定Modbus 是典型的主从协议总线上只能有一个主站从站不会主动发数据必须由主站发起请求从站才应答。这个项目里 Express Pi 扮演主站角色负责按顺序轮询各个从站。轮询策略的设计直接影响系统实时性。最简单的做法是顺序轮询从站地址 1 读完读 2读完 2 读 3循环往复。这种方式的优点是逻辑简单、总线冲突少缺点是如果某个从站响应慢或者掉线会拖累整个轮询周期。所以必须设置合理的超时时间一般单次请求超时设在 100 到 300 毫秒之间超时后跳过该从站继续下一个同时记录故障次数。另一个要考虑的是轮询间隔。如果从站数量多、寄存器数量大一轮下来可能要好几秒。对于变化缓慢的温度、湿度这类数据几秒的刷新周期完全够用但对于需要快速响应的控制场景就要精简轮询的寄存器数量只读关键数据。2.3 软件分层串口层、协议层、应用层代码结构上我习惯分三层来做这样后期维护和移植都方便。串口层负责打开设备、配置波特率/数据位/停止位/校验位、设置超时、读写原始字节。这一层跟具体协议无关换一个协议也能复用。协议层负责组帧、计算 CRC、发送请求、接收响应、校验、解析数据。这一层是 Modbus RTU 的核心。应用层负责业务逻辑比如轮询哪些从站、读哪些寄存器、数据怎么处理、异常怎么上报。分层的最大好处是调试方便。当通信出问题时你可以先确认串口层能不能正常收发字节再确认协议层的帧对不对最后才怀疑业务逻辑。如果三层揉在一起排查起来就是一团乱麻。2.4 关键参数的计算依据波特率和轮询周期的关系需要心里有数。以 9600 波特率、8 数据位、1 停止位、无校验为例每个字节实际传输 10 位1 起始位 8 数据位 1 停止位所以每秒最多传 960 个字节。一个读保持寄存器的请求帧是 8 个字节响应帧是 5 加上 2 倍寄存器数量的字节。假设读 10 个寄存器响应帧就是 5 20 25 字节加上请求 8 字节一共 33 字节传输时间约 34 毫秒。再加上从站处理时间和主站间隔单次交互大概 50 到 80 毫秒。如果轮询 10 个从站一轮就是 0.5 到 0.8 秒。这个估算能帮你判断当前配置能不能满足实时性要求。3. 核心细节解析与实操要点3.1 串口配置的关键参数与易错点Linux 下配置串口用的是termios结构体。核心参数包括波特率用cfsetispeed和cfsetospeed设置常见值 9600、19200、38400、115200。必须和从站设备一致否则收到的全是乱码。数据位一般是 8 位用CS8设置。停止位通常 1 位~CSTOPB表示 1 位停止位CSTOPB表示 2 位。校验位Modbus RTU 常用无校验~PARENB也有用偶校验PARENBPARODD清零或奇校验的。必须和从站匹配。流控RS485 半双工场景下要关闭硬件流控~CRTSCTS否则会干扰收发切换。原始模式设置也很关键。要用cfmakeraw或者手动清除ICANON、ECHO、ISIG等标志让串口按字节流工作不做任何行缓冲和字符转换。否则你发出去的字节可能被系统“加工”过从站根本认不出来。实操心得配置完termios后一定要用tcsetattr的TCSANOW立即生效并且检查返回值。我见过好几次因为没检查返回值配置根本没生效排查了半天以为是硬件问题。3.2 Modbus RTU 帧结构逐字节拆解一个标准的 Modbus RTU 请求帧长这样字段长度说明从站地址1 字节1 到 2470 是广播地址功能码1 字节如 0x03 读保持寄存器0x06 写单个寄存器数据域N 字节起始地址、寄存器数量或写入值CRC 校验2 字节低字节在前高字节在后以读保持寄存器为例请求帧是地址 0x03 起始地址高 起始地址低 数量高 数量低 CRC低 CRC高共 8 字节。响应帧是地址 0x03 字节数 数据... CRC低 CRC高。CRC 计算是重点。Modbus 用的是 CRC-16/MODBUS多项式 0xA001反向的 0x8005初始值 0xFFFF。计算时对每个字节先和 CRC 低字节异或然后右移 8 次每次如果最低位是 1 就异或 0xA001。最终结果低字节在前、高字节在后填入帧尾。uint16_t modbus_crc16(const uint8_t *buf, int len) { uint16_t crc 0xFFFF; for (int i 0; i len; i) { crc ^ buf[i]; for (int j 0; j 8; j) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }这个函数返回的crc直接按低字节在前写入缓冲区即可。很多人在这里犯错把高低字节搞反了从站直接不响应。3.3 超时与重试机制的设计串口通信最怕的就是从站不响应或者响应不完整。select或者poll配合读超时是标准做法。设置一个总的响应超时比如 200 毫秒在这个时间内如果没收到完整帧就判定超时。判断“完整帧”有个技巧Modbus RTU 规定帧与帧之间至少要有 3.5 个字符时间的静默间隔。在 9600 波特率下一个字符时间约 1.04 毫秒3.5 个字符就是 3.6 毫秒左右。实际实现中可以用“收到第一个字节后等待一段时间没有新字节到来”来判断帧结束。更简单的做法是根据功能码预估响应长度收够就停。重试策略上我一般设置 2 到 3 次重试。连续失败超过阈值就把该从站标记为离线降低轮询频率或者暂时跳过避免拖累整体。重试之间要有短暂延时给从站恢复的时间。注意重试不是万能的。如果物理线路有问题重试只会浪费时间。先确认接线、终端电阻、共地这些基础项再调软件。3.4 数据解析与字节序处理Modbus 寄存器是 16 位的但很多设备的数据是 32 位浮点数或者 32 位整数占用两个连续寄存器。这时候字节序就成了大坑。常见的有四种组合ABCD大端、CDAB字交换、BADC字节交换、DCBA小端。设备手册里一般会写明但有些手册写得含糊需要实际测试。我的做法是先按大端解析如果数值明显不合理比如温度读出几千度就换字交换试试。写一个转换函数把两个寄存器拼成 32 位再按不同字节序解释调试时逐个试。float regs_to_float(uint16_t high, uint16_t low, int order) { uint8_t bytes[4]; switch (order) { case 0: // ABCD bytes[0] high 8; bytes[1] high 0xFF; bytes[2] low 8; bytes[3] low 0xFF; break; case 1: // CDAB bytes[0] low 8; bytes[1] low 0xFF; bytes[2] high 8; bytes[3] high 0xFF; break; // 其他顺序类似处理 } float f; memcpy(f, bytes, 4); return f; }4. 实操过程与核心环节实现4.1 环境准备与串口设备确认先在 Express Pi 上确认串口设备节点。常见的是/dev/ttyS0、/dev/ttyS1或者 USB 转串口的/dev/ttyUSB0。用ls -l /dev/tty*看一下再用dmesg | grep tty确认驱动加载情况。权限问题很常见。普通用户默认没有串口读写权限要么用sudo要么把用户加入dialout组sudo usermod -aG dialout $USER然后重新登录生效。生产环境建议用 udev 规则固定设备名避免每次插拔后设备号变化。接线方面Express Pi 的串口如果是 TTL 电平接 RS485 模块时注意 TX 接模块的 DIRX 接模块的 RO还有一个方向控制引脚 DE/RE 需要 GPIO 控制。有些模块自动收发切换就不用管方向脚有些需要手动控制发数据前拉高发完拉低。这个细节如果搞错表现就是能发不能收或者完全没反应。4.2 串口初始化代码实现下面是一个完整的串口初始化函数参数可配置int serial_open(const char *dev, int baud, int databits, int stopbits, char parity) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial); return -1; } struct termios opt; tcgetattr(fd, opt); cfmakeraw(opt); // 波特率 speed_t speed; switch (baud) { 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 | (databits 7) ? CS7 : CS8; // 停止位 if (stopbits 2) opt.c_cflag | CSTOPB; else opt.c_cflag ~CSTOPB; // 校验位 if (parity N) { opt.c_cflag ~PARENB; } else { opt.c_cflag | PARENB; if (parity O) opt.c_cflag | PARODD; else opt.c_cflag ~PARODD; } // 关闭流控 opt.c_cflag ~CRTSCTS; opt.c_iflag ~(IXON | IXOFF | IXANY); // 读超时设置 opt.c_cc[VMIN] 0; opt.c_cc[VTIME] 20; // 2秒 tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, opt) ! 0) { perror(tcsetattr); close(fd); return -1; } return fd; }VMIN0和VTIME20的组合表示读操作最多等待 2 秒有数据就立即返回。实际协议层还会用select做更精细的超时控制。4.3 请求帧组装与发送以读保持寄存器功能码 0x03为例组装函数如下int build_read_holding(uint8_t *frame, uint8_t addr, uint16_t start, uint16_t count) { frame[0] addr; frame[1] 0x03; frame[2] start 8; frame[3] start 0xFF; frame[4] count 8; frame[5] count 0xFF; uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; return 8; }发送前如果用的是手动方向控制的 RS485 模块要先拉高 DE 引脚write完之后等数据真正发出去可以用tcdrain等待再拉低 DE 切回接收。这个切换时机很关键拉低太早会截断最后一个字节从站收到的帧 CRC 就错了。// 假设 de_fd 是方向控制 GPIO 的文件描述符 gpio_set(de_fd, 1); write(fd, frame, len); tcdrain(fd); gpio_set(de_fd, 0);4.4 响应接收与校验接收用select监听可读事件然后循环读取直到收够预期长度或者超时int recv_response(int fd, uint8_t *buf, int maxlen, int timeout_ms) { fd_set rfds; struct timeval tv; int total 0; while (total maxlen) { 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) break; // 超时或错误 int n read(fd, buf total, maxlen - total); if (n 0) break; total n; // 简单判断如果已经收到足够字节提前退出 // 更严谨的做法是根据功能码计算预期长度 } return total; }收到数据后先校验 CRC再检查从站地址和功能码是否匹配。如果功能码最高位是 1比如 0x83说明从站返回了异常异常码在数据域第一个字节常见的有 0x01 非法功能、0x02 非法数据地址、0x03 非法数据值。4.5 完整轮询循环的搭建把上面的模块串起来主循环大概是这样typedef struct { uint8_t addr; uint16_t start; uint16_t count; uint16_t regs[32]; int online; int fail_count; } slave_t; void poll_loop(int fd, slave_t *slaves, int n) { uint8_t frame[256], resp[256]; for (int i 0; i n; i) { slave_t *s slaves[i]; int len build_read_holding(frame, s-addr, s-start, s-count); gpio_set(de_fd, 1); write(fd, frame, len); tcdrain(fd); gpio_set(de_fd, 0); int rlen recv_response(fd, resp, 5 s-count * 2, 200); if (rlen 5 check_crc(resp, rlen) resp[0] s-addr resp[1] 0x03) { int byte_count resp[2]; for (int j 0; j byte_count / 2; j) { s-regs[j] (resp[3 j*2] 8) | resp[4 j*2]; } s-online 1; s-fail_count 0; } else { s-fail_count; if (s-fail_count 3) s-online 0; } usleep(20000); // 从站间间隔 20ms } }这个循环每轮把所有从站过一遍在线状态和失败计数都维护着。实际项目中还会加日志、数据缓存、上报逻辑但核心骨架就是这些。5. 常见问题与排查技巧实录5.1 通信完全无响应的排查顺序遇到从站不响应按这个顺序查能省很多时间物理层A/B 线有没有接反RS485 的 A 接 A、B 接 B接反了大部分设备不通信。用万用表量一下 A/B 之间有没有约 120 欧姆的终端电阻如果两端都接了。共地RS485 虽然差分传输但长距离时共地很重要否则共模电压可能超出收发器范围。串口参数波特率、数据位、停止位、校验位是否和从站完全一致。用示波器或者逻辑分析仪抓一下 TX 波形确认波特率对不对。方向控制手动 DE 控制的模块确认发送时 DE 拉高、接收时拉低。用示波器看 DE 和 TX 的时序关系。从站地址确认请求的地址和从站实际地址一致。有些设备地址通过拨码开关设置容易看错。5.2 CRC 校验失败的典型原因CRC 错误说明收到了数据但内容不对。常见原因帧不完整接收超时太短数据还没收完就判定结束。适当加大超时。字节丢失串口缓冲区溢出或者读取太慢。检查read的调用频率和缓冲区大小。干扰RS485 线缆没有屏蔽或者走线靠近动力线导致位翻转。改善布线加磁环。波特率偏差主从设备的时钟源精度不够长帧累积误差导致采样错误。换用精度更高的晶振或者降低波特率。5.3 多从站轮询时的总线冲突理论上 Modbus 主从架构不会冲突因为从站只在被问到时才说话。但实际中如果主站超时设置太短上一个从站的响应还没到就发了下一个请求就可能撞车。解决办法是确保超时时间大于最慢从站的最大响应时间并且在两次请求之间留足静默间隔。还有一种情况是总线上混入了其他主站设备或者某个从站故障后不停发数据。这时候需要用示波器看总线波形确认是不是有非预期的发送方。5.4 常见问题速查表现象可能原因排查方法完全无响应接线错误、参数不匹配、方向控制错误查接线、对参数、看 DE 时序CRC 校验失败帧不完整、干扰、波特率偏差加大超时、改善布线、降波特率偶发超时总线冲突、从站处理慢加大间隔、单独测试该从站数据明显错误字节序不对、寄存器地址偏移换字节序、核对手册地址部分从站正常部分异常地址冲突、线路分支过长单独测试、检查拓扑实操心得调试 Modbus 一定要有一个趁手的工具。我习惯用一个 USB 转 RS485 接到电脑上用串口调试助手手动发帧先确认从站本身是好的再排查自己的代码。这样能把问题范围快速缩小到“设备侧”还是“代码侧”。6. 性能优化与稳定性加固6.1 轮询周期的动态调整固定轮询周期在从站数量少时没问题但规模上去后就要优化。我的做法是给每个从站设置不同的优先级关键数据每轮都读次要数据隔几轮读一次。用一个计数器控制比如温度每轮读累计电量每 5 轮读一次。这样能在不增加总线负载的前提下保证关键数据的实时性。另外从站离线后不要一直用正常频率去试可以退避到每 10 轮试一次减少无效等待。恢复后再回到正常频率。6.2 数据缓存与异常上报采集到的数据不要只放在内存里加一个环形缓冲区或者简单的文件缓存防止程序重启后数据丢失。异常事件超时、CRC 错误、从站离线要记录日志带上时间戳和从站地址方便事后分析。上报逻辑上可以设置变化阈值只有数据变化超过一定幅度才上报减少上层系统的压力。比如温度变化小于 0.1 度就不报这样能过滤掉大量噪声。6.3 长时间运行的稳定性考虑嵌入式设备往往要连续运行几个月甚至几年稳定性是硬指标。几个要点文件描述符泄漏串口打开后如果异常路径没有关闭反复重连会耗尽 fd。确保所有错误分支都close。内存泄漏动态分配的内存要配对释放最好用静态缓冲区避免碎片。看门狗启用硬件看门狗主循环定期喂狗程序卡死能自动重启。日志轮转日志文件不能无限增长设置大小上限或者按天切割。我在一个实际项目里遇到过连续运行一周后串口读写出错的情况最后定位到是select的超时结构体在循环中被修改了没有重置。这种细节在短时间测试中根本发现不了只有长时间跑才会暴露。7. 从实例到产品可扩展的方向这套 Modbus RTU 主站实现跑通之后往上可以叠加很多东西。比如加一个 MQTT 客户端把采集的数据推到消息队列或者加一个本地 Web 服务用浏览器就能看实时数据再或者加规则引擎根据采集值自动触发控制指令。协议层面也可以扩展比如同时支持 Modbus TCP让以太网设备也能接进来或者支持 Modbus 从站模式让 Express Pi 本身作为从站被其他主站轮询。这些扩展都建立在当前这个清晰的串口层和协议层之上改动量可控。我个人在实际操作中的体会是Modbus RTU 的难点从来不在协议本身协议很简单难的是现场的各种意外线接错了、参数对不上、干扰导致偶发错误、从站固件有 bug。所以调试时一定要有耐心用工具把每一层都验证到位不要一上来就怀疑代码。把物理层和参数确认清楚代码只要逻辑正确基本都能跑通。最后再分享一个小技巧在代码里加一个“回环测试”模式把 TX 和 RX 短接自己发自己收能快速验证串口配置和收发逻辑是否正确排除硬件和接线因素后再接真实从站效率会高很多。
阅读完成 · 觉得有帮助?