进入内核后,上篇内容已经将汇编部分讲完了,接下来讲解 C 语言部分,即 i386_init,在这段代码中会初始化控制台和内存,随后进入一个无限循环,等待用户的命令。例如用户输入 help 会触发相应的函数,控制台输出相应的内容。

i386_init

下面是 i386_init 对应的代码,随后是对这段代码的详细解释:

void
i386_init(void)
{
	extern char edata[], end[];

    // 在执行其他任何操作之前,完成ELF加载过程。
    // 清除程序的未初始化全局数据(BSS)段。
    // 这确保了所有静态/全局变量都从零开始。
	memset(edata, 0, end - edata);

    // 初始化控制台。
    // 在执行这个操作之前,不能调用cprintf函数!
	cons_init();

	mem_init();

    // 进入内核监视器。
    // 无限循环,等待监视器的命令。
	while (1) {
		monitor(NULL);
	}
}
  1. extern char edata[], end[];:这两个变量是由链接器提供的,表示程序的数据段(包括初始化的全局变量)和 BSS 段(包括未初始化的全局变量)的结束地址。edata 指向数据段的结束地址,end 指向 BSS 段的结束地址。

  2. memset(edata, 0, end - edata);:这行代码将 BSS 段的所有字节设置为 0。这是因为 C 语言规定,未初始化的全局变量和静态变量应该被初始化为 0。这里的 end - edata 计算的是 BSS 段的大小。

下面是 edata 和 end 这两个变量在链接器中的定义:

	.bss : {
		PROVIDE(edata = .);
		*(.bss)
		PROVIDE(end = .);
		BYTE(0)
	}

这段代码定义了一个名为 .bss 的段,这个段用于存储程序的未初始化的全局变量和静态变量。

PROVIDE(edata = .); 这行代码定义了一个符号 edata,并将其设置为当前位置(. 表示当前位置)。在链接器脚本中,. 代表当前输出段的位置计数器,也就是当前已经输出到的地址。因此,edata 的值就是 .bss 段的开始地址。

*(.bss) 这行代码将所有输入文件中的 .bss 段合并到输出文件的 .bss 段中。* 是通配符,表示所有的文件,.bss 表示 .bss 段。

PROVIDE(end = .); 这行代码定义了一个符号 end,并将其设置为当前位置。因为这行代码在 *(.bss) 之后,所以 end 的值就是 .bss 段的结束地址。

BYTE(0) 这行代码在 .bss 段的末尾添加了一个字节的空间,并将其初始化为 0。这是为了确保 .bss 段在内存中的实际大小至少为一个字节,即使在源代码中没有任何未初始化的全局变量或静态变量。

总的来说,这段代码的作用是设置 .bss 段的开始和结束地址,并将所有输入文件中的 .bss 段合并到一起。

  1. cons_init();:这个函数用于初始化控制台。在这个函数被调用之前,不能使用 cprintf 函数,因为 cprintf 函数依赖于控制台的初始化。

  2. mem_init();:这个函数用于初始化内存管理系统。在这个函数被调用之后,操作系统就可以正常地分配和释放内存了。下一章回详细讲解这部分实现细节。

  3. while (1) { monitor(NULL); }:这是一个无限循环,它会不断地调用 monitor 函数。monitor 函数是一个简单的命令行界面,它会等待用户输入命令,然后执行相应的操作。这个无限循环保证了,即使 monitor 函数返回,操作系统也不会退出,而是继续等待下一个命令。

内存布局

下面展现了一个进程在内存中的布局,结合内存布局讲解 BSS 段和数据段。

高地址          ---> .----------------------.
                    |      Environment     |
                    |----------------------|
                    |                      |   在栈上声明的函数和变量
                    |         STACK        |   栈上的空间被基指针(base pointer)指示。
base pointer ->     | - - - - - - - - - - -|
                    |           |          |
                    |           v          |
                    :                      :
                    .                      .   栈向未使用的空间方向增长,而堆向上增长。
                    .          空          .
                    .                      .
                    .                      .   (这里可能会发生其他内存映射,如动态库,不同的内存分配方式)
                    .                      .
                    :                      :
                    |           ^          |
                    |           |          |
 brk point ->       | - - - - - - - - - - -|   堆上声明动态内存
                    |          HEAP        |
                    |                      |
                    |----------------------|
                    |          BSS         |   未初始化数据 (BSS)
                    |----------------------|
                    |          Data        |   初始化数据 (DS)
                    |----------------------|
                    |          Text        |   二进制代码
低地址         ----> '----------------------'

其中数据段和 BSS 段是程序内存布局的两个重要部分。

  1. 数据段:数据段主要用于存储程序的全局变量和静态变量。这些变量在程序编译时就已经确定了初始值。例如,如果你在程序中定义了一个全局变量 int g_var = 10;,那么这个变量就会被存储在数据段中,其初始值为 10。

  2. BSS 段:BSS 段用于存储未初始化的全局变量和静态变量。在 C 语言中,如果全局变量和静态变量在声明时没有显式初始化,那么它们会被自动初始化为 0。这些变量就会被存储在 BSS 段中。例如,如果你在程序中定义了一个全局变量 int g_var;,那么这个变量就会被存储在 BSS 段中,其初始值为 0。

这两个段的主要区别在于,数据段中的变量有初始值,而 BSS 段中的变量没有初始值。在程序加载到内存时,操作系统会为数据段和 BSS 段分配内存空间,并将数据段的变量初始化为预设的值,将 BSS 段的变量初始化为 0。

堆栈的布局

在 x86 架构中,esp(堆栈指针寄存器)和 ebp(基指针寄存器)是两个非常重要的寄存器,它们在函数调用和堆栈操作中起着关键的作用。

  1. esp(堆栈指针寄存器):esp 寄存器指向当前堆栈的顶部。当我们向堆栈中压入数据时,esp 的值会减小(因为在 x86 架构中,堆栈是向下增长的),当我们从堆栈中弹出数据时,esp 的值会增大。因此,esp 总是指向当前堆栈帧的顶部。

  2. ebp(基指针寄存器):ebp 寄存器通常用作帧指针,指向当前堆栈帧的底部。在函数调用时,ebp 的值会被压入堆栈,然后 esp 的当前值会被复制到 ebp,这样 ebp 就指向了新的堆栈帧的底部。在函数返回时,ebp 的值会被恢复,指向上一个堆栈帧的底部。

这两个寄存器的使用使得我们可以在堆栈中有效地定位和访问数据。例如,我们可以通过 ebp 来访问函数的参数和局部变量(它们都存储在堆栈中),而 esp 则用于管理堆栈的增长和缩小。

下面表示在 x86 架构中函数调用时,堆栈的布局。

+---------------------+
|    Function Call    |
|---------------------|
|       ...           |
|   Previous Frame    | <- ebp points here (bottom of the current stack frame)
|   Local Variables   |
|       ...           |
|                     |
|---------------------|
|   Return Address    |
|---------------------|
|   Parameters        |
|       ...           |
|                     |
|---------------------|
|      Old EBP        |
|---------------------|
|      Local Data     |
|       ...           |
|                     |
|---------------------|
|      ...            |
|                     |
|---------------------|
|      ...            |
|                     |
|---------------------|
|    New Stack Frame  |
|                     |
|---------------------|
|       ...           |
|  Previous ESP Value | <- esp points here (top of the current stack frame)
+---------------------+

这样的结构使得函数调用和返回可以方便地在堆栈上进行,以及访问局部变量和控制程序的流程。

在 x86 架构中,当一个函数被调用时,它会将当前的 ebp 寄存器的值(也就是上一个函数的 ebp 值)保存(push)到堆栈中,然后将 esp(堆栈指针)寄存器的值复制到 ebp 寄存器,这样 ebp 就指向了新的堆栈帧的底部。因此,堆栈中保存的 ebp 值实际上形成了一个链表,每一个 ebp 值都指向了上一个函数的堆栈帧。 因此,我们可以通过从当前的 ebp 值开始,逐个追踪保存在堆栈中的 ebp 值,来回溯整个堆栈,这就是所谓的"通过跟踪保存的 ebp 指针链回溯堆栈"。这种技术常常被用于调试和错误诊断,即 backtrace 。例如,当程序崩溃时,我们可以通过回溯堆栈来找出导致崩溃的函数调用序列。

ebp(基指针寄存器)本身不是一个链表。然而,在函数调用过程中,ebp 寄存器的值会被保存在堆栈中,这样就形成了一个链式结构,我们可以通过这个链式结构回溯函数调用栈。这就是为什么有时候我们会说"ebp 指针链"或者"ebp 链"。但实际上,这是一种比喻,用来描述 ebp 在函数调用和返回过程中的行为。

总结

接下来讲解内存管理,即上面提及的 mem_init 部分。