UP | HOME

长模式

Table of Contents

接下来将会看到在保护模式中的最初几步,比如:

注意:这部分将会有大量的汇编代码,如果不熟悉汇编,建议找本书参考一下

在前一章节,停在了跳转到位于 arch/x86/boot/pmjump.S 的 32 位入口点这一步:

jmpl    *%eax

eax 寄存器包含了 32 位入口点的地址。可以在 x86 linux 内核引导协议 中找到相关内容:

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

当使用 bzImage 时,保护模式下的内核被重定位至 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 (代表了全局描述符表中的第二个索引项), eip 寄存器的值是 0x100000,并且包括代码段在内的所有内存段的基地址都为0。所以可以得到物理地址: 0:0x100000 或者 0x100000,这和协议规定的一样

现在就可以从 32 位入口点代码开始

32 位入口点

可以在汇编源码 arch/x86/boot/compressed/head_64.S 中找到 32 位入口点的定义

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

首先,为什么目录名叫做 被压缩的 compressed ?实际上 bzimage 是由 vmlinux + 头文件 + 内核启动代码gzip 压缩之后获得的

在前几个章节已经看到了启动内核的代码

head_64.S 的主要目的就是做好进入长模式的准备之后进入长模式,进入以后再解压内核

接下来将会看到直到内核解压缩之前的所有步骤

在 arch/x86/boot/compressed 目录下有两个文件:

这里只和 x86_64 有关,所以只会关注 head_64.S, head_32.S 没有被用到

先看一下 arch/x86/boot/compressed/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) 所设置的文件执行链接操作,即 head_32.o 或者 head_64.o 。$(BITS) 在 arch/x86/Makefile 之中根据 .config 文件另外定义:

ifeq ($(CONFIG_X86_32),y)
BITS := 32
...
...
else
    BITS := 64
    ...
    ...
    endif
现在知道从哪里开始了,那就来吧

重新加载内存段寄存器

首先看到了在 startup_32 之前的特殊段属性定义:

__HEAD
.code32
ENTRY(startup_32)

这个 __HEAD 是一个定义在头文件 include/linux/init.h 中的宏,展开后就是下面这个段的定义:

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

其拥有 .head.text 的命名和 ax 标记。这些标记说明这个段是 可执行的,或者换种说法,包含了代码。可以在 arch/x86/boot/compressed/vmlinux.lds.S 这个链接脚本里找到这个段的定义:

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

简单来说,这个 . 符号是一个 链接器 的特殊变量 位置计数器 。其被赋值为 相对于该段偏移

在这里,将位置计数器赋值为0,这意味着代码被链接到内存的 0 偏移处

如果不熟悉 GNU LD 这个链接脚本语言的语法,可以在 https://sourceware.org/binutils/docs/ld/Scripts.html#Scripts 中找到更多信息

此外,可以从注释里找到更多信息:

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

要小心, head_64.S 中一些部分假设 startup_32 位于地址 0

在 startup_32 函数的开始,可以看到 cld 指令将 标志寄存器的 DF 方向标志位 清空。当方向标志被清空,所有的串操作指令像 stosscas 等等将会 增加 索引寄存器 esi 或者 edi 的值

需要清空方向标志是因为接下来会使用汇编的串操作指令来做为页表腾出空间等工作

在清空 DF 标志后,下一步就是从内核加载头中的 loadflags 字段来检查 KEEP_SEGMENTS 标志

在本书的最初一节,其实已经看到过 loadflags

在那里检查了 CAN_USE_HEAP 标记以使用堆

现在需要检查 KEEP_SEGMENTS 标记。这些标记在 linux 的 引导协议 文档中有描述:

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).


第 6 位 (写): KEEP_SEGMENTS
  协议版本: 2.07+
  - 为0,在32位入口点重载段寄存器
  - 为1,不在32位入口点重载段寄存器。假设 %cs %ds %ss %es 都被设到基地址为0的普通段中(或者在他们的环境中等价的位置)

所以,如果 KEEP_SEGMENTS 位在 loadflags 中没有被设置,需要重置 ds , sses 段寄存器到一个基地址为 0 的普通段中。如下:

testb $(1 << 6), BP_loadflags(%esi)
jnz 1f

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

记住 __BOOT_DS0x18 (位于 全局描述符表数据段 的索引)。如果设置了 KEEP_SEGMENTS ,就跳转到最近的 1f 标签,或者当没有 1f 标签,则用 __BOOT_DS 更新段寄存器

这非常简单,但这是一个有趣的操作

如果已经读了前一章节,或许还记得在 arch/x86/boot/pmjump.S 中切换到保护模式的时候已经更新了这些段寄存器

那么为什么还要去关心这些段寄存器的值呢?

答案很简单,Linux 内核也有32位的引导协议,如果一个引导程序之前使用32位协议引导内核,那么在 startup_32 之前的代码就会被忽略

在这种情况下 startup_32 将会变成引导程序之后的第一个入口点,不保证段寄存器会不会处于未知状态

下一步就是计算代码的加载和编译运行之间的位置偏差了

记住 setup.ld.S 包含了以下定义:在 .head.text 段的开始 . = 0

这意味着这一段代码被编译成从 0 地址运行

可以在 objdump 工具的输出中看到:

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)

objdump 工具显示 startup_32 的地址是 0

但实际上并不是。现在需要知道实际上在哪里

在长模式下,这非常简单,因为其支持 rip 相对寻址

但是当前处于保护模式下。将会使用一个常用的方法来确定 startup_32 的地址。需要定义一个标签并且跳转到它,然后把栈顶抛出到一个寄存器中:

        call label
label: pop %reg

在这之后,那个寄存器将会包含标签的地址,Linux 内核中类似的寻找 startup_32 地址的代码:

        leal    (BP_scratch+4)(%esi), %esp // 把 scratch 的地址加 4 存入 esp 寄存器
        call    1f // 跳转到1f 
1:  popl        %ebp // 把1f标签的地址放入ebf 
        subl    $1b, %ebp 

esi 寄存器包含了 boot_params 结构的地址,这个结构在切换到保护模式之前已经被填充了。bootparams 这个结构体包含了一个特殊的字段 scratch ,其偏移量为 0x1e4 。这个 4 字节的区域将会成为 call 指令临时栈

之所以在 BP_scratch 基础上加 4 是因为,如之前所说的,这将成为一个临时的栈

而在 x86_64 架构下,栈是自顶向下生长的。所以栈指针就会指向栈顶

接下来就可以看到上面描述的过程。跳转到 1f 标签并且把该标签的地址放入 ebp 寄存器

因为在执行 call 指令之后我们把返回地址放到了栈顶

那么,既然已经拥有 1f 标签的地址,也能够很容易得到 startup_32 的地址

只需要把 从栈里得到的地址 减去 标签的地址

startup_32 (0x0)     +-----------------------+
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
                     |                       |
1f (0x0 + 1f offset) +-----------------------+ %ebp - 实际物理地址
                     |                       |
                     |                       |
                     +-----------------------+

startup_32 被链接为在 0x0 地址运行,这意味着 1f 的地址0x0 + 1f 的偏移量 。实际上偏移量大概是 0x22 字节。 ebp 寄存器 包含了 1f 标签的实际物理地址 。所以如果从 ebp 中减去 1f ,就会得到 startup_32 的实际物理地址。Linux 内核的引导协议描述了保护模式下的内核基地址是 0x100000

可以用 gdb 来验证。启动调试器并且在 1f 的地址 0x100022 添加断点。如果这是正确的,将会看到在 ebp 寄存器中值为 0x100022 :

$ 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 ,将会看到:

nexti
...
ebp            0x100000 0x100000
...
好了,那是对的。startup_32 的地址是 0x100000

知道了 startup_32 的地址之后,可以开始准备切换到 长模式

下一个目标是建立栈并且确认 CPU 对长模式和 SSE 的支持

栈的建立和 CPU 的确认

如果不知道 startup_32 标签的地址,就无法建立栈

可以把栈看作是一个数组,并且栈指针寄存器 esp 必须指向 数组的底部 。当然可以在自己的代码里定义一个数组,但是需要知道其真实地址来正确配置栈指针。看一下代码:

movl    $boot_stack_end, %eax // eax 寄存器将包含 boot_stack_end 链接后的地址 (0x0 + boot_stack_end) 
addl    %ebp, %eax // ebp 寄存器里是 startup_32 的实际物理地址
movl    %eax, %esp // esp 指向 boot_stack_end的实际物理地址

boots_stack_end 标签被定义在同一个汇编文件 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:
  1. 首先,把 boot_stack_end 放到 eax 寄存器 中。那么 eax 寄存器将包含 boot_stack_end 链接后的地址或者说 0x0 + boot_stack_end
  2. 为了得到 boot_stack_end 的实际地址,需要加上 startup_32 的实际地址

    回忆一下,前面找到了这个地址并且把它存到了 ebp 寄存器中
    
  3. 最后,eax 寄存器将会包含 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 标签

    no_longmode:
    1:
            hlt
            jmp     1b
    
    • 用 hlt 指令停止 CPU ,期间不会发生硬件中断
  • 如果 eax 的值为0,万事大吉,可以继续

计算重定位地址

下一步是在必要的时候计算解压缩之后的地址

首先,需要知道 内核重定位 的意义

我们已经知道 Linux 内核的32位入口点地址位于 0x100000,但是那是一个32位的入口

默认的内核基地址由内核配置项 CONFIG_PHYSICAL_START 的值所确定,其默认值为 0x1000000 或 16 MB

主要问题是如果内核崩溃了,内核开发者需要一个配置于不同地址加载的 救援内核 来进行 kdump 。Linux 内核提供了特殊的配置选项以解决此问题 CONFIG_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.

这建立了一个保留了重定向信息的内核镜像,这样就可以在默认的 1MB 位置之外加载了。

注意:如果 CONFIG_RELOCATABLE=y, 那么 内核将会从其被加载的位置运行,编译时的物理地址 (CONFIG_PHYSICAL_START) 将会被作为最低地址位置的限制

简单来说,这意味着相同配置下的 Linux 内核可以从不同地址被启动。这是通过将程序以 位置无关代码 的形式编译来达到的。如果参考 arch/x86/Makefile 将会看到解压器的确是用 -fPIC 标记编译的:

KBUILD_CFLAGS += -fno-strict-aliasing -fPIC

当使用位置无关代码时,一段代码的地址是由一个 控制地址 加上 程序计数器 计算得到的

可以从任意一个地址加载使用这种方式寻址的代码。这就是为什么我们需要获得 startup_32 的实际地址

现在回到 Linux 内核代码。目前的目标是计算出内核解压的地址

这个地址的计算取决于内核配置项 CONFIG_RELOCATABLE

#ifdef CONFIG_RELOCATABLE
        movl    %ebp, %ebx // ebp 寄存器的值就是 startup_32 标签的物理地址
        movl    BP_kernel_alignment(%esi), %eax
        decl    %eax
        addl    %eax, %ebx
        notl    %eax
        andl    %eax, %ebx // 对齐到 2M 的整数倍
        cmpl    $LOAD_PHYSICAL_ADDR, %ebx // 和 LOAD_PHYSICAL_ADDR 的值
        jge     1f
#endif
        // 加上偏移来获得解压内核镜像的地址
        movl    $LOAD_PHYSICAL_ADDR, %ebx
1:
        addl    $z_extract_offset, %ebx // 直接加上 z_extract_offset

如果在内核配置中 CONFIG_RELOCATABLE 内核配置项开启:

  1. 就把 ebp 寄存器放到 ebx 寄存器中
  2. 对齐到 2M 的整数倍
  3. 和 LOAD_PHYSICAL_ADDR 的值比较
    • LOAD_PHYSICAL_ADDR 宏定义在头文件 arch/x86/include/asm/boot.h 中:

      #define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \
                                      + (CONFIG_PHYSICAL_ALIGN - 1)) \
                                      & ~(CONFIG_PHYSICAL_ALIGN - 1))
      
      该宏只是展开成对齐的 CONFIG_PHYSICAL_ALIGN 值,其表示了内核加载位置的物理地址
      
  4. 给 startup_32 加上 偏移 来获得 解压内核镜像的地址
    • 如果 CONFIG_RELOCATABLE 选项在内核配置时没有开启,就直接将默认的地址加上 z_extract_offset
在前面的操作之后,ebp 包含了加载时的地址,ebx 被设为内核解压缩的目标地址

更新全局描述符表和启用PAE

在得到了重定位内核镜像的基地址之后,开始做切换到64位模式之前的最后准备

首先,需要更新全局描述符表:

leal    gdt(%ebp), %eax // 把 ebp 寄存器加上 gdt 的偏移存到 eax 寄存器
movl    %eax, gdt+2(%ebp) // 把这个地址放到 ebp 加上 gdt+2 偏移的位置上
lgdt    gdt(%ebp) // 用 lgdt 指令载入 全局描述符表

为了理解这个神奇的 gdt + 2偏移量,需要关注 全局描述符表 的定义。可以在同一个源文件中找到其定义:

        .data
gdt:
        .word   gdt_end - gdt // 全局描述符表的大小 (16位) 
        .long   gdt // 全局描述符表的基址 (32位)
        .word   0
        .quad   0x0000000000000000      /* NULL descriptor */
        .quad   0x00af9a000000ffff      /* __KERNEL_CS */
        .quad   0x00cf92000000ffff      /* __KERNEL_DS */
        .quad   0x0080890000000000      /* TS descriptor */
        .quad   0x0000000000000000      /* TS continued */
gdt_end:

全局描述符表位于 .data 段,并且包含了5个描述符: null内核代码段内核数据段 和其他两个 任务描述符

已经在上一章节载入了 全局描述符表 ,现在要做的也差不多

需要把描述符改为 CS.L = 1, CS.D = 0 从而在 64 位模式下执行

而gdt 的定义:

  • 两个字节:gdt_end - gdt ,代表了 gdt 表的最后一个字节,或者说表的范围
  • 4个字节包含了 gdt 的基地址

全局描述符表 保存在 48位 GDTR寄存器 中,由两个部分组成:

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

所以,当把 gdt 的地址放到 eax 寄存器,然后存到 .long gdt 和 gdt+2。最后用 lgdt 指令 载入 到GDPR寄存器 中。

这样全局描述符表就更新完毕了

接下来必须启用 PAE 模式。方法是将 cr4 寄存器 的值传入 eax ,将 第5位 1 ,然后再 写回 cr4

movl    %cr4, %eax // 载入 cr4 寄存器
orl     $X86_CR4_PAE, %eax // 第5位置为1
movl    %eax, %cr4 // 写回 cr4 寄存器
现在已经接近完成进入64位模式前的所有准备工作了

最后一步是建立页表,但是在此之前,先介绍一些关于长模式的知识

长模式

长模式是 x86_64 系列处理器的原生模式。首先看一看 x86_64 和 x86 的一些区别,64位 模式提供了一些新特性,比如:

  • r8r15 8个新的通用寄存器,并且所有通用寄存器都是 64位
  • 64位指令指针: RIP
  • 新的操作模式:长模式
  • 64位地址和操作数
  • RIP 相对寻址

长模式是一个传统保护模式的扩展,其由两个子模式构成:

  1. 64位模式
  2. 兼容模式

为了切换到 64位 模式,需要完成以下操作:

  • 启用 PAE
  • 建立 页表 并且将顶级页表的地址放入 cr3 寄存器
  • 启用 EFER.LME
  • 启用 分页

页表初始化

现在,现在来看看如何建立初始的 4G 启动页表

注意:不会在这里解释虚拟内存的理论

Linux 内核使用 4级 页表,通常会建立 6个页表

  • 1PML4 或称为 4级页映射 表,包含 1 个项
  • 1PDP 或称为 页目录指针 表,包含 4 个项
  • 4页目录表 ,一共包含 2048 个项

首先在内存中为页表清理一块缓存。每个表都是 4096 字节 ,所以我们需要 24 KB 的空间

leal    pgtable(%ebx), %edi // 把和 ebx 相关的 pgtable 的地址放到 edi 寄存器中 
xorl    %eax, %eax // 清空 eax 寄存器 
movl    $((4096*6)/4), %ecx // 将 ecx 赋值为 6144
        // rep stosl 指令将会把 eax 的值写到 edi 指向的地址,然后给 edi 加 4 , ecx 减 4
        // 重复直到 ecx 小于等于 0 
rep     stosl

pgtable 定义在 head_64.S 的最后:

        .section ".pgtable","a",@nobits
        .balign 4096
pgtable:
        .fill 6*4096, 1, 0
可以看到,其位于 .pgtable 段,大小为 24KB 

为 pgtable 分配了空间之后,可以开始构建顶级页表 PML4

leal    pgtable + 0(%ebx), %edi // 把和 ebx 相关的,或者说和 startup_32 相关的 pgtable 的地址放到 edi 寄存器 
leal    0x1007 (%edi), %eax // 相对此地址偏移 0x1007 的地址放到 eax 寄存器中
movl    %eax, 0(%edi) // 把第一个 PDP(页目录指针) 项的地址写到 PML4 中 
0x1007 是 PML4 的大小 4096 加上 7

7 代表了 PML4 的项标记,这些标记是 PRESENT + RW + USER 

接下来的一步,会在 页目录指针 PDP 表(3级页表)建立 4 个带有 PRESENT+RW+USE 标记的 Page Directory (2级页表) 项:

        leal    pgtable + 0x1000(%ebx), %edi // 把 3 级页目录指针表的基地址(从 pgtable 表偏移 4096 或者 0x1000 )放到 edi 
        leal    0x1007(%edi), %eax // 把第一个 2 级页目录指针表的首项的地址放到 eax 寄存器
        movl    $4, %ecx // 把 4 赋值给 ecx 寄存器,会作为接下来循环的计数器
1:  movl        %eax, 0x00(%edi) // 将第一个页目录指针项写到 edi 指向的地址。因此 edi 将会包含带有标记 0x7 的第一个页目录指针项的地址
        addl    $0x00001000, %eax // 计算下一个页目录指针项的地址,把地址赋值给 eax
        addl    $8, %edi // 写入下一个页目录项的地址,因为每一项占用8字节,所以必须写入到 edi + 8 处 
        decl    %ecx // 重复循环 
        jnz     1b

最后一步就是建立 2048 个 2MB 页的页表项:

        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

所有的表项都带着标记 $0x00000183 PRESENT + WRITE + MBZ 。最后将会拥有 2048 个 2MB 大的页,或者说:

>>> 2048 * 0x00200000
4294967296

这是一个 4G 页表,其映射了 4G 大小的内存

现在可以把高级页表 PML4 的地址放到 cr3 寄存器 中了:

leal    pgtable(%ebx), %eax
movl    %eax, %cr3
所有的准备工作都已经完成,可以开始看如何切换到长模式了

切换到长模式

首先需要设置 MSR 中的 EFER.LME 标记为 0xC0000080

movl    $MSR_EFER, %ecx // 把 MSR_EFER 标记对应的寄存器放入 ecx 寄存器
rdmsr // 读取 MSR 寄存器,结果读取到 edx:eax (高 32 位在 EDX,低 32 位在 EAX),其取决于 ecx 的值
btsl    $_EFER_LME, %eax // 检查 EFER_LME 位(位测试并置位)
wrmsr // 将 eax 的数据写入 MSR 寄存器
  1. 把 MSR_EFER 标记(在 arch/x86/include/uapi/asm/msr-index.h 中定义)放到 ecx 寄存器中
  2. rdmsr: 读取 MSR 寄存器
  3. btsl 指令检查 EFER_LME 位
  4. 通过 wrmsr 指令将 eax 的数据写入 MSR 寄存器

下一步将内核段代码地址入栈(在 GDT 中定义了),然后将 startup_64 的地址导入 eax

pushl   $__KERNEL_CS // 内核段代码地址入栈 
leal    startup_64(%ebp), %eax // 将 startup_64 的地址导入 eax

把这个地址入栈然后通过设置 cr0 寄存器 中的 PGPE 启用 分页

movl    $(X86_CR0_PG | X86_CR0_PE), %eax
movl    %eax, %cr0 

然后执行:

lret 
前一步已经将 startup_64 函数的地址入栈

在 lret 指令之后,CPU 取出了其地址跳转到那里

这样终于来到了64位模式:

        .code64
        .org 0x200
ENTRY(startup_64)
....
....
....
下一节将会看到内核解压缩流程
Next:解压内核 Previous: 进入保护模式 Home:启动引导