调试与测试

本页目录

一次构建成功、一次无告警运行、一个用例 SKIP,分别代表不同结果。内核验证需要把输入、执行路径、环境前提与结果留在同一份记录里。

查阅目标阅读位置
定位执行路径日志与 Dynamic Debug
验证不同边界KUnit、kselftest、sanitizer、模糊测试
失败分支与交付证据故障注入、复现记录
从可复现问题到有针对性的验证 保存环境与复现输入 内核提交、配置、模块、硬件或 VM 参数 1 定位失败机制 日志、trace、sanitizer、锁依赖与断点 2 最小化并修复 保留触发条件和错误路径 3 回归与边界验证 正例、反例、并发及失败注入 4 覆盖率和一次通过都不是正确性证明;工具应围绕待验证的机制选择。

printk 与控制台等级

pr_err、pr_info、pr_debug 等提供日志入口,dev_* 形式附带设备上下文。日志记录到内核缓冲与最终输出到控制台是不同阶段,console_loglevel 过滤控制台输出,不等于删除所有较低等级记录。

日志等级数值越小越严重。console_loglevel=4 通常输出数值小于 4 的消息,即 error 及更严重级别;不应把这项设置描述为系统只有这些日志存在。高频路径日志还会改变时序与性能,需要限速或改用合适跟踪点。

Dynamic Debug 控制已有调用点

dynamic debug 在支持并编译了相应调用点的前提下,按模块、函数、文件、行号或其他条件开启 pr_debug/dev_dbg 等信息。它不能凭空给任意没有调试语句的函数添加入口日志。

当前文档提供 /proc/dynamic_debug/control;启用并挂载 debugfs 时也可能有 /sys/kernel/debug/dynamic_debug/control。先读取实际存在的控制文件,确认条目和原始 flags,再做有限范围修改。Dynamic debug

例如命令字符串 module my_driver +p 表示启用对应模块条目;-p 关闭打印,f/l/t 等标志添加函数、行号和线程信息。恢复时应按先前状态恢复,不要无条件清掉其他人已经启用的调试点。

KUnit 与 kselftest 检查不同边界

KUnit 在内核环境中验证内部函数或子系统逻辑,可通过 UML、QEMU 或测试内核执行。kselftest 通常从用户空间检查内核接口与行为,有些套件会配合测试模块;两者互补。KUnit 入门、kselftest

KUnit 不只有 KUNIT_CASE 数组,还需要注册 suite,并在 Kconfig/Kbuild 中接入。下面用缓冲区区间检查作为例子:容量为 capacity,访问 [offset, offset+length);允许长度为零且 offset 恰好等于容量,但禁止越界。若直接判断 offset + length <= capacity,无符号加法回绕可能把非法区间误认为合法。

保存为 kb_range_test.c:

#include <kunit/test.h>
#include <linux/module.h>
#include <linux/limits.h>

static bool range_fits(size_t capacity, size_t offset, size_t length)
{
    return offset <= capacity && length <= capacity - offset;
}

static void range_boundary_test(struct kunit *test)
{
    KUNIT_EXPECT_TRUE(test, range_fits(4096, 4092, 4));
    KUNIT_EXPECT_FALSE(test, range_fits(4096, 4092, 5));
    KUNIT_EXPECT_TRUE(test, range_fits(4096, 4096, 0));
    KUNIT_EXPECT_FALSE(test, range_fits(4096, 4097, 0));
    KUNIT_EXPECT_TRUE(test, range_fits(0, 0, 0));
}
static void range_overflow_test(struct kunit *test)
{
    KUNIT_EXPECT_FALSE(test, range_fits(4096, SIZE_MAX - 3, 4));
    KUNIT_EXPECT_TRUE(test, range_fits(SIZE_MAX, SIZE_MAX, 0));
    KUNIT_EXPECT_FALSE(test, range_fits(SIZE_MAX, SIZE_MAX, 1));
}
static struct kunit_case range_cases[] = {
    KUNIT_CASE(range_boundary_test),
    KUNIT_CASE(range_overflow_test),
    {}
};
static struct kunit_suite range_suite = {
    .name = "kb-range",
    .test_cases = range_cases,
};
kunit_test_suite(range_suite);
MODULE_LICENSE("GPL");

第一组检查排他上界和空区间契约,第二组检查最大整数附近的加法风险。offset <= capacity 还保证了后续减法不会下溢;C 的 && 短路求值使第二项只在此前提成立时计算。这个 helper 只验证数值范围,不保证指针有效、页面可访问或设备有权执行 DMA。

接入测试源码目录时,增加对应 Kconfig 条目和 Kbuild 规则,并让上层配置与构建包含该目录:

config KB_RANGE_KUNIT_TEST
    tristate "Range boundary tests" if !KUNIT_ALL_TESTS
    depends on KUNIT
    default KUNIT_ALL_TESTS
obj-$(CONFIG_KB_RANGE_KUNIT_TEST) += kb_range_test.o

对接入了该测试的干净源码树,可在独立构建目录使用 .kunitconfig 启用 CONFIG_KUNIT=y 与 CONFIG_KB_RANGE_KUNIT_TEST=y,再按 suite 名称筛选。过滤器只选择已编入的测试,不能代替配置启用。

./tools/testing/kunit/kunit.py run --build_dir=.kunit-range --kunitconfig=.kunitconfig kb-range

默认 UML 路径会构建并运行一个用户态 Linux 测试内核,不是在当前宿主加载测试模块。源码树若已有不兼容的构建产物,应使用单独准备的干净源码副本;不能为了教程命令直接对正在维护的内核树执行 mrproper。KUnit 配置、构建与过滤

内建测试与模块测试的执行时机不同。kunit.py 能帮助配置、构建和执行测试,首次运行可能需要完整的测试内核构建;仅编译该 C 文件并不算测试已经执行。

结果解释:SKIP 与 PASS 分开记录

selftests 的 TARGETS 可筛选套件,但网络、BPF 等测试需要对应内核配置、权限和依赖,并可能改变测试环境。优先运行与修改相关的套件,记录 SKIP 原因;“全部跳过”不是“全部通过”。

sanitizer、锁检查与断点各自提供证据

KASAN 关注适用的越界和释放后使用,KCSAN 帮助发现数据竞争,lockdep 检查锁依赖等约束。它们受配置、执行路径与采样机制影响,不能把未报告问题当作无错误证明。

KGDB 允许在适当配置和调试链路下使用 GDB 调试内核。SysRq-g 可能停住目标内核等待调试器,适合有恢复手段的测试环境;不应作为日常只读诊断命令直接执行。

断点和日志都会改变调度时序。难复现竞争可结合 tracepoint、最小输入与重复触发,保存发生先后关系,而不是只保留最后的栈。

syzkaller 与 LTP

syzkaller 使用系统调用描述、资源关系与覆盖率反馈生成并变异程序,在配置好的目标环境中寻找崩溃、挂起等异常,再尝试最小化和复现。QEMU 是常用执行后端,不是唯一后端;自动 bisect 也需要相应基础设施,不能把任意运行都说成包含它。syzkaller

LTP 提供系统接口等回归测试集合,适合补充内核和 libc 交互行为验证。用例失败可能来自内核、环境、权限或测试前提,应保留具体结果与版本,而不是只报告一个总失败数。LTP

故障注入:把不可控失败变成可验证输入

内存分配、I/O 和函数返回等注入点帮助检查清理、重试和回滚路径。框架的 probability、interval、times、task-filter 及过滤条件共同影响触发;单独把 failslab/interval 设为 100 不保证每 100 次任意分配必定失败一次。故障注入

阶段应确认的事实
注入前目标任务/路径已限定,基线行为明确
注入中预期失败确实发生,清理与回滚可验证
注入后资源已回收,相关参数恢复

理论示例无需在当前宿主开启全局注入;测试 VM 更便于复现和恢复。

按获取顺序逐个失败,检查是否只释放已拥有的资源

假设初始化依次获取 context、buffer、registration,全部成功后才把对象发布给调用者。应分别测试每一步失败,而不是只让第一次分配失败一次就称为“错误路径已覆盖”。

失败位置失败前已拥有应回滚的顺序
context无不释放未获得的资源
buffercontextcontext
registrationcontext、bufferbuffer → context
全部成功后退出三项资源,且对象已发布先停止对外发布,再按资源依赖清理
第三步获取失败:只回收前两步已经拥有的资源 获取 context 成功 获取 buffer 成功 获取 registration 失败,未取得所有权 释放 buffer 释放 context 未成功获得 registration,不释放它;对象尚未发布,调用者不应看到半初始化状态。

下面是确定性的本地生命周期模型。第 fail_at 次获取在建立资源前失败,成功对象的所有权转交给调用者;退出时先撤销发布,再释放资源。它不会开启宿主 failslab,也不模拟真正设备的异步回调。

class Resources:
    def __init__(self, fail_at=None):
        self.fail_at = fail_at
        self.attempts = 0
        self.live = set()
        self.freed = []
        self.published = None

    def acquire(self, name):
        self.attempts += 1
        if self.attempts == self.fail_at:
            raise MemoryError(name)
        assert name not in self.live
        self.live.add(name)
        return name

    def release(self, name):
        assert self.published is None and name in self.live
        self.live.remove(name)
        self.freed.append(name)

    def create(self):
        acquired = []
        try:
            for name in ("context", "buffer", "registration"):
                acquired.append(self.acquire(name))
        except MemoryError:
            for name in reversed(acquired):
                self.release(name)
            raise
        self.published = tuple(acquired)
        return self.published

    def destroy(self, owned):
        assert self.published is owned
        self.published = None
        for name in reversed(owned):
            self.release(name)

for failure, expected in [(1, []), (2, ["context"]), (3, ["buffer", "context"])]:
    resources = Resources(failure)
    try:
        resources.create()
    except MemoryError:
        pass
    else:
        raise AssertionError("目标失败未发生")
    assert resources.attempts == failure
    assert not resources.live and resources.published is None
    assert resources.freed == expected

resources = Resources()
owned = resources.create()
assert resources.live == set(owned) and not resources.freed
resources.destroy(owned)
assert not resources.live and resources.published is None
assert resources.freed == ["registration", "buffer", "context"]

这里不仅检查函数返回失败,还检查目标注入点确实到达、后续获取没有继续、资源没有泄漏或重复释放,以及未完成对象没有对外可见。这些才是错误路径的可验证不变量。

真实驱动的注册操作可能在返回前启动工作,或者外部对象仍持有引用;撤销发布后还需取消新请求、等待在途操作和回调退出,再释放其使用的 buffer/context。机械地反转几条 free 语句并不足够。注入 API 的概率与过滤参数用于触发路径,资源与可见性检查用于判断该路径是否正确,两者不能互相替代。

把失败点放在不同资源取得之后,再切换清理方式。除了剩余资源数量,还检查是否在依赖者仍活着时提前释放了它所依赖的对象。

正在呈现知识画面
失败落在不同位置,清理责任怎样变化?

失败路径只能释放已经取得的资源,并按使用依赖撤销。最后资源数为零也不足以证明中途没有先释放仍被使用的缓冲区。

可交付的复现记录

记录准确的内核版本或提交、.config、启动参数、模块和 taint 状态,以及最小输入、实际行为、预期行为和原始日志。若修复仅在某个配置下验证,应明确该边界。

把这些证据连接到 构建与模块 的产物,才能确认测试运行的确实是目标代码,而不是另一套同名内核。