说实话,我第一次编译内核的理由特别朴素:想给自己的机器加一个自定义的系统调用,看看用户态程序是怎么“掉进”内核里的。结果那一次折腾了两个通宵,中间经历了编译报符号错误、装完重启进不了图形界面、模块版本对不上、安全启动拦下未签名模块等一连串问题。现在回头看,Linux 内核编译流程本身并不复杂,真正的成本在“配置取舍”和“装完之后的收尾”上,尤其是跑在 ubuntu 这类 desktop 发行版上,发行版自行添加的一套东西会让标准的make && make install走不通。这篇就把我整套流程完整拆一遍:从环境依赖、配置裁剪、并行编译、打包安装、回滚预案,一直讲到怎么在源码树里加一个属于自己的系统调用并验证它。内容偏向已经会用命令行、想深入内核层面的读者;完全没接触过内核编译的人也能跟着走,只是需要多一点耐心。
1. 编译内核前需要决策的几件事
1.1 先明确目的:三种动机对应三种完全不同的策略
很多人一上来就问“编译内核要多久”,这个问题其实没有统一答案,因为目的不同,工作量差一个数量级。我把常见动机归成三类,你可以先找自己那一类,再进行手工操作。
第一类是学习目的。想看看.config里几千个选项到底是什么,想知道自己写的系统调用怎么被用户态调用到,想理解 initramfs 和内核模块的关系。这类需求的核心是“看得见”,所以我强烈建议用make menuconfig而不是直接make defconfig,也不要用O=输出目录把中间产物藏到另一个文件夹里,产物的可追溯性比干净整洁重要得多。学习目的也意味着你不需要追求体积最小,CONFIG_DEBUG_INFO该开就开,后面用objdump、gdb看符号全靠它。
第二类是功能定制。典型场景是改了内核里某个参数、打了某个补丁、加了自己写的模块或系统调用,必须重新生成内核镜像。这种需求的核心是“改完的东西要被加载进去”,所以配置基线必须来自你当前正在跑的那个内核的/boot/config-$(uname -r),而不是上游defconfig,否则你会在一台正常的机器上莫名其妙丢掉一堆驱动。
第三类是优化和精简。比如给嵌入式板子或虚拟机裁一个体积小、启动快的内核镜像。这类需求的核心是“删得对”,localmodconfig是关键工具,但它有副作用,后面第 3 节详细说明。
三类动机里,前两类的操作路线几乎一样,第三类会多出一轮反复试错。我下面主要按“第二类 + 顺便学习”的路线写,这是绝大多数人在个人机器上实际会遇到的情况。
1.2 版本选择:优先沿用当前运行的版本,不要一开始就追最新
一个很常见的误区是“既然要编译,那就编最新的”。我不建议这么做,至少第一次不要。原因有三个层面。
第一,你当前系统里跑的内核版本必然和这套系统的用户态组件、固件包、initramfs 工具链是磨合过的。发行版在打包内核时改动过很多细节,比如模块签名策略、initramfs 生成脚本的钩子、DKMS 的适配。你换成上游最新版本,这些适配很可能不再成立;第一次编译就同时面对“新版本兼容问题”和“我的操作对不对”两个变量,排查起来极其痛苦。
第二,版本跨度大的时候,配置项的语义会变。老.config里的一些选项在新内核里被删除、重命名或拆分,make oldconfig会让你一个个回答新选项,答错一个可能就导致某个子系统整个没编进去,而启动过程中你只看到一句“VFS: Unable to mount root fs”,根本不知道是哪一步丢的。
第三,学习成本。同一版本的内核源码目录结构、syscalls表的写法在不同主版本之间有差异。5.x 之后 x86 的系统调用表从“直接写明函数名”变成了带 ABI 标记的写法,6.x 之后sys_call_table的生成方式又变过一次。你在网上搜到的教程大概率和你手上的源码不是同一个大版本,照着抄十有八九对不上。所以版本尽量贴近uname -r,让教程和源码的差距小一点。
具体操作上,先uname -r看当前版本,比如6.8.0-45-generic,那核心版本就是6.8。你可以去源码站下载6.8.x里最新的一个小版本。小版本之间的差异远小于大版本,风险可控。如果你想试新版本,我建议先成功走通一次“同版本重编”,确认自己的流程没问题,再换版本。
1.3 三条安装落地路径,各自适合什么场景
编译完之后把内核装进系统,有三种做法,差别很大,我在实际使用中三种都用过。
| 方式 | 命令 | 产物位置 | 优点 | 主要缺点 |
|---|---|---|---|---|
| 直接安装 | make install && make modules_install | /boot与/lib/modules | 步骤少,直接生效 | 卸载麻烦,容易留垃圾,包管理器“不认识”它 |
| 打成本地 deb 包 | make bindeb-pkg | 上层目录生成.deb | 可用包管理器安装和卸载,干净可控 | 需要额外的打包依赖,首次配置稍繁琐 |
| 输出到自定义目录 | make O=/path/out+INSTALL_PATH= | 你指定的路径 | 完全不影响宿主系统,适合实验 | 需要手工处理模块路径和 initramfs,不适合日常启动 |
我现在的固定做法是:第一次跑通流程用打 deb 包的方式。理由很实在:编译内核几乎必然要试错几轮,直接make install每轮都会往/boot和/lib/modules里堆一份新东西,三轮之后你的/boot里就有六七个内核镜像和一堆System.map备份,/boot分区本来就小,很快会满,满了之后连apt都跑不动。deb 包的好处是装和卸都只有一条命令,dpkg -r之后干干净净,不会残留。
O=输出目录我一般只用在两种场景:一是在服务器上做纯编译验证,不打算装到自己机器;二是需要给不同配置并行编译多份做对比。日常使用反而增加心智负担,因为make modules_install的路径推断会变得不那么直观。
2. 环境准备:依赖、空间和时间这三笔账
2.1 依赖清单:每一条为什么必须装
在 ubuntu 上直接一条命令装齐,但我想逐条解释一下它们各自负责什么,这样出错的时候你能判断是哪个环节的问题。
sudo apt update sudo apt install -y build-essential libncurses-dev bison flex \ libssl-dev libelf-dev bc dwarves rsync cpio zstd kmodbuild-essential提供编译器、make、链接器这一整套。注意内核某些部分依赖的编译器版本有下限,太老的发行版自带的gcc可能编不过,遇到奇怪的语法错误先gcc --version看一眼。
libncurses-dev是make menuconfig的界面库。缺了它你会得到Unable to find the ncurses package,并且menuconfig相关的几条命令全部不可用。很多人图省事用make config或直接改.config,我认为不可取,几千个选项逐条问答会疯掉,手改.config更容易因为依赖关系没满足被make悄悄改掉,你以为改了其实没生效。
bison和flex是语法分析器和词法分析器生成工具,内核里解析器、dtc 之类的地方要用。缺了它们的报错通常出现在编译中段,比如flex: command not found,这个时候前面已经编了十几分钟,特别浪费。
libssl-dev提供加密库,内核签名、模块签名、部分加密算法实现要用。libelf-dev是CONFIG_UNWINDER_ORC和 BTF 生成相关。这两个是最容易漏的,尤其是你抄了一份精简的依赖列表的时候。
bc很多人会忽略,但内核编译过程中有脚本拿它做整数运算,缺了会报bc: not found,出现在kernel/timeconst.h相关的规则里。
dwarves提供pahole,这个是给CONFIG_DEBUG_INFO_BTF用的。如果开了 BTF 又没装它,你会看到.tmp_vmlinux.btf: pahole (pahole) is not available,然后编译失败。如果你不想装,就得把CONFIG_DEBUG_INFO_BTF关掉,但很多 eBPF 工具依赖 BTF,关掉之后bpftool之类的东西会不好用,所以我建议装上。
rsync、cpio、zstd是打包和压缩用的。cpio是生成 initramfs 的底层工具,zstd是当前主流内核镜像压缩格式,rsync在make install的某些路径上会用到。
2.2 磁盘、内存、CPU 这三笔账要提前算
这部分是我踩坑最多的地方,尤其是磁盘。
源码树解压后大概 1.3 到 2 GB,看版本。这个还不是问题,问题是编译中间产物。如果不带调试信息,O=目录大概 3 到 5 GB;带上CONFIG_DEBUG_INFO(默认值通常是DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT或DEBUG_INFO_DWARF5),中间产物的.o文件会膨胀到 15 到 30 GB,我见过开DEBUG_INFO又没裁剪的情况下直接冲到 40 GB 的。所以在动手之前先df -h看一眼,/或者你放源码的分区至少留出 30 GB 余量。我因为这个问题浪费过一次完整编译:编到 90% 报No space left on device,清完之后重编。
内存的账是这样算的:make -jN里每个并行任务在编译大文件时峰值占用大概 1 到 2 GB 内存,开了 LTO 或者调试信息会更高。所以并行度的朴素规则是“nproc和 内存GB数/2 取较小值”。比如 8 核 16 GB 内存,取 8;8 核 8 GB 内存,取 4 就更稳。内存不够的后果是触发 OOM killer,你可能会看到某个cc1进程被 kill,make报Killed然后停在那里,看起来像编译错误其实不是。
时间上,我实测一组参考值,供你判断“卡住了”还是“就是慢”:8 核 16 GB NVMe 的机器,全量编译一次 6.x 内核(带调试信息)大概 15 到 25 分钟,增量编译改一两个文件几十秒到两分钟。老的四核笔记本配机械盘,一小时以上不奇怪。所以别在编译开始五分钟就觉得出问题了。
2.3 源码获取与完整性校验
源码从kernel.org或者镜像站拿linux-6.8.x.tar.xz,同时下载同目录下的sha256sums校验文件,sha256sum -c跑一遍。这一步很多人跳过,但下载中断导致 tar 包截断的情况真的会发生,解压的时候报Unexpected EOF in archive还算幸运,更麻烦的是解压成功但某个文件内容不完整,编译时报一堆莫名奇妙的语法错误,你会怀疑人生。
源码放到哪儿也有讲究。我习惯放~/src/linux-6.8.x,不放/usr/src,因为/usr/src通常有包管理器装的头文件目录,混在一起容易误删。解压前顺手确认文件系统支持大小写敏感(涉及跨系统拷贝的情况,自己机器一般不用担心)。
3. 配置环节:从当前内核中获取的起点
3.1 拿/boot/config当基线,再用 localmodconfig 做减法
这是整套流程里最关键的一步,配置错了后面全白搭。步骤如下:
cd ~/src/linux-6.8.x cp /boot/config-$(uname -r) .config make olddefconfigmake olddefconfig的作用是:把你这份旧配置里已经不存在于当前源码的选项丢掉,对新增选项全部采用默认值,并且不产生任何交互。相比make oldconfig(逐条问你),olddefconfig更适合自动化,但代价是那些“默认值不是我想要的”的选项会被悄悄设成默认。所以我一般先用olddefconfig生成一份可用的,然后make menuconfig手工检查几个关键项(见 3.2)。
如果你要减体积,可以在olddefconfig之后跑:
make localmodconfig它的逻辑是看当前系统里已经加载了哪些模块(lsmod),然后只保留这些模块对应的配置,把用不到的驱动全关掉。效果非常明显,配置项能从一万多条减到三四千条,编译时间砍掉一半以上。
但这里有巨大的隐患:localmodconfig只保留“当前已加载”的模块。当前没加载不等于不需要,比如你用的是 USB 网卡但这次开机没插;比如你现在用的是有线网但日常会用无线;比如某个存储控制器的模块在启动时加载后可能被卸载了。这些都会被裁掉,结果是新内核启动后再插上设备毫无反应。我见过最典型的情况是:无线网卡驱动被裁掉,装完重启发现连不了网,只能靠手机共享网络或者再切回旧内核。所以跑完localmodconfig之后,务必在menuconfig里把网络、存储、输入设备这几大类重新检查一遍。
3.2 六个必须重点检查的配置项
不管用什么方式生成.config,我都会过一遍下面这几项,这些都是我出过问题的地方。
| 配置项 | 建议值 | 为什么 |
|---|---|---|
CONFIG_MODULES | y | 关掉后所有模块都无法使用,很多驱动、文件系统依赖模块形式,基本不能关 |
CONFIG_MODULE_SIG | 视安全启动而定 | 开了安全启动会拦截未签名模块,详见第 4 节 |
CONFIG_SYSTEM_TRUSTED_KEYS | "" | 发行版配置里通常指向一个证书路径,你的源码树里没有这个文件,闲着就会编译失败 |
CONFIG_DEBUG_INFO | 学习/调试开,日常发行可关 | 决定你能不能拿到完整符号,也直接决定磁盘占用 |
CONFIG_DEBUG_INFO_BTF | 装了pahole就保持 | eBPF 相关工具依赖,缺pahole会编译失败 |
CONFIG_IKCONFIG | y(推荐) | 编完之后可以把.config塞进内核镜像,用extract-ikconfig还原,便于日后追踪“这个内核到底是哪份配置” |
CONFIG_SYSTEM_TRUSTED_KEYS这一项尤其值得说,它是 ubuntu 系用户最容易在第一时间遇到的错误。发行版的配置里这一项写的是debian/certs/xxx.pem之类的相对路径,校验机制会在编译某个阶段检查这个文件是否存在。上游源码树里当然没有,于是直接报:
No rule to make target 'debian/certs/xxx.pem', needed by 'certs/x509_certificate_list'报错行内容会因版本略有不同,但关键字一定是certs/。解决办法是把它清空。用menuconfig的路径是Cryptographic API -> Certificates for signature checking -> Additional X.509 keys for default system keyring,把里面内容删掉;或者直接改.config:
scripts/config --set-str SYSTEM_TRUSTED_KEYS "" scripts/config --set-str SYSTEM_REVOCATION_KEYS ""注意第二项SYSTEM_REVOCATION_KEYS也经常是同一个坑,两个一起清。改完之后再跑一次make olddefconfig让依赖关系重新计算。
3.3 ubuntu 桌面环境特有的两个问题
第一个是安全启动。ubuntu 桌面版很多机器默认开了固件安全启动,这意味着内核加载未签名模块会被拒绝。你自己编的内核没有发行版的签名,所以可能出现两种情况:一是内核本身根本起不来,二是内核起来了但模块全加载失败,表现为没网、没声音、分辨率不对。解决路径有两条:进固件设置关掉安全启动(最简单),或者给自己的内核和模块做自签名并注册密钥(流程较长,日常自己用不划算)。我是在自己的测试机上直接关掉安全启动的,符合规则也省事,但你要清楚这会降低启动链的校验强度,做在个人实验机上可以,生产设备上要评估。
第二个是闭源显卡驱动。这类驱动是编译好的内核模块,强依赖内核版本和符号版本。你换了自编内核,它必然加载失败,表现是进不了图形界面,卡在登录管理器或者直接黑屏转文本。我的处理方式是:在配置里保证开源驱动栈完整(CONFIG_DRM、CONFIG_DRM_I915或CONFIG_DRM_AMDGPU之类按显卡选),然后新内核启动后先用开源驱动把桌面跑起来,需要的话再重装闭源驱动。千万不要在一次重启里同时切换内核和重装显卡驱动,一旦出问题你分不清是哪个引起的。
4. 编译与安装:命令链每一步在做什么
4.1 并行度与输出目录
配置确认之后就是编译。我平时用的命令是:
time make -j$(nproc) 2>&1 | tee build.log-j$(nproc)是最朴素的写法,但前面说过,内存吃紧的机器要往下调。time是为了以后估算时间有个依据。tee build.log是我强烈建议加上的一步:内核编译的报错有时候会刷出去几十屏,你往上翻的时候日志已经被后续输出淹没了,有个完整日志文件可以grep -i error build.log快速定位。我甚至习惯在第一次编译时加V=0(默认),只有排查具体某条规则时才用make V=1重编那个目标,因为V=1会把每条命令都打出来,日志会膨胀好几倍。
如果你要用独立的输出目录:
make O=~/build/linux-6.8.x -j$(nproc) menuconfig make O=~/build/linux-6.8.x -j$(nproc) make O=~/build/linux-6.8.x modules_install注意每次都要带上同一个O=,漏一次它就会在源码树里新建一套产物,然后你会遇到“明明改了配置为什么没生效”的诡异情况。这是我在实战中真正被坑过的地方,因为我在另一个终端窗口里忘了带O=,结果两份产物混在一起,症状是模块签名报错,查了两小时才发现是路径问题。
4.2modules_install和install分别落在哪里
编译完成之后(会看到Kernel: arch/x86/boot/bzImage is ready字样),两条命令的实际动作如下。
sudo make modules_install会把所有编成模块(=m)的部分装到/lib/modules/<版本号>/下,同时生成modules.dep、modules.alias、modules.builtin等索引文件,并调用depmod。这个目录名里的版本号来自内核源码的Makefile里EXTRAVERSION加上编译时的CONFIG_LOCALVERSION。如果你忘了设置CONFIG_LOCALVERSION,装上去的目录名可能和发行版已有的目录名冲突,这是很危险的情况,会被覆盖。所以我习惯在配置里设一个明显不同的后缀,比如CONFIG_LOCALVERSION="-myk"或-test1,这样/lib/modules里一眼就能区分。
sudo make install会做三件事:把bzImage复制到/boot/vmlinuz-<版本>;把System.map复制过去;调用当前系统的installkernel脚本。在 ubuntu 上,最后这一步会触发update-initramfs和update-grub。这里有个隐含依赖:只有当你的内核被正确识别为“已安装”,grub 才会给它生成菜单项。如果你用的是O=输出目录并且INSTALL_PATH没设,make install可能根本不往/boot里写东西,然后你重启发现压根没有新内核选项。
4.3 打 deb 包:我现在默认用的方式
前面说过我偏好打包。命令只有一行:
make -j$(nproc) bindeb-pkg它会在源码树的上一级目录生成几个.deb,通常包括linux-image-<版本>_*.deb、linux-headers-<版本>_*.deb、linux-libc-dev_*.deb。然后用sudo dpkg -i装 image 和 headers 就行。
这条路线的优势在卸载时体现得最明显:sudo dpkg -r linux-image-<版本>干净利落,/boot里的镜像、/lib/modules里的目录、grub 菜单项都会跟着清理。而make install装进去的东西,包管理器认为它“不存在”,你得手工删/boot/vmlinuz-xxx、/boot/initrd.img-xxx、/lib/modules/xxx,再update-grub,漏一步就会在 grub 菜单里留下一个启动不了的死项。
有个细节要注意:bindeb-pkg依赖debhelper、fakeroot之类,缺了会报错。如果你机器上没装,sudo apt install -y debhelper补上。另外打包过程比直接make install多花几分钟做压缩,这是正常的。
4.4 initramfs 与回滚预案
无论用哪种方式,装完之后确认两件事:
第一,initramfs 是否生成。新内核如果/分区在 LVM、加密卷、RAID 或者用了某些存储控制器,启动初期必须靠 initramfs 里的模块才能挂上根文件系统。ubuntu 上手工处理是:
sudo update-initramfs -c -k 6.8.0-myk ls -lh /boot/initrd.img-6.8.0-myk如果第二条命令报“No such file”,那几乎必然启动失败,不要继续往下走。用 deb 包安装的话,这一步由包管理器的钩子自动完成,通常不用管,但装完还是瞄一眼/boot比较安心。
第二,回滚路径。这一步是保命的,必须在重启前想清楚。ubuntu 上的做法是:重启时按住Esc或狂点Shift进 grub 菜单,选Advanced options for Ubuntu,里面能看到旧内核。所以在重启之前,先确认旧内核在 grub 菜单里还看得见,办法是grep -c menuentry /boot/grub/grub.cfg大致看条目数量,或者直接update-grub看输出。如果旧内核的initrd.img因为/boot满了被删了,那就没有退路了。
我给自己定的规矩是:/boot分区至少保留两份完整的内核(旧的和新的),并且不在同一台机器上一次装两个自编内核。/boot空间不足时,先卸掉更早的那份,别硬塞。
5. 自己加一个系统调用:从 syscall 指令到 sys_call_table
5.1 一次系统调用在内核中走的几步
先把原理说通,不然改代码就是照抄。以 x86_64 为例,用户态调一个系统调用,大致是这么走的:
- 用户态的库函数(比如
write)最终执行一条syscall指令,同时把系统调用号放在rax寄存器里,参数依次放在rdi、rsi、rdx、r10、r8、r9(注意第三个之后不是rcx,rcx被用来存返回地址了)。 - CPU 切到内核态,跳到
entry_SYSCALL_64,这段汇编保存用户态寄存器现场,然后进入 C 代码do_syscall_64。 do_syscall_64里做参数检查(nr是否超出范围),然后从sys_call_table这个函数指针数组里按nr取出对应函数地址并调用。- 被调用的函数通常由
SYSCALL_DEFINE1这类宏定义,宏负责把参数从寄存器里解出来、做类型转换,并生成一个形如__x64_sys_xxx的真实符号。 - 返回值通过
rax带回用户态。负数表示错误,-ERANGE、-EFAULT之类,libc 会把它转成errno并返回-1。
理解了这条链路,你就知道要加一个系统调用需要动哪几处:编号表(告诉分发逻辑多少号对应哪个函数)、函数定义(真正的实现)。在老版本内核里还要在include/linux/syscalls.h加一行声明,但在近几年的 x86 架构上,声明是由表自动生成的,不用手工加。
5.2 三处改动与号码的选择
第一处,打开系统调用表:
arch/x86/entry/syscalls/syscall_64.tbl这个文件按 ABI 分段,开头的列分别是“号”、“ABI 类型”、“名字”、“实现入口”。ABI 类型有三种:common(64 位和 x32 共用)、64(仅 64 位)、x32(仅 x32)。文件里会看到类似这样的段:
0 common read sys_read 1 common write sys_write ... 462 common landlock_create_ruleset sys_landlock_create_ruleset ... 512 x32 rt_sigaction compat_sys_rt_sigaction0到462附近是已经用掉的,512开始那段是 x32 专用,绝对不能碰。中间463到511是相对安全的空洞。所以正确做法是先grep一下确认:
awk '$1 ~ /^[0-9]+$/ && $1 >= 450 {print}' arch/x86/entry/syscalls/syscall_64.tbl | sort -n | tail -20看清楚最大公共号之后,往后取一个没被占用的。我第一次做这个的时候随手写了个548,结果正好撞进 x32 段,编译过了,调用的时候行为莫名其妙,最后发现是号冲突。这个坑值得记一下。
假设我们选定463,在表里合适的位置加一行:
463 common hello sys_hello第二处,实现函数。我一般加在kernel/sys.c文件末尾,因为那是系统调用实现比较集中的地方,方便找。
无参数的版本:
SYSCALL_DEFINE0(hello) { pr_info("hello from my kernel syscall\n"); return 0; }有参数、需要从用户态拷数据的版本:
SYSCALL_DEFINE2(hello_name, const char __user *, name, int, len) { char kbuf[64]; int n; if (len <= 0 || len >= (int)sizeof(kbuf)) return -EINVAL; /* 关键:绝对不能直接解引用用户态指针 */ if (copy_from_user(kbuf, name, len)) return -EFAULT; kbuf[len] = '\0'; pr_info("hello, %s\n", kbuf); return len; }这里有一个必须说清楚的事情:__user标记的指针在内核态不能直接读写。用户态指针指向的可能是无效地址,直接解引用轻则触发缺页处理异常,重则被恶意构造的地址搞崩。正确的做法是copy_from_user/copy_to_user,它们内部做了地址合法性检查。这条规矩在新手代码里出错率极高,我见过有人直接printk("%s", name),在自己机器上跑得好好的,换个地址就整个内核 panic。
第三处,如果没有走common sys_xxx这种老式写法而是用了__x64_sys_xxx的形式,记得不要手工在include/linux/syscalls.h里重复声明,否则会报重复定义。判断方法很简单:编译一次看报错说什么,如果提示找不到__x64_sys_hello,就说明表里的写法要改成common hello sys_hello。
改完之后重新编译。因为是增量编译,只改了kernel/sys.c和那个表,通常一两分钟就出镜像。这一步最能体现“循环迭代”的效率,这也是我建议用O=输出目录做这种实验的原因:源码树干净,重编快。
5.3 用户态测试与 strace 验证
装好新内核、重启进新系统后,验证代码大概是这样:
/* hello_test.c */ #include <unistd.h> #include <sys/syscall.h> #include <stdio.h> #include <string.h> #include <errno.h> #define __NR_hello 463 #define __NR_hello_name 464 int main(void) { long r; r = syscall(__NR_hello); printf("hello() -> %ld\n", r); const char *msg = "test-user"; r = syscall(__NR_hello_name, msg, (int)strlen(msg)); if (r < 0) { printf("hello_name failed, errno=%d (%s)\n", errno, strerror(errno)); return 1; } printf("hello_name() -> %ld\n", r); return 0; }编译运行:
gcc -O2 -o hello_test hello_test.c ./hello_test sudo dmesg | tail -5正常情况下dmesg里能看到你自己的pr_info输出。如果看不到,先确认dmesg没有因为内核配置把日志级别过滤掉,可以用sudo dmesg -w实时看,同时另一个终端跑测试程序。
strace是另一个很有用的验证手段:
strace -e trace=463,464 ./hello_test输出里会显示syscall_0x1cf(...)(463 的十六进制是 0x1cf)。如果你的号没被识别,strace会显示ENOSYS,那就说明表里那一行没生效,或者你装的内核根本不是刚编译的那个(检查uname -r)。
还有一个常见困惑:/usr/include/asm/unistd_64.h里没有你新加的号。这是正常的,那个头文件是编译时从表生成的,在构建目录里(arch/x86/include/generated/uapi/asm/unistd_64.h),系统的头文件目录不会自动更新。所以我测试程序里直接用#define硬编码号,而不是#include。
5.4 直接修改内核并非唯一选择:kprobe 和 eBPF 的更温和方案
如果你只是想在某个事件发生时打印点东西、收集点数据,完全不必为了这个去改系统调用表。修改内核的代价是:以后每次升级内核都得重新打补丁,而且这个号在别人的内核上不存在,你的程序换台机器就跑不起来。
更轻量的路子有两条。一是kprobe,可以在运行时把探针挂到任意内核函数的入口或返回处,通过tracefs就能配置,不用重编译:
sudo mount -t tracefs nodev /sys/kernel/tracing echo 'p:myprobe do_sys_open' | sudo tee /sys/kernel/tracing/kprobe_events二是 eBPF,配合bpftrace或bcc工具集,能做的事情比 kprobe 更进一步,包括读参数、聚合统计。这两条路都不需要改源码,也不需要重启。我把系统调用表这一套当成“学习用途 + 特定功能必须改内核”的手段,日常观测优先用 eBPF。分清这两者的适用边界,能省掉大量重复劳动。
6. 常见故障速查与排查实录
6.1 编译阶段
| 报错关键字 | 原因 | 处理 |
|---|---|---|
No rule to make target 'debian/certs/xxx.pem' | 发行版配置里的证书路径在你的源码树不存在 | 把SYSTEM_TRUSTED_KEYS和SYSTEM_REVOCATION_KEYS置空 |
pahole (pahole) is not available | 开了 BTF 但没装dwarves | 装dwarves或关掉DEBUG_INFO_BTF |
flex: command not found | 缺依赖 | 补装flex、bison |
Killed/ 进程被杀 | 内存不足触发 OOM | 降-j并行度 |
No space left on device | 磁盘满 | 清空间或关掉DEBUG_INFO |
undefined reference to __x64_sys_xxx | 表里写了新名字但没有对应实现 | 检查SYSCALL_DEFINE是否加了、函数名是否拼错 |
| 编译到一半突然大量语法错误 | 头文件被改坏或源码不完整 | 重新解压源码,别在原树上硬改 |
这张表里最后一条我想多说两句。内核编译的报错有个特点:第一个错误才是真的,后面的往往是它的连锁反应。所以正确的排查顺序永远是grep -iE "error|错误" build.log | head -20,看最前面几条,不要被最后几屏吓到。
6.2 启动阶段
黑屏/卡在启动画面。第一件事是判断卡在哪:开机时去掉quiet splash(grub 里按e编辑启动参数),让内核日志打出来。如果日志停在Loading initial ramdisk,那基本是 initramfs 里缺模块;如果日志走了一段之后停在VFS: Unable to mount root fs,那是根文件系统驱动没进 initramfs;如果一路走到systemd相关输出就停住,通常是图形驱动的问题。
进不了 grub 菜单。ubuntu 默认隐藏 grub 菜单,开机时按住Esc,或者直接改/etc/default/grub把GRUB_TIMEOUT_STYLE改成menu、GRUB_TIMEOUT设成 5,然后sudo update-grub。这一步建议在装新内核之前就做,不然出问题的时候你连选择旧内核的机会都没有。
模块加载失败。dmesg | grep -i "module verification failed"能看到签名相关的失败信息。先确认安全启动状态(mokutil --sb-state),如果是 enabled,要么关掉,要么接受这个现实只用开源驱动栈。
6.3 系统调用相关
ENOSYS(errno为 38)。说明内核不认这个号。依次检查:uname -r是不是新内核;表里那一行是否真的写进了源码(grep hello arch/x86/entry/syscalls/syscall_64.tbl);编译输出里是否真的重新生成了syscall_64.tbl相关的中间文件。我遇到过一次是因为用了O=输出目录但第二次编译忘了带O=,编出来的是另一份产物,make install装的却是我以为的那份。
返回-EFAULT。copy_from_user失败,最常见的原因是长度传得比字符串实际长度大,跨过了字符串结尾进入未映射的页。这种错误定位方法是把len打印出来,和strlen对一下。
调用后系统崩溃或 panic。八成是直接解引用了用户态指针,或者返回值用了int而内核期望long。SYSCALL_DEFINEn宏生成的函数返回类型固定是long,如果你手工写asmlinkage long sys_xxx就要注意类型一致。
7. 几次反复折腾后才养成的习惯
我现在做这套流程有几个固定动作,都是被坑出来的,分享出来供你参考。
第一,源码目录的名字里带上版本号和用途,比如linux-6.8.9-syscall-lab。编译一份内核要花半小时到一小时,最痛苦的不是过程中的报错,而是三天后你回来想知道“这个内核我到底改了什么”。目录名和CONFIG_LOCALVERSION双保险,uname -r一看就知道是哪次实验的产物。
第二,每次编译前先df -h /和free -g,两秒钟的事,能避免一次白干一小时的编译。我现在的习惯是把这两个命令和make写进一个脚本里,脚本第一件事就是检查磁盘余量够不够,不够直接退出。
第三,任何一次“装新内核”都当成一次可能失败的操作,重启前先确认旧内核在 grub 菜单里可见。这个习惯救过我至少三次,尤其是那次我在配置里误关了存储控制器驱动,新内核起来根本找不到根分区,靠 grub 菜单切回旧内核才把问题救回来。
第四,改内核代码前,先在一个O=独立目录里验证编译通过,确认没问题再往正式的源码树上搬。这个小步骤能让你在实验失败时随时删掉整个目录重来,不用担心污染主力源码树。
第五,系统调用号、模块名、打印信息里都带上自己的标识(比如统一用myk前缀),这样dmesg、lsmod、strace的输出里你一眼就能挑出自己那一份,和系统自带的混在一起也不至于看错。这些细节不写进任何文档,但真出事的时候,能节省下来的时间远超当初多打几个字符的成本。