0%

多核启动基本逻辑分析

基本逻辑

计算机系统有多个核的时候,多个核之间启动的时候要遵守一定的逻辑关系。虽然,多核系统
上每个核都可以独立的运行程序,但是总会有多个核共享的资源,对于这些资源配置和访问
需要串行,比如,固件或者内核的BSS段,再比如固件的重定位,这些都只需要搞一次就好,
一般就用一个核搞定就好,其他核后续可以再此基础上继续做各自核的初始化。

本文分析多核启动中的这种逻辑关系。

硬件逻辑

我们从qemu启动多核看看硬件是怎么看待多核启动的。qemu里每个vcpu用一个线程模拟,
多核的启动流程大概是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
device_set_realized
+-> riscv_cpu_realize
+-> qemu_init_vcpu
[...]
/* 如下是vcpu模拟要执行的线程函数,代码在qemu/accel/tcg/tcg-accel-ops-mttcg.c */
+-> mttcg_cpu_thread_fn
+-> cpu_reset
+-> device_cold_reset
+-> resettable_reset
+-> resettable_assert_reset
+-> resettable_phase_hold
/* CPU复位函数,CPU的初始状态在这里配置 */
+-> riscv_cpu_reset
+-> mcc->parent_realize (cpu_common_realizefn)
+-> cpu_resume

其中我们要搞清楚CPU复位配置初始状态和CPU开始运行是怎么衔接起来的。如上,在qemu_init_vcpu
里会拉起模拟vcpu的线程,该线程的主体逻辑就是在一个大循环里反复做取指令,翻译和执行,
但是,CPU复位是在后面的cpu_reset里面才执行的。vcpu做取指令,翻译和执行之前会先
判断CPU的状态,如果CPU在停止状态,就一直等待,qemu_init_vcpu一进来就会配置CPU在
stopped状态,这样vcpu的模拟线程起来也是在等待的状态。riscv_cpu_realize最后会调用
父类的realize函数,也就是CPUClass的realize函数,这个里面会调用cpu_resume把CPU设置
到可以运行的状态。(to check)

总结下,从qemu的角度看,多核启动就是多个核独立开始执行指令,这个设计其实把多核启动
的控制逻辑给到了随后的固件和内核。

qemu和opensbi的接口比较有意思,我们具体看下这里的实现。qemu在启动BIOS(opensbi)
之前,会运行一小段rom上的指令,这段指令core id配置到a0,dts基地址配置到a1,qemu和
opensbi传递信息的一片内存的地址配置到a2。下面我们逐行来看看这段代码:

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
/* hw/riscv/virt.c, 有"n:"前缀是本文新加的注释 */
riscv_setup_rom_reset_vec
[...]
uint32_t reset_vec[10] = {
/*
* n: 需要注意的是,这里汇编语意和二进制语意是不一样的。从汇编语意上看,这
* 句的意思是,%pcrel_hi(fw_dyn)表示计算fw_dyn这个符号相对于当前PC的偏移
* 的高20bit,而auipc t0, imm表示把imm和当前PC相加,结果保存到t0。所以,
* 这条指令整体上的结果是加载fw_dyn地址的高20bit到t0。
*
* t0这里实际上是当前PC的值。
*
* 从汇编上看这条指令和下面一条指令一起完成计算fw_dyn地址的功能。下面
* 一条指令的意思是,找见位置是label 1b的指令,计算pcrel_hi修饰的
* 符号和auipc这条指令的偏差的低12bit, 这个就是%pcrel_lo(1b)的值。
* 然后"addi a2, t0,imm"把fw_dyn这个符号的低12bit补上,写到a2里,所以
* 这里a2就是fw_dyn的地址。
*/
0x00000297, /* 1: auipc t0, %pcrel_hi(fw_dyn) */
0x02828613, /* addi a2, t0, %pcrel_lo(1b) */
/* n: a0是当前核的id */
0xf1402573, /* csrr a0, mhartid */
0,
0,
/* n: 如下,t0是opensbi的地址,这里就直接跳到opensbi了 */
0x00028067, /* jr t0 */
/* n: 下个bootloader的地址,一般rv上就是opensbi的加载地址 */
start_addr, /* start: .dword */
start_addr_hi32,
/* n: dts的基地址 */
fdt_load_addr, /* fdt_laddr: .dword */
fdt_load_addr_hi32,
/* fw_dyn: */
};
if (riscv_is_32bit(harts)) {
reset_vec[3] = 0x0202a583; /* lw a1, 32(t0) */
reset_vec[4] = 0x0182a283; /* lw t0, 24(t0) */
} else {
/* n: a1存放的是dts的基地址 */
reset_vec[3] = 0x0202b583; /* ld a1, 32(t0) */
/* n: t0存放的是下一跳的地址,就是opensbi的加载地址 */
reset_vec[4] = 0x0182b283; /* ld t0, 24(t0) */
}

固件逻辑

我们直接看opensbi中的多核启动逻辑,具体opensbi的代码分析可以看这里

总体上看,opensbi大概分为汇编部分和C代码部分,这两部分都会涉及到多核启动的逻辑。
qemu rom启动opensbi时会有一个主核选择的逻辑,opensbi的三种固件类型在这个逻辑上基
本都是用所谓lottery算法,只有fw_dynamic_version_2用的是指定核启动。opensbi在C代码
部分会再次随机的选一个核做主核。

lottery算法的逻辑很直白,就是多个核去抢做主启动核,主核就一个人去做公共资源的初始化,
其它核(从核)就等着,直到主核把公共资源初始化完,从核继续做每个核各自的初始化内容。

从硬件的视角看,riscv qemu virt上的各个核在系统初始化后都立即投入运行, 多核启动
的逻辑都用软件搞定的,其中选核可以用类似lottery的算法,从核可以用wfi挂起,主从核
可以用IPI来通信。我们把整个逻辑描述在如下的图里:

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
汇编部分:
core0 core1 ... coreN <--- 这里core0抢到了。如果是指定核启动,就不用抢了
|
| +->-+ +->-+
| ^ v ^ v
| +-<-+ +-<-+
| | |
| | |
C: sbi_init sbi_init sbi_init <--- 这里core1抢到了coldboot
| | |
+->-+ | +->-+
wfi ^ v | wfi ^ v <------- warmboot先等coldboot完成
+-<-+ <+ | +> +-<-+
| | | | |
| | | | |
| +- wake up -+ |
| core0 and N |
| | |
| | |
HSM STOPPED | HSM STOPPED
(wfi) | (wfi)
| ^ | ^ |
| | | | |
| | | | |
| | mret | |
| | | | |
| | | | |
| | | | |
| +-sbi_hsm_hart_start | |
| (core0, addr) | |
| sbi_hsm_hart_start -+ |
| (coreN, addr) |
| | |
mret | mret
| | |
| | |

如上C部分依然使用lottery算法,选择一个核先作为主核,初始化公共资源,这个时候各个
从核是挂起的,在主核准备进入内核前,主核通过IPI解开挂起的从核,不过从核执行走几步
之后,又会挂起。从核继续运行需要内核通过sbi的HSM接口下发启动命令,使用的是HSM的
hart start接口,这个接口可以指定需要启动的hart以及执行内核(S mode)执行的起始地址。
从后面内核分析中可以看出,从核的内核启动地址并不是_start,而是secondary_start_sbi。

内核逻辑

内核多核启动的逻辑和上面的逻辑类似,只不过内核里公共的资源更多,从核启动的时候,
在各个核之间做同步的时候注意的问题会更多,比如,内核页表在各个核之间的同步问题。
Linux内核riscv下启动汇编的分析可以这里

riscv的多核启动有两种实现,一种是基于spinwait的,在这种启动模拟下,多核均直接进入
内核,内核使用lottery算法选出一个主核,其它核spinwait,riscv内核Kconfig里提示,
这种方式不支持CPU hotplug和sparse hartid,这种方式只用在只支持M mode或者是BIOS
不支持SBI HSM扩展的情况。另一种是ordered booting,这种方式要依赖HSM扩展,配合上面
提到的fw_dynamic_version_2从固定核启动的方式,opensbi只容许主核进到内核,然后内核
通过HSM方法启动从核,我们下面重点看下ordered booting。

riscv ordered booting的基本逻辑:

1
2
3
4
5
6
start_kernel
/* arch/riscv/kernel/setup.c */
+-> setup_arch
+-> setup_smp
/* 根据内核的配置方式,挂接sbi或者是spinwait的回调函数 */
+-> struct cpu_operations cpu_ops[cpuid] = &cpu_ops_sbi

如上是cpu_ops的配置逻辑,根据是否支持SBI,决定是挂接spinwait还是SBI的回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* linux/init/main.c,kernel_init会拉起一号进程,在这之前会在smp_init里拉起从核 */
kernel_init
+-> smp_init
/* 在一个循环里一个接一个的拉起从核 */
+-> bringup_noboot_cpus
/* 拉起一个core的函数 */
+-> cpu_up
+-> _cpu_up
[...]
/* 最终会一路调到riscv的实现函数(linux/arch/riscv/kernel/smpboot.c) */
+-> __cpu_up
+-> start_secondary_cpu
/*
* sbi_cpu_start, 传入的启动参数包括:启动地址,task_struct指针,
* 栈指针。sbi_cpu_start的接口通过a0传启动地址,通过a1传打包
* task_struct指针和栈指针的数据结构的地址。opensbi中不处理这
* 两个参数,在启动内核的时候又通过a0和a1传给内核,内核解析a1
* 地址上的数据,得到task_struct指针和栈指针。
*/
+-> cpu_ops[cpu]->cpu_start