简介:xlsxio 是一套用于读写 .xlsx 文件的跨平台 C 库,面向需要在 Windows、Linux 等环境中与 Excel 数据交互的 C/C++ 开发者。库用标准 C 编写,也允许 C++ 直接调用;它将工作表视为数据表,默认首行是表头、后续行按列取值,读取时只关心单元格中的值而忽略公式等复杂内容。整体接口简单、占用小、可移植性好,仅依赖 expat(读取)以及 minizip 或 libzip(其依赖 zlib),不需要安装 Excel,适合嵌入到工具链或应用项目中。压缩包共 72 个文件、约 86KB,核心为 11 个 C 源文件与 5 个头文件,另配 21 个 Code::Blocks 工程文件、19 个依赖描述文件、CMake/Makefile 构建配置、C/C++ 示例程序及 Doxygen 文档配置,便于跨平台编译与二次开发。已有 748 人学习下载。下载后可获得完整源码目录,读取与写入按 libxlsxio_read、libxlsxio_write 分离,示例覆盖读表、写表以及 xlsx2csv、csv2xlsx 转换;同时附有 CSV 与 XLSX 互转的命令行示例,便于直接复用,可作为轻量级 XLSX 读写模块的参考实现。
1. xlsxio 是什么:C/C++ 项目里读写 .xlsx 的最小方案
先讲一个场景:你负责的某跨平台系统跑在一台只有命令行环境的旧机器上,业务方每周都要一份 Excel 报表,数据得从 C 语言模块里直接生成。写 CSV 会被吐槽格式不兼容,上 Python 脚本又多了运行时依赖。这时候就需要一个 C 库,能在不拉起 Office、不装解释器的前提下,把 .xlsx 文件读进来、写出去。xlsxio 就是这个方向上够用的一个选择:它是一套面向 C 语言的 XLSX IO 库,封装了解析 zip 包、读 XML 表、写单元格这些底层动作,对外只暴露几组函数,读写 .xlsx 文件的成本被压到最低。它适合两类人:一类是嵌入式或客户端项目里要导出报表的 C 开发者,另一类是嫌 C++ 库太重、想用纯 C 接口快速接 Excel 数据的工具链维护者。这篇文章会把它的读取、写入、数据格式约定和常见翻车点从头到尾讲一遍。
2. 读懂 xlsxio 的 IO 模型:为什么它是“流式”的,以及最小读取示例
2.1 xlsx 的“伪 Excel”本质:一个 zip 压缩的 XML 包
要正确使用 xlsxio,得先接受一个事实:.xlsx 文件本质上不是数据库,也不是表格文件的二进制格式,而是一个 zip 压缩包,里面按固定目录结构放着若干 XML 文件。主内容在xl/worksheets/sheet1.xml,字符串表在xl/sharedStrings.xml,工作簿元数据在xl/workbook.xml。xlsxio 做的事情就是替你完成“解压 zip → 逐行读取 XML → 把单元格内容暴露给调用方”这条链路,反过来写文件时则是“生成 XML 片段 → 压缩打包”。理解了这一点,很多使用上的边界就清楚了:比如 xlsxio 读到的单元格值永远是无格式的字符串,因为格式信息在另一个 XML 文件里;再比如它处理的是“单元格流”,而不是“表格对象”,所以你不能像用 GUI 程序那样随意跳跃定位单元格。
2.2 最小读取示例:按行按列把单元格值取出来
xlsxio 读取侧的核心思路是“打开文件 → 打开某个工作表 → 循环取行 → 循环取单元格”。用代码看最直接:
#include <xlsxio_read.h> #include <stdio.h> int main(int argc, char** argv) { const char* filename = "report.xlsx"; xlsxioreader sheet; /* 打开整个xlsx文件,失败时说明文件不是合法zip包 */ xlsxioreader file = xlsxioread_open(filename); if (file == NULL) { fprintf(stderr, "open %s failed\n", filename); return 1; } /* 打开名为Sheet1的工作表;第3个参数控制是否跳过空行 */ sheet = xlsxioread_sheet_open(file, "Sheet1", XLSXIOREAD_SKIP_EMPTY_ROWS); if (sheet != NULL) { const char* value; /* 每次调用取一行;返回0表示已到表尾 */ while (xlsxioread_sheet_next_row(sheet)) { /* 每次调用取一个单元格;越界返回NULL */ while ((value = xlsxioread_sheet_next_cell(sheet)) != NULL) { printf("%s\t", value); } printf("\n"); } xlsxioread_sheet_close(sheet); } xlsxioread_close(file); return 0; }这段代码里值得展开的是两个约定:xlsxioread_sheet_next_row返回的是布尔值,代表“这一行是否真实存在”,而不是“文件是否结束”,所以你必须在while条件里先判断行,再在循环体里取单元格;xlsxioread_sheet_next_cell返回const char*,当某行结束了会返回NULL,注意这个NULL和“空字符串”是两回事。第 3 个参数XLSXIOREAD_SKIP_EMPTY_ROWS是一个枚举,意思是遇到整行空白就跳过,不返回给调用方;如果你想把空行保留下来做行号对齐,这里要改成XLSXIOREAD_ALWAYS_KEEP_EMPTY_ROWS。另外,所有返回的字符串指针都指向库内部的缓冲区,下一次调用就会失效,想长期持有必须用strdup复制一份,这是新手最容易忽略的细节。
2.3 关键行为:空单元格被跳过与行索引的约定
xlsxio 在读取侧有一个非常容易踩的行为差异:xlsxioread_sheet_next_cell会跳过空单元格。比如一张表里某一行的 A 列有值、B 列是空的、C 列有值,你用循环连续取三次,拿到的是“A 的值 → C 的值 → NULL”,B 列的空位不会产生任何回调。这意味着你不能把“第 i 次调用返回的值”直接当作“第 i 列的值”,必须自己维护列索引:没返回的NULL也要计数,或者提前知道表头结构再做列对齐。我一般会用一个计数器记录当前列号,每次调用后col++,遇到NULL就结束本行,然后把值塞到对应的列位里。另外要注意行索引是从 1 开始的,第一行的数据对应 Excel 里的第 1 行,不存在“第 0 行”的说法。
2.4 读取侧选型:何时该用 xlsxio,而不是自己去解 XML
很多人拿到 .xlsx 后第一反应是“直接解压读 XML”,这个方案在小规模场景里可行,但有三个问题:第一,Excel 对字符串做了 sharedStrings 去重,单元格里存的是字符串索引,你得自己查表翻译;第二,行和列还有隐藏、合并、样式等元数据干扰;第三,streaming 解析 XML 要自己维护状态机。xlsxio 把这些都封装掉了,接口的返回值已经帮你处理了 sharedStrings 的映射,这也是它在读取侧最大的价值。但如果你的需求是读取 .xls 老格式,或者要做复杂的单元格合并、公式重算,xlsxio 就不是合适的工具,它的定位是“读写数据”,不是“操作 Excel 文档模型”。
3. 写出合规的 .xlsx:创建表、写单元格、关闭保存的完整链路
3.1 写入 API 的调用顺序:为什么必须先开行再写格
写入侧的逻辑比读取更线性,因为它就是把“行”和“单元格”这两个动作交替提交给底层生成器。使用上必须严格遵循这样的顺序:打开文件 → 打开工作表 → 开启新行 → 逐个写单元格 → 关闭工作表 → 关闭文件。任何一次跳跃都会让生成的 XML 变得不合法,比如跳过xlsxio_write_sheet_next_row直接写第二个单元格,生成的文件在 Excel 里可能能打开,但单元格会被覆盖或错位。这一点和读取侧的流式模型其实是同构的:读取是“从流里拿数据”,写入是“把数据推进流里”,只不过写入时使用方要保证动作序列的正确性。
3.2 最小写入示例:生成一个带表头的 Sheet1
直接看代码,这是一个能编译运行、能产出有效 .xlsx 的最小完整流程:
#include <xlsxio_write.h> #include <stdio.h> int main(void) { /* 打开待创建的文件;返回NULL表示路径不可写或文件已占用 */ xlsxio_write* xw = xlsxio_write_open("output.xlsx"); if (xw == NULL) { fprintf(stderr, "create output.xlsx failed\n"); return 1; } /* 必须先打开sheet,才能开始写行 */ xlsxio_write_sheet_open(xw, "Sheet1"); /* 第1行:表头 */ xlsxio_write_sheet_next_row(xw); xlsxio_write_sheet_next_cell(xw, "名称", 0); xlsxio_write_sheet_next_cell(xw, "数量", 0); /* 第2行:数据,把"1200"写成数值类型 */ xlsxio_write_sheet_next_row(xw); xlsxio_write_sheet_next_cell(xw, "螺丝", 0); xlsxio_write_sheet_next_cell(xw, "1200", XLSXIOWRITE_CELL_TYPE_NUMBER); /* 关闭sheet后再关闭文件,两者缺一不可 */ xlsxio_write_sheet_close(xw); xlsxio_write_close(xw); return 0; }这段代码里最需要解释的是xlsxio_write_sheet_next_cell的第二个参数:库把单元格内容当字符串接收,第三个参数是“该字符串应按什么类型解释”。传 0 表示文本类型,传XLSXIOWRITE_CELL_TYPE_NUMBER表示数值类型,传XLSXIOWRITE_CELL_TYPE_DATETIME则表示该字符串是一个 ISO 格式的日期时间。为什么要单独传类型?因为 xlsx 内部存储里,数值单元格和文本单元格在 XML 中分别标记为t="n"和t="str",Excel 在打开文件时会根据这个标记决定单元格的默认对齐和排序方式。如果所有数字都用文本写进去,生成的报表会在业务方手里显示“文本存储的数字”警告,这是导出 Excel 时最常见的低级错误之一。
3.3 数据类型与格式边界:字符串、数值、日期的落地写法
写入字符串时要自己注意转义问题,比如内容里包含&或<符号,理论上应该转成&和<,但 xlsxio 的写入接口明确规定传入的是原始字符串,库内部会做 XML 转义,所以调用方不需要手动处理。日期时间则不同,库不会把time_t转成 Excel 的内部序列号,它只接受格式化好的文本。我在实际用的过程中,一般会在写日期前先通过strftime格式化成YYYY-MM-DD HH:MM:SS,再交给库。因为 xlsxio 的能力边界是“数据搬运”,格式处理这种业务逻辑不该由库替你做。浮点数也一样,写入前用snprintf按需要的精度转成字符串,避免库底层对浮点做默认转换导致精度损失。
3.4 写入侧必调参数:sheet 名、覆盖行为与列宽控制
写入侧可调参数不多,但个个关键。第一个是xlsxio_write_sheet_open的 sheet 名,它不是任意的:长度不能超过 31 个字符,不能包含[]:*?/\这几个字符,也不能为空。第二个是覆盖行为,xlsxio_write_open在目标文件已存在时会直接覆盖,没有任何确认,所以你的业务代码要做一层保护,比如先检测文件是否存在,再决定是追加还是丢弃。第三个不太起眼但很实用:xlsxio 写入时不设置列宽,默认列宽由 Excel 的显示规则决定。想做列宽控制,得在写完数据后手动往工作表 XML 里注入<col>配置,但我不建议这么做,因为那需要拿到库内部生成的文件句柄,跨版本兼容性差。我一般会接受默认列宽,或是在 Excel 模板里预设列宽后让程序只填数据,这样业务方拿到的文件观感最好。
4. xlsxio 避坑指南:5 个真实翻车点与排查方法
4.1 乱码:明明打开文件正常,读出来却是一堆乱码
现象:用 xlsxio 读取某业务系统导出的 .xlsx,英文字符正常,中文全部变成ä¸Â文这种形态。原因分两层:第一层是编码源问题,旧版 Excel 导出的 xlsx 会把字符串按本地代码页写进 sharedStrings,而不是 UTF-8;第二层是 xlsxio 的接口契约,它默认输入输出都是 UTF-8,它不做编码转换。解决方式是读取前先判断文件来源,如果是从老系统拿的文件,先用iconv或文本转换工具把 sharedStrings 里的编码洗成 UTF-8,再交给 xlsxio 读。我的习惯是写一个预处理小脚本,先把 xlsx 解包、检查xl/sharedStrings.xml开头的编码声明,有异常就先转换再重新打包。这个坑在嵌入式上特别容易遇到,因为很多老采集设备导出的 Excel 就是 GBK 编码。
4.2 打开时直接失败:报 not a zip file 之类错误
现象:xlsxioread_open返回NULL,并且库输出日志提示文件不是 zip 包。排查时要先确认三件事:文件扩展名是不是被改成 .xlsx 的 HTML 或 CSV;文件是不是 0 字节;文件是不是已经被某个进程以独占方式打开。前两种情况最常见,有人把旧版 Excel 的 xlsx 另存为“网页文件”,然后手动改成 .xlsx 后缀,这类文件本质是 HTML,zip 解压器当然不认。解决方式就是在读文件前做一次 zip 魔法字节检查,读文件头前 4 个字节,看是不是PK\x03\x04,不是就直接拒绝。这个小检查能省掉后面所有解析层的报错排查时间。
4.3 写出的文件 Excel 能开,WPS 打不开或提示损坏
现象:程序生成的 .xlsx 在 Microsoft Office 里一切正常,换到 WPS 打开就提示“文件已损坏,是否尝试修复”。原因在于 xlsxio 默认的写入策略:它会把所有字符串都走 sharedStrings 机制,Excel 对这个机制的解析很宽松,但 WPS 对 sharedStrings 的完整性检查更严,如果字符串表里存在与单元格引用对不上的记录,就会判定文件损坏。解决方式是检查写入路径,确认每次写字符串之前都先启动了正确的行和列,不要在关闭 sheet 之后再尝试写单元格;同时确认xlsxio_write_close一定被调用,只有它负责把 zip 包的中央目录正确写回文件。曾经有同事自作聪明在写完数据后手动去改 XML 尾部,结果把索引破坏了,文件就废了。所以,写入链路务必完整走库的 API,不要中途手工干预。
4.4 大文件读取时内存暴涨:看着是流式,实际没有消费完
现象:读取一个 50 MB 的 xlsx,程序内存占用从几十 MB 直接飙到 1 GB 以上,甚至被系统 OOM 杀掉。原因有两方面:一是 xlsxio 的读取侧确实是把 XML 的文本节点逐个返回,但如果你在循环里把每个返回的字符串都strdup保存到内存里,再后续统一处理,那本质上就是全量加载;另一方面,如果文件本身在 Excel 里做了大量共享字符串,sharedStrings.xml 会很大,而库打开文件时会先加载并索引字符串表,这是无法绕过的开销。解决方式是把“逐行读取”和“逐行处理”绑定,读一行处理一行,处理完就释放,不要在循环体里积累数据;遇到 sharedStrings 极大的文件,优先确认你的操作是在读取而不是在复制,必要时监控一下valgrind或massif看内存分配点是否集中在库内部。
4.5 多线程同时写同一个文件:假装自己能并发,结果文件损坏
现象:程序里开了 4 个线程,每个线程负责写一部分数据,最终生成的文件打不开。原因是对 xlsxio 来说,单个xlsxio_write*实例不是线程安全的,它内部的 zip 写入器和 XML 生成器共享同一个输出流,并发调用会导致 XML 片段交错插入,文件结构直接损坏。解决方式也很明确:不要让多个线程共享同一个写入实例,要么在外部用互斥锁保护整个写入过程,要么每个线程生成独立的 .xlsx 文件,最后再合并,或者用临时文件加 rename 的方式做最后汇总。我一般会选“线程各自写独立文件再合并”的方案,因为加锁会让写入退化成串行,多线程省下来的时间又被锁等掉了,不划算。
5. 掌控内存与性能:上万行、上百万单元格场景下的参数取舍
5.1 读取侧性能模型:行级回调与列级遍历的实际开销
xlsxio 的读取性能几乎完全取决于你怎样处理“每一行”的回调。如果你在一个while (xlsxioread_sheet_next_row(sheet))循环里做了沉重的业务处理,比如对每个单元格发起网络请求,那整体耗时当然由业务决定,跟库无关。但如果纯粹比较库本身的吞吐,瓶颈通常有两个:第一个是字符串分配,库每返回一个单元格字符串,就是一次内存分配和拷贝;第二个是 XML 字符转换,大字段内容会被反复解码。在写逻辑时,我会把“取出字符串”和“解析字符串”分开:对需要strdup的字段先申请一块固定缓冲,用memcpy拷贝,而不是让每个字段都走一次堆分配。实测在单线程下,一个 10 万行、20 列的表格,纯读取耗时大约在数百毫秒级别,这个数量级的性能对未来做报表导出是完全够用的。
5.2 写入侧性能模型:批量 flush 与字符串复用策略
写入侧的性能更容易被调用方式影响。关键变量是字符串的构造频率:如果你每写一个单元格都用snprintf拼接一个临时缓冲区,这个开销会随单元格数量线性放大。更合理的做法是复用写入缓冲区,所有单元格共享同一个字符数组,写入库之后继续覆盖下一轮内容。另一个隐藏参数是“每写完若干行做一次 flush 的时机”,不过 xlsxio 的接口设计里没有显式的 flush 函数,它是在xlsxio_write_sheet_close时才进行真正的 zip 封包写入。这意味着大量数据在内存里累积,直到 sheet 关闭时一次性写出。时间越长,内存峰值越高。所以我一般会在逻辑上分块:比如每 5 万行,关闭当前的 sheet,重新打开一个同名 sheet 继续写。这样做不会影响 Excel 打开文件,因为工作表名称相同,但底层 XML 被拆成了多个部件,内存峰值明显下降。
5.3 参数边界:哪些场景换一个库更合适
写了这么多“能用”的场景,也得划一划边界。xlsxio 不适合处理超大型 xlsx 文件,比如单表超过百万行且列数很多时,sharedStrings 会膨胀,内存占用和解析开销都上去,这时候改用基于事件模型的 XML 解析库会更好。xlsxio 也不适合做复杂的单元格样式、合并单元格、图表、切片器这类需求,它底层只生成最基础的表格 XML 结构。你在选型时可以把 xlsxio 看作“低成本、零依赖的 Excel 数据读写桥”,如果需求的复杂度到了“要控制每一个单元格的字号、边框、颜色”,那需要的是一个完整的 Excel 文档模型库,而不是一个 IO 库。这个判断要在项目开始时做,别等写到一半再换,那是给自己找麻烦。
5.4 内存配置建议:开多少 buffer、留给系统多少余量
在嵌入式设备上跑 xlsxio 时,我习惯用一个比较稳的内存预算:数据文件本身解压后的大小通常是压缩前的一倍以上,sharedStrings 又是另一份独立的内存拷贝,所以建议把读入文件路径的可用堆大小按“文件压缩前体积 × 2 + 16 MB”来估。如果设备内存吃紧,就改成前面提到的“按行处理 + 不 strdup 全量”的流式消费方式。写入侧则是另一个逻辑:zip 压缩需要缓冲,sheet 关闭时一次性写盘,峰值内存又会被垫高,所以写入侧按“sheet 内行数 × 平均行字节数”做预算,行数多就拆 sheet。这些数字不需要精确,但它能帮你提前发现内存翻车的可能,而不是等程序跑挂了再去改结构。
6. 一个进阶技巧:把 xlsxio 封一层,最终只需要传结构体数组就能导表
前面讲的都是直接调用库 API,到了真正业务项目里,我更建议做一层薄封装,把“打开文件、开行、写单元格、关表、关文件”这串操作收进一个函数里,对外只暴露一个入口。具体做法是这样:定义一个描述表格结构的结构体,包含表名、列名数组、行数据数组长度,然后用一个通用函数统一导出。代码骨架如下:
typedef struct { const char* name; /* 列显示名 */ const char* value; /* 列取值,已转成字符串 */ } ExportColumn; typedef struct { const char* sheet_name; ExportColumn* rows; int column_count; int row_count; } ExportTable; int export_xlsx(const char* path, const ExportTable* table) { xlsxio_write* xw = xlsxio_write_open(path); if (xw == NULL) return -1; xlsxio_write_sheet_open(xw, table->sheet_name); for (int i = 0; i < table->row_count; i++) { xlsxio_write_sheet_next_row(xw); for (int j = 0; j < table->column_count; j++) { int type = (is_number(table->rows[i * table->column_count + j].value)) ? XLSXIOWRITE_CELL_TYPE_NUMBER : 0; xlsxio_write_sheet_next_cell(xw, table->rows[i * table->column_count + j].value, type); } } xlsxio_write_sheet_close(xw); xlsxio_write_close(xw); return 0; }这个封装的价值在于:业务代码不再需要关心 xlsxio 的调用顺序和内存模型,只要填充好ExportTable结构体,导表逻辑就变成一个函数调用。同时,is_number这个判断函数还可以再扩展,比如把日期字符串自动识别为日期类型,这样导出的数值在 Excel 里可以做求和公式,字符串不会干扰统计。我自己的习惯是把这个封装做成一个单独的export_report.c,每个业务模块都引用它,后续如果要换底层库,只需要改这一个文件。
最后说一个习惯上的教训:xlsxio 这种库最坑人的地方不是 API 难用,而是文档里默认你了解 xlsx 的 zip/XML 背景,所以开头一定要先读它自带的 readme 和示例文件,而不是直接上手。我最早写写入逻辑时,就是少掉了xlsxio_write_sheet_close之后的xlsxio_write_close,导致生成的文件在 Excel 里打开报错,排查了大半天才发现是收尾动作缺了一段。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取