一个字节的三个老板:智能道闸里的多任务状态同步课
一个字节的三个老板:智能道闸里的多任务状态同步课
系列:鸿蒙智联实验箱踩坑实录 · B07 外传
性质:架构分析。闸态这一个字节,怎么同时伺候好屏幕、云端和倒计时三位老板,还不把自己逼疯。
前置知识:LiteOS 任务与调度的基本概念(会数任务数就行)。
开场:一个字节的社交困境
智能道闸的核心状态是什么?扒掉所有业务包装,就一个字节:闸是开着还是关着。
但就是这么一个字节,在系统里有三位老板盯着它:
- 显示任务(view_task):要拿它在 TFT 屏上画抬杆/落杆图标、写"道闸:打开/关闭";
- 云端上报:要把它装进协议结构体发给物联网平台,让运维在网页上看到"开/关"历史;
- 自动落杆逻辑:开闸后它要被一个 5 秒倒计时盯着,时间一到就得变回"关"。
三个老板,三个不同的任务上下文,读取时机各不相同。如果让它们直接伸手改这个字节,会发生什么?——经典多线程灾难三件套:显示刷一半状态变了、上报报了半新半旧的值、倒计时和人工指令打架把杆子抖成风中的树叶。
B07 工程用三个轻量机制把这个问题解得很干净:单一作者、信箱握手、事件标志。下面一个个拆。
机制一:单一作者——闸态只有一个"法定写入者"
翻遍整个工程,改动闸态的代码路径只有一条:
void gate_deal(uint8_t state) // 道闸动作:唯一的闸态写入者
{
gate_state = state; // ★全工程只有这里写闸态
if (state == 1) {
close_gate_time_cnt = 5; // 开闸:装填 5 秒倒计时
Servo_Open();
} else if (state == 0) {
Servo_Close();
}
}
谁能调用 gate_deal()?查一遍调用关系,只有两个入口:
- 平台指令:
ptl_Receive_OCData_Handle()解析 Funcode 0x31 后调它; - 自动落杆:控制任务主循环里倒计时归零调它。
其他任何代码想表达"我要改闸态",都必须走 gate_deal() 这扇门。这就是单一作者原则:一个共享状态只有一个写入函数,所有变更集中过审。
好处立刻显现——出问题不用全工程搜"谁动了闸态",断点就下在 gate_deal() 一处。实际调试中我们就靠这一招抓到过一个疑似 bug:某次开闸后平台状态迟迟不更新,在 gate_deal() 一看,闸态变了、上报标志也置了,问题根本不在这层(后文机制三讲怎么定位的)。写入点唯一,排查才有锚点。
顺带一提写入时的连带动作:gate_deal() 改闸态的同时会把 TranView.GateOutput(显示结构体)、g_pltSendData.dOutput_Gate(上报结构体)一起更新——闸态是"真相",这两个是"真相的副本"。副本随真相同步更新,读副本的人永远拿到一致视图。
机制二:信箱握手——显示任务的"请求-应答"
显示任务和控制任务是两个独立任务,控制任务改完闸态,怎么让显示任务知道"该重画了"?
暴力做法是控制任务直接调显示函数——但 TFT 屏驱动是出了名的慢(一次全屏刷新毫秒级),在控制任务里同步刷屏,等于让通信、心跳、倒计时全体陪屏幕罚站。
B07 的做法是把"刷新"变成一封信:
/* 控制任务:状态变了,投递刷新请求 */
Scene.state = 1; // 立一个"有变化"的标志
/* 显示任务:每 10 节拍看一眼信箱 */
static void Gate_View(void)
{
if (Scene.state == 1) // 有请求
{
Scene.req_state = 1; // ★回执:我收到了
// ……按 TranView.GateOutput 重画图标和文字……
}
else
{
Scene.req_state = 0; // 无请求时清回执
}
}
/* 控制任务:看到回执才清请求 */
if (Scene.req_state == 1)
{
Scene.state = 0; // 请求已被处理,可以安全清除
}
注意这套握手的三个细节,全是实战里磨出来的:
细节一:请求和应答是两个变量。 如果只有一个标志,控制任务置 1、显示任务清 0,中间任何时刻控制任务再置 1,就可能被显示任务的清 0 动作吞掉——一次刷新请求凭空蒸发。拆成 state(请求)和 req_state(应答)之后,控制任务只在看到应答之后才清请求,逻辑上不可能丢。
细节二:显示任务拿的是副本不是原件。 Gate_View() 读的是 TranView.GateOutput——闸态的副本(由 gate_deal() 在改闸态时同步更新)。显示任务刷屏期间,就算闸态又变了,它读到的也是一份连续的快照;新变化会通过下一轮"请求-应答"再来一次。宁可慢一轮,不可画一半。
细节三:整个过程没有任何阻塞。 控制任务置标志是纳秒级,显示任务 10 节拍(约 10ms)内必然会看到信。没有互斥锁、没有信号量、没有休眠唤醒——在这个"单字节、单方向、低频"的场景里,标志位握手就是最合适的复杂度。并发控制的功力不在于用了多重的锁,而在于用对了多轻的锁。
机制三:事件标志——上报的"节流阀"
云端上报也有同样的多任务问题,而且多了一层:上报周期是 5 秒一次(Task1_Deal 的组包节奏),但闸态变化是事件性的——如果只靠 5 秒周期上报,开闸后运维平均要等 2.5 秒才在平台上看到状态;如果每次变化都立刻单独建连接上报,又太奢侈。
工程里用了第三种粒度——事件标志:
/* 状态变化处(gate_deal 被调用后) */
Scene.event_sendflag = 1; // "有话要说"的便签
/* 通信任务:ptl_Handle() 每轮循环检查便签 */
/* 看到便签:把最新状态立即组帧上报,然后撕掉便签 */
效果是:变化秒级上云,空闲零流量。5 秒周期包负责"我还活着"的心跳语义,事件包负责"我变了"的时效语义,两种语义分两层走,各自干净。
排障时这套设计还立过功:有一次开闸后平台状态记录偶尔缺失。到 gate_deal() 一看,闸态和标志都对;再往通信任务看,才发现问题出在标志置位与组包的时序上——状态更新代码写在 gate_deal 之后但个别分支漏了置标志。因为"写入点唯一",很快锁定了漏置标志的那个分支补上。如果上报散落在各个角落,这个 bug 的排查成本至少翻三倍。
顺带修正一个容易产生的误解:
event_sendflag不是中断,也不会"排队多条事件"。它就是一张便签——置 1 表示"有变化",上报后清 0。若 5 秒内状态变了三次,平台收到的是一条"最终状态"事件包加周期包。对本场景(闸态只有两值)这完全够用;若未来要记录每次变化的流水,就该升级成环形缓冲队列了。机制没有高低级,匹配场景才是高级。
把三个机制装回一张图
┌─────────────────────────────────────────┐
│ gate_deal(state) │
│ ★闸态唯一写入点 │
│ 同步更新:TranView / g_pltSendData │
└───────┬──────────────────┬──────────────┘
│ │
Scene.state=1 │ │ Scene.event_sendflag=1
(刷新请求) │ │(上报便签)
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 显示任务 │ │ 通信任务 │
│ Gate_View(): │ │ ptl_Handle(): │
│ 画图标+写状态文字 │ │ 组帧上报最新状态 │
│ Scene.req_state=1│ │ 撕掉便签 │
└────────┬─────────┘ └──────────────────┘
│ 回执 req_state=1
▼
控制任务看到回执 → 清 Scene.state
两位写入入口(平台指令、自动落杆)汇入唯一的 gate_deal();两个消费任务各自通过轻量标志取数,互不阻塞、互不越权。
收尾:这套课的通用价值
把 B07 的具体名字全部抹掉,剩下的是一张可以复用的多任务状态同步模板:
| 场景特征 | 该用什么 | 不该用什么 |
|---|---|---|
| 一个状态、多个任务消费 | 单一写入函数 + 快照副本 | 各任务直接改共享变量 |
| 低频的"该刷新了"通知 | 双变量请求-应答握手 | 跨任务直接调 UI 函数 |
| 事件性数据上报 | 事件标志 + 周期心跳分层 | 全靠周期轮询(慢)或全靠即时上报(费) |
| 状态变更排查 | 断点打在唯一写入函数 | 全工程搜赋值语句 |
判断标准就一句话:先问"这个状态有几个作者、几个读者、多久变一次",答案决定了该用多重的并发机制。 单字节、双值、低频,就配用标志位握手;等到真有多字段一致性要求时,再上互斥锁不迟。
LiteOS 给了任务调度的骨架,但"谁在什么时机碰什么数据"的纪律,永远是应用层自己给的。这次道闸实验最大的收获不是闸杆会动了,而是亲眼看见:三个轻机制叠起来,就能让一个字节体面地伺候三位老板。
更多推荐




所有评论(0)