操作系统实战之从裸机到内核(x86-64)

时间:2022-07-23 02:56:03

裸机与引导程序

  裸机是什么?
    裸机其实就是不包含操作系统的计算机,若是定义的更严格一点,那可以说是不包含任何应用程序的计算机。但对PC来讲,PC出厂时都是被厂商烧入了BIOS的,所以裸机的叫法一般就指只包含BIOS的计算机了。
  x86平台的引导
    x86平台的引导对于很多有心人应该在网络上很多地方了解过了,但要说操作系统却总是避免不了说的这些,所以这儿就以尽量简单易懂的方式向大家讲述这是怎么一回事。
    前面说道PC出厂时都被厂商烧入了BIOS程序,而BIOS的功能至少有以下几点:
    硬件自举与初始化
    中断服务
    基本引导
    计算机上电或复位后,在执行第一条指令前,cs寄存器被置为0xf000, ip指令指针寄存器置为0xfff0,对于i8086的机器,寻址方式遵循实模式下的寻址,第一条指令的地址就是0xffff0,即1M地址的末尾16字节开始处,但从i80386以后,第一条指令的寻址方式就是通过CS的隐藏寄存器寻址,如下图是在执行第一条指令前CS寄存器及其隐藏寄存器的状态。
    操作系统实战之从裸机到内核(x86-64)
    其中隐藏寄存器dh,dl中保存的其实就是段描述符,在执行第一条指令前PC还没有到实模式,当然也不是保护模式,不过寻址却采用保护模式的那种机制,从图中看出段基址base=0xffff0000,再加上偏移量,ip=0xfff0,所以pc上电或复位后执行的第一条指令就是地址0xfffffff0处的指令(不了解保护模式下的寻址方式的可百度)。
    接着便是执行BIOS代码,因为intel规定在即非保护模式,也非实模式的状态下重新给CS寄存器载入值后,CPU将进入实模式,所以,执行完第一条指令后(jmpf 0xf000:e05b),CS的值会重新载入(长跳转的特征),CPU将进入实模式。至此,CPU将在实模式下开始执行BIOS例程,当硬件,中断等初始化完毕后,BIOS将按指定的启动顺序读取硬盘或软盘等第一个扇区的512字节到0x7c00地址处,并跳转到该地址处开始执行。
  引导程序例子
    从以上介绍的80x86平台上电或复位后的启动特征,我们可以知道在磁盘或软盘等存储设备第一个扇区(MBR)中存储的代码即为在BIOS例程执行后的首先开始执行的代码。所以我们可以将我们想要执行的代码写入磁盘的MBR中,这样就能够在裸机上执行我们设计的程序了。如下是一个例子及在vbox虚拟机上的演示。

/* header.S */
BOOTSEG = 0x07c0
SYSSEG = 0x10000
VIDEO_TEXT_SEG = 0xb800

#define MSG(x, color, offset) movw $x, %si; \
movw $color, %dx; \
movw $offset, %di; \
call message
#define OFFSET(x) (x*2)

.section ".bstext", "ax"
.global bs_sect_start
bs_sect_start:
ljmp $BOOTSEG, $_start
_start:
movw %cs, %ax
movw %ax, %ds
movw %ax, %ss
xorw %sp, %sp

//set %es segment
movw $VIDEO_TEXT_SEG, %ax
movw %ax, %es
sti
cld

MSG(notify, 0x7, OFFSET(0))
1:
hlt
jmp 1b
notify:
.asciz "/* Under glibc some of the constants involved have gotten"
/*
src: ds:si
dst: es:di
*/

1:
stosb
movb %dl, %al
stosb
message:
lodsb
cmp $0x0, %al
jne 1b
ret

.org 510
.short 0xaa55

  以上代码是我们设计的将要放到MBR中的代码段,其效果就是在屏幕上打印notify标识处定义的那字符串,其中涉及到操纵屏幕使其显示字符这个关键的知识点。0xA0000开始的一段地址范围被用来寻址图形模式的显存,而0xB8000开始的一段地址范围用来寻址字符模式的显存,而要在屏幕上显示,只需按显存每个字节的定义操作显存即可,比如0xb8000处的这个字节代码屏幕上左上角第一个位置处显示的字符的值,下一个字节就代表第一个位置显示字符的显示属性,之后的以此类推…
  代码中还涉及到寄存器的初始化,这个根据实际情况可能是必须的,也可能不必须,不过通常在寄存器未被初始化的情况下,尽量先初始化必要的寄存器后再做其它操作。
  末尾在510字节的偏移处填充了0xaa55这个值,这个值是必须的,因为在BIOS加载MBR并跳转到0x7c00地址处之前,会先验证0x7efe处的值是否是0xaa55,这是一个魔数(magic),只是为了判断MBR是否有效。
  编译与链接,要编译这个程序需要一些额外的东西,如链接脚本(想更深入了解链接脚本可参考http://blog.csdn.net/yxhlfx/article/details/54881941)。

/* setup.lds */
OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)

SECTIONS
{
. = 0;
.bstext : { *(.bstext) }
.bsdata : { *(.bsdata) }
.inittext : { *(.inittext) }
.initdata : { *(.initdata) }
__end_init = .;
.intext : { *(.intext) }
.text : { *(.text) }
.text32 : { *(.text32) }

}

  如下,有了以上的源码后,就可以开始编译了。

$ gcc -m32 -c header.S
$ ld header.o -T setup.lds -o boot
$ objcopy -R .note -R .comment -O binary boot boot.bin

  经过以上步骤,已经得到了可以放入MBR中的二进制文件(boot.bin),现在我们需要将其放入一块磁盘并从该磁盘启动来看看运行效果。
  假设你已经安装了VBOX,同时已经创建了一个虚拟机,和一个vhd格式的虚拟磁盘,正常情况下通过Vbox创建虚拟机时按照提示一步步创建VHD格式虚拟磁盘后,磁盘所在位置在”~/VirtualBox\ VMs/(你所创建的虚拟机名)/“目录下。我们需用用dd工具将上面我们得到的二进制代码写入虚拟磁盘。
  

$ dd if=boot.bin of=~/VirtualBox\ VMs/(你所创建的虚拟机名)/(你所创建的虚拟机名).vhd conv=notrunc

  完成以上操作后就可正常启动虚拟机,将会看到在虚拟机屏幕上打印出的“/* Under glibc some of the constants involved have gotten”。

引导程序到平台初始化

  从BIOS到kernel的几次跳转
  我们现在已经知道了从CPU上电复位到BIOS跳转到0x7c00开始执行的具体细节,但是这只是一个开始。grub/grub2是一个操作系统启动程序,也是现在linux发行版所主要使用的启动程序,所以我们就从grub2开始来进入kernel的世界。下图是以CD-ROM的方式启动后grub2引导启动内核的流程图。
  操作系统实战之从裸机到内核(x86-64)
  以上这些流程都可以通过调试的方法看到,具体调试观察的方法将会用新的文章描述,后续会在此处补充链接地址。
  我们在上图中看到了通过grub2引导内核其中大致经历的哪些动作,当然引导的过程这并不是唯一的,有些引导程序也会直接跳转到0x7e00处,由内核部分的代码来做后续引导,那儿的流程也变得更简单,因此内核提供内适应多种不同情况的入口点,以适应不同的bootloader程序,而关于引导也有了标准的规范,在内核源码Document/x86/boot.txt中可以查阅linux的引导接口与规范。
  平台设置与初始化
  从bootloader跳转到内核代码后,以grub2引导64位内核来说,从实模式到保护模式的跳转在grub2中已经完成了,所以grub2就直接跳转到了0x1000000这个入口点,在这儿需要做的只剩下重保护模式跳转到64位模式了,而除了这些模式的切换外,还有堆栈的建立,段描述符的设置,页表的构建等工作,当然,如果内核是压缩的,还需要解压内核。这些可以从linux/arch/x86/boot/compressed/head_64.S中看到。

  • 建立堆栈
      堆栈的建立比较*,只需选择一块合适的内存区域设置好堆栈指针即可建立堆栈,也就是堆栈指令如pop, push就可以使用了。在内核代码中定义了一块特定的堆栈区域,建立堆栈的代码如下:
      
leaq    boot_stack_end(%rbx), %rsp

  即将定义的堆栈区域的末尾地址赋值给rsp寄存器,这儿的堆栈建立已经是进入64位模式之后了。之前有一处建立堆栈的地方,当那儿不够直观易懂。至于到底需不需要建立堆栈,这就得根据实际情况来了,如果在代码段里没有任何地方会用到堆栈,那么甚至可以不建立任何堆栈结构。
- 加载段描述符表
  对于段描述符如果想要深入了解可以参考<<深入理解linux内核>>一书,里面有对段描述符的详细解释。说白了段描述符是用来寻址的,我们可以选择不同的段对不同的内存区域进行寻址。而不用改变线性地址。当然这样做的话得拥有足够多的段描述符才能用一个线性地址对所有的内存进行寻址了,但实际上段描述符最多只能有8191个,额外有一个空的段描述符。
  如下是内核中对段描述符表的一个定义及加载全局段描述符表的方法:
  

gdt:
.word gdt_end - gdt
.long gdt
.word 0
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00af9a000000ffff /* __KERNEL_CS */
.quad 0x00cf92000000ffff /* __KERNEL_DS */
.quad 0x0080890000000000 /* TS descriptor */
.quad 0x0000000000000000 /* TS continued */
gdt_end:

/* Load new GDT with the 64bit segments using 32bit descriptor */
leal gdt(%ebp), %eax
movl %eax, gdt+2(%ebp)
lgdt gdt(%ebp)

  在看上面全局描述符表定义前我们先来看看段描述符的定义及结构,由内存分段机制,我们可以将内存分成不同功能,不同大小的段,每个段由一块连续的内存区域组成,而段描述符就是描述一个段的相关属性的一个数据结构,段描述符在内存中的结构如下:
  操作系统实战之从裸机到内核(x86-64)
  也可以用如下结构体表示:

struct segment_descriptor{
union dl{
struct {
int16_t limit_l_16;
int16_t base_l_16;
};
int8_t reserve_dl[32];
};
union dh{
struct {
int8_t base_hl_8;
char type:4;
char _s:1;
char _dpl:2;
char _p:1;
char limit_h_4:4;
char _avl:1;
char _l:1;
char _d:1;
char _g:1;
int8_t base_hh_8;
};
int8_t reserve_dh[32];
}
} __attribute__((packed));

  因此要看懂段描述符,只需将其分成高32位和低32位即可,基地址就等于高32位中的最高的一个字节与其最低字节相连,再连接上低32位的最高的2字节。拿__KERNEL_CS来说,它的基地址就是0x00000000, 段界限就是0xfffff,即1M大小的空间。所以当全局描述符表被加载后,寻址时cs中应该保存的是段描述符在段描述符表中的索引号,其中__KERNEL_CS的索引号是1,如果当前选择的是__KERNEL_CS段,则逻辑地址即ip中的值就是相对于基地址0x00000000的偏移量。因此有了分段的机制后,我们就可以更加灵活的操作内存了,远远比实模式下的内存控制与操作来得方便。
- 开启分页机制
  我们已经看过了内存的分段机制以及在kernel中是怎么来使用该机制的,那现在我们再来看看x86-64架构下的cpu所提供的另外一种机制——分页。分页机制是在分段机制的基础上建立起来的,分段机制帮助我们把逻辑地址转化为线性地址,而分页机制则帮助我们进一步把线性地址转换成物理地址。一组连续的线性地址我们称其为页,而在物理上连续的一段内存区域我们称其为页框,页和页框定义的大小是一致的,只是一个是对线性地址的划分,一个是对物理内存的划分。还有一种数据结构称为页表,它保存了线性地址到物理地址的映射关系。
  对于分页,不得不涉及到x86的控制寄存器CR0,2~4,对于控制寄存器的Spec在附录已经给出,如果想看到更详细的Spec说明,需要查阅intel相关文档。查看spec我们可以发现要开启分页首先需要设置CR0寄存器的PG位,同时还要设置CR4寄存器以选择CPU的不同的模式。CR0寄存器在grub2中已经设置分页选项,这儿就可以不用设置;当然CR0还控制着CPU的其它特性,这在内核解压后在内核64位入口点的代码中会将那些特性都打开。这儿只对grub2引导之后所必须做的几件事做说明和分析。下面列出了构建页表及开启分页的代码:

    /* Enable PAE mode */
movl %cr4, %eax
orl $X86_CR4_PAE, %eax
movl %eax, %cr4

/*
* Build early 4G boot pagetable
*/

/* Initialize Page tables to 0 */
leal pgtable(%ebx), %edi
xorl %eax, %eax
movl $(BOOT_INIT_PGT_SIZE/4), %ecx
rep stosl

/* Build Level 4 */
leal pgtable + 0(%ebx), %edi
leal 0x1007 (%edi), %eax
movl %eax, 0(%edi)

/* Build Level 3 */
leal pgtable + 0x1000(%ebx), %edi
leal 0x1007(%edi), %eax
movl $4, %ecx
1: movl %eax, 0x00(%edi)
addl $0x00001000, %eax
addl $8, %edi
decl %ecx
jnz 1b

/* Build Level 2 */
leal pgtable + 0x2000(%ebx), %edi
movl $0x00000183, %eax
movl $2048, %ecx
1: movl %eax, 0(%edi)
addl $0x00200000, %eax
addl $8, %edi
decl %ecx
jnz 1b

/* Enable the boot page tables */
leal pgtable(%ebx), %eax
movl %eax, %cr3

  在分析上面创建页表的代码前我们先看看页表中保存的是什么东西。页表保存了页表项,而页表项则描述了某个线性地址在某个物理页框上的偏移,页表项(页目录项与页表项结构相同)在内存中的组织结构如下图所示:
操作系统实战之从裸机到内核(x86-64)
  依据这页表项的格式示意图就好理解在第4级页表为什么是将0x1007为偏移的一个地址填充进页表项了,因为低12位表示的不是地址,而是属性,所以实际的偏移应该是0x1000。
- 从32位保护模式进入64位模式(long mode)
  其实上面说了这么多所要做的只有一件事,就是为CPU进入64位保护模式后能够正常的对内存寻址进行配置,一切的目的都是位了对内存寻址。所以将这些东西的完成后,实际上就可以开始利用CPU的功能做更多的事儿了。而在Linux中对于内存管理的配置就更为复杂,所以以上完成的配置也只是最开始的一小部分配置而已。

从编译到链接

  编译阶段做了哪几件事儿
  以X86-64的linux内核编译为例,我们可以很容以的看到其显示将内核相关的源码(init ,mm,kernel)等编译链接成了一个64位的elf格式的二进制文件保存的内核根目录(即vmlinux)。得到最原始的内核后可能在编译选项中还选择了编译压缩内核,这是就会编译arch/x86/boot/compressed下的源码,过程如下:
  编译mkpiggy工具,然后将vmlinux内核文件编译成vmlinux.bin.gz这样的压缩二进制文件,之后利用mkpiggy工具生成piggy.S,再编译compressed目录下的所有源码文件,这样会生成一个包含自解压代码的内核文件(新的vmlinux),将其处理成纯二进制文件vmlinux.bin放到arch/x86/boot下;之后会编译内核header部分的代码生成一个setup.bin的二进制文件;这会儿就需要一个工具将setup.bin和vmlinux.bin文件连接到一起,这个工具是arch/x86/boot/tool下的build.c编译得到的,使用那个工具将setup.bin和vmlinux.bin连接到一起后就可以得到我们比较熟悉的bzImage的内核文件。

附录

  • x86中的控制寄存器的SPEC
      CR0控制寄存器:
    操作系统实战之从裸机到内核(x86-64)
      CR2控制寄存器:
    操作系统实战之从裸机到内核(x86-64)
      CR3控制寄存器:
    操作系统实战之从裸机到内核(x86-64)
      CR4控制寄存器:
    操作系统实战之从裸机到内核(x86-64)