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 知识体系总览

下面这张图帮你理清三种解析器的核心逻辑:

配置文件解析器核心逻辑 配置文件输入 INI解析器 JSON解析器 CSV解析器 逐行读取 识别 [section] 解析 key=value 跳过注释和空行 全文件读入 状态机解析 提取 key:value 处理引号转义 逐字符读取 引号状态跟踪 逗号/换行分隔 处理空字段 结构化数据输出

嗯,看到这张图你应该明白了——三种解析器虽然处理方式不同,但本质都是「输入文本 → 状态机解析 → 输出结构化数据」。搞懂了这个本质,你就能举一反三,遇到TOML、YAML什么的也不怵。

我的经验:写解析器时,先把测试用例写好。各种边界情况——空文件、只有注释、值带引号、字段缺失——都列出来。我吃过亏,上线后发现某个配置文件少了个逗号,整个解析就崩了。从那以后,我写解析器必先写测试。

好了,三种解析器就聊到这儿。代码都在上面,你可以直接拿去用。记住一点:解析器是程序的「入口」,它要是跪了,后面全完。所以,稳一点,再稳一点。


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