81、配置文件解析:如何解析 ini 文件?

嵌入式开发中,配置文件是个绕不开的话题。我个人习惯用 ini 格式,简单、直观,不用引入 JSON 或 XML 那套重型解析库。说白了,ini 文件就是「节 + 键值对」的组合,你打开任何一个 Windows 下的 .ini 文件,一眼就能看懂。

但真正自己动手写解析器时,坑还挺多的。我曾在项目里遇到过客户反馈「配置加载失败」,查了半天发现是 ini 文件里多了个 BOM 头——嗯,Windows 的记事本干的好事。从那以后,我写的解析器都会主动跳过 BOM。

ini 文件的基本结构

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

; 这是注释
[uart]
baudrate = 115200
data_bits = 8
stop_bits = 1
parity = none

[gpio]
pin_led = 12
pin_button = 5
mode = output

结构其实就三样东西:

  • 注释:以 ;# 开头,整行忽略
  • 节(Section):用方括号 [section_name] 包裹
  • 键值对key = value 格式,等号两边可以有空格

你想想看,这种结构是不是特别适合嵌入式场景?不需要动态内存分配,一行一行读,解析完就能用。

手写一个轻量级解析器

我不会一上来就上什么高大上的状态机。先写个最朴素的版本,能跑通再说。

#include <stdio.h>
#include <string.h>
#include <ctype.h>

#define MAX_LINE 256
#define MAX_SECTION 64
#define MAX_KEY 64
#define MAX_VALUE 256

typedef struct {
    char section[MAX_SECTION];
    char key[MAX_KEY];
    char value[MAX_VALUE];
} IniEntry;

int parse_ini_line(const char *line, IniEntry *entry) {
    const char *p = line;

    // 跳过前导空格
    while (*p && isspace((unsigned char)*p)) p++;

    // 空行或注释
    if (*p == '\0' || *p == ';' || *p == '#') return 0;

    // 节
    if (*p == '[') {
        p++;
        int i = 0;
        while (*p && *p != ']' && i < MAX_SECTION - 1) {
            entry->section[i++] = *p++;
        }
        entry->section[i] = '\0';
        entry->key[0] = '\0';
        entry->value[0] = '\0';
        return 1; // 表示这是一个节
    }

    // 键值对
    char key_buf[MAX_KEY], val_buf[MAX_VALUE];
    int i = 0;
    while (*p && *p != '=' && i < MAX_KEY - 1) {
        key_buf[i++] = *p++;
    }
    key_buf[i] = '\0';

    if (*p != '=') return 0; // 没有等号,非法行
    p++; // 跳过 '='

    // 去掉 key 尾部空格
    char *end = key_buf + strlen(key_buf) - 1;
    while (end >= key_buf && isspace((unsigned char)*end)) *end-- = '\0';

    // 读取 value
    while (*p && isspace((unsigned char)*p)) p++; // 跳过 value 前导空格
    i = 0;
    while (*p && i < MAX_VALUE - 1) {
        val_buf[i++] = *p++;
    }
    val_buf[i] = '\0';

    // 去掉 value 尾部空格
    end = val_buf + strlen(val_buf) - 1;
    while (end >= val_buf && isspace((unsigned char)*end)) *end-- = '\0';

    strncpy(entry->key, key_buf, MAX_KEY);
    strncpy(entry->value, val_buf, MAX_VALUE);
    return 2; // 表示这是一个键值对
}

这段代码我刻意写得「啰嗦」了一点,为什么?因为嵌入式环境里,strtok 这类函数会修改原字符串,容易出 bug。我宁愿手动解析,每一行都处理干净。

我的习惯:解析 ini 时,永远不要假设输入是合法的。我在项目中见过有人在 value 里写中文、写换行符、甚至写二进制数据。解析器必须能优雅地跳过非法行,而不是直接崩溃。

状态机解析:更健壮的方式

上面那种逐行解析的方式,对于简单场景够用了。但如果 ini 文件很大,或者你需要支持多行 value、转义字符,那就得上状态机了。

状态机的核心思想很简单:解析器内部维护一个「当前状态」,根据读到的字符决定下一步做什么。

状态机解析 ini 的典型状态:

  • STATE_LINE_START:行首,等待第一个有效字符
  • STATE_SECTION:正在读取节名
  • STATE_KEY:正在读取键名
  • STATE_EQUALS:遇到等号,准备读值
  • STATE_VALUE:正在读取值
  • STATE_COMMENT:注释行,直接跳过

我画了一张状态流转图,帮你理清思路:

LINE_START SECTION KEY EQUALS VALUE COMMENT '[' 字母/数字 '=' 非空格 ';' 或 '#' 换行 换行

状态机的实现代码我就不贴全了,核心逻辑就是 switch-case 配合一个 current_state 变量。每次读一个字符,根据当前状态决定下一步动作。

我曾经踩过的坑:状态机里忘记处理「节名后面直接跟键值对」的情况。比如 [uart]baudrate=115200 这种写法,虽然不规范,但有些工具会生成。解析器如果直接报错,客户会骂人的。我现在的做法是:遇到节名后,如果同一行还有内容,就继续解析键值对。

实用技巧:缓存当前节

解析 ini 时,一个常见的需求是:我想知道某个键属于哪个节。比如 baudrate 属于 [uart] 节。

我的做法很简单:维护一个全局的 current_section 变量。每次解析到节时更新它,解析到键值对时,把当前的节名一起存下来。

char current_section[MAX_SECTION] = "";

void parse_ini_file(const char *filename) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return;

    char line[MAX_LINE];
    IniEntry entry;

    while (fgets(line, sizeof(line), fp)) {
        int ret = parse_ini_line(line, &entry);
        if (ret == 1) {
            // 这是一个节,更新 current_section
            strncpy(current_section, entry.section, MAX_SECTION);
        } else if (ret == 2) {
            // 这是一个键值对,此时 entry.section 是空的
            // 我们可以手动补上当前节名
            strncpy(entry.section, current_section, MAX_SECTION);
            // 然后存储 entry 到你的配置结构体
            store_config(&entry);
        }
    }
    fclose(fp);
}

这样设计的好处是:你不需要在解析时就把所有数据塞进一个巨大的结构体。可以一边解析一边处理,内存占用极低。

ini 解析的边界情况

我整理了一份清单,这些情况在实际项目中都遇到过:

情况 示例 处理建议
空节 [empty] 后面直接跟另一个节 允许,不报错
重复键 同一个节里出现两次 key=value 后出现的覆盖前面的,或者报 warning
value 带引号 name = "hello world" 去掉引号,保留内部内容
BOM 头 文件开头有 0xEF 0xBB 0xBF 读取前三个字节,如果是 BOM 就跳过
Windows 换行 \r\n fgets 读完后去掉末尾的 \r
空值 key= value 设为空字符串,不要报错
我的建议:不要试图在解析器里做「配置校验」。解析器只负责把文本转换成数据结构,至于 baudrate 的值是不是合法的波特率,那是上层逻辑的事。职责分离,代码才干净。

总结

ini 文件解析,说白了就是「读一行,判断类型,提取信息」。别把它想得太复杂。我见过有人为了解析 ini 引入了一整个 lex/yacc 生成的解析器——杀鸡用牛刀了。

对于嵌入式场景,我推荐的做法是:

  • fgets 逐行读取,不要一次读整个文件
  • 手动解析每一行,不要依赖 sscanf(格式不灵活)
  • 维护一个当前节变量,方便关联键值对
  • 做好边界处理,尤其是注释、空行、BOM

嗯,这套思路我用了好几年,在多个项目里验证过,稳定可靠。你拿去用,基本不会出问题。

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