简介:面向Linux平台上的Qt5开发者,这份资源提供针对ExcelFormat第三方库修改后的完整工程,核心解决2003格式xls文件在中文路径下无法读写的问题。资源适用于统信UOS等国产化系统,已在aarch64、mips64架构下通过gcc编译验证,兼顾开发与运维场景。压缩包共21个文件,以cpp、h源码和编译中间产物o为主,同时包含pro工程文件、Makefile、UI界面以及demo示例,整体仅2.61MB,结构精简便于移植。作者定位到原库在wcstombs与mbstowcs转换时对非英文字符处理出错,通过在create、open两个函数中增加NON_ENGLISH_PATH_FILENAME_SUPPORT宏分支,并修改文件名长度计算,从而完整支持中文、日文和韩文路径。除修复源码外,资源还附带可直接运行的demo和表格样式设置示例,开发者可快速参考并集成到自己的Qt工程中,节省重新排查编码问题的时间。
1. 用 Qt5 在 Linux 下读写 2003 版 xls:这次卡住我的不是 Excel 格式,是路径
前段时间接到一个 Linux 桌面小工具的需求,要求用 QT5 支持中文路径读写 2003 的 excel 文件,也就是 .xls。一开始我以为难点在 2003 格式的解析上,结果第三方库一接进来,文件读得出来,中文 sheet 名全乱码,路径带个“台账”两个字直接打不开。查了两天才明白:真正卡人的是编码链路,不是 Excel 格式本身。
做这套方案的核心,是拿一个不处理中文路径的第三方 Excelformat 类库源码,自己改一版。适合谁读?一类是要在 Linux 上处理历史遗留 .xls 文件的嵌入式或桌面应用开发者,另一类是正在被“Windows 上好好的,搬到 Linux 就崩”折磨的跨平台维护者。下面我把这套修改思路、关键代码、验证步骤和踩过的坑一次讲清楚。
2. 中文路径读写 xls 为什么翻车:BIFF8 编码与第三方库的假设
2.1 三条编码链:QString、窄字符路径、BIFF8 字符串
一个 xls 文件的完整读取链路其实是三段。第一段是应用的 QString,Qt 内部用 UTF-16 存字符串;第二段是 Linux 文件系统,文件名在系统调用层是 UTF-8 字节;第三段是 xls 文件内部的 BIFF8 记录,2003 格式的字符串在记录里可能是压缩单字节,也可能是 UTF-16LE 双字节。三段编码规则完全不是一套,只要中间某一层的库做了“想当然”的转换,就立刻出问题。
# Qt5 中 QString 到 Linux 文件系统调用的默认链路 QString → toUtf8() → char* → open()/fopen() # 第三方 xls 库常见的错误假设链路 QString → toLocal8Bit() → 当前 locale 编码 → 文件系统第二行是大多数第三方库的默认做法,它在 Windows 上问题不大,因为 Windows 的本地代码页和 ANSI 之间转换相对直接。到了 Linux,如果进程 locale 是常见的 UTF-8,toLocal8Bit()恰好也是 UTF-8,路径还能碰巧对上。一旦进程跑在LANG=C或者POSIXlocale 下,toLocal8Bit()出来的就是 US-ASCII,中文字符直接变成?,文件路径在系统调用层就断了。这里最大的变量不是 Qt,而是运行环境的 locale 设置。
2.2 Excelformat 这类库对 2003 格式的经典误判
2003 的 xls 用的是 BIFF8 二进制流,整份文件由一串记录组成,每条记录头是 2 字节 ID 加 2 字节长度。字符串在记录里的存储有两种:压缩格式用 1 字节表示一个字符,非压缩格式用 2 字节 UTF-16LE 表示一个码元。文件名路径是 char*,记录内字符串又有自己的压缩标志,一个只支持 ASCII 的库拿到这两种混合编码基本必出乱码。
我见过某个第三方 Excel 格式库的解析代码,它对字符串的读取有个简化逻辑:先读一个字符数cch,再读一个标志字节grbit,然后直接用cch作为字节数去截取数据。这个做法在纯英文 xls 里一辈子不出错,但一旦碰到含中文的 sheet 名或单元格,cch表示的是字符数,非压缩字符串的实际字节数是cch * 2,一旦按字节数截取,后续所有记录偏移全部错位,读出来的内容全是错的,甚至直接段错误。这也是标题里“源码修改版”存在的根本原因。
2.3 为什么不直接换库或用 xlsx 一劳永逸
常见的思路是:既然 xls 这么麻烦,为什么不把文件转成 xlsx,或者直接换一个现代库?这个问题的答案通常不在技术上,而在业务约束里。大量旧系统导出的报表、财务台账、设备清单都是 2003 格式,迁移成本高,用户要求的就是原样读写;而 xlsx 是 zip 包加 XML,中文路径问题确实绕开了,但打开老系统的历史文件需要先做格式转换,转换过程本身又会丢失格式、宏、批注,业务上根本不能接受。
换新库我也试过。有的库接口设计老旧,返回值全是char*,和 QString 接驳要自己写转换;有的库不提供源码,出了边界问题只能绕。更关键的是,自己手里这份源码已经在特定数据上做过容错修正,比如遇到非法记录跳过而不是直接崩溃,这个行为迁到新库要重新验证一遍。综合下来,在原有第三方库源码上做修改,是所有路线里改动面最小、验证成本最低的一条。
3. 源码修改版怎么做:把文件打开与解析解耦的 3 个动作
3.1 第一步:先把读写函数收口到唯一入口
拿到一份第三方 xls 库源码,第一件事不是去翻它的字符串解码,而是找到所有和文件路径打交道的地方。常见的入口包括打开文件、保存文件、另存为这三个函数,里面通常都是const char* path参数,然后内部调fopen或std::ifstream。我的做法是把它们全部改成一个统一入口:外部把文件字节流传进来,库只负责解析内存,不再自己碰文件名。
// 修改前的常见写法(某第三方库伪代码) bool XlsWorkbook::open(const char* path) { FILE* fp = fopen(path, "rb"); // 中文路径在部分 Linux locale 下失败 if (!fp) return false; // 后续解析逻辑全部基于 FILE* } // 修改后:路径层与解析层分离 bool XlsWorkbook::openFromMemory(const unsigned char* data, size_t len) { // 只做 BIFF8 记录解析,完全不关心数据来自哪里 } bool XlsWorkbook::openQt(const QString& path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return false; QByteArray bytes = file.readAll(); // 注意:库内部如果持有这个指针,需要拷贝一份,不能直接指向临时对象 return openFromMemory( reinterpret_cast<const unsigned char*>(bytes.constData()), static_cast<size_t>(bytes.size())); }这个收口动作的价值在于把两个问题彻底隔开:路径编码问题归路径层,格式解析问题归解析层。后面不管环境 locale 怎么变,解析层都不会受影响。openFromMemory的内存生命周期是个关键参数,如果库解析完就释放指针,bytes在函数结束后失效没关系;但有些库会持有指针以备后续写入,这种情况下必须在openFromMemory内部做一次拷贝,否则就是野指针崩溃。
3.2 第二步:用 QFile 接管文件字节流,绕开 locale
Linux 下中文路径最稳的打开方式就是 QFile,不需要自己做任何编码转换。Qt 的文件层会按当前系统规则处理路径,而系统文件名本质是字节序列,Linux 默认接受 UTF-8。真正的问题不是系统不支持中文文件名,而是fopen在非 UTF-8 locale 下拿到的是错误编码的路径参数。用 QFile 直接从 QString 出发,不经过窄字符串转换,这个风险从根上消失。
QFile file(QStringLiteral("/tmp/中文目录/台账.xls")); if (!file.open(QIODevice::ReadOnly)) { qWarning() << "打开失败:" << file.errorString(); return false; } QByteArray bytes = file.readAll();对于大多数业务里的 xls 文件,几百 KB 到几 MB 是常态,readAll()一次读入内存没有压力。如果遇到超大的 xls,可以改用file.read(recordSize)按记录读取,但解析层要配合改造,工作量大不少。我一般先把readAll()跑通,再根据实际文件大小决定要不要走流式。还有一个容易忽略的点:QFile打开成功后要判断file.errorString(),很多中文路径失败到这里根本没走进去,报错信息会直接给出原因。
3.3 第三步:按 fHighByte 处理 BIFF8 字符串解码
路径搞定了,下一步是改字符串解析。BIFF8 记录的字符串结构是:头部 1 或 2 字节字符数cch,1 字节选项标志grbit,后面跟着字符串数据。grbit最低位fHighByte决定存储格式:为 0 时是压缩单字节字符串,为 1 时是 UTF-16LE 字符串。第三方库最常见的坑就是只实现了压缩格式,遇到中文的 UTF-16LE 数据直接按单字节读,结果自然全乱。
bool readBiff8String(const QByteArray& data, int& pos, QString& out) { if (pos + 2 > data.size()) return false; quint16 cch = 0; uchar grbit = 0; // 先读字符数(1 或 2 字节取决于长的记录类型) cch = static_cast<quint8>(data.at(pos++)); grbit = static_cast<uchar>(data.at(pos++)); if (grbit & 0x01) { // fHighByte = 1,UTF-16LE int byteLen = cch * 2; if (pos + byteLen > data.size()) return false; QByteArray raw = data.mid(pos, byteLen); out = QString::fromUtf16( reinterpret_cast<const ushort*>(raw.constData()), cch); pos += byteLen; } else { // 压缩单字节格式,按当前代码页解,中文环境常见 GBK/CP936 int byteLen = cch; if (pos + byteLen > data.size()) return false; QByteArray raw = data.mid(pos, byteLen); QTextCodec* codec = QTextCodec::codecForName("GBK"); out = codec->toUnicode(raw); pos += byteLen; } return true; }这段代码有两个参数最容易写错。一是cch的单位是字符,不是字节,非压缩格式下跳过长度一定要乘以 2;二是QString::fromUtf16的第二个参数是字符数,不是字节数,如果传成byteLen会越界。压缩格式下用 GBK 解码,是因为 2003 版 xls 在中文系统上生成的压缩字符串基本就是 GBK 字节序列。如果文件来自英文系统,代码页会是 CP1252,这个可以根据实际数据来源做成可配置项。
4. 在 Linux 上跑通中文路径读写 xls 的最小工程
4.1 最小 qmake 工程与可复现的调用代码
最佳实践回归到一个能跑的工程。这里我拿 qmake 举例,Qt5 环境基本都带。工程文件不需要额外链接第三方库,因为我们是源码修改版,直接把自己改好的源文件加进工程即可。
QT += core CONFIG += console c++11 TARGET = xls_demo SOURCES += main.cpp \ xls_workbook.cpp \ xls_records.cpp HEADERS += xls_workbook.h \ xls_records.h主程序里做的事情很简单:构造一个中文路径,打开 xls,遍历 sheet 名并打印出来。为了验证写回功能,再把第一个单元格写入一个中文值,保存为一个新文件。
#include <QCoreApplication> #include <QDebug> #include "xls_workbook.h" int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QString path = QStringLiteral("/tmp/中文目录/台账.xls"); XlsWorkbook wb; if (!wb.openQt(path)) { qWarning() << "打开失败,请检查路径与权限"; return 1; } qInfo() << "sheet 数量:" << wb.sheetCount(); for (int i = 0; i < wb.sheetCount(); ++i) { qInfo() << "sheet 名:" << wb.sheetName(i); } wb.setCellValue(0, 0, 0, QStringLiteral("中文写入测试")); QString writePath = QStringLiteral("/tmp/中文目录/台账_输出.xls"); if (wb.saveQt(writePath)) { qInfo() << "写入成功:" << writePath; } return 0; }QStringLiteral是我在源码中处理中文字面量的习惯写法,前提是源码文件本身以 UTF-8 编码保存。如果是从命令行参数接收路径,需要用QString::fromLocal8Bit配合环境 locale 处理,不要直接用argc[1]去构造 QString。wb.setCellValue的前两个参数是行列索引,第三个参数是 sheet 索引,这部分接口名字不同库不一样,替换成自己手里那版源码对应的名字即可。
4.2 三种改法取舍:内存接管、标准库 u8path、显式 locale
围绕“如何让库吃到正确编码的路径”,业界常见做法有三种。我分别列出来对比。
| 改法 | 实现难度 | 风险点 | 适用场景 |
|---|---|---|---|
| QFile 读内存后交给库解析 | 低,只改入口 | 大文件需考虑流式读取 | 通用,个人最推荐 |
C++17std::filesystem::u8path构造路径 | 中,依赖编译器与标准库版本 | 库内部如果又转一次 locale,仍然乱码 | 本身就用 C++17 的工程 |
| 设置 locale 为 UTF-8 后再调库 | 低,一条setlocale()即可 | 影响整个进程,多线程环境容易出幺蛾子 | 单线程小工具应急 |
第三种做法看起来省事,隐患最大。setlocale(LC_ALL, "")改的是进程级状态,如果这个库在解析过程中调用了任何依赖 locale 的 C 函数,都会受到牵连;万一你的程序还有别的编码敏感模块在同进程运行,几个模块之间的行为就会相互干扰。我见过一个项目用了这个“捷径”,结果程序里同时用 Qt 的QTextCodec和 C 的mbstowcs,两边对同一段字节的解释不一致,数据直接错乱,成为排查了一整天的老难题。
4.3 验证清单:从打开到写回要过的 4 个检查点
改完代码不是能编译就算完,验证要按这个顺序来。第一,确认中文路径文件能被打开并读完整;第二,打印 sheet 名,肉眼确认没有乱码;第三,写入中文单元格,然后立即重新打开新文件,读回单元格,确认与写入值一致;第四,把写出的文件另存一份,在装有 2003 兼容组件的环境或 LibreOffice 里打开,检查是否提示文件损坏。
cd /tmp/中文目录 ./xls_demo # 预期输出: # sheet 数量: 3 # sheet 名: "汇总" "明细" "备注" # 写入成功: /tmp/中文目录/台账_输出.xls这四条验证里,最容易漏的是第三条。很多库读取时中文没问题,但写回时在saveQt阶段又做了一次编码转换,把 UTF-16 转成了错误代码页,结果读出来的是乱码。写回后的文件除了要能读,还要在外部办公软件里打开一次,因为有些库写出来的文件内部记录长度是对的,但字符串的代码页标志写错了,Office 兼容组件会直接判定为格式异常。
5. 中文路径读写 xls 避坑手册:5 个高频问题与排查顺序
5.1 现象 1:中文路径 fopen 失败或读到空文件
现象是文件明明存在,fopen返回空指针,或者打开后读取字节数为 0。原因基本是进程 locale 为 POSIX/C,导致 UTF-8 路径被截断。解决方法是抛弃路径层直接调 C 库函数,改用 QFile 读入内存,或者至少保证进程执行前用setlocale(LC_ALL, "")把 locale 从 C 切到环境配置。我一般直接上 QFile,路径问题从此一劳永逸。
5.2 现象 2:文件名正常但 sheet 名乱码
路径能读,文件名也对,但 sheet 名打出来是“绠$旱”之类的天书。原因是库内部解析 BIFF8 字符串时,把非压缩的 UTF-16LE 数据按单字节读,或者把压缩字符串按 Latin-1 解码了。解决方法是按grbit标志分支处理,参照 3.3 节代码,压缩字符串按实际代码页解,非压缩字符串按 UTF-16LE 解。
5.3 现象 3:写出的 xls 打开报格式损坏
写回能成功,看起来文件也生成了,但用办公软件打开就弹“文件格式与扩展名不匹配”。通常是记录长度字段写错,字符串的字符数被当成字节数写入记录头,导致整条记录长度超过实际数据。排查时把生成文件和原始文件用十六进制对比,重点看字符串记录附近的 4 字节长度字段,确认是len * 2还是len。
5.4 现象 4:纯英文路径正常,路径带中文就崩溃
这类崩溃多数不是段错误,而是断言失败或者无限循环。原因在于某条解析逻辑读到了错误的长度值,偏移直接跳到非法内存,然后再在后续解析中触发越界。解决方式是先按记录头把文件结构走一遍,确保每条记录的边界都合法,再去解析内容。我在改库源码时,会在解析循环里加一层边界检查,发现记录长度超过文件剩余长度就停止解析,而不是继续读。
5.5 现象 5:调试一整天只剩最后一个字节不对
这是最折磨人的一种:所有内容都对了,只有某个单元格末尾多了一个不可见字符。原因是 BIFF8 字符串后面可能跟一个可选的扩展标志,某些记录会在字符串末尾附加一个fExtSt标志,第三方库把它当成了字符串的一部分。解决方法是先打印出该字符串附近的原始字节,看一下多出来的是什么字符,再决定在解码时把它跳过。这类问题没有通用解,只能靠对比字节流来定位。
排查顺序也有讲究:先验证路径层,再验证记录偏移,最后验证字符串解码。路径层用 QFile 能覆盖九成问题;记录偏移通过遍历记录头比对长度,能在五分钟内发现解析层的结构性错误;字符串解码放在最后,因为只有偏移正确了,解码才有意义。
6. 用记录遍历器验证你的 xls 修改版没有改坏文件
源码改完,功能验证也过了,还剩一件事我每次必做:用记录遍历器把整个 xls 文件的记录结构走一遍。这一步能发现隐藏最深的偏移错误,比单纯打开文件做功能测试可靠得多。2003 的 xls 本质是记录流,只要每条记录的边界正确,文件结构就是健康的;一旦某一处长度算错,后面所有记录全部错位,功能测试可能碰巧没触发。
// 最小记录遍历器:验证文件结构完整性 bool validateXlsStructure(const QByteArray& data) { int pos = 0; while (pos + 4 <= data.size()) { quint16 id = 0; quint16 len = 0; memcpy(&id, data.constData() + pos, 2); memcpy(&len, data.constData() + pos + 2, 2); // 记录头 4 字节,后面 len 字节是记录体 if (pos + 4 + len > data.size()) { qWarning() << "记录越界,偏移:" << pos << "记录ID:" << QString::number(id, 16); return false; } pos += 4 + len; } return pos == data.size(); }把这个函数挂到修改版库的自测流程里,每次改完字符串解码逻辑,都先拿一批真实 xls 文件跑一遍。我踩过最大的一个坑就是:某个数据类型在功能测试时看起来全对,但记录遍历发现它的长度多算了两字节,导致其后一条格式完全错乱。遍历器抓到这类问题只需要几毫秒。
这套方案做完后,我的习惯是保留一份“修改前 vs 修改后”的字节流对比脚本,每次发布前跑一遍,确认新代码没有引入新的偏移变化。中文路径和 2003 格式校验是两件事,分开验证,彼此不干扰,问题才能定位得准。这套折腾下来,以后再遇到类似需求,我会先问一句话:你手里的第三方库对路径做了什么假设?希望帮到你。
本文还有配套的精品资源,点击获取