实战案例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(®ex, pattern, REG_EXTENDED);
if (ret) {
fprintf(stderr, "正则编译失败\n");
return -1;
}
ret = regexec(®ex, 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(®ex);
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(®ex, pattern, REG_EXTENDED);
while (regexec(®ex, 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(®ex);
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解析这些年,我踩过的坑真不少。这里列几个最常见的:
- 空字段:比如
a,,c,中间那个空字段也要保留。正则匹配时要注意,空字符串也是有效字段 - 前后空格:CSV规范里,引号外的空格应该被忽略。但很多生成工具会保留空格,最好加个trim处理
- 编码问题:我遇到过UTF-8 BOM头导致第一行解析失败的情况。处理前先检查文件头三个字节是不是
0xEF 0xBB 0xBF - 大文件性能:如果CSV文件有几十万行,逐行正则匹配可能会慢。这时候可以考虑用状态机来优化
核心要点:CSV解析的本质是「上下文相关的分割」。你不能简单地按逗号或换行符分割,必须考虑引号带来的上下文变化。正则表达式提供了一种优雅的解决方案,但要注意边界情况和性能问题。
好了,CSV解析的核心内容就这些。代码我已经在实际项目里跑过很多次了,基本稳定。你如果遇到什么奇怪的数据格式,欢迎来交流。