恢复出厂:从“一个人大扫除”到“可插拔清单”
一次嵌入式恢厂框架的解耦笔记
“恢复出厂”是产品里的重启人生按钮。用户以为它是一键清净,开发者知道它是一套顺序不能错的搬家流程:先关灯,别让用户看到中间状态;再停手,别让业务一边写一边被删;然后打包、扔垃圾、留默认;最后锁门、重启。
任何一个环节出错,用户看到的就不是“恢复出厂”,而是“恢复出厂失败,且桌面没了”。
最近的一次提交,就是把这套“大扫除”拆成了一张可插拔清单。这篇不写产品文档,写点代码背后的人话、取舍和坑。
0. 先说结论
这次改动只做了一件事:
框架只负责“收到通知、按优先级叫人、看结果、失败擦屁股、成功关灯”;每个业务模块自己注册处理函数,自己决定清什么、默认值是什么、失败了怎么回滚。
记住四句话就够了:
- 框架不碰业务数据,也不认识“默认主题”长什么样。
- 业务不碰全局重启,也不管别人的数据。
- 全部成功才重启;任一失败就停、恢复、不重启。
- 没人注册也不重启——一个清理都没做就重启,不叫成功,叫假装成功。
还有一个很重要的边界:
这次提交只造了“插座”,还没插“电器”。主题、桌面、地图、业务数据、OTA 的清理函数还需要各自注册;打开开关但没人注册时,框架会安全地拒绝重启。
这不是半成品,这是故意的安全阀。
具体到代码,这次提交就五块:
| 改动 | 作用 |
|---|---|
| 公共头文件 | 定义优先级、handler/restore 类型、注册接口、错误码、总开关 |
| 内部头文件 | 框架内部接口,不对外 |
| 注册表/调度器 | 注册、排序、快照、串行调用、失败恢复、日志 |
| 协调器 | 订阅事件、开机防抖、订阅重试、工作线程、成功重启 |
| 构建脚本 | 按宏选源文件、加头文件路径,新旧协调器二选一 |
没有改业务清理,没有改事件映射,没有改重启实现。一笔提交只改一层,是好事;最怕“顺手”把五层都改了,review 时像拆盲盒。
1. 旧链路:一个人干完整栋楼的保洁
旧流程大概是这样:
设置页
→ 上位控制器
→ 设备事件总线
→ 旧协调器
├─ 退出导航
├─ 调主题恢厂(直接 include 主题头文件)
├─ 同步上位控制器(空桩)
└─ 重启
具体命令号已打码,反正你也不想知道。
旧实现不是“写得烂”,而是“知道得太多”。它像一个什么都管的物业经理:谁家垃圾放哪、先擦哪块玻璃、垃圾袋在哪,全在他脑子里。每新增一个住户,就得改一次他的脑子。
问题也就跟着来了:
| 问题 | 人话翻译 |
|---|---|
| 框架直接 include 主题头、直接调主题函数 | 物业经理兼职修水电,主题一改名,框架就跟着感冒 |
| 新增清理模块就要改协调器 | 每来一个住户,物业经理就得重写一遍大脑 |
| 黑屏写死在流程里 | 以后想换开机动画,得给框架做手术 |
| 失败没有统一出口 | 清到一半失败,没人负责把界面/背光恢复;最坏是半套数据 + 重启 |
| 顺序靠注释和记忆力 | 先黑屏、再停地图、再切主题、再停 UI,全写在注释里 |
最后一条尤其真实。注释是写给未来的自己看的,但未来的自己通常假装没看见。
2. 新设计:框架只当“包工头”
新框架把角色拆得很干脆:
| 角色 | 负责 | 不负责 |
|---|---|---|
| 框架 | 订阅事件、开机防抖、重复去重、优先级排序、串行调用、结果判定、失败恢复、重启、日志 | 清什么、默认值是什么、UI 怎么做 |
| 业务参与者 | 注册自己的 handler、清理自己的数据、切自己的默认值、失败时尽量回滚 | 全局重启、别人的数据 |
| 构建 | 用宏二选一新旧实现 | 运行时行为 |
对外接口就这几个:
typedef int32_t (*FactoryResetHandler)(void *ctx);
typedef void (*FactoryResetRestoreHandler)(void *ctx);
void FactoryReset_Init(void);
bool FactoryReset_RegisterNamed(const char *name,
FactoryResetPriority priority,
FactoryResetHandler handler,
void *ctx);
bool FactoryReset_RegisterRestore(FactoryResetRestoreHandler handler, void *ctx);
约束也写得明明白白:
name只用于日志,必须是静态字符串,框架不复制、不释放。ctx由注册方管理,必须活到恢厂结束。- handler 返回
0表示成功,非0表示失败。 - handler 运行在框架的工作线程,不在 UI 线程,也不在事件总线线程。
- handler 如果要用 UI,自己投递并等待,而且必须自带超时。
- restore 只在失败时调用一次;成功不调用。
注册宏还顺手把函数名变成了日志名:
#define FactoryReset_Register(priority, handler, ctx) \
FactoryReset_RegisterNamed(#handler, (priority), (handler), (ctx))
少写一个字符串,少一次拼错。程序员和字符串的关系,能少一次就少一次。
2.1 三档优先级,够用就好
typedef enum {
FR_PRIORITY_HIGH = 1,
FR_PRIORITY_MEDIUM = 2,
FR_PRIORITY_LOW = 3
} FactoryResetPriority;
典型分配:
HIGH 先保命:黑屏、停地图/导航、切默认主题、停 UI
MEDIUM 再清数据:删用户主题/壁纸、清桌面、清地图、清业务数据
LOW 最后收尾:flush、finalize
规则只有几条:
- 先
HIGH,再MEDIUM,最后LOW。 - 同一优先级按注册顺序执行,不并行。
- 返回
0继续下一位;非0立刻停。 - 失败就调用恢复函数,不重启。
- 全部返回
0,才重启。
同一优先级按注册顺序,别赌链接器的心情。 链接器排的不是你的业务顺序,是它自己的心情。
2.2 为什么顺序是“黑屏 → 停地图 → 切主题 → 停 UI”
这不是拍脑袋,是依赖关系:
- 黑屏第一:用户不该围观手术现场。
- 停地图/导航第二:先释放地图主题和后台任务,免得切主题时两边抢资源。
- 切默认主题第三:切失败就停,后面的文件一个都别删。否则可能出现“默认主题没了,用户主题也删了”的完美空城。
- 停 UI 第四:切主题可能还需要 UI/主题服务活着;先停 UI 再切主题,容易把梯子踢了。
这四步像拆炸弹,顺序错了就上新闻。
三档够用吗?大多数恢厂流程够。真不够,通常不是缺优先级,而是模块之间的依赖没画清楚。加第四档,往往只是把“谁先谁后”的复杂度从代码搬到了会议里。
3. 一次恢厂的完整旅程
[事件总线线程]
收到复位事件
├─ 开机不到 5 秒?装没听见
├─ 已经在跑?装没听见
└─ 投递工作线程,自己继续喝茶
[工作线程]
1. 锁内拷贝注册表快照
2. 按“优先级 + 注册顺序”排序
3. 锁外逐个同步调用 handler
├─ 返回 0:下一位
└─ 非 0:停,调用恢复函数,不重启
4. 全部 0:等 300ms,重启
下面这些细节,才是这次改动真正有意思的地方。
3.1 订阅会失败,别装死
设备刚启动时,事件总线可能还没准备好,订阅会返回“忙”。旧代码只订阅一次,失败就永远收不到恢厂事件;用户按了按钮,设备一脸无辜。
新框架的做法很朴素:失败就每 500ms 重试,成功就停掉重试定时器。没有花活,但很有效。
人话:总线有起床气,你别只敲一次门就走。
3.2 开机 5 秒,先装没听见
上一次恢厂可能在总线上留了“余音”。设备刚起来就收到旧事件,容易二次恢厂。框架在协调器初始化后的前 5 秒忽略复位事件,避免“ ghost reset ”。
代价也要说清楚:防抖是装没听见,不是把话记下来。 如果上游只喊一次,而用户真的在开机 5 秒内按了按钮,这次事件就真丢了。产品要么接受这个窗口,要么让上游重发,要么以后做延迟重放。代码不能既装没听见,又假装自己听见了。
3.3 重复触发,门只开一次
handling_ 这个标志位负责看门:
- 正在恢厂时又收到事件:忽略,打一条 warn。
- 失败后把
handling_放回 false,允许下一次触发重试。 - 成功后不放开:因为马上就要重启了,没必要再开一局。
简单,够用。复杂的并发控制,往往是因为状态没拆干净。
3.4 锁内拷贝,锁外干活
注册表是全局的,注册和恢厂可能在不同线程。框架的做法是:
- 加锁,把注册表拷贝成一份快照。
- 解锁。
- 对着快照挨个调用 handler。
好处有两个:
- handler 里如果又注册/注销,不会和框架的锁互相掐死。
- 文件 I/O、等 UI 这种慢活,不会一直占着注册表的锁。
这是整个实现里最值得保留的细节之一。锁只保护数据,不保护业务;业务一慢,锁就成了全楼唯一的电梯。
3.5 16 个槽位,没有 malloc
注册表是固定大小数组,最多 16 个参与者,不动态分配。表满就返回 false,打 error 日志。
这很嵌入式:内存预算写脸上,不为一个恢厂流程引入堆碎片。16 个够不够?对“黑屏、停地图、切主题、停 UI、删主题、清桌面、清地图、清业务数据、flush、finalize”这种清单,通常够。真不够,先看看是不是把“一个模块的十个小步骤”拆成了十个参与者。
3.6 没人注册,不许重启
这是我最喜欢的一条安全阀:
参与者数量 == 0
→ 打 error
→ 调恢复函数
→ 不重启
为什么?因为“什么都没清就重启”是最危险的假成功。用户以为恢复了,实际上旧数据原封不动;下次再出问题,排查难度翻倍。
框架可以允许“没有业务”,但不能允许“没有业务还宣布胜利”。
3.7 失败就停,别“尽力而为”
恢厂不是自助餐,不能每样都来一点。
只要有一个 handler 返回非 0,框架就:
- 停止后续所有 handler;
- 调用 restore,恢复界面/背光;
- 不重启;
- 把
handling_放回 false,等待下一次触发。
为什么不停下来?因为半套数据比旧数据更可怕。比如默认主题还没切成功,用户主题已经被删了;或者地图数据清了一半,桌面还在读缓存。这种状态重启之后,用户看到的是“薛定谔的桌面”。
失败后能不能重试,还要看上游:如果上位控制器一直保留着复位状态、不再发新事件,框架也没法凭空变出一次重试。框架只负责把门打开,不负责替你按门铃。
3.8 超时是业务自己的 KPI
框架是同步调用 handler 的,它不能安全地“杀掉”一个卡住的函数。所以超时分两层:
- 业务自己超时:等 UI、等地图、等文件操作,必须自己带超时,超时返回非 0。
- 框架软超时:记录每个 handler 的耗时;整体耗时超过预算时,在结束时打 warning。它不能强行打断。
- 看门狗:最后的安全网。handler 永久卡死,只能靠它。
这话听起来有点甩锅,但它是事实:框架只能看表,不能替选手跑步。 把“超时”写进接口契约,比假装框架能解决一切要诚实。
3.9 日志是飞行记录仪
每次调用都会打印:
[FactoryReset] call <name> priority=<n> elapsed=<ms> ret=<n>
失败时还会记录是谁失败、总耗时多少。恢厂这种“一辈子可能只发生几次”的流程,日志比调试器有用。用户说“按了没反应”的时候,你至少能回答:是没收到、被防抖了、正在跑、还是某个 handler 卡住了。
3.10 文档里的状态机,代码里的两个 bool
设计文档里画了很漂亮的状态机:
IDLE → RUNNING → SUCCESS → 重启
└→ FAILED → 恢复 → IDLE
代码里实际是 handling_、resetPending_、workerStarted_、subscribed_ 几个 bool 在支撑。不是不行,当前状态也确实够用;但如果以后再加“等待用户确认”“后台补偿”之类的状态,建议把隐式状态收成一个显式 enum。文档是精装房,代码是毛坯房,住着不漏就行;但别在毛坯房里继续加盖三楼。
4. 一个宏,两个世界
新旧两套协调器都带启动初始化。如果同时编进去,轻则重复符号,重则两个初始化都跑,谁先谁后看缘分。所以构建层必须二选一:
FR_NEW ?= 1
DEFS += -DFR_NEW=$(FR_NEW)
ifeq ($(FR_NEW),1)
SRCS += base/factory_reset/src/factory_reset_framework.cpp \
base/factory_reset/src/factory_reset_coordinator.cpp
else
SRCS += legacy/factory_reset_coordinator.cpp
endif
宏名和路径是示意,重点在思路:
- 宏 = 1:只编新框架,旧协调器不参与编译。
- 宏 = 0:只编旧协调器,新框架不参与编译。
- 不要靠“空文件”糊弄。宏 = 0 时应该从源文件列表里彻底拿掉,而不是留一个空 TU 占位;产物干不干净,
nm一看就知道。 - 宏值必须一处定义。应用和库各玩各的宏,最后就是“一边认识新函数,一边只认识旧函数”,链接器当场表演裂开。
- 老产品不是二等公民。旧路径原样保留,迁移可以慢慢来;新框架只在新产品上开。
还有一个很容易被忽略的点:
打开宏 ≠ 功能可用。
框架只是插座。没人注册 handler,它会在“参与者数量 == 0”那里安全停车,不重启。插座装好了,电器还没买,灯当然不亮。
这次提交的边界也在这里:只新增框架和构建开关,不改主题/桌面/地图/业务数据/OTA 的具体清理,不改事件映射,不改重启实现。下一步才是各业务把自己的 handler 接进来,把“清单”填满。
5. 代码 review:夸一夸,也挑挑刺
5.1 值得点赞的
- 失败路径是认真设计过的:停止、恢复、不重启,而不是“出错了也先重启再说”。
- 锁内拷贝、锁外回调:避免回调里注册导致死锁,也避免慢业务长期占锁。
- 固定数组、无动态内存:对资源紧张的嵌入式设备很友好。
- 没人注册就不重启:防假成功,这是产品级的安全意识。
- 日志带名字、优先级、耗时、返回值:排查问题时不用靠猜。
- 构建层二选一:老产品零打扰,新产品可开关,迁移成本可控。
5.2 下次一定的
1)解耦解了一半,日志还姓“主题”
通用框架仍然 include 了主题侧的日志头,还依赖平台的时间函数。功能上没问题,但架构上不够纯:以后把框架搬到别的产品,第一件事是给日志改姓。
建议把日志和时钟抽成通用适配层,或者从外面注入。框架不该知道日志是谁家的。
2)双重检查锁,在标准 C++ 下不严谨
GetInstance() 先在锁外读 instance_,再在锁内赋值。单核嵌入式上经常“看起来没事”,但标准 C++ 内存模型下,这是数据竞争。能用函数内静态变量就用;工具链不支持线程安全静态初始化,就老老实实 pthread_once。
跑得快不代表内存模型同意。
3)工作线程起不来,别在别人客厅开挖掘机
pthread_create 失败时,代码会回退到事件总线线程直接执行恢厂。恢厂里有文件 I/O、UI 等待、重启,这些都不适合压在总线线程上。
更稳妥的降级是:明确报错、拒绝本次恢厂、延迟重试,或者进入一个“不可恢厂”的安全状态。线程起不来可以认输,但别把别人的线程拖下水。
4)没有硬超时
框架不能抢占 handler,这是事实。但至少可以在调用下一个 handler 之前检查总预算,超了就停止并走失败路径,而不是等所有 handler 都跑完才在日志里说“超时了”。
当然,真正的解法还是业务自己超时。框架只能看表,不能替选手跑步。
5)重启函数是 void
如果重启函数真的不回来,皆大欢喜;如果它因为某种原因回来了,handling_ 还停在 true,后续事件都会被当成“正在跑”忽略,设备就卡在一个奇怪的半成功状态。
建议让重启接口返回 bool,或者在调用后记录“重启请求已发出但返回”,至少留一条日志和一个可恢复状态。别把“不会返回”当成类型系统里的承诺。
6)restore 是单例,后注册覆盖先注册
FactoryReset_RegisterRestore() 只保存一个恢复函数,后注册的会把先注册的埋了。当前只有一个恢复入口时没问题;如果以后多个模块都想恢复 UI/背光,要么在文档里写清楚“只能有一个”,要么支持组合/列表。
7)小细节:一个没用的 include
协调器里 include 了一个同步头,但主流程并没有调用对应函数。不影响功能,只是洁癖会皱眉。代码 review 不就是为了让洁癖有事可做吗。
6. 测试清单:把“万一”挨个点名
恢厂这种低频高危流程,测试不能只测“正常点一下”。
正常路径
- 事件能收到,订阅成功后重试定时器停止。
- 执行顺序是
HIGH → MEDIUM → LOW,同档按注册顺序。 - 黑屏先执行,用户看不到中间状态。
- 停地图/导航在切默认主题之前。
- 切默认主题在删用户数据之前。
- 停 UI 在切主题之后。
- 全部成功只重启一次。
失败路径
- 每个优先级各注入一次失败:停止后续、恢复界面、不重启。
- 切默认主题失败时,不再删除任何用户数据。
- 失败后重新触发,能再跑一次。
- 恢复函数能把背光和界面拉回来。
- handler 超时返回非 0,框架按失败处理,不假装成功。
边界
- 运行中重复收到事件:忽略,不重入。
- 开机 5 秒内收到事件:忽略;同时确认产品能接受这次事件丢失,或上游会重发。
- 没有任何参与者注册:不重启,打 error,调恢复。
- 注册表满:注册返回 false,日志能看出来是谁没进去。
- 清理过程中掉电:下次开机不崩;每个 handler 尽量原子/可重试,不出现半套数据。
- 旧版本留下的临时标记/回收站目录:升级流程主动清,新框架不背旧账。
构建
- 宏 = 1:只编新框架,旧协调器不在。
- 宏 = 0:只编旧协调器,新框架符号不在。
- 用
nm检查两边没有交叉符号。 - 应用和库的宏值一致,没有“一边新一边旧”。
7. 最后的碎碎念
好的恢厂框架像体检套餐:前台只负责喊号、记录、最后关灯;每个科室自己检查自己的项目。框架不需要知道你看的是眼科还是口腔,也不需要知道你的默认主题叫什么。
恢厂不是删库跑路,是有秩序地搬家:先关灯,再停手,再打包,最后锁门。
这次提交最大的价值,不是多了几百行代码,而是把“谁该负责什么”写清楚了。代码会过期,边界不会。
下次有人问:“加一个清理模块要不要改框架?”
答案应该是:
不用,去注册。
更多推荐
所有评论(0)