Xmake 是一个基于 Lua 的轻量级跨平台构建工具。
它非常的轻量,没有任何依赖,因为它内置了 Lua 运行时。
它使用 xmake.lua 维护项目构建,相比 makefile/CMakeLists.txt,配置语法更加简洁直观,对新手非常友好,短时间内就能快速入门,能够让用户把更多的精力集中在实际的项目开发上。
我们能够使用它像 Make/Ninja 那样可以直接编译项目,也可以像 CMake/Meson 那样生成工程文件,另外它还有内置的包管理系统来帮助用户解决 C/C++ 依赖库的集成使用问题。
目前,Xmake 主要用于 C/C++ 项目的构建,但是同时也支持其他 native 语言的构建,可以实现跟 C/C++ 进行混合编译,同时编译速度也是非常的快,可以跟 Ninja 持平。
Xmake = Build backend + Project Generator + Package Manager + [Remote|Distributed] Build + Cache
尽管不是很准确,但我们还是可以把 Xmake 按下面的方式来理解:
Xmake ≈ Make/Ninja + CMake/Meson + Vcpkg/Conan + distcc + ccache/sccache
在介绍新特性之前,我们有一个好消息要告诉大家,Xmake 最近进入了 Debian 的官方仓库:https://packages.debian.org/sid/xmake,
等到明年4月份 Ubuntu 24.04 发布,我们应该就能直接通过 apt install xmake
命令去快速安装 Xmake 了。
同时也感谢 @Lance Lin 的帮助,他全程帮助我们维护并上传 Xmake 包到 Debian 仓库,真的非常感谢!
接下来,我们来介绍下 2.8.5 版本引入的一些改动,这个版本带来了很多的新特性,尤其是对链接排序,链接组的支持,还有对 xmake test
内置单元测试的支持。
另外,我们还新增了 Apple XROS 平台的构建支持,可以用于构建苹果新的 VisionOS 上的程序,还有我们还提供了更加灵活通用的 check_sizeof
检测接口,用于快速检测类型的大小。
这是一个存在了两年多的需求,主要用于调整 target 内部的链接顺序。
由于 xmake 提供了 add_links
, add_deps
, add_packages
, add_options
接口,可以配置目标、依赖,包和选项中的链接,尽管 add_links
本身的链接顺序可以根据添加顺序来调整。
但是 links,deps 和 packages 之间的链接顺序,只能按固定顺序生成,无法灵活调整,这对于一些复杂的项目,就有点显得力不从心了。
而我们在这个版本,彻底解决了这个问题,新增了 add_linkorders
接口,可用于配置目标、依赖、包、选项、链接组引入的各种链接顺序。
更多详情和背景,请见:#1452
为了更加灵活的调整 target 内部的各种链接顺序,我们可以通过 add_linkorders
这个新接口来实现,例如:
add_links("a", "b", "c", "d", "e")
-- e -> b -> a
add_linkorders("e", "b", "a")
-- e -> d
add_linkorders("e", "d")
add_links 是配置的初始链接顺序,然后我们通过 add_linkorders 配置了两个局部链接依赖 e -> b -> a
和 e -> d
后。
xmake 内部就会根据这些配置,生成 DAG 图,通过拓扑排序的方式,生成最终的链接顺序,提供给链接器。
当然,如果存在循环依赖,产生了环,它也会提供警告信息。
另外,对于循环依赖,我们也可以通过 add_linkgroups
配置链接组的方式也解决。
并且 add_linkorders
也能够对链接组进行排序。
add_links("a", "b", "c", "d", "e")
add_linkgroups("c", "d", {name = "foo", group = true})
add_linkorders("e", "linkgroup::foo")
如果要排序链接组,我们需要对每个链接组取个名,{name = "foo"}
,然后就能在 add_linkorders
里面通过 linkgroup::foo
去引用配置了。
我们也可以排序链接和 macOS/iPhoneOS 的 frameworks。
add_links("a", "b", "c", "d", "e")
add_frameworks("Foundation", "CoreFoundation")
add_linkorders("e", "framework::CoreFoundation")
相关的完整例子,我们可以看下:
add_rules("mode.debug", "mode.release")
add_requires("libpng")
target("bar")
set_kind("shared")
add_files("src/foo.cpp")
add_linkgroups("m", "pthread", {whole = true})
target("foo")
set_kind("static")
add_files("src/foo.cpp")
add_packages("libpng", {public = true})
target("demo")
set_kind("binary")
add_deps("foo")
add_files("src/main.cpp")
if is_plat("linux", "macosx") then
add_syslinks("pthread", "m", "dl")
end
if is_plat("macosx") then
add_frameworks("Foundation", "CoreFoundation")
end
add_linkorders("framework::Foundation", "png16", "foo")
add_linkorders("dl", "linkgroup::syslib")
add_linkgroups("m", "pthread", {name = "syslib", group = true})
完整工程在:linkorders example
另外,这个版本,我们还新增了链接组的原生支持,它目前主要用于 linux 平台的编译,仅支持 gcc/clang 编译器。
需要注意的是 gcc/clang 里面的链接组概念主要特指:-Wl,--start-group
而 Xmake 对齐进行了封装,做了进一步抽象,并且不仅仅用于处理 -Wl,--start-group
,还可以处理 -Wl,--whole-archive
和 -Wl,-Bstatic
。
下面我们会一一对其进行讲解。
更多详情见:#1452
-Wl,--start-group
和 -Wl,--end-group
是用于处理复杂库依赖关系的链接器选项,确保链接器可以解决符号依赖并成功连接多个库。
在 xmake 中,我们可以通过下面的方式实现:
add_linkgroups("a", "b", {group = true})
它会对应生成 -Wl,--start-group -la -lb -Wl,--end-group
链接选项。
如果 a 和 b 库之间有符号的循环依赖,也不会报链接错误,能够正常链接成功。
对于不支持的平台和编译,会退化成 -la -lb
--whole-archive
是一个链接器选项,通常用于处理静态库。
它的作用是告诉链接器将指定的静态库中的所有目标文件都包含到最终可执行文件中,而不仅仅是满足当前符号依赖的目标文件。
这可以用于确保某些库的所有代码都被链接,即使它们在当前的符号依赖关系中没有直接引用。
更多信息,可以参考 gcc/clang 的文档。
在 xmake 中,我们可以通过下面的方式实现:
add_linkgroups("a", "b", {whole = true})
它会对应生成 -Wl,--whole-archive -la -lb -Wl,--no-whole-archive
链接选项。
对于不支持的平台和编译,会退化成 -la -lb
另外,我们可以同时配置 group/whole:
add_linkgroups("a", "b", {whole = true, group = true})
-Bstatic
也是用于编译器(如gcc)的选项,用于指示编译器在链接时只使用静态库而不使用共享库。
更多信息,可以参考 gcc/clang 的文档。
在 xmake 中,我们可以通过下面的方式实现:
add_linkgroups("a", "b", {static = true})
它会对应生成 -Wl,-Bstatic -la -lb -Wl,-Bdynamic
链接选项。
新版本中,我们还增加了一个内置的测试命令:xmake test
,我们只需要在需要测试的 target 上通过 add_tests 配置一些测试用例,就可以自动执行测试。
即使当前 target 被设置成了 set_default(false)
,在执行测试的时候,xmake 也还是会先自动编译它们,然后自动运行所有的测试。
我们可以先看个整体的例子,大概知道下它是怎么样子的。
add_rules("mode.debug", "mode.release")
for _, file in ipairs(os.files("src/test_*.cpp")) do
local name = path.basename(file)
target(name)
set_kind("binary")
set_default(false)
add_files("src/" .. name .. ".cpp")
add_tests("default")
add_tests("args", {runargs = {"foo", "bar"}})
add_tests("pass_output", {trim_output = true, runargs = "foo", pass_outputs = "hello foo"})
add_tests("fail_output", {fail_outputs = {"hello2 .*", "hello xmake"}})
end
这个例子,自动扫描源码目录下的 test_*.cpp
源文件,然后每个文件自动创建一个测试目标,它被设置成了 set_default(false)
,也就是正常情况下,默认不会编译它们。
但是,如果执行 xmake test
进行测试,它们就会被自动编译,然后测试运行,运行效果如下:
ruki-2:test ruki$ xmake test
running tests ...
[ 2%]: test_1/args .................................... passed 7.000s
[ 5%]: test_1/default .................................... passed 5.000s
[ 8%]: test_1/fail_output .................................... passed 5.000s
[ 11%]: test_1/pass_output .................................... passed 6.000s
[ 13%]: test_2/args .................................... passed 7.000s
[ 16%]: test_2/default .................................... passed 6.000s
[ 19%]: test_2/fail_output .................................... passed 6.000s
[ 22%]: test_2/pass_output .................................... passed 6.000s
[ 25%]: test_3/args .................................... passed 7.000s
[ 27%]: test_3/default .................................... passed 7.000s
[ 30%]: test_3/fail_output .................................... passed 6.000s
[ 33%]: test_3/pass_output .................................... passed 6.000s
[ 36%]: test_4/args .................................... passed 6.000s
[ 38%]: test_4/default .................................... passed 6.000s
[ 41%]: test_4/fail_output .................................... passed 5.000s
[ 44%]: test_4/pass_output .................................... passed 6.000s
[ 47%]: test_5/args .................................... passed 5.000s
[ 50%]: test_5/default .................................... passed 6.000s
[ 52%]: test_5/fail_output .................................... failed 6.000s
[ 55%]: test_5/pass_output .................................... failed 5.000s
[ 58%]: test_6/args .................................... passed 7.000s
[ 61%]: test_6/default .................................... passed 6.000s
[ 63%]: test_6/fail_output .................................... passed 6.000s
[ 66%]: test_6/pass_output .................................... passed 6.000s
[ 69%]: test_7/args .................................... failed 6.000s
[ 72%]: test_7/default .................................... failed 7.000s
[ 75%]: test_7/fail_output .................................... failed 6.000s
[ 77%]: test_7/pass_output .................................... failed 5.000s
[ 80%]: test_8/args .................................... passed 7.000s
[ 83%]: test_8/default .................................... passed 6.000s
[ 86%]: test_8/fail_output .................................... passed 6.000s
[ 88%]: test_8/pass_output .................................... failed 5.000s
[ 91%]: test_9/args .................................... passed 6.000s
[ 94%]: test_9/default .................................... passed 6.000s
[ 97%]: test_9/fail_output .................................... passed 6.000s
[100%]: test_9/pass_output .................................... passed 6.000s
80% tests passed, 7 tests failed out of 36, spent 0.242s
我们也可以执行 xmake test -vD
查看详细的测试失败的错误信息:
我们也可以指定运行指定 target 的某个测试:
$ xmake test targetname/testname
或者按模式匹配的方式,运行一个 target 的所有测试,或者一批测试:
$ xmake test targetname/*
$ xmake test targetname/foo*
也可以运行所有 target 的同名测试:
$ xmake test */testname
其实,默认就是并行化运行的,但是我们可以通过 -jN
调整运行的并行度。
$ xmake test -jN
$ xmake test -g "foo"
$ xmake test -g "foo*"
如果没有配置任何参数,仅仅配置了测试名到 add_tests
,那么仅仅测试这个目标程序的是否会运行失败,根据退出代码来判断是否通过测试。
target("test")
add_tests("testname")
我们也可以通过 {runargs = {"arg1", "arg2"}}
的方式,给 add_tests
配置指定测试需要运行的参数。
另外,一个 target 可以同时配置多个测试用例,每个测试用例可独立运行,互不冲突。
target("test")
add_tests("testname", {runargs = "arg1"})
add_tests("testname", {runargs = {"arg1", "arg2"}})
如果我们没有配置 runargs 到 add_tests
,那么我们也会尝试从被绑定的 target 中,获取 set_runargs
设置的运行参数。
target("test")
add_tests("testname")
set_runargs("arg1", "arg2")
我们也可以通过 rundir 设置测试运行的当前工作目录,例如:
target("test")
add_tests("testname", {rundir = os.projectdir()})
如果我们没有配置 rundir 到 add_tests
,那么我们也会尝试从被绑定的 target 中,获取 set_rundir
设置的运行目录。
target("test")
add_tests("testname")
set_rundir("$(projectdir)")
我们也可以通过 runenvs 设置一些运行时候的环境变量,例如:
target("test")
add_tests("testname", {runenvs = {LD_LIBRARY_PATH = "/lib"}})
如果我们没有配置 runenvs 到 add_tests
,那么我们也会尝试从被绑定的 target 中,获取 add_runenvs
设置的运行环境。
target("test")
add_tests("testname")
add_runenvs("LD_LIBRARY_PATH", "/lib")
默认情况下,xmake test
会根据测试运行的退出代码是否为 0,来判断是否测试通过。
当然,我们也可以通过配置测试运行的输出结果是否满足我们的指定的匹配模式,来判断是否测试通过。
主要通过这两个参数控制:
参数 | 说明 |
---|---|
pass_outputs | 如果输出匹配,则测试通过 |
fail_outputs | 如果输出匹配,则测试失败 |
传入 pass_outputs
和 fail_outputs
的是一个 lua 匹配模式的列表,但模式稍微做了一些简化,比如对 *
的处理。
如果要匹配成功,则测试通过,可以这么配置:
target("test")
add_tests("testname1", {pass_outputs = "hello"})
add_tests("testname2", {pass_outputs = "hello *"})
add_tests("testname3", {pass_outputs = {"hello", "hello *"}})
如果要匹配成功,则测试失败,可以这么配置:
target("test")
add_tests("testname1", {fail_outputs = "hello"})
add_tests("testname2", {fail_outputs = "hello *"})
add_tests("testname3", {fail_outputs = {"hello", "hello *"}})
我们也可以同时配置它们:
target("test")
add_tests("testname", {pass_outputs = "foo", fail_outputs = "hello"})
由于一些测试输出的结果,尾部会有一些换行什么的空白字符,干扰匹配模式,我们可以再配置 trim_output = true
,先截断空白字符后,再做匹配。
target("test")
add_tests("testname", {trim_output = true, pass_outputs = "foo", fail_outputs = "hello"})
我们还可以配置 {plain = true}
是禁用 lua 模式匹配,仅仅做最基础的平坦文本匹配。
target("test")
add_tests("testname", {plain = true, pass_outputs = "foo", fail_outputs = "hello"})
我们也可以通过 group = "foo"
来配置一个测试组,进行分组测试:
target("test")
add_tests("testname1", {group = "foo"})
add_tests("testname2", {group = "foo"})
add_tests("testname3", {group = "bar"})
add_tests("testname4", {group = "bae"})
其中 testname1/testname2 是一个组 foo,另外两个是在另外一个组。
然后,我们就可以使用 xmake test -g groupname
来进行分组测试了。
$ xmake test -g "foo"
$ xmake test -g "foo*"
!> 运行分组,也是支持模式匹配的。
另外,如果没有设置 group
参数给 add_tests
,我们也可以默认获取绑定到 target 的组名。
target("test")
add_tests("testname")
set_group("foo")
我们还新增了 before_test
, on_test
和 after_test
配置脚本,用户可以在 rule 和 target 域,自定义配置它们实现定制化的测试执行。
target("test")
on_test(function (target, opt)
print(opt.name, opt.runenvs, opt.runargs, opt.pass_outputs)
-- do test
-- ...
-- passed
return true
-- failied
return false, errors
end)
其中,opt 里面可以获取到所有传入 add_tests
的参数,我们在 on_test 里面自定义测试逻辑,然后返回 true 就是测试通过,返回 false 就是测试失败,然后继续返回测试失败的错误信息。
由于测试目标在正常开发构建阶段,通常是不需要被构建的,因此我们会设置 set_default(false)
。
target("test")
add_tests("testname")
set_default(false)
但是运行 xmake test
进行测试时候,这些测试对应的 target 还是会被自动构建,确保能够被运行。
$ xmake test
[ 25%]: cache compiling.release src/main.cpp
[ 50%]: linking.release test
running tests ...
[100%]: test/testname .................................... passed 6.000s
100% tests passed, 0 tests failed out of 1, spent 0.006s
默认情况下,xmake test
会等到所有测试都运行完,不管里面有多少是没通过的。
而有时候,我们想在第一个测试没通过,就直接中断测试,那么我们可以通过下面的配置启用:
set_policy("test.return_zero_on_failure", true)
默认情况下,只要有一个测试没通过,等到 xmake test
运行完成,它都会返回非0退出代码,这对于一些 CI 环境非常有用,可以中断 CI 的其他脚本继续运行。
然后触发信号告诉 CI,我们需要生成测试报告和告警了。
然后,如果我们想要压制这种行为,可以强制将 xmake test
的退出代码总是设置成 0。
set_policy("test.return_zero_on_failure", true)
有时候,我们仅仅想要测试代码是否通过编译,或者没有通过编译,不需要运行它们,那么可以通过配置 build_should_pass
和 build_should_fail
来实现。
target("test_10")
set_kind("binary")
set_default(false)
add_files("src/compile.cpp")
add_tests("compile_fail", {build_should_fail = true})
target("test_11")
set_kind("binary")
set_default(false)
add_files("src/compile.cpp")
add_tests("compile_pass", {build_should_pass = true})
这通常用于一些测试代码中带有 static_assert
的场景,例如:
template
bool foo(T val) {
if constexpr (std::is_same_v) {
printf("int!\n");
} else if constexpr (std::is_same_v) {
printf("float!\n");
} else {
static_assert(false, "unsupported type");
}
}
int main(int, char**) {
foo("BAD");
return 0;
}
我们还可以在配置测试用例的时候,对每个测试配置额外需要编译的代码,以及一些宏定义,实现内联测试。
xmake 会为每个测试单独编译一个独立的可执行程序去运行它,但这并不会影响到 target 在生产环境的编译结果。
target("test_13")
set_kind("binary")
set_default(false)
add_files("src/test_1.cpp")
add_tests("stub_1", {files = "tests/stub_1.cpp", defines = "STUB_1"})
target("test_14")
set_kind("binary")
set_default(false)
add_files("src/test_2.cpp")
add_tests("stub_2", {files = "tests/stub_2.cpp", defines = "STUB_2"})
target("test_15")
set_kind("binary")
set_default(false)
add_files("src/test_1.cpp")
add_tests("stub_n", {files = "tests/stub_n*.cpp", defines = "STUB_N"})
以 doctest 为例,我们可以在不修改任何 main.cpp 的情况下,外置单元测试:
add_rules("mode.debug", "mode.release")
add_requires("doctest")
target("doctest")
set_kind("binary")
add_files("src/*.cpp")
for _, testfile in ipairs(os.files("tests/*.cpp")) do
add_tests(path.basename(testfile), {
files = testfile,
remove_files = "src/main.cpp",
languages = "c++11",
packages = "doctest",
defines = "DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN"})
end
定义 DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN 会引入额外的 main 入口函数,因此我们需要配置 remove_files 去移除已有的 main.cpp 文件。
运行效果如下:
ruki-2:doctest ruki$ xmake test
running tests ...
[ 50%]: doctest/test_1 .................................... failed 0.009s
[100%]: doctest/test_2 .................................... passed 0.009s
50% tests passed, 1 tests failed out of 2, spent 0.019s
ruki-2:doctest ruki$ xmake test -v
running tests ...
[ 50%]: doctest/test_1 .................................... failed 0.026s
[doctest] doctest version is "2.4.11"
[doctest] run with "--help" for options
===============================================================================
tests/test_1.cpp:7:
TEST CASE: testing the factorial function
tests/test_1.cpp:8: ERROR: CHECK( factorial(1) == 10 ) is NOT correct!
values: CHECK( 1 == 10 )
===============================================================================
[doctest] test cases: 1 | 0 passed | 1 failed | 0 skipped
[doctest] assertions: 4 | 3 passed | 1 failed |
[doctest] Status: FAILURE!
run failed, exit code: 1
[100%]: doctest/test_2 .................................... passed 0.010s
50% tests passed, 1 tests failed out of 2, spent 0.038s
通常,add_tests
仅用于对可执行程序进行运行测试,运行动态库需要有一个额外的 main 主入口,因此我们需要额外配置一个可执行程序去加载它,例如:
target("doctest_shared")
set_kind("shared")
add_files("src/foo.cpp")
for _, testfile in ipairs(os.files("tests/*.cpp")) do
add_tests(path.basename(testfile), {
kind = "binary",
files = testfile,
languages = "c++11",
packages = "doctest",
defines = "DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN"})
end
通过 kind = "binary"
可以将每个单元测试改为 binary 可执行程序,并通过 DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
引入 main 入口函数。
这样就能实现动态库目标中外置可运行的单元测试。
在先前的版本中,我们可以通过 check_csnippets
和 output = true
的方式,来实现类型检测。
check_csnippets("INT_SIZE", 'printf("%d", sizeof(int)); return 0;', {output = true, number = true})
但是这种方式,是通过尝试运行测试代码,然后获取运行输出结果,提取类型大小信息。
这对于交叉编译,就不适用了。
在 2.8.5 版本中,我们新增了 check_sizeof
辅助接口,可以通过直接解析测试程序的二进制文件,提取类型大小信息。
由于不需要运行测试,这种方式不仅可以支持交叉编译,而且对检测效率也有极大的提升,使用也更加的简单。
includes("@builtin/check")
target("test")
set_kind("static")
add_files("*.cpp")
check_sizeof("LONG_SIZE", "long")
check_sizeof("STRING_SIZE", "std::string", {includes = "string"})
$ xmake f -c
checking for LONG_SIZE ... 8
checking for STRING_SIZE ... 24
另外,我也可以通过 target:check_sizeof
在脚本域进行检测。
苹果在 Xcode15 中新增了 visionOS 设备的构建支持,因此我们也在第一时间对其进行了支持,只需要执行:
$ xmake f -p applexros
$ xmake
就可以完成 visionOS/XROS 平台的构建。
最后,我们还提供了一个小工具模块,它可以用于快速合并指定 target 里面的所有 c/c++ 和 头文件源码到单个源文件。
会生成类似 sqlite3.c 的这种单源码文件,用户可以根据自己的实际需求来决定是否使用这个功能。
而在做合并的时候,Xmake 会将内部 includes 头文件全部展开,并生成 DAG,通过拓扑排序引入。
默认它会处理所有 target 的合并,例如:
$ xmake l cli.amalgamate
build/tbox.c generated!
build/tbox.h generated!
我们也可以指定合并需要的目标:
$ xmake l cli.amalgamate tbox
build/tbox.c generated!
build/tbox.h generated!
也可以在合并每个源文件时候,指定一个自定义的 unique ID 的宏定义,来处理符号冲突问题。
$ xmake l cli.amalgamate -u MY_UNIQUEU_ID
build/tbox.c generated!
build/tbox.h generated!
如果多个源文件内部有重名符号,就可以判断这个 MY_UNIQUEU_ID
宏是否被定义,如果定义了,说明是在单文件中,就自己在源码中处理下重名符号。
#ifdef MY_UNIQUEU_ID
// do some thing
#endif
我们也可以指定输出位置:
$ xmake l cli.amalgamate -o /xxx
/xxx/tbox.c generated!
/xxx/tbox.h generated!
通过这个策略,我们可以快速方便的设置并启用 Windows UAC。
它支持以下几个 Level:
例如:
set_policy("windows.manifest.uac", "admin")
它等价于设置
if is_plat("windows") then
add_ldflags("/manifest:embed", {"/manifestuac:level='requireAdministrator' uiAccess='false'"}, {force = true, expand = false})
end
但是更加方便简洁,并且不需要判断平台,其他平台自动忽略。
我们也可以通过 windows.manifest.uac.ui
策略,设置 Windows UAC 的 uiAccess,如果没有设置它,默认是 false。
set_policy("windows.manifest.uac.ui", true)
xmake test
支持https://tboox.org/cn/2023/11/05/xmake-update-v2.8.5/