内核启动

目录

info

QEMU模拟器中,当kernel映像文件被bootloader加载到内存中后,内核会被直接带到预先设置好的地址,即 _start 函数(0x80000),我们将从这里逐步启动CPU的核心,并做一些必要的设置

warning

这部分内容是源码解析的一部分,在完成Lab1后,再阅读这部分内容。

让我们把目光放到 start.S 文件上,这里是内核启动的开始:

多内核启动及设置

总览

对于多内核的chcore系统,我们在启动内核的时候通常会让一个内核进入启动流程,让其他内核先进行等待,待该内核完成基本的初始化之后,再让其他核心进行这些流程

note

通俗理解,就是“排好队,一个一个来”

启动 CPU 0 号核

既然是排队,那么总要有一个先后顺序,我们在chcore中的策略是让0号核心先启动,看代码如下:

BEGIN_FUNC(_start)
 mrs x8, mpidr_el1
 and x8, x8, #0xFF
 cbz x8, primary

关于 mpidr_el1 这样的系统寄存器,可以在lab文档里给到的manual里查到相关信息(备注:更方便的手段是先询问llm,然后再在manual里面求证即可):

由此我们得知,mpidr_el1寄存器存储的是CPU核心的唯一标识符,这里我们使用它来区分不同的核心,逻辑如下:

  • 读取系统寄存器的值到 x8
  • 0xFF 进行与操作,即保留低8位,是一个mask操作,这样可以去除掉高位的不必要的信息
  • 将得到的值与0比较,若相等,则跳转到 primary 标签,进行后续操作

如何让内核依次启动?

继续浏览start.S,根据上文的逻辑,在判断出当前CPU是否为0号核心之后,0号核心与非0号核心需要执行的操作是不同的

但是如何让0号核和其他核区别开来,做好自己的启动工作呢?这里给出一个大概的逻辑

0号核

注意到此时代码跳转到了primary标签

primary:

 /* Turn to el1 from other exception levels. */
 bl  arm64_elX_to_el1

 /* Prepare stack pointer and jump to C. */
 adr  x0, boot_cpu_stack
 add  x0, x0, #INIT_STACK_SIZE
 mov  sp, x0

 b  init_c

 /* Should never be here */
 b .

关于降低异常级别的部分会在下面提到,我们现在只需要站在宏观的视角理解0号核干了什么:

  • 从其他的异常级别降低到1
  • 为跳转到C语言部分代码做设置栈的准备
  • 跳转到init_c
  • 代码的最后是一个死循环,如果前面发生了故障可以将内核卡死在这里,注意到注释也提到了“Should never be here”

非0号核

非0号核在cbz指令判断失败后,会按照顺序继续执行下面的代码,如下所示:

 /* Wait for bss clear */
wait_for_bss_clear:
 adr x0, clear_bss_flag
 ldr x1, [x0]
 cmp     x1, #0
 bne wait_for_bss_clear

...

 /* Turn to el1 from other exception levels. */
 bl  arm64_elX_to_el1

 /* Prepare stack pointer and jump to C. */
 mov x1, #INIT_STACK_SIZE
 mul x1, x8, x1
 adr  x0, boot_cpu_stack
 add x0, x0, x1
 add x0, x0, #INIT_STACK_SIZE
 mov sp, x0

wait_until_smp_enabled:
 /* CPU ID should be stored in x8 from the first line */
 mov x1, #8
 mul x2, x8, x1
 ldr x1, =secondary_boot_flag
 add x1, x1, x2
 ldr x3, [x1]
 cbz x3, wait_until_smp_enabled

 /* Set CPU id */
 mov x0, x8
 b  secondary_init_c

 /* Should never be here */
 b .

这里的代码采用了轮询的手段,通俗的讲,就是反复检查相关条件是否满足。CPU不断检查 clear_bss_flagsecondary_boot_flag 数组里的内容,若收到信号,则执行对应操作。

tip

更多信息可以参考轮询的维基百科

二者具体的操作逻辑不细讲,概括如下:

  • bss段清零后,同样执行降低内存级别的操作,随后设置栈
  • 这一段完成后继续等待信号,收到通知后即设置CPU id并跳转到这部分内核对应的c代码

后续操作

内核进行完毕初始设置后,即进入 init_c.c 部分的代码,在c代码的程序中继续完成相关设置:

  • 叫醒其他核
  • 清理bss段数据
  • 初始化串口
  • 设置mmu
  • 注意这里不同内核执行的函数不一样,有高低贵贱之分
void init_c(void)
{
 /* Clear the bss area for the kernel image */
 clear_bss();

 /* Initialize UART before enabling MMU. */
 early_uart_init();
 uart_send_string("boot: init_c\r\n");

 wakeup_other_cores();

 /* Initialize Kernell Page Table. */
 uart_send_string("[BOOT] Install kernel page table\r\n");
 init_kernel_pt();

 /* Enable MMU. */
 el1_mmu_activate();
 uart_send_string("[BOOT] Enable el1 MMU\r\n");

 /* Call Kernel Main. */
 uart_send_string("[BOOT] Jump to kernel main\r\n");
 start_kernel(secondary_boot_flag);

 /* Never reach here */
}

void secondary_init_c(int cpuid)
{
 el1_mmu_activate();
 secondary_cpu_boot(cpuid);
}

内核启动时的设置

上一部分我们对多内核启动的全部过程有了一个大概的了解,而这一部分则主要讲解内核在启动过程中的具体设置,包括汇编与C代码中的重要函数

事实上,它们是相互交错运行的,共同为新生伊始的CPU内核配置好相关设置

关于栈的设置

/* Prepare stack pointer and jump to C. */
 adr  x0, boot_cpu_stack
 add  x0, x0, #INIT_STACK_SIZE
 mov  sp, x0

代码中的设置部分是将栈指针的内容准备(获取栈的基地址,计算栈顶地址)好后,直接移动到sp寄存器中,即完成了栈的设置

栈是系统用来存储局部变量、函数参数、返回地址、寄存器值的重要部分,若不设置这一部分,sp寄存器会指向随机地址,对系统的后续行为是毁灭性的打击

bss段清零

.bss段用于存储未初始化的全局变量和静态变量,将这部分值统一设置为0

若没有这一部分操作,则会让全局变量和静态变量的0初始值受到破坏

假如遇到程序或内核操作需要用到默认为0的全局变量,未初始化bss段数据的行为将会导致相应的操作出现bug

这一部分的代码在 init_c.c 中,可自行阅读

切换内核异常级别

上面分析 start.S 代码时,我们遇到了 arm_elX_to_el1 函数,其作用是将内核的异常级别从el3降低到el1。相关代码在同目录 tool.S 文件中,我们现在对其进行考察与分析:

BEGIN_FUNC(arm64_elX_to_el1)
 mrs x9, CurrentEL

 // Check the current exception level.
 cmp x9, CURRENTEL_EL1
 beq .Ltarget
 cmp x9, CURRENTEL_EL2
 beq .Lin_el2
 // Otherwise, we are in EL3.

 // Set EL2 to 64bit and enable the HVC instruction.

  ...

 // Set the return address and exception level.
 adr x9, .Ltarget
 msr elr_el3, x9
 mov x9, SPSR_ELX_DAIF | SPSR_ELX_EL1H
 msr spsr_el3, x9

.Lin_el2:
 // Disable EL1 timer traps and the timer offset.
 // Disable stage 2 translations.
 // Disable EL2 coprocessor traps.
 // Disable EL1 FPU traps.

  ...

 // Check whether the GIC system registers are supported.
 mrs x9, id_aa64pfr0_el1
 and x9, x9, ID_AA64PFR0_EL1_GIC
 cbz x9, .Lno_gic_sr

 // Enable the GIC system registers in EL2, and allow their use in EL1.
 // Disable the GIC virtual CPU interface.
 
  ...
  
.Lno_gic_sr: // No GIC System Registers

 // Set EL1 to 64bit.

  ...

 // Set the return address and exception level.
 adr x9, .Ltarget
 msr elr_el2, x9
 mov x9, SPSR_ELX_DAIF | SPSR_ELX_EL1H
 msr spsr_el2, x9

 isb
 eret

.Ltarget:
 ret
END_FUNC(arm64_elX_to_el1)

(部分细节处的琐碎设置代码已略去,看注释即可)

纵观全局,我们的源码符合lab文档里“没有直接写死从el3到el1”的逻辑,而是降低异常级别的行动分成了数个步骤来执行:

  • 先获取当前异常级别
  • 若级别是el3,则直接往下执行
  • 若级别是el2/el1,则跳转到相应的部分,总体上是3→2→1的逻辑
  • 在最后调用 eret 指令,正式调整内核级别
graph TD;
  判断当前级别-->el3
  判断当前级别-->el2
  判断当前级别-->el1
    el3-->el2;
    el2-->el1;
    el1-->return;

对于 eret 指令,这是一个用来从高级别跳转到低级别的指令,执行它需要我们设置两个寄存器:

  • elr_elx:异常链接寄存器,保存跳转级别后执行的指令地址

hint

在这里即为 .target 标签

  • spsr_elx:保存的程序状态寄存器,包含异常返回后的异常级别

hint

在这里即为 SPSR_ELX_DAIF | SPSR_ELX_EL1H。 由于我们需要将异常级别控制在el1,这里我们的设置是直接将相关宏做或操作后赋值

关于代码的其他部分,可以阅读注释作初步了解,深入学习可以结合llm与教材

启用MMU

如果你看过 init_c.c 文件,会发现我们在内核启动是还需要进行启动页表的相关配置。关于页表的具体配置较为复杂,会在另一篇解析单独讲解,这里主要讲解启用MMU的部分

启用MMU部分的代码同样在 tool.S 文件中,相关代码如下:

BEGIN_FUNC(el1_mmu_activate)
 stp     x29, x30, [sp, #-16]!
 mov     x29, sp

 bl invalidate_cache_all

 /* Invalidate TLB */
 /* Initialize Memory Attribute Indirection Register */
 /* Initialize TCR_EL1 */
 /* set cacheable attributes on translation walk */
 /* (SMP extensions) non-shareable, inner write-back write-allocate */
 /* Write ttbr with phys addr of the translation table */

  ...
 
 mrs     x8, sctlr_el1
 /* Enable MMU */
 orr     x8, x8, #SCTLR_EL1_M
 /* Disable alignment checking */
 bic     x8, x8, #SCTLR_EL1_A
 bic     x8, x8, #SCTLR_EL1_SA0
 bic     x8, x8, #SCTLR_EL1_SA
 orr     x8, x8, #SCTLR_EL1_nAA
 /* Data accesses Cacheable */
 orr     x8, x8, #SCTLR_EL1_C
 /* Instruction access Cacheable */
 orr     x8, x8, #SCTLR_EL1_I
 /* Writable eXecute Never */
 orr     x8, x8, #SCTLR_EL1_WXN
 msr     sctlr_el1, x8

 ldp     x29, x30, [sp], #16
 ret
END_FUNC(el1_mmu_activate)

这时候我们的内核异常级别已经降低到el1,而启用MMU的操作同样是通过为系统寄存器进行相应的赋值(即硬件与软件的相互配合),代码中则是通过不断配置相关的字段来实现的,对于这里的源码,我们执行的操作如下:

  • 启用MMU,即M字段,这个是必须的
  • 禁用内存对齐检查,即A,SA0,SA,nAA字段
  • 启用指令与数据缓存,即C,I字段
  • 启用写保护,即WXN字段,可写页但不可执行

初始化串口输出

同样是 init_c.c 中的操作,我们需要对树莓派的UART串口进行初始化启用,从而使kernel能输出字符

具体的实现在 uart.c 文件中,代码结构如下所示:

   #if USE_mini_uart == 1
   
   // Mini UART代码
   void early_uart_init(void) { ... }
   static unsigned int early_uart_lsr(void) { ... }
   static void early_uart_send(unsigned int c) { ... }
   
   #else
   
   // PL011代码
   void early_uart_init(void) { ... }
   static unsigned int early_uart_fr(void) { ... }
   static void early_uart_send(unsigned int c) { ... }
   
   #endif
   
   void uart_send_string(char *str) {
  int i;
  for (i = 0; str[i] != '\0'; i++) {
   if (str[i] == '\n')
   early_uart_send('\r');
  early_uart_send(str[i]);
  }
 }

其中上半部分的代码内容涉及到硬件的操作,如设置引脚、波特率等,我们无需了解。而这里的条件编译结构则为我们提供了两种uart——mini uart主uart,同时二者对外的字符串发送接口是一样的,对外部保持了统一与抽象屏障

下半部分则是对字符串的具体发送工作,逻辑很简单——使用一个循环溜过去即可,遇到字符串结束符 \0 即停止

代码中在 \n 前方添加 \r 是为了兼容不同终端的换行处理。例如,在早期的Mac OS中,使用的是Carriage Return(CR),即 \r 作为换行符

success

至此,内核启动部分的源码解析全部结束,页表映射的部分将在接下来的文章中讲述,希望对你的学习进步有所裨益!

Last change: 2025-02-14, commit: 9fa9752