rcore 笔记 第一个裸机程序(一)https://developer.aliyun.com/article/1391888
编译流程
从源代码得到可执行文件的编译流程可被细化为多个阶段(虽然输入一条命令便可将它们全部完成):
- 编译器(Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个ASCII 或其他编码的文本文件;
- 汇编器(Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的目标文件(Object File);
- 链接器(Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
汇编器输出的每个目标文件都有一个独立的程序内存布局,它描述了目标文件内各段所在的位置。而链接器所做的事情是将所有输入的目标文件整合成一个整体的内存布局。在此期间链接器主要完成两件事情:
• 第一件事情是将来自不同目标文件的段在目标内存布局中重新排布。如下图所示,在链接过程中,分别来自于目标文件1.o和2.o段被按照段的功能进行分类,相同功能的段被排在一起放在拼装后的目标文件output.o中。注意到,目标文件1.o和2.o的内存布局是存在冲突的,同一个地址在不同的内存布局中存放不同的内容。而在合并后的内存布局中,这些冲突被消除。
• 第二件事情是将符号替换为具体地址。这里的符号指什么呢?我们知道,在我们进行模块化编程的时候,每个模块都会提供一些向其他模块公开的全局变量、函数等供其他模块访问,也会访问其他模块向它公开的内容。要访问一个变量或者调用一个函数,在源代码级别我们只需知道它们的名字即可,
这些名字被我们称为符号。取决于符号来自于模块内部还是其他模块,我们还可以进一步将符号分成内部符号和外部符号。然而,在机器码级别(也即在目标文件或可执行文件中)我们并不是通过符号来找到索引我们想要访问的变量或函数,而是直接通过变量或函数的地址。例如,如果想调用一个函数,那么在指令的机器码中我们可以找到函数入口的绝对地址或者相对于当前PC的相对地址。
内核第一条指令
编写内核第一条指令
首先,我们需要编写进入内核后的第一条指令,这样更方便我们验证我们的内核镜像是否正确对接到Qemu
上。
# os/src/entry.asm .section .text.entry .globl _start _start: li x1, 100
实际的指令位于第5行,也即li x1, 100。li是LoadImmediate的缩写,也即将一个立即数加载到某个寄存器,因此这条指令可以看做将寄存器x1赋值为100。第4行我们声明了一个符号_start,该符号指向紧跟在符号后面的内容——也就是位于第5行的指令,因此符号_start的地址即为第5行的指令所在的
地址。第3行我们告知编译器_start是一个全局符号,因此可以被其他目标文件使用。第2行表明我们希望将第2行后面的内容全部放到一个名为.text.entry的段中。一般情况下,所有的代码都被放到一个名为.text的代码段中,这里我们将其命名为.text.entry从而区别于其他.text的目的在于我们想要确保该段被放置在相比任何其他代码段更低的地址上。这样,作为内核的入口点,这段指令才能被最先执行。
接着,我们在main.rs中嵌入这段汇编代码,这样Rust编译器才能够注意到它,不然编译器会认为它是一个与项目无关的文件:
//os/src/main.rs #![no_std] #![no_main] mod lang_item; usecore::arch::global_asm; global_asm!(include_str!("entry.asm"));
第8行,我们通过include_str!宏将同目录下的汇编代码entry.asm转化为字符串并通过global_asm!宏嵌入到代码中。
调整内核的内存布局
由于链接器默认的内存布局并不能符合我们的要求,为了实现与Qemu正确对接,我们可以通过链接脚本(LinkerScript)调整链接器的行为,使得最终生成的可执行文件的内存布局符合Qemu的预期,即内核第一条指令的地址应该位于0x80200000。我们修改Cargo的配置文件来使用我们自己的链接脚本os/src/linker.ld而非使用默认的内存布局:
//os/.cargo/config [build] target ="riscv64gc-unknown-none-elf" [target.riscv64gc-unknown-none-elf] rustflags= [ "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes" ]
链接脚本os/src/linker.ld如下:
OUTPUT_ARCH(riscv) ENTRY(_start) BASE_ADDRESS =0x80200000; SECTIONS { . = BASE_ADDRESS; skernel = .; stext= .; .text:{ *(.text.entry) *(.text.text.*) } . = ALIGN(4K); etext= .; srodata = .; .rodata:{ *(.rodata .rodata.*) *(.srodata .srodata.*) } . = ALIGN(4K); erodata = .; sdata= .; .data:{ *(.data.data.*) *(.sdata.sdata.*) } . = ALIGN(4K); edata= .; .bss:{ *(.bss.stack) sbss=.; *(.bss.bss.*) *(.sbss.sbss.*) } . = ALIGN(4K); ebss= .; ekernel = .; /DISCARD/ :{ *(.eh_frame) } }
第1行我们设置了目标平台为riscv;第2行我们设置了整个程序的入口点为之前定义的全局符号_start;第3行定义了一个常量BASE_ADDRESS为0x80200000,也就是我们之前提到内核的初始化代码被放置的地址;
从第5行开始体现了链接过程中对输入的目标文件的段的合并。其中.表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件中收集来的段。我们可以对.进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为.从而记录这一时刻的位置。我们还能够看到这样的格式:
.rodata: { *(.rodata) }
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为(SectionName),表示目标文件ObjectFile的名为SectionName的段需要被放进去。我们也可以使用通配符来书写和分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件中各个常见的段.text,.rodata.data,.bss从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,且每个段都有两个全局符号给出了它的开始和结束地址。
第12行我们将包含内核第一条指令的.text.entry段放在最终的.text段的最开头,同时注意到在最终内存布局中代码段.text又是先于任何其他段的。因为所有的段都从BASE_ADDRESS也即0x80200000开始放置,这就能够保证内核的第一条指令正好放在0x80200000从而能够正确对接到Qemu上。
手动加载内核可执行文件
上面得到的内核可执行文件完全符合我们对于内存布局的要求,但是我们不能将其直接提交给Qemu,因为它除了实际会被用到的代码和数据段之外还有一些多余的元数据,这些元数据无法被Qemu在加载文件时利用,且会使代码和数据段被加载到错误的位置。如下图所示:
图中,红色的区域表示内核可执行文件中的元数据,深蓝色的区域表示各个段(包括代码段和数据段),而浅蓝色区域则表示内核被执行的第一条指令,它位于深蓝色区域的开头。图示的上半部分中,我们直接将内核可执行文件os提交给Qemu,而Qemu会将整个可执行文件不加处理的加载到Qemu内存的0x80200000处,由于内核可执行文件的开头是一段元数据,这会导致Qemu内存0x80200000处无法找到内核第一条指令,也就意味着RustSBI无法正常将计算机控制权转交给内核。相反,图示的下半部分中,将元数据丢弃得到的内核镜像os.bin被加载到Qemu之后,则可以在0x80200000处正确找到内核第一条指令。
使用如下命令可以丢弃内核可执行文件中的元数据得到内核镜像:
rust-objcopy--strip-all target/riscv64gc-unknown-none-elf/release/os-O binary
为内核支持函数调用
函数调用与栈
指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在RISC-V架构上,有两条指令即符合这样的特征:
指令 | 指令功能 |
jal rd, imm[20 : 1] | rd ←pc+4 pc ←pc+imm |
jalr rd, (imm[11 : 0])rs | rd ←pc+4 pc ←rs+imm |
从中可以看出,这两条指令在设置pc寄存器完成跳转功能之前,还将当前跳转指令的下一条指令地址保存在rd寄存器中,即rd←pc+4这条指令的含义。在RISC-V架构中,
通常使用ra寄存器(即x1寄存器)作为其中的rd对应的具体寄存器,因此在函数返回的时候,只需跳转回ra所保存的地址即可。事实上在函数返回的时候我们常常使用一条汇编伪指令(PseudoInstruction)跳转回调用之前的位置:ret。它会被汇编器翻译为jalr x0, 0(x1),含义为跳转到寄存器ra保存的物理地址,由于x0是一个恒为0的寄存器,在rd中保存这一步被省略。
由于每个CPU只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,就需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在物理内存中的一个区域保存(Save)函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并恢复(Restore)函数调用上下文中的寄存器。
实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
• 被调用者保存(Callee-Saved)寄存器:被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,这些寄存器保持不变;
• 调用者保存(Caller-Saved)寄存器:被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数来保证在调用前后,这些寄存器保持不变。
rcore 笔记 第一个裸机程序(三)https://developer.aliyun.com/article/1391890