ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Windows C++ CMake 从环境配置到独立 exe 发布

Windows C++ CMake 从环境配置到独立 exe 发布 1. 为什么 Windows 上的 C 项目最后都会走到 CMake 这一步1.1 从手敲编译命令说起刚接触 Windows 下 C 开发的人大概率都经历过这样的阶段打开 Visual Studio 建一个工程点一下绿色三角exe 就躺在x64/Debug目录里了一切都很美好。直到某天你想把这个项目发给同事或者丢到 CI 上跑问题就来了——.sln和.vcxproj这两个文件是二进制加 XML 混合体版本一换就打架别人用 MinGW 或者 clang 根本打不开Git 上一合并全是冲突。这时候 CMake 就登场了。CMake 本身不编译代码它是个构建系统生成器。你写一份CMakeLists.txt它读取之后替你生成 Visual Studio 的 sln、Ninja 的 build.ninja、MinGW 的 Makefile。同一份源码描述换一台机器、换一个编译器只改一个命令行参数不用动任何配置文件。这是它在 Windows 上最核心的价值也是我这些年所有跨平台项目都从 CMake 起步的原因。这篇文章面向的是这样几类人刚在 Windows 上装了 CMake 但cmake命令敲下去报不是内部或外部命令的能跑通 Hello World 但搞不清为什么 exe 跑到build/Debug里去了的以及想让自己的程序双击就能跑、不弹控制台、还能自带图标的。我会把从装工具到出成品 exe 的完整链路走一遍中间那些文档里不写、但一定会踩的坑也会一并交代。1.2 Windows 下 CMake 能产出哪几种 exe同样是 exeWindows 上的形态差别比很多人想的大。第一种是控制台程序双击会弹一个黑窗口main函数是入口链接时子系统是CONSOLE。第二种是窗口程序GUI双击直接出界面不弹控制台链接子系统是WINDOWS在 CMake 里靠add_executable(名字 WIN32 ...)里的那个WIN32关键字控制。第三种是DLL虽然扩展名不是 exe但经常跟 exe 一起出现add_library(xxx SHARED ...)生成运行时要跟着 exe 放一起或者塞进系统目录。还有一层区别容易被忽略依赖运行时库的方式。MSVC 编译出来的 exe 默认动态链接VCRUNTIME140.dll、MSVCP140.dll这些东西。你在自己机器上跑得好好的拷到一台干净的 Windows 上双击就报缺少 MSVCP140.dll。想彻底甩掉这个依赖就得把运行时改成静态链接也就是/MT。这个话题后面第 4 章会专门拆因为它直接决定了你的 exe 能不能单文件发布。搞清楚这三种形态和两种链接方式后面所有配置项的选择逻辑就都能自己推导出来了而不是靠背参数。2. 环境准备把工具链摆顺比写代码更重要2.1 CMake 的安装与 PATH 那个经典坑CMake 官方提供两种 Windows 安装包一种是.msi安装程序一种是免安装的.zip。我强烈建议用.msi安装时勾选那个Add CMake to the system PATH for all users或者 for current user 的选项。很多人一路 Next 点过去忘了勾结果打开 PowerShell 敲cmake --version直接给你来一句cmake : 无法将“cmake”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查名称的拼写如果包括路径请确保路径正确然后再试一次。这是 Windows 新手遇到的第一号报错跟 CMake 本身半毛钱关系没有纯粹是环境变量没配。解决方式有两条路。第一条是重跑安装包选 Modify把 PATH 那个勾补上。第二条是手动加把 CMake 安装目录下的bin文件夹通常是C:\Program Files\CMake\bin加到系统环境变量Path里然后关掉所有已经打开的终端重新开一个。这一点特别重要环境变量的修改对已经运行的进程不生效我见过太多人加完 PATH 原地重敲命令发现还是不行气得砸键盘。注意如果你用的是免安装 zip 版建议解压到一个没有空格、没有中文的路径比如C:\Tools\cmake-3.29.0。C:\Program Files这种带空格的路径在某些老旧的构建脚本里会被拼错虽然 CMake 自身处理得不错但下游工具不一定。验证装好了没用这两条命令cmake --version cmake --help--help的输出最后会列出当前环境支持的所有生成器Generators这个列表非常有用后面选生成器的时候要回头看它。如果你的列表里没有 Visual Studio 开头的项说明这台机器没装 Visual Studio 的 C 组件如果只有 VS 没有 Ninja那是正常的Ninja 得单独装。至于卸载Windows 上 CMake 的 msi 安装走设置 → 应用 → 已安装的应用正常卸载就行。但 PATH 里那条记录有时候会残留卸载完记得去环境变量里瞅一眼不然你下次装了新版本、老版本的路径还挂着会出现明明卸载了却还能跑的诡异情况。2.2 编译器三选一MSVC、MinGW、clangCMake 只是生成器真正的编译器得另外装。Windows 上主流的三个选择MSVC微软自家。装 Visual Studio 的时候勾选使用 C 的桌面开发工作负载就会带上 MSVC 工具链。这个是 Windows 上的原生选择兼容性最好调试信息最完整。缺点是安装包巨大动辄十个 G 起步。如果你不想装完整 VS也可以只装Build Tools for Visual Studio体积小很多命令行功能一个不少CI 环境基本都用这个。MinGW-w64。这是一个把 GCC 移植到 Windows 的项目。好处是编译出来的东西跟 Linux 下习惯接近体积小装起来快。推荐用 MSYS2 来管理一条pacman -S mingw-w64-x86_64-gcc就搞定。缺点是某些 Windows 特有的 API、COM 组件支持不如 MSVC而且生成的 exe 默认依赖libstdc-6.dll、libgcc_s_seh-1.dll分发时同样要处理。clang / clang-cl。LLVM 出的编译器前向兼容性好报错信息比 MSVC 友好得多。clang-cl这个变体可以直接模仿 MSVC 的命令行接口配合 Visual Studio 的生成器用起来很顺。我的常规做法是需要跟 Windows 系统深度交互注册表、窗口、COM就上 MSVC写纯算法、工具类的小程序用 MinGW 省事追求编译速度和更好的诊断信息用 clang Ninja 组合。2.3 生成器怎么选一张表说清楚很多人卡在-G参数上不知道填哪个。核心判断依据是你用哪个编译器以及要不要多配置Debug/Release 并存。生成器名称配套编译器配置模式产物默认位置适用场景Visual Studio 17 2022MSVC多配置build/Debug/、build/Release/Windows 主力开发、需要 VS 调试器NinjaMSVC / clang / gcc 皆可单配置build/直接放编译速度快CI 首选Ninja Multi-Config同上多配置build/Debug/、build/Release/想要 Ninja 速度又要多配置MinGW MakefilesMinGW-w64 的 gcc/g单配置build/MSYS2 环境、轻量项目Unix MakefilesMSYS2 下的 make单配置build/一般不用容易和 MinGW 混这里有个新手最容易翻车的点单配置生成器Ninja、MinGW Makefiles必须显式指定CMAKE_BUILD_TYPE不指定默认是空的编译出来的东西既不是 Debug 也不是 Release优化开关一个都没开性能能差好几倍。而多配置生成器Visual Studio则完全无视CMAKE_BUILD_TYPE你在命令行加-DCMAKE_BUILD_TYPERelease它当没看见得在--build的时候用--config Release指定。我见过有人在 VS 生成器下加-DCMAKE_BUILD_TYPERelease然后发现 exe 跑到 Debug 目录里去了来回折腾一下午。记住这条规则能省掉大量时间。3. 最小可运行工程从零到拿到 exe3.1 目录结构与 CMakeLists.txt 逐行拆解先搭一个最干净的结构别一上来就搞复杂hello/ ├── CMakeLists.txt └── src/ └── main.cppmain.cpp随便写点#include iostream int main() { std::cout hello from cmake std::endl; return 0; }关键在于CMakeLists.txt我把它拆成带注释的版本每一行都说明白# 声明 CMake 最低版本低于这个版本会直接报错退出 # 定在 3.20 是因为 CMAKE_MSVC_RUNTIME_LIBRARY 这类变量在这个版本才完善 cmake_minimum_required(VERSION 3.20) # 项目名、版本号、启用哪些语言 # 显式写 LANGUAGES CXX 能跳过 C 编译器的检测配置阶段能快一点 project(hello VERSION 1.0.0 LANGUAGES CXX) # 设置 C 标准REQUIRED 表示如果编译器不支持就直接报错而不是悄悄降级 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关掉编译器私有扩展代码更接近标准换编译器时不容易出岔子 set(CMAKE_CXX_EXTENSIONS OFF) # 定义可执行目标第一个参数是产物名后面是源码列表 # 注意这里不加 WIN32 关键字所以它是控制台程序 add_executable(hello src/main.cpp) # 给目标加上编译特性现代 CMake 推荐用 target_ 系列而不是全局变量 target_compile_features(hello PRIVATE cxx_std_17)这份文件里project()那行有个细节如果你不写VERSION后面想用${PROJECT_VERSION}就没有值做版本宏定义的时候会踩空。建议从第一天就写上。CMAKE_CXX_EXTENSIONS OFF这行也值得强调。MSVC 默认不开 GNU 扩展GCC 默认开启-stdgnu17而不是-stdc17。如果代码里不小心用了typeof、变长数组这类扩展语法在 MinGW 下编译通过、换到 MSVC 直接炸。关掉它能提前把这些不兼容暴露出来。3.2 配置与构建两条命令走完CMake 的工作流分两步配置configure和构建build务必把这个心智建立起来。配置阶段读CMakeLists.txt检查编译器、找依赖、生成构建文件构建阶段才真正调编译器出 exe。推荐用-S和-B这套现代写法语义清楚# 配置源码在当前目录构建文件放到 build 子目录 cmake -S . -B build -G Visual Studio 17 2022 -A x64 # 构建指定 Release 配置 cmake --build build --config Release-A x64是给 Visual Studio 生成器指定目标架构的。不加的话某些 VS 版本会默认生成 Win32也就是 32 位的工程你编译出来的 exe 在任务管理器里会看到带个(32 位)后缀。想要 32 位就写-A Win32注意不是-A x86。--build build后面那个build是构建目录不是目标名。--config Release只对多配置生成器有意义。-j或者更高版本的--parallel可以并行编译cmake --build build --config Release -j 8能明显提速。换成 Ninja 的话是这套cmake -S . -B build-ninja -G Ninja -DCMAKE_BUILD_TYPERelease cmake --build build-ninja注意这里用-DCMAKE_BUILD_TYPERelease而不是--config。配错了不会报错只会静默地给你编出一个没有优化的版本特别隐蔽。提示用 Ninja 必须先保证ninja.exe在 PATH 里。Visual Studio 安装时自带的那个 Ninja 藏在 VS 的安装目录下如果不想单独装可以借用 VS 开发者命令行Developer Command Prompt来启动那个环境里 ninja 和 cl 都已经配好了。3.3 产物到底在哪儿输出路径的三个坑配置构建完之后第一件事就是找 exe 在哪。默认规则是这样的多配置生成器VSbuild/Config/hello.exe也就是build/Debug/hello.exe、build/Release/hello.exe单配置生成器Ninja / MinGWbuild/hello.exe直接扔在构建目录根下问题来了。一是目录不统一脚本里写路径得判断半天二是构建目录和产物目录混在一起build/里塞满了.obj、.pdb、CMake 缓存文件想打包发布的时候得大海捞针。我一般会在CMakeLists.txt里统一指定产物位置# 所有可执行文件统一输出到构建目录下的 bin 文件夹 set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) # 多配置生成器会给每种配置加子目录这里手动抹平 foreach(cfg IN ITEMS DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${cfg} ${CMAKE_BINARY_DIR}/bin) endforeach()那个foreach循环是必须的。多配置生成器会去读CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG这类带配置后缀的变量如果你只设了通用的那个Debug 和 Release 的产物会互相覆盖——先编 Debug 再编 Release前面那个直接被冲掉。这个坑我在项目里踩过一次当时还以为是编译失败了其实是文件被覆盖。第二个坑是路径里带中文或者空格。MSVC 本身扛得住但一些第三方库的构建脚本、以及部分老的构建系统遇到非 ASCII 路径会乱码或者直接失败。所以构建目录名就用英文别偷懒叫什么临时构建。第三个坑是MSVC 的 PDB 文件。调试符号默认和 exe 放一起发布的时候别手滑一起发出去体积不小而且暴露源码路径信息。可以在安装规则里用install(FILES $TARGET_PDB_FILE:hello DESTINATION ...)单独处理或者在打包脚本里排除。4. 让 exe 变成能发给别人的成品4.1 运行时库/MT 和 /MD 到底选哪个这一节是全文最实用的部分因为它直接决定你的 exe 能不能在别人电脑上双击就跑。MSVC 有两套运行时库选项/MD是动态链接MultiThreadedDLL/MT是静态链接MultiThreaded。默认是/MD编译出来的 exe 依赖VCRUNTIME140.dll、MSVCP140.dll、ucrtbase.dll这些。目标机器如果没装过 VC 运行库双击就是一句冷冰冰的由于找不到 MSVCP140.dll无法继续执行代码。重新安装程序可能会解决此问题。两条解法。正规做法是给用户装Microsoft Visual C Redistributable或者用安装包带上它。偷懒做法就是改成静态链接把所有运行时都塞进 exe 里单文件直接发。CMake 里改这个要用 3.15 之后引入的抽象变量别去手动改CMAKE_CXX_FLAGSif(MSVC) # MultiThreaded 对应 /MTDebug 配置自动切换成 /MTd # 生成器表达式 $CONFIG:Debug 会在 Debug 配置下求值为 1 set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:Debug) endif()这行生成器表达式是精髓。写死MultiThreaded的话Debug 配置也会用/MT然后链接时会报RuntimeLibrary不匹配的警告——因为调试版和发布版的运行时分配器不一样混用会导致堆内存跨模块释放时崩溃。带上$$CONFIG:Debug:Debug之后Debug 自动变/MTdRelease 是/MT干净。MinGW 那边是另一套逻辑用链接选项控制if(MINGW) # -static 让 gcc 把 libstdc、libgcc 都静态链进去 target_link_options(hello PRIVATE -static -static-libgcc -static-libstdc) endif()注意静态链接会让 exe 体积涨个几百 KB 到一两 MB这是正常的换来的是零依赖。如果你用了 GPL 协议的库静态链接还会牵扯到许可证问题商业项目要提前确认。4.2 去掉控制台黑窗、加上图标和版本信息控制台程序改成 GUI 程序只需要在add_executable里加一个关键字add_executable(hello WIN32 src/main.cpp)加了这个WIN32之后链接子系统变成WINDOWS双击不再弹黑窗入口函数从main变成WinMain不过 MSVC 会帮你做一层兼容main也能跑。反过来说如果你写的是 GUI 程序却忘了加WIN32程序跑起来会一直挂着一个黑窗口关掉黑窗口程序也一起没了这个现象很典型。给 exe 换图标和填版本信息靠的是资源文件.rc。先写一个app.rc#include windows.h // 图标IDI_ICON1 是资源 ID数字随便取但别和系统保留值冲突 IDI_ICON1 ICON app.ico // 版本信息块 VS_VERSION_INFO VERSIONINFO FILEVERSION 1,0,0,0 PRODUCTVERSION 1,0,0,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS 0x40004L FILETYPE 0x1L FILESUBTYPE 0x0L BEGIN BLOCK StringFileInfo BEGIN BLOCK 040904b0 BEGIN VALUE FileDescription, Hello App VALUE FileVersion, 1.0.0.0 VALUE ProductName, Hello VALUE LegalCopyright, Copyright (C) 2024 END END BLOCK VarFileInfo BEGIN VALUE Translation, 0x409, 1200 END END然后在 CMake 里把它加进源文件列表add_executable(hello WIN32 src/main.cpp app.rc)CMake 会自动识别.rc文件并交给rc.exe处理不需要额外配置。app.ico要放在和app.rc同一个目录或者写相对路径。图标文件必须是真正的多尺寸 ico用单张 png 改扩展名是没用的Windows 只认 ico 格式里嵌的各级尺寸图像。有个细节.rc文件里的#include windows.h依赖 MSVC 的头文件搜索路径。如果换 MinGW路径不一样得换成#include windef.h或者干脆把需要的宏自己定义一遍。所以我一般把资源编译这块用if(MSVC)包起来其他编译器就跳过图标保证工程能编过。4.3 引入第三方库find_package 还是 FetchContent真实项目不可能只有标准库。引第三方库在 CMake 里有两个主流姿势选错了后期维护会很痛苦。find_package是找已经装好的库。比如要找 zlibfind_package(ZLIB REQUIRED) target_link_libraries(hello PRIVATE ZLIB::ZLIB)用find_package的关键是优先用带::的目标名那是现代 CMake 的 IMPORTED target会自动帮你带上头文件路径和依赖。老教程里那种include_directories(${ZLIB_INCLUDE_DIRS})加target_link_libraries(hello ${ZLIB_LIBRARIES})的写法能跑但把路径污染给了全局多个目标之间会互相干扰新项目别学。麻烦在于 Windows 上很多库没有官方的 CMake config 文件find_package找不到。这时候要么手动写FindXXX.cmake要么上vcpkg这类包管理器。vcpkg 的用法很直接装好之后配置时加一行cmake -S . -B build -DCMAKE_TOOLCHAIN_FILEC:/vcpkg/scripts/buildsystems/vcpkg.cmake之后find_package就能自动找到 vcpkg 装的库。FetchContent是构建时把源码下载下来一起编。适合那种 header-only 或者源码量不大的库include(FetchContent) FetchContent_Declare( fmt GIT_REPOSITORY https://github.com/fmtlib/fmt.git GIT_TAG 10.2.1 ) FetchContent_MakeAvailable(fmt) target_link_libraries(hello PRIVATE fmt::fmt)好处是版本锁定在 CMakeLists 里任何人克隆下来一配就有一致的依赖不需要先装环境。坏处是每次全新的构建目录都要重新下载网络不好会很慢而且对 CI 的缓存策略有要求。我的经验判断标准是依赖是系统级的、跨项目复用的OpenSSL、Boost用 vcpkg find_package依赖是项目特有的、跟源码强绑定的fmt、nlohmann_json、googletest用 FetchContent。混着用也完全没问题。5. 报错现场这些坑我基本都踩过5.1 编译期报错的排查顺序拿到一个不认识的 CMake 报错先看报错信息的最后 10 行CMake 的错误输出习惯把真正的原因放在最后前面一堆是上下文回溯。常见的几类CMake Error: Could not find CMAKE_CXX_COMPILER——找不到编译器。VS 生成器下一般是没装 C 工作负载Ninja 生成器下是cl.exe或者g.exe不在 PATH 里。解决办法是打开Developer Command Prompt for VS那里面环境是配好的。CMake Error at CMakeLists.txt:5 (project): No CMAKE_CXX_COMPILER could be found——同上但更常见于在普通 PowerShell 里直接用 Ninja 生成器配 MSVC。The C compiler identification is unknown——路径里有中文或者特殊字符编译器的探测脚本跑失败了。挪到一个纯英文路径下重试。CMake Warning: Manually-specified variables were not used by the project——你传了一个 CMakeLists 里根本没读的变量。多数时候是打错了名字比如把CMAKE_BUILD_TYPE手滑敲成CMAKE_BUILD_TYPE_。这个警告经常被忽略但其实是拼写错误的强信号。5.2 运行期报错DLL 去哪儿了编译过去了双击 exe 报找不到 XXX.dll这是 Windows 上的高频问题。排查思路是分层的报错信息缺失的东西解决方式找不到 MSVCP140.dll / VCRUNTIME140.dllMSVC 运行时装 VC Redist或改/MT静态链接找不到 libstdc-6.dll / libgcc_s_seh-1.dllMinGW 运行时加-static链接选项找不到 xxx.dll自建库你自己的动态库把 DLL 拷到 exe 同目录或改静态库找不到 Qt5Core.dll 之类第三方框架运行时用框架自带的部署工具拷贝依赖最省事的定位方法是Dependencies或者Dependency Walker这类工具把 exe 拖进去它会列出所有依赖的 DLL 以及每个 DLL 能不能找到。注意 Dependency Walker 太老了遇到 API Set 的东西会误报优先用 Dependencies。另外一个隐藏坑Debug 和 Release 的 DLL 不能混用。Debug 版 exe 链接了 Release 版的第三方库编译期能过运行起来会随机崩溃位置还不固定。因为两边的 CRT 堆不一样跨模块new/delete会直接炸。构建时保证所有组件配置一致这个纪律必须守住。提示检测 exe 到底依赖哪些 DLL用dumpbin /dependents hello.exeVS 自带或者objdump -p hello.exe | findstr DLLMinGW 自带都可以比图形工具更适合写进脚本。5.3 构建目录的清理与增量编译陷阱CMake 的增量构建很省时间但有些改动它检测不到会导致你改了配置却发现没生效改了CMakeLists.txt里的-G生成器。生成器一旦选定就写死在 CMakeCache 里了直接再跑一次cmake -S . -B build -G Ninja会报生成器不匹配的错。必须删掉整个build目录重来。改了CMAKE_MSVC_RUNTIME_LIBRARY这类影响全局编译选项的变量。理论上 CMake 会重新生成编译命令但残留的.obj可能不会重编。改了工具链文件CMAKE_TOOLCHAIN_FILE。这个变量是缓存的改了不生效。遇到配置改了但没反应的情况第一反应就是删构建目录# PowerShell 下 Remove-Item -Recurse -Force build # CMD 下 rmdir /s /q build嫌删目录太暴力可以只删缓存del build\CMakeCache.txt然后重跑配置。这样能保留一部分已编译的目标文件速度介于全删和不动之间。还有个值得一提的CMakePresets.json。这是 CMake 3.19 之后引入的配置文件把那些长长的命令行参数写进 JSON 里之后只需要cmake --preset windows-release就能配置。多平台、多配置的项目用起来非常舒服也能让新人不用背命令。缺点是学习成本小项目上马有点重我一般在项目超过两个人协作的时候才引入。6. 打包一只 exe 发出去之前我会检查的几件事代码编出来只是第一步真正发到别人手里还差几个动作。我现在做完一个 Windows 可执行程序会按这个顺序过一遍。先确认目标机器的架构。-A x64出来的东西在 32 位系统上跑不了反过来倒是兼容。不确定就给 32 位兼容性最广。用dumpbin /headers hello.exe | findstr machine能看出目标架构输出x64或者x86。再确认依赖是否齐全。静态链接最省心动态链接就写个copy脚本把所有需要的 DLL 一起拷过来。Qt 项目用windeployqt hello.exe一条命令搞定它会自动分析依赖并把插件目录也带上。然后是文件大小和启动速度。Release 配置配上/O2MSVC或-O3GCC外加 LTOinclude(CheckIPOSupported) check_ipo_supported(RESULT ipo_ok OUTPUT ipo_msg) if(ipo_ok) set_property(TARGET hello PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) endif()LTO 在小项目上效果不一定明显但代码量上去之后跨编译单元的优化能带来实打实的提升代价是编译时间成倍增长。所以只在 Release 配置上开。最后别把带调试信息的东西发出去。Release 编译的 exe 旁边那个 PDB 文件留着自己排查崩溃用发布包里剔掉。.ilk、.exp、.lib这些中间产物也一样。用一个干净的目录只装 exe 和必需的 DLL双击验证一遍能跑起来才算真的完成。我在实际项目里最常犯的错误是在开发机上验证通过就以为万事大吉发给同事才发现少个 DLL。后来养成了习惯找一台没装开发环境的虚拟机或者干净账号跑一遍五分钟的验证能省掉半天的沟通成本。这个流程一旦固化成脚本每次发布都跑一遍基本就不会再出问题。
返回列表