Linux0.11内核-HITOSLAB(1.引导启动程序boot与哈工大操作系统实验一)

Linux0.11内核-HITOSLAB(1.引导启动程序boot与哈工大操作系统实验一)

最近在自学李老师的操作系统,顺便做一下附带的OSLAB.在做的过程中遇到了各种各样的问题,需要查很多资料和掌握很多前置知识点(特别是对于没有汇编知识基础的)。所以就专门写一篇文章,把各种知识和过程中的问题都总结一下。文章可能会有一些错误,欢迎大家指正。

另外实验的内容一般都相对来说比较基础,所以还会结合Linux0.11的内核源码来进行分析,这样可以对整个OS有一个大体上的了解,对于课上的很多知识点也能有一个实践上的认识。(中间有参考很多相关文章,侵删)


一、整体功能介绍

在这里插入图片描述
总体功能:在这里插入图片描述
1.首先PC电源打开后,从ROM-BIOS物理地址0xFFFF0处读取代码:(执行某些系统检测,并在0地址处设置中断向量表,并启动设备的第一个扇区(bootsect.S)并读入ROM0x7C00处)

2.执行bootsect.S代码[0x7C00,31KB]:1.将自己转移到0x90000(576KB)内存位置 2.将setup.s[576KB]读取到0x90200处 3.将内核其他代码(system模块[512KB])读入到0x10000(64KB)处

3.执行setup.s:识别主机特性以及VGA卡类型,将system模块移动到0x00000000地址处(由于setup需要利用ROM-BIOS的中断调用来获取机器的一些参数(显卡模式,硬盘参数表),所以一开始不会将system放到0地址处,否则会覆盖ROM-BIOS设置的中断向量表)。加载中断描述符表IDT和全局符号描述表GDT。设置cpu的控制寄存器cr0,进入32位保护模式。跳转到system的head.s并运行。

4.system模块:重新设置IDT,GDT,设置分页工作,跳转并调用main()函数
在这里插入图片描述

二、Linux0.11 引导过程源码分析

下面直接进入对引导过程的源码分析,顺便对前置的知识,例如x86汇编,实模式保护模式等进行介绍,从源码角度了解Linux中操作系统的引导过程。
这里主要参考了:Linux探究之路和Linux内核完全注释v3.0

https://blog.csdn.net/m0_53157173/category_11964444.html

话不多说,下面开始
首先,当你按下开机键的那一刻,在主板上提前写死的固件程序 BIOS 会将硬盘中启动区的 512 字节的数据,原封不动复制到内存中的 0x7c00 这个位置,并跳转到那个位置进行执行。(这里的BIOS固件是主板程序,与操作系统无关)
在这里插入图片描述
随后 CPU 便会从从0x7c00这个位置开始执行,不断往后一条一条语句无脑地执行下去。

1.bootsect.s

执行的第一个文件便是bootsect.s文件
在这里插入图片描述
.globl用于定义随后的标识符是外部或者全局的,全局标识符,供ld86链使用
.text段表示文本段开始位置,.data数据段,.text代码段(详细知识可以参考CSAPP)
这里.text、.data、.bss表明这3个段重叠,不进行分段

SETUPLEN = 4				! nr of setup-sectors
BOOTSEG  = 0x07c0			! original address of boot-sector
INITSEG  = 0x9000			! we move boot here - out of the way
SETUPSEG = 0x9020			! setup starts here
SYSSEG   = 0x1000			! system loaded at 0x10000 (65536).
ENDSEG   = SYSSEG + SYSSIZE		! where to stop loading

这里的BOOTSEG表示bootsect.s开始地址,INITSEG为bootsect移动后的存储地址,SETUPSEG为setup.s的开始地址,SYSSEG为system开始放置地址。
(这里很多人会有个疑问,例如Bootsect的内存初始地址应该为0x07c00),为什么这里只有0x07c0,这主要是由于X86特殊的寻址方法:

实模式下访问寄存器
实模式下,X86 CPU的寄存器如下图,每个寄存器都是16位宽。
在这里插入图片描述
实模式下访问内存:
CPU要访问内存(从内存上取指令或数据),需要向总线上发送要访问的内存地址,实模式下,这个地址是怎么计算出来的呢?来看下面这张图:
在这里插入图片描述
可以这样理解,在实模式下因为Intel想要实现20位的地址访问,但是任意寄存器最多只有16位指令地址,因此CPU会通过两个寄存器的值组合来得到地址,也就是
(段寄存器:偏移地址(也是由寄存器存储))这样的地址组合模式来构成,其计算方法为:段寄存器<<4+偏移地址。例如值CS所存储的地址左移4位加IP的值:(CS << 4) + IP用来进行取值指令。
常见的寄存器组合访问:

C S : I P CS:IP CS:IP(进行取值指令)

D S : S I DS:SI DS:SI(访问内存源地址) E S : D I ES:DI ES:DI(访问内存目的地址)

[一般配合rep movw使用,即从DS:SI移动cx个字到ES:DI]

S S : S P SS:SP SS:SP(栈地址,一般用来访问c语言临时变量和函数栈信息)

因此对于 INITSEG = 0x9000,往往伴随偏移地址进行访问。 例如 当 ds设置为#INITSEG,即0x9000时,[2]表示访问(ds:2)即 9000<<4+2 = 0x90002位置。

下面便将开始bootsect在cpu中运行的开始部分

start: 标志bootsect.s在cpu执行部分
mov ax,#BOOTSEG 表示将ax的寄存器设置为BOOTSEG的值,即0x07c0,
mov ds,ax表示将ax的值传递给ds,即将ds寄存器的值变为0x7c0,后续所有的mov指令都不再做分析。
这段代码的前段部分表示:将 ds 寄存器的值变为 0x07c0 了,然后又通过同样的方式将 es 寄存器的值变成 0x9000,将si,di设置为0。
在这里插入图片描述
在这里插入图片描述

rep movw

其中 rep 表示重复执行后面的指令。rep的次数由cx的值决定。
而后面的指令 movw 表示复制一个字(word 16位),那其实就是不断重复地复制一个字。其表示从(ds:si)移动字到(es:di)。

jmpi go,0x9000
go:

jmpi 是一个段间跳转指令,表示跳转到 0x9000:go 处执行。go 就是一个标签,最终编译成机器码的时候会被翻译成一个值,这个值就是 go 这个标签在文件内的偏移地址。这个偏移地址再加上 0x90000,就刚好是 go 标签后面那段代码 mov ax,cs 此时所在的内存地址了。

因此这段的整体代码表示为:将内存地址 0x7c00 处开始往后的 512 字节的数据,原封不动复制到 0x90000 处

下面是go段:
在这里插入图片描述
这段代码的直接意思很容易理解,就是把 cs 寄存器的值分别复制给 ds、es 和 ss 寄存器,然后又把 0xFF00 给了 sp 寄存器。cs 寄存器表示代码段寄存器,CPU 当前正在执行的代码在内存中的位置,就是由 cs:ip 这组寄存器配合指向的,其中 cs 是基址,ip 是偏移地址。所以现在 cs 寄存器里的值就是 0x9000,ip 寄存器里的值是 go 这个标签的偏移地址。那这三个 mov 指令就分别给 ds、es 和 ss 寄存器赋值为了 0x9000。

ds 为数据段寄存器,之前我们说过了,当时它被复制为 0x07c0,是因为之前的代码在 0x7c00 处,现在代码已经被挪到了 0x90000 处,所以现在自然又改赋值为 0x9000 了。

es 是扩展段寄存器,仅仅是个扩展,不是主角,先不用理它。

ss 为栈段寄存器,后面要配合栈基址寄存器 sp 来表示此时的栈顶地址。而此时 sp 寄存器被赋值为了 0xFF00 了,所以目前的栈顶地址就是 ss:sp 所指向的地址 0x9FF00 处。

接下来将执行load_setup部分,将setup模块写入内存:
在这里插入图片描述
前三行设置dx,cx,bx,ax的值,接下来执行int 0x13中断函数。
int 0x13 表示发起 0x13 号中断,这条指令上面给 dx、cx、bx、ax 赋值都是作为这个中断程序的参数。具体的0x13功能可以自行搜索。这里只讲这里用到的0x13功能。
在这里插入图片描述
很多人看到这里其实很懵,因为并没有出现int13要求的ah,ch,dh等值,因此这里需要补一下寄存器相关的知识:
对于DX:通用寄存器,低8位为DL寄存器,高8位为DH寄存器

因此这里的寄存器值设置表示为如下含义:
dx:0x0000 即表示为 dh:00 dl:00 ,这里表示参数设置为 0扇面,软驱动。
cx:0x0002 即表示为 ch:00(0磁道) cl:02(2号扇区)
bx:0x0200->由于es:bx指向内存读取地址,即(0x90200 即:9000:0200),所以设置bx为0x200
ax:0x0200+SETUPLEN -> ah = 0x02即表示int 13功能为磁盘扇区到内存,al=SETUPEN即读取al=SETUPLEN个扇区到内存中去

这里很多人可能对于扇面,磁道,扇区的概念并不是很了解,所以下面顺便介绍一下相关的知识:*

硬盘参数释疑

很久以前, 硬盘的容量还非常小的时候, 人们采用与软盘类似的结构生产硬盘. 也就是硬盘盘片的每一条磁道都具有相同的扇区数. 由此产生了所谓的3D参数 (Disk Geometry). 既磁头数(Heads), 柱面数(Cylinders), 扇区数(Sectors)。

其中:磁头数(Heads) 表示硬盘总共有几个磁头,也就是有几面盘片, 最大为 255 (用 8 个二进制位存储);柱面数(Cylinders) 表示硬盘每一面盘片上有几条磁道, 最大为 1023(用 10 个二进制位存储);扇区数(Sectors) 表示每一条磁道上有几个扇区, 最大为 63 (用 6个二进制位存储).每个扇区一般是 512个字(0x200)。所以磁盘最大容量为:255 * 1023 * 63 * 512 / 1048576 = 8024 GB ( 1M = 1048576 Bytes )

在 CHS 寻址方式中, 磁头, 柱面, 扇区的取值范围分别为 0 到 Heads - 1, 0 到 Cylinders - 1, 1 到 Sectors (注意是从 1 开始).

磁盘的实际访问由磁盘控制器进行,我们可以通过控制磁盘控制器来访问磁盘。只能以扇区为单位对磁盘进行读写。在读写扇区的时候,要给出面号、磁道号和扇区号。面号和磁道号从0开始,而扇区号从1开始。

BIOS提供的访问磁盘的中断例程为int 13h。读取0面0道1扇区的内容到0:200的程序如下所示。

mov ax,0
mov es,ax
mov bx,200h //es:bx表示写入地址

mov al,1
mov ch,0 //磁道
mov cl,1 //扇区
mov dl,0
mov dh,0 //磁头
mov ah,2
int 13h

源码读到这里,也就完成了移动setup.s到0x90200的功能。接下来会执行如下代码:
在这里插入图片描述
其中seg cs可以参考源码注释中的解释,对于

mov sector.cx
mov ax,#INITSEG
mov es,ax !获取cx中的每磁道扇区数

获取cx中的每磁道扇区数这句话其实非常难理解,下面重新解释一下:
对于ch:ci 由于软盘最大磁道号不会超过256,因此最大磁道号高2位(位6,7)为0,所以只需要将ch设置为0x00即可以通过读取cx来读取到每个磁道的最大扇区数(cl的位0·5)

接下来便是实验二需要频繁用到的打印功能代码:

在这里插入图片描述
XOR AH,BH:是将AH中的内容和BH中的内容按位异或 其他代码则根据注释和之前的解释便可读懂。

第一个int10通过将ah设置为0x03进行读取光标位置操作,第二个int 10通过将ah设置为0x13进行字符打印,显示字符个数为cx=24个,显示字符串的地址为 es:bp=0x9000:msg1。msg1信息如下:

在这里插入图片描述

回车,换行 13,10,13 ,10 ,13,10共6个字符,"Loading system …"共18个字符,因此共24个字符(注意system和…之间还有一个空格)
打印信息过后,便将es指向system的段地址,执行read_it,kill_motor函数。后续还会检测使用哪个跟文件系统。这三块内容都与当时的硬件设备有所相关,属于一些细节内容,感兴趣的可以自己阅读相关的代码源码。

2.setup.s

一.读取系统参数并存储

setup.s的第一个部分是利用ROM BIOS的中断读取系统参数,并将这些数据保留到0x90000处,覆盖原先已经被读取的bootsect.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

这里就不做过多的讲解了,了解是利用int中断获取信息即可。

二.移动system块到0x00000处

cli         ! no interrupts allowed ;表示关闭中断
; 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:
    ...

其中,cld表示设置方向标志位(代码段是递增还是递减)
do_move:从0x00000开始移动system模块,每次移动0x8000字,即64KB,直到最后一段0x80000处开始的64KB移动完为止。
其实这里应该是要有疑问的:为什么每次只移动0x8000字,并且每一次重新移动的时候,都要从整数(0x00000,0x10000,0x20000)移动
我个人的解释是:应该是由于每次都是一块一块进行读取即64KB

此时的内存结构如下图所示:
在这里插入图片描述

三.设置GDT,IDT并进入保护模式

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

保护模式(分段)
产生原因:1.实模式下,寄存器都是16位的,以CS为例,实际最大能寻址的范围是CS左移4位,总共20位地址,因此可以访问1MB空间的内存。当内存超过1MB时,实模式的第一个问题就来了,保护模式需要解决这个问题。(扩展地址位) 2.实模式的第二个问题是CPU不加区分执行指令,对所访问的内存地址也没有任何限制。保护模式对这个问题也进行了解决。保护模式需要提供优先级的区分,从而限制不同优先级对于内存地址的访问 (因此保护模式就围绕着如何根据优先级从而提供不同的内存访问模式)

保护模式寄存器:
保护模式下,所有通用寄存器位32位,也可以单独使用低16位,低16位又可以拆分成两个8位寄存器。
在这里插入图片描述
保护模式特权等级

保护模式下对指令(如in,out,cli)和资源(如寄存器,I/O端口,内存地址)等进行了权限区分。

权限分为4级,R0-R3,每种权限执行的指令数量不同,R0权限最高,可以执行所有指令,R1,R2,R3权限逐级递减。内存访问的权限通过后面要说的段描述符和特权级别配合实现,对于R0来说权限最大,可以访问所有资源。
在这里插入图片描述
在保护模式下,对于数据的内存地址访问并不再依赖于先前的(段地址<<4+偏移地址的计算模式)。从设计方向上来说,可以理解为:对于每一个段,他都需要有特定的优先级和特定的内存访问地址。优先级决定了可以访问的指令和资源类型,特定的内存访问地址则由(固定的物理内存基地址+段限长组成),因此设计了一种特殊的存储方式用来辅助这样的访问模式,即保护模式。
在这里插入图片描述
上图右上角的表为全局段描述符表。当访问一个内存地址时,CPU会先将GDTR寄存器的值和段寄存器的值相结合,找到内存中的段描述符(这里的段描述符即可以理解为一个存储了段的(地址+权限)的数据结构。再通过段描述符里的段基地址和权限等信息来找到实际内存地址和访问权限。

因此,保护模式下,段寄存器里存放的不再是段基地址,而是一个段选择子(可以理解为段描述符索引,但实际上还包含权限,描述符表索引等),用来找到内存中的段描述符

具体的段描述符数据结构如下:
在这里插入图片描述
X86处理器有三种描述符表:全局描述符表(GDT),局部描述符表(LDT)和中断描述符表(IDT)。 GDT是全局的,一个系统中通常只有一个GDT,供系统中的所有程序和任务使用。LDT与任务有关,每个任务可以有一个LDT,也可也让多个任务共享一个LDT。IDT的数量是和处理器的数量相关的,系统通常会为每个CPU建立一个IDT。

GDTR在32位模式下,长度是48位,高32位是段全局描述符表基地址,低16位是表大小边界值。
举个例子,假设GDTR = 0x800000003FF, 则可以得出,段全局描述符表基地址为0x80000000,表的长度为0x3FF + 1 = 0x400(1024个字节+1是因为0x0 - 0x3FF总共有0x400个字节),因此可以知道,一个GDTR所指向的空间可以存放1024 / 8 = 128个描述符。

保护模式段选择子

CS,DS,ES,SS,FS,GS这些寄存器,里面的格式如下:
在这里插入图片描述
其中影子寄存器是硬件管理,软件不可用,其作用是作为段描述符的高速缓存,用于提升段描述符的访问速度,总共64位、8字节。 X86保护模式下有CPL(当前权限级别),段选择子的RPL(请求权限级别),段描述符字段里还有一个DPL(描述符权限级别)。
这里说起来比较抽象,下面有一个具体的例子来解释整个过程:
在这里插入图片描述
如上图:假设我执行代码

movl $0x10, %eax !将0x100001 0000赋给eax
mov %ax,%ds !0001 0000赋给ds
mov $1,0x02

下面讲解一下具体的代码寻址过程:

  1. 首先根据前两行代码ds会获取到0x10,即二进制0001 0000,根据保护模式的段选择解读,ds寄存器0~1地址表示RPL,2表示为TI,段描述索引从3开始,因此对于0001 0000来说,ds的段索引值为:00010.
  2. 根据段索引值00010即十进制2,因此ds指向的地址为全局描述符表索引值为2的段描述符。即GDTR[2]。此时GDTR[2]中根据段描述符,可以获取到他的基地址为:0x0001 0000。至此,前两行代码执行完毕
    在这里插入图片描述
    3.接下来执行第三行代码,mov $1,0x02,其描述将地址为:ds+偏移地址(2)的位置存入值1。根据前两行,此时ds值为0x00010000,加上偏移地址0x02,因此访问地址为0x00010002。
    在这里插入图片描述
    总结:实模式下访问地址:ds<<4+偏移地址 保护模式下,即 gdtr[ds[:3]].addr+偏移地址。对于中断描述符可以理解为类似于段描述符的数据结构,其主要用于设置中断功能。
    设置gdtr与ldtr依赖于lidt,lgdt两个指令,指令功能如下:
    在这里插入图片描述
    在这里插入图片描述
    设置IDT于GDT的代码如下:
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
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

对于GDT表,首段设置为四字(8字节)均为0
代码段(偏移地址为(0+4word.size())=0x08),设置利用4字即64位用来设置参数,具体的参数信息可以参考上面的描述符段介绍。
接下来数据段(偏移地址(0x08+4
word.size())= 0x10),具体过程于代码段一致
在这里插入图片描述
idt_48于gdt_48则是用来设置ldtr和gdtr寄存器的值。
前两字节用来表示表限长,后面4字节用来表示地址,如gdtr的后四字节则表示GDT的线性基地址:0x90000+512(0x0200,setup.s偏移地址)+gdt(上述的gdt段开始地址)
至此,则完成了进入保护模式前的准备工作。(当然还有A20地址线和芯片设置等内容,这些属于历史遗留的硬件文件,在这里不做过多讨论)
此时的内存结构如下图:
在这里插入图片描述
接下来进入正式的切换过程,代码就几行:

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(代码段寄存器)的值8,0 表示偏移地址。请注意,此时已经是保护模式了,
回顾下段选择子的模样。
在这里插入图片描述
8 用二进制表示就是:00000,0000,0000,1000
对照上面段选择子的结构,可以知道描述符索引值是 1,也就是要去全局描述符表gdt中找第一项段描述符。根据全局描述符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。所以这里取的第一项就是代码段,根据他的第二字0x0000,获取到他的基地址为0.再根据偏移地址为0.所以跳转的地址就为0地址处,即system的开始地址。
至此,setup.s模块完成

3.head.s

head.s在编译生成目标文件后会与内核其他程序一起链接到system模块,位于0地址处的便是head.s模块。

一.初始化寄存器的值

_pg_dir:
_startup_32:
    mov eax,0x10
    mov ds,ax
    mov es,ax
    mov fs,ax
    mov gs,ax
    lss esp,_stack_start

注意到开头有个标号 _pg_dir。先留个心眼,这个表示页目录,之后在设置分页机制时,页目录会存放在这里,也会覆盖这里的代码。

再往下连续五个 mov 操作,分别给 ds、es、fs、gs 这几个段寄存器赋值为 0x10(gdt中偏移地址为0x10的段,即数据段),根据段描述符结构解析,表示这几个段寄存器的值为指向全局描述符表中的第二个段描述符,也就是数据段描述符。

最后 lss 指令相当于让 ss:esp 这个栈顶指针指向了 _stack_start 这个标号的位置。可以理解为初始化帧栈(后续需要用到函数调用)
这个 stack_start 标号定义在了很久之后才会讲到的 sched.c 里:

long user_stack[4096 >> 2];

struct
{
  long *a;
  short b;
}
stack_start = {&user_stack[4096 >> 2], 0x10};

可以看到stack_start返回了两个值:一个是user_stack的最后一个元素的地址值,另一个是偏移地址0x10.因此这里esp指向了user_stack栈栈顶,ss=0x10.

二.重新设置了 idt 和 gdt

call setup_idt ;设置中断描述符表
call setup_gdt ;设置全局描述符表
mov eax,10h
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

先设置了 idt 和 gdt,然后又重新执行了一遍刚刚执行过的代码。
为什么要重新设置这些段寄存器呢?因为上面修改了 gdt,所以要重新设置一遍以刷新才能生效。

setup_idt:
    lea edx,ignore_int
    mov eax,00080000h
    mov ax,dx
    mov dx,8E00h
    lea edi,_idt
    mov ecx,256
rp_sidt:
    mov [edi],eax
    mov [edi+4],edx
    add edi,8
    dec ecx
    jne rp_sidt
    lidt fword ptr idt_descr
    ret

idt_descr:
    dw 256*8-1
    dd _idt

_idt:
    DQ 256 dup(0)

这里不用细看,只需要了解一下最终效果:这段程序的作用就是,设置了 256 个中断描述符,并且让每一个中断描述符中的中断程序例程都指向一个 ignore_int 的函数地址,这个是个默认的中断处理程序,之后会逐渐被各个具体的中断程序所覆盖。比如之后键盘模块会将自己的键盘中断处理程序,覆盖过去。
设置中断描述符表 setup_idt 说完了,那接下来 setup_gdt 就同理了。我们就直接看设置好后的新的全局描述符表长什么样吧?

_gdt:
    DQ 0000000000000000h    ;/* NULL descriptor */
    DQ 00c09a0000000fffh    ;/* 16Mb */
    DQ 00c0920000000fffh    ;/* 16Mb */
    DQ 0000000000000000h    ;/* TEMPORARY - don't use */
    DQ 252 dup(0)

其实和我们原先设置好的 gdt 一模一样。
也是有代码段描述符和数据段描述符,然后第四项系统段描述符并没有用到,不用管。最后还留了 252 项的空间,这些空间后面会用来放置任务状态段描述符 TSS 和局部描述符 LDT。
至此,内存空间如下图:
在这里插入图片描述

三.设置分页机制并跳转到main函数

jmp after_page_tables
...
after_page_tables:
    push 0
    push 0
    push 0
    push L6
    push _main
    jmp setup_paging
L6:
    jmp L6

这里的代码便是跳转到setup_paging进行分页设置,这里的五个push操作先在这里卖个关子,后面再解释。

分页机制介绍:
首先先回忆一下分段机制:
在这里插入图片描述
这是在没有开启分页机制的时候,只需要经过这一步转换即可得到最终的物理地址了,但是在开启了分页机制后,又会多一步转换。
在这里插入图片描述
也就是说,在没有开启分页机制时,由程序员给出的逻辑地址,需要先通过分段机制转换成物理地址。但在开启分页机制后,逻辑地址仍然要先通过分段机制进行转换,只不过转换后不再是最终的物理地址,而是线性地址,然后再通过一次分页机制转换,得到最终的物理地址。
分段机制我们已经清楚如何对地址进行变换了,那分页机制又是如何变换的呢?我们直接以一个例子来学习过程。
比如我们的线性地址(已经经过了分段机制的转换)是

15M

二进制就是

0000000011_0100000000_000000000000

我们看一下它的转换过程
在这里插入图片描述
也就是说,CPU 在看到我们给出的内存地址后,首先把线性地址被拆分成

高 10 位:中间 10 位:后 12 位

高 10 位负责在页目录表中找到一个页目录项,这个页目录项的值加上中间 10 位拼接后的地址去页表中去寻找一个页表项,这个页表项的值,再加上后 12 位偏移地址,就是最终的物理地址。
因此作为操作系统这个软件层,我们只需要提供好页目录表和页表即可,这种页表方案叫做二级页表,第一级叫页目录表 PDE,第二级叫页表 PTE。他们的结构如下。
在这里插入图片描述
之后再开启分页机制的开关。其实就是更改 cr0 寄存器中的一位即可(31 位),还记得我们开启保护模式么,也是改这个寄存器中的一位的值。
在这里插入图片描述
所以这段代码,就是帮我们把页表和页目录表在内存中写好,之后开启 cr0 寄存器的分页开关,仅此而已,我们再把代码贴上来。

setup_paging:
    mov ecx,1024*5
    xor eax,eax
    xor edi,edi
    pushf
    cld
    rep stosd
    mov eax,_pg_dir
    mov [eax],pg0+7
    mov [eax+4],pg1+7
    mov [eax+8],pg2+7
    mov [eax+12],pg3+7
    mov edi,pg3+4092
    mov eax,00fff007h
    std
L3: stosd
    sub eax,00001000h
    jge L3
    popf
    xor eax,eax
    mov cr3,eax
    mov eax,cr0
    or  eax,80000000h
    mov cr0,eax
    ret

这里也不做太多的解释,大致效果是:
当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF。

而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。
4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB

这段代码会将页目录表设置在head.s的开头处,即覆盖之前以前被运行掉的部分head.s,还记得head.s开始处的代码嘛:

_pg_dir:
_startup_32:

_pg_dir则表示设置的页目录表地址,也就是head.s的开始处地址,所以后续设置页目标表时会覆盖先前已经被运行的代码,达到节省内存的目的。
之后紧挨着这个页目录表,放置 4 个页表,代码里也有这四个页表的标签项。

.org 0x1000 pg0:
.org 0x2000 pg1:
.org 0x3000 pg2:
.org 0x4000 pg3:
.org 0x5000

最终将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制。此时内存中的页表相关的布局如下。
在这里插入图片描述
这些页目录表和页表放到了整个内存布局中最开头的位置,就是覆盖了开头的 system 代码了,不过被覆盖的 system 代码已经执行过了,所以无所谓。
同时,如 idt 和 gdt 一样,我们也需要通过一个寄存器(cr3)告诉 CPU 我们把这些页表放在了哪里,就是这段代码。

xor eax,eax
mov cr3,eax

这段代码相当于告诉 cr3 寄存器,0 地址处就是页目录表,再通过页目录表可以找到所有的页表,也就相当于 CPU 知道了分页机制的全貌了。
至此,整个内存格局如下:
在这里插入图片描述
那么具体页表设置好后,映射的内存是怎样的情况呢?那就要看页表的具体数据了

setup_paging:
    ...
    mov eax,_pg_dir
    mov [eax],pg0+7
    mov [eax+4],pg1+7
    mov [eax+8],pg2+7
    mov [eax+12],pg3+7
    mov edi,pg3+4092
    mov eax,00fff007h
    std
L3: stosd
    sub eax, 1000h
    jpe L3
    ...

很简单,对照刚刚的页目录表与页表结构看。
在这里插入图片描述
前五行表示,页目录表的前 4 个页目录项,分别指向 4 个页表。比如页目录项中的第一项 [eax] 被赋值为 pg0+7,也就是 0x00001007,根据页目录项的格式,表示页表地址为 0x1000,页属性为 0x07 表示该页存在、用户可读写。

后面几行表示,填充 4 个页表的每一项,一共 4*1024=4096 项,依次映射到内存的前 16MB 空间。

setup_paging最后会执行一个ret函数跳转返回
在这里插入图片描述
这里大家可能会比较疑惑,setup_paging并没有进行call调用,为什么会使用ret返回。事实上,ret 可以表示为pop IP,即跳转运行栈顶的函数,
在这里插入图片描述

还记得之前的after_page_tables段嘛:

after_page_tables:
    push 0
    push 0
    push 0
    push L6
    push _main
    jmp setup_paging

这里的ret便会返回到栈顶函数,即_main函数,因此代码会跳转到_main函数进行运行。前面的三次push 0也就可以解释了,即使main函数需要的三个传入参数。
至此,head.s模块结束,即整个的boot引导程序结束,程序跳转到_main执行。

三、Oslab的引导启动实验

一.实验要求

在这里插入图片描述

二.实验过程

首先对于bootsect.s,根据之前的源码分析

	mov	ah,#0x03		! read cursor pos
	xor	bh,bh			! 页号bh=0
	int	0x10
	
	mov	cx,#22
	mov	bx,#0x0007		! page 0, attribute 7 (normal) 页号BH=0 属性BL=7正常显示
	mov	bp,#msg1		! ES:BP要显示的字符串地址
	mov	ax,#0x1301		! write string, move cursor AH=13显示字符串 AL=01光标跟随移动
	int	0x10

msg1:
	.byte 13,10
	.ascii "Hello YangShine!"
	.byte 13,10,13,10

.org 510

只需要修改cx的值以及msg1中的信息即可。
此外,整体代码不需要Linux0.11那么复杂,只需要能读取setup.s并跳转执行即可

globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text

SETUPLEN = 2				! nr of setup-sectors
BOOTSEG  = 0x07c0			! original address of boot-sector
INITSEG  = 0x9000			! we move boot here - out of the way
SETUPSEG = 0x9020			! setup starts here
SYSSEG   = 0x1000			! system loaded at 0x10000 (65536).
ENDSEG   = SYSSEG + SYSSIZE		! where to stop loading

! ROOT_DEV:	0x000 - same type of floppy as boot.
!		0x301 - first partition on first drive etc
ROOT_DEV = 0x306

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
! put stack at 0x9ff00.
	mov	ss,ax
	mov	sp,#0xFF00		! arbitrary value >>512 (栈指针sp指向远大于512个字节偏移(即地址0x90200)处都可以)

! load the setup-sectors directly after the bootblock.
! Note that 'es' is already set up.

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

ok_load_setup:

! Print some inane message

	mov	ah,#0x03		! read cursor pos
	xor	bh,bh			! 页号bh=0
	int	0x10
	
	mov	cx,#22
	mov	bx,#0x0007		! page 0, attribute 7 (normal) 页号BH=0 属性BL=7正常显示
	mov	bp,#msg1		! ES:BP要显示的字符串地址
	mov	ax,#0x1301		! write string, move cursor AH=13显示字符串 AL=01光标跟随移动
	int	0x10

! ok, we've written the message, now

! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:

	
	jmpi	0,SETUPSEG

sectors:
	.word 0

msg1:
	.byte 13,10
	.ascii "Hello YangShine!"
	.byte 13,10,13,10

.org 510



boot_flag:
	.word 0xAA55

.text
endtext:
.data
enddata:
.bss
endbss:

只需要注意.org 510,这里可能很多人没有看懂指导书的.org 510到底是什么意思
事实上根据源码:

.org 508
root_dev:
	.word ROOT_DEV
boot_flag:
	.word 0xAA55

程序依然可以正常执行,只是这里的实验用不到ROOT_DEV段地址,所以root_dev可以去除。而当root_dev去除后,由于源码需要保证0xAA55需要固定在511和512字节,因此需要将变为.org 510表示后续地址将从地址510开始。

对于setup.s,由于实验要求不对system进行读取,所以只要进行简单的参数读写并进行打印即可。大致的过程都在之前的源码分析里讲过。下面放一个我的代码示例,这里只打印了memory的长度信息:

SETUPLEN = 2	

INITSEG  = 0x9000	! we move boot here - out of the way
SYSSEG   = 0x1000	! system loaded at 0x10000 (65536).
SETUPSEG = 0x9020	! this is the current segment

.globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text

entry start
start:

!using int10 to get the position of the cursor
    mov	ah,#0x03		! read cursor pos
	xor	bh,bh			! 页号bh=0
	int	0x10

!print 
!ah=0x13  al:the attribute of the cursor es:bp:the position of the strart of the cursor
!cx:the number of the string bh:page
!bl:string attribute dh dl:row line 
	mov	cx,#25
	mov	bx,#0x0007		! page 0, attribute 7 (normal) 页号BH=0 属性BL=7正常显示
	mov	bp,#msg2		! ES:BP要显示的字符串地址
    mov ax,#SETUPSEG
    mov es,ax
	mov	ax,#0x1301		! write string, move cursor AH=13显示字符串 AL=01光标跟随移动
	int	0x10

! ok, the read went well so we get current cursor position and save it for
! posterity.

	mov	ax,#INITSEG	! this is done in bootsect already, but...
	mov	ds,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 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



is_disk1:
    !set stack 
    mov	ss,ax
	mov	sp,#0xFF00	


Print_Memory:

	mov	ah,#0x03		! read cursor pos
	xor	bh,bh			! 页号bh=0
	int	0x10
	
	mov	cx,#12
	mov	bx,#0x0007		! page 0, attribute 7 (normal) 页号BH=0 属性BL=7正常显示
	mov	bp,#Memory		! ES:BP要显示的字符串地址
    mov ax,#SETUPSEG
    mov es,ax
	mov	ax,#0x1301		! write string, move cursor AH=13显示字符串 AL=01光标跟随移动
	int	0x10

	mov ax, #2			!set bp = 0x0002
	mov bp, ax
    mov ax,#SETUPSEG
    mov es,ax
    call print_hex
	
	!显示扩展内存最后的单位"KB"
	mov	ah,#0x03		! read cursor pos
	xor	bh,bh			! 页号bh=0
	int	0x10

	mov	cx,#2
	mov	bx,#0x0007		! page 0, attribute 7 (normal) 页号BH=0 属性BL=7正常显示
	mov	bp,#KB		! ES:BP要显示的字符串地址
	mov	ax,#0x1301		! write string, move cursor AH=13显示字符串 AL=01光标跟随移动
	int	0x10

	call print_nl


inf_loop:
    jmp inf_loop


!16进制方式打印栈顶的16位数
print_hex:
	mov cx, #4		!4个十六进制数字
	mov dx, (bp)	!(bp)所指的值放入dx中,如果bp是指向栈顶的话
print_digital:
	rol dx, #4
	mov ax, #0xe0f
	and al, dl
	add al, #0x30
	cmp al, #0x3a
	jl outp
	add al, #0x07
outp:
	int 0x10
	loop print_digital
	ret



!print 13,10
print_nl:
	mov ax, #0xe0d
	int 0x10
	mov al, #0xa
	int 0x10
	ret




msg2:
	.byte 13,10
	.ascii "Now we are in SETUP"
	.byte 13,10,13,10

Memory:
	.ascii "Memory SIZE:"	!0x90002 2bytes

KB:
	.ascii "KB"

	
.text
endtext:
.data
enddata:
.bss
endbss:


值得讲解的主要是print_hex和print_nl两个函数:
下面讲解一下:
首先print_nl表示打印13.10,这里调用了int 13,并设置ah为0xe,表示的功能是在光标处打印一个字符。al表示为要打印字符的ascii码
所以这里就很好理解了,两次执行int 13,一次的ax为0xe0d,一次为0xe0a,即表示打印ascii码为d和a即13和10的两个ascii码。
接下来是print_hex函数:首先会利用bp存取要打印的字符的值并存入dx中:

	mov ax, #2			!set bp = 0x0002
	mov bp, ax
    mov ax,#SETUPSEG
    mov es,ax
    call print_hex

例如在这里,我要读取内存长度的值:
在这里插入图片描述
根据存储信息,内存数保存在0x90002处,因此通过设置es:bp可以获取到放置在0x90002处的值,即扩展内存数。
接着设置cx=4,表示要打印4个16进制的数,即16位,将16位的值读取并以16进制的形式打印。以十六进制方式显示比较简单。这是因为十六进制与二进制有很好的对应关系(每 4 位二进制数和 1 位十六进制数存在一一对应关系),显示时只需将原二进制数每 4 位划成一组,按组求对应的 ASCII 码送显示器即可。ASCII 码与十六进制数字的对应关系为:0x30 ~ 0x39 对应数字 0 ~ 9,0x41 ~ 0x46 对应数字 a ~ f。从数字 9 到 a,其 ASCII 码间隔了 7h,这一点在转换时要特别注意。为使一个十六进制数能按高位到低位依次显示,实际编程中,需对 bx 中的数每次循环左移一组(4 位二进制),然后屏蔽掉当前高 12 位,对当前余下的 4 位(即 1 位十六进制数)求其 ASCII 码,要判断它是 0 ~ 9 还是 a ~ f,是前者则加 0x30 得对应的 ASCII 码,后者则要加 0x37 才行,最后送显示器输出。以上步骤重复 4 次,就可以完成 bx 中数以 4 位十六进制的形式显示出来。

其中用到的 BIOS 中断为 INT 0x10,功能号 0x0E(从光标处显示一个字符),即AH=0x0E,AL=要显示字符的 ASCII 码。
至此,bootsect.s和setup.s都已经编写完毕。
随后,由于辅助编译工具build.c中并没有考虑没有读取system的情况,因此需要将build.c文件进行修改,可以参考下面的方式,将圈起来的部分注释掉。
在这里插入图片描述
执行如下命令即可:

$ cd ~/oslab/linux-0.11
$ make BootImage
$ ../run

到这里本次的实验就已经完成了。
写完了才发现一个boot引导程序要写这么多,当时做的时候和看源码的时候都是一点一点摸的,没想到一下子要讲下来需要写这么多东西,后续可能会考虑把一篇文章分成几章来讲。

  • 3
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值