
简介这是一份基于VS2008的Modbus RTU串口驱动示例工程面向工业自动化领域的C开发者解决上位机通过串口与Modbus设备通信的问题。压缩包共23个文件大小3.4MB包含7个.h头文件、5个.cpp源文件以及.sln/.vcproj工程文件、可直接运行的.exe测试程序和版本资源文件.rc/.ico/.aps等结构清晰便于直接打开工程编译和对照学习。工程演示了从串口打开、波特率/数据位等参数设置到Modbus报文构建、CRC校验、收发解析的完整流程并提供了同步与异步两种通信模型的实现思路。已有269人学习下载。阅读源码可掌握CreateFile、SetCommState、ReadFile、WriteFile等Windows API的串口操作细节理解RTU帧中地址、功能码、数据段与校验码的组装和解析方式。配套测试程序还能模拟与Modbus Slave设备的交互帮助开发者验证协议正确性并借鉴其中的超时重试、错误处理等健壮性设计快速移植到实际项目中。对初学者而言可参照示例完成从串口配置到数据交互的完整闭环降低入门门槛。1. Modbus串口驱动到底在解决什么问题从一根串口线到一套可靠通信协议Modbus串口驱动这六个字在工控圈里被念叨了几十年但真正能把它写明白的资料少之又少。很多工程师拿到项目第一反应是用现成库可一旦碰到老式电表、PLC、温控器才发现现成库读不出数据最后还得自己动手。这里说的串口驱动不是用串口助手发几行十六进制凑合而是要实现一套能扛住现场干扰、设备掉线、参数错配的主从轮询机制。这篇文章用 Python 从零写一个可用的 Modbus RTU 串口驱动覆盖报文封装、CRC 校验、超时重试和离线判定并给出调试思路适合正在被为什么偶发超时折磨的开发者。2. Modbus串口驱动的工作原理RTU报文、CRC校验和主从时序2.1 主从模型谁发起、谁响应时序为什么是驱动的命门Modbus 串口总线是典型的主从结构。一个主站带若干个从站所有通信由主站发起从站被动响应。驱动要扮演的角色就是主站它负责定时下发请求帧等待对应从站返回应答帧。这里最关键的一点是串口总线上同一时刻只能有一个发送者。如果是 RS232 点对点拓扑简单相对好处理但工业现场更多是 RS485 半双工总线多台设备共享一对双绞线如果驱动没有严格遵守先发后收、收完再发的规矩两个节点同时把电平拉上去就是总线冲突轻则一帧数据作废重则烧毁收发器。时序问题具体体现在三个地方。第一请求和响应之间必须有间隔Modbus 协议没有规定具体时间但很多从站需要几十毫秒处理时间。第二从站响应过程中字节与字节之间不能断太久一旦超过帧超时驱动就会把半截响应当废帧丢掉。第三主站连续下发两帧之间要留出足够空闲时间让从站来得及处理上一帧。很多自研驱动翻车不是算错了 CRC而是把时序压得太紧以为波特率高响应就快结果被中间线路回波和从站处理时间双重打脸。我一般会在驱动里把时序拆成两个独立参数响应超时和帧间隙。响应超时从请求发送完成时开始计时到收到第一个响应字节结束通常给 100 到 500 毫秒具体看从站手册。帧间隙按波特率算比如 9600 波特率下 1 个字节约 1.04 毫秒帧间隙设为 3.5 个字符时间约 4 毫秒。不能用同一个超时值去推断所有环节这是新手最容易掉进去的坑。2.2 RTU报文结构地址、功能码、数据、CRC的字节级拆解Modbus RTU 的报文是二进制帧不是 ASCII 字符串。一帧的构成可以用下面这张表说明字段长度说明从站地址1 字节1~2470 是广播功能码1 字节0x03 读保持寄存器0x06 写单寄存器等数据不定按功能码不同包含寄存器地址、数量或数值CRC162 字节对前面所有字节的校验低字节在前发送以最常用的读保持寄存器为例主站请求帧需要填充从站地址、功能码 0x03、起始寄存器地址两个字节、寄存器数量两个字节。注意这里所有多字节字段都是大端序即高字节在前。响应帧则由从站地址、功能码 0x03、数据字节数一个字节组成后面的寄存器数据每两个字节是一个大端序整数。有几个特别容易混淆的细节CRC 是低字节在前但寄存器的数据是高字节在前请求帧里的地址和数量是高字节在前任何一处字节序写反CRC 校验就会对不上。CRC16 的计算是 Modbus 串口驱动绕不开的硬功夫。多项式固定是 0xA001初始值 0xFFFF。现场手册里给的往往是一张查表但驱动里我更推荐用逐位算法逻辑简单且不容易写错def calc_crc16(data: bytes) - bytes: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc 1 crc ^ 0xA001 else: crc 1 return crc.to_bytes(2, byteorderlittle)这个函数接受请求帧去掉 CRC 的部分返回两个字节低字节在前。注意函数里每个字节需要循环 8 次遍历完整个数据。如果用查表法表生成算法用的也是同样的多项式。在写驱动时我会把 CRC 计算单独放一个模块并写一组测试向量比如报文01 03 00 00 00 01CRC 应该是84 0A。凡是算出来不是这个结果的不用怀疑设备先怀疑自己的字节序。还有一种响应是异常响应。从站收到错误请求时会把功能码的最高位置 1比如 0x03 变成 0x83后面跟一个异常码。常见异常码包括 02非法数据地址、03非法数据值、04从站设备故障。驱动不能把异常响应当成无效帧丢弃而要解析出异常码转成业务错误上层才能决定是重试还是报警。另外要划清驱动和上层应用的边界驱动只负责收发帧、校验 CRC、处理超时不负责解析浮点数的字节序。寄存器原始值是 uint16 数组浮点转换是上层的事。一旦驱动开始替上层做各种高低字交换、大小端猜测这个驱动就会变得又脏又难维护。2.3 串口参数波特率、数据位、校验位怎么选才不翻车Modbus RTU 依赖串口物理层参数对齐通信双方任何一项不一致都会导致乱码。最典型的参数组合是 9600 波特率、8 数据位、无校验、1 停止位也就是常说的 8N1。但现场老化设备经常用 8E1偶校验或 8O1奇校验有的甚至用 1.5 停止位。驱动必须把波特率、数据位、校验位、停止位、流控全部做成可配置项不能写死。数据位尤其要注意Modbus RTU 是二进制协议必须用 8 位数据位才能表达 0x00 到 0xFF 的任意字节。如果某台 PLC 只支持 7 位数据位那它只能跑 Modbus ASCII不能跑 RTU。所以拿到设备手册第一件事不是看寄存器表而是先确认它支不支持 RTU支持的话串口参数是什么。另一个常见误区是流控。很多 USB 转串口适配器默认开了 RTS/CTS 流控但 Modbus 从站设备根本不提供 CTS 信号结果驱动发送正常接收永远不会触发。我在调试时遇到只发不收第一件事就是检查适配器是否关闭了硬件流控。还要提 RS485 的方向切换。RS485 是半双工发送时需要把收发器置为发送模式发送完立刻切回接收模式。一些老式 PCI 串口卡要求软件去拉 RTS 电平现代 USB 转 485 适配器大多自动切换但驱动代码里最好预留一个发送后切换延时接口。这个参数通常只有几百微秒到几毫秒但如果驱动在 write 函数返回后马上调用 read而硬件还没来得及切回接收第一帧响应就会错失。3. 从零写一个Modbus串口驱动轮询主站与寄存器读写实现3.1 打开串口的一步到位配置pyserial关键参数我用 Python 做这类驱动最频繁因为 pyserial 跨平台对超时的控制粒度也够。安装很简单pip install pyserial就够了。打开串口的一步到位配置如下import serial ser serial.Serial( port/dev/ttyUSB0, # Linux 下常见 USB 转串口设备 baudrate9600, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.2, # 读超时单位秒 write_timeout0.2, # 写超时 )timeout是 read 操作的最长阻塞时间。它决定驱动等待响应时的耐心如果从站一直没有回复read 会在 0.2 秒后返回空字节驱动据此判定超时。write_timeout防止串口被外部占用时 write 一直卡住不返回。bytesize、parity、stopbits必须和从站手册保持一致常见的是 8N1但老设备可能是 8E1改一个参数整个通信行为都会变。如果适配器需要手动控制 RS485 方向pyserial 也支持rs485_mode。用法是import serial.rs485 ser.rs485_mode serial.rs485.RS485Settings( rts_level_for_txTrue, rts_level_for_rxFalse, delay_before_tx0.001, # 发送前延时 delay_before_rx0.001 # 发送后切换接收延时 )这个设置依赖硬件支持。如果你的 USB 转 485 适配器没有自动方向切换就必须在“发送后切换接收”上做文章否则后面会频繁超时。3.2 读保持寄存器03功能码的请求封装与响应解析读保持寄存器是现场最常用的操作用来读取设备当前值、运行状态、设定参数等。请求帧的格式是从站地址 0x03 起始地址高字节 起始地址低字节 数量高字节 数量低字节 CRC。我把封装和解析拆成两个函数import struct def build_read_holding_registers_request(slave_id: int, start_address: int, quantity: int) - bytes: # 功能码 0x03读保持寄存器 if not (1 quantity 125): raise ValueError(quantity must be 1..125) frame struct.pack(B B H H, slave_id, 0x03, start_address, quantity) return frame calc_crc16(frame) def parse_read_holding_registers_response(response: bytes, expected_slave_id: int) - list[int]: # 校验最少长度地址功能码字节数crc if len(response) 5: raise ValueError(response too short) slave_id, func_code, byte_count response[0], response[1], response[2] if slave_id ! expected_slave_id: raise ValueError(slave id mismatch) if func_code 0x80: # 异常响应功能码最高位为 1 raise ModbusException(func_code 0x0F, response[3]) data_bytes response[3:3 byte_count] # 每两个字节成为一个寄存器值大端序 registers [ int.from_bytes(data_bytes[i:i 2], big) for i in range(0, len(data_bytes), 2) ] return registersstruct.pack(B B H H, ...)中的表示大端序H是两字节无符号整数正好对应寄存器地址和数量。calc_crc16返回的是已经排好序的低字节在前的 CRC所以直接拼接即可。解析函数里先处理异常响应再按数据字节数切寄存器避免把 CRC 当数据解析出来。有了请求和解析还需要一个可靠的接收函数。串口 read 的返回长度不保证和请求一致不能写成resp ser.read(5 2 * quantity)一锤子买卖。我一般这样读def read_exact(ser, length: int, idle_timeout: float 0.05) - bytes: data b deadline time.time() idle_timeout while len(data) length: chunk ser.read(length - len(data)) if chunk: data chunk deadline time.time() idle_timeout elif time.time() deadline: break return dataread_exact不断读取直到凑够指定长度或者帧间隙超过idle_timeout。这里的idle_timeout就是前文说的帧超时它比整体超时小得多。这样处理的好处是响应被拆成两段到达时驱动不会因为读了一次没读满就误判超时。3.3 写单个寄存器06功能码的构造与错误码识别写单个寄存器的场景也很常见比如给设备下发设定值、切换状态位。0x06 功能码的请求帧是从站地址 0x06 寄存器地址 写入值 CRC。正常情况下从站会把请求帧原样回显给主站所以解析逻辑比较简单def build_write_single_register_request(slave_id: int, address: int, value: int) - bytes: # 功能码 0x06写单个保持寄存器 frame struct.pack(B B H H, slave_id, 0x06, address, value) return frame calc_crc16(frame) def parse_write_single_register_response(response: bytes) - tuple[int, int, int]: # 响应是请求的完整回显 if len(response) 8: raise ValueError(response too short) slave_id, func_code, address, value struct.unpack(B B H H, response[:6]) if func_code 0x80: # 高位为 1 表示写失败response[3] 是异常码 raise ModbusException(func_code 0x0F, response[3]) return slave_id, address, value写操作最怕不是超时而是写入成功但解析失败。从站回显是原帧CRC 也一样所以struct.unpack(B B H H)取前 6 个字节就够了后两个字节不必再看。如果返回的func_code是 0x86说明从站认为这次写入非法此时response[3]的异常码直接决定上层要不要报警。实际使用时读写不能每次都重新开串口。一个健壮的做法是把串口对象放进一个驱动类通过方法来访问后面再讲封装。这里先注意一点写请求和读请求的构造方式不同但 CRC 计算完全一样不要为每个功能码重复写一套 CRC 函数。4. Modbus串口驱动的调试与排查抓包对照、超时重试和日志玄学4.1 现象一能收不能发或能发不能收串口方向与线序问题现象驱动发送请求后从站没有任何响应。用串口调试助手手动发送同一帧从站也没反应。或者从站明明在回复但上位机收不到任何字节。原因串口线 TX/RX 接反或者 RS485 的 A/B 线接反。RS232 是交叉线RS485 是 A 对 A、B 对 B很多新手用 RS232 的交叉习惯去接 RS485自然一收就掉线。还有一种可能是 USB 转串口适配器的硬件流控默认开启CTS 信号悬空导致数据只能往外发收不进来。解决先用“自发自收”排除硬件问题。把串口调试助手的发送区和接收区打开直接将 TX 和 RX 短接发一个字节看能否收到回显。收到说明串口通路正常再检查线序。RS485 接反时交换 A/B 试一次。硬件流控则到系统设备管理器里把“使用流控”关掉或者改用没有流控的 USB 转 RS485 适配器。4.2 现象二偶发超时无响应RS485方向切换延迟与收发使能现象程序跑起来大部分时间正常但每隔一段时间就出现一次超时重试后恢复。单次手动发送请求时永远正常一旦连续轮询就开始掉链子。原因RS485 是半双工驱动调用 write 发送完最后一个字节后收发器需要从发送模式切回接收模式。有些适配器的方向切换由 RTS 控制如果软件没有延时就立刻读前几十微秒到几毫秒仍然处于发送状态从站返回的响应电平会被自己的发送电路吃掉一部分导致驱动只收到半帧或空帧。解决确认适配器是硬件自动切换还是软件切换。软件切换的在 write 之后加一个切换延时长度取波特率下两三个字符的时间即可。例如 9600 波特率一个字符约 1.04 毫秒延时 2~3 毫秒足够。还可以在驱动里把“发送后接收延时”做成配置项现场遇到顽固设备时逐个加直到超时消失。4.3 现象三CRC校验总是失败字节大小写和帧间隙问题现象驱动能收到响应但每次解析时 CRC 校验都过不了。用调试助手收到的报文和自己的请求帧对比看起来完全一样但算出来的 CRC 就是不一致。原因第一个常见原因是字节序写反。CRC 在 Modbus 帧里是低字节在前发送但很多驱动参考例程用高字节在前结果校验结果互换。第二个常见原因是帧间隙设置过短从站响应中的两个字节间隔超过了驱动设定的idle_timeoutread_exact 把完整的响应拆成了两截截断的那一半 CRC 当然对不上。第三个原因是在波特率高时数据字节的位序处理错误少了一次移位。解决先把收到的完整报文用打印日志的方式记下来发给一个成熟的 Modbus 测试工具做对照。用工具计算 CRC 并与自己的算法结果比对。确认 CRC 函数输出字节序是little。接着把read_exact的idle_timeout调大比如从 0.01 调到 0.1观察是否稳定。现场总线距离长、负载重时字节间隙很容易被拉到几十毫秒这个参数不能只按理论值设置。4.4 现象四多站点轮询卡顿单站超时时间设置错误现象总线上挂了十几个从站轮询一圈要几十秒。有一台设备掉线整个轮询周期被拖得肉眼可见其他正常的站也跟着变慢。原因驱动对每个站都使用固定的长超时正常站几毫秒就响应掉线站则要等到超时结束。如果每站超时设 2 秒总线上挂 10 个站一圈下来就是 20 秒。解决把响应超时设置为“正常响应时间的 3~5 倍”而不是固定大值。比如正常站 50 毫秒内响应超时设 250 毫秒已足够。对掉线站做“快速重试 离线惩罚”第一次超时后立即重试一次再超时就标记该站为离线后续轮询跳过它过 5 秒再探测一次。这样现场维护时既不会漏报也能保证正常站不被拖累。5. 把它封装成可复用的驱动异常重试、并发锁和日志记录5.1 驱动类的骨架连接、关闭、轮询循环的职责分离把前面的函数塞进一个类里驱动才能长期跑在轮询线程里。类的职责分三层串口生命周期管理、基础 Modbus 事务、高层业务接口。一个最小骨架如下import threading import time import serial class ModbusRTUDriver: def __init__(self, port: str, baudrate: int 9600, timeout: float 0.2): self.port port self.baudrate baudrate self.timeout timeout self.ser None self.lock threading.Lock() def connect(self): self.ser serial.Serial( portself.port, baudrateself.baudrate, timeoutself.timeout, write_timeoutself.timeout, ) # 如果使用 rs485_mode需要在这里设置 def close(self): if self.ser and self.ser.is_open: self.ser.close() self.ser None def _transact(self, request: bytes, expected_len: int) - bytes: 发送请求并读取响应不带重试 self.ser.write(request) resp read_exact(self.ser, expected_len) if len(resp) 5: raise ModbusTimeout(response timeout) return resp def read_holding_registers(self, slave_id: int, start: int, quantity: int) - list[int]: req build_read_holding_registers_request(slave_id, start, quantity) resp self._transact(req, 5 2 * quantity) return parse_read_holding_registers_response(resp, slave_id)connect和close负责串口资源的开关。_transact是事务原语只做写请求和读帧不掺合业务逻辑。read_holding_registers组合请求构造、事务、解析三步调用方不需要关心 CRC 和字节序。这样设计后以后加写功能码只需要新增一个 public 方法_transact原样复用。轮询循环不应该写进驱动类。驱动类只提供单次操作循环和调度交给上层线程。这样单元测试也好写模拟一个假的串口对象直接调用read_holding_registers就能验证解析逻辑。5.2 异常重试与退避不把偶发超时当成致命错误现场总线受干扰、从站瞬间忙等原因偶发超时是常态。驱动不能一遇到超时就把整个线程崩溃也不能不计代价地连续重试导致总线雪崩。我一般这样写重试逻辑class ModbusTimeout(Exception): pass class ModbusOffline(Exception): pass def read_holding_registers_with_retry(self, slave_id, start, quantity, retries3, base_backoff0.1): for attempt in range(retries): try: return self.read_holding_registers(slave_id, start, quantity) except ModbusTimeout: time.sleep(base_backoff * (attempt 1)) except ModbusException as exc: # 异常码 02/03/04 多数是从站主动拒绝不用盲目重试 raise raise ModbusOffline(slave_id)base_backoff是退避基数第一次重试前等 0.1 秒第二次等 0.2 秒第三次等 0.3 秒。这样可以给从站留出恢复时间同时避免集中请求。注意区分两类失败ModbusTimeout是通信层面没收到响应重试可能有效ModbusException是从站明确返回异常代表请求本身不合法重试只会浪费总线时间。这里的代码把异常码直接抛出而不是吞掉方便上层定位配置错误。5.3 串口写锁多线程并发下避免报文交错如果驱动的调用方既有轮询线程又有手动操作面板线程两个线程同时调用 read 或 write串口数据会交错出现“你发一半我发一半”的报文从站根本识别不了。解决方式是在驱动类里加一把锁将整个事务包起来def read_holding_registers_locked(self, slave_id, start, quantity): with self.lock: return self.read_holding_registers(slave_id, start, quantity)锁必须包住“从写请求到读完响应”的完整过程不能只锁 write。因为 write 后立刻读是一个整体中途插入另一个 write 会让当前事务收到错误响应。还要注意connect和close也要拿同一把锁防止在事务中途关闭串口导致异常。除了锁日志记录也是驱动可维护性的关键。每一次_transact都该记下请求字节、响应字节、耗时、成功与否。日志格式尽量一行一条便于用文本工具排查2025-01-12 10:23:45.123 TX 01 03 00 00 00 01 84 0A 2025-01-12 10:23:45.187 RX 01 03 02 00 05 F9 53 elapsed64ms ok有这份日志现场遇到“某个寄存器偶尔读不到”不用靠猜直接看耗时和重试计数就能定位是网络延迟还是从站处理慢。6. 用模拟从站验证驱动的正确性虚拟串口对与自动化回归驱动写完不能直接上真机先用模拟从站把协议层跑通。我常用 socat 创建一对虚拟串口一端给模拟从站一端给驱动完全绕开硬件socat -d -d pty,raw,echo0,link/tmp/tty0 pty,raw,echo0,link/tmp/tty1然后驱动连接/tmp/tty1模拟从站连接/tmp/tty0。模拟从站可以选择任意一个开源 Modbus 从站模拟器打开后配置成从站地址 1波特率 9600寄存器表里放几个已知值。接下来跑一个回归脚本drv ModbusRTUDriver(port/tmp/tty1, baudrate9600, timeout0.2) drv.connect() regs drv.read_holding_registers(slave_id1, start0, quantity5) assert regs[0] 100, funexpected value: {regs[0]}这个脚本能验证驱动能不能正确解析正常响应。为了测异常响应和超时我通常还会在模拟器上故意配置一个不存在的寄存器地址或者让模拟器强制延迟响应。测试用例至少覆盖三种情况正常读返回预期值、非法地址返回 ModbusException、从站停机返回 ModbusTimeout 并最终触发 Offline。跑完这些再上真机翻车概率会小很多。我在做这类项目时养成了一个习惯驱动里所有超时参数、重试次数、日志开关都写进配置文件不在代码里写死。现场设备千奇百怪有的从站要求帧间隙 50 毫秒有的 5 毫秒就行测试时调一个参数就能解决的事千万别让现场工程师改代码。这个习惯帮我少跑了无数趟现场也让我体会到Modbus 串口驱动真正的难点不在报文算法而在对各种异常场景的耐受度。希望这篇文章能帮你把驱动做到“能跑”甚至“好养”愿你的串口总线少一点玄学多一些确定性。本文还有配套的精品资源点击获取