本章节所有代码托管在miniOS_32
章节任务介绍
任务简介
上一节,我们介绍了如何开启分页机制,从而让内核能够在分页机制下工作
本节我们将在正式书写内核之前完成loader的最后一项任务——加载内核
本章的主要任务有:
- 将可执行内核程序从磁盘加载到内存
- 读取可执行内核程序的elf header,从中读取内核程序的代码段和数据段等内容,并将其加载到内存的指定位置
内存布局
本节我们的任务完成后,loader的使命也就告一段落了,以下是本节完成后目前的内存布局
前置知识
目标文件的分类
- 静态链接文件::这类文件通常是
.a
或.o
文件,包含已经静态链接的代码和数据,可以被其他程序引用并最终生成可执行文件。- 动态链接文件::这类文件通常是
.so
文件(共享对象文件),它们不包含完整的程序代码,而是依赖于运行时加载共享库(共享对象)。- 可执行文件:最终生成的可执行文件,通常是
.out
或类似的格式,包含了可以被操作系统加载和执行的程序代码。
ELF与可执行文件的组成
ELF:即Executable and Linkable Format,可执行文件和目标文件的一种标准格式。它仅仅代表一种文件格式,类似与windows的exe格式,在linux中,目标文件都按照ELF文件格式进行组织.
程序是由段(如代码段、数据段)组成的,而段是由节组成的(如.text节、.data节、.bss节等),因此在程序头中要有一个段头表(程序头表)和节头表来描述程序中各种段及节的信息,故
- 程序头表:也称段头表,用于记录程序中的各个段的信息
- 节头表:其内元素用于描述程序中的各个节
由于程序头(段头)和节头的数量不固定,因此程序头表和节头表的大小也就不固定,因此需要一个数据结构来说明程序头表和节头表的大小和位置信息,这个数据结构就是elf header,这就是elf header的由来
故,一个目标文件按照ELF格式进行组织,其主要组成为
- ELF header
- 程序体(代码和数据等)
ELF header就是文件头,记录了程序体的相关信息(如程序的入口地址、各种段的信息、符号信息等),并非代码也并非数据,是无法执行的,因此当一个程序加载到内存后,需要根据ELF header抽取出程序的代码和数据,然后将其放到指定内存处才能真正执行代码
elf header详解
以下是一个典型的elf可执行目标文件组成
可以看到,本质上程序是由各种节组成的,其中.init、.text、.rodata组成了代码段,.data、.bss组成了数据段,还有其他的一些段
以下是这些节的概念
- **.text:**已编译程序的机器代码。
- .rodata:只读数据,比如 printf 语句中的格式串和开关语句的跳转表。
- .data:已初始化的全局和静态 C 变量。局部 C 变量在运行时被保存在栈中,既不岀现在 .data 节中,也不岀现在 .bss 节中。
- .bss:未初始化的全局和静态 C 变量,以及所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为 0。
- .symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过 -g 选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在 .symtab 中都有一张符号表(除非程序员特意用 STRIP 命令去掉它)。然而,和编译器中的符号表不同,.symtab 符号表不包含局部变量的条目。
- .rel.text:一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
- .rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
- .debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的 C 源文件。只有以 - g 选项调用编译器驱动程序时,才 会得到这张表。
- .line:**原始 C 源程序中的行号和 .text 节中机器指令之间的映射。只有以 -g 选项调用编译器驱动程序时,才会得到这张表。
- .strtab:一个字符串表,其内容包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。字符串表就是以 null 结尾的字符串的序列。
elf header组成
在程序中,elf header以结构体的形式存在,如下所示,elf header除了记录程序头表和节头表的信息外,还记录了程序的大小端、elf文件类型等信息
- e_ident[16]:16字节,用来表示elf字符等信息,开头的4个字节是固定不变的,是elf文件的魔数,它们分别是0x7f,以及字符串ELF的asc码:0x45,0x4c,0x46;有关e_ident[5]大小端字节序,,用 file 命令就能够查看到elf格式的可执行程序是LSB,还是MSB。
- e_type:2字节,指定elf目标文件的类型,可以看到,可执行文件、动态共享文件以及可重定位文件(静态链接文件)都属于ELF格式的文件,除此以外,代码出现core dump错误时生成的core文件也是一个ELF格式文件
- e_machine:2字节,描述elf目标文件要在那种硬件平台运行
- e_version:4字节,版本信息
- e_entry:占用4字节,用来指明操作系统运行该程序时,将控制权转交到的虚拟地址
- e_phoff(program header table offset):4字节,程序头表在文件内的字节偏移量
- e_shoff(section header table offset):4字节,节头表在文件内的偏移量
- e_flags:4字节,指明与处理器相关的标志
- e_ehsize:2字节,指明elf header字节大小
- e_phentsize;2字节,指明程序头表中每个条目(entry)的字节大小,也就是每个用来描述段信息的数据结构的字节大小
- e_phnum:2字节,程序头表中条目的数量
- e_shentsize:2字节,节头表中每个条目的字节大小
- e_shnum:2字节,节头表中条目的数量
- e_shstrndx:2字节,指明string name table在节头表中的索引index
程序头表组成
同理,程序头表也是以结构体形式存在,记录了程序各段的信息
- p_type:4字节,程序中段的类型
- p_offset:4字节,本段在文件内的起始偏移地址
- p_vaddr:4字节,本段在内存中的起始虚拟地址
- p_paddr:4字节,暂且保留,未设定
- p_filez:4字节,本段在文件中的大小
- p_memsz:4字节,本段在内存中的大小
- p_flags:4字节,指明与本段相关的标志,如本段的权限信息
- p_align:4字节,用来指明本段在文件和内存中的对齐方式。如果值为0或1,则表示不对齐。否则p_align应该是2的幂次数。
如何查看一个程序的elf header信息
如下,我们写一个简单的程序
|
|
然后使用gcc编译它
|
|
在Linux中,查看一个可执行文件的工具是objdump
,如下我们使用objdump
查看可执行程序main的头信息
|
|
显示的信息比较多,我们分开来看
|
|
这段信息告诉我们这是一个64位的ELF文件,并且该文件是针对 64 位 x86 架构构建的,支持兼容Intel 32 位指令集,程序的入口地址是0x00000000000004f0
|
|
我们主要看这段信息,这段信息就是我们说的elf header里的程序头表(Program Header table),再次强调
elf header里记录了程序头表和节头表的信息,程序头表记录了程序头(段)的信息,节头表记录了节的信息
观察上述程序头表的这段信息
这段信息起始就是代码段的相关信息,我们以这段信息为例:
- off:该段在目标文件中的偏移
- vaddr/paddr:该段在内存中的虚拟地址
- align:对齐要求
- filesz:目标文件中该段的大小
- memsz:内存中该段的大小
- flags:运行时该段的访问权限
|
|
因此上述信息告诉我们,代码段具有可读、执行权限,开始于虚拟内存地址0x0000000000000000处,总共的内存大小是0x00000000000007d8字节
同理以下是程序的数据段信息,这段信息告诉我们,数据段具有读写权限,开始于虚拟内存地址0x0000000000200df0处,总共的内存大小是0x0000000000000228字节
|
|
接下来我们再来看一下节的相关信息
|
|
我们仍旧截取一些我们熟悉的节进行说明
|
|
这段信息表示,这是一个.text节,隶属于代码段,只读,在Linux程序中的索引是12(程序以索引标记一个节),节的大小是00000192
同理以下是.data节的相关信息
|
|
除此以外,我们还需要知道,程序在链接的时候需要知道各种符号的信息,所谓符号就是函数名、全局变量名等,注意局部变量不是符合,因为局部变量本质上是在程序执行后存在于栈中,随着函数栈的开辟和销毁而生存和死亡
|
|
以上是我们程序的符号信息,这些符号信息记录在符号表中,我们还截取一些我们知道的说明
|
|
00000000000005fa:这是符号在内存中的地址,表示
main
函数在程序内存中的位置。g,符号类型:
g
表示符号是 全局符号。F,符号类型(进一步说明):
F
表示这个符号是一个 函数。因此,main
符号代表的是一个函数的入口。.text,节区(Section):
.text
是二进制文件中的代码段(text section)。在 ELF 文件中,.text
节区存放的是可执行代码0000000000000016,大小:这表示
main
函数的大小是16
字节(十六进制表示0x16
,即 22 字节)。main:这是符号的名称。这里表示的是程序的入口函数
main
。
同理,我们也可以找到全局变量num1的符号信息,其中O
表示符号是 对象,也就是说,num1
是一个数据对象,而不是一个函数。这通常指的是变量或常量,而不是执行代码的函数
|
|
观察上述符号信息,我们发现是没有num2这个局部变量的符号信息的,这个局部变量的生命周期由栈控制的,不参与链接过程
加载内核
代码目录结构
|
|
将内核载入内存
回忆我们之前加载mbr程序和loader程序的过程
- mbr程序固定在磁盘0块0扇区,大小固定512B,最后两个字节固定是0x55,0xaa,由BIOS开启后加载到内存的0x7c00位置处执行
- loader程序由我们自定义在磁盘0块2号扇区,同时由我们写的mbr程序将其加载到0x900位置处(可执行代码的位置是0xc00,中间是512字节的GDT和256字节的物理内存容量信息,这段0x300字节的信息是保存在内存中加载内核的必要信息,是不可执行的)
因此,本节我们加载内核文件也是同样的过程,也需要由loader将内核文件从磁盘的某个位置加载到内存的某个位置,于是我们定义
- 内核文件在磁盘的位置是0块9号扇区
- 加载到内存的物理位置是0x70000
另外我们的内核加载代码有以下特点
- 在保护模式下运行,保护模式是32位的,因此需要修改之前我们加载loader的16位程序
- 在分页机制开启之前进行加载,这一点并非固定,只是在分页机制开启之前加载,0x70000直接就是物理地址,我们可以直接内核加载到该物理地址处
要加载内核,我们首先需要写一个简单的内核代码
/lib/kernel/main.c
|
|
这里我们只是先写一段简单的代码以作测试,真正的内核代码我们后续再进行补充
编译内核
|
|
链接内核
|
|
这里有一个小插曲,由于目前我们使用的操作系统一般都是64位的,因此使用默认的编译器编译出来的结果都是64位的
故而在最后的编译阶段需要降低gcc版本至gcc-4.4,同时使用-m32选项使编译出来的结果是32位,否则便会报错
具体操作如下:
1.打开apt-get源
|
|
增加如下内容
|
|
2.更新apt源
|
|
3.安装gcc-4.4
|
|
4.如果最后仍旧报错,则执行
|
|
接下来我们开始正式将上述编译成功的内核文件kernel.bin加载到内存,以下是加载内核的代码
/boot/loader.S
|
|
代码中相关的宏如下所示
/boot/include/boot.inc
|
|
同mbr加载loader一样,我们使用写好的函数将内核文件从磁盘加载到内存指定位置,只是这里是在32位保护模式下运行,因此需要简单修改一下代码,如下所示
/boot/loader.S
|
|
解析内核头文件,加载内核映像
内核映像,其实就是解析内核的elf头之后,将内核文件的程序代码和程序数据抽出来,然后放到内存的指定位置处执行,之所以这样做是因为elf头文件只是记录程序信息的结构化信息,不能执行,但是计算机不知道这一点,需要我们执行这一步操作
注意,解析内核头文件的工作是在开启分页机制后进行的,因为内核将来要做分页机制下工作,因此我们直接在分页机制下加载内核映像
那么问题来了,我们要将内核映像加载到内存的哪里呢?
- 关于这个问题,我们知道,将来我们的内核会越来越大,而我们的内核物理空间在低1M字节这块区域内,因此我们尽量让我们的内核映像放置到内存低字节位置,这样将来的内核才有可余空间增长
- 另一方面,我们知道,我们的loader程序已经占据在了内存的0x900位置,其中0x900 ~ 0xb00是GDT的信息,0xb00 ~ 0xc00是存放的物理内存容量信息,这些信息都不能被覆盖,因此我们的内核映像不能覆盖掉loader
- 于是乎,假设我们loader大小最大是2000B,则0x900+2000=0x10d0,向上取整就将内核的物理加载地址选为0x1500
- 读者会发现,之前我们给内核文件加载的位置的0x70000,而我们的内核映像是在0x1500,如果内核映像覆盖掉原始内核文件怎么办,这一点不必担心,因为内核文件在被抽离出来内核映像之后就没用了,因此即使覆盖掉也没关系
综上,我们有如下定义
/boot/include/boot.inc
|
|
注意,
0xc0001500
这个内核映像的虚拟入口地址是由gcc-4.4编译器用选项-Ttext 0x00001500
指定的
另外,我们还需要添加一段宏定义信息,在提取elf头信息时使用
|
|
有了以上准备后,我们就可以正式加载内核映像了
/boot/loader.S
|
|
代码逻辑很简单:
- 从elf头中读取程序头表的信息
- 程序头表的初始偏移
- 程序头表中条目(记录段的信息)的数量
- 程序头表中每个条目的大小
- 读取到程序头表的信息后,我们就可以像遍历数组一样遍历程序头表,取出程序头表中的每个程序头(也就是段头)的信息
- 本段在文件内的大小
- 本段在文件内的起始偏移
- 本段在内存中的起始虚拟地址
- 将段复制到内存指定的虚拟地址处
以下是完整代码
/boot/include/boot.inc
|
|
/boot/loader.S
|
|
编译运行
/start.sh
|
|