
简介这份资源是重庆大学软件学院操作系统实验一的配套资料面向正在学习操作系统内核编程的本科生与自学者聚焦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错误码 38Function 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.xsyscall_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这里三个字段分别是编号、ABIcommon表示 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内核不认这套。常见错误码对照错误码值含义EPERM1权限不足EINVAL22参数非法EFAULT14地址访问错误ENOSYS38调用未实现4.3 用户态指针必须校验任何从用户态传进来的指针在内核里都不能直接解引用。除了copy_from_user还要注意access_ok检查。虽然copy_from_user内部会做但如果你先解引用再拷贝就已经晚了。我见过有同学写if (*uptr 0)来判断结果在开启 SMAP 的机器上直接崩现象是系统卡死只能重启。正确做法是先把数据拷到内核缓冲区再在内核缓冲区上做判断。5. 避坑系统调用实验里最常见的五个翻车现场5.1 编译通过但调用返回 ENOSYS现象用户态程序跑起来返回Function not implementeddmesg里没有任何输出。原因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 tracemyadd ./test_myadd如果strace不认myadd这个名字因为它读的是系统自带的调用名表可以先用编号strace -e trace335 ./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你至少知道从哪查起。希望帮到你。本文还有配套的精品资源点击获取