最近被一个新同学问住了:CMakeLists 里明明写的是add_executable(demo demo.c),我也在 CMake 里选了 C++ 编译器,为什么构建日志里它还是把demo.c当成 C 代码在编?这个问题的答案说穿了就一句话——CMake 决定源文件用什么语言,靠的是文件扩展名,而不是你全局指定的编译器;而 Windows 上的 MSVC 到了编译这一步,还会根据扩展名再判断一次。两层判断叠加,就形成了“我明明设了 C++ 怎么还是不行”的经典困惑。
这篇文章我就把“Windows 环境下如何让 CMake 把 .c 文件按 C++ 代码编译”完整捋一遍,包含 CMake 属性怎么改、编译器参数怎么加、完整的实操命令,以及我实际踩过的链接和编译坑。适合两类读者:一是手上有一批 C 代码、想用 C++ 编译器做严格检查做渐进迁移的;二是必须把第三方 C 库纳入 C++ 工程、但又不方便改文件后缀的人。
1. 先搞清楚源头:CMake 是怎么定语言的
1.1 扩展名映射是整个机制的地基
CMake 收到一个源文件,第一步就是识别语言。内置规则大致是:.c归 C,.cpp/.cc/.cxx/.c++归 CXX,.f/.f90归 Fortran,.cu归 CUDA。这个识别发生在project()或enable_language()阶段,它决定两件具体的事:
- 这个文件挂在哪类编译规则下,对应使用
CMAKE_C_COMPILER还是CMAKE_CXX_COMPILER; - 编译命令里插入的配置参数是
CMAKE_C_FLAGS/CMAKE_C_FLAGS_<CONFIG>,还是 CXX 对应的那套变量。
所以如果你的工程开头写的是project(foo C),那 CXX 这套编译器探测和运行时初始化根本没发生。这时候你再给某个文件盖上“CXX 语言”的章,CMake 要么报The CXX compiler identification is unknown,要么直接跟你说找不到CMAKE_CXX_COMPILER。这个现象特别容易误导人,很多人以为是语法写错了,其实根子是语言列表没配齐。
动手之前第一件事永远是自查:project()里 C 和 CXX 有没有一起列出来。这也是我在所有示例开头都写project(xxx C CXX)的原因。
1.2 第二层判断:编译器自己也认扩展名
CMake 层的语言定好之后,它只负责拼命令行并调用编译器。这时关键问题出现了:编译指令里源文件的后缀仍然是.c,没变。
MSVC 的cl.exe对后缀非常敏感——.c默认按 C 编译,.cpp默认按 C++ 编译;GCC 和 Clang 同理,也默认按扩展名选语言。这就是第二层判断。要让编译器无视扩展名、强制执行 C++ 模式,MSVC 需要加/TP(文档里也写作/Tp),GCC/Clang 需要加-x c++。
提示:
-x c++的意思是“对下一个输入文件按 C++ 处理”,/TP的意思是“本次编译调用里所有输入都按 C++ 处理”。因为 CMake 默认一个源文件对应一次编译调用,这两个参数在单文件场景下效果完全一样。
“两层判断”是这篇文章最核心的认知模型。很多时候你改了 CMake 的LANGUAGE属性发现没效果,不是没生效,而是第二层没打通。下面说的解法,本质就是把这两层都安排明白。
2. 核心解法:一个属性加一个参数
2.1 第一步:把源文件的 LANGUAGE 属性改成 CXX
CMake 每个源文件都有一堆属性,其中LANGUAGE专门控制这个文件按哪门语言参与构建。改法有两种,完全等价:
set_source_files_properties(demo.c PROPERTIES LANGUAGE CXX)set_property(SOURCE demo.c PROPERTY LANGUAGE CXX)第一种是传统写法,出现的频率最高;第二种是更现代的写法,语义更清晰。两者都行,我习惯用第一种,因为这个宏还可以顺带设别的源文件属性。
执行完这条之后,CMake 内部会把这个文件从 C 的构建规则挪到 CXX 的构建规则:编译器换成了 C++ 编译器,编译参数也换成 CXX 那套。但如果到此为止,你大概率会遇到“看起来没生效”的状态——因为编译器那边还认着.c后缀。
2.2 第二步:给编译器加“强制 C++”的参数
这一步必须在 CMake 里区分编译器类型,Windows 上主要两派:
if(MSVC) set_source_files_properties(demo.c PROPERTIES COMPILE_FLAGS "/TP") else() set_source_files_properties(demo.c PROPERTIES COMPILE_FLAGS "-x c++") endif()这里特意用COMPILE_FLAGS这个源文件属性,而不是target_compile_options,原因很实在:它只往demo.c这一条编译命令后面追加参数,不会波及目标里其他源文件。如果目标里还有正常的.c文件要保持 C 编译,这个隔离就非常重要。
COMPILE_FLAGS在官方文档里被标记为 deprecated,但目前所有主流 CMake 版本都还在支持。偏好新语法的同学可以用生成表达式来实现同样效果:
target_compile_options(demo PRIVATE "$<$<COMPILE_LANGUAGE:CXX>:/TP>")意思很直白:只要文件是 CXX 语言,就给它加/TP。因为已经强行归到 CXX 的文件本身就满足这个条件,所以效果等价,而且这行代码还能在只有 MSVC 目标里安全复用。缺点是要理解生成表达式的写法,新手看着容易发怵。
2.3 别忘了 project() 的语言列表
整套配置里最容易漏的就是这个。很多人直接在原来的project(foo C)后面补代码,结果怎么改都不出正确结果。正确做法是把语言列表补齐:
project(foo C CXX)如果因为某些原因不方便改project(),也可以单独加一句enable_language(CXX)。这一步的本质是把 C++ 编译器探测提前到 configure 阶段,后头所有跟 CXX 有关的宏和变量才能用。
3. 完整实操:一个 Windows 下可以直接复现的示例
3.1 准备一个“故意用 C++ 语法”的 demo.c
为了验证“真的按 C++ 编了”,我会刻意在一个后缀为.c的文件里写 C++ 的类。如果它能通过编译,说明两层判断都被正确绕过;如果哪一层没设置,编译必然爆错。这个验证思路比看日志更过硬。
// demo.c —— 故意写成 .c 后缀,但里面使用了 C++ 语法 #include <iostream> class Counter { public: Counter() : count_(0) {} void add(int n) { count_ += n; } int get() const { return count_; } private: int count_; }; int main() { Counter c; c.add(5); c.add(7); std::cout << "counter = " << c.get() << std::endl; return 0; }别担心,实际项目里你要编译的 .c 文件大概率没有这么“C++”,这个例子只是为了制造一个强验证条件。
3.2 编写 CMakeLists.txt
cmake_minimum_required(VERSION 3.16) project(cpp_from_c_demo C CXX) add_executable(demo demo.c) # 第 1 步:告诉 CMake 这个 .c 文件按 CXX 语言处理 set_source_files_properties(demo.c PROPERTIES LANGUAGE CXX) # 第 2 步:告诉编译器无视扩展名,强制按 C++ 编译 if(MSVC) set_source_files_properties(demo.c PROPERTIES COMPILE_FLAGS "/TP") else() set_source_files_properties(demo.c PROPERTIES COMPILE_FLAGS "-x c++") endif()这里有个细节值得说:add_executable(demo demo.c)放在改属性之前,顺序没问题,因为 CMake 对源文件语言的最终判定发生在生成构建规则的阶段,而不是add_executable那一行。你完全可以先写add_executable,再统一改属性。
3.3 编译:VS 生成器与 Ninja 两条线路
Windows 上主流的构建方式有两种。先看 Visual Studio 生成器:
cmake -S . -B build -G "Visual Studio 17 2022" -A x64 cmake --build build --config Release如果用 Ninja,需要在“适用于 VS 的开发者命令行”里先把环境变量导进来,再执行:
cmake -S . -B build-ninja -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build-ninja如果你用的是 MinGW-w64 那套工具链,把生成器换掉即可:
cmake -S . -B build-mingw -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release cmake --build build-mingw我实测下来,这套 CMakeLists 在这三条线路上都能编过,运行demo.exe会输出counter = 12。
3.4 怎么确认真的按 C++ 编译了
构建时加--verbose直接看命令行,是最快的确认方式:
cmake --build build --config Release --verboseVS 生成器下你会看到类似:
cl.exe /nologo /TP /utf-8 ... /I... /c demo.c /Fodemo.c.objMinGW/Ninja 下会看到:
g++ ... -x c++ ... demo.c只要命令行里同时出现 C++ 编译器和后缀为.c的源文件,再加上/TP或-x c++,就说明两层都打通了。
顺便说一个反向验证小技巧:把 CMakeLists 里第 2 步的/TP或-x c++注释掉,重新构建。这时demo.c里的class会触发 C 语法错误,比如 MSVC 报error C2059: syntax error: 'class',GCC 报expected '=', ',', ';', 'asm' or '__attribute__' before 'Counter'。这一报错反而是好消息——它帮你确认了 CMake 把文件交给了 C++ 编译器,只是编译器还在按 C 解释。把参数加回去,问题立刻消失。
4. 进阶:把整个 C 库用 C++ 编译器过一遍
4.1 一个很实用的模式:C/C++ 双编译体检
理解了单文件玩法,就能玩一个更高级的套路:同一个 C 库源码,同时生成 C 版本和 C++ 版本的可执行文件。这在团队里有个讲法叫“C++ 兼容性体检”——用 C++ 编译器的严格规则把 C 代码里的隐患全部揪出来,但正式交付仍然用 C 版本。
cmake_minimum_required(VERSION 3.16) project(c_check C CXX) add_library(core STATIC core.c util.c) # 把同一份 C 源码编成一个“C++ 体检目标” add_executable(core_cpp_check core.c util.c) set_source_files_properties(core.c util.c PROPERTIES LANGUAGE CXX) if(MSVC) target_compile_options(core_cpp_check PRIVATE /TP) else() target_compile_options(core_cpp_check PRIVATE "-x c++") endif()注意这里我改用了target_compile_options,而不是逐文件设COMPILE_FLAGS。理由是这样更省事,而且core_cpp_check里的源文件全部被盖过 CXX 章,-x c++对它们一视同仁,不会误伤谁。假如目标里混着没盖章的.c文件,那就要谨慎了——-x c++会让 C 编译器也去按 C++ 理解源码,反而乱了套。
4.2 extern "C" 与名字修饰不一致的坑
把.c文件编成 C++,副作用是里面的全局函数默认会被做 C++ 名字修饰(name mangling)。C 语言没有修饰,函数在符号表里就叫foo;C++ 会把它变成类似?foo@@YAHH@Z的怪名字。
麻烦的是,如果你的头文件里写了extern "C"的声明,但源文件里没有配合:
- 头文件声明
extern "C" int foo(int);,其他.cpp文件按 C 链接去找foo; - 而
foo.c编成 C++ 时,定义却按 C++ 修饰成了带装饰符的名字。
两边对不上,MSVC 链接器报LNK2019 unresolved external symbol,GCC/Clang 报undefined reference to 'foo'。排查的方法很直接:看头文件里extern "C"包裹的是声明还是定义,确保所有翻译单元对“这个符号到底是什么链接属性”的认识一致。
有一种常见情况反而没问题:很多 C 库的头文件自带兼容保护:
#ifdef __cplusplus extern "C" { #endif int foo(int); #ifdef __cplusplus } #endif当foo.c被 C++ 编译时,宏__cplusplus被定义,头文件里声明恰好被extern "C"包住,于是定义也变成 C 链接。这时候foo在符号表里就叫foo,跟其他 C 文件调用方完全一致,链接不会出错。所以我的经验是:优先保证头文件的兼容保护是完备的,再谈强制 C++ 编译。
4.3 C 代码在 C++ 编译器下的典型报错清单
有些 C 代码搬到 C++ 下不是链接问题,而是直接编译不过。我把最常见的几类整理成了一张表,照着改就行:
| C 代码常见写法 | C++ 编译器的反应 | 修改建议 |
|---|---|---|
void *p = malloc(100); | 报错:C++ 不允许隐式void*转其他指针 | 改成int *p = (int*)malloc(100);或用 C++ 的new |
C99 指定初始化{.x = 1, .y = 2} | C++20 之前直接报错,之后也只是部分支持 | 改用构造函数或逐字段赋值 |
旧式函数定义int f(a) int a; | 直接报语法错误 | 改成标准原型int f(int a) |
| 隐式函数声明,调用前没声明 | C++ 里是硬错误 | 补头文件或前向声明 |
使用restrict/_Bool关键字 | 不支持或行为不同 | 用宏定义一层兼容层 |
这不是说所有 C 库都能无痛切到 C++ 编译。我的建议是,如果库的代码量大、C 特性用得多,先跑一次体检看看报错清单再决定要不要推广这种做法,别一上来就把整个库都切过去。
5. 踩坑实录:这些问题我基本都撞过
5.1 忘了加 /TP,编译命令看着已经是 C++ 但还在按 C 编
这是新手最容易卡住的地方。--verbose里明明看到了cl.exe,就以为大功告成,结果demo.c里一写 C++ 语法就报 C 错误。原因前面已经说透:cl.exe认后缀,.c就默认按 C 编。对策就是必加/TP或-x c++,这一行跟LANGUAGE CXX是配套的,缺一不可。
5.2 只改属性没启用 CXX,configure 阶段直接失败
我在 2.3 节强调过project(foo C CXX),但实际项目里总有人忘了同步修改。症状是cmake配置到一半报错,提示找不到 CXX 编译器或语言未启用。这类问题最容易在旧 CMake 版本上出现,因为有些版本不会自动补齐语言,直接给你难看的错误。改法就一行:语言列表加 CXX。
5.3 静态库被纯 C 目标链接时缺 C++ 运行时
还有一个隐蔽场景:你把一个静态库里的.c全部改成按 C++ 编译,库本身编译通过,但下游有个纯 C 可执行程序链它,链接器突然报一堆找不到operator new、std::cout之类的符号。原因很简单:库的目标文件是用 C++ 编译器生成的,里面的代码引用了 C++ 运行时库,而下游 C 目标只按 C 来组织链接。
解决思路有两个,选一个即可:
- 给下游目标也创建为 C++ 语言,哪怕它的源文件全是
.c,也照前面方法设置LANGUAGE CXX; - 或者显式指定库的
LINKER_LANGUAGE:
set_target_properties(core PROPERTIES LINKER_LANGUAGE CXX)这个属性能强制 CMake 在链接环节使用 C++ 链接器和运行库,算是处理混合语言工程的常用后手。
5.4 COMPILE_FLAGS 被弃用后的迁移思路
COMPILE_FLAGS在文档里被打上了 deprecation 标记。我个人的建议是:新项目直接用生成表达式版本,一行搞定,不再依赖这个旧属性:
if(MSVC) target_compile_options(demo PRIVATE "$<$<COMPILE_LANGUAGE:CXX>:/TP>") else() target_compile_options(demo PRIVATE "$<$<COMPILE_LANGUAGE:CXX>:-x c++>") endif()需要留意的是-x c++作为两个独立参数传给编译器是符合 GCC/Clang 预期的,CMake 会把 options 按空格拆开,因此-x和c++会以两个参数的形式送进命令行,行为正确。而旧项目里只要COMPILE_FLAGS还在工作,也不必急着改,知道有这么一回事就行。
5.5 顺手记一个调试思路:先分离变量再排查
遇到编译或者链接异常,我的排查顺序固定是:先确认 configure 阶段语言有没有启用,再确认生成的命令行里编译器类型对不对,再确认有没有强制语言参数,最后才去查源码里的extern "C"和 C 语法。这个顺序是从“两层判断”模型推出来的,按这个走,九成问题都能在两轮以内定位。
从我个人实际操作中得来的体会是:把.c文件当 C++ 编译这件事,真正的难点不在 CMake 语法,而在你心里有没有“两层判断”这张图。CMake 管语言归属,编译器管语法模式,两层都打通,剩下的就是源码里那些 C/C++ 语法差异了。如果你只是临时需要验证某个库的兼容性,我建议直接照 4.1 的“双编译体检”模式来,成本最低,收益最直观,还不影响正式交付版本的构建链路。