《Linux Inside》kernel booting process (一)

1. 术语

  • CS:IP
    • 8086: CPU reset后CS寄存器值为0xFFFF,IP寄存器值为0,(左移16位加IP)即0xFFFF0,1MB往下16字节的位置
    • 80286:CPU reset后CS寄存器值为0xF000,IP寄存器的值为0xFFF0,(左移16位加IP)即0xFFFF0
    • 80386:CPU reset后CS寄存器值为0xF000,CS base寄存器为0xFFFF0000,IP的值为0xFFF0,(base和ip相加)即0xFFFFFFF0,4GB往下16字节的位置
  • A20线
  • MBR分区布局(master boot record)
  • qemu文本模式显示:qemu_system_x86_64 bootloader -curses
    • 文本模式下的退出Esc + 2
    • gui界面的退出:Ctrl + a + x
  • coreboot
  • UEFI + GPT和BIOS + MBR

下面是AT&T语法和intel语法的一些不同之处

1
2
3
4
5
6
7
8
9
10
11
12
13
* 寄存器命名原则
    AT&T: %eax                      Intel: eax
* 源/目的操作数顺序 
    AT&T: movl %eax, %ebx           Intel: mov ebx, eax
* 常数/立即数的格式 
    AT&T: movl $_value, %ebx        Intel: mov eax, _value
  把value的地址放入eax寄存器
    AT&T: movl $0xd00d, %ebx        Intel: mov ebx, 0xd00d
* 操作数长度标识 
    AT&T: movw %ax, %bx             Intel: mov bx, ax
* 寻址方式 
    AT&T:   immed32(basepointer, indexpointer, indexscale)
    Intel:  [basepointer + indexpointer × indexscale + imm32)

1.1. 一些指令

  • cld指令:将flag寄存器中方向标志位DF清零。
  • rep; stosl: stosleax中四字节数据存储到edi指向的内存中,并将edi加4.rep重复执行后面的指令,每次执行ecx减1,直到ecx为0.

2. 你好,世界

1. press power button -> 2. motherboard sends signal to power supply device -> 3. motherboard receives power good signal -> 4. start CPU -> 5. CPU resets all leftover data in registers and sets predefined values for each

对于80386及之后的处理器架构,其初始化的cs和ip寄存器如下

1
2
3
IP  0xfff0
CS selector 0xf000
CS base 0xffff0000

处理器从实模式开始工作,实模式被所有x86系列处理器所支持。8086处理器具有20位地址总线,但是寄存器只有16位,为了让16位的寄存器能索引20位的地址空间,采用对内存分段的方式,将内存地址分为段地址和偏移地址两部分。物理地址计算方式如下:

\[物理地址\; =\; Segment\; Selector\; *\; 16\; +\; Offset\]

cs寄存器(code segment): 包含两部分,segment selector和隐藏的base address(通常为segment selector的值乘以16)。base address初始化为0xffff0000,cs初始化为0xf000,处理器会一直使用base address直到cs发生改变。
ip寄存器(instruction pointer): 初始化为0xfff0

因此,starting地址为$0xffff0000 + 0xfff0 = 0xfffffff0$,这个值位于4GB下方16字节处,被称为复位向量(reset vector)。其中包含了一条jmp指令跳转到BIOS的入口点(entry point)。

之后BIOS会找到可启动设备。当尝试从磁盘启动时,BIOS会尝试寻找第一个boot扇区,在以MBR作为分区布局的磁盘中第一个扇区的前446字节为启动扇区。第一个扇区的最后两个字节0x550xaa表明该设备是可启动的。

一个启动扇区的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;
; Note: this example is written in Intel Assembly syntax
;
[BITS 16] ; 16位实模式

boot:
    mov al, '!'
    mov ah, 0x0e
    mov bh, 0x00
    mov bl, 0x07

    int 0x10
    jmp $ ; $表示当前地址,即死循环

times 510-($-$$) db 0 ; 重复510次,用0填满510字节空间

db 0x55 ; 魔数
db 0xaa ; 魔数

如上,BIOS将MBR中内容load到内存0x7c00后,BIOS将控制权交给MBR,接着启动扇区的代码会在16位实模式执行。代码调用0x10中断会打印!。同时填充100字节的0以及最后的魔数0x55和0xaa。

实模式下的内存映射:

1
2
3
4
5
6
7
8
9
10
11
0x00000000 - 0x000003FF - Real Mode Interrupt Vector Table
0x00000400 - 0x000004FF - BIOS Data Area
0x00000500 - 0x00007BFF - Unused
0x00007C00 - 0x00007DFF - Our Bootloader
0x00007E00 - 0x0009FFFF - Unused
0x000A0000 - 0x000BFFFF - Video RAM (VRAM) Memory
0x000B0000 - 0x000B7777 - Monochrome Video Memory
0x000B8000 - 0x000BFFFF - Color Video Memory
0x000C0000 - 0x000C7FFF - Video ROM BIOS
0x000C8000 - 0x000EFFFF - BIOS Shadow Area
0x000F0000 - 0x000FFFFF - System BIOS

可以注意到实模式可用内存只有1MB,这是因为8086地址总线只有20位,即最多支持1MB地址空间。

对于复位向量,其被保存在ROM中而非RAM中,进而被映射到地址空间中。

0xFFFE_0000 - 0xFFFF_FFFF: 128 kilobyte ROM mapped into address space

3. bootloader: GRUB 2

bootloader的实现需要遵守boot protocol。

BIOS将控制权交给启动扇区的代码,并从boot.img处开始执行。其代码非常简单,仅仅是跳转到GRUB 2的core image的位置。core image从diskboot.img开始,其通常存储在第一个扇区之后。这里的代码将GRUB 2的内核和文件系统驱动装载到内存中。之后,执行grub_main函数。

grub_main函数初始化终端、获取模块的基地址、设置根设备、装载并解析grub配置文件、装载模块等。grub_main最终会进入normal mode,grub_normal_execute函数会完成最终的准备阶段并给出能够选择的操作系统列表。当选择之后,grub_menu_execute_entry函数会运行,并执行boot命令启动被选择的操作系统。

bootloader必须读取并写入内核设置部分(setup header)的某些域。这些域的位置由linker script给出,即位于内核设置代码的0x01f1偏移处。如下:

    .globl hdr
hdr:
    setup_sects: .byte 0
    root_flags:  .word ROOT_RDONLY
    syssize:     .long 0
    ram_size:    .word 0
    vid_mode:    .word SVGA_MODE
    root_dev:    .word 0
    boot_flag:   .word 0xAA55

这些值由命令行给出或者在booting过程中计算得到。

如boot protocol中,内核装载到内存之后的地址空间映射如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
         | Protected-mode kernel  |
100000   +------------------------+
         | I/O memory hole        |
0A0000   +------------------------+
         | Reserved for BIOS      | Leave as much as possible unused
         ~                        ~
         | Command line           | (Can also be below the X+10000 mark)
X+10000  +------------------------+
         | Stack/heap             | For use by the kernel real-mode code.
X+08000  +------------------------+
         | Kernel setup           | The kernel real-mode code.
         | Kernel boot sector     | The kernel legacy boot sector.
       X +------------------------+
         | Boot loader            | <- Boot sector entry point 0x7C00
001000   +------------------------+
         | Reserved for MBR/BIOS  |
000800   +------------------------+
         | Typically used by MBR  |
000600   +------------------------+
         | BIOS use only          |
000000   +------------------------+

bootloader将控制权交给内核时,从

\[X + sizeof(KernelBootSector) + 1\]

启动。X是kernel boot sector被装载的地址。

4. The Beginning of the Kernel Setup Stage

目前,内核还没有运行。内核配置阶段(setup part)需要配置解压缩器和内存管理。之后会解压缩实际的内核并跳转过去。setup part从arch/x86/boot/header.S_start符号开始执行。

  .globl _start
_start:
  .byte 0xeb
  .byte start_of_setup-1f
1:
  //
  // rest of the header
  //
# End of setup header #####################################################

	.section ".entrytext", "ax"
start_of_setup:
# Force %es = %ds
	movw	%ds, %ax
	movw	%ax, %es
	cld

0xeb表示jmp指令,是一条相对跳转指令。start_of_setup - 1f表示start_of_setuplabel和本地label1:地址相减的结果。该指令位于内核实模式偏移的0x200处,即第一个512字节之后。根据kernel boot protocol,需要保证

1
2
3
segment = grub_linux_real_target >> 4;
state.gs = state.fs = state.es = state.ds = state.ss = segment;
state.cs = segment + 0x20;

若内核被装载到物理地址0x10000处,则

1
2
gs = fs = es = ds = ss = 0x1000 ; 其它段选择器
cs = 0x1020 ; 左移4位后,就指向第一条的jmp指令

在跳转到start_of_setup后,内核需要:

  • 设置所有段寄存器的值,保证它们相同
  • 设置栈
  • 设置bss
  • 跳转到arch/x86/boot/main.c中的c代码处。

5. Aligning the Segment Registers

首先保证esds相同

movw    %ds, %ax # %ax = %ds
movw    %ax, %es # %es = %ax
cld

同时也需要把cs寄存器的值设置为和ds寄存器相同,

  pushw   %ds
  pushw   $6f
  lretw 
  # 1. ret从栈中将返回地址pop到EIP寄存器中
  # 2. l前缀表示`far return`,因此指令首先从栈中pop值到EIP寄存器,然后pop第二个值到CS寄存器。
  # 3. w后缀表示实模式下操作数宽度为16位
6:

这里的代码将ds的值和label6:的地址依次压入栈,然后执行lretw指令。当lretw被调用,它将会把ip寄存器的地址设置为6:的地址,同时把cs的值设置为ds的值

6. Stack Setup

setup代码主要是为了实模式下的c语言环境做准备。下一步是验证并设置ss寄存器的值

movw    %ss, %dx # %dx = %ss
cmpw    %ax, %dx # %dx - %ax
movw    %sp, %dx # %dx = %sp
je      2f

此时ax的值为ds的值。比较axss可能有三种可能结果

  • ssax相等为有效的值0x1000
  • ss无效且CAN_USE_HEAP被置位
  • ss无效且CAN_USE_HEAP没有被置位

ss0x1000有效时会跳转到2:如下:

2:  # Now %dx should point to the end of our stack space 
    andw    $~3, %dx      # %dx &= 1100b 四字节对齐
    jnz     3f            # if %dx != 0 goto 3
    movw    $0xfffc, %dx  # %dx = 0xfffc
3:  movw    %ax, %ss      # %ss = %ax
    movzwl  %dx, %esp     # %esp = %dx
    sti

此时dx中是sp的值。首先将dx四字节对齐(末两位置0),然后检查是否为0。若为0,则把dx置为0xfffc(64KB段中最后一个四字节对齐的位置)。若非0,则继续使用bootloader给出的sp的值。接着,设置ss0x1000,设置espdx的值。此时内存空间如下图,底部是setup代码,

stack-1

ss!=ds时,处理代码如下

    # Invalid %ss, make up a new stack
    xmovw	$_end, %dx
    xtestb	$CAN_USE_HEAP, loadflags
    jz	1f
    movw	heap_end_ptr, %dx
1:  addw	$STACK_SIZE, %dx
    jnc	2f
    xorw	%dx, %dx  # Prevent wraparound

首先将_end地址的值放入dx中,然后使用testb测试loadflags掩码,loadflags定义如下:

1
2
3
4
#define LOADED_HIGH     (1<<0)
#define QUIET_FLAG      (1<<5)
#define KEEP_SEGMENTS   (1<<6)
#define CAN_USE_HEAP    (1<<7)

如果CAN_USE_HEAP置位了,则将dx设置为heap_end_ptr(_end+STACK_SIZE-512),并加上STACK_SIZE(最小的栈大小,1024字节)。这之后,如果dx结果没有溢出,就跳转到2:处执行。(说明dx此时已经指向栈顶)

如果CAN_USE_HEAP没有置位,则使用一个最小栈:从_end_end + STACK_SIZE。如下:

stack-2

7. BSS Setup

接着比较setup_sig和魔数(0x5a5aaa55)。

6:
# Check signature at end of setup
	cmpl	$0x5a5aaa55, setup_sig
	jne	setup_bad

若魔数能够成功匹配,则表明段寄存器和栈设置成功。因此在跳转到main函数前只需设置BSS段。

BSS段用于存储静态未初始化/初始化为0的变量。Linux确保BSS段初始化为0。如下,

# Zero the bss
	movw	$__bss_start, %di
	movw	$_end+3, %cx
	xorl	%eax, %eax
	subw	%di, %cx
	shrw	$2, %cx # cx >> 2
	rep; stosl

首先将__bss_start地址移动到di中。接着_end + 3(4字节对齐)被放入到cx中。清零eax。并将bss节的大小cx - di放入cx中。之后cx被除以4(右移两位)。再重复使用stosl指令,即将eax的值(=0)存入di指向的地址中,每次自动将di加4,cx减1,直到cx为0。

8. Jump to main

设置好栈和BSS后,就需要跳转到main()函数,如下代码所示

calll main

main函数位于arch/x86/boot/main.c中。

9. 小结

总结一下,上电启动阶段主要涉及三个东西

  • BIOS: 复位向量给出的第一条指令(jmp)跳转到BIOS。
  • bootloader(GRUB 2): 启动扇区为boot.img,之后跟着的是core.img。其会遵守kernel boot protocol,将内核装载到内存(一般为0x10000)处。
  • 内核实模式代码: GRUB会将控制权交给内核实模式代码,其会设置各个段寄存器的值、初始化堆、栈、BSS段等,共占用64KB空间(包括代码以及boot sector)。boot sector最开始两个字节为0x4d0x5a,即MZ表示MS-DOS header。