程序员的学习笔记 程序员的学习笔记
首页
  • 计算机科学导论
  • 数字电路
  • 计算机组成原理
  • C语言
  • 数据结构
  • 汇编语言
  • 操作系统
  • Linux
  • 编译原理
  • 计算机网络
  • 数据库
  • Java基础
  • JavaWeb
  • 笔记软件
  • Quicker
  • Qttabar
  • Wgesture
  • 浏览器与插件
  • 视频播放器
  • 待办清单
  • 终端软件
  • uTools
  • 番茄盒子
  • 网站日记
  • 赞赏支持
  • 关于本站
  • 如何搭建一个博客
  • 如何搭建一个邮箱
GitHub (opens new window)
GitHub (opens new window)
首页
  • 计算机科学导论
  • 数字电路
  • 计算机组成原理
  • C语言
  • 数据结构
  • 汇编语言
  • 操作系统
  • Linux
  • 编译原理
  • 计算机网络
  • 数据库
  • Java基础
  • JavaWeb
  • 笔记软件
  • Quicker
  • Qttabar
  • Wgesture
  • 浏览器与插件
  • 视频播放器
  • 待办清单
  • 终端软件
  • uTools
  • 番茄盒子
  • 网站日记
  • 赞赏支持
  • 关于本站
  • 如何搭建一个博客
  • 如何搭建一个邮箱
GitHub (opens new window)
GitHub (opens new window)
  • 计算机科学导论

  • 操作系统

    • 我的操作系统学习笔记

      • 学习操作系统之前
      • 从操作系统启动开始
      • 操作系统接口
        • 接口是什么
        • 为什么要有操作系统接口
        • 深入下命令行
        • 简单介绍下图形化
        • 什么是操作系统接口
        • 标准接口
        • 有必要用系统调用吗?
        • 如何防止直接访问内核
        • 如何访问内核态的数据:中断
        • 系统调用的实现大致过程
        • 宏展开--准备中断的传参
        • IDT表的初始化
        • 中断处理函数system_call做了什么
        • 总结
      • 操作系统历史与学习任务
    • 我的操作系统实验笔记

  • Linux

  • 编译原理

  • 计算机网络

  • 数据库

  • 计算机科学导论
  • 计算机基础
  • 操作系统
  • 我的操作系统学习笔记
2022-10-31
目录

操作系统接口

# 2. 操作系统接口

我们前面讲了操作系统启动的全过程,现在我们讲下上层应用怎么进到操作系统里面,从而最终使用硬件。也就是说,我们会讲应用程序和操作系统之间的那一层接口:

应用软件(我们平常使用的程序,浏览器,Word等)
操作系统(Windows,Linux等)
计算机硬件(CPU,内存,显卡等)

‍

# 接口是什么

我们以生活中的例子为例,比如我们平时使用的插座,和汽车的油门:

image​

‍

我们只需将插头插入到插座里,我们就可以使用电了,至于插座背后原理是什么,什么是火线,什么是零线,电压是多少,我们都不用关心,只要会用就可以了;对于司机来说,如果要加速,只要按下油门即可,不用知道中间经过了什么机械装置,内部原理是怎么样的。生活中还有很多这样的例子,例如骑自行车,我们并不需要懂得自行车是拼装的细节;坐飞机也不需要知道飞机是怎么造出来的,背后的原理细节是什么……

其实接口不仅仅是操作系统的概念,也是一个常识。对于用户来说,不需要知道接口背后做了什么事情,都不用关心,只要会用就可以了。也就是说,有了接口以后,我们使用就非常方便。

但对于我们这些要编写操作系统的人来说,接口是必须要关心的,我们要知道接口背后做了什么事情。我们设计的接口要连接上层应用程序和操作系统,并且要简单,屏蔽细节和完成转换。

本课主要讲什么是操作系统接口,以及操作系统接口背后的原理

‍

# 为什么要有操作系统接口

我们先说说为什么会有操作系统接口这个东西。比如为什么会出现插座?因为我们要用电,要开电脑,要充电手机;而为什么我们需要有操作系统接口呢?因为我们要使用操作系统。

举个C语言的例子,我们需要在屏幕上显示一句 “HelloWorld”,我们只需借住printf语句,就可以了。这个命令在操作系统一顿操作,屏幕上就会显示执行的结果。换句话说,我们通过printf语句,告诉操作系统我们要在屏幕上显示HelloWorld,那么操作系统就会执行这句代码,执行结束后,操作系统会操作硬件(显示器),显示运行的结果

image​

‍

除了代码,没有别的方式使用操作系统了吗?也不一定。总共有3种方式

  • 命令行(代码)。比如我们写了一个C语言程序,编译成一个可执行文件,然后我们用命令行运行这个程序(通过命令行),就会出来执行结果。
  • 图形化界面:比如我们打开浏览器,打开文件夹,都是用鼠标的
  • 应用程序。比如我们用Word写一些资料,保存的时候,就可以保存到磁盘上

‍

# 深入下命令行

其实命令,就是一段C语言的程序。比如我们写一个简单的命令行工具(命名为output.c):

#include<stdio.h>
int main(int argc, char* argv[]){
	print("ECHO:%s\n", argv[1])
}

编译生成可执行文件后,我们运行它:

$ gcc -o output output.c 
$ ./output "hello"
ECHO:hello

这段程序非常简单,就是读取命令行里的参数,然后输出到屏幕上。

复杂一点的命令,例如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行,用fork函数向操作系统申请使用CPU,然后第12行执行了一个shell,用来执行用户输入的命令。

也就是,操作系统启动完后,最后执行了一个shell(其实也是一段程序),这里是一个死循环,也就是说一直等待用户的输入,用户输入后就用shell执行这段命令(比如我们之前输入的output)

这里的fork 和 exec 是非常重要的,关键性的函数

‍

‍

‍

# 简单介绍下图形化

图形按钮它基于的是一套消息机制。

image​

‍‍实现一个消息队列,‍‍当鼠标点下去的时候,就要通过中断放到系统内部的一个消息队列,‍‍而应用层需要写一个系统调用,要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)

Windows上不用说了,我们开机时都要选择一个用户去输入账密登录。也可以用资源管理器看当前登录的用户:

image​

而操作系统有一个叫做whoami的系统调用。为什么说它是系统调用?因为这个用户的信息在内存里,我们要进入到操作系统里面,所以这个是系统调用。

在讲如何实现这个系统调用之前,我们先来思考一个问题:为什么不能直接去内存里取值并打印?这是一个很很直接的想法。这个计算机是我购买的,内存条也是我买的;内核程序在内存里,应用程序也在内存里,只不过在内存的位置不同而已,为什么不能直接访问?

首先说答案:肯定是不能的(如果可以的话,也不会有操作系统这门课了)。因为直接访问的话,我直接调用一个普通函数就可以了,这样就成了函数调用,而不是系统调用。操作系统里面有很多重要的东西,不能随意的访问(也不能随意的修改,包括数据和jmp)

比如,当有一个木马程序,直接去内存里将操作系统的用户密码都窃取了,那么计算机就完全没有安全性可研。

我们这里引出3个问题:

  1. 为什么不可以直接访问:我们已经讲过了,不安全
  2. 怎么才能防止直接访问与修改
  3. 既然不能直接访问和修改内存,要怎么进入内核中

‍

‍

# 如何防止直接访问内核

先说结论:得用硬件实现,只有硬件才有这种能力。

可以看到操作系统和硬件联系非常紧密,没有这种硬件设计,就没有什么系统调用,也就没操作系统这堂课,后面讲的内存管理之类也就不会有。所以说硬件操作系统和是和一个和硬件非常紧密相关的一门科学,所以我们也前面说过明白操作系统需要深刻的明白硬件

‍

用硬件怎么实现呢?它把内存分成了很多区域,这里我们讲两个,第一个是用户态(应用程序所在的目录),对应的内存区域叫用户段;另一个区域是核心态(操作系统所在的内存区域),对应的内存区域叫内核段;

在汇编中我们学过,计算机对内存的使用都是一段一段的,比如数据段寄存器,栈段,代码段等。

特点和示意图如下:

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

image​

‍

‍

‍

具体怎么实现的呢?内核段,用户段,其实都得靠段寄存器来保存段基址;我们可以用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)。当直接访问内核态的数据,是检查不通过的,没法直接访问

image​

‍

‍

# 如何访问内核态的数据:中断

既然应用程序不能直接操作硬件,那么操作系统总得提供一个手段,让应用程序能简介操作硬件。

**计算机提供了唯一的方法,通过中断才能进入内核。 **汇编的跳转,mov等指令都不能进入内核。当然也不是所有的中断都可以进入内核,只有部分才可以。

前面说到的whoami展开来就是一段包含中断的代码。在C语言的库函数里,实际上写了一段包含中断的代码。因此C语言执行的过程大致是这样的:

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

所以大家可以看到,表明上只是一个printf函数,其实背后有很多事情发生。这也印证了接口的概念,表面看起来就是个插座,但背后连了很多电路。

image​

‍

我们之前学习汇编的时候,一个中断是怎么执行的?就是先保存当前运行的程序所用到的寄存器,然后根据中断向量表 查找中断例程的地址,跳转到该地址去执行中断例程,执行结束后,继续执行之前执行到一半的代码。

我们接下来就会展开来说具体是怎么执行的,大家一定要牢记基本的调用过程,这样就不会迷失在细节里:

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

‍我们接下来会将如何调用接口以及中断向量表IDT,以及中断处理函数做了什么

# 系统调用的实现大致过程

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

具体怎么变成中断的? 我们可以看看相关的代码: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)

fd:要进行写操作的文件描述词。
buf:需要输出的缓冲区
count:最大输出字节计数

‍

_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; \
}

我们暂停下,整理下系统调用的过程:

用户编写C语言程序调用printf → printf调用C的库函数 → 库函数里调用_syscall3

‍

image​

‍

‍

‍

# 宏展开--准备中断的传参

我们来看看_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

‍

‍

_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; \
}

系统调用的细节,就从这个宏说起。这个宏就是典型的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

我们逐个参数进行替换,例如第一个函数的第一个参数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;
}

‍

‍

  • 第1行是函数定义,之前讲过了

  • 第2行定义了一个long类型的变量

  • 第3行是内联汇编。内联汇编的格式为:

    asm (
      "汇编语句模板"
      :输出寄存器
      :输入寄存器
      :会被修改的寄存器
    )
    

    “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) 

‍

但无论需要多少个参数,核心代码都是int 0x80,通过宏里面的 内嵌汇编展开一段具体的实现。通过传递系统调用号给eax寄存器,(一般ax都存放功能号,这是我们汇编里学过的中断知识),然后执行中断的时候就会根据功能号执行具体的中断例程。

‍

我们暂停下,整理下系统调用的过程:

用户调用printf → printf调用C的库函数 → 库函数里调用_syscall3 →_syscall3根据宏展开准备好参数

‍

# IDT表的初始化

既然我们要执行中断,那么int 0x80的中断处理函数在哪呢?汇编里我们学过是在中断向量表;

在操作系统里,中断的执行过程也类似,只不过我们不是用中断向量表了,而是IDT表,Interrupt Descriptor Table 中断描述符表(在操作系统的引导里讲过)。根据n去查表,取出中断例程地址后执行。

image​

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

‍

IDT的一个表项的组成结构:

image​

‍

‍

而 IDT 表,在操作系统启动的时候已经初始化好了,我们来看是如何初始化的。

  1. 在main.c 的第132行处的这个方法就是初始化:
sched_init();

‍

  1. 该方法在 linux-0.11\kernel\sched.c的385行,该方法的最后一行如下,也就是设置中断号为80时,调用system_call函数,也就说后续80号中断都由这个函数来执行

    IDT每一个表项的内容就是中断例程的地址,每个表项也可以称为中断处理门,这就是为什么函数名有个gate

void sched_init(void)
{
	//…… 这里省略其他代码
	set_system_gate(0x80,&system_call);
}

‍

在 include\asm\system.h的第39行,有这样一个宏定义:

#define set_system_gate(n,addr) \
	_set_gate(&idt[n],15,3,addr)   //这里IDT是中断向量表基址,addr就是system_call函数的地址

也就是说,我们会在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))

我们分析下函数头

  • gate_addr是IDT的地址 addr就是sched.c里传的 &system_call地址
  • 15传给type
  • 关键是这个3 传给了DPL
  • 后续的C内嵌汇编就是将相关信息(特别是system_call的地址和DPL的值)填充IDT表项

image

我们解读下函数体

_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~ 5行是汇编代码,\n\t表示换行,我们先去掉:

movw %%dx,%%ax  //表示用 dx 加载 ax;
movw %0,%%dx   //表示用(0x8000+(dpl<<13)+(type<<8))加载 dx,
movl %%eax,%1
movl %%edx,%2

第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最后两位变成了三,就又变成了用户态的东西,然后继续执行用户的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

‍

_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 };

‍

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

_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   ‍

image​

  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步的时候就可以访问内核段的数据了

‍

这堂课对应实验2,只有做完了这堂课,后面的课程才能理解。

‍

‍

编辑 (opens new window)
上次更新: 2022/10/27 13:56:29
从操作系统启动开始
操作系统历史与学习任务

← 从操作系统启动开始 操作系统历史与学习任务→

最近更新
01
操作系统历史与学习任务
10-31
02
搭建实验环境
10-31
03
操作系统的引导
10-31
更多文章>
Theme by Vdoing | Copyright © -2022 粤ICP备2022067627号-1 粤公网安备 44011302003646号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式