X86操作系统引导器:MBR、Loader与内核加载全解析

代码

代码引自《操作系统真象还原》第五章第三节:加载内核
下面四段代码共同构成了一个从零开始、功能完整的简易操作系统引导链。它们协同工作,将系统从加电状态一步步引导至内核运行。这是一个经典的 x86 保护模式启动流程。

                                                    ;-------------	 loader和kernel   ----------
LOADER_BASE_ADDR equ 0x900 
LOADER_START_SECTOR equ 0x2

LOADER_STACK_TOP equ LOADER_BASE_ADDR               ;这一条之前是在loader.S中定义,现在搬过来了

KERNEL_BIN_BASE_ADDR equ 0x70000                    ;定义内核在内存中的缓冲区,也就是将编译好的内核文件暂时存储在内存中的位置
KERNEL_START_SECTOR equ 0x9                         ;定义内核在磁盘的起始扇区
KERNEL_ENTRY_POINT equ 0xc0001500                   ;定义内核可执行代码的入口地址

PAGE_DIR_TABLE_POS equ 0x100000                     ;页目录表在内存中的起始位置——从1M开始的位置

                                                    ;--------------   模块化的gdt描述符字段宏-------------
DESC_G_4K   equ	  1_00000000000000000000000b        ;设置段界限的单位为4KB
DESC_D_32   equ	   1_0000000000000000000000b        ;设置代码段/数据段的有效地址(段内偏移地址)及操作数大小为32位,而非16位
DESC_L	    equ	    0_000000000000000000000b	    ;64位代码段标记位,我们现在是在编写32位操作系统,此处标记为0便可。
DESC_AVL    equ	     0_00000000000000000000b	    ;此标志位是为了给操作系统或其他软件设计的一个自定义位,
                                                    ;可以将这个位用于任何自定义的需求。
                                                    ;比如,操作系统可以用这个位来标记这个段是否正在被使用,或者用于其他特定的需求。
                                                    ;这取决于开发者如何使用这个位。但从硬件的角度来看,AVL位没有任何特定的功能或意义,它的使用完全由软件决定。
DESC_LIMIT_CODE2  equ 1111_0000000000000000b        ;定义代码段要用的段描述符高32位中16~19段界限为全1
DESC_LIMIT_DATA2  equ DESC_LIMIT_CODE2              ;定义数据段要用的段描述符高32位中16~19段界限为全1
DESC_LIMIT_VIDEO2  equ 0000_000000000000000b        ;定义我们要操作显存时对应的段描述符的高32位中16~19段界限为全0
DESC_P	    equ		  1_000000000000000b            ;定义了段描述符中的P标志位,表示该段描述符指向的段是否在内存中
DESC_DPL_0  equ		   00_0000000000000b            ;定义DPL为0的字段
DESC_DPL_1  equ		   01_0000000000000b            ;定义DPL为1的字段
DESC_DPL_2  equ		   10_0000000000000b            ;定义DPL为2的字段
DESC_DPL_3  equ		   11_0000000000000b            ;定义DPL为3的字段
DESC_S_CODE equ		     1_000000000000b            ;无论代码段,还是数据段,对于cpu来说都是非系统段,所以将S位置为1,见书p153图
DESC_S_DATA equ	  DESC_S_CODE                       ;无论代码段,还是数据段,对于cpu来说都是非系统段,所以将S位置为1,见书p153图
DESC_S_sys  equ		     0_000000000000b            ;将段描述符的S位置为0,表示系统段
DESC_TYPE_CODE  equ	      1000_00000000b	        ;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.  
DESC_TYPE_DATA  equ	      0010_00000000b	        ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.


                                                    ;定义代码段,数据段,显存段的高32位
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

                                                    ;--------------   模块化的选择子字段宏  ---------------
RPL0  equ   00b                                     ;定义选择字的RPL为0
RPL1  equ   01b                                     ;定义选择子的RPL为1
RPL2  equ   10b                                     ;定义选择字的RPL为2
RPL3  equ   11b                                     ;定义选择子的RPL为3
TI_GDT	 equ   000b                                 ;定义段选择子请求的段描述符是在GDT中
TI_LDT	 equ   100b                                 ;定义段选择子请求的段描述符是在LDT中

                                                    ;---------模块化的页目录表字段,PWT PCD A D G AVL 暂时不用设置   ----------
PG_P  equ   1b
PG_RW_R	 equ  00b 
PG_RW_W	 equ  10b 
PG_US_S	 equ  000b 
PG_US_U	 equ  100b  

                                                    ;-------------  程序段的 type 定义   --------------
PT_NULL equ 0

                                    ;主引导程序 
                                    ;------------------------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00         
    mov ax,cs      
    mov ds,ax
    mov es,ax
    mov ss,ax
    mov fs,ax
    mov sp,0x7c00
    mov ax,0xb800
    mov gs,ax

                                    ; 清屏
                                    ;利用0x06号功能,上卷全部行,则可清屏。
                                    ; -----------------------------------------------------------
                                    ;INT 0x10   功能号:0x06	   功能描述:上卷窗口
                                    ;------------------------------------------------------
                                    ;输入:
                                    ;AH 功能号= 0x06
                                    ;AL = 上卷的行数(如果为0,表示全部)
                                    ;BH = 上卷行属性
                                    ;(CL,CH) = 窗口左上角的(X,Y)位置
                                    ;(DL,DH) = 窗口右下角的(X,Y)位置
                                    ;无返回值:
    mov ax, 0600h
    mov bx, 0700h
    mov cx, 0                       ; 左上角: (0, 0)
    mov dx, 184fh		            ; 右下角: (80,25),
				                    ; 因为VGA文本模式中,一行只能容纳80个字符,共25行。
				                    ; 下标从0开始,所以0x18=24,0x4f=79
    int 10h                         ; int 10h

                                    ; 输出字符串:MBR
    mov byte [gs:0x00],'1'
    mov byte [gs:0x01],0xA4

    mov byte [gs:0x02],' '
    mov byte [gs:0x03],0xA4

    mov byte [gs:0x04],'M'
    mov byte [gs:0x05],0xA4	        ;A表示绿色背景闪烁,4表示前景色为红色

    mov byte [gs:0x06],'B'
    mov byte [gs:0x07],0xA4

    mov byte [gs:0x08],'R'
    mov byte [gs:0x09],0xA4
	 
    mov eax,LOADER_START_SECTOR	    ; 起始扇区lba地址
    mov bx,LOADER_BASE_ADDR         ; 写入的地址
    mov cx,4			            ; 待读入的扇区数
    call rd_disk_m_16		        ; 以下读取程序的起始部分(一个扇区)
  
    jmp LOADER_BASE_ADDR + 0x300
       
                                    ;-------------------------------------------------------------------------------
                                    ;功能:读取硬盘n个扇区
rd_disk_m_16:	   
                                    ;-------------------------------------------------------------------------------
				                    ; eax=LBA扇区号
				                    ; ebx=将数据写入的内存地址
				                    ; ecx=读入的扇区数
    mov esi,eax	                    ;备份eax
    mov di,cx		                ;备份cx
                                    ;读写硬盘:
                                    ;第1步:选择特定通道的寄存器,设置要读取的扇区数
    mov dx,0x1f2
    mov al,cl
    out dx,al                       ;读取的扇区数

    mov eax,esi	                    ;恢复ax

                                    ;第2步:在特定通道寄存器中放入要读取扇区的地址,将LBA地址存入0x1f3 ~ 0x1f6
                                    ;LBA地址7~0位写入端口0x1f3
    mov dx,0x1f3                       
    out dx,al                          

                                    ;LBA地址15~8位写入端口0x1f4
    mov cl,8
    shr eax,cl
    mov dx,0x1f4
    out dx,al

                                    ;LBA地址23~16位写入端口0x1f5
    shr eax,cl
    mov dx,0x1f5
    out dx,al

    shr eax,cl
    and al,0x0f	                    ;lba第24~27位
    or al,0xe0	                    ; 设置7~4位为1110,表示lba模式
    mov dx,0x1f6
    out dx,al

                                    ;第3步:向0x1f7端口写入读命令,0x20 
    mov dx,0x1f7
    mov al,0x20                        
    out dx,al

                                    ;第4步:检测硬盘状态
.not_ready:
                                    ;同一端口,写时表示写入命令字,读时表示读入硬盘状态
    nop
    in al,dx
    and al,0x88	                    ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
    cmp al,0x08
    jnz .not_ready	                ;若未准备好,继续等。

                                    ;第5步:从0x1f0端口读数据
    mov ax, di                      ;di当中存储的是要读取的扇区数
    mov dx, 256                     ;每个扇区512字节,一次读取两个字节,所以一个扇区就要读取256次,与扇区数相乘,就等得到总读取次数
    mul dx                          ;8位乘法与16位乘法知识查看书p133,注意:16位乘法会改变dx的值!!!!
    mov cx, ax	                    ; 得到了要读取的总次数,然后将这个数字放入cx中
    mov dx, 0x1f0
.go_on_read:
    in ax,dx
    mov [bx],ax
    add bx,2		  
    loop .go_on_read
    ret

    times 510-($-$$) db 0
    db 0x55,0xaa

%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR

                                                
GDT_BASE:                                               ;构建gdt及其内部的描述符
    dd 0x00000000 
	dd 0x00000000

CODE_DESC:  
    dd 0x0000FFFF 
	dd DESC_CODE_HIGH4

DATA_STACK_DESC:  
    dd 0x0000FFFF
    dd DESC_DATA_HIGH4

VIDEO_DESC: 
    dd 0x80000007	                                    ;limit=(0xbffff-0xb8000)/4k=0x7
    dd DESC_VIDEO_HIGH4                                 ; 此时dpl已改为0

    GDT_SIZE equ $ - GDT_BASE
    GDT_LIMIT equ GDT_SIZE - 1 
    times 60 dq 0					                    ; 此处预留60个描述符的空间
    SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0       ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
    SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0	    ; 同上
    SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0	    ; 同上 

total_mem_bytes dd 0				                    ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
                                                        ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
                                                        ; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址	 
                                                        
gdt_ptr dw GDT_LIMIT                                    ;定义加载进入GDTR的数据,前2字节是gdt界限,后4字节是gdt起始地址,
	    dd  GDT_BASE

ards_buf times 244 db 0                                 ;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_nr dw 0		                                    ;用于记录ards结构体数量


loader_start:
                                                        ;-------  int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局  -------

    xor ebx, ebx		                                ;第一次调用时,ebx值要为0
    mov edx, 0x534d4150	                                ;edx只赋值一次,循环体中不会改变
    mov di, ards_buf	                                ;ards结构缓冲区
.e820_mem_get_loop:	                                    ;循环获取每个ARDS内存范围描述结构
    mov eax, 0x0000e820	                                ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
    mov ecx, 20		                                    ;ARDS地址范围描述符结构大小是20字节
    int 0x15
    add di, cx		                                    ;使di增加20字节指向缓冲区中新的ARDS结构位置
    inc word [ards_nr]	                                ;记录ARDS数量
    cmp ebx, 0		                                    ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
    jnz .e820_mem_get_loop

                                                        ;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
    mov cx, [ards_nr]	                                ;遍历每一个ARDS结构体,循环次数是ARDS的数量
    mov ebx, ards_buf 
    xor edx, edx		                                ;edx为最大的内存容量,在此先清0
.find_max_mem_area:	                                    ;无须判断type是否为1,最大的内存块一定是可被使用
    mov eax, [ebx]	                                    ;base_add_low
    add eax, [ebx+8]	                                ;length_low
    add ebx, 20		                                    ;指向缓冲区中下一个ARDS结构
    cmp edx, eax		                                ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
    jge .next_ards
    mov edx, eax		                                ;edx为总内存大小
.next_ards:
    loop .find_max_mem_area

    mov [total_mem_bytes], edx	                        ;将内存换为byte单位后存入total_mem_bytes处。

                                                        ;-----------------   准备进入保护模式   ------------------------------------------
                                                        ;1 打开A20
                                                        ;2 加载gdt
                                                        ;3 将cr0的pe位置1

                                                        ;-----------------  打开A20  ----------------
    in al, 0x92
    or al, 0000_0010B
    out 0x92,al

                                                         ;-----------------  加载GDT  ----------------
    lgdt [gdt_ptr]


                                                        ;-----------------  cr0第0位置1  ----------------
    mov eax,cr0
    or eax,0x00000001
    mov cr0,eax

                                                        ;jmp dword SELECTOR_CODE:p_mode_start	    
    jmp SELECTOR_CODE:p_mode_start	                    ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
					                                    ; 这将导致之前做的预测失效,从而起到了刷新的作用。

.error_hlt:		                                        ;出错则挂起
    hlt

[bits 32]
p_mode_start:
    mov ax,SELECTOR_DATA
    mov ds,ax
    mov es,ax
    mov ss,ax
    mov esp,LOADER_STACK_TOP
    mov ax,SELECTOR_VIDEO
    mov gs,ax

                                                        ; -------------------------   加载kernel  ----------------------
    mov eax, KERNEL_START_SECTOR                        ; kernel.bin所在的扇区号
    mov ebx, KERNEL_BIN_BASE_ADDR                       ; 从磁盘读出后,写入到ebx指定的地址
    mov ecx, 200			                            ; 读入的扇区数
    call rd_disk_m_32
                                                        
    call setup_page                                     ;创建页目录表的函数,我们的页目录表必须放在1M开始的位置,所以必须在开启保护模式后运行

                                                        ;以下两句是将gdt描述符中视频段描述符中的段基址+0xc0000000
    mov ebx, [gdt_ptr + 2]                              ;ebx中存着GDT_BASE
    or dword [ebx + 0x18 + 4], 0xc0000000               ;视频段是第3个段描述符,每个描述符是8字节,故0x18 = 24,然后+4,是取出了视频段段描述符的高4字节。然后or操作,段基址最高位+c
                                           
    add dword [gdt_ptr + 2], 0xc0000000                 ;将gdt的基址加上0xc0000000使其成为内核所在的高地址

    add esp, 0xc0000000                                 ; 将栈指针同样映射到内核地址

    mov eax, PAGE_DIR_TABLE_POS                         ; 把页目录地址赋给cr3
    mov cr3, eax
                                                        
    mov eax, cr0                                        ; 打开cr0的pg位(第31位)
    or eax, 0x80000000  
    mov cr0, eax
                                                      
    lgdt [gdt_ptr]                                      ;在开启分页后,用gdt新的地址重新加载

enter_kernel:    
    call kernel_init
    mov esp, 0xc009f000
    jmp KERNEL_ENTRY_POINT                              ; 用地址0x1500访问测试,结果ok

                                                        ;-----------------   将kernel.bin中的segment拷贝到编译的地址   -----------
kernel_init:
    xor eax, eax                                        ;清空eax
    xor ebx, ebx		                                ;清空ebx, ebx记录程序头表地址
    xor ecx, ecx		                                ;清空ecx, cx记录程序头表中的program header数量
    xor edx, edx		                                ;清空edx, dx 记录program header尺寸

    mov dx, [KERNEL_BIN_BASE_ADDR + 42]	                ; 偏移文件42字节处的属性是e_phentsize,表示program header table中每个program header大小
    mov ebx, [KERNEL_BIN_BASE_ADDR + 28]                ; 偏移文件开始部分28字节的地方是e_phoff,表示program header table的偏移,ebx中是第1 个program header在文件中的偏移量
					                                    ; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
    add ebx, KERNEL_BIN_BASE_ADDR                       ; 现在ebx中存着第一个program header的内存地址
    mov cx, [KERNEL_BIN_BASE_ADDR + 44]                 ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment:
    cmp byte [ebx + 0], PT_NULL		                    ; 若p_type等于 PT_NULL,说明此program header未使用。
    je .PTNULL

                                                        ;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size)
    push dword [ebx + 16]		                        ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size
    mov eax, [ebx + 4]			                        ; 距程序头偏移量为4字节的位置是p_offset,该值是本program header 所表示的段相对于文件的偏移
    add eax, KERNEL_BIN_BASE_ADDR	                    ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
    push eax				                            ; 压入函数memcpy的第二个参数:源地址
    push dword [ebx + 8]			                    ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址
    call mem_cpy				                        ; 调用mem_cpy完成段复制
    add esp,12				                            ; 清理栈中压入的三个参数
.PTNULL:
   add ebx, edx				                            ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header 
   loop .each_segment
   ret

                                                        ;----------  逐字节拷贝 mem_cpy(dst,src,size) ------------
                                                        ;输入:栈中三个参数(dst,src,size)
                                                        ;输出:无
                                                        ;---------------------------------------------------------
mem_cpy:		      
    cld                                                 ;将FLAG的方向标志位DF清零,rep在执行循环时候si,di就会加1
    push ebp                                            ;这两句指令是在进行栈框架构建
    mov ebp, esp
    push ecx		                                    ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
    mov edi, [ebp + 8]	                                ; dst,edi与esi作为偏移,没有指定段寄存器的话,默认是ss寄存器进行配合
    mov esi, [ebp + 12]	                                ; src
    mov ecx, [ebp + 16]	                                ; size
    rep movsb		                                    ; 逐字节拷贝

                                                        ;恢复环境
    pop ecx		
    pop ebp
    ret



                                                       
setup_page:                                             ;------------------------------------------   创建页目录及页表  -------------------------------------
                                                        ;----------------以下6行是将1M开始的4KB置为0,将页目录表初始化
    mov ecx, 4096                                       ;创建4096个byte 0,循环4096次
    mov esi, 0                                          ;用esi来作为偏移量寻址
.clear_page_dir:
    mov byte [PAGE_DIR_TABLE_POS + esi], 0
    inc esi
    loop .clear_page_dir

                                                        ; ----------------初始化页目录表,让0号项与768号指向同一个页表,该页表管理从0开始4M的空间
.create_pde:				                            ;一个页目录表项可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,这是为将地址映射为内核地址做准备
    mov eax, PAGE_DIR_TABLE_POS                         ; eax中存着页目录表的位置
    add eax, 0x1000 			                        ; 在页目录表位置的基础上+4K(页目录表的大小),现在eax中第一个页表的起始位置
    mov ebx, eax				                        ; 此处为ebx赋值,现在ebx存着第一个页表的起始位置
    or eax, PG_US_U | PG_RW_W | PG_P	                ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
                                                        ; 现在eax中的值符合一个页目录项的要求了,高20位是一个指向第一个页表的4K整数倍地址,低12位是相关属性设置
    mov [PAGE_DIR_TABLE_POS + 0x0], eax                 ; 页目录表0号项写入第一个页表的位置(0x101000)及属性(7)
    mov [PAGE_DIR_TABLE_POS + 0xc00], eax               ; 页目录表768号项写入第一个页表的位置(0x101000)及属性(7)
					                                    
    sub eax, 0x1000                                     ;----------------- 使最后一个目录项指向页目录表自己的地址,为的是将来动态操作页表做准备
    mov [PAGE_DIR_TABLE_POS + 4092], eax	            ;属性包含PG_US_U是为了将来init进程(运行在用户空间)访问这个页目录表项
                                                        
    mov ecx, 256				                        ; -----------------初始化第一个页表,因为我们的操作系统不会超过1M,所以只用初始化256项
    mov esi, 0                                          ; esi来做寻址页表项的偏移量
    mov edx, PG_US_U | PG_RW_W | PG_P	                ; 属性为7,US=1,RW=1,P=1
.create_pte:				                            ; 创建Page Table Entry
    mov [ebx+esi*4],edx			                        ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址 
    add edx,4096                                        ; edx指向下一个4kb空间,且已经设定好了属性,故edx中是一个完整指向下一个4kb物理空间的页表表项
    inc esi                                             ; 寻址页表项的偏移量+1
    loop .create_pte                                    ;循环设定第一个页表的256项

                                                        ; -------------------初始化页目录表769号-1022号项,769号项指向第二个页表的地址(此页表紧挨着上面的第一个页表),770号指向第三个,以此类推
    mov eax, PAGE_DIR_TABLE_POS                         ; eax存页目录表的起始位置
    add eax, 0x2000 		                            ; 此时eax为第二个页表的位置
    or eax, PG_US_U | PG_RW_W | PG_P                    ; 设置页目录表项相关属性,US,RW和P位都为1,现在eax中的值是一个完整的指向第二个页表的页目录表项
    mov ebx, PAGE_DIR_TABLE_POS                         ; ebx现在存着页目录表的起始位置
    mov ecx, 254			                            ; 要设置254个表项
    mov esi, 769                                        ; 要设置的页目录表项的偏移起始
.create_kernel_pde:
    mov [ebx+esi*4], eax                                ; 设置页目录表项
    inc esi                                             ; 增加要设置的页目录表项的偏移
    add eax, 0x1000                                     ; eax指向下一个页表的位置,由于之前设定了属性,所以eax是一个完整的指向下一个页表的页目录表项
    loop .create_kernel_pde                             ; 循环设定254个页目录表项
    ret


                                                        

                                                        ;-------------------------------------------------------------------------------
                                                        ;功能:读取硬盘n个扇区
rd_disk_m_32:	   
                                                        ;-------------------------------------------------------------------------------
				                                        ; eax=LBA扇区号
				                                        ; ebx=将数据写入的内存地址
				                                        ; ecx=读入的扇区数
    mov esi,eax	                                        ;备份eax
    mov di,cx		                                    ;备份cx
                                                        ;读写硬盘:
                                                        ;第1步:选择特定通道的寄存器,设置要读取的扇区数
    mov dx,0x1f2
    mov al,cl
    out dx,al                                           ;读取的扇区数

    mov eax,esi	                                        ;恢复ax

                                                        ;第2步:在特定通道寄存器中放入要读取扇区的地址,将LBA地址存入0x1f3 ~ 0x1f6
                                                        ;LBA地址7~0位写入端口0x1f3
    mov dx,0x1f3                       
    out dx,al                          

                                                        ;LBA地址15~8位写入端口0x1f4
    mov cl,8
    shr eax,cl
    mov dx,0x1f4
    out dx,al

                                                        ;LBA地址23~16位写入端口0x1f5
    shr eax,cl
    mov dx,0x1f5
    out dx,al

    shr eax,cl
    and al,0x0f	                                        ;lba第24~27位
    or al,0xe0	                                        ; 设置7~4位为1110,表示lba模式
    mov dx,0x1f6
    out dx,al

                                                        ;第3步:向0x1f7端口写入读命令,0x20 
    mov dx,0x1f7
    mov al,0x20                        
    out dx,al

                                                        ;第4步:检测硬盘状态
.not_ready:
                                                        ;同一端口,写时表示写入命令字,读时表示读入硬盘状态
    nop
    in al,dx
    and al,0x88	                                        ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
    cmp al,0x08
    jnz .not_ready	                                    ;若未准备好,继续等。

                                                        ;第5步:从0x1f0端口读数据
    mov ax, di                                          ;di当中存储的是要读取的扇区数
    mov dx, 256                                         ;每个扇区512字节,一次读取两个字节,所以一个扇区就要读取256次,与扇区数相乘,就等得到总读取次数
    mul dx                                              ;8位乘法与16位乘法知识查看书p133,注意:16位乘法会改变dx的值!!!!
    mov cx, ax	                                        ; 得到了要读取的总次数,然后将这个数字放入cx中
    mov dx, 0x1f0
.go_on_read:
    in ax,dx
    mov [ebx],ax                                        ;与rd_disk_m_16相比,就是把这两句的bx改成了ebx
    add ebx,2		        
                                                        ; 由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。
                                                        ; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位,
                                                        ; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时,
                                                        ; 从硬盘上读出的数据会把0x0000~0xffff的覆盖,
                                                        ; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地址,
                                                        ; 故程序出会错,不知道会跑到哪里去。
                                                        ; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。
                                                        ; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式.
                                                        ; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16位,处于32位模式时,
                                                        ; 也会认为要执行的指令是32位.
                                                        ; 当我们在其中任意模式下用了另外模式的寻址方式或操作数大小(姑且认为16位模式用16位字节操作数,
                                                        ; 32位模式下用32字节的操作数)时,编译器会在指令前帮我们加上0x66或0x67,
                                                        ; 临时改变当前cpu模式到另外的模式下.
                                                        ; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位.
                                                        ; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位.
                                                        ; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址
                                                        ; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址.
    loop .go_on_read
    ret

int main(void){
    while(1);
    return 0;
}

提示:

流程解析

我们来梳理整个操作系统引导程序的完整执行顺序。这个过程从系统加电开始,由 BIOS 启动,最终将控制权交给内核。整个流程可以清晰地划分为几个阶段,每个阶段都承担着特定的任务。

第一阶段:MBR (主引导记录) 执行

这是启动流程的第一步,由 BIOS 负责加载和执行。

  1. BIOS 加载 MBR:
    系统加电后,BIOS 将硬盘的第一个扇区(512字节)加载到内存地址 0x7C00 处,并跳转到该地址开始执行。
  2. 初始化环境:
    设置段寄存器: 将 CS, DS, ES, SS, FS 都设置为 CS 的值,确保所有段寄存器指向同一个基址。
    设置栈指针: 将 SP 设置为 0x7C00,栈空间位于其下方。
    设置显存段寄存器: 将 GS 设置为 0xB800,以便后续向显存写入字符。
  3. 清屏与显示信息:
    调用 BIOS 中断 int 0x10,功能号 0x06,清空整个屏幕。
    直接向显存 gs:0x00 写入字符 ‘1’、’ '、‘M’、‘B’、‘R’ 及其属性,使屏幕左上角显示 “1 MBR” 字样,表明 MBR 正在运行。
  4. 读取加载器 (Loader):
    调用子程序 rd_disk_m_16。
    从硬盘第 3 个扇区(LBA 地址 0x2)开始,读取 4 个扇区的数据。
    将这些数据写入内存地址 0x900 处。
    跳转到加载器入口点:
    执行 jmp LOADER_BASE_ADDR + 0x300,即跳转到 0xC00 处,将控制权移交给加载器程序。

第二阶段:加载器 (Loader) 执行

加载器在实模式下开始执行,其核心任务是完成向保护模式的过渡,并为内核的加载做好准备。

  1. 获取系统内存布局:
    调用 BIOS 中断 int 0x15,子功能号 0xE820,获取系统的内存布局信息。
    将返回的 ARDS(Address Range Descriptor Structure)结构存储在 ards_buf 缓冲区中,并记录数量 ards_nr。
    遍历所有 ARDS 结构,计算每个内存区域的结束地址(基地址 + 长度),并找出其中最大的一个,将其值存入 total_mem_bytes 变量,作为系统的总内存容量。
  2. 准备进入保护模式:
    打开 A20 地址线: 通过向端口 0x92 写入特定值,启用 A20 地址线,以访问超过 1MB 的内存。
    加载 GDT: 将预先定义好的全局描述符表(GDT)加载到 GDTR 寄存器。
    切换到保护模式: 设置 CR0 寄存器的 PE 位,CPU 进入保护模式。
    刷新流水线: 执行一个长跳转指令 jmp SELECTOR_CODE:p_mode_start,确保 CPU 在保护模式下正确执行后续指令。
  3. 保护模式下的初始化:
    设置段寄存器: 将 DS, ES, SS 设置为数据段选择子 SELECTOR_DATA,将 GS 设置为显存段选择子 SELECTOR_VIDEO。
    设置栈指针: 将 ESP 设置为 LOADER_STACK_TOP(即 0x900)。
  4. 读取内核 (Kernel):
    调用子程序 rd_disk_m_32。
    从硬盘第 9 个扇区(LBA 地址 0x9)开始,读取 200 个扇区的数据。
    将这些数据写入内存地址 0x70000 处。
  5. 设置分页机制:
    调用子程序 setup_page,创建页目录表和页表。
    建立虚拟地址到物理地址的映射关系:
    将虚拟地址 0x00000000 到 0x003FFFFF 映射到物理地址 0x00000000。
    将虚拟地址 0xC0000000 到 0xC03FFFFF 也映射到物理地址 0x00000000。
    修改 GDT 中视频段描述符的基址,使其高地址部分为 0xC0000000,这样可以通过 GS 段寄存器访问 0xC00B8000 处的显存。
    将 GDT 的基址和栈指针 ESP 也映射到高地址空间。
    将页目录表的物理地址加载到 CR3 寄存器,并开启分页机制(设置 CR0 的 PG 位)。
    重新加载 GDT,使其位于高地址空间。
  6. 解析并拷贝内核:
    调用子程序 kernel_init。
    解析内核文件 kernel.bin 的 ELF 格式头,获取程序头表(Program Header Table)的位置和大小。
    遍历程序头表中的每一个段(Segment),根据其 p_vaddr(虚拟地址)、p_offset(文件偏移)和 p_filesz(文件大小),将内核的各个段从 0x70000 处拷贝到它们各自指定的虚拟地址处(如 0xC0001500)。
  7. 跳转到内核入口点:
    设置一个新的栈指针 ESP 为 0xC009F000。
    执行 jmp KERNEL_ENTRY_POINT,即跳转到 0xC0001500 处,将控制权移交给内核。

第三阶段:内核 (Kernel) 执行

内核在保护模式和分页机制下开始执行。

  1. 执行 main 函数:
    内核的 main 函数被调用。
    其内容非常简单:一个无限循环 while(1);。
    这表示操作系统已经成功启动,并进入了稳定状态,等待后续的中断或系统调用。

jmp KERNEL_ENTRY_POINT 重点详解

这条指令是整个引导加载程序(Loader)的终点,也是操作系统内核(Kernel)的起点。它的执行标志着控制权从引导程序正式移交给了操作系统。

  1. 指令的上下文
    在执行 jmp KERNEL_ENTRY_POINT 之前,加载器已经完成了所有准备工作:
    环境初始化:已进入保护模式,开启了分页机制,GDT 和页表都已正确设置。
    内存布局:内核文件 kernel.bin 已被读取到内存地址 0x70000 处。
    内核解析与拷贝:通过 kernel_init 子程序,内核的各个段(如 .text 代码段、.data 数据段等)已根据 ELF 文件头中的程序头表(Program Header Table),从 0x70000 处被精确地拷贝到了它们各自指定的虚拟地址上(例如,代码段可能被拷贝到 0xC0001000)。
    栈指针重置:栈指针 ESP 已被设置为 0xC009F000,这是一个位于高地址空间的安全栈,远离内核代码和数据区域。
    此时,系统已准备好运行一个功能完整的 32 位保护模式下的程序。
  2. KERNEL_ENTRY_POINT 的定义
    在 boot.inc 头文件中,KERNEL_ENTRY_POINT 被定义为:
KERNEL_ENTRY_POINT equ 0xc0001500

KERNEL_ENTRY_POINT equ 0xc0001500
这意味着,jmp KERNEL_ENTRY_POINT 等价于 jmp 0xC0001500。
这个地址 0xC0001500 是一个虚拟地址。它是由内核开发者在链接脚本(linker script)中指定的,通常是内核 main 函数的入口地址。当编译器编译 main.c 时,会将 main 函数的机器码放在这个地址。

  1. 执行过程详解
    当 CPU 执行到 jmp 0xC0001500 时,会发生以下一系列事件:
    第一步:计算目标物理地址
    CPU 不直接访问虚拟地址,它需要通过分页机制将其转换为物理地址。
    虚拟地址结构:0xC0001500 的二进制表示为 1100 0000 0000 0000 0001 0101 0000 0000。
    最高 10 位 (1100000000) 是页目录索引 (Page Directory Index),即 768。
    中间 10 位 (0000000001) 是页表索引 (Page Table Index),即 1。
    最低 12 位 (010100000000) 是页内偏移 (Page Offset)。
    查找页目录项 (PDE):
    CPU 使用 CR3 寄存器中存储的页目录表基址(在 setup_page 中设置为 0x100000)。
    它找到页目录表的第 768 项(因为虚拟地址最高 10 位是 768)。
    根据 setup_page 中的设置,第 768 项指向的是第一个页表(其物理地址为 0x101000)。
    查找页表项 (PTE):
    CPU 使用上一步得到的页表物理地址 0x101000。
    它找到该页表的第 1 项(因为虚拟地址中间 10 位是 1)。
    根据 setup_page 中的设置,第 1 项指向的物理页帧地址是 0x1000(因为第一个页表的第 0 项指向 0x00000000,第 1 项指向 0x00001000)。
    计算物理地址:
    物理地址 = 页帧地址 + 页内偏移 = 0x00001000 + 0x500 = 0x00001500。
    因此,虚拟地址 0xC0001500 被映射到物理地址 0x00001500。
    第二步:跳转到目标地址
    更新 EIP:CPU 将指令指针寄存器 EIP 设置为 0xC0001500。这意味着 CPU 下一条要执行的指令就是位于这个虚拟地址处的指令。
    刷新流水线:由于这是一个无条件跳转,CPU 会清空其指令预取队列和分支预测器中的内容,确保下一条指令是从新的地址开始获取的。
    第三步:执行内核代码
    main 函数入口:0xC0001500 处的指令正是内核 main 函数的起始指令。
int main(void){
    while(1);
    return 0;
}

编译后,这会生成一条无限循环的汇编指令。
无限循环:CPU 开始执行 while(1); 对应的机器码,进入一个永不退出的循环。这标志着操作系统已经成功启动,并进入了稳定状态。

  1. 关键点总结
    虚拟地址 vs 物理地址:jmp 指令操作的是虚拟地址。真正的执行发生在由分页机制转换后的物理地址上。
    控制权完全移交:一旦执行了 jmp,加载器的代码就彻底停止运行,CPU 完全按照内核的指令序列执行。
    环境准备至关重要:jmp 能够成功执行的前提是,加载器已经为内核搭建好了所有必要的运行环境,包括保护模式、分页、正确的内存布局和栈。
    入口地址的约定:0xC0001500 是一个约定俗成的地址,它由内核的链接脚本决定,确保了编译后的内核代码能够被正确地放置和执行。

总结

整个启动流程是一个环环相扣、层层递进的过程:
MBR 是“引路人”,负责初始化基本环境,加载并移交控制权给 加载器。
加载器 是“工程师”,负责完成从实模式到保护模式的复杂转换,设置内存管理机制(GDT 和分页),并最终加载和定位 内核。
内核 是“管理者”,在获得控制权后,开始执行其核心逻辑,标志着操作系统的正式运行。
这个流程展示了操作系统底层开发的核心思想:通过精心设计的引导程序,逐步建立起一个功能完备的运行环境,为上层应用提供服务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lcreek

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值