Files
linux-insides-zh/Booting/linux-bootstrap-2md.md
2016-02-19 12:12:08 -05:00

22 KiB
Raw Blame History

在内核安装代码的第一步

#https://0xax.gitbooks.io/linux-insides/content/Booting/linux-bootstrap-2.html

内核启动的第一步

上一节中我们开始接触到内核启动代码,并且分析了初始化部分,最后我们停在了对main函数(main函数是第一个用C写的函数的调用main函数位于arch/x86/boot/main.c

在这一节中我们将继续对内核启动过程的研究,我们将

  • 认识保护模式
  • 如何从实模式进入保护模式
  • 堆和字符界面初始化
  • 内存检测cpu验证键盘初始化
  • 还有更多

现在让我们开始我们的旅程

保护模式

在操作系统可以使用Intel 64位CPU的长模式之前内核必须首先将CPU切换到保护模式运行。

什么是保护模式保护模式于1982年被引入到Intel CPU家族并且从那之后知道Intel 64出现保护模式都是Intel CPU的主要运行模式。

淘汰实模式的主要原因是因为在实模式下系统能够访问的内存非常有限。如果你还记得我们在上一节说的在实模式下系统最多只能访问1M内存而且在很多时候实际能够访问的内存只有640K。

保护模式带来了很多的改变不过只要的改变都集中在内存管理方法。在保护模式中实模式的20位地址线被替换成32位地址线因此系统可以访问多大4GB的地址空间。另外在保护模式中引入了内存分页功能,在后面我们将介绍这个功能。

保护模式提供了2种完全不同的内存关机机制

  • 段式内存管理
  • 内存分页

在这一节中,我们只介绍段式内存管理,内存分页我们将在后面的章节进行介绍。

在上一节中我们说过在实模式下一个物理地址是由2个部分组成的

  • 内存段的基地址
  • 从基地址开始的偏移

通过这2个信息我们可以通过下面的公式计算出对应的物理地址

PhysicalAddress = Segment * 16 + Offset

在保护模式中内存段的定义和实模式完全不同。在保护模式中每个内存段不再是64K大小段的大小和起始位置是通过一个叫做段描述符的数据结构进行描述的。所有内存段的段描述符存储在一个叫做全局描述符表(GDT)的内存结构中。

全局描述符表示一个内存数据结构,但是它在内存中的位置并不是固定的,它的地址保存在一个特殊寄存器GDTR中。在后面的章节中我们将在Linux内核代码中看到全局描述符表的地址是如何被保存到GDTR中的。具体的汇编代码看起来是这样的:

lgdt gdt

lgdt汇编代码将把全局描述符表的基地址和大小保存到GDTR寄存器中。GRTD是一个48位的寄存器这个寄存器中的保存了2部分的内容:

  • 全局描述符表的大小 (16位
  • 全局描述符表的基址 (32位)

就像前面的段落说的,全局描述符表包含了所有内存段的段描述符。每个段描述符长度是64位结构如下图描述

31          24        19      16              7            0
------------------------------------------------------------
|             | |B| |A|       | |   | |0|E|W|A|            |
| BASE 31:24  |G|/|L|V| LIMIT |P|DPL|S|  TYPE | BASE 23:16 | 4
|             | |D| |L| 19:16 | |   | |1|C|R|A|            |
------------------------------------------------------------
|                             |                            |
|        BASE 15:0            |       LIMIT 15:0           | 0
|                             |                            |
------------------------------------------------------------

粗粗一看上面的结构非常吓人不过实际上这个结构是非常容易理解的。比如在上图中的LIMIT 15:0表示这个数据结构的0到15位保存的内存段的大小的0到15位。相似的LIMITE 19:16表示上述数据结构的16到19位保存的是内存段大小的16到19位。从这个分析中我们可以看出每个内存段的大小是通过20位进行描述的。下面我们将对这个数据结构进行仔细分析

  1. Limit[20位]被保存在上述内存结构的0-15和16-19位。根据上述内存结构中G位的设置这20位内存定义的内存长度是不一样的。下面是一些具体的例子
  • 如果G= 0, 并且Limit = 0 那么表示段长度是1 byte
  • 如果G = 1, 并且Limit = 0, 那么表示段长度是4K bytes
  • 如果G = 0并且Limit = 0xfffff那么表示段长度是1M bytes
  • 如果G = 1并且Limit = 0xfffff那么表示段长度是4G bytes

从上面的例子我们可以看出:

  • 如果G = 0, 那么内存段的长度是按照1 byte进行增长的 ( Limit每增加1段长度增加1 byte )最大的内存段长度将是1M bytes
  • 如果G = 1, 那么内存段的长度是按照4K bytes ( Limit每增加1段长度增加4K bytes )进行增长的最大的内存段长度将是4G bytes;
  • 段长度的计算公司是 base_seg_length * ( LIMIT + 1)。
  1. Base[32-bits]被保存在上述地址结构的0-15 32-39以及56-63位。Base定义了段基址。

  2. Type/Attribute (40-47 bits) 定义了内存段的类型以及支持的操作。

  • S 标记( 第44位 )定义了段的类型,S = 0说明这个内存段是一个系统段S = 1说明这个内存段是一个代码段或者是数据段 堆栈段是一种特使类型的数据段,堆栈段必须是可以进行读写的段 )。

S = 1的情况下上述内存结构的第43位决定了内存段是数据段还是代码段。如果43位 = 0拿说明是一个数据段否则就是一个代码段。

对于数据段和代码段,下面的表格给出了段类型定义

|           Type Field        | Descriptor Type | Description
|-----------------------------|-----------------|------------------
| Decimal                     |                 |
|             0    E    W   A |                 |
| 0           0    0    0   0 | Data            | Read-Only
| 1           0    0    0   1 | Data            | Read-Only, accessed
| 2           0    0    1   0 | Data            | Read/Write
| 3           0    0    1   1 | Data            | Read/Write, accessed
| 4           0    1    0   0 | Data            | Read-Only, expand-down
| 5           0    1    0   1 | Data            | Read-Only, expand-down, accessed
| 6           0    1    1   0 | Data            | Read/Write, expand-down
| 7           0    1    1   1 | Data            | Read/Write, expand-down, accessed
|                  C    R   A |                 |
| 8           1    0    0   0 | Code            | Execute-Only
| 9           1    0    0   1 | Code            | Execute-Only, accessed
| 10          1    0    1   0 | Code            | Execute/Read
| 11          1    0    1   1 | Code            | Execute/Read, accessed
| 12          1    1    0   0 | Code            | Execute-Only, conforming
| 14          1    1    0   1 | Code            | Execute-Only, conforming, accessed
| 13          1    1    1   0 | Code            | Execute/Read, conforming
| 15          1    1    1   1 | Code            | Execute/Read, conforming, accessed

从上面的表格我们可以看出当第43位是0的时候,这个段描述符对应的是一个数据段,如果该位是1那么表示这个段描述符对应的是一个代码段。对于数据段第424140位表示的是(E扩展,W可写,A可访问对于代码段第424140位表示的是(C一致,R可读,A可访问)。 Accessible) or CRA(Conforming Readable Accessible)。

  • 如果E = 0数据段是向上扩展数据段反之为向下扩展数据段。关于向上扩展和向下扩展数据段可以参考下面的链接。在一般情况下,应该是不会使用向下扩展数据段的。
  • 如果W = 1说明这个数据段是可写的否则不可写。所有数据段都是可读的。
  • A位表示该内存段是否已经被CPU访问。
  • 如果C = 1说明这个代码段可以被第优先级的代码访问比如可以被用户态代码访问。反之如果C = 0说明只能同优先级的代码段可以访问。
  • 如果R = 1说明该代码段可读。代码段是永远没有写权限的。
  1. DPL[2-bits, bit 45 和 46] (描述符优先级) 定义了该段的优先级。具体数值是0-3。

  2. P 标志(bit 47) - 说明该内存段是否已经存在于内存中。如果P = 0那么在访问这个内存段的时候将报错。

  3. AVL 标志(bit 52) - 这个位在Linux内核中没有被使用。

  4. L 标志(bit 53) - 只对代码段有意义,如果L = 1说明该代码段需要运行在64位模式下。

  5. D/B flag(bit 54) - 根据段描述符描述的是一个可执行代码段、下扩数据段还是一个堆栈段这个标志具有不同的功能。对于32位代码和数据段这个标志应该总是设置为1对于16位代码和数据段这个标志被设置为0。

  • 可执行代码段。此时这个标志称为D标志并用于指出该段中的指令引用有效地址和操作数的默认长度。如果该标志置位则默认值是32位地址和32位或8位的操作数如果该标志为0则默认值是16位地址和16位或8位的操作数。指令前缀0x66可以用来选择非默认值的操作数大小前缀0x67可用来选择非默认值的地址大小。
  • 栈段由SS寄存器指向的数据段。此时该标志称为BBig标志用于指明隐含堆栈操作如PUSH、POP或CALL时的栈指针大小。如果该标志置位则使用32位栈指针并存放在ESP寄存器中如果该标志为0则使用16位栈指针并存放在SP寄存器中。如果堆栈段被设置成一个下扩数据段这个B标志也同时指定了堆栈段的上界限。
  • 下扩数据段。此时该标志称为B标志用于指明堆栈段的上界限。如果设置了该标志则堆栈段的上界限是0xFFFFFFFF4GB如果没有设置该标志则堆栈段的上界限是0xFFFF64KB

在保护模式下,段寄存器保存的不再是一个内存段的基地址,而是一个称为段选择子的结构。每个段描述符都对应一个段选择子段选择子是一个16位的数据结构下图显示了这个数据结构的内容

-----------------------------
|       Index    | TI | RPL |
-----------------------------

其中,

  • Index 表示在GDT中对应段描述符的索引号。
  • TI 表示要在GDT还是LDT中查找对应的段描述符
  • RPL 表示请求者优先级。这个优先级将和段描述符中的优先级协同工作,共同确定访问是否合法。

在保护模式下每个段寄存器实际上包含下面2部分内容

  • 可见部分 - 段选择子
  • 隐藏部分 - 段描述符

在保护模式中cpu是通过下面的步骤来找到一个具体的物理地址的

  • 代码必须将相应的段选择子装入某个段寄存器
  • CPU根据段选择子从GDT中找到一个匹配的段描述符然后将段描述符放入段寄存器的隐藏部分
  • 在没有使用向下扩展段的时候,那么内存段的基地址就是段描述符中的基地址,段描述符的limit + 1就是内存段的长度。如果你知道一个内存地址的偏移,那么在没有开启分页机制的情况下,这个内存的物理地址就是基地址+偏移

linear address

当代码要从实模式进入保护模式的时候,需要执行下面的操作: is:

  • 禁止中断发生
  • 使用命令lgdt将GDT表装入内存
  • 设置CR0寄存器的PE位为1是CPU进入保护模式
  • 跳转开始执行保护模式代码

在后面的章节中我们将看到Linux 内核中完整的转换代码。不过在系统进入保护模式之前,还有很多准备工作需要完成。

让我们代开C文件 arch/x86/boot/main.c。这个文件包含了很多的函数,这些函数分别会执行键盘初始化,内存堆初始化等等操作...,下面让我们来具体看一些重要的函数。

将启动参数拷贝到"zeropage"

让我们从main函数开始看起,这个函数中,首先调用了copy_boot_params(void)

这个函数将内存设置信息拷贝到boot_params结构的相应字段。大家可以在arch/x86/include/uapi/asm/bootparam.h找到boot_params结构的定义。

  1. header.S中定义的hdr结构中的内容拷贝到boot_params结构的字段struct setup_header hdr中。

  2. 如果内核是通过老的命令行协议运行起来的,那么就更新内核的命令行指针。

这里需要注意的是拷贝hdr数据结构的memcpy函数不是C语言中的函数而是定义在 copy.S。让我们来具体分析一下这段代码:

GLOBAL(memcpy)
	pushw	%si          ;push si to stack
	pushw	%di          ;push di to stack
	movw	%ax, %di     ;move &boot_param.hdr to di
	movw	%dx, %si     ;move &hdr to si
	pushw	%cx          ;push cx to stack ( sizeof(hdr) )
	shrw	$2, %cx    
	rep; movsl           ;copy based on 4 bytes
	popw	%cx          ;pop cx
	andw	$3, %cx      ;cx = cx % 4
	rep; movsb           ;copy based on one byte
	popw	%di
	popw	%si
	retl
ENDPROC(memcpy)

copy.S文件中,你可以看到所有的方法都开始于GLOBAL宏定义,而结束于ENDPROC宏定义。

你可以在 arch/x86/include/asm/linkage.h中找到GLOBAL宏定义。这个宏给代码段分配了一个名字标签,并且让这个名字全局可用。

#define GLOBAL(name)	\
	.globl name;	\
	name:

你可以在include/linux/linkage.h中找到ENDPROC宏的定义。 这个宏通过END(name)代码标识了汇编函数的结束,同时将函数名输出,从而静态分析工具可以找到这个函数。

#define ENDPROC(name) \
	.type name, @function ASM_NL \
	END(name)

memcpy的实现代码是很容易理解的。首先,代码将sidi寄存器的值压入堆栈进行保存,这么做的原因是因为后续的代码将修改sidi寄存器的值。memcpy函数也包括其他定义在copy.s中的其他函数使用了fastcall调用规则,意味着所有的函数调用参数是通过ax, dx, cx寄存器传入的,而不是传统的通过堆栈传入。因此在使用下面的代码调用memcpy函数的时候

memcpy(&boot_params.hdr, &hdr, sizeof hdr);

函数的参数是这样传递的

  • ax 寄存器指向boot_param.hdr的内存地址
  • dx 寄存器指向hdr的内存地址
  • cx 寄存器包含hdr结构的大小

memcpy函数在将sidi寄存器压栈之后,将boot_param.hdr的地址放入di寄存器,将hdr的地址放入si寄存器,并且将hdr数据结构的大小压栈。 接下来代码首先以4个字节为单位si寄存器指向的内存内容拷贝di寄存器指向的内存。当剩下的字节数不足4字节的时候代码将原始的hdr数据结构大小出栈放入cx,然后对cx的值对4求模接下来就是根据cx的值,以字节为单位将si寄存器指向的内存内容拷贝到di寄存器指向的内存。当拷贝操作完成之后,将保留的si以及di寄存器值出栈,函数返回。

控制台初始化

hdr结构体被拷贝到boot_params.hdr成员之后,系统接下来将进行控制台的初始化。控制台初始化时通过调用arch/x86/boot/early_serial_console.c中定义的console_init函数实现的。

这个函数首先查看命令行参数是否包含earlyprintk选项。如果命令行参数包含该选项,那么函数将分析这个选项的内容。得到控制台将使用的串口信息,然后进行串口的初始化。以下是earlyprintk选项可能的取值:

  • serial,0x3f8,115200
  • serial,ttyS0,115200
  • ttyS0,115200

当串口初始化成功之后,我们将看到如下的输出如果命令行参数包含debug选项。

if (cmdline_find_option_bool("debug"))
    puts("early console in setup code\n");

puts函数定义在tty.c。这个函数只是简单的调用putchar函数将输入字符串中的内容按字节输出。下面让我们来看看putchar函数的实现:

void __attribute__((section(".inittext"))) putchar(int ch)
{
    if (ch == '\n')
        putchar('\r');

    bios_putchar(ch);

    if (early_serial_base != 0)
        serial_putchar(ch);
}

__attribute__((section(".inittext"))) 说明这段代码将被放入.inittext代码段。关于.inittext代码段的定义你可以在 setup.ld中找到。

首先如果需要输出的字符是\n,那么putchar函数将调用自己首先输出一个字符\r。接下来,就调用bios_putchar函数将字符输出到显示器使用bios int10中断

static void __attribute__((section(".inittext"))) bios_putchar(int ch)
{
    struct biosregs ireg;

    initregs(&ireg);
    ireg.bx = 0x0007;
    ireg.cx = 0x0001;
    ireg.ah = 0x0e;
    ireg.al = ch;
    intcall(0x10, &ireg, NULL);
}

在上面的代码中initreg函数接受一个biosregs结构的地址作为输入参数,该函数首先调用memset函数将biosregs结构体所有成员清0。

    memset(reg, 0, sizeof *reg);
    reg->eflags |= X86_EFLAGS_CF;
    reg->ds = ds();
    reg->es = ds();
    reg->fs = fs();
    reg->gs = gs();

下面让我们来看看memset函数的实现 :

GLOBAL(memset)
    pushw   %di
    movw    %ax, %di
    movzbl  %dl, %eax
    imull   $0x01010101,%eax
    pushw   %cx
    shrw    $2, %cx
    rep; stosl
    popw    %cx
    andw    $3, %cx
    rep; stosb
    popw    %di
    retl
ENDPROC(memset)

首先你会发现,memset函数和memcpy函数一样使用了fastcall调用规则,因此函数的参数是通过axdx以及cx寄存器传入函数内部的。

就像memcpy函数一样memset函数一开始将di寄存器入栈,然后将biosregs结构的地址从ax寄存器拷贝到di寄存器。记下来,使用movzbl指令将dl寄存器的内容拷贝到ax寄存器的滴字节,到这里ax寄存器就包含了需要拷贝到di寄存器所指向的内存的值。

接下来的imull指令将eax寄存器的值乘上0x01010101。这么做的原因是代码每次将尝试拷贝4个字节内存的内容。下面让我们来看一个具体的例子假设我们需要将0x7这个数值放到内存中,在执行imull指令之前,eax寄存器的值是0x7,在imull指令被执行之后,eax寄存器的内容变成了0x070707074个字节的0x7)。在imull指令之后,代码使用rep; stosl指令将eax寄存器的内容拷贝到es:di指向的内存。

bisoregs结构体被initregs函数正确填充之后,bios_putchar 调用中断 0x10 在显示器上输出一个字符。接下来putchar函数检查是否初始化了串口,如果串口被初始化了,那么将调用serial_putchar将字符输出到串口。

堆初始化

当堆栈和bss段在header.S中被初始化之后 (细节请参考上一篇part), 内核需要初始化全局堆,全局堆heap 的初始化是通过 init_heap function.

First of all init_heap checks the CAN_USE_HEAP flag from the loadflags in the kernel setup header and calculates the end of the stack if this flag was set:

    char *stack_end;

    if (boot_params.hdr.loadflags & CAN_USE_HEAP) {
        asm("leal %P1(%%esp),%0"
            : "=r" (stack_end) : "i" (-STACK_SIZE));

or in other words stack_end = esp - STACK_SIZE.

Then there is the heap_end calculation:

    heap_end = (char *)((size_t)boot_params.hdr.heap_end_ptr + 0x200);

which means heap_end_ptr or _end + 512(0x200h). The last check is whether heap_end is greater than stack_end. If it is then stack_end is assigned to heap_end to make them equal.

Now the heap is initialized and we can use it using the GET_HEAP method. We will see how it is used, how to use it and how the it is implemented in the next posts.