本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

参数表设计:
用一张四元组表撑起校验、格式化与权限

一台带显示的仪表,可设置项很容易堆到一百多项。如果每一项都单独写显示格式、 范围校验、出厂值和权限判断,主程序会被大量重复的分支淹没。 这篇记录的是一种“一张表驱动全部行为”的做法: 每项参数只描述四个字段,其余能力全部由通用逻辑推导出来。

参数管理 配置表 人机界面 代码结构 已发布

一、设置项为什么会失控

我做的第一台带段码屏和键盘的设备,最初只有十来个设置项,写法很直接: 一个 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,编译期或加载时就会暴露。

中心是“参数表(最小值/最大值/出厂值/类型标志)”,四个箭头指向四周的显示层、权限层、校验层、存储层,每个箭头标注读取
图 1 · 一张表驱动四层逻辑的结构图

三、类型标志:一个位域同时解决四件事

这套设计里最关键、也最需要提前想清楚的是那个“类型标志”字段。 我把它按十进制位拆成三段来用,这样人看数字就能读出来:

位段含义取值
末位 查看/修改权限 0 = 不可查看;1 = 需基础密码;2 = 需高级密码
次末位 显示时的小数位数 0 = 无小数;1 = 一位小数;2 = 两位小数
首位 是否落盘 0 = 普通参数,保存到存储器;1 = 功能触发项,不保存值

这样编码的好处是可读:0x102 一眼看出“需基础权限、两位小数、普通参数”。 比用三个独立的布尔字段省空间,也比用枚举更紧凑。

由这一个字段,显示层可以自动决定“这个值该显示成 12 还是 1.2 还是 0.12”, 权限层可以自动决定“当前密码等级能不能改它”, 存储层可以自动决定“这个值要不要写进存储器”。三层的逻辑都只有一份。

一个整数被拆成三段并放大:末位是权限等级、次末位是显示小数位数、首位是是否落盘,每段给出取值含义
图 2 · 类型标志位段的拆解图

四、由此“免费”得到的能力

这张表的收益可以列成一张清单:

能力原来怎么写现在怎么写
显示格式化每项自己拼字符串、自己插小数点读类型标志里的小数位数,统一格式化
范围校验每项自己写上下界判断读 min/max,统一校验
恢复出厂一大段赋值语句,容易漏项遍历表,把出厂值写回去,一项不漏
权限分级每项自己判断密码等级读类型标志的权限段,统一判断
参数自检没有加载后遍历表逐项校验,越界即回出厂值并记录
设置项清单要靠读代码整理表本身就是清单,可以导出成文档

其中“参数自检”是加了这张表之后才顺手能做出来的: 因为有了 min/max,启动时就可以遍历一遍所有参数, 把越界的值替换成出厂值并记一条事件。 这比“整体 CRC 通过就认为参数没问题”要强得多—— CRC 只能证明数据没被破坏,证明不了数据是对的。

五、这套做法的代价与坑

这个做法我用了几年,确实省事,但它不是没有代价。下面几条都是我实际撞过的。

5.1 “功能触发项”混进参数表,是个不干净的设计

表里有一部分项其实不是“值”,而是“动作”—— 比如“恢复出厂设置”“传感器自检”这类,它们的语义是“执行一次”而不是“存一个数”。 我把它们也放进了同一张表(用类型标志的首位区分),好处是显示和权限逻辑可以复用, 坏处是语义变混了:本该是“命令”的东西伪装成了“参数”, 读代码的人会以为它有个持久化的值。

更干净的做法是把“参数表”和“命令表”分开,两者共享显示与权限逻辑, 但数据结构各管各的。

5.2 位域编码的语义只活在注释里

位域省空间又好读,但它的含义没有类型系统保护。 半年后回来看,或者换一个人看,“首位是 1 表示什么”必须去翻注释。 我现在的做法是:在定义旁边用宏把位段命名, 并且给几个典型组合定义成有名字的常量,让代码里尽量不出现裸的十六进制数。

5.3 默认值改过,但没有版本号

有一次调整了某项参数的默认显示值(把偏大的初值改成更合理的小值)。 问题是:已经出厂、参数区里已经存了老值的设备,升级固件后不会自动变成新值—— 因为它们读的是存储里的值,而存储里是合法的,不会被自检拦下。

这就引出参数表必须配的一个东西:参数版本号。 固件启动时先读版本号,如果发现是老版本,就执行一次迁移逻辑 (并把新版本号写回去)。没有版本号,参数结构的任何演进都会变成“要么不动、要么人工处理”。

5.4 一个值放不下,就变成两个设置项

表项是定宽的,遇到需要更大范围的值(比如单价这种要更多位数的量), 自然就会拆成“高位数”和“低位数”两个设置项。 这在功能上可行,但对用户不友好—— 设置一个单价要改两项,还要保证两项合起来落在合法区间里。

我现在倾向的做法是:为这类值单独定义“复合项”类型, 在表里占两个相邻位置、由一个编辑入口统一处理, 校验也在编辑入口里按组合后的值做,而不是按拆开的半截值做。

六、哪些东西不该放进这张表

用了几年之后,我给自己划了一条边界:

适合放进表里不适合放进表里
用户或产线需要调整的数值(阈值、延时、系数、编号) 只有开发者才关心的编译期常量(缓冲区长度、任务栈大小)
有明确上下界、可以用一个整数表达的量 结构化数据(比如一整张列表、一段字符串、一组映射关系)
需要在本地界面上显示与编辑的项 纯内部状态(累计值、临时标志、校验字)
需要按权限分级的项 安全凭据本身(密码应当单独存储、单独处理,不要混在普通设置项里)

最后一条特别重要:把密码做成“第一百零几号设置项”是很常见的做法,但它不合适。 密码需要单独的存储位置、单独的读写接口、单独的清除流程, 混进通用参数表之后,它会被“恢复出厂设置”一起改掉、 会被“导出参数”一起导出去、会在调试打印里被一起打出来。

七、我现在的做法

  1. 参数表只放“数值型设置项”,命令与结构化配置各用各的结构。
  2. 类型标志用宏命名位段,并给常见组合起名字,避免裸十六进制。
  3. 参数区头部放“参数版本号”,任何结构演进都走一次显式迁移。
  4. 启动时遍历全表做一次自检,越界即回出厂值并记事件。
  5. 把表导出成文档作为设置项清单,评审时对着它看有没有越权或缺项。
  6. 密码、密钥这类凭据单独存放,不混进通用参数表。
  7. 定期盘点设置项数量,该删的删、该收进厂家菜单的收进去。

如果让我只用一句话总结这套设计的价值: 它把“容易漏”的三件事——范围校验、权限判断、出厂恢复—— 从“每项都要记得写”变成了“结构上不可能漏”。 这类“用结构消除错误可能”的改动,收益往往比优化算法大得多。

八、几条我反复用到的经验

  1. 重复到第三次就该抽出来了。抽出来的往往不是函数,而是一张表。
  2. 能放进表里的规则,就不要放进分支。表可以被遍历、被导出、被校验,分支不能。
  3. 位域要配宏,不要裸写数字。省下来的空间不值得换来半年后的困惑。
  4. 参数结构一定会演进,所以一定要有版本号。要么现在就加,要么将来人工处理。
  5. CRC 通过只说明没坏,不说明是对的。逐项范围检查才能真正兜住。
  6. 方便的东西会膨胀。加设置项容易,就更要定期做减法。

参考资料与说明

  • C 语言中位域、结构体填充与常量表在只读段中存储行为的通用说明(各编译器手册)。
  • 所用 MCU 的参考手册中关于非易失存储读写与参数区划分的章节。
  • 本文将个人项目中使用过的一种参数组织方式做了抽象归纳, 其中具体的参数项、取值范围与出厂值均为产品相关内容,未予公开; 文中示意代码为按个人理解重写的通用片段,不代表任何产品或交付代码。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码。
  • 文中芯片与工具名称仅用于说明技术方案,与相关厂商无隶属或授权关系。