34、配置文件解析:基于标准库实现INI/JSON/CSV解析器
配置文件解析,说白了就是让程序能读懂人的语言。我做了这么多年嵌入式开发,几乎每个项目都离不开这玩意儿。你想想看,产品交付后客户要改个参数,总不能让人家重新编译代码吧?
今天咱们就聊聊三种最常见的配置文件格式:INI、JSON、CSV。我会带着你,只用C标准库,手撸三个轻量级解析器。不依赖第三方库,纯手工打造。
34.1 INI文件解析:最朴素的配置格式
INI格式我最早接触是在Windows的.ini文件里。结构简单,就是「节+键值对」。我个人习惯把它当作嵌入式系统的首选配置格式——够轻,够直观。
一个典型的INI文件长这样:
; 这是注释
[network]
ip = 192.168.1.100
port = 8080
timeout = 30
[logging]
level = debug
file = /var/log/app.log
解析思路其实不复杂:逐行读取,遇到[section]就切换当前节,遇到key=value就存下来。我在项目中遇到过一个问题——有些同事喜欢在等号两边加空格,有些又不加。所以解析时一定要做trim处理。
来看代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.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;
// 去掉字符串首尾空白
char* trim(char* str) {
char* end;
while(isspace((unsigned char)*str)) str++;
if(*str == 0) return str;
end = str + strlen(str) - 1;
while(end > str && isspace((unsigned char)*end)) end--;
*(end+1) = 0;
return str;
}
int parse_ini(const char* filename, INIEntry* entries, int max_entries) {
FILE* fp = fopen(filename, "r");
if(!fp) return -1;
char line[MAX_LINE];
char current_section[MAX_SECTION] = "";
int count = 0;
while(fgets(line, sizeof(line), fp)) {
char* p = trim(line);
// 跳过空行和注释
if(*p == '\0' || *p == ';' || *p == '#') continue;
// 检测节
if(*p == '[') {
char* end = strchr(p, ']');
if(end) {
*end = '\0';
strncpy(current_section, p+1, MAX_SECTION-1);
}
continue;
}
// 解析键值对
char* eq = strchr(p, '=');
if(eq) {
*eq = '\0';
char* key = trim(p);
char* value = trim(eq+1);
if(count < max_entries) {
strncpy(entries[count].section, current_section, MAX_SECTION-1);
strncpy(entries[count].key, key, MAX_KEY-1);
strncpy(entries[count].value, value, MAX_VALUE-1);
count++;
}
}
}
fclose(fp);
return count;
}
小提示:我建议你在解析时把注释符也做成可配置的。有些项目用#,有些用;,甚至两者混用。灵活一点,少挨骂。
34.2 JSON解析:轻量级但够用
JSON现在太流行了。但说实话,在资源受限的MCU上跑完整JSON库有点奢侈。我做过一个项目,Flash只剩8KB,哪敢上cJSON?于是自己写了个极简版。
我们只解析扁平JSON,不支持嵌套对象和数组。够用就行:
typedef struct {
char key[MAX_KEY];
char value[MAX_VALUE];
} JSONEntry;
int parse_json_flat(const char* filename, JSONEntry* entries, int max_entries) {
FILE* fp = fopen(filename, "r");
if(!fp) return -1;
// 读整个文件到内存
fseek(fp, 0, SEEK_END);
long fsize = ftell(fp);
fseek(fp, 0, SEEK_SET);
char* content = (char*)malloc(fsize + 1);
fread(content, 1, fsize, fp);
content[fsize] = '\0';
fclose(fp);
// 简单状态机解析
char* p = content;
int count = 0;
char current_key[MAX_KEY] = "";
int in_key = 0, in_value = 0;
char temp[MAX_VALUE];
int ti = 0;
while(*p && count < max_entries) {
if(*p == '"') {
if(!in_key && !in_value) {
in_key = 1;
ti = 0;
} else if(in_key) {
temp[ti] = '\0';
strncpy(current_key, temp, MAX_KEY-1);
in_key = 0;
} else if(in_value) {
temp[ti] = '\0';
strncpy(entries[count].key, current_key, MAX_KEY-1);
strncpy(entries[count].value, temp, MAX_VALUE-1);
count++;
in_value = 0;
}
} else if(*p == ':' && !in_key && !in_value) {
in_value = 1;
ti = 0;
} else if((*p == ',' || *p == '}') && !in_key && !in_value) {
// 跳过
} else if(in_key || in_value) {
if(ti < MAX_VALUE-1) temp[ti++] = *p;
}
p++;
}
free(content);
return count;
}
注意:这个解析器只处理字符串值。数字和布尔值会被当作字符串存储。如果你需要类型识别,得自己加逻辑。我曾经偷懒没加,结果客户传了个true,我这边当字符串"true"处理,差点出事故。
34.3 CSV解析:表格数据的搬运工
CSV解析看起来简单,坑其实不少。引号内的逗号要不要处理?换行符在引号内算不算行结束?我当年第一次写CSV解析器就栽在引号嵌套上。
一个健壮的CSV解析器至少要考虑三点:
- 支持引号包裹的字段(字段内可含逗号和换行)
- 支持转义引号(两个连续引号表示一个引号)
- 正确处理空字段
来看实现:
typedef struct {
char** fields;
int field_count;
} CSVRow;
typedef struct {
CSVRow* rows;
int row_count;
int capacity;
} CSVTable;
CSVTable* parse_csv(const char* filename) {
FILE* fp = fopen(filename, "r");
if(!fp) return NULL;
CSVTable* table = (CSVTable*)calloc(1, sizeof(CSVTable));
table->capacity = 64;
table->rows = (CSVRow*)calloc(table->capacity, sizeof(CSVRow));
char ch;
char field[1024];
int fi = 0;
int in_quotes = 0;
int row_capacity = 16;
while((ch = fgetc(fp)) != EOF) {
// 处理引号
if(ch == '"') {
if(!in_quotes) {
in_quotes = 1;
} else {
// 检查是不是转义引号
int next = fgetc(fp);
if(next == '"') {
field[fi++] = '"';
} else {
in_quotes = 0;
if(next != EOF) ungetc(next, fp);
}
}
continue;
}
// 逗号分隔(不在引号内)
if(ch == ',' && !in_quotes) {
field[fi] = '\0';
// 添加到当前行
CSVRow* row = &table->rows[table->row_count];
if(row->field_count == 0) {
row->fields = (char**)calloc(row_capacity, sizeof(char*));
} else if(row->field_count >= row_capacity) {
row_capacity *= 2;
row->fields = (char**)realloc(row->fields, row_capacity * sizeof(char*));
}
row->fields[row->field_count] = strdup(field);
row->field_count++;
fi = 0;
continue;
}
// 换行(不在引号内)
if(ch == '\n' && !in_quotes) {
field[fi] = '\0';
CSVRow* row = &table->rows[table->row_count];
if(row->field_count == 0) {
row->fields = (char**)calloc(row_capacity, sizeof(char*));
} else if(row->field_count >= row_capacity) {
row_capacity *= 2;
row->fields = (char**)realloc(row->fields, row_capacity * sizeof(char*));
}
row->fields[row->field_count] = strdup(field);
row->field_count++;
table->row_count++;
// 扩展行数组
if(table->row_count >= table->capacity) {
table->capacity *= 2;
table->rows = (CSVRow*)realloc(table->rows, table->capacity * sizeof(CSVRow));
memset(&table->rows[table->row_count], 0,
(table->capacity - table->row_count) * sizeof(CSVRow));
}
fi = 0;
row_capacity = 16;
continue;
}
// 普通字符
if(fi < 1023) field[fi++] = ch;
}
fclose(fp);
return table;
}
核心要点:解析CSV时,永远不要用fgets按行读。因为引号内的换行符是字段内容,不是行结束符。老老实实一个字符一个字符地读,状态机走起。
34.4 三种解析器的对比与选型
我整理了一张表,方便你根据项目需求做选择:
| 特性 | INI | JSON(扁平) | CSV |
|---|---|---|---|
| 代码量 | 约80行 | 约60行 | 约100行 |
| 内存占用 | 低(逐行) | 中(需全读) | 高(全表) |
| 可读性 | 好 | 好 | 一般 |
| 适用场景 | 嵌入式、简单配置 | Web API、结构化数据 | 数据导出、表格处理 |
| 解析难度 | 低 | 中 | 中高(引号处理) |
我个人习惯是:嵌入式裸机用INI,需要和Web服务对接用JSON,批量数据处理用CSV。别在一个场景里硬套不合适的格式。
34.5 知识体系总览
下面这张图帮你理清三种解析器的核心逻辑:
嗯,看到这张图你应该明白了——三种解析器虽然处理方式不同,但本质都是「输入文本 → 状态机解析 → 输出结构化数据」。搞懂了这个本质,你就能举一反三,遇到TOML、YAML什么的也不怵。
我的经验:写解析器时,先把测试用例写好。各种边界情况——空文件、只有注释、值带引号、字段缺失——都列出来。我吃过亏,上线后发现某个配置文件少了个逗号,整个解析就崩了。从那以后,我写解析器必先写测试。
好了,三种解析器就聊到这儿。代码都在上面,你可以直接拿去用。记住一点:解析器是程序的「入口」,它要是跪了,后面全完。所以,稳一点,再稳一点。
公众号:蓝海资料掘金营,微信deep3321