从本章开始,我们的代码将会在保护模式下工作,除了开启虚拟内存外,我们还会接触到其他硬件,从本章开始,我们才算开始了真正的操作系统学习之旅
5.1 获取物理内存容量
进入保护模式后,我们讲接触虚拟内存,虚拟容量,但这些和内存相关的概念都是建立在物理内存之上,所以为了在后期作好内存管理工作,我们先得知道自己有多大的物理内存。
linux中有多种方式获取内存容量,如果一种方法失败,就会试用其他方式。我们调用BIOS中断0x15的3个子功能来获取内存容量,子功能号要存放到寄存器EAX或AX中,如下。
- EAX = 0xE820 :遍历主机上全部内存
- AX = 0xE801 :分别检测低15MB 和 16MB~4GB的内存,最大支持4GB。
- AH = 0x88 :最多检测出64MB内存,实际内存超过此容量也按照64MB返回
BIOS中断是实模式下的方式,所以我们进入保护模式前调用。
0xE820 子功能
由于系统内存各部分的类型属性不一样,BIOS 就按照类型属性来划分这片系统内存,所以这种调查呈迭代式。因为0xE820返回的信息较丰富,包括多个属性字段,所以需要一种格式结构来组织这些数据,被称之为地址范围描述符ARDS。
此结构中的字段大小是4字节,共5个字段,所以大小为20字节。
其中的type字段用来描述这段内存的内型。所谓类型就是说明这段内存的用途,即其是可以被操作系统使用,还是保留起来不能用。
由于我们是在32位环境下工作,所以ARDS中我们只使用到低32位属性。BaseAddrLow + LengthLow 是一片内存区域上限,单位是字节。
0xE801 子功能
此方法简单但并不强大,最多识别4GB内存,不过对于我们32位地址总线足够了。稍微有点不便就是次方法检测到的内存是分别存放到两组寄存器中。低于15MB的内存以1KB位单位大小来记录,单位数量在寄存器AX和CX中记录,其中AX = CX,所以在15MB空间一下的实际内存容量 = X*1024。AX和CX最大值为0x3c00,即0x3c00 * 1024 = 15MB。16MB~4GB是以64KB为单位大小来记录的,单位数量在BX和DX寄存器中。所以实际大小是 BX*64*1024。
具体为什么分前15MB和16MB以上,看书p180。
0x88 子功能
该方法最简单,功能也最简单,简单到最大只能识别64MB。即使内存容量大于64MB,也只会显示63MB,因为此中断只会显示1MB以上的内存,不包括这1MB,我们在使用的时候记得带上这1MB。
子功能代码实战
中断的调用步骤如下。
- 将AX寄存器写入子功能号。
- 执行中断调用 int 0x15。
- 在CF位为0的情况下,“返回后输出”中对应寄存器便会有对应结果。
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE: 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个描述符的空位(slot)
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用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
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 ; 刷新流水线,避免分支预测的影响,这种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
mov byte [gs:160], 'P'
jmp $
我认为此处内存容量查询有误,具体可看《操作系统真象还原》第五章 保护模式进阶 向内核迈进_kanshanxd的博客-CSDN博客
5.2 启用内存分页机制,畅游虚拟空间
目前我们虽然进入了保护模式,但在目前只分段的情况下,原来的“段基址:段内偏移地址”被称为线性地址,CPU认为线性地址等于物理地址,线性地址有许多的局限,例如物理地址必须连续,这极大的影响了我们程序运行的效率。所以我们要做的事解除线性地址与物理地址一一对应的关系,然后通过某种映射关系,将线性地址映射到任意物理地址。
一级页表
分页机制是建立于分段机制之上的,在选择子中找到段基址,以“段基址:段内偏移地址”的格式找到线性地址,这个线性地址在分段机制下被CPU认为是物理地址,但是如果打开了分页机制,这个线性地址就不再等同于物理地址,而是虚拟地址,此虚拟地址对应的物理地址需要在页表中查找,这项查找工作是由页部件自动完成的。
从线性空间到虚拟空间再到物理地址空间,每个空间都是4GB。此转换过程对每一个进程都是一样的,也就是说,每个进程都有自己的4GB虚拟空间。每加载一个进程,操作系统按照进程中各段的起始范围,在进程自己的4GB虚拟地址空间中寻找可用空间分配内存段,此虚拟地址可以是页表,也可以是操作系统维护的某种数据结构,总之此阶段的分配是逻辑上的,并没有真正写入物理内存。接着操作系统开始为这些虚拟内存也分配真实的物理内存页,他查找物理内存中可用的页,然后在页表中登记这些物理页地址,这样就完成了虚拟页到物理页的映射,每个进程都以为自己独享4GB地址空间。
为了平衡页表项个数和页大小,选择了每个页大小为4kb,即4GB大小对应1M个页表项,也就是说一个页表项管理着4kb大小的虚拟地址空间。
这里我们需要深度理解页表的查询过程。对于mov ax [0x1234]而言,0x1234是一个线性地址,该线性地址的高20位作为页表项的索引,低12位是4kb的大小,也就是页内偏移地址,对于高20位来说,我们把线性地址划分为了一个个大小为4kb的页,而这个页的下标就是这高20位的值,这样就说明了为什么我们的线性地址空间可以转换为虚拟地址空间。而页表中存放的是真实的物理地址,由此我们可以通过高20位的下标来搜寻页表从而找到真正对应的物理地址。
二级页表
由于一级页表占用空间巨大(每个进程1M个页表项,每个页表项4字节)且我们需要动态创建页表项。所以提出了二级页表,二级页表结构如下。
二级页表将地址转换为3各部分:高10位作为页表的索引,用于在页表表中定位一个页目录项PDE,PDE中有页表物理页地址。也就是定位到了某个页表。中间10位作为物理页的索引,用于在页表内定位到某个页表项PTE,页表项中有分配的物理页地址,也就是定位到了某个物理页。低12位作为页内偏移量用于在已经定位到的物理页内寻址。
由于页目录项PDE和页表项PTE都是4字节大小,给出了PDE和PTE索引后,还需要再背后乘以4再加上页表物理地址,即索引乘以4后是偏移地址要再加上物理地址,这才是要访问的绝对物理地址。
因为标准页大小是4kb,所以地址都是4kb的倍数,对于0~11位4kb的偏移地址就无需记录,可以添加其他属性。
控制器cr3用于存储页表物理地址,所以又成委屈页目录基址寄存器PDBR
下面我们来动手实践一下吧。
启动分页机制
用户进程在实现中需要使用到一些硬件,这些硬件需要操作系统去操作。举个例子,你购物,用户程序就好像点击购买,而操作系统则是负责吧物品打包并运送。所以我们在设计页表的时候要满足用户进程共享操作系统的需求,这个实现十分简单,只要操作系统属于用户进程的虚拟地址空间就行了。即虚拟地址空间的 0~3GB 是用户进程的,3~4GB 是操作系统。所有进程的虚拟地址3GB~4GB本质上都是指向同一片物理页地址,这片物理页是操作系统的实体代码。
页目录表的位置,我们存放在物理地址0x100000处。这里讲页表和页目录表挨着,因为页目录表1024个目录项,每项4字节,也就是页目录大小4kb,所以页表的物理地址位0x101000。
在boot.inc中插入以下代码
;------------- loader和kernel ----------
PAGE_DIR_TABLE_POS equ 0x100000
;---------------- 页表相关属性 --------------
;---------模块化的页目录表字段,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代码
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE: 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个描述符的空位(slot)
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用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_buf times 244 db 0
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
jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
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
jmp .mem_get_ok
;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法
;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份
;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok
;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF
;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16 ;把dx移到高16位
or edx, eax ;把积的低16位组合到edx,为32位的积
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB
.mem_get_ok:
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 ; 刷新流水线,避免分支预测的影响,这种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
; 创建页目录及页表并初始化页内存位图
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
sgdt [gdt_ptr] ; 存储到原来gdt所有的位置
;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故0x18。
;段描述符的高4字节的最高位是段基址的31~24位
;将gdt的基址加上0xc0000000使其成为内核所在的高地址
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000 ; 将栈指针同样映射到内核地址
; 把页目录地址赋给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
; 打开cr0的pg位(第31位)
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
;在开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr] ; 重新加载
mov byte [gs:160], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr
jmp $
;------------- 创建页目录及页表 ---------------
setup_page: ;以下六行把页目录占用的空间逐字节清0
mov ecx, 4096 ;loop循环次数
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi ;esi+1
loop .clear_page_dir
;----------------开始创建页目录项(PDE)--------------
.create_pde: ; 创建Page Directory Entry
mov eax, PAGE_DIR_TABLE_POS ;eax位页目录表基址
add eax, 0x1000 ; 此时eax为页表中第一个页表项的位置
mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为页表基址。
; 下面将页目录项0和0xc00都存为第一个页表的地址,
; 一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7),也就是第一个页表项
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
sub eax, 0x1000 ;使最后一个目录项指向页目录表自己的地址
mov [PAGE_DIR_TABLE_POS + 4092], eax ; 属性包含PG_US_U是为了将来init进程(运行到用户空间)做准备
;----------------下面创建页表项(PTE)
mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
mov esi, 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 ;页表项地址及属性
inc esi ;下标++
loop .create_pte
;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US,RW和P位都为1
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 范围为第769~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
nasm -I include/ -o loader.bin loader.S
dd if=/home/moyao/Desktop/bochs/boot/loader.bin of=/home/moyao/Desktop/bochs//hd60M.img bs=512 count=4 seek=2 conv=notrunc
在上面的代码中,我们将页目录项最后一项指向了页目录物理地址,也就是说当我们访问页目录项最后一项时,我们吧页目录项当成了页表,使用页表项的索引访问页目录项。
5.3 加载内核
elf文件结构介绍
ELF指的是Executable and Linkable Format,可执行链接格式。ELF文件指的是经过编译连接后的二进制可执行文件,该文件能够直接运行。本节所说的目标文件即指各种类型符合ELF规范的文件。
程序中段和节的信息使用header来描述的,程序头是program header,节头是 section header。程序中段的大小和数量是不固定的,节的大小和数量也是不固定的,因此需要专门的描述结构来描述它们,这个描述结构就是程序头表和节头表。强调一点,程序头表的元素全是程序头,节头表的元素全是节头表。
由于程序中段和节的数量并不固定,而且各表在程序文件中的存储顺序自然也要有个先后,因此,必须在一个固定的位置,用一个固定大小的数据结构来描述程序头表和节头表的大小及位置信息,这个结构便是 ELF header,它位于文件最开始的部分,并具有固定大小。
总之 ELF header是个用来描述各种“头”的“头”,程序头表和节头表中的元素也是程序头和节头。
ELF文件格式依然分为文件头和文件体两部分,先用个ELF header从“全局上”给出程序文件的组织结构,概要出程序中其他头表的位置大小等信息。然后,各个段和节的位置、大小等信息再分别从“具体的”程序头表和节头表中的节头表中给予说明。
e_ident[16]是个16字节大小的数组,用来表示elf字符等信息,开头4个字节固定不变的,是elf文件的魔数。他们分别是0x7f,以及字符串ELF的asc码:0x45,0x4从,0x46。
e_type占用2字节,是用来指定elf目标文件的类型。这里我们只需要关注取值为2的ET_EXEC类型,他的意义为程序可执行,就是我们平时编译连接好的可执行程序的类型。
e_machine占用2字节,用来描述elf目标文件的体系结构类型,也就是说该文件要在哪种硬件平台上才能运行。
下面是程序头表中的条目的数据结构,这是用来描述各个段的信息用的,其名为 struct ELF32_Phdr。
p_type占用4字节,来用指明程序中该段的类型。
将内核加载入内存
我们的内核文件时kernel.bin,这个文件是由loader将其从硬盘上读出并加载到内存中的,也就是说我们需要实现把kernel.bin定入硬盘。这里选择第9扇区。
dd if=/home/moyao/Desktop/bochs/kernel/kernel.bin of=/home/moyao/Desktop/bochs//hd60M.img bs=512 count=200 seek=9 conv=notrunc
为了方便,我们可以把下面三个命令,编译、链接、在写入硬盘一起完成,您可以将它们写成一个脚本,脚本内容如下:
gcc -c -o main.o main.c && ld main.o -Ttext 0xc0001500 -e main -o kernel.bin && dd if=/home/moyao/Desktop/bochs/boot/loader.bin of=/home/moyao/Desktop/bochs//hd60M.img bs=512 count=200 seek=9 conv=notrunc
我们的内核是由loader加载的,所以我们还要去修改下loader.S
- 加载内核:需要把内核文件加载到内存缓冲区
- 初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此loader的工作结束。
第一个加载内核,只是把内核从磁盘上拷贝到内存中,并不是运行内核代码。为了简单,我们在分页开启前加载。
内核被加载到内存后,loader通过分析其elf结构将其展开到新的位置,所以内核在内存中由两份拷贝,一份是elf格式的原文件kernel.bin,另一份是loader解析elf格式的kernel.bin后在内存中生成的内核映像,这个映像才是真正运行的内核。
内核文件要放在地址较高的空间,内核映像要放置在较低的地址。内核文件经过loader解析后就没用了,这样内核映像将来往高地址处扩展时也可以覆盖原来的内核文件kernel.bin。这里选择0x70000
代码实现如下:
首先,我们先修改boot.inc文件,在其中加几个宏定义。
;------------- loader和kernel ----------
KERNEL_BIN_BASE_ADDR equ 0x70000 ;定义内核映像地址
KERNEL_START_SECTOR equ 0x9 ;定义kernel.bin在磁盘的扇区位置
KERNEL_ENTRY_POINT equ 0xc0001500 ;定义可执行代码的入口地址
由于现在我们在32位下的环境工作,原先写的rd_disk_m_16函数不能直接使用,得在loader.S中重新写一个rd_disk_m_32函数。
;---------功能:读取硬盘n个扇区
rd_disk_m_32:
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax ; 备份eax
mov di,cx ; 备份扇区数到di
;读写硬盘:
;第1步:选择特定的通道,往该通道的sector count寄存器中写入代操作的扇区数
mov dx,0x1f2 ;sector count 寄存器
mov al,cl ;把待操作的扇区数给al
out dx,al ;写入待操作的扇区数
mov eax,esi ;恢复ax,ax是LBA扇区号即扇区的LBA地址
;第2步:往该通道上的三个LBA寄存器和device寄存器写入扇区的起始地址和属性设置
;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al
;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl ;右移8位
mov dx,0x1f4
out dx,al
;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al
shr eax,cl ;右移8位
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
;至此,硬盘控制器便从指定的lba地址(eax)处,读出连续的cx个扇区,下面检查硬盘状态
;第4步:检测硬盘状态
.not_ready: ;测试0x1f7端口(status寄存器)的的BSY位
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等。
;第5步:从0x1f0端口读数据
mov ax, di ;以下从硬盘端口读数据用insw指令更快捷,不过尽可能多的演示命令使用,
;在此先用这种方法,在后面内容会用到insw和outsw等
mov dx, 256 ;di为要读取的扇区数,一个扇区有512字节,每次读入一个字,共需di*512/2次,所以di*256
mul dx
mov cx, ax
mov dx, 0x1f0
.go_on_read:
in ax,dx
mov [ebx], ax
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
还需要在boot.inc文件中添加以下宏定义
;------------- 程序段的 type 定义 --------------
PT_NULL equ 0
kernel_init
解析内存中的编译好的elf内核文件,创建内核映像
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在文件中的偏移量
add ebx, KERNEL_BIN_BASE_ADDR ; 现在ebx中存着第一个program header的内存地址
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示段的数量
.each_segment:
cmp byte [ebx + 0], PT_NULL ; 段头结构第一位是p_type,若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
拷贝函数,用于将数据从内存中一个位置拷贝置另一个位置(myos/boo/loader.S)
;---------- 逐字节拷贝 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
loader使命就此结束,接下来跳跃到内核开始执行
lgdt [gdt_ptr] ;在开启分页后,用gdt新的地址重新加载
enter_kernel:
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT ; 用地址0x1500访问测试,结果ok
内核代码 main.c
int main() {
while(1);
return 0;
}
5.4 特权级深入浅出
特权级按照权力大小分为0、1、2、3级,数字越小,权利越大。
TSS简介
TSS即Task State Segment,意为任务状态段,是一种数据结构,用于存储数据任务的环境。TSS是每个任务都有的结构,它用于一个任务的识标,相当于任务的身份证,程序拥有此结构才能运行。由于内核程序位于0特权级,用户程序位于3特权级,所以一个任务按特权级来划分,实际上被分为了3特权级的用户程序和0特权级的内核程序,两个部分加起来才是能让处理器完整运行的程序,即完整的任务要经历这两种特权的变换。
TSS的作用有很多,但这里我们只需关心与特权级有关的低28位,即三个栈指针。在不同特权级下,我们所使用的栈是不一样的,这是因为如果不同特权级都使用一个栈,这种交叉引用会使得栈变得十分混乱。全面说了特权级有4种,那为什么tss只记录了3个栈呢,这是因为从低特权级到高特权级才需要主动去寻找栈指针,例如特权级3需要主动寻找特权级0、1、2的栈,而对于高特权级到低特权级则只需要跳转,即从低特权级到高特权级时会在高特权级的栈记录低特权级的栈地址,从高特权级返回低特权级时(返回指令)无需从tss中寻找。
CPL和DPL
计算机特权级的标签体现在DPL、CPL、RPL。
指令请求、访问其他资源的能力被称之为请求特权级,因为只有指令才具备访问、请求其他资源的能力。指令存放在代码段中,代码段寄存器CS和指令指针寄存器EIP中指向的指令便是当前在处理器中正在运行的代码,所以代码段寄存器CS中选择子不仅被称为请求特权级,又被称为当前特权级,即处理器当前的特权级是CS.RPL。
指令会从属于当前代码段的DPL(描述符特权级Descriptor Privilege Level),DPL也就是当前CPU所处的特权级,这个特权级被称为当前特权级CPL(current privilege level),当处理器从一个特权级的代码段转移到另一个特权级的代码段上执行时,处理器的特权级就会发生变化,经过一些条件判断(后续RPL会讲)新的代码段的DPL就变成了处理器的CPL,也就是目标代码段描述符的DPL将保存在代码段寄存器CS中的RPL位。也就是说当前代码段的DPL等于当前处理器的CPL等于当前代码段寄存器的RPL。
这里强调一下,访问者就是代码段中的指令。若访问数据段,特权级要大于等于,而代码段只能是平级访问。一般而言,高特权级不会主动降级,不过有唯一一种处理器会从高特权级降到低特权级运行的情况:处理器从中断处理程序返回到用户态的时候。这是因为中断发生要用的硬件和特权指令,用户程序要升为0级在降回3级。
有没有一种好方法,既执行高特权级代码段上的指令,又不提升特权级?方法是利用一致性代码段。一致性代码段,用来实现低特权级的代码向高特权级的代码转移,条件是CPL小于等于一致性代码段的DPL,并且转移后CPL不会变,因此RPL也不参与。
门、调用门与RPL序 ![](https://i-blog.csdnimg.cn/blog_migrate/bbf39c724b9b9ec1265df6c3cfed4ed8.png)
当用户程序调用内核时,需要使用中断门陷进内核。门中包含的目标程序所在的段的特权级DPL要高于或等于当前特权级CPL。
RPL代表真正请求者的特权级,让处理器认识到请求者的等级从而保护内核数据。