ARTICLE · 1324442
在嵌入式软件中,通信协议解析为什么应该和业务处理分开?
蓝牙收到一帧数据,判断帧头,算一遍 CRC,按命令字 switch 一下,把载荷拷进配置结构体,写 Flash,回一个 OK。很多设备的参数设置功能就是这么写的,二三十行,和 App 联调一次就能跑通。
这段代码的问题不在于哪一行写错了,而在于它把好几件不同的事揉进了一个函数:零散的字节怎么拼成一帧,这一帧在路上有没有被改过,每个字段的长度和取值对不对,这条命令该交给谁,设备现在的状态允不允许改,改完以后怎么保存。前面几件事只取决于协议怎么定,后面几件事只取决于设备的业务规则。它们变化的原因不同,变化的时间不同,经常连负责的人都不同。挤在同一个函数里,任何一边一变,影响就会顺着这段代码传到另一边去。
这篇文章用一个例子讲到底:一台通过手机蓝牙设置参数的冷链温度记录仪。它的参数设置功能先后出过三次问题:换了一批安卓手机,保存设置总是失败;固件给配置结构体加了一个字段,老版本 App 一保存,设备的延时启动变成了十三天;App 里填了个 0,设备开始反复重启。三次出问题的时候,CRC 校验都是对的。
我会先给出最常见的写法,交代它在什么条件下成立,再看这三个问题是怎么冒出来的;然后讲怎么判断一段代码该归协议还是归业务,给出改进后的结构和核心代码,算一算多花了什么;最后讲怎么验证,哪些靠推导,哪些放到 PC 上跑,哪些必须上板。
有一点先说在前面:本文说的分开,是职责和数据的分开,不是非得多建几个任务、多包几层接口。小项目里,协议和业务可以跑在同一个任务里,甚至就写在相邻的两个文件里,只要中间那条线在。
一、先看场景:手机改参数,记录仪照着执行
设备是一台冷链温度记录仪,跟着冷藏车一起跑,一节锂电池要撑几个月。MCU 是一颗带蓝牙的 Cortex-M4 SoC,跑 RTOS。司机或者仓库人员用手机 App 连上它,设置参数、启动记录、导出数据。和本文有关的参数有五个:
CL- 前缀过滤扫描结果 |
除了每个参数自己的范围,还有一条跟设备状态有关的规则:记录进行中不允许改采样间隔,否则同一份记录里前后两段数据的间隔不一样,导出报表时对不齐。报警上下限则可以随时改,改完立即生效。
App 和设备之间用的是一个类似串口透传的自定义 GATT 服务:App 往一个特征值里写数据,设备通过另一个特征值的通知回应答。蓝牙只负责把字节送过来,帧格式是双方自己定的:

ver 是协议版本,第一版是 1。cmd 是命令字,本文只关心两条:0x10 写配置,0x11 读配置。CRC-16 覆盖从帧头到载荷最后一个字节。设备的应答也用同样的帧格式,命令字最高位置 1,载荷第一个字节是结果码,0 表示成功。
写配置的载荷在第一版里是 20 字节,全部小端:

加上 4 字节帧头和 2 字节 CRC,一帧写配置是 26 字节。这个数字先记住,它比 20 大,第三节会用到。
数据从手机进来以后,要经过这些地方:

二、原始实现:一个回调,从字节一路改到 Flash
第一版的代码是这样的。配置结构体按协议载荷的样子定义,收到写配置命令,直接把载荷拷进去:
#pragma pack(push, 1)typedef struct { uint16_t sample_interval_s; /* 采样间隔,秒 */ int16_t alarm_high; /* 报警上限,0.1℃ */ int16_t alarm_low; /* 报警下限,0.1℃ */ uint16_t start_delay_min; /* 延时启动,分钟 */ char name[12]; /* 蓝牙广播名 */} config_t; /* 20 字节,和协议载荷一模一样 */#pragma pack(pop)config_t g_cfg;/* App 每写一次,协议栈就调用一次 */void on_rx_write(const uint8_t *data, uint16_t len){ if (len < 6 || data[0] != 0xA5) { return; } if (crc16(data, len - 2) != (data[len - 2] | (data[len - 1] << 8))) { return; } switch (data[2]) { case CMD_SET_CONFIG: memcpy(&g_cfg, &data[4], data[3]); /* 载荷直接盖到配置上 */ flash_write_config(&g_cfg); /* 擦扇区,再写入 */ record_timer_set_period(g_cfg.sample_interval_s); ble_set_adv_name(g_cfg.name); send_ack(CMD_SET_CONFIG, 0); break; case CMD_GET_CONFIG: send_rsp(CMD_GET_CONFIG, &g_cfg, sizeof(g_cfg)); break; /* 启动记录、导出数据等命令,略 */ }}说它有问题之前,先说句公道话:下面几个条件同时成立的时候,这段代码是对的。
App 每写一次,正好是一帧完整的数据。一帧不会被拆成两次写,两帧也不会挤在一次写里。 配置结构体在内存里的排布,和协议载荷逐字节相同:字段顺序一样,中间没有填充,MCU 是小端。 发数据的 App 只有一个版本,而且永远发合理的值。 在协议栈的回调里擦写 Flash,不会耽误协议栈自己的事。 设备没有“某些时候不许改”的状态。
第一版开发的时候,这五条确实都成立。联调用的是 iPhone,连接时协商出了一百多字节的 MTU,26 字节一次就写完了;App 和固件是同一个小组写的,配置页面上的输入框都做了限制;记录仪也还没有“记录中不许改间隔”这条规则,这条是后来加的。
麻烦在于,这五条里没有一条是这段代码自己能保证的。第一条取决于手机和 App 怎么写,第二条取决于以后谁去改 config_t,第三条取决于 App 的发版节奏和用户升不升级,第四条取决于协议栈和 Flash 的特性,第五条取决于产品下一版加什么功能。这些条件一个个失效,问题也就一个个冒出来了。
三、问题是怎么出现的:三次故障,CRC 都是对的
3.1 换了一批安卓手机,保存设置总是失败
低功耗蓝牙里,一次写操作能带多少数据,取决于连接双方协商出来的 ATT_MTU。不协商的话,ATT_MTU 是默认的 23 字节,去掉 3 字节的操作码和句柄,一次写最多带 20 字节。iOS 建立连接时会主动协商一个大得多的值;安卓要 App 自己调用 requestMtu() 去申请,不申请就一直停在 23。
App 的安卓版没有申请 MTU,26 字节的写配置帧就被拆成了两次写:

这个问题本身不难修。当时的修法是在回调里加一块静态缓冲区,收到的字节先攒着,攒够长度字段说的字节数再处理:
static uint8_t s_buf[64];static uint16_t s_cnt;void on_rx_write(const uint8_t *data, uint16_t len){ if (s_cnt + len > sizeof(s_buf)) { s_cnt = 0; /* 放不下,全部丢掉重来 */ return; } memcpy(&s_buf[s_cnt], data, len); s_cnt += len; if (s_cnt < 4 || s_cnt < 6u + s_buf[3]) { return; /* 还没攒够,等下一次写 */ } handle_frame(s_buf, 6u + s_buf[3]); /* 原来那一整段:查帧头、CRC、switch、memcpy */ s_cnt = 0;}安卓上能保存了。过了一阵,现场又报上来一个新问题:重新连上设备以后,第一次操作经常失败,再点一次又好了。

这块缓冲区存的是“半帧”,它的寿命本该跟着连接走:连接断开,半帧就该作废。可它被塞进了一个处理业务的回调里,写这段代码的人想的是“收到配置怎么处理”,没人去想“断线的时候这块内存归谁管”。顺带一提,这个修法还有别的漏洞:一次写里如果带了两帧,处理完第一帧就 s_cnt = 0,第二帧被直接清掉了。
3.2 CRC 全对,延时启动变成了十三天
固件 1.2 版加了一个功能:报警延时。温度越限要持续若干分钟(0 ~ 120)才报警,免得开车门装货时的短时波动触发误报。负责的同事在 config_t 里加了一个字段,和报警有关的参数放在一起,就放在报警下限后面:
typedef struct { uint16_t sample_interval_s; int16_t alarm_high; int16_t alarm_low; uint16_t alarm_delay_min; /* 1.2 版新增:报警延时,分钟 */ uint16_t start_delay_min; char name[12];} config_t; /* 变成 22 字节 */新版 App 跟着改了,写配置的载荷变成 22 字节,联调通过,发布。但用户手机上的 App 不会和固件同时升级。一位没升级 App 的仓管,给一台刷了新固件的记录仪设置参数:采样间隔 300 秒,上限 8.0℃,下限 2.0℃,延时启动 0,设备名 CL-A1024。旧版 App 发出的还是 20 字节的载荷,固件照着 len 拷了 20 字节:

设备回了 OK,App 显示设置成功。仓管按下启动,记录仪在车厢里待了一路,一条温度都没记,因为它要等十三天以后才开始记录。设备名变成 -A1024,App 按 CL- 前缀过滤扫描结果,这台设备在列表里也找不到了。
整个过程里,蓝牙传输没有出错,CRC 是对的,App 发的每个字节都符合它那个版本的协议。出错的是固件:它把一段外部字节直接当成了内部结构体的内存映像。这样一来,config_t 的每一次改动,不管改的人有没有意识到,都是一次协议改动。帧里明明有 ver 字段,可原始代码从来不看它,新旧两种载荷在固件眼里没有区别。
反过来的情况更危险。如果是 App 先升级,多发了两个字节,memcpy 就会写过 g_cfg 的末尾,踩到链接时紧挨着它的那个全局变量,那就是一个不容易查到根上的问题了。有人会说,加一句 len == sizeof(config_t) 的检查不就行了?加了确实不会改坏数据,旧版 App 会统一收到失败。但长度对了也不代表能直接拷:字段顺序、字节序、单位,都是协议的约定,不该由内部结构体怎么写来决定。
3.3 App 里填了个 0,设备开始反复重启
后来 App 的设置页改版,采样间隔的单位从秒改成了分钟。用户想设 30 秒,输入 0.5 分钟,App 先把输入转成了整数,再乘 60,发出去的采样间隔是 0。固件这边的执行顺序是这样的:

软件定时器的周期不能为 0,FreeRTOS 的源码里就用 configASSERT 检查了这一点,别的 RTOS 大多也有类似限制,只是表现不同,有的断言,有的返回错误码。这台设备的断言处理是关中断死循环,等看门狗复位。复位以后从 Flash 读出来的还是 0,于是陷入循环。蓝牙来不及起来,App 也就没机会把参数改回去,设备只能寄回来重新烧录。
这里有两个顺序上的错误:值没有检查就生效了;而且是先存 Flash 再生效,一次错误的设置,就变成了一个重启也甩不掉的错误。还有一个容易被忽视的地方:开机从 Flash 读配置时也没有检查。对配置模块来说,Flash 里读出来的东西和蓝牙送来的字节一样,都是从外面来的。
报警上下限也有同样的漏洞。上限被设得比下限还低,每一次采样,温度不是高于上限就是低于下限,记录仪会一直报警。至于“记录中不许改采样间隔”这条后加的规则,原始结构里根本没有合适的地方放:要放,只能在蓝牙回调里去问记录模块现在是不是在记录,协议代码从此就得认识记录模块。
3.4 小结:三个现象,三处错位
三个问题有一个共同点:外部字节在变成内部状态的修改之前,没有经过一道“它到底是不是一个可以执行的请求”的确认。CRC 只回答了一个问题:这些字节在传输途中有没有被改过。它回答不了这个请求该不该执行。
四、动手之前:怎么判断一行代码该归协议还是归业务
4.1 一个判断方法:这行代码要写对,得知道什么
拿原始的 on_rx_write 来,一行一行问:这行代码要写对,需要知道什么?
len 截出一帧 | ||
ver,看 cmd 认不认识 | ||
“协议”那几行,需要的知识都写在协议文档里;“业务”那几行,需要的知识来自产品需求和设备的运行状态。前者在 App 发版、换手机、加协议版本的时候变,后者在产品加功能、改规则的时候变。一行代码只依赖其中一类知识,它就该待在那一类的地盘里。一行代码同时依赖两类知识,往往说明两件事被写进了同一步,需要拆开。
memcpy(&g_cfg, &data[4], data[3]) 就是最典型的一行。它既依赖协议载荷的布局,又依赖内部结构体的布局,还悄悄要求两者永远一致。3.2 节那十三天,就是这个要求被打破的结果。
这个方法我自己用得很多,不只用在蓝牙上。串口、CAN、Modbus 寄存器表、MQTT 下发的 JSON,拿过来一行行问一遍,线基本就划出来了。
4.2 变化点:四样东西,各变各的
把上面的判断再往前推一步,看看这条接收路径上,到底有哪些东西会变,又是谁让它们变的:

中间那条线上流过的东西,我叫它“有效请求”:帧是完整的,内容没被改过,版本和命令是认识的,每个字段的长度对,字节已经换算成了内部用的类型和单位。上面两样变了,只改线上面的代码;下面两样变了,只改线下面的代码;线上流过的请求本身,尽量不动。原始实现没有这条线,四样东西的变化全都落在同一个函数里。
4.3 状态所有者:配置只有一个主人
配置的主人是设置服务,也就是 settings 模块。g_cfg 只有它能写;别人要改,提交一个请求;要读,拿一份拷贝。协议这一侧解析得再好,也只能走到“提交请求”为止。
取值规则、状态规则、保存顺序,都必须由主人来负责。原因有两个。一是只有主人掌握全部规则,比如“记录中不许改间隔”,就需要知道记录状态,这件事不该让协议代码去打听。二是修改配置的入口不会只有一个:产线要用串口工装写设备名,以后可能还有云端下发、按键菜单。规则放在主人那里,每个入口自然都受约束;放在蓝牙的处理函数里,第二个入口一来,规则就要抄第二份,两份迟早会不一样。
4.4 执行上下文:每一段代码在谁的任务里跑
原始实现里,从拼帧到写 Flash 全在协议栈的回调里执行。很多蓝牙协议栈的文档都会要求回调尽快返回,不要在里面阻塞,因为回调占着的是协议栈自己的执行时间。擦一个 Flash 扇区,少则几十毫秒,慢的器件要几百毫秒,有的单 Bank 片内 Flash 擦写期间连取指都会停下来。这些时间都算在协议栈头上,轻则应答变慢、App 超时重发,重则影响连接本身。
改进后的分工是:
蓝牙回调,在协议栈的任务里:只把字节拷走,不解析,不改状态,不碰 Flash。 拼帧、校验、分发、解码,在通信任务 comm_task里。规则检查、生效、保存,由设置服务的接口完成。它在调用者的任务里同步执行,内部自己串行化;写 Flash 的那几十毫秒,阻塞的是 comm_task,不是协议栈。
4.5 资源寿命:每块内存活多久
回调参数 data指向协议栈的缓冲区,只在回调期间有效,回调返回前必须拷走。拼帧缓冲区里存的是半帧,它跟着连接走:断开时清空;一帧收到一半、隔了 500 毫秒还没有下文,也清空。 有效请求只有几十个字节,按值传递,不存在谁来释放的问题。 应答缓冲区归协议这一侧。设置服务只返回一个结果码,不碰应答帧。
四件事都想清楚以后,改法其实已经出来了。
五、最小改进:先形成有效请求,再交给设置服务
5.1 职责怎么变

和原始实现相比,代码量多了,但每一段只依赖一类知识。左边四段只看协议文档,右边一段只看产品规则和设备状态,中间用 settings_req_t 接起来。设置服务看不到帧、看不到版本号,也不知道请求是从蓝牙来的还是从串口来的。
5.2 蓝牙回调只搬字节
typedef struct { uint8_t type; /* MSG_RX 或 MSG_LINK_DOWN */ uint8_t len; uint8_t data[40];} comm_msg_t;static queue_t s_comm_q; /* 深度 8,第六节算这个数 */static uint32_t s_rx_drop; /* 队列满丢掉的次数,排查用 *//* 在协议栈的任务里调用:只拷贝,不解析 */void on_rx_write(const uint8_t *data, uint16_t len){ comm_msg_t m = { .type = MSG_RX }; while (len > 0) { m.len = (len > sizeof(m.data)) ? sizeof(m.data) : (uint8_t)len; memcpy(m.data, data, m.len); if (queue_send(&s_comm_q, &m, NO_WAIT) != 0) { s_rx_drop++; /* 不等待;丢了记一笔,后面靠分帧重新同步 */ } data += m.len; len -= m.len; }}void on_disconnected(void){ comm_msg_t m = { .type = MSG_LINK_DOWN }; queue_send(&s_comm_q, &m, NO_WAIT);}void comm_task(void *arg){ static framer_t fr; comm_msg_t m; frame_t f; (void)arg; framer_reset(&fr); for (;;) { queue_recv(&s_comm_q, &m, WAIT_FOREVER); if (m.type == MSG_LINK_DOWN) { framer_reset(&fr); /* 半帧跟着连接一起作废 */ continue; } for (uint8_t i = 0; i < m.len; i++) { if (framer_feed(&fr, m.data[i], os_now_ms(), &f)) { proto_dispatch(&f); } } }}回调里只做了一件事:把字节拷进队列,立刻返回。它不等队列,不碰 Flash,也不认识任何命令。
断开连接用的也是同一个队列,而不是单独的事件标志,这是为了保住顺序。断开消息一定排在上一个连接最后一段数据的后面、下一个连接第一段数据的前面,comm_task 照着队列的顺序处理,就不会把两个连接的字节拼到一起。用事件标志的话,“断开”和“收到数据”是两个独立的位,comm_task 醒来时分不清谁先谁后。万一断开消息因为队列满没送进去,分帧层还有 500 毫秒的超时兜底,手机断开再重连,一般也要超过这个时间。
5.3 分帧和 CRC:拼出一帧完整、没被改过的字节
分帧是一个很小的状态机:

#define FRAME_HEAD 0xA5#define FRAME_MAX_PAYLOAD 32#define FRAME_GAP_MS 500typedef struct { uint8_t ver; uint8_t cmd; uint8_t len; uint8_t payload[FRAME_MAX_PAYLOAD];} frame_t;typedef struct { uint8_t buf[4 + FRAME_MAX_PAYLOAD + 2]; uint16_t cnt; uint32_t last_ms;} framer_t;void framer_reset(framer_t *fr){ fr->cnt = 0;}/* 每次喂一个字节;返回 1 表示 out 里是一帧完整、CRC 正确的数据 */int framer_feed(framer_t *fr, uint8_t b, uint32_t now_ms, frame_t *out){ if (fr->cnt > 0 && now_ms - fr->last_ms > FRAME_GAP_MS) { fr->cnt = 0; /* 半帧放太久了,扔掉 */ } fr->last_ms = now_ms; if (fr->cnt == 0 && b != FRAME_HEAD) { return 0; /* 不是帧头,接着找 */ } fr->buf[fr->cnt++] = b; if (fr->cnt == 4 && fr->buf[3] > FRAME_MAX_PAYLOAD) { fr->cnt = 0; /* 长度字段不可信,重新找帧头 */ return 0; } if (fr->cnt < 4 || fr->cnt < 4u + fr->buf[3] + 2u) { return 0; /* 还没收齐 */ } uint16_t n = 4u + fr->buf[3]; uint16_t crc = (uint16_t)(fr->buf[n] | (fr->buf[n + 1] << 8)); fr->cnt = 0; if (crc16(fr->buf, n) != crc) { return 0; } out->ver = fr->buf[1]; out->cmd = fr->buf[2]; out->len = fr->buf[3]; memcpy(out->payload, &fr->buf[4], out->len); return 1;}这一层不认识任何命令,它只知道帧长什么样。拼帧缓冲区是它自己的东西,断线和超时都由它处理。一次写里带的是半帧、一帧还是三帧,对它来说都一样,因为它是一个字节一个字节地吃。
长度字段要特别小心。它在 CRC 验证之前就要被用来计算“还要收多少字节”,这时候它是不可信的,所以先跟上限比,再拿来用。原始代码里 memcpy 用的就是一个没人检查过的长度。
还有一个简化要交代清楚:CRC 不对的时候,这里把整段候选帧都扔掉,从下一个字节开始重新找帧头。如果真正的帧头恰好藏在被扔掉的那一段里,这一帧就丢了,要靠 App 超时重发。链路比较干净、帧又不密的时候,这个代价可以接受;误码多的链路,可以改成从候选帧的第二个字节开始重新扫描,多十几行代码。
5.4 命令分发:先认命令和版本,再找处理函数
enum { PERR_OK = 0, PERR_UNKNOWN_CMD, /* 不认识的命令 */ PERR_BAD_VERSION, /* 命令认识,这个版本不支持 */ PERR_BAD_LENGTH, /* 载荷长度不对 */ PERR_BAD_FIELD, /* 字段格式不对,比如设备名没有结尾 */ PERR_OUT_OF_RANGE, /* 取值不被接受 */ PERR_BUSY, /* 设备当前状态不允许 */ PERR_INTERNAL, /* 设备内部错误,比如写 Flash 失败 */};typedef uint8_t (*cmd_handler_t)(const frame_t *f, uint8_t *rsp, uint8_t *rsp_len);typedef struct { uint8_t cmd; uint8_t ver; cmd_handler_t handler;} cmd_entry_t;static const cmd_entry_t s_cmd_table[] = { { CMD_SET_CONFIG, 1, h_set_config_v1 }, { CMD_SET_CONFIG, 2, h_set_config_v2 }, { CMD_GET_CONFIG, 1, h_get_config_v1 }, { CMD_GET_CONFIG, 2, h_get_config_v2 }, /* 启动记录、导出数据等,略 */};void proto_dispatch(const frame_t *f){ const cmd_entry_t *e = NULL; int cmd_known = 0; uint8_t rsp[FRAME_MAX_PAYLOAD - 1]; /* 应答载荷第一个字节留给结果码 */ uint8_t rsp_len = 0; uint8_t code; for (size_t i = 0; i < sizeof(s_cmd_table) / sizeof(s_cmd_table[0]); i++) { if (s_cmd_table[i].cmd != f->cmd) { continue; } cmd_known = 1; if (s_cmd_table[i].ver == f->ver) { e = &s_cmd_table[i]; break; } } if (e != NULL) { code = e->handler(f, rsp, &rsp_len); } else { code = cmd_known ? PERR_BAD_VERSION : PERR_UNKNOWN_CMD; } proto_send_rsp(f->ver, f->cmd | 0x80, code, rsp, rsp_len);}分发表按“命令加版本”找处理函数,两样里有一样对不上,就明确地回一个错误码,而不是像原始代码那样默默丢掉,让 App 干等超时。“命令认识、版本不支持”和“命令根本不认识”分成两个码,App 收到前者,就知道该提示用户升级固件,或者退回旧版本的格式再发一次。
应答用请求里的版本号来编码,旧版 App 收到的永远是它看得懂的格式。这里有一个前提:帧头的格式在所有版本之间保持不变,变的只是载荷。只有这样,哪怕版本不认识,“版本不支持”这个应答也发得出去,对方也读得懂。设计第一版协议的时候,就要把 ver 放在一个以后永远不会挪动的位置上。
5.5 字段校验和解码:把字节翻译成内部数据模型
先看协议这一侧要交出去的东西。它定义在 settings.h 里,归设置服务所有,跟协议无关:
/* settings.h */#define SET_F_INTERVAL (1u << 0)#define SET_F_ALARM_HIGH (1u << 1)#define SET_F_ALARM_LOW (1u << 2)#define SET_F_ALARM_DELAY (1u << 3)#define SET_F_START_DELAY (1u << 4)#define SET_F_NAME (1u << 5)#define SETTINGS_NAME_MAX 11typedef struct { uint32_t mask; /* 这次要改哪些字段 */ uint16_t sample_interval_s; int16_t alarm_high; /* 0.1℃ */ int16_t alarm_low; /* 0.1℃ */ uint16_t alarm_delay_min; uint16_t start_delay_min; char name[SETTINGS_NAME_MAX + 1]; /* 以 0 结尾 */} settings_req_t;每个协议版本一个解码函数,放在协议目录里:
static uint16_t rd_u16(const uint8_t *p){ return (uint16_t)(p[0] | (p[1] << 8));}/* 设备名字段 12 字节,不足补 0;12 个字节里必须至少有一个 0 */static int rd_name(const uint8_t *p, char *out){ const uint8_t *end = memchr(p, 0, 12); if (end == NULL) { return -1; } memcpy(out, p, (size_t)(end - p)); out[end - p] = '\0'; return 0;}/* 版本 1:载荷 20 字节,没有报警延时 */static uint8_t h_set_config_v1(const frame_t *f, uint8_t *rsp, uint8_t *rsp_len){ settings_req_t req = { 0 }; const uint8_t *p = f->payload; (void)rsp; *rsp_len = 0; if (f->len != 20) { return PERR_BAD_LENGTH; } if (rd_name(&p[8], req.name) != 0) { return PERR_BAD_FIELD; } req.sample_interval_s = rd_u16(&p[0]); req.alarm_high = (int16_t)rd_u16(&p[2]); req.alarm_low = (int16_t)rd_u16(&p[4]); req.start_delay_min = rd_u16(&p[6]); req.mask = SET_F_INTERVAL | SET_F_ALARM_HIGH | SET_F_ALARM_LOW | SET_F_START_DELAY | SET_F_NAME; /* 没有报警延时这一位 */ return to_proto_err(settings_apply(&req));}/* 版本 2:载荷 22 字节,报警延时在下限后面 */static uint8_t h_set_config_v2(const frame_t *f, uint8_t *rsp, uint8_t *rsp_len){ settings_req_t req = { 0 }; const uint8_t *p = f->payload; (void)rsp; *rsp_len = 0; if (f->len != 22) { return PERR_BAD_LENGTH; } if (rd_name(&p[10], req.name) != 0) { return PERR_BAD_FIELD; } req.sample_interval_s = rd_u16(&p[0]); req.alarm_high = (int16_t)rd_u16(&p[2]); req.alarm_low = (int16_t)rd_u16(&p[4]); req.alarm_delay_min = rd_u16(&p[6]); req.start_delay_min = rd_u16(&p[8]); req.mask = SET_F_INTERVAL | SET_F_ALARM_HIGH | SET_F_ALARM_LOW | SET_F_ALARM_DELAY | SET_F_START_DELAY | SET_F_NAME; return to_proto_err(settings_apply(&req));}逐个字段按小端拼,看着比一句 memcpy 啰嗦,换来的是两边的布局各管各的。config_t 调整字段顺序、加字段、换类型,影响不到这里;协议加一个版本,也影响不到 config_t。它现在是一个纯粹的内部结构,#pragma pack 可以删掉了,编译器爱怎么对齐就怎么对齐。版本号也就停在了这一层:分发表用它选解码函数,解码出来的 settings_req_t 里没有版本号,设置服务从头到尾不知道协议有几个版本。
版本 1 的请求里没有报警延时,mask 里就没有这一位,设置服务会保留设备上原来的值。这里不要图省事填一个 0:0 是一个合法的报警延时,填进去就等于替用户做了一次修改。mask 表达的是“这次改了什么”,它和“值是多少”是两回事,内部数据模型里要能把两者区分开。
这一层查的是“长得对不对”:长度对不对,设备名有没有结尾。“值能不能接受”,它不管。采样间隔 5 秒,在协议上是一个完全合法的 uint16;能不能接受,要看产品规则。哪天换了功耗更低的传感器,下限放宽到 5 秒,改的是设置服务,协议解码一行都不用动。
单位和枚举的翻译也放在这一层。协议用 0.1℃,内部恰好也用 0.1℃,所以直接赋值;哪天内部为了一颗高精度传感器改成 0.01℃,乘 10 的换算就加在这两个函数里,旧版 App 照样能用。如果载荷里有枚举字段,比如记录模式,协议里的 1 不一定等于内部枚举里的 1,翻译表也写在这里,遇到协议里没定义的取值,直接回 PERR_BAD_FIELD。
5.6 设置服务:查规则,整体生效,先存后改
typedef enum { SET_OK = 0, SET_ERR_RANGE, /* 某个字段超出范围 */ SET_ERR_CONFLICT, /* 字段之间矛盾,比如下限不低于上限 */ SET_ERR_BUSY, /* 当前状态不允许,比如记录中改采样间隔 */ SET_ERR_STORAGE, /* 写 Flash 失败 */} set_result_t;static config_t g_cfg; /* 只有本文件能写 */static mutex_t s_apply_lock; /* 同一时间只处理一个修改请求 */static set_result_t check_rules(const config_t *c, uint32_t mask){ if (c->sample_interval_s < 10 || c->sample_interval_s > 3600) return SET_ERR_RANGE; if (c->alarm_high > 850 || c->alarm_low < -400) return SET_ERR_RANGE; if (c->alarm_delay_min > 120 || c->start_delay_min > 1440) return SET_ERR_RANGE; if (!name_is_printable(c->name)) return SET_ERR_RANGE; if (c->alarm_low >= c->alarm_high) return SET_ERR_CONFLICT; if ((mask & SET_F_INTERVAL) && record_is_running() && c->sample_interval_s != g_cfg.sample_interval_s) { return SET_ERR_BUSY; } return SET_OK;}static void merge(config_t *c, const settings_req_t *r){ if (r->mask & SET_F_INTERVAL) c->sample_interval_s = r->sample_interval_s; if (r->mask & SET_F_ALARM_HIGH) c->alarm_high = r->alarm_high; if (r->mask & SET_F_ALARM_LOW) c->alarm_low = r->alarm_low; if (r->mask & SET_F_ALARM_DELAY) c->alarm_delay_min = r->alarm_delay_min; if (r->mask & SET_F_START_DELAY) c->start_delay_min = r->start_delay_min; if (r->mask & SET_F_NAME) memcpy(c->name, r->name, sizeof(c->name));}set_result_t settings_apply(const settings_req_t *req){ config_t next; set_result_t r; mutex_lock(&s_apply_lock); next = g_cfg; /* 只有持锁的人会写 g_cfg,这里直接读是安全的 */ merge(&next, req); /* 只覆盖 mask 里有的字段 */ r = check_rules(&next, req->mask); if (r == SET_OK && config_store(&next) != 0) { r = SET_ERR_STORAGE; /* Flash 没写成,RAM 里的配置也不动 */ } if (r == SET_OK) { os_critical_enter(); g_cfg = next; /* 整体替换,读的一方看不到改了一半的配置 */ os_critical_exit(); settings_notify(req->mask); /* 记录、报警、广播名,各自来取新值 */ } mutex_unlock(&s_apply_lock); return r;}void settings_get(config_t *out){ os_critical_enter(); *out = g_cfg; os_critical_exit();}void settings_init(void){ config_t c; if (config_load(&c) != 0 || check_rules(&c, 0) != SET_OK) { config_default(&c); /* Flash 里的值不合法,回到默认配置 */ fault_record(FAULT_CFG_INVALID); } g_cfg = c;}几个地方值得多说两句。
规则查的是合并以后的完整配置,不是请求本身。一个只改上限的请求,也会拿新上限和设备上现有的下限去比。顺着这一点,还能看出协议设计反过来受业务规则约束的地方:上下限最好放在同一个请求里一起改。如果协议把它们拆成两条命令,App 就得知道先发哪条,往低温段改要先改下限,往高温段改要先改上限,顺序错了,中间那一步就违反“下限低于上限”。互相约束的字段,应该一起检查,一起生效。
顺序是“查规则,写 Flash,替换 RAM,通知”。写 Flash 失败的时候,RAM 里的配置一个字节都没动,设备的行为和 Flash 里存的保持一致,App 收到的是内部错误,而不是一个半真半假的成功。3.3 节那个 0 现在被挡了两次:第一次在 check_rules,它根本到不了 Flash;第二次在 settings_init,就算 Flash 里因为老固件已经存了坏值,开机也会回到默认配置,并留下一条故障记录。
锁的用法也分了两档。s_apply_lock 让修改一个一个来,写 Flash 时也拿着它;读的一方走 settings_get,只在拷贝那二十几个字节的时候进一下临界区,不会被几十毫秒的 Flash 写卡住。
settings_notify 怎么实现,按你项目里已有的机制来,事件、消息、回调都可以。我只坚持一点:记录模块、报警模块收到通知以后,自己调 settings_get 取新值,在自己的任务里生效,设置服务不替它们去操作定时器。check_rules 里需要问一句记录模块“是不是在记录”,这是一个只读查询;如果你的工程里记录模块已经依赖了设置服务,可以反过来让记录模块在启动、停止时把状态告诉设置服务,免得两个模块互相包含。
5.7 应答:内部结果码翻译成协议错误码
static uint8_t to_proto_err(set_result_t r){ switch (r) { case SET_OK: return PERR_OK; case SET_ERR_RANGE: return PERR_OUT_OF_RANGE; case SET_ERR_CONFLICT: return PERR_OUT_OF_RANGE; /* 现有协议没有单独的“冲突”码 */ case SET_ERR_BUSY: return PERR_BUSY; default: return PERR_INTERNAL; }}错误码也是协议的一部分。设置服务只返回自己的枚举,翻译成哪个字节、某个版本的 App 认不认识,由协议这一侧决定。以后协议出了版本 3,想把“冲突”单独报给 App,改的是这个函数,按版本选一张翻译表就行,设置服务不用动。
5.8 回头对一遍第三节
comm_task 里 |
六、成本与边界:多花了什么,哪些地方不必这么做
6.1 RAM 和 ROM
RAM 主要多在这几处:
队列:8 条消息,每条 42 字节,三百多字节,再加上队列控制块,多少看 RTOS。 分帧器:38 字节缓冲区加几个计数,四十多字节。 comm_task的栈:如果为它新建一个任务,就要给它一份栈。栈上的大头是 frame_t、应答缓冲、settings_req_t和config_t,各几十字节,再加上 CRC 和 Flash 驱动的调用深度。可以先给 1KB,跑起来看栈水位再调。如果协议处理放在已有的任务里,这一项就没有。
ROM 方面,分帧状态机、分发表、两个解码函数和规则检查加起来,按经验是一两 KB 以内的量级,这是估算,准确的数字以你工程 map 文件里的增量为准。原始实现本来就有 CRC 和 switch,真正新增的是分帧、解码和规则检查。规则检查本来就该有,这笔账不能算在分层头上。
队列深度 8 是这样估的:App 是一问一答的,发完一条命令要等应答才发下一条。最坏的情况是 comm_task 正在写 Flash,这时 App 超时重发,或者用户又点了一次读配置。ATT_MTU 为 23 时,一帧写配置是两次写,也就是两条消息,8 条能容下三四帧。如果你的 App 会不等应答连发命令,就要按“最长阻塞时间内最多会来多少次写”重新算,再用 s_rx_drop 计数在实机上核对。
6.2 时延
多出来的是一次入队、一次任务切换和逐字节的分帧。任务切换在 Cortex-M4 上通常是微秒级,分帧加 CRC 处理一帧 26 字节,一般也在几十微秒以内。手机常用的连接间隔是几十毫秒,这点开销可以忽略。写 Flash 的时间没有变,只是从协议栈的回调挪到了 comm_task 里,App 看到的应答时延基本不变,协议栈却不再被它卡住。
6.3 复杂度上的代价
一条命令要顺着分发表、解码函数、设置服务三处才能看完,第一次读代码的人会觉得绕。在分发表旁边写一行注释,说明每条命令最终调用了哪个服务接口,能省不少事。 每个协议版本一个解码函数,只要还有旧版 App 在用,旧函数就得留着。最好事先定一个支持窗口,比如最近两个 App 大版本,到期有计划地删,并且在 App 的版本说明里写清楚。 settings_req_t和 config_t今天长得很像,迟早会有人提议合并。它们像,只是因为第一版协议是照着内部结构定的;合并以后,3.2 节的问题就又回来了。Flash 里存的配置也有自己的布局版本。1.2 版给 config_t加字段时,Flash 里旧布局的数据同样要迁移。这和协议版本是两回事,别共用一个版本号。
6.4 这套做法不必用的地方
纯透传设备。比如蓝牙转串口模块,它本身不理解数据,只负责搬运,没有业务状态可改,做好分帧(有时连分帧都不用)就够了。 大块数据。OTA 固件包、历史数据导出,数据量大,按块流式处理,没必要把每一块都解码成请求结构体。协议这一侧负责分帧和完整性,把数据块交给 OTA 模块,由它按自己的规则写 Flash。原则还是同一条:整包校验通过之前,不切换启动分区。 一次性的工装和调试接口。一两条命令,不会出第二版,也不会有第二个入口,写在一个函数里也说得过去。但“先查范围再改状态”这一条,哪怕是调试命令也别省。 硬实时的控制命令。比如每毫秒一帧的电机设定值,走专用总线。这种场合可以在中断里完成分帧和解码,直接写进一个双缓冲的设定值,不经过任务和队列。分开的依然是职责:中断里只管把字节变成数值,限幅和状态检查留给控制环自己做。
七、验证:哪些靠推导,哪些在 PC 上跑,哪些必须上板
7.1 静态推导:读代码、搜工程就能确认的
搜 g_cfg:写它的地方只能在settings.c里,而且只在settings_apply和settings_init两处。搜协议目录里的 memcpy和指针强转:不应该出现把载荷拷进业务结构体,或者把载荷指针强转成结构体指针的写法。搜版本号: ver只能出现在协议目录里。业务目录里出现了它,说明版本已经漏过了边界。看依赖方向:设置服务的文件不包含任何协议头文件;协议目录包含 settings.h是允许的,依赖方向是协议指向设置服务,不能反过来。看回调: on_rx_write里只有拷贝和入队,没有 Flash、锁、延时一类的调用,入队用的是不等待的版本。看每个解码函数:第一件事是不是检查长度;之后每一次读字段,偏移加字段宽度是不是都落在检查过的长度以内。
这些检查不需要跑任何代码,代码评审时对着清单过一遍就行,也可以写成脚本放进持续集成。
7.2 宿主机验证:把逻辑搬到 PC 上跑
分帧、解码和规则检查都是纯逻辑,不依赖硬件。把 config_store、record_is_running、临界区和锁换成桩函数,就能在 PC 上编译运行。分帧最值得测的是“切分位置”:
/* 同一帧从每一个位置切成两段喂进去,都必须正好交出一帧 */static void test_split_everywhere(void){ uint8_t raw[64]; uint16_t n = build_frame(raw, 1, CMD_SET_CONFIG, k_payload_v1, 20); /* 26 字节 */ frame_t out; for (uint16_t cut = 1; cut < n; cut++) { framer_t fr; int got = 0; framer_reset(&fr); for (uint16_t i = 0; i < cut; i++) { got += framer_feed(&fr, raw[i], 0, &out); } for (uint16_t i = cut; i < n; i++) { got += framer_feed(&fr, raw[i], 10, &out); /* 后一段晚到 10 毫秒 */ } assert(got == 1); assert(out.cmd == CMD_SET_CONFIG && out.len == 20); }}按同样的思路,把正常流程、异常流程和极端顺序都列成用例:
正常流程:一帧一次喂完;逐字节喂;两帧拼在一起一次喂进去,必须交出两帧。 极端顺序:喂半帧,调 framer_reset模拟断线,再喂一帧完整的,只能交出后面这一帧;喂半帧,隔 600 毫秒再喂一帧完整的,前半帧被超时丢掉,后一帧正常交出。异常流程:帧前面加几个垃圾字节、长度字段改成 200、CRC 错一位,都不能交出帧,而且后面紧跟的正确帧必须能收到。垃圾字节里恰好含 0xA5 的情况单独写一条用例,看清楚它会连累后面几帧,心里有数。 解码:同一组参数分别按版本 1 和版本 2 编码,解出来的 settings_req_t除报警延时以外完全相同,版本 1 的mask里没有报警延时;版本 1 带 22 字节、版本 2 带 20 字节、设备名 12 个字节都没有结尾,全部被拒绝,g_cfg逐字节不变。规则:采样间隔 9、10、3600、3601;上限 850 和 851;下限等于上限;记录中改采样间隔,回“设备忙”。还有一条很容易漏:App 的设置页每次都把所有字段一起发,记录中用户只改了上下限,请求里的采样间隔和原来一样,这时必须放行。 存储失败:让 config_store的桩返回失败,g_cfg必须和调用前逐字节相同,settings_notify一次都不能被调用。
最后再加一条随机压测,测一个不变式:不管外面来什么,配置永远合法。纯随机字节几乎凑不出 CRC 正确的帧,压不到后面几层,所以要混着来,一部分是随机字节,一部分是 CRC 正确、载荷随机的帧,再随机切成段喂进去,每处理完一帧检查一次 g_cfg 是否仍然满足 check_rules。随机数种子要打印出来,失败的时候才能复现。
7.3 实机验证:必须在板子上和真手机上看的
手机矩阵:iOS、协商了 MTU 的安卓、没协商 MTU 的安卓,每种连续保存 100 次。没协商 MTU 的情况不好找机型,可以在测试版 App 里加一个开关,故意不申请 MTU。 断线时机:用测试版 App 发出半帧以后立刻断开,或者直接关掉手机蓝牙,重连后的第一条命令必须成功。 回调耗时:在 on_rx_write的入口和出口翻转一个 GPIO,用逻辑分析仪看最长的一次有多长,而且这个时间不应该随“是否在保存设置”而变化。写 Flash 期间:保存设置的同时,让 App 不停地发读配置命令,看应答是否都回来了,队列最高水位是多少, s_rx_drop有没有增加。新旧版本交叉:旧 App 配新固件,新 App 配旧固件,各跑一遍。后一种情况,新 App 应该收到“版本不支持”或“长度错误”,并提示用户升级。 掉电:在 config_store写到一半时断电,上电后配置必须是完整的旧值或者完整的新值。要说明的是,这一条考的是config_store自己,常见做法是双备份加校验,本文的分层保证不了它,别把两件事的功劳混在一起。资源回收:反复连接、断开一千次,中间穿插发半帧;结束后看 comm_task的栈水位、队列最高水位和s_rx_drop计数,用调试命令确认分帧器的cnt回到了 0。
三层的分工大致是:读代码管结构,PC 管逻辑和顺序,板子管手机、时间和电。
八、带回自己项目的八个问题
不用照搬这套代码,先拿下面几个问题,去看看自己项目里的接收代码:
找到第一行修改业务状态的代码,往回数:在它之前,分帧、完整性、字段格式、取值、设备状态这几道检查,各在哪里?哪一道是缺的? 有没有 memcpy或者指针强转,把载荷直接盖到内部结构体上?下次有人给这个结构体加字段,他知道自己在改协议吗?版本号出现在哪些文件里?过了解码这一步,业务代码还看得到版本号吗? 协议栈或者中断的回调里做了什么?有没有写 Flash、等锁、延时? 拼帧缓冲区归谁管?断线、超时以后有没有人清?一次收到半帧、一帧、两帧,是不是都对? 取值和状态规则写在哪里?如果只写在蓝牙的处理函数里,明天加一个串口工装、一个按键菜单,规则会不会被抄成三份? 一个请求被拒绝的时候,状态是完全没动,还是动了一半?Flash 里读出来的配置,开机时有没有再检查一遍? 回给对方的错误码,能不能让对方分清是长度不对、版本不对、超出范围,还是设备正忙?
这几年我看接收代码,第一件事就是找那行改状态的代码,看它前面站着什么。CRC 几乎人人都会加,可它只回答了一个问题:这些字节在路上有没有被改过。设备真正需要的是另外两个回答:这些字节组成的是不是一个看得懂的请求,这个请求现在该不该执行。前一个归协议,后一个归业务。分开回答,中间用一个有效请求接起来,App 怎么发版、手机怎么分包、配置结构体怎么加字段,影响就都停在各自那一侧。
这篇讲的是一条边界:外部字节在变成内部状态的修改之前,要先在协议这一侧变成一个有效请求,再交给状态的主人去执行。再往上走一层,问题就变成了整个系统怎么组织:配置、记录、报警这些状态分别归哪个模块所有,通信任务和业务任务怎么拆,模块之间用事件还是命令沟通,依赖朝哪个方向,出了问题靠什么日志和故障码去定位。这些没有想清楚,协议和业务就算在一处分开了,别的地方还会以另一种方式缠在一起。
这一层的内容,我整理成了《嵌入式软件架构实战合集》。它不讲空泛的概念,而是结合 MCU、RTOS、驱动适配、协议解析、状态机、事件驱动、任务模型、日志诊断这些真实项目场景,讲怎么把一个嵌入式软件系统设计得更清晰、更稳定、更容易扩展和维护。内容包括软件分层、模块边界、接口设计、依赖解耦、状态机、事件驱动、RTOS 任务模型、协议与业务解耦、日志与故障码设计、工程结构组织,以及真实项目的重构案例。本文只点到为止的任务划分、模块边界、依赖方向和故障码,在合集里都有专门的展开。
如果你已经有一定的嵌入式开发经验,正被代码混乱、模块耦合、功能难扩展、问题难定位这些事困扰,不只想“把功能写出来”,还想弄明白一个项目该怎么组织、怎么演进、怎么长期维护,可以去看看。
你们项目里,蓝牙或者串口收到的数据,在改掉配置之前要过几道检查?欢迎在留言区聊聊。
往期推荐















































































