把一段 C++ 源码变成可以双击运行的.exe,在 Windows 上最常用的工具链就是 MSVC 编译器。提起“编译C++的几种方式”,很多人第一反应是“在 IDE 里点一下编译按钮”,但真正跟 MSVC 这套工具链打过交道之后你会发现,同样一个编译动作,至少有四五条完全不同的路径可以走。这篇内容不聊 C++ 语法,而是把 MSVC 编译器的几种主流调用方式掰开揉碎,从 IDE 里的可视化操作讲起,再到cl.exe命令行、MSBuild、CMake、NMAKE,适合刚开始接触 Windows C++ 的初学者,也适合已经能跑通项目、但想搞明白“编译背后到底发生了什么”的开发者。
1. 先搞清楚:MSVC 到底能用哪几种方式编译 C++
1.1 同一个编译动作,五条不同路径
先说结论。在 Windows 上用 MSVC 编译 C++,常见路径基本就是下面这五条:
| 路径 | 核心入口 | 典型使用场景 |
|---|---|---|
| Visual Studio IDE 编译 | 按 F7 / 菜单“生成解决方案” | 日常开发、调试、学习语法 |
命令行cl.exe | 开发者命令提示符 | 单文件或多文件小项目、验证编译参数 |
| MSBuild 命令行 | msbuild *.vcxproj | 构建已有的 VS 工程、CI 自动化 |
| CMake + MSBuild | cmake --build | 跨平台项目、现代 C++ 工程组织 |
| NMAKE + Makefile | nmake | 老式 C/C++ 工程、部分第三方源码包 |
注意,这条路径里 CMake 严格来说并不是编译器,它只是生成构建脚本的工具,最终在 Windows 上还是会调用 MSBuild 或者 NMAKE 去驱动cl.exe。但既然大家实际用的时候都把“用 CMake 构建”当成一种编译方式,这里就一起列出来。这五条路看起来很多,其实背后只有一个核心:cl.exe是真正干活的编译器,其它工具都是在帮我们组织参数、管理依赖、安排编译顺序。
1.2 编译器只有一个,变的是“谁来喊它”
很多初学者会把“Visual Studio”和“MSVC 编译器”搞混。实际上,前者是一整套 IDE,后者是藏在里面的编译器工具链。MSVC 工具链里真正跑 C++ 编译的进程叫cl.exe,链接阶段由link.exe完成,资源文件则由rc.exe处理。不管你在 IDE 里看到多漂亮的界面,最终底层干活的都是这几个命令行程序。
所以“几种编译方式”本质上不是编译器变了,而是“谁来调用 cl.exe、以什么方式组织参数”变了。IDE 负责帮你生成一堆编译参数,MSBuild 读取.vcxproj工程文件后把里面的配置翻译成 cl/link 参数,CMake 先生成.sln解决方案再转交给 MSBuild,NMAKE 则按照 Makefile 里的规则自己拼命令行。理解这一点之后,你再遇到什么“在某某工具里编译不过,命令行却能编译过”的怪问题,就不会觉得玄学了——十有八九是两边传给 cl.exe 的参数不一致。
1.3 不同场景到底该选哪条路
我的个人建议是这样的:
- 刚入门、每天就写几个演示程序:直接用 Visual Studio IDE,按 F7 是最省心的方式。
- 想验证某个语法特性、快速试一段代码:命令行
cl.exe最直接,不用建工程。 - 接手的是已有的 VS 工程,又要上自动构建:用 MSBuild,命令行一条命令搞定。
- 项目需要跨平台,或者团队里有人用其它 IDE:用 CMake,一次编写到处生成。
- 有时候拿到老的开源库,里面只有 Makefile:可能需要 NMAKE,这个相对少见但值得了解。
选路的关键不是“哪个更高级”,而是“哪个更符合你当前的工程形态”。下面我从环境准备讲起,然后把每条路都实际跑一遍。
2. 动手前的基础:MSVC 编译环境怎么搭才算真正能用
2.1 装完整 IDE 还是只装 Build Tools
想用 MSVC 编译 C++,首先要保证电脑里有“MSVC 编译器”这个东西。安装 Visual Studio 的时候,记得在“工作负载”里勾选“使用 C++ 的桌面开发”,这一项会把 MSVC 工具链、Windows SDK、CMake、测试工具等常用组件一起装上。如果你平时根本不想打开 IDE 界面,也可以单独安装“生成工具(Build Tools)”,它是一套精简的命令行构建环境,只有编译器、链接器、头文件、库文件,没有图形界面,非常适合 CI 服务器或者喜欢纯命令行的开发者。
我见过不少新手栽在第一步:装了 Visual Studio,但安装时没勾选 C++ 工作负载,结果新建项目时找不到 C++ 模板,命令行里cl也永远提示找不到。如果你也碰到类似情况,去 Visual Studio Installer 里把“使用 C++ 的桌面开发”勾上再等安装完成就行。这个组件体积不小,但它是后续所有编译方式的地基。
2.2 开发者命令提示符与 vcvars64.bat
环境装上之后,很多人会在普通 CMD 窗口里敲cl,结果看到“cl不是内部或外部命令”的错误。这很正常。MSVC 编译器的路径并不在系统默认的 PATH 环境变量里,而且编译器运行还依赖一堆环境变量,比如INCLUDE(头文件搜索路径)和LIB(库文件搜索路径)。普通 CMD 启动时不会自动加载这些配置。
正确做法是使用“开发者命令提示符”。安装完 Visual Studio 或 Build Tools 后,开始菜单里会出现类似 “x64 Native Tools Command Prompt for VS 2022” 的快捷方式,打开它,cl就能用了。这个快捷方式实际上是在打开终端后执行了一个批处理脚本,常见路径是:
C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat如果你已经在一个普通 CMD 里,也可以手动执行这个脚本,执行完当前窗口就变成了带 MSVC 环境的终端。这里面值得注意的细节是:vcvars64.bat配置的是 64 位编译环境,项目里如果有 x86 的需求,要去找对应的vcvars32.bat或者使用vcvarsall.bat加参数。
2.3 验证环境是否就绪
环境变量配好之后,第一步先验证。在开发者命令提示符里执行:
cl如果环境正常,屏幕上会输出一大段 MSVC 的用法说明,其中包含支持的编译选项列表。什么都没输出或者直接提示“不是内部或外部命令”,说明当前终端不对,或者工具链没装完整。
再用where cl看一下可执行文件路径,如果输出的路径指向你安装 Visual Studio 的目录,那基本就稳了。保险起见,还可以先编译一个最简单的程序验证完整链路,不过在那之前,下面先讲每种编译方式的具体实操。
3. 最常见也最省心:Visual Studio IDE 里的直接编译
3.1 新建项目与一键 F7
如果你是 C++ 新手,最友好的编译方式当然是 Visual Studio IDE。打开 Visual Studio,选择“新建项目”,在模板里搜“控制台应用”,项目名称和位置随意,然后点创建。IDE 会帮你生成一个包含main函数的cpp文件,直接按 F7 或者点菜单里的“生成解决方案”,编译器就开始干活了。如果一切顺利,底部输出窗口会显示“生成成功”,并且你可以在项目目录的x64\Debug或Release文件夹里找到生成的.exe。
在这个过程中,IDE 已经替你处理了大量脏活:自动设置包含路径、库路径、选择当前平台、决定 Debug/Release 的宏定义、把多个源文件逐个交给cl.exe,最后再调用link.exe链接。你在界面里看到的可能只是===== 生成: 1 成功,0 失败 =====,但实际上背后可能执行了几十条命令行。
3.2 观察 IDE 的“编译”到底在做什么
如果你不想让 IDE 变成“黑盒”,可以打开“工具 -> 选项 -> 项目和解决方案 -> 生成并运行”,把“生成输出详细级别”改成“详细”。设置完成后重新生成一次项目,在输出窗口里就能看到每一步实际执行的命令,包括cl.exe的完整参数、link.exe的输入文件列表、MSBuild 的配置过程。这招对我理解 MSVC 特别有帮助,尤其是遇到编译选项冲突或者找不到符号的时候,看一眼输出窗口的真实命令,往往能直接定位问题。
顺带说一下,IDE 生成项目时,默认创建的不是直接用 cl 的批处理,而是一个.vcxproj工程文件。这个文件本质上是 XML,记录了所有源码文件、编译选项、平台配置等。IDE 每次点生成按钮,本质上是在调用 MSBuild 去解析这个工程文件。所以“IDE 编译”和“MSBuild 编译”其实只是同一件事的两种触发方式。
3.3 适合 IDE 的场景和它天生的局限
IDE 方式最大的优势是自动化程度高。它自带智能感知、代码补全、断点调试、内存窗口、性能分析器,这些对日常开发体验的提升是命令行无法替代的。对于只写几个小 demo 或者正在熟悉 C++ 语法的朋友,我建议先用 IDE 建立起“改代码 -> 编译 -> 运行 -> 调试”的正向循环,别一上来就跟命令行硬磕。
但 IDE 方式的局限也很明显:不便于自动化。你没法在 CI/CD 脚本里“点一下按钮”,也没办法在服务器上打开 Visual Studio 界面。另外,IDE 帮你隐藏了太多细节,如果你完全依赖 IDE,遇到编译错误时可能只会看红波浪线,但不知道真正的原因是头文件路径不对还是链接库缺失。所以下面几条路径才更值得动手练。
4. 命令行自由派:cl.exe + 开发者命令提示符
4.1 最简单的单文件编译
先用记事本或任意编辑器写一个最基础的 Hello World:
#include <iostream> int main() { std::cout << "hello msvc" << std::endl; return 0; }保存为hello.cpp,然后在开发者命令提示符里,切到文件所在目录,执行:
cl /EHsc /std:c++17 /W4 hello.cpp这条命令会生成hello.exe和中间产物hello.obj。我简单解释一下参数:
/EHsc表示启用 C++ 异常处理,绝大多数 C++ 项目都应该带上。/std:c++17指定使用 C++17 标准;想用 C++20 就改成/std:c++20。/W4表示把警告级别开到第四级,能帮你提前发现很多隐藏问题。
如果编译成功,直接执行hello.exe就能看到输出。这是最原始、最透明的一条编译路径:没有工程文件、没有依赖分析,纯粹把你写的参数丢给编译器。想看看 MSVC 到底支持哪些标准,可以加一个/std:c++latest试试。
4.2 多文件编译与输出目录管理
实际项目通常不止一个源文件。比如你有main.cpp、utils.cpp和一个头文件utils.h,编译时可以一次性列在后面:
cl /EHsc /std:c++17 /W4 main.cpp utils.cpp /Fe:app.exe如果不特别指定,cl.exe会把所有.cpp文件逐个编译成.obj文件,放在当前目录,然后链接成app.exe。/Fe用来指定输出可执行文件的名称。如果你想保持源码目录整洁,可以把.obj和.exe都放进专门的build目录:
mkdir build cl /EHsc /std:c++17 /W4 /Fo:build\ /Fe:build\app.exe main.cpp utils.cpp这里/Fo:build\表示把.obj文件输出到build文件夹。要注意目录必须提前存在,否则 cl 会报“找不到目录”的错误。我在第一次用/Fo时就没建目录,结果被这个头铁的错误绊了一下。
4.3 优化级别、警告级别与调试信息
命令行方式手动编译时,参数灵活性很大,但也要对后果有概念。常用参数大致如下:
| 参数 | 作用 | 说明 |
|---|---|---|
/Od | 禁用优化 | Debug 模式常用,编译快,调试方便 |
/O1 | 优化代码体积 | 生成的程序更小 |
/O2 | 优化执行速度 | Release 模式常用 |
/Zi | 生成调试符号 | 配合.pdb文件调试崩溃现场 |
/W4 | 高警告级别 | 建议默认开启 |
/WX | 把警告视为错误 | 适合 CI,严格把关 |
/MT | 静态链接运行时库 | 程序不依赖运行时 DLL |
/MD | 动态链接运行时库 | 程序体积小,依赖系统运行时 DLL |
Debug 和 Release 的区别不只是一个名字。命令行下手动编译时,要自己决定用哪组参数。典型的 Debug 编译可以是:
cl /EHsc /std:c++17 /Od /Zi /MTd hello.cpp而 Release 编译则是:
cl /EHsc /std:c++17 /O2 /MT hello.cpp这里出现了一个关键坑:/MT和/MD不能随便混用。如果一个.obj使用静态运行库编译,另一个.obj使用动态运行库编译,链接阶段可能会出现LNK2038这类“运行时库不匹配”的错误。理想情况下,一个项目里所有编译单元应该用同一套运行时库策略。这个坑我会在后面的排查章节再展开。
4.4 写出一个顺手的小批处理
命令行参数敲多了,手动重复很烦。我一般会写一个小批处理build.bat:
@echo off setlocal if not exist build mkdir build cl /EHsc /std:c++17 /W4 /O2 /Fo:build\ /Fe:build\app.exe main.cpp utils.cpp if %errorlevel% neq 0 ( echo build failed exit /b 1 ) echo build ok这个脚本先创建build目录,再调用 cl,最后检查错误码。如果 cl 返回非零,说明编译失败,脚本直接退出。%errorlevel%是命令行世界里常见的“上一次命令返回值”,记住这个概念,后面排查自动构建问题时特别有用。批处理里要注意的是,if的判断条件写法和 C++ 里完全不一样,第一眼可能不太习惯,但照着多写几次就记住了。
5. 工程化必备:MSBuild 和 CMake 两种自动化编译路径
5.1 MSBuild:IDE 背后的真身
只要你在 Visual Studio 里创建过项目,目录下就会有一个.vcxproj文件。这个文件是 MSBuild 的项目描述文件,IDE 本质上只是在调用 MSBuild 去构建它。所以当你拿到别人发来的 VS 工程时,完全可以不用打开 IDE,直接在命令行里编译:
msbuild 你的项目.vcxproj /p:Configuration=Release /p:Platform=x64执行前同样要先进入开发者命令提示符,否则msbuild可能不在 PATH 里。这条命令会读取.vcxproj,解析里面定义的源码列表、编译选项、预处理宏、链接库等,最终完成构建。你可以在命令行后面加/t:Build来指定目标任务,也可以加/m启用多核并行编译:
msbuild 你的项目.vcxproj /t:Build /p:Configuration=Release /p:Platform=x64 /m多核编译对大型项目提速非常明显。我接手过一个相对较大的旧工程,单线程构建要几分钟,加了/m之后基本能压到一分钟左右。如果你用msbuild /verbosity:detailed,还会看到它到底调用了哪些 cl/link 命令,这又是一个理解编译过程的好入口。
5.2 CMake + Visual Studio 生成器:跨平台项目落地 Windows
现代 C++ 项目越来越喜欢用 CMake,因为它能让同一份源码在不同平台、不同编译器下工作。CMake 本身不编译代码,它只是“生成构建系统的工具”。在 Windows 上,最典型的用法是:
cmake -S . -B build -G "Visual Studio 17 2022" -A x64这行命令的意思是:以当前目录为源码根目录,用 Visual Studio 2022 作为生成器,输出 64 位工程到build目录。执行完成后,build里会出现一个.sln解决方案文件和若干.vcxproj文件。接着编译:
cmake --build build --config Release这条命令会调用 CMake 内部逻辑,最终仍然通过 MSBuild 来构建生成的.sln。一个最简单的CMakeLists.txt可以长这样:
cmake_minimum_required(VERSION 3.20) project(demo_app) add_executable(myapp main.cpp utils.cpp )如果你是跨平台项目,在 Linux 上同样的 CMakeLists.txt 就可以生成 Makefile 或者 Ninja 工程,源码不用改,构建逻辑却能跟着平台走。这也是为什么现在越来越多的新库默认只提供 CMake 构建方式,而不是直接给你一个.vcxproj。
5.3 两种自动化路径的选择建议
MSBuild 和 CMake 并不冲突,但选择时可以从两个角度判断:
- 项目如果本来就是 Visual Studio 原生工程,团队也用 VS 开发,MSBuild 是最直接的自动化方式,不用额外引入构建层。
- 项目如果未来要跨平台、要在 Linux/macOS 上编译,或者团队中有人用 CLion、VS Code,那我强烈建议用 CMake 管理源码,CI 里也统一走 CMake。
还有一个很常见的实践:CMake 生成工程后,加入cmake --build build --target install之类命令可以把产物安装到指定目录。配合 CI 流水线,代码一提交,自动完成配置、构建、测试、安装,这就是自动化编译路径的意义。
6. 老牌 Makefile 流派:NMAKE 配合 cl.exe
6.1 NMAKE 到底在做什么
NMAKE 是 Visual Studio 自带的 Make 工具,历史的年头很长。它通过读取一个叫 Makefile 的文本文件,根据“目标文件依赖哪些源文件”的规则,来决定什么需要重新编译、用什么命令编译。虽然现在新项目很少直接写 Makefile,但很多老的开源库、第三方 SDK、以及部分 Windows 驱动相关代码仍然沿用这种风格,所以我建议你至少能看懂、能跑通。
NMAKE 的基本逻辑是“目标: 依赖项”,下面缩进的行是“生成目标的命令”。比如:
app.exe: main.obj utils.obj cl main.obj utils.obj /Fe:app.exe main.obj: main.cpp utils.h cl /EHsc /c main.cpp /Fo:main.obj utils.obj: utils.cpp utils.h cl /EHsc /c utils.cpp /Fo:utils.obj clean: del *.obj *.exe当你执行nmake时,它会检查app.exe是否存在、它的时间戳是否比main.obj和utils.obj新。如果某个.obj不存在或者时间戳比对应的.cpp旧,就执行对应的编译命令。这种方式能让编译过程带上“自动化依赖判断”,比直接写批处理高级一点。
6.2 一个能跑的 Makefile 示例
把上面那个 Makefile 存成Makefile(注意大小写和文件名),在开发者命令提示符里执行:
nmake这里有一个非常经典的坑:Makefile 里“命令”那一行必须用 Tab 缩进,不能用空格。如果用了空格,NMAKE 会报“缺少分隔符”之类错误。这个错误几乎每个接触 Makefile 的人都会遇到,我第一写时也是被这个 Tab 坑得不轻。建议在编辑器里开启“显示空白字符”,确保那行前面确实是 Tab。
Makefile 也算一种构建描述文件,它比手写批处理更接近“工程化”,但比起 MSBuild 和 CMake,它对大型项目的支持弱很多,不支持复杂的配置矩阵,也很难和 IDE 的智能提示联动。所以我的观点是:知道它存在、能看懂,但不建议新项目再用 NMAKE 搭构建体系。
6.3 现在还有用武之地吗
说实话,新项目里 NMAKE 的出镜率已经很低了。CMake 在 Windows 上也能生成 NMAKE 格式的构建系统,但更主流的还是生成 Visual Studio 工程。NMAKE 最大的价值在于兼容老代码。我早年接过一个第三方 C 库,文档里只给了 makefile 和几个.c文件,当时就是用 NMAKE 一条路跑通的。那时候我对“编译C++的方式”又多了一层理解:方式再多,本质还是“工具把 cl 命令组织起来”。
7. 常见报错与排查技巧实录
7.1 C1083:无法打开包括文件
fatal error C1083: Cannot open include file: 'iostream': No such file or directory是新手最容易碰到的错误之一。大部分时候不是因为iostream不存在,而是因为当前终端不是开发者命令提示符,INCLUDE环境变量没有设置,编译器不知道去哪里找标准库头文件。解决办法就是切换到正确的命令行环境。
还有一种情况是自己的头文件找不到,比如#include "utils.h"但utils.h不在当前目录。这时候可以用/I参数指定额外头文件路径:
cl /EHsc /I. main.cpp utils.cpp /Fe:app.exe/I.表示把当前目录加入头文件搜索路径。MSVC 对带引号的#include "..."会优先搜索源文件所在目录,但如果在子目录里包含文件,还是要靠/I显式指定。排查这类问题很简单:看看编译器实际报错的是标准库头文件还是自己的头文件,前者多半是环境变量问题,后者多半是路径没写对。
7.2 LNK2019 / LNK2038:链接阶段两大拦路虎
链接错误比编译错误更让人头疼,因为报错信息里往往没有直接指出源码行号。最常见的是LNK2019: unresolved external symbol。意思是链接器在某个.obj里看到代码引用了某个函数或变量的“声明”,但找不到它的“定义”。常见原因包括:
- 声明了函数但没写实现体。
- 实现体写在
.cpp里,但这个.cpp没有参与编译。 - 函数签名不一致,比如头文件里是
int foo(int),实现却是int foo(float)。 - 调用了某个第三方库函数,但忘了在工程配置里链接对应的
.lib文件。
排查思路不难:先确认函数是否真的有定义,再确认定义所在的文件有没有被加入编译列表,最后确认依赖库有没有被链接。命令行方式下,链接库可以直接写在 cl 命令后面,比如cl main.obj utils.obj /Fe:app.exe user32.lib。
LNK2038 RuntimeLibrary mismatch则是更隐蔽的一种。它通常出现在混用了/MT和/MD编译出的.obj或.lib时。比如一个静态库是用/MD编译的,你的主程序却用/MT编译,链接时 MSVC 会检测到一组叫RuntimeLibrary的匹配项不一致,直接拒绝生成。解决办法是让所有工程统一运行时库配置。这个错误在 IDE 里也会遇到,经常发生在“引用第三方库”时,第三方库发布时用了一套运行时库,你项目又用了另一套。最稳妥的做法是让项目配置尽量和库的制作方保持一致。
7.3 中文乱码与源文件编码
代码里写了中文注释或者中文字符串,编译时出现警告C4819,或者运行后字符串打印成乱码,这是 MSVC 的老问题了。MSVC 默认会按当前系统的本地代码页解释源文件,如果源码保存为 UTF-8,在中文系统上就可能被误读。最直接的解决办法是在命令行中指定 UTF-8 作为源文件编码:
cl /EHsc /utf-8 hello.cpp/utf-8是 MSVC 专门解决源文件字符集问题的选项。另外,建议在所有需要处理中文的 C++ 项目里统一使用 UTF-8 编码保存源码,并加上/utf-8编译选项。IDE 中可以在“项目属性 -> 常规 -> 字符集”里调整,底层效果和命令行参数一致。
7.4 “cl 不是内部或外部命令”
这个问题在前文已经强调过。普通 CMD 窗口里执行cl报“不是内部或外部命令”,是因为环境变量没配置。解决方式有三种:一是打开开始菜单里的开发者命令提示符快捷方式;二是在普通 CMD 里先执行vcvars64.bat;三是把相关路径手动加进系统 PATH,但我不推荐第三种,很容易污染全局环境,而且 Visual Studio 更新后路径可能变化。最规范的做法永远是前两种。
同理,msbuild和nmake找不到时也要先想想终端环境。有些人明明装了 Visual Studio,却在普通 PowerShell 里调用这些工具,自然找不到。这不是工具不存在,而是当前进程还没加载工具链环境。
7.5 常见问题速查表
| 错误信息 | 常见原因 | 解决方向 |
|---|---|---|
| C1083 | INCLUDE 路径缺失或头文件路径错误 | 切换开发者命令提示符;加/I |
| C2011 / C2065 | 头文件重复包含、变量未声明 | 检查宏保护、包含顺序 |
| LNK2019 | 函数只有声明没有定义 | 检查实现文件是否参与编译、是否链接对应库 |
| LNK2038 | 运行时库不一致 | 统一/MT、/MD策略 |
| C4819 | 源文件编码和编译器期望不一致 | 使用/utf-8,统一源码编码 |
| “cl”不是内部或外部命令 | 未加载环境变量 | 用开发者命令提示符或执行 vcvars64.bat |
| “msbuild”不是内部或外部命令 | 未加载环境变量 | 同上 |
写在最后的一点个人经验
我自己用 MSVC 编译 C++ 的习惯,很早就从“只按 IDE 编译按钮”过渡到了“命令行验证 + 工程化工具收尾”。日常开发我还是会在 Visual Studio 里写代码,但每次遇到诡异问题,我第一反应不是盯着红波浪线看,而是打开开发者命令提示符,用最原始的 cl 命令去单独编译那个事发文件。这一招几乎能隔离掉所有“工程配置污染”问题。等你手动敲过几遍 cl、看过 MSBuild 的输出日志、跑通过一次 CMake 全流程之后,再回头看那些编译报错,你会发现自己已经能根据报错信息迅速判断是环境问题、源码问题还是链接问题,这种判断力基本都是靠踩坑换来的。
还有一个小建议:新机器装完环境,第一件事不要急着建项目,先打开开发者命令提示符,写一个含 STL、含中文输出、含多文件的小例子,分别用 cl、msbuild、cmake 各编一遍。这个流程能跑通,说明环境是健康的,后面再遇到问题你也有足够的参照系。编译 C++ 的方式虽然多,但底层逻辑就那么一套,练熟了就不会再被工具链折腾了。