从 01 开始 从 01 开始
首页
  • 📚 计算机基础

    • 计算机简史
    • 数字电路
    • 计算机组成原理
    • 操作系统
    • Linux
    • 计算机网络
    • 数据库
    • 编程工具
    • 装机
  • 🎨 前端

    • Node
  • JavaSE
  • Java 高级
  • JavaEE

    • 构建、依赖管理
    • Ant
    • Maven
    • 日志框架
    • Junit
    • JDBC
    • XML-JSON
  • JavaWeb

    • 服务器软件
    • 环境管理和配置管理-科普篇
    • Servlet
  • Spring

    • Spring基础
  • 主流框架

    • Redis
    • Mybatis
    • Lucene
    • Elasticsearch
    • RabbitMQ
    • MyCat
    • Lombok
  • SpringMVC

    • SpringMVC 基础
  • SpringBoot

    • SpringBoot 基础
  • Windows 使用技巧
  • 手机相关技巧
  • 最全面的输入法教程
  • 最全面的浏览器教程
  • Office
  • 图片类工具
  • 效率类工具
  • 最全面的 RSS 教程
  • 码字工具
  • 各大平台
  • 校招
  • 五险一金
  • 职场规划
  • 关于离职
  • 杂谈
  • 自媒体
  • 📖 读书

    • 读书工具
    • 走进科学
  • 🌍 英语

    • 从零开始学英语
    • 英语兔的相关视频
    • Larry 想做技术大佬的相关视频
  • 🏛️ 政治

    • 反腐
    • GFW
    • 404 内容
    • 审查与自我审查
    • 互联网
    • 战争
    • 读书笔记
  • 💰 经济

    • 关于税
    • 理财
  • 💪 健身

    • 睡眠
    • 皮肤
    • 口腔健康
    • 学会呼吸
    • 健身日志
  • 🏠 其他

    • 驾驶技能
    • 租房与买房
    • 厨艺
  • 电影

    • 电影推荐
  • 电视剧
  • 漫画

    • 漫画软件
    • 漫画推荐
  • 游戏

    • Steam
    • 三国杀
    • 求生之路
  • 小说
  • 关于本站
  • 关于博主
  • 打赏
  • 网站动态
  • 友人帐
  • 从零开始搭建博客
  • 搭建邮件服务器
  • 本站分享
  • 🌈 生活

    • 2022
    • 2023
    • 2024
    • 2025
  • 📇 文章索引

    • 文章分类
    • 文章归档

晓林

程序猿,自由职业者,博主,英语爱好者,健身达人
首页
  • 📚 计算机基础

    • 计算机简史
    • 数字电路
    • 计算机组成原理
    • 操作系统
    • Linux
    • 计算机网络
    • 数据库
    • 编程工具
    • 装机
  • 🎨 前端

    • Node
  • JavaSE
  • Java 高级
  • JavaEE

    • 构建、依赖管理
    • Ant
    • Maven
    • 日志框架
    • Junit
    • JDBC
    • XML-JSON
  • JavaWeb

    • 服务器软件
    • 环境管理和配置管理-科普篇
    • Servlet
  • Spring

    • Spring基础
  • 主流框架

    • Redis
    • Mybatis
    • Lucene
    • Elasticsearch
    • RabbitMQ
    • MyCat
    • Lombok
  • SpringMVC

    • SpringMVC 基础
  • SpringBoot

    • SpringBoot 基础
  • Windows 使用技巧
  • 手机相关技巧
  • 最全面的输入法教程
  • 最全面的浏览器教程
  • Office
  • 图片类工具
  • 效率类工具
  • 最全面的 RSS 教程
  • 码字工具
  • 各大平台
  • 校招
  • 五险一金
  • 职场规划
  • 关于离职
  • 杂谈
  • 自媒体
  • 📖 读书

    • 读书工具
    • 走进科学
  • 🌍 英语

    • 从零开始学英语
    • 英语兔的相关视频
    • Larry 想做技术大佬的相关视频
  • 🏛️ 政治

    • 反腐
    • GFW
    • 404 内容
    • 审查与自我审查
    • 互联网
    • 战争
    • 读书笔记
  • 💰 经济

    • 关于税
    • 理财
  • 💪 健身

    • 睡眠
    • 皮肤
    • 口腔健康
    • 学会呼吸
    • 健身日志
  • 🏠 其他

    • 驾驶技能
    • 租房与买房
    • 厨艺
  • 电影

    • 电影推荐
  • 电视剧
  • 漫画

    • 漫画软件
    • 漫画推荐
  • 游戏

    • Steam
    • 三国杀
    • 求生之路
  • 小说
  • 关于本站
  • 关于博主
  • 打赏
  • 网站动态
  • 友人帐
  • 从零开始搭建博客
  • 搭建邮件服务器
  • 本站分享
  • 🌈 生活

    • 2022
    • 2023
    • 2024
    • 2025
  • 📇 文章索引

    • 文章分类
    • 文章归档
  • 计算机简史

  • 数字电路

  • 计算机组成原理

  • 操作系统

    • 我的操作系统学习笔记

      • 学习操作系统之前
      • 从操作系统启动开始
      • 操作系统接口
        • 接口是什么
        • 为什么要有操作系统接口
        • 深入下命令行
        • 简单介绍下图形化
        • 什么是操作系统接口
        • 标准接口
        • 有必要用系统调用吗?
        • 如何防止直接访问内核
        • 如何访问内核态的数据:中断
        • 系统调用的实现大致过程
        • 宏展开--准备中断的传参
        • 系统调用的细节,就从这个宏说起。这个宏就是典型的 C 语言内嵌汇编。我们来分析这段宏 ‍
        • IDT 表的初始化
        • 中断处理函数 system_call 做了什么
        • 总结
      • 操作系统历史与学习任务
      • 如何高效管理 CPU:并发和进程
      • 如何支持多进程
      • 用户级线程
    • 我的操作系统实验笔记

    • 操作系统网课-王道考研

  • Linux

  • 计算机网络

  • 数据库

  • 编程工具

  • 装机

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

操作系统接口

# 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])
}
1
2
3
4

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

$ gcc -o output output.c 
$ ./output "hello"
ECHO:hello
1
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();
}
1
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)
1
2

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

​

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

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

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

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

我们这里引出 3 个问题:

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

# 如何防止直接访问内核

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

可以看到操作系统和硬件联系非常紧密,没有这种硬件设计,就没有什么系统调用,也就没操作系统这堂课,后面讲的内存管理之类也就不会有。所以说硬件操作系统和是和一个和硬件非常紧密相关的一门科学,所以我们也前面说过明白操作系统需要深刻的明白硬件 ‍ 用硬件怎么实现呢?它把内存分成了很多区域,这里我们讲两个,第一个是用户态(应用程序所在的目录),对应的内存区域叫用户段;另一个区域是核心态(操作系统所在的内存区域),对应的内存区域叫内核段;

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

特点和示意图如下:

  1. 内核段的代码只在内核态下运行,也只用它可以操作内核段;
  2. 用户态的程序不能操作内核段(访问或者跳转都不行)
  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>
1

"stdio" 是 "standard input & output" 的缩写。

因此 C 语言执行的过程大致是这样的:

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

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

​ ‍

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

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

用户编写程序调用printf函数 
↓
printf调用C语言的库函数 
↓
库函数里实现系统调用 
↓
根据中断进入内核
↓
执行中断程序,处理系统调用 
↓
返回
1
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)
1
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; \
}
1
2
3
4
5
6
7
8
9
10
11
12

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

用户编写 C 语言程序调用 printf → printf 调用 C 的库函数 write → 库函数调用_syscall3 ‍ ​图来自《 Linux 内核剖析》5.5 节​

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

我们来看看_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
1
2
3
4
5

_syscall3 函数的签名如下:

_syscall3(int,write,int,fd,const char *,buf,off_t,count)
1

而我们调用 C 的 printf 是这样调用的:

print("ECHO:%s\n", argv[1])
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; \
}
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
1
2

我们逐个参数进行替换,例如第一个函数的第一个参数 int 替换下面的 type, name 对应下面的 write,以此类推,......

type=int,name=write,atype=int,a=fd,btype=const char * ,b=buf,ctype=off_t,c=count;
1

因此 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
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) 
1
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 表,在操作系统启动的时候已经初始化好了,我们来看是如何初始化的。

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

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

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

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

1
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函数的地址
1
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))
1
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))
}

1
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
1
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
1
2
3
4
5
6
7
8
9
10
11

‍ _sys_call_table 是一个全局函数表,在 include/Computer/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 };

1
2
3
4
5
6
7
8
9
10
11
12
13
14

‍ fn_ptr 是什么?在 include/Computer/Linux/sched.h 中定义了:

typedef int (*fn_ptr)();
1

第 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
1
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

​​

  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,只有做完了这堂课,后面的课程才能理解。

上次更新: 2025/5/9 14:55:39
从操作系统启动开始
操作系统历史与学习任务

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

最近更新
01
语雀文档一键下载至本地教程
07-04
02
要成功,就不要低估环境对你的影响
07-03
03
血泪教训:电子设备要定期开机
07-02
更多文章>
Theme by Vdoing | Copyright © 2022-2025 | 粤 ICP 备 2022067627 号 -1 | 粤公网安备 44011302003646 号 | 点击查看十年之约
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式