当保存参数使用结构体时必备的开发技巧方式

1、前言

想必做嵌入式产品开发都遇到过设备需要保存参数,常用的方式就是按照结构体的方式管理参数,保存时将整个结构体数据保存在 Flash 中,方便下次读取。

1.1、目的

本文时分析嵌入式/单片机中参数保存的几种方式的优点和缺点(仅针对单片机/嵌入式开发而言),同时针对以结构体的方式解决一些弊端问题(重点在第 3 节)。


2、参数保存格式

2.1、结构体格式

该方式是嵌入式/单片机中开发最常用的,将所有的系统参数通过结构体的方式定义,然后保存数据,介绍一下该方式的优缺点。

储存方式:二进制 bin 文件格式

优点:

  1. 管理简单:无需额外的代码直接就能很方便的管理参数
  2. 内存最小:通过结构体的形式保存在Flash中,占用内存最小

缺点:

  1. 扩展性差:
    1. 从产品角度来说,产品需要升级,若是涉及增加参数,则升级后参数通常无法校验通过(通常包含长度校验等),导致参数被恢复默认
    2. 若是每个模块都存在自己的独有结构体参数定义,删除/新增时势必影响到其他的,导致设备升级后参数错乱(结构体中的变量地址在 bin 文件中是固定的)
  2. 阅读性差:若参数需要导出,bin文件没有可读性

改进措施:

结构体增加预留定义,若之后需要新增参数,则在预留空间新增即可,能在一定程度上解决扩展性差的问题,即新增不影响原有的结构体大小和其他成员变量的位置,删除恢复成预留即可。

为啥说只能在一定程度上解决该问题,因为之后的升级某些模块可能很长时间或者从不需要增加新的参数,这种势必就会造成内存的无效占用,或者有些模块频繁增加参数导致预留大小不够等问题,只能在前期设计时多加思考预留的分配情况(毕竟内存只有那么大)

/*****************************
           改进之前
*****************************/

typedef struct
{
    uint8_t testParam;
    uint8_t testParam2;
} TestParam_t;    /* 某模块参数 */

typedef struct
{
    uint8_t testParam;
    uint8_t testParam2;
    TestParam_t tTestParam;
} SystemParam_t; /* 系统参数 */

/*****************************
           改进之后
*****************************/

typedef struct
{
    uint8_t testParam;
    uint8_t testParam2;
    uint8_t reserve[6];    // 预留
} TestParam_t;    /* 某模块参数 */

typedef struct
{
    uint8_t testParam;
    uint8_t testParam2;
    TestParam_t tTestParam;
    uint8_t reserve[50];   // 预留
} SystemParam_t; /* 系统参数 */

2.2、JSON格式

最近Json格式很是流行使用,特别是数据交换中用的很多,但是它也可以用来保存参数使用,JSON 的是 “{键:值}” 的方式。

储存方式:字符串格式,即文本的形式

优点:

  1. 扩展性好:由于Json的格式,找到对应键值(一般都是该变量的标识),就能找到对应的值
  2. 阅读性好:有标识所以导出参数文件通过普通的文本文件打开都能看懂

缺点:

  1. 管理相对复杂:没有结构体那么简单,不熟还得先学习 JSON 的写法
  2. 内存占用较大:内容不只有值,而且都按照字符串的形式保存的
  3. 使用相关困难:需要解析,C语言虽然有开源库,但是由于语言性质使用不方便,C++ 反而使用简单
{
    "SYS":
    {
        "testParam" : 2,
        "testParam2" : 5,
        "tTestParam":
        {
            "testParam" : 2,
            "testParam2" : 5
        }
    }
}

//压缩字符串为:
{"SYS":{"testParam":2,"testParam2":5,"tTestParam":{"testParam":2,"testParam2":5}}}

2.3、键值格式

和上述的 JSON 格式很类似,都是键值对的格式,但是比JSON简单

储存方式:字符串格式,即文本的形式

优点:

  1. 扩展性好:找到对应键值(一般都是该变量的标识),就能找到对应的值
  2. 阅读性好:有标识所以导出参数文件通过普通的文本文件打开都能看懂

缺点:

  1. 内存占用较大:内容不只有值,而且都按照字符串的形式保存的
  2. 使用稍微困难:需要简单解析处理
  3. 管理不变:不方便按照一定的规则管理各模块的参数
testParam=2
testParam2=5
T_testParam=2
T_testParam2=5

2.4 其他

还有其他,如 xml (类似JSON)等,就不多介绍了


3、编译器检查结构体的大小和成员变量的偏移

在第 2 节中介绍了关于参数保存的三种方式,但是对于嵌入式单片机开发而言,Flash 大小不富裕,所以通常都是通过二进制的形式保存的,所以这节重点解决结构体管理保存参数的扩展性问题。

先说一下痛点(虽然对扩展性问题做了改进措施,除了前面讲到的问题,还有其他痛点,虽不算问题,但是一旦出现往往最要命)

  1. 在原来的预留空间中新增参数,要确保新增后结构体的大小不变,否则会导致后面的其他参数偏移,最后升级设备后参数出现异常(如果客户升级那就是要命啊)
  2. 确保第一点,就必须在每次新增参数都要计算检查一下结构体的大小有没有发生变化,而且有没有对结构体中的其他成员也产生影响

每次新增参数,手动计算和校验 99% 可以检查出来,但是人总有粗心的时候(加班多了,状态不好…),且结构体存在填充,一不留神就以为没问题,提交代码,出版本(测试不一定能发现),给客户,升级后异常,客户投诉、扣工资(难啊….)

遇到这种问题后:难道编译器就不能在编译的时候检查这个大小或者结构体成员的偏移吗,每次手动计算校验好麻烦啊,一不留神还容易算错 # _ #

按照正常情况,编译器可不知道你写的结构体大小和你想要的多大,所以检查不出来(天啊,崩溃了0.0….)

别急,有另类的方式可以达到这种功能,在编译时让编译器为你检查,而且准确性 100%(当然,这个添加新参数时你还得简单根据新增的参数大小减少预留的大小,这个是必须要的)

见代码:

/**
  * @brief 检查结构体大小是否符合
  *        在编译时会进行检查
  * @param type 结构体类型
  * @param size 结构体检查大小
  */
#define TYPE_CHECK_SIZE(type, size) extern int sizeof_##type##_is_error [!!(sizeof(type)==(size_t)(size)) - 1]

/**
  * @brief 结构体成员
  * @param type   结构体类型
  * @param member 成员变量
  */
#define TYPE_MEMBER(type, member) (((type *)0)->member)


/**
  * @brief 检查结构体成员大小是否符合
  *        在编译时会进行检查
  * @param type 结构体类型
  * @param member 结构体类型
  * @param size 结构体检查大小
  */
#define TYPE_MEMBER_CHECK_SIZE(type, member, size) extern int sizeof_##type##_##member##_is_error \
    [!!(sizeof(TYPE_MEMBER(type, member))==(size_t)(size)) - 1]


/**
  * @brief 检查结构体中结构体成员大小是否符合
  *        在编译时会进行检查
  * @param type 结构体类型
  * @param member 结构体类型
  * @param size 结构体检查大小
  */
#define TYPE_CHILDTYPE_MEMBER_CHECK_SIZE(type, childtype, member, size) extern int sizeof_##type##_##childtype##_##member##_is_error \
    [!!(sizeof(TYPE_MEMBER(type, childtype.member))==(size_t)(size)) - 1]


/**
  * @brief 检查结构体成员偏移位置是否符合
  *        在编译时会进行检查
  * @param type 结构体类型
  * @param member 结构体成员
  * @param value 成员偏移
  */
#define TYPE_MEMBER_CHECK_OFFSET(type, member, value) \
         extern int offset_of_##member##_in_##type##_is_error \
        [!!(__builtin_offsetof(type, member)==((size_t)(value))) - 1]


/**
  * @brief 检查结构体成员偏移位置是否符合
  *        在编译时会进行检查
  * @param type 结构体类型
  * @param member 结构体成员
  * @param value 成员偏移
  */
#define TYPE_CHILDTYPE_MEMBER_CHECK_OFFSET(type, childtype, member, value) \
         extern int offset_of_##member##_in_##type##_##childtype##_is_error \
        [!!(__builtin_offsetof(type, childtype.member)==((size_t)(value))) - 1]

通过以上代码,就能解决这个问题,这个写法只占用文本大小,编译后不占内存!!!

用法:

typedef struct
{
    uint8_t testParam;
    uint8_t testParam2;
    uint8_t reserve[6];    // 预留
} TestParam_t;    /* 某模块参数 */

TYPE_CHECK_SIZE(TestParam_t, 8); // 检查结构体的大小是否符合预期

typedef struct
{
    uint8_t testParam;
    uint8_t testParam2;
    TestParam_t tTestParam;
    uint8_t reserve[54];   // 预留
} SystemParam_t; /* 系统参数 */

TYPE_CHECK_SIZE(SystemParam_t, 64); // 检查结构体的大小是否符合预期
TYPE_MEMBER_CHECK_OFFSET(SystemParam_t, tTestParam, 2); // 检查结构体成员tTestParam偏移是否符合预期

假设新增了参数,预留写错了,导致结构体的大小不符合,则编译时报错,且提示内容也能快速定位问题。


关于这种方式的检查,你了解或者能理解多少呢?有兴趣的朋友可以留下你的评论。

张贴在2