我第一次用 C 语言处理 CSV 文件时,脑子里想的是“这还不简单,读一行按逗号切分,完事”。结果在某个模拟数据导入环节里被一份真实导出的报表连续教育了几次,才发现 CSV 里头的门道比看上去多得多:字段里可以带引号,引号字段里可以带逗号,甚至还能带换行;双引号本身还要转义;文件行的结尾可能是 CRLF 也可能是 LF;稍不注意还会混进来一个 UTF-8 BOM。正是这些边界条件,决定了你写出来的究竟是一个能用的解析器,还是一个随时会被数据整崩溃的玩具。
这篇文章就把我手写 C 语言 CSV 解析器的完整思路和踩坑过程整理出来,适合已经有 C 基础、正准备处理 CSV 数据或者想理解“为什么不能简单 split”的朋友。我会从格式规则讲起,然后给出一个可以用在实际项目里的解析器实现,最后聊聊性能、内存和真实数据里常见的坑。
1. CSV 真没你想象中那么“纯文本”,规则先确认好
很多人觉得 CSV 就是把文本用逗号切开,事实上它是一套有引号包裹规则、有转义规则、有行结束约定的结构化格式。别看它长得像普通文本,处理起来和 JSON、INI 一样需要正经解析。
1.1 CSV 的语法其实只有几条规则,却让很多人翻车
CSV 格式经常被拿来和 RFC 4180 关联,虽然现实中的文件并不会 100% 遵循标准,但核心规则可以概括成四条:
- 字段之间用分隔符隔开,默认是逗号,但也可以是制表符或别的字符;
- 如果字段内容里包含分隔符、双引号或者换行,整个字段必须用双引号括起来;
- 字段内容里的双引号,用两个连续的双引号转义;
- 记录之间用 CRLF 或 LF 换行,文件最后一行不强制要求有换行符。
这里最容易被人忽略的是第三条和第四条的组合效果。比如下面这行 CSV:
"hello,world",42第一个字段是hello,world,因为逗号被引号包裹住了,它是内容不是分隔符。再比如:
"say ""hello""",1第一个字段解析出来是say "hello"。连续两个双引号表示一个普通双引号,而不是字段结束。类似这种组合不处理,解析出来的列数和字段值都会是错的。
1.2 为什么“逐行读再切分”的方案在真实数据上不可靠
我见过太多刚开始做 CSV 解析的人,包括当初的我,上来就是fgets逐行读,然后strtok按逗号切。这个方案在一种极其理想的条件下是成立的:所有字段里都没有分隔符、没有引号、没有换行,且每行结束符号统一。
但真实数据不是这样的。别人导出的表格里,只要有一个字段内容包含\n或者,,并且按照规则加了引号,那“按物理行读取”这个动作就已经把语法结构破坏了。等读到一半发现字段还没结束,后面就算想补救也很难拼接。
正确理解应该是:CSV 的解析单位不是“物理行”,而是“逻辑字段”。一个字段是否结束,不取决于有没有碰到换行,而取决于当前处在引号之外还是引号之内。所以真正可靠的解析方式,通常是逐字符扫描,用“状态机”来跟踪当前位置。
1.3 用现成库还是自己写:我这边的选择逻辑
C 语言生态里确实有现成的 CSV 解析库,功能完整、测试也相对充分。但在实际项目里,我遇到过几种情况让我更倾向写一个精简版本:
- 嵌入式设备交叉编译,引入第三方代码要处理依赖关系,成本有点高;
- 分析任务只针对一个固定格式的文件,为一个小功能拉整个库不划算;
- 项目对许可证、代码可控性有要求,希望解析逻辑完全在自己手里;
- 想要深度定制容错行为,比如“遇到非引号字段里的引号,是报错还是忽略”。
如果业务面对的是完全不可控的外部数据,那用成熟库更稳妥;如果只是处理自己定义、自己导出的固定格式,自写解析器完全没问题。关键是不要小看这个格式,准备几份包含边界条件的测试数据再加写代码,效果会好很多。
2. 手写解析器:结构设计、状态机和核心代码
既然决定自己写,我第一个考虑的问题是“解析结果怎么给出去”。第二个问题才是“内部怎么解析”。下面这两部分我先分开说。
2.1 输出结构设计:回调式还是全量存储
最简单直观的做法是解析完整个文件,把所有行都存到一个二维数组里。代码写起来方便,但内存占用和文件大小成正比。遇到几百 MB 的 CSV,就算每行只有几十个字段,也很容易把内存吃满。
我实际更常用的是“回调式解析”:解析器每解析出一行完整记录,就调用一次用户提供的回调函数。由回调决定怎么消费这行数据。这样有个天然的好处:如果你只需要统计求和或者过滤某些行,处理完一行就能释放一行,内存峰值不随文件大小线性增长。如果你确实需要保存全部数据,回调里也可以自己拷贝字段到自定义结构体里。
基于这个思路,我定义一个解析接口:
typedef int (*csv_line_cb)(int line_no, char **fields, size_t n_fields, void *ud);line_no是逻辑行号,fields是这一行的字段数组,n_fields是字段数量,ud是用户在外部传入的上下文指针。这样写起来很干净,解析器本身不关心数据最终去哪。
2.2 解析状态机:四个状态轮流切换
我设计的核心状态有四个:
ST_START:一个字段开始前的位置,等待第一个有效字符。ST_FIELD:正在解析一个普通未加引号的字段。ST_QUOTE:正在解析一个被双引号包裹的字段。ST_QEND:刚刚读到一个引号,需要判断这个引号是字段结束,还是转义双引号。
状态跳转的逻辑其实很固定:
- 在
ST_START遇到普通字符,进入ST_FIELD; - 在
ST_START遇到引号,进入ST_QUOTE; - 在
ST_FIELD遇到分隔符或换行,结束当前字段; - 在
ST_QUOTE遇到引号,进入ST_QEND; - 在
ST_QEND再遇到一个引号,说明是转义双引号,把它追加进字段内容并回到ST_QUOTE; - 在
ST_QEND遇到分隔符或换行,说明字段已经结束,按换行或分隔符处理。
你可能会觉得状态机写起来麻烦,但它的优点非常明显:遇到“引号字段里的换行”这种特殊情况时,代码不需要去猜上一行发生了什么,因为状态本身已经记录了上下文。
2.3 核心代码:长度自适应的字段缓冲区
下面这段代码我尽量保持在可直接使用的程度。它采用可变长度缓冲区存放当前字段,字段结束就复制一份挂到当前行数组里;整行解析完就回调给外部。代码里做了两处“宽容处理”:非引号字段中出现的普通引号、引号结束后紧跟普通字符,我都会按普通内容继续解析。严格按 RFC 标准这些都应该判错,但真实文件里经常出现,所以我先选择不崩。
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef int (*csv_line_cb)(int line_no, char **fields, size_t n_fields, void *ud); static int ensure_cap(void **ptr, size_t *cap, size_t need, size_t elem) { if (need <= *cap) return 0; size_t next = (*cap == 0) ? 32 : *cap; while (next < need) next <<= 1; void *tmp = realloc(*ptr, next * elem); if (!tmp) return -1; *ptr = tmp; *cap = next; return 0; } static int add_field(char ***row, size_t *rcap, size_t *rlen, const char *field, size_t flen) { if (ensure_cap((void **)row, rcap, *rlen + 1, sizeof(char *)) < 0) return -1; char *copy = malloc(flen + 1); if (!copy) return -1; memcpy(copy, field, flen); copy[flen] = '\0'; (*row)[(*rlen)++] = copy; return 0; } static int finish_row(char ***row, size_t *rcap, size_t *rlen, const char *field, size_t flen, int line_no, csv_line_cb cb, void *ud) { if (*rlen == 0 && flen == 0) return 0; if (add_field(row, rcap, rlen, field, flen) < 0) return -1; if (*rlen == 0) return 0; int ret = cb(line_no, *row, *rlen, ud); for (size_t i = 0; i < *rlen; i++) free((*row)[i]); *rlen = 0; return ret; } static void skip_lf(FILE *fp) { int d = getc(fp); if (d != '\n' && d != EOF) ungetc(d, fp); } int csv_parse(FILE *fp, csv_line_cb cb, void *ud, int sep) { enum { ST_START, ST_FIELD, ST_QUOTE, ST_QEND } st = ST_START; char *field = NULL; size_t fcap = 0, flen = 0; char **row = NULL; size_t rcap = 0, rlen = 0; int line_no = 1; int c; while ((c = getc(fp)) != EOF) { switch (st) { case ST_START: if (c == sep) { if (add_field(&row, &rcap, &rlen, field, flen) < 0) goto fail; flen = 0; } else if (c == '\n' || c == '\r') { if (finish_row(&row, &rcap, &rlen, field, flen, line_no, cb, ud) < 0) goto fail; flen = 0; if (c == '\r') skip_lf(fp); line_no++; } else if (c == '"') { st = ST_QUOTE; } else { if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = (char)c; st = ST_FIELD; } break; case ST_FIELD: if (c == sep) { if (add_field(&row, &rcap, &rlen, field, flen) < 0) goto fail; flen = 0; st = ST_START; } else if (c == '\n' || c == '\r') { if (finish_row(&row, &rcap, &rlen, field, flen, line_no, cb, ud) < 0) goto fail; flen = 0; if (c == '\r') skip_lf(fp); line_no++; st = ST_START; } else { if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = (char)c; } break; case ST_QUOTE: if (c == '"') { st = ST_QEND; } else if (c == '\n') { if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = '\n'; } else if (c == '\r') { skip_lf(fp); if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = '\n'; } else { if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = (char)c; } break; case ST_QEND: if (c == '"') { if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = '"'; st = ST_QUOTE; } else if (c == sep) { if (add_field(&row, &rcap, &rlen, field, flen) < 0) goto fail; flen = 0; st = ST_START; } else if (c == '\n' || c == '\r') { if (finish_row(&row, &rcap, &rlen, field, flen, line_no, cb, ud) < 0) goto fail; flen = 0; if (c == '\r') skip_lf(fp); line_no++; st = ST_START; } else { if (ensure_cap((void **)&field, &fcap, flen + 1, 1) < 0) goto fail; field[flen++] = (char)c; st = ST_FIELD; } break; } } if (flen > 0 || rlen > 0) { if (finish_row(&row, &rcap, &rlen, field, flen, line_no, cb, ud) < 0) goto fail; } free(field); free(row); return 0; fail: free(field); free(row); return -1; }代码里几个点需要解释一下。
skip_lf是为了处理 CRLF:当读到\r时,再去读一个字符,如果读到的是\n就消费掉,否则用ungetc放回去。这样不管是\n、\r\n还是单独的老式\r,都能稳定识别。
flen > 0 || rlen > 0这个收尾条件很重要。文件如果最后一行没有换行,比如内容以a,b结尾而不是a,b\n,主循环结束之后字段和行还留在缓冲区里,必须做一次收尾。如果文件是空的,这个条件就不会触发,不会产生多余的空记录。
2.4 为什么“宽容处理”是务实之选
严格来说,ST_FIELD状态里遇到"是格式错误,ST_QEND状态里引号后面紧跟普通字符也算格式错误。但我在实际项目中经常遇到来源不明的 CSV,里面就有这种“不太标准但能看懂”的数据。把错误直接报死,用户拿到的是一段段看不懂的报错;宽容处理至少能把数据解析出来,再用数据校验逻辑去判断哪一行不合法。
如果你负责的系统对数据要求非常严格,那可以把这两处改成返回具体错误码,并让调用方决定是终止还是跳过。总之不要把容错策略写死在解析器里,最好做成可在项目里定制。
3. 实战:解析一张成绩表并实时算出统计结果
有了解析器,接下来拿一个真实场景跑通一遍。
3.1 准备测试数据与服务目标
假设有一张成绩表scores.csv:
name,math,physics,english A同学,85,90,78 B同学,92,88,94 C同学,76,80,86 D同学,Error,91,88目标是解析这个文件,跳过表头,把每个同学三科的平均分算出来;遇到无法转成数字的字段,输出异常提示,但不中断整个文件的解析。
3.2 回调接口如何消费解析结果
回调函数会在每一行解析完成后被调用。我在回调里拿到字段数组后,先判断line_no == 1跳过表头,再判断字段数量够不够四个。接下来把第 1、2、3 列用strtod转成数值。
typedef struct { double total; int count; int bad; } ScoreStats; static int process_line(int line_no, char **fields, size_t n_fields, void *ud) { ScoreStats *st = ud; if (line_no == 1) return 0; if (n_fields < 4) { st->bad++; return 0; } double sum = 0; int ok = 1; for (int i = 1; i < 4; i++) { char *end = NULL; double v = strtod(fields[i], &end); if (end == fields[i] || *end != '\0') { ok = 0; break; } sum += v; } if (!ok) { st->bad++; printf("第 %d 行数据有误: %s\n", line_no, fields[0]); return 0; } st->total += sum / 3; st->count++; printf("%s 的平均分是 %.2f\n", fields[0], sum / 3); return 0; } int main(void) { FILE *fp = fopen("scores.csv", "r"); if (!fp) return 1; ScoreStats st = {0}; int ret = csv_parse(fp, process_line, &st, ','); fclose(fp); if (ret != 0) { fprintf(stderr, "解析失败\n"); return ret; } if (st.count > 0) { printf("有效记录 %d 条,总平均 %.2f,异常行 %d 条\n", st.count, st.total / st.count, st.bad); } return 0; }跑出来的结果大概是这样:
A同学 的平均分是 84.33 B同学 的平均分是 91.33 C同学 的平均分是 80.67 第 4 行数据有误: D同学 有效记录 3 条,总平均 85.44,异常行 1 条注意,回调函数返回 0 表示继续解析,返回非 0 会让csv_parse终止。如果你在回调里发现当前行已经彻底不可用,或者文件不符合预期,可以直接返回 -1 中断整个解析流程,外层拿到非 0 返回值就能知道有问题。
3.3 数值转换:strtod 和它的两个陷阱
用strtod做字段转换时,有两个很容易被忽略的点。
第一,判断转换是否失败,不能只看返回值,要同时看end指针。如果strtod返回 0.0 且end == fields[i],说明位置没有前进,该字段根本不是合法数字。第二,*end必须是'\0',否则像"85abc"这种字段会被当成 85 解析,剩下的abc被悄悄忽略。对于成绩表这种严格数值场景,我会直接判断*end != '\0'就把整行标记为异常。
3.4 表头、空行和坏数据的过滤策略
表头有几种过滤方式。第一种是固定跳过第一行,适合已知文件结构;第二种是判断首行字段内容是否等于name之类的关键字;第三种是把两种都结合,比如第一行字段里有不认识的列名就跳过。
空行也需要考虑。按我的解析器实现,连续两个换行之间的空行会被finish_row忽略,所以不会进入回调。但如果你自己实现的解析器把空行当成一条空记录,那会莫名其妙多出很多脏数据。建议在解析层就把空行过滤掉,而不是丢给业务层挨个判断。
4. 性能、内存和跨平台兼容:四个不能忽略的细节
解析器能跑通是一码事,拿到大文件和奇怪编码的文件还能不能稳,又是另一码事。
4.1 getc 读取大文件时,缓冲区和 setvbuf 怎么配合
getc本身不是问题,因为它读的是标准库的缓冲。但不同平台的默认缓冲区大小和策略不一样,有的比较小。读取几百 MB 文件时会频繁触发系统调用,拖慢速度。
一个便宜又有效的优化是在打开文件后设置更大的缓冲区:
FILE *fp = fopen("large.csv", "rb"); setvbuf(fp, NULL, _IOFBF, 1 << 20);把缓冲区调到 1 MB 左右,getc大部分时间都在内存里取字符,系统调用次数会大幅减少。对于大多数业务场景,这个优化已经足够了。如果你要处理的是 GB 级以上文件,还可以考虑自己实现输入接口,用fread一次读一大块,再在解析器内部管理一个游标,但复杂度会上升一个台阶。
4.2 字段内存怎么分配才不容易碎片化
上面的解析器实现里,每个字段结束时都会malloc一次,然后拷贝内容。写起来简单,但如果文件有上百万行、每行几十个字段,就会产生大量小内存块。分配和释放的开销、内存碎片问题都会显现。
如果实际场景对性能很敏感,我有两个思路:
- 在解析完后统一管理字段的内存池,用一个大缓冲区承接所有字段内容,字段之间存在问题就不好处理;
- 把多个小字段集中存到一段连续内存里,用一个索引结构去记录每个字段的偏移和长度。
普通规模的 CSV 不用太操心,但如果你是在嵌入式或低内存环境下跑,建议先在原型阶段用大数据量测一下碎片。比如反复解析同一份文件,观察mallinfo之类的输出,确认是否有明显的 memory fragmentation。
4.3 CRLF、UTF-8 BOM 和全角逗号
不同来源的 CSV,行结束符并不统一。Windows 下通常导出\r\n,Linux 下常见\n,甚至还有老式 Mac 的\r。我建议打开文件时使用二进制模式,例如fopen(path, "rb"),然后让解析器自己识别三种换行。这样比依赖标准库在文本模式下偷偷转换更可控。
UTF-8 BOM 是另一个容易踩的坑。某些 Windows 导出工具会在文件开头写入EF BB BF。如果你不处理,第一行的第一个字段就会变成"name"前面带了一小段不可见字节。可以在解析前判断文件头三个字节是不是 BOM,是的话就从文件里跳过:
int c1 = fgetc(fp); int c2 = fgetc(fp); int c3 = fgetc(fp); if (!(c1 == 0xEF && c2 == 0xBB && c3 == 0xBF)) { rewind(fp); }还有一种情况:如果文件里混入了中文全角逗号“,”而不是半角逗号,解析器按,来分就是错的。这种多半是数据导出工具的问题,不是解析器能兜住的。排查时看一眼原始文件内容就明白了。
4.4 解析器要不要支持自定义分隔符
CSV 里的C虽然代表 Comma,但实际很多文件用制表符\t当分隔符,这种文件通常也叫 TSV。把分隔符作为csv_parse的参数传进去,而不是写死为逗号,是非常值得的接口设计。我上面的接口就已经这么做了,调用时传','或'\t'都行。
如果未来要支持字段级自定义引号字符,可以再把quote也做成参数。大部分时候默认用双引号就够了,如果你遇到用单引号的怪文件,再扩展不迟。
5. 复盘:我在实际项目中踩过的六个 CSV 坑
最后一节,我按时间顺序回忆一遍自己踩过的坑。每一个都真实发生过,对应到上面的解析器实现,其实都有明确的原因和解决方案。
5.1 引号字段里的换行让“行号”失去意义
第一次遇到带引号字段的换行时,我还在用fgets逐行读。当时一份备注列里有大段多行文本,结果每段文本都被错误地拆成了好几行。后来我才理解,CSV 一个字段可以跨物理行,所谓“行号”应该是逻辑行号。在我最终的解析器里,只有真正遇到字段结束且行结束才会把line_no加一。如果你想统计物理行号,必须在更外层自己维护。
5.2 文件末尾多出一个空记录
有一版实现解析循环结束后,无论有没有内容都尝试提交一次当前行。结果一个正常的文件解析完,末尾总是多出一条空记录,导致统计结果多算一行。解决办法就是我代码里的条件:flen > 0 || rlen > 0,只有缓冲区里确实还有内容才做收尾。
5.3 每行最后一个字段多了 \r
这个问题发生在 Linux 上读取 Windows 生成的 CSV。当时最后一个字段解析出来结尾总是带着\r,字符串比较永远不相等。后来我在解析循环里遇到\r和\n都当作行结束处理,并用skip_lf消费掉多余的\n,这个问题才算彻底根治。这个细节建议你在代码里一定要显式处理。
5.4 连续空字段a,,b被错误合并
有些实现遇到分隔符直接跳过,不生成空字段,导致a,,b被解析成a和b两个字段。这样数据顺序就全乱了。正确的做法是:每当遇到分隔符,无论当前字段内容是否为空,都要把当前字段提交一次。我的add_field就是无条件执行,这样a,,b才能解析成三个字段,在业务上才能和列标题对齐。
5.5 转义引号""被当成字段结束
这个问题比想象中难查。样例数据显示"a""b"应该解析为a"b,但如果没有在ST_QEND里处理“再次读到引号”,解析器就会在第一个引号处提前结束字段,留下多余的"b在下一列。我在状态机里用ST_QEND专门处理这个动作:引号后面跟引号,就是转义;后面跟分隔符或换行,才是字段结束。
5.6 未转义引号等脏数据,建议早做拦截
最后一类坑来自来源不可控的数据。比如某列明明没有引号,内容里却带了一个",这种文件不会被我的宽容模式中断,但到业务层数值转换时可能会出问题。我的建议是:解析器可以宽容,但业务层必须做二次校验。也就是解析阶段保证“不崩、不丢列”,数值转换阶段再严格判断“字段合法”。这样用户体验最好,代码也不至于为脏数据付出太多调试成本。
我在实际使用这个解析器的过程中,还有一个体会:不要把 CSV 解析当成一次性的小函数草草写完。最好准备一份固定的测试样本,里面至少包含引号内逗号、引号内换行、转义引号、空字段、CRLF 行尾、BOM、末尾无换行这几种情况。每次改了代码都拿这份样本跑一遍,能省下很多排查时间。如果你的项目里也需要频繁处理 CSV 文件,建议把这份测试样本连同解析器一起沉淀下来,后续维护会轻松很多。