SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
无线通信子篇 / 01

手写协议:从字节流认出一条消息

上一篇已经把手机、无线芯片和 STM32 接通。现在从一条电池消息开始,逐步约定它从哪里开始、表示什么、占多长,以及收到后怎样检查,再把这些规则写成 C 程序。

前置:串口字节、数组、移位沿问题逐步增加字段使用项目现有外层格式

第 1 节 · 要发送 4000,先发送哪几个字节?

假设手表电池为 4000 毫伏。整数 4000 的十六进制写法是 0x0FA0,超过一个字节能表示的范围,所以用两个字节保存。还要约定先发哪一个:项目默认低字节在前,即先 A0、后 0F,叫小端字节序。

接收方用“第一个字节+第二个字节左移八位”恢复整数。先确定这条约定,才能避免两边把同一组字节解释成不同的数。发送二进制 A0、0F,与发送四个字符“4”“0”“0”“0”是两种格式;本项目使用前者。

uint16_t millivolts = 4000;
uint8_t payload[3];
payload[0] = (uint8_t)millivolts;        /* A0:低八位 */
payload[1] = (uint8_t)(millivolts >> 8); /* 0F:高八位 */
payload[2] = 60;                         /* 电量百分比 */

payload 表示载荷,也就是消息实际携带的内容。这里约定前三项分别为电压低字节、电压高字节、电量百分比,得到 A0 0F 3C。60 的十六进制为 3C;接收方需要知道第三项的单位是百分比。

第 2 节 · 只有载荷,接收方怎样认出这是一条电池消息?

如果心率、步数和电池字节连续到来,接收方需要知道当前消息从哪里开始、是哪一种、还要收几个字节。于是依次增加三个字段:

  1. 帧头 AA。先找到一个约定的起点。
  2. 类型 06。告诉接收方按电池消息的字段解释。项目还有 02 心率、91 时间设置等其他类型。
  3. 载荷长度 03。说明接下来三个字节属于本条消息;不包含帧头、类型、长度自身和最后的校验字节。

把一条消息按照这种格式包起来,叫组帧。目前已有 AA 06 03 A0 0F 3C。如果其中一个字节传错,接收方还需要一种检查手段,因此最后再附加一个校验字节。

让所有字节相加,低八位等于零

先把已有六个字节相加:170+6+3+160+15+60=414。414 的低八位是 158,再加 98,就得到 512,低八位为零。98 的十六进制是 62,因此最终一帧为:

电池消息的五个字段AA帧头,06类型,03载荷长度,A0 0F 3C载荷,62校验。总长7字节,电压4000毫伏和电量60%。一条电池上报:把三个载荷字节包成完整消息AA帧头06电池类型03载荷长度A0 0F 3C4000 毫伏、60%62校验一共 7 字节;03 只计算中间三个载荷字节。全部字节相加,低八位为 00。
字节显示为十六进制。类型和载荷字段均按当前项目协议解释。

这里每个数字均为十六进制。发送端计算 校验 = (uint8_t)(0U - sum);接收端把整帧相加,检查转为 uint8_t 后是否为零。uint8_t 只保留低八位,使加法在 0~255 范围内循环。这个简单和校验能发现一部分错误,并不能发现所有多字节错误,也不证明发送者身份。

项目载荷最多 124 字节,连同四个外层字节,总帧长最多 128 字节。收到 length=255 时,应立即拒绝,不能先写进 124 字节数组再检查。帧头也可能出现在载荷里:一旦已知道长度,就按长度取完,不能见到 AA 就重开一帧。

第 3 节 · 已经写过一条,再把重复动作封装成打包函数

电池、心率等消息的外层动作相同:放帧头、类型和长度,复制载荷,再算校验。这时再提取为 pack(),可以省掉重复代码。它接收类型、载荷地址、载荷长度、输出数组和输出容量;成功返回实际帧长,空间或参数不符合要求时返回 0。

完整例程见本节末尾。先看已经解释过的核心步骤:

out[0] = 0xAA;
out[1] = type;
out[2] = length;
for (uint8_t i = 0; i < length; i++) out[3 + i] = payload[i];
uint8_t sum = 0;
for (size_t i = 0; i < (size_t)length + 3; i++) sum += out[i];
out[3 + length] = (uint8_t)(0U - sum);

调用前已经在上一节准备好了 payload;输出数组为最大帧留够空间:

uint8_t frame[128];
size_t frame_size = pack(0x06, payload, 3, frame, sizeof(frame));
/* 成功时 frame_size 为 7,frame 是 AA 06 03 A0 0F 3C 62。 */

基础链路中可调用 HAL_UART_Transmit(&huart1, frame, frame_size, 50) 发送,最后一个参数是最长等待毫秒数。数组在发送完成前必须有效。当前阻塞发送会等待串口送出;返回 HAL_OK 只说明串口这一步成功,手机是否收全要在接收端确认。

输入一帧,检查长度与校验

修改其中一个载荷字节,观察校验失败;把长度改成 04,观察当前字节数不足。它只检查外层格式,不会真的发送命令。

第 4 节 · 一次来一个字节,程序怎样记住收到哪了?

串口可能先送来 AA、06,过一会儿才来剩余字节。函数返回以后,下次仍要知道“类型已收到,接下来等长度”。用一个变量记录当前位置,这就是这里需要的解析状态。状态机表示:当前等什么,收到以后做什么,再转去等什么。

当前等什么这次怎样处理接着等什么
帧头跳过其他字节,遇到 AA 记下起点类型
类型保存这一个字节载荷长度
长度检查不超过 124,清除已收数量长度为零直接等校验,否则等载荷
载荷依次保存,直到收到规定数量校验
校验检查整帧低八位和是否为零回到帧头;正确时报告一条消息完成

教学例程的 receive_byte(byte) 每次接收一个字节。返回 0 表示还没有得到完整正确消息;返回 1 时,可以读取 frame_type、frame_length、frame_payload,再处理下一字节。它们是本例的接收结果,不是串口硬件寄存器。

完整例程中,正在等待载荷这一分支

case WAIT_PAYLOAD:
    frame_payload[index_in_payload++] = byte;
    running_sum += byte;
    if (index_in_payload == frame_length) state = WAIT_CHECKSUM;
    break;

长度在前一状态已经限制到数组范围,才能安全保存。下面对一段已到达的字节数组逐个调用;每认出一条,就先处理它,再继续喂后面的字节:

for (size_t i = 0; i < count; i++) {
    if (receive_byte(bytes[i])) {
        if (frame_type == 0x06 && frame_length == 3) {
            uint16_t mv = (uint16_t)frame_payload[0]
                        | ((uint16_t)frame_payload[1] << 8);
            uint8_t percent = frame_payload[2];
            /* 这里使用 mv 和 percent,再处理后续字节。 */
        }
    }
}

把一帧分成 2 字节和 5 字节送入,或把两帧接在一起送入,都应该得到相同结果。解析器关心连续字节顺序,不依赖一次蓝牙回调或串口回调的边界。

半帧之后再也没有数据怎么办?

不能永远停在“还差两个载荷字节”。接收端记录最后来字节的时刻,超过约定等待上限就调用 reset_receiver(),重新找帧头。缓冲区溢出、已知丢字节时也要丢弃损坏的半帧。当前仓库的普通半帧超时为 100 毫秒,具体选择要结合链路;教学纯 C 例程把重置接口留出来,由使用它的主循环处理时间。

展开完整打包与逐字节解析例程

这是本篇用到的完整 C 例程。先分段读懂,再整体对照;只在单一主循环中调用解析函数并即时使用完成结果。

/* 教学例程:外层格式与当前手表一致;在主循环中逐字节调用。 */
#include <stdint.h>
#include <stddef.h>

/* 调用 receive_byte() 返回 1 后,先使用这三个结果,再喂下一个字节。 */
uint8_t frame_type;
uint8_t frame_length;
uint8_t frame_payload[124];

/* 返回实际帧长;参数或空间不足时返回 0。长度是载荷字节数。 */
size_t pack(uint8_t type, const uint8_t *payload, uint8_t length,
            uint8_t *out, size_t capacity)
{
    if (out == NULL || length > 124 || capacity < (size_t)length + 4)
        return 0;
    if (length != 0 && payload == NULL) return 0;
    out[0] = 0xAA;
    out[1] = type;
    out[2] = length;
    for (uint8_t i = 0; i < length; i++) out[3 + i] = payload[i];
    uint8_t sum = 0;
    for (size_t i = 0; i < (size_t)length + 3; i++) sum += out[i];
    out[3 + length] = (uint8_t)(0U - sum);
    return (size_t)length + 4;
}

/* 状态只表示“下一个字节应该放在哪个字段”。 */
enum { WAIT_HEADER, WAIT_TYPE, WAIT_LENGTH, WAIT_PAYLOAD, WAIT_CHECKSUM };
static uint8_t state = WAIT_HEADER;
static uint8_t index_in_payload;
static uint8_t running_sum;

/* 丢字节、缓冲区溢出或半帧超时后调用,重新寻找帧头。 */
void reset_receiver(void)
{
    state = WAIT_HEADER;
    index_in_payload = 0;
    running_sum = 0;
}

/* 返回 1:刚得到一条校验正确的消息;返回 0:继续等后续字节。 */
int receive_byte(uint8_t byte)
{
    switch (state) {
    case WAIT_HEADER:
        if (byte == 0xAA) {
            running_sum = byte;
            state = WAIT_TYPE;
        }
        break;
    case WAIT_TYPE:
        frame_type = byte;
        running_sum += byte;
        state = WAIT_LENGTH;
        break;
    case WAIT_LENGTH:
        if (byte > sizeof(frame_payload)) {
            reset_receiver();
            break;
        }
        frame_length = byte;
        index_in_payload = 0;
        running_sum += byte;
        state = byte == 0 ? WAIT_CHECKSUM : WAIT_PAYLOAD;
        break;
    case WAIT_PAYLOAD:
        frame_payload[index_in_payload++] = byte;
        running_sum += byte;
        if (index_in_payload == frame_length) state = WAIT_CHECKSUM;
        break;
    case WAIT_CHECKSUM: {
        int valid = (uint8_t)(running_sum + byte) == 0;
        reset_receiver();
        return valid;
    }
    default:
        reset_receiver();
        break;
    }
    return 0;
}

下载本节完整 C 例程,或直接对照上面的分段讲解。

第 5 节 · 校验通过了,就能说明手表执行成功吗?

一条消息还要经过字段检查和具体操作。例如授时命令类型为 91,载荷必须正好四字节,接收端才能按约定恢复时间并设置时钟。外层帧正确、字段有效、操作完成,是依次发生的几步。

需要让发送端知道操作结果时,接收端再返回一条应用确认消息。若等不到,就在超时后决定重试。重试可能让同一条命令到两次,因此“把时间设为某值”与“给计数器加一”的行为不同:后者若重复执行会多加一次,需要给请求增加编号并识别重复。

项目的原型模型恢复需要发送很长的数据,所以又增加事务编号、分片编号、整体校验和“最终提交”确认。先理解这些需求,再阅读相应实现;入门发送一条电池消息不需要提前创建整套传输管理对象。具体大包流程放在 原型网络支线,当前所有消息格式查 项目协议规范。

到这里已经能把数值打成帧,也能从分批到来的字节中恢复消息。下一篇 项目中怎样实现,沿一条电池上报和一条时间命令,把这些动作对应到三端源码。

源码与格式对照