☰
CMake构建配置:正确添加.c和.h源文件与头文件路径
2026/10/20 4:39:04 网站建设 项目流程

很多刚开始接触CMake的C/C++开发者,第一道坎往往不是语法本身,而是“我明明把新写的.c文件放进文件夹了,为什么编译不过?”或者“头文件也加了,但还是报找不到文件”。这类问题在嵌入式开发、桌面工具、算法Demo里反复出现,看起来小,排查起来却很费时间,尤其是当项目里文件数量多、目录层级深的时候,你根本分不清是哪个环节出了问题。

这篇文章想解决的,就是“如何正确把.c文件和.h文件添加到CMakeLists.txt”这一件事。我会从最基本的写法讲起,再聊到自动收集文件的GLOB方式、头文件搜索路径的配置,最后整理几个我实际踩过的坑。适用人群主要是刚开始接触CMake的C/C++开发者,也适合嵌入式、桌面工具和算法Demo类的项目参考。

1. 源文件清单:构建系统眼中的“食材清单”

1.1 为什么CMake只编译你列出的文件?

先把最核心的概念讲清楚:CMake并不是编译器,它是构建系统的生成器。它读取CMakeLists.txt里的描述,生成对应的构建脚本——可能是Makefile、Ninja文件,也可能是某IDE的工程文件——然后真正的编译动作由编译器完成。编译器编译哪些源文件,完全取决于CMakeLists.txt里列出的源文件清单。

我用一个生活化类比:做一顿饭,第一步是确定食材。CMakeLists.txt里的源文件列表就是食材清单。你把菜放进厨房(把文件放进项目目录),但如果没有告诉厨师“这道菜用这棵菜”,厨师根本不会碰它。很多朋友的困惑就在这里——文件明明在目录里,IDE里也看得到,但编译出来的程序完全不带它的功能,原因往往就是:这个.c文件压根没被写进CMakeLists.txt。

所以我建议所有刚接触CMake的朋友先把这句话刻在脑子里:源文件必须显式出现在某个target(add_executable或add_library)的文件列表里,才会被编译。这不是“最好这么写”,而是“必须这么写”。

1.2 头文件在构建流程中的角色和容易混淆的点

关于头文件,先说一个容易混淆的点:头文件(.h)通常不是编译单元,它不直接作为编译器的输入。编译的真正单位是.c/.cpp文件,头文件是被这些源文件通过#include引用的。所以在构建层面,头文件并不会被“编译”。但这不意味着你可以在CMakeLists.txt里完全忽略它。

我通常建议的做法是:把自己维护的、对外开放的.h文件也列进源文件列表。目的有两个。第一,IDE会扫描源文件列表并建立索引,头文件列出来后,在工程视图里可见,跳转、自动补全、重构都能正常工作;第二,头文件列表是模块接口的直观文档,维护者一眼就能看出这个模块暴露了哪些API。

这里要特别强调:把头文件列出来并不等于编译器能找得到它。要让编译器在遇到#include "xxx.h"时正确找到文件,还需要配置头文件搜索路径。这两个动作是两个维度,少了哪一个都会出问题。路径配置的问题,我会在第4章单独展开。

1.3 动手之前,先确认项目结构和当前目录

在往CMakeLists.txt里写路径之前,我建议花30秒确认两件事:项目里究竟有几个CMakeLists.txt,以及源文件和头文件放在哪些目录。

常见结构有两种。一种是单目录扁平结构,所有.c和.h跟CMakeLists.txt放在同一个目录里,这时候路径写法最简单,直接写文件名就行。另一种是多目录层级结构,比如src/放源文件,include/放公开头文件,test/放测试代码,甚至每个模块还有自己的子目录。这时就必须搞清楚一件事:CMakeLists.txt里写相对路径,基准是“当前CMakeLists.txt所在的目录”,而不是项目根目录,也不是你运行cmake命令时所在的目录。

我见过太多报错最后追根究底都是路径基准搞错了。比如你从build目录执行cmake ..,CMakeLists.txt在上一级目录,那么里面写的src/main.c,指的是“上一级目录下的src/main.c”,而不是build/src/main.c。这个基准问题,在配置多个子目录时尤其容易踩。我的习惯是:每写一个路径,脑子里过一遍“这个路径相对当前CMakeLists.txt的位置在哪”,而不是凭感觉写一个看着像对的路径。

2. 最直观的写法:把文件路径直接罗列进target

2.1 add_executable和add_library的基本写法

最简单的写法,是把所有源文件一次性写在add_executable后面。

add_executable(my_app main.c src/uart.c src/gpio.c src/display.c include/display.h )

这里有几个极其容易被新手忽略的细节。

第一,路径分隔符。统一使用正斜杠/,别在Windows上写反斜杠\。反斜杠在CMake里可能被当作转义字符解析,很多莫名其妙的报错就是这么来的。第二,文件名的大小写要与磁盘上完全一致。这句话在Windows上无所谓,因为NTFS默认不区分大小写;但到了Linux上,大小写就是两个不同的文件。一个项目如果要在多个平台编译,大小写规范要从第一天就执行。第三,文件路径支持相对路径和绝对路径。多数场景用相对路径就够了,但如果你喜欢用变量或者需要跨目录引用,用绝对路径更稳,建议用${CMAKE_CURRENT_SOURCE_DIR}来拼。

再看一个常见变形:如果你构建的是库而不是可执行程序,用的是add_library。

add_library(my_lib STATIC src/uart.c src/gpio.c include/uart.h include/gpio.h )

STATIC表示静态库,SHARED表示动态库。源文件添加的逻辑与add_executable完全一样,区别在于最终产物是库文件而非可执行文件。

2.2 用变量和target_sources把长列表拆开

当项目文件超过二十个,还全部堆在add_executable里,可读性会迅速下降。我见过一个项目,add_executable后面跟了上百个文件,每次提交都很难看出增加和删除的是什么。这种时候,我习惯先把列表存到变量里。

set(APP_SOURCES main.c src/uart.c src/gpio.c src/display.c src/encoder.c src/pid.c ) add_executable(my_app ${APP_SOURCES})

这样一来,add_executable那行异常清爽,新增文件只用在set列表里加一行。再进一步,还可以用target_sources按模块拆分。

add_executable(my_app main.c) target_sources(my_app PRIVATE src/uart.c src/gpio.c src/display.c src/encoder.c src/pid.c )

这里的PRIVATE表示这些源文件仅对my_app这个target本身生效。如果你是在写库,target_sources可以写PUBLIC、INTERFACE或PRIVATE。源文件的编译特性属于实现细节,所以即便在库项目里,源文件列表一般也用PRIVATE;PUBLIC通常用在“决定这个库的接口应该被谁看见”的场景,比如把头文件的包含路径暴露给外部使用方。很多文档在这一点上写得很绕,我的理解就一句话:源文件列表关注“谁来编译这些文件”,include路径关注“谁能看见这些头文件”,两个维度别混在一起。

2.3 这一节直接回答:.h文件到底要不要写进列表

接下来回应最直接的那个疑问:.h文件要不要写进add_executable里?

我给的答案分两层。从“能不能编译通过”的角度说,不写.h也能编译,因为头文件不是编译单元。从工程体验的角度说,我建议把项目自己维护的公开头文件写进去。理由前面说过了:IDE索引、工程视图可见性、模块接口文档化。尤其是在某些可视化IDE里打开CMake项目时,没列入列表的头文件在工程视图里是“隐身”的,跳转和补全都受影响。

但要注意边界:第三方库内部的私有头文件,不应该出现在你的源文件列表里。它应该由那个库自己的CMakeLists去管理。你自己项目内部的私有头文件(比如某个模块的internal.h),是否列入则看团队习惯。我倾向于列入,因为在IDE的“全局搜索文件”功能里,列出来的头文件检索起来更快。

写到这,我得提一个反直觉的坑:把.h文件加到列表里之后,如果.h文件本身有语法错误,并不会像.c文件那样在“编译这个文件”时报错,它只会在某个包含它的.c文件编译时报错。所以往源文件列表里加头文件这个操作,纯粹是为了方便人和IDE,不是为了“让编译器检查头文件”。

3. 自动收集文件:file(GLOB)与CONFIGURE_DEPENDS

3.1 file(GLOB)怎么用,能解决什么问题

手动罗列文件确实麻烦,尤其当项目频繁增删源文件时,很多人第一反应是:能不能让CMake自己扫描目录?能,用file(GLOB)或file(GLOB_RECURSE)。

file(GLOB APP_SOURCES src/*.c include/*.h ) add_executable(my_app ${APP_SOURCES})

这样写,CMake在配置阶段会把src/目录下所有.c文件和include/目录下所有.h文件收集进APP_SOURCES变量。GLOB_RECURSE会递归扫描子目录,适合目录层级比较深的项目。如果还想排除掉某些文件,可以配合list(FILTER)或者直接用正则写得细一点。

3.2 为什么不建议在正式项目里直接用GLOB

这里要泼盆冷水。file(GLOB)最大的坑,是它只在CMake配置阶段执行一次。配置阶段就是你运行cmake命令,或者IDE触发重新配置的时候。配置结束后,生成好的构建系统里源文件列表已经固定。之后你在src/里新建了一个foo.c,如果不重新运行cmake,这个文件就不会出现在编译列表里,编译器根本不知道有它存在。

这不是最麻烦的。更讨厌的是删除文件或改后缀名的情况。你删掉一个被GLOB扫到的.c文件,但构建系统里可能还残留着它的编译规则;你把某文件从.c改成.cpp,构建系统可能还试着用C编译器去编译它。这些残留规则轻则报错,重则触发难以排查的缓存问题。

团队协作时这个问题会被放大。A同学新增了文件,但忘了重新跑cmake,B同学拉完代码一编译,怎么都找不到那个新文件。两个人来回确认“你跑cmake了吗”,沟通成本远高于手动加一行CMake代码。官方文档的态度也很明确:不推荐作为默认做法,除非你能接受重新配置的不便。

3.3 CONFIGURE_DEPENDS:GLOB的“官方补丁”

如果项目规模确实大,手动枚举有点痛苦,也有折中的办法。CMake从3.12开始,给file(GLOB)加了一个CONFIGURE_DEPENDS选项。

file(GLOB APP_SOURCES CONFIGURE_DEPENDS src/*.c include/*.h )

加上这个选项后,CMake会在生成阶段同时埋一个“目录内容检查”逻辑。每当构建系统实际执行构建时,它会先检查匹配模式对应的目录内容是否变化,一旦发现新增或删除文件,就自动触发重新配置。这样一来,新文件加入后,下次构建就会自动识别。

代价是每次构建都会多一次目录扫描。在多文件大型项目里,这会拖慢构建启动速度,不过对大多数中小项目来说,这个开销可忽略。我个人在做个人小工具和快速原型时,会用GLOB加CONFIGURE_DEPENDS的组合;但在准备长期维护的项目里,仍然倾向于手动枚举,原因后面第6章会展开。

另外,如果你的项目对路径规范性要求很高,可以配GLOB_REALPATH使用。这个模式会返回规范化后的绝对路径,避免软链接、大小写不清等问题带来的奇奇怪怪路径。它和CONFIGURE_DEPENDS可以同时写:

file(GLOB APP_SOURCES CONFIGURE_DEPENDS GLOB_REALPATH src/*.c )

4. 头文件搜索路径:让编译器找得到头文件

4.1 编译器搜索头文件的底层逻辑

加了文件之后,最常见的编译错误不是“文件没加”,而是“头文件找不到”。报错类似:

fatal error: uart.h: No such file or directory 5 | #include "uart.h"

想解决它,要先知道编译器搜头文件的顺序。对于#include "uart.h"这种双引号形式,搜索顺序大致是:先找包含这条include语句的源文件所在目录;再找通过-I选项指定的目录;最后找系统头文件目录。对于#include <uart.h>这种尖括号形式,则直接跳过当前源文件目录,从-I目录和系统目录开始搜索。

所以,当源文件和头文件不在同一个目录时,必须用include_directories或target_include_directories告诉编译器去哪些目录找头文件。很多刚上手的同学以为“把头文件写进add_executable就万事大吉”,其实完全没有解决路径问题,编译器还是不知道去哪里找这个头文件。

4.2 全局与目标级:include_directories和target_include_directories

我见过一些老工程在顶层直接写:

include_directories(include src)

这个命令是全局的,它会让当前CMakeLists及所有子目录里的所有目标,都加上这些头文件搜索路径。小项目无所谓,但项目一变大,路径过宽很容易让编译器找到不该找到的同名头文件。比如两个模块都有config.h,全局搜索路径一多,编译出来的行为就容易变得不可预测——你根本不知道编译器选中的是哪一个config.h。

更推荐的做法是绑定到具体目标上:

target_include_directories(my_app PRIVATE include src )

PRIVATE表示这些目录只在编译my_app自身时使用。如果你在写库,还要区分PUBLIC和INTERFACE:PUBLIC表示不仅编译库自己时使用,链接这个库的外部目标也会自动继承这些头文件路径;INTERFACE表示只在外部使用方生效,库自己编译时不使用。简单说:想让外部使用方拿到公开头文件,就用PUBLIC;不想暴露内部实现,就用PRIVATE把内部头文件路径藏起来。

这里面还有个概念要分清:include_directories影响的是“目录属性”,所有加了搜索路径的目标都会受影响;target_include_directories影响的是“目标属性”,只对指定目标生效。我用target_include_directories,几乎不用普通include_directories。原因很简单:修改一个target的配置,不影响其他target,错误不会跨模块扩散。

4.3 头文件都列了、路径也配了,还是报找不到?

这种“明明按流程做了,还是报错”的魔幻时刻,基本每个人都遇到过。我通常按下面顺序排查:

  1. 检查include语句里路径的大小写和实际文件名是否完全一致。Linux文件系统大小写敏感,Header.h和header.h是两个文件。
  2. 检查target_include_directories是否写在add_executable/add_library之后。CMake对顺序很敏感,你在target声明之前调用target_xxx命令,会直接报target不存在。
  3. 检查配置的是不是正确的target。有多个可执行文件和库时,只给A配路径,B当然找不到。
  4. 检查是不是漏了子目录的配置。用add_subdirectory时,子目录CMakeLists不会自动继承父目录的target_include_directories;只有include_directories这种目录属性才会往下传递。这也是我坚持用target_include_directories的另一个原因:行为更可预期。

按这个顺序走一遍,绝大多数“找不到头文件”的问题都能定位。

5. 真实项目中常见的报错与排查方法

5.1 新加的.c文件没有参与编译,链接报undefined reference

症状:新写了foo.c,里面定义了foo_init(),编译main.c时提示隐式声明,或者链接阶段报undefined reference tofoo_init。

排查顺序我整理成固定流程:

  • 打开CMakeLists.txt,确认foo.c确实出现在add_executable/add_library或target_sources的列表里;
  • 如果用了GLOB,先重新执行cmake配置,或者补上CONFIGURE_DEPENDS后重新生成;
  • 看编译日志里有没有foo.c的编译行,没有就说明没进构建列表;
  • 确认foo.c被加到了正确的target下。比如加在库A里,却在可执行程序B里引用foo_init,链接时照样找不到。

经验之谈:链接错误比编译错误更常见,而且大部分人看到undefined reference第一反应是补extern声明,这是错误方向。正确的第一步是回CMakeLists看源文件有没有进列表。我甚至遇到过一种情况:文件加进去了,也链接成功了,但运行时行为不对,后来发现是因为构建目录里残留了旧的中间文件,把build目录删掉重新构建后才正常。所以“有问题先clean”这句老话是有道理的。

5.2 链接时报重复定义multiple definition

症状:链接阶段报multiple definition of 'foo_init',first defined here之类的信息。

原因通常有三种。第一种是同一份.c文件被重复加进了同一个target。我处理过的一个案例:工程里既用了file(GLOB src/*.c),又在add_executable里手动写了src/foo.c,结果foo.c被编了两次,符号重复。解决方法是统一用一种来源,GLOB和手动枚举不能混用。

第二种是头文件里定义了全局变量。比如在头文件里写int g_counter;,多个.c文件都include它,预处理后每一个源文件都生成了一份g_counter的定义,链接时就炸了。正确做法是头文件里只放extern声明,真正的定义放到某个.c文件里。

第三种是两个库或两个可执行文件都编译了同一份源文件,并且最终连到了同一个可执行程序里。这种错误在单元测试场景里特别容易出:测试代码和被测代码各自编译了mock和一个公共实现,链接时如果没有屏蔽符号,就会出现冲突。排查时可以看链接器给出的“object file path”,找到是哪些编译产物导致的重复,然后回源文件列表里查重复引用。

5.3 源文件路径报找不到,CMake直接报错

有时候连源文件本身都找不到,报错长这样:

CMake Error at CMakeLists.txt:3: Cannot find source file "src/display.c"

这类问题绝大多数是路径基准错了。CMakeLists.txt里的相对路径,以该CMakeLists.txt所在目录为基准,不是以你运行cmake命令的目录为基准。比如CMakeLists在project/目录下,你在project/build/里执行cmake ..,那src/display.c指的是project/src/display.c,而不是project/build/src/display.c。

如果确认路径没错,还可以查一下文件是不是真的存在。曾经有位同事遇到这种报错,折腾了半天,最后发现源文件压根还没提交到仓库,本地是有的,别人pull下来根本没有。这虽然听起来像低级错误,但在团队协作里真的频繁发生。

还有一个小坑:Windows下不区分大小写,显示为display.c,实际可能是Display.c,在Windows上编译没问题,一移植到Linux立刻报找不到。所以从第一天起,文件名大小写就严格保持一致,别给未来挖坑。

5.4 一个很容易被忽视的坑:源文件扩展名

单独拎出来说,因为太容易被忽略。GCC对扩展名有默认规则:.c按C编译,.cpp/.cc/.cxx按C++编译,而.C(大写)在某些平台会按C++处理。如果你的项目里混用了.c和.C,或者第三方SDK里有大写.C的源文件,编译器就会把它当C++文件来编译,报出一堆C语言里根本不会出现的语法错误。

解决办法是在CMake里显式指定语言类型:

set_property(SOURCE src/foo.C PROPERTY LANGUAGE C)

同理,如果想强制把某个.c文件当C++编译,也可以把LANGUAGE属性改成CXX。这个知识点在移植旧代码或对接厂商SDK时特别有用。我第一次碰到时,完全没头绪,排查好久才发现是一个老项目里传下来的.C文件,GCC默认按C++处理,那一堆报错的根源就这一行属性没设置。

5.5 常见问题速查表

问题现象最可能原因第一排查动作
新文件没有参与编译文件没进源文件列表,或GLOB未重新配置检查CMakeLists并重新cmake
fatal error: xxx.h找不到include路径没配或配错target检查target_include_directories
undefined reference.c没参与编译,或加到错误target回看源文件列表
multiple definitionGLOB与手动枚举混用,或头文件定义全局变量统一文件来源,extern+定义分离
.C文件报语法错误扩展名被编译器当C++处理set_property指定LANGUAGE C
路径报找不到相对路径基准错误确认相对当前CMakeLists的路径

6. 工程化建议:从“能编译”到“好维护”

6.1 用add_subdirectory拆分子模块

回到开头的场景:项目文件越来越多,全堆在顶层CMakeLists.txt里,几百行读起来非常痛苦。我的建议是,按模块拆分子目录,每个子目录放自己的CMakeLists.txt,用add_subdirectory挂进主构建。

结构示意:

project/ ├─ CMakeLists.txt ├─ src/ │ ├─ CMakeLists.txt │ ├─ main.c │ ├─ uart/ │ │ ├─ CMakeLists.txt │ │ ├─ uart.c │ │ └─ uart.h │ └─ display/ │ ├─ CMakeLists.txt │ ├─ display.c │ └─ display.h └─ include/ └─ project.h

顶层CMakeLists只做全局设置,比如项目名、编译选项、C标准,然后add_subdirectory(src)。src/CMakeLists.txt里再add_subdirectory(uart display),以及add_executable或add_library。每个子模块自己的CMakeLists.txt里,用target_sources和target_include_directories管好自己的源文件和头文件路径。要替换一个模块时,只需要动那个子目录,风险隔离非常清晰。

6.2 建立自己的命名规范,别让变量名变成天书

从项目一开始,建议就定几条规则。我这里提供一个相对通用版本,你可以按项目口味调整:源文件列表变量统一叫xxx_SOURCES,头文件列表统一叫xxx_HEADERS;target名称用项目缩写加模块名,比如app_core、app_hal;所有手动枚举的文件列表,路径尽量用相对当前CMakeLists的方式,并且在注释里标明基准目录。

我见过最混乱的情况,是同一个CMakeLists里既有SOURCES、SRC、src_list这种变量,还有人直接在add_executable里现场写一长串。读起来完全靠猜。与其花时间猜,不如一开始就给变量命名规范,让整个项目的CMakeLists像同一人写的。

补充一个细节:当你在变量里存文件列表时,尽量用显式路径,不依赖工作目录。很多人写的set(SOURCES src/main.c)碰巧能跑,是因为构建目录刚好在工程目录旁;一旦构建目录换位置,这种写法就可能出问题。CMake提供了${CMAKE_CURRENT_SOURCE_DIR},用它拼路径最稳:

set(APP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/main.c ${CMAKE_CURRENT_SOURCE_DIR}/src/uart.c )

6.3 我的个人取舍和一点建议

最后说点个人倾向。小型Demo和原型项目,直接GLOB加CONFIGURE_DEPENDS,省事,文件少,扫描开销可以忽略。中型项目,十到几十个源文件,手动枚举配上target_sources最清晰,新增文件多写一行不麻烦,但构建行为完全确定。大型项目,坚持模块化拆分,每个子目录独立CMakeLists,源文件用手动枚举,公开头文件通过PUBLIC或INTERFACE传播。

这个取舍的本质,是在“灵活省事”和“确定可控”之间做交换。GLOB省了手动维护的麻烦,却增加了构建行为不确定的风险;手动枚举增加了一点维护成本,换来了非常明确的边界。我不认为有绝对正确或错误的方案,但一个项目内部的标准必须统一,最怕的是有人用GLOB有人手动枚举,最后两边文件重复定义互相打架。

在实际项目里,我还会做一个小动作:在CMakeLists.txt顶部写一行注释,说明本文件维护的文件列表范围和命名规范。这样半年后回来改时,自己也能快速进入状态。CMake很灵活,但正是这份灵活,让保持克制变得越来越重要。少一点花活,多一点确定,工程维护的幸福感会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询