内核启动

内核启动

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

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

多内核启动及设置

总览

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

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

启动 CPU 0 号核

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

1
2
3
4
BEGIN_FUNC(_start)
mrs x8, mpidr_el1
and x8, x8, #0xFF
cbz x8, primary

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

manual

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

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

如何让内核依次启动?

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

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

0 号核

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 指令判断失败后,会按照顺序继续执行下面的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
	/* 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 数组里的内容,若收到信号,则执行对应操作

ref: https://en.wikipedia.org/wiki/Polling_(computer_science)

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

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

后续操作

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

  • 叫醒其他核
  • 清理 bss 段数据
  • 初始化串口
  • 设置 mmu
  • 注意这里不同内核执行的函数不一样,有高低贵贱之分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 内核配置好相关设置

关于栈的设置

1
2
3
4
/* 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 文件中,我们现在对其进行考察与分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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:异常链接寄存器,保存跳转级别后执行的指令地址

在这里即为 .target 标签

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

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

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

启用 MMU

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 文件中,代码结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 #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作为换行符

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


内核启动
http://example.com/2024/12/22/KernelBooting/
作者
思源南路世一劈
发布于
2024年12月22日
许可协议