从 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
  • 📇 文章索引

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

  • 数字电路

  • 计算机组成原理

  • 操作系统

    • 我的操作系统学习笔记

      • 学习操作系统之前
      • 从操作系统启动开始
        • 打开电源之后……
        • 准备 Linux 源码
        • setup.s
        • head.s
        • main.c
        • 谈点题外话:操作系统是怎么生成的
        • 文件系统
        • 小知识
        • 参考
      • 操作系统接口
      • 操作系统历史与学习任务
      • 如何高效管理 CPU:并发和进程
      • 如何支持多进程
      • 用户级线程
    • 我的操作系统实验笔记

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

  • Linux

  • 计算机网络

  • 数据库

  • 编程工具

  • 装机

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

从操作系统启动开始

# 1. 从操作系统启动开始

我们从计算机的启动开始,讲解开机的时候,计算机内部到底发生了什么。这是大家每次开机都会看到的画面,也是第一幅画面,从这里开始合情合理;并且安排了实验一,控制计算机的启动。

​​

‍‍

# 打开电源之后……

打开电源之后,计算机就开始工作了,那么计算机是如何工作的呢?

我们简单回顾下计组里的内容:计算机是在一个计算模型下设计出来的,也就是说计算机是计算模型的一种实现,最著名的模型就是图灵提出的图灵机(可以回顾计算机简史)。

图灵机是怎么定义的呢?实际上图灵机借鉴了人在计算的过程:例如我们计算 3+2,我们用眼睛看到 3+2 后,就知道要做加法,然后在脑海里运算,得到结果是 5,然后将答案写出。而图灵机也是这样,当图灵机在纸带上读到 3+2 这条指令后,就知道要做加法,并且用运算器 得出结果,并写回到纸带上。

计算机的工作原理无非 4 个字:取指执行。大家一定一定要牢记这 4 个字。CPU 从内存中取出指令,如果是加法指令,就执行加法; 如果是乘法指令,就执行乘法。这就是通用图灵机。同样的,如果我们将应用程序放到内存里,例如浏览器,那么 CPU 就运行浏览器;如果是 Word,CPU 就运行 Word。 ‍ 一开始内存是空的,CPU 从哪里取指呢?第一条指令是多少?PC 指针的初值是多少?这个由硬件设计者决定的。

这就要了解一下硬件的知识。以 x86 结构为例,刚一上电的时候,内存中有一部分是固化的 ROM,叫做 ROM BIOS。BIOS 全称 Basic Input Output System,基本输入输出系统。也就是说,总得有一个基本的输入输出,如果什么都没有,CPU 是做不到取指执行的。那么这一段指令做了什么事情呢?

  1. 以 x86 为例,一上电的时候,CPU 会自动处于实模式
  2. CS 寄存器 = 0xFFFF, IP = 0x0000
  3. 因此 CPU 就会去执行 0xFFFF:0x0000 处的指令,也就是内存 0xFFFF0 处的 ROM BIOS 的指令,并且会在物理地址 0 处初始化中断向量
  4. BIOS 处的指令首先会检查外设,例如 CPU,键盘,显示器,硬盘等
  5. 然后会将磁盘 0 磁道 0 扇区的内容读到内存的 0x7C00 处
  6. 最后设置 CS = 0x7C00, IP = 0x0000,开始执行引导扇区里的代码。 ‍ 关于第 4 步的补充:如果有给电脑加过内存条的经历应该知道,加内存条后的第一次开机,计算机会在屏幕上提示 The amount of system memory has changed. 也就是计算机检测到内存总量变化了,是否继续开机:

‍

关于第 5 步的补充:在计组里我们学过,一个扇区是 512 个字节的。而 0 磁道 0 扇区就是操作系统的引导扇区,因此就是将引导扇区里的指令读到内存里,并开始执行。这也就是操作系统的第一段代码。 ‍

# 准备 Linux 源码

接下来我们会讲 Linux 的部分源码,同学们可以先准备下,接下来我们仅仅会挑部分重点代码进行解读。

Linux 0.11 版本的代码地址:http://oldlinux.org/Linux.old/Linux-0.11/sources/system/linux-0.11.tar.Z

也可以在我的 Gitee 上下载:https://gitee.com/peterjxl/learn-os

本段代码很小,不到 500k; 代码里也有详细的注释。

我们接下来讲的 bootsect.s,setup.s 和 head.s 的路径:linux-0.11\boot\。该目录下也就只有这几个文件

引导扇区里的代码:是一段汇编代码,文件名是 bootsect.s

为什么第一段代码是汇编代码呢?因此我们要对计算机进行精细的控制,而 C 语言的代码编译之后,它的内存位置是人为不可控的(比如自动分配栈)。而汇编就不一样了,汇编中的每一条指令最后都变成了这都变成了真正的机器指令,所以你可以对它进行完整的控制。而在引导过程中,你当然要对他进行完整的控制,绝对不能有任何差错或者细小的出入的控制。在操作系统中有很多地方都要实现这样精细的控制。

我们会挑 bootsect.s 的一些关键代码来解读。

# 第一件事:将代码挪到 0x9000 处执行

INITSEG = 0x9000 ! we move boot here - out of the way SETUPSEG = 0x9020 ! setup starts here entry start start: mov ax,#BOOTSEG mov ds,ax mov ax,#INITSEG mov es,ax mov cx,#256 sub si,si sub di,di rep movw jmpi go,INITSEG !段间跳转 cs=INITSEG, ip=go go: mov ax,cs mov ds,ax mov es,ax

‍
我们来解读这段代码

1 ~ 4 行可以理解为定义了一个变量,值是我们后续会用到的地址

第 5 行:关键字 entry 告诉链接器“程序入口” 从 start 标号开始

第 6 行:CPU 开始执行第 5 行的指令(上一句表明了程序的入口)
‍
接下来执行完这两句后,ds = 0x07c0 :

```x86asm
mov	ax,#BOOTSEG
mov	ds,ax
1
2
3
4
5
6
7
8
9
10
11
12
13
14

同理,执行完这两句后,es = 0x9000 :

mov	ax,#INITSEG

接下来做什么呢?就是将 bootsect.s 里的代码,全部挪到内存 0x9000 处:使用的是 rep 指令,复制 256 个字,也就是 512 字节,刚好一个扇区,从 0x7c00 开始的一个扇区的代码。为什么要将本段代码移动到 9000,我们后续再讲

```x86asm
	mov	cx,#256
	sub	si,si
	sub	di,di
	rep	movw

接下来执行一个跳转指令:

```x86asm
jmpi	go,INITSEG		!段间跳转 cs=INITSEG, ip=go
1
2
3
4
5
6
7
8
9
10
11
12
13
14

也就是将 0x9000 作为基址,go 作为偏移地址。我们可以推理出来,既然 bootsect.s 已经挪到 0x9000 处, 我们就应该跳转到 0x9000 处,继续往下执行 bootsect.s 处的代码,且我们可以看到 go 标号就在 跳转指令的下一行,所以其实就是继续往下执行 bootsect.s。 ‍

# 第二件事:加载 setup 模块

接下来 bootsect.s 做了什么事情呢?我们继续看一些关键的代码:

go:	mov	ax,cs
	mov	ds,ax
	mov	es,ax
	mov	ss,ax
	mov	sp,#0xFF00		! arbitrary value >>512 
load_setup:
	mov	dx,#0x0000		! drive 0, head 0
	mov	cx,#0x0002		! sector 2, track 0
	mov	bx,#0x0200		! address = 512, in INITSEG
	mov	ax,#0x0200+SETUPLEN	! service 2, nr of sectors
	int	0x13			! read it
	jnc	ok_load_setup		! ok - continue
	mov	dx,#0x0000
	mov	ax,#0x0000		! reset the diskette
	int	0x13
	jmp	load_setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

当执行到 go 处的代码时,CS = 9000,而 go 标号处 到 load_sectup 标号处的代码,将段寄存器 DS,ES,SS 的值都设置为了 9000。因此目前所有段寄存器的值都是 0x9000

下一步,就是用 0x13 号中断 ,将 setup 模块从磁盘加载到内存里。因为我们一开始只将引导扇区读入进来了,而操作系统还有很多内容要读进来。

我们来解读下 load_setup 代码:

在执行 13 号中断之前,我们要先传参给中断例程,其中

  • ah 是功能号 02 表示读磁盘。 00 表示磁盘系统复位
  • al 是读取扇区的数量,因为 ax = 0x0200 + SETUPLEN,因此 al = 4,表示读取 4 个扇区
  • ch 是柱面号, 这里表示读取 0 柱面号,
  • cl 是要读取的扇区。这里是 2,表明读 2 号扇区(这里需要说明一下,其实扇区是从 1 开始编号的,而第一个扇区是引导扇区,因此读下一个扇区就是 2 号扇区;而之前说的 0 磁道 0 扇区指的是 0 号逻辑扇区,其对应的是 1 号物理扇区。如果不理解也没关系,记住是读下一个扇区即可)
  • dh 是磁头号, dl 是驱动器号 这里都是 0,表明还是读 0 磁道 0 号驱动器
  • es:bx 内存地址,用于存放磁盘里的内容

因此 load_setup 就是读取 4 个扇区的内容到 0x90200 处。十六进制是 200,转换成十进制就是 512,因此就是 sectup 模块在内存的地址就是紧挨着 bootsect.s 。内存示意图如下:

内存地址 内存的内容
0xFFFF0 ROM BIOS 区
.......... .............
0x90200 setup 模块
0x90000~0x901FF bootsect.s 模块
.............. ....................

# 第三件事:显示开机画面并读 system 模块

读入 setup 模块后,接下来做什么呢?setup 模块只有 4 个扇区,操作系统当然不只这么少代码,因此读入了 setup 模块后,继续读入操作系统的 system 模块。我们来看看 ok_load_setup 的关键代码:

ok_load_setup:
	mov	ch,#0x00
	mov	sectors,cx
	mov	ax,#INITSEG
	mov	es,ax


	mov	ah,#0x03

	mov	bx,#0x0007
	mov	bp,#msg1
	mov	ax,#0x1301
	int	0x10	; ah功能为为13,作用是显示字符串,es:bp是串地址,cx是串长度

	mov	ax,#SYSSEG
	mov	es,ax		! segment of 0x010000
	call	read_it   ; 这里就是读入system模块
	jmpi	0,SETUPSEG  ; 跳转到0x09020:0000处执行代码,也就是执行setup.s


; ...........这里省略一些代码, 以下这段代码在bootsect.s的244行.......
msg1:
	.byte 13,10
	.ascii "Loading system ..."
	.byte 13,10,13,10
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

‍ 我们来分析下上述代码做了什么:

  1. 首先是用 int 0x10 读取光标的位置,
  2. 然后将字符串 "Loading system ..."显示到光标的位置上
  3. 然后执行 reat_it 函数读入 system 模块,读到内存 0x10000 处,这里我们就不继续展开了
  4. 最后跳转到 setup.s 处执行
  5. 至此,bootsect.s 代码执行结束 ‍ 其实,第二步就是我们看到的开机画面了,只不过当时显示的比较简单和粗糙。因此,我们可以做实验一了,就是修改一下计算机的开机画面,例如改成 “Hello World!” 。这里也说一下大致的思路:首先修改下 msg 处的字符串,然后数一下要显示的字符个数,然后修改 cx,重新汇编 bootsect.s 即可。 ‍ 例如,下面就是我们做实验时看到的画面,可以看到有 Loading system……(其他的打印我们暂且不看)

​实验截图​

# bootsect.s 小结

bootsect.s 做了以下事情:

  1. 将自己挪到 0x90000 处
  2. 读入 setup 模块
  3. 读入 system 模块,并在屏幕上打印 “Loading System”
  4. 将控制权交给 setup 模块

具体代码执行逻辑:

  1. 通过汇编的 rep movw 指令,将自己的代码复制到 0x9000:0000 处 512 字节(256 个字,所以 cx=256)

  2. 执行跳转指令 jmp go 9000 (go 是标号,会赋值给 IP, 9000 会赋值给 CS,作为段基址)。其实就是顺序执行,只不过因为在第一步里将自己挪到了 9000,所以 CS:IP 也指到那里去

  3. 目前 bootset.s 的段基址是 9000,然后长度是 256 个字,引导扇区在内存里的结束地址是 90200H

  4. 然后根据 13 号中断,从第二个扇区开始(第一个扇区是 bootsect.s),读取 setup 的 4 个扇区的内容(al 存放扇区数量),也就是 2kb

  5. 至此,bootsect.s 结束

# setup.s

目前操作系统还在启动中,还需要精细的控制,因此 setup 也是一段汇编。首先我们根据文件名可以联想到:setup 应该是完成 OS 启动前的一些设置。我们还是挑部分重点代码来看。 ‍

mov	ax,#INITSEG ; INITSEG 在setup.s里的第17行 定义为0x9000
mov	ds,ax   ; ds = 0x9000
mov	ah,#0x03
xor	bh,bh
int	0x10	 ; 10号中断的3号功能 读光标位置
mov	[0],dx	; 取出光标位置 放到0x9000处


mov	ah,#0x88
int	0x15
mov	[2],ax   ; 获取扩展内存大小,并放到0x9000处



mov	ah,#0x12
mov	bl,#0x10
int	0x10    ; 获取根设备号  放到0x9000处
mov	[8],ax
mov	[10],bx
mov	[12],cx
‍
我们可以解读下这段代码:首先获取光标,内存,显卡等参数(后面还有很多代码获取硬件参数,这里不表),并放到 0x90000 处(此时 bootsect.s 已经不在用到,可以被覆盖)。完整的参数和保留的位置请见下表:(该表来自《Linux 内核完全剖析  第 6.3.1 节》)

|内存地址|长度(字节)|名称|描述|
| --------| ----------| ----------| --------------------------------------------------|
|0x90000|2|光标位置|列号(0x00-最左端),行号(0x00-最顶端)|
|0x90002|2|扩展内存数|系统从 1MB 开始的扩展内存数值(KB)。|
|0x90004|2|显示页面|当前显示页面|
|0x90006|1|显示模式||
|0x90007|1|字符列数||
|0x90008|2|??||
|0x9000A|1|显示内存|显示内存(0x00-64k,0x01- 128k,0x02- 192k,0x03=256k)|
|0x9000B|1|显示状态|0x00-彩色,I/O=0x3dX﹔ 0x01-单色,I/O=0x3bX|
|0x9000C|2|特性参数|显示卡特性参数|
|...||||
|0x90080|16|硬盘参数表|第 1 个硬盘的参数表|
|0x90090|16|硬盘参数表|第 2 个硬盘的参数表(如果没有,则清零)|
|0x901FC|2|根设备号|根文件系统所在的设备号(bootsec.s 中设置)|


其中,获取扩展内存数是非常重要的。

1. 什么是扩展内存:早期计算机中,地址总线只有 20 位,因此只能寻址 1M 以内的内存;而如今的计算机,都是 8G,16G 内存起步的,那么通常把 1M 以后的这些内存就叫扩展内存
2. 为什么获取内存很重要:操作系统,就是帮我们管理硬件的,而内存就是一个重要的硬件。要管理好内存,首先得知道内存的多大。

我们再谈谈,为什么这段代码的文件名是 setup.s ? 要管理好硬件,就得设置一些数据结构去保存这些信息。就好比学校里管理学生,就会有一个学生信息表来存储学生的信息,方便查询、修改和删除等;不仅仅是内存,还有光标,显卡参数等,获取完这些信息后先存起来,后面会形成一些数据结构来保存这些信息,这就是为什么叫 setup.s 。(实际上操作系统开机就做了 2 件事,第一件事就是读取操作系统到内存里,第二件事就是 setup,初始化,我们后面会慢慢体会到这句话)


### 第二件事:挪动 system 模块

在获取完硬件参数后的代码如下:

```x86asm
	cli	; 不允许中断
	mov	ax,#0x0000
	cld	
do_move:
	mov	es,ax
	add	ax,#0x1000
	cmp	ax,#0x9000
	jz	end_move
	mov	ds,ax
	sub	di,di
	sub	si,si
	mov 	cx,#0x8000


do_move,我们可以猜测是移动的意思,那么移动什么呢?

首先 ES 被置成 0;

在第一次循环中,DS = 1000,然后设置 cx,因此就是将  DS:SI 也就是 1000:0000 处的代码,挪到 ES:DI 也就是 0000:0000 处。而在 bootsect.s 里,1000:0 处的代码就是 system 模块。因此本段代码的作用就是循环,将 system 模块挪到 0 地址处。(每次复制 system 模块的 0x8000 个字节到 0 地址处,然后 ax 自增,判断是否复制完了,没有则继续循环)


之后,system 模块就会一直在 0 地址处。而 system 之后的内存,我们就可以用于运行我们自己的程序了,例如浏览器,Word 等。


### 第三件事:进入保护模式,将控制权交给操作系统

setup 模块该做的事都差不多了,接下来就是将控制权交给操作系统了,但在这之前,还有一件非常重要的事去做:进入保护模式。

我们知道,早期计算机只支持 1M 的内存,这指的是早期的寻址方式,只支持 1M。早期使用的是段基址 + 偏移地址这样的方式寻址的,这种方式不能满足 4G 内存的寻址,因此,我们要切换到一个新的寻址模式。因此 CPU 接下来会从 16 位寻址模式(也叫实模式)切换到 32 位寻址模式(也叫保护模式)。

那么 CPU 是怎么切换寻址模式呢?根据一个寄存器:CR0 。 如果这个寄存器的最后一位是 0,CPU 就会用 16 位模式;如果是 1,就用保护模式(其实就是换一条电路去寻址)。

```x86asm
mov	ax,#0x0001
lmsw	ax
jmpi	0,8	
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

在 setup.s 第 192 行,有个 lmsw ax,就是将 ax 的值赋给 CR0 寄存器,然后接下来的指令,就是用 32 位寻址模式了。 ‍ 那么 32 位寻址模式怎么寻址呢?这就要提到一个非常著名概念叫 GDT(全局描述表 Global Descriptor Table),GDT 表里面存放的才是基址。 当然这也是硬件帮我们实现的寻址方式(因为硬件快)。如何用 GDT 寻址?

在 16 位模式下,代码寻址是用 CS:IP 实现的,而在 32 位模式下,CS 不再左移 4 位产生一个地址,而是用作选择子,换句话说就是 CS 的内容是 GDT 表的下标,对应的 GDT 表项的内容,才是段基址。

因此,32 位寻址模式是这样工作的:首先根据 CS 取出 GDT 表的内容作为基址,IP 还是作为偏移地址,因此来产生一个新的地址,示意图:

​​ ‍

同样的,保护模式下,中断例程的寻址方式也发生了变化:仿照 GDT 表,新建了一个 IDT 表(中断描述符表 Interrupt Descriptor Table),int n 就用 n 进行查表取出中断例程的地址,然后执行:

​​

gdt: .word 0,0,0,0 ! dummy

.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
.word	0x0000		! base address=0
.word	0x9A00		! code read/exec
.word	0x00C0		! granularity=4096, 386

.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
.word	0x0000		! base address=0
.word	0x9200		! data read/write
.word	0x00C0		! granularity=4096, 386

idt_48: .word 0 ! idt limit=0 .word 0,0 ! idt base=0L gdt_48: .word 0x800 ! gdt limit=2048, 256 GDT entries .word 512+gdt,0x9 ! gdt base = 0X9xxxx


我们可以看到有很多 word 指令,一个 word 就是 16 位,而 GDT 表一个表项占 8 字节(64 位),因此每 4 个 word 就是 一个 GDT 的表项(其中,第一个表项为空不使用)

每个表项的组成如下:

​![](https://image.peterjxl.com/blog/image-20221023103400-83jrdge.png)​
‍

而 GTD 的下标如何确定呢?依次为 0,8,16…………  我们以 setup.s 的表为例:

|GDT 表下标|GDT 表内容|
| ----------| -----------------------------------------|
|16|.word       0x07FF  0x0000  0x9200 0x00C0|
|8|.word	0x07FF  0x0000  0x9A00  0x00C0|
|0|.word	0,0,0,0|
‍
GDT 的相关部分我们介绍完了,我们回到本小结的开始 ,`jmpi 0,8` 这条指令是如何确定跳转到哪呢?

```x86asm
mov	ax,#0x0001
lmsw	ax
jmpi	0,8	
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

由于 CS 是 8,因此我们去查 8 这个下标的内容:

.word	0x07FF  0x0000  0x9A00  0x00C0
1

‍ 而这几个 word 是如何存放到 GDT 表的呢?


.word	0x07FF  0x0000  0x9A00  0x00C0
在内存中,从高地址到 低地址极速 0x00C0 9A00  0000  07FF

用二进制展开来就是
0x00C0: 0000 0000 1100 0000
0x9A00: 1001 1010 0000 0000
0x0000: 0000 0000 0000 0000
0x07FF: 0000 0111 1111 1111
1
2
3
4
5
6
7
8
9

因此,我们可以这样将 07FF 放到 0~15 的地方,然后 0x0000 放到 16 ~ 31 的地方,然后 0x9A00 的后两个字节,00,放到 16~23 这个地方

​​

放完后,我们可以看到,段基址就是 0。因此,jmpi 0,8 其实就是跳到 0 地址处去执行。至此,set up 的工作到此就完成。

# setup.s 小结

setup 做的事情小结:

  1. 因为操作系统就是管理硬件的,因此首先得知道硬件的情况:读了一些硬件参数并存到内存里
  2. 把 system 挪到 0 地址处,将来操作系统运行的时候,system 模块会一直存在那里
  3. 然后启动了保护模式(通过修改 CR0 寄存器),最后运用应用了 32 位的汇编指令 JMPI 0, 8 跳到了 0 地址处去执行
  4. 0 地址就是 system 模块,因此后面就是操作系统运行起来了

补充两个知识点:

  • 为什么 bootsect 的代码在一开始不直接把 system 模块挪到 0 地址处呢?因为在 setup 模块中,还需要用到 ROM BIOS 的中断例程,而中断向量表是在 0 地址处的,因此要使用完 BIOS 的中断例程后,才能覆盖 0 地址处。
  • setup.s 之所以临时设置 IDT 和 GDT 表,也是为了后续 system 模块的汇编代码能在 32 位模式下运行。在 system 模块会根据需要重新设置这些描述符表。

# head.s

system 模块的第一个部分是 head.s ,head.s 做了什么呢? ‍

# 初始化 GDT, IDT

我们可以看看其关键的代码:还是初始化了 GDT 和 IDT 表(之前的 setup.s 里建立的 GDT 只是临时用于跳转而已),现在操作系统是真正的开始工作了,所以还要再次建立这个表

startup_32:
	movl $0x10,%eax
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	mov %ax,%gs 	; 指向gdt的0x10项(数据段)
	lss _stack_start,%esp  ; 设置系统栈


还有其他的一些内容,这里不表。
‍
### 三种汇编格式

我们可以看到,这里的汇编和之前的汇编代码的格式有点不同,因为现在是 32 位保护模式,用了 32 位的汇编。我们对汇编的格式做个简单的介绍

(1) as86 汇编:能产生 16 位代码的 Intel 8086(386)汇编

```x86asm
mov ds, ax,   ;  ax → ds, 目标操作数在前
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
movl var, %eax  ; (var) → eax
movb -4(%ebp), %al ; 取出一字节
1
2

(3) 内嵌汇编,gcc 编译 x.c 会产生中间结果 as 汇编文件 x.s

__asm__(“汇编语句”
: 输出
: 输入
: 破坏部分描述);

//例如
__asm__(“movb 
%%fs:%2, %%al”  //%2表示addr,
:”=a”(_res)  //a表示使用eax,并编号%0
:”0”(seg),”m”(*(addr))  //0或空表示使用与相应输出一样的寄存器  m表示使用内存
);
1
2
3
4
5
6
7
8
9
10
11

‍ 其实操作系统确实是一个复杂的工程,光汇编就用了 3 种:16 位汇编(bootsect.s 和 setup.s),32 位汇编(head.s),还有内嵌汇编(在后面讲到的 C 语言代码里,因为有些指令还是要精细的控制)。这里不展开讲这 3 种汇编的语法,这些不是本堂课的主线,如果等学完了三种汇编,黄花菜都凉了,因此待后续讲到的时候再简单的说一说和查找资料即可 ‍

# 跳转到 main

当 head.s 执行完后,接下来就是执行 main.c 代码了。

after_page_tables:
	pushl $0	;These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6	; return address for main, if it decides to.
	pushl $_main
	jmp setup_paging
L6:	jmp L6	 	; main should never return here, but just in case, we know what happens.

setup_paging:
	; …………这里省略一些设置页表代码……
	ret	


我们知道,汇编执行子程序的话,可以通过跳转指令;

C 语言执行函数(子程序)的话,用的是调用函数的语句,例如调用方法 b(假设需要传参),就用 `b(int a, int b)`​  即可。但其实,,然后才被 CPU 执行。

因此,本段汇编代码的一开始几条压栈语句,就是传参给 CPU;然后将 main 函数的地址压到栈中;当 setup_paging 执行 ret 后,就回执行函数 main 了!(执行 ret 指令后,会将栈里的内容取出作为下一个要执行的代码的地址)
‍
### 如果 main 返回了……

最后我们来看一个奇怪的语句:

```x86asm
L6:	jmp L6	 	; main should never return here, but just in case, we know what happens.
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

这段代码不就是死循环吗?为什么要设置成死循环?

其实,操作系统是一个永远不停止的程序。如果一旦 main 函数停止了,就会跳转到这里,然后死循环,也就是我们常见的死机…………

# head.s 小结

head.s 做了什么?

  1. 建立 GDT 和 IDT 表(用的是 32 位的汇编),设置页表等(后面会讲)
  2. 执行 main.函数(通过汇编跳转到 main 函数,本质上就是调整 PC 指针而已)

# main.c

代码路径:init/main.c

我们看一些关键的代码

void main(void)
{	
	// ........省略部分代码	........
	mem_init(main_memory_start,memory_end);
	trap_init();
	blk_dev_init();
	chr_dev_init();
	tty_init();
	time_init();
	sched_init();
	buffer_init(buffer_memory_end);
	hd_init();
	floppy_init();
	sti();
	move_to_user_mode();
	if (!fork()) {
		init();
	}
	// ........省略部分代码	........
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

首先,为什么 main 函数的参数是 void? 其实三个参数分别是 envp,argv,argc,但目前版本的 main 没有使用,且我们在 head.s 里可以看到,在 push main 函数之前,都压栈了 3 个 0,所以这里是没有问题的

其次,我们可以看到,有很多的函数,并且都是带 init 字眼的,这些就是初始化内存,中断,时钟,硬盘,显示器等(Linux0.11 不支持鼠标)

每一个都可以说很久很久,我们这里简单说说 mem_init()​,其他的都类似

​mem_init()​ 顾名思义就是初始化内存的。前面我们提到操作系统就是管理硬件的,内存是一个重要的硬件,因此本函数就是初始化一些数据结构用来保存内存的信息,例如哪些被使用了,哪些是空闲的。

我们看看 mem_init()​ 的部分代码(在 linux-0.11\mm\memory.c 中 399 行)

#define USED 100
#define PAGING_PAGES (PAGING_MEMORY>>12)

	int i;

	HIGH_MEMORY = end_mem;
	for (i=0 ; i<PAGING_PAGES ; i++)
		mem_map[i] = USED;
	i = MAP_NR(start_mem);
	end_mem -= start_mem;
	end_mem >>= 12;
	while (end_mem-->0)
		mem_map[i++]=0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

‍ 我们先看看参数,start_mem, end_mem 是 main 函数里传参的,而 main 函数是这样调用的:

static long main_memory_start = 0;
static long memory_end = 0;
//..........省略部分代码
memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;
//..........省略部分代码
mem_init(main_memory_start,memory_end);
1
2
3
4
5
6
7

其实内存的大小,在 setup.s 里就已经存储到了 0x90000 处,通过 main 函数里读取,然后传个 mem_init()​ 函数。 ‍ 接下来 mem_init()​ 里做什么呢?首先有一个全局变量 mem_map,里面的值如果是 0,表明内存没有被使用,如果是 100,表明是已经被使用了。

所以在 mem_init()​ 首先将 0 地址处(也就是自己 system 模块)的代码标记为已使用;然后将剩余的内存表明为未使用

​​

# main.c 小结

  1. 很多很多的初始化,每个都可以讲很久
  2. 这里讲下 memory 的初始化,建立一个数组,每个数组项就是一页内存,然后置为 0,表示没使用过

# 谈点题外话:操作系统是怎么生成的

我们可以画下操作系统在磁盘里的逻辑示意图:

​图来自《 Linux 内核完全剖析》​

第一个部分存放 bootsect.s,第二个部分存放 setup 模块,第三个部分存放 system 模块(大概占 240 个扇区)。这个顺序是不能变的,如果有一点点差错,都会死机。那么,操作系统是如何从源代码,编译成我们想要的样子呢?这就得提到 make file。

我们平时写 C 语言的时候,都是由 IDE 帮我们编译并运行的,不用关心程序运行在内存的哪里;而如果做操作系统,一切的事情都要自己控制。除了要写源码之外,还要确定如何编译操作系统生成镜像(这里镜像可以简单理解为操作系统安装包,英文名 Image),也就是 make file

初步讲下如何确保磁盘里的第一段代码是 bootsect,第二段是 setup 呢?通过 makefile,并且有很多依赖文件,例如 head.s,main.s,驱动等等。把这些汇编成.o 文件,然后链接起来生成 system。 ‍ 我们可以看到 Linux 根目录下有个 Makefile 文件,我们可以看看里面的关键内容:

Image: boot/bootsect boot/setup tools/system tools/build
 tools/build boot/bootsect boot/setup tools/system $(ROOT_DEV) > Image

1
2
3

我们略略说一下,最后操作系统镜像 Image,是依赖于 bootsect 的,还依赖于 setup,system,还有很多工具类(tools),最后将这些代码链接起来,生成镜像,然后就可以运行这个镜像了。 ‍

# 文件系统

仅仅在内存中加载了这些代码模块,并不能让 Linux 运行起来。完整可运行的 Linux 还需要一个基本的文件系统支持,即根文件系统。Linux0.11 内核仅支持 MINIX 的 1.0 文件系统。在 bootsect.s 的第 43 行给出了根文件系统所在的默认块设备号,更多知识我们后续再讲。 ‍

# 操作系统启动小结

我们总结下这堂课:

  1. bootsect 将操作系统从磁盘读进来,
  2. 而 setup 获得了一些参数,启动了保护模式,
  3. head 初始化了 GDT 和 IDT 表,初始化了一些页表,然后跳到 mian
  4. mian 里又是很多 int 初始化函数,比如初始化内存,中断,时钟,硬盘,显示器 ‍ 以下截图来自《Linux 内核完全剖析基于 0.11 内核》第六章

​​

图 6-2 清晰地显示出 Liux 系统启动时这几个程序或模块在内存中的动态位置。其中,每一竖条框代表某一时刻内存中各程序的映像位置图。先看图例

​​

我们本堂课主要讲了引导扇区的代码做了什么事,其实我们可以换一个角度想想,bootsect.s 应该做什么?

在刚一上电的时候,操作系统一定还在硬盘上,而计算机的工作原理是取指执行,因此就要运行操作系统,就必须将操作系统读到内存里。所以 bootsect.s 应该就是将操作系统从磁盘读入到内存里。

当操作系统读入到内存后,操作系统就要管理硬件,因此需要初始化,读取硬件的参数,形成一些关键重要的数据结构来管理内存(例如 mem_map 管理内存)

所以,计算机在启动的时候就做了两件事,第一件事情把操作系统读到内存里,而第二步就是初始化操作系统运行需要的一些参数。

# 小知识

为什么 bootsect.s 会被加载到 0x7C00?

这个地址来源于 IBM 的 PC 5150 BIOS 开发团队,该团队最早开发了 DOS 1.0 的相关设计。0x7C00 其实是数值上等于 31KB,而最初的 DOS 1.0 最少需要 32KB 的 RAM。设计团队想给 OS 在这 32KB 内留下足够的空间用于 OS 加载,而加载程序需要 512 个字节。这样,团队选择了 32KB 中的最后 1KB 用来加载 OS。一旦 OS 加载之后,这部分 RAM 其实是可以继续让 OS 使用的。

那为什么后续到了其他的应用中依然还用这个地址呢?很简单了,肯定是为了各种兼容而存在的了。 ‍

# 参考

操作系统(哈工大李治军老师)32 讲(全) (opens new window) 第 2 课,第 3 课

《Linux 内核完全剖析基于 0.11 内核》第 6.1 节 (本书好像已经不再发版了,当当上都是二手书。)

Linux v0.11 源码:http://oldlinux.org/Linux.old/Linux-0.11/sources/system/linux-0.11.tar.Z

bootsect.s,setup.s 和 head.s 的路径:linux-0.11\boot\。该目录下也就只有这几个文件 ‍

上次更新: 2025/5/17 12:26:09
学习操作系统之前
操作系统接口

← 学习操作系统之前 操作系统接口→

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