h参考文章:《操作系统真象还原》第五章 ---- 轻取物理内存容量 启用分页畅游虚拟空间 力斧直斩内核先劈一角 闲庭信步摸谈特权级
一、获取内存物理信息
必须先知道物理内存有多大、哪些可用、哪些是保留 / 空洞,才能做后续分页与内核放置
使用BIOS 0x15中断获取内存容量的探测方法(3种):
AH=0x88:最多识别 64MB,简单但不准。AX=0xE801:识别低 15MB 与 16MB~4GB,适合 32 位系统。EAX=0xE820(推荐 ):遍历完整内存布局,返回 "起始地址 + 长度 + 类型",能识别可用 / 保留 / ACPI / 内存空洞(15MB~16MB。
代码实现
在loader.s中增添如下代码(内存探测):
;----------------------------E820 内存探测代码----------------------------
.e820_mem_get_loop:
mov eax,0x0000E820 ;BIOS规定,eax存放int 0x15 的功能号,0xE820 就是 "获取系统内存布局" 功能号
mov ecx,0x14 ;告诉BIOS,缓冲区大小是20字节,写入ecx是固定搭配
mov edx,0x534d4150 ;0x534D4150 是 ASCII 字符串 SMAP 的十六进制值,BIOS 会检查这个签名是否正确
int 0x15 ;触发0x15中断,调用BIOS的E820功能,获取一个内存块的信息,写入 es:di 指向的缓冲区
jc .e820_failed_so_try_e801 ;如果 CF=1(调用失败),就跳转到 E801 探测方案
add di,cx ;把di的数值增加20,让di指向下一个缓冲区位置,为下一次调用准备
inc word [ards_nr] ;ards_nr 变量的值加 1,即记录"获取到的内存块数量"加1
cmp ebx,0 ;判断 BIOS 是否还有下一个内存块。BIOS 会在最后一个内存块返回时,把 ebx 设为 0
jne .e820_mem_get_loop ;如果ebx != 0(不相等,说明内存块还没读完),跳回循环开头继续读取
mov cx,[ards_nr] ;ards_nr 变量的值,即内存块的数量赋值给cx
mov ebx,ards_buf ;把 ards_buf 缓冲区的地址赋值给 ebx,让 ebx 指向第一个内存块
xor edx,edx ;edx 用来保存所有内存块中最大的地址,把它初始化设为0
;---------------------------------------"求最大内存地址"代码------------------
.find_max_mem_area:
mov eax,[ebx] ;ebx 指向第一个内存块,[ebx]取的是ARDS结构的基地址低32位,让eax也指向第一个内存块
add eax,[ebx+8] ;[ebx+8]得到当前内存块长度的低32位,add后就得到了当前内存块的结束地址
add ebx,20 ;让 ebx 指向下一个内存块
cmp edx,eax ;edx 保存的是当前已探测内存块中的最大结束地址,eax 是当前内存块的结束地址
jge .next_ards ;如果 edx ≥ eax,说明当前块的结束地址不是最大结束地址,说明还有内存快没读完,此时去出路下一个内存块
mov edx,eax ;如果 eax > edx,说明当前块的结束地址是新的最大值,把 eax 的值更新到 edx,让 edx 保存新的最大地址
.next_ards:
loop .find_max_mem_area ;loop 是汇编循环指令
;先把 cx 寄存器的值减 1;如果减完后 cx != 0,就跳转到 .find_max_mem_area 标签;
;如果 cx == 0,则不跳转,继续执行下一条指令
;前面 mov cx, [ards_nr] 已经把内存块数量存进了 cx
jmp .mem_get_ok ;前面的循环结束后,所有内存块都被探测完了
;---------------------------"E820探测失败,使用E801探测"代码---------------------------
.e820_failed_so_try_e801:
mov ax,0xe801 ;同之前的一样,吧功能号赋值给ax
int 0x15 ;
jc .e801_failed_so_try_88
;计算出来低15MB的内存
mov cx,0x400
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx,0x100000
mov esi,edx
;计算16MB以上的内存,以字节为单位
xor eax,eax
mov ax,bx
mov ecx,0x10000
mul ecx
mov edx,esi
add edx,eax
jmp .mem_get_ok
;---------------------------"E820、E801探测失败,使用0x88探测"代码---------------------------
.e801_failed_so_try_88:
mov ah,0x88 ;同样赋值功能号
int 0x15
jc .error_hlt
and eax,0x0000FFFF
mov cx,0x400 ;1024
mul cx
shl edx,16
or edx,eax
add edx,0x100000
;-----------------------------"所有内存探测方案失败,停止运行"代码-------------------------
.error_hlt:
jmp $
;------------------------------内存探测成功代码-------------------------------------------
.mem_get_ok:
mov [total_mem_bytes],edx ;内存探测成功,把最终得到的物理内存大小保存进edx,为后面的分页初始化准备
进入bochs进行仿真运行,输入以下命令:
;LOADER_BASE_ADDR 是 0x600
;前面的 GDT、预留空间、gdt_ptr 等数据占了 0x200 字节
;所以 total_mem_bytes 这个变量的物理地址就是:0x600+0x200=0x800
;xp 0x800:查看物理地址 0x800 处的内容
xp 0x800
结果如图所示:

0x20000000 是十六进制数,换算成十进制:0x20000000=2×167=536,870,912 字节=512 MB而我们在 Bochs 配置里设置的虚拟机内存就是 512MB。
二、分页
为什么要分页:
在操作系统进入保护模式后,实行的是通过「段选择子:段内偏移」的格式来得到地址,在这样的格式下,CPU 会根据段选择子去 GDT 中找到对应的段描述符,然后取出对应的段的基地址,再加上段内偏移,最终得到了线性地址。即:分段机制完成了从 逻辑地址 → 线性地址 的转换。
但是,分段机制存在着一些缺陷:
- 段的空间尺寸大,容易造成内存空间的浪费,必须占用连续的物理内存空间,物理内存碎片化后难以高效利用;
- 无法实现多进程独立地址空间隔离,没有成熟的内存访问控制体系。
因此,在操作系统保护模式的基础性上,引入分页机制。
分页核心概念:
页(Page):虚拟内存的最小管理单位 ,这里规定大小为 4KB。系统将整个 4GB 线性地址空间,按固定 4KB 大小切分为一个个虚拟页。
**页框(Page Frame):物理内存的最小分配单位,**大小同样为 4KB。物理内存条被切分为连续、等大的物理页框,用于存放虚拟页的数据。
分页核心本质:建立虚拟页与物理页框的映射关系。
二级分页架构
32 位线性地址总寻址范围:0~4GB,共 32 位二进制,x86 采用两级页表分层管理,将 32 位线性地址划分为三段:
高 10 位:页目录索引(PDI) +中间 10 位:页表索引(PTI) + 低 12 位:页内偏移 (Offset)
页目录(Page Directory): 10 位寻址范围:2的10次方,即1024个项,每个页目录项占 4 字节,用来存储某一张页表的物理基地址 + 内存属性标志位,整个操作系统只有一个页目录,管控整个4GB大小的虚拟空间。
页表(Page Table): 10 位寻址范围:2的10次方,即1024个项,每个页表录项占 4 字节,用来存储某一张页表的物理基地址 + 内存属性标志位,整个操作系统只有一个页目录,管控整个4GB大小的虚拟空间
通过唯一的页目录找到一个页表--->通过这个页表找到一个页表项--->通过页表项得到最终的物理地址
在引入例如分页后,操作系统内的完整寻址流程就如下所示:逻辑地址(对应着虚拟内存) → 分段机制 → 线性地址 → 分页机制 → 物理地址(对应实际存在的内存条)
地址转换详细流程:
(1)CPU 取出 32 位线性地址,拆分:高 10 位 PDI、中间 10 位 PTI、低 12 位页内偏移;
(2) 从寄存器 CR3 中读取页目录物理起始地址;
(3) 页目录基地址 + PDI×4,计算出目标页目录项内存地址;
(4) 读取页目录项,校验 P 位是否有效,取出其中页表物理基地址;
(5) 页表基地址 + PTI×4,计算出目标页表项内存地址;
(6) 读取页表项,取出物理页框基地址;
(7) 物理页框基地址 + 低 12 位页内偏移,拼接得到最终物理内存地址;
(8) CPU 访问该物理地址,完成内存读写。

代码实现
在boot.inc中增添如下代码:
;------------------ 开启页表所需要的宏定义---------------------------
PAGE_DIR_TABLE_POS equ 0x100000 ;定义页目录表的起始物理地址为 0x100000(1MB)
;------------------ 对页表的相关属性进行定义---------------------------------
PG_P equ 1b ;PG目录项第0位P位,当p位=1 表示存在于当前物理内存
PG_RW_R equ 00b ;第1位Read/Write 位,当该位=0表示该页只读
PG_RW_W equ 10b ; =1表示可写可读
PG_US_S equ 000b ;第2位user位,当该位=0表示超级用户
PG_US_U equ 100b ; =1普通用户
;-------------------加载内核所需要的宏定义 -------------------------------
KERNEL_BIN_SECTOR equ 0x9 ;定义内核二进制文件在硬盘上的起始扇区号为 0x9
;后面 rd_disk_m_32 函数会从这个扇区开始读取内核
KERNEL_BIN_BASE_ADDR equ 0x70000 ;定义内核二进制文件加载到内存的物理地址为 0x70000
;Loader会把内核文件读到这,后面 kernel_init 会解析这个地址上的 ELF 文件头
KERNEL_ENTER_ADDR equ 0xc0001500 ;定义内核的入口虚拟地址为 0xc0001500
;在开启分页后,loader程序会跳转到这个地址(虚拟地址),然后开始进入内核
PT_NULL equ 0x0 ;定义ELF程序段的 PT_NULL 类型为 0
;在 kernel_init 中,用该变量来判断当前段是否为空段,如果是,则跳过该段进行后续操作
在loader.s中增添如下代码(开启分页+在32位模式下读取硬盘):
;------------------------------- 加载内核到缓冲区 -------------------------------------------------
mov eax, KERNEL_BIN_SECTOR ;把 KERNEL_BIN_SECTOR(0x9)赋值给 eax,从第9个扇区开始读内核
mov ebx, KERNEL_BIN_BASE_ADDR ;从扇区中读到的内核数据放到以0x70000为起始地址的内存地址上
mov ecx,200 ;读取扇区的数量为200个
call rd_disk_m_32
;--------------------------------开始启动分页 ---------------------------------------------------
call setup_page ;对业目录表和页表进行初始化(这个函数的具体实现在后面)
;gdtr的格式:0-15位:GDT界限 16-47位:起始地址
sgdt [gdt_ptr] ;将gdt寄存器中的值,写入到gdt_ptr 变量中,保存当前 GDT 的信息
mov ebx,[gdt_ptr+2] ;[gdt_ptr+2]即GDT的起始地址,写入ebx
or dword [ebx+0x18+4],0xc0000000 ;把显存段的基地址从0xB8000 修改为 0xC00B8000
;使其映射到虚拟内存中,这样设置之后,即使开启分页也能访问显存
add dword [gdt_ptr+2],0xc0000000 ;gdt_ptr+2是GDT的起始地址,同样将它也进行映射,分页开启后能找到GDT
add esp,0xc0000000 ;栈指针同样进行映射
mov eax,PAGE_DIR_TABLE_POS
mov cr3,eax ;把页目录表的地址写入 cr3 寄存器,告诉 CPU 页目录表在哪里,这是开启分页的前提
mov eax,cr0
or eax,0x80000000 ;把 cr0 的最高位(PG 位)设为1,开启分页
mov cr0,eax
lgdt [gdt_ptr] ;加载映射后的新的GDT地址
mov eax,SELECTOR_VIDEO
mov gs,eax ;重新加载显存段
mov byte [gs:160],'V' ;验证分页是否开启成功,如果屏幕上显示了 V,说明显存映射正常,分页工作正常
jmp SELECTOR_CODE:enter_kernel ;跳转到内核区
;------------------------------ 跳转到内核区
enter_kernel:
call kernel_init ;初始化内核(具体实现在后面)解析内核ELF文件,把内核的各个段加载到正确的虚拟地址上,完成重定位,为执行内核做准备
mov esp,0xc009f000 ;内核设置新的栈指针
jmp KERNEL_ENTER_ADDR ;跳转到内核的入口
;------------------------------- 创建页表 ------------------------------------------------
setup_page:
mov ecx,0x1000
mov esi,0
.clear_page_dir_mem: ;对页目录表进行初始化,全部清空
mov byte [PAGE_DIR_TABLE_POS+esi],0
inc esi
loop .clear_page_dir_mem
.create_pde: ;创建一个页目录项
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;页表物理地址 = PAGE_DIR_TABLE_POS + 0x1000
or eax, PG_P | PG_RW_W | PG_US_U
mov [PAGE_DIR_TABLE_POS+0x0],eax
mov [PAGE_DIR_TABLE_POS+0xc00],eax
sub eax,0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax
mov eax,PAGE_DIR_TABLE_POS ;
add eax,0x1000
mov ecx,256
mov esi,0
mov ebx,PG_P | PG_RW_W | PG_US_U
.create_kernel_pte: ;填充低地址页表:物理地址 0x00000000 ~ 0x000FFFFF(0~1MB)的物理内存,映射到虚拟地址
mov [eax+esi*4],ebx
inc esi
add ebx,0x1000
loop .create_kernel_pte
;为内核的高地址空间页目录项设置初始的页表地址
mov eax,PAGE_DIR_TABLE_POS ;为内核高地址空间(0xC0400000 ~ 0xFFFFFFFF),初始化页目录项,让它们指向连续的页表
add eax,0x2000
or eax,PG_P | PG_RW_W | PG_US_U
mov ebx,PAGE_DIR_TABLE_POS
mov ecx,254
mov esi,769
.create_kernel_pde:
mov [ebx+esi*4],eax
inc esi
add eax,0x1000
loop .create_kernel_pde
ret
;----------------------- 初始化内核 把缓冲区的内核代码放到0x1500区域 ------------------------------------------
;这个地方主要对elf文件头部分用的很多
;可以参照着书上给的格式 来比较对比
kernel_init:
xor eax,eax ;寄存器全部清零
xor ebx,ebx
xor ecx,ecx
xor edx,edx
;解析 ELF 文件头,获取程序段表信息
mov ebx,[KERNEL_BIN_BASE_ADDR+28]
add ebx,KERNEL_BIN_BASE_ADDR ;ebx当前位置为程序段表
mov dx,[KERNEL_BIN_BASE_ADDR+42] ;获取程序段表每个条目描述符字节大小
mov cx,[KERNEL_BIN_BASE_ADDR+44] ;段的总数量
;遍历每个程序段
.get_each_segment:
cmp dword [ebx+0],PT_NULL
je .PTNULL
mov eax,[ebx+8]
cmp eax,0xc0001500
jb .PTNULL
push dword [ebx+16]
mov eax,[ebx+4]
add eax,KERNEL_BIN_BASE_ADDR
push eax
push dword [ebx+8]
call mem_cpy
add esp,12
;若当前段是空的,则跳过当前段,对下一个段进行处理
.PTNULL:
add ebx,edx
loop .get_each_segment
ret
;对内存的数据进行复制备份
mem_cpy:
cld ;清除方向标志位,确保复制数据时是从低位到高位进行复制的
push ebp ;建立栈帧、保存寄存器
mov ebp,esp
push ecx
mov edi,[ebp+8] ;[ebp+8]对应目标地址,存入edi
mov esi,[ebp+12] ;[ebp+12]对应源地址, 存入esi
mov ecx,[ebp+16] ;[ebp+16]对应"一次复制多大的数据" ,存入ecx
rep movsb ;开始进行复制,一个一个字节复制
pop ecx ;恢复栈帧、寄存器
pop ebp
ret
;------------------------ rd_disk_m_32 ----------------------
rd_disk_m_32:
;1 写入待操作磁盘数
;2 写入LBA 低24位寄存器 确认扇区
;3 device 寄存器 第4位主次盘 第6位LBA模式 改为1
;4 command 写指令
;5 读取status状态寄存器 判断是否完成工作
;6 完成工作 取出数据
;;;;;;;;;;;;;;;;;;;;;
;1 写入待操作磁盘数
;;;;;;;;;;;;;;;;;;;;;
mov esi,eax ; !!! 备份eax
mov di,cx ; !!! 备份cx
mov dx,0x1F2 ; 0x1F2为Sector Count 端口号 送到dx寄存器中
mov al,cl ; !!! 忘了只能由ax al传递数据
out dx,al ; !!! 这里修改了 原out dx,cl
mov eax,esi ; !!!袄无! 原来备份是这个用 前面需要ax来传递数据 麻了
;;;;;;;;;;;;;;;;;;;;;
;2 写入LBA 24位寄存器 确认扇区
;;;;;;;;;;;;;;;;;;;;;
mov cl,0x8 ; shr 右移8位 把24位给送到 LBA low mid high 寄存器中
mov dx,0x1F3 ; LBA low
out dx,al
mov dx,0x1F4 ; LBA mid
shr eax,cl ; eax为32位 ax为16位 eax的低位字节 右移8位即8~15
out dx,al
mov dx,0x1F5
shr eax,cl
out dx,al
;;;;;;;;;;;;;;;;;;;;;
;3 device 寄存器 第4位主次盘 第6位LBA模式 改为1
;;;;;;;;;;;;;;;;;;;;;
; 24 25 26 27位 尽管我们知道ax只有2 但还是需要按规矩办事
; 把除了最后四位的其他位置设置成0
shr eax,cl
and al,0x0f
or al,0xe0 ;!!! 把第四-七位设置成0111 转换为LBA模式
mov dx,0x1F6 ; 参照硬盘控制器端口表 Device
out dx,al
;;;;;;;;;;;;;;;;;;;;;
;4 向Command写操作 Status和Command一个寄存器
;;;;;;;;;;;;;;;;;;;;;
mov dx,0x1F7 ; Status寄存器端口号
mov ax,0x20 ; 0x20是读命令
out dx,al
;;;;;;;;;;;;;;;;;;;;;
;5 向Status查看是否准备好惹
;;;;;;;;;;;;;;;;;;;;;
;设置不断读取重复 如果不为1则一直循环
.not_ready:
nop ; !!! 空跳转指令 在循环中达到延时目的
in al,dx ; 把寄存器中的信息返还出来
and al,0x88 ; !!! 0100 0100 0x88
cmp al,0x08
jne .not_ready ; !!! jump not equal == 0
;;;;;;;;;;;;;;;;;;;;;
;6 读取数据
;;;;;;;;;;;;;;;;;;;;;
mov ax,di ;把 di 储存的cx 取出来
mov dx,256
mul dx ;与di 与 ax 做乘法 计算一共需要读多少次 方便作循环 低16位放ax 高16位放dx
mov cx,ax ;loop 与 cx相匹配 cx-- 当cx == 0即跳出循环
mov dx,0x1F0
.go_read_loop:
in ax,dx ;两字节dx 一次读两字
mov [ebx],ax
add ebx,2
loop .go_read_loop
ret ;与call 配对返回原来的位置 跳转到call下一条指令
三、最终代码
boot.inc:
;---------------------------------------进入loader所需的宏定义
LOADER_START_SECTOR equ 2 ;把 LOADER_START_SECTOR 这个符号直接替换成数值 2
;表明Loader 程序在磁盘上的起始扇区号是2扇区
;扇区从0开始计数,第0扇区是MBR,第1扇区是1,所以2就是第3个扇区
LOADER_BASE_ADDR equ 0x600 ;Loader 程序被加载到内存的起始地址是 0x600
;MBR 引导程序在调用 BIOS 中断读取磁盘时,会把 0x600 作为目标内存地址,把 Loader 读到这里,然后跳过去执行
;------------------------------------对gdt描述符属性进行定义(高32位)
;下划线没有语法作用,只是为了让人看清哪一位是哪一位!
DESC_G_4K equ 1_00000000000000000000000b ;第23位,G位表示4K或者1字节位,将此位设置为1,则表明此时粒度的大小则为4kB
DESC_D_32 equ 1_0000000000000000000000b ;第22位,D/B位,将此位设置为1,表明此时使用32位保护模式
DESC_L equ 0_000000000000000000000b ;第21位,L位,将此位设置为0,表明此时所写的代码都是32位的代码
DESC_AVL equ 0_00000000000000000000b ;第20位,软件可用位,暂时用不到它,设置为0
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;第16-19位,段界限的高四位 全部初始化为1 因为最大段界限*粒度必须等于
;0xffffffff,即4GB的大小 ,段界限的后16位是在低32位中的,到时由我们自行写入
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 ;相同的值 数据段与代码段段界限相同
DESC_LIMIT_VIDEO2 equ 0000_0000000000000000b ;这里将显存段的段界限高4位填0000,显存不需要4GB那么大,只需一小块
DESC_P equ 1_000000000000000b ;第15位,P位,设置为1,用于判断段是否存在于内存
;1 = 段存在,必须是 1,否则 CPU 抛异常
DESC_DPL_0 equ 00_0000000000000b ;第13-14位,权限位
DESC_DPL_1 equ 01_0000000000000b ;0为操作系统内核,权力最高 3为用户程序,权力最低
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b
DESC_S_sys equ 0_000000000000b ;第12位为0 则表示系统段,为1则表示数据段或代码段
DESC_S_CODE equ 1_000000000000b ;判断是否为系统段还是数据段
DESC_S_DATA equ DESC_S_CODE ;代码段、数据段都填 1
;第9-11位表示该段类型 1000 可执行 不允许可读 已访问位0
;当第12位为1时:若11位为1,则为代码段;若11位为0,则为数据段
DESC_TYPE_CODE equ 1000_00000000b ;1000,表示:代码段、可执行、不可读、未访问
DESC_TYPE_DATA equ 0010_00000000b ;0010,表示:数据段、可读写
;拼出 GDT 代码段描述符的 "高32位",将其特征属性放在一起,高32位全是属性位,用宏定义拼写较省力
;低32位根据需要,要写什么就写什么,格式为:段基址+段界限
;DESC_G_4K-->4KB粒度
;DESC_D_32-->32位保护模式
;DESC_L-->32位代码段
;DESC_LIMIT_CODE2-->段界限的最高4位1111,段最大,能访问 4GB
;DESC_P-->段在内存中
;DESC_DPL_0-->最高权限(内核)
;DESC_S_CODE-->代码/数据段
;DESC_TYPE_CODE-->若是代码段则是1000,即可执行
;0x00-->段基址是0x00,段界限
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
;拼出 GDT 数据段描述符的 "高32位",将其特征属性放在一起
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
;拼出 GDT 显存段描述符的 "高32位",将其特征属性放在一起
;显存基址是 0xB8000,高字节是 0x0B
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 ;4个访问段时的特权级,与前面段本身的特权级是两个概念,注意不要混淆
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b ;TI = 1 表示用 LDT(局部表),0 表示用 GDT(全局表)
TI_LDT equ 100b
;------------------ 开启页表所需要的宏定义---------------------------
PAGE_DIR_TABLE_POS equ 0x100000 ;定义页目录表的起始物理地址为 0x100000(1MB)
;------------------ 对页表的相关属性进行定义---------------------------------
PG_P equ 1b ;PG目录项第0位P位,当p位=1 表示存在于当前物理内存
PG_RW_R equ 00b ;第1位Read/Write 位,当该位=0表示该页只读
PG_RW_W equ 10b ; =1表示可写可读
PG_US_S equ 000b ;第2位user位,当该位=0表示超级用户
PG_US_U equ 100b ; =1普通用户
;-------------------加载内核所需要的宏定义 -------------------------------
KERNEL_BIN_SECTOR equ 0x9 ;定义内核二进制文件在硬盘上的起始扇区号为 0x9
;后面 rd_disk_m_32 函数会从这个扇区开始读取内核
KERNEL_BIN_BASE_ADDR equ 0x70000 ;定义内核二进制文件加载到内存的物理地址为 0x70000
;Loader会把内核文件读到这,后面 kernel_init 会解析这个地址上的 ELF 文件头
KERNEL_ENTER_ADDR equ 0xc0001500 ;定义内核的入口虚拟地址为 0xc0001500
;在开启分页后,loader程序会跳转到这个地址(虚拟地址),然后开始进入内核
PT_NULL equ 0x0 ;定义ELF程序段的 PT_NULL 类型为 0
;在 kernel_init 中,用该变量来判断当前段是否为空段,如果是,则跳过该段进行后续操作
loader.s:
%include "boot.inc"
SECTION loader vstart=LOADER_BASE_ADDR ;告诉汇编器:这段代码的运行地址是 0x600
LOADER_STACK_TOP equ LOADER_BASE_ADDR ;栈顶指针 = 0x600,实模式和保护模式都需要栈,这里同样设置为0x600
jmp loader_start ;下面存放数据段 构建gdt 跳跃到下面的代码区
;--------------------------------------构建全局描述附表
;db define byte,dw define word,dd define dword
;一个段描述符是8个字节
GDT_BASE : dd 0x00000000 ;Intel规定:GDT第0个段描述符不可用,必须是空描述符,必须全 0
dd 0x00000000
CODE_DESC : dd 0x0000FFFF ;代码段描述符,权限:内核级、可执行、可读
dd DESC_CODE_HIGH4 ;FFFF(4GB)是与其他的几部分相连接 形成0XFFFFF段界限,0x00000000是基地址 ;下面这个dd就是我们在boot.inc中组合出的,段描述符的高32位
DATA_STACK_DESC : dd 0x0000FFFF ;数据段描述符,权限:内核级、可读写
dd DESC_DATA_HIGH4 ;下面这个dd就是我们在boot.inc中组合出的,段描述符的高32位
VIDEO_DESC : dd 0x80000007 ;0xB8000 到0xBFFFF为文字模式显示内存 B只能在boot.inc中出现定义了 此处不够空间了 8000刚好够
dd DESC_VIDEO_HIGH4 ;0x0007 (bFFFF-b8000)/4k = 0x7
GDT_SIZE equ $ - GDT_BASE ;GDT 总大小:当前位置减去GDT_BASE的地址
GDT_LIMIT equ GDT_SIZE - 1 ;GDT 界限(必须是大小 - 1)
times 59 dq 0 ;为GDT预留59个 define double四字型 8字描述符
times 5 db 0 ;4个段描述符32字节+59个8字节,共472字节+填充5字节=512字节(0x200)
;让 total_mem_bytes 变量刚好落在 0x600+0x200=0x800 地址上
total_mem_bytes dd 0 ;该变量存储 E820/E801 探测到的总内存字节数
gdt_ptr dw GDT_LIMIT ;定义GDT,格式:低16位,存GDT界限,高32位,存GDT的起始地址
dd GDT_BASE
ards_buf times 244 db 0 ;定义244字节的缓冲区,初始值全为0,BIOS E820 中断会把内存布局信息(ARDS 结构)写到这里
ards_nr dw 0 ;该变量存储"E820中断探测到的内存块数量(ARDS结构的数量)
;段选择子格式如下:
;位15~3:索引 + 位2:TI + 位 1~0:RPL
SELECTOR_CODE equ (0X0001<<3) + TI_GDT + RPL0 ;01代码段描述符索引(左移3位是因为段选择子的格式规定,索引必须放在第 3~15 位;TI_GDT:是常量0,表示选择的是GDT全局描述符表;RPL0:是常量0,表示请求特权级为0内核级)
SELECTOR_DATA equ (0X0002<<3) + TI_GDT + RPL0 ;数据段
SELECTOR_VIDEO equ (0X0003<<3) + TI_GDT + RPL0 ;显存段
;----------------------------loader启动代码---------------------------
loader_start:
mov sp,LOADER_BASE_ADDR ;初始化栈指针,设置栈顶地址(0X600)
xor ebx,ebx ;初始化:把 ebx 寄存器清零
mov ax,0
mov es,ax ;初始化es段寄存器为 0
;后面mov di, ards_buf,而di是偏移地址,最终的物理地址是 es:di;把es设为 0,就能保证 es:di 指向的就是物理地址 di
mov di,ards_buf ;把ards_buf变量的地址,赋值给 di 寄存器,此时di指向了缓冲区
;----------------------------E820 内存探测代码----------------------------
.e820_mem_get_loop:
mov eax,0x0000E820 ;BIOS规定,eax存放int 0x15 的功能号,0xE820 就是 "获取系统内存布局" 功能号
mov ecx,0x14 ;告诉BIOS,缓冲区大小是20字节,写入ecx是固定搭配
mov edx,0x534d4150 ;0x534D4150 是 ASCII 字符串 SMAP 的十六进制值,BIOS 会检查这个签名是否正确
int 0x15 ;触发0x15中断,调用BIOS的E820功能,获取一个内存块的信息,写入 es:di 指向的缓冲区
jc .e820_failed_so_try_e801 ;如果 CF=1(调用失败),就跳转到 E801 探测方案
add di,cx ;把di的数值增加20,让di指向下一个缓冲区位置,为下一次调用准备
inc word [ards_nr] ;ards_nr 变量的值加 1,即记录"获取到的内存块数量"加1
cmp ebx,0 ;判断 BIOS 是否还有下一个内存块。BIOS 会在最后一个内存块返回时,把 ebx 设为 0
jne .e820_mem_get_loop ;如果ebx != 0(不相等,说明内存块还没读完),跳回循环开头继续读取
mov cx,[ards_nr] ;ards_nr 变量的值,即内存块的数量赋值给cx
mov ebx,ards_buf ;把 ards_buf 缓冲区的地址赋值给 ebx,让 ebx 指向第一个内存块
xor edx,edx ;edx 用来保存所有内存块中最大的地址,把它初始化设为0
;---------------------------------------"求最大内存地址"代码------------------
.find_max_mem_area:
mov eax,[ebx] ;ebx 指向第一个内存块,[ebx]取的是ARDS结构的基地址低32位,让eax也指向第一个内存块
add eax,[ebx+8] ;[ebx+8]得到当前内存块长度的低32位,add后就得到了当前内存块的结束地址
add ebx,20 ;让 ebx 指向下一个内存块
cmp edx,eax ;edx 保存的是当前已探测内存块中的最大结束地址,eax 是当前内存块的结束地址
jge .next_ards ;如果 edx ≥ eax,说明当前块的结束地址不是最大结束地址,说明还有内存快没读完,此时去出路下一个内存块
mov edx,eax ;如果 eax > edx,说明当前块的结束地址是新的最大值,把 eax 的值更新到 edx,让 edx 保存新的最大地址
.next_ards:
loop .find_max_mem_area ;loop 是汇编循环指令
;先把 cx 寄存器的值减 1;如果减完后 cx != 0,就跳转到 .find_max_mem_area 标签;
;如果 cx == 0,则不跳转,继续执行下一条指令
;前面 mov cx, [ards_nr] 已经把内存块数量存进了 cx
jmp .mem_get_ok ;前面的循环结束后,所有内存块都被探测完了
;---------------------------"E820探测失败,使用E801探测"代码---------------------------
.e820_failed_so_try_e801:
mov ax,0xe801 ;同之前的一样,吧功能号赋值给ax
int 0x15 ;
jc .e801_failed_so_try_88
;计算出来低15MB的内存
mov cx,0x400
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx,0x100000
mov esi,edx
;计算16MB以上的内存,以字节为单位
xor eax,eax
mov ax,bx
mov ecx,0x10000
mul ecx
mov edx,esi
add edx,eax
jmp .mem_get_ok
;---------------------------"E820、E801探测失败,使用0x88探测"代码---------------------------
.e801_failed_so_try_88:
mov ah,0x88 ;同样赋值功能号
int 0x15
jc .error_hlt
and eax,0x0000FFFF
mov cx,0x400 ;1024
mul cx
shl edx,16
or edx,eax
add edx,0x100000
;-----------------------------"所有内存探测方案失败,停止运行"代码-------------------------
.error_hlt:
jmp $
;------------------------------内存探测成功代码-------------------------------------------
.mem_get_ok:
mov [total_mem_bytes],edx ;内存探测成功,把最终得到的物理内存大小保存进edx,为后面的分页初始化准备
; --------------------------------- 设置进入保护模式 -----------------------------
; 1 打开A20 gate
; 2 加载gdt
; 3 将cr0 的 pe位置1
in al,0x92 ;端口号0x92 中 第1位变成1即可
or al,0000_0010b
out 0x92,al
lgdt [gdt_ptr]
mov eax,cr0 ;cr0寄存器第0位设置位1
or eax,0x00000001
mov cr0,eax
;-------------------------------- 已经打开保护模式 ---------------------------------------
jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
[bits 32]
p_mode_start:
mov ax,SELECTOR_DATA
mov ds,ax
mov es,ax
mov ss,ax
mov esp,LOADER_STACK_TOP
;------------------------------- 加载内核到缓冲区 -------------------------------------------------
mov eax, KERNEL_BIN_SECTOR ;把 KERNEL_BIN_SECTOR(0x9)赋值给 eax,从第9个扇区开始读内核
mov ebx, KERNEL_BIN_BASE_ADDR ;从扇区中读到的内核数据放到以0x70000为起始地址的内存地址上
mov ecx,200 ;读取扇区的数量为200个
call rd_disk_m_32
;--------------------------------开始启动分页 ---------------------------------------------------
call setup_page ;对业目录表和页表进行初始化(这个函数的具体实现在后面)
;gdtr的格式:0-15位:GDT界限 16-47位:起始地址
sgdt [gdt_ptr] ;将gdt寄存器中的值,写入到gdt_ptr 变量中,保存当前 GDT 的信息
mov ebx,[gdt_ptr+2] ;[gdt_ptr+2]即GDT的起始地址,写入ebx
or dword [ebx+0x18+4],0xc0000000 ;把显存段的基地址从0xB8000 修改为 0xC00B8000
;使其映射到虚拟内存中,这样设置之后,即使开启分页也能访问显存
add dword [gdt_ptr+2],0xc0000000 ;gdt_ptr+2是GDT的起始地址,同样将它也进行映射,分页开启后能找到GDT
add esp,0xc0000000 ;栈指针同样进行映射
mov eax,PAGE_DIR_TABLE_POS
mov cr3,eax ;把页目录表的地址写入 cr3 寄存器,告诉 CPU 页目录表在哪里,这是开启分页的前提
mov eax,cr0
or eax,0x80000000 ;把 cr0 的最高位(PG 位)设为1,开启分页
mov cr0,eax
lgdt [gdt_ptr] ;加载映射后的新的GDT地址
mov eax,SELECTOR_VIDEO
mov gs,eax ;重新加载显存段
mov byte [gs:160],'V' ;验证分页是否开启成功,如果屏幕上显示了 V,说明显存映射正常,分页工作正常
jmp SELECTOR_CODE:enter_kernel ;跳转到内核区
;------------------------------ 跳转到内核区
enter_kernel:
call kernel_init ;初始化内核(具体实现在后面)解析内核ELF文件,把内核的各个段加载到正确的虚拟地址上,完成重定位,为执行内核做准备
mov esp,0xc009f000 ;内核设置新的栈指针
jmp KERNEL_ENTER_ADDR ;跳转到内核的入口
;------------------------------- 创建页表 ------------------------------------------------
setup_page:
mov ecx,0x1000
mov esi,0
.clear_page_dir_mem: ;对页目录表进行初始化,全部清空
mov byte [PAGE_DIR_TABLE_POS+esi],0
inc esi
loop .clear_page_dir_mem
.create_pde: ;创建一个页目录项
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;页表物理地址 = PAGE_DIR_TABLE_POS + 0x1000
or eax, PG_P | PG_RW_W | PG_US_U
mov [PAGE_DIR_TABLE_POS+0x0],eax
mov [PAGE_DIR_TABLE_POS+0xc00],eax
sub eax,0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax
mov eax,PAGE_DIR_TABLE_POS ;
add eax,0x1000
mov ecx,256
mov esi,0
mov ebx,PG_P | PG_RW_W | PG_US_U
.create_kernel_pte: ;填充低地址页表:物理地址 0x00000000 ~ 0x000FFFFF(0~1MB)的物理内存,映射到虚拟地址
mov [eax+esi*4],ebx
inc esi
add ebx,0x1000
loop .create_kernel_pte
;为内核的高地址空间页目录项设置初始的页表地址
mov eax,PAGE_DIR_TABLE_POS ;为内核高地址空间(0xC0400000 ~ 0xFFFFFFFF),初始化页目录项,让它们指向连续的页表
add eax,0x2000
or eax,PG_P | PG_RW_W | PG_US_U
mov ebx,PAGE_DIR_TABLE_POS
mov ecx,254
mov esi,769
.create_kernel_pde:
mov [ebx+esi*4],eax
inc esi
add eax,0x1000
loop .create_kernel_pde
ret
;----------------------- 初始化内核 把缓冲区的内核代码放到0x1500区域 ------------------------------------------
;这个地方主要对elf文件头部分用的很多
;可以参照着书上给的格式 来比较对比
kernel_init:
xor eax,eax ;寄存器全部清零
xor ebx,ebx
xor ecx,ecx
xor edx,edx
;解析 ELF 文件头,获取程序段表信息
mov ebx,[KERNEL_BIN_BASE_ADDR+28]
add ebx,KERNEL_BIN_BASE_ADDR ;ebx当前位置为程序段表
mov dx,[KERNEL_BIN_BASE_ADDR+42] ;获取程序段表每个条目描述符字节大小
mov cx,[KERNEL_BIN_BASE_ADDR+44] ;段的总数量
;遍历每个程序段
.get_each_segment:
cmp dword [ebx+0],PT_NULL
je .PTNULL
mov eax,[ebx+8]
cmp eax,0xc0001500
jb .PTNULL
push dword [ebx+16]
mov eax,[ebx+4]
add eax,KERNEL_BIN_BASE_ADDR
push eax
push dword [ebx+8]
call mem_cpy
add esp,12
;若当前段是空的,则跳过当前段,对下一个段进行处理
.PTNULL:
add ebx,edx
loop .get_each_segment
ret
;对内存的数据进行复制备份
mem_cpy:
cld ;清除方向标志位,确保复制数据时是从低位到高位进行复制的
push ebp ;建立栈帧、保存寄存器
mov ebp,esp
push ecx
mov edi,[ebp+8] ;[ebp+8]对应目标地址,存入edi
mov esi,[ebp+12] ;[ebp+12]对应源地址, 存入esi
mov ecx,[ebp+16] ;[ebp+16]对应"一次复制多大的数据" ,存入ecx
rep movsb ;开始进行复制,一个一个字节复制
pop ecx ;恢复栈帧、寄存器
pop ebp
ret
;------------------------ rd_disk_m_32 ----------------------
rd_disk_m_32:
;1 写入待操作磁盘数
;2 写入LBA 低24位寄存器 确认扇区
;3 device 寄存器 第4位主次盘 第6位LBA模式 改为1
;4 command 写指令
;5 读取status状态寄存器 判断是否完成工作
;6 完成工作 取出数据
;;;;;;;;;;;;;;;;;;;;;
;1 写入待操作磁盘数
;;;;;;;;;;;;;;;;;;;;;
mov esi,eax ; !!! 备份eax
mov di,cx ; !!! 备份cx
mov dx,0x1F2 ; 0x1F2为Sector Count 端口号 送到dx寄存器中
mov al,cl ; !!! 忘了只能由ax al传递数据
out dx,al ; !!! 这里修改了 原out dx,cl
mov eax,esi ; !!!袄无! 原来备份是这个用 前面需要ax来传递数据 麻了
;;;;;;;;;;;;;;;;;;;;;
;2 写入LBA 24位寄存器 确认扇区
;;;;;;;;;;;;;;;;;;;;;
mov cl,0x8 ; shr 右移8位 把24位给送到 LBA low mid high 寄存器中
mov dx,0x1F3 ; LBA low
out dx,al
mov dx,0x1F4 ; LBA mid
shr eax,cl ; eax为32位 ax为16位 eax的低位字节 右移8位即8~15
out dx,al
mov dx,0x1F5
shr eax,cl
out dx,al
;;;;;;;;;;;;;;;;;;;;;
;3 device 寄存器 第4位主次盘 第6位LBA模式 改为1
;;;;;;;;;;;;;;;;;;;;;
; 24 25 26 27位 尽管我们知道ax只有2 但还是需要按规矩办事
; 把除了最后四位的其他位置设置成0
shr eax,cl
and al,0x0f
or al,0xe0 ;!!! 把第四-七位设置成0111 转换为LBA模式
mov dx,0x1F6 ; 参照硬盘控制器端口表 Device
out dx,al
;;;;;;;;;;;;;;;;;;;;;
;4 向Command写操作 Status和Command一个寄存器
;;;;;;;;;;;;;;;;;;;;;
mov dx,0x1F7 ; Status寄存器端口号
mov ax,0x20 ; 0x20是读命令
out dx,al
;;;;;;;;;;;;;;;;;;;;;
;5 向Status查看是否准备好惹
;;;;;;;;;;;;;;;;;;;;;
;设置不断读取重复 如果不为1则一直循环
.not_ready:
nop ; !!! 空跳转指令 在循环中达到延时目的
in al,dx ; 把寄存器中的信息返还出来
and al,0x88 ; !!! 0100 0100 0x88
cmp al,0x08
jne .not_ready ; !!! jump not equal == 0
;;;;;;;;;;;;;;;;;;;;;
;6 读取数据
;;;;;;;;;;;;;;;;;;;;;
mov ax,di ;把 di 储存的cx 取出来
mov dx,256
mul dx ;与di 与 ax 做乘法 计算一共需要读多少次 方便作循环 低16位放ax 高16位放dx
mov cx,ax ;loop 与 cx相匹配 cx-- 当cx == 0即跳出循环
mov dx,0x1F0
.go_read_loop:
in ax,dx ;两字节dx 一次读两字
mov [ebx],ax
add ebx,2
loop .go_read_loop
ret ;与call 配对返回原来的位置 跳转到call下一条指令
四、仿真运行验证
同样,进入bochs仿真
写入loader.s:
nasm -I /home/xpy/bochs/include/ -o loader.bin loader.s
dd if=/home/xpy/bochs/boot/loader.bin of=/home/xpy/bochs/hd60M.img bs=512 count=3 seek=2 conv=notrunc
bin/bochs -f bochsrc.disk
输入c,结果如下图所示,字母V被打印了,说明分页成功开启:

输入 ctrl+c,中断仿真,再输入info gdt 查看全局描述符表,其正确显示了:

输入 info tab,查看当前的页表、段描述符表、内存映射关系:

映射关系正确显示
在bochs下新建文件夹kernel,在kernel文件夹内新建内核文件main.c:
int main()
{
while(1);
return 0;
}
然后再kernel文件夹下对内核文件进行编译与写入:
;把 C 语言源码 编译成 32 位目标文件,还没链接
gcc -m32 -c -o /home/xpy/bochs/kernel/main.o /home/xpy/bochs/kernel/main.c
;链接 生成内核镜像 kernel.bin
;-m elf_i386:生成 32 位 ELF 格式(内核必须是这个格式)
;-Ttext 0xc0001500:指定代码段起始地址 = 0xc0001500
;这是内核的虚拟地址,和你汇编代码里的 KERNEL_ENTER_ADDR 完全一致
;-e main:指定入口符号是 main
ld -m elf_i386 /home/xpy/bochs/kernel/main.o -Ttext 0xc0001500 -e main -o /home/xpy/bochs/kernel/kernel.bin
;把内核写入虚拟机硬盘
dd if=/home/xpy/bochs/kernel/kernel.bin of=/home/xpy/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc
在kernel文件夹下打开终端输入命令:xxd kernel.bin 查看内核二进制文件的信息:

在bochs仿真软件内输入命令:xp /100 0x70000 ,查看内存0x70000处是否存储的是内核二进制文件信息:

发现全为0,说明没有正确写入,先排查虚拟硬盘问题:在bochs文件夹下输入以下命令:
检查 hd60M.img 第 9 扇区是否有内核
# 从 hd60M.img 读取第9扇区开始的200个扇区,查看开头是否是ELF魔数 0x7f454c46
dd if=hd60M.img bs=512 skip=9 count=1 | xxd

开头显示 7f 45 4c 46,说明写入成功,再次进行仿真,进入boch软件,输入以下命令:
b 0x7000 ;运行到该位置
c ;集训运行,此时才开始加载loader程序
xp 0x70000
结果如下所示:

开头显示 7f 45 4c 46,说明成功写入(一些与原作者细微的差距是因为编译器的不同导致的,只要开头出现了 7f 45 4c 46,就说明内核成功写入了)
然后在kernel文件夹下进入终端,输入以下命令:
;查看 ELF 文件详细信息
;-e = 显示全部信息(全部头)
readelf -e kernel.bin
结果如下所示:

与上面在bochs中查看的相一致,成功。