30、INI配置文件解析:键值对解析,节区管理,读写实现

INI文件,说白了就是最朴素的配置文件格式。你肯定见过——方括号括起来的节区名,等号连接的键值对。这东西在嵌入式系统里特别常见,因为简单、轻量、人类可读。我最早接触INI解析是在一个工控项目上,设备参数全存在一个config.ini里,当时我心想:这么简单的格式,手写解析不就完了?结果真写起来才发现,坑比想象的多。

INI文件的基本结构

先看一个典型的INI文件长什么样:

; 这是注释(分号开头)
[system]
baudrate = 115200
parity = none
stopbits = 1

[sensor]
type = temperature
range_min = -40
range_max = 85
unit = celsius

[network]
dhcp = true
ip = 192.168.1.100
mask = 255.255.255.0

结构其实就三层:

  • 注释行:以分号或井号开头,解析时直接跳过
  • 节区(Section):用方括号括起来的名称,表示一个配置分组
  • 键值对(Key-Value):等号或冒号分隔,左右两侧可以有多余空格

嗯,这里要注意:等号两边可能有空格,也可能没有。我见过有些设备生成的INI文件,等号前后各加两个空格,看着整齐但解析时得处理。

解析器的核心设计

我个人习惯把INI解析器设计成状态机。为什么?因为逐行读取时,每一行属于哪个节区、是键值对还是注释,状态非常清晰。

核心数据结构:

// 键值对节点
typedef struct kv_node {
    char key[64];
    char value[128];
    struct kv_node *next;
} kv_node_t;

// 节区节点
typedef struct section_node {
    char name[64];
    kv_node_t *kv_head;   // 该节区下的键值对链表
    struct section_node *next;
} section_node_t;

// 整个INI文件的管理结构
typedef struct {
    section_node_t *section_head;
    int section_count;
} ini_manager_t;

我用链表来管理节区和键值对,而不是数组。原因很简单:嵌入式环境里,你事先不知道配置项有多少条。链表可以动态增长,内存按需分配。我在一个项目里遇到过配置文件有200多个键值对,如果用固定数组,要么浪费内存,要么不够用。

解析流程:逐行处理

解析的核心逻辑其实就三步:

  1. 读一行,去掉首尾空白字符
  2. 判断类型:注释跳过,节区名创建新节区,键值对解析并挂到当前节区下
  3. 错误处理:格式不对的行,记录日志但不要崩

我曾经犯过一个错误:解析时遇到空行直接返回错误。结果客户反馈说他们的INI文件里习惯用空行分组,我的解析器直接罢工了。从那以后,我养成了一个习惯——对格式宽容,对逻辑严格

int ini_parse_line(ini_manager_t *mgr, const char *line, section_node_t **cur_section) {
    char buffer[256];
    strncpy(buffer, line, sizeof(buffer) - 1);
    
    // 去掉首尾空格
    char *p = buffer;
    while (*p == ' ' || *p == '\t') p++;
    size_t len = strlen(p);
    while (len > 0 && (p[len-1] == ' ' || p[len-1] == '\t' || p[len-1] == '\r' || p[len-1] == '\n')) {
        p[--len] = '\0';
    }
    
    // 空行或注释,跳过
    if (len == 0 || p[0] == ';' || p[0] == '#') {
        return 0;
    }
    
    // 节区
    if (p[0] == '[') {
        char *end = strchr(p, ']');
        if (end == NULL) return -1;  // 格式错误
        *end = '\0';
        *cur_section = ini_add_section(mgr, p + 1);
        return 0;
    }
    
    // 键值对
    char *eq = strchr(p, '=');
    if (eq == NULL) eq = strchr(p, ':');  // 也支持冒号分隔
    if (eq == NULL) return -1;
    
    *eq = '\0';
    char *key = p;
    char *value = eq + 1;
    
    // 去掉key和value两边的空格
    // ...(省略具体实现)
    
    ini_add_kv(*cur_section, key, value);
    return 0;
}

小技巧:解析时最好保留原始字符串的副本,不要直接修改文件缓冲区。这样如果后续需要写回文件,可以保留注释和格式。

节区管理:查找与遍历

节区管理说白了就是链表的增删改查。但有一个细节很多人会忽略:节区名大小写敏感吗?

我建议统一转成小写再比较。为什么?因为Windows和Linux下,INI文件的节区名大小写习惯不一样。Windows下经常是首字母大写,Linux下全是小写。如果你不做归一化,同一个配置文件在两个平台上解析结果可能不同。

section_node_t* ini_find_section(ini_manager_t *mgr, const char *name) {
    section_node_t *cur = mgr->section_head;
    char name_lower[64];
    // 将name转成小写
    str_to_lower(name_lower, name);
    
    while (cur) {
        char sec_lower[64];
        str_to_lower(sec_lower, cur->name);
        if (strcmp(sec_lower, name_lower) == 0) {
            return cur;
        }
        cur = cur->next;
    }
    return NULL;
}

读写实现:序列化与反序列化

读(解析)我们已经讲了,写(序列化)其实更简单——遍历链表,按格式输出即可。

int ini_save_to_file(ini_manager_t *mgr, const char *filename) {
    FILE *fp = fopen(filename, "w");
    if (!fp) return -1;
    
    section_node_t *sec = mgr->section_head;
    while (sec) {
        fprintf(fp, "[%s]\n", sec->name);
        kv_node_t *kv = sec->kv_head;
        while (kv) {
            fprintf(fp, "%s = %s\n", kv->key, kv->value);
            kv = kv->next;
        }
        fprintf(fp, "\n");
        sec = sec->next;
    }
    
    fclose(fp);
    return 0;
}

这里有个问题:写回文件时,原来的注释和空行全丢了。如果你需要保留这些,就得在解析时把每一行(包括注释)都存下来。我一般只在两种场景下用INI:

  • 配置文件由程序自动生成,不需要保留注释
  • 配置文件由人工编辑,但程序只读不写

如果既要人工编辑又要程序修改,我建议用JSON或YAML,它们有成熟的库支持。

避坑指南

我曾经踩过的坑:

  • 值中包含等号:比如密码字段"password = abc=123",解析时只取第一个等号,后面的都算值的一部分
  • 节区名重复:同一个节区出现两次,是合并还是覆盖?我建议合并,后出现的键值对覆盖前面的
  • 内存泄漏:链表节点都是malloc出来的,解析失败或重新加载时一定要释放干净
  • 编码问题:INI文件没有指定编码,如果配置项包含中文,建议统一用UTF-8 without BOM

知识体系总览

下面这张图概括了INI解析器的整体架构和核心流程:

INI解析器架构总览 INI文件输入 逐行解析引擎(状态机) 跳过注释 → 识别节区 → 解析键值对 内存数据结构(链表) section_node_t 链表 kv_node_t 键值对链表 ini_manager_t 管理结构 操作接口:查找节区 / 读取键值 / 修改键值 / 写回文件 应用程序使用配置值

实际项目中的取舍

说实话,INI格式在大型系统里已经不太够用了。但在嵌入式领域,它依然活得很好。原因有三:

场景 推荐格式 原因
MCU裸机或RTOS INI 解析器代码量小(几百行C),无依赖
Linux嵌入式 JSON/YAML 有现成库,支持嵌套结构
需要人工频繁编辑 INI 人类可读性最好,语法最宽松
配置项超过500个 SQLite/二进制 INI的线性查找效率太低

我个人建议:如果你的项目里配置项不超过100个,且不需要嵌套结构,用INI完全够用。别为了用JSON而引入一个JSON库,那可能比你的业务代码还大。

一个实用建议:写INI解析器时,把错误处理做得友好一点。比如解析失败时,打印出是哪一行出了问题,而不是简单返回-1。我在调试一个传感器网络时,就因为配置文件里多了一个不可见字符,排查了整整一个下午。从那以后,我的解析器都会输出类似这样的信息:

[WARN] 第42行解析失败:'baudrate = 115200\x00' 包含非法字符

好了,INI解析器就聊到这儿。核心就三件事:逐行解析、链表管理、容错处理。你可以在自己的项目里实现一个轻量版,也可以直接用现成的库。但不管哪种方式,理解底层的解析逻辑,对你以后处理任何文本格式都有帮助。


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