简介:这份资源是重庆大学软件学院操作系统实验一的配套资料,面向正在学习操作系统内核编程的本科生与自学者,聚焦Linux环境下系统调用的实现与验证。实验以EPOS开源项目为载体,要求读者在内核空间编写sys_time()函数、声明接口并定义系统调用号,再在用户空间通过汇编包装与C接口完成调用,最终在QEMU中打印时间戳,涉及版本控制、C与汇编混合编程及Bochs调试等技能。资源包为单个docx文档,压缩后约740KB,内容按实验步骤组织,涵盖从SVN拉取源码、make run运行到make debug排错、make clean清理的完整流程,并给出内核态与用户态两侧的代码片段与预期输出。目前已有185人学习下载,适合作为课程实验的对照参考,帮助读者理清系统调用从定义、注册到用户态调用的链路,掌握内核级编程与虚拟环境验证的基本方法。
1. 系统调用实验为什么总在syscall编号上翻车
操作系统实验里,「系统调用」这四个字看着简单,真动手时却经常卡在最不起眼的地方:编号对不上、参数传错、编译报implicit declaration。我带过几届做重大软院操作系统实验一的学生,十个人里有七个第一次跑make就挂,剩下三个跑通了但说不清自己到底改了什么。这个实验的本质,是让你在一个真实内核里新增一个系统调用,从用户态发起请求,穿过中断门进入内核态,最终拿到返回值。它解决的是「用户程序如何安全地请求内核服务」这个问题,适合已经学过 C 和基本 Linux 命令、想真正理解内核入口机制的开发者。下面我按自己踩过的顺序,把这条链路拆开讲清楚。
2. 系统调用从用户态到内核态到底走了哪几步
2.1 一次syscall的完整生命周期
用户程序调用printf时,glibc 最终会执行一条syscall指令(x86-64 下),把系统调用号放进rax,参数依次放进rdi、rsi、rdx、r10、r8、r9。CPU 切换到内核态后,进入内核的入口汇编代码,根据rax查sys_call_table,跳转到对应的处理函数。处理函数执行完,返回值放回rax,再切回用户态。
这条链路里,你能改的地方有三处:系统调用号的定义、sys_call_table里的函数指针、以及处理函数本身的实现。实验通常要求你三处都动,少一处就报ENOSYS(错误码 38,Function not implemented)。
理解这个流程的意义在于:当你的调用返回-1且errno是 38 时,你就知道是表里没登记,而不是参数传错了。这是排查的第一步。
2.2 为什么选「新增调用」而不是「改现有调用」
有些同学图省事,直接改write或getpid的行为。这在实验里是禁忌,原因有两个。第一,现有系统调用被大量用户态程序依赖,改坏了系统可能起不来。第二,新增调用能完整走一遍「定义编号 → 注册表项 → 实现函数 → 用户态验证」的流程,这才是实验想训练的能力。
常见做法是新增一个功能简单但可验证的调用,比如返回当前进程的某个计数、或者对传入的整数做一次运算再返回。功能越简单,越容易定位问题出在链路哪一环。
2.3 内核版本决定你的改法
不同内核版本,系统调用的注册方式差别很大。老版本(2.6 到 4.x 早期)直接在arch/x86/entry/syscalls/syscall_64.tbl里加一行,然后在kernel/sys.c里写函数。较新的版本(5.x 以后)引入了SYSCALL_DEFINE宏体系,编号表格式也有调整。
我一般会先确认内核版本:
uname -r输出类似5.15.0-xx-generic。如果是 5.x,syscall_64.tbl里每行格式是「编号 名称 入口点 实现文件」,你新增的行要保证编号不冲突。编号范围 0 到 334 左右是已占用的,新调用从 335 往后挑一个没用的。挑之前一定用grep确认:
grep -r "335" arch/x86/entry/syscalls/syscall_64.tbl没有输出才说明 335 可用。这一步不做,后面编译可能不报错,但运行时调用会跳到别的函数,现象是返回值完全对不上,非常难查。
3. 动手新增一个系统调用:从改表到验证
3.1 第一步:在编号表里登记
假设我们新增一个调用叫sys_myadd,功能是把两个整数相加返回。先编辑arch/x86/entry/syscalls/syscall_64.tbl,在文件末尾附近找一块连续未使用的编号区域,加一行:
# 在 syscall_64.tbl 末尾追加 335 common myadd sys_myadd这里三个字段分别是编号、ABI(common表示 64 位和 32 位通用)、名称。名称要和后面SYSCALL_DEFINE里的名字一致,否则链接阶段会报未定义符号。
改完保存,用grep myadd确认只有这一行,避免重复登记。
3.2 第二步:用SYSCALL_DEFINE写实现
在kernel/sys.c文件末尾添加实现。5.x 内核推荐用宏:
// kernel/sys.c 末尾追加 SYSCALL_DEFINE2(myadd, int, a, int, b) { int result = a + b; printk(KERN_INFO "myadd called: %d + %d = %d\n", a, b, result); return result; }SYSCALL_DEFINE2里的2表示两个参数,后面成对出现「类型, 参数名」。参数类型要用内核能识别的类型,int、long、char __user *这些。如果你要传指针,必须用__user标注,并且用copy_from_user/copy_to_user访问,不能直接解引用,否则在开启 SMAP 的机器上会直接崩。
printk那行是给你调试用的,通过dmesg能看到。正式提交前可以留着,不影响功能。
3.3 第三步:编译并安装新内核
改完两个文件,回到内核源码根目录编译:
make -j$(nproc) make modules_install make install-j$(nproc)用满所有核心加速编译。编译时间取决于机器,一般十几分钟到半小时。编译过程中如果报myadd相关的未定义引用,八成是syscall_64.tbl里的名称和SYSCALL_DEFINE的名称不一致,回去核对。
make install会更新引导配置。重启前用grep myadd /boot/System.map-*确认符号已经进内核镜像。重启后在uname -r里应该能看到你编译的版本号带本地后缀。
3.4 第四步:用户态写测试程序验证
内核起来后,写一个最小测试程序:
// test_myadd.c #include <unistd.h> #include <sys/syscall.h> #include <stdio.h> #include <errno.h> #define __NR_myadd 335 int main(void) { long ret = syscall(__NR_myadd, 3, 4); if (ret == -1) { perror("myadd"); return 1; } printf("myadd(3,4) = %ld\n", ret); return 0; }编译运行:
gcc -o test_myadd test_myadd.c ./test_myadd期望输出myadd(3,4) = 7。如果输出myadd: Function not implemented,说明编号没登记进表,或者你启动的还是旧内核。用dmesg | tail看有没有myadd called那行,有就说明内核侧通了,问题在用户态编号或头文件。
注意:
__NR_myadd这个宏在系统头文件里是没有的,必须自己#define。有些同学直接写syscall(335, 3, 4)也行,但可读性差,不推荐。
4. 参数传递与返回值:那些让你怀疑人生的细节
4.1 参数个数超过六个怎么办
x86-64 的syscall指令只支持六个寄存器传参。如果你的调用需要七个以上参数,不能直接加,得用结构体指针传。做法是定义一个结构体,用户态填好,把指针传进来,内核态用copy_from_user拷出来。
struct my_args { int a; int b; int c; int d; int e; int f; int g; }; SYSCALL_DEFINE1(myadd_many, struct my_args __user *, uargs) { struct my_args kargs; if (copy_from_user(&kargs, uargs, sizeof(kargs))) return -EFAULT; return kargs.a + kargs.b + kargs.c + kargs.d + kargs.e + kargs.f + kargs.g; }copy_from_user返回非零表示拷贝失败,通常是指针非法,直接返回-EFAULT。这里不能省掉检查,否则内核会 oops。
4.2 返回值的约定
系统调用返回long。返回非负值表示成功,返回负值表示错误,用户态syscall封装会把负值转成-1并设置errno。所以你的实现里如果要报错,返回-EINVAL、-EFAULT这类负的错误码,不要返回-1再自己设errno,内核不认这套。
常见错误码对照:
| 错误码 | 值 | 含义 |
|---|---|---|
| EPERM | 1 | 权限不足 |
| EINVAL | 22 | 参数非法 |
| EFAULT | 14 | 地址访问错误 |
| ENOSYS | 38 | 调用未实现 |
4.3 用户态指针必须校验
任何从用户态传进来的指针,在内核里都不能直接解引用。除了copy_from_user,还要注意access_ok检查。虽然copy_from_user内部会做,但如果你先解引用再拷贝,就已经晚了。我见过有同学写if (*uptr > 0)来判断,结果在开启 SMAP 的机器上直接崩,现象是系统卡死,只能重启。
正确做法是先把数据拷到内核缓冲区,再在内核缓冲区上做判断。
5. 避坑:系统调用实验里最常见的五个翻车现场
5.1 编译通过但调用返回 ENOSYS
现象:用户态程序跑起来返回Function not implemented,dmesg里没有任何输出。
原因:syscall_64.tbl改了但没重新编译安装内核,或者启动时选的还是旧内核。另一个可能是编号写错,比如你登记的是 335,用户态写的是 336。
解决:uname -r确认当前内核版本,grep myadd /boot/System.map-$(uname -r)确认符号存在。用户态编号和表里编号逐位核对。
5.2 编译报implicit declaration of function 'sys_myadd'
现象:make到链接阶段报未定义符号。
原因:SYSCALL_DEFINE宏展开后的函数名和你表里写的名称不一致。比如表里写myadd,实现里写SYSCALL_DEFINE2(my_add, ...),下划线位置不同就找不到。
解决:表里名称、SYSCALL_DEFINE名称、用户态__NR_宏名三者保持一致。改完make clean再编,避免旧目标文件干扰。
5.3 传指针参数导致内核 oops
现象:调用后系统卡死或重启,dmesg里有BUG: unable to handle kernel paging request。
原因:直接解引用了用户态指针,没有用copy_from_user。
解决:所有用户态指针参数必须走copy_from_user/copy_to_user,并且检查返回值。指针类型加__user标注,让编译器帮你做静态检查。
5.4 返回值正确但errno被误设
现象:调用返回了期望的正数,但errno里有个残留值,导致后续判断出错。
原因:errno只在调用失败时被设置,成功时不会清零。如果你在成功路径上读errno,读到的是上一次失败留下的值。
解决:判断成功与否只看返回值是否为-1,不要看errno。需要清零时手动errno = 0。
5.5 多核机器上printk输出乱序
现象:dmesg里myadd called的输出顺序和调用顺序对不上。
原因:多核并发调用,printk虽然内部有锁,但输出到环形缓冲区的顺序受调度影响。
解决:调试阶段可以接受乱序,只要内容对就行。如果要严格顺序,在测试程序里加sleep或绑核。生产环境不要用printk做业务输出,它只适合调试。
6. 进阶:用strace和ftrace验证你的调用真的进了内核
6.1 用strace看用户态入口
strace能跟踪用户态发起的系统调用。跑:
strace -e trace=myadd ./test_myadd如果strace不认myadd这个名字(因为它读的是系统自带的调用名表),可以先用编号:
strace -e trace=335 ./test_myadd输出里会有一行myadd(3, 4) = 7,说明用户态到内核的入口通了。如果strace报Unknown syscall,说明你的编号没被strace的数据库识别,不影响功能,但说明你用的编号可能和某个已占用编号冲突,回去再查一遍表。
6.2 用ftrace看内核态执行路径
ftrace是内核自带的跟踪器,能看到函数调用链。先挂上:
cd /sys/kernel/debug/tracing echo function > current_tracer echo sys_myadd > set_ftrace_filter echo 1 > tracing_on ./test_myadd echo 0 > tracing_on cat tracetrace文件里应该能看到sys_myadd被调用。如果set_ftrace_filter写不进去,说明你的函数没被编译进内核,或者符号名不对。用cat available_filter_functions | grep myadd确认。
这个方法的优势是能看到调用发生在哪个 CPU、哪个进程上下文、耗时多少。对于理解系统调用的真实开销很有帮助。我一般会对比sys_myadd和sys_getpid的耗时,前者通常比后者慢,因为多了参数拷贝和printk。
6.3 一个容易忽略的验证点:32 位兼容
如果你的机器上跑 32 位程序,syscall_64.tbl里common类型的调用会被 32 位程序用int 0x80或sysenter触发,走的是另一套入口。实验通常只要求 64 位,但如果你在common里登记了,32 位程序也能调到。验证方法是编译一个-m32的测试程序,看返回值是否一致。不一致的话,检查arch/x86/entry/syscalls/syscall_32.tbl里有没有对应登记。
我自己的习惯是:每次改完内核,先跑 64 位测试,再跑 32 位测试,两个都过才算完。这个习惯帮我提前发现过好几次编号冲突问题。系统调用实验看着简单,但细节密度高,编号、名称、参数、返回值、指针校验,每一环都能让你卡半天。把上面这些步骤走一遍,再遇到ENOSYS或 oops,你至少知道从哪查起。希望帮到你。
本文还有配套的精品资源,点击获取