有没有过这样的排查经历。上报出去的数据偶尔串行,十次里错一次,重现不了。你盯着通信协议查了三天,最后发现问题在内存:采集模块正往全局数组里写,上报模块同时在读,读到了半新半旧的一帧。全局变量就是这样,用的时候最省事,出事的时候最难找。
《嵌入式软件架构(一):HAL / BSP / 驱动 / 应用,这四层到底怎么切》讲的是上下怎么分层,《嵌入式软件架构(二):事件驱动,怎么把主循环里的标志位地狱收拾干净》讲的是事情怎么触发。这篇讲横向的事,模块和模块之间,数据到底该怎么递过去。
全局变量为什么是个坑
拿个例子。做一台数据采集网关。
四路传感器各自采样,一个上报模块负责打包发出去。
最直接的写法,是开一个全局结构体,采集的往里写,上报的从里读。
sensor_data_t g_data; /* 谁都能碰 */
这么干,头三天很爽。问题都在后面。
第一个坑,说不清谁改了它。全工程几十处引用,出了怪值你不知道是哪一处写的。加断点都不知道该加在哪。
第二个坑,读到一半被改了。上报模块正在拷这个结构体,中断里的采集把前半截更新了。你打包出去的,是半新半旧的一帧。
第三个坑,模块彻底绑死。上报模块认识 g_data 这个名字,换个项目就搬不走,因为那边没有这个变量。
全局变量不是不能用,是不该用来当模块间的传输通道。
一次只递一份,用环形缓冲
模块间递数据,最常用的家伙是环形缓冲。
它和全局变量的根本区别在于,数据是有归属的。写进去的那一份,在被读走之前,谁也别想动它。
static frame_t buf[8];
static uint8_t head, tail;
int fifo_put(const frame_t *f) {
uint8_t next = (head + 1) & 7;
if (next == tail) return 0; /* 满了,宁可丢新的也不覆盖旧的 */
buf[head] = *f; /* 整帧拷进去 */
head = next;
return 1;
}
注意那句整帧拷贝。数据一旦进了缓冲,就和采集模块那边的变量断了关系。采集那边接着采下一帧,动不到已经交出去的这一份。
这就叫值传递。传值不传址,是模块间通信的第一条规矩。
生产者消费者,把节奏差异吸收掉
环形缓冲还顺手解决了另一个麻烦,两边速度不一样。
采集是定时来的,一秒四次,很规律。上报要走网络,可能几百毫秒才发完一包,还可能重试。
要是采集直接调上报函数,采集就得等着上报干完,节奏立刻被拖乱。
有了缓冲,两边就能各走各的。采集只管往里放,放完就走。上报有空了就来取,取多少算多少。
/* 采集侧:放完就走,不等 */
void on_sample(void) {
frame_t f = build_frame();
if (!fifo_put(&f)) drop_cnt++; /* 满了记一笔,别装看不见 */
}
/* 上报侧:有空就取,一次取一帧 */
void report_task(void) {
frame_t f;
if (fifo_get(&f)) net_send(&f);
}
这就是生产者消费者。中间那个缓冲,吸收的是两边的节奏差。
缓冲满了怎么办,这事要提前想清楚。是丢最新的,还是覆盖最旧的,取决于你的数据谁更值钱。实时监控丢旧的没关系,计量数据一条都不能丢,那就得考虑加大缓冲或者落到 Flash。
不管选哪种,都记得计个数。默默丢数据而不留痕迹,是排查时最要命的事。
一份数据多家要,用发布订阅
再往上一层的需求,是一份数据好几个模块都想要。
温度这个值,显示模块要拿去刷屏,报警模块要拿去比阈值,记录模块要拿去存 Flash。
笨办法是采集模块挨个去调它们。
lcd_update(t);
alarm_check(t);
log_write(t);
能跑,但采集模块被绑死了。它现在认识三个上层模块,多加一个功能就得回来改它一行。这正好违反了分层里"下层不该反调上层"那条。
好办法是发布订阅。采集模块只管喊一嗓子,谁关心谁自己登记。
/* 各模块自己登记 */
sub_register(TOPIC_TEMP, lcd_on_temp);
sub_register(TOPIC_TEMP, alarm_on_temp);
/* 采集侧只管发布,不认识任何订阅者 */
publish(TOPIC_TEMP, &t);
采集模块从此不认识任何上层。加一个订阅者,它一个字都不用改。
代价是绕了一层,调试的时候不能一眼看出这数据流到哪去了。所以别滥用,一对一的场合老老实实直接调,多方关心的数据才值得上发布订阅。
共享要保护,这条躲不掉
上面几种方式,只要涉及一头在中断里、一头在主循环里,都躲不开一个问题,临界区。
head 和 tail 这两个指针,两边同时改就会乱套。
处理办法有两种。
一种是关中断保护。改指针那几行,前后关一下开一下,简单直接。要点是范围一定要小,只圈住改指针那几句,别把整个拷贝过程都关进去。
uint32_t p = enter_critical();
head = next;
exit_critical(p);
另一种是从设计上回避。保证只有一个人写 head、只有一个人读 tail,那这两个变量就各有各的主,不用锁也不会乱。这种单生产单消费的模型最省心,能设计成这样就尽量设计成这样。
至于共享变量加 volatile,那是防编译器优化,和临界区是两码事,别混为一谈。
几个容易翻车的地方
传指针不传值。把指向局部变量的指针放进队列,等对方取出来,那块栈早没了。
用完不标记。有些人用一个数组加一个 ready 标志充当队列,读完忘了清标志,同一份数据被处理两遍。
缓冲满了默默丢。不计数、不报警,等现场丢了数据你完全不知情。
一个缓冲多路复用。四路传感器共用一个缓冲还不带来源标记,取出来分不清是谁的。要么分开建,要么帧里带上通道号。
订阅表越加越长。发布订阅用上瘾,什么都发一遍,最后没人说得清一个事件到底触发了多少回调。
小结
模块之间递数据,核心就一句话:别共享,要传递。
一对一且有节奏差的,用环形缓冲做生产者消费者,传值不传址。一对多的,用发布订阅,让下层不认识上层。跨中断和主循环的,临界区该护就护,能设计成单生产单消费就别上锁。
全局变量的问题从来不是慢,是没人知道谁动了它。你把通道立起来,数据流向才看得见。
打开你那个工程搜一下 g_ 开头的变量,有几个是被两个以上模块同时碰的?
嵌入式软件架构 · 系列五篇
《嵌入式软件架构(一):HAL / BSP / 驱动 / 应用,这四层到底怎么切》 《嵌入式软件架构(二):事件驱动,怎么把主循环里的标志位地狱收拾干净》 《嵌入式软件架构(三):模块之间怎么通信,别再让全局变量满天飞》 (本篇) 《嵌入式软件架构(四):参数与配置管理,掉电不丢、改版不崩》 《嵌入式软件架构(五):错误处理与故障自恢复,现场没有复位键怎么活下来》
夜雨聆风