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 |
|
1.1. 一些指令
cld
指令:将flag寄存器中方向标志位DF清零。rep; stosl
:stosl
将eax
中四字节数据存储到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 |
|
处理器从实模式开始工作,实模式被所有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字节为启动扇区。第一个扇区的最后两个字节0x55
和0xaa
表明该设备是可启动的。
一个启动扇区的示例
1 |
|
如上,BIOS将MBR中内容load到内存0x7c00后,BIOS将控制权交给MBR,接着启动扇区的代码会在16位实模式执行。代码调用0x10中断会打印!
。同时填充100字节的0以及最后的魔数0x55和0xaa。
实模式下的内存映射:
1 |
|
可以注意到实模式可用内存只有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 |
|
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_setup
label和本地label1:
地址相减的结果。该指令位于内核实模式偏移的0x200
处,即第一个512字节之后。根据kernel boot protocol,需要保证
1 |
|
若内核被装载到物理地址0x10000
处,则
1 |
|
在跳转到start_of_setup
后,内核需要:
- 设置所有段寄存器的值,保证它们相同
- 设置栈
- 设置bss
- 跳转到arch/x86/boot/main.c中的c代码处。
5. Aligning the Segment Registers
首先保证es
和ds
相同
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
的值。比较ax
和ss
可能有三种可能结果
ss
和ax
相等为有效的值0x1000
ss
无效且CAN_USE_HEAP
被置位ss
无效且CAN_USE_HEAP
没有被置位
当ss
为0x1000
有效时会跳转到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
的值。接着,设置ss
为0x1000
,设置esp
为dx
的值。此时内存空间如下图,底部是setup代码,
当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 |
|
如果CAN_USE_HEAP
置位了,则将dx
设置为heap_end_ptr
(_end+STACK_SIZE-512),并加上STACK_SIZE
(最小的栈大小,1024字节)。这之后,如果dx
结果没有溢出,就跳转到2:
处执行。(说明dx
此时已经指向栈顶)
如果CAN_USE_HEAP
没有置位,则使用一个最小栈:从_end
到_end + STACK_SIZE
。如下:
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最开始两个字节为
0x4d
和0x5a
,即MZ
表示MS-DOS header。