Lab2 添加系统调用

在Linux 0.11上添加两个系统调用,并编写两个简单的应用程序测试它们。

此次实验的基本内容是:在Linux 0.11上添加两个系统调用,并编写两个简单的应用程序测试它们。

iam()

第一个系统调用是iam(),其原型为:

1
int iam(const char * name);

完成的功能是将字符串参数name的内容拷贝到内核中保存下来。要求name的长度不能超过23个字符。返回值是拷贝的字符数。如果name的字符个数超过了23,则返回“-1”,并置errno为EINVAL。

在kernal/who.c中实现此系统调用。

whoami()

第二个系统调用是whoami(),其原型为:

1
int whoami(char* name, unsigned int size);

它将内核中由iam()保存的名字拷贝到name指向的用户地址空间中,同时确保不会对name越界访存(name的大小由size说明)。返回值是拷贝的字符数。如果size小于需要的空间,则返回“-1”,并置errno为EINVAL。

也是在kernal/who.c中实现。

测试程序

运行添加过新系统调用的Linux 0.11,在其环境下编写两个测试程序iam.c和whoami.c。最终的运行结果是:

1
2
3
$ ./iam lizhijun
$ ./whoami
lizhijun

评分标准

  • testlab2.c 在修改过的Linux 0.11上编译运行,显示的结果即内核程序的得分。满分50%
  • 只要至少一个新增的系统调用被成功调用,并且能和用户空间交换参数,可得满分
  • 将脚本 testlab2.sh 在修改过的Linux 0.11上运行,显示的结果即应用程序的得分。满分30%
  • 实验报告,20%

核心步骤:

首先,请将Linux 0.11的源代码恢复到原始状态

《注释》的5.5节详细讲述了0.11如何处理系统调用,是非常有价值的参考。

操作系统实现系统调用的基本过程是:

  1. 应用程序调用库函数(API);
  2. API将系统调用号存入EAX,然后通过中断调用使系统进入内核态;
  3. 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
  4. 系统调用完成相应功能,将返回值存入EAX,返回到中断处理函数;
  5. 中断处理函数返回到API中;
  6. API将EAX返回给应用程序。

实操

  • 添加系统调用号(修改include/unistd.h文件,里面有所有的系统调用号)
  • 修改kernel/system_call.s文件的系统调用数目
  • include/linux/sys.h中添加系统调用引用
  • 实现系统调用,在自己创建的kernel/who.c
  • 编写测试文件,编译,测试

步骤1:首先,在include/unistd.h文件中,添加两个新的系统调用,并且编号延续前面的系统调用编号。

1
2
#define __NR_whoami 72
#define __NR_iam 73

步骤2:修改kernel/system_call.s文件的系统调用数目,由于加了2个系统调用,所以从72更改为74.

1
nr_system_calls = 74

步骤3:在include/linux/sys.h中添加系统调用引用。

修改第一处地方:

1
2
extern int sys_whoami();
extern int sys_iam();

修改的第二处地方:在fn_ptr sys_call_table[]后面添加以下内容。

1
sys_whoami,sys_iam

步骤4:实现系统调用,添加系统调用的最后一步,是在内核中实现函数sys_iam()sys_whoami()

首先自己创建的kernel/who.c

此时,需要在文档中寻找有用的信息。

信息1:每个系统调用都有一个sys_xxxxxx()与之对应。比如在fs/open.c中的sys_close(int fd).

信息2:put_fs_xxx()get_fs_xxx()都是用户空间和内核空间之间的桥梁.用get_fs_byte()获得一个字节的用户空间中的数据。同理,put_fs_byte实现从核心态拷贝数据到用心态内存空间中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#define __LIBRARY__
#include <unistd.h>
#include <errno.h>
#include <asm/segment.h>
#include <linux/kernel.h>

//
char my_name[24];

// 系统调用函数,用于将用户传入的名字保存到全局变量 _myname 中

int sys_iam(const char * name){

int num=0;
while (get_fs_byte(name+num)!='\0'){
num++;
}
if(num>23){
errno = EINVAL;
return -1;
}else{
int i=0;
for(i=0;i<num;i++){
my_name[i]=get_fs_byte(name+i);
}
}
}
// 系统调用函数,将全局变量 _myname 中的名字复制到用户空间的 name 中
int sys_whoami(char* name, unsigned int size){
if(strlen(my_name)>size){
errno = EINVAL;
return -1;
}
printk("%s\n", my_name); // 在内核日志中打印名字
int i=0;
for(i=0;i<strlen(my_name);i++){
put_fs_byte(my_name[i], name + i);
}
}

步骤5: 修改Makefile

总共修改2处

第一处:

1
2
3
4
kernel/Makefile
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

改为

1
2
3
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

第二处

1
2
3
4
5
6
### Dependencies:
exit.s exit.o: exit.c ../include/errno.h ../include/signal.h
../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h
../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h
../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h
../include/asm/segment.h

改为

1
2
3
4
5
6
7
### Dependencies:
who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h
exit.s exit.o: exit.c ../include/errno.h ../include/signal.h
../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h
../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h
../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h
../include/asm/segment.h

【注意】:makefile文件请注意空格!!!

Makefile修改后,和往常一样“make all”就能自动把who.c加入到内核中了。

1
2
3
4
5
6
7
8
9
10
11
penge@penge-virtual-machine:~/Desktop/hit_os/hit-oslab-linux-20110823/oslab/linux-0.11/kernel$ make
gcc-3.4 -march=i386 -m32 -g -Wall -O -fstrength-reduce -fomit-frame-pointer -finline-functions -nostdinc -I../include \
-c -o who.o who.c
who.c: In function `sys_iam':
who.c:27: warning: control reaches end of non-void function
who.c: In function `sys_whoami':
who.c:30: warning: implicit declaration of function `strlen'
who.c:39: warning: control reaches end of non-void function
who.c:39:2: warning: no newline at end of file
ld -m elf_i386 -r -o kernel.o 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
sync
1
2
3
penge@penge-virtual-machine:~/Desktop/hit_os/hit-oslab-linux-20110823/oslab/linux-0.11$ make clean
penge@penge-virtual-machine:~/Desktop/hit_os/hit-oslab-linux-20110823/oslab/linux-0.11$ make
penge@penge-virtual-machine:~/Desktop/hit_os/hit-oslab-linux-20110823/oslab$ ./run

笔者这两条语句均运行

测试程序

首先在oslab目录下编写iam.cwhoami.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* iam.c */
#define __LIBRARY__
#include <unistd.h>
#include <errno.h>
#include <asm/segment.h>
#include <linux/kernel.h>
_syscall1(int, iam, const char*, name);

int main(int argc, char *argv[])
{
/*调用系统调用iam()*/
iam(argv[1]);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* whoami.c */
#define __LIBRARY__
#include <unistd.h>
#include <errno.h>
#include <asm/segment.h>
#include <linux/kernel.h>
#include <stdio.h>

_syscall2(int, whoami,char *,name,unsigned int,size);

int main(int argc, char *argv[])
{
char username[64] = {0};
/*调用系统调用whoami()*/
whoami(username, 24);
printf("%s\n", username);
return 0;
}

以上两个文件需要放到启动后的linux-0.11操作系统上运行,验证新增的系统调用是否有效,那如何才能将这两个文件从宿主机转到稍后虚拟机中启动的linux-0.11操作系统上呢?

这里我们采用挂载方式实现宿主机与虚拟机操作系统的文件共享,在 oslab目录下执行以下命令挂载hdc目录到虚拟机操作系统上。

1
sudo ./mount-hdc 

再通过以下命令将上述两个文件拷贝到虚拟机linux-0.11操作系统/usr/root/目录下,命令在oslab/目录下执行:

1
cp iam.c whoami.c hdc/usr/root

bug:

image-20240903105056066

如果目标目录下存在对应的两个文件则可启动虚拟机进行测试了。

【注意】这里修改的是linux0.11即bochs运行黑框中的文件

1
sudo vim /usr/include/unistd.h

为新增系统调用设置调用号

1
2
#define __NR_whoami	   	72
#define __NR_iam 73

image-20240903105109944

注意:由于调整完后会存在重影,这里我使用ctrl + L的方式清楚屏幕。

最后,请再认真熟读实验指导书QWQ

剖析

应用程序如何调用系统调用

在通常情况下,调用系统调用和调用一个普通的自定义函数在代码上并没有什么区别,但调用后发生的事情有很大不同。调用自定义函数是通过call指令直接跳转到该函数的地址,继续运行。而调用系统调用,是调用系统库中为该系统调用编写的一个接口函数,叫API(Application Programming Interface)。API并不能完成系统调用的真正功能,它要做的是去调用真正的系统调用,过程是:

  • 把系统调用的编号存入EAX
  • 把函数参数存入其它通用寄存器
  • 触发0x80号中断(int 0x80)

0.11的lib目录下有一些已经实现的API。Linus编写它们的原因是在内核加载完毕后,会切换到用户模式下,做一些初始化工作,然后启动shell。而用户模式下的很多工作需要依赖一些系统调用才能完成,因此在内核中实现了这些系统调用的API。

我们看一下close的API,位于文件lib/close.c

1
2
3
4
#define __LIBRARY__  //有它,_syscall1等才有效。详见unistd.h 
#include <unistd.h>

_syscall1(int,close,int,fd)

其中_syscall1是一个宏,在include/unistd.h中定义

1
2
3
4
5
6
7
8
9
10
11
12
#define _syscall1(type,name,atype,a) \
type name(atype a) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a))); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}

将参数带入进去展开

1
2
3
4
5
6
7
8
9
10
11
int close(int fd) 
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "0" (__NR_close),"b" ((long)(fd)));
if (__res >= 0)
return (int) __res;
errno = -__res;
return -1;
}

这就是API的定义。它先将宏__NR_close存入EAX,将参数fd存入EBX,然后进行0x80中断调用。调用返回后,从EAX取出返回值,存入__res,再通过对__res的判断决定传给API的调用者什么样的返回值。

其中,__NR_close就是系统调用的编号,在include/unistd.h中定义

1
#define __NR_close	6

补充:常见寄存器

线程需要保存的上下文通常包括以下内容:

  1. 线程栈指针(Stack Pointer,SP):保存线程栈的当前位置。
  2. 程序计数器(Program Counter,PC):保存线程当前正在执行的指令的地址。
  3. 通用寄存器(General-Purpose Registers,GPRs):如EAX、EBX、ECX、EDX等寄存器,保存线程执行时的数据。
  4. 浮点寄存器(Floating Point Registers):保存线程执行时使用的浮点数。
  5. 状态寄存器(Status Register):保存线程的一些状态信息,如进位标志、溢出标志等。

在线程上下文切换时,需要将这些上下文信息保存到内存中,以便在切换回该线程时能够恢复执行。这些上下文信息保存在操作系统内核的线程控制块(Thread Control Block,TCB)中,以便操作系统能够管理线程的状态。

SP、PC、EAX这些寄存器的具体作用如下:

  1. SP寄存器保存线程栈的当前位置,因为线程在执行时会使用栈来保存局部变量、函数调用信息和返回地址等信息,所以在切换线程时需要保存SP寄存器的值,以便下次恢复时能够正确地访问线程栈中的数据。
  2. PC寄存器保存线程当前正在执行的指令的地址,因为在线程执行时需要不断切换执行的指令,所以在切换线程时需要保存PC寄存器的值,以便下次恢复时能够从正确的指令地址继续执行。
  3. EAX累加寄存器等通用寄存器保存线程执行时的数据,因为线程执行时需要对数据进行计算和处理,所以在切换线程时需要保存这些寄存器的值,以便下次恢复时能够正确地处理数据。

从“int 0x80”进入内核函数

int 0x80触发后,接下来就是内核的中断处理了。先了解一下0.11处理0x80号中断的过程。

在内核初始化时,主函数(在init/main.c中)调用了sched_init()初始化函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void main(void)    
{
……
time_init();
sched_init();
buffer_init(buffer_memory_end);
……
}
sched_init()在kernel/sched.c中定义为:

void sched_init(void)
{
……
set_system_gate(0x80,&system_call);
}

上述代码的流程:就是填写IDT(中断描述符表),将system_call函数地址写到0x80对应的中断描述符中,也就是在中断0x80发生后,自动调用函数system_call。

细节是魔鬼~

系统调用

int $0x80指令属于软中断(software interrupt)。软中断又叫做编程异常(programmed exception),是异常的一种。该指令的作用是以0x80作为索引值,用于在中断描述符表IDT中查找存储了中断处理程序信息的描述符。在计算机中,中断分为同步中断和异步中断两种。

  1. 同步中断
    同步中断也叫做异常。同步中断是当指令执行时由CPU控制单元产生,并且只有在该指令终止执行后CPU才会发出中断。

  2. 异步中断

    异步中断也叫做中断。异步中断是由其他硬件设备随机产生的。 异步中断又可以细分为

    处理器探测异常(process-detected exception)和编程异常(programmed exception)。具体细分如下:

    1. 处理器探测异常

    处理器探测异常是当CPU执行指令时探测到的一个反常条件所产生的异常。根据CPU控制单元产生异常时保存在内核态堆栈EIP寄存器的值,可以将处理器探测异常分为3类:

    • 故障(trap)
      故障通常可以纠正。发生故障时保存在EIP中的值是引起故障的指令地址。因此,当异常处理程序顺利完成,即故障纠正,就会重新执行引起故障的那条指令。缺页异常就是基于这个机制。
    • 陷阱(trap)
      陷阱指令执行后会立即报告给CPU。保存在内核态堆栈EIP的值是一个随后要执行的指令地址。因此,当陷阱处理程序终止时,就会接着执行下一条指令。只有当没有必要重新执行已终止的指令时,才触发陷阱。陷阱的主要用途是为了调试程序。
    • 异常终止(abort)
      异常中止出现在发生了一个严重的错误。此时控制单元出了问题,不能在内核态堆栈EIP寄存器中保存引起异常的指令所在的确切位置。因此,异常中止会使受影响的进程终止。 2. 编程异常
      编程异常在编程者发出请求时发生。控制单元把编程异常作为陷阱来处理。因此,编程异常和陷阱类似,当编程异常处理程序终止时,紧接着执行下一条指令。

    int 指令:int 指令的作用是触发一个软件中断。这个指令会让 CPU 中断当前的执行流程,转而去执行中断向量表(通常是 IDT)中对应的中断处理程序。
    中断向量号:int 指令后面跟着一个字节的参数,这个参数是一个中断向量号,用来指定要触发的中断类型。每个中断向量号对应着一个中断处理程序的入口地址。

既然int $0x80的作用是以0x80为索引值,在IDT中查找对应的描述符,我们首先要找到在IDT中设置0x80这个索引项的描述符的代码。在Linux 0.11的目录树下查找0x80这个关键字,最终在kernel/sched.c中的sched_init()函数中找到了用于设置IDT中0x80这个索引项的描述符的代码:

1
2
3
4
5
6
7
8
void sched_init(void)
{
......

set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);
}

其中,set_system_gate(0x80,&system_call)就是用于设置的代码。 2. set_system_gate()宏
set_system_gate()是一个宏,在include/asm/system.h中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义一个宏来设置中断门、陷阱门或系统门
#define _set_gate(gate_addr, type, dpl, addr) \
__asm__ ("movw %%dx,%%ax\n\t" \ // 将 addr 的低 16 位移到 ax 寄存器
"movw %0,%%dx\n\t" \ // 将门描述符的低 16 位移到 dx 寄存器
"movl %%eax,%1\n\t" \ // 将 ax 和 dx 组合成的 32 位值写入 gate_addr
"movl %%edx,%2" \ // 将 dx 和 addr 的高 16 位组合成的 32 位值写入 gate_addr + 4
: \
: "i" ((short) (0x8000 + (dpl << 13) + (type << 8))), \ // 门描述符的低 16 位
"o" (*((char *) (gate_addr))), \ // gate_addr 的低 4 字节
"o" (*(4 + (char *) (gate_addr))), \ // gate_addr 的高 4 字节
"d" ((char *) (addr)), "a" (0x00080000)) // addr 的低 16 位和段选择子

// 设置中断门
#define set_intr_gate(n, addr) \
_set_gate(&idt[n], 14, 0, addr) // 中断门类型为 14,特权级为 0

// 设置陷阱门
#define set_trap_gate(n, addr) \
_set_gate(&idt[n], 15, 0, addr) // 陷阱门类型为 15,特权级为 0

// 设置系统门
#define set_system_gate(n, addr) \
_set_gate(&idt[n], 15, 3, addr) // 系统门类型为 15,特权级为 3

可以看出set_system_gate()主要借助另一个宏_set_gate()对IDT表中的0x80表项指定的描述符进行设置。 描述符根据其描述符类型标志S位的不同取值可以分为两类:代码或数据段描述符(当S=1)和系统段描述符(当S=0)。 其中系统段描述符又可以分为段描述符和门描述符两类,具体分类如下:

1
2
3
4
5
6
7
8
9
10
11
|- 描述符
|- 代码或数据段描述符
|- 系统段描述符
|- 段描述符
|- 局部描述符表(LDT)的段描述符
|- 任务状态段(TSS)描述符
|- 门描述符
|- 调用门描述符
|- 中断门描述符
|- 陷阱门描述符
|- 任务门描述符

IDT包含三种类型的描述符:中断门描述符,陷阱门描述符和任务门描述符。下面分别为这三种门描述符的字节分布:

  1. 中断门描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
​```register
63 48 47 46 44 43 40 39 37 36 32
+----------------------------------+--+----+--+--------+-+-+-+----------+
| |P | D |S | | | |
| Procedure Entry Address | | P | | TYPE |0 0 0| Reserved |
| 31...16 | | L |0 | 1|1|1|0| | |
+-------------+--+--+--+--+--------+--+----+--+--------+-+-+-+----------+
31 17 16 0
+----------------------------------+------------------------------------+
| | |
| Segment Selector | Procedure Entry Address |
| | 15...0 |
+----------------------------------+------------------------------------+
​```
  1. 陷阱门描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
​```register
63 48 47 46 44 43 40 39 37 36 32
+----------------------------------+--+----+--+--------+-+-+-+----------+
| |P | D |S | | | |
| Procedure Entry Address | | P | | TYPE |0 0 0| Reserved |
| 31...16 | | L |0 | 1|1|1|1| | |
+-------------+--+--+--+--+--------+--+----+--+--------+-+-+-+----------+
31 17 16 0
+----------------------------------+------------------------------------+
| | |
| Segment Selector | Procedure Entry Address |
| | 15...0 |
+----------------------------------+------------------------------------+
​```
  1. 任务门描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
​```register
63 48 47 46 44 43 40 39 37 36 32
+----------------------------------+--+----+--+--------+-+-+-+----------+
| |P | D |S | | | |
| Reserved | | P | | TYPE |0 0 0| Reserved |
| | | L |0 | 0|1|0|1| | |
+-------------+--+--+--+--+--------+--+----+--+--------+-+-+-+----------+
31 17 16 0
+----------------------------------+------------------------------------+
| | |
| TSS Segment Selector | Reserved |
| | |
+----------------------------------+------------------------------------+
​```

Linux利用中断门处理中断,利用陷阱门处理异常。因为int $0x80是编程异常,所以set_system_gate()在调用_set_gate()进行门描述符设置时,传入的参数是_set_gate(&idt[n], 15, 3, addr),其中15是陷阱门中的TYPE字段的值,表明了Linux确实是用陷阱门处理异常的。
下面我们具体下来set_system_gate(0x80,&system_call)是如何设置IDT中索引值为0x80的这个陷阱门描述符的。

1
2
3
4
5
6
7
8
9
10
11
12
13
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))

set_system_gate(0x80,&system_call)展开后调用的是_set_gate(&idt[0x80],15,3,&system_call)。而_set_gate()是包含了嵌入汇编的宏。这里的嵌入汇编代码包含了输入和输出参数,具有输入和输出参数的嵌入汇编的基本格式为:

1
2
3
4
__asm__("asm statements"
:outputs
:inputs
: register-modify)

在输入参数中,%0是指第一个输入参数,即((short) (0x8000+(3<<13)+(15<<8)))%1是第二个输入参数,即(*((char *) (&idt[0x80]))),指的是IDT中索引为0x80的陷阱门描述符的8个字节的低4个字节的内容,%2是第三个输入参数,即(*(4+(char *) (&idt[0x80]))),指的是IDT中索引为0x80的陷阱门描述符的8个字节中的高4个字节的内容,第四个输入参数写入到了EDX寄存器,其值为((char *) (&system_call)),指的是中断处理过程system_call的入口地址,第五个参数写入到了EAX寄存器,即值0x00080000
汇编指令部分中,执行movw %%dx, %%ax后,寄存器EAX的内容为:

1
2
3
4
31                   16 15                     0
+----------------------+-----------------------+
| 0x0008 | &system_call[15:0] |
+----------------------+-----------------------+

执行movw %0, %%dx后,寄存器EDX的内容为:

1
2
3
4
31                   16 15     11     7        0
+----------------------+------+------+---------+
| &system_call[31:16] |1|00|0| 1111 | 00000000|
+----------------------+-----------------------+

执行movl %%eax, %1将寄存器EAX的值写入到IDT表中索引值为0x80的陷阱门描述符所占的8个字节的内存空间的低4个字节的内存中,执行movl %%edx, %2将寄存器EDX的值写入到IDT表中索引值为0x80的陷阱门描述符所占的8个字节的内存空间的高4个字节的内存中。此时设置好的该陷阱门描述符的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
63                               48 47 46  44 43    40 39  37 36      32
+----------------------------------+--+----+--+--------+-+-+-+----------+
| | | | | | | |
| &system_call[31:16] |P |DPL |S | TYPE |0 0 0| Reserved |
| |1 | 00 |0 | 1|1|1|1| | |
+-------------+--+--+--+--+--------+--+----+--+--------+-+-+-+----------+
31 17 16 0
+----------------------------------+------------------------------------+
| | |
| Segment Selector | &system_call[15:0] |
| 0x0008 | |
+----------------------------------+------------------------------------+

门描述符是一个 8 字节(64 位)的数据结构,用于描述一个特定的中断、异常或系统调用的处理程序的入口地址和相关属性。描述符字段的含义如下:

  1. 高 32 位(63:32)
  • &system_call[31**:16]** (63:48):表示中断或异常处理程序的入口地址的高 16 位。system_call 是处理程序的名字,&system_call[31:16] 表示其地址的高 16 位。
  • P (47):表示段存在位(Present)。如果 P=1,表示这个描述符是有效的;如果 P=0,表示这个描述符无效。
  • DPL (46:45):描述符特权级别(Descriptor Privilege Level)。这是一个 2 位字段,表示可以访问该描述符的最低特权级别。DPL 的值范围是 0 到 3,0 级别的权限最高,3 级别的权限最低。
  • S (44):存储段位(S bit)。对于门描述符,此位为 0。
  • TYPE (43:40):描述符类型。对于中断或陷阱门,TYPE 通常为 1110(14,表示中断门)或 1111(15,表示陷阱门)。从你的描述来看,这里是 1111,因此它是一个陷阱门。
  • 0 0 0 (39:37):保留字段,通常设置为 0。
  • Reserved (36:32):保留字段,通常设置为 0。
  1. 低 32 位(31:0)
  • **Segment** **Selector (31:16)**:段选择子(Segment Selector),用于选择中断或异常处理程序所在的代码段。值为 0x0008,通常指向 GDT 中的内核代码段描述符。
  • &system_call[15:0] (15:0):中断或异常处理程序入口地址的低 16 位。

而中断或异常的处理过程是:

  1. 确定与中断或异常关联的向量号i(0 <= i <= 255)

  2. 以该向量号i为索引查找由idtr寄存器指向的IDT表的对应表项的门描述符(在下面的描述中,我们假定IDT表项中包含的是一个中断门或一个陷阱门)

  3. 根据IDT表中获取的门描述符中的段选择符的值,以这个值作为索引查找由gdtr寄存器指向的GDT表的对应表项的描述符。从GDT表中获取的描述符中含有中断或异常处理程序所在段的基地址。

  4. 特权级检查
    首先将当前特权级CPL(存放在CS寄存器的低两位)与从GDT中获取的段描述符中的描述符特权级DPL比较,如果CPL小于DPL就产生一个“General protection”异常,因为中断处理程序的特权不能低于引起中断的程序的特权。【自动屏蔽机制】对于编程异常,则做进一步的安全检查:比较CPL与从IDT中获取的门描述符的DPL,如果DPL小于CPL就产生一个“General protection”异常。这个检查可以避免用户应用程序访问特殊的陷阱门或中断门。也就是说,对于int $0x80这个编程异常,必须同时满足两个条件:DPL(in GDT) <= CPL(in CS)和CPL(in CS) <= DPL(in IDT)。因为是用户程序调用int $0x80指令的,所以CS = 3,而DPL(in IDT) = 3,同时该陷阱门描述符中的段选择符的值为0x0008,选中的是GDT表中内核代码段的段描述符,其DPL(in GDT) = 0,满足条件DPL(in GDT) <= CPL同时CPL <= DPL(in LDT)。

    • 中断有自动屏蔽机制(中断门),而异常通常没有(陷阱门),也就是上面表述的内容。
  5. 检查是否发生了特权级的变化,也就是说,CPL是否不同于所选择的段描述符的DPL。如果是,控制单元必须开始使用新的特权级的栈,要发生栈切换。

  6. 在栈中保存EFLAGS,CS及EIP的内容

  7. 如果异常产生一个硬件出错码,则将它保存在栈中

  8. 装载CS和EIP寄存器的值,其值分别是IDT表中第i项门描述符的段选择符和偏移量字段[我理解的偏移量就是系统调用函数相对段基地址的偏移量]。这些值给出了中断或异常处理程序的第一条指令的线性地址.

  9. 中断或异常处理程序执行

  10. 中断或异常处理程序执行完毕后,返回到被中断程序的保存在栈中的下一条指令继续执行。
    该过程示意图如下:

    image-20240904231007486

接下来看system_call。该函数纯汇编打造,定义在kernel/system_call.s中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
……
nr_system_calls = 72 #这是系统调用总数。如果增删了系统调用,必须做相应修改
……
.globl system_call
.align 2
system_call:
cmpl $nr_system_calls-1,%eax #检查系统调用编号是否在合法范围内
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx
pushl %ebx # push %ebx,%ecx,%edx,是传递给系统调用的参数
movl $0x10,%edx # 让ds,es指向GDT,内核地址空间
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # 让fs指向LDT,用户地址空间
mov %dx,%fs
call sys_call_table(,%eax,4)
pushl %eax
movl current,%eax
cmpl $0,state(%eax)
jne reschedule
cmpl $0,counter(%eax)
je reschedule

上述代码我们重点查看call sys_call_table(,%eax,4)这句。

1
call sys_call_table + 4 * %eax   # 其中eax中放的是系统调用号,即__NR_xxxxxx

显然,sys_call_table一定是一个函数指针数组的起始地址,它定义在include/linux/sys.h中:

1
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,……

该部分讲解可以观看视频。

上部分汇编的详细注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
system_call:
cmpl $nr_system_calls-1,%eax # 检查系统调用号是否在合法范围内(小于系统调用总数)。
ja bad_sys_call # 如果系统调用号不合法,跳转到 bad_sys_call 处理非法系统调用。

# 保存当前任务的段寄存器和通用寄存器的状态
push %ds # 保存数据段寄存器 %ds
push %es # 保存额外段寄存器 %es
push %fs # 保存附加段寄存器 %fs
pushl %edx # 保存通用寄存器 %edx
pushl %ecx # 保存通用寄存器 %ecx
pushl %ebx # 保存通用寄存器 %ebx,%ebx, %ecx, %edx 作为系统调用的参数

# 设置数据段寄存器 %ds 和 %es 指向内核空间
movl $0x10,%edx # 将段选择子 0x10(内核数据段)加载到 %edx
mov %dx,%ds # 将 %ds 设置为 %edx,指向内核空间
mov %dx,%es # 将 %es 设置为 %edx,指向内核空间

# 设置附加段寄存器 %fs 指向用户空间的本地数据段
movl $0x17,%edx # 将段选择子 0x17(用户空间数据段)加载到 %edx
mov %dx,%fs # 将 %fs 设置为 %edx,指向用户地址空间

# 根据系统调用号,从系统调用表中调用相应的系统调用处理函数
call sys_call_table(,%eax,4) # 从 sys_call_table 中调用系统调用函数,索引为 %eax * 4

# 系统调用返回后的处理
pushl %eax # 将系统调用返回值(通常存储在 %eax 中)压入栈中
movl current,%eax # 将当前进程的任务结构指针存入 %eax

# 检查当前任务的状态
cmpl $0,state(%eax) # 检查当前任务的状态(是否为 TASK_RUNNING)
jne reschedule # 如果状态不是 TASK_RUNNING,跳转到 reschedule 进行重新调度

# 检查当前任务的时间片
cmpl $0,counter(%eax) # 检查当前任务的时间片(counter 字段)
je reschedule # 如果时间片为 0,跳转到 reschedule 进行重新调度

ret_from_sys_call:
movl current,%eax # 将当前任务的任务结构指针存入 %eax
cmpl task,%eax # 检查当前任务是否是 init 任务(task[0]),init 任务不能有信号
je 3f # 如果是 init 任务,跳转到标签 3

# 检查是否需要处理信号
cmpw $0x0f,CS(%esp) # 检查旧的代码段是否为内核态(CS 不为 0x0f 表示在内核态)
jne 3f # 如果不在内核态,跳转到标签 3
cmpw $0x17,OLDSS(%esp) # 检查旧的栈段是否为用户态(0x17 表示用户栈段选择子)
jne 3f # 如果不在用户态,跳转到标签 3

# 处理待处理的信号
movl signal(%eax),%ebx # 获取当前任务的信号集 %ebx = signal(%eax)
movl blocked(%eax),%ecx # 获取当前任务的屏蔽信号集 %ecx = blocked(%eax)
notl %ecx # 取反 %ecx,得到未屏蔽的信号集
andl %ebx,%ecx # 获取未屏蔽的待处理信号集合 %ecx
bsfl %ecx,%ecx # 查找第一个未屏蔽的待处理信号
je 3f # 如果没有信号需要处理,跳转到标签 3

# 清除该信号并调用信号处理函数
btrl %ecx,%ebx # 清除找到的信号
movl %ebx,signal(%eax) # 更新信号集
incl %ecx # 递增信号编号
pushl %ecx # 将信号编号压入栈中
call do_signal # 调用信号处理函数
popl %eax # 恢复 %eax 寄存器

3: popl %eax # 恢复 %eax 寄存器(系统调用返回值)
popl %ebx # 恢复 %ebx 寄存器
popl %ecx # 恢复 %ecx 寄存器
popl %edx # 恢复 %edx 寄存器
pop %fs # 恢复 %fs 段寄存器
pop %es # 恢复 %es 段寄存器
pop %ds # 恢复 %ds 段寄存器
iret # 中断返回,切回用户态继续执行

实现sys_iam()和sys_whoami()

完成上述部分,我们就可以实现函数的编写啦(^_^)

简单来说:就是在用户态填写相关调用信息,int 0x80中断进入内核,然后在内核处理相关内容,将最终结果保存在寄存器放回给用户。

补充2.0

系统调用整体流程

步骤1:应用程序调用库函数(API)

lib/close.c目录

1
2
3
#define __LIBRARY__
#include <unistd.h>
_syscall1(int,close,int,fd)

步骤2:此时,API将系统调用号存入EAX,然后通过中断调用使系统进入内核态;

_syscall1这是一个宏,在include/unistd.h中定义。将其展开

1
2
3
4
5
6
7
8
9
10
11
int close(int fd) 
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "0" (__NR_close),"b" ((long)(fd)));
if (__res >= 0)
return (int) __res;
errno = -__res;
return -1;
}

它先将宏__NR_close存入EAX,将参数fd存入EBX,然后进行0x80中断调用。调用返回后,从EAX取出返回值,存入__res,再通过对__res的判断决定传给API的调用者什么样的返回值。其中__NR_close就是系统调用的编号.

步骤3:内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);

int 0x80触发后,接下来就是内核的中断处理了。先了解一下0.11处理0x80号中断的过程。

在内核初始化时,主函数(在init/main.c中,Linux实验环境下是main(),Windows下因编译器兼容性问题被换名为start())调用了sched_init()初始化函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void main(void)    
{
……
time_init();
sched_init();
buffer_init(buffer_memory_end);
……
}
sched_init()在kernel/sched.c中定义为:

void sched_init(void)
{
……
set_system_gate(0x80,&system_call);
}
set_system_gate是个宏,在include/asm/system.h中定义为:

#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
_set_gate的定义是:

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))

上述代码就是填写IDT(中断描述符表),将system_call函数地址写到0x80对应的中断描述符中,也就是在中断0x80发生后,自动调用函数system_call

❓:为啥会自动调用system_call

kernel/system_call.s中查看system_call这个部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
……
nr_system_calls = 72 #这是系统调用总数。如果增删了系统调用,必须做相应修改
……
.globl system_call
.align 2
system_call:
cmpl $nr_system_calls-1,%eax #检查系统调用编号是否在合法范围内
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx
pushl %ebx # push %ebx,%ecx,%edx,是传递给系统调用的参数
movl $0x10,%edx # 让ds,es指向GDT,内核地址空间
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # 让fs指向LDT,用户地址空间
mov %dx,%fs
call sys_call_table(,%eax,4)
pushl %eax
movl current,%eax
cmpl $0,state(%eax)
jne reschedule
cmpl $0,counter(%eax)
je reschedule

重点查看这句话sys_call_table。我们是通过这条语句找到相应的系统调用。

1
2
3
4
5
6
7
8
9
10
11
system_call: //所有的系统调用都从system_call开始
……
pushl %edx
pushl %ecx
pushl %ebx # push %ebx,%ecx,%edx,这是传递给系统调用的参数
movl $0x10,%edx # 让ds,es指向GDT,指向核心地址空间
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # 让fs指向的是LDT,指向用户地址空间
mov %dx,%fs
call sys_call_table(,%eax,4) # 即call sys_open

补充:由上面的代码可以看出,获取用户地址空间(用户数据段)中的数据依靠的就是段寄存器fs,下面该转到sys_open【fs/open.c】执行了

步骤4:系统调用完成相应功能,将返回值存入EAX,返回到中断处理函数;

步骤5:中断处理函数返回到API中;

步骤6:API将EAX返回给应用程序。

CPU运行情况

  • 在单核CPU上,用户态和内核态的代码必定是在同一个CPU核心上顺序执行的,因为CPU只能同时执行一个任务。
  • 在多核CPU上,通常情况下,用户态和内核态的代码还是会在同一个CPU核心上执行。操作系统通常避免在系统调用期间切换核心,以减少上下文切换的开销。
  • 特殊情况:在某些情况下,例如由于调度、抢占或多线程程序的存在,内核态代码可能会在不同的核心上执行,但这是例外而不是常态。