Featured image of post 操作系统启动过程探索之旅(1)

操作系统启动过程探索之旅(1)

字数: 3445

前面我们已经进入到了 SeaBIOS 做了一个长跳转回到了实模式可寻地址里。回到 seabios/src/romlayout.S 文件:

1
2
3
4
        ORG 0xfff0 // Power-up Entry Point
        .global reset_vector
reset_vector:
        ljmpw $SEG_BIOS, $entry_post

entry_post

这里长跳转 ljmpw 不仅跳转到新的偏移地址,还会加载新的段选择子。
一般的跳转指令是 jmp 这里加的前缀 l 表示长跳转,w 表示操作的偏移地址宽度是 16 位,也就是将 SEG_BIOS 加载到 cs 段寄存器然后将 entry_post 加载到 ip寄存器。开始执行该段代码。
entry_post 还在 romlayout.s 里:

1
2
3
4
entry_post:
        cmpl $0, %cs:HaveRunPost                // Check for resume/reboot
        jnz entry_resume
        ENTRY_INTO32 _cfunc32flat_handle_post   // Normal entry point

首先检查 cs:HaveRunPost 该变量,cs:HaveRunPost 用于判断系统是否已经完成了加点自检的阶段。如果已经完成也就是设备是从睡眠休眠的方式启动,这样会跳过硬件检测继续运行。
不管 entry_resume,我们看最后一行:ENTRY_INTO32 _cfunc32flat_handle_post,ENTRY_INTO32 实际是一个汇编宏,定义在 seabios/src/entryfuncs.s 内:

1
2
3
4
5
6
7
.macro ENTRY_INTO32 cfunc
xorw %dx, %dx
movw %dx, %ss
movl $ BUILD_STACK_ADDR , %esp
movl $ \cfunc , %edx
jmp transition32
.endm

这里从 16 位实模式过渡到 32 位保护模式,前面做了一系列的寄存器修改复制。首先清零 dx 寄存器,之后赋值给 ss (栈段寄存器),这样清空栈段寄存器,之后栈位置的实际地址就都由 esp 寄存器确定,因为段寄存器已经置零。

1
movl $ BUILD_STACK_ADDR , %esp

这里 BUILD_STACK_ADDR 是 SeaBIOS 链接时候确定的地址,我还不了解 SeaBIOS 的链接过程的地址分配,先不展开。反正就是 BUILD_STACK_ADDR 的地址是专门分配用作 32 位栈空间用的,之后将函数指针赋值给 edx 寄存器,从上面我们知道这里的 cfunc 是 _cfunc32flat_handle_post 这个函数。(还是得扯到 SeaBIOS 的链接过程………………)

因为 SeaBIOS 是混合内存模式,同一套 C 语言代码需要编译成三种模式:(16 位实模式、32 位分段模式和 32 为扁平模式)以此来满足不同内存模式的函数调用。这里的 _cfunc32flat_handle_post 就是 handle_post C 函数代码编译成 32 位扁平模式的函数,前面的 _cfunc32flat_ 就是前缀,和 C++ 为了多态的各种前缀有点像哈。
将函数指针赋值给 edx 寄存器后就跳转到 transition32 label 开始从实模式向保护模式转移。
这里又用 grep -rn 'transition32' * 查询定义发现又跳回 romlayout.S 里了……

transition32

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
        DECLFUNC transition32
        .global transition32_nmi_off
transition32:
        // Disable irqs (and clear direction flag)
        cli
        cld

        // Disable nmi
        movl %eax, %ecx
        movl $CMOS_RESET_CODE|NMI_DISABLE_BIT, %eax
        outb %al, $PORT_CMOS_INDEX
        inb $PORT_CMOS_DATA, %al

        // enable a20
        inb $PORT_A20, %al
        orb $A20_ENABLE_BIT, %al
        outb %al, $PORT_A20
        movl %ecx, %eax

transition32_nmi_off:
        // Set segment descriptors
        lidtw %cs:pmode_IDT_info
        lgdtw %cs:rombios32_gdt_48

        // Enable protected mode
        movl %cr0, %ecx
        andl $~(CR0_PG|CR0_CD|CR0_NW), %ecx
        orl $CR0_PE, %ecx
        movl %ecx, %cr0

        // start 32bit protected mode code
        ljmpl $SEG32_MODE32_CS, $(BUILD_BIOS_ADDR + 1f)

        .code32
        // init data segments
1:      movl $SEG32_MODE32_DS, %ecx
        movw %cx, %ds
        movw %cx, %es
        movw %cx, %ss
        movw %cx, %fs
        movw %cx, %gs

        jmpl *%edx

很清晰的实模式到保护模式跳转,我在 x86 从实模式到保护模式 博文中已经有介绍,但是 SeaBIOS 的代码非常清晰包括 gdt 选择子写得也很不错,而且还是用 C 语言实现的,这里就再详细写下。

关闭 INTR、清除方向标志

1
2
cli
cld

关闭可屏蔽中断(INTR) 确保切换过程绝对不能被打断。清除方向标志使得操作从低地址向高地址处理和 C 语言环境一致。

关闭 NMI

1
2
3
4
movl %eax, %ecx
movl $CMOS_RESET_CODE|NMI_DISABLE_BIT, %eax
outb %al, $PORT_CMOS_INDEX
inb $PORT_CMOS_DATA, %al

NMI 是非屏蔽中断,通过 outb 向 CMOS 端口写入关闭 NMI 的位来关闭 NMI。
写入后还需要做一次inb 对数据端口进行读写操作才能将 NMI 的控制位锁存到内部电路中真正启用,关闭 NMI。

开启 A20 地址线

1
2
3
4
inb $PORT_A20, %al
orb $A20_ENABLE_BIT, %al
outb %al, $PORT_A20
movl %ecx, %eax

读取 系统控制端口(0x92) 当前状态值到 al 寄存器,然后对其与 A20_ENABLE_BIT (0x2) 按位或操作,也就是将 bit1 置 1,该位置就是 A20 的控制位。
之后用 outb 往 PORT_A20 端口写入一字节数据,也就是 al,将 A20 控制位打开后的状态写入,最后恢复之前使用的 eax 寄存器。

设置 IDT

1
lidtw %cs:pmode_IDT_info

IDT 是中断描述符表,定义在 seabios/src/misc.c 内:

1
2
3
4
5
6
7
8
9
// Dummy IDT that forces a machine shutdown if an irq happens in
// protected mode.
u8 dummy_IDT VARFSEG;

// Protected mode IDT descriptor
struct descloc_s pmode_IDT_info VARFSEG = {
    .length = sizeof(dummy_IDT) - 1,
    .addr = (u32)&dummy_IDT,
};

不过在这里发挥不了作用,是 “Dummy” 的。

设置 GDT

1
lgdtw %cs:rombios32_gdt_48

GDT 是全局描述符表,描述了内存段的信息。也定义在 seabios/src/misc.c 中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// GDT
u64 rombios32_gdt[] VARFSEG __aligned(8) = {
    // First entry can't be used. 强制占位
    0x0000000000000000LL,
    // 32 bit flat code segment (SEG32_MODE32_CS)
    GDT_GRANLIMIT(0xffffffff) | GDT_CODE | GDT_B,
    // 32 bit flat data segment (SEG32_MODE32_DS)
    GDT_GRANLIMIT(0xffffffff) | GDT_DATA | GDT_B,
    // 16 bit code segment base=0xf0000 limit=0xffff (SEG32_MODE16_CS)
    GDT_LIMIT(BUILD_BIOS_SIZE-1) | GDT_CODE | GDT_BASE(BUILD_BIOS_ADDR),
    // 16 bit data segment base=0x0 limit=0xffff (SEG32_MODE16_DS)
    GDT_LIMIT(0x0ffff) | GDT_DATA,
    // 16 bit code segment base=0xf0000 limit=0xffffffff (SEG32_MODE16BIG_CS)
    GDT_GRANLIMIT(0xffffffff) | GDT_CODE | GDT_BASE(BUILD_BIOS_ADDR),
    // 16 bit data segment base=0 limit=0xffffffff (SEG32_MODE16BIG_DS)
    GDT_GRANLIMIT(0xffffffff) | GDT_DATA,
};

// GDT descriptor
struct descloc_s rombios32_gdt_48 VARFSEG = {
    .length = sizeof(rombios32_gdt) - 1,
    .addr = (u32)rombios32_gdt,
};

这里就定义了好几个内存段,仔细看注释还是能看懂的,不细展开。

设置 CRO 开启保护模式

1
2
3
4
5
// Enable protected mode
movl %cr0, %ecx
andl $~(CR0_PG|CR0_CD|CR0_NW), %ecx
orl $CR0_PE, %ecx
movl %ecx, %cr0

将 cr0 寄存器的数据赋值给 ec0 寄存器,然后清除 PG、CD、NW 位 这里直接贴 deepseek 的解释吧:

orl $CRO_PE, $ecx 设置保护模式位,确定打开保护模式然后将修改后的数据覆盖 cr0 寄存器。
到目前为止就完成了进入保护模式的准备过程。

长跳转到 32 位代码段

1
ljmpl $SEG32_MODE32_CS, $(BUILD_BIOS_ADDR + 1f)

长跳转到 32 位代码段,并执行当前标签为 1 的代码。

设置 32 位数据段

1
2
3
4
5
6
1:      movl $SEG32_MODE32_DS, %ecx
        movw %cx, %ds
        movw %cx, %es
        movw %cx, %ss
        movw %cx, %fs
        movw %cx, %gs

这里就是标签为 1 的位置。实际就是将 32 位平坦数据段选择子通过 ecx 赋值给 32 位平坦数据段寄存器。

跳转 C 函数

1
jmpl *%edx

还记得在最开始 edx 存了什么吗?最开始的汇编宏 ENTRY_INTO32 将 _cfunc32flat_handle_post 函数地址放入了 edx,现在这段代码就要跳转到 handle_post 里。我们得以逃离汇编语言,走向 C 语言!

handle_post

handle_post(void) 函数定义在 /seabios/src/post.c 中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
void VISIBLE32FLAT
handle_post(void)
{
    if (!CONFIG_QEMU && !CONFIG_COREBOOT)
        return;

    serial_debug_preinit();
    debug_banner();

    // Check if we are running under Xen.
    xen_preinit();

    // Allow writes to modify bios area (0xf0000)
    make_bios_writable();

    // Now that memory is read/writable - start post process.
    dopost();
}

前面的 VISIBLE32FLAT 宏用于告诉编译器 handle_post 将编译成 32 位平坦代码段。
handle_post 只有几行函数调用,前面用于确定是否开启串口调试,还有检查是否在 xen 下。
这里的 make_bios_writable() 将 BIOS 区域设置为可写,让我们再一次回到 qemu,回到将 bios 放入内存段的时候:

1
2
// qemu/hw/i386/x86-common.c  load_bios_from_file
memory_region_set_readonly(&x86ms->bios, !isapc_ram_fw); // 将 BIOS 内存区域设为只读

也就是在最开始,bios 区域的内容都是只读的。但是在进入 bios 之后需要对其的变量,数据段进行读写,此时需要做一些 trick 达到这一目的。
这个函数主要是通过 shadow memory 技术来实现的。具体不展开,反正结果就是我们可以读写 bios 的数据和栈段了。
该函数使得 0xc0000 ~ 0xfffff 可读写,接下来就调用 dopost() 进行代码的重定位并继续初始化。

dopost

handle_post 程序最后调用 dopost 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void VISIBLE32INIT
dopost(void)
{
    code_mutable_preinit();

    // Detect ram and setup internal malloc.
    qemu_preinit();
    coreboot_preinit();
    malloc_preinit();  // 初始化内存分配器

    // Relocate initialization code and call maininit().
    reloc_preinit(maininit, NULL);
}

dopost 彻底的将 BIOS 从 ROM 中移动到 RAM。将 bios 从 ISA 地址的顶端到更低地址的合适区域。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
code_mutable_preinit();

void
code_mutable_preinit(void)
{
    if (HaveRunPost)
        // Already run
        return;
    // Setup reset-vector entry point (controls legacy reboots).
    rtc_write(CMOS_RESET_CODE, 0);
    barrier();
    HaveRunPost = 1;
    barrier();
}

在 dopost 调用时,计算机的初始化已经基本完成,可以确定此时已经上电完成。
code_mutable_preinit(void) 用于标记 POST 已执行。其通过将 CMOS 的 CMOS_RESET_CODE 位置 0 。
下面的两次 barrier() 也是 trick 。

1
#define barrier() __asm__ __volatile__("": : :"memory")

没有任何汇编代码,但是用于告诉编译器做了内存修改不能对指令顺序重排,防止编译器优化。
最后将 HaveRunPost 赋值为 1。再一次 barrier 后回到 dopost。

1
2
3
qemu_preinit();
coreboot_preinit();
malloc_preinit();

根据特定平台做初始化之后初始化内存分配器,这里就暂不展开了。

1
reloc_preinit(maininit, NULL);

reloc_preinit 重定位初始化代码并调用主初始化函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void __noreturn
reloc_preinit(void *f, void *arg)
{
    void (*func)(void *) __noreturn = f;
    if (!CONFIG_RELOCATE_INIT)
        func(arg);

    // Allocate space for init code.
    u32 initsize = SYMBOL(code32init_end) - SYMBOL(code32init_start);
    u32 codealign = SYMBOL(_reloc_min_align);
    void *codedest = memalign_tmp(codealign, initsize);
    void *codesrc = VSYMBOL(code32init_start);
    if (!codedest)
        panic("No space for init relocation.\n");

    // Copy code and update relocs (init absolute, init relative, and runtime)
    dprintf(1, "Relocating init from %p to %p (size %d)\n"
            , codesrc, codedest, initsize);
    s32 delta = codedest - codesrc;
    memcpy(codedest, codesrc, initsize);
    updateRelocs(codedest, VSYMBOL(_reloc_abs_start), VSYMBOL(_reloc_abs_end)
                 , delta);
    updateRelocs(codedest, VSYMBOL(_reloc_rel_start), VSYMBOL(_reloc_rel_end)
                 , -delta);
    updateRelocs(VSYMBOL(code32flat_start), VSYMBOL(_reloc_init_start)
                 , VSYMBOL(_reloc_init_end), delta);
    if (f >= codesrc && f < VSYMBOL(code32init_end))
        func = f + delta;

    // Call function in relocated code.
    barrier();
    func(arg);
}

函数有点复杂。

1
void (*func)(void *) __noreturn = f;

声明一个函数指针 func,指向 f 函数。

1
2
if (!CONFIG_RELOCATE_INIT)
    func(arg);

CONFIG_RELOCATE_INIT 判断是否需要重定位,如果不要就不做下面的代码,直接调用 func(arg)。

1
2
3
4
u32 initsize = SYMBOL(code32init_end) - SYMBOL(code32init_start); // 计算 code32init 段大小
u32 codealign = SYMBOL(_reloc_min_align);   // 确定重定位所需最小的对齐值
void *codedest = memalign_tmp(codealign, initsize); // 寻找到合适的内存块
void *codesrc = VSYMBOL(code32init_start);  // 将 code32init_start 转换成 void *

code32init_start 这类变量被声明是 extern char[] 但是这类实际上是连接器定义的地址标记符号,用于配合链接的,好神奇,好抽象……

计算新旧地址的偏移量:

1
s32 delta = codedest - codesrc;

将初始化代码(.init) 段从 codesrc 复制到 codedest。

1
memcpy(codedest, codesrc, initsize);

修正地址引用:

1
2
3
4
5
6
updateRelocs(codedest, VSYMBOL(_reloc_abs_start), VSYMBOL(_reloc_abs_end)
             , delta);  // 修正 init 段内绝对地址的引用
updateRelocs(codedest, VSYMBOL(_reloc_rel_start), VSYMBOL(_reloc_rel_end)
             , -delta); // 修正 init 段内相对地址的引用
updateRelocs(VSYMBOL(code32flat_start), VSYMBOL(_reloc_init_start)
             , VSYMBOL(_reloc_init_end), delta);  // 修正运行时段对 init 段的引用

调整目标函数指针:

1
2
if (f >= codesrc && f < VSYMBOL(code32init_end))
    func = f + delta;

确保内存修改后调用目标函数,这里的目标函数就是 maininit。

1
2
barrier();
func(arg);

进入 maininit

到这一步,终于到主初始化函数了,前期工作才刚刚完成。 init 段迁移到新的内存段, runtime 段代码还永远地留在原处。
这一步就要按顺序一步步初始化所有子系统:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static void
maininit(void)
{
    // Initialize internal interfaces.
    interface_init();

    // Setup platform devices.
    platform_hardware_setup();

    // Start hardware initialization (if threads allowed during optionroms)
    if (threads_during_optionroms())
        device_hardware_setup();

    // Run vga option rom
    vgarom_setup();
    sercon_setup();
    enable_vga_console();

    // Do hardware initialization (if running synchronously)
    if (!threads_during_optionroms()) {
        device_hardware_setup();
        wait_threads();
    }

    // Run option roms
    optionrom_setup();

    // Allow user to modify overall boot order.
    interactive_bootmenu();
    wait_threads();

    // Prepare for boot.
    prepareboot();

    // Write protect bios memory.
    make_bios_readonly();

    // Invoke int 19 to start boot process.
    startBoot();
}

看着好复杂,但回头望去我们已经走好远了。已经到达 32 位实模式,虽然仅仅将 bios.bin 加载完毕……

gdb 调试 ing

参考资料

  1. seabios/docs/Linking_overview.md
  2. 中断描述符表(Interrupt Descriptor Table,IDT)
  3. SeaBIOS实现简单分析

演示:本讲内容速览

下面是本讲的 Reveal.js 演示,覆盖从 reset vector 到 32 位保护模式、再到 maininit 的整条调用链。右上角可切换深浅主题,按 → / ← 翻页。

小提示:点击右上角 ◐ 主题 在深色 / 浅色 / 跟随系统间切换; 按 S 打开演讲者视图。