经典三问

Q: 串口协议帧是什么?
A: 它是给一串字节划定边界、并说明每个字节含义的约定,通常包含帧头、长度、内容和校验。

Q: 为什么不能只靠换行分隔命令?
A: 换行符本身也是一个字节,它丢了、错了或者被拆开时,接收端就再也分不清哪里是一条命令的头。

Q: 协议帧常见使用场景?
A: 命令下发给电机、状态回传给上位机、遥控器和单片机之间,以及任何不能用眼睛盯着看的通信链路。


上一回那条文本通道,靠的是换行符:程序把字节一个个拼进数组,凑到 \r 或 \n 就当成一整条命令。手在键盘上敲的时候,这套办法很酥服。

它有两个脆弱的地方,都出在同一个字节上。发送 LED ON 和 LED OFF 两行,如果中间那个换行字节在线路上丢了,程序攒出来的是这么一行:

LED ONLED OFF

它既不是开灯也不是关灯,只能回一句 Unknown command。另一种情况更糟:How are you? 里丢了一个字符,程序照样凑到了换行,从它自己的角度看这一行是完整的,只是内容对不上——接收端没有任何办法判断“少了一个字节”,它连自己收少了都不知道。

上一回结尾留下的那句话就是这件事:帧头、长度、校验和超时恢复,是二进制协议用来界定数据边界和处理错误的办法。下面把它做出来。

第一版:先定一份帧长什么样

要在字节流里划出“一帧”,最省事的办法是把几个固定的字节摆在开头和结尾当路标,中间用长度说明还有多远:

AA 55 | 01 | 01 | 02 | 00 | 01 | 内容... | SUM8 | 0D
 帧头   ID  类型  命令 长度  版本            校验  帧尾

每个字段各挡一类问题:

字段 示例 它挡住什么问题
帧头 AA 55 从乱码、残帧和连续的字节里重新找到起点
设备 ID 01 同一条链路上有多个设备时,知道这条命令给谁
帧类型 01 命令、81 应答 “控制小车”和“小车回状态”不混在一起
命令字 02 前进 业务层该做什么
长度 00 或 01 内容到哪里结束,下一帧从哪里开始
版本 01 以后加字段时,旧设备不会照着错误的格式去解释
内容 速度 1E 命令携带的参数,长度由上一个字段决定
SUM8 28 有一个字节被改掉时,先别去动电机
帧尾 0D 再确认一次帧的边界

命令字先定六个,够把一辆小车指挥起来:

命令字 含义 内容长度
01 停止 0
02 前进 0
03 后退 0
04 左转 0
05 右转 0
06 设置速度百分比 1

SUM8 的算法用纸和笔就能验:从设备 ID 开始,一直加到最后一个内容字节,只留低 8 位。 帧头和帧尾不参与。

前进命令没有内容,长度为 00:

AA 55 01 01 02 00 01 05 0D

05 是这么来的:01 + 01 + 02 + 00 + 01 = 05。

把速度设成 30,命令字变成 06,长度为 01,内容是十进制 30 对应的 1E:

AA 55 01 01 06 01 01 1E 28 0D

28 是 01 + 01 + 06 + 01 + 01 + 1E 的低 8 位,也就是十进制的 40。

六个命令的完整帧列在这里,方便打印出来对着发:

帧 十六进制
停止 AA 55 01 01 01 00 01 04 0D
前进 AA 55 01 01 02 00 01 05 0D
后退 AA 55 01 01 03 00 01 06 0D
左转 AA 55 01 01 04 00 01 07 0D
右转 AA 55 01 01 05 00 01 08 0D
速度 30 AA 55 01 01 06 01 01 1E 28 0D
速度 100 AA 55 01 01 06 01 01 64 6E 0D

常量按名字写进程序,后面所有代码都用名字,不再出现裸数字:

#define PROTOCOL_HEADER_1      0xAAU
#define PROTOCOL_HEADER_2      0x55U
#define PROTOCOL_TAIL          0x0DU
#define PROTOCOL_DEVICE_ID     0x01U
#define PROTOCOL_VERSION       0x01U
#define PROTOCOL_TYPE_COMMAND  0x01U
#define PROTOCOL_TYPE_REPLY    0x81U
#define PROTOCOL_FIXED_COUNT   5U      /* ID、类型、命令、长度、版本 */
#define PROTOCOL_PAYLOAD_MAX   16U
#define PROTOCOL_TIMEOUT_MS    100U

#define CAR_CMD_STOP           0x01U
#define CAR_CMD_FORWARD        0x02U
#define CAR_CMD_BACKWARD       0x03U
#define CAR_CMD_LEFT           0x04U
#define CAR_CMD_RIGHT          0x05U
#define CAR_CMD_SET_SPEED      0x06U

第二版:让状态机把一帧攒出来

有了约定,接下来是接收端的问题:串口一次只给一个字节,程序必须记住“自己刚才收到哪了”。

先看上一回的做法为什么不够。它只记了一个长度:凑到换行就算一行。现在一帧里有帧头、五个固定字段、不定长的内容、校验和帧尾,光靠一个长度记不住这些区别——同样是第 3 个字节,在“正在找帧头”的阶段毫无意义,在“正在收固定字段”的阶段就是命令字。

所以要把“收到哪了”写成一个明确的状态:

typedef enum
{
    PARSER_WAIT_HEADER_1 = 0,   /* 在等帧头的第一个字节 */
    PARSER_WAIT_HEADER_2,       /* 已经收到 AA,在等 55 */
    PARSER_FIXED_FIELDS,        /* 在收 ID、类型、命令、长度、版本 */
    PARSER_PAYLOAD,             /* 在按长度收内容 */
    PARSER_CHECKSUM,            /* 在收校验字节 */
    PARSER_TAIL                 /* 在收帧尾 */
} ParserState;

解析器手上的东西和状态放在一起:

static ParserState parser_state = PARSER_WAIT_HEADER_1;
static uint8_t fixed_fields[PROTOCOL_FIXED_COUNT];
static uint8_t fixed_field_index = 0U;
static uint8_t frame_payload[PROTOCOL_PAYLOAD_MAX];
static uint8_t payload_index = 0U;

static uint8_t last_command = 0U;
static uint32_t good_count = 0U;
static uint32_t error_count = 0U;

static int16_t speed_percent = 30;    /* 前进、后退、转向都用它当基准 */

static uint8_t CarCommand_Execute(uint8_t command,
                                  const uint8_t *payload,
                                  uint8_t length);
static void Protocol_Reset(void);
static void Protocol_FinishFrame(void);

fixed_field_index 记录“固定字段已经收到第几个”,payload_index 记录内容收到第几个。进度不能靠“某个变量是不是 0”来猜:长度字段本身完全可以是 00,内容里也完全可以出现 0x00,只有这两个下标记得住它们。

最后那三行是函数声明。解析器要用到的 Protocol_Reset()、Protocol_FinishFrame() 和业务层的 CarCommand_Execute() 都在下面才定义,先声明一行,Protocol_Service() 才能调用它们。这三个名字和签名是同一份约定的两半,改一个就要改另一个。

每来一个字节,程序只往前走一步:

void Protocol_Service(uint8_t data)
{
    switch (parser_state)
    {
        case PARSER_WAIT_HEADER_1:
            if (data == PROTOCOL_HEADER_1)
            {
                parser_state = PARSER_WAIT_HEADER_2;
            }
            break;

        case PARSER_WAIT_HEADER_2:
            if (data == PROTOCOL_HEADER_2)
            {
                fixed_field_index = 0U;
                parser_state = PARSER_FIXED_FIELDS;
            }
            else if (data != PROTOCOL_HEADER_1)
            {
                parser_state = PARSER_WAIT_HEADER_1;
            }
            /* 又是 AA 时停在原地:它就当下一个帧头的第一个字节 */
            break;

        case PARSER_FIXED_FIELDS:
            fixed_fields[fixed_field_index] = data;
            fixed_field_index++;

            if (fixed_field_index >= PROTOCOL_FIXED_COUNT)
            {
                payload_index = 0U;

                if (fixed_fields[3] == 0U)
                {
                    parser_state = PARSER_CHECKSUM;
                }
                else
                {
                    parser_state = PARSER_PAYLOAD;
                }
            }
            break;

        case PARSER_PAYLOAD:
            frame_payload[payload_index] = data;
            payload_index++;

            if (payload_index >= fixed_fields[3])
            {
                parser_state = PARSER_CHECKSUM;
            }
            break;

        case PARSER_CHECKSUM:
            parser_state = PARSER_TAIL;
            break;

        case PARSER_TAIL:
            if (data == PROTOCOL_TAIL)
            {
                Protocol_FinishFrame();
            }
            Protocol_Reset();
            break;

        default:
            Protocol_Reset();
            break;
    }
}

fixed_fields[3] 就是长度字段(00 是 ID,01 是类型,02 是命令,03 是长度,04 是版本)。长度是 00 时直接跳到校验那一档,不用进内容;长度是几,PARSER_PAYLOAD 就收几个字节再往下走。

这一版故意不比较校验:PARSER_CHECKSUM 只是把那个字节跳过去。它先要把“边界”这件事说清楚,校验留给下一版。

收完一帧和重新开始,各自一个短函数:

static void Protocol_Reset(void)
{
    parser_state = PARSER_WAIT_HEADER_1;
    fixed_field_index = 0U;
    payload_index = 0U;
}

static void Protocol_FinishFrame(void)
{
    if ((fixed_fields[0] != PROTOCOL_DEVICE_ID) ||
        (fixed_fields[1] != PROTOCOL_TYPE_COMMAND) ||
        (fixed_fields[4] != PROTOCOL_VERSION))
    {
        error_count++;          /* 帧是完整的,但不是说给我们的 */
        return;
    }

    good_count++;
    last_command = fixed_fields[2];
    CarCommand_Execute(fixed_fields[2], frame_payload, fixed_fields[3]);
}

Protocol_Reset() 在每帧结束时都调用一次——成功也好,失败也好。接收端最怕的不是报错,是停在一个错误的状态里等一个永远不会来的字节。

业务层只认命令字和内容,不认帧长什么样:

static uint8_t CarCommand_Execute(uint8_t command,
                                  const uint8_t *payload,
                                  uint8_t length)
{
    switch (command)
    {
        case CAR_CMD_STOP:
            Motor_Stop();
            LED_WriteOne(0, 0U);
            return 0U;

        case CAR_CMD_FORWARD:
            Motor_SetTargetPercent(speed_percent, speed_percent);
            LED_WriteOne(0, 1U);
            return 0U;

        case CAR_CMD_BACKWARD:
            Motor_SetTargetPercent(-speed_percent, -speed_percent);
            LED_WriteOne(0, 1U);
            return 0U;

        case CAR_CMD_LEFT:
            Motor_SetTargetPercent(10, speed_percent);
            LED_WriteOne(0, 1U);
            return 0U;

        case CAR_CMD_RIGHT:
            Motor_SetTargetPercent(speed_percent, 10);
            LED_WriteOne(0, 1U);
            return 0U;

        case CAR_CMD_SET_SPEED:
            if ((length != 1U) || (payload[0] > 100U))
            {
                return 1U;      /* 内容不对,拒绝 */
            }
            speed_percent = (int16_t)payload[0];
            return 0U;

        default:
            return 1U;          /* 不认识这条命令 */
    }
}

Motor_SetTargetPercent(left, right) 是前面调速度环、做循迹那几篇一直用的两轮接口,给的是左右轮的百分比,负数是反转(这个约定来自 PWM 那篇的 Motor_SetPercent());Motor_Stop() 把两路都停掉。LED_WriteOne(0, 1U) 是把第 0 颗外接灯(PC13)写成亮,(0, 0U) 是写灭,也是前面一直在用的写法。转向用“一轮慢、一轮快”的办法,和循迹篇找回线时的做法一致。

主循环把取到的字节直接喂给解析器:

static uint8_t received_byte;

while (1)
{
    Mode_Loop();

    while (Uart_ReadByte(&received_byte))
    {
        Protocol_Service(received_byte);
    }
}

Key_Tick() 挂在 1ms 中断里,Mode_Loop() 还在主循环里;Uart_ReadByte() 是上一回那个从环形缓冲区取字节的函数,取到一个返回 1,仓库空了返回 0。

上一回那条文本通道(把字节拼成一行再认识的 Uart_ServiceLine())在这一版不再接进来。一条链路上只跑一种格式:两种混着来,程序就要先猜“这个字节算文本还是算帧”,那等于把刚解决的问题又请回来。

第三版:校验和长度上限

第二版能让帧跑通,但它有一个危险的性质:只要边界对得上,内容错了它也照执行。 线上任意一个字节被干扰成别的值,命令字就从“前进”变成别的命令。

校验就是补这一刀。同一个和,收的时候顺手加上去,收到校验字节时对一次;另外长度字段来自线上,它可以被干扰成任何值,程序绝不能照着它无限制地往数组里写。

变量多一个,Protocol_Service() 有三处变化,整个函数重放一遍:

static uint8_t sum8 = 0U;   /* 从设备 ID 累加到最后一个内容字节 */
void Protocol_Service(uint8_t data)
{
    switch (parser_state)
    {
        case PARSER_WAIT_HEADER_1:
            if (data == PROTOCOL_HEADER_1)
            {
                parser_state = PARSER_WAIT_HEADER_2;
            }
            break;

        case PARSER_WAIT_HEADER_2:
            if (data == PROTOCOL_HEADER_2)
            {
                sum8 = 0U;                  /* 每帧从零开始累加 */
                fixed_field_index = 0U;
                parser_state = PARSER_FIXED_FIELDS;
            }
            else if (data != PROTOCOL_HEADER_1)
            {
                parser_state = PARSER_WAIT_HEADER_1;
            }
            break;

        case PARSER_FIXED_FIELDS:
            fixed_fields[fixed_field_index] = data;
            fixed_field_index++;
            sum8 = (uint8_t)(sum8 + data);

            if (fixed_field_index >= PROTOCOL_FIXED_COUNT)
            {
                if (fixed_fields[3] > PROTOCOL_PAYLOAD_MAX)
                {
                    error_count++;      /* 长度越界,这一帧不要 */
                    Protocol_Reset();
                    break;
                }

                payload_index = 0U;

                if (fixed_fields[3] == 0U)
                {
                    parser_state = PARSER_CHECKSUM;
                }
                else
                {
                    parser_state = PARSER_PAYLOAD;
                }
            }
            break;

        case PARSER_PAYLOAD:
            frame_payload[payload_index] = data;
            payload_index++;
            sum8 = (uint8_t)(sum8 + data);

            if (payload_index >= fixed_fields[3])
            {
                parser_state = PARSER_CHECKSUM;
            }
            break;

        case PARSER_CHECKSUM:
            if (data != sum8)
            {
                error_count++;
                Protocol_Reset();
                break;
            }
            parser_state = PARSER_TAIL;
            break;

        case PARSER_TAIL:
            if (data == PROTOCOL_TAIL)
            {
                Protocol_FinishFrame();
            }
            Protocol_Reset();
            break;

        default:
            Protocol_Reset();
            break;
    }
}

三处变化分别是:帧头配对成功后把 sum8 清零;每收到一个固定字段字节和一个内容字节就把它加上去;收到校验字节时真的比一次。

和是边收边加的,不用回头再扫一遍数组。帧头那两个字节不参与,所以从 PARSER_FIXED_FIELDS 的第一个字节开始累加,加到校验字节之前,得到的正好是 SUM8 该有的值。

长度上限那一句也在这里:frame_payload[] 只有 16 个字节,长度写成 20(十进制 32)时,程序在读完五个固定字段的那一刻就把这一帧丢掉,绝不会收着收着写穿数组。长度字段既是数据,也是必须被检查的数据——这是所有解析器都要过的一道关。

校验不过的帧,程序只做两件事:错误计数加一,回到等待帧头。它不会去碰电机,也不会把半帧留在手里。

第四版:坏字节、半帧和粘帧

现在解析器能扛住“帧内字节错了”,还有三种现场要处理。

第一种是帧头落空。 假设线上来了 AA AA 55 ...:第一个 AA 让状态进入“等 55”,紧接着的却是 AA,不是 55。如果这时直接退回“等第一个帧头”,第二个 AA 就被吃掉了,后面那个 55 再也凑不成帧头,这一帧就丢了。正确做法是把它留下。

第二种是半帧。 发送方发到一半停了:AA 55 01 01 06,然后没了。解析器现在停在“收固定字段”,等着后面的字节。它不能永远等下去,所以要一个超时。

第三种是粘帧。 两帧紧挨着发出来,中间没有任何间隔。在使用帧协议之前是个麻烦,但是现在有了帧头和帧尾,所以这一版什么都不用改。代码层面再解释一遍就是:读完第一帧的帧尾就 Protocol_Reset() 回到等帧头的状态,紧跟着的 AA 自然成了下一帧的起点。

变量先加三个:

static uint32_t resync_count = 0U;    /* 见了 AA 却没等到 55 */
static uint32_t timeout_count = 0U;
static uint32_t last_byte_ms = 0U;

Protocol_Service() 又有两处变化——开头记一次时间戳,帧头那一档多一条分支。整个函数重放一遍:

void Protocol_Service(uint8_t data)
{
    last_byte_ms = tick_ms;         /* 这一字节是什么时候被处理的 */

    switch (parser_state)
    {
        case PARSER_WAIT_HEADER_1:
            if (data == PROTOCOL_HEADER_1)
            {
                parser_state = PARSER_WAIT_HEADER_2;
            }
            break;

        case PARSER_WAIT_HEADER_2:
            if (data == PROTOCOL_HEADER_2)
            {
                sum8 = 0U;
                fixed_field_index = 0U;
                parser_state = PARSER_FIXED_FIELDS;
            }
            else if (data == PROTOCOL_HEADER_1)
            {
                resync_count++;     /* 还是 AA:它当下一个帧头的第一个字节 */
            }
            else
            {
                parser_state = PARSER_WAIT_HEADER_1;
            }
            break;

        case PARSER_FIXED_FIELDS:
            fixed_fields[fixed_field_index] = data;
            fixed_field_index++;
            sum8 = (uint8_t)(sum8 + data);

            if (fixed_field_index >= PROTOCOL_FIXED_COUNT)
            {
                if (fixed_fields[3] > PROTOCOL_PAYLOAD_MAX)
                {
                    error_count++;
                    Protocol_Reset();
                    break;
                }

                payload_index = 0U;

                if (fixed_fields[3] == 0U)
                {
                    parser_state = PARSER_CHECKSUM;
                }
                else
                {
                    parser_state = PARSER_PAYLOAD;
                }
            }
            break;

        case PARSER_PAYLOAD:
            frame_payload[payload_index] = data;
            payload_index++;
            sum8 = (uint8_t)(sum8 + data);

            if (payload_index >= fixed_fields[3])
            {
                parser_state = PARSER_CHECKSUM;
            }
            break;

        case PARSER_CHECKSUM:
            if (data != sum8)
            {
                error_count++;
                Protocol_Reset();
                break;
            }
            parser_state = PARSER_TAIL;
            break;

        case PARSER_TAIL:
            if (data == PROTOCOL_TAIL)
            {
                Protocol_FinishFrame();
            }
            Protocol_Reset();
            break;

        default:
            Protocol_Reset();
            break;
    }
}

帧头那一档里,data 是 AA 时既不前进也不后退,状态留在原地——看上去什么也没做,其实是把位置让给了它。

超时是一个每圈检查一次的动作,时间来自那条 1ms 时间轴(每毫秒给 tick_ms 加一的那条):

void Protocol_CheckTimeout(void)
{
    if (parser_state == PARSER_WAIT_HEADER_1)
    {
        return;                 /* 手上没有半帧,不用管 */
    }

    if ((tick_ms - last_byte_ms) >= PROTOCOL_TIMEOUT_MS)
    {
        timeout_count++;
        Protocol_Reset();
    }
}

四个计数器要看得见,才能判断解析器到底健康不健康:

static uint32_t panel_last_ms = 0U;

static const char *state_names[] = { "H1", "H2", "FIX", "PAY", "SUM", "TAIL" };

static void Protocol_ShowPanel(void)
{
    if ((tick_ms - panel_last_ms) < 200U)
    {
        return;
    }
    panel_last_ms = tick_ms;

    OLED_Clear();
    OLED_ShowString(0, 0, (char *)state_names[parser_state], OLED_6X8);

    OLED_ShowString(0, 8, "CMD", OLED_6X8);
    OLED_ShowNum(30, 8, last_command, 3, OLED_6X8);

    OLED_ShowString(0, 16, "GOOD", OLED_6X8);
    OLED_ShowNum(30, 16, good_count, 4, OLED_6X8);

    OLED_ShowString(0, 24, "ERR", OLED_6X8);
    OLED_ShowNum(30, 24, error_count, 4, OLED_6X8);

    OLED_ShowString(0, 32, "RESYNC", OLED_6X8);
    OLED_ShowNum(48, 32, resync_count, 4, OLED_6X8);

    OLED_ShowString(0, 40, "TIMEOUT", OLED_6X8);
    OLED_ShowNum(54, 40, timeout_count, 4, OLED_6X8);

    OLED_Update();
}

第一行是解析器此刻的状态:一直是 H1 说明没有帧进来,卡在 PAY 说明有一个半帧正在等。state_names[] 的顺序要和状态枚举一一对应。另外那几个数字分成两对:GOOD 和 ERR 说明收到的帧对不对,RESYNC 和 TIMEOUT 说明字节流的边界有多脏。

用到的那几个显示函数还是前面调试面板里的老面孔:OLED_Clear() 把显存清空,OLED_ShowString() 写一行字,OLED_ShowNum() 按位数写一个数字,最后 OLED_Update() 才把整屏推上屏幕。

主循环末尾加上两句:

    Protocol_CheckTimeout();
    Protocol_ShowPanel();

第五版:回一条应答

命令下发了,上位机怎么知道板子收下了?让板子回一帧。应答沿用同一个骨架,只把类型换成 81,内容一个字节表示结果:00 接受,01 拒绝。

static void Protocol_SendReply(uint8_t command, uint8_t result)
{
    uint8_t sum = 0U;

    Uart_SendByte(PROTOCOL_HEADER_1);
    Uart_SendByte(PROTOCOL_HEADER_2);

    sum = (uint8_t)(sum + PROTOCOL_DEVICE_ID);
    Uart_SendByte(PROTOCOL_DEVICE_ID);

    sum = (uint8_t)(sum + PROTOCOL_TYPE_REPLY);
    Uart_SendByte(PROTOCOL_TYPE_REPLY);

    sum = (uint8_t)(sum + command);
    Uart_SendByte(command);

    sum = (uint8_t)(sum + 1U);          /* 内容是 1 个字节 */
    Uart_SendByte(1U);

    sum = (uint8_t)(sum + PROTOCOL_VERSION);
    Uart_SendByte(PROTOCOL_VERSION);

    sum = (uint8_t)(sum + result);
    Uart_SendByte(result);

    Uart_SendByte(sum);
    Uart_SendByte(PROTOCOL_TAIL);
}

发一条速度 30 的帧,收到的应答是:

AA 55 01 81 06 01 01 00 8A 0D

8A 是 01 + 81 + 06 + 01 + 01 + 00 的低 8 位。

这里有一处必须说清楚:二进制帧不能用发字符串的函数送出去。 上一回的 Uart_SendString() 靠 '\0' 判断字符串结束,而这一帧里就有内容为 00 的字节——用它发送,那一帧会被从中间截断。二进制一律用 Uart_SendByte() 一个字节一个字节发。

Protocol_SendReply() 也在下面才定义,照上一版的办法,在那组声明里再加一行:

static void Protocol_SendReply(uint8_t command, uint8_t result);

Protocol_FinishFrame() 末尾把业务层的结果回上去:

    good_count++;
    last_command = fixed_fields[2];
    Protocol_SendReply(fixed_fields[2],
                       CarCommand_Execute(fixed_fields[2], frame_payload, fixed_fields[3]));

一帧应答只有 10 个字节,115200 下不到 1ms 就发完了。

六个现场实验

电机命令已经接到业务层,所以第一次做实验时把轮子架空,或者先只接 LED。解析出错时最不想看到的事情,是车在桌面上突然窜出去。

VOFA+ 切到十六进制发送,两边说的都是二进制;不要用普通文本窗口期待看见 OK。

1. 让车转起来,再停下来

依次发:

AA 55 01 01 02 00 01 05 0D
AA 55 01 01 06 01 01 64 6E 0D
AA 55 01 01 01 00 01 04 0D

第一帧前进(默认 30%),第二帧把速度改成 100,第三帧停止。屏幕上的 CMD 依次显示 02、06、01,GOOD 加三次;串口回三条应答:

AA 55 01 81 02 01 01 00 86 0D
AA 55 01 81 06 01 01 00 8A 0D
AA 55 01 81 01 01 01 00 85 0D

应答的 81 表示这是回给上位机的,中间那个字节是它对应的命令字,再后面那个 00 表示接受。灯在第一帧亮、第三帧灭,和命令对得上。

2. 一次粘两帧

把下面这一整串一次发出去:

AA 55 01 01 02 00 01 05 0D AA 55 01 01 01 00 01 04 0D

先前进,紧接着停止。GOOD 一次加二,ERR 不动。字节紧挨着来并不构成问题,读完第一帧的帧尾,解析器已经回到等帧头的状态。

3. 故意改坏校验和

把速度帧的 28 改成 29:

AA 55 01 01 06 01 01 1E 29 0D

速度不会变,GOOD 不动,ERR 加一。紧接着发一条正确的停止帧,确认解析器已经从坏帧里退出来了。

4. 把长度写成离谱的数

把前进帧的长度字段从 00 改成 20:

AA 55 01 01 02 20 01 05 0D

程序在读完固定字段的那一刻就丢掉这一帧,ERR 加一。长度上限是 16,20 是十进制 32——它绝不会照着这个数字去写内容数组。

5. 只发半帧,等一会儿

先发:

AA 55 01 01 06

停一秒钟,再发一条完整的停止帧。屏幕第一行的状态会先在 FIX 停住,100ms 之后 TIMEOUT 加一、状态回到 H1,随后那条停止帧正常执行。发送方眼里的“一帧”,在接收侧可能被拆成好几次才到,也可能永远缺最后几个字节。

6. 在帧头前塞垃圾字节

发:

12 34 AA AA 55 01 01 01 00 01 04 0D

前两个字节被忽略,第一个 AA 发现下一位仍是 AA 后把位置让出去,第二个 AA 55 成为有效帧头。RESYNC 加一,停止命令照常执行。

SUM8 能做什么,做不了什么

SUM8 能把“少一个字节、错一个字节、字段整体错位”这类现场挡住绝大部分——字段错位后各字节的位置全变了,和几乎不可能还对得上。

它不是安全锁。8 位只有 256 种取值,不同的错误完全可能碰巧算出同一个和;两个字节一个加一、一个减一,和甚至完全不变。线路长、干扰强、报文重要时,常规做法是升级到 CRC16:帧的骨架、状态机、超时和计数都不用推倒,只把校验字段从 1 个字节换成 2 个字节,算法换掉,其余照旧。

设备 ID、版本、长度上限、帧尾和超时也不是多余的东西。它们各管一层:帧头管起点,长度管终点,校验管内容,超时管“永远到不了的终点”。抽掉任何一层,都会留下一种错误没人管。

最后橘猫说

应答里的 00 只说明单片机接受并交给了业务层,它不保证电机真的按预期转动。要确认执行结果,得让板子主动回传实际状态——编码器速度、当前模式、故障标志,用同一个帧骨架,只换命令字和内容。回传帧和命令帧一样有帧头、长度和校验,解析它的上位机也只是把这一套反过来做一遍。

超时这一条有个要留意的细节:它计算的是两次处理字节之间的间隔,不是字节在线上到达的间隔。主循环被别的事情挡住(比如上一回那个 300ms 的空等),这段被挡住的毫秒也会算进超时里,可能出现一次误报。真要按“线上到达时间”判断,就得在中断里记时间戳——而中断里只搬字节这个约定,比一个精确的超时更值钱。

三点要记住:

  • 一条链路只跑一种格式。 文本命令适合手敲调试,二进制帧适合程序自动发送、参数频繁变化、需要验错的场合;没有特殊情况,两者最好不要混在同一条链路上。
  • 错误要有数字。 校验不过、帧头落空、半帧超时各自一个计数器,屏幕上看一眼就知道解析器健康不健康。没有数字的错误,只能靠猜。
  • 业务层不认识帧。 CarCommand_Execute() 拿到的是命令字和内容,它不知道帧头在哪、校验怎么算。将来换链路、换格式、换上位机,动的都是解析器,业务层的六个命令一句都不用改。

原文地址: https://www.cveoy.top/t/topic/qHR7 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录