3. Modbus RTU协议实现:主机/从机状态机设计、功能码(03/06/16)实现、异常响应处理、多从机轮询机制
各位同学,咱们今天聊点硬核的。Modbus RTU,这玩意儿在电力监控里太常见了。说白了,它就是工控界的“普通话”,上到继电保护装置,下到智能电表,几乎都在用它。我做了这么多年嵌入式,跟它打交道的时间比跟老婆说话的时间都长(开个玩笑)。
这一章,咱们不扯虚的,直接撸起袖子干。我会带着你把主机和从机的状态机拆开揉碎,把03、06、16这三个最常用的功能码实现一遍,再聊聊异常处理和多从机轮询。嗯,都是实战干货。
3.1 主机状态机设计:别让CPU空转
先说说主机。主机是啥?就是发号施令的那个。它负责轮询各个从机,读取数据,下发指令。但这里有个坑:你不能发了请求就傻等。万一从机掉线了,或者网络延迟了,你总不能一直卡在那儿吧?
我个人习惯用有限状态机来管理主机的收发流程。状态不多,就四个:
- IDLE(空闲):啥也不干,等待定时器触发下一次轮询。
- TX_WAIT(发送等待):数据刚发出去,等待发送完成中断。
- RX_WAIT(接收等待):数据发完了,等待从机回复。这里要开一个超时定时器。
- PROCESS(处理):收到回复了,解析数据,然后回到IDLE。
你看,就这么简单。但实际项目中,我见过有人把状态机搞出十几个状态,结果自己都绕晕了。没必要,真的没必要。
核心要点:主机状态机的核心是“非阻塞”。发送完请求后,立刻退出,让CPU去干别的事。等中断来了,再切状态。千万别用delay()死等,那是单片机初学者的玩法。
代码骨架大概是这样的:
typedef enum {
HOST_IDLE,
HOST_TX_WAIT,
HOST_RX_WAIT,
HOST_PROCESS
} HostState_t;
HostState_t hostState = HOST_IDLE;
void Host_Task(void) {
switch(hostState) {
case HOST_IDLE:
// 检查轮询定时器是否到期
if (PollTimer_Expired()) {
BuildRequestFrame(); // 组装请求帧
UART_Send(); // 启动发送
hostState = HOST_TX_WAIT;
}
break;
case HOST_TX_WAIT:
// 等待发送完成中断置位标志
if (txCompleteFlag) {
txCompleteFlag = 0;
StartTimeoutTimer(100); // 100ms超时
hostState = HOST_RX_WAIT;
}
break;
case HOST_RX_WAIT:
if (rxCompleteFlag) {
rxCompleteFlag = 0;
hostState = HOST_PROCESS;
} else if (TimeoutTimer_Expired()) {
// 超时了,处理异常
HandleTimeout();
hostState = HOST_IDLE;
}
break;
case HOST_PROCESS:
ParseResponse();
hostState = HOST_IDLE;
break;
}
}
避坑指南:我曾经在一个项目里,把超时时间设成了10ms。结果发现从机偶尔会慢半拍,导致频繁超时重发,把总线都搞乱了。后来改成50ms,世界清净了。记住,超时时间要留余量,尤其是用485总线时,要考虑收发切换时间。
3.2 从机状态机设计:被动但要有底线
从机就简单多了。它就是个“服务员”,你点菜(发请求),我上菜(回响应)。但服务员也不能啥菜都接,万一你点个“红烧CPU”呢?所以从机要有异常处理。
从机状态机我一般分三个状态:
- IDLE(空闲):监听总线,等待接收完整的一帧数据。
- RX_DONE(接收完成):收到一帧了,开始解析。
- TX_DONE(发送完成):响应发完了,回到IDLE。
你可能会问:“怎么没有处理状态?” 嗯,我习惯把解析和处理放在RX_DONE里一起做,省得状态切来切去。
从机最关键的是地址匹配。不是发给你的帧,你千万别搭理。我见过一个新手写的代码,从机收到任何帧都回复,结果总线上两台从机打起来了……
void Slave_RxHandler(uint8_t *buffer, uint16_t len) {
// 第一步:检查地址
if (buffer[0] != MY_SLAVE_ADDR && buffer[0] != BROADCAST_ADDR) {
return; // 不是找我的,忽略
}
// 第二步:CRC校验
uint16_t crc = CalculateCRC(buffer, len - 2);
uint16_t recvCrc = (buffer[len-1] << 8) | buffer[len-2];
if (crc != recvCrc) {
return; // CRC错了,直接丢弃
}
// 第三步:解析功能码
switch(buffer[1]) {
case 0x03: HandleReadHoldingRegisters(buffer, len); break;
case 0x06: HandleWriteSingleRegister(buffer, len); break;
case 0x10: HandleWriteMultipleRegisters(buffer, len); break;
default: SendException(buffer[1], EX_ILLEGAL_FUNCTION); break;
}
}
注意:从机收到广播地址(0x00)时,只需要执行命令,不需要回复。这是Modbus协议的规定。我曾经忘了这个细节,结果广播写寄存器时,从机也回复了,导致主机以为收到了多条响应,解析全乱套了。
3.3 功能码03/06/16实现:读写寄存器是基本功
这三个功能码,覆盖了90%的应用场景。03是读保持寄存器,06是写单个寄存器,16是写多个寄存器。说白了,就是读写数据。
功能码03(读保持寄存器):
请求格式:从机地址 + 0x03 + 起始地址(2字节) + 寄存器数量(2字节) + CRC
响应格式:从机地址 + 0x03 + 字节数 + 数据(N字节) + CRC
这里有个细节:寄存器数量最大是125个(0x7D)。为什么?因为响应帧的数据部分最多255字节,减去地址、功能码、字节数、CRC,剩下250字节,除以2就是125个寄存器。嗯,协议就是这么设计的。
功能码06(写单个寄存器):
这个最简单。请求和响应的格式完全一样:从机地址 + 0x06 + 寄存器地址(2字节) + 数据(2字节) + CRC。收到啥,就原样返回啥,表示确认。
功能码16(写多个寄存器):
请求格式:从机地址 + 0x10 + 起始地址(2字节) + 寄存器数量(2字节) + 字节数 + 数据(N字节) + CRC
响应格式:从机地址 + 0x10 + 起始地址(2字节) + 寄存器数量(2字节) + CRC
注意,16功能码的响应不返回数据内容,只返回地址和数量。这是协议规定的,别搞错了。
实战经验:我在做电力仪表项目时,发现有些从机对06功能码的处理有bug。你写一个寄存器,它返回的响应里数据却是错的。后来我学乖了,写完寄存器后,再用03功能码读回来验证一下。这叫“写后读校验”,能避免很多坑。
3.4 异常响应处理:优雅地拒绝
从机不是万能的。你让它读一个不存在的寄存器,或者写一个只读的寄存器,它得告诉你“不行”。Modbus协议定义了异常响应机制。
异常响应的格式很简单:把功能码的最高位置1,然后跟一个异常码。比如,你发0x03,从机回复0x83 + 异常码。
常见的异常码有这些:
| 异常码 | 名称 | 含义 |
|---|---|---|
| 0x01 | 非法功能码 | 从机不支持这个功能码 |
| 0x02 | 非法数据地址 | 寄存器地址超出范围 |
| 0x03 | 非法数据值 | 写入的数据值不合法 |
| 0x04 | 从机设备故障 | 从机内部出问题了 |
主机收到异常响应后,应该怎么处理?我个人建议:记录日志,然后跳过这个从机,继续轮询下一个。不要反复重试同一个异常,否则会把总线堵死。
void SendException(uint8_t funcCode, uint8_t exceptionCode) {
uint8_t frame[5];
frame[0] = MY_SLAVE_ADDR;
frame[1] = funcCode | 0x80; // 最高位置1
frame[2] = exceptionCode;
uint16_t crc = CalculateCRC(frame, 3);
frame[3] = crc & 0xFF;
frame[4] = (crc >> 8) & 0xFF;
UART_Send(frame, 5);
}
避坑指南:我曾经遇到一个奇葩从机,它收到非法请求后,既不回复正常响应,也不回复异常响应,直接沉默。主机等超时后重发,它还是沉默。后来发现是它的固件有bug,异常响应函数里有个死循环。嗯,从那以后,我写从机代码时,都会在异常处理里加个看门狗喂狗操作。
3.5 多从机轮询机制:别让任何一个从机被冷落
一个主机带多个从机,这是最常见的场景。轮询策略直接决定了系统的实时性。
最简单的策略是“顺序轮询”:从机1、从机2、从机3……一个一个来。但问题来了:如果某个从机响应慢,或者干脆掉线了,后面的从机都得等着。
我常用的策略是“带超时的顺序轮询”:
- 维护一个从机列表,每个从机有地址、轮询间隔、上次轮询时间。
- 主循环里,遍历列表,找到“该轮询了”的从机。
- 发送请求,启动超时定时器。
- 收到响应或超时后,更新该从机的状态,然后继续下一个。
这样,即使某个从机掉线了,超时后主机就会跳过它,不会影响其他从机。
代码实现思路:
typedef struct {
uint8_t addr;
uint16_t pollInterval; // 轮询间隔,单位ms
uint32_t lastPollTime; // 上次轮询时间戳
uint8_t timeoutCount; // 连续超时次数
} SlaveNode_t;
SlaveNode_t slaveTable[] = {
{0x01, 1000, 0, 0}, // 从机1,每秒轮询一次
{0x02, 2000, 0, 0}, // 从机2,每2秒轮询一次
{0x03, 500, 0, 0}, // 从机3,每500ms轮询一次
};
void Polling_Task(void) {
static uint8_t index = 0;
uint32_t now = GetTick();
// 找下一个需要轮询的从机
for (uint8_t i = 0; i < sizeof(slaveTable)/sizeof(SlaveNode_t); i++) {
uint8_t idx = (index + i) % sizeof(slaveTable)/sizeof(SlaveNode_t);
if (now - slaveTable[idx].lastPollTime >= slaveTable[idx].pollInterval) {
index = idx;
break;
}
}
// 发起轮询
if (hostState == HOST_IDLE) {
BuildRequestFrame(slaveTable[index].addr);
UART_Send();
slaveTable[index].lastPollTime = now;
hostState = HOST_TX_WAIT;
}
// 处理超时
if (hostState == HOST_RX_WAIT && TimeoutTimer_Expired()) {
slaveTable[index].timeoutCount++;
if (slaveTable[index].timeoutCount > 3) {
// 连续超时3次,标记为离线
MarkSlaveOffline(slaveTable[index].addr);
}
hostState = HOST_IDLE;
}
}
核心要点:轮询间隔要根据实际需求来定。电力监控里,遥测数据(电压、电流)通常需要快速刷新,可以设500ms。而遥信数据(开关状态)变化不频繁,设2秒就够了。别一股脑全设成100ms,那样总线负载太高,反而容易丢包。
好了,这一章的内容就这些。从状态机到功能码,从异常处理到轮询机制,都是我在项目里一点点踩坑踩出来的经验。你把这些吃透了,Modbus RTU这块基本就稳了。下一章,咱们聊聊Modbus TCP,那又是另一番天地了。