简介面向需要在 Windows 平台处理 Excel 的 C 开发者这份 VS2015 工程示例基于开源库 xlnt演示不依赖 COM 与 Office Interop、直接用 C 读写 xlsx 的做法可用于报表生成、数据导入导出与自动化脚本等场景。压缩包共 99 个文件、约 3.21MB以 68 个 hpp 头文件为主体配 dll、lib 等链接库与调试符号含可直接打开的 VS 解决方案、2 个 xlsx 读写样例及可执行程序。资源已有 4260 人学习。读者可对照样例跑通工作簿创建与打开、活动工作表切换、单元格取值赋值、字体加粗等样式设置、公式写入以及保存到文件等关键环节并借用两个表格文件验证输出快速把 xlnt 接入自有工程。1. xlnt_demo 要先说清的事C 里读写 Excel 为什么不用 COM 了去年给一个设备巡检项目做数据导出服务器是 Windows Server不准装 Office客户又咬死要 .xlsx——CSV 打开是乱码、没有表头样式、日期变成一串数字。走 COM 调 Excel没装 Excel 直接歇菜用 ODBC 驱动装驱动又是一堆事。最后落在xlnt这个纯 C 的 xlsx 读写库上不依赖 Office、不依赖 COM把头文件和静态库塞进 VS2015 工程就能编。这个 xlnt_demo 就是当时抽出来的最小工程一个解决方案、一份预编译静态库、一个读表示例、一个写表示例带样式、合并单元格、日期和公式。适合两类人手上有 C 桌面端或服务端、需要批量生成报表的开发者以及接过「把数据库数据导成 Excel 给业务」这种需求、正在找不折腾方案的人。Excel 读写这件事C 生态里能选的不多xlnt 算踩坑成本比较低的一个。2. 工程结构拆解一个 VS2015 解决方案里都放了什么xlnt_demo 没搞复杂一个 sln、一个 vcxproj所有依赖以「头文件 静态库」的形式躺在仓库里拷贝到别的机器上不用再装环境。这种组织方式对老项目特别友好——VS2015 的工程最怕依赖链能编过就是硬道理。2.1 目录骨架头文件、静态库、源码、样例数据各就各位xlnt_demo/ ├─ include/ │ └─ xlnt/ # xlnt 公共头文件整目录拷进来 │ ├─ xlnt.hpp # 汇总头业务代码只 include 这一个 │ ├─ workbook.hpp │ ├─ worksheet.hpp │ ├─ cell.hpp │ └─ ... ├─ lib/ │ └─ x64/ │ ├─ Debug/xlnt.lib │ └─ Release/xlnt.lib ├─ src/ │ ├─ main.cpp # demo 入口读一张表写一张表 │ ├─ ExcelReader.hpp/.cpp # 读封装 │ └─ ExcelWriter.hpp/.cpp # 写封装 ├─ data/ │ ├─ input.xlsx # 读取样例 │ └─ output.xlsx # 运行后生成 ├─ third_party/ │ └─ libstudxml/ # xlnt 依赖的 XML 解析库源码 ├─ xlnt_demo.sln └─ xlnt_demo.vcxprojinclude/xlnt是整个目录搬进来的不要只拷xlnt.hpp它内部会把detail/下的头一层层展开少一个文件就是一堆「无法打开源文件」。lib按平台和配置分目录Debug 配 Debug 库、Release 配 Release 库混用会出现运行期崩溃且崩溃位置和调用栈对不上查起来很费劲。third_party/libstudxml是 xlnt 解析 xlsx 内部 XML 的依赖。如果你只拿预编译好的静态库这个目录可以不留要从源码自己编就得靠它。样例数据单独放data是因为 demo 的输入输出路径写的是相对路径工作目录不对就找不到文件——这是新手第一个会撞的墙。2.2 VS2015 工程配置包含目录、库目录、XLNT_STATIC 三处开关工程属性里有五处必须改对少一处就是链接错误或者编译错误。我把它们列成表照着填即可配置项值作用C/C → 常规 → 附加包含目录$(SolutionDir)include让#include xlnt/xlnt.hpp能找到C/C → 语言 → C 语言标准ISO C14xlnt 用了 C14 特性C11 编不过C/C → 预处理器 → 预处理器定义XLNT_STATIC静态链接必须定义否则按 dllimport 处理链接器 → 常规 → 附加库目录$(SolutionDir)lib\$(Platform)\$(Configuration)按 x64/Debug 自动切换链接器 → 输入 → 附加依赖项xlnt.lib把静态库链进来C/C → 代码生成 → 运行库多线程 DLL (/MD)必须与 xlnt 编译时一致XLNT_STATIC这一条最容易被忽略。xlnt 的头文件里会用宏控制__declspec(dllimport)和__declspec(dllexport)静态库模式下不定义它编译器仍按「从 DLL 导入」生成符号链接阶段就会报一堆 LNK2019提示找不到xlnt::workbook::workbook()之类的函数。很多人第一反应是「库没链上」翻半天附加依赖项其实只是少了个宏。运行库选项/MD与 xlnt 默认的 CMake 配置一致。如果你手上的旧工程是/MT两边 CRT 不一致链接器会直接甩 LNK2038提示RuntimeLibrary不匹配。别想着强改一边糊过去统一成/MD或者把 xlnt 源码拉进工程一起用/MT编二选一。2.3 自己编 xlnt 静态库CMake 生成 VS2015 解决方案仓库里那份xlnt.lib不是天上掉下来的是从 xlnt 源码用 CMake 生成 VS2015 工程编出来的。整套命令不复杂但生成器名字必须写对写成Visual Studio 15 2017就会生成你打不开的工程:: 在 xlnt 源码根目录执行 mkdir build cd build :: 生成 VS2015 x64 工程STATICON 出静态库 cmake -G Visual Studio 14 2015 Win64 ^ -DSTATICON ^ -DTESTSOFF ^ -DSAMPLESOFF ^ -DBENCHMARKSOFF ^ -DCMAKE_INSTALL_PREFIX../install .. :: 编译 Release 并安装到 install 目录 cmake --build . --config Release --target install-G指定生成器Visual Studio 14 2015 Win64对应 VS2015 的 64 位工程-DSTATICON决定产物是.lib而不是.dll这一步和 demo 里的XLNT_STATIC是配套的关掉TESTS、SAMPLES、BENCHMARKS纯粹为了省时间它们会拉一堆额外依赖。执行完install后install/include和install/lib里就是可以直接搬进 demo 的东西。VS2015 编 xlnt 有个前提必须打 Update 3。早期版本的 MSVC 对 C14 的constexpr和变参模板支持不全会在 xlnt 的模板代码里报一串莫名其妙的语法错误看着像源码写错了其实是编译器版本不够。这是血泪经验别在 Update 1 上死磕。2.4 demo 入口 main先跑通「读一个格、写一个格」的最小闭环在拆读写细节之前先看main.cpp的骨架。它把整个链路压到三十行先跑通再谈封装#include xlnt/xlnt.hpp #include iostream int main() { try { // 1. 打开输入工作簿失败会抛 xlnt::exception xlnt::workbook in; in.load(data/input.xlsx); // 2. 按索引取工作表索引从 0 开始 xlnt::worksheet ws in.sheet_by_index(0); // 3. 读 A1to_string 对任意类型都能返回文本 std::cout A1 ws.cell(A1).to_string() std::endl; // 4. 新建工作簿默认带一张 Sheet1 xlnt::workbook out; xlnt::worksheet out_ws out.active_sheet(); out_ws.title(结果); out_ws.cell(A1).value(hello xlnt); out.save(data/output.xlsx); } catch (const xlnt::exception e) { // xlnt 自己的异常带文件、单元格等上下文 std::cerr xlnt error: e.what() std::endl; } catch (const std::exception e) { std::cerr std error: e.what() std::endl; } return 0; }这段代码里藏着两个索引基准不一致的坑sheet_by_index(0)是从 0 开始的cell(A1)里的行列是从 1 开始的。写循环的时候很容易顺手写成sheet_by_index(1)想取第一张表结果取到第二张或者表只有一张直接抛异常。load和save都用相对路径从 VS 里直接 F5 时工作目录是 vcxproj 所在目录从资源管理器双击 exe 时工作目录变成 exe 所在目录找不到文件是常态调试时把工作目录固定住最省事。to_string()是个「兜底」方法数字、布尔、日期、字符串都能转成文本但它不保证格式——日期可能变成一串序列号。要精确取值得走下一章的valueT()。3. 读 Excel 的完整链路从 load 到把整张表读进 vector读这块的核心就三件事找准工作簿里的哪张表、判断单元格里到底是什么类型、按行列把数据捞出来。xlnt 的 API 不难难在边界情况的处理比如空单元格、合并单元格、公式单元格。3.1 打开工作簿与定位工作表xlnt::workbook wb; // load 失败文件不存在、不是合法 xlsx、被占用抛 xlnt::exception wb.load(data/input.xlsx); // 工作表数量与标题列表 std::cout sheet count: wb.sheet_count() std::endl; for (auto ws : wb) { std::cout sheet: ws.title() std::endl; } // 方式一按标题取标题不存在会抛异常 if (wb.contains(订单明细)) { xlnt::worksheet ws wb.sheet_by_title(订单明细); } // 方式二按索引取0 表示第一张 xlnt::worksheet first wb.sheet_by_index(0); // 方式三取当前激活的表适合「用户打开时停在哪张就读哪张」 xlnt::worksheet active wb.active_sheet();load只认.xlsx老式.xls会直接抛异常。这一点在接需求时要先确认清楚业务方给的模板经常是另存的.xls得先让他们在 Excel 里「另存为 xlsx」或者在代码里给出明确提示不然用户只看到「打开失败」四个字。sheet_by_title在标题不存在时抛异常contains先判断再取是稳妥写法。工作表标题有长度限制31 个字符也有若干禁用字符写的时候如果标题超长title()设置会失败这一点在生成报表时要注意。3.2 单元格取值to_string 和 valueT 的边界ws.cell(A1)返回一个xlnt::cell对象它是值语义的轻量包装。取值有两个入口xlnt::cell c ws.cell(B3); // 先判断有没有值空单元格直接 valueT() 会抛异常 if (!c.has_value()) { std::cout empty std::endl; } else { switch (c.data_type()) { case xlnt::cell_type::number: { double d c.valuedouble(); break; } case xlnt::cell_type::shared_string: case xlnt::cell_type::inline_string: { std::string s c.valuestd::string(); break; } case xlnt::cell_type::boolean: { bool b c.valuebool(); break; } default: // 日期、错误值、公式等先用 to_string 兜底 std::string raw c.to_string(); break; } }data_type()返回的是 xlsx 文件里记录的原始类型不是「你希望它是什么类型」。日期在 xlsx 里本质是数字加数字格式所以data_type()很可能返回number而不是date。想稳定拿到日期要么判断数字格式要么约定好模板里那一列就是日期列直接按valuexlnt::datetime()取并捕获异常。这是 xlnt 用起来最别扭的地方之一后面避坑章节还会提。to_string()在空单元格上返回空字符串不会抛异常适合做「展示」valueT()适合做「计算」但必须先has_value()。把这两者的分工记住能少写很多 try-catch。3.3 行列遍历与 range把一张表读成结构体数组真实项目里读表不会一个个格取都是整行整行扫。xlnt 提供rows()和columns()返回的是 range 对象可以嵌套迭代struct OrderRow { std::string order_no; std::string customer; double amount 0.0; }; std::vectorOrderRow read_orders(xlnt::worksheet ws) { std::vectorOrderRow result; // rows(false) 表示空行也返回保证行号和 Excel 里看到的一致 for (auto row : ws.rows(false)) { // row 是一个 rangefront() 拿首格用来判断行号 if (row.front().row() 1) { continue; // 跳过表头 } auto it row.begin(); OrderRow r; r.order_no it-to_string(); it; r.customer it-to_string(); it; r.amount it-has_value() ? it-valuedouble() : 0.0; // 整行全空则丢弃避免把格式行也塞进结果 if (!r.order_no.empty() || !r.customer.empty() || r.amount ! 0.0) { result.push_back(r); } } return result; }rows(false)的参数叫skip_empty传false表示空行也返回。听起来反直觉但这是对的Excel 里删过行的表经常有「中间空一行」的情况跳过空行会让行号和原始文件对不上排查问题时非常难受。传true适合只关心数据的场景传false适合要报「第几行出错」的场景。row.front().row()返回 1 基的行号可以直接用来做错误提示。row.begin()拿到的是单元格迭代器it-to_string()这种写法比ws.cell(xlnt::cell_reference(col, row))快得多因为它不会重复解析单元格地址字符串。如果表列数不固定可以先用ws.highest_column()和ws.highest_row()拿到边界再决定读多少列。这两个函数返回的是「有内容的最大列/行」不是整张表的物理尺寸足够用了。3.4 读的性能取舍按需取还是全量扫一万行以内的表怎么读都行十万行以上就得注意了。ws.cell(A1)每次调用都会解析一次字符串地址在热循环里开销明显。我的习惯是内层循环用 range 迭代器或者用xlnt::cell_reference(col, row)构造好后复用外层循环只读需要的列不整行整行地to_string()。xlnt 还有一套 streaming 接口xlnt::streaming_workbook_reader按行流式读适合几十万行的大表。demo 里没有用它因为接口和普通worksheet不完全一样改写成本高。如果手上的表真的很大先评估「能不能分批读」——按 sheet 拆、按行区间拆往往比换流式接口更省事。4. 写 Excel 的核心细节写数据、合并、样式与公式写比读更容易出「看起来对了但打开不对」的问题值写进去了但显示成####、日期变成 45000 多、公式不计算、合并单元格只剩左上角。这一章按「先能写出来再写好看了」的顺序拆。4.1 从空工作簿开始建表、写表头、落盘xlnt::workbook wb; xlnt::worksheet ws wb.active_sheet(); // 新建的 workbook 自带一张表 ws.title(汇总); // 写表头cell_reference 的构造顺序是 (列, 行)都是 1 基 const char *headers[] {订单号, 客户, 金额}; for (int i 0; i 3; i) { ws.cell(xlnt::cell_reference(static_castxlnt::column_t(i 1), 1)) .value(headers[i]); } // 写数据行 ws.cell(A2).value(NO-001); ws.cell(B2).value(某客户); ws.cell(C2).value(1280.50); // 保存目录不存在或被占用会抛异常 wb.save(data/output.xlsx);xlnt::cell_reference(column, row)是先列后行和cell(A1)的「先字母后数字」正好反过来写循环时很容易传反。我一般只在循环里用它固定位置一律写A2这种字符串可读性高也不容易错。value()是个模板方法传const char*、std::string、int、double、bool、xlnt::datetime都能自动分派到正确的单元格类型。保存时如果目标文件正被 Excel 打开save会抛异常业务代码里要捕获并提示「请先关闭文件」。4.2 样式三件套字体、填充、边框怎么落到单元格xlnt 的样式对象是「值对象」构造好之后cell.font(f)挂上去作用域是单个单元格。想要整行整列统一就循环挂void style_header(xlnt::cell c) { // 字体名称、字号、加粗、白色 xlnt::font f; f.name(微软雅黑); f.size(11); f.bold(true); f.color(xlnt::color(xlnt::rgb_color(0xFF, 0xFF, 0xFF))); c.font(f); // 填充纯色模式前景色是深蓝 xlnt::fill fill; fill.pattern_type(xlnt::pattern_fill_type::solid); fill.foreground(xlnt::color(xlnt::rgb_color(0x2F, 0x54, 0x96))); c.fill(fill); // 对齐水平垂直居中 xlnt::alignment al; al.horizontal(xlnt::horizontal_alignment::center); al.vertical(xlnt::vertical_alignment::center); c.alignment(al); // 边框只加下边框细线 xlnt::border bd; bd.side(xlnt::border_side::bottom).style(xlnt::border_style::thin); c.border(bd); }rgb_color三个参数分别是红、绿、蓝取值 0 到 255。填充不设pattern_type时颜色不生效这是最容易漏的一步——很多人只设了foreground打开 Excel 发现还是白底原因是默认 pattern 是none。字体名称写中文没问题但源码文件本身的编码要跟编译选项匹配否则「微软雅黑」四个字到运行时就成了乱码Excel 打开会用默认字体看起来像是样式没设置成功。4.3 合并、列宽、行高与冻结窗格// 合并表头区域只有左上角单元格保留值 ws.merge_cells(A1:C1); // 列宽按列号设置1 表示 A 列 ws.column_width(1, 18.0); ws.column_width(2, 24.0); ws.column_width(3, 14.0); // 行高 ws.row_height(1, 28.0); // 冻结首行滚动时表头不跑 ws.freeze_panes(A2);column_width的第一个参数是column_t本质是std::uint32_t。千万别写ws.column_width(A, 18)字符A会被当成数字 65结果是把第 65 列拉宽了A 列纹丝不动。这个坑我在两个项目里都见过别人踩自己写的时候也中招过一次查了半天才发现是类型隐式转换。merge_cells之后如果要往里写值写在左上角那个格上写其他格会被忽略或者被合并逻辑覆盖。合并区域里的其他格has_value()返回 false读取时要注意。freeze_panes(A2)表示冻结第一行freeze_panes(B2)表示同时冻结第一行和第一列。参数是一个单元格地址代表「从这个单元格左上角开始切分」。4.4 公式、日期与数字格式写进去和显示出来是两回事// 公式xlnt 只负责把公式字符串写进文件不负责计算 ws.cell(C10).formula(SUM(C2:C9)); // 日期先写 datetime再设置日期数字格式 ws.cell(D2).value(xlnt::datetime(2024, 5, 20)); ws.cell(D2).number_format(xlnt::number_format::date_ddmmyyyy()); // 金额两位小数 ws.cell(C2).number_format(0.00);公式这块要建立正确预期xlnt 不会计算公式结果保存出来的文件里只有公式字符串没有缓存值。Excel 打开时会自动重算所以人眼看没问题但如果有程序在保存后立刻用 xlnt 再读这个文件valuedouble()会失败to_string()返回的是SUM(C2:C9)。要么让下游用 Excel 或其它会重算的工具打开要么在代码里自己算好值写死。日期同理value(xlnt::datetime(...))写进去的是序列号不设数字格式Excel 里显示的是一串数字比如 45432。number_format可以是预定义枚举也可以是 Excel 格式字符串yyyy-mm-dd、0.00%、#,##0.00这些都能直接用。4.5 用模板表更省事加载已有 xlsx 再填数样式复杂到一定程度用代码一行行设字体边框就是自虐。更常见的做法是让业务方给一张排好版的模板 xlsx程序load进来往指定单元格填数另存为新文件。样式、列宽、打印区域全都保留代码量能砍掉一大半。xlnt::workbook wb; wb.load(data/template.xlsx); xlnt::worksheet ws wb.sheet_by_title(月报); ws.cell(B3).value(2024-05); ws.cell(C3).value(1280.50); // 另存不要覆盖模板本身 wb.save(data/report_202405.xlsx);模板方案的前提是模板本身是合法的 xlsx且占位单元格的位置固定。业务方改模板是常事所以填充代码里对「找不到某个 sheet」和「占位格为空」要有明确报错别静默跳过。5. 避坑与排查那几个让我加班到半夜的问题xlnt 的坑集中在「编译链接」和「类型编码」两块前者报错信息吓人但原因单一后者不报错但结果不对更难查。下面四条是出现频率最高的每条按现象、原因、解决写。5.1 LNK2019 / LNK2038宏没定义或者运行库不一致现象编译能过链接阶段报一堆 LNK2019提示无法解析的外部符号全是xlnt::开头的构造函数和方法或者报 LNK2038提示RuntimeLibrary的值不匹配。原因两种。第一种是没在预处理器里定义XLNT_STATIC头文件按动态库导入的方式生成符号静态库里根本没有对应的导出符号第二种是 demo 用/MT、xlnt 静态库用/MD两套 CRT 不能混链。解决工程属性 → C/C → 预处理器加上XLNT_STATIC再检查「代码生成 → 运行库」Debug 用/MDd、Release 用/MD和编译 xlnt 时保持一致。如果手上是别人编好的库、不知道用的哪个运行库最稳的办法是拿 xlnt 源码在本机重编一遍让运行库选项完全可控。改完记得清理解决方案再重编VS 的增量编译有时候不会重新链接。5.2 中文乱码源文件编码、控制台编码、路径编码是三件事现象写进去的「客户名称」在 Excel 里显示成乱码或者程序运行到load直接抛异常日志里路径是乱码。原因xlnt 内部所有字符串都是 UTF-8 编码的std::string。如果你的.cpp文件是 GBK 编码字符串字面量在内存里就是 GBK 字节直接交给 xlnt 就存成了乱码反过来从 Excel 读出来的 UTF-8 字符串直接用std::cout输出到 GBK 控制台也是乱码。解决分三层。源码层把.cpp另存为「UTF-8 带 BOM」或者在 VS2015 的工程里加/utf-8编译选项需要 Update 2 以上。控制台层输出前做一次 UTF-8 到 GBK 的转换或者调用SetConsoleOutputCP(CP_UTF8)把控制台切到 UTF-8。路径层Windows 上std::ifstream按当前 ANSI 代码页解释窄字符路径所以传进去的路径字符串必须是 GBK 字节不能是 UTF-8路径里带中文时宁可先转成 GBK 再传给load别想当然地以为「都是字符串」。顺带说一句工程属性里的「字符集」设成 Unicode 还是多字节对 xlnt 没有影响它不用TCHAR只认std::string。别在这个选项上浪费时间。5.3 空单元格与公式单元格valueT 不是万能取现象读某张表时程序突然崩异常信息看不出是哪个格或者读出来的「合计」列全是字符串SUM(...)。原因valueT()在空单元格上会抛异常在公式单元格上也会失败——公式格在文件里存的是公式字符串没有计算结果。这两个情况在业务表里极其常见尤其是合并单元格展开后的空白区域。解决取值前一律先has_value()或先看data_type()。公式格用to_string()拿到公式串需要结果的话在代码里自己算或者约定「公式列不读」。批量读表时给每一行套 try-catch把行号带进异常信息里日志里能直接定位是第几行第几列出问题。合并单元格只有左上角有值读取时要么展开本文还有配套的精品资源点击获取