我们已经了解了操作系统启动的过程, 现在我们需要更进一步的了解软件是如何调用硬件的

介绍

当我们在编写一段代码时,比如 printf("Hello World")执行后,会在屏幕中输出我们的内容, 但是这部分内容是如何输出到屏幕中的, 我们毫不知情,因为这部分调用是由编译软件负责帮助我们进行沟通的, 编译软件通过调用 OS 的接口来实现这些功能.

那么 OS 接口是什么? 在底层他是如何实现的?

我们都学过编程, 当我们想要调用一个产生随机数的函数时,我们会通过调用 random函数来获取一个随机数, 那么 OS 接口就类似于这里的 random() (这里得出 OS 接口其实就是一些函数) 同时我们需要注意的是因为这些函数是操作系统提供给我们的,为了和普通调用区别我们将其称为 System Call (系统调用).

标准接口

现在我们对 OS 接口已经有了基本的了解, 那么另一个问题出现了,你搞一个 A 系统然后出一个 A_OS 接口 , 我搞一个 B系统 出一个 B_OS 接口, 这时候 C 写了一个程序,在 A_OS 上使用的好好的, 放到 B_OS 上就死了, 这时候还得为了 B_OS 专门在写一个程序,这时候就很不友好,为了解决这一乱象,IEEE 协会制定了一个 标准接口

分类 POSIX 定义 描述
任务管理 fork 创建一个进程
execl 运行一个可执行程序
pthread_create 创建一个线程
文件系统 open 打开一个文件或目录
EACCES 返回值, 表示没有权限
mode_t st_mode 文件头结构:文件属性

系统调用是必要的?

在 Linux 中我们有时候需要知道自己的当前登陆的用户,我们可以在 Linux 使用 whoami来查看当前用户是谁, 但是这部分数据是存储在内存中的,所以我们需要进行一个 系统调用来进行操作.

这时候我们提出几个问题?

  • 为什么需要使用系统调用实现,而不是函数调用?
  • 如何防止直接访问修改
  • 如何进入内核

为什么需要使用系统调用实现,而不是函数调用?

对于第一个问题,我们可以直接给出答案: 因为内存中会存在很多重要的数据比如用户密码等等数据, 如果我们可以通过函数调用来进行读取, 我们就可以通过编写一个函数来实现随意的修改和读取机器内存中数据,进而获取到用户密码或者破坏机器的目的.

如何防止直接访问内核

我们是依靠硬件实现的,那么如何实现的?

我们的 OS 会将内存分为很多的区域, 但是一般我们都以两种类别: 用户态对应的内存区域叫用户段, 核心态对应的内存区域为内核态

特点和示意图如下:

  1. 内核段的代码只在内核态下运行,也只用它可以操作内核段;
  2. 用户态的程序不能操作内核段(访问或者跳转都不行)
  3. 我们可以用数字来表示特权级。数字越低,级别越高

操作系统接口 - 图1

上面只是简述了简单的一个例子, 我们再次精进进行一波思考如何实现的?

内核段,用户段,其实都得靠段寄存器来保存段基址;我们可以用CS段寄存器的低两位(称为CPL),和DS的低两位(称为DPL)来实现区分。其全称和含义如下:

  1. DPLDescriptor Privilege LevelD也可以解释为Destination 目标段
  2. CPLCurrent Privilege Level 当前的特权级
DPL是用来描述目标段这就是一个目标内存段,用来表示目标内存段的特权级,就是你要跳往的、要访问的目标区域,它的特权级。 <font style="color:rgb(0, 50, 60);">whoami</font> 特权级等于多少?操作系统在初始化的时候,就已经将系统调用的函数地址放到内核区了,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了,由硬件来检查这条指令是不是合法,是不是满足特权的要求,如果不满足特权要求就进不去。

如何进入内核? 中断

:::success 一个新的问题: 在第二个问题中解释了如何防止直接访问内核数据, 那么 OS 应该提供另一个方法来操作硬件?

这里计算机提供了唯一的方法: 中断, 只有通过中断才能进入内核. 当然也不是所有的中断都可以进入内核,只有部分才可以

:::

我们在前面提到过一个 whoami指令,在该指令查阅就是一段包含中断的代码, 在C语言的库函数里,实际上写了一段包含中断的代码。因此C语言执行的过程大致是这样的:

用户编写程序调用printf函数 → printf调用C语言的库函数 → 库函数里实现系统调用 → 根据中断进入内核 → 执行中断程序,处理系统调用 → 返回

所以大家可以看到,表明上只是一个printf函数,其实背后有很多事情发生。

操作系统接口 - 图2

我们之前学习汇编的时候,一个中断是怎么执行的?就是先保存当前运行的程序所用到的寄存器,然后根据中断向量表 查找中断例程的地址,跳转到该地址去执行中断例程,执行结束后,继续执行之前执行到一半的代码。 我们接下来就会展开来说具体是怎么执行的,大家一定要牢记基本的调用过程,这样就不会迷失在细节里:
  1. 用户编写程序调用printf函数
  2. printf调用C语言的库函数
  3. 库函数里实现系统调用
  4. 根据中断进入内核
  5. 执行中断程序,处理系统调用
  6. 返回

系统调用的实现过程

系统调用是通过int 0x80 这个中断进去内核的,这是操作系统的规定。

具体怎么变成中断的? 我们可以看看相关的代码:<font style="color:rgb(0, 50, 60);">write.c</font>就只有3行代码:
  1. /*
  2. * linux/lib/write.c
  3. *
  4. * (C) 1991 Linus Torvalds
  5. */
  6. #define __LIBRARY__
  7. #include <unistd.h>
  8. // fd: 要进行写操作的文件描述此
  9. // buf : 需要输出的缓冲区
  10. // count : 最大输出字节数
  11. _syscall3(int,write,int,fd,const char *,buf,off_t,count)

那么 _syscall3是如何执行的? 使用 宏替换, 我们可以看linux-0.11\include\unistd.h的关键代码,第5行有int 0x80中断的字眼

  1. #define _syscall3(type,name,atype,a,btype,b,ctype,c) \
  2. type name(atype a,btype b,ctype c) \
  3. { \
  4. long __res; \
  5. __asm__ volatile ("int $0x80" \
  6. : "=a" (__res) \
  7. : "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
  8. if (__res>=0) \
  9. return (type) __res; \
  10. errno=-__res; \
  11. return -1; \
  12. }
我们暂停下,整理下系统调用的过程: 用户编写C语言程序调用printf → printf调用C的库函数 → 库函数里调用_syscall3

操作系统接口 - 图3

宏展开—准备中断的传参

我们来看看_syscall3 里做了什么。 在继续讲之前,补充一个小知识点:在Linux里,每个系统调用都具有唯一的一个系统调用号,这些功能号定义在unistd.h的第60号开始处。例如write对应的功能号是4
  1. #define __NR_setup 0
  2. #define __NR_exit 1
  3. #define __NR_fork 2
  4. #define __NR_read 3
  5. #define __NR_write 4
_syscall3函数的签名如下:
  1. _syscall3(int,write,int,fd,const char *,buf,off_t,count)
而我们调用C的printf是这样调用的:
  1. print("ECHO:%s\n", argv[1])
可以看到两个函数的参数都对不上,因此首先库函数会将printf转换为write所需的参数,然后再调用write变成一段包含int 0x80的中断代码,而这个中断代码再通过系统调用进入到操作系统里面。 在unistd.h里,我们说过通过 宏 展开一个具体的代码,里面包含了int 0x80中断
  1. #define _syscall3(type,name,atype,a,btype,b,ctype,c) \
  2. type name(atype a,btype b,ctype c) \
  3. { \
  4. long __res; \
  5. __asm__ volatile ("int $0x80" \
  6. : "=a" (__res) \
  7. : "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
  8. if (__res>=0) \
  9. return (type) __res; \
  10. errno=-__res; \
  11. return -1; \
  12. }
系统调用的细节,就从这个宏说起。这个宏就是典型的C语言内嵌汇编。我们来分析这段宏 我们先分析函数头:先对比下两个函数的签名:
  1. _syscall3(int, write, int, fd, const char *, buf, off_t,count) //write.c
  2. _syscall3(type, name, atype, a, btype, b, ctype, c) //unistd.h

进行参数替换,结果变为:

  1. 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) 我们来解读下函数体。我们先去掉反斜线,让其语法高亮,方便解读:
  1. type name(atype a, btype b, ctype c) { // 函数定义
  2. long __res; // 定义一个变量
  3. __asm__ volatile( // 内联汇编 (注解1)
  4. "int $0x80"
  5. : "=a"(__res)
  6. : "0"(__NR_##name), "b"((long)(a)), "c"((long)(b)), "d"((long)(c)));
  7. if (__res >= 0)
  8. return (type)__res;
  9. errno = -__res;
  10. return -1;
  11. }

注解1
  1. asm (
  2. "汇编语句模板"
  3. :输出寄存器
  4. :输入寄存器
  5. :会被修改的寄存器
  6. )

“asm” 是内联汇编语句关键词,表明接下来是汇编语句了;“volatile” 表示编译器不要优化代码,后面的指令 保留原样;

  • "int $0x80" : 中断语句
  • "=a"(res) : 输出寄存器,这里是表示代码运行结束后将 eax 所代表的寄存器的值放入 res 变量中;也就是返回值
  • "0"(NR_##name), "b"((long)(a)), "c"((long)(b)), "d"((long)(c))) : 输入寄存器,NR_##name 其实是将函数参数里的name替换了这里的name,因此最后结果是__NR_write; 因此,操作系统就会知道是4号的系统调用号,知道要去执行write这个系统调用。

"b"((long)(a)) 这里是把函数的参数a 置给EBX,

"c"((long)(b))第二个参数置给ECX,

"d"((long)(c)) 第三个参数置给EDX

现在为什么来说下,为什么这个函数名叫_syscall3:因为有3个参数,只要是3个参数的都会用这个宏,在unistd.h里还有其他的函数:
  1. #define _syscall0(type,name)
  2. #define _syscall1(type,name,atype,a)
  3. #define _syscall2(type,name,atype,a,btype,b)
但无论需要多少个参数,核心代码都是int 0x80,通过宏里面的 内嵌汇编展开一段具体的实现。通过传递系统调用号给eax寄存器,(一般ax都存放功能号,这是我们汇编里学过的中断知识),然后执行中断的时候就会根据功能号执行具体的中断例程

IDT 表的初始化

既然我们要执行中断,那么int 0x80的中断处理函数在哪呢?汇编里我们学过是在中断向量表; 在操作系统里,中断的执行过程也类似,只不过我们不是用中断向量表了,而是IDT表,Interrupt Descriptor Table 中断描述符表(在操作系统的引导里讲过)。根据n去查表,取出中断例程地址后执行。

操作系统接口 - 图4

同理,用户调用printf的时候,在执行int 0x80 中断时也会去IDT查表,然后执行中断,执行完后,将处理结果返回给 __res变量,再回来执行剩下的C语言代码

操作系统接口 - 图5

而 IDT 表,在操作系统启动的时候已经初始化好了,我们来看是如何初始化的。
  1. 在main.c 的第132行处的这个方法就是初始化:
  1. sched_init();
  1. 该方法在 linux-0.11\kernel\sched.c的385行,该方法的最后一行如下,也就是设置中断号为80时,调用system_call函数,也就说后续80号中断都由这个函数来执行IDT每一个表项的内容就是中断例程的地址,每个表项也可以称为中断处理门,这就是为什么函数名有个gate
  1. void sched_init(void)
  2. {
  3. //…… 这里省略其他代码
  4. set_system_gate(0x80,&system_call);
  5. }
  1. 在 include\asm\system.h的第39行,有这样一个宏定义:
  1. #define set_system_gate(n,addr) \
  2. _set_gate(&idt[n],15,3,addr) //这里IDT是中断向量表基址,addr就是system_call函数的地址
set_system_gate 又调用了这样一个宏:_set_gate 在 include\asm\system.h的 第22行,是这样定义的:
  1. _set_gate(gate_addr,type,dpl,addr) {
  2. __asm__ ("movw %%dx,%%ax\n\t" //表示用 dx 加载 ax;
  3. "movw %0,%%dx\n\t" //表示用(0x8000+(dpl<<13)+(type<<8))加载 dx,
  4. "movl %%eax,%1\n\t"
  5. "movl %%edx,%2"
  6. :
  7. : "i" ((short) (0x8000+(dpl<<13)+(type<<8))),
  8. "o" (*((char *) (gate_addr))),
  9. "o" (*(4+(char *) (gate_addr))),
  10. "d" ((char *) (addr)),"a" (0x00080000))
  11. }
  • gate_addr是IDT的地址 addr就是sched.c里传的 &system_call地址
  • 15传给type
  • 关键是这个3 传给了DPL
  • 后续的C内嵌汇编就是将相关信息(特别是system_call的地址和DPL的值)填充IDT表项

操作系统接口 - 图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,因此可以执行内核里的代码。

中断处理函数system_call做了什么

system_call函数的地址:linux/kernel/system_call.s ,我们挑一些关键的代码:
  1. _system_call:
  2. ; ……其他代码
  3. pushl %ebx
  4. movl $0x10,%edx
  5. mov %dx,%ds
  6. mov %dx,%es ; dses都等于0x10, 二进制的最后2位是0,设置数据段为内核的数据段
  7. ; 关键就是下面的代码:
  8. call _sys_call_table(,%eax,4)
  9. ret_from_sys_call:
  10. popl %eax
_sys_call_table 是一个全局函数表,在include/linux/sys.h中这样定义了一个数组:
  1. fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
  2. sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
  3. sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
  4. sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
  5. sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
  6. sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
  7. sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
  8. sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
  9. sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
  10. sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
  11. sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
  12. sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
  13. sys_setreuid,sys_setregid };
fn_ptr 是什么?在include/linux/sched.h中定义了:
  1. typedef int (*fn_ptr)();
是函数指针,也就是函数的地址 ‍ 第4个元素就是放了write系统调用的地址。而我们之前传的参数就是4,因此会调用write函数。 我们说过,unistd.h里定义了系统调用号和名字的关系,因此会根据__NR_write 取出调用号之后,就可以去_sys_call_table 取出 系统调用函数的地址,就可以执行了
  1. #define __NR_setup 0
  2. #define __NR_exit 1
  3. #define __NR_fork 2
  4. #define __NR_read 3
  5. #define __NR_write 4
_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 ‍

操作系统接口 - 图7

  1. 用户调用printf的时候,CPL = 3,会展开成一段包含int 0x80的代码
  2. 在系统初始化的时候,设置了IDT表,将int 0x80的中断处理函数设置成system_call,并且设置DPL也等于3,所以才可以执行 “跳转到system_call”这条指令。进入system_call函数后,CPL是变成0的 ,接下来就在内核里处理
  3. system_call 里 会根据系统调用号,查表sys_call_table
  4. 这里printf的系统调用号是4
  5. 因此最后会调用sys_write(这里就可以操作和访问内核的数据段)
我们之前提到的whoami调用,到第5步的时候就可以访问内核段的数据了