1.获取物理内存容量
操作系统需要管理硬件,自然就需要知道自己有哪些硬件可用以及这些硬件的信息。内存容量是一个非常重要的信息,操作系统获得内存容量的方法通常在实模式工作时就调用BIOS中断来获取,然后存储在内存中,进入保护模式后再使用这些信息(因为保护模式不能使用BIOS中断)。BIOS 中断 0x15 的子功能 0xE820 能够获取系统的内存布局,由于系统内存各部分的类型属性不同,BIOS 就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次 BIOS 只返回一种类型的内存信息,直到将所有内存类型返回完毕。
地址范围描述符:内存信息的内容是用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符(Address Range Descriptor Structure,ARDS)
此结构中的字段大小都是 4 字节,共 5 个字段,所以此结构大小为 20 字节。每次 int 0x15 之后,BIOS 就返回这样一个结构的数据。注意,ARDS 结构中用 64 位宽度的属性来描述这段内存基地址(起始地址)及其长度,所以表中的基地址和长度都分为低 32 位和高 32 位两部分。(因为我们所实现的操作系统是32位,故基地址只用取低32位)。
代码实现:在第四章的loader.s文件中添加一下代码
total_mem_bytes dd 0 ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
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处。
total_mem_bytes是一个4 字节大小的变量,此变量用于存储获取到的内存容量,以字节为单位。并且total_mem_bytes加载到内存时的地址为0xb00。ards_buf为ARDS结构的缓冲区,用于存放每执行一次BIOS中断所返回的ARDS结构,便于以后进行对所有的内存段进行遍历。ards_nr用于存放ARDS结构的数量。
修改mbr.s文件
jmp LOADER_BASE_ADDR+0x300
使得mbr跳转到loader时直接跳转到loader的代码段起始地址。经过nasm命令编译,dd命令写入磁盘后,启动bochs虚拟机,通过x /30xw 0xb0a 就可以获取到6个ARDS结构体数据,如下图所示:
2.分页机制
在内存分段机制下,如果系统的应用进程过多或者内存碎片过多无法装入新的进程,就需要根据段描述符对段进行换入换出的操作。当物理内存过小就会导致进程无法运行,或进程的段比较大,就需要做更多的IO操作导致整个系统的性能下降。因此,就引入了分页机制。
(1)一级页表
由于虚拟内存中是连续的,而在物理内存中每个页都是不连续的,被离散的插在了不同地方。所以我们要有一个类似于目录的东西,来找到虚拟内存中的每一块对应到物理内存中的某一块。也就是我给你一个虚拟地址,你就要给我一个对应的物理地址,指向某一个数据。我们使用一个页表来存储物理内存对应的页表项PTE。这个页表类似于一个目录。 这个目录我们的每一项表示一个4K大小的页,而不是定位到某个具体物理地址,那么这个目录我们只需要1M个项即可,而每一项中的内容其实就是4K页的起始地址。不是为每个地址创建一条表单条目,而是为每个page创建一条表单条目,所以每一次地址翻译都是针对一个page,那么我们页表共有1M个条目来存储物理页的序号,每个条目占4byte,所以这个页表总共占4M。
一个32为地址,可以用高20位来定位到那个物理页,用低12为来表示4k页中的偏移量。例如地址0x1234(0000 0000 0000 0000 0001 0010 0011 0100),高20位1表示到第一个页进行索引,得到该页的起始地址为0x9000,低12位为0x234表示在该页中的偏移量,故物理地址为该页的起始地址(0x9000)+页内偏移量(0x234)=0x9234
(2) 二级页表
为何要有二级页表
第一级页表要常驻内存。一级页表大小是4M。如果开启多个进程,每个进程占用4M就很占内存。
这还只是是32位系统,如果64位系统那就是2的64次方除以10。非常大,放到内存中是不可能的。无论是几级页表,标准页的尺寸都是 4KB,这一点是不变的。所以 4GB 线性地址空间最多有 1M 个 标准页。一级页表是将这 1M 个标准页放置到一张页表中,二级页表是将这 1M 个标准页平均放置 1K 个页表中。每个页表中包含有 1K 个页表项。页表项是 4 字节大小,页表包含 1K 个页表项,故页表大小为4KB,这恰恰是一个标准页的大小。
每个页表中可容纳 1024 个物理页,故每个页表可表示的内存容量是 1024*4KB=4MB。页目录中共有 1024 个页表,故所有页表可表示的内存容量是 1024*4MB=4GB,这已经达到了 32 位地址空间的最大容量。 所以说,任意一个 32 位物理地址,它必然在某个页表之内的某个物理页中。我们定位某一个物理页,必然 要先找到其所属的页表。页目录中 1024 个页表,只需要 10 位二进制就能够表示了,所以,虚拟地址的高 10 位(第 31~22 位)用来在页目录中定位一个页表,也就是这高 10 位用于定位页目录中的页目录项 PDE, PDE 中有页表物理页地址。找到页表后,到底是页表中哪一个物理页呢?由于页表中可容纳 1024 个物理页, 故只需要 10 位二进制就能够表示了。所以虚拟地址的中间 10 位(第 21~12 位)用来在页表中定位具体的 物理页,也就是在页表中定位一个页表项 PTE,PTE 中有分配的物理页地址。由于标准页都是 4KB,12 位 二进制便可以表达 4KB 之内的任意地址,故线性地址中余下的 12 位(第 11~0 位)用于页内偏移量。例如地址0x1234567(0000 0001 0010 0011 0100 0101 0110 0111),高10位为4,表示为第4个页目录项,通过页目录表物理地址+4*4(一个页目录项大小)即可得到该页目录项所指向页表的起始物理地址。页目录表物理地址存放在cr3寄存器中。再通过中10位(0x234)得到在二级页表的第几个页表项(得到该页表项的起始地址),最后通过低12位(0x567)页内偏移量就可以得到真实的物理地址。
(3)页目录项和页表项结构
如上图所示,页目录项和页表项大小都为4字节大小,高20位代表物理地址,低12位为属性值,因为页目录项和页表项中的都是物理页地址,标准页大小是 4KB,故地址都是 4K 的倍数,也就是地址的低12位是0,故低12位可用来代表特有的属性值。
启动分页机制的三个步骤:
1.初始化页目录表以及页表
2.将页目录表地址存入cr3寄存器
3.寄存器cr0的PG位置为1
为了系统的安全和实现共享,虚拟地址空间的 0~3GB 是用户进程,3GB~4GB 是操作系统。
在boot.inc文件中添加以下配置:
PAGE_DIR_TABLE_POS equ 0x100000 ;页目录表在内存中的起始位置——从1M开始的位置
;---------模块化的页目录表字段,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
loader.s文件加入以下代码:
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来做寻址页表项的偏移量
xoe edx, edx ;将edx置为0,现在edx指向0地址
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
该段代码定义了函数setup_page,第一部分执行一段循环,将页目录表占用的空间清零,ecx寄存器中存放循环次数,loop每执行一次ecx寄存器减1,减到0为止。 第二部分寄存器 eax中是页目录项的内容(提醒一下,PG_US_U | PG_RW_W | PG_P 逻辑或的结果是0x7),分别将其存入到页目录项的第 0 项和第 768 项,0xc00/4=768。页目录项代表一个页表,也就是说,这两处都是指向同一个页表。
在loader.s文件中调用上面所定义的函数完成页目录表和页表的初始化工作,因为操作系统应该位于虚拟内存空间的3GB以上,所以将gdt表的基址、显存段描述符中的段基址、栈指针+0xc0000000映射到内核空间中。
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新的地址重新加载
mov byte [gs:160], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr
编译mbr.s和loader.s文件并写入磁盘,启动bochs虚拟机,使用info gdt可以看到gdt表的基址被修改到了0xc0000900,显存段的段描述符中的段基址被修改为0xc0000b00。
3.loader的最后使命
loader最后需要将操作系统的二进制文件加载到内存中,根据elf文件头信息为其创建内核映像。
程序中最重要的部分就是段和节。program header用于描述每个段的信息,section header用于描述每个节的信息。由于程序中段与节的大小和数量是不固定的,所以program header与section header的数量大小也是不固定的,因此需要为它们专门找个数据结构来描述它们,这个描述结构就是program header table与section header table。但是,多一个段,就多一个program header,program header table就会变大,所以program header table与section header table的大小也是不固定的,就又需要一个elf header来描述program header table与section header table。
通过elf头可以得到Program header table的地址,通过Program header table可以分别获取Program header的信息,根据这个信息去确定每个段的大小,起始地址,在内存中的目的地址(都是虚拟地址)。elf header和Program header的结构详情见书上p215页。
创建一个c语言文件kernel.c作为内核文件,该文件内容如下:
int main(){
while(1);
return 0;
}
使用gcc-4.4 /home/yyx/Desktop/chapter5_b/c/kernel/main.c -o /home/yyx/Desktop/kernel -c -m32
命令编译该c语言文件,使用ld /home/yyx/Desktop/kernel -Ttext 0xc0001500 -e main -o /home/rlk/Desktop/kernel.bin -m elf_i386命令完成链接,最后使用dd if=/home/yyx/Desktop/kernel.bin of=/home/yyx/Desktop/bochs/hd60M.img bs=512 count=200 conv=notrunc seek=9命令写入磁盘的9号扇区。
在boot.inc中添加一些宏定义
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 ;定义内核可执行代码的入口地址
;------------- 程序段的 type 定义 --------------
PT_NULL equ 0
在loader.s定义在32位环境下读取磁盘中内容的函数
;-------------------------------------------------------------------------------
;功能:读取硬盘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
然后将我们写入磁盘中的内核文件读取到指定的内存地址
; ------------------------- 加载kernel ----------------------
mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ; 从磁盘读出后,写入到ebx指定的地址
mov ecx, 200 ; 读入的扇区数
call rd_disk_m_32
我们需要根据elf文件格式解析内核文件,将文件中的各个段拷贝到自己被编译的虚拟地址处,先定义拷贝函数和解析elf文件函数kernel_init。
;---------- 逐字节拷贝 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
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
最后,在loader.s文件中跳转到内核开始执行。启动bochs虚拟机进行测试。ctrl+c,发现程序停留在0xc0001503地址处(jmp指令占3个字节大小)
lgdt [gdt_ptr] ;在开启分页后,用gdt新的地址重新加载
enter_kernel:
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT ; 用地址0x1500访问测试,结果ok