跨平台这事,做过的都说难,没做过的都觉得神秘。实际上如果把工具链选型、构建系统、平台差异处理这几件事搞明白,C++跨平台开发就是你平时写代码的延伸,根本不需要什么特殊魔法。我见过太多人在 Windows 上编译得好好的,一放到 Linux 就报一堆 ld 错误,或者在 mac 上写得正开心,代码拉到 Windows 上却跑不起来——这些问题的根源,十有八九不是语法问题,而是工具链和平台 API 的认知偏差。
这篇文章我准备从工具链搭建、CMake 配置、代码细节处理到真实项目踩坑,完完整整走一遍。适合三类人看:一是刚开始接触跨平台开发的 C++ 初学者,二是在 Windows 上写了很多年想往 Linux 或者 mac 迁移的老手,三是被各种跨平台编译问题折腾到头疼、想系统梳理一遍的中间态开发者。看完之后你会发现,跨平台开发的本质,就是提前把平台差异当成需求的一部分来管理,而不是出了问题再去补洞。
1. 跨平台开发的前提与路线选择
1.1 原生路线 vs 框架路线:没有绝对好坏,只有合适不合适
跨平台开发第一件要决定的事,不是选语言也不是选编译器,而是选路线。C++ 的跨平台路线大体分成两条:一条是纯原生路线,直接用标准 C++ 加平台相关的少量系统调用,配合 CMake 构建;另一条是框架路线,借助 Qt 这类跨平台框架封装,把 GUI、网络、文件操作都交给框架处理。
原生路线的优势是依赖少、体积小、启动快、行为可控,特别适合服务端程序、命令行工具、嵌入式系统、游戏引擎底层这类场景。框架路线的优势是开发效率高,尤其是带界面的桌面应用,Qt 直接帮你把窗口、控件、事件循环都封装好了,你不用分别针对 Windows API 和 X11 写两套界面代码。
我个人的判断标准很简单:如果你的项目需要复杂 GUI,比如可视化编辑器、设计工具、调试器这类,直接上 Qt;如果你的项目是服务端、中间件、工具链、底层库,那就老老实实走原生路线。这里有个容易踩的误区——有些人为了逃避平台差异,什么项目都上 Qt,结果一个内存占用要求严格的嵌入式中介程序被 Qt 拖得很大,得不偿失。反过来,有些人写一个需要表格、树形控件的管理工具,非要纯用原生去调 Win32 和 GTK,那更是自己给自己挖坑。先明确场景,再谈技术选型。
1.2 先定义平台矩阵,再动手写代码
所谓平台矩阵,就是你明确告诉自己和团队:这个项目到底要跑在哪几个操作系统、哪几种架构、哪几种编译器组合上。听起来很简单,但我见过大量项目一开始只说“要跨平台”,结果代码写了两三个月,有人突然说“我们还要支持 ARM 嵌入式 Linux”,整个构建系统直接崩盘。
一个合理的平台矩阵通常包含三列:目标系统、目标架构、编译器。我做一个服务端组件的典型矩阵大概是这样:
| 系统 | 架构 | 编译器 |
|---|---|---|
| Windows | x64 | MSVC 2022 |
| Ubuntu 20.04+ | x64 | GCC 9+ |
| macOS | arm64/x86_64 | Apple Clang |
| 嵌入式 Linux | armv7/aarch64 | 交叉编译工具链 |
定义好矩阵之后,你的所有构建配置、条件编译、依赖管理都围绕这个矩阵展开。这样做的最大好处是,你在开发时就能用“是否支持某个平台”来评估每一次改动。比如你新用了一个 API,如果它在矩阵中的某个平台不可用,你马上就得想替代方案,而不是等到 CI 跑挂了才发现。
这里有个实际经验:平台矩阵越早定义越好,而且最好写进项目 README 或者文档里。新成员加入时先看这个矩阵,就不会出现“我写了一个依赖 Linux 特有系统调用的模块,然后说跨平台”这种事了。
2. 工具链搭建:VS Code + CMake 这套组合怎么配
2.1 编译器到底选谁:MSVC、GCC、Clang 的三方角力
跨平台开发的第一个硬骨头是编译器。不同平台默认的编译器完全不同,而且它们对 C++ 标准的支持程度、标准库实现、ABI 兼容性都存在差异。Windows 上默认是 MSVC,Linux 上默认是 GCC,macOS 上默认是 Apple Clang。如果项目要在这三个平台上编译,你就得确保代码在这三家的实现上都跑得通。
具体差异体现在哪些地方呢?比如#include <filesystem>,GCC 8 以前需要链接stdc++fs库,MSVC 直接就能用;再比如std::format,GCC 13 才开始完整支持,MSVC 2022 倒是很早就有了。这种“同一个标准,不同支持时间”的问题是跨平台 C++ 开发最常见的坑。
我建议的做法是:在矩阵里把编译器版本明确写死,不要在“编译器任何版本”这种模糊状态下开发。然后在所有目标平台上开编译警告,把-Wall -Wextra之类的开关全打开,尽量让代码在三个编译器下都是零警告编译。虽然刚开始会遇到一些“这个编译器对那个编译器”的警告差异,但把警告清零之后,跨平台问题至少少一半。
2.2 VS Code 配置 C++ 环境:三个文件看懂就够了
VS Code 几乎是跨平台 C++ 开发的标配编辑器,但它本身不编译、不调试,它只是通过插件去调用底层编译器。很多人在这一步就卡住了,因为不知道 c_cpp_properties.json、tasks.json、launch.json 这三个文件到底各自干嘛。
c_cpp_properties.json 负责配置的是 IntelliSense 的路径和标准版本。它并不参与真正的编译,只是让 VS Code 的代码提示、跳转、静态检查能找到头文件和标准库。这个文件里最常配的是"cppStandard": "c++17"以及includePath,把项目用到的第三方库头文件目录加进去,提示就不会飘红了。
tasks.json 负责的是编译任务。你在命令行里执行的cmake ..、cmake --build .这些命令,可以封装成 task。配置好之后按 Ctrl+Shift+B 就能一键构建,不用切到终端手动敲命令。launch.json 负责调试,告诉 VS Code 用哪个 GDB 或者 MSVC 调试器去执行哪个可执行文件。这三者的关系可以这样理解:properties 是给编辑器看的,tasks 是给构建看的,launch 是给调试看的。三个文件配合好,VS Code 的跨平台开发体验就很接近专业 IDE 了。
2.3 为什么是 CMake:从 Makefile 到 CMake 的跨越
很多人最开始学跨平台构建时接触的是 Makefile,然后一脸疑惑:Makefile 不是也能编译吗?为什么要学 CMake?这里涉及一个关键概念:Makefile 是直接写给构建工具看的,而 CMake 是一个生成器,它生成的内容会交给不同平台的原生构建工具去执行。
换句话说,CMake 不直接编译代码,它负责“读取你的构建意图,然后按目标平台的习惯生成对应的构建工程”。在 Windows 上它生成 Visual Studio 的 .sln 工程或者 NMake Makefile,在 Linux 和 macOS 上它生成 Makefile,同时也能生成 Ninja 的构建文件。这就像你写了一份菜谱,然后自动翻译成中文、英文、日文给不同国家的厨师看,厨师们各自用自己习惯的工具做出同一道菜。
CMake 对跨平台开发的另一个重要价值是模块化的依赖管理和平台检测能力。它内置了WIN32、UNIX、APPLE等平台标识符,也内置了大量 find_package 模块,能自动检测 OpenSSL、SQLite3、Boost 等第三方库的存在与否。这些功能组合起来,你就能在 CMakeLists.txt 里写“如果是 Windows 就链接 ws2_32,如果是 Linux 就链接 pthread”,构建系统自动适配,不用写一堆平台分支脚本。
3. CMake 跨平台构建脚本实战
3.1 一份能直接用的基础 CMakeLists.txt
光说概念没用,直接给一份可以作为起点的 CMakeLists.txt。这个文件同时适用于 Windows、Linux、macOS,需要 C++17 及以上标准的项目可以拿来直接改。
cmake_minimum_required(VERSION 3.20) project(CrossPlatformDemo VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() add_executable(demo main.cpp) # 平台差异化链接 if(WIN32) target_compile_definitions(demo PRIVATE _CRT_SECURE_NO_WARNINGS) endif() if(UNIX AND NOT APPLE) find_package(Threads REQUIRED) target_link_libraries(demo PRIVATE Threads::Threads) endif()逐行拆解一下关键点。cmake_minimum_required指定最低 CMake 版本,我建议用 3.20 以上,因为低版本对 FetchContent、source 目录可写性等支持不友好。project里LANGUAGES CXX告诉 CMake 本工程只需要 C++,不需要找 C 编译器,能省一些配置时间。
CMAKE_CXX_STANDARD 17和CMAKE_CXX_STANDARD_REQUIRED ON组合起来的意思是:项目必须用 C++17 编译,不允许 CMake 偷偷降级到低标准。CMAKE_CXX_EXTENSIONS OFF则是关闭编译器特有的扩展语法,这样代码移植性更好。很多人不写这三行,然后抱怨代码在某个编译器上编译不过去,实际上就是标准版本不一致导致的。
WIN32与UNIX分支里的内容,是整个跨平台构建脚本的精髓。find_package(Threads REQUIRED)这块尤其重要,因为 C++11 标准规定std::thread的实现通常依赖系统线程库,Linux 上就是 pthread,而 Windows 上线程支持由系统运行时自带,不需要额外链接。这一行就把平台差异吃掉了。
3.2 平台差异配置的常见写法与技巧
跨平台项目的 CMakeLists.txt 会随着项目变大而逐渐复杂,我整理几个高频出现的配置模式,都是我在真实项目里反复使用的。
第一个是条件编译宏注入。很多时候你在代码里要写#ifdef _WIN32,但某些平台自带的宏并不统一,比如 Windows 上识别平台用的是_WIN32,Linux 上没有这个宏,macOS 上则是__APPLE__。你可以直接在 CMake 里统一注入一个项目自定义的平台宏,让代码更清爽。比如:
if(WIN32) target_compile_definitions(demo PRIVATE PLATFORM_WINDOWS) elseif(APPLE) target_compile_definitions(demo PRIVATE PLATFORM_APPLE) else() target_compile_definitions(demo PRIVATE PLATFORM_LINUX) endif()这样在代码里只需要判断PLATFORM_WINDOWS而不是去记忆_WIN32和__APPLE__,项目代码的可读性明显提升。
第二个是链接库的平台分支。Windows 下很多系统库名和 Linux 下不同,最典型的就是网络编程。Windows 需要链接ws2_32,Linux 不需要额外链接。写成 CMake 就是:
if(WIN32) target_link_libraries(demo PRIVATE ws2_32) endif()类似的还有 Windows 下的advapi32、iphlpapi等,都用这种模式。第三是统一设置输出目录和运行时库,Windows 下经常会出现运行 exe 时找不到 DLL 的问题,把输出目录统一到bin/,能省掉很多把 DLL 拷来拷去的麻烦。
3.3 管理第三方依赖:find_package 与 FetchContent
跨平台项目几乎不可能只用标准库,第三方库的处理是跨平台构建的核心难点。这里的最常用方案是find_package,也就是让 CMake 从系统里查找已经安装的库。比如系统安装了 OpenSSL,你就可以写find_package(OpenSSL REQUIRED),然后target_link_libraries(demo PRIVATE OpenSSL::SSL OpenSSL::Crypto)。好处是每个平台各凭本事安装库,CMake 只负责找;坏处是如果你的队友没有装,编译就直接失败。
如果希望项目“拉到就能编译”,FetchContent是更好的选择。它允许你在 CMake 配置阶段直接拉取源码,然后把它作为一个子目录编入你的构建流程。典型写法:
include(FetchContent) FetchContent_Declare(spdlog GIT_REPOSITORY https://github.com/gabime/spdlog.git GIT_TAG v1.12.0 ) FetchContent_MakeAvailable(spdlog)写完这段,spdlog头文件和库就自动进到你的依赖列表中,target_link_libraries(demo PRIVATE spdlog::spdlog)直接用就行。这种方式非常契合跨平台开发中“依赖不可靠、网络环境各异”的现实,因为每个平台都从源头取同一个版本,不会出现 Windows 上装了 1.10、Linux 上装了 1.12 这种版本不一致的问题。
我实际使用的判断标准是:如果目标平台有成熟的包管理器(比如 Ubuntu 的 apt、macOS 的 Homebrew),并且团队对安装流程有清晰文档,用 find_package;如果要保证任何人拉到代码都能立刻编译,用 FetchContent。两者可以混用,比如核心依赖用 FetchContent,非关键依赖用 find_package,也是常见的折中方案。
4. 跨平台代码里的那些坑
4.1 条件编译的姿势:能用,但别滥用
跨平台代码最直接的工具是条件编译:#ifdef _WIN32或者#if defined(__linux__)。这个工具本身没有错,但百分之八十的跨平台代码腐烂,都是从滥用条件编译开始的。
如果你在业务逻辑里到处写#ifdef _WIN32,代码很快就会变成一团乱麻——每个函数里都藏着平台分支,测试还只能测当前平台的分支,另外几个分支有没有编译错,你根本不知道。更麻烦的是,条件编译分支之间的交互逻辑很难测试,一个 Linux 上编译通过的宏,可能在 Windows 上根本不生效,因为你拼错了宏名或者头文件没包含到。
正确做法是“平台差异集中在底层,业务逻辑保持平台中立”。也就是用一个平台抽象层把系统 API 包装起来,对外提供统一的接口。比如你要做一个文件监控功能,Windows 上用 ReadDirectoryChangesW,Linux 上用 inotify,macOS 上用 FSEvents,这三种 API 差异巨大。你可以设计一个DirectoryWatcher类,头文件里只暴露start()、stop()、onFileChanged()接口,然后针对每个平台编写一个实现文件。在 CMake 里根据平台选择编译哪个实现文件,而不是在同一个文件里布满条件编译。
if(WIN32) target_sources(demo PRIVATE DirectoryWatcher_win.cpp) elseif(APPLE) target_sources(demo PRIVATE DirectoryWatcher_mac.cpp) else() target_sources(demo PRIVATE DirectoryWatcher_linux.cpp) endif()这种方式的好处是:每个平台的实现完全隔离,出错时定位快;新增平台时只要加一个实现文件;业务层根本不知道自己跑在哪个系统上。这才是跨平台代码的正确架构。
4.2 文件路径、编码与换行符:三个不起眼但致命的小问题
很多跨平台问题不是编译期报错,而是运行期表现不一致,其中文件路径、编码和换行符是重灾区。
先说文件路径。Windows 用反斜杠作为分隔符,POSIX 系统用正斜杠。如果你在代码里硬编码"data\\file.txt",到 Linux 上就找不到文件。正确做法是把路径分隔符抽象掉。C++17 标准库提供了std::filesystem::path,它天然支持两种分隔符的转换,path("data/file.txt")在 Windows 上也会自动处理成分隔符。在拼接路径时不要手写字符串,一律用path / "file.txt",这是最低成本的跨平台路径方案。
再说编码。Windows 上很多 API 默认使用 UTF-16,而 Linux 上基本都是 UTF-8。比如你读取文件名时换了编码,中文文件名就会出现乱码。我建议在项目里约定“所有对外接口统一使用 UTF-8”,Windows 下做转换时再用MultiByteToWideChar和WideCharToMultiByte转到 UTF-16 来调用 Windows API。这个方法很笨,但工程上稳定可靠。
换行符的问题也值得警惕。Windows 下文本默认以 CRLF 结尾,Linux 和 macOS 用 LF。如果你在 Windows 上写的代码文件里有 CRLF,拿到 Linux 上编译,GCC 会报奇怪的expected unqualified-id之类的错误,而且报错位置完全不指向真实问题。解决方式很简单:在仓库根目录放一个.gitattributes文件,强制文本文件统一使用 LF 换行,这样无论哪个平台的开发者签出代码,换行符都是一致的。
4.3 系统 API 的统一与差异:标准库已经帮你干掉一半了
早期跨平台开发最痛苦的事情之一,就是线程、时间、原子操作这些基础能力,每个平台的系统 API 都长得完全不一样。Windows 创建线程用 CreateThread,Linux 用 pthread_create,两套 API 的参数和语义差异巨大。这也是当年很多项目引入 boost 库的核心原因。
C++11 标准库正式把这些基础能力纳入了标准:std::thread、std::atomic、std::chrono、std::mutex等,底层虽然还是会调用不同系统的 API,但接口已经被标准库统一了。这就意味着,只要你不去直接调 CreateThread 或者 pthread_create,你的线程代码天然是跨平台的,而且不需要额外链接任何库。
但还有一些系统 API 标准库暂时没有覆盖,最典型的就是网络。C++ 标准库至今没有网络库,Windows 上只能走 Winsock,Linux 上只能走 Berkeley Socket,两套 API 的初始化方式、错误处理、关闭函数都不一致。这类功能要么你自己封装一层网络抽象,要么引入库(如 ASIO、libuv),要么用 C++20 的std::jthread加第三方网络库的组合。我自己项目里的习惯是:如果项目正式且体量大,用 ASIO;如果只是临时工具,直接根据平台条件编译两套 socket 代码,把差异隔离在一个小程序块里。
5. 实战记录:实现一个跨平台的目录遍历工具
5.1 需求设计:小而精,但足够展示跨平台要点
理论讲得再多,不如动手跑一个完整的例子。我选了一个有代表性又不过度复杂的项目:一个跨平台的目录扫描工具。功能包括:递归遍历指定目录下的所有文件,输出文件路径和大小,支持通过命令行参数指定目录和输出格式。这个需求虽然简单,但它会用到文件系统操作、命令行解析、标准库容器、输出格式化等,几乎覆盖了跨平台项目的所有关键点。
设计这类工具的时候,我会先把目标工程师需求矩阵化:第一,代码必须只用 C++17 标准库,不依赖任何系统 API;第二,构建系统必须在 Windows、Linux、macOS 上都能顺利完成;第三,接受路径参数时兼容当前平台的路径格式;第四,输出保持统一的 UTF-8 编码。这样从第一分钟开始,我就不需要到后面再来补救平台差异。
5.2 核心代码实现与分析
目录遍历直接使用 C++17 的std::filesystem,它既是跨平台的标准实现,又能处理路径分隔符的问题。核心代码如下:
#include <iostream> #include <filesystem> #include <vector> #include <string> struct FileEntry { std::string path; uintmax_t size; }; std::vector<FileEntry> scanDirectory(const std::string& root) { namespace fs = std::filesystem; std::vector<FileEntry> result; std::error_code ec; fs::recursive_directory_iterator it( fs::path(root), fs::directory_options::skip_permission_denied, ec ); if (ec) { std::cerr << "无法读取目录: " << root << std::endl; return result; } for (const auto& entry : it) { std::error_code fileEc; if (entry.is_regular_file(fileEc)) { result.push_back({ entry.path().string(), entry.file_size(fileEc) }); } } return result; } int main(int argc, char* argv[]) { if (argc < 2) { std::cerr << "用法: " << argv[0] << " <目录路径>" << std::endl; return 1; } auto files = scanDirectory(argv[1]); uintmax_t total = 0; for (const auto& f : files) { std::cout << f.path << " " << f.size << " bytes" << std::endl; total += f.size; } std::cout << "文件总数: " << files.size() << std::endl; std::cout << "总大小: " << total << " bytes" << std::endl; return 0; }这段代码有几个值得注意的地方。第一,recursive_directory_iterator递归遍历目录的功能,以前在 Windows 上要写几十行 FindFirstFile 循环,Linux 上又要用 opendir/readdir,现在标准库一行就搞定了。第二,directory_options::skip_permission_denied这个选项很关键——Windows 和 Linux 都有大量你不一定有权访问的目录,不加这个选项,遍历到一个无权限目录时迭代器会抛异常,程序直接崩溃;加上这个选项,遇到无权限目录会自动跳过。第三,entry.path().string()返回的是 UTF-8 编码的路径字符串,无论是 Windows 还是 Linux,这里面都经过了标准库的编码转换,所以中文路径在这两个平台上都能正常显示。
5.3 各平台构建与验证:同样的代码,不同的执行环境
代码写完之后,我在三个平台上分别做了构建验证。Windows 上用的是 VS Code + MSVC 工具链,打开终端执行:
cmake -S . -B build -G "Visual Studio 17 2022" cmake --build build --config Release .\build\Release\demo.exe "C:\Program Files"Linux 上用的是 GCC,构建命令几乎相同,只是生成器换成默认的 Unix Makefiles:
cmake -S . -B build cmake --build build ./build/demo "/usr/lib"macOS 上用的是 Apple Clang,构建流程同样只是执行 cmake 那两行。你会发现,只要 CMakeLists.txt 写得够规范,三个平台的构建命令几乎不做任何改动——这正是 CMake 的价值所在。
在实测中我还特意测试了中文目录名和空格路径。Windows 上路径带空格是常态,Linux 下路径里中文也很常见。由于std::filesystem::path在内部对各平台做了正确的编码转换,程序在这两种场景下都能正常运行。这个工具简单,但已经足够验证跨平台开发的整个链路:工具链、构建系统、标准库、代码结构。
6. 常见问题排查与踩坑实录
6.1 链接失败:LNK2019 与 undefined reference 的处理方法
链接错误是跨平台开发里出现频率最高的问题,也是最让人头大的问题。现象倒是统一:Windows 上出现 LNK2019,Linux 上出现 undefined reference,本质都是编译器告诉你某个符号找不到定义。排查方法其实有规律可循。
第一步,确认你写的代码引用了哪个库的函数,但没有链接对应的库。Windows 上最常见的是网络函数报错,因为 Winsock 不会自动链接;Linux 上最常见的是数学库函数没链接,因为 GCC 默认不链接 libm。第二步,确认库的链接顺序。GCC 在链接时对静态库的依赖关系非常敏感,你把一个库放到了依赖它的库前面,就会出现 undefined reference,即使库本身已经安装了。第三步,确认 ABI 兼容性。Windows 上用 MinGW 编译的静态库和 MSVC 编译的静态库不兼容,你把一个 MinGW 生成的 .a 文件用到 MSVC 项目里,必然报告一堆 LNK 错误。这是平台自带的历史遗留问题,没有好的解决方案,只能在项目里统一工具链,别混用。
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| LNK2019 / undefined reference | 未链接必要的库 | 检查依赖库是否加入target_link_libraries |
| 链接顺序导致失败 | G++ 静态库顺序问题 | 将底层库放在依赖它的库后面 |
| 符号表不一致 | DLL 导出或 stdcall 调用约定不匹配 | 检查__declspec(dllexport)与函数指针定义 |
| 头文件路径错误 | 找不到头文件却编译通过 | CMake 中target_include_directories遗漏 |
6.2 那些反复出现的神奇坑:宏冲突、目录差异、编码问题
除了链接错误,我累积了几个值得单独记录的经验型问题。第一个是 Windows 平台的windows.h会定义min和max宏,如果你在代码里用了std::numeric_limits<int>::max(),预处理阶段宏会把max替换成一坨括号和问号表达式,直接导致编译错误。解决方式是在包含windows.h之前定义NOMINMAX宏,或者在 CMake 中全局加上add_definitions(-DNOMINMAX)。
第二个是 Windows 和 Linux 的路径长度限制。Windows 上经典路径长度限制是 260 个字符,如果你的项目目录嵌套很深,光是路径就可能超过限制,导致编译时出现“文件名太长”的错误。现代 Windows 虽然支持打开长路径开关,但默认关闭。我的建议是:项目目录尽量扁平化,比如不要用D:\projects\foo\bar\build这种深层次结构,控制在两层以内,能省掉大量坑。
第三个是大小写问题。Windows 文件系统默认不区分大小写,Linux 区分。所以你在 Windows 上写了#include "MyHeader.h"而实际文件名是myheader.h,在 Windows 上编译通过,到 Linux 上直接报No such file or directory。这是跨平台项目部署到 Linux 后最常见的编译失败原因之一。解决方案是约定所有头文件名小写,并在代码和文件系统里保持严格一致。
第四个问题出现在输出编码上。Windows 控制台默认使用代码页 936(GBK),而 Linux 终端默认 UTF-8。你用std::cout输出中文时,Windows 下不设置控制台代码页就会出现乱码,Linux 下则一切正常。这类问题不算编译错误,但影响用户体验。解决方案是在程序入口处,Windows 平台调用SetConsoleOutputCP(CP_UTF8),其他平台什么都不用做。用条件编译隔离这两个平台行为,也很直观。
6.3 跨平台项目持续集成的经验
最后分享一点贴近实际的体会:跨平台开发一定不要在单平台上硬写很久之后才去其他平台验证。我吃过最大的亏就是连着在 Linux 上写了三周代码,信心满满地切到 Windows 上编译,结果冒出来七十多个错误,光修这些错误就花了两天。原因就是我没有在开发过程中同步考虑平台差异,结果积累了太多“在自己的平台上看不出来、换个平台就炸”的代码。
所以我现在做跨平台项目,第一件事就是把 CI 跑起来。GitHub Actions 上有现成的三个平台的 runner,Windows、Ubuntu、macOS 都有,配置也不复杂,push 之后三个系统自动拉代码、装依赖、编译、跑测试,有任何一个平台挂了都能立刻收到通知。这样一来,跨平台问题的发现周期从几周缩短到几分钟。这件事听起来有点偏“工程化”,但在 real 的跨平台开发里,它会让你轻松十倍。
另外,写跨平台代码的时候,请一定记得把编译选项打开得更严格一些。我在自己的项目里常开-Wall -Wextra -Wpedantic,如果项目预算允许,还会加-Werror把警告当错误处理。这是因为很多跨平台问题其实最早是以警告形式出现的,比如不同平台下整数类型大小差异、未使用的参数、隐式类型转换等等。你把警告堵死,就等于提前拦截了一批跨平台隐患。
写在最后的一点经验
做了这么多年 C++ 跨平台开发,我的体会是:跨平台的关键成功因素,不是某个奇淫巧技,而是把平台差异当做一种设计输入,并贯穿在架构、构建、编码、集成每一个环节里。工具链统一了,CMake 配置规范了,平台相关代码隔离了,跨平台开发就会变成一套流程自然的结果。
最后再分享一个小技巧:每当你准备在代码里写下#ifdef _WIN32的时候,停一下,问自己一句——这个差异能不能提炼成一个更抽象的接口,让业务代码完全不需要感知平台差异?如果能,花点时间做抽象;如果不能,再把条件编译写上。这个原则沿用了我好几年,几乎所有的跨平台难题都因此被消灭在了萌芽阶段。