Linux从开机开始的分析(4):转入64位模式

 

向64位的转换

内核启动程序第四阶段,我们将看到在保护模式下进行的每一步,例如,检查cpu是否支持长模式和sse.初始化页表,在最后将cpu转换为长模式处理

前一段中我们停留在跳转到32位入口点处arch/x86/boot/pmjump.S:

jmpl    *%eax

eax中储存着入口点地址

linux kernel x86 boot protocol:

When using bzImage, the protected-mode kernel was relocated to 0x100000

如下,32位入口点时寄存器值

eax            0x100000    1048576
ecx            0x0        0
edx            0x0        0
ebx            0x0        0
esp            0x1ff5c    0x1ff5c
ebp            0x0        0x0
esi            0x14470    83056
edi            0x0        0
eip            0x100000    0x100000
eflags         0x46        [ PF ZF ]
cs             0x10    16
ss             0x18    24
ds             0x18    24
es             0x18    24
fs             0x18    24
gs             0x18    24

cs寄存器值为0x10,(在前一部分提到过,这是GDT表下标为2的地方),eip值为0x100000,包括代码段在内的所有基址都是0

所以内核加载的物理地址明显是0x100000 或者说是0:0x100000

 

32位入口点

这一部分定义在arch/x86/boot/compressed/head_64.S

    __HEAD
    .code32
ENTRY(startup_32)
....
....
....
ENDPROC(startup_32)

首先,为什么该目录名是compressed,? bzimage是一个gzipped打包的文件

vmlinux``headerkernel setup code组成.在前面我们已经看过kernel set code这些代码的主要目的就是为进入长模式做准备,进入长模式后解压内核文件.我们将在这一部分逐步展开内核解压的部分.

我们在arch/x86/boot/compressed目录下会找到以下两个文件

在这里我们只讨论head_64.S;

首先看一看Makefile文件

vmlinux-objs-y := $(obj)/vmlinux.lds $(obj)/head_$(BITS).o $(obj)/misc.o \
    $(obj)/string.o $(obj)/cmdline.o \
    $(obj)/piggy.o $(obj)/cpuflags.o

$(obj)/head_$(BITS).o.这意味着我们需要通过$(bits)选择哪一种文件来设定.而$(BITS)的值在 arch/x86/Makefile中通过内核选项被定义

ifeq ($(CONFIG_X86_32),y)
        BITS := 32
        ...
        ...
else
        BITS := 64
        ...
        ...
endif

现在我们知道了从哪里开始,然后继续.

 

重新加载段(如果需要)

像上面所说的,我们在 arch/x86/boot/compressed/head_64.S 开始,首先看到一个特殊的定义在startup_32函数前.

    __HEAD
    .code32
ENTRY(startup_32)

__HEAD是一个宏 ,展开后如下

#define __HEAD        .section    ".head.text","ax"

.head.text是该段的名称,ax是标记位,这里的标记代表该段是可执行的.这里在链接器脚本中

SECTIONS
{
    . = 0;
    .head.text : {
        _head = . ;
        HEAD_TEXT
        _ehead = . ;
     }
     ...
     ...
     ...
}

如果对GNU LD链接器脚本并不熟悉,可以参考documentation.简而言之,.是一个特殊的链接器脚本变量,位置计数器.这个值标志着跟这一个段相关的偏移.我们将这个值设置为0,意思是我们的代码被链接从0偏移处执行.这一点被表述为如下

Be careful parts of head_64.S assume startup_32 is at address 0.

现在我们找到了中心,我们看看startup_32函数的内容

startup_32函数的开头,我们看到cld指令,清除flags寄存器的DF位.当方向位清除后,所有的字符处理指令stos``scas都会增加esi和edi寄存器.我们需要先清除方向位,因为接下来我们会使用字符操作来运行一些例如为页表清除空间的操作

在清除DF位后,下一步是检查Keep_SEGMENTS位,位于loadflagkernel setup header. 在第一段中已经讲述过这一块的内容. 检查CAN_USE_HEAP标志位来确定使用堆的能力,.然后我们需要检查KEEP_SEGMENT标志位,在引导协议的文档里描述如下:

Bit 6 (write): KEEP_SEGMENTS
  Protocol: 2.07+
  - If 0, reload the segment registers in the 32bit entry point.
  - If 1, do not reload the segment registers in the 32bit entry point.
    Assume that %cs %ds %ss %es are all set to flat segments with
        a base of 0 (or the equivalent for their environment).

如果KEEP_SENGMENT位没有被设置,我们需要设置ds``sses段寄存器为数据段的下标,基址为0;然后

    testb $KEEP_SEGMENTS, BP_loadflags(%esi)
    jnz 1f

    cli
    movl    $(__BOOT_DS), %eax
    movl    %eax, %ds
    movl    %eax, %es
    movl    %eax, %ss

__BOOT_DS值为0x18(全局描述符里数据段的下标),如果KEEP_SEGMENT被设置,跳转到最近的1f标签,如果没有,则通过__BOOT_DS更新段寄存器.这一点很简单,但是仍有一些问题:我们已经更新过这些寄存器在前一部分(具体在转换到保护模式之前arch/x86/boot/pmjump.S)为什么我们仍需要再次关心这些段寄存器的值.

原因是linux内核也有32位的引导协议,如果引导器使用这个协议去加载内核,所有在startup_32之前的代码都会被忽略.这种情况下startup_32会成为第一个入口点.此时我们无法确定段寄存器是否在一个确定的值

在确定KEEP_SEGMENT位和寄存器都处于正确的状态,下一步是计算内核编译运行的地址和加载进入的地址之间的差别.在setup.ld.s.head.text我们知道.=0.这意味着代码编译运行在0处.obj-dump的输出里可以看出

arch/x86/boot/compressed/vmlinux:     file format elf64-x86-64


Disassembly of section .head.text:

0000000000000000 <startup_32>:
   0:   fc                      cld
   1:   f6 86 11 02 00 00 40    testb  $0x40,0x211(%rsi)

这里看到startup_32的地址为0 但实际上并非如此,我们需要知道实际地址在哪,

这些在长模式下做起来是很简单的.因为他提供了rip,但现在我们在保护模式下,我们使用另外一个方式来寻找地址.我们需要定义一个标签,调用跳转到那里 将栈顶的值pop到一个寄存器中.

call label
label: pop %reg

之后,寄存器保存label的地址.

接下来是用来寻找地址,通过以下代码

        leal    (BP_scratch+4)(%esi), %esp
        call    1f
1:      popl    %ebp
        subl    $1b, %ebp

esi寄存器包含了boot params的地址,该结构体的0x1e4偏移处是一个为call指令准备的一个暂时的栈区域,我们设置esp为这个栈地址+4的地方,正如描述的那样,他成为了临时的栈空间,同时栈自顶向下增长在x86架构下.因此我们的栈指针指向临时栈空间的顶部,然后我们调用1f处标签,将栈顶放入ebp中,由于call将返回地址存在栈顶,我们现在有了label1处的地址,通过这个地址,很容易计算出startup_32的地址.我们只需要将label1地址减去对应偏移.

startup_32 (0x0)     +-----------------------+
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
1f (0x0 + 1f offset) +-----------------------+ %ebp - real physical address
                     |                       |
                     |                       |
                     +-----------------------+

在内核引导协议中说保护模式内核的基址为0x100000,我们可以用gdb来验证.

如果这是正确的,我们在ebp寄存器里看到的值就应该是0x100021

$ gdb
(gdb)$ target remote :1234
Remote debugging using :1234
0x0000fff0 in ?? ()
(gdb)$ br *0x100022
Breakpoint 1 at 0x100022
(gdb)$ c
Continuing.

Breakpoint 1, 0x00100022 in ?? ()
(gdb)$ i r
eax            0x18    0x18
ecx            0x0    0x0
edx            0x0    0x0
ebx            0x0    0x0
esp            0x144a8    0x144a8
ebp            0x100021    0x100021
esi            0x142c0    0x142c0
edi            0x0    0x0
eip            0x100022    0x100022
eflags         0x46    [ PF ZF ]
cs             0x10    0x10
ss             0x18    0x18
ds             0x18    0x18
es             0x18    0x18
fs             0x18    0x18
gs             0x18    0x18

下一条执行语句 subl $1b, %ebp,我们将看到

(gdb) nexti
...
...
...
ebp            0x100000    0x100000
...
...
...

ok,我们确定了startup_32的地址是0x100000.在知道了地址后,准备像长模式的转变.接下来开始设置栈,并验证cpu支持长模式和sse.

 

设置栈以及cpu验证

在知道startup_32实际地址前我们无法设置栈空间,如果把栈想象为一个数组,栈指针esp必须指向它的尾部,当然,我们也可以在自己的代码里设置一个数组,但是我们必须首先知道实际的地址来正确的配置栈指针.

    movl    $boot_stack_end, %eax
    addl    %ebp, %eax
    movl    %eax, %esp

boot_stack_end也是定义在arch/x86/boot/compressed/head_64.S 里,位于.bss段内

    .bss
    .balign 4
boot_heap:
    .fill BOOT_HEAP_SIZE, 1, 0
boot_stack:
    .fill BOOT_STACK_SIZE, 1, 0
boot_stack_end:

首先将boot_stack_end值放入eax寄存器中,在链接后eax寄存器储存了boot_stack_end的值,即0x0+boot_stack_end为了获取真实地址,将startup_32的真实地址加上.就是boot_stack_end,将esp调整为boot_stack_end.栈指针指向正确的栈顶

设立好栈空间后,接下来是cpu的检查,由于我们要转向长模式cpu必须支持长模式和sse,这些通过verify_cpu来执行

    call    verify_cpu
    testl    %eax, %eax
    jnz    no_longmode

该函数定义在arch/x86/kernel/verify_cpu.S 而且包含了很多对于cpuid的调用.这个指令用来获取关于处理器的信息.在这里,他检查长模式和sse支持情况以及设置eax寄存器为0代表成功,1代表失败.

如果eax不是0,跳转到no_longmode,当没有硬件中断时,通过hlt语句终止cpu,

no_longmode:
1:
    hlt
    jmp     1b

如果是0,则一切正常继续

 

计算重定位后的地址

下一步是为解压缩计算重定位后地址.首先我们需要知道对于内核来说可重定位意味着什么,我们已经知道了linux内核中32位地址的入口基址是0x100000,但这是一个32位的入口点,默认的基址在CONFIG_PHYSICAL_START内核设置选项中被设定,这个值默认是0x1000000 ,但主要的问题是如果内核崩溃,内核开发人员必须有一个急救内核用来使用kdump.

内核提供了一个特殊的选项来解决这个问题:CONGFIG_RELOCATABLE,在内核文档中这样描述

This builds a kernel image that retains relocation information
so it can be loaded someplace besides the default 1MB.

Note: If CONFIG_RELOCATABLE=y, then the kernel runs from the address
it has been loaded at and the compile time physical address
(CONFIG_PHYSICAL_START) is used as the minimum location.

 

重新加载段

在前文中看到

#define __HEAD        .section    ".head.text","ax"

正常情况下,这意味着带有这个选项的内核可以从不同的地址开始引导.实际上,在编译,这些作为位置独立的代码,在makefile文件中编译选项中-fpic

KBUILD_CFLAGS += -fno-strict-aliasing -fPIC

当我们使用这些代码时,地址会被填充.这也是为什么我们必须要取得startup_32物理地址的原因,我们现在的目标是为解压器计算出在内核中重定位后的地址.,计算这个地址依赖于CONFIG_RELOCATABLE内核选项

#ifdef CONFIG_RELOCATABLE
    movl    %ebp, %ebx
    movl    BP_kernel_alignment(%esi), %eax
    decl    %eax
    addl    %eax, %ebx
    notl    %eax
    andl    %eax, %ebx
    cmpl    $LOAD_PHYSICAL_ADDR, %ebx
    jge    1f
#endif
    movl    $LOAD_PHYSICAL_ADDR, %ebx

ebp寄存器的值是startup_32的地址,如果CONFIG_RELOCATABLE设置了,我们将ebp的值放入ebx,将它按2mb对齐,然后将结果与LOAD_PHYSICAL_ADDR宏比较,

#define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \
                + (CONFIG_PHYSICAL_ALIGN - 1)) \
                & ~(CONFIG_PHYSICAL_ALIGN - 1))

该宏被拓展为CONFIG_PHYSICAL_ALIGN 对齐后的值,代表内核地址被加载的地址.

在以上的计算之后,ebp会存有内核加载的地址,ebx是解压内核重定位后的地址,压缩的内核镜像需要移动到解压缓冲区,

1:
    movl    BP_init_size(%esi), %eax
    subl    $_end, %eax
    addl    %eax, %ebx

准备转入长模式

获取到重定位后的解压地址后,我们需要在能进入64位做最后一步.

首先,将全局描述符表更新到64位.因为可重定位的内核可以在512GB下的任何地址运行.

    addl    %ebp, gdt+2(%ebp)
    lgdt    gdt(%ebp)

这里,我们矫正GDT表的基地址为我们实际上加载内核的地址.通过lgdt加载全局描述符表

为了了解gdt偏移的魔数,我们看一看它的定义

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

gdt位于.data,包含了五个描述符,第一个是32-bit描述符为内核代码段

内核64-bit代码段,内核数据段,两个任务描述符

我们在之前已经加载了Global Descriptor Table,现在我们会再做一次基本一样的事情.不同的是我们设置描述符时让CS.L = 1 CS.D = 0 ,这里是为了64位的执行.gdt以一个2byte的值开始,gdt_end - gdt,代表gdt表的最后byte,或者说是表的限制地址.接下来4byte包含了gdt表的基址.

在通过lgdt加载GDT表后,我们通过将cr4寄存器的值放入eax中来启用PAE

    movl    %cr4, %eax
    orl    $X86_CR4_PAE, %eax
    movl    %eax, %cr4

下一步是建立页表,在这之前了解一下长模式

长模式

长模式是x86-64架构下的原生模式,首先看看x86-64x86的区别

64位下提供以下的特性

  • 8个新的寄存器r8-r15
  • 所有寄存器改为64bit
  • 64位的rip
  • 新的寻址模式
  • 64位地址和操作数
  • rip相关地址

长模式是保护模式的一个拓展,有两种相加的模式

  • 64位模式
  • 兼容模式

转入64-bit需要以下几个条件

  • 支持PAE
  • 建立页表以及加载最高级页表到cr3寄存器
  • 支持EDER.LME
  • 支持页

在前一部分我们已经打开了’PAE’,接下来就是为页建造结构.

页表的早期初始化

NOTE :这里暂时不会讨论虚拟内存

linux内核使用4级页缓存,我们一般建立6个页表

  • 一个PML4或者说 Page Map Level 4表,带有一个入口点
  • 一个PDP 或者说 Page Directory Pointer,带有四个入口点
  • 4个页表 一共2048个入口

看看这些是如何定义的,首先,清理内存中页表的缓冲区,每一个表4096byte,所以我们需要清理一个24kb的缓冲区

    leal    pgtable(%ebx), %edi
    xorl    %eax, %eax
    movl    $(BOOT_INIT_PGT_SIZE/4), %ecx
    rep    stosl

pgtable的地址和ebx的偏移放入edi寄存器中,清理eax寄存器,将ecx寄存器值设置为6144

rep stosl会将eax寄存器的值写入edi所指的内存处,edi加4,ecx减1

这个操作重复执行直到ecx寄存器值为0,这是为什么eax被设定为 BOOT_INIT_PGT_SIZE/4也就是6144

pgtable定义在 arch/x86/boot/compressed/head_64.S的最后

    .section ".pgtable","a",@nobits
    .balign 4096
pgtable:
    .fill BOOT_PGT_SIZE, 1, 0

它的大小由CONFIG_X86_VERBOSE_BOOTUP选项决定,

#  ifdef CONFIG_X86_VERBOSE_BOOTUP
#   define BOOT_PGT_SIZE    (19*4096)
#  else /* !CONFIG_X86_VERBOSE_BOOTUP */
#   define BOOT_PGT_SIZE    (17*4096)
#  endif
# else /* !CONFIG_RANDOMIZE_BASE */
#  define BOOT_PGT_SIZE        BOOT_INIT_PGT_SIZE
# endif

有了pgtable的缓冲区后,我们开始建立PML4

    leal    pgtable + 0(%ebx), %edi
    leal    0x1007 (%edi), %eax
    movl    %eax, 0(%edi)

我们将pgtableebx关联后的结果放到edi中,(ebxstartup_32基地址)

然后将这个地址加上0x1007偏移放入eax寄存器. 0x1007PML4的size加上7

在这里7代表一些PML4入口的标志位.在这里这些标志是PRESENT+RW+USER

最后,将第一个PDp入口点写入PML4表中

接下来在’Page Directory Pointer表中’建立4个Page Directory入口,使用PRESENT+RW+USE标志位.

    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

设置edi页目录指针,(pgtable + 0x1000(%ebx))

eax为第1个页目录指针的偏移.

ecx设置为4作为接下来循环的计数器

将第一个页目录指针写入edi寄存器,然后edi会包含第一个页目录指针地址(带有标志位0x7)

计算接下来页目录指针的地址,每一个指针8byte,将他们值写入eax

最后一步为2Mbyte页表建立2048个入口点

    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

这一步基本与之前的两个步骤相同,所有的入口点都通过这些标志联系.$0x00000183PRESENT + WRITE + MBZ

最后,我们得到2048个2mb的页,一共4gb内存

我们只完成了建立早期的页表结构,映射了4gb的内存,我们可以将高级页表的地址放到cr3控制寄存器中

    leal    pgtable(%ebx), %eax
    movl    %eax, %cr3

接下来就是转入64位了

转入64位

首先我们需要设置EFER.LME标志在MSR0xC0000080:

    movl    $MSR_EFER, %ecx
    rdmsr
    btsl    $_EFER_LME, %eax
    wrmsr

我们将MSR_EFER标志位(arch/x86/include/asm/msr-index.h)放入ecx,

执行rdmsr 语句,来读取MSR寄存器,之后,将获得结果的数据会储存在edx:eax

检查当前的EFER_LMEbit位,转移它到携带标志位,更新bit位.这些通过btsl语句执行.然后我们ebx:eax值写回MSR寄存器

在下一步中,将内核段地址压入栈中,将startup_64地址放入eax

    pushl    $__KERNEL_CS
    leal    startup_64(%ebp), %eax

然后,将eax压入栈中,通过设置PG标志位来启用页.将PEbits放入cr0寄存器

然后执行lret

lret

我们已经将startup_64函数地址放入栈中,CPU提取该地址并跳转到这里

最终经过一系列的设置,我们进入了64位模式

    .code64
    .org 0x200
ENTRY(startup_64)
....
....
....

 

Links

(完)