基本逻辑
计算机系统有多个核的时候,多个核之间启动的时候要遵守一定的逻辑关系。虽然,多核系统
上每个核都可以独立的运行程序,但是总会有多个核共享的资源,对于这些资源配置和访问
需要串行,比如,固件或者内核的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
|