本文主要是介绍二、进入保护模式--内核加载器LOADER:实模式下内存容量检测、开启保护模式、开启分页模式、加载kernel到内存缓冲区、加载kernel到内存(内存复制函数)-kernel,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
保护模式
CPU扩展:
实模式是使用的8086的CPU的16位,保护模式的运行环境变为了32位。所以开机时32位的CPU先处于16位的状态,再转为32位。但是16位也可以访问32位的资源。16位模式下默认操作数位16位,32位模式下默认操作数位32位
寄存器扩展:
寻址扩展:
运行模式反转:
操作数反转前缀:指令前缀0x66
寻址方式反转前缀:指令前缀0x67
指令扩展
loop:
实模式下用cx来储存循环次数,保护模式下要用ecx。每操作一次循环体后cx减1,然后执行loop指令前要拿ecx和0比较(循环条件),等于0则停止循环,不等于0则执行loop指令。相当于for
mul:
div:
push:
不管在实模式还是保护模式都可以同时处理16位和32位的数据。
push 立即数
push 寄存器
push 内存
push 立即数:
实模式下:
压入16位立即数,cpu会将其直接入栈。sp-2
压入32位立即数,cpu会将其直接入栈。sp-4
保护模式下:
压入8位立即数,因为默认操作数是32位,CPU位将其扩展为32位后,将其入栈,sp-4
压入16位立即数,cpu会将其直接入栈,sp-2
压入32位立即数,cpu会将其直接入栈,sp-4
push 寄存器:
实模式下:
压入段寄存器:cs、ds、es、fs、gs、ss,,按照当前默认操作数大小压入,sp-2
压入通用寄存器,如果压入的16位的,sp-2,如果压入32位的,sp-4
保护模式下:
push 内存
实模式:
压入的16位数据,sp-2 eg:push word [0x1234]
压入的64位数据,sp-4 eg:push dword [0x1234]
保护模式:
压入的16位数据,sp-2
压入的64位数据,sp-4
获取内存容量:
BIOS中断0x15子功能0xE820获取内存
0xE820能够获取系统的内存布局,按照内存的类型属性来划分这片内存,BIOS会按照类型的来返回内存信息,即返回地址描述符结构。一般有:系统的ROM,设备内存映射的内存区域、不可用内存、DRAM等。比如我们的内存有6种类型,就会返回6个ARDS到我们指定的内存区域中,到时候我们只需要根据ARDS的各单位的偏移量都出来就行。
对于32位系统来说,最大内存位4G,所以我们检查时候的只看BaseAddLow+LengthLow这两个单元即可。
我们调用BIOS的中断只按要求提供参数即可:
所以步骤为:
1,填好“调用前输入”中列出的寄存器
2,执行中断调用int 0x15
3,在CF位为0的情况下,“返回后调用”中对应的寄存器就会有结果。
BIOS中断0X15子功能0Xe801获取内存
AX=0XE801:分别检测低15MB和16MB-4GB的内存,最大支持4GB。
在15M空间容量=AX*1024,AX与CX最大值为0x3c00,0x3c00*1024=15M。
在16M-4GB的内存容量=BX*64*1024。
所以我们可以看出来要在我们求出的容量上面加上1MB。
步骤为:
1,将AX寄存器写入0xE801
2,执行中断调用int 0x15
3,在CF位位0的情况下,“返回后输出”中对应的寄存器便有相应结果。
BIOS中断0X88获取内存
AH=0X88:最多检测出64MB内存,实际内存超过64MB按照64MB返回。
求出来也要在加上1MB。
步骤为:
1,将AX寄存器写入0X88
2,执行中断调用int 0X15
3,在CF位为0的情况下,“返回后输出”中对应的寄存器就是结果。
;equ属于伪指令,是个替换指令,只是告诉编译器我们定义的这个名字相当于后面的东西,但并不对内存进行操作,只对编译器进行操作。
;我们现在生成的文件都是存bin文件,所以需要注意的是代码段和数据段不同属性都是放在一个段中的,而不是和elf格式一样将相同属性的段放在一起,里面也没有elf格式的地址信息,所以我们一定要将定义的数据和可执行的代码分开,数据段放在一起,代码段放在一起,然后jmp或者call时候加上标号就行了,这样就不会执行到数据段出错了。
;我们现在是实模式下面,默认的16位,所以会编译器会根据32位资源生成相应的前缀反转机器码,到进入保护模式时候一定加上[bits 32]
total_mem_bytes dd 0
ards_nr dw 0; 用于记录ards结构体数量
loader_start :
; ---- int 15h eax = 0000E820h, edx = 534D4150h获取内存布局,用edx来存放结果----
xor ebx, ebx; 第一次调用时,ebx值要为0,现在处于实模式下面,默认[bits 16]编译器会生成相应的机器码加上前缀反转
mov edx, 0x534d4150; edx只赋值一次,循环体中不会改变
mov di, ards_buf; ards结构缓冲区
mov ecx, 20; ARDS地址范围描述符结构大小是20字节
.e820_mem_get_loop:; 循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820; 执行int 0x15后, eax值变为0x534d4150, 所以每次执行int前都要更新为子功能号。
int 0x15
jc.e820_failed_so_try_e801; 此时函数执行完毕,先判断cf位,若cf位为1则有错误发生,尝试0xe801子功能
add di, cx; 若cf不等于1,则使di增加20字节指向缓冲区中新的ARDS结构位置
inc word[ards_nr]; 记录ARDS数量
cmp ebx, 0; cmp相当于减法,将ebx和0比较,若ebx为0且cf不为1, 这说明ards全部返回,当前已是最后一个
jnz.e820_mem_get_loop; 若ebx不等于0则继续循环,若等于0则往下面执行; 在所有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, 最大的内存块一定是DRAM,可以被使用的
mov eax, [ebx]; base_add_low
add eax, [ebx + 8]; length_low
add ebx, 20; 指向缓冲区中下一个ARDS结构
cmp edx, eax; 冒泡排序,找出最大, edx寄存器始终是最大的内存容量
jge.next_ards; edx - eax,若edx大于等于eax时候jump过去,表示edx中的值不改变还是最大值
mov edx, eax; 否则赋值,edx为总内存大小
.next_ards:; jump族后面加的是标号,不能跟指令,所以不能jge后面加上loop。。
loop.find_max_mem_area;此时循环体执行完毕,将cx减一,然后判断循环条件,cx与0比较,若等于0,则跳出,不等于0执行循环
jmp.mem_get_ok; ------ int 15h ax = E801h 获取内存大小, 最大支持4G,所以用32位寄存器edx来存放结果------
; 返回后, 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; 函数执行完毕,判断先判断cf位,若当前e801方法失败, 就尝试0x88方法; 1 先算出低15M的内存, ax和cx中是以KB为单位的内存数量, 将其转换为以byte为单位
mov cx, 0x400; cx和ax值一样, cx用做乘数,0x400的十进制是1024
mul cx; cx为16为,另一个乘数是ax,ax*cx = dx:ax
shl edx, 16; edx左移16位,将低16位移到高16位
and eax, 0x0000FFFF; 然后将eax的低16保持原值,高16位都置0
or edx, eax; 相加,所以本质就是将dx:ax中的值移动到edx中
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.edx:eax
add esi, eax; 由于此方法只能测出4G以内的内存, 故32位eax足够了, edx肯定为0, 只加eax便可
mov edx, esi; edx为总内存大小
jmp.mem_get_ok; --- - int 15h ah = 0x88 获取内存大小, 只能获取64M之内,所以要用32位寄存器edx存放结果-------
.e801_failed_so_try88:
; int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc.error_hlt
and eax, 0x0000FFFF; 将低16位保持原值,高16位置0; 16位乘法,被乘数是ax, 积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400; 0x400等于1024, 将ax中的内存容量换为以byte为单位
mul cx ;cx*ax=dx:ax
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处。
开启保护模式
全局段描述符表GDT:
我们在实模式下面的内存访问形式为:段基址:段内偏移地址,保护模式要考虑到兼容问题,所以也才采用这种形式:段选择子:段偏移地址
各种描述符一般都是8个字节,64位。段寄存器叫做选择子-sector,会在段描述符中检查各种权限和寻找段基址,权限通过后才能访问硬件。
实模式下面地址宽度为20位,1M内存,保护模式下面是32位,大小位4G。
GDT中存放的都是全局段描述符,GDTR寄存器中存放着GDT的起始位置。GDTR位48位,0-15 这16位为GDT界限,GDT界限为GDT占的总长度字节,除以8字节得到GDT数量,最多定义2的16次方除以8=8192。16-47这32位为起始地址,格式为:
lgdt 48位内存数据
选择子结构:3-15位是索引值,2的13次方=8192,索引值表示的是第几个描述符,用GDT的起始地址+索引值*8=目标段描述符。第0个描述符不可用。TI和RPL专门将
打开A20地址线:
实模式下面是地址回绕的(wrap-around)。20地址线,1M内存,当地址进位到1MB以上时候,因为没有第21根地址线,会丢失进位,变成内存最低值。保护模式要打开A20这根地址线:将端口0x92第1位置1即可。
int al,0x92
or al,0000_0010B
out 0x92,al
打开保护模式的开关:CR0寄存器的PE位
PE位:protection enable,只要打开了这个开关,那么不管GDT和A20有没有设置好,都会进入保护模式,所以这是最后一步。
mov eax,cr0
or eax,0x00000001
mov cr0,eax
至此进入保护模式!注意的是进入保护模式后要设置[bits 32]和刷新流水线。
所以进入保护模式的三个步骤为:
1,设置好GDT和GDTR
2,打开A19地址线
3,打开cr0的PE位
开启分页模式
因为我们的系统设置的内存为32M,实模式下面我们使用的是分段机制,地址都是真实的物理地址,当CPU将段基址+段内偏移地址时候只能访问到32M。我们想实现和linux下面一样的内存机制:4G内存,操作系统运行在3G-4G虚拟地址之上,其他用户进程运行在3G虚拟地址之下。就是说我们的内存虽然只要32M,但是我们可以用虚拟地址来实现4G内存。不过本质上4G内存经过还原后还是在这32M内存中访问。
我们采用二级页表来构建分页机制。页是地址空间的计量单位,标准页的尺寸为4KB,那么4GB内存,就需要1MB个标准页。在一级页表中,只有一个页表,里面包含了1MB个页表项,每个页表项4字节大小,页表共4MB大小,一个页表项指向一个4KB大小的标准页,所以表示的范围为1MB*4KB=4G。在二级页表中,有1个页目录和1024个页表,页目录中有1024个页目录项,一个页目录项的大小为4B,页目录大小为4K(标准页大小)。一个页目录项指向一个页表,一个页表里有1024个页表项,一个页表大小就为4KB(标准页),一个页表项可以指向一个标准页,所以一个页表项表示的范围就是4M,所以一个页目录项表示4M大小内存。当然我们用不了这么多的页表,我们用多少分配多少就行。我们写的kernel只用不到1M内存大小,所以我们就只用第一个页表里面的256个页表项即可。
页目录项和页表项都是4字节大小,但是里面不全部都是地址值,只表示了31-12位这30位,低12位没有表示,默认为0。因为地址空间的单位都是标准页4K=0x1000。所以低12位都位0。比如一个PDE指向的是页表,一个页表是4K大小(1024项PTE),所以PDE中地址的前12位可以为0。PTE指向的是一个标准物理页,也是4K大小,所以PTE中地址的前12位也可以为0。
可以看到:有一个页目录表和1024个页表,但是我们的操作系统太小了用不到这么多,我们只用第一个页表的256项,表示出1M内存即可。
段部件:cpu集成了这个模块,当CPU要访问内存时候,要通过段寄存器和偏移地址访问,段部件集成了这个算法:首先先根据段选择子计算GDT中的段基址,然后加上偏移地址得到线性地址,这是个虚拟地址。
页部件:cpu集成了这个模块,当段部件输出线性地址后进入页部件,页部件会根据二级页表的情况来取得实际的物理地址。
具体方法为:
1,用虚拟地址的高10位乘以4,作为页目录表内的偏移地址,加上页目录表的起始物理地址,求和便是页目录项PDE的物理地址。读取该页目录项的值,便是所指页表的起始物理地址。//高10位,2的10次方=1024,页目录项有1024个,所以高10位的值就对应着 PDEn,乘以4加上起始地址为 PDEn 的具体物理地址。
2,用虚拟地址的中间10位乘以4,作为页表内的偏移地址,加上上一步得到的页表物理地址,求和便是页表项PTE的物理地址。读取该页表项的值,便是所指的物理页的起始物理地址。 //中间10位,2的10次方=1024,页表项有1024个,所以中间10位的值对应着 PTEn,乘以4加上起始地址为 PTEn 的具体物理地址。
3,用虚拟地址的低12位,作为物理页的偏移量,加上上一步所求的物理页的起始地址,得到我们想要访问的内存。
确定页目录和页表的物理地址,明确虚拟地址和实际地址的映射关系
页目录和页表的物理地址: 我们把页目录项的起始地址设在1M之上:0x100000,范围是0x100000-0x101000。我们只用一个页表项足以应付我们的1M的kenel,我们将页表项紧挨着页目录项:0x101000-0x102000。我们只用到这个页表项中的PTE0-PTE255这256项,一项指的范围为4KB,所以256项为1MB。
映射关系: 我们的内核要加载到32M内存的低1M之内,要想把我们的kernel运行到3G之上,就要用虚拟地址从3G起始的1M地址:(0xc000 0000--0xc010 0000)映射到我们的32M内存的低1M(0x0000 0000--0x0010 0000)。
根据映射关系填好相应的页目录项和页表项
当CPU拿到虚拟地址为3G=0xc000 0000应该访问物理地址0x0000 0000。按照虚拟地址的解析步骤,首先应该看的高10位是:高16位:1100 0000 0000 0000b,高10位:1100 0000 00-->0011 0000 0000-->0x300=768,所以要首先访问 PDE768 ,物理地址为:0x300*4+0x100000=0x100c00。将这个地址处的值填为PTE0,即0x101000。表示这一页目录项指向的是物理地址0x101000起始的页表。
接着看中间10项:0xc000 0000的中间10项为0,由于我们需要 PTE0--PTE255,一个页表项对应物理地址的4KB,所以PTE0 的值为0,访问到了物理地址0x0000 0000-0x0000 1000。最后虚拟地址的最后12位对应着的这4KB的具体每个字节。 同理,PTE1 对应着物理地址的 (0x0000 1000-0x0000 2000),所以PTE1的值位0x0000 1000。依次类推,最后的 PTE 255的值对应着 0x000E 0000-0x0010 0000,PTE255应该填入0x000F 0000。
这样便建立起来了我们内核的映射关系: 0xC000 0000--0xC010 0000 到 0x0000 0000--0x0010 0000。分页模式开启后,程序中的每个地址都是虚拟地址了,都要通过段部件和页部件来访问硬件。当kernel程序中要通过段选择子和偏移地址访问内存时候,CPU取到段寄存器和偏移地址值,首先经过段部件输出线性地址(虚拟地址),然后通过页部件来输出真实物理地址。
分页模式的步骤:
1,准备好页目录和页表
2,将页表地址写入CR3
3,将寄存器CR0的PG位置1
加载内核
之前我们编译的都是纯二进制程序,只含有机器码,没有其他地址信息,要靠我们手动的把机器码加载到指定内存中,所以我们的把MBR dd在第0个扇区(LBA),将loader dd在第2个扇区,然后在MBR的程序将loader从硬盘中加载到0x900内存处。我们用GCC编译器编译出来的程序默认被加载到0x0804 8000地址处左右。
GCC将一个程序生成可执行文件时,要经过预处理器,汇编器,编译器,链接器4步才能生成可执行文件。那么我们就可以使用链接器ld来指定可执行代码的虚拟地址,用-Ttext参数来指定,则CPU就会在此处开始执行程序。eg:ld main.o -Ttext 0xc000 1500 -e main -o kernel.bin
一个可执行的ELF文件有很多机器码是多余的,CPU执行了就会报错,一个纯二进制文件也不是说程序从开头的机器码就可以直接执行,因为可能是数据(我们的loader)。所以一个程序总该有入口地址,这个地址表示的是程序将从哪里开始执行。对于一个ELF可执行文件来说,它的地址是在链接阶段将几个ELF重定位文件链接在一起的,所以链接器规定只把名为_start的函数作为程序的入口地址。我们可以用-e来指定起始的函数名为main或者其他标号。若程序中没有_start,编译器将会生成一个_start,然后调用main函数来执行,所以没有_start时候,0xc000 1500处是_start,然后在往上面才是main。有了-e main 后,0xc000 1500就是main了,而不是_start。
ELF文件介绍
ELF文件包括:待重定位的ELF文件,可执行的ELF文件和共享目标文件。
程序中最重要的部分就是段(segment)和节(section),段是由节来组成的,多个相同属性的节链接在一起形成了段。
段和节的信息是由header来描述的,描述一个段的有关信息(大小、属性、偏移地址等)的叫做程序头(program header)(也叫段头,段就等同于程序),描述一个节的有关信息(大小、属性、偏移地址等)的叫做节头(section header)。由于段有很多,所以程序头就储存在程序头表(program header table)中,由于节也有很多,所以节头就储存在节头表(section header table)中。程序头表的大小和位置也是随着段的变化而变化的,节头表也是。所以我们用一个固定位置、固定大小的 ELF header来描述程序头表和节头表的大小和位置信息。ELF header位于文件最开始部分。
ELF文件(待重定位的ELF文件和可执行的ELF文件)分为文件头和文件体两部分。先用ELF header来简单的说了程序头表和节头表的大小和位置。然后根据这些找到程序头表和节头表,在具体的描述这些段和节的详细信息。ELF格式的作用体现在链接阶段和运行阶段。在待重定位文件中,文件开头必须是ELF header,下面可能是program header table,对于可执行的文件来说,文件开头必须是ELF header,下面必须是program header table。下图分别是待重定位文件和可执行文件的信息。
关于ELF的任何定义,都可以在linux系统的usr/include/elf.h中找到。
ELF header结构
程序头(program header)结构
将内核载入内存要有两个步骤
1,加载内核到内存缓冲区:需要把内核文件加载到内存缓冲区,只是将elf内核从硬盘上拷贝到内存中。
2,将内核从内存缓冲区加载到特定的虚拟内存处。通过分析elf header和program header table的结构只将segment加载到特定的虚拟内存处。
所以内核在内存中有两份拷贝,一份是ELF格式的原文件kernel,另一份是loader经过解析后的内核映像。过程为:要解析elf header 的e_phentsize找到program header的大小,然后找到e_phoff找到program header table在文件中的偏移量,然后找到e_phnum,表示有几个program header。由于第一个段.text也包括了elf header 和 program header table 和可执行机器代码,所以我们加载到指定的虚拟内存时候其实也是把各种header也加载到了里面。
大数据复制三剑客:cld、(ecx、esi、edi、),rep movs[bwd]
kernel_init:
xor eax,eax
xor ebx,ebx ;ebx记录程序头表的起始地址
xor ecx,ecx ;ecx记录程序头表中程序头的数量
xor edx,edx ;edx记录程序头表中一个程序头的大小mov dx,[KERNEL_BIN_BASE_ADDR+42] ;偏移42字节处是e_phentsize,表示程序头大小
mov ebx,[KERNEL_BIN_BASE_ADDR+28] ;偏移28字节是e_phoff,第一个程序头的偏移地址add ebx,KERNEL_BIN_BASE_ADDR ;ebx为第一个段的实际地址
mov cx,[KERNEL_BIN_BASE_ADDR+44] ;偏移44字节是e_phnum,表示有几个程序头 .loop_each_segment: ;每一个段都调用一次 内存复制 函数
cmp byte[ebx+0],PT_NULL ;第一个段的类型是PT_NULL,则终止
je .PTNULLpush dword[ebx+16] ;先压入程序头的p_filesz,表示一个段的大小
mov eax,[ebx+4] ;程序头的p_offset,表示一个段的的偏移地址:即起始地址
add eax,KERNEL_BIN_BASE_ADDR ;eax中表示这个段的起始地址:即段大小
push ax
push dword[ebx+8] ;表示一个段的目的虚拟地址:即目的地址call mem_cpy
add esp+12.PTNULL:
add ebx,edx ;指向指向下一个段
loop .loop_each_segmentmem_cpy:
cld
push ebp
push ecx
mov ebx,esp
mov edi,[ebp+8]
mov esi,[ebp+12]
mov ecx,[ebp+16]
rep movsb ;一次复制一字节,复制一次ecx减1,esi和sdi自动加一字节,然后将ecx和0比较,不相等则继续赋值pop ecx
pop ebp
ret
这篇关于二、进入保护模式--内核加载器LOADER:实模式下内存容量检测、开启保护模式、开启分页模式、加载kernel到内存缓冲区、加载kernel到内存(内存复制函数)-kernel的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!