Linux 内核 kselftest + KUnit 深度工程——从内核模块单元测试到 CI 持续集成的完整框架
TL;DR: Linux 内核代码跑在 ring 0,一次越界就能让整个系统 panic。这篇文章从内核测试的根本问题出发,系统讲解 kselftest 和 KUnit 两个测试框架的设计思想、API 用法与生产实践,并通过可运行的代码示例展示如何在 CI 中建立有效的内核代码质量保障体系。
一、为什么内核代码的测试如此之难
写用户态代码时,测试流程大致如此:写用例 -> 编译 -> 运行 -> 看结果。但在内核态,这条链路面临三个根本性挑战:
1. 崩溃即全局崩溃 — 内核模块的段错误不会只杀掉自己的进程,而是让整机宕机。一台开发机蓝屏,你连 backtrace 都来不及看。
2. 环境依赖极重 — 内核符号、硬件状态、调度器行为都会影响测试结果的确定性。同一台机器上连续跑两次,第二次的结果可能因为上次没清理干净而不同。
3. 工具链极为脆弱 — 内核在编译时禁用了大量标准库函数(如 memcpy 要用 __builtin_memcpy),用户态测试框架完全无法直接使用。
正因为如此,Linux 社区在漫长的 30 年里形成了两条路径:kselftest 做端到端集成测试,KUnit 做函数级单元测试。
二、kselftest — 内核的端到端集成测试框架
2.1 架构总览
kselftest 位于 tools/testing/selftests/ 目录下,本质是一组用户态可执行文件。它不是"测试框架库"而是一种约定:每个测试程序以特定退出码表达结果(PASS=0,SKIP=3,FAIL=非0),通过调用 ksft_print_msg、ksft_test_result 等宏输出标准格式的结果。
kselftest 执行流程:
┌──────────────────────────────────────────────────────┐
│ kselftest Makefile │
│ ├── Makefile (顶层调度) │
│ ├── net/ │
│ │ ├── netfilter/ │
│ │ │ ├── netfilter_conntrack_test.sh │
│ │ │ └── Makefile │
│ ├── vm/ │
│ │ ├── compaction_test.c │
│ │ ├── hugepage-mdwe-test.c │
│ │ └── Makefile │
│ └── bpf/ ← libbpf 在这里被间接依赖 │
└──────────────────────────────────────────────────────┘
2.2 编写第一个 kselftest
kselftest 的源文件非常"朴实"——就是普通的 C 程序,核心宏定义在 tools/testing/selftests/kselftest_harness.h 中:
/* ksft_example.c - 最简单的 kselftest 用例 */
#include "../kselftest_harness.h"
TEST(test_echo_path) {
/* 假设测试 /proc 文件系统返回值 */
int fd = open("/proc/sys/net/ipv4/ip_forward", O_RDONLY);
ASSERT_GE(fd, 0);
char buf[16];
ssize_t n = read(fd, buf, sizeof(buf));
EXPECT_GT(n, 0);
close(fd);
}
TEST_HARNESS_MAIN
MAKEFILE 必须匹配 kselftest 约定的格式:
# Makefile (放在测试同目录)
TOPDIR = ../..
include $(TOPDIR)/scripts/Makefile.include
TEST_PROGS := ksft_example.c
TEST_GEN_PROGS := test_ksft
include $(TOPDIR)/scripts/Makefile.include
2.3 运行与结果输出
# 进入 kselftest 根目录
cd tools/testing/selftests
# 编译所有测试
make -C tools/testing/selftests
# 运行所有测试
make -C tools/testing/selftests run_tests
# 运行特定子集
./net/netfilter_conntrack_test.sh
KSFT 标准输出(第三方 CI 可直接解析):
# TAP version 13
# Starting 3 tests from 2 test cases.
# RUN test_echo_path ...
# OK test_echo_path
# PASSED test_echo_path
# Ran 1 tests - 0 failures
ok 1 test_echo_path
# junit.xml: ...
2.4 kselftest 的四种结果语义
kselftest 的设计中,"跳过"和"失败"有严格区分:
| 宏 | 退出码 | 含义 |
|---|---|---|
TEST_PASS |
0 | 测试通过 |
TEST_SKIP |
3 | 前置条件不满足(如 root 权限、特定内核配置项) |
TEST_FAIL |
非0 | 明确失败 |
ASSERT_* |
- | 失败时立即终止当前 TEST |
EXPECT_* |
- | 失败时记录错误但继续执行 |
实际工程中,这个区分极为重要。一个 BPF 测试如果在内核未配置 CONFIG_BPF_SYSCALL 时直接报 FAIL,会在 CI 中产生大量假阴性。
三、KUnit — 内核的单元测试框架
3.1 设计理念
KUnit 追求的是"用被测内核模块自身的编译器来编译测试代码"——测试代码与被测模块在相同环境下编译,甚至嵌入同一个 .ko 文件。其核心设计:
- 不依赖用户态
- 测试用例在内核启动早期即可执行
- 通过
kunit_test_suites()将测试注册到内核中
3.2 KUnit 的最小可运行示例
/* test-example.c - 一个不依赖任何外部模块的 KUnit 测试 */
#include <kunit/test.h>
static void example_simple_test(struct kunit *test)
{
/* 基本断言 */
KUNIT_EXPECT_EQ(test, 1, 1);
KUNIT_EXPECT_LT(test, 2, 3);
}
static void example_str_test(struct kunit *test)
{
const char *str = "hello";
KUNIT_EXPECT_STREQ(test, str, "hello");
KUNIT_EXPECT_NE(test, strlen(str), 0);
}
static struct kunit_case example_test_cases[] = {
KUNIT_CASE(example_simple_test),
KUNIT_CASE(example_str_test),
{ } /* 哨兵 */
};
static struct kunit_suite example_test_suite = {
.name = "example",
.test_cases = example_test_cases,
};
kunit_test_suites(&example_test_suite);
Makefile 对比 kselftest 更加简洁:
# Makefile
obj-$(CONFIG_KUNIT_TEST) += test-example.o
# 如果要编译进模块
obj-m += mymodule_test.o
mymodule_test-objs := test-example.o
3.3 KUnit 在内核模块测试中的核心用法
KUnit 的真正威力在于测试你可能随时修改的内核子系统。以测试一个简单的"模块参数验证器"为例:
/* param_validator.c 内嵌 KUnit 测试 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <kunit/test.h>
/* 被测函数:验证端口号在有效范围内 */
static inline bool is_valid_port(int port)
{
return port > 0 && port <= 65535;
}
/* 被测函数:字符串转端口号,返回0表示错误 */
static int parse_port_str(const char *str, int *out_port)
{
int val;
int ret = kstrtoint(str, 10, &val);
if (ret < 0)
return ret;
if (!is_valid_port(val))
return -ERANGE;
*out_port = val;
return 0;
}
/* ================= KUnit 测试用例 ================= */
static void parse_port_valid_cases(struct kunit *test)
{
int port;
KUNIT_EXPECT_EQ(test, parse_port_str("8080", &port), 0);
KUNIT_EXPECT_EQ(test, port, 8080);
KUNIT_EXPECT_EQ(test, parse_port_str("1", &port), 0);
KUNIT_EXPECT_EQ(test, parse_port_str("65535", &port), 0);
}
static void parse_port_invalid_cases(struct kunit *test)
{
int port;
/* 边界外 */
KUNIT_EXPECT_NE(test, parse_port_str("0", &port), 0);
KUNIT_EXPECT_NE(test, parse_port_str("65536", &port), 0);
KUNIT_EXPECT_NE(test, parse_port_str("-1", &port), 0);
/* 非法格式 */
KUNIT_EXPECT_NE(test, parse_port_str("abc", &port), 0);
KUNIT_EXPECT_NE(test, parse_port_str("", &port), 0);
}
static struct kunit_case port_validator_cases[] = {
KUNIT_CASE(parse_port_valid_cases),
KUNIT_CASE(parse_port_invalid_cases),
{ }
};
static struct kunit_suite port_validator_suite = {
.name = "port-validator",
.test_cases = port_validator_cases,
};
kunit_test_suites(&port_validator_suite);
3.4 从用户态运行 KUnit 测试
现代内核(5.15+)支持直接加载 kunit_test.ko 并读取结果:
# 法一:直接作为内置模块运行(需要内核开启 CONFIG_KUNIT)
modprobe kunit-test
# 法二:将测试编译为独立模块后加载测试
insmod port-validator.ko
dmesg | tail -20 # 结果会打印到 dmesg
# 法三:使用 kunit_tool 解析输出
python3 tools/testing/kunit/kunit.py run --kunitconfig=lib/
输出示例(TAP 格式):
# TAP version 13
# Subtest: port-validator
# module: port_validator
1..2
# parse_port_valid_cases: ok 1 - valid cases
ok 1 - parse_port_valid_cases
# parse_port_invalid_cases: ok 1 - invalid cases
ok 2 - parse_port_invalid_cases
ok 1 - port-validator
四、kselftest vs KUnit:选择策略与协同工作
4.1 何时用 kselftest,何时用 KUnit
| 场景 | 推荐框架 | 理由 |
|---|---|---|
| 验证子系统整体行为(如 cgroup 限制是否实际生效) | kselftest | 能完整启动通过 sysfs/netlink 与内核交互 |
| 验证一个纯函数(如位运算、解析器) | KUnit | 启动快、无需用户态环境 |
| 需要用户态工具链(如 iproute2、bpftool) | kselftest | 天然在用户态,能直接 shell 调用 |
| 被测代码是内核启动阶段调用的 | KUnit | 不依赖用户态,可尽早运行 |
| 需要 mock 硬件行为 | KUnit(配合 mock 框架) | 更轻量,方便控制 |
| 需要测量性能/延迟 | kselftest | 用户态计时器更精确 |
4.2 实战:同一子系统的双轨测试策略
现代 Linux 子系统(如 bpf、cgroup)通常会同时提供 kselftest 和 KUnit 两层:
tools/testing/selftests/bpf/ ← kselftest:端到端验证 BPF 加载、挂载、数据平面
└── test_progs-bpf.c ← 通过 libbpf 加载完整 BPF 程序
kernel/bpf/ ← KUnit:验证 bpf 内部函数逻辑
└── bpf_map_test.c ← 测试 bpf_map 查找、更新的边界
└── bpf_csum_test.c ← 测试校验和函数的 bit-level 正确性
重要的工程原则: KUnit 走的是"验证计算正确性"路线,kselftest 走的是"验证入网行为符合预期"路线。两者不是替代而是互补。
五、进阶技巧与工程实战
5.1 KUnit 的测试 fixture 与数据驱动
KUnit 支持 KUNIT_ARRAY_PARAM 宏实现数据驱动测试:
struct port_test_case {
const char *name;
const char *input;
int expected_result;
int expected_port;
};
static void run_port_test(struct kunit *test)
{
const struct port_test_case *tc = test->param_value;
int port;
int ret = parse_port_str(tc->input, &port);
KUNIT_EXPECT_EQ(test, ret, tc->expected_result);
if (ret == 0) {
KUNIT_EXPECT_EQ(test, port, tc->expected_port);
}
}
static const struct port_test_case port_test_data[] = {
{ "valid_8080", "8080", 0, 8080 },
{ "valid_min", "1", 0, 1 },
{ "valid_max", "65535", 0, 65535 },
{ "invalid_0", "0", -ERANGE, 0 },
{ "invalid_neg", "-1", -ERANGE, 0 },
{ "format_err", "abc", -EINVAL, 0 },
};
static void port_tc_desc(char *desc, size_t len,
const struct port_test_case *tc)
{
snprintf(desc, len, "%s", tc->name);
}
KUNIT_ARRAY_PARAM(port, port_test_data, port_tc_desc);
5.2 在 QEMU 中运行 kselftest
kselftest 官方支持在 QEMU 自动化运行,这对 CI 环境极为关键:
# 编译带 kselftest 的内核
make -C tools/testing/selftests INSTALL_PATH=./ksft_install install
# 生成 QEMU 启动脚本
./tools/testing/selftests/run_kselftest.sh \
--qemu-args="kernel=/path/to/bzImage rootfs=/path/to/rootfs.img"
# 结果自动通过串口回传到 host
内部机制:QEMU 启动定制化的 virtio 内核,在内核启动后执行 /sbin/kselftest-runner.sh,串口输出作为 JUnit 文件传回。
6.3 编写跨内核版本兼容的测试
内核 API 会随版本演变。一个 production-quality 的 kselftest 应该优雅处理这种差异:
#include <linux/version.h>
/* 旧版内核没有 dup2 系统调用模拟 */
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6,8,0)
static void check_new_fallback_behavior(struct kunit *test)
{
KUNIT_EXPECT_EQ(test, new_api_call(), 0);
}
#endif
static void check_compat_layer(struct kunit *test)
{
/* 公共路径:两版内核都能走的逻辑 */
KUNIT_EXPECT_EQ(test, legacy_api_call(), 0);
}
/* kselftest 里依赖运行时检测而不是编译时 */
static void runtime_feature_detect(struct __test_metadata *_metadata)
{
if (!kernel_has_xarray_rcu_walk())
_metadata->skip = true; /* 自动 SKIP 而非 FAIL */
}
六、CI 持续集成实战
6.1 Gerrit + KernelCI 结合的工作流
现代 kernel patch review 流程中,kselftest 是 Gerrit bot 自动触发的:
开发者 push patch 到 Gerrit
│
▼
Patchwork CI 触发
│
├── checkpatch.pl (静态检查)
├── kselftest-builder (编译 + 运行网络/stack/vm/...)
├── kunit.py (KUnit 全量运行)
└── syzkaller (模糊测试)
│
▼
+1/-1 反馈到 Gerrit Change
6.2 自建 CI:Linux 内核模块开发的推荐流程
假设你在维护一个外核模块(out-of-tree module)的私有仓库:
# .github/workflows/ci.yml
name: Kernel Module CI
on: [push, pull_request]
jobs:
kunit-test:
runs-on: ubuntu-24.04
container:
image: ubuntu:22.04
steps:
- uses: actions/checkout@v4
- name: Install deps
run: |
apt-get update && apt-get install -y \
build-essential linux-headers-$(uname -r) \
kmod clang llvm
- name: Run KUnit tests
run: |
cd module/
make -C /lib/modules/$(uname -r)/build \
M=$(pwd) KUNIT=1 test
kselftest-lite:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Run scripts/ integration tests
run: |
chmod +x test/*.sh
make -C test run_tests
6.3 覆盖率与回归检测
推荐组合:
# KUnit 覆盖率
./tools/testing/kunit/kunit.py run --kunitconfig=lib/ --allconfigs
# kselftest + gcov
make -C tools/testing/selftests GCOV_PROFILE=1 run_tests
lcov --capture --output-file coverage.info
genhtml coverage.info --output-directory cov-html/
使用 gcovr 或 lcov 生成 HTML 报告,可以直接集成到 CI 中作为质量门控。
七、总结与工程观点
kselftest 和 KUnit 代表了 Linux 内核社区在质量保障上的两种工程哲学:
- KUnit 追求"尽早、尽小、尽快"的反馈循环——函数级别的纯逻辑验证,启动到出结果通常在毫秒级。
- kselftest 追求"端到端行为一致性"——通过完整的用户态-内核态交互路径,验证补丁不破坏用户可见的行为。
对于从事内核/驱动/eBPF 开发的工程师,我的建议是:
- 先 KUnit 后 kselftest: 纯计算逻辑先写单元测试;用户态交互再补端到端测试。每个 patch 都应附带至少其中一个。
- 尽量混合驱动数据: 使用
KUNIT_ARRAY_PARAM管理测试矩阵,手动维护枚举用例容易失真。 - 不做"全绿即可"的表面文章: 错误的测试比没有测试更危险。ASSERT 条件要精确,避免永远为真的 check。
- perf comparisons 要用 kselftest: 性能测试必须跑在真实环境(disable Turbo、lock frequency),KUnit 不适合度量真实延迟。
- SKIP 不是耻辱,是精确性: 条件不满足时果断 SKIP,比硬凑一个 PASS 要诚实得多。
Linux 内核是目前为止世界上最大的协作软件项目之一,而 kselftest + KUnit 两大框架是这个项目的"免疫系统"。深入掌握它们,才能写出真正能在主干活 10 年的代码。

发表评论 取消回复