ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CMake add_library 深度解析:五种库类型、依赖传递与跨平台构建实践

CMake add_library 深度解析:五种库类型、依赖传递与跨平台构建实践 很多人第一次接触CMake是从一个最简单的add_library开始的但真到了项目里要组织多个模块、处理第三方依赖、还要考虑跨平台编译的时候才发现这个命令远没有看起来那么简单。add_library是CMake里用来创建构建目标target的核心命令它不只是“生成一个库文件”那么直白还牵扯到静态库和动态库的区别、源文件组织方式、依赖传递、甚至嵌入式MCU项目的固件构建方式。这篇东西我打算把这几年实际项目里用add_library踩过的坑和沉淀下来的经验系统地拆开讲一遍适合刚入手CMake的人也适合已经在用但偶尔被各种坑卡住的开发者。1. add_library的完整命令形态五种库类型一次讲清1.1 从一句命令看CMake的设计思路先看最基本的形式add_library(mylib STATIC src/a.c src/b.c)这条命令干了什么它声明了一个叫mylib的构建目标类型是静态库源文件是src/a.c和src/b.c。CMake会根据你当前使用的编译器把它翻译成对应的编译和打包命令。如果是在Linux上用GCC就是生成libmylib.a在Windows上用MSVC就是生成mylib.lib。很多人觉得CMake复杂因为它有太多“隐式默认”。比如上面的命令你甚至可以省略STATIC直接写add_library(mylib src/a.c src/b.c)这时候CMake会查看变量BUILD_SHARED_LIBS的值来决定生成静态库还是动态库。这个变量默认为空CMake就默认生成静态库。如果你在命令里显式写了STATIC或SHARED那么不管BUILD_SHARED_LIBS是什么都优先以命令里的类型为准。这个机制让“一次性显式指定”和“全局统一切换”两种需求都得到了支持但初用者往往不清楚就会出现“我明明没写STATIC怎么生成的是静态库”的困惑。1.2 五种目标类型的适用场景对照add_library除了最常见的静态库、动态库形式还有其他几种形态搞清楚它们各自的定位能解决很多“不知道该用哪种方式组织代码”的困惑。这里我直接把五种类型和它们的典型场景列成一张表目标类型命令格式产物典型场景静态库add_library(name STATIC 源文件...).a/.lib嵌入式固件、内部工具链、需要独立分发的模块代码动态库add_library(name SHARED 源文件...).so/.dylib/.dll插件系统、公共运行时、跨项目复用组件对象库add_library(name OBJECT 源文件...).o/.obj文件列表想统一管理目标文件后续再决定打包方式接口库add_library(name INTERFACE)无实体产物纯头文件库、传递编译选项和依赖关系导入库add_library(name IMPORTED)不构建只导入现有库对接预编译好的第三方库这里有个关键点OBJECT、INTERFACE、IMPORTED这三类库没有实体文件或者有实体文件但不参与构建它们属于“逻辑目标”或者“包装目标”。把你的项目组织成一个个逻辑目标之后target_link_libraries、target_include_directories这些命令才能把依赖关系串起来这是CMake增量构建和依赖管理的基石。2. 静态库和动态库怎么选底层发生了什么2.1 链接和运行时的区别一张图说清静态库和动态库的选择是add_library使用里最核心的决策之一。静态库在链接阶段会被完整地复制到可执行文件里之后运行时不依赖外部文件。动态库则是在链接时只记录依赖信息运行时才由动态链接器加载。打个比方静态库像你买了一套家具直接装进家里搬家时跟着就走动态库像你租房时用的公共洗衣房要用的时候去用但搬走时洗衣机还在原来的房子。这个区别直接决定了几个实际表现。用静态库编出来的程序文件大但部署简单用动态库编出来的程序文件小但运行时必须能找到对应的.so或.dll找不到就报错。Linux下常见的报错是error while loading shared libraries: libfoo.so: cannot open shared object file: No such file or directoryWindows下则是程序点开直接弹“找不到DLL”的提示。除了产物和行为上的区别还要注意PIC问题。动态库的代码需要与位置无关Position Independent Code才能在运行时被加载到任意内存地址。CMake编译动态库时默认开启-fPIC但编译静态库时不会默认开启。如果你想把一个静态库再链接进动态库这个静态库最好编译时也开启-fPIC否则在部分平台和架构上会链接失败或运行时报错。在CMake里可以统一加上set(CMAKE_POSITION_INDEPENDENT_CODE ON)或者在单独的库目标上设置set_target_properties(mylib PROPERTIES POSITION_INDEPENDENT_CODE ON)2.2 动态库在Windows和Linux下的产物差异add_library(mylib SHARED ...)这条命令在不同平台上生成的产物名和格式很不一样很多从Linux转到Windows上开发的人都在这里懵过。在Linux上生成的是libmylib.so编译时的链接名和运行时的加载名是同一个文件。在macOS上生成的是libmylib.dylib行为类似。在Windows上如果编译器是MSVCCMake默认生成mylib.dll和一个配套的导入库mylib.lib。链接可执行文件时链接的是mylib.lib运行程序时加载的是mylib.dll。如果用的是MinGW则又不一样会生成libmylib.dll和libmylib.dll.a。这些差异都会影响你后续的批处理和打包脚本。我建议在做跨平台项目时尽量用CMake的生成器表达式来判断平台而不是手写一堆if WIN32之类的分支。比如把输出的DLL统一拷贝到可执行文件旁边可以用add_custom_command(TARGET myapp POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different $TARGET_FILE:mylib $TARGET_FILE_DIR:myapp )$TARGET_FILE:mylib会自动带出对应平台上这个库的实际文件路径和完整文件名Windows下就是.dllLinux下就是.so省去了手动判断扩展名的麻烦。2.3 命名冲突和版本管理的坑动态库的版本管理也是一个经常被忽视的坑。Linux的.so通常有严格的命名规则比如libfoo.so.1.2.3、libfoo.so.1、libfoo.so分别代表完整版本号、主版本号、链接名。CMake里设置VERSION和SOVERSION属性才能正确产出这样的命名体系set_target_properties(mylib PROPERTIES VERSION 1.2.3 SOVERSION 1 )如果不设置CMake默认只生成一个裸的libmylib.so不会带版本号后缀。对于内部项目可能无所谓但如果这个动态库要作为正式组件分发给其他团队没有版本管理的动态库会导致升级之后旧程序和新程序共用同一个文件极易出现ABI不兼容的崩溃问题。我之前就遇到过同事把新版本.so直接覆盖到运行环境结果老程序调用新库时因为某个结构体变了布局当场段错误。从那以后凡是打算对外发布的动态库我都强制要求写上VERSION和SOVERSION。3. 对象库和接口库比想象中好用的两个特殊类型3.1 OBJECT库的三大实用场景对象库add_library(name OBJECT ...)有点特殊。它会把源文件编译成目标文件.o或.obj但并不打包成.a或.dll。这些目标文件可以后续通过$TARGET_OBJECTS:name被其他目标引用。这个特性解决了很多实际问题。场景一多个目标想复用同一组源文件但不想重复编译。一个典型项目里同一套算法可能既想编成测试用的可执行文件又想打包成交给客户或者后续链接的静态库。如果不使用对象库这组源码会被编译两次。用对象库的话编译结果只生成一份两个目标共享这些.o文件add_library(core OBJECT src/algorithm.c src/utils.c) add_executable(demo main.c $TARGET_OBJECTS:core) add_library(core_static STATIC $TARGET_OBJECTS:core)场景二嵌入式MCU裸机项目里想把多个外设驱动编成一个整体又不希望过早把它们塞进特定的库格式。先用OBJECT库统一编译每个驱动的源文件最后在生成固件时选择性地合并某些对象灵活度非常高。场景三项目里有一部分代码需要用不同的编译选项编译。你可以把常规代码编成一个OBJECT库把另外几个需要特殊优化的汇编文件或裸寄存器操作文件单独编成一个OBJECT库因为每个add_library目标可以单独设置target_compile_options最后在链接阶段合并就实现了“同一项目不同优化级别”的需求。3.2 INTERFACE库纯头文件和依赖传递的最佳帮手接口库add_library(name INTERFACE)没有任何实体产物不会编译任何源文件。它的作用就是作为一个逻辑锚点把一组头文件路径、宏定义、编译选项、链接库这些“使用要求”捆绑在一起然后通过target_link_libraries传播给其他库或者可执行文件。一个最常见的应用是管理纯头文件库比如现代的C模板库或者像cJSON这样只提供几个.c/.h的轻量依赖。假设项目里需要引入一个json_parser它没有预先编译好的库文件就是一堆头文件你可以这样组织add_library(json_parser INTERFACE) target_include_directories(json_parser INTERFACE $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include $INSTALL_INTERFACE:include ) target_compile_definitions(json_parser INTERFACE JSON_USE_IMPLICIT_CONVERSIONS0)之后任何需要用到它的地方只要写一句target_link_libraries(myapp PRIVATE json_parser)include路径、宏定义就会自动加到myapp的编译命令行里。这个机制的核心语法是关键字INTERFACE它表示这条使用要求只对“使用者”可见对接口库本身并没有实体编译没有意义。这也是CMake使用要求传播机制的半壁江山另一半天是下面要说的PRIVATE和PUBLIC。可以把INTERFACE理解成“只传递不自用”PRIVATE是“只自用不传递”PUBLIC是“既自用又传递”。这三个词在target_link_libraries、target_include_directories、target_compile_definitions里一以贯之。很多初学者不加区分全部用PUBLIC短时间内项目能跑但一旦模块多了依赖关系就会变得像一团乱麻改一个公共头文件导致全项目重编这类问题十有八九是可见性关键字用错了。3.3 ALIAS别名库给长名字一个短称呼还有一个和add_library配套的常用命令是别名库add_library(very_long_module_name STATIC ...) add_library(short ALIAS very_long_module_name)别名库不会产生新的构建目标只是给已有的库目标起了一个新名字。好处是可以在target_link_libraries里用简短的名字或者在重构时保持对外接口不变。比如你打算把core_engine_v2这个库改名为engine但又不希望改到所有引用了它的CMakeLists那就可以建一个别名过渡。唯一需要注意的是别名不能用于IMPORTED库也不能跨目录随意使用它遵循普通目标的作用域规则。4. 源文件怎么给最优雅文件组织的几条经验4.1 三种给add_library喂源文件的方式给add_library指定源文件有三种常见方式各有各的适用场景选错后面会吃苦头。方式一直接在add_library里列出add_library(mylib STATIC src/a.c src/b.c src/c.c )这种方式最直观适合源文件少且稳定的项目缺点是随着文件增多这个函数调用会变得很长而且后续每新增一个文件都要找到这一处来修改。方式二用target_sources追加。CMake在3.1版本之后推荐的模式是先把库目标建起来然后单独维护源文件列表add_library(mylib STATIC) target_sources(mylib PRIVATE src/a.c src/b.c src/c.c )PRIVATE在这里表示这些源文件只是mylib自身的编译输入不会传递给链接了mylib的其他目标。这种方式的好处是便于按目录拆分维护比如一个复杂模块可以把公共头文件、源文件分别写在不同的target_sources里可读性高很多。方式三用GLOB自动收集file(GLOB MYLIB_SOURCES src/*.c) add_library(mylib STATIC ${MYLIB_SOURCES})这个方式看起来最省事但隐藏着一个大坑CMake不会自动感知到新添加的源文件。你在src目录里新增了一个d.c下一次直接执行make或ninja时新文件不会被编进去因为你没有重新运行CMake配置步骤GLOB的结果在生成构建系统时就已经固化了。虽然可以用CONFIGURE_DEPENDS选项让构建系统每次自动重新扫描file(GLOB MYLIB_SOURCES CONFIGURE_DEPENDS src/*.c)但这个是靠构建系统内部依赖来触发的有些场景下并不完全可靠而且会额外增加每次构建的扫描开销。我的建议是小项目随便用大项目还是老老实实用target_sources维护列表配合目录结构拆分虽然每次加文件要多写一行但构建反馈极其明确。4.2 头文件路径和链接库的可见性配合源文件喂给add_library之后还要处理头文件路径。没有正确的include路径编译会直接失败。这个很好理解但要特别注意PRIVATE和PUBLIC的区别add_library(mylib STATIC src/a.c src/b.c) target_include_directories(mylib PRIVATE src/ # 只有mylib编译时能看见 PUBLIC include/ # mylib和使用mylib的目标都能看见 )为什么include/要用PUBLIC因为mylib的头文件比如include/mylib.h里可能声明了接口而这个接口使用的类型也许定义在另一个头文件里。当外部程序#include mylib.h时它必须能找到mylib.h里引用的其他头文件所以mylib.h所在的目录必须对使用者可见。如果mylib.h还包含了第三方库的头文件这个传递关系还要继续往下串。这里有个很容易犯的错误一个大模块内部头文件互相包含很深编译时把所有的include路径全部设为PUBLIC结果下游每个目标都背着一长串include目录一旦目录结构调整就会波及很多目标。合理的做法是对外公开的接口头文件归到一个include/目录用PUBLIC内部实现细节头文件放在src/下用PRIVATE外部根本不需要知道它们的存在。4.3 链接库的传递从add_library到target_link_librariesadd_library本身只负责创建目标真正把目标串成依赖网络的是target_link_libraries。这句命令的PRIVATE、PUBLIC、INTERFACE语义必须和target_include_directories配合理解。举例说明。项目里有两个库core和ioio内部用到core的接口同时io对外提供的头文件里也包含了core的部分类型。那么io的写法应该是add_library(core STATIC src/core.c) add_library(io STATIC src/io.c) target_link_libraries(io PUBLIC core)因为core既是io编译时需要的依赖也是io的使用者编译时需要传递的依赖。如果io只是内部偷偷用了core对外头文件完全不暴露那应该用PRIVATEtarget_link_libraries(io PRIVATE core)这样一来下游目标链接io时不会自动带上core链接命令行更干净增量编译时也能少触发很多无谓的重新链接。这类“看起来能跑但实际依赖关系混乱”的问题在大型项目里是构建性能的最大杀手之一。5. 实战案例从普通App到嵌入式MCU的一次完整构建5.1 场景A一个共享库加一个可执行程序的完整CMakeLists这里给出一份可以直接试跑的完整示例。目录结构如下project/ ├── CMakeLists.txt ├── src/ │ ├── math_ops.c │ ├── math_ops.h │ └── main.c根目录的CMakeLists.txt这样写cmake_minimum_required(VERSION 3.16) project(calculator_demo C) set(CMAKE_C_STANDARD 11) add_library(math_ops SHARED src/math_ops.c) target_include_directories(math_ops PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) add_executable(calculator src/main.c) target_link_libraries(calculator PRIVATE math_ops)这里的关键点是math_ops库自己只包含src/math_ops.c头文件math_ops.h和它同一个目录通过target_include_directories(math_ops PUBLIC ...)暴露给使用者。main.c里直接#include math_ops.h即可。链接时calculator通过target_link_libraries连上了math_opsCMake会自动处理include路径和链接库依赖还会在编译命令行里加上-I和-L/-l参数。构建方式cmake -B build cmake --build build这样生成的calculator在Linux下运行时能自动找到libmath_ops.so吗不一定。因为动态库的搜索路径默认不包含当前目录除非你设置了LD_LIBRARY_PATH或者用前面提到的add_custom_command把库文件拷贝到可执行文件旁边或者在CMake里设置CMAKE_INSTALL_RPATH。这是新手最常遇到的问题之一日志里看到的是“找不到共享库”实际上就是运行时搜索路径的问题和链接时的库路径没关系。5.2 场景B嵌入式MCU工程如何用add_library组织固件很多人问CMake能不能替代Keil确切地说是能不能替代Keil里那套编译和链接流程。CMake本身不是编译器也不是IDE它是一个构建系统生成器。在嵌入式领域配合arm-none-eabi-gcc和openocdCMake完全可以取代Keil的编译构建环节调试再配合VS Code或CLion体验并不差。一个典型的STM32裸机工程的CMakeLists.txt结构是这样的cmake_minimum_required(VERSION 3.16) project(stm32_demo C ASM) add_library(hal STATIC Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.c Core/Src/main.c Core/Src/stm32f4xx_it.c ) target_include_directories(hal PUBLIC Drivers/CMSIS/Include Drivers/STM32F4xx_HAL_Driver/Inc Core/Inc ) target_compile_definitions(hal PUBLIC STM32F407xx USE_HAL_DRIVER ) add_executable(firmware $TARGET_OBJECTS:hal )这里我把驱动和业务代码编成一个STATIC库然后把这个库的对象文件传给最终的可执行目标。实际项目里还要处理链接脚本.ld、编译选项-mcpucortex-m4 -mthumb等这些可以在给hal或firmware设置target_compile_options时统一加上。为什么嵌入式项目更适合静态库而不是动态库因为MCU上跑的固件最终是要烧录到Flash里的整段镜像动态库依赖运行时加载机制在裸机环境下不现实。静态库在链接时会把需要的函数挑出来嵌入到最终的.elf文件里减小文件体积也更方便做裁剪和链接优化。从Keil工程迁移到CMake时通常不需要重写每个源文件只需要把.uvprojx里的源文件列表和include路径抄到CMakeLists里再补上编译选项即可。有一些工具能自动解析但我更建议手动整理一遍在这个过程中往往能发现很多历史遗留的无效include路径和重复源文件。5.3 场景C对接第三方依赖时add_library的多种方式对接第三方开源库时add_library有几种不同玩法。如果第三方库本来就是CMake项目最好的方式是直接用add_subdirectory把它加进来这样它内部的add_library目标会被直接纳入你的构建系统add_subdirectory(third_party/libjson) target_link_libraries(myapp PRIVATE libjson)这种方式的好处是能拿到源文件级调试能力还能和主项目共享编译选项坏处是如果第三方库的目标名和你的项目目标名冲突会在CMake配置阶段直接报错。此时可以给目标设置EXCLUDE_FROM_ALL或者用add_subdirectory的EXCLUDE_FROM_ALL参数add_subdirectory(third_party/libjson EXCLUDE_FROM_ALL)这样第三方库默认不构建只有被其他目标引用时才会触发编译。如果第三方库只提供了预编译好的二进制和头文件这时应该用add_library的IMPORTED形式。假设下载到了libfoo.a和头文件可以这样声明add_library(foo STATIC IMPORTED GLOBAL) set_target_properties(foo PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/libs/libfoo.a INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_SOURCE_DIR}/include ) target_link_libraries(myapp PRIVATE foo)注意IMPORTED库不参与构建它只是一个给CMake看的“引用目标”用于把库的位置和头文件路径组织起来。这种方式在Windows上对接各种SDK时特别常见许多厂商只发布预编译的.lib和.dll用IMPORTED管理是最好的选择。对于像cJSON这样只需要几个.c/.h的库我更推荐直接用接口库或者把它挂到某个add_library目标后面连编译都省了。6. 常见问题与排查技巧实录6.1 高频报错速查表问题现象根本原因解决方式目标不存在Cannot specify link libraries for target foo which is not built by this project库目标名写错了或者目标在别的目录没有可见性检查目标名拼写必要时在add_library里加GLOBAL关键字重复符号multiple definition of xxx同一个源文件被重复加入了不同库目标检查target_sources列表移除重复项找不到头文件fatal error: xxx.h: No such file or directoryinclude路径没设对或可见性设成了PRIVATE用target_include_directories设置公共头用PUBLIC运行时找不到动态库error while loading shared libraries动态库搜索路径没包含库所在目录Linux下设置LD_LIBRARY_PATHWindows下保证dll和exe同目录或用INSTALL_RPATH平台相关库扩展名不同在Windows下生成.lib而不是预期的.a没理解不同平台、不同编译器产物差异用$TARGET_FILE:target生成实际文件名避免手写扩展名隐式类型导致库类型不对默认生成静态库动态库没被生成没写SHAREDBUILD_SHARED_LIBS也没设置显式写SHARED或设置BUILD_SHARED_LIBSON6.2 我踩过的一些实操坑第一个坑源文件重复加入。早期项目里我习惯在add_library里列源文件的同时又在子目录CMakeLists.txt里用target_sources加了一遍结果编译阶段大量重复定义报错。这个折磨了我一下午最后发现是一个文件同时被两个目录的CMakeLists引用了。排查方法很简单查看具体编译命令看有没有哪个.o文件被生成了两次。第二个坑Windows下动态库排错困难。Linux下动态库找不到会直接给出很明确的路径提示Windows下的DLL加载机制则要晦涩很多经常是程序启动就闪退也没有明确的错误信息。后来我学乖了Windows项目里能不动态库就不动态库优先STATIC省下一堆DLL分发的烦恼。只有在插件架构或者多个可执行文件共享大块公共代码时才用SHARED。第三个坑GLOB不自动更新。那时候写了一个工具库用file(GLOB ...)收集源码后来连续两天被“我明明加了新文件运行结果却还是旧行为”的问题困扰。排查到最后发现新源文件根本没被编进去构建系统压根不知道它的存在。手动重新跑一次cmake配置就能解决但这个坑会反复出现每次都让人怀疑人生。6.3 一份可以直接落地的add_library使用建议根据这些年的经验我给自己定了一套使用add_library的规范分享出来可以参考所有库目标都显式指定类型STATIC还是SHARED不依赖BUILD_SHARED_LIBS的隐式判断。源文件统一用target_sources追加不用GLOB每个子目录维护自己的CMakeLists。对外公开的include路径用PUBLIC内部实现用的include路径用PRIVATE。库之间链接尽量用PRIVATE只有确实需要传递依赖时才升级为PUBLIC。嵌入式项目一律用STATIC库组织模块最后通过链接脚本生成固件镜像。第三方库优先用add_subdirectory集成预编译库用IMPORTED纯头文件库用INTERFACE。这套规范在多个项目里实践下来构建速度和依赖可维护性都提升不少。6.4 补充一个实用小技巧最后再分享一个小技巧。在调试add_library相关问题时可以给CMake加上--trace或--trace-expand选项重新配置能清楚看到每个命令被展开成了什么。如果只是确认路径有没有传递到目标上可以用cmake -LAH查看缓存变量。设置一个目标的所有属性也很方便cmake --build build --verbose能看到每条编译和链接命令的完整参数排查include路径和链接库传递问题时非常直观。这个技巧比对着CMakeLists逐行猜高效得多。CMake的add_library在我没摸清之前像个黑盒摸清之后会发现它的设计其实很讲究。它把“生成一个库”这件小事拆成了目标创建、源文件收集、使用要求传递、平台差异适配这几个层次每层都有对应的命令和属性。理解了这个层次你再看任何复杂的CMake工程基本都能快速理清脉络。
返回列表