从零开始写 OS 内核 - 加载并进入 kernel

SegmentFault

共 4729字,需浏览 10分钟

 · 2021-06-13

作者:hyuan

来源:SegmentFault 思否社区

系列目录

  • 序篇
  • 准备工作
  • BIOS 启动到实模式
  • GDT 与保护模式
  • 虚拟内存初探
  • 加载并进入 kernel
  • 显示与打印
  • GDT 和 IDT,中断处理
  • 虚拟内存完善
  • 实现堆和 malloc
  • 创建第一个内核线程
  • 多线程运行与切换
  • 锁与多线程同步
  • 进程的实现
  • 进入用户态
  • 一个简单的文件系统
  • 加载可执行程序
  • 系统调用的实现
  • 键盘驱动
  • 运行 shell

kernel 磁盘镜像

接上一篇 虚拟内存初探,本篇将正式加载并启动 kernel,也就是图中绿色的部分:


当然 kernel 镜像要从磁盘上读取加载,所以这里回顾一张老图,是 disk 和 memory(物理内存)的数据对应关系:


顺便提一下,上图中斜线阴影打问号的部分,就是上一章讲的 kernel page tables,即第一张图的橙色部分,共 256 张占地 1MB。

编写 kernel

回到 kernel ,即图中绿色部分,它现在实际上还不存在,所以首先我们需要实现、编译一个简单的 demo 性质的 kernel。如果对 kernel 是什么还没有概念的同学,可能会问:到底 kernel 长什么样?
答案非常简单:kernel 和你平时用 C 语言写的可执行程序几乎没有任何区别,也是从一个 main 函数开始。
下面我们就实现我们的第一个 kernel:
main() {
  while (1) {}
}
就是这样简单,除了一个 while 循环,没有任何其它东西,但它足以用作我们这里的 demo。

编译 kernel

这里有很多编译参数,例如以 32 位编码,禁用 C 标准库等(这是我们自己定制的 OS,和 C 标准库不可能兼容)。
gcc -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector -no-pie -fno-pic -c main.c -o main.o

链接 kernel:

ld -m elf_i386 -Tlink.ld -o kernel main.o
这里会用到一个 link 配置文件 link.ld
ENTRY(main)
SECTIONS
{
  .text 0xC0800000:
  {
    code = .; _code = .; __code = .;
    *(.text)
  }

  .data ALIGN(4096):
  {
     data = .; _data = .; __data = .;
     *(.data)
     *(.rodata)
  }

  .bss ALIGN(4096):
  {
    bss = .; _bss = .; __bss = .;
    *(.bss)
    . = ALIGN(4096);
  }

  end = .; _end = .; __end = .;
}
这里最重要的就是定义了 text 段的起始地址 0xC0800000,也是整个 kernel 编址的起始。如果你还记得上一篇的内容,我们规划了 kernel 空间的虚拟内存分布:


0xC0800000 将是 kernel 的入口地址,因为 text 段会被加载到此处,往后依次是 databss 等段。loader 结束后将会跳转到该地址。
另外上面还定义了整个可执行文件的入口函数为 main
编译链接后的 kernel 是一个 ELF 格式的二进制,我们不妨将它反汇编 dump 看一下:
objdump -dsx kernel


可以看到 main 函数的地址为 0xC080000,这是进入 kernel 后的第一条指令。

制作 kernel 镜像

dd if=kernel of=scroll.img bs=512 count=2048 seek=9 conv=notrunc
seek=9 是因为前面 mbr 和 loader 已经在磁盘上占据了前 9 个 sectors。这里 kernel 大小为 2048 个 sectors 共 1MB,对于我们这个项目而言已经足够大了,完全够用。
现在磁盘镜像终于变成了这样:

读取并加载 kernel

镜像准备完毕,接下来就可以将 kernel 读取并且加载了。首先还是给出代码链接 init_kernel,供你参考。
和之前 mbr 和 loader 的加载不同,这里将读取加载两个词分开,是因为它们是两个步骤:
  • 读取:是将 kernel 磁盘镜像的 原始二进制 复制到内存中某空闲处,这里的二进制是 ELF 格式的;
  • 加载:是将前一步得到的 ELF 可执行二进制进行解析,将每一个 section 复制到它们被 编址 的地方;
首先来看第一步“读取”。我们选择的是虚拟内存顶部的 1MB,即 (0xFFFFFFFF - 1MB) ~0xFFFFFFFF 的 1MB 空间作为二进制镜像的存放地址。当然也要为它分配相应的物理页 frames,在 page table 中建立映射。然后就可以像之前读取 mbr 和 loader 一样,将 kernel 镜像读取进来。
接下来是第二步“加载”。这里涉及到了根据 ELF 文件格式的规范进行解析,主要就是从 program header table 中获取每个 section 的位置和大小,以及加载的内存地址(当然是 virtual 地址),然后将数据 copy 过去。这一次加载的内存地址,才是 0xC0800000 开始的位置。当然在 copy 之前,当然要为它们预先分配好 frames 并且在 page table 中建立好内存映射。这一切工作都在 allocate_pages_for_kernel 这个函数中提前完成了。

进入 kernel

一切准备就绪,接下来就可以真正进入 kernel 了:
init_kernel:
  call allocate_pages_for_kernel
  call load_hd_kernel_image
  call do_load_kernel
  
  ; init floating point unit before entering the kernel
  finit

  ; move stack to 0xF0000000
  mov esp, KERNEL_STACK_TOP - 16
  mov ebp, esp

  ; let's jump to kernel entry :)
  jmp eax
  ret
首先初始化了 CPU 的浮点数单元,防止它后面异常。
然后我将 stack 移到了比较高的地址 0xF0000000 位置,这当然不是必须的,当前的 stack 位置其实也不错(大约在 0x7B00 以下附近的位置,这是在 mbr 中转移过去的,如果你还记得的话)。只是我希望后面的 stack 位置能被移到 0xC0000000 以上的 kernel 空间中,所以才这么做了一步。stack 的位置是比较灵活的,只要是一个闲置的,不会受到干扰的地方就可以。

然后非常简单,jmp eax 一条指令跳到了 kernel 入口处。
为什么是 eax?这是上面函数 do_load_kernel 的返回值,这个函数就是我们解析加载 kernel 的 ELF 二进制的函数,它会返回值 kernel 的入口地址,即 main 函数地址,这个地址是由 ELF 文件中 ELF Header 的 e_entry 字段给出的。ELF 可执行二进制的入口地址是在链接阶段确定的,它实际上是由之前的 link.ld 里的 ENTRY(main) 指定的。
顺利的话,运行的结果如下:


程序已经成功地进入 kernel 并且运行到了 0xC0800003 处,就是那个 while 循环的位置,这将是 kernel 征途的真正开篇:)


点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,扫描下方”二维码“或在“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

- END -


浏览 12
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报