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 的典型状态:
- STATE_LINE_START:行首,等待第一个有效字符
- STATE_SECTION:正在读取节名
- STATE_KEY:正在读取键名
- STATE_EQUALS:遇到等号,准备读值
- STATE_VALUE:正在读取值
- STATE_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
嗯,这套思路我用了好几年,在多个项目里验证过,稳定可靠。你拿去用,基本不会出问题。