Linux0.12内核源码解读(3)-Setup.S

作者:小牛呼噜噜 | https://xiaoniuhululu.github.io

计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜

大家好,我是呼噜噜,接着上一篇文章Linux0.12内核源码解读(2)-Bootsect.S,本文继续向着操作系统内核的世界前进,一起来看看setup.S

当bootsect程序执行到jmpi 0,SETUPSEG,bootsect程序到这里就结束了,跳转到实际物理内存地址0x90200,此时setup获得了计算机的控制权

setup.Sbootsect.S一样都是操作系统进入system的准备工作,其主要功能就是获取各种硬件参数,并将这些数据保存到内存0x90000处,也就是覆盖bootsect程序所在的空间,然后进入保护模式。

笔者画了张setup.S的流程图,建议大家跟着这图,阅读以下全文

获取各种硬件参数

我们先来看第一段代码

1
2
 mov	ax,#INITSEG	! this is done in bootsect already, but...  ;INITSEG=0x9000
mov ds,ax

由于bootsect的ds寄存器地址是已经设置好了,刚开始运行setup时,其实此时CPU是指向实际物理内存地址0x90200的。但是我们完全可以重新设置 ds寄存器地址,没错linus当时也是这么想的

将ds寄存器初始化为段地址0x9000,这一点非常重要,后续程序会频繁用到,并以该地址为基准。

接下来我们看下一段代码:

1
2
3
4
5
 ! Get memory size (extended mem, kB) 获取内存大小信息

mov ah,#0x88
int 0x15
mov [2],ax

这段主要是为了获取内存大小的信息,int 0x15为中断号,ah = 0x88叫功能号,我们可以把中断号看做c语言的”函数”,区别在于中断号只能通过寄存器去传参,所以ah=#0x88就是其参数,意味着15号中断的88号子程序
另外[2]在程序中表示偏移地址2,我们需要找到它的ds寄存器的值,那这里它的实际物理地址= 0x9000 <<4 + 0x02= 0x90002

总结一下就是:利用 BIOS 中断号0x15,功能号 ah = 0x88 取系统所含扩展内存大小信息,并将数据保存到实际内存 0x90002

同理 setup后面的代码 依次获取各个硬件参数,并保存这些信息,就不展开讲了,原理都是类似的

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
70

! 检查显示方式EGA/VGA并获取参数,保存
! check for EGA/VGA and some config parameters

mov ah,#0x12
mov bl,#0x10
int 0x10
mov [8],ax
mov [10],bx
mov [12],cx
mov ax,#0x5019
cmp bl,#0x10
je novga
call chsvga 检测显示卡厂家和类型
novga: mov [14],ax !保存屏幕当前行列值
mov ah,#0x03 ! read cursor pos 读光标位置
xor bh,bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.

! 获取显示卡当前显示模式参数,并保存
! Get video-card data:

mov ah,#0x0f
int 0x10
mov [4],bx ! bh = display page
mov [6],ax ! al = video mode, ah = window width

! 获取第一个硬盘的信息,并保存
! Get hd0 data

mov ax,#0x0000
mov ds,ax
lds si,[4*0x41] !取中断向量 0x41 的值,即 hd0 参数表的地址
mov ax,#INITSEG
mov es,ax
mov di,#0x0080
mov cx,#0x10
rep
movsb

! Get hd1 data

mov ax,#0x0000
mov ds,ax
lds si,[4*0x46] !取中断向量 0x46 的值,即 hd1 参数表的地址
mov ax,#INITSEG
mov es,ax
mov di,#0x0090
mov cx,#0x10
rep
movsb

! 检查系统是否有第 2 个硬盘。如果没有则把第 2 个表清零
! Check that there IS a hd1 :-)

mov ax,#0x01500
mov dl,#0x81
int 0x13
jc no_disk1
cmp ah,#3
je is_disk1 ! 如果硬盘存在,就跳转到标号is_disk1处
no_disk1: ! 第 2 个硬盘不存在,则对第 2 个硬盘表清零
mov ax,#INITSEG
mov es,ax
mov di,#0x0090
mov cx,#0x10
mov ax,#0x00
rep
stosb

这里比较特殊是:获取第一个磁盘信息的时候,没有借助BIOS中断来实现。那还有什么方法能够实现?

我们先来看下这部分汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
!  获取第一个硬盘的信息,并保存
! Get hd0 data

mov ax,#0x0000
mov ds,ax
lds si,[4*0x41] !取中断向量 0x41 的值,即 hd0 参数表的地址

mov ax,#INITSEG !下面这段是一个循环复制
mov es,ax
mov di,#0x0080
mov cx,#0x10
rep
movsb

此时的ds寄存器重新设置为0x0000,我们知道BIOS中断向量表(Interrupt Vector Table)现在就在内存零地址处,bios中断向量表中的每个中断向量大小是4字节。这4字节描述了一个中断处理程序的段基址和段内偏移地址

因为中断向量表的长度为1024字节,故该表最多容纳256个中断向量处理程序。计算机启动之初,中断向量表中的中断例程是由BIOS建立的,它从物理内存地址0x0000处初始化并在中断向量表中添加各种处理例程。

lds si,[4*0x41] 实际指向的段地址是ds:si=0x0000 + 0x410x41表示41号中断的物理偏移地址。另外[4*0x41]开始的4个字节是41号中断的中断向量,存放的是中断处理函数的地址这个地址也就是第一个硬盘hd0的参数表的地址,我们只需把这块的数据搬运过去即可
所以我们概括一下lds si,[4*0x41]就是将ds:si=0x0000 + 0x41开始的4个字节,前2个字节放在si寄存器中,后2个字节放在ds寄存器中。

我们继续往下看,movsb和bootsect.S中的movw作用类似,是不是很像,其实也就最后的字母不一样。movsb表示是每次移动一个字节(byte),movw是每次移动一个字(word)。

rep movsb增加一个重复前缀rep,表示重复移动搬运。那么这一小段的总体作用就是 将ds:si=0x0000 + 0x41指向的内存单元中的字节,搬运到es:di=0x9000:0x0080处,一共复制10个字节数据。

当setup依次获取各个硬件参数后,最终保留的参数在内存上的分布图如下:

这些参数,留待后续程序使用。

准备进入保护模式

我们知道在8086那个时代CPU、内存都很昂贵,CPU 和寄存器等宽度都是 16 位的,其可寻址最大内存空间是64kb,然而8086芯片有20根地址线,可寻址的最大内存空间是1MB。

CPU和寄存器的寻址能力远远不能满足使用,为了解决这个问题,采用了内存寻址分段技术,通过段基址+段内偏移地址的方式生成20位的地址,扩大寻址能力,从而实现对1MB内存空间的寻址。后来这也叫做”实模式”

但随着CPU的发展,CPU的地址线的个数也从原来的20根变为现在的32根,所以可以访问的内存空间也从1MB变为现在4GB,寄存器的位数也变为32位。所以实模式下的内存寻址计算方式就不适用了,而且由于用户程序可以直接修改物理内存的数据,导致整个操作系统的数据都可以被随意地删改,缺乏安全性。

所以急需一个新的模式,这就是保护模式,提供更大空间的,更灵活也更安全的内存访问机制

保护模式非常复杂,本文先简单介绍一下,主要专注于setup.S进行了哪些准备工作来实现实模式到保护模式的转换,后续专门出一篇关于保护模式的文章。

关闭中断和system模块搬运

进入保护模式的准备工作第一步就是关闭中断,我们来看源代码:

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

! now we want to move to protected mode ...

cli ! no interrupts allowed ! 关闭中断

! 搬运system模块 从0x10000处后,到 内存地址0x0000位置

mov ax,#0x0000
cld ! 'direction'=0, movs moves forward
do_move:
mov es,ax ! destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax ! source segment
sub di,di ! 清零
sub si,si ! 清零
mov cx,#0x8000
rep
movsw
jmp do_move

cli就是关闭中断的指令,那为什么这里要关中断呢?

我们知道操作系统system后续最终是要从物理内存起始位置处 地址0开始存放,好处是让system代码中的地址对应上实际的物理地址。
在bootsect.S引导程序将system模块搬运到实际内存地址0x10000处后,所以这里do_move部分 会再把整个 system 模块移动到 内存地址0x0000位置。

一般情况下,发生BIOS中断时,通过软中断指令int 中断号来调用的,CPU会把向量号当作下标,去中断向量表中,定位对应的中断程序去执行。
那么在system搬运的过程中,原来在内存0x0000处初始化的中断向量表会被覆盖了,这个时候如果再发生中断,由于找不到中断向量,会报错直接死机,后果严重。

所以需要再在搬运前提前把中断给关了。具体搬运的指令与前文类似,这里就不再赘述了。

加载IDT和GDT

由于我们刚刚搬运system模块时,将ds寄存器的值改了,现在得先恢复到#SETUPSEG = 0x9020,这样setup.S才能继续执行下去。

1
2
3
4
5
6
7
8
9
! then we load the segment descriptors 然后我们加载段描述符

end_move:
mov ax,#SETUPSEG ! right, forgot this at first. didn't work :-)
mov ds,ax

lidt idt_48 ! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate

我们接着看2条非常重要的指令:

  1. lidt idt_48,通过idt_48来设置CPU中IDTR寄存器的值,也就是将IDT地址加载到IDTR
  2. lgdt gdt_48,同理设置了CPU中GDTR寄存器的值,即将GDT地址加载到IDTR。其中lidt指令用于加载中段描述表IDT寄存器,lgdt指令用于加载全局描述符表GDT寄存器

乍眼一看是不是很懵?

我们需要将 标号idt_48,gdt_48处的代码联系成一个整体,才能更方便地理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
gdt:              ! 描述符表由多个8字节长的描述符项组成。这里给出了 3 个描述符项。
.word 0,0,0,0 ! dummy 第1个为空描述符,无用,但必须存在

.word 0x07FF ! 段界限为 8M,limit=2047 (2048*4096=8Mb) 第2个为空描述符
.word 0x0000 ! 段基址为 0
.word 0x9A00 ! code read/exec P=1, DPL=00, S=1, 代码段,只读,可执行
.word 0x00C0 ! granularity=4096, 386

.word 0x07FF ! 段界限为 8M - limit=2047 (2048*4096=8Mb) 第3个为空描述符
.word 0x0000 ! 段基址为 0
.word 0x9200 ! P=1, DPL=00, S=1, 数据段,可读可写
.word 0x00C0 ! granularity=4096, 386

idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L 即idt 基址为 0

gdt_48:
.word 0x800 ! 段限长暂时定为 2KB,能放 256 个描述符
.word 512+gdt,0x9 ! gdt 基址为0x90200 + gdt

那为啥要加载IDT、GDT呢?IDT、GDT究竟是什么??

我们先来回顾一下,8086的CPU 和寄存器等宽度都是 16 位,其可寻址最大内存空间是64kb,然而8086芯片却有20根地址线,可寻址的最大内存空间是1MB。此时可没有”实模式”的概念

CPU和寄存器的寻址能力远远不能满足使用,为了解决这个问题,采用了内存寻址分段技术,通过段基址+段内偏移地址的方式生成20位的地址,扩大寻址能力,从而实现对1MB内存空间的寻址

然而随着CPU 发展到 32 位后,寄存器的位数也变为32位,CPU的地址线的个数也从原之前的20根变为现在的32根,其寻址空间更达到了 32 次方, 4GB 内存寻址空间。我们的内存寻址方式已经不适用,需要做出改变,但是还必须兼容老办法,所以会理解起来比较困难

这个我们称之为”保护模式”,保护模式概念首次出现于80286,并将以前”老办法”称为实模式,80286 虽然有了保护模式,地址总线是 24根,寻址空间变成了 2^24 =16MB, 但其CPU、通用寄存器还是16位, 即单独的一个寄存器还是只能访问64KB的空间,要想访问完整的 16MB 内存,只能频繁地变换段基址,非常影响计算机的性能

因此80286太鸡肋了,很快Intel推出了80386,CPU、寄存器、地址总线都是32位的,寻址空间直接到4GB,在当时CPU非常昂贵的时代背景下,可以说”硬件直接拉满”,从这个时候开始,保护模式才大放异彩!

由于为了兼容实模式,在保护模式下,段寄存器(比如 ds、ss、cs)中存放的不再是寻址段的基地址,而是一个一个”索引”,称为段选择符(或称段选择子),由段选择符从全局描述符表GDT中找到8个字节长的段描述符,段描述符里存储着段基址,再加上偏移地址就可以得到实际内存物理地址。这里我们只考虑段模式,页模式暂不展开,其实页模式也是基于段模式的

GDT, Global Descriptor Table 就是全局描述符表,由于在保护模式下内存段增加了许多描述信息,其中包括段的最大长度限制(16位)、段的线性基址(32位)、段的特权级、段是否在内存、读写许可等等,首先需要一个数据结构来保存所有的相关信息,这就是GDT;这些信息非常庞大,不是一个寄存器就能够保存的下去的,需要在操作系统启动时,加载到内存中;由于放在内存中,需要通过GDTR寄存器来告诉CPU,GDT的内存位置。

那IDT呢?别急,马上就来

IDT,Interrupt Descriptor Table,即中断描述符表,和GDT类似,记录着0~255的中断号和调用函数之间的关系,与中段向量表有些相似,但要包含更多的信息。

实模式下,16位的中断机制依赖的是中断向量表,中断向量表初始化在0x0000处,位置是固定的。但当我们把system模块搬到零地址处,中断向量表就被覆盖了。

而在保护模式下,中断机制用的是中断描述符表(IDT),位置是不固定的,设计操作系统时可以灵活设置,只需最后把其地址赋值给IDTR寄存器。

此时IDT虽然已经设置,但其实就是一张空表,大家还记得在system搬运前,已经关闭中断了,无需调用中断服务程序。不必纠结为啥设置个空表放在这,这里设置idt,gdt 是临时设置一下,后面到操作系统c程序还会重新设置的。

打开A20地址线

这是一个历史遗留的问题,打开A20是一个为了解决80286的一个bug引入的,什么bug呢?

我们知道在8086中, CPU 和寄存器等宽度都是 16 位的,地址线有20根,为了能够访问1M内存,采用分段机制来访问内存,但这个方案产生有个问题是,其能够表示的最大内存为:FFFFh:FFFFh=FFFF0h+FFFFh=10FFEFh=1M+64K-16Bytes,这多余出来的空间被称为高端内存。当我们操作计算机去访问超过1M,也就是100000h~10FFEFh之间的内存,操作系统并不认为其访问越界而产生异常,而是自动从重新0开始计算。

当80286问世后,其地址总线发展为24根,虽然其CPU、通用寄存器还是16位,但是其保护模式下最大访问内存能达到2^24=16M,为了保证兼容性,Intel希望实模式能完全兼容8086,这就存在一个bug:此时去访问高端内存,系统将实际能够访问到这块内存

为了解决这个问题,引入A20地址线,在A20关闭的情况下,系统仍然使用8086/8088的方式,访问超过2^20 =1MB内存时,会自动回卷;当在A20打开的情况下,才会突破地址信号线20位的宽度,变成32位可用,实现最大寻址空间4GB

1
2
3
4
5
6
7
8
9
10
! that was painless, now we enable A20

call empty_8042 ! 等待输入缓冲器空
mov al,#0xD1 ! 0xD1 命令码-表示要写数据到

out #0x64,al ! 8042 的 P2 端口
call empty_8042 ! 再次等待缓冲区为空
mov al,#0xDF ! 将A20打开
out #0x60,al
call empty_8042 ! 若此时输入缓冲器为空,则表示 A20 线已经选通

控制A20总线的端口被称为A20-Gate。使用in/out指令控制,即可控制A20总线是否打开。 in/out指令是x86汇编可以访问硬件的方式,那我们回顾一下,之前介绍过操作系统硬件访问方式有:借助bios中断、通过MMIO方式读写映射过的硬件寄存器(将寄存器映射到内存地址空间上),这里in/out指令也可以访问硬件

设置8259中断控制器的中断号

由于系统中保留的中断号是不能冲突的,CPU 在保护模式下,int 0x00~0x1F 被 Intel 保留作为内部(不可屏蔽)中断和异常中断。但IBM在原 PC 机中搞糟了,以后也没有纠正过来。 如果不对 8259 进行重新编程,int 0x00~0x1F 将被覆盖

所以需要对 8259 中断控制器进行编程,放到 Intel 保留的硬件中断后面,即int 0x20~0x2F。linus都在注释中吐槽这是”不得不做,却又一点意思也没有”,具体操作我们这里就简单了解一下即可

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
mov	al,#0x11		! initialization sequence
out #0x20,al ! send it to 8259A-1
.word 0x00eb,0x00eb ! jmp $+2, jmp $+2
out #0xA0,al ! and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 ! start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 ! start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 ! 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 ! 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 ! 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF ! mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al

我们只需关注的是编程前后发生了什么变化,笔者这里特点画了2张图,帮大家理解

进入保护模式

进入保护模式很简单,就3行

1
2
3
mov	ax,#0x0001	! 保护模式比特位(PE)
lmsw ax ! 加载机器状态字
jmpi 0,8 ! 跳转至 cs 段偏移 0 处

首先加载机器状态字lmsw(Load Machine Status Word),也称控制寄存器CR0,这是一个非常重要的控制寄存器,硬件的很多重大控制都是通过设置 CR0 来完成的,将 CR0这个寄存器的最后一位设置为 1,模式就从实模式切换到保护模式!

当设置完CR0后,接着必须是一条段间跳转指令以 用于刷新 CPU 当前指令队列,在进入保护模式以后那些属于实模式的预先取得的指令信息就变得不再有效,段间跳转指令可以刷新 CPU 的当前指令队列,即丢弃这些无效信息

jmpi 0,8,段寄存器cs的值是8,不过此时不再是基地址,而是保护模式下的段选择符,段选择符是16位的数据,第0到1位表示特权级别,第2位的0表示使用全局描述符表GDT(之前设置过GDT,这个基地址就是0),第3位的1表示索引。

所以jmpi 0,8最终不会跳到0x0008:0000处,而是会跳转到0x0000处,也就是system模块加载的起始地址(system模块之前已经搬运到了零地址)。其实也就是跳转去执行system中的代码,而system模块的头文件就是head,后面我们继续探索操作系统的奥秘!

小结

Setup.S 是一个操作系统加载程序,它的主要作用是利用BIOS 中断读取计算机相关数据(光标,内存,显卡,磁盘等参数),并将这 些数据保存到 0x90000 开始的位置,留待后续使用,并进入保护模式。想必大家对本文内存的变化了解地不够直观,呼噜噜这里再吐血画了张图,串联全文,帮助大家理解

参考资料:

《Linux内核完全注释5.0》

https://blog.csdn.net/ruyanhai/article/details/7181842


全文完,感谢您的阅读,如果我的文章对你有所帮助的话,还请点个免费的,你的支持会激励我输出更高质量的文章,感谢!
计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!