在熟悉 RT-Smart 架构的过程中,研究其启动过程的是必不可少的,那么在系统正常运行之前,需要做哪些准备工作呢。本文将以 32 位 RT-Smart 的源代码为基础,讲解 RT-Smart 的启动过程。
RT-Smart 与 RT-Thread 的一大区别是用户态和内核态的地址空间被隔离开来。内核运行在内核地址空间,用户进程运行在用户地址空间。由下图可知,RT-Smart 32 位内核运行在地址空间的高地址,而用户程序代码运行在低地址。
上面说到 RT-Smart 将内核搬运到高地址空间运行,为了能让内核正常运行在内核地址空间,需要一些初始化对 MMU
进行逐步配置,初始化步骤如下:
- 使用实际物理地址设置栈,为调用 C 语言函数对
MMU
页表进行初始化作准备 - 建立从
0x60010000
到0x60010000
的原地址映射 - 建立从
0x60010000
到0xc0010000
的物理地址到内核地址空间的映射 - 使能
MMU
,使地址映射生效,需要建立双重映射的原因是,如果只建立第三步的映射,此时程序还运行在0x60010000
的物理空间上,此时开启MMU
,当前程序正在运行的地址空间变得无法访问,导致程序 fault 无法继续运行 - 切换到内核地址空间,在内核地址空间重新设置栈
- 解除
0x60010000
到0x60010000
的原地址映射关系
系统启动前,内核程序被加载到 0x60010000
,但内核在编译时被链接到 0xc0010000
的位置,此时 MMU
还没有开启。如果此时想要使用全局变量的,就需要将全局变量的地址加上 PV_OFFSET
(物理地址减去虚拟地址的偏移量)获取实际的物理地址,才能正常访问该全局变量。
.equ UND_Stack_Size, 0x00000400
.equ SVC_Stack_Size, 0x00000400
.equ ABT_Stack_Size, 0x00000400
.equ RT_FIQ_STACK_PGSZ, 0x00000000
.equ RT_IRQ_STACK_PGSZ, 0x00000800
.equ USR_Stack_Size, 0x00000400
#define ISR_Stack_Size (UND_Stack_Size + SVC_Stack_Size + ABT_Stack_Size + \
RT_FIQ_STACK_PGSZ + RT_IRQ_STACK_PGSZ)
.section .data.share.isr
/* stack */
.globl stack_start
.globl stack_top
stack_start:
.rept ISR_Stack_Size
.byte 0
.endr
stack_top:
/*
使用 1M 大小的 section 映射,描述符的类型为 unsigned int,占用 4 个字节内存,整个系统地址空间为 4GB,因此需要 4096 个描述符,总共占用内存 16kb。
*/
#ifdef RT_USING_USERSPACE
.data
.align 14
init_mtbl:
.space 16*1024
#endif
.text
/* reset entry */
.globl _reset
_reset:
#ifdef RT_USING_USERSPACE
ldr r5, =PV_OFFSET
mov r7, #0x100000
sub r7, #1
mvn r8, r7 /* r8: 0xfff0_0000 */
ldr r9, =KERNEL_VADDR_START
ldr r6, =__bss_end
add r6, r7
and r6, r8 /* r6 end vaddr align up to 1M */
sub r6, r9 /* r6 is size */
ldr sp, =stack_top
add sp, r5 /* 使用栈的物理地址初始化栈 */
ldr r0, =init_mtbl
add r0, r5
mov r1, r6
mov r2, r5
bl init_mm_setup /* 初始化内存映射表,建立双重映射,即程序加载原地址映射与原地址到内核地址空间映射 */
ldr lr, =after_enable_mmu
ldr r0, =init_mtbl
add r0, r5
b enable_mmu /* 使用初始化后的映射表使能 MMU */
after_enable_mmu:
#endif
/* set the cpu to SVC32 mode and disable interrupt */
cps #Mode_SVC
/* disable the data alignment check */
mrc p15, 0, r1, c1, c0, 0
bic r1, #(1<<1)
mcr p15, 0, r1, c1, c0, 0
/* setup stack */
bl stack_setup /* 使用内核空间栈的虚拟地址初始化栈 */
/* clear .bss */
mov r0,#0 /* get a zero */
ldr r1,=__bss_start /* bss start */
ldr r2,=__bss_end /* bss end */
bss_loop:
cmp r1,r2 /* check if data to clear */
strlo r0,[r1],#4 /* clear 4 bytes */
blo bss_loop /* loop until done */
/* initialize the mmu table and enable mmu */
ldr r0, =platform_mem_desc
ldr r1, =platform_mem_desc_size
ldr r1, [r1]
bl rt_hw_init_mmu_table
#ifdef RT_USING_USERSPACE
ldr r0, =MMUTable /* vaddr */
add r0, r5 /* to paddr */
bl switch_mmu
#endif
/* call C++ constructors of global objects */
ldr r0, =__ctors_start__
ldr r1, =__ctors_end__
ctor_loop:
cmp r0, r1
beq ctor_end
ldr r2, [r0], #4
stmfd sp!, {r0-r1}
mov lr, pc
bx r2
ldmfd sp!, {r0-r1}
b ctor_loop
ctor_end:
/* start RT-Thread Kernel */
ldr pc, _rtthread_startup
_rtthread_startup:
.word rtthread_startup
void init_mm_setup(unsigned int *mtbl, unsigned int size, unsigned int pv_off) {
unsigned int va;
for (va = 0; va < 0x1000; va++) {
unsigned int vaddr = (va << 20);
if (vaddr >= KERNEL_VADDR_START && vaddr - KERNEL_VADDR_START < size) {
mtbl[va] = ((va << 20) + pv_off) | NORMAL_MEM;
} else if (vaddr >= (KERNEL_VADDR_START + pv_off) && vaddr - (KERNEL_VADDR_START + pv_off) < size) {
mtbl[va] = (va << 20) | NORMAL_MEM;
} else {
mtbl[va] = 0;
}
}
}
该函数初始化了内存映射表,从 0 地址开始,以 1M
的粒度扫描整个 4G
地址空间,建立两段映射关系:
- 如果发现虚拟地址在内核地址空间上,则建立从内核地址空间到内核程序加载地址的映射
- 如果发现虚拟地址在处于内核程序的加载地址,则建立相对应的原地址映射
- 其他地址配置成无效,如下图中的空白部分
配置的映射关系如下图所示:
.align 2
.global enable_mmu
enable_mmu:
orr r0, #0x18
mcr p15, 0, r0, c2, c0, 0 // ttbr0
mov r0, #(1 << 5) // PD1=1
mcr p15, 0, r0, c2, c0, 2 // ttbcr
mov r0, #1
mcr p15, 0, r0, c3, c0, 0 // dacr
// invalid tlb before enable mmu
mov r0, #0
mcr p15, 0, r0, c8, c7, 0
mcr p15, 0, r0, c7, c5, 0 ; // iciallu
mcr p15, 0, r0, c7, c5, 6 ; // bpiall
mrc p15, 0, r0, c1, c0, 0
orr r0, #(1 | 4)
orr r0, #(1 << 12)
mcr p15, 0, r0, c1, c0, 0
dsb
isb
mov pc, lr
.global switch_mmu
switch_mmu:
orr r0, #0x18
mcr p15, 0, r0, c2, c0, 0 //ttbr0
// invalid tlb
mov r0, #0
mcr p15, 0, r0, c8, c7, 0
mcr p15, 0, r0, c7, c5, 0 ;//iciallu
mcr p15, 0, r0, c7, c5, 6 ;//bpiall
dsb
isb
mov pc, lr
stack_setup:
ldr r0, =stack_top /* 获取内核地址空间下的栈地址,然后设置各模式下的栈 */
@ Set the startup stack for svc
mov sp, r0
@ Enter Undefined Instruction Mode and set its Stack Pointer
msr cpsr_c, #Mode_UND|I_Bit|F_Bit
mov sp, r0
sub r0, r0, #UND_Stack_Size
@ Enter Abort Mode and set its Stack Pointer
msr cpsr_c, #Mode_ABT|I_Bit|F_Bit
mov sp, r0
sub r0, r0, #ABT_Stack_Size
@ Enter FIQ Mode and set its Stack Pointer
msr cpsr_c, #Mode_FIQ|I_Bit|F_Bit
mov sp, r0
sub r0, r0, #RT_FIQ_STACK_PGSZ
@ Enter IRQ Mode and set its Stack Pointer
msr cpsr_c, #Mode_IRQ|I_Bit|F_Bit
mov sp, r0
sub r0, r0, #RT_IRQ_STACK_PGSZ
/* come back to SVC mode */
msr cpsr_c, #Mode_SVC|I_Bit|F_Bit
bx lr
与先前的 rt-thread 宏内核相比,整个 SMART 的启动过程主要多了对 MMU 的配置,这是因为 SMART 是一个区分用户态和内核态的操作系统。用户态进程与操作系统内核运行在各自私有的地址空间,为了实现这样的功能,要利用 MMU 提供的虚拟内存机制做更进一步的虚拟地址空间管理。
在 SMART 操作系统中,内存管理部分是比较复杂的,后续针对这一块还需要多多研究。
.L__in_el1: /* 清空早期使用的两个页表 */
adr x1, __start /* 将代码段的地址写入 x1,在 x1 中暂存该地址,该地址也是栈的顶端 */
ldr x0, =~0x1fffff /* 向 x0 存入 2M 地址对齐掩码 */
and x0, x1, x0 /* 将 x1 中存放的地址向下 2M 对齐,存入 x0 */
add x2, x0, #0x2000 /* 将 x0 增加 8k并存入 x2,注意:早期版本这里使用了 x1 存放计算后的值,会导致在本段代码最后一行读取 x1 的值作为栈时,导致设置了错误的栈,进而导致后续调用 c 函数的过程中程序运行错误 */
.L__clean_pd_loop:
str xzr, [x0], #8 /* 清空从 x0 - x2 之间 8k 的地址空间,用作页表*/
cmp x0, x2
bne .L__clean_pd_loop
adr x19, .L__in_el1
ldr x8, =.L__in_el1
sub x19, x19, x8 /* get PV_OFFSET */
mov sp, x1 /* in EL1. Set sp to _start */
ARMv8 架构的 MMU 初始化过程与上述内容稍有不同,原因是在 ARMv8 中可以利用 TTBR0_EL1 和 TTBR1_EL1 寄存器来区分对高位地址和低位地址的访问。在操作系统启动初期。
对 MMU 进行初始化,同 ARMv7 启动一样,也要进行两次映射,这样才能保证在开启 MMU 之后,后续指令可以正常执行。
/**
* This function creates two early page tables, one for the mapping of high virtual addresses
* to physical addresses and one for the one-to-one mapping of virtual addresses to physical addresses.
*
* @param tbl0 user space address page table
* @param tbl1 kernel space address page table
* @param size mapping size
* @param pv_off Offset from a virtual address to a physical address
*/
void rt_hw_mmu_setup_early(unsigned long *tbl0, unsigned long *tbl1, unsigned long size, unsigned long pv_off)
{
int ret;
unsigned long va = KERNEL_VADDR_START;
unsigned long count = (size + ARCH_SECTION_MASK) >> ARCH_SECTION_SHIFT;
unsigned long normal_attr = MMU_MAP_CUSTOM(MMU_AP_KAUN, NORMAL_MEM);
/* 创建从高位虚拟地址(以 0xffff 起始的地址)到物理地址的映射 */
ret = armv8_init_map_2M(tbl1 , va, va + pv_off, count, normal_attr);
if (ret != 0)
{
while (1);
}
/* 创建物理地址到物理地址的一一对应映射,保证在跳转到链接高位的函数前,指令仍然可以继续执行 */
ret = armv8_init_map_2M(tbl0, va + pv_off, va + pv_off, count, normal_attr);
if (ret != 0)
{
while (1);
}
}
很明显在 ARMv8 中建立双重映射的方式与 ARMv7 中不同,在 ARMv8 中使用了两张页表,而 v7 中只用了一张页表,这是架构差异导致的。在 ARMv8 中访问低位地址默认会使用 TTBR0_EL1 指向的页表,而访问高位地址默认会使用 TTBR1_EL1 指向的页表。
同时可以参考该函数被调用时的代码,可以知道在早期建立的虚拟地址映射的大小为 1G。
ldr x2, =0x40000000 /* map 1G memory for kernel space */
mov x3, x19 /* set PV_OFFSET to x3 reg */
bl rt_hw_mmu_setup_early
早期页表的映射是比较粗略的,在系统启动后内核还会创建更精细的页表,不过早期粗略的页表也不是完全没用了,后续该 CPU 的第二个核心启动时,就可以复用该页表而不需要再次初始化了。等到所有的核心都启动完毕后,早期的粗略页表就完全不会再次被使用了。
初始化的目的是,使得系统可以从物理地址运行逐步迁移到以 0xFFFF 起始的虚拟地址运行,这需要一个过度的过程,可以观察如下代码了解如何实现从物理地址到虚拟地址的切换。
/* jump to C code, should not return */
.L__jump_to_entry:
bl get_free_page /* 获取空闲页,用于存放页表 */
mov x21, x0
bl get_free_page /* 获取空闲页,用于存放页表 */
mov x20, x0
mov x1, x21
bl mmu_tcr_init /* 配置 MMU 的基础属性,如虚拟地址位数、页大小、页属性等 */
mov x0, x20
mov x1, x21
msr ttbr0_el1, x0 /* 将页表放入 ttbr0_el1 寄存器,配置低位地址映射 */
msr ttbr1_el1, x1 /* 将页表放入 ttbr1_el1 寄存器,配置高位地址映射 */
dsb sy /* 数据同步内存屏障,确保在下一个指令执行前,所有的存储器访问都已经完成 */
ldr x2, =0x40000000 /* 为内核映射 1G 内存空间 */
ldr x3, =0x1000060000000 /* 设置 PV_OFFSET 到 x3 寄存器 */
bl rt_hw_mmu_setup_early /* 调用 MMU 配置函数,初始化页表 */
ldr x30, =after_mmu_enable /* 将 after_mmu_enable 函数的地址存入 LR 寄存器,这是一个高位虚拟地址 */
mrs x1, sctlr_el1
bic x1, x1, #(3 << 3) /* dis SA, SA0 */
bic x1, x1, #(1 << 1) /* dis A */
orr x1, x1, #(1 << 12) /* I */
orr x1, x1, #(1 << 2) /* C */
orr x1, x1, #(1 << 0) /* M */
msr sctlr_el1, x1 /* 使能 MMU,还可以执行下一条指令,因为低位地址进行了一一映射 */
dsb sy /* 使能 MMU 后 */
isb sy /* 指令屏障指令,在执行下一条指令前,所有的指令都已经完成 */
ic ialluis /* 无效所有的指令缓存 */
dsb sy
isb sy
tlbi vmalle1 /* 无效所有 el1 的 tlb 转换表 */
dsb sy
isb sy
ret /* 返回到虚拟地址的 after_mmu_enable 函数,自此操作系统完成到虚拟地址的切换 */
after_mmu_enable:
mrs x0, tcr_el1 /* 关闭 ttbr0 上的映射,操作系统将不再访问低位地址 */
orr x0, x0, #(1 << 7)
msr tcr_el1, x0
msr ttbr0_el1, xzr
dsb sy
mov x0, #1
msr spsel, x0
adr x1, __start
mov sp, x1 /* 设置 sp_el1 为 _start 符号地址 */
b rtthread_startup
rt_page_init
函数会初始化页表管理器,用于管理物理内存,从如下代码可以看出,物理内存的范围为内核地址空间的 16 M 到 128M 的位置,该函数的详细实现原理参考[《RT-Smart 物理页管理详解》](25_RT-Smart 物理页管理详解.md)。
#define HEAP_END ((size_t)KERNEL_VADDR_START + 16 * 1024 * 1024)
#define PAGE_START HEAP_END
#define PAGE_END ((size_t)KERNEL_VADDR_START + 128 * 1024 * 1024)
rt_region_t init_page_region = {
PAGE_START,
PAGE_END,
};
这里使用的是虚拟地址,但实际上与物理地址是一一对应的,根据 PV_OFFSET
就可以很方便地找到对应的物理地址 。这里只是将可以用于被页管理器管理的地址空间加入到页管理器,并不牵扯到内存的映射和解映射,原因很简单,通过 PV_OFFSET
就可以很方便地解决问题。
系统中有很多地方会使用到物理页的申请和释放,这里有 128M 的限制,其实也就意味着不能申请到更多内存了。
在系统切换到虚拟地址运行前,我们已经进行过 MMU 的早期初始化,但那时的映射方式是非常粗略的,后续继续使用该页表是不合适的,因此需要重新初始化内核态地址空间的页表。那用户态的页表怎样处理呢,暂时不用关心,因为每个进程都有独立的页表,后续创建相应的进程后,会给每个进程分配独立的页表。
早期 MMU 初始化帮助系统从物理地址切换到虚拟地址运行, rt_hw_mmu_setup
函数将重新初始化内核的页表,将内核地址空间前 256M 进行映射,作为内核的地址空间。
struct mem_desc platform_mem_desc[] =
{
{
KERNEL_VADDR_START, KERNEL_VADDR_START + 0x0fffffff,
KERNEL_VADDR_START + PV_OFFSET, NORMAL_MEM
}
};
void rt_hw_mmu_setup(struct mem_desc *mdesc, int desc_nr)
{
/* set page table */
for (; desc_nr > 0; desc_nr--)
{
rt_hw_mmu_setmtt(mdesc->vaddr_start, mdesc->vaddr_end,
mdesc->paddr_start, mdesc->attr);
mdesc++;
}
rt_hw_cpu_dcache_ops(RT_HW_CACHE_FLUSH, (void *)MMUTable, sizeof MMUTable);
kernel_mmu_switch((unsigned long)MMUTable);
}
从上面的实现可以看出,内核实际上只映射了 256 M 的地址空间。
内核中指定了部分地址空间用于给内核动态分配,这部分内存由 rt_hw_mmu_map_init
函数来指定,代码如下:
rt_hw_mmu_map_init(&mmu_info, (void *)0xfffffffff0000000, 0x10000000, MMUTable, PV_OFFSET);
从上面的代码可以看出,指定了内核地址空间一个高位地址 0xfffffffff0000000
开始的 256M 空间用作动态申请与释放。这部分地址空间后续可以用于 ioremap 以及 share memory 时使用。
需要申请地址空间时,就使用 rt_hw_mmu_map
和 rt_hw_mmu_unmap
这两个函数来进行地址空间的申请和释放。
在启动代码的汇编部分,前几行代码执行汇编的 ret 函数返回指令,这时候如果代码执行了,就会返回到 uboot,你用这种方法可以知道是否正常执行了固件。
确定固件启动了之后,可以直接写一段汇编代码,向串口的 tx fifo 写字符,因为 uboot 都初始化好了,所以可以直接用改串口来打印。
mmu 开启后,会导致方法二无法使用,这时候需要将串口的地址空间范围加入到 MMU 的映射范围内,这样程序才能继续访问串口地址空间。
这时需要修改系统映射 mmu 映射范围更大,使得你可以在虚拟地址和物理地址上都访问串口寄存器的地址范围,同时将 rt_kprintf 配置成直接调用 puts 输出,这样做可以帮助你调试建立 mmu 粗映射到建立正式的系统映射这期间的代码,示例如下:
- 加载地址 0x81080000
- 内核链接地址 0xffff000000080000
- PV_OFFSET 0x1000081000000
- 串口地址 0x20008000
上述代码片段,扩大了物理地址一一映射的范围,向前偏移了 1.75G,保证了串口的地址空间可以访问,后续就可以正常使用 rt_kprintf 打印调试。