INI配置文件:ini解析器实现、键值对读写、section管理
INI文件,说白了就是最朴素的配置文件格式。没有XML那么啰嗦,没有JSON那么多括号,也没有YAML那些缩进陷阱。我最早接触INI是在做嵌入式Linux项目的时候,系统启动参数、服务配置、甚至一些硬件参数都靠它来管理。你想想看,一个几百K的二进制程序,配一个几K的INI文件,就能灵活调整行为——这种组合在资源受限的环境下特别实用。
今天我们就手撸一个INI解析器。别小看这个活儿,我在项目中遇到过至少三种不同的INI方言:有的支持注释,有的不支持;有的允许等号两边有空格,有的必须紧贴;还有的section可以嵌套……嗯,我们先从最通用的版本开始。
INI文件的基本结构
一个标准的INI文件长这样:
; 这是注释
[section1]
key1 = value1
key2 = value2
[section2]
key3 = value3
key4 = "value with spaces"
核心规则就三条:
- section:用方括号包裹,表示一个配置组
- 键值对:key = value 的形式,等号两边可以有空格
- 注释:分号或井号开头,整行忽略
我个人习惯把INI解析器拆成三个层次:词法扫描层、语法解析层、数据管理层。这样每个模块职责清晰,测试起来也方便。
核心设计原则:解析器只负责把文本变成内存中的数据结构,不负责校验业务逻辑。校验是调用方的事。
数据结构设计
我们先定义内存中的表示方式。一个INI文件可以看作:多个section,每个section包含多个键值对。用链表或哈希表都行,我倾向于用哈希表——查找快,尤其是section数量多的时候。
typedef struct ini_kv {
char *key;
char *value;
struct ini_kv *next; // 同一个section内的链表
} ini_kv_t;
typedef struct ini_section {
char *name;
ini_kv_t *kv_head; // 键值对链表头
struct ini_section *next; // section链表
} ini_section_t;
typedef struct {
ini_section_t *section_head;
int section_count;
} ini_t;
为什么用链表而不是数组?因为INI文件是顺序解析的,链表插入方便。而且实际项目中,一个section里的键值对通常不超过几十个,链表遍历的开销可以忽略。
小技巧:如果你追求极致性能,可以把section和kv都改成哈希表。但代价是内存占用变大,而且需要处理哈希冲突。我个人建议——先跑起来,再优化。
解析器核心实现
解析过程就是逐行读取,根据行首字符判断类型。我习惯用状态机的方式写:
typedef enum {
STATE_NONE, // 未进入任何section
STATE_SECTION, // 正在解析section
STATE_COMMENT, // 注释行
STATE_KEYVALUE // 键值对
} parse_state_t;
ini_t* ini_parse(const char *filename) {
FILE *fp = fopen(filename, "r");
if (!fp) return NULL;
ini_t *ini = calloc(1, sizeof(ini_t));
ini_section_t *cur_section = NULL;
char line[1024];
int line_num = 0;
while (fgets(line, sizeof(line), fp)) {
line_num++;
char *trimmed = trim_whitespace(line);
if (!trimmed || trimmed[0] == '\0') continue;
if (trimmed[0] == ';' || trimmed[0] == '#') continue;
if (trimmed[0] == '[') {
// 解析section名
char *end = strchr(trimmed, ']');
if (!end) {
fprintf(stderr, "第%d行: section格式错误\n", line_num);
continue;
}
*end = '\0';
cur_section = add_section(ini, trimmed + 1);
} else {
// 解析键值对
char *eq = strchr(trimmed, '=');
if (!eq) {
fprintf(stderr, "第%d行: 缺少等号\n", line_num);
continue;
}
*eq = '\0';
char *key = trim_whitespace(trimmed);
char *value = trim_whitespace(eq + 1);
add_kv(cur_section, key, value);
}
}
fclose(fp);
return ini;
}
这段代码里有个细节:trim_whitespace 函数。我曾经在项目里吃过亏——某个配置文件里等号后面跟了两个空格,解析出来的value前面多了个空格,导致字符串比较失败。所以,一定要做trim。
注意:上面的代码为了清晰做了简化。实际生产环境中,你需要处理:
- 行尾的换行符(
strcspn去掉) - 空section(没有键值对的section)
- 重复的key(我习惯后出现的覆盖前面的)
- 内存分配失败检查
键值对读写接口
解析完了,怎么用?我提供四个核心接口:
// 获取字符串值
const char* ini_get_string(ini_t *ini, const char *section, const char *key);
// 获取整数值,失败返回默认值
int ini_get_int(ini_t *ini, const char *section, const char *key, int default_val);
// 设置键值对(如果section不存在则创建)
int ini_set_string(ini_t *ini, const char *section, const char *key, const char *value);
// 保存回文件
int ini_save(ini_t *ini, const char *filename);
实现 ini_get_string 时,我用了两层查找:先找section,再找key。查找section可以用strcmp遍历链表,查找key同理。
const char* ini_get_string(ini_t *ini, const char *section, const char *key) {
if (!ini || !section || !key) return NULL;
ini_section_t *sec = ini->section_head;
while (sec) {
if (strcmp(sec->name, section) == 0) {
ini_kv_t *kv = sec->kv_head;
while (kv) {
if (strcmp(kv->key, key) == 0) {
return kv->value;
}
kv = kv->next;
}
return NULL; // key不存在
}
sec = sec->next;
}
return NULL; // section不存在
}
这里有个小坑:返回的value指针指向的是内部存储,调用方不应该修改它。如果你需要修改,建议返回一个strdup的副本,让调用方自己free。
Section管理
Section管理其实就三个操作:增、删、查。查上面已经说了,增和删我简单说一下。
增加section时,我习惯先检查是否已存在。如果存在,直接返回已有的section指针;如果不存在,才创建新的。这样调用方可以放心地调用 ini_set_string 而不用担心重复创建。
ini_section_t* add_section(ini_t *ini, const char *name) {
// 先查找是否已存在
ini_section_t *sec = find_section(ini, name);
if (sec) return sec;
// 不存在则创建
sec = calloc(1, sizeof(ini_section_t));
sec->name = strdup(name);
sec->next = ini->section_head;
ini->section_head = sec;
ini->section_count++;
return sec;
}
删除section时,要注意释放所有键值对的内存。我曾经在项目里漏掉了这一步,导致内存泄漏——程序跑了三天后OOM了。排查了半天才发现是INI解析器的问题。
经验之谈:写解析器时,ini_free 函数一定要和 ini_parse 成对出现。我习惯在解析器初始化时就注册一个清理函数,用atexit或者RAII风格(如果C++的话)。C语言里就老老实实手动调用吧。
保存回文件
修改完配置后,需要写回文件。保存的逻辑就是遍历section和kv,按INI格式输出:
int ini_save(ini_t *ini, const char *filename) {
FILE *fp = fopen(filename, "w");
if (!fp) return -1;
ini_section_t *sec = ini->section_head;
while (sec) {
fprintf(fp, "[%s]\n", sec->name);
ini_kv_t *kv = sec->kv_head;
while (kv) {
fprintf(fp, "%s = %s\n", kv->key, kv->value);
kv = kv->next;
}
fprintf(fp, "\n"); // section之间空一行,更易读
sec = sec->next;
}
fclose(fp);
return 0;
}
注意:保存时我故意在每个section后面加了一个空行。这不是必须的,但能让文件更易读。你想想看,如果几百行的INI文件挤在一起,谁看了都头疼。
INI解析器整体架构
下面这张图展示了整个解析器的数据流和模块关系:
从图中可以看到,数据流是单向的:文件 → 词法扫描 → 语法解析 → 数据管理。保存流程则是反向的:数据管理 → 格式化输出 → 文件。错误处理贯穿整个过程,每个阶段都可能抛出异常。
实际项目中的坑
最后分享几个我踩过的坑:
- 编码问题:INI文件没有指定编码。我在一个跨国项目里遇到过UTF-8和GBK混用的情况,解析出来的value乱码。解决方案:统一用UTF-8,并在文件头加BOM标记(虽然不标准,但实用)。
- 大小写敏感:Windows下的INI解析器通常不区分大小写,但Linux下区分。我建议在解析时统一转成小写,或者提供一个配置选项让调用方决定。
- 转义字符:有些INI方言支持转义,比如
\n表示换行。我一般不支持转义——保持简单。如果value里需要特殊字符,让调用方自己处理。 - 多线程安全:我的解析器默认不是线程安全的。如果多个线程同时读写同一个ini_t结构,需要加锁。我习惯在ini_t结构里加一个pthread_mutex_t,所有接口都先锁再操作。
重要提醒:永远不要信任外部传入的INI文件。我在安全审计时发现过有人通过INI文件注入恶意内容——比如在value里塞入分号,试图破坏解析逻辑。所以,解析时一定要做边界检查,value长度、key长度都要限制。
好了,INI解析器的核心内容就这些。代码量不大,但细节不少。你写的时候可以先用单链表实现,跑通后再考虑优化。记住:先让程序正确,再让它快。
总结:INI解析器 = 词法扫描 + 语法解析 + 数据管理。核心数据结构是section链表和kv链表。接口设计要简单、稳定、可预测。保存时注意格式美观,解析时注意错误处理。
公众号:蓝海资料掘金营,微信deep3321