第一章 汇编语言、内核引导规范和链接脚本 - 从零开始开发UEFI引导的64位操作系统内核

声明:此文章为本人在知乎首发的原创文章


  • 上一章我们做好了开发内核前的准备工作。不过这一章,我们需要学习一些对于内核开发很有必要但是绝大多数人都不会学到的知识。

一、x86_64汇编语言

首先,我拒绝任何内嵌汇编,因为这个教程没有指定某个平台架构,只要是高级语言代码,就一定要让它们轻松地跨平台,作为教程的示例代码;针对某平台编写的汇编代码则自成另外的源文件,在链接过程中放入内核中,作为教程的参考代码。
内核中总有一些代码需要直接用汇编写,而众所周知汇编语言在每个不同的架构上都是不同的,甚至一种架构的不同版本都是不同的。
理论上,你可以在任何架构上开发一个内核,作为参考,我使用英特尔64位x86平台(或称amd64平台)。
如果你会,你还可以用aarch64汇编为64位arm架构开发内核、用rv64汇编为64位riscv架构开发内核等。
作为一个教程,本文无法将所有的汇编指令教给读者,更多的汇编指令请直接查阅英特尔白皮书。
接下来的内容仅仅足够读者看懂本教程并跟着教程开发一个功能非常简单的内核。


1. 寄存器

寄存器是CPU核心中非常重要的部件,负责临时保存程序的数据。
寄存器的读写速度非常快,完全能跟上CPU的速度。集成在CPU中的高速缓存以及内存相对于CPU来说速度其实非常慢,因此在编写汇编语言时除堆栈操作和必要的保存结果的操作外尽量不使用内存寻址,而是直接用寄存器。
在qemu中,可以点击工具栏中的视图菜单,选择compatmonitor0将屏幕切换到qemu的命令行,输入info registers看到x86架构在ia32e模式(64位模式)中所有的寄存器:x86 ia32e模式下所有的寄存器以下是常用且非常重要的寄存器(寄存器名的中文含义有些只代表在x86早期这个寄存器的作用):

  • 通用寄存器(都是64位大小),只有这些寄存器可以在指令中使用

rax - (accumulator)累加器,在C语言的调用约定中作为返回值使用。
rcx - (counter)计数器,在C语言调用约定中作为函数的第四个参数,串指令和loop指令会将它作为计数器使用。
rbx - (base address)基址寄存器,在乘除法指令中有用。
rdx - (data)数据寄存器,在C语言调用约定中作为函数的第三个参数。
rsi - (source index)源变址寄存器,在C语言调用约定中作为函数的第二个参数,串指令将它作为源操作数。
rdi - (destination index)目的变址寄存器,在C语言调用约定中作为函数的第一个参数,串指令将它作为目标操作数。
rbp - (base pointer)栈基指针,指向当前函数栈帧的栈底。
rsp - (stack pointer)堆栈指针,指向当前函数栈帧的栈顶。
r8 - ia32e模式新增寄存器,在C语言调用约定中作为函数的第五个参数。
r9 - ia32e模式新增寄存器,在C语言调用约定中作为函数的第六个参数。
r10 ~ r15 - ia32e模式新增寄存器,没什么特殊用途,可以随便用。

  • ?s - 段寄存器:在64位模式中不可变,几乎没用,但是我们需要正确地配置它们。
  • rip - (instruction pointer)指令指针寄存器,64位,指向下一条指令的地址。
  • rflags - 标志位寄存器,64位,标志位记录CPU核心的状态,条件指令通过访问这些标志位确定条件。
  • cr3 - 四级页表或五级页表的首地址及一些标志位。
  • cr0/cr2/cr4 - 控制寄存器,也是储存一些标志位,记录CPU核心的状态。

2. x86_64堆栈和调用约定

很多人可能学过英特尔风格的16位实模式汇编,nasm汇编器沿用了这种风格到x86_64汇编上。
堆栈中压入和弹出的是栈帧,一个栈帧由函数创建和删除,栈帧中储存了函数中所谓的栈上变量,在本节中,假设所有的栈上变量共占用储存空间为n字节。

  • 栈帧的建立和退出

函数被调用时,栈帧还处于调用前的那个函数的状态。
首先要记录上一个栈帧:

push rbp
mov rbp, rsp
sub rsp, n

首先把上一个栈帧的栈底压栈,这样只要使用pop rbp就能将上一个栈帧的栈底恢复。
其次将上一个栈帧的栈顶作为这一栈帧的栈底,这样只需要mov rsp, rbp就能将上一栈帧的栈顶恢复。
最后将本栈帧压栈,因为x86架构的堆栈是从高地址向低地址延伸的,所以使用减法指令。
这样,从[rsp][rsp+n]的内容就是本栈帧,给每个栈上变量设置一个合适的位置就可以了。
所以,退出本栈帧就是相反的过程:

mov rsp, rbp
pop rbp

退出栈帧后使用ret命令返回即可。

  • C语言调用约定

我们需要在C语言和汇编语言间相互调用,因此需要遵守C语言调用约定。
函数返回值 - 使用rax寄存器,因此,对于有返回值的函数,在退出栈帧之前需要将返回值写入rax。
函数参数 - 前6个占用小于等于8字节(寄存器一共8字节)的参数依次使用rdi,rsi,rdx,rcx,r8,r9,其余的参数依次压栈,我们不需要学习如何读取栈上的参数,因为以下两个原因:第一,函数的参数过多会导致代码可读性变差,我们需要避免定义参数过多的函数;第二,占用空间过大的数据类型应该传递它的指针,因为压栈意味着使用内存,而内存速度比较慢,把指针(即地址)写入寄存器速度会更快。
另外,栈帧建立和退出的规则也是C语言调用约定的一部分。

3. nasm语法

  • 段(与分段机制无关)

段声明依然使用section伪指令,不过我们不需要定义段的起始地址,内存对齐也通常只有特定的数据段需要。

  • 定义原始数据

db - 定义一系列字节
dw - 定义一系列字(2字节)
dd - 定义一系列双字
dq - 定义一系列四字(8字节)

  • .bss段的保留空间伪指令

resb n留空n字节
resw n留空n个字
resd n留空n个双字
resq n留空n个四字

  • 定义常量
CONSTVAR equ value

与C语言的#define宏类似,代码中的CONSTVAR会替换成value,不过nasm会事先计算value的值。

  • 多文件符号共享

extern <SYMBOL> - 使用其它源文件中的符号<SYNBOL>。
global <SYMBOL> - 将符号<SYMBOL>暴露给其它源文件使用。

  • endbr64 - 在ia32e模式中新加入的指令,用于屏蔽之前的分支预测信息,通常在函数开头使用,降低黑客利用分支预测信息对程序进行攻击的可能。

二、Multiboot2引导规范

multiboot2是GNU制定的一个通用内核引导规范,它提供了非常丰富的选项和API,还提供了直接访问EFI的方式。
multiboot2规范原文:multiboot2规范原文

通常我们只会用到一部分功能,如果你仅仅想写一个能运行的内核,完全不需要阅读原文,跟着教程走就可以。

  • multiboot2头

multiboot2头可以嵌入在任何格式的文件中,只要内核头出现在文件前8192字节中,就可以被识别;在这里我们将multiboot2头嵌入elf64可执行文件中,因为grub可以加载elf格式的内核。
multiboot2头的定义如下:

偏移量类型内容
0u32magic;魔数,0xe85250d6
4u32arch;架构类型,x86_64置0
8u32length;multiboot2头长度
12u32checksum;校验码,使得前四项相加结果为0
16 - xx-tags;标签列表
  • multiboot2标签

multiboot2标签在multiboot2头的16字节处开始,以8字节对齐一次排列。
由于只使用个别标签,在使用时再做出有关的说明。

  • multiboot2 information

内核被引导后multiboot2提供一系列数据结构为内核提供硬件系统的基本数据,每一种数据结构都有一个type码。


三、GNU ld链接脚本

以下以一个x86-64平台的内核链接脚本为例说明GNU ld链接脚本的语法:

OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(init32)

SECTIONS {
    . = 1M;
    .entry :
    {
        init32 = .;
        *(.entry)
    }
    .multiboot2 :
    {
        *(.multiboot2)
    }
    . = 4M;
    .bss :
    {
        kstack = .;
        *(.bss)
    }
    . = 16M;
    .text :
    {
        kmain = .;
        *(.text)
    }
    .data :
    {
        *(.data)
    }
    .rodata :
    {
        *(.rodata)
    }
    .bss :
    {
        *(.bss)
    }
    .eh_frame :
    {
        *(.eh_frame)
    }
    .got :
    {
        *(.got)
    }
    .got.plt :
    {
        *(.got.plt)
    }
    .iplt :
    {}
    .rela.dyn :
    {}
    .igot.plt :
    {}
    .kend :
    {
        *(.kend)
    }
}

首先声明可执行文件的格式、和目标架构;然后声明程序入口点,在这个示例中,内核从init64这个符号开始运行。
在SECTIONS声明中,.符号相当于一个随着声明的段和符号自动增加的变量,还可以在段声明之间设置它的值。.代表的是当前的地址,.的地址就是当前这个段的起始地址, 在段声明中,可以通过符号名 = .;的方式设置符号的位置。*(段名)的语法可以将这个段中的符号按照其在.o文件的顺序依次链接,每经过一个符号,.就将自己增加这个符号所占的空间大小。
这样,我们就能任意调整程序在内存中的分布了。

  • 17
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

指向BIOS的野指针

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值