实战案例6:CSV文件解析

CSV文件解析,说白了就是处理那些用逗号隔开的表格数据。你想想看,日常开发中到处都能碰到CSV——导出报表、导入配置、数据交换……我刚开始做嵌入式项目时,就经常被CSV里的各种坑绊倒。

嗯,今天咱们就来彻底搞定它。

CSV解析的核心难点

CSV看起来简单,但实际处理起来有几个让人头疼的地方:

  • 逗号分隔:最基本的,每行数据用逗号隔开
  • 引号内的逗号:比如 "Hello, World" 这个逗号不能当作分隔符
  • 换行符:引号内的换行符属于字段内容,不能当作行结束
  • 转义引号:用两个引号 "" 表示一个引号

我在项目中遇到过最坑的一次,是客户给的CSV文件里,某个字段居然包含了完整的HTML代码,里面引号、逗号、换行符全都有。当时用简单的 strtok 去解析,结果数据全乱了。

正则表达式方案

用正则来处理CSV,其实是个很自然的选择。为什么?因为CSV的格式本质上就是「模式匹配」——每个字段要么是普通文本,要么是被引号包裹的文本。

我个人习惯用这个正则来匹配单个字段:

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

// 匹配CSV字段的正则表达式
// 解释:
//   "([^"]*?)"   - 匹配引号内的内容(非贪婪)
//   |            - 或者
//   ([^,\n]*)    - 匹配非逗号、非换行的普通文本
const char *pattern = "\"([^\"]*?)\"|([^,\n]*)";

int parse_csv_field(const char *input, char *output, int max_len) {
    regex_t regex;
    regmatch_t matches[3];
    int ret;
    
    ret = regcomp(&regex, pattern, REG_EXTENDED);
    if (ret) {
        fprintf(stderr, "正则编译失败\n");
        return -1;
    }
    
    ret = regexec(&regex, input, 3, matches, 0);
    if (ret == 0) {
        // 检查是引号匹配还是普通文本匹配
        if (matches[1].rm_so != -1) {
            // 引号内的内容
            int len = matches[1].rm_eo - matches[1].rm_so;
            if (len < max_len) {
                strncpy(output, input + matches[1].rm_so, len);
                output[len] = '\0';
            }
        } else if (matches[2].rm_so != -1) {
            // 普通文本
            int len = matches[2].rm_eo - matches[2].rm_so;
            if (len < max_len) {
                strncpy(output, input + matches[2].rm_so, len);
                output[len] = '\0';
            }
        }
    }
    
    regfree(&regex);
    return ret;
}
小技巧:正则中的 ? 表示非贪婪匹配。如果不加这个,遇到 "a","b" 这样的数据,可能会把整个 "a","b" 当成一个字段匹配掉。

完整的CSV行解析

单字段匹配搞定了,接下来就是整行解析。这里有个关键点:你不能简单地用 strtok 按逗号分割,因为引号内的逗号不能算。

我的做法是:用正则逐个匹配字段,同时记录当前匹配的位置,然后继续往后匹配。

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

#define MAX_FIELDS 100
#define MAX_FIELD_LEN 256

typedef struct {
    char fields[MAX_FIELDS][MAX_FIELD_LEN];
    int count;
} CSVRow;

int parse_csv_line(const char *line, CSVRow *row) {
    regex_t regex;
    regmatch_t matches[3];
    const char *pattern = "\"([^\"]*?)\"|([^,\n]*)";
    const char *cursor = line;
    int field_count = 0;
    
    regcomp(&regex, pattern, REG_EXTENDED);
    
    while (regexec(&regex, cursor, 3, matches, 0) == 0) {
        if (field_count >= MAX_FIELDS) break;
        
        // 提取字段内容
        if (matches[1].rm_so != -1) {
            // 引号内的字段
            int start = matches[1].rm_so;
            int end = matches[1].rm_eo;
            int len = end - start;
            if (len < MAX_FIELD_LEN) {
                strncpy(row->fields[field_count], cursor + start, len);
                row->fields[field_count][len] = '\0';
            }
        } else if (matches[2].rm_so != -1) {
            // 普通字段
            int start = matches[2].rm_so;
            int end = matches[2].rm_eo;
            int len = end - start;
            if (len < MAX_FIELD_LEN) {
                strncpy(row->fields[field_count], cursor + start, len);
                row->fields[field_count][len] = '\0';
            }
        }
        
        field_count++;
        cursor += matches[0].rm_eo;
        
        // 跳过逗号
        if (*cursor == ',') cursor++;
    }
    
    row->count = field_count;
    regfree(&regex);
    return field_count;
}
注意:上面的代码没有处理转义引号 ""。我曾经因为这个吃过亏——客户数据里有个字段值是 他说"你好",CSV里写成了 "他说""你好""",结果解析出来变成了 他说"你好"。这个转换逻辑需要额外加一层处理。

处理换行符

CSV规范允许引号内包含换行符。这意味着你不能简单地按 \n 分割行。我一般这样处理:

  • 先按 \n 分割成「候选行」
  • 检查每行中引号的数量——如果是奇数,说明引号没闭合,需要和下一行合并
int is_line_complete(const char *line) {
    int quote_count = 0;
    for (const char *p = line; *p; p++) {
        if (*p == '"') quote_count++;
    }
    return (quote_count % 2 == 0);
}

// 读取完整的CSV行(可能跨多行)
int read_csv_line(FILE *fp, char *buffer, int max_len) {
    int total_len = 0;
    char temp[1024];
    
    while (fgets(temp, sizeof(temp), fp)) {
        int len = strlen(temp);
        if (total_len + len < max_len) {
            strcpy(buffer + total_len, temp);
            total_len += len;
            
            // 去掉末尾的换行符
            if (total_len > 0 && buffer[total_len-1] == '\n') {
                buffer[total_len-1] = '\0';
                total_len--;
            }
            
            // 检查行是否完整
            if (is_line_complete(buffer)) {
                return total_len;
            }
        } else {
            break;
        }
    }
    
    return total_len;
}

解析为二维数组

有了上面的基础,解析整个CSV文件就水到渠成了。我习惯把结果存成二维数组,方便后续处理。

#define MAX_ROWS 1000
#define MAX_COLS 50

typedef struct {
    char data[MAX_ROWS][MAX_COLS][MAX_FIELD_LEN];
    int rows;
    int cols;
} CSVTable;

int parse_csv_file(const char *filename, CSVTable *table) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return -1;
    
    char line[4096];
    CSVRow row;
    int row_count = 0;
    int max_cols = 0;
    
    while (read_csv_line(fp, line, sizeof(line)) > 0) {
        if (row_count >= MAX_ROWS) break;
        
        parse_csv_line(line, &row);
        
        for (int i = 0; i < row.count && i < MAX_COLS; i++) {
            strcpy(table->data[row_count][i], row.fields[i]);
        }
        
        if (row.count > max_cols) max_cols = row.count;
        row_count++;
    }
    
    table->rows = row_count;
    table->cols = max_cols;
    fclose(fp);
    return row_count;
}

知识体系总览

下面这张图总结了CSV解析的核心流程和关键节点:

CSV解析核心流程 CSV原始数据 第一步:按换行符分割 检查引号是否闭合 第二步:正则匹配每个字段 "([^"]*?)" | ([^,\n]*) 第三步:处理转义引号 "" → " 去掉首尾引号,替换双引号 二维数组

避坑指南

做CSV解析这些年,我踩过的坑真不少。这里列几个最常见的:

  • 空字段:比如 a,,c,中间那个空字段也要保留。正则匹配时要注意,空字符串也是有效字段
  • 前后空格:CSV规范里,引号外的空格应该被忽略。但很多生成工具会保留空格,最好加个trim处理
  • 编码问题:我遇到过UTF-8 BOM头导致第一行解析失败的情况。处理前先检查文件头三个字节是不是 0xEF 0xBB 0xBF
  • 大文件性能:如果CSV文件有几十万行,逐行正则匹配可能会慢。这时候可以考虑用状态机来优化

核心要点:CSV解析的本质是「上下文相关的分割」。你不能简单地按逗号或换行符分割,必须考虑引号带来的上下文变化。正则表达式提供了一种优雅的解决方案,但要注意边界情况和性能问题。

好了,CSV解析的核心内容就这些。代码我已经在实际项目里跑过很多次了,基本稳定。你如果遇到什么奇怪的数据格式,欢迎来交流。

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