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……一个一个来。但问题来了:如果某个从机响应慢,或者干脆掉线了,后面的从机都得等着。

我常用的策略是“带超时的顺序轮询”:

  1. 维护一个从机列表,每个从机有地址、轮询间隔、上次轮询时间。
  2. 主循环里,遍历列表,找到“该轮询了”的从机。
  3. 发送请求,启动超时定时器。
  4. 收到响应或超时后,更新该从机的状态,然后继续下一个。

这样,即使某个从机掉线了,超时后主机就会跳过它,不会影响其他从机。

代码实现思路:

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,那又是另一番天地了。