Windows 上用 CMake 做 C/C 混合项目这个问题隔三岔五就会有人问一次.c文件到底能不能在 CMake 里直接编译成 C 代码。我最初遇到它是接手一个遗留算法库的时候核心文件全是一堆.c主程序却早就全面 C 化了新模块全是类、模板和智能指针唯独老算法文件绕不过去。当时我想着“这还不简单改个扩展名”结果被链接器教育了一下午。这个问题的表面是一个 CMake 语法疑问实际拆开至少有三层CMake 如何根据扩展名决定编译语言、如何让编译器把.c文件当 C 来编译、以及编译通过后链接层怎么处理符号和运行库。只解决第一层后面必定连环踩坑。这篇文章我会把三层都写清楚适合被混合语言项目折磨的 C 工程师也适合刚接触 CMake 的读者把它当一份可操作的排查手册。1. 先把机理讲透编译器认扩展名CMake 只是传令兵1.1 一条.c文件进入 CMake 之后发生了什么CMake 本身不编译任何东西它只是根据你写的 target 描述生成构建系统真正执行编译任务的是后端工具链。而“用什么编译器、开什么语言开关”完全取决于 CMake 对源文件“语言”的默认判断。这个判断标准就是扩展名。.c默认归 C 语言.cpp、.cc、.cxx默认归 C 语言.asm归汇编.rc归资源编译器。拿 Windows 上最常见的 Visual Studio 生成器举例.c文件最终会进入cl.exe的命令行并被附加/TC参数意思是“强制按 C 规则编译”.cpp文件则附加/TP参数意思是“强制按 C 规则编译”。这里的/TC、/TP是 MSVC 自带的语言强制开关CMake 只是根据扩展名帮你把对应开关挂了上去。MinGW/GCC 这边同理gcc驱动编译.c时走 C 规则g驱动编译.cpp时走 C 规则。所以核心矛盾就出现了——你希望“某个.c文件使用 C 语法与 C 编译器来处理”但 CMake 默认按 C 对待它编译器也就按 C 规则处理于是文件里只要出现 C 关键字、类、重载、模板就会编译失败。只理解到这一层还不够。编译器选定语言之后还会连带影响符号修饰规则、异常处理、RTTI、运行库依赖这些通常在链接阶段爆发。第三章我会专门展开。1.2 什么场景下你才会非要这么做我在不同项目里见到的需求基本逃不出三类。第一类老 C 代码模块想直接进驻 C 工程。比如一套算法库、协议栈整体迁到 C 成本太高但新代码全用 C干脆让老文件也吃 C 编译器的严格检查。第二类第三方只给.c源码而你的整个工程默认就是 C 项目构建脚本统一到一个体系里又不想额外抽一层接口。第三类最尴尬文件内容本来就是 C 写的只是当初被保存成了.c扩展名或者从某个源码包里拖出来时扩展名就是错的。大多数情况下我不推荐真去“改写法”更理想的方案是 C 文件保持 C 编译、外面包一个 C 适配层这一点在第 6 章细说。但如果你需要快速让单个文件按 C 处理或者要评估一个老 C 模块向 C 迁移的可行性下面这些方法就是现成的工具箱。2. 三种让 CMake 把.c交给 C 编译器的写法2.1 首选方案set_source_files_properties(... LANGUAGE CXX)这是 CMake 语义下最正规、最精准的写法核心就一行set_source_files_properties(math_ops.c PROPERTIES LANGUAGE CXX)它的作用是把源文件math_ops.c的LANGUAGE属性设置成CXX。设置之后无论扩展名是什么CMake 在生成构建系统时都会把这个文件分配到 C 编译器那一组。对这个文件来说MSVC 的命令行会从/TC变成/TPGCC 系会直接调用g来编译它。为什么把这作为首选因为它不依赖某个编译器的私有开关跨生成器、跨编译器都好使。无论你用 Visual Studio 生成器、Ninja 还是传统 Makefile无论底层是 MSVC、MinGW-GCC 还是 Clang-cl这一行的语义都是一致的。而且它只影响目标文件本身不波及其他.c文件这是全局开关做不到的。有两个细节要注意。第一生成这个 target 的project()里必须启用了 CXX 语言否则生成阶段会直接报错。最稳妥的写法是project(MyDemo LANGUAGES C CXX)如果手头已经写了project(...)或project(... C)可以在后面补一行enable_language(CXX)。第二这行建议放在add_executable之前保持“先声明文件属性、再组装 target”的顺序可读性更好虽然放在后面也能生效。2.2 全局方案MSVC 的/TP与 GCC 的-x c有些人想省事希望目标里所有.c文件全部按 C 编译。MSVC 下的全局开关就是/TPif(MSVC) add_compile_options(/TP) endif()加完以后目标内所有编译命令末尾都会追加/TP效果等同于把所有.c、.cpp统一按 C 处理。如果你的项目畸形到“所有源文件本来就应该按 C 编译”只是扩展名写错了那这个方案没问题。但绝大多数情况下全局开关是危险的尤其是 MinGW/GCC 环境下的-x c参数add_compile_options(-x c)GCC 的-x与 MSVC 的/TP语义不一样。/TP是针对一次编译器调用里的输入文件统一按 C 处理-x c更像一个语言覆盖状态一旦开启后续所有输入文件都按指定语言处理。CMake 会把选项均匀附加到全部编译命令上导致所有.c和.cpp全被强制按 C 处理。如果里面还混着必须保留 C 编译的文件你就得想办法在后续某条命令里穿插-x c来复位而 CMake 并不保证附加选项顺序可控非常容易踩坑。所以我的建议很明确需要全局批量处理时别用编译器选项用 CMake 属性批量设置更安全。比如把src目录下所有.c统一设置为 CXXfile(GLOB_RECURSE C_FILES src/*.c) set_source_files_properties(${C_FILES} PROPERTIES LANGUAGE CXX)效果等价于全局但管辖范围清晰以后收缩到具体目录也好改。2.3 复制一份.cpp最暴力的 Plan C最后一种人尽皆知的办法手工把.c改名为.cpp。它在小规模评估时很快但我不建议直接改原文件否则版本历史会留下一大坨 rename 噪音改动语义被淹没。更温和的做法是复制一份出来专门用于评估set(GENERATED_CPP ${CMAKE_CURRENT_BINARY_DIR}/generated/math_ops_ctor.cpp) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/math_ops.c ${GENERATED_CPP} COPYONLY) add_executable(demo main.cpp ${GENERATED_CPP})复制成.cpp之后编译器按 C 语法检查立刻全面铺开C 代码里任何隐式void*转换、旧式函数声明、缺失类型都会变成错误。这本质上是一场高效的“兼容性体检”但只能当体检工具不能当长期维护方案。源文件与复制品会失同步谁改了谁都说不清。三种方法的取舍我整理成一张表方案影响范围跨工具链长期维护性适合场景set_source_files_properties LANGUAGE CXX单个/批量精确控制很好好生产环境/TP或-x c目标内全部源文件一般差临时救火改名.cpp/ 复制.cpp单文件好差一次性兼容性体检3. 编译过了别急着高兴链接层的四个隐形杀手3.1 头文件里的extern C与你的目标正好相反这是把.c按 C 编译之后遇到的第一道坎。老 C 代码的头文件通常都带这套模板#ifdef __cplusplus extern C { #endif int add_int(int a, int b); #ifdef __cplusplus } #endif头文件的本意是当它被 C 文件包含时告诉 C 编译器这些函数按 C 链接约定去外部找符号。注意这里的extern C只管调用侧。问题来了你把math_ops.c设置成LANGUAGE CXX后这个文件的定义侧也按 C 编译了。编译器处理math_ops.c时会为add_int生成 C 修饰后的符号名类似?add_intYAHHHZ而main.cpp通过头文件里的extern C到外部查找的却是_add_int。两个符号对不上链接器直接报 LNK2019无法解析的外部符号。如果这个.c没有设置成 CXX、仍然按 C 编译符号名是_add_int反而能对上一切正常。很多人反复试都看不出问题在哪。解决办法只有两条路取决于你真正要什么如果只是让这个.c文件内部享受 C 能力且模块不需要对外暴露 C 接口那就把头文件里的extern C去掉函数声明按普通 C 链接处理如果这个模块必须保留 C ABI 给其他 C 代码调用那就不要把它按 C 编译正确做法是保持 C 编译另写一层 C 封装。3.2 CRT 运行库模式不一致引发的各种 LNK2038Windows 下 MSVC 的运行时分为静态/动态、Debug/Release 四套/MT、/MTd、/MD、/MDd。一个进程内所有编译单元的运行库设置必须兼容否则会冒出一堆莫名其妙的链接错误报错文本里根本看不出是这里出了问题。CMake 在 Windows 上默认会根据生成器与配置自动选择通常 Release 配/MD、Debug 配/MDd。如果你在某处手工改了CMAKE_CXX_FLAGS_RELEASE把/MD换成/MT而另一个库还留在/MD链接阶段就会冒出诸如 LNK2038 mismatch detected for RuntimeLibrary 的错误。C 代码直接依赖 CRLC 代码又依赖标准库这种冲突在混合语言工程里出现的频率比纯 C 工程高得多。要避免这类问题建议统一走 CMake 的运行时库变量。CMake 3.15 及以上可以在 MSVC 工具链下这样设置if(MSVC) set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:DebugDLL) endif()这行的效果是静态编译时用/MDDebug 配置自动加d变成/MDd整个 target 保持同一套动态运行时。除非交付要求里明确指定静态链接否则不要在一个混合语言 target 里混用 MT 和 MD。3.3 入口点与 C 运行库依赖纯 C 程序被悄悄“升级”还有一层隐藏较深的坑。假设你的最终可执行文件原本是一个纯 C 程序由main.c构成你会下意识觉得它只需要 CRT 就够了。但一旦某个静态库里有一两个.c被你设置成了LANGUAGE CXX这些文件编译出的目标文件可能带上 C 运行时依赖链接时 MSVC 会把 C 标准库也一起拽进来。这时如果最外层的 CMakeLists 没有启用 CXX 语言就可能直接报错说找不到某些 C 入口或启动函数。解决办法很简单project()里老老实实声明LANGUAGES C CXX。这也解释了为什么前面反复强调语言声明别偷懒——宁可多声明一个语言也不要让链接器在半路替你推断。如果可执行文件入口是main()按 C 编译后微软链接器依然能识别通常不需要手动指定入口点。只有当你用/ENTRY手工指定自定义入口点、且那个入口是纯 C 代码时才需要额外小心。3.4windows.h带来的 C 行为差异把.c按 C 编译等同于把所有 C 代码放到 C 编译器的聚光灯下Windows 头文件是重灾区。最常见的是min/max宏。windows.h会定义min和max两个宏而 C 代码里可能有std::max、std::min这些标准库名字预处理阶段直接展开报出一堆让人摸不着头脑的语法错误。解法是老规矩if(MSVC) target_compile_definitions(demo PRIVATE NOMINMAX) endif()其次是类型转换。C 语言允许void*到任意对象指针的隐式转换很多 Windows API 和旧代码依赖这个行为C 不允许必须显式强转。很多老代码顺手就写void* p SomeApi(...)按 C 编译必错。这一点在第五章会演示到但这里先打个预防针你要做好给一堆 C 风格隐式转换补强转的思想准备。4. 一个开箱即用的完整示例从源码构建到 Windows 下运行4.1 目录与源码准备我搭一个最小可运行的示例目录结构如下demo/ ├─ CMakeLists.txt ├─ math_ops.c ├─ math_ops.h └─ main.cpp其中math_ops.c按老派 C 风格写两个函数故意保留了需要强转的malloc调用方便你观察 C 编译器的诊断信息#include math_ops.h #include stdlib.h int add_int(int a, int b) { return a b; } int *make_array(size_t n) { return (int *)malloc(n * sizeof(int)); }头文件math_ops.h按老 C 模块习惯带上extern C模板也就是 3.1 讲的“坑源”先原样保留#pragma once #include stddef.h #ifdef __cplusplus extern C { #endif int add_int(int a, int b); int *make_array(size_t n); #ifdef __cplusplus } #endifmain.cpp则是标准 C 入口#include cstdlib #include iostream #include math_ops.h int main() { int sum add_int(2, 3); std::cout sum sum std::endl; int *values make_array(4); if (values) { values[0] 7; std::cout values[0] values[0] std::endl; } free(values); return 0; }这里make_array返回的内存由malloc分配释放继续用free没有混用new/delete避免引入另一类问题。4.2CMakeLists.txt逐行拆解完整的CMakeLists.txtcmake_minimum_required(VERSION 3.16) project(C2CppDemo LANGUAGES C CXX) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set_source_files_properties(math_ops.c PROPERTIES LANGUAGE CXX) add_executable(demo main.cpp math_ops.c ) if(MSVC) target_compile_definitions(demo PRIVATE _CRT_SECURE_NO_WARNINGS NOMINMAX) endif()逐行看project(C2CppDemo LANGUAGES C CXX)声明了 C 与 C 双语言支持。这里不能省否则后面的LANGUAGE CXX设置没有语言可用set(CMAKE_EXPORT_COMPILE_COMMANDS ON)在 Ninja 或 Makefile 生成器下会导出compile_commands.json方便后续验证编译命令。Visual Studio 生成器不生成这个文件用 VS 的人可以直接跳过这一行set_source_files_properties(...)就是 2.1 的关键一行把math_ops.c的语言覆盖成 CXXadd_executable正常组装不需要对源文件做扩展名改动MSVC 下的两个宏一个抑制常见安全告警一个关掉windows.h的min/max宏属于 Windows 工程的基础卫生措施。4.3 两次构建先复现链接坑再修正通过在 Windows 上先用 Ninja 或 Visual Studio 生成器构建一次cmake -S . -B build -G Visual Studio 17 2022 -A x64 cmake --build build --config Release如果你的环境和这里一致第一次构建很可能卡在链接阶段。原因就是math_ops.c已经按 C 编译出 C 修饰符号而main.cpp通过math_ops.h的extern C声明去找 C 修饰符号。典型报错是demo.obj : error LNK2019: unresolved external symbol int __cdecl add_int(int,int) referenced in function main复现这个错误本身就是理解 3.1 最好的实验不用怕。既然math_ops.c已经是 C 编译单元模块内部又不需要对外提供 C ABI那就把头文件里的extern C模板去掉改成#pragma once #include stddef.h int add_int(int a, int b); int *make_array(size_t n);重新构建程序就能跑通sum 5 values[0] 7这个示例虽小却完整走了一遍“编译语言切换”与“链接符号匹配”的全链路。实际项目卡住的地方和这个最小例子高度同构。5. 我实际遇到的三个编译错误与排查思路5.1 类型不兼容只是一角C 与 C 的典型语法差异设置成 CXX 之后编译器会拿 C 标准来审阅每一个文件。老 C 代码如果用了这些写法会立刻变成错误或警告void*到任意对象指针的隐式转换C 合法C 必须显式强转旧风格函数声明比如int foo();在 C 里表示“参数未知”在 C 里表示“无参数”枚举与整数之间的隐式转换C 约束更严尤其是 C11 以后的强枚举类型零长度数组int a[0];C 的 GNU 扩展里常用C 标准不支持通常需要用柔性数组方案重写结构体成员名与全局函数名重名时C 的处理规则更加敏感。看这些内容确实烦但换个角度这正是 C 编译器帮你做“代码体检”这些语法在 C 工程里继续留存的成本比修复更高。遇到一条改一条比最后运行时出错再排查要容易得多。5.2 LNK2019 与 LNK2005 的定位思路链接错误最常见的是 LNK2019无法解析的外部符号和 LNK2005符号重复定义。我自己的排查顺序一直是先确认符号类型再查文件归属最后看头文件包含路径。Windows 下可以用dumpbin查看目标文件的符号比如dumpbin /symbols build/CMakeFiles/demo.dir/math_ops.c.obj重点看add_int那几行。C 编译产物会显示类似?add_intYAHHHZ的修饰名C 编译产物则显示_add_int。看到修饰名基本就能锁定定义侧是 C 符号声明侧还在找 C 符号问题出在extern C的约定不匹配。LNK2005 则多半是头文件里写了函数实现或全局变量定义被多个.c/.cpp单元包含之后就重复了。修法不是删代码而是把实现挪到源文件里头文件只留声明或者给内部符号加static限定。5.3 别忽视预处理宏对 C 函数的干扰把windows.h纳入 C 工程时min/max宏会在任何用到std::min、std::max的文件里爆炸。这类错误特别难看比如error C2059: syntax error: (你看着是在标准库头文件内部报错其实祸根在windows.h。解决办法就是全局加NOMINMAX如果还想顺手加快编译速度可以再加WIN32_LEAN_AND_MEANtarget_compile_definitions(demo PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX)WIN32_LEAN_AND_MEAN能把windows.h里用不到的网络、加密、进程间通信等头文件排除掉减少宏污染面。这个习惯我建议所有 Windows 下的 C 工程默认都带上。6. 我的建议什么场景别硬转以及更稳的过渡方案6.1 硬转不是银弹要对外提供 C ABI 时别碰LANGUAGE CXX把.c设置成 CXX坏处是它会改变函数的外部符号。如果这个模块要作为 C 接口库交付给其他 C 程序链接C 修饰过的符号完全不符合 C ABI对方一调就是链接错误。这时候硬转等于给自己制造一个不可能通过的验收。类似的如果源码依赖 GNU C 的私有扩展、大量使用 C 风格宏技巧、或者有编译器特定的布局假设先搬到 C 编译器下面等同于做一次全面重构成本和风险会迅速膨胀。正确思路是老 C 模块保持 C 编译外面写一层薄薄的 C 包装类例如class MathOps { public: int add(int a, int b) { return add_int(a, b); } };把 C 函数包进 C 接口。这样 C 代码一动不动C 侧获得类型安全和 RAII 便利链接器也不会闹事。6.2 渐进式迁移路径把 CXX 当探照灯而不是终点如果你确实想把整个老 C 模块逐步 C 化我的经验是把它当成渐进迁移项目而不是一个开关动作。第一步只把目标文件加上LANGUAGE CXX让它触发编译诊断先不急着引入 C 特性。第二步修复显式强转、函数声明等语法差异让它在 C 编译器下通过。第三步逐个函数用现代 C 重构比如用std::vector替代手写数组用std::unique_ptr管理malloc/free对。第四步等所有 C 代码都被 C 等价物替换完再回头清理头文件里的extern C。每走一步都保持构建绿灯。这个思路可以避免“一次性跳悬崖”也能精确回答“老 C 库到底能不能融进新工程”——能但需要按步骤来不是在 CMake 里加一行属性就结束了。6.3 一个特别有用的验证习惯查看实际编译命令最后分享一个我常用来验证“到底有没有按 C 编译”的技巧构建完成后查看编译命令实参。在 CMakeLists 里打开set(CMAKE_EXPORT_COMPILE_COMMANDS ON)Ninja 或 Makefile 生成器会在 build 目录生成compile_commands.json。搜索math_ops.c那一行看开头用的编译器出现g、clang或者 MSVC 命令行里出现/TP说明LANGUAGE CXX确实生效了。如果看到的是gcc、cl且没有/TP那就还是 C 模式说明某个地方覆盖了你的设置。这里有个坑要提醒Visual Studio 生成器不生成compile_commands.json。如果你用的是 VS 生成器可以用cmake --build build --verbose查看带参数的命令行或者本地装一个 Ninja 专门用于“语言验证”。我在实际项目里经常同时配 VS 和 Ninja 两种生成器Ninja 用来快速构建和查编译参数Visual Studio 留给需要 IDE 调试的同事。两份生成器共用同一份 CMakeLists语言设置一致也就不会再出现“在我机器上明明按 C 编译了”的争论。养成看编译实参而不是只看 CMake 变量的习惯很多玄学问题都会瞬间变得清楚。