资讯详情

资讯详情

手写C语言CSV解析器:状态机处理引号换行与转义的完整实践

我第一次用 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 换行文件最后一行不强制要求有换行符。这里最容易被人忽略的是第三条和第四条的组合效果。比如下面这行 CSVhello,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.csvname,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\nLinux 下常见\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 转义引号被当成字段结束这个问题比想象中难查。样例数据显示ab应该解析为ab但如果没有在ST_QEND里处理“再次读到引号”解析器就会在第一个引号处提前结束字段留下多余的b在下一列。我在状态机里用ST_QEND专门处理这个动作引号后面跟引号就是转义后面跟分隔符或换行才是字段结束。5.6 未转义引号等脏数据建议早做拦截最后一类坑来自来源不可控的数据。比如某列明明没有引号内容里却带了一个这种文件不会被我的宽容模式中断但到业务层数值转换时可能会出问题。我的建议是解析器可以宽容但业务层必须做二次校验。也就是解析阶段保证“不崩、不丢列”数值转换阶段再严格判断“字段合法”。这样用户体验最好代码也不至于为脏数据付出太多调试成本。我在实际使用这个解析器的过程中还有一个体会不要把 CSV 解析当成一次性的小函数草草写完。最好准备一份固定的测试样本里面至少包含引号内逗号、引号内换行、转义引号、空字段、CRLF 行尾、BOM、末尾无换行这几种情况。每次改了代码都拿这份样本跑一遍能省下很多排查时间。如果你的项目里也需要频繁处理 CSV 文件建议把这份测试样本连同解析器一起沉淀下来后续维护会轻松很多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →