操作系统接口
# 2. 操作系统接口
我们前面讲了操作系统启动的全过程,现在我们讲下上层应用怎么进到操作系统里面,从而最终使用硬件。也就是说,我们会讲应用程序和操作系统之间的那一层接口:
应用软件(我们平常使用的程序,浏览器,Word 等) |
---|
操作系统(Windows,Linux 等) |
计算机硬件(CPU,内存,显卡等) |
# 接口是什么
我们以生活中的例子为例,比如我们平时使用的插座,和汽车的油门:
我们只需将插头插入到插座里,我们就可以使用电了,至于插座背后原理是什么,什么是火线,什么是零线,电压是多少,我们都不用关心,只要会用就可以了;对于司机来说,如果要加速,只要按下油门即可,不用知道中间经过了什么机械装置,内部原理是怎么样的。生活中还有很多这样的例子,例如骑自行车,我们并不需要懂得自行车是拼装的细节;坐飞机也不需要知道飞机是怎么造出来的,背后的原理细节是什么……
其实接口不仅仅是操作系统的概念,也是一个常识。对于用户来说,不需要知道接口背后做了什么事情,都不用关心,只要会用就可以了。也就是说,有了接口以后,我们使用就非常方便。
但对于我们这些要编写操作系统的人来说,接口是必须要关心的,我们要知道接口背后做了什么事情。我们设计的接口要连接上层应用程序和操作系统,并且要简单,屏蔽细节和完成转换。
本课主要讲什么是操作系统接口,以及操作系统接口背后的原理
# 为什么要有操作系统接口
我们先说说为什么会有操作系统接口这个东西。比如为什么会出现插座?因为我们要用电,要开电脑,要充电手机;而为什么我们需要有操作系统接口呢?因为我们要使用操作系统。
举个 C 语言的例子,我们需要在屏幕上显示一句 “HelloWorld”,我们只需借住 printf 语句,就可以了。这个命令在操作系统一顿操作,屏幕上就会显示执行的结果。换句话说,我们通过 printf 语句,告诉操作系统我们要在屏幕上显示 HelloWorld,那么操作系统就会执行这句代码,执行结束后,操作系统会操作硬件(显示器),显示运行的结果
除了代码,没有别的方式使用操作系统了吗?也不一定。总共有 3 种方式
- 命令行(代码)。比如我们写了一个 C 语言程序,编译成一个可执行文件,然后我们用命令行运行这个程序(通过命令行),就会出来执行结果。
- 图形化界面:比如我们打开浏览器,打开文件夹,都是用鼠标的
- 应用程序。比如我们用 Word 写一些资料,保存的时候,就可以保存到磁盘上
# 深入下命令行
其实命令,就是一段 C 语言的程序。比如我们写一个简单的命令行工具(命名为 output.c):
#include<stdio.h>
int main(int argc, char* argv[]){
print("ECHO:%s\n", argv[1])
}
2
3
4
编译生成可执行文件后,我们运行它:
$ gcc -o output output.c
$ ./output "hello"
ECHO:hello
2
3
这段程序非常简单,就是读取命令行里的参数,然后输出到屏幕上。
复杂一点的命令,例如 GCC,其实也是一段程序,只不过比较复杂而已。
其实所有的命令,都对应着一段程序,顶多就是稍微复杂一点;这些程序编译后生成可执行文件,在命令行我们可以执行这些程序。
那么,敲入命令行后,发生了什么?其实是打开了一个 shell。
在操作系统引导的课程里,最后会打开一个 shell(或者说打开一个桌面),我们可以看 main.c 的一些关键代码(我们重点看第 12 行)
while (1) {
if ((pid=fork())<0) {
printf("Fork failed in init\r\n");
continue;
}
if (!pid) {
close(0);close(1);close(2);
setsid();
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
_exit(execve("/bin/sh",argv,envp));
}
while (1)
if (pid == wait(&i))
break;
printf("\n\rchild %d died with code %04x\n\r",pid,i);
sync();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在第 2 行,用 fork 函数向操作系统申请使用 CPU,然后第 12 行执行了一个 shell,用来执行用户输入的命令。
也就是,操作系统启动完后,最后执行了一个 shell(其实也是一段程序),这里是一个死循环,也就是说一直等待用户的输入,用户输入后就用 shell 执行这段命令(比如我们之前输入的 output)
这里的 fork 和 exec 是非常重要的,关键性的函数
# 简单介绍下图形化
图形按钮它基于的是一套消息机制。
实现一个消息队列,当鼠标点下去的时候,就要通过中断放到系统内部的一个消息队列,而应用层需要写一个系统调用,要 get message,把这些消息一个的取出,每次取出后根据消息的内容来改变屏幕上的像素
其实也就是循环调用一个函数取出消息,这就是著名的消息处理机制。
# 什么是操作系统接口
无论是命令行还是图形化界面,实际上都是一些程序,这些程序和 C 语言程序没有太大区别(顶多复杂一点),关键是调用了一些函数,通过这些函数来使用操作系统。
由此可以看出,上层应用是怎么使用底层的硬件的呢?例如 C 语言程序,就是一些普通的 C 语言代码,通过调用一些关键性的函数来使用操作系统。
因此,操作系统接口,其实就是一些函数。
因为使用这些函数的方式,就和普通的C语言调用函数一样,我们也可以叫它为调用;但它和普通函数不一样,它是操作系统提供给我们的,我们一般叫它为系统调用,system call。
# 标准接口
有哪些具体的操作系统接口呢?虽然我们前面讲了 C 语言的 printf 函数,但它其实不是系统接口,只是 C 语言内部帮我们封装了系统调用,最后我们其实调用的是 write 接口(后面会讲)。
操作系统的接口有很多很多,有没一个标准呢?有的。
就好比我们平时使用的插座,如果每个厂商生产的插座都不同,那么用户使用起来就非常麻烦。比如安卓手机用 Type-C 充电口,苹果手机使用的是 lighting C 接口,两者不能共用;如果换手机,那么充电线也要换。
标准的操作系统接口:POSIX,全称 Portable Operating System Interface of Unix,是 IEEE 协会制定的一个标准。
有了标准后,应用程序编写起来就很方便了。比如我在一台 Linux 的电脑上面编写了应用程序,如果另一个操作系统也用的是同样的接口,那么应用程序不用改动就可以在另一个操作系统上面跑。
用专业的说法就是,增强了程序的可移植性。
接下来,我们讲系统调用的实现。
# 有必要用系统调用吗?
举个例子,在操作系统内核里有一部分内存存储了当前登录的用户。我们可以在 Ubuntu,用 who 看当前用户是谁
$ who
root pts/1 2022-10-22 15:43 (113.xxx.xx.xxx)
2
Windows 上不用说了,我们开机时都要选择一个用户去输入账密登录。也可以用资源管理器看当前登录的用户:
而操作系统有一个叫做 whoami 的系统调用。为什么说它是系统调用?因为这个用户的信息在内存里,我们要进入到操作系统里面,所以这个是系统调用。
在讲如何实现这个系统调用之前,我们先来思考一个问题:为什么不能直接去内存里取值并打印?这是一个很很直接的想法。这个计算机是我购买的,内存条也是我买的;内核程序在内存里,应用程序也在内存里,只不过在内存的位置不同而已,为什么不能直接访问?
首先说答案:肯定是不能的(如果可以的话,也不会有操作系统这门课了)。因为直接访问的话,我直接调用一个普通函数就可以了,这样就成了函数调用,而不是系统调用。操作系统里面有很多重要的东西,不能随意的访问(也不能随意的修改,包括数据和 jmp)
比如,当有一个木马程序,直接去内存里将操作系统的用户密码都窃取了,那么计算机就完全没有安全性可研。
我们这里引出 3 个问题:
- 为什么不可以直接访问:我们已经讲过了,不安全
- 怎么才能防止直接访问与修改
- 既然不能直接访问和修改内存,要怎么进入内核中
# 如何防止直接访问内核
先说结论:得用硬件实现,只有硬件才有这种能力。
可以看到操作系统和硬件联系非常紧密,没有这种硬件设计,就没有什么系统调用,也就没操作系统这堂课,后面讲的内存管理之类也就不会有。所以说硬件操作系统和是和一个和硬件非常紧密相关的一门科学,所以我们也前面说过明白操作系统需要深刻的明白硬件
用硬件怎么实现呢?它把内存分成了很多区域,这里我们讲两个,第一个是用户态(应用程序所在的目录),对应的内存区域叫用户段;另一个区域是核心态(操作系统所在的内存区域),对应的内存区域叫内核段;
在汇编中我们学过,计算机对内存的使用都是一段一段的,比如数据段寄存器,栈段,代码段等。
特点和示意图如下:
- 内核段的代码只在内核态下运行,也只用它可以操作内核段;
- 用户态的程序不能操作内核段(访问或者跳转都不行)
- 我们可以用数字来表示特权级。数字越低,级别越高
具体怎么实现的呢?内核段,用户段,其实都得靠段寄存器来保存段基址;我们可以用 CS 段寄存器的低两位(称为 CPL),和 DS 的低两位(称为 DPL)来实现区分。其全称和含义如下:
DPL:Descriptor Privilege Level,D 也可以解释为 Destination 目标段 CPL:Current Privilege Level 当前的特权级
DPL 是用来描述目标段这就是一个目标内存段,用来表示目标内存段的特权级,就是你要跳往的、要访问的目标区域,它的特权级。
而 whoami 特权级等于多少?操作系统在初始化的时候,就已经将系统调用的函数地址放到内内核区了,DPL 是 0。实际上在我们前面讲初始化,head.s 里面,就将 GDT 表初始化好了,每个表项就来描述一段内存。所以在操作系统里面,无论是操作系统是数据段还是代码段,它的 GDP 表中的表项对应的 DPL 全等于 0,
而普通应用程序,就用 CPL 表示当前的特权级。当前的特权级取决于你执行的是什么指令。这里我们执行的是 main 函数,每一个执行指令的时候都得有 PC, 而 PC 就是由 CS 和 IP 合在一起的,所以 CS 其中一个部分就来表示这一段程序它所处于的特权级,当然它的特权级比较低,是 3
在每次访问的时候,都要看一看当前的特权级和访问的区域特权级并比较,这里检查:CPL ≤ DPL
如果当前特权级 CPL 是 0 的话,当 DPL 是 0,可以访问;当 CPL 是 0,DPL 的是 3,也可以访问,也就是说当前特权级是内核级,可以访问用户内存,也可以访问内核态内存,
如果当前特权级 CPL3 的话,例如 3,只能访问用户特权级 3,不能访问内核段 0,也就是说,我们一开始的 C 语言程序里要调用系统函数,但 CS 对应的当前特权当前特权级是 3,就不允许直接跳到这里系统函数里,因为系统函数的 DPL 等于 0。
那么就是这一套硬件机制表,通过这一套硬件机制,这种特权级也成为保护环,那么实际上最核心的就是靠 DPL 了和 CPL 了,由硬件来检查这条指令是不是合法,是不是满足特权的要求,如果不满足特权要求就进不去。
换句话说,当 CPU 执行内核的代码的时候,我们可以称此时 CPU 处于内核态,可以使用特权指令,这些指令权限很高,可以控制计算机的硬件;当 CPU 执行应用程序的时候,此时 CPU 处于用户态,不能使用特权指令。
最后我们小结下如何防止直接访问内核。whoami 是内核里的代码,在系统初始化的时候,DPL 已经被初始化为 0 了。在执行应用程序的时候,CPU 是处于用户态的(CPL=3)。当直接访问内核态的数据,是检查不通过的,没法直接访问
# 如何访问内核态的数据:中断
既然应用程序不能直接操作硬件,那么操作系统总得提供一个手段,让应用程序能简介操作硬件。
计算机提供了唯一的方法,通过中断才能进入内核。汇编的跳转,mov 等指令都不能进入内核。当然也不是所有的中断都可以进入内核,只有部分才可以。
前面说到的 whoami 展开来就是一段包含中断的代码。在 C 语言的库函数里,实际上写了一段包含中断的代码。我们平时调用 printf,通常都会引入标准输入输出的头文件:
#include <stdio.h>
"stdio" 是 "standard input & output" 的缩写。
因此 C 语言执行的过程大致是这样的:
用户编写程序调用 printf 函数 → printf 调用 C 语言的库函数 → 库函数里实现系统调用 → 根据中断进入内核 → 执行中断程序,处理系统调用 → 返回
所以大家可以看到,表明上只是一个 printf 函数,其实背后有很多事情发生。这也印证了接口的概念,表面看起来就是个插座,但背后连了很多电路。
我们之前学习汇编的时候,一个中断是怎么执行的?就是先保存当前运行的程序所用到的寄存器,然后根据中断向量表 查找中断例程的地址,跳转到该地址去执行中断例程,执行结束后,继续执行之前执行到一半的代码。
我们接下来就会展开来说具体是怎么执行的,大家一定要牢记基本的调用过程,这样就不会迷失在细节里:
用户编写程序调用printf函数
↓
printf调用C语言的库函数
↓
库函数里实现系统调用
↓
根据中断进入内核
↓
执行中断程序,处理系统调用
↓
返回
2
3
4
5
6
7
8
9
10
11
我们接下来会将如何调用接口以及中断向量表 IDT,以及中断处理函数做了什么
# 系统调用的实现大致过程
系统调用是通过 int 0x80 这个中断进去内核的,这是操作系统的规定。
具体怎么变成中断的? 我们可以看一个库函数,write。write.c 就只有 3 行代码:
/*
* linux/lib/write.c
*
* (C) 1991 Linus Torvalds
*/
#define __LIBRARY__
#include <unistd.h>
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
2
3
4
5
6
7
8
9
10
fd:要进行写操作的文件描述词。 buf:需要输出的缓冲区 count:最大输出字节计数
那么,这个库函数是怎么实现系统调用的呢?我们得看_syscall3。
_syscall3 是怎么执行的呢?用宏替换,我们可以看 linux-0.11\include\unistd.h 的关键代码,第 5 行有 int 0x80 中断的字眼(只看第 5 行就行,其他的我们后面讲):
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
2
3
4
5
6
7
8
9
10
11
12
我们暂停下,整理下系统调用的过程:
用户编写 C 语言程序调用 printf → printf 调用 C 的库函数 write → 库函数调用_syscall3
# 宏展开--准备中断的传参
我们来看看_syscall3 里做了什么。
在继续讲之前,补充一个小知识点:在 Linux 里,每个系统调用都具有唯一的一个系统调用号,这些功能号定义在 unistd.h 的第 60 号开始处。例如 write 对应的功能号是 4
#define __NR_setup 0
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
2
3
4
5
_syscall3 函数的签名如下:
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
而我们调用 C 的 printf 是这样调用的:
print("ECHO:%s\n", argv[1])
可以看到两个函数的参数都对不上,因此首先库函数会将 printf 转换为 write 所需的参数,然后再调用 write 变成一段包含 int 0x80 的中断代码,而这个中断代码再通过系统调用进入到操作系统里面。
在 unistd.h 里,我们说过通过 宏 展开一个具体的代码,里面包含了 int 0x80 中断
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
2
3
4
5
6
7
8
9
10
11
12
系统调用的细节,就从这个宏说起。这个宏就是典型的 C 语言内嵌汇编。我们来分析这段宏
我们先分析函数头:先对比下两个函数的签名:
_syscall3(int, write, int, fd, const char *, buf, off_t,count) //write.c
_syscall3(type, name, atype, a, btype, b, ctype, c) //unistd.h
2
我们逐个参数进行替换,例如第一个函数的第一个参数 int 替换下面的 type, name 对应下面的 write,以此类推,......
type=int,name=write,atype=int,a=fd,btype=const char * ,b=buf,ctype=off_t,c=count;
因此 type name(atype a, btype b, ctype c)
就变成了 int write(int fd,const char * buf, off_t count)
我们来解读下函数体。我们先去掉反斜线,让其语法高亮,方便解读:
type name(atype a, btype b, ctype c) {
long __res;
__asm__ volatile(
"int $0x80"
: "=a"(__res)
: "0"(__NR_##name), "b"((long)(a)), "c"((long)(b)), "d"((long)(c)));
if (__res >= 0)
return (type)__res;
errno = -__res;
return -1;
}
2
3
4
5
6
7
8
9
10
11
12
第 1 行是函数定义,之前讲过了
第 2 行定义了一个 long 类型的变量
第 3 行是内联汇编。内联汇编的格式为:
asm ( "汇编语句模板" :输出寄存器 :输入寄存器 :会被修改的寄存器 )
1
2
3
4
5
6“asm” 是内联汇编语句关键词,表明接下来是汇编语句了;“volatile” 表示编译器不要优化代码,后面的指令 保留原样;
第 4 行是汇编语句,这里是中断;
第 5 行是输出寄存器,这里是表示代码运行结束后将 eax 所代表的寄存器的值放入 __res 变量中;也就是返回值
第 6 行是输入寄存器,
__NR_##name
其实是将函数参数里的 name 替换了这里的 name,因此最后结果是__NR_write; 因此,操作系统就会知道是 4 号的系统调用号,知道要去执行 write 这个系统调用。
"b"((long)(a))
这里是把函数的参数 a 置给 EBX,
"c"((long)(b))
第二个参数置给 ECX,"d"((long)(c))
第三个参数置给 EDX接下来几行就是判断中断执行有无异常,没有就返回 res,有的话就返回异常信息
现在为什么来说下,为什么这个函数名叫_syscall3:因为有 3 个参数,只要是 3 个参数的都会用这个宏,在 unistd.h 里还有其他的函数:
#define _syscall0(type,name)
#define _syscall1(type,name,atype,a)
#define _syscall2(type,name,atype,a,btype,b)
2
3
但无论需要多少个参数,核心代码都是 int 0x80,通过宏里面的 内嵌汇编展开一段具体的实现。通过传递系统调用号给 eax 寄存器,(一般 ax 都存放功能号,这是我们汇编里学过的中断知识),然后执行中断的时候就会根据功能号执行具体的中断例程。
我们暂停下,整理下系统调用的过程:
用户调用 printf → printf 调用 C 的库函数 → 库函数里调用_syscall3 →_syscall3 根据宏展开准备好参数
# IDT 表的初始化
既然我们要执行中断,那么 int 0x80 的中断处理函数在哪呢?汇编里我们学过是在中断向量表;
在操作系统里,中断的执行过程也类似,只不过我们不是用中断向量表了,而是 IDT 表,Interrupt Descriptor Table 中断描述符表(在操作系统的引导里讲过)。根据 n 去查表,取出中断例程地址后执行。
同理,用户调用 printf 的时候,在执行 int 0x80 中断时也会去 IDT 查表,然后执行中断,执行完后,将处理结果返回给 __res 变量,再回来执行剩下的 C 语言代码
IDT 的一个表项的组成结构:
而 IDT 表,在操作系统启动的时候已经初始化好了,我们来看是如何初始化的。
- 在 main.c 的第 132 行处的这个方法就是初始化:
sched_init();
该方法在 linux-0.11\kernel\sched.c 的 385 行,该方法的最后一行如下,也就是设置中断号为 80 时,调用 system_call 函数,也就说后续 80 号中断都由这个函数来执行
IDT 每一个表项的内容就是中断例程的地址,每个表项也可以称为中断处理门,这就是为什么函数名有个 gate
void sched_init(void)
{
//…… 这里省略其他代码
set_system_gate(0x80,&system_call);
}
2
3
4
5
6
在 include\asm\system.h 的第 39 行,有这样一个宏定义:
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr) //这里IDT是中断向量表基址,addr就是system_call函数的地址
2
也就是说,我们会在 IDT 表里存入 80 号中断,以及 80 号中断的处理函数的地址,后面遇到 80 号中断,就会去执行 system_call 函数
set_system_gate 又调用了这样一个宏:_set_gate
在 include\asm\system.h 的 第 22 行,是这样定义的:
#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))
2
3
4
5
6
7
8
9
10
我们分析下函数头
- gate_addr 是 IDT 的地址 addr 就是 sched.c 里传的 &system_call 地址
- 15 传给 type
- 关键是这个 3 传给了 DPL
- 后续的 C 内嵌汇编就是将相关信息(特别是 system_call 的地址和 DPL 的值)填充 IDT 表项
我们解读下函数体
_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))
}
2
3
4
5
6
7
8
9
10
11
12
第一行是函数定义,之前讲过了各参数的意义
第 2~ 5 行是汇编代码,\n\t 表示换行,我们先去掉:
movw %%dx,%%ax //表示用 dx 加载 ax;
movw %0,%%dx //表示用(0x8000+(dpl<<13)+(type<<8))加载 dx,
movl %%eax,%1
movl %%edx,%2
2
3
4
第 6 行只有一个单引号,表明没有输出寄存器
第 7 到 10 行表明是输入:
-
"i" ((short) (0x8000+(dpl<<13)+(type<<8)))
i 表示直接操作数,short 表示操作字节,这是第 0 个操作数 -
"o" (*((char *) (gate_addr))),
o 表示内存单元 这是第一个操作数 -
"o" (*(4+(char *) (gate_addr))),
,这是第二个操作数 -
"d" ((char *) (addr))
这是第 3 个操作数,d 表示寄存器 edx,表示用 addr 加载 edx - 最后的一个
"a" (0x00080000))
表示将 0x0008 0000 的前 4 个十六进制数(共 16bit),赋值给段选择符
因此,汇编语句的第一行是将 dx 的值置给 ax
现在我们说下为什么能通过中断执行系统调用。在 C 语言进入内核前的那一刻,也就是执行 C 语言的的 prinf 的时候,其 CPL 是等于 3 的;
而我们刚刚讲到,这里也将 DPL 设置成 3,因此 CPL 和 DPL 都相等,可以执行系统调用。然后就可以跳到内核里去执行了。
在执行的时候,段选择符会被作为 CS 的值,CS=8 ;然后 systemcall 会被作为 IP 的值。
还记不记得我们在将操作系统引导的时候,setup.s 会开启 32 位汇编,jmpi 0, 8 最后会跳转到 0 地址处,执行 system 模块的代码?这里也是一样的,会跳转到 0 地址处,然后 IP 就是 system_call 的地址,开始执行 system_call 函数
8 的二进制就是 1000,因此最后两位 CPU 就等于 0,因此可以执行内核里的代码。
小结:在初始化的时候,将 80 号中断的 DPL 等于 3,因此用户程序可以进来;因此 DPL 和 CPL 相等,是可以执行中断处理函数的跳转指令的;而跳转指令 jmpi system_call, 8,根据我们之前讲过的知识可知,目前是 32 位的寻址模式,最后会跳转到 0 地址处去执行,并且 CPL 也会被设置成 0,可以执行后续的中断处理函数的指令。
当然将来在中断在返回的时候会执行一条指令,这条指令执行完了以后,CS 最后两位变成了 3,就又变成了用户态的东西,然后继续执行用户的 C 语言代码
# 中断处理函数 system_call 做了什么
system_call 函数的地址:linux/kernel/system_call.s ,我们挑一些关键的代码:
_system_call:
; ……其他代码
pushl %ebx
movl $0x10,%edx
mov %dx,%ds
mov %dx,%es ; ds和es都等于0x10, 二进制的最后2位是0,设置数据段为内核的数据段
; 关键就是下面的代码:
call _sys_call_table(,%eax,4)
ret_from_sys_call:
popl %eax
2
3
4
5
6
7
8
9
10
11
_sys_call_table 是一个全局函数表,在 include/linux/sys.h 中这样定义了一个数组:
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid,sys_setregid };
2
3
4
5
6
7
8
9
10
11
12
13
14
fn_ptr 是什么?在 include/linux/sched.h 中定义了:
typedef int (*fn_ptr)();
第 4 个元素就是放了 write 系统调用的地址。而我们之前传的参数就是 4,因此会调用 write 函数。
我们说过,unistd.h 里定义了系统调用号和名字的关系,因此会根据__NR_write 取出调用号之后,就可以去_sys_call_table 取出 系统调用函数的地址,就可以执行了
#define __NR_setup 0
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
2
3
4
5
_sys_call_table + 4 * %eax 就是相应系统调用处理函数入口。这里的是 4 表明每个系统调用的地址占 4 个字节,32 位二进制
至于 write 里怎么实现写内存的,得后面讲完 IO 后再说(具体看 fs/read_write.c)。因此,系统调用这个故事讲到这里就可以了。
我们现在讲了系统调用的时候,边界大致发生了什么事情,至于更底层,内部到底做了什么,后面再说。
# 总结
一个系统调用的过程:printf ->_syscall3 ->write -> int 0x80 -> system_call -> sys_call_table -> sys_write
- 用户调用 printf 的时候,CPL = 3,会展开成一段包含 int 0x80 的代码
- 在系统初始化的时候,设置了 IDT 表,将 int 0x80 的中断处理函数设置成 system_call,并且设置 DPL 也等于 3,所以才可以执行 “跳转到 system_call”这条指令。进入 system_call 函数后,CPL 是变成 0 的 ,接下来就在内核里处理
- system_call 里 会根据系统调用号,查表 sys_call_table
- 这里 printf 的系统调用号是 4
- 因此最后会调用 sys_write(这里就可以操作和访问内核的数据段)
我们之前提到的 whoami 调用,到第 5 步的时候就可以访问内核段的数据了
这堂课对应实验 2,只有做完了这堂课,后面的课程才能理解。