一、计算机的启动流程
- 在开机的一瞬间,CPU的
cs:ip
寄存器被强制初始化为0xf000:0xfff0
。由于开机的时候处于实模式,在实模式下的段基址要乘以16,也就是左移4位,于是0xf000:0xfff0
的等效地址是0xffff0
,此地址便是BIOS的入口地址。 - 地址
0xffff0
距1MB只有16个字节,BIOS真正的代码不在这,此处的代码只是个跳转指令jmp far f000:e05b
。段基址0xf000
左移4位+0xe05b
,即跳向了0xfe05b
处,这是BIOS代码真正开始的地方。接下来BIOS便马不停蹄地检测内存、显卡等外设信息,当检测通过,并初始化好硬件后,开始在内存中0x000-0x3ff
处建立数据结构,中断向量表(IVT)并填写中断例程。BIOS最后一项工作是校验启动盘中位于0号硬盘0磁道1扇区的内容。如果此扇区末尾的两个字节分别是魔数0x55
和0xaa
,BIOS便认为此扇区中确实存在可执行的程序(MBR),便加载到物理地址0x7c00
,随后跳转到此地址,继续执行。BIOS跳转到0x7c00
是用jmp 0:0x7c00
实现的,这是jmp
指令的直接绝对远转移用法,段寄存器cs
会被替换,这里的段基址是0,即cs
由之前的0xf000
变成了0
。 - MBR(主引导记录,Main Boot Record)的任务是加载某个程序(这个程序一般是内核加载器,很少有直接加载内核的)到指定位置,并将控制权交给它。所谓的交控制权就是
jmp
过去而已。MBR代码通常只有512字节,无法容纳完整的操作系统或内核加载程序,因此通常会加载一个更大的二级加载器。 - 引导加载程序负责进一步初始化硬件,并将操作系统内核加载到内存中。它通常会读取磁盘上的文件系统,找到内核文件,并将其加载到内存的适当位置。内核加载完成后,引导加载程序将执行权交给内核,开始执行操作系统内核代码。操作系统内核接管后,开始初始化更高层次的系统资源和服务,最终启动用户空间的应用程序。
二、实模式vs保护模式
1、实模式
x86在上电启动后自动进入实模式,即16位工作模式,这种模式是最早期的8086芯片所使用的工作模式。早期的芯片设计得较简单、工作模式也较简单,所以有诸多限制:
- 最大只能访问1MB的内存。采用段值:偏移的方式访问,内核寄存器最大为16位宽。如段寄存器CS, DS, ES, FS, GS, SS均为16位宽,AX, BX, CX DX, SI, DI, SP等也均为16位宽
- 所有的操作数最大为16位宽,出栈入栈也以16位为单位
- 没有任何保护机制,意味着应用程序可以读写内存中的任意位置
- 没有特权级支持,意味着应用程序可以随意执行任何指令,例如停机指令、关中断指令
- 没有分页机制和虚拟内存的支持
2、保护模式
在后续的芯片设计中,intel为处理器增加了一些新的功能,可以实现某些保护功能,即保护模式。具体的特点如下:
- 寄存器位宽扩展至32位,例如AX扩展至32位的EAX,最大可访问4GB内存(段寄存器依旧为16位)。
- 所有操作数最大为32位宽,出入栈也为32位。
- 提供4种特权级。操作系统可以运行在最高特权级,可执行任意指令;应用程序可运行于最低特权级,避免其执行某些特权指令,例如停机指令、关中断指令。
- 支持虚拟内存,可以开启分页机制,以隔离不同的应用程序。
2.1 段描述符
在80386及其后续处理器的保护模式下,段描述符是一个8字节(64位)的数据结构,用于定义内存段的基址、限长和访问权限等信息。以下是段描述符的结构及各字段的详细作用。
位范围 | 字段名称 | 大小 | 描述 |
---|---|---|---|
0-15 | 段界限 | 16 bits | 段的低16位限长,定义段的大小(以字节为单位)。 |
16-31 | 段基址 | 16 bits | 段的低16位基地址,定义段的起始地址。 |
32-39 | 段基址 | 8 bits | 段的中间8位基地址。 |
40-43 | 类型TYPE | 4 bits | 描述段类型,例如代码段、数据段、系统段等。 |
44 | S 位 | 1 bit | 描述符类型标志,0表示系统段描述符,1表示数据段描述符。 |
45-46 | DPL | 2 bits | 特权级,定义段的特权级别(0到3)。 |
47 | P 位 | 1 bit | 段存在标志,1表示段存在,0表示段不存在。 |
48-51 | 段界限 | 4 bits | 段的高4位限长。 |
52 | AVL | 1 bit | 可用位,系统软件可自由使用。 |
53 | L 位 | 1 bit | L为1表示64位代码段,否则表示32位代码段,仅在IA-32e模式下有效。 |
54 | D/B 位 | 1 bit | 默认操作大小(0表示16位段,1表示32位段)。 |
55 | G 位 | 1 bit | 粒度标志,0表示段限长以字节为单位,1表示段限长以4KB为单位。 |
56-63 | 段基址 | 8 bits | 段的高8位基地址。 |
2.2 段选择子
x86访问内存的机制是“段基址 : 偏移地址”,无论是实模式,还是保护模式,都要遵循此方式。在实模式下,段基址直接写在段寄存器中,而在保护模式下,段寄存器中的不再是段基址,而是段选择子,通过该选择子从GDT或LDT中找到相应的段描述符,从该描述符中获取段的起始地址。由于选择子的索引值部分是13位,即2的13次方是8192,故最多可以索引8192个段段选择子的结构如下:
位 | 字段名称 | 描述 |
---|---|---|
0-1 | 请求特权级(RPL) | 描述符请求特权级,表示访问权限级别,范围0-3。 |
2 | 表指示符(TI) | 表示使用GDT还是LDT,0表示GDT,1表示LDT。 |
3-15 | 描述符索引(Index) | 描述符在GDT或LDT中的索引,用于定位具体的段描述符。 |
2.3 段描述符表&GDT寄存器
在x86架构的保护模式下,段描述符表用于管理内存段,其是一个包含多个段描述符的数组。段描述符表有两种主要类型:全局描述符表(GDT)和局部描述符表(LGDT位于内存中,需要用专门的寄存器指向它后,CPU才知道它在哪里。这个专门的寄存器便是GDTR,即GDT Register,专门用来存储GDT的内存地址及大小。GDTR是个48位的寄存器,结构如下:
字段名称 | 大小 | 描述 |
---|---|---|
基地址 | 32 bits | GDT的起始地址。 |
限长 | 16 bits | GDT的大小(减1,以字节为单位)。 |
由于GDT的大小是16位二进制,其表示的范围是2的16次方等于65536字节。每个描述符大小是8字节,故GDT中最多可容纳的描述符数量是65536/8=8192个,即GDT中可容纳8192个段或门。对此寄存器的访问,不能够用mov gdtr, xxx 这样的指令为GDTR初始化,有专门的指令来做这件事,这就是lgdt指令。lgdt的指令格式是:lgdt 48位内存数据。
2.4 进入保护模式
进入保护模式的三个步骤:
三、实模式下获取物理内存容量
BIOS中断0x15的子功能0xE820能够获取系统的内存布局,由于系统内存各部分的类型属性不同,BIOS就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次BIOS只返回一种类型的内存信息,直到将所有内存类型返回完毕。子功能0xE820的强大之处是返回的内存信息较丰富,包括多个属性字段,所以需要一种格式结构来组织这些数据。内存信息的内容是用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符(Address Range Descriptor Structure,ARDS),格式见表5-1。
此结构中的字段大小都是4字节,共5个字段,所以此结构大小为20字节。每次int 0x15之后,BIOS就返回这样一个结构的数据。注意,ARDS结构中用64位宽度的属性来描述这段内存基地址(起始地址)及其长度,所以表中的基地址和长度都分为低32位和高32位两部分。其中的Type字段用来描述这段内存的类型,这里所谓的类型是说明这段内存的用途,即其是可以被操作系统使用,还是保留起来不能用。Type字段的具体意义见表5-2。
BIOS中断只是一段函数例程,调用它就要为其提供参数,现在介绍下BIOS中断0x15的0xe820子功能需要哪些参数。表5-3所示是使用此中断的方法,分输入和输出两部分。
INT 0x15的子功能0xE820使用步骤:
- 初始化寄存器: 将EAX设置为0xE820,EDX设置为SMAP,EBX设置为0,ECX设置为20,ES设置为指向缓冲区的指针。
- 调用中断: 执行INT 0x15中断。
- 检查结果: 检查EAX是否返回SMAP以及CF是否清除。如果CF置位,表示调用失败。
- 处理返回的数据: 从ES指向的缓冲区读取内存映射表条目,并根据Type字段处理。
- 继续调用: 将EBX设置为上一次调用返回的EBX值,再次调用中断,直到EBX返回0。
四、保护模式下使用LBA读取磁盘
进入保护模式后,无法使用实模式下的BIOS中断的磁盘读取服务。由于读取的磁盘数据会放在1MB以上的内存区域,所以也不便于在进入保护模式前使用BIOS的磁盘读取服务来读取。在保护模式下,通常通过直接与硬件通信或者使用操作系统提供的接口来读取磁盘,loader要加载操作系统内核,就必须要和磁盘打交道。打交道的方式很简单,就是通过in 与out指令与磁盘暴露在外的寄存器交互。下图是in与out指令的用法:
让硬盘工作,我们需要通过读写硬盘控制器的端口,端口就是位于I0控制器上的寄存器,此处的端口是指硬盘控制器上的寄存器。 下面列出了部分端口:
data寄存器:相当于数据的门,数据能进,也能出,所以其作用是读取或写入数据。数据的读写还是越快越好,所以此寄存器较其他寄存器宽一些,16位(表中其他寄存器都是8位的)。在读硬盘时,硬盘准备好的数据后,硬盘控制器将其放在内部的缓冲区中,不断读此寄存器便是读出缓冲区中的全部数据。在写硬盘时,我们要把数据源源不断地输送到此端口,数据便被存入缓冲区里,硬盘控制器发现这个缓冲区中有数据了,便将此处的数据写入相应的扇区中。
Sector count寄存器:用来指定待读取或待写入的扇区数。硬盘每完成一个扇区,就会将此寄存器的值减1,所以如果中间失败了,此寄存器中的值便是尚未完成的扇区。
LBA寄存器:硬盘中的扇区在物理上是用”柱面-磁头-扇区“来定位的(Cylinder Head Sector),简称为CHS,但每次我们要事先算出扇区是在哪个盘面,咱们希望有一套对人来说较直观的寻址方法,即磁盘中扇区从0开始依次递增编号,不用考虑扇区所在的物理结构(LBA)。LBA是一种逻辑上为扇区编址的方法,全称为逻辑块地址(Logical Block Address)。 LBA有两种,一种是LBA28,用28位比特来描述一个扇区的地址。最大寻址范围是2的28次方等于268435456个扇区,每个扇区是512字节,最大支持128GB。LBA48用48位比特来描述一个扇区的地址,最大可寻址范围是2的48次方,等于281474976710656个扇区,乘以512字节后,最大支持131072TB,即128PB。
Status&Command:在读硬盘时,端口0x1F7或0x177的寄存器名称是Status,它是8位宽度的寄存器,用来给出硬盘的状态信息。在写硬盘时,端口0x1F7或0x177的寄存Command,此寄存器用来存储让硬盘执行的命令,只要把命令写进此寄存器,硬盘就开始工作了。在咱们的系统中,主要使用了三个命令:0xEC(硬盘识别),0x20(读扇区),0x30(即写扇区)。
虽然操作磁盘很复杂,但都是有章可循的,按照如下步骤操作磁盘即可:
五、启用内存分页机制
1、一级页表
分页机制的核心是将程序所要用到的虚拟内存空间,拆分成一个个固定大小的小块(虚拟的,4K),与物理内存上的固定大小的小块建立映射关系。就是这样的一种分离,带来了内存管理的粒度更小(原有的段机制下段可能远比4K大得多)、粒度更标准(段大小不一,现在统一成了4K大小)、更灵活(将程序暂时用不到的小块不放入内存中,等用到了再从磁盘中调入)等优点。分页机制打开前要将页表地址加载到控制寄存器cr3中,这是启用分页机制的先决条件之一。32位x86页表项结构如下:
位数范围 | 字段名称 | 大小 | 描述 |
---|---|---|---|
0 | P | 1 bit | Present 位,页是否在物理内存中,1表示存在,0表示不存在。 |
1 | R/W | 1 bit | Read/Write 位,0表示只读,1表示读写。 |
2 | U/S | 1 bit | User/Supervisor 位,0表示内核模式,1表示用户模式。 |
3 | PWT | 1 bit | Page Write Through 位,控制页的写策略。 |
4 | PCD | 1 bit | Page Cache Disable 位,控制页的缓存策略。 |
5 | A | 1 bit | Accessed 位,表示该页是否被访问过。 |
6 | D | 1 bit | Dirty 位,表示该页是否被写入过。 |
7 | PAT | 1 bit | Page Attribute Table 位,控制页的缓存属性。 |
8 | G | 1 bit | Global 位,全局页标志,1表示全局页,不会在上下文切换时刷新TLB。 |
9-11 | AVL | 3 bits | 可供操作系统软件使用的位。 |
12-31 | 页框地址 | 20 bits | 物理页框地址的高20位 |
CR4寄存器中的第4位(位4),即PSE(Page Size Extension)位,用于控制页的大小。当PSE位为0:页大小为4KB(传统的分页方式)。当PSE位为1:支持4MB的大页(通常用于内核或大块连续内存的映射)。在32位操作系统中,CR3寄存器中的页目录表物理地址只需要20位,是因为页目录表的地址必须是对齐到4KB(4096字节)的边界。这种对齐要求使得物理地址的低12位总是为0,因此可以省略,只需存储高20位。
线性地址转换物理地址过程:
- 一个页表项对应一个页,所以,用线性地址的高20位作为页表项的索引,每个页表项要占用4字节大小,所以这高20位的索引乘以4后才是该页表项相对于页表物理地址的字节偏移量。
- 用cr3寄存器中的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址。
- 然后用线性地址的低12位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。
2、二级页表
二级页表线性地址转换物理地址过程如下:
- 用虚拟地址的高10位乘以4,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
- 用虚拟地址的中间10位乘以4,作为页表内的偏移地址,加上在第1步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
- 虚拟地址的高10位和中间10位分别是PDE(页目录项)和PTE(页表项)的索引值,所以它们需要乘以4。但低12位就不是索引值啦,其表示的范围是0-0xFFF,作为页内偏移最合适,所以虚拟地址的低12位加上第2步中得到的物理页地址,所得的和便是最终转换的物理地址。
3、启用页表机制
启用分页机制,我们要按顺序做好三件事:
- 准备好页目录表及页表。
- 将页表地址写入控制寄存器cr3。
- 寄存器cr0的PG位置1。
为清晰起见,给出控制寄存器CR0的各位:
-
位数范围 字段名称 描述 0 PE 保护模式使能,1表示启用保护模式。 1 MP 协处理器监控,与EM位一起用于控制协处理器。 2 EM 协处理器仿真,1表示禁止FPU操作,所有FPU指令会产生异常。 3 TS 任务切换标志,用于处理器在任务切换时的协处理器状态管理。 4 ET 协处理器类型,指示处理器的FPU类型。 5 NE FPU错误处理使能,1表示处理器使用内建的错误处理机制。 16 WP 写保护控制,1表示在内核模式下启用对用户模式页的写保护。 18 AM 对齐检查控制,1表示启用对齐检查(需要与EFLAGS中的AC位配合使用)。 29 NW 禁用写通缓存,1表示禁用写通缓存。 30 CD 缓存禁止,1表示禁用缓存。 31 PG 分页使能,1表示启用分页机制。
六、ELF文件
windows的可执行文件格式通常为.exe结尾的PE 文件格式,而linux/unix用得更多的则为elf文件格式。我们的工具链编译生成的即为这种文件格式。此种文件格式结构简要图如下。对我们而言,只需要关注下图右半部分(即Execution View,左半部分是给链接器用的)。
ELF文件中包含了.text/.rodata/.data/.bss各段的信息。通常解析该文件,找到程序头表,从该表中即可从中读取出相应的代码、数据段的相关信息,并可根据该信息将代码或数据加载到对应的内存中,完成整个加载过程。
ELF 头结构
字段名称 | 大小 | 描述 |
---|---|---|
e_ident | 16 bytes | 魔数和其他信息,标识文件是ELF文件,并提供文件的基本属性(如字节序等)。 |
e_type | 2 bytes | 文件类型(如可执行文件、重定位文件、共享对象等)。 |
e_machine | 2 bytes | 目标机器架构(如x86, ARM等)。 |
e_version | 4 bytes | ELF 文件版本,通常为1。 |
e_entry | 4 bytes | 程序入口地址,指示程序开始执行的位置。 |
e_phoff | 4 bytes | 程序头表在文件中的偏移。 |
e_shoff | 4 bytes | 节头表在文件中的偏移。 |
e_flags | 4 bytes | 处理器特定标志。 |
e_ehsize | 2 bytes | ELF 头的大小。 |
e_phentsize | 2 bytes | 用来指明程序头表中每个条目的字节大小。 |
e_phnum | 2 bytes | 用来指明程序头表中条目的数量,实际上就是段的个数。 |
e_shentsize | 2 bytes | 用来指明节头表中每个条目的字节大小。 |
e_shnum | 2 bytes | 用来指明节头表中条目的数量,实际上就是节的个数 |
e_shstrndx | 2 bytes | 节头字符串表索引。 |
程序头结构
字段名称 | 大小 | 描述 |
---|---|---|
p_type | 4 bytes | 段类型(如LOAD, DYNAMIC等)。 |
p_offset | 4 bytes | 段在文件中的偏移。 |
p_vaddr | 4 bytes | 段在内存中的虚拟地址。 |
p_paddr | 4 bytes | 段在内存中的物理地址,通常用于不可分页设备。 |
p_filesz | 4 bytes | 段在文件中的大小。 |
p_memsz | 4 bytes | 段在内存中的大小。 |
p_flags | 4 bytes | 段标志(如可读、可写、可执行)。 |
p_align | 4 bytes | 段在内存中的对齐要求。 |
节头结构
字段名称 | 大小 | 描述 |
---|---|---|
sh_name | 4 bytes | 节名称字符串表索引。 |
sh_type | 4 bytes | 节类型(如PROGBITS, SYMTAB等)。 |
sh_flags | 4 bytes | 节标志(如可写、可分配等)。 |
sh_addr | 4 bytes | 节在内存中的虚拟地址。 |
sh_offset | 4 bytes | 节在文件中的偏移。 |
sh_size | 4 bytes | 节的大小。 |
sh_link | 4 bytes | 链接到其他节的索引。 |
sh_info | 4 bytes | 额外的节信息。 |
sh_addralign | 4 bytes | 节的对齐要求。 |
sh_entsize | 4 bytes | 如果节包含表,则每个表项的大小。 |
常见的节类型
节名称 | 描述 |
---|---|
.text | 包含程序代码。 |
.data | 包含已初始化的全局变量。 |
.bss | 包含未初始化的全局变量。 |
.rodata | 包含只读数据(如字符串常量)。 |
.symtab | 包含符号表。 |
.strtab | 包含字符串表。 |
.rel.text | 包含对 .text 节的重定位信息。 |
.rel.data | 包含对 .data 节的重定位信息。 |
七、函数调用约定
![](https://i-blog.csdnimg.cn/direct/da4afb3b6f9d43a8b0c6010dc15ed537.png)
八、内联汇编
在GCC中,内联汇编使用 asm
或 __asm__
关键字,基本语法如下:
asm [volatile] ("assembly code" : output : input : clobber/modify);
assembly code:汇编代码,可以是多条指令。output: 输出操作数列表。input:输入操作数列表。clobber/modify:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样gcc就知道哪些寄存器或内存需要提前保护起来。
1、内联汇编之寄存器约束:![](https://i-blog.csdnimg.cn/direct/7da7aa142f26418ab46e930e39a8f40f.png)
![](https://i-blog.csdnimg.cn/direct/9c50ead3b1ff44e585d03316d1fcba10.png)
2、内联汇编之名称占位符 :
名称占位符与序号占位符不同,序号占位符靠本身出现在output和input中的位置就能被编译器辨识出来。而名称占位序需要在output和input中把操作数显式地起个名字,它用这样的格式来标识操作数:[名称]”约束名”(C变量)。这样,该约束对应的汇编操作数便有了名字,在assemblycode中引用操作数时,采用%[名称]的形式就可以了。
#include <stdio.h>
int main() {
int a = 10, b = 20, c;
// 使用内联汇编计算 c = a + b
__asm__ (
"add %[result], %[value1], %[value2]" // 汇编代码
: [result] "=r" (c) // 输出操作数
: [value1] "r" (a), [value2] "r" (b) // 输入操作数
);
printf("Result: %d\n", c);
return 0;
}
解释:
- "add %[result], %[value1], %[value2]":汇编代码执行加法操作,将a和b的值相加,并将结果存储在c中。%[result]、%[value1]和%[value2]是占位符,用于引用在操作数列表中指定的C变量。
- [result] "=r" (c):输出操作数部分,[result]是占位符名称,"=r"表示输出到一个通用寄存器,(c)表示将输出结果存储到C变量c中。
- [value1] "r" (a), [value2] "r" (b):输入操作数部分,[value1]和[value2]是占位符名称,"r"表示从一个通用寄存器读取,(a)和(b)表示输入的C变量a和b。
九、代码
1、boot部分
boot/start.S
/**
* 自己动手写操作系统
*
* 系统引导部分,启动时由硬件加载运行,然后完成对二级引导程序loader的加载
* 该部分程序存储于磁盘的第1个扇区,在计算机启动时将会由BIOS加载到0x7c00处
* 之后,将由BIOS跳转至0x7c00处开始运行
*
* 作者:李述铜
* 联系邮箱: 527676163@qq.com
*/
#include "boot.h"
// 16位代码,务必加上
.code16
.text
.global _start
.extern boot_entry
_start:
// 重置数据段寄存器
mov $0, %ax
mov %ax, %ds
mov %ax, %ss
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
// 根据https://wiki.osdev.org/Memory_Map_(x86)
// 使用0x7c00之前的空间作栈,大约有30KB的RAM,足够boot和loader使用
mov $_start, %esp
// 通过BIOS int 0x10中断,显示boot加载完成提示
mov $0xe, %ah
mov $'L', %al
int $0x10
// 通过BIOS int 0x13中断,加载位于硬盘2扇区中的loader到内存的0x8000处,只支持磁盘1
// https://wiki.osdev.org/Disk_access_using_the_BIOS_(INT_13h)
read_loader:
mov $0x8000, %bx // 读取到的内存地址
mov $0x2, %cx // ch:磁道号,cl起始扇区号
mov $0x2, %ah // ah: 0x2读磁盘命令
mov $64, %al // al: 读取的扇区数量, 必须小于128,暂设置成64B*512B=32KB
mov $0x0080, %dx // dh: 磁头号,dl驱动器号0x80(磁盘1)
int $0x13 // 出口参数:CF=0 操作成功,AH=00H,AL=传输的扇区数
jc read_loader
// 跳转至c部分执行,再由c部分做一些处理
jmp boot_entry
// 原地跳转
jmp .
// 引导结束段
.section boot_end, "ax"
boot_sig: .byte 0x55, 0xaa
boot/boot.c
__asm__(".code16gcc");
#include "boot.h"
#define LOADER_START_ADDR 0x8000 // loader加载的地址
/**
* Boot的C入口函数
* 只完成一项功能,即从磁盘找到loader文件然后加载到内容中,并跳转过去
*/
void boot_entry(void) {
//将LOADER_START_ADDR强转为无参无返回值的函数指针,
//然后通过这个函数指针调用代码,即跳转到loader中
((void (*)(void))LOADER_START_ADDR)();
}
在Linux下用于链接的程序是ld,链接有一个好处,可以用-Ttext参数指定最终生成的可执行文件的起始虚拟地址,链接部分参数如下:
-m elf_i386 -Ttext=0x7c00 --section-start boot_end=0x7dfe
2、loader部分
loader/start.S
/**
* 二级引导,负责进行硬件检测,进入保护模式,然后加载内核,并跳转至内核运行
*/
// 16位代码,务必加上
.code16
.text
.extern loader_entry
.global _start
_start:
// 栈和段等沿用之前的设置,也可以重新设置
// 这里简单起见,就不做任何设置了
// 你可能会想,直接跳到loader_entry,但这样需要先知道loader_entry在哪儿
// boot没有这个能力做到,所以直接用汇编,以便_start处于整个bin文件开头,这样boot直接跳到开头就可以
//进入loader_16中的loader_entry
jmp loader_entry
// 32位保护模式下的代码
.code32
.text
.global protect_mode_entry
.extern load_kernel
protect_mode_entry:
// 重新加载所有的数据段描述符
mov $16, %ax // 16为数据段选择子
mov %ax, %ds
mov %ax, %ss
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
// 长跳转进入到32位内核加载模式中,loader_32中的load_kernel
jmp $8, $load_kernel
loader/loader_16.c
// 16位代码,必须加上放在开头,以便有些io指令生成为32位
__asm__(".code16gcc");
#include "loader.h"
boot_info_t boot_info; // 启动参数信息,boot_info.h中进行了相关定义
/***************************************
#define BOOT_RAM_REGION_MAX 10 // RAM区最大数量
typedef struct _boot_info_t {
// RAM区信息
struct {
uint32_t start;
uint32_t size;
}ram_region_cfg[BOOT_RAM_REGION_MAX];
int ram_region_count;
}boot_info_t;
****************************************/
//实模式下使用BIOS int 0x10中断写显存,显示字符串
static void show_msg (const char * msg) {
char c;
while ((c = *msg++) != '\0') {
__asm__ __volatile__(
"mov $0xe, %%ah\n\t"
"mov %[ch], %%al\n\t"
"int $0x10"::[ch]"r"(c));
}
}
//调用BIOS中断0x15子功能0xe820获取内存总容量
static void detect_memory(void) {
uint32_t contID = 0;
SMAP_entry_t smap_entry;
int signature, bytes;
show_msg("try to detect memory:");
// 初次:EDX=0x534D4150,EAX=0xE820,ECX=24,INT 0x15, EBX=0(初次)
// 后续:EAX=0xE820,ECX=24,
// 结束判断:EBX=0
boot_info.ram_region_count = 0;
for (int i = 0; i < BOOT_RAM_REGION_MAX; i++) {
SMAP_entry_t * entry = &smap_entry;
__asm__ __volatile__("int $0x15"
: "=a"(signature), "=c"(bytes), "=b"(contID)
: "a"(0xE820), "b"(contID), "c"(24), "d"(0x534D4150), "D"(entry));
if (signature != 0x534D4150) {
show_msg("failed.\r\n");
return;
}
if (bytes > 20 && (entry->ACPI & 0x0001) == 0){
continue;
}
// 保存RAM信息,只取32位,空间有限无需考虑更大容量的情况
if (entry->Type == 1) {
boot_info.ram_region_cfg[boot_info.ram_region_count].start = entry->BaseL;
boot_info.ram_region_cfg[boot_info.ram_region_count].size = entry->LengthL;
boot_info.ram_region_count++;
}
if (contID == 0) {
break;
}
}
show_msg("ok.\r\n");
}
// GDT表 临时用,后面内容会替换成自己的
uint16_t gdt_table[][4] = {
{0, 0, 0, 0},
{0xFFFF, 0x0000, 0x9A00, 0x00CF},
{0xFFFF, 0x0000, 0x9200, 0x00CF},
};
/**
* 进入保护模式
*/
static void enter_protect_mode() {
// 关中断
cli();
//进入保护模式需要三个步骤:(1)打开A20,(2)加载GDT,(3)将CR0的PE位置1
// 开启A20地址线,使得可访问1M以上空间
// 使用的是Fast A20 Gate方式,见https://wiki.osdev.org/A20#Fast_A20_Gate
uint8_t v = inb(0x92);
outb(0x92, v | 0x2);
// 加载GDT。由于中断已经关掉,IDT不需要加载
lgdt((uint32_t)gdt_table, sizeof(gdt_table));
// 打开CR0的保护模式位,进入保持模式
uint32_t cr0 = read_cr0();
write_cr0(cr0 | (1 << 0));
// 长跳转进入到保护模式,start.s中的protect_mode_entry
// 使用长跳转,以便清空流水线,将里面的16位代码给清空
far_jump(8, (uint32_t)protect_mode_entry);
}
void loader_entry(void) {
show_msg("....loading.....\r\n");
detect_memory();
enter_protect_mode();
}
loader/loader_32.c
/*
* 32位引导代码
*/
#include "loader.h"
#include "comm/elf.h"
// 保护模式下使用LBA48位模式读取磁盘
// 主要用于将内核的elf文件的读取到内存
static void read_disk(int sector, int sector_count, uint8_t * buf) {
outb(0x1F6, (uint8_t) (0xE0));
outb(0x1F2, (uint8_t) (sector_count >> 8));
outb(0x1F3, (uint8_t) (sector >> 24)); // LBA参数的24~31位
outb(0x1F4, (uint8_t) (0)); // LBA参数的32~39位
outb(0x1F5, (uint8_t) (0)); // LBA参数的40~47位
outb(0x1F2, (uint8_t) (sector_count));
outb(0x1F3, (uint8_t) (sector)); // LBA参数的0~7位
outb(0x1F4, (uint8_t) (sector >> 8)); // LBA参数的8~15位
outb(0x1F5, (uint8_t) (sector >> 16)); // LBA参数的16~23位
outb(0x1F7, (uint8_t) 0x24);
// 读取数据
uint16_t *data_buf = (uint16_t*) buf;
while (sector_count-- > 0) {
// 每次扇区读之前都要检查,等待数据就绪
while ((inb(0x1F7) & 0x88) != 0x8) {}
// 读取并将数据写入到缓存中,SECTOR_SIZE为512表示一个扇区的大小
//由于一次读取16位为两字节,故一次for循环读取一个扇区
for (int i = 0; i < SECTOR_SIZE / 2; i++) {
*data_buf++ = inw(0x1F0);
}
}
}
//解析内核elf文件,提取内容到相应的内存中
static uint32_t reload_elf_file (uint8_t * file_buffer) {
// 读取的只是ELF文件,不像BIN那样可直接运行,需要从中加载出有效数据和代码
// 通过ELF文件魔数,简单判断是否是合法的ELF文件,其中ELF_MAGIC为 0x7F
Elf32_Ehdr * elf_hdr = (Elf32_Ehdr *)file_buffer;
if ((elf_hdr->e_ident[0] != ELF_MAGIC) || (elf_hdr->e_ident[1] != 'E')
|| (elf_hdr->e_ident[2] != 'L') || (elf_hdr->e_ident[3] != 'F')) {
return 0;
}
// 然后从中加载程序头,将内容拷贝到相应的位置
for (int i = 0; i < elf_hdr->e_phnum; i++) {
// e_phoff表示程序头表在文件中的偏移,程序头表是个数组,表项是段
//逐段解析,然后写入内存
Elf32_Phdr * phdr = (Elf32_Phdr *)(file_buffer + elf_hdr->e_phoff) + i;
if (phdr->p_type != PT_LOAD) { //PT_LOAD被定义为1,表示可加载类型
continue;
}
// 全部使用物理地址,此时分页机制还未打开
uint8_t * src = file_buffer + phdr->p_offset; //p_offset表示段在文件中的偏移
uint8_t * dest = (uint8_t *)phdr->p_paddr; //p_paddr表示段在内存中的物理地址(4 bytes)
for (int j = 0; j < phdr->p_filesz; j++) { //p_filesz表示段在文件中的大小(4 bytes)
*dest++ = *src++; //将内核写入内存
}
//如果memsz大于filesz时,后续要填0
dest= (uint8_t *)phdr->p_paddr + phdr->p_filesz;
for (int j = 0; j < phdr->p_memsz - phdr->p_filesz; j++) {
*dest++ = 0; //p_memsz表示段在内存中的大小(4 bytes)
}
}
// e_entry表示程序入口地址,指示程序开始执行的位置
return elf_hdr->e_entry;
}
/**
* 开启分页机制
* 将0-4M空间映射到0-4M和SYS_KERNEL_BASE_ADDR~+4MB空间
* 0-4MB的映射主要用于保护loader自己还能正常工作
* SYS_KERNEL_BASE_ADDR+4MB则用于为内核提供正确的虚拟地址空间
*/
void enable_page_mode (void) {
#define PDE_P (1 << 0)
#define PDE_PS (1 << 7)
#define PDE_W (1 << 1)
#define CR4_PSE (1 << 4)
#define CR0_PG (1 << 31)
// 使用4MB页块,这样构造页表就简单很多,只需要1个表即可。
// 以下表为临时使用,用于帮助内核正常运行,在内核运行起来之后,将重新设置
static uint32_t page_dir[1024] __attribute__((aligned(4096))) = {
[0] = PDE_P | PDE_PS | PDE_W, // PDE_PS,开启4MB的页
};
// 设置PSE,以便启用4M的页,而不是4KB
uint32_t cr4 = read_cr4();
write_cr4(cr4 | CR4_PSE);
// 设置页表地址
write_cr3((uint32_t)page_dir);
// 开启分页机制
write_cr0(read_cr0() | CR0_PG);
}
// 从磁盘上加载内核
void load_kernel(void) {
// 读取的扇区数一定要大一些,保不准kernel.elf大小会变得很大
//从第100个扇区开始读,即内核的elf文件事先被写到了磁盘的第100个扇区处
//SYS_KERNEL_LOAD_ADDR被定于为 1024*1024,即将内核的elf文件加载到内存的1M处
read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);
// 解析ELF文件,并通过调用的方式,进入到内核中去执行,同时传递boot参数
// 临时将elf文件先读到SYS_KERNEL_LOAD_ADDR处,再进行解析
uint32_t kernel_entry = reload_elf_file((uint8_t *)SYS_KERNEL_LOAD_ADDR);
if (kernel_entry == 0) {
while(1); //死机
}
// 开启分页机制
enable_page_mode();
//将kernel_entry强转为带参(boot_info_t *)无返回值的函数指针,
//然后通过这个函数指针调用代码,即跳转到kernel_entry中,同时boot_info也传递到了内核程序中
((void (*)(boot_info_t *))kernel_entry)(&boot_info);
}
3、内核部分
kernel\init\start.S
.text
.global _start
.extern kernel_init
_start:
# 将loader_32传递过来的数据&boot_info重新压入栈中,传递给kernel_init
# 第一种方法(需要返回的函数调用一般这么做)
# push %ebp
# mov %esp, %ebp
# mov 0x8(%ebp), %eax
# push %eax
# 第二种方法,由于不需要返回,故不需要将ebp再压入栈中备份
# mov 4(%esp), %eax
# push %eax
# 第三种方法
push 4(%esp)
# kernel_init(boot_info)
call kernel_init
jmp .
4、相关的头文件
comm\elf.h
/*
* ELF相关头文件及配置
*/
#ifndef OS_ELF_H
#define OS_ELF_H
#include "types.h"
// ELF相关数据类型
typedef uint32_t Elf32_Addr;
typedef uint16_t Elf32_Half;
typedef uint32_t Elf32_Off;
typedef uint32_t Elf32_Sword;
typedef uint32_t Elf32_Word;
#pragma pack(1)
// ELF Header
#define EI_NIDENT 16
#define ELF_MAGIC 0x7F
#define ET_EXEC 2 // 可执行文件
#define ET_386 3 // 80386处理器
#define PT_LOAD 1 // 可加载类型
//ELF文件头结构
typedef struct {
char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
}Elf32_Ehdr;
#define PT_LOAD 1
//ELF程序头结构
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
#pragma pack()
#endif //OS_ELF_H
comm\cpu_instr.h
/**
* 汇编指令的封装
*
* 作者:李述铜
* 联系邮箱: 527676163@qq.com
*/
#ifndef CPU_INSTR_H
#define CPU_INSTR_H
#include "types.h"
// 从指定的端口读取一个字节
static inline uint8_t inb(uint16_t port) {
uint8_t rv;
__asm__ __volatile__("inb %[p], %[v]" : [v]"=a" (rv) : [p]"d"(port));
return rv;
}
//从指定的端口读取一个字
static inline uint16_t inw(uint16_t port) {
uint16_t rv;
__asm__ __volatile__("in %1, %0" : "=a" (rv) : "dN" (port));
return rv;
}
// 向指定的端口写入一个字节
static inline void outb(uint16_t port, uint8_t data) {
__asm__ __volatile__("outb %[v], %[p]" : : [p]"d" (port), [v]"a" (data));
}
// 向指定的端口写入一个字
static inline void outw(uint16_t port, uint16_t data) {
__asm__ __volatile__("out %[v], %[p]" : : [p]"d" (port), [v]"a" (data));
}
// 禁用中断
static inline void cli() {
__asm__ __volatile__("cli");
}
// 启用中断
static inline void sti() {
__asm__ __volatile__("sti");
}
/**
* 加载全局描述符表
*
* @param start GDT的起始地址
* @param size GDT的大小
*/
static inline void lgdt(uint32_t start, uint32_t size) {
struct {
uint16_t limit;
uint16_t start15_0;
uint16_t start31_16;
} gdt;
gdt.start31_16 = start >> 16;
gdt.start15_0 = start & 0xFFFF;
gdt.limit = size - 1;
__asm__ __volatile__("lgdt %[g]"::[g]"m"(gdt));
}
// 读取CR0寄存器的值
static inline uint32_t read_cr0() {
uint32_t cr0;
__asm__ __volatile__("mov %%cr0, %[v]":[v]"=r"(cr0));
return cr0;
}
//写入值到CR0寄存器
static inline void write_cr0(uint32_t v) {
__asm__ __volatile__("mov %[v], %%cr0"::[v]"r"(v));
}
static inline uint32_t read_cr2() {
uint32_t cr2;
__asm__ __volatile__("mov %%cr2, %[v]":[v]"=r"(cr2));
return cr2;
}
static inline void write_cr3(uint32_t v) {
__asm__ __volatile__("mov %[v], %%cr3"::[v]"r"(v));
}
static inline uint32_t read_cr3() {
uint32_t cr3;
__asm__ __volatile__("mov %%cr3, %[v]":[v]"=r"(cr3));
return cr3;
}
static inline uint32_t read_cr4() {
uint32_t cr4;
__asm__ __volatile__("mov %%cr4, %[v]":[v]"=r"(cr4));
return cr4;
}
static inline void write_cr4(uint32_t v) {
__asm__ __volatile__("mov %[v], %%cr4"::[v]"r"(v));
}
/**
* 进行远跳转
*
* @param selector 选择子
* @param offset 偏移量
*/
static inline void far_jump(uint32_t selector, uint32_t offset) {
uint32_t addr[] = {offset, selector };
__asm__ __volatile__("ljmpl *(%[a])"::[a]"r"(addr));
}
/**
* 加载中断描述符表
*
* @param start IDT的起始地址
* @param size IDT的大小
*/
static inline void lidt(uint32_t start, uint32_t size) {
struct {
uint16_t limit;
uint16_t start15_0;
uint16_t start31_16;
} idt;
idt.start31_16 = start >> 16;
idt.start15_0 = start & 0xFFFF;
idt.limit = size - 1;
__asm__ __volatile__("lidt %0"::"m"(idt));
}
// 处理器暂停执行,进入低功耗状态
static inline void hlt(void) {
__asm__ __volatile__("hlt");
}
/**
* 加载任务寄存器
*
* @param tss_selector TSS选择子
*/
static inline void write_tr (uint32_t tss_selector) {
__asm__ __volatile__("ltr %%ax"::"a"(tss_selector));
}
/**
* 读取EFLAGS寄存器的值
*
* @return EFLAGS寄存器的值
*/
static inline uint32_t read_eflags (void) {
uint32_t eflags;
__asm__ __volatile__("pushfl\n\tpopl %%eax":"=a"(eflags));
return eflags;
}
/**
* 写入值到EFLAGS寄存器
*
* @param eflags 要写入的值
*/
static inline void write_eflags (uint32_t eflags) {
__asm__ __volatile__("pushl %%eax\n\tpopfl"::"a"(eflags));
}
#endif