串口命令如何从字符串长成协议:帧头、长度、校验和与状态机
经典三问
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 著作权归作者所有。请勿转载和采集!