一、设置项为什么会失控
我做的第一台带段码屏和键盘的设备,最初只有十来个设置项,写法很直接:
一个 switch 按序号分支,每支里写“怎么显示、怎么改、合法范围是多少、按什么键保存”。
这种写法在前十项时很舒服,问题出在后面。
设置项涨到一百多项之后,出现了三种症状:
- 重复代码爆炸。一百多个分支里,真正不同的只有“名字、范围、显示格式”, 但每个分支都要把那套通用流程再抄一遍。
- 改一处要改一片。加入“高级密码才能改”这个规则时, 我需要在一百多个分支里逐个判断——漏掉一个就是一个越权入口。
- 无法一眼看出全貌。想知道“哪些设置项用户能改、哪些只有厂家能改”, 只能一个分支一个分支去读。
真正的转折是意识到:这些分支之间 90% 是相同的,把“相同”抽出来, 剩下的 10% 其实只是数据。于是就有了下面这张表。
二、把设置项做成一张表:四元组
做法很朴素:把所有设置项的差异,压缩成每项四个字段, 按序号排成一张常量表。
| 字段 | 含义 | 被谁使用 |
|---|---|---|
| 最小值 | 该项允许的下界 | 编辑时的范围校验、非法值拦截 |
| 最大值 | 该项允许的上界 | 同上 |
| 出厂值 | 恢复出厂设置时写入的值 | 出厂恢复流程、参数区损坏时的兜底 |
| 类型标志 | 一个位域,编码“显示格式、权限等级、是否落盘” | 显示层、权限层、存储层 |
表本身就是一个常量数组,“序号”就是数组下标,所以增删一项参数只需要动一行数据, 不需要碰任何逻辑代码。
/* 参数表的一项:四个字段描述一个设置项的全部差异 */
typedef struct {
uint16_t min; /* 允许下界 */
uint16_t max; /* 允许上界 */
uint16_t def; /* 出厂值 */
uint16_t flag; /* 类型标志位域:显示格式 / 权限等级 / 是否落盘 */
} param_item_t;
/* 表按序号排列,序号即下标。新增一项 = 新增一行,不改逻辑 */
static const param_item_t param_table[] = {
/* { min, max, def, flag } */
{ 0, 9, 0, 0x0001 }, /* 示意:一位显示、出厂 0 */
{ 0, 9999, 1000, 0x0102 }, /* 示意:两位小数、需基础权限 */
/* ... */
};
/* 通用校验:所有设置项共用同一段逻辑 */
static int param_is_valid(uint16_t index, uint16_t value)
{
if (index >= sizeof(param_table) / sizeof(param_table[0])) return 0;
return (value >= param_table[index].min && value <= param_table[index].max);
}有了这张表,“范围校验”这个功能一次写完,一百多项全部生效, 而且不可能漏——因为漏掉的那一项在表里没有 min/max,编译期或加载时就会暴露。
三、类型标志:一个位域同时解决四件事
这套设计里最关键、也最需要提前想清楚的是那个“类型标志”字段。 我把它按十进制位拆成三段来用,这样人看数字就能读出来:
| 位段 | 含义 | 取值 |
|---|---|---|
| 末位 | 查看/修改权限 | 0 = 不可查看;1 = 需基础密码;2 = 需高级密码 |
| 次末位 | 显示时的小数位数 | 0 = 无小数;1 = 一位小数;2 = 两位小数 |
| 首位 | 是否落盘 | 0 = 普通参数,保存到存储器;1 = 功能触发项,不保存值 |
这样编码的好处是可读:0x102 一眼看出“需基础权限、两位小数、普通参数”。
比用三个独立的布尔字段省空间,也比用枚举更紧凑。
由这一个字段,显示层可以自动决定“这个值该显示成 12 还是 1.2 还是 0.12”, 权限层可以自动决定“当前密码等级能不能改它”, 存储层可以自动决定“这个值要不要写进存储器”。三层的逻辑都只有一份。
四、由此“免费”得到的能力
这张表的收益可以列成一张清单:
| 能力 | 原来怎么写 | 现在怎么写 |
|---|---|---|
| 显示格式化 | 每项自己拼字符串、自己插小数点 | 读类型标志里的小数位数,统一格式化 |
| 范围校验 | 每项自己写上下界判断 | 读 min/max,统一校验 |
| 恢复出厂 | 一大段赋值语句,容易漏项 | 遍历表,把出厂值写回去,一项不漏 |
| 权限分级 | 每项自己判断密码等级 | 读类型标志的权限段,统一判断 |
| 参数自检 | 没有 | 加载后遍历表逐项校验,越界即回出厂值并记录 |
| 设置项清单 | 要靠读代码整理 | 表本身就是清单,可以导出成文档 |
其中“参数自检”是加了这张表之后才顺手能做出来的: 因为有了 min/max,启动时就可以遍历一遍所有参数, 把越界的值替换成出厂值并记一条事件。 这比“整体 CRC 通过就认为参数没问题”要强得多—— CRC 只能证明数据没被破坏,证明不了数据是对的。
五、这套做法的代价与坑
这个做法我用了几年,确实省事,但它不是没有代价。下面几条都是我实际撞过的。
5.1 “功能触发项”混进参数表,是个不干净的设计
表里有一部分项其实不是“值”,而是“动作”—— 比如“恢复出厂设置”“传感器自检”这类,它们的语义是“执行一次”而不是“存一个数”。 我把它们也放进了同一张表(用类型标志的首位区分),好处是显示和权限逻辑可以复用, 坏处是语义变混了:本该是“命令”的东西伪装成了“参数”, 读代码的人会以为它有个持久化的值。
更干净的做法是把“参数表”和“命令表”分开,两者共享显示与权限逻辑, 但数据结构各管各的。
5.2 位域编码的语义只活在注释里
位域省空间又好读,但它的含义没有类型系统保护。 半年后回来看,或者换一个人看,“首位是 1 表示什么”必须去翻注释。 我现在的做法是:在定义旁边用宏把位段命名, 并且给几个典型组合定义成有名字的常量,让代码里尽量不出现裸的十六进制数。
5.3 默认值改过,但没有版本号
有一次调整了某项参数的默认显示值(把偏大的初值改成更合理的小值)。 问题是:已经出厂、参数区里已经存了老值的设备,升级固件后不会自动变成新值—— 因为它们读的是存储里的值,而存储里是合法的,不会被自检拦下。
这就引出参数表必须配的一个东西:参数版本号。 固件启动时先读版本号,如果发现是老版本,就执行一次迁移逻辑 (并把新版本号写回去)。没有版本号,参数结构的任何演进都会变成“要么不动、要么人工处理”。
5.4 一个值放不下,就变成两个设置项
表项是定宽的,遇到需要更大范围的值(比如单价这种要更多位数的量), 自然就会拆成“高位数”和“低位数”两个设置项。 这在功能上可行,但对用户不友好—— 设置一个单价要改两项,还要保证两项合起来落在合法区间里。
我现在倾向的做法是:为这类值单独定义“复合项”类型, 在表里占两个相邻位置、由一个编辑入口统一处理, 校验也在编辑入口里按组合后的值做,而不是按拆开的半截值做。
六、哪些东西不该放进这张表
用了几年之后,我给自己划了一条边界:
| 适合放进表里 | 不适合放进表里 |
|---|---|
| 用户或产线需要调整的数值(阈值、延时、系数、编号) | 只有开发者才关心的编译期常量(缓冲区长度、任务栈大小) |
| 有明确上下界、可以用一个整数表达的量 | 结构化数据(比如一整张列表、一段字符串、一组映射关系) |
| 需要在本地界面上显示与编辑的项 | 纯内部状态(累计值、临时标志、校验字) |
| 需要按权限分级的项 | 安全凭据本身(密码应当单独存储、单独处理,不要混在普通设置项里) |
最后一条特别重要:把密码做成“第一百零几号设置项”是很常见的做法,但它不合适。 密码需要单独的存储位置、单独的读写接口、单独的清除流程, 混进通用参数表之后,它会被“恢复出厂设置”一起改掉、 会被“导出参数”一起导出去、会在调试打印里被一起打出来。
七、我现在的做法
- 参数表只放“数值型设置项”,命令与结构化配置各用各的结构。
- 类型标志用宏命名位段,并给常见组合起名字,避免裸十六进制。
- 参数区头部放“参数版本号”,任何结构演进都走一次显式迁移。
- 启动时遍历全表做一次自检,越界即回出厂值并记事件。
- 把表导出成文档作为设置项清单,评审时对着它看有没有越权或缺项。
- 密码、密钥这类凭据单独存放,不混进通用参数表。
- 定期盘点设置项数量,该删的删、该收进厂家菜单的收进去。
如果让我只用一句话总结这套设计的价值: 它把“容易漏”的三件事——范围校验、权限判断、出厂恢复—— 从“每项都要记得写”变成了“结构上不可能漏”。 这类“用结构消除错误可能”的改动,收益往往比优化算法大得多。
八、几条我反复用到的经验
- 重复到第三次就该抽出来了。抽出来的往往不是函数,而是一张表。
- 能放进表里的规则,就不要放进分支。表可以被遍历、被导出、被校验,分支不能。
- 位域要配宏,不要裸写数字。省下来的空间不值得换来半年后的困惑。
- 参数结构一定会演进,所以一定要有版本号。要么现在就加,要么将来人工处理。
- CRC 通过只说明没坏,不说明是对的。逐项范围检查才能真正兜住。
- 方便的东西会膨胀。加设置项容易,就更要定期做减法。
参考资料与说明
- C 语言中位域、结构体填充与常量表在只读段中存储行为的通用说明(各编译器手册)。
- 所用 MCU 的参考手册中关于非易失存储读写与参数区划分的章节。
- 本文将个人项目中使用过的一种参数组织方式做了抽象归纳, 其中具体的参数项、取值范围与出厂值均为产品相关内容,未予公开; 文中示意代码为按个人理解重写的通用片段,不代表任何产品或交付代码。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码。
- 文中芯片与工具名称仅用于说明技术方案,与相关厂商无隶属或授权关系。