MMU : Memory Managment Unit , 是绑定在 ARCH 上的一个硬件。作用是做地址转换,CPU 在开启 MMU 之后,访问内存会首先经过 MMU 完成地址转化。
Phsical Memory Map : 一块SOC 的物理地址空间布局, 如 qemu 的 memory 是从 0x80000000 开始的,不同板卡的布局不一致
Virtual Memory Map : 虚拟地址空间布局,在 riscv linux 上是统一一致的,这个主要是填mmu 表填出来的
FPIC : Position independent Code 地址无关代码,如果一段代码是与地址无关的,那么这段代码可以加载到任意地方,在现代操作系统中,共享库通常是被编译为地址无关代码,随之而来的还有一些 GOT PLT 表,RELOCATION 等
-
在 Linux 里面一个 ARCH 只会用到一个 LD脚本,而 LD 脚本主要是用来描述编译出来的二进制代码的内存布局的,比如如何安排全局变量,静态变量,字符串,以及代码段的位置等,最后形成一个 ELF 二进制文件格式,想要加载这个 ELF 文件,我们需要把他使用 OBJCOPY 变成 Bin 文件然后放到CPU开始的地方运行。
-
LD 脚本主要用来规划整个内存布局,当我们在函数里面想要访问某个全局变量,在C层次里面是直接使用变量名,二进制需要考虑的更多,通常是通过地址对这个变量实现访问,而这个地址在哪,怎么知道,就是 LD 来排布的了
-
由于 LInux 一个 ARCH 只用一个 LD 脚本,那么针对一个ARCH的不同的BOARD, 也是同一个LD 脚本,但是不同的板卡 ,内存所在的位置都不一样,也就是内核加载的地址不同。比如qemu 的内存从 0x80000000 开始,内核放到 0x80000000 开始, 其他板卡可能从 0x90000000 开始,因为 RISC-V ARCH SPEC 里面并没有说 RISC-V CPU 初始的 pc 值是多少。不同板卡共用同一个 LD ,访问全局变量的时候就出问题了 , LD 排布全局变量地址,访问全局变量又是用地址访问的。如下给出 Linux 部分LD 的脚本
SECTIONS { /* Beginning of code and text segment */ . = LOAD_OFFSET; _start = .; HEAD_TEXT_SECTION . = ALIGN(PAGE_SIZE); .text : { _text = .; _stext = .; TEXT_TEXT SCHED_TEXT LOCK_TEXT KPROBES_TEXT ENTRY_TEXT IRQENTRY_TEXT SOFTIRQENTRY_TEXT _etext = .; } .... ... }
-
可以看到 LD 的起始地址是 LOAD_OFFSET 我们再找找这个在哪里定义的,下面给出找的过程
[ arch/riscv/kernel/vmlinux.lds.S ] define LOAD_OFFSET KERNEL_LINK_ADDR ------------------------------------------- [arch/riscv/include/asm/pgtable.h] #ifndef CONFIG_MMU #define KERNEL_LINK_ADDR PAGE_OFFSET #define KERN_VIRT_SIZE (UL(-1)) #else #define ADDRESS_SPACE_END (UL(-1)) #ifdef CONFIG_64BIT /* Leave 2GB for kernel and BPF at the end of the address space */ #define KERNEL_LINK_ADDR (ADDRESS_SPACE_END - SZ_2G + 1) #else #define KERNEL_LINK_ADDR PAGE_OFFSET #endif ------------------------------------------------------ [arch/riscv/include/asm/page.h] /* * PAGE_OFFSET -- the first address of the first page of memory. * When not using MMU this corresponds to the first free page in * physical memory (aligned on a page boundary). */ #ifdef CONFIG_64BIT #ifdef CONFIG_MMU #define PAGE_OFFSET kernel_map.page_offset #else #define PAGE_OFFSET _AC(CONFIG_PAGE_OFFSET, UL) #endif /* * By default, CONFIG_PAGE_OFFSET value corresponds to SV57 address space so * define the PAGE_OFFSET value for SV48 and SV39. */ #define PAGE_OFFSET_L4 _AC(0xffffaf8000000000, UL) #define PAGE_OFFSET_L3 _AC(0xffffffd800000000, UL) #else #define PAGE_OFFSET _AC(CONFIG_PAGE_OFFSET, UL) #endif /* CONFIG_64BIT */
-
看到这里不禁 wtf ?? sv39 从地址 0xffffffd800000000开始排布, 全局变量定位不得起飞咯
那么 RISCV Linux 这里到底是怎么解决的呢
- 我第一时间想到来把代码变成 FPIC 的代码 ,这样整个代码就全部与地址无关了,再由 u-boot 这些 固件去填好 GOT PLT 跳转表啥的,但是转念一想,QEMU-VIRT 里面可没有用 U-BOOT 来加载 Kernel , 肯定这里有什么我不知道的魔法!
- 事实证明,我的想法是对的, RISC-V Linux Setup 确实是用了一些魔法,在仔细对比了一些代码,以及查阅了一些资料后,我发现 RISC-V 的两个重要的二进制指令, auipc 和 lui ,我们可以使用这两个指令在二进制层面实现一段完全地址无关的代码,即使需要访问全局变量,同时我注意到编译器的一个 CFLAG -mcmodel = medany ,仔细研究后,可以得到如下总结:
- 加上来 -mcmodel=medany FLAG 之后,所有的代码都会类似变成地址无关的代码,但又没办法完全 cover 替代 GOT PLT 跳转表的做法,无论访问全局变量还是字符串,都变成地址无关了,唯一有一种情况可能没办法 COVER , 详情 SAMPLE 代码 https://github.com/Jer6y/riscv_cmodel_sample_code
- 切 MMU 的时候,加载 satp 的一瞬间,视角完全和物理内存的视角不一样来了,咋做到切换的?
- xv6 是一个比较经典的 类unix 操作系统,里面处理这种方式使用的是 trampoline 跳板页,也就是两个视角共享一页代码,这页代码在两个视角的地址都一样,我切换页表的时候处于 trampoline 跳板页,来到新视角后,由于同样映射了同一页在同一个地址,所以不会影响后面的代码,在新的视角里面再跳到正确的入口地址即可
- RISCV Linux 的做法是更巧的,充分利用 trap 机制。切换新视角后,必然会导致在新的 MMU 的视角里面,pc 指向的地址不是我们想要执行的命令,而且精心设计后,直接是空洞。访问空洞必定会导致 trap 异常,来到 stvec 寄存器指向的地址,RISCV LInux 直接将新视角里面需要执行的地址填到 stvec 里面,利用trap 机制直接在切换视角的一瞬间,来到新的预期地址
- 上面说了 RISCV Linux 里面会用到 mcmodel 来实现地址无关的代码,同时切视角后,我们希望延续之前的执行流程,继续执行下去,还需要做一些变动,fix ra 地址, fix sp 地址。ra 会保存函数的返回地址,是以一个64位的地址来保存的,来到新视角后,这个地址任然指向旧视角的对应函数的地址,所以需要 fix 修复,因为是进行的直接偏移量的线性映射,直接加上一个 offset 就好,同理 sp 也是这样的
完成了上面的三个操作,我们才能安全 safe 的抵达新的视角(MMU提供的视角)
同时要注意,不仅仅是寄存器,但凡是所有旧视角里面的曾经填过地址的全局指针变量等这些变量,全部都要进行 fix 才能使用。