抄写Linux源码(Day13:从 MBR 到 C main 函数 (2:研究 setup.s) )

回忆我们需要做的事情:
为了支持 shell 程序的执行,我们需要提供:
1.缺页中断(不理解为什么要这个东西,只是闪客说需要,后边再说)
2.硬盘驱动、文件系统 (shell程序一开始是存放在磁盘里的,所以需要这两个东西)
3.fork,execve, wait 这三个系统调用,也可以说是 进程调度 (否则无法 halt shell 程序并且启动另外的程序)
4.键盘驱动、VGA/console/uart 驱动、中断处理 (支持键盘输入和屏幕显示)
5.内存管理 (shell 启动其它进程时,不能共用内存,而是切换其它进程的页表)
6.为了写代码方便,我们需要从 MBR 进入到 main 函数,这也是从 汇编 切换到 C 语言
7.应用程序申请内存的接口

那么回到最初的问题:
为了实现复杂的内核,毫无疑问我们需要使用 C 语言,需要从汇编跳转到 C 语言。由于 MBR 只有 512 字节,所以我们没法直接把内核写在 MBR,而是需要使用 MBR 加载内核到内存上。那么问题来了,现在我们已经把内核和setup.s 加载到内存上了。为什么不直接 jmp 到内核里的 main 函数,而是要先 jmp 到 setup.s 以及 head.s(system 的 entry)。它们做了些什么?这些事情可以被省略掉吗?我们还需要继续研究。

为什么 setup.s 是不可省略的,为什么不能直接跳转到内核?继续研究 setup.s

首先看闪客文章第五回

看闪客文章第五回:https://mp.weixin.qq.com/s?__biz=Mzk0MjE3NDE0Ng==&mid=2247499558&idx=1&sn=d5d057fae2fb835d38f82804c2649463&chksm=c2c5858bf5b20c9de2db5c31dd34fbe766f6c46cda17f54f4518a75fc1838f2ada6fe5717aad&scene=178&cur_album_id=2123743679373688834#rd

从 setup.s 代码的 _start: 开始往下看,有

  # 在 bootsect.s 中已经把 cs=0x9000 赋值给 ds 和 es 了,不过为了降低代码之间的耦合性,这里我们再做一次
	mov %cs,%ax
	mov %ax,%ds
	mov %ax,%es

  # GUESS: BIOS 应该初始化计算机到文字模式,这种模式下,一屏幕可以显示25行,每行80个字符,也就是80列
# 打印一段提示信息,告知我们现在已经进入了 setup.s 文件里
##print some message
	mov $0x03, %ah
	xor %bh, %bh # bh = 0
	int $0x10


	mov $29, %cx
	mov $0x000b,%bx
	mov $msg2,%bp
	mov $0x1301, %ax
	int $0x10

还有通过 BIOS 调用获取光标位置信息,并且储存到内存位置 0x90000

# ok, the read went well so we get current cursor position and save it for
# posterity.
  # 使用 BIOS 0x10 显示服务里的读取光标位置功能
	mov	$INITSEG, %ax	# this is done in bootsect already, but...
	mov	%ax, %ds
	mov	$0x03, %ah	# read cursor pos
	xor	%bh, %bh
	int	$0x10		# save it in known place, con_init fetches
  # 这个中断返回时,dx存储了光标位置,dh是行号, dl是列号
  # 把光标位置存在 0x90000 处,这样也就覆盖掉了之间放在 0x90000 处的 MBR
	mov	%dx, %ds:0	# it from 0x90000.

接下来分别是通过 BIOS 调用获取 内存大小、显卡显示模式、检查显示方式获取参数等等。

但让我奇怪的是,在获取第一块硬盘信息的时候,并没有看到 int 指令,为什么?如下

# Get hd0 data
# 调用 BIOS,获取第一块磁盘的信息  TODO: 没有看到 int 指令,怎么获取到这些信息的?之前读过吗?
	mov	$0x0000, %ax
	mov	%ax, %ds
	lds	%ds:4*0x41, %si
	mov	$INITSEG, %ax
	mov	%ax, %es
	mov	$0x0080, %di
	mov	$0x10, %cx
	rep
	movsb

lds 命令没见过,根据查询 intel 指令集手册,猜测它的作用是:
lds %ds:40x41, %si 把 ds 寄存器的值赋值给 ds,随后把 40x41 的值赋值给 si 寄存器

我们要做个验证,用调试器去连接 Linux0.11,对 LDS 指令打断点,接着看 LDS 指令执行后的效果

经过了解,qemu-gdb 通常只能用于操作系统进入 32 位模式后,x86 实模式位于 16 位模式,很难使用 qemu-gdb 去调试。此时更加适合用于调试的模拟器应该是 bochs

让我们研究一下怎么使用 bochs 调试 Linux0.11

(TODO: 唉,关于 setup.s 是如何在没用使用 int 指令的情况下获取 hd0 信息这件事情后面再说吧,我们先把 Linux0.11 骨架给搞明白)

继续看闪客文章第五回,可知 setup.s 中的下面这段代码:

比如获取内存信息。
; Get memory size (extended mem, kB)
    mov ah,#0x88
    int 0x15
    mov [2],ax
获取显卡显示模式。
; Get video-card data:
    mov ah,#0x0f
    int 0x10
    mov [4],bx      ; bh = display page
    mov [6],ax      ; al = video mode, ah = window width
检查显示方式并取参数
; 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
获取第一块硬盘的信息。
; Get hd0 data
    mov ax,#0x0000
    mov ds,ax
    lds si,[4*0x41]
    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]
    mov ax,#INITSEG
    mov es,ax
    mov di,#0x0090
    mov cx,#0x10
    rep
    movsb

它的作用基本上就是使用 BIOS 中断调用,获取计算机上各个设备的信息,接着把这些信息存放到内存中的指定位置,如下:

在这里插入图片描述
可以看到这些东西都存放在 0x90000 - 0x90200,也就是覆盖了之前那个 MBR 的内存。没关系,bootsect.s 的内容我们不会再执行了。

在储存好设备信息之后(我用的 Linux0.11 源码似乎还把这些储存的信息打印出来了),会执行下面这个指令

cli         ; no interrupts allowed ;

就一行 cli,表示关闭中断的意思。

因为后面我们要把原本是 BIOS 写好的中断向量表给覆盖掉,也就是给破坏掉了,写上我们自己的中断向量表,所以这个时候是不允许中断进来的。(需要使用 BIOS 中断的行为(获取设备信息)都已经做完了)

继续看。

; first we move the system to it's rightful place
    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
; then we load the segment descriptors
end_move:
    ...

这段代码做的事情,起始就是把内存 0x10000 - 0x90000 的内容移动到了 0x0。

覆盖掉了 BIOS, 0x7c00的MBR 的代码 (后边我们不会再执行 BIOS 的代码了)

此时的内存布局是:0x0 ~ 240个扇区 = 操作系统内核的代码和数据
0x90000 - 0x90200: 设备信息
0x90200 ~ 4个扇区 setup.s 代码和数据 (估计后边还会被栈给覆盖掉)

在这里插入图片描述
那么此时,内存布局成了下面这样

TODO: 我个人觉得闪客这里是有问题的,因为前面一张图说 system 占用内存是 120K,下面这张图又说是 512K。

在这里插入图片描述
那么,到这里,闪客文章第五回就看完了

接着看闪客文章第六回

接下来就要进行真正的第一项大工程了,那就是模式的转换,需要从现在的 16 位的实模式转变为之后 32 位的保护模式。

当然,虽说是一项非常难啃的大工程,但从代码量看,却是少得可怜,所以不必太过担心。

每次讲这里都十分的麻烦,因为这是 x86 的历史包袱问题,现在的 CPU 几乎都是支持 32 位模式甚至 64 位模式了,很少有还仅仅停留在 16 位的实模式下的 CPU。所以我们要为了这个历史包袱,写一段模式转换的代码,如果 Intel CPU 被重新设计而不用考虑兼容性,那么今天的代码将会减少很多甚至不复存在。

我不打算直接说实模式和保护模式的区别,我们还是跟着代码慢慢品味,来。

这里仍然是 setup.s 文件中的代码咯。

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

idt_48:
    .word   0     ; idt limit=0
    .word   0,0   ; idt base=0L

上来就是两行看不懂的指令,别急。

要理解这两条指令,就涉及到实模式和保护模式的第一个区别了。我们现在还处于实模式下,这个模式的 CPU 计算物理地址的方式还记得么?不记得的话看一下 第一回 最开始的两行代码

就是段基址左移四位,再加上偏移地址。比如:

在这里插入图片描述
是不是觉得很别扭,那更别扭的地方就要来了。当 CPU 切换到保护模式后,同样的代码,内存地址的计算方式还不一样,你说气不气人?

变成啥样了呢?刚刚那个 ds 寄存器里存储的值,在实模式下叫做段基址,在保护模式下叫段选择子。段选择子里存储着段描述符的索引。

在这里插入图片描述
通过段描述符索引,可以从全局描述符表 gdt 中找到一个段描述符,段描述符里存储着段基址。

在这里插入图片描述
段基址取出来,再和偏移地址相加,就得到了物理地址,整个过程如下。

在这里插入图片描述
感觉保护模式下的地址翻译,有点页表翻译那味了

总结一下就是,段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表中寻找段描述符,从中取出段基址。

好了,那问题自然就出来了,全局描述符表(gdt)长什么样?它在哪?怎么让 CPU 知道它在哪?

长什么样先别管,一定又是一个令人头疼的数据结构,先说说它在哪?在内存中呗,那么怎么告诉 CPU 全局描述符表(gdt)在内存中的什么位置呢?答案是由操作系统把这个位置信息存储在一个叫 gdtr 的寄存器中。

在这里插入图片描述
怎么存呢?就是刚刚那条指令。

lgdt    gdt_48

其中 lgdt 就表示把后面的值(gdt_48)放在 gdtr 寄存器中,gdt_48 标签,我们看看它长什么样。

gdt_48:
    .word   0x800       ; gdt limit=2048, 256 GDT entries
    .word   512+gdt,0x9 ; gdt base = 0X9xxxx     (实模式下,估计要加上 ds 的偏移,也就是 0x90200)

可以看到这个标签位置处表示一个 48 位的数据,其中高 32 位存储着的正是全局描述符表 gdt 的内存地址

0x90200 + gdt

gdt 是个标签,表示在本文件内的偏移量,而本文件是 setup.s,编译后是放在 0x90200 这个内存地址的,还记得吧?所以要加上 0x90200(512) 这个值。

那 gdt 这个标签处,就是全局描述符表在内存中的真正数据了。

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

具体细节不用关心,跟我看重点。

根据刚刚的段描述符格式。

在这里插入图片描述

可以看出目前全局描述符表有三个段描述符,第一个为空,第二个是代码段描述符(type=code),第三个是数据段描述符(type=data),第二个和第三个段描述符的段基址都是 0,也就是之后在逻辑地址转换物理地址的时候,通过段选择子查找到无论是代码段还是数据段,取出的段基址都是 0,那么物理地址将直接等于程序员给出的逻辑地址(准确说是逻辑地址中的偏移地址)。先记住这点就好。

在这里插入图片描述

具体段描述符的细节还有很多,就不展开了,比如这里的高 22 位就表示它是代码段还是数据段。

在这里插入图片描述
在这里插入图片描述
这里我把 idtr 寄存器也画出来了,这个是中断描述符表,其原理和全局描述符表一样。全局描述符表是让段选择子去里面寻找段描述符用的,而中断描述符表是用来在发生中断时,CPU 拿着中断号去中断描述符表中寻找中断处理程序的地址,找到后就跳到相应的中断程序中去执行,具体我们后面遇到了再说。

好了,今天我们就讲,操作系统设置了个全局描述符表 gdt,为后面切换到保护模式后,能去那里寻找到段描述符,然后拼凑成最终的物理地址,就这个作用。当然,还有很多段描述符,作用不仅仅是转换成最终的物理地址,不过这是后话了。

这仅仅是进入保护模式前准备工作的其中一个,后面的路还长着呢。欲知后事如何,且听下回分解。

那么,到这里,闪客文章第六回就看完了。总的来说,为了进入保护模式(32位),我们需要先设置 idt 和 gdt,原因是 CPU 进入保护模式之后翻译逻辑地址的方式会发生变化,所以进入保护模式之前需要先设置 idt 和 gdt

接着看闪客文章第七回

书接上回,上回书咱们说到,操作系统设置了个全局描述符表 gdt。
在这里插入图片描述
为后面切换到保护模式后,能去那里寻找到段描述符,然后拼凑成最终的物理地址。
在这里插入图片描述

而此时我们的内存布局变成了这个样子。

在这里插入图片描述

这仅仅是进入保护模式前准备工作的其中一个,我们接着往下看。代码仍然是 setup.s 中的。

mov al,#0xD1        ; command write
out #0x64,al
mov al,#0xDF        ; A20 on
out #0x60,al

这段代码的意思是,打开 A20 地址线。

说人话就是,打开 A20 地址线。哈哈,开玩笑,到底什么是 A20 地址线呢?

简单理解,这一步就是为了突破地址信号线 20 位的宽度,变成 32 位可用。这是由于 8086 CPU 只有 20 位的地址线,所以如果程序给出 21 位的内存地址数据,那多出的一位就被忽略了,比如如果经过计算得出一个内存地址为

1 0000 00000000 00000000

那实际上内存地址相当于 0,因为高位的那个 1 被忽略了,地方不够。

当 CPU 到了 32 位时代之后,由于要考虑兼容性,还必须保持一个只能用 20 位地址线的模式,所以如果你不手动开启的话,即使地址线已经有 32 位了,仍然会限制只能使用其中的 20 位。

简单吧?我们继续。

接下来的一段代码,你完全完全不用看,但为了防止你一直记挂在心上,我给你截出来说道说道,这样以后我说完全不用看的代码时,你就真的可以放宽心完全不看了。

就是这一大坨,还有 Linus 自己的注释。

; well, that went ok, I hope. Now we have to reprogram the interrupts :-(
; we put them right after the intel-reserved hardware interrupts, at
; int 0x20-0x2F. There they won't mess up anything. Sadly IBM really
; messed this up with the original PC, and they haven't been able to
; rectify it afterwards. Thus the bios puts interrupts at 0x08-0x0f,
; which is used for the internal hardware interrupts as well. We just
; have to reprogram the 8259's, and it isn't fun.

    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

这里是对可编程中断控制器 8259 芯片进行的编程。

因为中断号是不能冲突的, Intel 把 0 到 0x19 号中断都作为保留中断,比如 0 号中断就规定为除零异常,软件自定义的中断都应该放在这之后,但是 IBM 在原 PC 机中搞砸了,跟保留中断号发生了冲突,以后也没有纠正过来,所以我们得重新对其进行编程,不得不做,却又一点意思也没有。这是 Linus 在上面注释上的原话。

所以我们也不必在意,只要知道重新编程之后,8259 这个芯片的引脚与中断号的对应关系,变成了如下的样子就好。

在这里插入图片描述

mov ax,#0x0001  ; protected mode (PE) bit
lmsw ax      ; This is it;
jmpi 0,8     ; jmp offset 0 of segment 8 (cs)

前两行,将 cr0 这个寄存器的位 0 置 1,模式就从实模式切换到保护模式了。

在这里插入图片描述

所以真正的模式切换十分简单,重要的是之前做的准备工作。

再往后,又是一个段间跳转指令 jmpi,后面的 8 表示 cs(代码段寄存器)的值,0 表示偏移地址。请注意,此时已经是保护模式了,之前也说过,保护模式下内存寻址方式变了,段寄存器里的值被当做段选择子。

回顾下段选择子的模样。

在这里插入图片描述

8 用二进制表示就是
00000,0000,0000,1000

对照上面段选择子的结构,可以知道描述符索引值是 1,也就是要去全局描述符表(gdt)中找第一项段描述符。

还记得上一讲中的全局描述符的具体内容么?

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

我们说了,第 0 项是空值,第一项被表示为代码段描述符,是个可读可执行的段,第二项为数据段描述符,是个可读可写段,不过他们的段基址都是 0。

所以,这里取的就是这个代码段描述符,段基址是 0,偏移也是 0,那加一块就还是 0 咯,所以最终这个跳转指令,就是跳转到内存地址的 0 地址处,开始执行。

零地址处是什么呢?还是回顾之前的内存布局图。

在这里插入图片描述
就是操作系统全部代码的 system 这个大模块,system 模块怎么生成的呢?由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码合并来的,可以理解为操作系统的全部核心代码编译后的结果。

tools/system: boot/head.o init/main.o \
    $(ARCHIVES) $(DRIVERS) $(MATH) $(LIBS)
    $(LD) $(LDFLAGS) boot/head.o init/main.o \
    $(ARCHIVES) \
    $(DRIVERS) \
    $(MATH) \
    $(LIBS) \
    -o tools/system > System.map

所以,接下来,我们就要重点阅读 head.s 了。(后边可以看看 head.s 是怎么被放置在 0x0 内存地址的)

这也是 boot 文件夹下的最后一个由汇编写就的源代码文件,哎呀,不知不觉就把两个操作系统源码文件(bootsect.s 和 setup.s)讲完了,而且是汇编写的令人头疼的代码。

head.s 这个文件仅仅是为了顺利进入由后面的 c 语言写就的 main.c 做的准备,所以咬咬牙看完这个之后,我们就终于可以进入 c 语言的世界了!也终于可以看到我们熟悉的 main 函数了!

在那里,操作系统真正秀操作的地方,才刚刚开始!欲知后事如何,且听下回分解。

那么,到这里,闪客文章第七回就看完了

那么回到最初的问题:
为了实现复杂的内核,毫无疑问我们需要使用 C 语言,需要从汇编跳转到 C 语言。由于 MBR 只有 512 字节,所以我们没法直接把内核写在 MBR,而是需要使用 MBR 加载内核到内存上。那么问题来了,现在我们已经把内核和setup.s 加载到内存上了。为什么不直接 jmp 到内核里的 main 函数,而是要先 jmp 到 setup.s 以及 head.s(system 的 entry)。它们做了些什么?这些事情可以被省略掉吗?我们还需要继续研究。

为什么 setup.s 是不可省略的,为什么不能直接跳转到内核?

为什么 setup.s 不可省略?不能直接从 MBR 跳转到内核 main 函数? 原因是,我们的系统是32位的,进入 main 函数之前得先进入保护模式,而启动保护模式之前需要进行一大堆初始化,包括:

  1. 在 BIOS 中断处理函数被覆盖之前,使用它获取设备信息,存在内存 0x90000
  2. 关闭中断,重置整个内存布局,把system放在内存地址 0x0
  3. 设置 gdt 和 idt,保证进入保护模式之后,内存和中断可以被正常访问
  4. 开启A20地址线,对可编程中断控制器 8259 芯片进行重编程

随后修改 cr0 寄存器,真正进入保护模式,接着跳转进入 system 区域(也就是 head.s)

到这里,setup.s 的作用也就看完啦!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值