资讯详情

资讯详情

CMakeLists.txt编写指南:从基础命令到工程实践的完整拆解

写C工程遇到CMakeLists.txt是绕不过去的一关。不管是自己建个新项目还是接手别人扔过来的代码打开目录第一眼看到的往往就是这个没有后缀的文本文件。它在Windows上能生成Visual Studio工程在Linux和macOS上能生成Makefile或者Ninja同一份配置在不同平台上跑出不同的构建系统这就是CMake的核心价值。这篇文章我想把CMakeLists.txt的编写思路完整拆一遍从最基础的三行命令讲到多目录项目的完整组织方式也会把我平时实际踩过的缓存、链接顺序、第三方库查找这类的坑一并说清楚。刚接触CMake的人可以从头跟着写已经写过但总是靠复制粘贴改来改去的朋友也能在这里找到一些更规范的写法。1. 为什么现代C项目越来越离不开CMakeLists.txt1.1 CMakeLists.txt到底解决了什么问题在没有CMake之前一个C项目想跨平台构建是一件很痛苦的事情。Linux下大家习惯写MakefileWindows下用Visual Studio的.slnmacOS下又可能是Xcode工程。每换一个环境就要维护一套构建脚本依赖路径、编译选项、链接库全都散落在不同的地方。CMake做的事情本质上是把这些信息收拢到一个声明式的文本文件里然后由它生成当前平台需要的构建文件。你可以把CMakeLists.txt理解成一份“构建需求说明书”。你告诉CMake我的项目叫什么名字、需要C17、有一个可执行程序、要链接某个库、头文件在某几个目录下。CMake拿到这些信息之后在Linux上生成Makefile在Windows上生成.sln在macOS上生成Xcode工程。你不需要亲自去写平台相关的编译命令CMake替你翻译。这个“声明式”的思路在工程规模小的时候看不出多大优势但只要项目里出现两个以上的源文件目录、出现第三方依赖、出现需要按条件选择不同编译参数的情况你就会发现把所有构建逻辑放在一份配置文件里管理比维护一堆Makefile要轻松太多。1.2 CMakeLists.txt在项目里是怎么分布的一个规范的CMake工程通常不止一个CMakeLists.txt。项目根目录有一个顶层文件负责声明项目信息、设置全局参数、把各个子目录加进来每个子目录里又可以有自己的CMakeLists.txt负责描述这个目录自己编译成什么目标。这个设计很像公司的组织架构。顶层文件是CEO只管大方向项目叫什么、用哪个C标准、哪些子部门存在。子目录的CMakeLists.txt是部门经理管自己的一亩三分地这个目录里的.cpp文件要编成静态库还是动态库对外暴露哪些头文件自己内部悄悄链接了什么。这种分层之后每个文件都很短职责清晰。你改一个子目录的构建规则不需要动顶层文件你想给整个项目加一个编译宏也只需要在顶层改一行。2. 上手必写的几个基础命令2.1 版本声明与项目声明一份CMakeLists.txt的第一行几乎永远是cmake_minimum_required。cmake_minimum_required(VERSION 3.16)这一行规定了运行这份脚本的CMake最低版本。为什么必须有因为CMake的语法在不同版本之间有过不兼容的变化。比如target_link_libraries在3.0之前和之后的解释方式完全不同如果让旧版CMake去解析为新版语法写的脚本结果可能是错的而且错得很隐蔽。项目声明用的是project命令project(MyProject VERSION 1.0.0 LANGUAGES CXX)project除了指定名字之外还能顺便声明版本号和语言。LANGUAGES CXX是告诉CMake只需要C编译器。如果项目里也有C文件就写LANGUAGES C CXX。不写的话CMake会默认探测C和C多了一点无谓的检查时间。注意VERSION不是必须的但建议写上。FetchContent、find_package、install导出这些高级玩法都依赖项目版本号现在写一行成本很低以后要用的时候就不用回头补了。2.2 生成目标add_executable与add_libraryCMake里最核心的概念是“目标”target。可执行程序是一个目标静态库是一个目标动态库也是一个目标。创建目标的命令分别是add_executable和add_library。add_executable(my_app main.cpp utils.cpp) add_library(my_lib STATIC calc.cpp)add_executable的第一个参数是目标名也就是生成的可执行文件的名字。add_library需要额外指定一种类型STactic是静态库SHARED是动态库INTERFACE比较特殊它不生成任何文件只用来传递依赖和头文件目录常用于头文件库和接口定义。这里有个细节值得注意目标名和最终的文件名不一定完全一样。在Linux下一个名为my_lib的静态库最终生成的文件是libmy_lib.a动态库是libmy_lib.so。这个命名规则是编译器给的CMake只是按规则生成。所以你写add_library(my_lib ...)和add_library(myLib ...)会得到两个不同的库文件避免混淆的通用做法是库目标名保持全小写加下划线。2.3 头文件目录、链接库与编译选项的正确写法如果你还在用include_directories和link_libraries这种全局命令需要提醒一句这些是CMake旧时代的写法它们影响的是“接下来所有目标”和命令出现的顺序相关。当项目里出现两个目标、其中一个不想被影响的时候这种全局设置就会到处闯祸。现代CMake推荐的是target_系列命令把配置挂在具体的目标上target_include_directories(my_app PRIVATE src include) target_link_libraries(my_app PRIVATE my_lib) target_compile_features(my_app PRIVATE cxx_std_17) target_compile_options(my_app PRIVATE -Wall -Wextra)target_include_directories的第二个参数是关键点它有PRIVATE、PUBLIC、INTERFACE三种可见性PRIVATE这个目录只对当前目标生效链接我的人看不到。INTERFACE当前目标自己不需要但链接我的人需要。PUBLIC我自己需要链接我的人也需要。这个可见性的含义很简单如果你的头文件里include了某个目录下的头文件那么任何#include你的头文件的用户都必须能在这个目录里找到那个被include的头文件所以要用PUBLIC。如果这个目录只是你的.cpp里用头文件里不引用用PRIVATE就够了。这三个可见性值是CMake里最容易被忽略、也最容易写错的细节。我见过不少项目全部无脑写PUBLIC结果就是所有间接依赖被无差别传递出去编译一个很小的目标却能触发一长串头文件搜索路径。正确评估依赖是Public还是Private比写对命令本身更重要。3. 变量、条件与生成器表达式3.1 用set和option管理配置项CMake脚本里用set定义变量这是最基础的存储方式set(MY_LIB_VERSION 2.0) set(MY_SOURCES src/a.cpp src/b.cpp)变量的作用域是整个CMakeLists.txt除非在子目录里用了set(... PARENT_SCOPE)往上传递。add_subdirectory引入的子目录默认看不到父目录里的普通变量但能看到缓存变量。还有一个很常用的option命令它定义的是一个布尔开关可以在命令行用-D参数覆盖option(MY_BUILD_TESTS 是否编译测试 ON)这个模式非常实用。项目默认不编译测试开发的时候你想临时打开测试就执行cmake -DMY_BUILD_TESTSON .所有用option定义的选项都会出现在CMakeCache.txt里下次cmake执行时如果你没有重新指定它会沿用上一次的值。这个“沿用”的特性有时候会坑人后面说缓存问题时再展开。3.2 生成器表达式写一次处处按需展开生成器表达式Generator Expression是CMake里语法上最劝退、但实际最值得学的东西。它的形态是$...CMake在生成构建文件的时候会根据当前环境、当前配置、当前目标去求值这个表达式。最常见的用途是区分编译配置target_compile_definitions(my_app PRIVATE $$CONFIG:Debug:DEBUG_MODE $$CONFIG:Release:NDEBUG )这段的意思很直白Debug配置下加-DDEBUG_MODERelease配置下加-DNEXDEG。另一个常见用途是区分平台target_link_libraries(my_app PRIVATE $$PLATFORM_ID:Windows:ws2_32 $$PLATFORM_ID:Linux:dl )如果你用if(WIN32)...else搭配target_link_libraries不是不行但一旦写到多配置生成器比如Visual Studio或者目标之间相互传递依赖条件分支会出现很多重复代码。生成器表达式把“选择”这件事从脚本构建期推迟到了构建系统生成期让规则的表达更精确。提示生成器表达式看起来难但实际只需要掌握三四个高频用法就能覆盖绝大多数场景。$ CONFIG:... 、$PLATFORM_ID:...、$TARGET_EXISTS:...、$ IF:... 加一个$BUILD_INTERFACE:...和$INSTALL_INTERFACE:...基本就够用了。一时记不住也没关系用到的时候查一下比背列表有高效得多。4. 一个完整项目的CMakeLists.txt实操4.1 场景设定单文件到多目录的演进前面聊了这么多命令现在把它们拼到一个真实的目录结构里。我假设一个模拟项目X目录结构是my_project/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ └── CMakeLists.txt ├── core/ │ ├── calc.cpp │ ├── calc.h │ └── CMakeLists.txt └── third_party/ └── json/ ├── json.hpp └── CMakeLists.txt这个结构很典型core是一个静态库src是最终的可执行程序third_party里放了一个头文件库。我们分别看每一层怎么配。4.2 顶层CMakeLists.txt怎么组织顶层文件要做的的事情不多但每件都影响全局cmake_minimum_required(VERSION 3.16) project(MyProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release CACHE STRING Build type FORCE) endif() add_subdirectory(core) add_subdirectory(third_party) add_subdirectory(src)CMAKE_CXX_STANDARD设置C标准CMAKE_CXX_STANDARD_REQUIRED设为ON是为了防止编译器明明不支持C17却悄悄按默认标准编。第三段if用来为单配置生成器Makefile和Ninja设置默认构建类型因为这两种生成器不像Visual Studio那样自带Debug和Release配置选项不设置的话很多优化开关默认是关的。add_subdirectory的顺序不是随便写的。CMake会按照出现顺序处理这些子目录。如果src要先链接core那core就必须先定义。实际项目里我就遇到过把src写在前面的情况结果配置阶段报Target not found把顺序换过来就好了。4.3 子目录与库目标的编写要点core/CMakeLists.txt看起来是长这样的add_library(core STATIC calc.cpp) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} )这里只需要两行。因为头文件calc.h就在core目录下与CMakeLists.txt同级直接用CMAKE_CURRENT_SOURCE_DIR指到当前目录。注意可见性用了PUBLIC可执行程序包含calc.h的时候需要能找到这个目录所以必须让这个目录被传递出去。third_party里的json是一个头文件库这种目标最合适的类型是INTERFACEadd_library(json INTERFACE) target_include_directories(json INTERFACE ${CMAKE_CURRENT_SOURCE_DIR} )INTERFACE库不编译任何源文件它只是一个“标签”把头文件路径挂上去让链接它的目标自动获得这个路径。这种用法在处理以头文件为主的三方库时非常干净。src/CMakeLists.txt是最终组装的一层add_executable(my_app main.cpp) target_link_libraries(my_app PRIVATE core json)main.cpp里include了calc.h和json.hpp编译命令里的头文件搜索路径是由target_link_libraries自动带过来的不需要再写target_include_directories。这就是现代CMake最舒服的地方依赖关系在库定义的时候就声明好了使用方只需要说“我要链接谁”剩下的CMake自动处理。4.4 安装规则与导出配置如果项目只是本地编译运行写到上面那一步已经够了。但如果你打算让其他项目用你自己的库install规则这关要过。安装规则的想法是把构建产物、头文件、还有CMake配置文件复制到系统目录或指定前缀目录include(GNUInstallDirs) install(TARGETS core ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ) install(FILES calc.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/core)GNUInstallDirs是CMake自带的一个模块它把/usr/local/lib、/usr/local/include这些平台的约定路径抽象成了变量。在Linux、macOS、Windows上一般路径规则不同用它的变量能让你的安装脚本更具通用性。安装到系统目录后让别的项目找到你的库还需要导出target。这一步相对进阶但它是库作者绕不开的终点。基本结构是install(TARGETS ... EXPORT ...)加install(EXPORT ...)以后再找一个机会专门写这个话题。这里先把安装目标和文件复制的方式说清楚能覆盖大部分自用场景。5. 实操中常见的坑与排查技巧5.1 被缓存坑过CMakeCache.txt导致改了不生效我在实际工作中遇到最多的CMake问题不是语法错误而是“我明明改了CMakeLists.txt为什么重新编译还是老样子”。原因通常出在CMakeCache.txt。第一次执行cmake的时候CMake会把所有变量、选项、路径都存到build目录下的CMakeCache.txt里。第二次执行的时候如果你没有重新指定某个变量CMake优先读取缓存里的旧值。典型的场景是你把option的默认值从OFF改成ON结果重新cmake发现还是OFF就是因为CMakeCache.txt里的旧值没有更新。解决办法有两个层面。临时方案是直接在缓存文件里改打开CMakeCache.txt找到对应变量改掉值删除该变量对应的一行并重新cmake。更稳的方案是既然CMake支持单独设置build目录那就养成“改关键变量就删掉build目录重新生成”的习惯。rm -rf build cmake -B build cmake --build build这套组合拳看起来粗暴但确实是最干净、最不容易引入疑难杂症的操作方式。提示用cmake -B build而不是在build目录里执行cmake ..这个习惯值得养成。它不依赖你当前终端所在的目录逻辑也更好理解-B指定构建目录CMake会自动创建。5.2 undefined reference背后的链接顺序问题链接报错的时候很多人的第一反应是查代码但如果编译都通过、链接时蹦出来一堆undefined reference重点往往在库的链接顺序上。在Linux的链接器里静态库的处理方式是“按需提取”。链接器从左往右扫描输入的库文件当前面有一个未解决的符号而它在右边的某个静态库里能找到链接器就会把这个库里的目标文件提取进来。但如果库在符号被引用之前就已经被扫描完了链接器不会回头去重新搜索。这就是为什么你写了-lfoo -lbar却报找不到foo里的符号改成-lbar -lfoo可能就好了。CMake的target_link_libraries在帮你管理这个顺序。当你写target_link_libraries(my_app PRIVATE core json)时CMake会自动按照依赖关系生成正确的链接顺序core在前json在后而且传递依赖也会被正确展开。所以解决undefined reference的第一原则是不要把库名直接加在target_link_libraries里然后指望它全能搞定先确认是否是通过target_link_libraries把依赖关系串起来的。如果你接手的是一个用add_library然后手动链接旧风格项目的代码库遇到链接顺序问题就只能手工调序这也是我推荐新代码一律走target链路的原因。5.3 路径与大小写那些事路径问题在不同平台上的表现完全不一样。Linux和macOS的文件系统默认大小写敏感Windows不敏感。你在CMakeLists.txt里写target_include_directories指向某个头文件目录在Linux上大小写敲错了就直接找不到文件在Windows上可能还能编译通过。这就是为什么很多Windows下开发得好好的工程一放到Linux的CI上就崩。还有一个经常被忽略的细节CMake里写的路径应该尽量用正斜杠/即使是在Windows上。CMake会帮你转换成Windows认识的格式但你如果直接写反斜杠\在某些生成器下会被当成转义字符直接出问题。跨平台项目里统一使用相对路径并且基于CMAKE_CURRENT_SOURCE_DIR和CMAKE_CURRENT_BINARY_DIR来组织路径能最大程度减少这类麻烦。5.4 find_package找不到第三方库时的三板斧用find_package找第三方库是很多新手最头疼的环节。find_package(OpenCV)找不到find_package(Protobuf)版本对不上这类问题在配置阶段直接报错。排查顺序可以按三板斧来。第一板斧是检查库本省有没有安装到系统目录。如果没装或者说装到了非标准位置先设置CMAKE_PREFIX_PATH把这个自定义前缀传进去cmake -B build -DCMAKE_PREFIX_PATH/path/to/your/prefixCMAKE_PREFIX_PATH是find_package搜索路径里优先级很高的一项目录很多第三方库的CMake配置文件就放在这个前缀下的lib/cmake或lib64/cmake里。第二板斧是看这个库是否提供CMake配置文件。有的库只提供了FindXXX.cmake模块但你直接find_package(XXX)找不到因为模块名和库名不一致或者模块不在CMAKE_MODULE_PATH里。这时你可以在CMakeLists.txt里list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)然后自己写一个FindXXX.cmake放在这个目录下。这个方法虽然要花点时间但对于那些没有官方CMake支持的库几乎是唯一的出路。第三板斧是直接手动指定路径。find_package多次失败之后不要再跟它纠结直接set(XXX_INCLUDE_DIR /path/to/include) set(XXX_LIBRARY /path/to/libxxx.so) add_executable(my_app main.cpp) target_include_directories(my_app PRIVATE ${XXX_INCLUDE_DIR}) target_link_libraries(my_app PRIVATE ${XXX_LIBRARY})这种写法牺牲了一些自动发现的便利但胜在直观可控适合内部使用的工程。5.5 常见问题速查表症状常见原因解决方向改了CMakeLists.txt但编译行为不变CMakeCache.txt缓存旧值删除build目录重新构建链接阶段undefined reference静态库链接顺序错误用target_link_libraries传递依赖头文件找不到no such fileinclude目录可见性设成PRIVATE检查target_include_directories的可见性find_package找不到库库安装路径不在默认搜索范围设置CMAKE_PREFIX_PATH或手动指定Windows能编译Linux失败大小写敏感、路径分隔符不统一统一路径、检查文件名大小写生成器报错“Target not found”add_subdirectory顺序不对调整子目录加入顺序多个配置编译结果相同单配置生成器未设置CMAKE_BUILD_TYPE在顶层CMakeLists.txt设置默认构建类型6. 从能跑到跑稳的一点体会CMakeLists.txt从能跑到跑稳中间隔着一堆细节。我个人在组织项目时最看重的是“目标职责清晰”这件事每个目录只管自己那一层的依赖声明不要越级去include别的目录里的头文件也不要在子目录里偷偷修改全局的include_directories。这样做的收益在项目中期会越来越明显——依赖清楚了改代码就有底气构建速度也因为头文件搜索路径的收敛而有所提升。编写CMakeLists.txt的最终目标是让它像一份清晰的说明书任何人拿到项目只需要执行cmake -B build然后cmake --build build就能得到一个可运行的产物。能做到这一步构建脚本就是项目里真正靠得住的工程资产而不只是几行能跑的配置文件。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →