问题背景
充电桩这类 RS485 工业设备普遍是数据孤岛:状态、电压、电流、电量、枪温、故障码都锁在本地,运维要靠人跑现场。网关要做的事很清晰——向下经 RS485 抄表,向上经以太网/Wi-Fi 双上行接入 MQTT 云平台,实现远程监控、阈值告警与远程启停。
中间必须自己写一套 Modbus RTU 主站。原因很实际:协议本身不复杂,但现场对时序、超时、总线占用有具体要求,通用库往往带着用不上的抽象和动态内存;网关又是编译期定长、零动态内存的架构,自己写反而更可控。
帧格式与组装
Modbus RTU 的 ADU 由从站地址、PDU、CRC16 构成,帧内字节间隔不能超过 1.5 字符,帧间隔为 3.5 字符。以 0x03 读保持寄存器为例:
tx[0] = slave;
tx[1] = 0x03;
tx[2] = reg >> 8; tx[3] = reg & 0xFF;
tx[4] = qty >> 8; tx[5] = qty & 0xFF;
crc = crc16(tx, 6);
tx[6] = crc & 0xFF; tx[7] = (crc >> 8) & 0xFF; /* 低字节先发 */两个容易错的点:CRC 是低字节在前;0x06 写单个寄存器的应答是回声帧,原样回显地址、功能码、寄存器地址和值,可以拿它确认写入真的生效。
CRC16:查表还是逐位计算
Modbus 的 CRC16 用多项式 0xA001(即 0x8005 的反转形式),初值 0xFFFF。逐位实现每个字节右移 8 次;查表法预生成 256 项表,每字节一次异或加一次查表:
uint16_t crc16(const uint8_t *d, size_t n) {
uint16_t c = 0xFFFF;
while (n--) c = (c >> 8) ^ tab[(c ^ *d++) & 0xFF];
return c;
}权衡很直接:查表占 512 字节存储,换来约 8 倍速度。ESP32-S3 上逐位算法其实也跑得动,但主站要同时管多从站、多轮询周期,CPU 余量应该留给业务,所以选查表。表在编译期由脚本生成,不进运行时计算。
状态机、超时与重试
一次事务是一个小状态机:抢总线 → 使能发送 → 等应答 → 收帧 → 验 CRC → 解析 → 释放。两个时间参数要分清,它们不是一回事:
- T3.5 帧间隔:按波特率换算,9600 下约 3.6ms,115200 下约 0.3ms,用于切分帧边界。
- 应答超时:从站最慢多久该回话,固定 100ms 起步,慢设备单独放宽。
超时不等于失败。做法是有限次重试加退避,连续失败把该设备标记为劣化并拉长它的轮询间隔,避免一个掉线从站拖垮整条总线。
异常应答处理
"没回包"和"回了但是拒绝"必须分开处理。异常应答的特征是功能码最高位置 1,第二个字节是异常码:
if (rx[1] & 0x80) {
result.err = rx[2]; /* 0x01 非法功能码 / 0x02 非法地址 / 0x04 从站故障 */
return DECODE_EXCEPTION;
}超时说明链路或从站没响应,异常码说明从站活着但请求不合法,两者的运维语义完全不同。日志必须能区分,否则现场排查时只能干瞪眼。
RS485 方向控制与总线互斥
RS485 是半双工,SP3485 这类收发器的 DE/RE 由 GPIO 控制。最容易埋坑的是拉低时机:必须等最后一个字节真正移位完成,凭感觉延时会把尾字节裁掉。
xSemaphoreTake(bus_mutex, portMAX_DELAY);
gpio_set_level(PIN_DE, 1);
uart_write_bytes(uart, tx, len);
uart_wait_tx_done(uart, pdMS_TO_TICKS(20)); /* 等移位完成再切方向 */
gpio_set_level(PIN_DE, 0);互斥锁保护的是一整个事务,不是单次写操作。采集任务要轮询,监控任务要远程启停,命令还可能来自 MQTT,多个任务抢同一条总线时,锁的粒度必须覆盖"抢占—发送—等待—释放"全程,中间任何一环放锁都会把两条帧拼在一起。
寄存器区间合并
一块充电桩要读 6 个测点,零散读就是 6 个小帧。点表按寄存器地址升序排列,相邻间隔小于阈值的并入同一个 0x03 事务,一次读完再按偏移拆包:
点表:0x0000, 0x0001, 0x0002, 0x0004, 0x0005, 0x0006
合并:读 0x0000 起 3 个 + 读 0x0004 起 3 个 (6 个测点 2 帧完成)合并有边界:间隔过大、或合并后字节数超过应答上限时自动拆批。每个测点带数据质量戳,拆包后逐点打标,坏帧不会污染正常点。
从站模拟器联调
协议栈不能等真设备到位才验证。配套写了一个 Modbus RTU 从站模拟器扮演充电桩,寄存器值可预设,并能主动注入异常:不回包、CRC 故意算错、回异常码、回半帧后断流。这四类场景覆盖了主站绝大部分分支——超时重试、CRC 丢弃、异常码分类、半帧重组与复位。先让模拟器把异常路径全跑通,真机联调就只剩物理层问题。
踩坑与经验
- CRC 字节序、功能码置位方式这类细节,对照抓包工具逐字节核,不要凭记忆。
- 从站地址冲突在现场很常见,模拟器要能改地址才能复现。
- 总线终端电阻缺失会导致反射和乱码,现象极像协议 bug,先用示波器排除物理层再改代码。
- 广播地址 0 只发不应答,校时类功能用它,但要知道没有确认机制。
小结
手写协议栈的价值不在"省了一个库",而在于每一行时序和错误处理都自己可控:CRC 怎么算、超时给多少、异常怎么分类、总线怎么抢,全部是明确的设计决策而不是库的默认行为。对资源受限、又要求长期无人值守运行的网关来说,这种可控性比代码量更值钱。