板子上的ROM BIOS在上电后会开始运行,将bootsect.s
由磁盘中拷贝到内存地址0x7C00处,接着跳转到0x7C00并执行。
bootsect.s
文件主要功能有以下几个:
首先将自身代码拷贝到内存地址0x90000处,接着跳转到0x90000继续执行;
利用0x13中断(磁盘操作中断)将磁盘第2个扇区开始的setup.s
代码(一共4个扇区)拷贝至内存地址0x90200处;
利用0x13中断(磁盘操作中断)读取磁盘参数表中当前启动引导盘的参数;
打印系统相关的字符串,如“Loading system...”;
利用0x13中断(磁盘操作中断)将磁盘第5个扇区开始的system
模块拷贝至内存地址0x10000处;
确定根文件系统的设备号;
跳转到setup.s
代码处并执行;
代码解析如下:
# 要加载的system模块的长度,当时认为system模块的长度不会超过0x3000,单位为节,1节=16字节
SYSSIZE = 0x3000
#定义了6个全局变量
.globl begtext, begdata, begbss, endtext, enddata, endbss
#定义代码段,数据段和未初始化数据段,这里都指向同一个地址,实际上意味着没有分段
.text
begtext:
.data
begdata:
.bss
begbss:
.text
# 要加载的setup模块占用扇区的数量
SETUPLEN = 4
# bootsect.s的原始段基址,由BIOS从磁盘中读到内存里
BOOTSEG = 0x07c0
# bootsect.s要搬移到的段基址
INITSEG = 0x9000
# setup模块要搬移到的段基址
SETUPSEG = 0x9020
# system模块要搬移到的段基址
SYSSEG = 0x1000
# system模块停止搬移的段基址
ENDSEG = SYSSEG + SYSSIZE
# 根文件系统所在硬盘的设备号
#########################
# 硬盘设备号具体值含义如下:
# 设备号 = 主设备号 * 256 + 次设备号
# (主设备号:1-内存;2-磁盘;3-硬盘;4-ttyx;5-tty;6-并行口;7-非命名管道)
#########################
ROOT_DEV = 0x306
在完成内存地址设置以后,bootsect.s
将要正式开始模块的搬移工作。首先要搬运的是自身代码:
# entry指令会指定程序的入口点,处理器从entry指定的地址处开始执行
entry _start
_start:
#将ds寄存器的值设置为0x07c0
mov ax,#BOOTSEG
mov ds,ax
#将ds寄存器的值设置为0x9000
mov ax,#INITSEG
mov es,ax
#设置重复次数为256,即拷贝256*2(512)字节的数据
mov cx,#256
#将si与di置为0,设置拷贝的源地址与目的地址
#拷贝的源地址:ds:si:0x7C00:0x0000
sub si,si
#拷贝的目的地址:ds:si:0x9000:0x0000
sub di,di
#重复movw直至cx寄存器的值变成0,由于之前cx被赋值256,因此需要重复256次
rep
movw
#跳转到 INITSEG:go 继续执行
jmpi go,INITSEG
在完成自身拷贝后通过jmpi
指令跳转到go处,开始设置段寄存器:
go: mov ax,cs
# 将ds,es和ss都设置成移动后代码所处的段内,即0x9000
mov ds,ax
mov es,ax
mov ss,ax
# 设置栈指针指向0x9000:0xFF00,即0x9FF00
#####################################
# 栈指针SP需要指向大于0x90200处(该处存放
# setup.s,长度约为4个扇区,因此sp要指向
# 0x200 + 0x200 * 4 + 堆栈大小处)
#####################################
mov sp,#0xFF00
设置完段寄存器之后开始进行setup.s
的搬运:
#########################################
# 利用int 0x13中断读取磁盘中的数据到内存中来
# int 0x13 输入参数如下:
# ah:0x02,int 0x13功能选择,读磁盘扇区到内存
# al:0x04,需要读出的扇区数量
# ch:0x00,磁道(柱面)号的低8位
# cl:0x02,开始扇区(bit0-bit5)、磁道(柱面)号的高2位(bit6-bit7)
# dh:0x00,磁头号
# dl:0x00,驱动器号
# es:bx:指向数据缓冲区
#########################################
load_setup:
mov dx,#0x0000
mov cx,#0x0002
mov bx,#0x0200
mov ax,#0x0200+SETUPLEN
int 0x13
jnc ok_load_setup
# 出错的处理流程,复位驱动器,重新拷贝
mov dx,#0x0000
mov ax,#0x0000
int 0x13
j load_setup
搬运完成后开始读取磁盘参数:
ok_load_setup:
#########################################
# 利用int 0x13中断读取磁盘的信息
# int 0x13 输入参数如下:
# ah:0x08,int 0x13功能选择,读磁盘驱动器的参数
# dl:0x00,驱动器号
# es:bx:指向数据缓冲区
#########################################
mov dl,#0x00
mov ax,#0x0800
int 0x13
mov ch,#0x00
# 该语句表示下一条语句的操作数在cs段寄存器所指的段中,前面提到本程序不分段,因此这条语句可以省略
seg cs
#
mov sectors,cx
mov ax,#INITSEG
mov es,ax
接下来是一些不太重要的代码,例如打印一些系统信息之类的,这里直接跳过,来看关键代码
......
mov ax,#SYSSEG
mov es,ax ! segment of 0x010000
call read_it
......
jmpi 0,SETUPSEG
......
read_it:
mov ax,es
test ax,#0x0fff
bootsect.s末尾会跳转到0x90200执行,这个地方存储的正是setup.s的代码,因此下面看看setup.s都做了什么。
首先是一个int,主要功能是读取光标位置并将数据存储到dx寄存器,高8位存储行号,低8位存储列号,接着将该数据存储到0x90000处。下面几行代码功能和这段类似,都是读取一些硬件信息并存到0x90000开始的内存处,具体读了哪些信息如图所示
entry start
start:
! 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.
接下来要继续搬运动作了,首先通过cli指令关闭中断,因为搬运工作会覆盖BIOS写好的IDT,所以要先禁止中断。接着把内存地址0x10000到内存地址0x90000处的内容全部搬运到起始地址为0的位置处。很明显,这个操作会覆盖内存地址0x0到内存地址0x80000区域内的所有内容,但是这并没有关系,因为setup.s的最后会跳到system模块执行,其他的代码不再重要了。
! now we want to move to protected mode ...
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
搬运之后的内存布局如下图所示,这张图很重要,在很长一段时间内都不会再发生变化。
ok,上面介绍的内容已经完成了内存中存放内容的调整,那接下来要进行点有技术含量的工作了,下面慢慢介绍。
setup.s后半部分的工作是完成CPU工作模式的转换,就是由实模式切换到保护模式。
实模式(Real Mode)
地址空间: 实模式使用20位的地址总线,可以访问1MB的内存(2^20 = 1MB)。
段:偏移地址: 使用段寄存器和偏移量组合的方式来计算内存地址,每个段的大小为64KB。
内存保护: 没有内存保护机制,程序可以直接访问系统的任何部分,包括操作系统和其他程序的内存,容易导致系统崩溃或数据损坏。
多任务处理: 不支持真正的多任务处理,因为所有程序都运行在同一地址空间内。
使用场景: 实模式主要用于早期的x86计算机(如8086/8088)以及在启动过程中的BIOS操作和DOS操作系统中。
保护模式(Protected Mode)
地址空间: 保护模式使用32位地址总线,可以访问4GB的内存(2^32 = 4GB)。在现代x86-64架构中,64位保护模式下可以访问更大的内存空间。
内存保护: 提供内存保护机制,通过分段(Segmentation)和分页(Paging)来管理内存,防止程序非法访问其他程序或操作系统的内存区域。
多任务处理: 支持真正的多任务处理,每个任务可以运行在独立的地址空间内,增强了系统的稳定性和安全性。
硬件支持: 支持硬件级别的虚拟内存管理,可以有效利用物理内存。
使用场景: 保护模式是现代操作系统(如Windows、Linux)运行的模式,允许操作系统更高效、安全地管理系统资源。
实模式主要用于早期系统和简单的操作环境,限制较多且不安全。保护模式则用于现代操作系统,提供了更强的内存保护和多任务处理能力,支持更大范围的内存寻址,是现代计算机的标准运行模式。直白一点说,实模式是古老的,落后的操作模式,但因为INTEL坚持向前的兼容性,即便是现在最新的CPU也要支持实模式,并且需要操作系统手动完成实模式向保护模式的转换。不过不用担心,说了这么多,但实际代码量却只有一行,下面来模式转换及之前的一些准备工作:
首先是给CPU中的IDTR寄存器和LDTR寄存器(这俩寄存器可以看附录)赋值,将中断描述符表和全局描述符表在内存中的地址写入这俩寄存器,让CPU能找到他们。
lidt idt_48 ! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate
接下来要打开A20地址线。
到底什么是A20地址线?为什么又要打开它呢?前面说过,我们在做模式的切换,从以前CPU的实模式切换到现代CPU的保护模式,这个A20地址线就是实模式留下的遗留问题。
在 8086 处理器中,CPU 的地址总线是 20 位的,这意味着它只能寻址 1MB 的内存空间(2^20 = 1MB)。任何超过 1MB 的内存地址会被截断回到 1MB 范围内。这就是所谓的“20位地址线”。随着 80286 处理器的出现,支持了超过 1MB 的内存访问。为了实现这一点,Intel 引入了 A20 地址线,即第21根地址线。该地址线负责处理超过 1MB 地址的访问。当A20地址线被打开时,处理器能够访问的内存范围扩展到21位地址线,可以寻址到2MB内存。
call empty_8042
mov al,#0xD1 ! command write
out #0x64,al
call empty_8042
mov al,#0xDF ! A20 on
out #0x60,al
call empty_8042
下面一大段代码完全不用管,主要功能就是对可编程中断控制器8259芯片的编程。
这个操作也是来自于工程的失误,整个世界就是一个巨大的草台班子。具体原因无需了解,我们只要知道编程之后8259A芯片的引脚和中断号的关系如图所示:
终于!!!经历了前面的写描述符表寄存器、打开地址线和编程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。lmsw
是专门用来给CR0寄存器赋值的命令。
所以,所谓的模式切换其实很简单,就是改一个寄存器的某一位而已。不过,改完这一位之后,CPU的操作逻辑就会完全不同,比如寻址方式等。紧接着下面的jmpi
语句就体现了这一点。jmpi 0,8
在模式切换前是直接跳转到0x80000地址处去执行,切换成保护模式后8
就成了段选择子,0
就成了偏移地址。8
的二进制表示是0000000000001000
,对比下段选择子的结构:
段描述符索引是1,也就是找全局描述符表里面索引为1的段。看下之前操作系统定义的全局描述符表可以发现,这个段就是代码段,并且代码段的段基址为0(参考附录中的笔记),再加上偏移地址也是0,因此在这条语句会指引CPU去内存地址0x0
处去执行。
那内存地址0x0
处是什么呢?看看前面的内存布局,这个地方存放的是system
这个模块。这个模块又是如何生成的呢?得看makefile
最前面两个是head.s和main.c,后面还有各个模块的代码,其实就是操作系统最核心的那些代码。也就是说,从这时候开始,操作系统就跑起来了。下面继续看最后一个汇编文件。
head.s
文件很小,我们直接来看代码。
首先将ds es fs gs
段寄存器赋值为0x10,看一下段选择子的接口可以发现是把他们指向了数据段。此外,注意下_pg_dir
标签,后面设置分页机制的时候页目录会覆盖掉这里原本的代码。
_pg_dir:
startup_32:
movl lss
x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp
在设置完段寄存器后,操作系统使用了ss:esp
命令,这个命令会把_stack_start
这个栈顶指针指向sched.c
标签的位置。这个标签在后面的long user_stack [ 4096>>2 ] ;
struct {
long * a;
short b;
} stack_start = { & user_stack [4096>>2] , 0x10 };
里面定义,我们看下代码
head.s
来捋一捋这个代码,在_stack_start
中把ss:esp
赋值给了_stack_start
,sched.c
又在SS栈段寄存器中被赋值,高16位的值为0x10,写入了user_stack
,低32位的值为 ESP寄存器这个数组最后一个元素后面的内存地址,赋给了user_stack
。那现在我们可以知道,段选择子为0x10,指向了数据段寄存器,偏移地址为 call setup_idt
call setup_gdt
movl
这个数组最后一个元素后面的内存地址,因此,栈顶地址也就指向了这个新的地址。后面压栈就是往这个地址压。setup_idt:
lea ignore_int,%edx
movl
x10,%eax # reload all the segment registers
mov %ax,%ds # after changing gdt. CS was already
mov %ax,%es # reloaded in 'setup_gdt'
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%espignore_int
x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw setup_gdt:
lgdt gdt_descr
ret
gdt_descr:
.word 256*8-1 # so does gdt (not that that's any
.long _gdt # magic number, but it works for me :^)
_gdt:
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
x8E00,%dx /* interrupt gate - dpl=0, present */
lea _idt,%edi
mov 6,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl ,%edi
dec %ecx
jne rp_sidt
lidt idt_descr
ret
idt_descr:
.word 256*8-1 # idt contains 256 entries
.long _idt
接下来就是重新设置中断描述符表和全局描述符表,并再次执行刚刚执行过的代码。
任务状态段描述符(TSS)
为什么要再次执行刚刚执行过的代码呢?原因很简单,因为我们重新设置了全局描述符表,所以所有涉及段基址的寄存器都要重新赋值。下面我们看看中断描述符具体怎么设置的:
局部描述符(LDT)
这段代码大意就是设置256个中断描述符,并将所有中断描述符的中断程序都指向一个叫setup.s
的默认函数,这个函数会在后面操作系统初始化的时候被各个具体的中断服务程序取代。
下面就是全局描述符表的设置,其实和中断描述符表类似,现在来看看怎么设置的:
setup.s
没想到吧?和之前设置的一样。第一个空的,第二个代码段,第三个数据段,第四个暂时空着,后面放head.s
和after_page_tables:
pushl
。setup_paging
# These are the parameters to main :-)
pushl 页目录表放在内存开头的位置
pushl 放置4个页表
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.
.align 2
setup_paging:
movl 24*5,%ecx /* 5 pages - pg_dir+4 page tables */
xorl %eax,%eax
xorl %edi,%edi /* pg_dir is at 0x000 */
cld;rep;stosl
movl $pg0+7,_pg_dir /* set present bit/user r/w */
movl $pg1+7,_pg_dir+4 /* --------- " " --------- */
movl $pg2+7,_pg_dir+8 /* --------- " " --------- */
movl $pg3+7,_pg_dir+12 /* --------- " " --------- */
movl $pg3+4092,%edi
movl 页目录表和页表填好对应的数值xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl CR3寄存器x1000,%eax
jge 1b
xorl %eax,%eax /* pg_dir is at 0x0000 */
movl %eax,%cr3 /* cr3 - page directory start */
movl %cr0,%eax
orl x80000000,%eax
movl %eax,%cr0 /* set paging (PG) bit */
ret /* this also flushes prefetch-queue */
那为什么和之前一样还要重新设置一遍呢?因为之前那次是在中设置的,这个模块在后续的工作中会被覆盖掉,所以需要在中重新定义一遍。
接着来到了最后一项工作:开启分页机制并跳转到main函数。在
重点在这个标号后面的代码里面。分页机制在附录中介绍了,这边不再赘述,
开启分页机制其实也很简单,还记得之前切换到保护模式的方法吗?开启分页机制同样是通过操作CR0寄存器实现的
在将CR0寄存器的31位置1之后,MMU就可以帮我们进行分页转换了,具体代码在上面。主要作用就是把,之后在紧挨着页目录表的位置,最终将。,操作完成后内存布局如下图,注意,页目录表和页表覆盖掉system模块的代码,但是这部分代码已经执行过了,所以不会影响操作系统正常运行。
接下来通过操作来告诉CPU页目录表的位置,操作完成后整体内存布局如下图所示:
紧接着操作系统的运行就要进入main.c了,下面总结一下之前我们干过的苦力活: