系统调用是用户态和内核唯一的正门:一次受控的特权级切换,带着编号和参数进门,办完事拿结果出去.1.1 讲过 EL 分层,1.3 讲过异常向量,这篇把这条路径彻底走通 — 包括真的给内核加一个自己的系统调用,从修改表到用户态调通,一步不缺.
这篇回答几件事:
- SVC 指令进来后,内核怎么按号分发
- 参数怎么传,系统调用号从哪查
- 怎么观测一个进程实际发了哪些系统调用
- 加一个新系统调用的完整流程,以及我踩的那个版本迁移坑
先把硬件侧流程串起来,大部分在 1.3 预习过:用户态执行 svc #0,硬件切到 EL1,跳进向量表的 el0_sync 槽,ESR 的 EC=0x15(SVC),分发到 el0_svc,再进 arch/arm64/kernel/syscall.c 的 el0_svc_common.分发那一行很短:
el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
规矩全在这:AARCH64 的系统调用号放在 x8,参数最多六个放 x0 到 x5,返回值 x0.没有栈传参这一说,超过六个参数的系统调用极少,真要多就打包成结构体传指针.表里查不到号,或者表项是空,统一 ENOSYS(Function not implemented)— 这个 errno 专门为"号不存在"保留.
号表本身:arm64 的系统调用表在 6.18 是从 arch/arm64/tools/syscall_64.tbl 生成的,文本表一行一个 — 编号,ABI 分类,名字,实现函数.scripts/syscallhdr.sh 把它编译成 unistd_64.h,内核和用户态的 C 头文件同源,所以两边永远不会对不上号.看几个眼熟的号:openat 是 56,close 是 57,newfstatat 是 79,号本身没有规律,纯粹按加入顺序排 — 这也是不同架构系统调用号不同的原因,x86_64 的 openat 是 257,写跨平台代码永远用 glibc 的包装函数,别写裸数字.
观测这块,板上直接用 tracepoint:
bash-1506 [001] sys_enter: NR 24 (a, 1, 0, 9, 8, 0)
bash-1506 [001] sys_enter: NR 57 (a, 0, 0, 0, 0, 0)
bash-1506 [001] sys_enter: NR 79 (ffffffffffffff9c, aaaae76e9ab0, ...)
raw_syscalls/sys_enter 这个埋点把号和六个参数原样吐出来,跑一个 /bin/true 就抓到一小串.第三行 NR 79(newfstatat)第一个参数是 0xffffffffffffff9c,即 -100 — AT_FDCWD,相对路径解析的"当前目录"句柄.内核里翻成无符号打印,负数就长这样,读 trace 时要有这个手感.strace 干的也是同一件事,不过它把号翻译成名字和结构体字段,日常用 strace,讲机制用 tracepoint,正好互补.
然后是这篇的主菜:自己加一个系统调用.目标很朴素,叫 kblog_info,把一串内核生成的文本拷给用户.三步.第一步写实现,kernel/sys.c 末尾:
SYSCALL_DEFINE3(kblog_info, char __user *, buf, size_t, len, int __user *, out)
{
char msg[64];
int n = snprintf(msg, sizeof(msg), "kblog syscall alive, boot=%lds",
ktime_get_boottime_seconds());
if (len < n)
n = len;
if (copy_to_user(buf, msg, n))
return -EFAULT;
if (put_user(462, out))
return -EFAULT;
return n;
}
SYSCALL_DEFINE 宏展开成 sys_kblog_info 函数加类型检查桩,参数自带 __user 标记.第二步注册,syscall_64.tbl 末尾加一行:
470 common kblog_info sys_kblog_info
第三步重编内核,QEMU 起来,用户态直接 syscall(470, ...):
syscall(470) -> 28: kblog syscall alive, boot=4s, magic=462
通了.参数进去,字符串出来,出参 magic 也到位,一条不到三十行的链路把"用户态 C 函数 → glibc 的 syscall 桩 → svc 指令 → 向量表 → el0_svc_common → 按号查表 → 我的函数 → 原路返回"全部点亮.这份体验平时只有 libc 和内核开发者有,自己动手一次,整条路径从此是熟人.
顺带交代翻的车.我一开始照着老教程把 __NR 定义补在 include/uapi/asm-generic/unistd.h,编译启动一切正常,调用返回 ENOSYS — 表里根本没有我.查生成的头文件才发现,arm64 早就迁到自己的 syscall_64.tbl,asm-generic 那份压根不参与编译.教材和现实差着一个架构迁移,这是内核学习反复出现的主题:文章教你机制,树里教你现实,动手之前先看 make 的 .cmd 和 generated 目录,确认这份源码到底吃哪份配置.
最后留几个容易忽略的细节:
- 系统调用没有 libc 语义的 errno:内核返回 -EFAULT,glibc 桩把它翻译成 errno=14 并给返回值 -1,自己用 syscall() 裸调时要记得判负数而不是判 errno.
- svc 只是入口约定,seccomp 就挂在 syscall 分发前,BPF 程序按号按参数决定放行或杀进程,容器的安全边界之一.
- restart_block:被信号打断的可重启调用(read 这类),内核返回 -ERESTARTSYS 后由出口逻辑自动重摆参数重启,用户根本看不见这个过程,4.3 篇实测过一次.
- 系统调用一旦分配号就是 ABI,永远不能改语义或挪号,发行版内核给新系统调用打 backport 时也要用和主线一致的号.
- vDSO 把 gettimeofday 这类"纯读取"调用搬进用户态,不陷内核,ldd 里看到的 linux-vdso.so 就是它;调用量大的监控程序拿它省下百万级陷入.
- compat 表:跑 32 位程序时走 sys32 的另一张表,号和结构体布局都不同,内核里带 compat_ 前缀的函数全是给这条路的.
- 加系统调用在新内核里几乎是"最后手段":同样的事用驱动 ioctl,文件系统,ebpf 大多更顺手,先把别的门想一遍.