KUnit内核单元测试实战:核心概念、常用套路与避坑指南
2026/9/13 15:56:40 网站建设 项目流程

拿到这个标题的时候,我第一反应是:KUnit这东西,确实只需要“够用就行”的知识就够了。作为Linux内核的单元测试框架,它的历史不短了,但很多人一查资料就被一堆API和设计理念劝退。其实真正上手写几个用例之后,你会发现核心概念就那么几个,剩下的都是在真实项目中慢慢磨出来的经验。

这篇笔记我想写给三类人:一是刚接触内核开发、想给自己的驱动或子系统补测试的人;二是被公司或社区要求提交代码时带上KUnit测试、但不知道从哪下手的人;三是已经在用KUnit、但每次写测试都要翻文档查宏定义的人。我会把常用知识过滤到只剩“够用”的层面,不讲KUnit的完整源码解析,也不铺设庞杂的API清单,而是把最核心的架构思路、最少必要代码套路、以及我在实际操作中踩过的坑讲明白。

1. KUnit到底解决什么问题,三个核心概念先记住

先抛开技术细节,想清楚KUnit在Linux内核开发里到底扮演什么角色。内核开发有个长期痛点:代码跑在操作系统底层,出问题很难定位。以前最常见的做法是往代码里塞printk,然后启动一个虚拟机或真机,看日志猜问题。这套流程效率极低,尤其是面对一些纯逻辑的功能(比如链表操作、状态机转换、字符串处理),你其实并不想真的把整个内核跑起来,只想知道“这个函数输入这个参数,是不是返回了我期望的值”。

KUnit就是干这个的。它是一个在Linux内核中运行的单元测试框架,测试代码直接跟被测试的内核代码跑在同一个内核空间里。测试会被编译成内核模块,加载后执行断言,然后把结果打印出来。它的特点是轻量、快速、和内核紧密结合。

1.1 KUnit比起传统内核模块测试好在哪里

以前没有KUnit的时候,开发者也会写一些简单的内核模块来做测试,但那是百花齐放、各写各的。有人直接在模块里写一堆printkif判断,有人自己实现一套简单的断言宏,还有人复用用户态的测试框架然后用系统调用包裹内核函数。

KUnit把这个状态统一了。它提供了一套标准化的断言宏(比如KUNIT_EXPECT_EQKUNIT_EXPECT_STREQ)、测试生命周期管理、测试套件(suite)和测试用例(case)的组织方式,还有一套运行器(runner)。更重要的是,它和内核的构建系统(Kbuild)深度集成,你只需要在Kconfig里加一个配置项、在Makefile里加一行编译规则,然后通过tools/testing/kunit/kunit.py这个脚本就能快速运行。

这套东西带来的直接好处是:测试代码不再是“乱七八糟的模块”,而是有统一格式、能被kunit_tool自动识别和统计结果的正规军。

1.2 三个核心概念:测试、套件与断言

要上手KUnit,先记住三个词:测试用例(test case)测试套件(test suite)断言(assertion)

测试用例是最小执行单元,通常对应一个被测试函数的一个关键路径。比如你写了一个解析内存块的函数memory_block_parse(),那你可能需要几个测试用例:一个测正常输入、一个测空指针、一个测非法标识符。每个测试用例是一个以void为返回值的函数。

测试套件是一组相关测试用例的集合。比如上面提到的memory_block_parse相关的3个用例,会统一放在一个struct kunit_case数组里,然后再定义一个struct kunit_suite结构体来描述这个套件。这个结构体里会填套件名字、初始化函数、退出函数,以及用例列表。

断言是判断测试是否通过的核心。KUnit提供两大类断言:一类是KUNIT_EXPECT_*系列,这类断言失败时不会终止当前测试函数,而是记录失败信息、继续运行后续代码;另一类是KUNIT_ASSERT_*系列,失败时会直接跳出当前测试函数,终止这个用例。这两者的区别很关键,下面我会在代码示例里详细说明选择逻辑。

看到这里,你应该已经明白KUnit的定位了:它就是一个跑在内核态、用于验证内核代码逻辑的测试框架。接下来我们把它跑起来,体验一下整个流程。

2. 从零跑通第一个KUnit测试,比想象中更简单

很多人写KUnit测试时卡在第一步:不知道怎么把它加到内核构建系统里。其实过程非常套路化,前提是你已经有了一套可以编译的内核源码树。我建议先在一个干净的内核目录里操作,避免被厂商改过的内核源码干扰。

2.1 准备环境:一个可编译的内核源码树

你得先有一个能做make defconfigmake(或者至少make modules_prepare)的内核源码目录。如果是在Ubuntu等桌面系统上开发,直接用apt-get source linux拉官方源码包;如果是在嵌入式或Android环境,就用对应的内核仓库。环境要求很简单:

  • 内核版本不要太老,建议5.10以上,越新越好。KUnit在5.5版本左右开始正式合入主线,后续迭代很快,老版本的API和kunit.py脚本功能都不够完整。
  • 确保gccmakeflexbisonlibssl-dev等基础构建依赖已经安装。
  • 最好把tools/testing/kunit目录完整拉下来,因为里面是KUnit的用户态运行脚本和依赖库。

这个阶段最容易踩的坑是:内核源码树不干净,之前编译过别的配置,导致kunit.py在生成.config时出现配置冲突。我习惯先执行一次make mrproper,把以前的编译产物全部清掉,再开始KUnit流程。

2.2 创建一个最简测试模块

我先拿一个最简单的场景练手,比如测试内核里的abs()函数(虽然是宏,但它确实值得测)。在内核源码的lib目录下新建一个文件,假设叫abs_kunit.c,内容如下:

// SPDX-License-Identifier: GPL-2.0 /* * KUnit test for abs(). */ #include <kunit/test.h> #include <linux/kernel.h> static void abs_test_normal_values(struct kunit *test) { KUNIT_EXPECT_EQ(test, abs(10), 10); KUNIT_EXPECT_EQ(test, abs(-10), 10); KUNIT_EXPECT_EQ(test, abs(0), 0); } static void abs_test_overflow(struct kunit *test) { /* * 注意:abs(INT_MIN) 在C标准里是未定义行为 * 内核里的abs()实现依赖编译器处理,这里只用来演示断言 */ KUNIT_EXPECT_EQ(test, abs(INT_MIN), INT_MIN); } static struct kunit_case abs_test_cases[] = { KUNIT_CASE(abs_test_normal_values), KUNIT_CASE(abs_test_overflow), KUNIT_CASE_NULL, /* 数组结束标记 */ }; static struct kunit_suite abs_test_suite = { .name = "abs_test", .test_cases = abs_test_cases, }; kunit_test_suite(abs_test_suite);

这个文件虽然简单,但已经完整演示了KUnit的最小结构:定义用例函数、组织用例数组、定义套件、通过kunit_test_suite()宏注册套件。KUNIT_CASE_NULL这个结束标记容易遗漏,一旦漏掉,KUnit在遍历用例数组时会越界,既可能导致崩溃,也可能产生假阳性结果。

2.3 修改Kconfig和Makefile,让构建系统认识测试

光有abs_kunit.c还不够,内核构建系统不会主动编译它。你要在lib/Kconfig.debug里加上一个新的配置项,如果该配置项被开启,就把这个测试编译成内核模块。代码示例:

config ABS_KUNIT_TEST tristate "KUnit test for abs()" if !KUNIT_ALL_TESTS depends on KUNIT default KUNIT_ALL_TESTS help This builds the KUnit test suite for the abs() function.

然后去lib/Makefile里加一行:

obj-$(CONFIG_ABS_KUNIT_TEST) += abs_kunit.o

需要注意,KUnit测试模块要编译成obj-$(CONFIG_X)而不是obj-$(CONFIG_X)直接编译进内核镜像?两者其实都行,但推荐用tristateobj-$(CONFIG_X)的方式,因为这样测试模块可以在运行时自由加载卸载,灵活性更高。当你把CONFIG_ABS_KUNIT_TEST设为m时,得到的是一个.ko文件,加载它就能跑测试。

2.4 使用kunit.py运行,一行命令搞定编译与执行

上面手动走完Kconfig和Makefile之后,理论上你可以用传统内核模块的方式编译、加载、看dmesg日志,但KUnit官方推荐的体验路径是用tools/testing/kunit/kunit.py脚本。在源码根目录执行:

./tools/testing/kunit/kunit.py run --kunitconfig=lib/Kconfig.debug

这里--kunitconfig参数让我解释一下。KUnit有个“最小化配置”的概念,它会自动帮你生成一个.kunitconfig文件,里面默认开启KUNITKUNIT_TEST等核心项,以及你在该文件中指定的其他配置项。如果你不给它指定文件,它会用默认的最小配置;如果你指定了lib/Kconfig.debug,它会把这个文件里的所有配置项纳入考虑范围。为了精确控制,你也可以自己创建一个.kunitconfig文件,写明需要的配置项。

如果一切顺利,你会看到类似这样的输出:

[11:11:11] ================== abs_test (2 subtests) ================== [11:11:11] [PASSED] abs_test_normal_values [11:11:11] [PASSED] abs_test_overflow [11:11:11] ================== [PASSED] abs_test ================== [11:11:11] ============================================================ [11:11:11] Testing complete. 2 tests run. 0 failed.

到这里,你已经成功跑通了第一个KUnit测试。整个过程比很多开发者想象中要简单,因为KUnit把“编译内核子集 + 启动用户态模拟环境 + 运行测试 + 收集结果”这整个链路都封装进了kunit.py。这也是为什么我建议第一遍一定要用这个脚本跑,而不是手动交叉编译模块、再用QEMU启动去加载模块——前者能快速帮你确认“测试代码本身没写错”,后者则涉及更多系统环境因素,容易干扰你对KUnit本身的判断。

打好这个基础后,下一步就是写出能真正解决实际问题的测试代码。

3. 编写测试的常用套路,以及参数化测试的活用

跑通一个demo之后,人很容易陷入“我学会了,但真要写自己的测试时又不知道咋写”的窘境。这一章我直接给出几个常用套路,你在自己项目里套用即可。

3.1 直接调用被测函数,像写普通C测试一样

KUnit测试最核心的套路就是:在测试函数里直接调用被测试函数,然后用断言比较返回值。这和你用JUnit测试Java方法、用gtest测试C++类没有任何本质区别。举个例子,假设内核里有一个函数:

int foo_validate_size(size_t size) { if (size < 8 || size > 4096) return -EINVAL; return 0; }

对应的KUnit测试:

static void foo_validate_size_test_valid(struct kunit *test) { KUNIT_EXPECT_EQ(test, foo_validate_size(8), 0); KUNIT_EXPECT_EQ(test, foo_validate_size(4096), 0); } static void foo_validate_size_test_invalid(struct kunit *test) { KUNIT_EXPECT_EQ(test, foo_validate_size(7), -EINVAL); KUNIT_EXPECT_EQ(test, foo_validate_size(0), -EINVAL); KUNIT_EXPECT_EQ(test, foo_validate_size(5000), -EINVAL); }

这种套路适用于纯逻辑、无副作用的函数。所谓无副作用,指的是函数不会修改全局状态、不访问硬件寄存器、不依赖特定的内核线程或中断上下文。这类函数在驱动的解析代码、文件系统的路径处理逻辑、网络协议的状态机解析里非常多,是KUnit最容易覆盖的场景。

3.2 内存操作类测试,用KUNIT_ASSERT避免空指针崩溃

如果你要测试的函数会动态分配内存,或者需要传入一个缓冲区,那么测试要覆盖失败分支。这类场景的经典写法是:

static void foo_parse_header_test(struct kunit *test) { struct foo_header hdr; int ret; /* 准备输入数据 */ memset(&hdr, 0, sizeof(hdr)); hdr.magic = 0xdeadbeef; hdr.length = 128; ret = foo_parse_header(&hdr); KUNIT_EXPECT_EQ(test, ret, 0); }

这里的难点在于:如果foo_parse_header()内部在解析失败时会返回错误码,并且可能不会修改hdr的内容,那么测试用例本身没有太大风险。但如果被测试函数在输入非法时可能会崩溃(比如解引用空指针),那么你就需要在测试里提前拦截。

举个例子,如果被测试函数依赖一个全局的ops结构体,而你忘记初始化这个结构体,那么调用时可能直接触发空指针异常。这种情况下,普通的KUNIT_EXPECT无能为力,因为它不会阻止崩溃发生。你应该用KUNIT_ASSERT系列来先检查前提条件,让测试在更早的时候安全退出:

static void foo_parse_header_invalid_test(struct kunit *test) { struct foo_header *hdr = NULL; /* 如果被测试函数没有空指针保护,这行会直接崩溃 */ KUNIT_ASSERT_NOT_ERR_OR_NULL(test, hdr); foo_parse_header(hdr); KUNIT_EXPECT_EQ(test, 0, 1); /* 理论上来不到这里 */ }

当然,上面的例子有点刻意,实际场景中你不会传一个已知的空指针进去。但这个思路非常关键:当被测试函数会在内部解引用某个指针时,你必须在测试中先用KUNIT_ASSERT确认该指针不为空、不指向非法内存。KUNIT_EXPECT失败只是打一条日志,KUNIT_ASSERT失败则会立即跳过后续代码,避免出现难以定位的内核崩溃。

3.3 参数化测试,避免复制粘贴大量相似用例

在写测试的过程中,你很快会发现一个问题:同一个函数,边界条件特别多,如果每个边界条件写一个测试函数,代码会变得又臭又长。KUnit虽然没有像JUnit的@ParameterizedTest那样直接内置参数化测试功能,但你可以自己用“数组+循环”的方式实现类似效果。

这里有个实用技巧:把测试用例想要覆盖的参数和期望值放进一个静态数组里,然后在单个测试函数里遍历。举个例子:

struct foo_boundary_test_case { size_t input; int expected; }; static void foo_validate_size_boundary_test(struct kunit *test) { struct foo_boundary_test_case cases[] = { { .input = 7, .expected = -EINVAL }, { .input = 8, .expected = 0 }, { .input = 100, .expected = 0 }, { .input = 4096, .expected = 0 }, { .input = 4097, .expected = -EINVAL }, }; int i; for (i = 0; i < ARRAY_SIZE(cases); i++) { KUNIT_EXPECT_EQ_MSG(test, foo_validate_size(cases[i].input), cases[i].expected, "input=%zu", cases[i].input); } }

KUNIT_EXPECT_EQ_MSG这个宏非常有用,它允许你在断言失败时打印自定义消息。上面的例子中,如果某个输入值没通过,日志里会直接告诉你“input=100”时断言失败。如果没有这个提示,你就只能回过头去数数组下标,极其痛苦。这个循环式测试是不是比复制粘贴一堆相似用例要干净?

不过这里有个度:如果每个分支之间的逻辑差异很大,或者每个参数都需要执行不同的准备步骤,那还是拆成独立测试函数更清晰。参数化适合“同一个函数、同一套流程、不同输入输出”的场景。

3.4 测试套件初始化与清理,处理全局状态

如果被测试的代码依赖某些全局状态(比如一个static变量作为缓存),那么你在测试用例之间要特别注意隔离。KUnit提供了套件级别的初始化函数init和退出函数exit。你可以在struct kunit_suite里填上这两个字段:

static int foo_suite_init(struct kunit *test) { /* 初始化全局状态 */ foo_cache_reset(); return 0; } static void foo_suite_exit(struct kunit *test) { /* 清理全局状态 */ foo_cache_destroy(); } static struct kunit_suite foo_test_suite = { .name = "foo_test", .init = foo_suite_init, .exit = foo_suite_exit, .test_cases = foo_test_cases, }; kunit_test_suite(foo_test_suite);

注意,init函数返回int,非0表示初始化失败,则整个套件里的测试用例都不会执行。exit函数返回void,因为你没法在退出阶段再中止什么。这个机制和xUnit家族的setUp/tearDown类很像。

但我要特别提醒:如果被测试代码依赖的全局状态是多个用例之间的“共享缓存”,你最好把它当成套件级(suite level)的共享资源,而不是用例级(case level)的。因为过于频繁的初始化/清理可能会掩盖真实使用场景中的问题。KUnit目前没有直接提供套件级初始化接口,常见做法是使用static int suite_init_done标记,在第一个用例执行前做一次全局初始化,最后一个用例执行完再做清理。这种hack虽然不优雅,但简单有效。

4. 在真实内核代码中怎么用得顺手,避坑经验分享

纸上谈兵没意义,我直接摘录我在实际内核子系统中使用KUnit的过程和踩坑教训,既涉及我自己的驱动项目,也参考了上游社区的常见模式。这套经验的核心围绕一个问题:当被测代码不是一个“纯净函数”时,怎么用KUnit把测试跑起来?

4.1 用“通用mock适配层”剥离硬件依赖

KUnit跑在真实内核里,但它毕竟不是跑在特定硬件上。如果你要测试的是一个I2C驱动、一个GPIO控制器或者一个DMA引擎,被测函数会调用i2c_transfer()gpiod_get_value()这类硬件操作函数。你不可能在没有硬件的CI机器上真正执行这些操作。

我的做法是:在被测代码和目标硬件操作之间加一个“适配层”。比如在写一个电源管理芯片驱动时,驱动内部会调用regmap_read()去读取寄存器。我在驱动代码里定义一个static的函数指针变量:

static int (*chip_read_reg)(struct chip_dev *chip, u32 reg, u32 *val) = regmap_read;

然后在正常代码路径中所有读寄存器的地方都调用chip_read_reg()。KUnit测试代码里,在套件初始化时把这个函数指针替换成模拟实现:

static int mock_read_reg(struct chip_dev *chip, u32 reg, u32 *val) { /* 根据 reg 返回预定义的值 */ if (reg == CHIP_REG_STATUS) *val = 0x01; else *val = 0x00; return 0; } static int chip_suite_init(struct kunit *test) { chip_read_reg = mock_read_reg; return 0; }

这种函数指针替换法是我见过的最轻量、也最可靠的mock方式。它的本质和面向对象里的依赖注入一模一样,只是C语言里没有interface,只能用函数指针模拟。它的好处是:

  • 不需要改动被测试代码的逻辑结构,只需要在定义处把直接调用改成通过指针调用。
  • 测试代码可以完全控制外设的响应,让被测驱动处于各种边界条件。
  • 当驱动代码被编译进真实内核时,函数指针的初始化值就是真实函数,没有任何额外开销。

当然,你会担心我是不是为了测试而修改了产品代码。我的观点是:如果这种改动能让驱动在真实硬件上更容易调试,那它就是值得的。事实上,上游很多驱动已经在用类似的可测试性设计,社区并不会排斥这种模式。

4.2 隔离内核全局状态:避免测试用例间的“串味”

KUnit测试跑在内核态,被测代码很有可能操作一些全局变量或共享状态。比如你测试的是一个块设备调度器,它内部有一个全局的待处理请求队列;或者测试的是一个网络过滤钩子,它注册了一个全局的netfilter钩子链。这种情况下,多个测试用例之间的隔离就变得非常重要,否则前一个用例的残留数据会影响后一个用例的执行结果,导致所谓“测试串味”。

我见过最典型的一个反例:被测代码在第一次调用时初始化了一个全局链表,后续再调用时会往链表里追加节点。如果测试用例A先执行,往链表里塞了10个节点;测试用例B再执行,发现链表不为空,逻辑就走了另一个分支。你很难从测试结果里判断到底是B的代码有问题,还是A残留的数据干扰了B。

解决这个问题有两种思路:

  1. 用例级清理:在每个测试用例结束时,把被测代码可能修改的全局状态恢复到初始值。这看起来简单,但实现起来繁琐,因为你需要了解被测代码的每一个细节。
  2. 套件级隔离:如果全局状态本身就是被测试系统的一部分,那不如把针对这个状态的所有验证放在同一个测试用例里,按照“准备数据 -> 触发动作 -> 校验结果 -> 清理数据”的顺序一次性完成。

我个人倾向于第二种思路。KUnit本身并不要求每个测试用例必须完全独立,只要套件整体有意义,用例内部的顺序逻辑是可控的,就没必要为了“看上去优雅”而拆得过于琐碎。不过,在使用kunit.py run时,测试套件的执行顺序是按照KUnit内核模块加载时的初始化顺序来的,同一个套件内的用例顺序则是test_cases[]数组的声明顺序。所以你在写用例时就要考虑到这个顺序可能带来的影响。

4.3 用KUnit的专用内存分配器处理资源释放问题

在用户态测试框架里,malloc/free不成问题,内存泄漏有valgrind帮你查。但在内核对态,你的被测试代码可能会调用kmalloc()kfree()devm_kzalloc()等函数。如果测试过程中分配了内存但没有释放,它会真正泄漏在内核空间里,而且很难被检测到。

KUnit比较贴心地提供了资源管理接口,类似devm_(设备资源管理)机制。你可以在测试函数里这样写:

static void foo_alloc_test(struct kunit *test) { struct foo_ctx *ctx; ctx = kunit_kzalloc(test, sizeof(*ctx), GFP_KERNEL); KUNIT_ASSERT_NOT_ERR_OR_NULL(test, ctx); foo_do_something(ctx); /* 不需要手动 kfree(ctx) */ }

这里的kunit_kzalloc(test, size, flags)会分配一块内存,并把它注册到当前测试资源的清理链表上。当测试用例结束时,KUnit会自动回收这块内存。这比手动kfree安全得多,因为如果测试中间发生了KUNIT_ASSERT失败或者内核panic,你手动释放的代码可能根本执行不到,而KUnit的资源回收机制可以处理这些异常路径。

类似的接口还有:

  • kunit_kfree(test, ptr):手动释放,但会从清理链表上移除,避免双重释放。
  • kunit_kmallockunit_kzalloc:主要的内存分配函数,推荐优先使用。
  • kunit_devm_kzalloc:在测试中使用设备资源管理(devm)风格分配内存,适用于被测代码期望传入一个struct device *的场景。

用这些接口提供的“自动清理内存”能力,是我强烈建议KUnit测试代码必须遵循的实践。它不像用户态测试那样“泄漏了也就泄漏了”,内核态内存泄漏累积起来可能导致系统不稳甚至crash。

4.4 断言宏的选型:EXPECT与ASSERT的使用边界

前面提到过KUNIT_EXPECT_*KUNIT_ASSERT_*的核心差异,这里补充一下实际选型逻辑。

KUNIT_EXPECT_*适用于“这个断言失败了我还想继续跑后续检查”的场景。比如一个测试函数要验证foo_parse()返回的多个字段,如果第一个字段对了、第二个字段错了,你希望测试继续执行,看看第三个字段对不对,以便在一次运行中尽量多地暴露问题。

KUNIT_ASSERT_*适用于“如果这个前提不满足,后续执行已经没有任何意义”的场景。最常见的场景是:被测试函数返回一个指针,你后续会解引用这个指针。如果在断言它不为空之前就已经为空,那后面的解引用必然崩溃,所以必须立即终止测试。另一个典型场景是准备阶段:如果你要往测试设备里写配置数据,写失败了后续的操作全部白搭,这时候用KUNIT_ASSERT_EQ(test, ret, 0)直接终止。

一句话口诀:EXPECT用于结果校验,ASSERT用于前置条件校验。如果两者混用错误,最常见的表现是测试代码本身写得不稳,动不动就死锁、crash,根源往往是你用了EXPECT去校验一个指针,结果发现它是NULL后继续执行,然后崩溃。

5. 踩坑实录:运行KUnit时最常见的几种报错与排查法

KUnit运行时的报错信息五花八门,但底层原因其实就那么几类。我把我在实际开发中遇到的、以及社区里被反复提的典型问题整理成一个速查表,帮你在10分钟内定位问题。

5.1 kunit.py报错“Could not find .kunitconfig”

这是新手最常见的错误。kunit.py在第一次运行时会在源码目录下生成一个.kunitconfig文件,它需要一个起点。默认情况下,如果源码根目录没有.kunitconfig,它会使用tools/testing/kunit/configs/default.config作为基础。但如果你是在一个非常老的内核版本(KUnit尚未完全支持)或交叉编译环境下运行,脚本可能找不到默认配置。

排查方法:

  • 检查源码根目录下是否有.kunitconfig文件。没有就手动创建一个,内容写:
    CONFIG_KUNIT=y CONFIG_KUNIT_TEST=y CONFIG_KUNIT_EXAMPLE_TEST=y CONFIG_ABS_KUNIT_TEST=m
  • 确认你当前所在目录确实是内核源码根目录,而不是子目录。
  • 如果自定义了Kconfig配置,需要在.kunitconfig里显式开启对应项,并且确保被测代码对应的Kconfig条目被正确解析。

5.2 编译报错“undefined reference tokunit_test_suite

这个报错十有八九是#include <kunit/test.h>缺失,或者CONFIG_KUNIT没有开启。KUnit的宏定义在include/kunit/test.h里,它依赖Kconfig的CONFIG_KUNIT来决定是否把kunit_test_suite()展开成实际的注册代码。如果你的.kunitconfig里没有开启CONFIG_KUNIT=y,测试文件编译时就会遇到未定义的引用。

另一个隐蔽原因是:你把测试文件放到了一个不属于内核编译目标的目录。比如你建了个drivers/misc/mytest/目录,却没有修改该目录的Makefile和上一级的Kconfig,那么obj-$(CONFIG_MY_TEST) += mytest/不会生效,整个子目录都不会参与编译。

5.3 测试模块加载了,但看不到任何测试结果

这种问题最打击人。模块加载成功,dmesg里也没有异常,但就是看不到[PASSED][FAILED]的输出。常见原因有几种:

  • 内核日志级别过低:KUnit的输出依赖内核的printk日志级别。如果你的系统内核日志级别设置过高(比如/proc/sys/kernel/printk4),KUnit的KERN_INFO级别输出可能被过滤掉。解决办法是临时调整日志级别:

    echo "8 4 1 7" > /proc/sys/kernel/printk

    或者用dmesg -n 8

  • 测试模块加载时没有触发测试执行:KUnit测试模块的特性是,加载时自动执行所有测试用例。如果你是用insmod手动加载,它会执行;但如果你在启动参数里通过modules_load加载,可能执行顺序有问题。一个简单验证方法是,在.kunitconfig里开启CONFIG_KUNIT_ALL_TESTS=y,让KUnit自带的示例测试跑起来,确认框架本身没问题。

  • 套件初始化函数返回非0:如果你的init函数返回了错误码,整个套件的所有用例会被跳过,不产生任何测试输出。这种情况尤其隐蔽,因为你可能根本没想到init里调用的某个函数会失败。建议在init函数里加一条kunit_info(test, "suite init done\n")输出,确认初始化流程走完了。

5.4 断言失败但日志里只有“FAILED”,没有具体行号

KUnit的输出默认会包含文件和行号,但如果你用了KUNIT_EXPECT_EQ这类不带_MSG后缀的宏,当断言失败时,输出可能不够直观。它会打印类似:

[11:11:11] # abs_test_overflow: EXPECTATION FAILED at lib/abs_kunit.c:42 [11:11:11] Expected: INT_MIN [11:11:11] But got: 2147483648

这其实已经够用了。但如果你在多个测试用例里反复使用同一个断言宏,而且消息都一样,定位起来还是麻烦。所以我更推荐用KUNIT_EXPECT_EQ_MSGKUNIT_EXPECT_STREQ_MSG这些带消息的变体,把上下文信息打进去。

另外,KUNIT_FAIL(test, "custom message")KUNIT_ASSERT_TRUE(test, false)可以用来在特定条件满足时强制标记测试失败,这在测试驱动中的错误处理路径时特别有用。

5.5 被测函数会睡眠,导致测试进程被调度走

这是内核态测试独有的问题。如果你的被测函数会调用msleep()schedule()wait_event_timeout()等可能睡眠的API,那么测试用例会在一个进程上下文里运行,睡眠会导致它被调度出去,延迟增加,但通常不会出错。真正的问题在于:如果你在一个不适合睡眠的上下文里调用了这类函数(比如在test suite初始化时不小心持有spinlock),就可能触发scheduling while atomic错误,导致系统状态异常。

解决办法是:对于会睡眠的被测代码,确保测试用例没有被任何自旋锁保护。KUnit的测试用例运行在普通进程上下文,理论上是可以睡眠的,但你需要检查被测代码的调用路径里有没有持锁。如果有,要么在测试前模拟出无锁状态,要么直接把锁相关逻辑砍掉单独测试。

5.6 使用kunit.py时网络问题的回避

如果你的开发环境处于隔离的内网,kunit.py在尝试下载交叉编译工具链或依赖包时可能会卡住或报错。它默认使用本机的gcc来编译,理论上不需要网络。但如果你的源码树配置里启用了某些需要下载源码的依赖,可能会触发网络请求。这种场景下,我建议完全离线工作:把内核源码、必要的工具链安装包都准备好,然后设置环境变量KBUILD_OUTPUT指向本地目录,避免脚本去访问网络。

这里需要特别说明的是,截止到当前主流内核版本(6.x),kunit.py的主要工作流程是“本地生成内核配置、编译内核子集、通过用户态QEMU(用户模式模拟)来运行测试”。如果你在编译过程中碰到网络相关的错误,先检查是否有代理或防火墙拦截,必要时直接禁用CONFIG_LOCALVERSION_AUTO等会自动获取版本信息的配置项,降低外部依赖。

6. 几个容易被忽略的KUnit配置与调试技巧

前面的内容已经把KUnit的“够用”知识讲完了,但我最后还想补充几个在实际使用中能明显提升体验、却经常被官方文档一笔带过的细节。这些不是必须学的,但学会了能省不少时间。

6.1 善用kunit_tool里的--raw_output--alltests

kunit.py run的默认输出是压缩过的“条数 + 结果”汇总,但有时候你想看内核完整的启动日志和测试输出。加上--raw_output参数,它会把内核的完整dmesg直接打出来。我个人调试时特别喜欢这个功能,因为它能让我看见KUnit测试之前的内核初始化过程,很多被测代码依赖子系统是否被正确初始化,一眼就能看出来。

还有一个参数是--alltests,它对应tools/testing/kunit/configs/all_tests.config,里面开启了内核里所有已支持的KUnit测试套件。这个配置在回归测试时非常有用,但它会导致编译时间暴涨。我说一个我自己常用的组合:

./tools/testing/kunit/kunit.py run --kunitconfig=.kunitconfig --raw_output --jobs=32

--jobs指定并行编译任务数,可以显著加快编译速度。如果机器内存不够,建议把jobs数设低一点,否则编译过程中可能会因为内存不足而OOM。

6.2 把KUnit测试嵌入内核镜像而非模块,保证尽早运行

KUnit测试模块可以是=m,也可以是=y。如果你把测试编成=y,它会直接编译进内核镜像,并在内核启动的早期阶段运行。这在测试一些基础设施代码(比如内存分配器、核心链表)时非常有用,因为这些代码可能在模块加载前就已被依赖。

但随之而来的坑是:如果你在测试代码里使用了一些只在模块初始化阶段才可用的服务,那么把它编进内核镜像后,可能在启动早期就触发资源不可用的问题。稳妥起见,我建议“默认编成模块,只有明确要测启动早期路径时才编进镜像”。

6.3 自定义kunitconfig,保持测试环境的可复现性

我强烈建议你为项目建一个固定的.kunitconfig文件,并把它提交到版本控制里。这个文件会锁定KUnit测试所需的最小内核配置,避免不同开发者的本地环境差异导致测试结果不可复现。一个典型的.kunitconfig长这样:

CONFIG_KUNIT=y CONFIG_KUNIT_TEST=y CONFIG_KUNIT_EXAMPLE_TEST=y CONFIG_DEBUG_KERNEL=y CONFIG_DEBUG_INFO=y CONFIG_GCOV_KERNEL=y CONFIG_GCOV_PROFILE_ALL=y CONFIG_FAULT_INJECTION=y CONFIG_FAULT_INJECTION_DEBUG_FS=y CONFIG_FAILSLAB=y CONFIG_FAIL_PAGE_ALLOC=y CONFIG_FAIL_MAKE_REQUEST=y CONFIG_FAIL_IO_TIMEOUT=y CONFIG_FAIL_MMC_REQUEST=y

这里CONFIG_FAULT_INJECTION系列配置可以在测试基础上引入故障注入,用于验证被测代码的错误处理路径。KUnit本身不强制要求这些配置,但如果你想测试错误处理逻辑,它们几乎是必需品。比如你的被测函数在kmalloc()失败时会走某条错误路径,通过故障注入模拟kmalloc()失败,就能覆盖到这条分支。

6.4 在CI流程中集成KUnit

最后聊一下KUnit的自动化集成。KUnit的设计初衷之一就是“能在几秒钟内跑完并给出明确的结果”,非常适合作为CI流水线的一环。一个很朴素的集成方式是:

  1. 使用kunit.py run运行所有测试。
  2. 解析输出中的Testing complete.行,判断有没有failed.
  3. 如果失败,把--raw_output的完整日志输出到CI的artifact里。

我见过不少项目在GitLab或Jenkins上用了这条链路。如果你想更精细,可以指定--kunitconfig只跑某个模块的测试,配合gitlab CI的rules,做到“只有修改了驱动A的代码,才触发驱动A的KUnit测试”。

不管怎么集成,核心思路都只有一个:让开发者在提交代码后,几分钟内就能知道自己改的这行代码有没有破坏已有的功能。这种即时反馈能力,才是KUnit真正有价值的地方。

KUnit还有很丰富的断言库和API,但掌握上面这些“够用”的知识,已经足够你在实际项目中跑起来。内核测试的好处是能非常快速地反馈你代码改动有没有破坏其他功能,这种即时反馈带来的安心感,是其他方案很难替代的。如果你正打算给内核代码加一层防护网,从今天开始,找一个你最常改的函数,写第一个测试用例吧。

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

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

立即咨询