尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

KUnit Linux内核单元测试框架实战指南

KUnit Linux内核单元测试框架实战指南 KUnitLinux内核的单元测试框架。如果你经常看内核邮件列表或者翻主线代码肯定见过*_kunit.c这类文件如果你维护过驱动、写过文件系统模块大概率也会好奇这套东西到底怎么用。我最初接触KUnit时的第一反应是这玩意儿资料不少但要么太浅只给一个“Hello World”要么一上来就甩全套框架源码解析实际落地时反而不知道该抄哪份作业。这篇文章就是给你一个“够用就行”的KUnit知识体系知道它是干什么的能解决什么问题然后立刻动手写出第一个能跑的测试。不会把框架内部设计掰碎了讲但会把套件、用例、断言、运行方式这些核心概念都盘明白最后附上我实际踩过的坑。1. KUnit到底是个啥从“给内核函数写测试”这个痛点说起1.1 内核代码测试为什么这么难内核态代码和用户态程序有一个本质区别它不能像普通进程那样随意启动、调用、退出。用户态写单元测试可以直接foo()调一下断言返回结果跑完就结束。内核里不行一个函数可能依赖全局状态、设备寄存器、内存分配器、锁机制单独摘出来非常费劲。早期给内核函数做测试最土的办法是写一个内核模块insmod之后printk打印几个关键点然后用dmesg肉眼对比再rmmod卸载。不能说没用但问题很明显没有统一断言没有测试汇总没有自动化的结果判定全靠人眼和记忆力。另一个麻烦是环境。内核代码运行在内核态测试一旦踩到非法指针就是oops严重时直接panic整台机器重启。所以很多人宁愿写用户态程序模拟函数也不愿意在内核里测。但模拟又解决不了“真实内核环境”带来的差异比如锁上下文、preempt状态、内存页大小、结构体内存对齐这些在用户态很难完全复现。KUnit就是冲着这个痛点来的。它允许你在一颗真实内核里运行单元测试把测试代码编译成一个模块或直接编译进内核加载后自动执行测试用例然后把结果用标准TAPTest Anything Protocol格式打印出来。断言失败不会导致panic而是记录成一条失败记录框架会继续往下跑最后汇总给你“几个通过、几个失败”。这听起来不算复杂但解决了“能不能安全地测、能不能自动判定结果”这两个最关键的问题。1.2 KUnit的设计思路一套跑在内核态的“断言式”测试KUnit的设计思路其实很朴素把测试用例注册到内核里的一个核心模块然后由这个模块调度执行统一收集断言结果。日常写测试时你只需要构造一个struct kunit指针传入测试函数然后调用各种KUNIT_EXPECT_*断言宏检查结果。这套框架最大的特点是轻量。它不依赖外部用户态库也不强制依赖真实硬件。无论是x86、ARM还是RISC-V只要Linux内核能跑KUnit就能跑。尤其值得说的是KUnit可以通过UMLUser Mode Linux运行也就是把一个简化版Linux作为用户态进程跑起来测试在这个“内核进程”里执行。这样一来你不需要准备虚拟机镜像不需要QEMU不需要target设备在开发机上直接一条命令就能跑完一大批单元测试速度还不慢。对于日常开发这个体验非常重要因为只有跑得快、跑得简单你才愿意每次改完代码都跑一遍。KUnit还做了一件贴心的事资源自动管理。你在测试里用kunit_kzalloc分配内存或者在资源列表里挂上清理函数当用例结束无论成功失败框架都会把资源统一收掉。这样测试代码不用到处处理异常分支也减少了很多因测试代码本身引入的内存泄漏。1.3 KUnit和selftests、kprobes、ktest的分工接触内核测试的时候容易把一堆工具混在一起。我简单给你分一下工方便你按场景选型。工具/方案运行位置主要用途和KUnit的区别KUnit内核态可UML运行单元测试测函数/算法/数据结构聚焦“某个表达式/断言是否成立”kselftest用户态为主系统调用、proc接口、用户态API测试更适合测“外部可观察行为”kprobes/ftrace内核态动态跟踪、插桩、性能分析不是测试框架是观测工具ktest多机/虚拟机启动测试自动化重启、配置内核、跑测试侧重“启动/安装/整体流程”实际上它们不是替代关系而是互补关系。KUnit适合做开发阶段的“函数级护栏”每次改算法先跑一遍kselftest适合做“系统对外表现”的回归ktest处理多轮启动和升级场景。如果你刚接触KUnit不需要关心后两者但要知道它们的边界避免拿KUnit去干它不擅长的事。2. 够用就行的核心概念suite、case、断言2.1 三个基本层级suite、case、expectation使用KUnit之前先建立一个小模型。整套测试体系其实只有三层测试套件suite、测试用例case、断言expectation。测试套件是一组相关用例的集合。比如你想测string模块就会建一个string_test_suite里面放test_strscpy_basic、test_strscpy_truncate、test_kstrtobool等多个用例。测试用例就是一个普通的函数签名固定为void func(struct kunit *test)。这个struct kunit *是框架给你的“测试上下文”所有断言宏都要把它作为第一个参数传进去它用来记录当前测试状态、管理资源。断言则是你对某个表达式的预期判断比如KUNIT_EXPECT_EQ(test, result, 5)如果result不等于5这条断言就失败测试用例最终标记为fail。运行流程也很直接内核启动或模块加载时宏kunit_test_suite(suite)会把suite注册到KUnit核心执行时框架遍历suite.test_cases数组逐个调用测试函数每个用例执行结束后框架收集该用例内的断言结果以TAP格式输出到内核日志或kunit.py的终端。新手最容易混淆的是“一个用例里可以有多个断言”并且“一个用例不一定要所有断言都通过才算有价值”。KUnit的默认行为是断言失败不会立刻中止整个用例只是记录失败继续执行后面的代码。这一点后面我会讲到在需要提前退出时要手动return。2.2 必须认识的几个宏KUnit没有太多魔法大部分时候你只需要记住几个宏就可以了。我把最常用的列在下面宏作用kunit_test_suite(name)定义并注册一个测试套件到内核KUNIT_SUITE(name)声明struct kunit_suite变量KUNIT_CASE(func)将一个测试函数声明为套件中的一个用例KUNIT_EXPECT_EQ(test, left, right)断言left right失败不退出KUNIT_EXPECT_NE(test, left, right)断言left ! rightKUNIT_EXPECT_STREQ(test, left, right)断言两个字符串相等KUNIT_EXPECT_TRUE(test, cond)断言条件为真KUNIT_EXPECT_FALSE(test, cond)断言条件为假KUNIT_EXPECT_NULL(test, ptr)断言指针为空KUNIT_EXPECT_NOT_ERR_OR_NULL(test, ptr)断言指针不是错误指针且非空KUNIT_ASSERT_EQ(test, left, right)断言相等失败则立即终止当前用例注意EXPECT和ASSERT的区别。我最初用KUnit时习惯全用EXPECT结果某个用例后面访问空指针导致误报成oops。后来才改成涉及后续代码安全的前提条件比如“取到的指针不能是NULL”用KUNIT_ASSERT_NOT_ERR_OR_NULL普通结果对比用EXPECT系列这样能在一个用例里尽量多暴露几个问题。2.3 回调函数init、exit、resources一个套件不只是“用例列表”这么简单它还有几个可选回调字段static struct kunit_suite example_test_suite { .name example, .init example_init, // 每个用例执行前调用 .exit example_exit, // 每个用例执行后调用 .test_cases example_test_cases, }; kunit_test_suite(example_test_suite);.init会在套件内每个用例执行前被调用.exit则在每个用例执行后被调用。你可能会想这不就是setUp和tearDown吗对和JUnit、Go test里的setUp/tearDown是一个意思。适合放一些每个用例都要用的公共准备工作比如分配公用的mock结构体、准备固定的输入数据。但更推荐的方式是结合KUnit的资源机制。框架提供kunit_kzalloc、kunit_kmalloc以及更通用的kunit_add_action在用例结束后由框架统一释放。这样你连.exit都不需要写太多清理逻辑const char *buf kunit_kzalloc(test, size, GFP_KERNEL); KUNIT_ASSERT_NOT_ERR_OR_NULL(test, buf); // 用完不手动释放用例结束自动回收资源机制有个很实用的好处即使断言失败导致提前return资源依然会被回收不会泄漏。这是手写printk调试完全比不了的。2.4 配置项与编译框架速览要让测试文件参与构建需要处理两个层面KUnit核心本身以及你的测试模块。KUnit核心的配置项是CONFIG_KUNIT它可以是y、m或n。大多数时候你希望测试能灵活打包所以开发机上设成m或y都行。但如果你用kunit.py run的UML模式框架会自动帮你生成.kunitconfig并启用相关配置这个暂时不用手写。你自己的测试代码通常放源码树里比如我习惯在lib/或drivers/xxx/下创建一个xxx_kunit.c。然后在Kconfig中添加一个CONFIG_XXX_KUNIT_TEST选项并在里面depends on KUNIT在Makefile中添加obj-$(CONFIG_XXX_KUNIT_TEST) xxx_kunit.o。Kconfig片段大致这样config XXX_KUNIT_TEST tristate KUnit test for xxx if !KUNIT_ALL_TESTS depends on KUNIT default KUNIT_ALL_TESTS这样配置含义是正常情况下默认关闭但如果你开了CONFIG_KUNIT_ALL_TESTS所有这类测试都会默认编进。这也是主线内核测试文件的常见写法。足够“够用就行”。3. 5分钟把第一个KUnit测试跑起来3.1 环境准备内核源码、工具链、依赖包动手之前先确认环境。你至少需要一份内核源码树推荐用你正在开发的分支或者上游某个固定tag。如果只是学习直接git clone主线仓库也可以但记得后续编译会比较吃磁盘和时间。工具链方面x86开发机上需要gcc、make、flex、bison、libelf-dev、openssl-devel这一类基础构建依赖。不同发行版包名略有差异装好能编译内核的基础包就行。如果你之前编过内核那么环境基本是满足的。然后安装Python3。KUnit自带的kunit.py是Python脚本主要用来帮你处理UML构建、运行、解析TAP输出。如果你的内核源码版本比较老比如5.4之前可能还没有这个工具建议直接切到5.15或更新的版本。现在主线上的KUnit已经很成熟文档也完善遇到问题上网搜答案也容易。3.2 编写第一个测试文件对一个简单函数下手我们挑一个内核里常用的函数来测strscpy。它把源字符串拷贝到目标缓冲并保证NUL结尾返回值是复制了多少字节不含NUL如果源字符串太长装不下则返回-E2BIG。拷贝长度、返回值、目标缓冲内容都是可预期的非常适合演示。在源码树的lib/目录下新建一个string_kunit_demo.c// SPDX-License-Identifier: GPL-2.0 #include kunit/test.h #include linux/string.h static void test_strscpy_basic(struct kunit *test) { char dst[6]; int ret; ret strscpy(dst, hello, sizeof(dst)); KUNIT_EXPECT_EQ(test, ret, 5); KUNIT_EXPECT_STREQ(test, dst, hello); } static void test_strscpy_truncate(struct kunit *test) { char dst[3]; int ret; ret strscpy(dst, hello, sizeof(dst)); KUNIT_EXPECT_EQ(test, ret, -E2BIG); KUNIT_EXPECT_STREQ(test, dst, he); // 紧凑截断保证NUL结尾 } static struct kunit_case string_demo_test_cases[] { KUNIT_CASE(test_strscpy_basic), KUNIT_CASE(test_strscpy_truncate), {} }; static struct kunit_suite string_demo_test_suite { .name string_demo, .test_cases string_demo_test_cases, }; kunit_test_suite(string_demo_test_suite); MODULE_LICENSE(GPL);这里有两个细节要注意。第一KUNIT_CASE数组最后一个空项不能漏它表示用例列表结束。第二如果这个文件会被编译成模块建议加上MODULE_LICENSE(GPL)避免加载时提示license问题虽然它不影响测试逻辑但省得被模块加载日志吵到。3.3 用kunit.py一键运行不用QEMU也行写完测试文件后先别急着直接make。内核源码自带了一个tools/testing/kunit/kunit.py脚本它会把测试编进一个UML内核然后快速运行。我最常用的命令是cd $KERNEL_SRC ./tools/testing/kunit/kunit.py run第一次运行会花一些时间构建UML内核CPU多的话大概几分钟。如果一切正常脚本会编译、启动、执行测试然后输出TAP格式的结果TAP version 14 1..2 ok 1 - string_demo.test_strscpy_basic ok 2 - string_demo.test_strscpy_truncate看到ok前缀就是通过的用例not ok就是失败。你不需要手动insmod也不需要启动虚拟机脚本全程帮你搞定。在我自己的开发流程里这个命令基本替代了“写printk、编译、刷板子、看串口日志”这一整套操作。如果只想运行自己关心的套件可以用--filter参数比如./tools/testing/kunit/kunit.py run --filter string_demo这个参数在套件很多时特别好用能省不少时间。3.4 手写Makefile和Kconfig的参考姿势如果你不打算用kunit.py而是想把测试编成普通内核模块在真实内核里加载运行也可以。你需要让构建系统看到这个文件。在lib/Kconfig.debug或者你自己的子目录Kconfig里加一个配置项然后在对应Makefile里加一行obj-$(CONFIG_STRING_DEMO_KUNIT_TEST) string_kunit_demo.o配置项类似这样config STRING_DEMO_KUNIT_TEST tristate KUnit test for string demo depends on KUNIT default KUNIT_ALL_TESTS之后在内核目录运行make menuconfig找到对应选项打开或者直接在.config里手动写CONFIG_STRING_DEMO_KUNIT_TESTm然后make modules。加载模块后可以用dmesg查看TAP输出。不过我的建议是如果只是想快速试水和学语法优先走kunit.py因为手写Kconfig和模块依赖会引入很多和“测试逻辑”无关的坑影响你的学习节奏。先把用例写明白再考虑集成到真实构建系统。4. 常见问题与排查技巧实录4.1 编译时找不到KUNIT宏最常见的问题是编译时提示kunit/test.h: No such file or directory这基本可以确定是KUnit核心没有被启用或者头文件路径不对。你用kunit.py run时它自己会处理.kunitconfig但如果你是在真实内核里手写模块就要检查三件事.config里有没有CONFIG_KUNITy或CONFIG_KUNITm测试文件有没有准确#include kunit/test.h如果测试模块依赖KUnit而KUnit本身是m你的测试也要编成m。还有一个容易忽略的点KUnit的头文件依赖CONFIG_KUNIT但如果你用的内核版本比较老某些EXPECT宏可能不存在。主线内核和长期支持分支在KUnit API上有细微差异我遇到过把5.15上的测试代码直接挪到5.10上编译失败的情况解决办法是去对应版本的Documentation/dev-tools/kunit.rst查一下宏定义。4.2 测试没有执行或没有进入suite有时候编译没问题模块也加载了但dmesg里看不到你预期的测试输出。我遇到过的原因主要有三种。第一种是kunit_test_suite(suite)没被编译进去。这个宏会把suite放到一个特殊的section里只有包含了这个宏的编译单元被链接进内核或模块KUnit核心才能找到它。检查一下你的Makefile拼写比如obj-$(CONFIG_XXX) xxx_kunit.o如果变量名和Kconfig里的配置项不一致文件根本没进构建。第二种是套件里的用例数组结尾没有空项。KUnit遍历test_cases数组时靠空项结束判断如果漏了{}它会一直读越界内存结局不可预料大概率不是崩溃就是测试列表异常。第三种是你加载模块前KUnit核心没有初始化。如果你把CONFIG_KUNITm要先确保KUnit core模块已经被加载。很多发行版不会默认加载需要手动modprobe kunit然后再加载你的测试模块否则注册入口根本找不到。4.3 日志里TAP结果怎么读KUnit运行完会在内核日志里输出TAP格式结果。如果你手写模块加载用dmesg | tail -100查看可能看到类似这样的内容TAP version 14 1..2 ok 1 - string_demo.test_strscpy_basic ok 2 - string_demo.test_strscpy_truncate这里1..2表示这个测试计划包含2个用例。ok是passnot ok是fail。后面跟着的是套件名.用例名。如果断言失败还会有失败详情比如期望值、实际值、所在文件和行号。需要特别注意的是当你把KUnit输出和内核其他日志混在一起时可能被printk的日志级别截断或穿插。用kunit.py run会过滤只显示TAP相关内容所以可读性更好。如果是在真机上调试建议把dmesg的日志重定向到文件再搜索TAP version。4.4 踩过的坑资源释放、断言副作用、模块依赖我踩得最深的坑是资源释放。早期我在测试里直接用kzalloc分配内存然后在用例末尾kfree。看起来没问题但中间某个断言失败我提前return了内存就泄漏了。KUnit每个用例会重复执行很多遍积少成多很容易看到系统内存被吃光。后来我改成统一用kunit_kzalloc让框架管生命周期再也没碰过这个坑。另一个坑是断言副作用。如果你用KUNIT_ASSERT_*在循环里它失败会直接终止整个用例这没问题但如果在一个用例里依赖“前面某个断言先执行”并且失败后没有提前返回后面的代码可能操作无效数据。所以我的原则是在不影响安全性的地方用EXPECT在可能引起后续oops、越界、空指针的地方用ASSERT。最后是模块依赖。如果测试代码和被测试的代码在同一个模块里而你又希望单独对某个静态函数做测试会遇到符号不可见的问题。一个常见的偏方是直接在测试文件里#include被测试的.c文件但这样会造成整个文件重复编译容易引入重复定义。更干净的做法是拆分代码把要测的逻辑抽成不依赖硬件和内部状态的辅助函数放在foo.c中导出符号KUnit测试模块通过外链符号调用它。这个习惯会倒逼你写出更可测试的代码。5. 再往前一步KUnit能怎么往上使5.1 怎么用KUnit测驱动子系统的一部分很多驱动开发者一听说“给驱动写单元测试”就头疼因为驱动和硬件强绑定拿不到设备寄存器就没法测。但实际驱动里并非所有逻辑都依赖硬件。比如firmware文件解析、参数校验、状态机转移、校验和计算这些都是纯CPU逻辑完全可以抽出来单测。我的做法是在驱动源文件里把这类逻辑放到一个独立的函数中不直接调用硬件的readl/writel而是通过函数参数传入要处理的数据。然后给这个驱动加一个KUnit测试文件专门喂边界值和异常数据断言处理结果是否符合预期。测试文件可以通过导出符号或把辅助函数放到独立文件的方式访问被测试逻辑。跑KUnit时不需要真实硬件因为测试路径根本不访问寄存器这也是KUnit在驱动开发里最大的价值给最常出错的分支逻辑建一道自动护栏。5.2 用KUnit做回归测试和CI集成当测试用例积累到几十个之后手动跑就变得乏味必须考虑自动化。kunit.py本身能自动构建和运行很适合放进CI流程。我自己的脚本大致这么用./tools/testing/kunit/kunit.py run \ --build_dirbuild/kunit \ --kunitconfigmy_kunitconfig \ --filter mydriver其中--build_dir指定输出目录方便增量构建--kunitconfig指定测试配置--filter指定只跑我的套件。如果想知道CI里哪次提交引入了回归只需要把测试结果输出为标准TAP文件再用工具解析即可。KUnit运行有非零退出码失败了会让CI流水线失败这就达到了“自动回归门禁”的效果。不过要注意KUnit跑在UML里的环境和你真实目标机环境有差异。某些和特定架构、页大小、cache行为相关的逻辑UML上可能测不出问题。所以KUnit适合做第一层门禁真机上的kselftest或实机冒烟测试该有还得有。5.3 KUnit的局限与“够用就行”的边界最后说句实在话KUnit不是银弹。它适合测单函数、纯算法、数据结构、可隔离的状态机不适合测完整设备初始化流程、中断时序、多核并发竞争、真实DMA行为。想测这些要么用真实硬件跑集成测试要么用QEMU配合kernel selftest做系统级验证。KUnit的mock设施也比较基础没有像用户态mock框架那样动态拦截函数调用的能力复杂依赖只能靠设计上的抽象来隔离。“够用就行”的意思就是知道它的边界后在边界内最大化利用它。我个人的体会是凡是一个函数满足“输入明确、输出可预测、不依赖外部硬件”都值得顺手补一个KUnit用例。刚开始可能觉得写测试的时间比写功能还长但只要做过一次重构、改过一条业务逻辑、结果跑挂一个老用例你就会明白这些测试省下的时间远大于当初投入的时间。你现在不需要把KUnit的所有高级API都背下来先掌握我这里讲的suite、case、断言把第一个测试跑通剩下的一切都会随着你的实际需求自然长出来。
返回列表