最近在修 QAC 报的一些错误,也因此重新认识了很多规则。比如:
- POD
constexpr- 禁止全局构造
- 控制
static - Init 顺序
开始的时候,我不以为然,只是当成要做的任务一样,枯燥乏味,但总是要做的。唯一的成就感是看着自己负责模块的 issues 数字越来越少,当然里面有很大一部分是通过 // PRQA S 给屏蔽掉了。
后来,不能说是“良心发现”,大概率是不想浪费了时间却还是一知半解,这也归因于我对代码还是有点洁癖的,便开始思考规则背后的缘由,整理如下。
能跑的代码,为什么仍然是“错误”的
在一般的软件开发中,能跑就行,我们平时也看到过很多梗图。确实如此,比如 PC 上,大不了杀了重启。但在嵌入式,能跑可能还不太够。就比如哪天你开着导航还唱着歌,车机突然黑屏重启了,这忍忍也能接受,但如果涉及到驾驶安全,我们需要系统不仅“能运行”,而且要非常“可靠”。
所以,嵌入式系统更关心的是另一件事:
系统行为是否可预测。
为什么“可预测性”是嵌入式(汽车电子)的核心
嵌入式系统不是运行在资源无限、调度宽松的环境里。它通常面对的是:
- 固定周期任务
- 有限 RAM / Flash
- 中断打断
- 严格的启动顺序
- 明确的实时性要求
在这样的系统里,代码不只是“实现功能”,它还定义了系统在时间、顺序和状态上的行为。如果这些行为不可预测,系统就很难被验证,也很难被信任。
这些规则在解决什么“本质问题”
表面上看,QAC 在限制很多东西:
- 不要随便用全局变量
- 不要全局构造复杂对象
- 尽量使用
constexpr - 控制
static - 明确初始化顺序
- 避免隐式依赖
但本质上,它们是在减少三类不确定性。
1. 时间的不确定性
std::vector<int> data;
data.push_back(1);
这段代码看起来很普通,但在嵌入式里,问题是:
- 是否会触发动态分配?
- 分配需要多久?
- 是否可能失败?
- 是否会造成堆碎片?
- 最坏执行时间是多少?
如果这些问题无法回答,那它就不适合出现在实时路径里,因为没法估算可执行时间。
2. 顺序的不确定性
// A.cpp
Foo foo;
// B.cpp
Bar bar(foo);
这类代码最大的问题不是语法,而是初始化顺序不一定由你控制。所以规范会要求:
- 禁止复杂全局构造
- 全局对象只允许 POD /
constexpr - 复杂初始化放到显式
Init()阶段 - 谨慎使用静态局部变量
- 避免跨文件初始化依赖
本质上,是把“隐式顺序”变成“显式顺序”。
3. 状态的不确定性
static int state;
单就这行代码没啥问题,但如果它被多个任务、中断或模块间接访问,问题就变复杂了。全局变量和静态变量的问题,不只是“作用域大”。更大的问题是:
谁能改它?什么时候改?是否并发访问?是否有生命周期约束?
所以好的设计会尽量让状态:
- 有明确 owner
- 有明确生命周期
- 有明确访问边界
- 有明确同步策略
尽量让系统在运行前就已经确定
所以,不难理解:
constexpr的价值,是把计算提前到编译期- POD 的价值,是避免运行期构造逻辑
- 禁止全局复杂对象的价值,是避免启动阶段出现不可控行为
- 控制
static的价值,是避免隐藏状态和隐式初始化 - 显式初始化顺序的价值,是让依赖关系写在代码里,而不是交给编译器、链接器或运行时
思考模型
以后写嵌入式 C++ 时,我觉得可以反复问自己四个问题:
- 它什么时候初始化?编译期?启动?第一次调用?
- 它依赖什么?
- 它耗时是否固定?
- 它的状态谁能改?
如果以上问题都能全部回答,那你的设计就是嵌入式友好的。
嵌入式 C++ 的所有“限制”,本质不是限制功能,而是把 C++ 从“自由系统”变成“可预测”,进而“可证明”。