ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Ubuntu内核编译实战:手把手添加自定义系统调用

Ubuntu内核编译实战:手把手添加自定义系统调用 做操作系统实验或者研究Linux内核安全的朋友迟早会遇到一块硬骨头在Ubuntu上增加一个自己的系统调用。这玩意听起来很高级本质拆开看其实就三件事——改一张表、加一行声明、写一个函数然后重新编译一遍内核。麻烦的部分全在“整套流程”上环境要配、工具链要齐、内核版本要对稍不留神编译到一半就报错光排错就能耗掉半天。这篇文章我就从零开始完整走一遍在Ubuntu上增加系统调用的流程包括为什么要编译内核、系统调用的注册机制、用户态和内核态的数据怎么传递以及我实际踩过的各种编译坑。适合刚接触Linux内核、准备做OS课程实验或者想在项目里给特定功能加一个内核入口的读者。内容会偏实操我不会把内核源码翻个底朝天但每个关键步骤背后的“为什么”都会讲清楚。1. 先搞清楚你在干什么系统调用到底是什么1.1 系统调用的运行机制系统调用是用户态程序进入内核态的唯一合法通道。你在用户态写C代码用的open()、read()、write()本质上都不是CPU直接执行的函数而是glibc对系统调用的封装。底层流程是glibc把系统调用号放进寄存器把参数放进另外几个寄存器然后执行一条syscall指令x86_64架构老一些的32位环境是int 0x80CPU随即切换到内核态内核根据系统调用号在sys_call_table这张表里索引到对应函数执行再返回用户态。这张表就是关键。它本质上是一个函数指针数组数组下标就是系统调用号数组内容就是内核函数的入口地址。比如open的系统调用号是2那么sys_call_table[2]指向的就是ksys_open这个内核函数。你想增加一个新的系统调用说白了就是在数组末尾加一项然后为这一项实现一个内核函数。1.2 自己加一个系统调用的应用场景可能有人会问现在内核已经有好几百个系统调用了难道还不够用吗这个问题的答案取决于你是谁、在做什么。操作系统课程里老师大概率会布置“增加一个系统调用”的作业目的是让你把用户态、内核态、系统调用表、内核编译这一整套链路跑通。在真实工程中新增系统调用的需求确实不多但有几种典型场景需要一个极小的、绕过所有libc封装的内核功能入口比如直接读取某个内核内部计数器。需要一个内核和高特权用户态程序之间的专用通信通道不希望暴露成普通设备文件。安全研究场景下需要验证某个内核防护机制是否覆盖本地系统调用路径。大多数时候我们想扩展内核功能优先会考虑内核模块insmod那种动态加载的方式根本不用改系统调用表。所以新增加系统调用这个操作更多是“学习链路”和“验证理念”的价值。1.3 为什么非得编译内核模块不行吗很多第一次接触这个实验的人都会有一个疑惑系统调用表是动态的吗我能不能在内核模块里往sys_call_table里加一项答案是模块能“篡改”表项但没法真正“新增”一个系统调用号。原因在于系统调用号的值比如__NR_open 2是用户态和内核态之间的一个公共约定。这个约定在编译内核时会被写成头文件asm/unistd_64.h用户态程序和内核模块都用它来对齐。模块加载到已经编译好的内核里时系统调用表的大小已经固定了你没法给一张固定大小的数组新增元素。虽然老内核里可以通过查找sys_call_table地址后修改某个表项来“劫持”已有调用号但这是黑客做法不属于“增加系统调用”的语义而且新内核里查表符号已经不再导出做法也越来越受限。所以结论很明确正规的、可持续的“增加一个系统调用”方案就是改内核源码里的系统调用表然后重新编译整个内核。这就是为什么这个项目必须和“编译内核”绑定在一起。2. 动手前的准备工作2.1 版本选择与环境依赖内核版本我推荐直接用主线长期维护版比如6.6.x或者6.1.x而不是用Ubuntu官方源里的自定义内核源码树。Ubuntu的自定义内核带了一堆发行版补丁对新手来说反而容易把问题复杂化。再者主线内核编译出的模块和当前系统内核的API差异较小遇到问题的概率更低。如果你用的是Ubuntu 22.04或24.04选择一个接近当前uname -r版本的主线内核会比较稳妥例如当前是6.8.0就选6.6 LTS分支。在开始之前先把编译工具链装齐不然编译到一半缺这缺那很崩溃。下面这行命令把常用依赖全部装上sudo apt update sudo apt install build-essential flex bison libncurses-dev libssl-dev libelf-dev dwarves bc rsync cpio当中每个包都有具体用途build-essentialgcc、make等基础编译工具。flex、bison内核配置脚本和设备树解析器会用到这两个生成器。libncurses-dev提供make menuconfig的文本图形界面。libssl-dev内核缺它会在生成证书和密钥相关代码时报错非常常见。libelf-dev内核的BPF和BTF相关工具链需要处理ELF文件。dwarves提供pahole命令没有它编译时若开启了CONFIG_DEBUG_INFO_BTF会出现BTF生成错误。bc内核构建脚本里有不少数值运算依赖它。这里建议不要偷懒跳过。我见过不少人只装了build-essential就开干结果在make阶段因为缺bison弹了一屏报错又回去重装白白浪费时间。2.2 下载并解压内核源码下载源码建议直接从内核官网获取例如https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.24.tar.xz。选版本的时候尽量选 .y 结尾的稳定修订版带rc字样的预发布版本不要碰。mkdir -p ~/kernel-lab cd ~/kernel-lab wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.24.tar.xz tar -xJf linux-6.6.24.tar.xz cd linux-6.6.24解压之后建议先确认磁盘空间。编译一个常见配置的内核源码加中间产物加模块通常需要10~20GB空间。你可以在编译前先执行df -h看一下挂载根目录或/usr/src所在分区的剩余空间防止编译到一半磁盘写满。后续编译产物会大量落在源码目录里所以源码目录所在分区尤其要留够余量。有一点要提醒不要在/tmp这类可能有noexec挂载选项的目录下解压和编译内核构建脚本里有些辅助程序需要执行权限。2.3 内核配置别让配置挡住你的路内核源码解压后系统默认没有.config配置文件。两个选择一是直接make menuconfig手工往里灌一套配置但几万个选项显然不现实二是我推荐的做法直接复用当前系统内核的配置。cp /boot/config-$(uname -r) .config make olddefconfigolddefconfig会根据当前系统已有的配置选项自动把新内核里新增的配置项填上默认值。这能最大程度保证编译出来的新内核和当前系统的硬件、驱动兼容。然后执行make menuconfig我们需要改三个地方修改General setup - Local version建议加上一个自定义后缀例如-mycall。这样编译出来的内核uname -r会显示成类似6.6.24-mycall的版本号方便和原系统内核区分后续引导也不会两个内核傻傻分不清。检查CONFIG_SYSTEM_TRUSTED_KEYS如果它指向某个.pem文件但在当前环境不存在编译时会报证书相关错误。最省事的办法是把该项留空实验环境不需要模块签名。搜索CONFIG_DEBUG_INFO_BTF如果已经安装dwarves可以保留如果没装最好关掉它避免后面生成BTF时卡住。配置保存后我建议先编译一下内核确认基础环境没问题再开始改系统调用代码。也就是说第一次编译的目标是“跑通编译链”而不是马上验证新系统调用。这样一个环节一个环节排错后面遇到问题会更容易定位。3. 手把手添加一个系统调用3.1 找到系统调用表并添加系统调用号以x86_64架构为例系统调用表位于arch/x86/entry/syscalls/syscall_64.tbl。这个文件内容大概长这样548 common rseq sys_rseq 549 common kexec_file_load sys_kexec_file_load ...每行包含四个字段系统调用号、ABI类型、调用名、入口函数名。系统调用号表内必须唯一。ABI类型common表示64位和x32都可用64表示仅64位进程可用还有i386用于32位兼容表。调用名用户态syscall()函数不直接用这个名字它是给人看的约定名称。入口函数名内核里实际执行的函数必须和C代码里定义的函数符号一致。往这个文件末尾新增一行例如550 common my_echo sys_my_echo系统调用号选多少原则是选取当前表内最大编号加1避免覆盖已有调用。不同内核版本最大编号不一样你打开文件看一眼最后一行就清楚了。这里我用550举例实际以你手里的表为准。顺带说一句如果以后需要在32位x86环境下也能调用就需要同步修改arch/x86/entry/syscalls/syscall_32.tbl。本实验用64位系统就专注64位不用管它。3.2 声明系统调用原型系统调用原型的统一声明位置在include/linux/syscalls.h。这个文件里全是asmlinkage long sys_xxx(...)形式的声明我们照着格式追加一行asmlinkage long sys_my_echo(const char __user *msg);注意两个细节asmlinkage是编译器宏它告诉gcc这个函数的参数通过栈传递而不是寄存器这是系统调用约定的一部分不能省。__user不是真正的类型修饰符它只是给静态分析工具Sparse看的标记表示这个指针来自用户空间内核代码不能直接解引用。后面写实现的时候你会看到它的作用。3.3 编写系统调用的内核实现内核实现我建议单独建一个文件比如kernel/my_echo.c这样结构清晰。以下是一个经典示例接收一个用户态字符串拷贝到内核缓冲区打印到内核日志并返回字符串长度。// kernel/my_echo.c #include linux/kernel.h #include linux/syscalls.h #include linux/uaccess.h #include linux/string.h SYSCALL_DEFINE1(my_echo, const char __user *, msg) { char buf[256]; long len; memset(buf, 0, sizeof(buf)); // 安全地把用户态字符串拷贝到内核缓冲区 len strncpy_from_user(buf, msg, sizeof(buf) - 1); if (len 0) return -EFAULT; printk(KERN_INFO my_echo: %s\n, buf); return len; }SYSCALL_DEFINE1是一个宏它会把my_echo声明并定义为真正的入口函数sys_my_echo。这个宏有SYSCALL_DEFINE0到SYSCALL_DEFINE6的不同版本分别对应0到6个参数。为什么建议用宏而不是直接写一个long sys_my_echo(...)因为宏会自动处理一些架构相关的兼容性包装而且和内核对系统调用函数的生成规则保持一致。写完C文件后还要把它加入编译目标。在kernel/Makefile中找到合适的位置追加一行obj-y my_echo.oobj-y表示这个文件无条件编译进内核镜像而不是作为模块。3.4 用户态与内核态的数据边界新手最容易踩的坑就是在内核函数里直接解引用用户态指针。比如直接写printk(%s\n, msg)然后一跑就崩溃内核报段错误。原因很简单用户态进程的虚拟地址空间和内核对进程的映射是不同的用户态传进来的指针只是一个指向用户空间地址的数值很可能没有映射到内核地址空间。内核直接去访问这个地址就会触发缺页异常严重情况下会直接导致oops甚至系统崩溃。再加上恶意程序完全可以传一个非法地址给你如果内核不做检查就访问那就是一个可利用的内核漏洞。正确做法是用strncpy_from_user/copy_from_user这类接口。它们内部会先做access_ok检查确认用户地址范围合法再通过异常表机制安全地逐字节拷贝。如果拷贝出了问题会返回一个负的错误码我们直接把这个错误码往上传给用户态即可。对应地如果要从内核往用户态拷数据用copy_to_user。这组接口是现代内核与用户态进行数据交换的标准通道。早期的内核还有一套set_fs机制允许临时扩大地址空间访问范围但现代内核5.10以后已经彻底移除了它就是在强制要求内核别碰用户态指针。4. 编译安装新版内核4.1 编译参数与常见错误处理系统调用代码写好后先在源码根目录把配置保存一下然后开始编译make -j$(nproc)-j指定并行编译的进程数理论上是CPU核心数越多越快。但要注意如果机器内存不够比如8GB以下并行编译很容易把内存吃满系统开始疯狂换页反而慢得不行。这种情况下建议用-j4甚至-j2。编译整个内核的时间跨度从10分钟到40分钟不等取决于机器性能。编译过程中最常遇到的坑有三个第一个是BTF相关报错提示pahole不可用。这是CONFIG_DEBUG_INFO_BTF引发的装上dwarves包即可解决或者在配置里关掉它。第二个是签名相关报错类似No rule to make target debian/certs/xxx.pem。这通常是CONFIG_SYSTEM_TRUSTED_KEYS指向了一个不存在的证书文件。如前所述把该项清空再重新编译。第三个是内存不足导致的编译进程被杀日志里能看到Killed字样。解决办法是降低并行度或者临时增加swap空间。我第一次做这个实验时就因为没装dwarves在最后生成镜像阶段被BTF问题卡了快一个小时后来才知道就是个依赖包的事。所以前面强调装齐依赖真的是血泪教训。4.2 安装与引导配置编译成功后依次执行sudo make modules_install sudo make install sudo update-grubmodules_install把内核模块安装到/lib/modules/$(uname -r)。make install把编译好的vmlinuz、initrd.img、System.map等复制到/boot并更新GRUB引导项。update-grub重新生成GRUB菜单让新内核出现在启动列表里。如果你是物理机且开启了Secure Boot新内核因为没签名可能会无法启动需要进BIOS关闭Secure Boot或者在编译时生成并导入自定义密钥。如果只是虚拟机实验环境一般不会有这个问题。安装完新内核后不要急着删旧内核这是个很好的习惯。万一新内核配置有问题起不来还能在GRUB的高级选项里选择旧内核进入系统不至于把自己锁在外面。重启后执行uname -r确认当前内核版本号带上了你之前设置的-mycall后缀就说明你正在运行新编译的内核。4.3 用syscall()函数验证新系统调用验证是最后也是最有成就感的一步。我们写一个普通的用户态C程序直接调用syscall()函数把系统调用号传进去。#include stdio.h #include unistd.h #include sys/syscall.h #define SYS_my_echo 550 int main(int argc, char *argv[]) { long ret; ret syscall(SYS_my_echo, argc 1 ? argv[1] : hello kernel); printf(ret %ld\n, ret); return 0; }编译运行gcc test_my_echo.c -o test_my_echo ./test_my_echo hello from user运行结束后用dmesg看内核日志dmesg | grep my_echo如果一切正常你会看到类似如下的输出[ 123.456789] my_echo: hello from user同时用户态程序打印出字符串长度。这个结果意味着整个链路已经完全打通用户态程序调syscall()系统调用号被内核用来在sys_call_table里索引到sys_my_echo函数安全地从用户空间拷贝字符串写入内核日志再返回结果。这里有个细节可以解释一下为什么用户态不直接定义my_echo()函数然后调用因为glibc库里没有这个系统调用的封装我们也没有改glibc源码。直接用syscall()函数传递系统调用号是最通用的验证方法相当于绕过了glibc封装层直接跟内核打交道。5. 踩坑实录这些问题我基本都遇到过5.1 编译阶段典型问题速查表症状原因解决办法编译到一半报pahole不存在CONFIG_DEBUG_INFO_BTFy但没装dwarves安装dwarves或关闭BTF配置提示找不到证书.pem文件CONFIG_SYSTEM_TRUSTED_KEYS指向不存在的文件清空该配置项提示缺少bison、flex工具链不全安装bison flex libncurses-dev等make menuconfig终端界面无法显示缺少libncurses-dev安装后重新执行编译时内存被耗尽进程被Killed并行度太高降低-j参数或增加swap源码目录所在分区磁盘写满空间预留不足清理旧内核或换到大分区编译其中证书问题和BTF问题占比最高建议大家提前检查.config里的配置项而不是等到报错才回来找原因。5.2 运行时验证典型问题排查症状原因解决办法新系统调用返回-1errno为ENOSYS系统调用号没生效可能运行了旧内核重启后用uname -r确认内核版本内核日志没有my_echo输出printk级别低于当前控制台日志级别用dmesg查看不一定直接在终端显示程序崩溃或内核oops内核函数直接解引用了用户态指针用strncpy_from_user等安全接口替代系统调用返回-1errno为EFAULT用户态传入的地址非法或缓冲区大小超限检查参数合法性合理设置拷贝长度新内核引导失败黑屏或卡在GRUB内核配置缺少必要驱动或Secure Boot问题用GRUB选择旧内核进入排查配置项系统调用号冲突已有调用行为异常新增编号和已有表项重叠使用表中最大编号1ENOSYS这个错误码值得特别说一句。它说明系统调用号在内核表里不存在最常见的诱因是你编译前确认新增了表项但重启后实际跑的还是旧内核。所以我每次重启后第一件事就是uname -r确认版本号里带了自定义后缀再继续验证。另一个容易被忽视的点是如果改的是arch/x86/entry/syscalls/syscall_64.tbl那么必须在源码根目录重新编译完整内核。这个表不是模块不重新编译内核镜像就永远不会生效。有些人会想“我改完表之后只make modules行不行”不行表的内容会被编译进vmlinux主体必须全量编译。5.3 一点实操心得这套流程做完你可能会发现真正写系统调用代码只占了整个项目一小部分时间大头全在环境准备和编译排错上。这其实是内核开发的常态写代码往往不难难的是配置、编译、启动、验证这一整套工程链路。我个人的习惯是新增系统调用之前先克隆一份虚拟机快照。这样如果新内核真的起不来回滚只需要一分钟不用慌慌张张去修GRUB。第二个习惯是每次重启后先uname -r确认当前内核再跑用户态测试程序。第三个习惯是把.config文件备份一份在源码目录外面因为反复make menuconfig可能会不小心改掉关键选项备份能让你随时回到一个已知可编译的状态。内核日志这块也提醒一句dmesg在新一些的Ubuntu版本里可能需要sudo否则看不到内核日志这是内核日志访问权限的限制不是你的代码有问题。如果printk的日志级别小于控制台的console_loglevel日志不会直接刷到屏幕上但一定会在dmesg的输出里所以“日志没显示”不等于“系统调用没执行”先查dmesg再下结论。这个实验做完之后你对操作系统里“用户态”和“内核态”这条分界线的理解会比单纯看书深刻得多。后续想继续深入的话可以沿着两个方向走一是研究一下eBPF如何在不需要修改内核代码的情况下完成类似的内核观测和控制工作二是把新增系统调用改成自己设计一套用户态通信协议在内核和用户程序之间实现定制化的数据交换。这两个方向对理解现代Linux内核的工作方式都很有帮助。
返回列表