先从一个很现实的场景说起吧。我刚接手一个比较大的C++项目时,根目录下的xmake.lua还是早期图省事写的样子:一个target,一个add_files("src/**.cpp"),所有源码、工具函数、业务逻辑全都堆在一起编译。刚开始确实省心,但随着模块变多,问题接踵而至——改一行底层代码要等整个工程重新编译,给底层模块写单元测试也只能硬着头皮塞进主程序里。后来我把项目拆成了几个子工程,用xmake的add_subdirs和add_deps管理模块关系,这期内容就把当时梳理出来的思路和踩过的坑完整记录下来。
这篇文章适合谁?如果你手上的工程已经不止一个产出物,比如要同时编译一个动态库、一个可执行程序、一套测试用例,或者想把通用模块抽出来单独维护,那这篇就是给你准备的。读完你至少能搞定三件事:用add_subdirs把子工程挂载到主工程、用add_deps理顺模块间的编译依赖、避免头文件和链接库传递时最常见的几个认知偏差。
1. 为什么要把子工程“拆”出来
1.1 单一target撑到什么时候会出问题
先说个具体的判断标准。当你的xmake.lua里出现这种迹象时,就该考虑拆分了:
- target里add_files开始出现大范围通配,比如
add_files("src/**.cpp"),且src下面已经有好几层目录。 - 每次改一个基础头文件,构建系统要重新编译几十上百个源文件。
- 同一个target里既有库代码,又有命令行入口,还有测试代码,但没法单独选择编译哪一个。
- 想给某个模块单独设编译选项,比如某个目录要用C++17,其余部分保持C++14,单一target内做这种差异配置非常别扭。
这些问题不是xmake特有的,任何构建系统在单target模式下都会碰到。但xmake的add_subdirs方案最大的好处是:你不用把构建脚本拆得七零八落,每个子工程只要维护好自己的target配置就行,主工程只负责把子工程挂进来、指定依赖顺序。
拆分子工程之后,增量编译的收益是很直观的。我那个项目里,core是一个被大量模块依赖的静态库,以前改core里的一个函数,连带编译整个项目要三四分钟,拆完之后通常十几秒就能完成主程序的重新链接。
1.2 拆分子工程的三个直接收益
除了编译效率,拆分还有一个容易被低估的好处:职责边界。当你的团队里有人负责UI层、有人负责底层库、有人负责工具链时,把各自的工作内容定义成独立的target,每个人只需要在自己负责的子目录里修改构建配置,交集自然就少了。
第二个收益是可测试性。把core拆成独立子工程后,我可以单独写一份测试target,只依赖core和测试框架,然后通过xmake build test_core只编译测试程序。这在单target模式下几乎做不到,因为所有代码都被捆在一起。
第三个收益是复用。子工程不一定要绑定在当前项目里,它可以是独立仓库、独立目录,通过add_subdirs挂到任意主工程里。只要你把target的公共头文件、链接依赖声明清楚,这个子工程就能被多个项目复用。这也是很多团队选择xmake作为统一构建工具的原因之一。
1.3 和CMake的add_subdirectory对比
很多从CMake转过来的朋友,第一次看到add_subdirs会觉得似曾相识。确实,它和CMake的add_subdirectory做的事情本质上是一样的:把另一个目录下的构建描述文件加载进来,让其中的target成为当前工程的一部分。
但两者的依赖传播模型不太一样。CMake里你需要明确区分INTERFACE、PUBLIC、PRIVATE三种链接和包含目录的传递方式,在xmake里同样有public/private语义,但它的接口更纯粹,大多数时候你只需要面对add_deps和{public = true}这两个概念就够了。后面的实操部分会详细讲这个,这是很多新手困惑的重灾区。
2. 子工程的设计与目录规划
2.1 一颗“可扩展”的目录树怎么搭
拆分子工程前,先把目录规划好,比直接动手改xmake.lua更重要。我习惯的组织方式是这样:
project_demo/ ├── xmake.lua ├── src/ │ ├── core/ │ │ ├── xmake.lua │ │ ├── core.h │ │ └── core.cpp │ ├── demo/ │ │ ├── xmake.lua │ │ ├── main.cpp │ │ └── app.h │ └── tools/ │ ├── xmake.lua │ └── main.cpp └── tests/ ├── xmake.lua └── test_core.cpp这个结构里,src/core是一个纯静态库,src/demo是主程序可执行文件,src/tools是一个辅助工具程序,tests是单独的测试程序。每个子目录都有一份自己的xmake.lua,彼此之间通过target名称互相引用。
这种布局的核心思路是:一个目录对应一个构建单元,构建单元之间尽量没有隐式耦合。如果你的目录层级更深,可以继续嵌套add_subdirs,xmake支持递归加载,只要每层都有对应的xmake.lua即可。
2.2 根xmake.lua里只做三件事
根目录的xmake.lua不用堆太多东西,我的习惯是只放三件事:全局工程信息、通用规则、子工程的挂载列表。举个例子:
set_project("demo") set_version("1.0.0") add_rules("mode.debug", "mode.release") set_languages("c++17") add_subdirs("src/core") add_subdirs("src/demo") add_subdirs("src/tools") add_subdirs("tests")这里的add_rules("mode.debug", "mode.release")用来启用xmake自带的调试/发布模式,然后add_subdirs把各个子工程挂载进来。
需要注意,add_subdirs的顺序会影响加载顺序,但不会决定编译顺序。编译顺序由target之间的add_deps依赖关系决定,这一点和很多人的直觉不一样。我见过有人试图通过调整add_subdirs的顺序来“控制编译先后”,结果加了新模块后又乱套了。正确做法是只关心依赖关系,把顺序问题交给xmake去拓扑排序。
2.3 子工程之间的“可见性”边界
子工程拆分后,target名称是全局唯一的。也就是说,你不能在src/core里定义了一个叫core的target,又在src/other里再定义一个同名target,xmake会直接报错。这算是一个设计约束,但实名制反而让依赖关系更清晰,你add_deps("core")的时候,明确知道指的是哪个target。
头文件目录和编译宏的“可见性”也是需要设计好的。如果你在core的target里写了add_includedirs(".", {public = true}),那么所有依赖core的子工程在编译时都会自动带上这个头文件搜索路径;如果你写成add_includedirs(".")(不带public),那么这个路径只在core自己内部有效,其他依赖core的target根本看不到。这个语义我后面会展开说,它是模块化构建脚本的一个核心知识点。
3. 核心实操:用add_subdirs添加子工程
3.1 三个文件的完整示例
现在直接看一份能跑的完整示例。整个工程还是按上面的目录结构组织,先从最底层的core开始。
src/core/xmake.lua:
target("core") set_kind("static") add_files("*.cpp") add_headerfiles("*.h") add_includedirs(".", {public = true})这里set_kind("static")表示生成静态库,add_files收集源码,add_headerfiles声明公共头文件,add_includedirs的public选项让头文件搜索路径对下游依赖者可见。
src/demo/xmake.lua:
target("demo") set_kind("binary") add_deps("core") add_files("*.cpp") set_targetdir("$(projectdir)/bin")demo是最终的可执行程序,set_kind("binary")表示生成二进制可执行文件。最关键的是add_deps("core"),这行代码声明了demo依赖core,xmake会保证先编译core再编译demo,并且自动把core的公共头文件目录、链接库信息传递给demo。
src/tools/xmake.lua:
target("tools") set_kind("binary") add_deps("core") add_files("main.cpp") set_targetdir("$(projectdir)/bin")tests/xmake.lua:
target("test_core") set_kind("binary") add_deps("core") add_files("test_core.cpp") set_targetdir("$(projectdir)/bin")依赖同一个core的子工程可以有很多个,xmake不会重复编译core,只会编译一次,然后让这些子工程各自链接。
3.2 add_deps:让依赖关系自动带领编译
add_deps设计得很聪明,它的作用不只是保证编译顺序,还会自动展开依赖信息。当demo依赖core时,xmake会:
- 把core的add_includedirs里标记为public的路径传给demo的编译命令行。
- 把core的add_defines里标记为public的宏传给demo的编译命令行。
- 把core生成的静态库文件加入到demo的链接列表里。
- 如果core本身还依赖其他target,这个传递关系会继续往下算。
这就意味着,你不需要在demo里手动写add_links("core"),也不需要手动指定core的头文件路径。你只要声明好依赖关系和相关接口的public属性,剩下的link搜索路径、include路径、宏定义,xmake会全部接管。
我还测试过另一种情况:core依赖另一个基础库base,然后demo只声明add_deps("core"),demo最终也会正确链接到base库。因为xmake在解析依赖关系时会形成一棵完整的依赖树,所有链接信息会被汇总到最终的可执行目标上。这比手动用add_links去拼凑库顺序要省心太多。
3.3 public细节:头文件和宏的自动传递
public这个修饰符值得单独拎出来讲,因为大部分新手在拆分模块时踩的坑都跟它有关。
先看一个反例。假设core的xmake.lua写成这样:
target("core") set_kind("static") add_files("*.cpp") add_headerfiles("*.h") add_includedirs(".") -- 少了 {public = true}编译demo时,demo里include了core.h,但编译器会报“core.h: No such file or directory”。原因就是add_includedirs(".")默认只在core内部生效,不会传递给依赖core的target。你必须在core里把它声明为public:
add_includedirs(".", {public = true})同理,add_defines也支持public语义。比如core里定义了:
add_defines("CORE_USE_STL", {public = true})那么所有依赖core的target在编译时都会自动获得这个宏定义。这个特性在控制模块对外表现时非常有用。比如你希望core以特定方式编译时,下游代码也做相应调整,就可以通过public宏来传递。
4. 子工程携带资源、配置与自定义选项
4.1 控制产物目录,避免“bundle地狱”
子工程多了以后,如果不控制产物路径,所有中间文件和最终二进制会混在一起,时间一长,根目录下全是out目录、bin目录、build目录,很难分辨哪个产物属于哪个子工程。
我的习惯是在每个子target里显式指定输出目录:
target("tools") set_kind("binary") set_targetdir("$(buildir)/bin/tools") set_objectdir("$(buildir)/objs/tools") add_deps("core") add_files("main.cpp")其中$(buildir)是xmake内置的构建根目录变量,默认是项目的build目录。把每个子工程的最终产物和中间产物都分开,后续做打包、清理、运行时定位二进制都方便很多。
如果你希望多个target共享同一个输出目录,比如demo和tools都想放到bin下,可以统一在根xmake.lua里设置,也可以在各自target里设置,但显式设置的好处是每个target的行为一目了然,不会因为全局变量被意外修改而影响其他target。
4.2 用option把开关下发到子工程
“添加子工程”不只是把目录挂进来,有时候你还希望某些子工程是可选的,比如工具程序只在调试或者需要生成文档时才编译。这时候可以借助xmake的option机制。
根xmake.lua里定义一个option:
option("ENABLE_TOOLS") set_default(false) set_showmenu(true) set_description("Build the tools executable")然后在src/tools/xmake.lua里用has_config判断:
target("tools") set_default(has_config("ENABLE_TOOLS")) set_kind("binary") add_deps("core") add_files("main.cpp")这样默认情况下,tools不会被编译;当你执行:
xmake f --ENABLE_TOOLS=y xmakexmake会重新配置工程,然后tools才会进入构建列表。set_default(has_config(...))的意思是:这个target是否默认参与构建,由用户是否启用了对应option决定。
这种做法的好处是,同一个工程文件可以服务多种构建需求。不需要把某个模块的代码物理删掉或者注释掉,只需要在配置时决定是否启用,很干净。
4.3 通过public宏做模块内外部差异化
还有一类常见需求是:同一个子工程在编译自身和编译下游时,可能需要不同的宏。比如core内部想启用一些私有特性,但对外暴露的接口希望保持稳定,或者反过来,core在构建时要针对不同平台定义不同的宏,并把这些宏传递给下游。
这里可以组合使用add_defines的public/private语义。比如:
target("core") add_defines("CORE_INTERNAL") -- 只对core自身生效 add_defines("CORE_PUBLIC_API", {public = true}) -- 传递到下游实际项目里,public宏最常见的用途是控制导出符号。比如你定义一个库,希望下游看到的是CORE_API这个宏,但宏的具体展开可能依赖平台,core内部定义好并标记为public,下游所有用到CORE_API的代码会自动获得正确的定义,不需要在每个子工程里重复写。
5. 常见问题与排查实录
5.1 头文件找不到的三种典型场景
子工程拆分后,第一个高频报错就是找不到头文件。我把实际遇到的场景整理成了一张表:
| 现象 | 原因 | 解法 |
|---|---|---|
| demo编译报“core.h: No such file or directory” | add_includedirs没有设置{public = true} | 在core的target中改为add_includedirs(".", {public = true}) |
| demo能找到core.h,但找不到core内部私有头文件 | core的私有头文件目录没有public,但demo误以为能访问 | 检查头文件目录层级,把公共头文件放到public目录,私有头文件留在内部目录 |
| 头文件在子工程内可以找到,在构建产物里没有 | 只用了add_files收集源码,忘了用add_headerfiles声明头文件 | 用add_headerfiles声明需要导出的头文件,并用set_installdir或install规则处理安装 |
很多情况下,头文件找不到并不是文件不存在,而是“头文件搜索路径没有传递给当前需要它的target”。检查的时候先确认当前target里有没有add_deps到上游target,再看上游target的相关路径有没有标public。
5.2 链接时符号冲突或未定义
拆分成多个库之后,链接错误会变得比单target时代更多。最常见的两种:
一个是undefined reference。多数情况下是因为某个target使用了另一个target里的符号,但忘了声明add_deps。记住,add_links和add_deps的关系是:add_deps是“项目内部target依赖”,add_links是“链接外部库”。项目内部模块之间优先使用add_deps,xmake会自动帮你处理链接顺序;只有外部预编译库才需要add_links。
另一个是符号重复定义。这种情况多半是同一个静态库被多个子工程分别add_links了,或者多个target都把同一个源文件add_files进来。排查时先确认源文件没有被多个target同时引用,再确认整个工程的链接列表里没有重复的库条目。用xmake -v看实际的链接命令行,一眼就能看出来是不是某个库被拼了两遍。
5.3 编译顺序“薛定谔”的原因
很多人以为add_subdirs的顺序就是编译顺序,实际不是。xmake的target排列顺序会影响一些全局配置的解析顺序,但真正的编译顺序是靠依赖树推算出来的。如果你发现某个库经常比依赖它的可执行文件编译得晚,检查一下是不是两个target之间缺少add_deps。
还有一种情况是多个子工程之间形成了循环依赖。比如core引用了utils里的符号,utils又引用了core里的符号。xmake在检测到循环依赖时会报警告,但有时候不会立刻报错,而是表现为链接阶段无法解析符号。遇到这种问题,应该先回头审视模块划分,把公共部分再抽出一层,打破循环。
5.4 子工程被意外编译的解决办法
有时候你只是想构建某个子工程,但xmake默认把所有默认target都编译了。如果某个target不想在默认情况下参与构建,就用set_default(false),就像前面option示例里写的那样。反之,如果希望某个target在依赖它的target被构建时即使默认关闭也能被拉起来,xmake也会自动处理。set_default控制的是“是否进入默认构建集合”,一旦有上游target依赖它,它就会进入实际构建图。
这个机制我用下来很顺手。比如单元测试默认不参与xmake,但你跑xmake build test_core时,它会被正常编译;如果主程序依赖了某个默认关闭的库,系统也会自动把它建出来。
6. 一些值得尝试的扩展用法
6.1 用set_group整理IDE里的目标树
当工程里的target数量超过十几个之后,在IDE里看目标列表会非常混乱。xmake提供了set_group接口,可以给target分组:
target("core") set_group("library") target("demo") set_group("application")这样在支持xmake插件的小熊猫C++、VS Code插件等编辑器里,target列表会按照分组折叠显示,看起来清爽很多。这个接口对命令行构建没有影响,纯粹是工程可维护性的提升,但实际体验差异挺大,建议target多了之后就加上。
6.2 子工程也能被外部工程复用
如果某个子工程做得足够通用,你可以把它从当前项目里独立出去,放到一个单独的仓库。需要用到它的项目,直接用add_subdirs指向那个目录即可。比如:
add_subdirs("../shared/utils")只要utils目录下有xmake.lua,并且target定义完整,主工程就能复用。这种跨项目的复用方式在团队内部很灵活,比library dependency或者git submodule更轻量,因为它直接基于文件系统路径,不需要额外的依赖管理工具。
如果你不想用相对路径,也可以用xmake提供的包管理和add_requires机制,把子工程发布成一个独立的xmake包,再通过版本号引用。这属于进阶玩法,适合要规范化分发模块的团队。
6.3 继续向前:把子工程拆成独立包
对于需要多人协作、多项目共享的模块,比起add_subdirs,xmake的包管理方案会更专业。你可以在子工程目录里维护一个xmake.lua,定义target和安装规则,然后通过xmake create -t package初始化成独立包,发布到私有或公共的仓库。
这样下游项目只需要写:
add_requires("core", {version = "1.0.0"}) target("demo") add_packages("core")不需要知道core的源码在哪里,构建时会自动下载或者从本地缓存里找到对应版本的core。这种方式的优点是可复用性和版本控制能力更强,但复杂度也比add_subdirs高。我的建议是:团队内部快速迭代用add_subdirs,正式对外发布和稳定版本管理再切换成包管理方案。
最后再分享一点个人的实操心得。我到现在还是喜欢在根xmake.lua里把add_subdirs一个个列得清清楚楚,不喜欢用通配符一次性挂载所有子目录。虽然xmake支持add_subdirs("src/*")这种写法,但通配符会掩盖目录之间的逻辑关系,而且挂载顺序不稳定。每新增一个子工程都明确写一行add_subdirs,虽然看起来啰嗦,但别人接手你的工程时,扫一眼根目录就能知道这个项目包含哪些模块,比翻半天文件结构确认要高效得多。工具这东西,用顺手了之后,你会发现构建脚本也是代码,也一样需要可读性。