第13章:配置模块的设计:配置文件解析(INI/JSON)、配置项的集中管理、实战:设计一个可扩展的配置模块

配置模块,说白了就是给程序一个「变通」的能力。你写死的代码是骨架,配置就是血肉。我见过太多项目,一开始图省事把参数硬编码,结果后期每次改参数都要重新编译、烧录,那叫一个痛苦。

这一章,我们就来聊聊怎么设计一个靠谱的配置模块。我会从最基础的配置文件解析讲起,再到如何集中管理配置项,最后带大家手撸一个可扩展的配置模块。

13.1 为什么需要配置模块?

你想想看,一个嵌入式产品,可能面对不同的客户、不同的应用场景。比如一个温控器,国内用摄氏度,国外用华氏度。如果没有配置模块,你就得维护两套代码。有了配置模块,改个配置文件就行。

我个人习惯,只要项目里出现超过3个需要「根据环境变化」的参数,我就会上配置模块。别等到项目做大了再重构,那成本太高了。

13.2 配置文件格式的选择:INI vs JSON

嵌入式领域,最常见的两种配置文件格式就是INI和JSON。它们各有千秋,我分别说说。

13.2.1 INI格式

INI格式简单、直观,非常适合资源受限的MCU。它的结构就是「节(Section)」+「键值对(Key=Value)」。

// config.ini 示例
[system]
baudrate=115200
log_level=3

[sensor]
temp_offset=2.5
humidity_calibration=1.02

解析INI文件,说白了就是逐行读取,遇到 [section] 就切换当前节,遇到 key=value 就存起来。我早期做的一个项目,用了一个不到200行的INI解析器,跑在Cortex-M0上,稳得很。

13.2.2 JSON格式

JSON表达能力更强,支持嵌套结构、数组、多种数据类型。但代价是解析器更复杂,内存开销也更大。

// config.json 示例
{
    "system": {
        "baudrate": 115200,
        "log_level": 3
    },
    "sensor": {
        "temp_offset": 2.5,
        "humidity_calibration": 1.02
    }
}

如果你的MCU有几百KB的RAM,用JSON完全没问题。但如果是几KB的RAM,我建议还是老老实实用INI。

我的建议:
  • RAM < 32KB:用INI,解析简单,内存占用小
  • RAM > 128KB:可以用JSON,灵活性更高
  • 中间地带:看团队习惯,我个人偏向INI

13.3 配置项的集中管理

配置文件解析出来之后,不能散落在各个模块里。你需要一个「配置中心」,统一管理所有配置项。

我常用的做法是定义一个配置结构体,把所有配置项都放在里面。然后提供一个全局的配置句柄,各个模块通过接口来读写。

// config_manager.h
typedef struct {
    // system section
    uint32_t baudrate;
    uint8_t  log_level;
    
    // sensor section
    float    temp_offset;
    float    humidity_calibration;
    
    // 预留扩展字段
    void*    ext_data;
} config_t;

// 初始化配置模块
int config_init(const char* filepath);

// 获取配置项(只读)
const config_t* config_get(void);

// 更新配置项(带校验)
int config_set_baudrate(uint32_t baudrate);
int config_set_log_level(uint8_t level);

为什么要用接口而不是直接暴露结构体?嗯,这里要注意:直接暴露结构体,别人就能随意修改,容易出问题。通过接口,你可以在设置值时做校验,比如波特率不能是0,日志级别不能超过5。

小技巧: 配置结构体里留一个 void* ext_data 指针,用于扩展。这样以后加新配置项,不用改结构体定义,只需要在外部定义自己的扩展结构体,然后挂到这个指针上。

13.4 实战:设计一个可扩展的配置模块

好,理论说完了,我们来动手。我会设计一个支持INI和JSON双格式的配置模块,并且预留扩展接口。

13.4.1 整体架构

先画个图,让大家对整体结构有个概念。

配置模块架构图 配置文件层 config.ini / config.json 解析器层 INI解析器 / JSON解析器(通过函数指针切换) 配置管理层 配置结构体 + 读写接口 + 校验逻辑 应用层(各业务模块)

13.4.2 核心代码实现

先定义解析器的接口。这样不管底层是INI还是JSON,上层调用都一样。

// parser_interface.h
typedef struct {
    int (*parse)(const char* filepath, config_t* cfg);
    int (*save)(const char* filepath, const config_t* cfg);
} parser_ops_t;

// 注册解析器
int config_register_parser(parser_ops_t* ops);

然后实现INI解析器。这里我只展示核心逻辑,完整的解析器代码量比较大,但思路就是逐行扫描。

// ini_parser.c
#include "parser_interface.h"
#include 
#include 

static int ini_parse(const char* filepath, config_t* cfg) {
    FILE* fp = fopen(filepath, "r");
    if (!fp) return -1;
    
    char line[128];
    char current_section[32] = {0};
    
    while (fgets(line, sizeof(line), fp)) {
        // 去掉换行符
        line[strcspn(line, "\n")] = 0;
        
        // 跳过空行和注释
        if (line[0] == '\0' || line[0] == ';' || line[0] == '#') continue;
        
        // 检查是否是节
        if (line[0] == '[') {
            sscanf(line, "[%[^]]]", current_section);
            continue;
        }
        
        // 解析键值对
        char key[32], value[64];
        if (sscanf(line, "%[^=]=%s", key, value) == 2) {
            // 根据 current_section 和 key 设置 cfg 的对应字段
            if (strcmp(current_section, "system") == 0) {
                if (strcmp(key, "baudrate") == 0) cfg->baudrate = atoi(value);
                else if (strcmp(key, "log_level") == 0) cfg->log_level = atoi(value);
            } else if (strcmp(current_section, "sensor") == 0) {
                if (strcmp(key, "temp_offset") == 0) cfg->temp_offset = atof(value);
                else if (strcmp(key, "humidity_calibration") == 0) cfg->humidity_calibration = atof(value);
            }
        }
    }
    
    fclose(fp);
    return 0;
}

static parser_ops_t ini_ops = {
    .parse = ini_parse,
    .save = NULL  // 保存功能类似,这里省略
};

13.4.3 配置管理器的实现

配置管理器负责协调解析器和上层应用。

// config_manager.c
#include "config_manager.h"

static config_t g_config;
static parser_ops_t* g_parser = NULL;

int config_init(const char* filepath) {
    // 设置默认值
    memset(&g_config, 0, sizeof(g_config));
    g_config.baudrate = 9600;      // 默认波特率
    g_config.log_level = 2;        // 默认日志级别
    g_config.temp_offset = 0.0f;
    g_config.humidity_calibration = 1.0f;
    
    if (!g_parser) return -1;      // 没有注册解析器
    
    return g_parser->parse(filepath, &g_config);
}

int config_register_parser(parser_ops_t* ops) {
    if (!ops || !ops->parse) return -1;
    g_parser = ops;
    return 0;
}

const config_t* config_get(void) {
    return &g_config;
}

int config_set_baudrate(uint32_t baudrate) {
    // 校验:波特率必须是标准值
    if (baudrate != 9600 && baudrate != 19200 && 
        baudrate != 38400 && baudrate != 115200) {
        return -1;  // 非法波特率
    }
    g_config.baudrate = baudrate;
    return 0;
}
避坑指南: 我曾经在一个项目里,配置模块没有做校验,结果有人把波特率设成了123456,导致设备无法通信。从那以后,我所有的配置设置接口都加了校验逻辑。记住:永远不要相信外部输入。

13.4.4 如何使用

在main函数里,使用起来非常简单:

// main.c
#include "config_manager.h"
#include "ini_parser.h"  // 或者 json_parser.h

int main(void) {
    // 注册解析器
    config_register_parser(&ini_ops);
    
    // 初始化配置
    if (config_init("config.ini") != 0) {
        printf("配置加载失败,使用默认配置\n");
    }
    
    // 读取配置
    const config_t* cfg = config_get();
    printf("当前波特率: %d\n", cfg->baudrate);
    
    // 修改配置(带校验)
    if (config_set_baudrate(115200) == 0) {
        printf("波特率修改成功\n");
    }
    
    // ... 其他业务逻辑
}

13.5 可扩展性设计

这个模块的可扩展性体现在哪里?我总结了几点:

  • 解析器可替换: 通过函数指针,INI和JSON解析器可以无缝切换。以后想支持YAML?写个YAML解析器,注册进来就行。
  • 配置项可扩展: 结构体里的 ext_data 指针,可以指向任意扩展结构。比如某个模块需要自己的配置,不用改核心代码。
  • 校验逻辑可定制: 每个配置项的设置接口都可以独立加校验,不影响其他项。
核心思想: 配置模块的设计,本质上是在「灵活性」和「安全性」之间找平衡。太灵活容易出bug,太死板又不好用。我个人习惯是:核心配置严格校验,扩展配置宽松管理。

13.6 小结

这一章我们聊了配置模块的设计思路。从INI和JSON的选型,到配置项的集中管理,再到一个可扩展的配置模块实战。核心就三点:解析器接口化、配置结构体统一管理、设置接口带校验。

配置模块看起来简单,但设计得好,能省很多后期维护的功夫。我见过太多项目,配置散落在各个.c文件里,改一个参数要找半天。嗯,希望这一章能帮你避免这种坑。

我的习惯: 每次新项目启动,我第一件事就是搭配置模块的架子。哪怕一开始只有两三个配置项,也按这个架构来。因为我知道,项目做到后面,配置项只会越来越多。

公众号:蓝海资料掘金营,微信deep3321