从零开始的ARMv8操作系统内核实践 2 hello the wold (其二)
接上一章节,我们来看看这个项目的代码,就从启动时的执行顺序开始吧
首先是entry.S 然后是main.c
entry.S
我们之前提到了,某些版本的qemu在启动时CPU的异常等级与真实的树莓派不一致,所以这里有一个判断的过程.如果你对这个感兴趣,可以跟着执行过程看,我写的注释也还算详细,或者,只看_entry与jump_tomain就好,这两个是这段代码最重要的地方
.section ".text._entry"
.global _entry
_entry:
// 读取当前CPUID,如果id不为0,使其跳至halt休眠
// mrs -- Move the contents of a special register to a general-purpose register.
// mpidr_el1 用来读取核心ID用
mrs x1, mpidr_el1
and x1, x1, #3 // 只取[2:0]即可拿到ID
cbnz x1, halt // Compare and Branch on Non-Zero 若不为0,跳转至指定位置
mrs x2, CurrentEL // 读取当前异常级别 在此寄存器中 我们需要的是[3:2]
and x2, x2, #0b1100
cmp x2, #0b1000
beq switch_to_el1_from_el2 // branch on equal
bgt switch_to_el2_from_el3
b halt // 这种是未定义情况 让cpu halt住方便调试
halt:
wfe // wait for event 休眠CPU
b halt
switch_to_el2_from_el3:
// 向scr_el3写入0101_1011_0001
// [0]:1 NS none secure EL0与EL1处于Non-secure状态
// [1]:0 IRQ 路由 当设为0时:(在EL3级别以下产生IRQ时,不会带入EL3;当在EL3时,不会产生IRQ) 当设为1时:无论哪一EL,产生IQR时都会带入到EL3
// [2]:0 FIQ 路由 此位控制IRQ 与上面的IRQ路由同理
// [3]:0 EA 路由 控制Abort与SErro路由 与上面IRQ同理
//
// [5:4]:1 RES1 保留位 置1 注意Arm手册中,有两种保留位,RES1需要置1;RES0需要置0
// [6]:0 RES0
// [7]:1 SMD Secure Monitor Call 在EL1级别及以上禁用SMC指令(Secure Monitor Call)
// [8]:1 HCE Hypervisor Call instruction enable. 使能hvc指令
// [9]:0 SIF Secure instruction fetch 允许在stage1的地址翻译时从标记为非安全状态的内存中获取安全状态的指令 置1为不允许,咱们用不上Arm的secure,那么置0就行
// [10]:1 若此位为0 则EL3级别以下只可使用aarch32 置1后可以使用aarch64
// 再高位的东西咱们也用不上,就不继续讲解了,有兴趣的可以参考aarch64手册
// https://developer.arm.com/documentation/ddi0595/2021-06/AArch64-Registers/SCR-EL3--Secure-Configuration-Register
mov x1, #0b10110110001
msr scr_el3, x1
// 向spsr_el3写入0b0011_1100_1100
// [3:0]:1100 在el3状态时,使用进入el3前的栈指针,这意味着使用el2时的栈指针
// [4]:0 此位为0,表示进入el3之前工作在aarch64为上,这样在el3通过eret命令跳转至el2状态时
// [5]:0 RES0
// [6]:1 FIQ interrupt mask 当跳转至EL2时,此位复制到EL2的PSTATE,也就关闭了FIQ中断
// [7]:1 IRQ mask
// [8]:1 SError mask
// [9]:1 Debug mask
// 剩下的用不上 有兴趣参考手册
// https://developer.arm.com/documentation/ddi0595/2021-06/AArch64-Registers/SPSR-EL3--Saved-Program-Status-Register--EL3-
mov x2, #0b01111001100
msr spsr_el3, x2
// 加载函数switch_to_el1_from_el2的地址,设置从EL3返回时的地址,这样调用eret即可跳转至此函数继续执行
adr x3, switch_to_el1_from_el2
msr elr_el3, x3
eret
switch_to_el1_from_el2:
/* 使能el1与el0对el物理计数器reg,定时器reg的访问 */
mrs x0, cnthctl_el2
orr x0, x0, #3
msr cnthctl_el2, x0
msr cntvoff_el2, xzr
/* Enable AArch64 in EL1. */
// hcr_el2.rw = 1 设置在el1时使用aarch64 (否则为aarch32)
mov x1, #(1 << 31)
// hcr_el2.swio = 1 设置在el1时 Set/Way指令有效
orr x1, x1, #(1 << 1) /* SWIO hardwired on Pi3 */
msr hcr_el2, x1
mrs x1, hcr_el2
/* Setup SCTLR access. */
mov x3, #0x0800
movk x3, #0x30d0, lsl #16
// 此时 x2为'0b110000110100000000100000000000'
// sctlr_el1.EOS = 1 设置在el1返回的异常为同步异常
// sctlr_el1.TSCXT = 1 禁止el0访问SCXTNUM_EL0(这个寄存器可以用来防止利用分支预测进行侧信道攻击,没什么用,关掉了之)
// sctlr_el1.EIS = 1 设置向进入el1的异常为同步异常
// sctlr_el2.SPAN = 1 发生异常到EL1时,PSTATE.PAN 不变(PAN Privileged Access Never 阻止内核访问用户内存,这个保持不变就好,所以置1)
// sctlr_el1.nTLSMD = 1 在通过A32或T32指令集(这两个都是aarch32)通过multi load/store访问Device Memory时,不要产生trap 我们将会用aarch64,将此位置1更多是为了兼容性
// sctlr_el1.LSMAOE = 1 A32和T32在EL使用multi load/store时,顺序和中断行为与Armv8.0的定义相同。
msr sctlr_el1, x3
/* Change execution level to EL1. */
// spsr_el2 Saved Program Status Register of EL2 这个寄存器保存着产生异常并进入EL2的状态
// 0x3c4 == '0b1111000100'
// spsr_el2.M = 0b0_0100 M[4]=0代表进入EL2前CPU工作在aarch64,若置位为1说明工作在aarch32
// M[3:0] = 0b0100 代表使用EL1t栈指针(即el0.sp)
// spsr_el2.F = 1 当因为异常进入EL2时,PSTATE.F会复制到此位,当从中断返回时,此位会复制回PSTATE.F
// 这样,即可在跳转到EL1时禁用FIQ中断 spsr_el2.I,A,D位置1同理,分别禁用IRQ,SError,Debug中断
mov x4, #0x3c4
msr spsr_el2, x4
// 读取符号"el1"的位置
adr x4, jump_to_main
// 设定从"el1"处进入当前的EL2状态,这样在使用eret指令后即可以EL1的状态跳转至"el1"
msr elr_el2, x4
eret
jump_to_main:
// 此时CPU已经工作在EL1状态
ldr x0, =_entry
mov sp, x0 // 先暂时将_entry作为内核栈的顶部
ldr x1, =main
br x1
首先是_entry这里是整个程序的入口.它的作用是判断当前的异常等级,然后跳至合适的异常等级.另外的两个调整异常级别的函数还会顺带关闭中断,毕竟我们这个时候还没办法处理.先关掉了之.
当最终来到jump_to_main后,此时CPU处于EL1级别. 都说汇编与C大概是等价的,但是还是有一点区别,那就是C语言程序一定会涉及到函数调用,就必然使用栈.
所以,jump_to_main的工作就是 1. 给接下来的C语言程序找一块地方做栈区 2. 跳转至C语言部分的函数.
main.c
main函数的工作就很简单了
- 初始化终端
- 向终端写入"hello the world"字样
这个就没什么好解释的了. 感兴趣可以看代码. 相信我 我注释写的超详细
linker.ld 链接器脚本
链接器脚本本身很简单 从内存低地址区域开始,用"."表示当前位置 逐一将多个obj文件中的对应段放置到链接后文件的对应地方
我们摘一小段讲
OUTPUT_ARCH("aarch64")
ENTRY( _entry )
SECTIONS{
. = 0x80000;
.text : {
*(.text._entry)
*(.text .text.*)
}
....
}
首先 设置 . = 0x80000 示意从这里开始.
然后 放置.text段的内容
这个 "(.text._entry)"中,前面的""意味着匹配任意obj文件.
就是说,我希望 链接后的文件 从0x80000开始,先放置.text段; 并且,在.text段中,要将obj文件中的".text._entry"段放在最前面,然后再放置其他的 .text段内容和 名字匹配".text.*"段的内容
上面的ENTRY命令告诉链接器,生成的elf文件执行入口地址是一个名字叫"_entry"的符号.而不是一般用的main.记得在链接时不要把它优化掉.
你有可能会奇怪,我们已经使用了ENTRY命令,为什么还要再来一个.text.entry段呢?
这个就是一个比较有意思的地方了. 我们知道,elf文件会说明自己的入口地址.
但这只是在有linux系统下,执行elf文件才能生效的. linux的loader根据elf记录的入口地址执行程序.
而在裸机情况下,我前面讲到了,CPU从0x8_0000开始执行,而至于那个地方放的是什么东西,CPU才不管.那我们就要想办法把_entry放到那个地方. 这有两种方法
-
在链接时,将entry.S.o作为第一个输入文件.
我们知道链接时,生成文件内部各obj的内容顺序是按照输入参数的顺序来的. 但是,这有一个问题,我们必须手动控制链接时的输入顺序,这好吗?这不好,不够优雅. -
将_entry放在另一个名字的段中,然后在链接脚本中要求将这个段放在.text段的最前面
我们使用的,就是这种方法. 在entry.S中,你可以看到 段名是".text.boot",而不是".text",就是为了实现这一目的.如果你想将某一C函数放在某一特定位置,可以在gcc编译时使用-ffunction-sections参数,这样,它会将每个函数都放在单独的段中,比如一个函数名为foo,那么它就会被放置在.text.foo段
注意,不要想当然哦,段名是没有嵌套结构的,不要以为.text.foo就是.text段中的一个名为.foo的段,对于链接器来说,这是完全不同的两个段,只是我们在链接器脚本中要求链接器把.text.foo段的内容放在了.text段中
那么,另一个问题就来了,既然已经我们通过段名将_entry放在0x8_0000了,为什么还有写一句ENTRY指令呢.这个其实不是给CPU看的,而是给链接器看的.
如果你不写的话,链接器会认为入口函数还是main,接着会认为,整个程序运行,都没碰到过访问.text._entry段,反正没人用,要不我给它删了省点地方?
所以这个ENTRY指令的作用就是这个,告诉链接器,不要删了_entry !!!
同样,如果有一些其他段你希望链接器也不要优化掉的话,可以使用KEEP命令,比如.text.foo段吧,可以这么写
KEEP(*(.text.foo))
这就告诉链接器,这段有用,别删!
另外啊,我实测,其实你不写ENTRY命令,也没事,但是链接器优化行为我可拿不准,指不定什么时候,哪个版本它又手痒,要来点激进的链接时优化(LTO)非要删了它.所以保险期间,KEEP,ENTRY这些命令该用就用,别小气
在链接脚本里面,你应该还看到另外一个命令 PROVIDE
PROVIDE(text_end = .);
使用这个命令后,如果链接器发现给定的obj文件中,没有text_end这个符号,就会向其中加入一个符号,取值依据表达式决定.
我们这么做,是希望标记一下内核所占内存.这样_entry到text_end就是内核所占的地址范围啦.我们之后会用到
最后,提醒一下,这个程序全程,我们都没有使用MMU,没有地址翻译,没有虚地址,我们做的,全都是在物理地址上完成的. 不要忘了哦.
另外,对于这篇文章,这个系列还有什么疑问的话,欢迎留言讨论. 虽然我说也还在摸索阶段 😃
下一篇可能会久一点才出,因为我没想好用伙伴系统还是Free List做内核内存分配,而且Arm的内存翻译也比较有趣,想完全说透还是比较复杂的.

浙公网安备 33010602011771号