环境:
处理器架构:arm64
uboot版本:uboot-2020.01
内核源码:linux-5.0
ubuntu版本:20.04.1
ATF版本:2.1
代码阅读工具:vim+ctags+cscope
一般嵌入式系统使用的都是对称多处理器(Symmetric Multi-Processor, SMP)系统,包含了多个cpu, 这几个cpu都是相同的处理器,如4核Contex-A53。但是在系统 启动阶段他们的地位并不是相同的,其中core0是主cpu(也叫引导处理器),其他core是从cpu(也叫辅处理器),引导cpu负责执行我们的启动加载程序如uboot,以及初始化内核,系统初始化完成之后主core会启动从处理器。
一般主处理器启动从处理器有以下三种:
(1).ACPI
(2).spin-table
(3).PSCI
第一种ACPI是高级配置与电源接口(Advanced Configuration and Power Interface)一般在x86平台用的比较多,而后两种spin-table(自旋表)和PSCI(电源状态协调协议 Power State Coordination)会在arm平台上使用,本系列主要讲解后两种。主要内容分为上下两篇如下:
上篇:
1.开场白
2.cpu启动的一些基本概念
3.支持spin-table情况
下篇:
4.支持psci情况
5.从处理器启动进入内核世界之后做了些什么
6.最后说两句
1)cpu启动的含义:cpu可以从内存中取指、译码、执行,当然内存可以是soc片内的sram,也可以是ddr。
2)我们要知道,程序为何可以在多个cpu上并发执行:他们有各自独立的一套寄存器,如:程序计数器pc,栈指针寄存器sp,通用寄存器等,可以独自 取指、译码、执行,当然内存和外设资源是共享的,多核环境下当访问临界区 资源一般 自旋锁来防止竞态发生。
3)soc启动流程:soc启动的一般会从片内的rom, 也叫bootrom开始执行第一条指令,这个地址是系统默认的启动地址,会在bootrom中由芯片厂家固化一段启动代码来加载启动bootloader到片内的sram,启动完成后的bootloader除了做一些硬件初始化之外做的最重要的事情是初始化ddr,因为sram的空间比较小所以需要初始化拥有大内存 ddr,最后会从网络/usb下载 或从存储设备分区上加载内核到ddr某个地址,为内核传递参数之后,然后bootloader就完成了它的使命,跳转到内核,就进入了操作系统内核的世界。
4)linux内核启动流程:bootloader将系统的控制权交给内核之后,他首先会进行处理器架构相关初始化部分,如设置异常向量表,初始化mmu(之后内核就从物理地址空间进入了虚拟地址空间的世界,一切是那么的虚无缥缈,又是那么的恰到好处)等等,然后会清bss段,设置sp之后跳转到C语言部分进行更加复杂通用的初始化,其中会进行内存方面的初始化,调度器初始化,文件系统等内核基础组件 初始化工作,随后会进行关键的从处理器的引导过程,然后是各种实质性的设备驱动的初始化,最后 创建系统的第一个用户进程init后进入用户空间执行用户进程宣誓内核初始化完成,可以进程正常的调度执行。
5)系统初始化阶段大多数都是主处理器做初始化工作,所有不用考虑处理器并发情况,一旦从处理器被bingup起来,调度器和各自的运行队列准备就绪,多个任务就会均衡到各个处理器,开始了并发的世界,一切是那么的神奇。
了解了关于cpu启动的一些基本概念,下面开始我们的正题,讲解arm64常用的两种cpu启动方式。首先,我们来看一下比较简单的自旋表的方式启动从处理器。
从bootloader说起(以uboot为例):首先,上电后主处理器和从处理器都会启动,执行uboot,从uboot的_start的汇编代码开始执行,主处理器在uboot中欢快的执行后启动内核,进入内核执行,而从处理器会执行到spin_table_secondary_jump中(注意:之前执行的代码,设置的寄存器都是各cpu独立的寄存器)
arch/arm/cpu/armv8/start.S:
19 .globl _start
20 _start:
...
151 #if defined(CONFIG_ARMV8_SPIN_TABLE) && !defined(CONFIG_SPL_BUILD)
152 branch_if_master x0, x1, master_cpu //判断是否为主cpu(core0),是跳转到master_cpu,否则往下走
153 b spin_table_secondary_jump //跳转执行
...
arch/arm/cpu/armv8/spin_table_v8.S:
9 ENTRY(spin_table_secondary_jump)
10 .globl spin_table_reserve_begin
11 spin_table_reserve_begin:
12 0: wfe
13 ldr x0, spin_table_cpu_release_addr
14 cbz x0, 0b
15 br x0
16 .globl spin_table_cpu_release_addr
17 .align 3
18 spin_table_cpu_release_addr:
19 .quad 0
20 .globl spin_table_reserve_end
21 spin_table_reserve_end:
22 ENDPROC(spin_table_secondary_jump)
在spin_table_secondary_jump中:首先会执行wfe指令,使得从处理器睡眠等待。如果被唤醒,则从处理器会判断spin_table_cpu_release_addr这个地址是否为0,为0则继续跳转到wfe处继续睡眠,否则跳转到spin_table_cpu_release_addr指定的地址处执行。
那么这个地址什么时候会被设置呢?答案是:主处理器在uboot中读取设备树的相关节点属性获得,我们来看下如何获得。执行路径为:
do_bootm_linux
->boot_prep_linux
->image_setup_linux
->image_setup_libfdt
->arch_fixup_fdt
->spin_table_update_dt
在spin_table_update_dt函数中做了几件非常重要的事情:
arch/arm/cpu/armv8/spin_table.c:
11 int spin_table_update_dt(void *fdt)
12 {
13 int cpus_offset, offset;
14 const char *prop;
15 int ret;
16 unsigned long rsv_addr = (unsigned long)&spin_table_reserve_begin;
17 unsigned long rsv_size = &spin_table_reserve_end -
18 &spin_table_reserve_begin;
19 //获取设备树的cpus节点的偏移
20 cpus_offset = fdt_path_offset(fdt, "/cpus");
21 if (cpus_offset < 0)
22 return -ENODEV;
23 //寻找每一个device_type属性为cpu的节点
24 for (offset = fdt_first_subnode(fdt, cpus_offset);
25 ¦ offset >= 0;
26 ¦ offset = fdt_next_subnode(fdt, offset)) {
27 prop = fdt_getprop(fdt, offset, "device_type", NULL);
28 if (!prop || strcmp(prop, "cpu"))
29 continue;
30
31 /*
32 ¦* In the first loop, we check if every CPU node specifies
33 ¦* spin-table. Otherwise, just return successfully to not
34 ¦* disturb other methods, like psci.
35 ¦*///获得enable-method属性,比较属性值是否为 "spin-table"(即是使用自旋表启动方式)
36 prop = fdt_getprop(fdt, offset, "enable-method", NULL);
37 if (!prop || strcmp(prop, "spin-table"))
38 return 0;
39 }
40
41 for (offset = fdt_first_subnode(fdt, cpus_offset);
42 ¦ offset >= 0;
43 ¦ offset = fdt_next_subnode(fdt, offset)) {
//找到cpu节点
44 prop = fdt_getprop(fdt, offset, "device_type", NULL);
45 if (!prop || strcmp(prop, "cpu"))
46 continue;
47 //重点:设置cpu-release-addr属性值为spin_table_cpu_release_addr的地址!
48 ret = fdt_setprop_u64(fdt, offset, "cpu-release-addr",
49 (unsigned long)&spin_table_cpu_release_addr);
50 if (ret)
51 return -ENOSPC;
52 }
53 //设置设备树的保留内存 :添加一个内存区域为16和17行描述的地址范围(这是物理地址)
54 ret = fdt_add_mem_rsv(fdt, rsv_addr, rsv_size);
55 if (ret)
56 return -ENOSPC;
57
58 printf(" Reserved memory region for spin-table: addr=%lx size=%lx\n",
59 ¦ rsv_addr, rsv_size);
60
61 return 0;
62 }
其实,他做的工作主要有两个:
1.将即将供内核使用的设备树的cpu节点的cpu-release-addr属性设置为spin_table_cpu_release_addr的地址(这个地址也就是cpu的释放地址)。
2.将spin_table_reserve_begin到spin_table_reserve_end符号描述的地址范围添加到设备树的保留内存中。
实际上保留的是spin_table_secondary_jump汇编函数的指令代码段和spin_table_cpu_release_addr地址内存,当然保留是为了在内核中不被内存管理使用,这样这段物理内存的数据不会被覆盖丢失。注意:spin_table_cpu_release_addr地址处被初始化为0(上面汇编19行)。
先来看一下一个使用自旋表作为启动方式的平台设备树cpu节点:
arch/arm64/boot/dts/xxx.dtsi:
cpu@0 {
device_type = "cpu";
compatible = "arm,armv8";
reg = <0x0 0x000>;
enable-method = "spin-table";
cpu-release-addr = <0x1 0x0000fff8>;
};
可以发现启动方法为spin-table,释放地址初始化为0x10000fff8。
那么什么时候释放地址spin_table_cpu_release_addr 的内容不是0呢?
那么我们得回到主处理器流程上来:主处理器设置好了设备树,传递给内核设备树地址之后就要启动内核,启动内核之后,执行初始化工作,执行如下路径:
setup_arch //arch/arm64/kernel/setup.c:
->smp_init_cpus //arch/arm64/kernel/smp.c
->**smp_cpu_setup**
->cpu_ops[cpu]->cpu_init(cpu)
->smp_spin_table_ops->cpu_init //arch/arm64/kernel/cpu_ops.c
->**smp_spin_table_cpu_init**//arch/arm64/kernel/smp_spin_table.c
我们来看下smp_spin_table_cpu_init函数:
36 static phys_addr_t cpu_release_addr[NR_CPUS];
54 static int smp_spin_table_cpu_init(unsigned int cpu)
55 {
56 struct device_node *dn;
57 int ret;
58
59 dn = of_get_cpu_node(cpu, NULL);
60 if (!dn)
61 return -ENODEV;
62
63 /*
64 ¦* Determine the address from which the CPU is polling.
65 ¦*/
66 ret = of_property_read_u64(dn, "cpu-release-addr",
67 ¦ &cpu_release_addr[cpu]);
68 if (ret)
69 pr_err("CPU %d: missing or invalid cpu-release-addr property\n",
70 ¦ cpu);
71
72 of_node_put(dn);gongz
73
74 return ret;
75 }
可以发现,函数读取设备树的cpu-release-addr属性值到cpu_release_addr[cpu]中,cpu_release_addr变量是个NR_CPUS个元素的数组,每个处理器占用一个元素,其实也就是将之前保存的spin_table_reserve_begin符号的物理地址保存到这个变量中。
现在还没有看到设置释放地址的地方,继续往下看:
主处理器继续执行如下路径:
start_kernel
->arch_call_rest_init
->rest_init
->kernel_init,
->kernel_init_freeable
->**smp_prepare_cpus** //arch/arm64/kernel/smp.c
->cpu_ops[cpu]->cpu_prepare
->smp_spin_table_ops->cpu_init //arch/arm64/kernel/cpu_ops.c
->**smp_spin_table_cpu_prepare**//arch/arm64/kernel/smp_spin_table.c
我们来看这个函数:
77 static int smp_spin_table_cpu_prepare(unsigned int cpu)
78 {
79 __le64 __iomem *release_addr;
80
81 if (!cpu_release_addr[cpu])
82 return -ENODEV;
83
84 /*
85 ¦* The cpu-release-addr may or may not be inside the linear mapping.
86 ¦* As ioremap_cache will either give us a new mapping or reuse the
87 ¦* existing linear mapping, we can use it to cover both cases. In
88 ¦* either case the memory will be MT_NORMAL.
89 ¦*/ //将释放地址的物理地址映射为虚拟地址
90 release_addr = ioremap_cache(cpu_release_addr[cpu],
91 ¦ sizeof(*release_addr));
92 if (!release_addr)
93 return -ENOMEM;
94
95 /*
96 ¦* We write the release address as LE regardless of the native
97 ¦* endianess of the kernel. Therefore, any boot-loaders that
98 ¦* read this address need to convert this address to the
99 ¦* boot-loader's endianess before jumping. This is mandated by
100 ¦* the boot protocol.
101 ¦*///将secondary_holding_pen地址写到释放地址处
102 writeq_relaxed(__pa_symbol(secondary_holding_pen), release_addr);
103 __flush_dcache_area((__force void *)release_addr,
104 ¦ sizeof(*release_addr));//刷数据cache
105
106 /*
107 ¦* Send an event to wake up the secondary CPU.
108 ¦*/
109 sev();//发送事件唤醒从处理器
110 //解除映射
111 iounmap(release_addr);
112
113 return 0;
114 }
上面函数主要做两点:
1.102行,cpu的释放地址处写入secondary_holding_pen的地址,由于获得的内核符号是虚拟地址所以转化为物理地址写到释放地址处。
2.109行,唤醒处于wfe状态的从处理器。
我们再次回到从处理器睡眠等待的地方:在汇编函数spin_table_secondary_jump中唤醒后执行,wfe的下几行指令,判断spin_table_cpu_release_addr地址处的内容是否为0,这个时候由于主处理器往这个地址写入了释放地址,所有会执行15行指令,跳转到secondary_holding_pen处执行,请注意:这个地址是物理地址,而且从处理器还没有开启mmu,所以从处理器还没有进入虚拟地址的世界。
获得释放地址后的从处理器,犹如脱缰的野马,唤醒后直接进入了内核的世界去执行指令,多么的残暴,来到了如下的汇编函数:
arch/arm64/kernel/head.S:
691 /*
692 ¦* This provides a "holding pen" for platforms to hold all secondary
693 ¦* cores are held until we're ready for them to initialise.
694 ¦*/
695 ENTRY(secondary_holding_pen)
696 bl el2_setup // Drop to EL1, w0=cpu_boot_mode
697 bl set_cpu_boot_mode_flag
698 mrs x0, mpidr_el1
699 mov_q x1, MPIDR_HWID_BITMASK
700 and x0, x0, x1
701 adr_l x3, secondary_holding_pen_release
702 pen: ldr x4, [x3]
703 cmp x4, x0
704 b.eq secondary_startup
705 wfe
706 b pen
707 ENDPROC(secondary_holding_pen)
但是事与愿违,在这个函数中又有了一层关卡:689行到701行 判断是否secondary_holding_pen_release被设置为了从处理器的编号,如果设置的不是我的编号,则我再次进入705行执行wfe睡眠等待,行吧,那就等待啥时候主处理器来将secondary_holding_pen_release设置为我的处理器编号吧。那么何时会设置呢?答案是最终要启动从处理器的时候。
我们再次回到主处理器的处理流程,上面主处理器执行到了smp_prepare_cpus之后,继续往下执行,代码路径如下:
start_kernel
->arch_call_rest_init
->rest_init
->kernel_init,
->kernel_init_freeable
->smp_prepare_cpus //arch/arm64/kernel/smp.c
->smp_init //kernel/smp.c (这是从处理器启动的函数)
->cpu_up
->do_cpu_up
->_cpu_up
->cpuhp_up_callbacks
->cpuhp_invoke_callback
->cpuhp_hp_states[CPUHP_BRINGUP_CPU]
->**bringup_cpu**
->__cpu_up //arch/arm64/kernel/smp.c
->boot_secondary
->cpu_ops[cpu]->cpu_boot(cpu)
->smp_spin_table_ops.cpu_boot //arch/arm64/kernel/cpu_ops.c
->smp_spin_table_cpu_boot //arch/arm64/kernel/smp_spin_table.c
我们来看smp_spin_table_cpu_boot函数:
38 /*
39 * Write secondary_holding_pen_release in a way that is guaranteed to be
40 * visible to all observers, irrespective of whether they're taking part
41 * in coherency or not. This is necessary for the hotplug code to work
42 * reliably.
43 */
44 static void write_pen_release(u64 val)
45 {
46 void *start = (void *)&secondary_holding_pen_release;
47 unsigned long size = sizeof(secondary_holding_pen_release);
48
49 secondary_holding_pen_release = val;
50 __flush_dcache_area(start, size);
51 }
116 static int smp_spin_table_cpu_boot(unsigned int cpu)
117 {
118 /*
119 ¦* Update the pen release flag.
120 ¦*/ //将secondary_holding_pen_release内容写为处理器id
121 write_pen_release(cpu_logical_map(cpu));
122
123 /*
124 ¦* Send an event, causing the secondaries to read pen_release.
125 ¦*/ //唤醒从处理器
126 sev();
127
128 return 0;
129 }
可以看到这里将从处理器编号写到了secondary_holding_pen_release中,然后唤醒从处理器,从处理器再次欢快的执行,最后执行到secondary_startup,来做从处理器的初始化工作(如设置mmu,异常向量表等),最终从处理器还是处于wfi状态,但是这个时候从处理器已经具备了执行进程的能力,可以用来调度进程,触发中断等,和主处理器有着相同的地位,后面我们会分析。耐心读到这里的读者,也很不容易了,为你们勇气点赞。我觉得源代码是最好的资料,阅读源代码才是最佳的学习理解内核的方法,当然不想看代码可以直接看下面这张图解:
spin-table方式的多核启动方式,顾名思义在于自旋,主处理器和从处理器上电都会启动,主处理器执行uboot畅通无阻,从处理器在spin_table_secondary_jump处wfe睡眠,主处理器通过修改设备树的cpu节点的cpu-release-addr属性为spin_table_cpu_release_addr,这是从处理器的释放地址所在的地方,主处理器进入内核后,会通过smp_prepare_cpus函数调用spin-table 对应的cpu操作集的cpu_prepare方法从而在smp_spin_table_cpu_prepare函数中设置从处理器的释放地址为secondary_holding_pen这个内核函数,然后通过sev指令唤醒从处理器,从处理器继续从secondary_holding_pen开始执行(从处理器来到了内核的世界),发现secondary_holding_pen_release不是自己的处理编号,然后通过wfe继续睡眠,当主处理器完成了大多数的内核组件的初始化之后,调用smp_init来来开始真正的启动从处理器,最终调用spin-table 对应的cpu操作集的cpu_boot方法从而在smp_spin_table_cpu_boot将需要启动的处理器的编号写入secondary_holding_pen_release中,然后再次sev指令唤醒从处理器,从处理器得以继续执行(设置自己异常向量表,初始化mmu等),最终在idle线程中执行wfi睡眠。其他从处理器也是同样的方式启动起来,同样最后进入各种idle进程执行wfi睡眠,主处理器继续往下进行内核初始化,直到启动init进程,后面多个处理器都被启动起来,都可以调度进程,多进程还会被均衡到多核。