
简介本资源面向在 Windows 平台下使用 C 读取 Excel 的开发者重点演示如何借助 VS2010 的 MFC 框架调用 WPS Office 接口读取 XLSX 文件。内容围绕 WPS 动态库加载、接口函数指针获取、工作簿与工作表遍历、单元格数据解析及 MFC 界面显示等环节展开适合具备一定 C 与 MFC 基础、需要在不依赖 Microsoft Office 环境下处理表格数据的读者参考。压缩包共 29 个文件以 12 个 h 头文件与 3 个 cpp 源文件为核心另有 sln、vcxproj 等工程配置及 rc、ico 等界面资源整体约 19.6MB工程结构完整可直接编译调试。目前已有 1807 人学习下载。通过该示例可掌握 WPS 接口调用流程、XLSX 数据解析思路与常见错误处理方式并据此扩展出符合自身业务需求的表格读取模块。1. 用 C 通过 WPS 读取 xlsx一条被低估的落地路径上周帮一个做工业数据采集的朋友排查问题他的 C 程序需要读取现场 Excel 报表但现场机器只装了 WPS没装 Microsoft Office。他一开始用 COM 组件调 Excel.Application结果直接报「无效的类字符串」程序卡死在 CoCreateInstance。这不是个例——很多工控、医疗、财务场景的终端环境都是 WPS 打底C 想读 xlsx要么绕开 Office 依赖要么就得学会跟 WPS 的 COM 接口打交道。这篇笔记拆的就是「C 通过 WPS 读取 Excel」这条路径。它解决的核心问题是在只有 WPS 的 Windows 环境下用 C 稳定读取 xlsx 的单元格、区域和公式结果。适合两类人一是维护老 MFC/Qt 项目、必须走 COM 的工程师二是想评估「WPS 自动化 vs 纯解析库」选型边界的开发者。下面从原理、环境、代码到踩坑一步步拆开。2. 先搞懂 WPS 的 COM 接口为什么 Excel.Application 会翻车2.1 WPS 的 ProgID 与 Excel 的差异Microsoft Office 的 Excel 对外暴露的 ProgID 是Excel.Application而 WPS 表格对应的 ProgID 是KET.Application部分版本也兼容ET.Application。这是第一个分水岭如果你的代码里写死了Excel.Application在纯 WPS 环境下 CoCreateInstance 会直接返回REGDB_E_CLASSNOTREG也就是「类未注册」。WPS 的 COM 接口在设计上尽量兼容 Excel 的对象模型Workbooks、Worksheets、Range、Cells这些对象名基本一致所以迁移成本主要集中在前期的创建和释放环节。但兼容不等于等价后面会讲到几个关键差异点。常见做法是先尝试KET.Application失败再回退到Excel.Application这样同一份代码在两种环境下都能跑。我一般会封装一个CreateExcelApp函数把 ProgID 探测逻辑收进去避免散落在业务代码里。2.2 环境准备注册表、位数与权限在动手写代码前有三件事必须先确认否则后面全是玄学问题。第一确认 WPS 的 COM 组件已注册。打开注册表编辑器定位到HKEY_CLASSES_ROOT\KET.Application如果这个键存在说明 WPS 表格的自动化接口可用。如果不存在通常是安装时没勾选「WPS 表格组件」或者装的是精简版。第二确认位数匹配。你的 C 程序是 32 位还是 64 位必须和 WPS 的 COM 服务器位数一致。64 位程序调 32 位 COM 服务器会失败反之亦然。现场排查时用任务管理器看一眼 WPS 进程的「平台」列就能确认。第三权限问题。如果程序以服务方式运行或者在高完整性级别下运行而 WPS 以普通用户权限运行COM 跨权限调用会被 UAC 拦截。常见做法是把程序清单里的requestedExecutionLevel设为asInvoker避免不必要的提权。提示不要用管理员权限去跑读取 Excel 的程序除非确实需要写系统目录。提权只会让 COM 激活更容易失败。2.3 一个最小可跑的 COM 调用骨架下面这段代码是读取 xlsx 的最小骨架用KET.Application创建实例打开工作簿读取 A1 单元格的值。编译时需要包含comdef.h和atlbase.h并链接ole32.lib、oleaut32.lib。#include windows.h #include comdef.h #include atlbase.h #include iostream // 初始化 COM尝试创建 WPS 表格应用实例 int main() { HRESULT hr CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) { std::cerr COM 初始化失败: std::hex hr std::endl; return -1; } CComPtrIDispatch spApp; // 优先尝试 WPS 的 ProgID hr spApp.CoCreateInstance(LKET.Application); if (FAILED(hr)) { // 回退到 Microsoft Excel hr spApp.CoCreateInstance(LExcel.Application); } if (FAILED(hr)) { std::cerr 无法创建 Excel/WPS 实例: std::hex hr std::endl; CoUninitialize(); return -1; } // 设置 Visible 为 false避免弹窗 CComVariant vVisible(false); spApp.InvokeHelper(0x0000012E, DISPATCH_PROPERTYPUT, vVisible); // Visible 的 DISPID 因版本而异建议用 GetIDsOfNames // 打开工作簿 CComPtrIDispatch spWorkbooks; { CComVariant vResult; spApp.GetPropertyByName(LWorkbooks, spWorkbooks); } CComPtrIDispatch spWorkbook; { CComVariant vFileName(LC:\\test\\demo.xlsx); CComVariant vResult; // Workbooks.Open 的参数较多这里用 Invoke 简化示意 // 实际项目中建议用 GetIDsOfNames 获取 Open 的 DISPID } // ... 后续读取 Cells 的代码 CoUninitialize(); return 0; }上面这段代码故意留了两个「坑点」一是InvokeHelper里硬编码 DISPID 不可靠不同 WPS 版本的 DISPID 可能不同二是Workbooks.Open的参数列表很长直接 Invoke 容易传错。下一章会给出更稳妥的封装方式。逻辑说明CoInitializeEx用COINIT_APARTMENTTHREADED是因为 COM 自动化对象通常是 STA 模型用 MTA 可能导致跨线程调用异常。CComPtr是 ATL 的智能指针自动管理引用计数避免手动 Release 漏掉。参数方面CoCreateInstance的第一个参数是 ProgID第二个参数是聚合对象通常 nullptr第三个是上下文CLSCTX_LOCAL_SERVER表示本地服务器第四个是接口 IID。3. 封装一个可复用的 WPS 读取类从 Open 到 Range 取值3.1 用 GetIDsOfNames 替代硬编码 DISPID上一章的骨架里InvokeHelper硬编码 DISPID 是个隐患。正确做法是用GetIDsOfNames根据方法名动态获取 DISPID。下面封装一个InvokeByName辅助函数把「按名字调用」这件事统一处理。// 辅助函数根据方法名获取 DISPID 并调用 HRESULT InvokeByName(IDispatch* pDisp, const wchar_t* name, WORD flags, DISPPARAMS* pParams, VARIANT* pResult) { if (!pDisp) return E_POINTER; DISPID dispid; OLECHAR* names[] { const_castOLECHAR*(name) }; HRESULT hr pDisp-GetIDsOfNames(IID_NULL, names, 1, LOCALE_USER_DEFAULT, dispid); if (FAILED(hr)) return hr; return pDisp-Invoke(dispid, IID_NULL, LOCALE_USER_DEFAULT, flags, pParams, pResult, nullptr, nullptr); }逻辑说明GetIDsOfNames的第二个参数是名字数组第三个参数是名字数量第四个是语言环境。Invoke的flags参数决定是调用方法DISPATCH_METHOD还是读写属性DISPATCH_PROPERTYGET/DISPATCH_PROPERTYPUT。参数说明pParams是DISPPARAMS结构里面包含参数数组和命名参数调用方法时参数顺序是逆序的这点很容易搞反。3.2 打开工作簿与读取单元格的完整流程有了InvokeByName打开工作簿和读取单元格就顺了。下面这段代码演示从创建实例到读取 A1 值的完整链路关键步骤都加了注释。// 打开工作簿并读取 A1 单元格 CComPtrIDispatch OpenWorkbook(IDispatch* pApp, const std::wstring path) { // 获取 Workbooks 集合 CComPtrIDispatch spWorkbooks; CComVariant vWorkbooks; InvokeByName(pApp, LWorkbooks, DISPATCH_PROPERTYGET, nullptr, vWorkbooks); spWorkbooks vWorkbooks.pdispVal; // 准备 Open 的参数FileName, UpdateLinks, ReadOnly, ... // DISPPARAMS 的参数是逆序的所以最后一个参数放最前面 CComVariant vFileName(path.c_str()); CComVariant vUpdateLinks(0); // 不更新链接 CComVariant vReadOnly(true); // 只读打开避免锁文件 VARIANT args[] { vReadOnly, vUpdateLinks, vFileName }; DISPPARAMS dp { args, nullptr, 3, 0 }; CComVariant vWorkbook; HRESULT hr InvokeByName(spWorkbooks, LOpen, DISPATCH_METHOD, dp, vWorkbook); if (FAILED(hr)) { std::cerr 打开工作簿失败: std::hex hr std::endl; return nullptr; } return vWorkbook.pdispVal; } // 读取指定工作表的单元格值 CComVariant ReadCell(IDispatch* pWorkbook, const std::wstring sheetName, const std::wstring cellAddr) { // 获取 Worksheets 集合 CComVariant vSheets; InvokeByName(pWorkbook, LWorksheets, DISPATCH_PROPERTYGET, nullptr, vSheets); CComPtrIDispatch spSheets vSheets.pdispVal; // 按名称获取工作表 CComVariant vSheetName(sheetName.c_str()); VARIANT sheetArgs[] { vSheetName }; DISPPARAMS sheetDp { sheetArgs, nullptr, 1, 0 }; CComVariant vSheet; InvokeByName(spSheets, LItem, DISPATCH_PROPERTYGET, sheetDp, vSheet); CComPtrIDispatch spSheet vSheet.pdispVal; // 获取 Range CComVariant vAddr(cellAddr.c_str()); VARIANT rangeArgs[] { vAddr }; DISPPARAMS rangeDp { rangeArgs, nullptr, 1, 0 }; CComVariant vRange; InvokeByName(spSheet, LRange, DISPATCH_PROPERTYGET, rangeDp, vRange); CComPtrIDispatch spRange vRange.pdispVal; // 读取 Value CComVariant vValue; InvokeByName(spRange, LValue, DISPATCH_PROPERTYGET, nullptr, vValue); return vValue; }逻辑说明Workbooks.Open的参数顺序在 COM 里是逆序的所以FileName放在args数组的最后。ReadOnly设为true是个好习惯避免程序占用文件导致用户无法编辑。Worksheets.Item既可以传索引也可以传名称传名称时如果工作表不存在会抛异常实际项目中要加 try-catch 或者先遍历判断。参数说明DISPATCH_PROPERTYGET用于读属性DISPATCH_PROPERTYPUT用于写属性DISPATCH_METHOD用于调方法。DISPPARAMS的cArgs是参数个数rgdispidNamedArgs用于命名参数通常 nullptrcNamedArgs是命名参数个数。3.3 批量读取区域比逐单元格快一个数量级逐单元格读Cells(i, j)在数据量大时会非常慢因为每次跨进程调用都有开销。正确做法是用Range一次性读取整个区域拿到一个二维VARIANT数组再在本地遍历。// 批量读取 A1:C100 区域返回二维数组 void ReadRange(IDispatch* pSheet, const std::wstring addr) { CComVariant vAddr(addr.c_str()); VARIANT rangeArgs[] { vAddr }; DISPPARAMS dp { rangeArgs, nullptr, 1, 0 }; CComVariant vRange; InvokeByName(pSheet, LRange, DISPATCH_PROPERTYGET, dp, vRange); CComVariant vValues; InvokeByName(vRange.pdispVal, LValue, DISPATCH_PROPERTYGET, nullptr, vValues); // vValues 是一个 VARIANT包含 SAFEARRAY if (vValues.vt (VT_ARRAY | VT_VARIANT)) { SAFEARRAY* psa vValues.parray; LONG lbRow, ubRow, lbCol, ubCol; SafeArrayGetLBound(psa, 1, lbRow); SafeArrayGetUBound(psa, 1, ubRow); SafeArrayGetLBound(psa, 2, lbCol); SafeArrayGetUBound(psa, 2, ubCol); for (LONG r lbRow; r ubRow; r) { for (LONG c lbCol; c ubCol; c) { VARIANT v; VariantInit(v); LONG idx[2] { r, c }; SafeArrayGetElement(psa, idx, v); // 处理 v注意 VariantClear VariantClear(v); } } } }逻辑说明Range.Value返回的VARIANT在区域大于一个单元格时是VT_ARRAY | VT_VARIANT维度是 2。SafeArrayGetLBound和SafeArrayGetUBound的第二个参数是维度编号从 1 开始。参数说明SafeArrayGetElement的idx数组是各维度的下标顺序和SafeArrayCreate时一致。注意批量读取时如果区域内有公式Value返回的是计算结果不是公式本身。要拿公式用Formula属性。4. 避坑与排查那些让程序卡死或读空的细节4.1 坑一CoCreateInstance 返回 0x80040154现象程序启动后创建实例失败错误码0x80040154对应REGDB_E_CLASSNOTREG。原因ProgID 写错或者 WPS 的 COM 组件没注册。有些 WPS 版本注册的是ET.Application而不是KET.Application还有的只注册了WPS.Application。解决先用OleView或者注册表搜索确认可用的 ProgID。代码里做多级回退KET.Application→ET.Application→Excel.Application。如果全失败提示用户检查 WPS 安装。4.2 坑二打开工作簿后进程残留现象程序退出后任务管理器里还有 WPS 进程或者文件被锁定无法编辑。原因COM 对象没有正确释放引用计数没归零。常见的是Workbook.Close没调或者Application.Quit没调。解决用CComPtr管理所有接口指针确保作用域结束时自动 Release。在退出前显式调用Workbook.Close(false)和Application.Quit()。如果还是残留检查是否有IDispatch被AddRef后忘了Release。4.3 坑三读取空单元格返回 VT_EMPTY 导致崩溃现象遍历区域时遇到空单元格程序崩溃或输出乱码。原因空单元格的Value返回VT_EMPTY或VT_NULL直接当字符串用会解引用空指针。解决取值前先判断vt。如果是VT_EMPTY或VT_NULL当作空字符串处理。如果是VT_BSTR用SysStringLen判断长度。如果是VT_R8转成字符串再拼接。4.4 坑四中文路径导致 Open 失败现象文件路径含中文时Workbooks.Open返回失败错误码0x800A03EC。原因COM 接口期望BSTR如果传入的是char*或者编码不对路径解析会出错。解决统一用std::wstring和CComBSTR。从std::string转换时用MultiByteToWideChar指定CP_ACP或CP_UTF8不要直接强转。4.5 坑五Visible 属性设置时机不对现象设置Visible false后仍然弹出 WPS 窗口或者设置时报错。原因Visible属性必须在打开工作簿之前设置打开后再设可能无效。另外某些 WPS 版本对Visible的 DISPID 和 Excel 不一致。解决创建实例后立即设Visible false。如果InvokeByName找不到Visible用GetIDsOfNames打印实际 DISPID 对比。实在不行接受窗口短暂弹出用ScreenUpdating false减少闪烁。5. 进阶技巧用 IDispatch 做延迟绑定与性能验证5.1 延迟绑定的取舍前面所有代码都走IDispatch延迟绑定好处是不需要导入类型库编译期不依赖 WPS 的 tlb 文件。代价是每次调用都要GetIDsOfNames有额外开销。如果读取频率很高可以把常用的 DISPID 缓存起来避免重复查询。// 缓存 DISPID 的简单示例 class DispIdCache { std::mapstd::wstring, DISPID cache_; IDispatch* pDisp_; public: explicit DispIdCache(IDispatch* p) : pDisp_(p) {} DISPID Get(const std::wstring name) { auto it cache_.find(name); if (it ! cache_.end()) return it-second; DISPID id; OLECHAR* n[] { const_castOLECHAR*(name.c_str()) }; pDisp_-GetIDsOfNames(IID_NULL, n, 1, LOCALE_USER_DEFAULT, id); cache_[name] id; return id; } };逻辑说明缓存以名字为键DISPID 为值。第一次查询后写入 map后续直接命中。参数说明GetIDsOfNames的LOCALE_USER_DEFAULT表示用当前用户区域设置一般不需要改。5.2 验证读取结果是否正确写完读取逻辑后怎么确认拿到的值是对的我一般用「三对照」一是和 WPS 界面里看到的值对照二是和用 Python 的 openpyxl 读出来的值对照三是和公式栏里的公式对照如果读的是 Formula。下面这个表格列出常见数据类型对应的 VARIANT 类型方便排查。Excel 内容类型VARIANT vt 值转换建议文本VT_BSTR直接用 CString 或 std::wstring 接收数字VT_R8用 %g 格式化避免多余小数日期VT_DATE用 VariantTimeToSystemTime 转换布尔VT_BOOL判断 VARIANT_BOOL 真假空单元格VT_EMPTY当空字符串处理错误值VT_ERROR检查 scode常见 #N/A 是 0x800A07D05.3 一个我踩过的性能坑早期我图省事用Cells(i, j)逐格读一个 5000 行的表结果跑了将近 40 秒。后来改成Range(A1:F5000).Value一次性读降到 0.8 秒。差距来自跨进程调用的次数逐格读是 30000 次 COM 调用批量读是 1 次。从那以后我每次写 WPS 读取代码都强制先问自己一句「能不能一次读一个区域」能批量就绝不逐格。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取