1. 实验前的准备:理解我们在做什么
如果你是第一次接触哈工大操作系统实验四(lab4),看到“系统调用”和“内核功能拓展”这些词,可能会觉得头大。别慌,咱们先把它翻译成“人话”。你可以把操作系统内核想象成一个大型商场的核心管理后台,而用户程序(比如你写的iam.c)就是商场里的顾客。顾客想存包(比如想在内核里存个名字),他不能直接闯进后台操作保险柜,对吧?他必须走到前台,对服务员说:“你好,请帮我存一下这个包。” 这个“走到前台并说出请求”的标准流程,就是系统调用。
所以,这个实验的本质,就是让我们自己动手,在这个叫 Linux 0.11 的古老但经典的“商场”里,新设立两个“服务窗口”。一个窗口叫iam,负责接收顾客(用户程序)递过来的一个名字字符串,并把它存到内核的某个保险柜(一个内核全局变量)里。另一个窗口叫whoami,负责把保险柜里存的名字再读出来,还给顾客。听起来很简单,对吧?但难点在于,整个流程涉及“用户态”和“内核态”两个完全隔离的世界,数据不能直接传递,权限也天差地别。我们需要搭建一座安全、标准的桥梁,这就是实验要我们完成的全部工作:从用户程序发起调用,到内核函数最终执行,打通整个链条。
在开始敲代码之前,有件事必须搞清楚,不然你会像我当初一样,在文件路径里转晕半天。实验环境里有一个关键的目录操作:挂载。我们实验的根目录是oslab,里面有一个hdc目录。这个hdc目录很特殊,在物理上,它是你宿主机(比如你的Ubuntu虚拟机)上的一个文件夹;但在逻辑上,我们需要让它“变成”即将启动的 Linux 0.11 虚拟机里的根文件系统。sudo ./mount-hdc这个命令,就是完成这个“变身”的魔法。执行后,你在oslab/hdc下创建的文件,在 Linux 0.11 启动后,就能在其根目录/下看到。反之,你在 Linux 0.11 里创建的文件,关机并解除挂载后,也会出现在oslab/hdc里。
所以,一个至关重要的工作流是:修改用户测试程序(iam.c,whoami.c)时,必须在挂载状态下,放到oslab/hdc/usr/root/目录下。因为这里对应着虚拟机启动后的/usr/root/,是我们可以操作的地方。而修改内核源代码(那些.h、.s、.c文件)时,则直接在oslab/linux-0.11/目录下进行,与hdc无关。最后,编译内核前,必须执行sudo umount hdc解除挂载,否则虚拟机无法启动。这个“挂载-修改用户程序,解除挂载-修改编译内核”的节奏,是实验顺畅进行的第一步,也是很多新手踩坑的地方。
2. 第一步:从用户态开始——编写测试程序
好,现在我们理解了基本框架。让我们先从用户的角度出发,看看顾客(用户程序)该如何规范地提出服务请求。我们将在oslab/hdc/usr/root/目录下创建两个C程序:iam.c和whoami.c。记住,现在先执行sudo ./mount-hdc挂载,再进入这个路径。
2.1 理解_syscallN宏:用户的“请求表单”
用户程序不能直接调用内核函数sys_iam,必须通过一个统一的入口。Linux 0.11 为我们提供了一系列宏,名字叫_syscall0到_syscall3,数字代表这个系统调用需要几个参数。它们就是封装好的“标准请求表单”。我们来看看iam.c该怎么写:
#define __LIBRARY__ /* 这个宏必须定义,才能使用 _syscallX 宏 */ #include <unistd.h> /* _syscallX 宏和系统调用号定义在这里 */ #include <stdio.h> /* 为了使用 printf */ /* 声明 iam 系统调用。这是一个“展开”出来的函数,不是我们实现的。 它告诉编译器:有一个叫 iam 的系统调用,类型是 int,它接受一个 const char* 类型的参数。 宏会帮我们生成调用汇编指令 int 0x80 的代码。 */ _syscall1(int, iam, const char*, name); int main(int argc, char** argv) { int wlen = 0; if(argc < 2) { /* 注意:argv[0]是程序名,argv[1]才是第一个参数 */ printf("Usage: %s <your-name>\n", argv[0]); return -1; } wlen = iam(argv[1]); /* 这里看似调用一个普通函数,实则触发了系统调用 */ printf("iam: wrote %d characters\n", wlen); return wlen; }我来解释几个关键点。第一行#define __LIBRARY__是钥匙,没有它,后面的_syscall1宏就不会展开。_syscall1(int, iam, const char*, name)这行可以理解为:“我要定义一个名为iam的函数,它返回int类型,它接受一个const char*类型的参数,这个参数在函数内部我们叫它name。”这个宏展开后,会生成一段包含int 0x80软中断指令的汇编代码,这正是从用户态陷入内核态的“门铃”。
同理,whoami.c需要能接收内核返回的字符串,所以它需要两个参数:一个缓冲区地址用来存放读出的名字,一个缓冲区大小防止越界。因此我们使用_syscall2宏。
#define __LIBRARY__ #include <unistd.h> #include <stdio.h> /* 声明 whoami 系统调用。它返回 int,接受两个参数:char* 和 unsigned int */ _syscall2(int, whoami, char*, name, unsigned int, size); int main() { char buffer[30]; /* 准备一个足够大的缓冲区 */ int rlen = 0; rlen = whoami(buffer, 30); /* 调用系统调用,试图读取内核存储的名字 */ if(rlen >= 0) { buffer[rlen] = '\0'; /* 确保字符串正确终止,虽然 whoami 应该做了 */ printf("whoami: %s (length: %d)\n", buffer, rlen); } else { printf("whoami: read failed with error code %d\n", rlen); } return rlen; }写完后,可以先在宿主机上用gcc -o iam iam.c -Wall编译一下,检查语法错误。但注意,此时链接会失败,因为iam和whoami函数在内核里还不存在,我们只是声明了它们。这没关系,我们的目的是确保用户态逻辑正确。真正的运行要等到整个内核改造完成后,在 Linux 0.11 虚拟机里进行。
3. 第二步:内核的“服务目录”与“工号”——修改系统调用表与编号
用户程序填好了“请求表单”(调用了iam()),接下来这个请求怎么找到内核里对应的处理函数sys_iam()呢?这需要内核内部有两份“名录”。
3.1 分配“工号”:在unistd.h中添加系统调用号
系统调用号就像是每个系统调用服务的唯一“工号”。当用户程序通过int 0x80陷入内核时,会把“工号”放在寄存器eax里。内核根据这个号码去查表,找到该谁干活。
我们需要修改oslab/linux-0.11/include/unistd.h文件(注意,这个路径在linux-0.11目录下,不在hdc里)。找到类似下面这样的一堆#define __NR_xxx的语句,在最后添加我们的两个新工号。原系统调用号已经排到了71(__NR_setreuid),所以我们接着排72和73。
/* 在文件末尾,已有的系统调用号定义之后添加 */ #define __NR_setreuid 71 /* 这是我们新增的两个 */ #define __NR_whoami 72 #define __NR_iam 733.2 更新“服务总机”:在system_call.s中增加调用总数
光有工号还不行,内核里维护着一个“当前支持的服务总数”变量,用于做边界检查。我们需要在oslab/linux-0.11/kernel/system_call.s这个汇编文件中找到它。用编辑器打开,找到类似.globl nr_system_calls和nr_system_calls = 72这样的行(具体行号可能因版本略有差异)。把总数从72改为74,因为我们增加了两个。
/* 在文件开头附近 */ .globl nr_system_calls nr_system_calls = 74 /* 原来是72,改为74 */3.3 登记“服务分机”:在sys.h中修改系统调用表
这是最关键的一步,相当于把新员工(sys_iam函数)的座机号码登记到公司的总机接线表上。这个“接线表”就是系统调用表sys_call_table,它位于oslab/linux-0.11/include/linux/sys.h。
首先,在文件前面的函数声明部分,添加我们即将实现的函数的声明:
/* 在 extern 声明的区域添加 */ extern int sys_iam(); extern int sys_whoami();然后,找到sys_call_table数组的定义。它是一个函数指针数组,顺序必须与unistd.h中的系统调用号严格对应。在数组的末尾,sys_setreuid之后,添加我们的两个新函数。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, /* ... 中间省略很多 ... */ sys_setreuid, /* 这是第71号 */ sys_iam, /* 这是我们新增的第72号 */ sys_whoami /* 这是我们新增的第73号 */ };这里有个大坑!原数组很可能以sys_setreuid结尾,后面没有逗号。你需要确保在sys_setreuid,后面加上sys_iam, sys_whoami,并且整个数组的末尾大括号前没有多余的逗号。数组的序号是从0开始的,所以sys_iam在这里是第72个元素(下标71),正好对应__NR_iam=73?等等,这里容易混乱。我们来看:sys_call_table[0]对应__NR_setup = 0,sys_call_table[71]对应__NR_setreuid = 71。那么,sys_call_table[72]就应该对应__NR_iam = 72,sys_call_table[73]对应__NR_whoami = 73。所以,在数组里,sys_iam必须放在sys_setreuid之后的第一位。请务必对照你的unistd.h中的编号,确保这个顺序完全一致,错一位都会导致调用错乱。
4. 第三步:实现核心服务——编写内核函数who.c
现在,该实现真正的服务逻辑了。我们在oslab/linux-0.11/kernel/目录下创建一个新文件who.c。所有内核函数的实现,习惯上以sys_开头。
4.1 数据存储与头文件
首先,我们需要一个内核空间的全局字符数组来存储名字。然后,包含必要的内核头文件。
/* who.c */ #define __LIBRARY__ /* 在内核中也需要,为了使用_syscallX相关的底层宏?不,这里主要是为了后面可能用到的宏,其实可以不要。但按照惯例保留 */ #include <asm/segment.h> /* 最重要!包含 get_fs_byte 和 put_fs_byte,用于和用户空间交换数据 */ #include <errno.h> /* 使用错误码 EINVAL */ #include <string.h> /* 使用 strlen, strcpy */ /* 内核全局变量,用于存储名字。长度定为24,包括23个字符和1个结尾的'\0' */ char kernel_msg[24]; int sys_iam(const char * name) { /* 功能:将用户空间字符串 name 拷贝到内核的 kernel_msg。 规则:name 的长度不能超过23个字符(不含结尾的\0)。 返回:成功拷贝的字符数(不含结尾的\0)。如果超长,返回 -EINVAL。 */ }4.2 实现sys_iam:从用户空间读取数据
用户空间的指针name在内核态是不能直接解引用的,必须通过专门的函数get_fs_byte来一个字节一个字节地读。这是一种安全机制。
int sys_iam(const char * name) { char tmp[30]; /* 临时缓冲区,稍大一些用于检查长度 */ int i = 0; int len = 0; /* 步骤1:安全地将用户空间字符串读入内核临时缓冲区 tmp */ for(i = 0; i < 30; i++) { /* get_fs_byte((char*)name + i) 从用户地址 name+i 读取一个字节 */ tmp[i] = get_fs_byte(name + i); if(tmp[i] == '\0') { /* 遇到字符串结束符,停止读取 */ break; } } /* 步骤2:计算字符串长度。注意,上面的循环可能在 i==30 时结束,此时字符串可能没有\0,是异常的。 */ /* 我们使用更安全的方式:遍历 tmp 直到遇到 \0 或到达30 */ for(len = 0; len < 30 && tmp[len] != '\0'; len++) { ; } /* 步骤3:检查长度是否合规 */ if(len > 23) { /* 设置全局错误变量 errno,并返回负的错误码 */ return -EINVAL; /* EINVAL 在 errno.h 中定义,表示无效参数 */ } /* 步骤4:长度合规,拷贝到永久存储区 kernel_msg */ strcpy(kernel_msg, tmp); /* 因为 tmp 是以 \0 结尾的,strcpy 是安全的 */ /* 步骤5:返回拷贝的字符数(不含\0) */ return len; }4.3 实现sys_whoami:向用户空间写入数据
这个函数是反向操作,将内核的kernel_msg写回用户空间。同样,不能直接赋值,要用put_fs_byte。
int sys_whoami(char* name, unsigned int size) { /* 功能:将内核的 kernel_msg 拷贝到用户空间 name 指向的缓冲区。 规则:确保不会越界。size 是用户提供的缓冲区大小。 返回:成功拷贝的字符数(不含结尾的\0)。如果 kernel_msg 长度(含\0)大于 size,返回 -EINVAL。 */ int kernel_len = 0; int i = 0; /* 步骤1:计算 kernel_msg 的长度(含结尾的\0) */ for(kernel_len = 0; kernel_len < 24 && kernel_msg[kernel_len] != '\0'; kernel_len++) { ; } kernel_len++; /* 把结尾的 \0 也算上,因为我们需要复制它 */ /* 步骤2:检查用户缓冲区是否足够大 */ if(kernel_len > size) { return -EINVAL; } /* 步骤3:逐个字节写回用户空间 */ for(i = 0; i < kernel_len; i++) { /* put_fs_byte(kernel_msg[i], name + i) 将字节写入用户地址 name+i */ put_fs_byte(kernel_msg[i], name + i); } /* 步骤4:返回拷贝的字符数(不含结尾的\0),即 strlen(kernel_msg) */ return kernel_len - 1; }这里关于长度的计算需要仔细理解。sys_whoami的size参数,用户期望它表示“缓冲区能容纳多少个字符”。通常,一个长度为 N 的字符串需要 N+1 个字节(包括\0)。所以,如果kernel_msg的内容是"hello"(长度5),那么kernel_len计算为6(包含\0)。我们要求size >= 6才能成功复制。返回值是5,即字符串内容的长度。这种设计更符合 C 语言字符串处理的习惯,也更能防止缓冲区溢出。
5. 第四步:让内核认识新员工——修改 Makefile
我们创建了who.c这个新文件,但内核的编译系统(Makefile)还不知道它的存在。我们需要修改oslab/linux-0.11/kernel/Makefile,告诉它:“编译内核时,请把who.c也编译进去,并把生成的who.o链接到最终的内核映像里。”
用编辑器打开这个 Makefile,你会看到一堆OBJS = ...的定义。我们需要在合适的地方添加who.o。
# 在 OBJS 的定义中找到合适的位置添加 who.o OBJS = sched.o system_call.o traps.o asm.o fork.o \ panic.o printk.o vsprintf.o sys.o exit.o \ signal.o mktime.o who.o # <-- 在这里添加 who.o通常,OBJS列表很长,你可以在sys.o附近找一个位置插入who.o。确保语法正确,用反斜杠\续行,并用空格分隔。另外,在 Makefile 的后面,可能还有针对每个.o文件的编译规则依赖关系。我们通常不需要为who.o单独添加规则,因为 Makefile 有默认的编译规则:who.o会自动由who.c编译生成。
修改完 Makefile,内核的改造部分就全部完成了。现在,是时候把它们组装起来了。
6. 第五步:编译、运行与测试——见证成果
这是最激动人心的环节。请确保你已经解除了挂载(sudo umount hdc),因为编译和运行虚拟机需要独占hdc镜像文件。
6.1 重新编译内核
在oslab/linux-0.11目录下,执行编译命令:
make clean # 可选,但建议先清理旧的编译结果,确保全新编译 make all如果一切修改正确,你会看到编译过程顺利进行,最后生成新的内核映像文件Image。如果出现错误,请仔细检查:
- 系统调用号
unistd.h和sys_call_table数组中的顺序是否对应。 sys.h中的函数声明extern int sys_iam();是否添加。who.c中的函数名是否与声明一致(sys_iam,sys_whoami)。kernel/Makefile中who.o的拼写和位置是否正确。- 所有文件是否保存在了正确的路径。
6.2 启动虚拟机并测试
编译成功后,在oslab目录下启动调试模式的 Bochs:
./dbg-bochs等待虚拟机启动,出现命令行提示符[/usr/root]#。现在,编译并运行我们之前写好的用户测试程序:
# 编译用户程序 gcc -o iam iam.c -Wall gcc -o whoami whoami.c -Wall # 测试1:正常写入和读取 ./iam Linus ./whoami # 预期输出:Linus # 测试2:写入超长字符串(超过23个字符) ./iam ThisIsAVeryLongNameThatExceedsLimit ./whoami # 预期输出:仍然是上一次成功的 "Linus",因为超长写入应该失败,内核变量未被修改。 # 并且 iam 程序应该返回一个负数(-EINVAL,即-22)。 # 测试3:测试 whoami 的缓冲区保护 ./iam Alice ./whoami # 预期输出:Alice # 现在写一个很小的缓冲区测试程序 echo -e '#define __LIBRARY__\n#include <unistd.h>\n_syscall2(int, whoami, char*, n, unsigned int, s);\nint main(){char b[5]; int r=whoami(b,5); printf("ret=%d\\n", r);}' > test_small.c gcc -o test_small test_small.c -Wall ./test_small # 预期输出:ret=-22 (EINVAL),因为缓冲区大小5小于字符串"Alice"所需的空间(6)。如果所有测试都符合预期,那么恭喜你!你已经成功地向 Linux 0.11 内核添加了两个全新的系统调用,完整地走通了从用户态到内核态,再返回用户态的整个数据流。这个过程虽然繁琐,但它清晰地揭示了操作系统如何为用户程序提供服务的底层机制:通过一个定义良好的软中断接口、一张精确的函数指针表、以及一套严格的内核数据访问规范。
7. 实验背后的思考:参数传递与机制扩展
实验做完了,我们再来深入思考两个问题,这也是实验报告中常要求的。
第一个问题:从 Linux 0.11 现在的机制看,它的系统调用最多能传递几个参数?
答案是3个。证据就在include/unistd.h中,它只定义了_syscall0到_syscall3这4个宏。在底层,int 0x80发生时,参数是通过寄存器ebx、ecx、edx传递的,而32位架构下通用寄存器数量有限,当时的设计者就定了3个参数这个上限。_syscall3宏就是利用这三个寄存器来传递它的三个参数a, b, c。
第二个问题:你能想出办法来扩大这个限制吗?
当然可以,这是一个经典的系统设计问题。当3个参数不够时,可以借鉴现代操作系统和一些软件的设计思路:
- 结构体打包:这是最常用、最优雅的方法。让用户传递一个结构体的指针。这个结构体可以包含任意多的字段。系统调用只需要一个参数(指针),但通过这个指针,内核可以读取到大量数据。这需要用户和内核共享同一个结构体定义,并确保指针有效性。
- 栈上传递:在陷入内核前,将多余的参数压入用户栈。内核通过某种方式(比如通过一个寄存器传递用户栈指针)来访问这些栈上的参数。但这需要内核能安全地访问用户栈,并且要处理好栈帧对齐等问题,实现起来较复杂。
- 寄存器组复用:如果参数都是较小的整数,可以考虑将两个16位参数合并到一个32位寄存器中传递。但这会限制参数类型和大小。
- 增加专用寄存器:修改硬件或利用更多通用寄存器(如
esi,edi)来传递参数。但这需要改动体系结构相关的底层汇编代码,兼容性差。
在实际的 Linux 发展史上,后来确实采用了类似结构体指针的方式来解决复杂系统调用的参数传递问题,使得系统调用的功能可以非常强大和灵活。
通过这个实验,你亲手打通了用户与内核的壁垒。这不仅仅是完成一次作业,更是对操作系统核心交互机制的一次深刻触摸。下次当你再在程序中调用read、write这样的函数时,你就能清晰地看到它背后那一套精密的“请求-派发-执行-返回”的舞蹈。这种从模糊到清晰的理解过程,正是操作系统学习的魅力所在。