1. 先把一个关键概念捋清楚:CMake不会“把.c编译成c++代码”
看到这个标题,我第一反应是:真正的问题多半不是“怎么把.c编译成cpp代码”,而是“我有一堆.c文件和.cpp文件,在Windows上用CMake怎么把它们组织起来、编译链接成能跑的程序”。这个理解上的偏差很常见,但如果不纠正,后面配置CMakeLists时十有八九要走弯路。
1.1 CMake是“构建脚本生成器”,不是编译器
CMake在工作流程里的位置很特殊。你在CMakeLists.txt里写的每一句add_executable、target_link_libraries,CMake本身并不执行编译动作,它只是先读取这些规则,然后在build目录里生成一套对应平台的原生构建脚本——比如Visual Studio的.sln/vcxproj、Ninja的build.ninja、MinGW的Makefile。之后真正把源文件变成目标文件、把目标文件链接成.exe或.dll的,是编译器(cl.exe、gcc、clang)和链接器。
用个直白的类比:CMake像包工头,只负责读图纸、分配工人和繁琐的进度表;真正搬砖的是编译器。你说“让CMake把.c变成C++代码”,相当于让包工头给工人改工种,但包工头自己不会砌墙。所以这个需求要转译一下——CMake能做到的,是精确控制某个源文件要交给哪位“工人”来处理,也就是决定它用哪一种编译语言规则。
强调这一点,是为了避免你去网上搜“c转cpp工具”“.c到cpp转换器”之类的东西。那些工具基本都是误导,因为.c文件和.cpp文件本质不是两种“语言等级”,而是同一类源代码的不同语法风格入口。编译时真正的处理逻辑,取决于编译器以哪种语言标准来解析这个文件。
1.2 .c和.cpp到底差在哪:语法规则的差异才是关键
C++在诞生时定位是“C的扩展集”,但它从来没有承诺100%兼容C。很多在C里合法、舒服的写法,到了C++编译器眼底下就是错误。最常见的有这么几个:
- C语言里
void*可以隐式转换给任何指针类型,C++里必须先显式强转。 - C允许“函数未声明就直接调用”,编译器会按隐式声明处理,C++直接报
was not declared in this scope。 - C99支持可变长度数组(VLA),C++首版标准不支持,后面对它的态度也是“可选扩展”。
- C语言的复合字面量
(struct Point){1,2}在C++里是非法语法。 - C语言不需要考虑函数重载,所以符号表里的函数名基本就是“函数名本身”;C++为了支持重载,会对函数名做一套名字修饰(name mangling)。
因此,同一个文件扩展名其实不是重点,重点是编译器按哪套语法来解析它。默认情况下,GCC/MinGW看到.c就按C标准编译,看到.cpp就按C++标准编译;MSVC的cl.exe也是类似规则。你把这个认知装进脑子,后面所有CMake配置就都好理解了。
1.3 标题背后真正想问的三种需求
我把这类问题见到的场景做个拆解,你先对号入座:
- 需求A:项目里既有C文件又有C++文件,希望它们各自按自己的语言编译,最后链接成一个程序。这是最常见的,比如引入一个开源C库,同时自己用C++写业务逻辑。
- 需求B:手里只有一个写着C++语法的源文件,但文件后缀叫
.c,希望CMake强行按C++规则处理。这种需求少见,但确实存在。 - 需求C:以为必须把C语言代码“转写成”C++,才能在C++工程里使用。这种认知是错的,读完全文你会发现根本不需要转写。
下面的章节,我按这三种需求逐一给方案。总之一句话:你要做的是在CMake里把源文件“语言”正确分配给对应编译器,而不是去转换源代码本身。
2. Windows下先把构建环境备好:CMake和编译器怎么搭配
在Windows上跑CMake,很多人第一个坑就是“装了一堆东西但配置时找不到编译器”。其实只要把一个原则记住:CMake只是壳,编译器才是干活的人,只要你安装的编译器能被CMake识别,后面就顺畅了。
2.1 Windows下两套主流编译器怎么选
Windows环境下,最常见的组合是下面这两套:
- MSVC:随Visual Studio(简称VS)一起安装。如果你用VS自带的生成器,CMake可以直接帮你生成.sln工程,然后在VS里点“生成”即可。它的优点是和Windows API、调试器集成最好;缺点是发明文模式时要在“开发人员命令提示符”里操作,否则命令行里找不到cl.exe,而且VS安装时还要勾选C++工作负载,否则CMake配置会直接报错“找不到CXX编译器”。
- MinGW-w64:开源、免费用gcc/g++编译。很多人喜欢它是图轻量,装完就能在CMD或者PowerShell里敲命令,不依赖庞大的VS。搭配Ninja或者MinGW Makefiles生成器很爽,并且对CMake基本零配置。它也方便做交叉验证,比如同时跑MSVC和MinGW两套构建检查代码写法问题。
如果只做Windows本地开发、没有跨Linux部署需求,我给的建议是:装了VS就用MSVC原生生成器,别折腾MinGW;如果只是写个几人的小工具、或者做开源维护,MinGW-w64 + Ninja会让你省很多配置成本。这个选择没有绝对对错,但你要知道CMake生成器决定了它去调用哪个编译器。
表格随手列一下:
| 编译器 | CMake生成器示例 | 适合场景 | 注意事项 |
|---|---|---|---|
| MSVC | -G "Visual Studio 17 2022" | 纯Windows桌面应用、重度调试场景 | 需提前安装C++工作负载;64位构建要加-A x64 |
| MinGW-w64 | -G "MinGW Makefiles" | 轻量项目、命令行工作流 | gcc和g++都要在PATH里,且版本最好统一 |
| Clang | -G "Ninja"搭配clang-cl | 跨平台代码排查、新特性验证 | Windows下Clang也依赖MSVC头文件和SDK |
2.2 安装与验证:命令行里确认三件事
不管选哪套,CMake本体肯定要装。从官网下载Windows安装包,安装时勾选“Add CMake to the system PATH for all users”,不然进命令行敲cmake会提示找不到命令。
装完后打开一个干净的CMD或PowerShell,依次敲:
cmake --version gcc --version g++ --version如果你用MinGW,这三条命令都能打印版本号。如果你用MSVC,直接在普通CMD里敲cl多半会失败,因为cl.exe不在PATH里。你需要先进入VS自带的“开发人员命令提示符”(Developer Command Prompt),或者在普通命令行里手动调用vcvars64.bat初始化环境。CMake的VS生成器比较特殊,它不依赖你在命令行里手动加载cl环境,它会自动去找VS安装目录,所以很多情况下你直接在CMake里指定VS生成器就能成功。
还有一个容易踩的坑:很多人电脑上同时装着多个来源的gcc,比如某个嵌入式IDE自带的MinGW、Git自带的bash工具等。先跑一下where gcc看看路径,确保你用的就是预期那个。版本混搭会让CMake缓存了A编译器后面又换B编译器,最后产生一堆诡异错误。
2.3 先跑通一个纯.c项目找找手感
我建议别上来就搞混合工程,先用一个最简单的.c单文件项目把CMake的配置——生成——构建整条链路跑通。创建目录,里面放两个文件:
main.c:
#include <stdio.h> int main(void) { printf("hello from C\n"); return 0; }CMakeLists.txt:
cmake_minimum_required(VERSION 3.16) project(HelloC LANGUAGES C) add_executable(hello_c main.c)注意project(... LANGUAGES C)里我显式声明只要C语言支持。接下来在项目目录里执行:
mkdir build cd build cmake .. cmake --build .如果你用MinGW,在cmake ..这一步可能需要追加-G "MinGW Makefiles",因为如果不加,CMake默认会选择一种本机可用生成器,很可能选到VS或者NMake,然后gcc就用不上了。用VS的情况下直接让CMake自动检测即可,我上面的命令通常能成功。
这个最小工程跑通之后,你会发现:CMake --build .底层调用的就是gcc或cl去编译main.c,根本没经过什么“转换”。你甚至可以打开build目录看看里面的产物,.c源文件对应的目标文件是.o或.obj,而不是什么.cpp。
3. C和C++混合项目的正确打开方式:CMake的LANGUAGES和符号桥接
这才是大多数人问这个标题问题的真实场景。你有现成的C源码,想在C++工程里用。正确的思路是:让C文件继续按C编译,让C++文件按C++编译,两边通过一个“符号桥”对接,而不是强行把C代码变成C++。
3.1 project()里同时声明C和CXX,少了这个会直接报错
先看一个很多人犯过的错误。工程里同时有.c和.cpp,但CMakeLists只写了project(Demo),或者只写了project(Demo LANGUAGES C),然后一配置就报类似“找不到CXX编译器,无法编译.cpp文件”的错误。
正确写法是:
cmake_minimum_required(VERSION 3.16) project(MixedDemo LANGUAGES C CXX) add_executable(mixed_demo main.cpp util.c)这里LANGUAGES C CXX表示:这个工程既需要C编译器,也需要C++编译器。CMake在配置阶段就会同时去找两套编译器。其实如果你完全省略LANGUAGES,CMake默认也会启用C和CXX,但我建议还是写清楚,因为显式声明的好处是可以避免CMake缓存一些你根本不需要的编译器探测结果,而且工程意图一目了然。
CMake是怎么决定哪个源文件用哪个编译器?很简单,看文件扩展名。.c结尾的自动交给C编译器,.cpp、.cc、.cxx结尾的自动交给C++编译器。这条路是CMake内部的默认行为,你不用额外写配置。
3.2 完整示例:C函数给C++调用,注意extern "C"
假设工程目录里有这几个文件:
util.c:
#include "util.h" int add(int a, int b) { return a + b; }util.h:
#ifndef UTIL_H #define UTIL_H #ifdef __cplusplus extern "C" { #endif int add(int a, int b); #ifdef __cplusplus } #endif #endifmain.cpp:
#include <cstdio> #include "util.h" int main() { std::printf("%d\n", add(3, 4)); return 0; }CMakeLists.txt:
cmake_minimum_required(VERSION 3.16) project(MixedDemo LANGUAGES C CXX) add_executable(mixed_demo main.cpp util.c)这里的核心在于util.h里的#ifdef __cplusplus包裹extern "C"。C++编译器在编译main.cpp时,会定义__cplusplus宏,于是它会看到这样一段声明:
extern "C" { int add(int a, int b); }这句告诉C++编译器:add这个函数的符号不要做名字修饰。前面提过,C++为了支持重载,默认会把main.cpp里对add的引用修饰成类似?add@@YAHHH@Z的符号,但util.c经过C编译器编译后,导出的符号就叫add。如果两边各修饰各的,链接时就会找不到对应符号;加上extern "C"后,main.cpp生成的引用符号就是add,于是链接器能顺利在util.obj里找到它。
在C文件自己的编译流程里,__cplusplus未定义,所以extern "C"这段根本不会出现在C编译器的解析范围里,util.c就是一份纯正的老式C代码,编译毫无负担。
踩过一次链接错误的开发者基本都能背出这个报错:MSVC下常见LNK2019 unresolved external symbol "int __cdecl add(int,int)" (?add@@YAHHH@Z),MinGW下常见undefined reference to add(int,int)。看到参数列表一起出现,就是名字修饰没对齐,优先检查extern "C"。
3.3 反向场景:C文件如何调用C++实现的函数
反过来就稍微麻烦一点。C编译器不认识extern "C"这个语法,所以不能在.c文件里直接写它。标准解法是:把C++函数包在一个纯C接口层里。
比如cpp_impl.cpp里写:
#include "cpp_impl.h" extern "C" int linked_func(int x) { // 这里可以正常使用C++特性 return x * 2; }然后在c_interface.h里用普通C风格声明:
#ifndef C_INTERFACE_H #define C_INTERFACE_H int linked_func(int x); #endif最后在main.c里直接调用linked_func,C编译器不需要理解extern "C",它只看到一个普通的函数声明。链接时,由于linked_func在定义处被extern "C"限定,导出符号就是linked_func,C编译器生成的引用符号也是linked_func,两边顺利对上。
这个模式说白了就是靠一层C接口把所有C++世界的东西挡在身后。很多开源库的商业实践都这么干,比如把C++引擎封装成C API供上层C或脚本语言调用。接口层里可以藏类、藏模板、藏STL,外面一律看不见。
3.4 生成和构建命令:VS生成器与MinGW各来一遍
配置这个混合工程,用MinGW时:
mkdir build cd build cmake .. -G "MinGW Makefiles" cmake --build .用VS 2022时,我更推荐:
mkdir build cd build cmake .. -G "Visual Studio 17 2022" -A x64 cmake --build . --config Release注意-A x64,这个参数很关键。VS默认平台是Win32,也就是32位,如果你的项目依赖64位库或者就是想构建64位程序,不指定x64会得到一堆链接错误。MinGW和Ninja组合也一样,要么默认64位环境正常,要么在工具链配置里明确架构。
构建成功后,直接在build目录下运行生成的exe。我实测这种工程,输出就是7,干净利落。这验证了一个核心事实:C文件和C++文件完全可以共存于同一个目标里,不需要把C代码翻译成C++。
4. 如果非要让一个.c文件按C++编译:使用源文件LANGUAGE属性
有些场景真的需要把.c文件按C++规则编译,比如文件内容本来就是C++语法,但名字不能改、不能动。这时可以用CMake源文件属性LANGUAGE,它允许你突破扩展名规则,强行指定编译语言。
4.1 set_source_files_properties基本用法
看这个CMakeLists:
cmake_minimum_required(VERSION 3.16) project(ForceCXX LANGUAGES CXX) add_executable(force_cpp main.cpp odd_file.c) set_source_files_properties(odd_file.c PROPERTIES LANGUAGE CXX)关键就这一行:set_source_files_properties(odd_file.c PROPERTIES LANGUAGE CXX)。它表示:这个文件虽然叫.c,但CMake在生成构建脚本时把它当作C++源文件处理,调用C++编译器,开启C++语法标准。
这套机制和在GCC命令行里直接写gcc -x c++ odd_file.c是同一个道理,本质都是覆盖“按扩展名推断语言”的默认逻辑。MSVC也一样,它本身支持用/Tp之类的开关强制按C++处理源码,只是你不需要在命令行里操心,CMake会在生成的构建脚本里处理好这些细节。
4.2 什么情况下用,什么情况下老老实实别用
我能想到的典型适用场景:
- 源代码来自第三方,内容已经写成了C++风格,但交付物约定必须叫
.c,这时只能在构建层解决。 - 构建产物需要在测试脚本里按文件名过滤,不能轻易改后缀。
- 项目里全是
.c文件但整个代码库已经“事实C++化”,写C编译器各种报错,改后缀又怕影响工具链,可以直接统一LANGUAGE CXX。
但如果你把一个语法完全C风格的文件强行按C++编译,通常会收获一大批编译错误。最容易翻车的就是void*隐式转指针、函数未声明调用、复合字面量这类写法。C编译器放行的代码,C++编译器不一定放行。所以我的建议是:能用“各按各的语言编译”解决,就别用这个属性强行统一。
4.3 反向也能用:让.cpp按C编译
LANGUAGE属性同样可以把.cpp文件指定成C语言:
set_source_files_properties(legacy.cpp PROPERTIES LANGUAGE C)这种行为一般只会在测试或者临时处理老代码时用。因为.cpp通常都用了C++语法或标准库,按C编译基本必错。但至少说明一点:扩展名不是唯一标准,CMake的LANGUAGE属性才是最终决定编译语言的主宰。
我提醒一点:如果一个目标里同时混用了默认语言和强制的LANGUAGE,而且目标本身语言声明不完整,CMake可能仍然报错。比如工程只声明了LANGUAGES C,但某个.cpp被加进add_executable,哪怕你把它的LANGUAGE设成C,也最好在project里同时声明C和CXX,避免CMake在配置阶段直接因为“没有CXX编译器”而中断。这种细节不影响最终构建,但能让你少掉几根头发。
4.4 用编译期宏验证当前文件到底按什么语言编译
当你实在不确定某个文件最终被编译器按哪种语言解析时,有个最直观的验证方法:在该源文件里写上预处理判断,编译运行后看输出。
在odd_file.c里写:
#include <stdio.h> int main(void) { #ifdef __cplusplus printf("compiled as C++\n"); #else printf("compiled as C\n"); #endif return 0; }如果你把它的LANGUAGE设成CXX,运行结果就是compiled as C++;如果没设,它就是compiled as C。这个技巧在排查“明明叫.c为什么报C++错误”或者“明明写的是C++语法为什么按C编译”时,比查任何配置都快。同理,在CMake里你也可以用get_source_file_property把当前文件的LANGUAGE值打出来:
get_source_file_property(cur_lang odd_file.c LANGUAGE) message(STATUS "odd_file.c language = ${cur_lang}")配置阶段看到CXX,说明你的强制指定生效了。
5. 常见问题与排查技巧实录
这部分是实打实的经验总结。混合编译这事,说难不难,但每个坑后面都有一个人熬夜上网搜索的背影。我把高频问题整理成清单,你遇到时可以直接照着排查。
5.1 LNK2019/undefined reference:符号找不到,先查extern "C"
这是我见过最多的错误。前面已经详细解释过原理,这里补充一点排查顺序:
- 确认C函数有没有通过头文件暴露给C++代码,而且头文件是否被C++文件include。
- 确认头文件里对C函数声明是否被
extern "C"包裹,包裹方式是否正确。 - 如果函数声明在头文件中没问题,查看目标文件里的实际符号名。MSVC下用
dumpbin /symbols util.obj,MinGW下用nm -g util.obj。看到带一堆类C++修饰符的符号,就是extern "C"没生效。
| 现象 | 典型报错 | 主要排查方向 |
|---|---|---|
| 链接时找不到函数 | LNK2019 / undefined reference | extern "C"是否缺失;是否有对应obj参与链接 |
| 编译时报语法错误 | C2664 / cannot convert | 文件可能被错误按C++或C语言解析,检查LANGUAGE |
| 配置阶段找不到编译器 | CMAKE_CXX_COMPILER not found | project是否声明CXX;VS是否安装了C++负载 |
| 莫名出现Win32/x86链接问题 | 无法解析的外部符号_xxx | 生成器是否加-A x64 |
5.2 CMake配置时报错找不到CXX编译器
如果工程里加了.cpp但project里只写了LANGUAGES C,CMake会在配置阶段报错,说找不到C或CXX编译器。但还有一种情况是什么都没写LANGUAGES,而本机只装了MinGW或者只装了VS,CMake自动探测失败了。解决办法:
- project里显式写
LANGUAGES C CXX; - MinGW用户确认gcc和g++都在PATH;
- VS用户重新运行安装器,勾选“使用C++的桌面开发”工作负载,别只装了个VS主程序就开干。
5.3 文件被错误地按另一种语言编译
典型场景:.cpp文件里用了C++标准库,但编译器报出一堆C语言风格错误,说明它被按C编译了。反过来,.c文件里全是C语法,却冒出一堆C++才有的错误,那多半是被强制LANGUAGE CXX了。
排查思路和我第4.4节说的一致:用__cplusplus宏打印确认,再用get_source_file_property看LANGUAGE。还有个小概率情形:你手动改过文件扩展名,比如把util.c改名成util.cpp,但里面的代码其实是老式C语言风格,这时它就会按C++编译并报错。文件名和代码风格不匹配,是很多人自己坑自己的原因。
5.4 Windows路径、中文注释、编码问题
Windows上CMake对路径里的中文字符和空格支持得不算完美。我建议项目和build目录都放在纯英文路径里,不要图省事放在“桌面/新建文件夹”这种位置。遇到奇怪的“无法访问文件”错误,先看看路径里有没有中文或空格。
源码文件编码也是个大坑。MSVC在默认情况下按系统代码页读源文件,如果你的源码是UTF-8无BOM且注释带中文,经常出现C4819警告甚至中文乱码。在CMake里加一行可以缓解:
if(MSVC) add_compile_options(/utf-8) endif()MinGW环境下对应的是:
if(MINGW) add_compile_options(-finput-charset=UTF-8 -fexec-charset=UTF-8) endif()我把这些选项放在混合工程里,实测中文注释和字符串输出都正常了许多。
5.5 实用速查表:目标和操作对照
| 你想做的事 | CMake写法 | 备注 |
|---|---|---|
| C文件按C编译、C++文件按C++编译 | project(x LANGUAGES C CXX) | 默认行为,无需额外属性 |
| 让C++调用C函数 | 头文件里用#ifdef __cplusplus包extern "C" | 最常使用的桥接方式 |
| 让C调用C++函数 | C++文件里定义extern "C"函数,C侧只放普通声明 | 建议封装纯C接口层 |
| 强制.c按C++编译 | set_source_files_properties(x.c PROPERTIES LANGUAGE CXX) | 少用,优先考虑改后缀 |
| 强制.cpp按C编译 | set_source_files_properties(x.cpp PROPERTIES LANGUAGE C) | 基本只用于老代码测试 |
| 查看文件当前语言 | get_source_file_property(... LANGUAGE) | 或者源文件里用__cplusplus宏 |
我个人在实际操作中体会最深的一点是:别和语言规则较劲。C代码就让它保持C的纯粹性,C++代码也不用刻意退回C风格,中间的符号桥用extern "C"架好,CMake的LANGUAGE属性和扩展名规则只是帮你把源文件准确送达给对应的编译器。把“把.c编译成c++代码”这个执念放下,换成“让.c和.cpp在同一个工程里好好协作”,问题立刻就清晰了。
最后再分享一个小技巧:如果你拿到一个来历不明的.c文件,既不确定它的语法风格,又担心放进C++工程后链接报错,先在CMake里单独把它编成一个静态库,用C编译器编译;再写一个极小的C++测试程序链接这个静态库,观察链接情况。这样能把“编译问题”和“链接问题”彻底隔离开,定位速度会快很多。