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 开发的工程师,我的建议是:

  1. 先 KUnit 后 kselftest: 纯计算逻辑先写单元测试;用户态交互再补端到端测试。每个 patch 都应附带至少其中一个。
  2. 尽量混合驱动数据: 使用 KUNIT_ARRAY_PARAM 管理测试矩阵,手动维护枚举用例容易失真。
  3. 不做"全绿即可"的表面文章: 错误的测试比没有测试更危险。ASSERT 条件要精确,避免永远为真的 check。
  4. perf comparisons 要用 kselftest: 性能测试必须跑在真实环境(disable Turbo、lock frequency),KUnit 不适合度量真实延迟。
  5. SKIP 不是耻辱,是精确性: 条件不满足时果断 SKIP,比硬凑一个 PASS 要诚实得多。

Linux 内核是目前为止世界上最大的协作软件项目之一,而 kselftest + KUnit 两大框架是这个项目的"免疫系统"。深入掌握它们,才能写出真正能在主干活 10 年的代码。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部