ARM64 SMP多核启动(上)- spin-table

2022-06-19 14:40:57 浏览数 (1)

1.开场白

环境:

处理器架构: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平台上使用,本系列主要讲解后两种。主要内容分为上下两篇如下:

2.cpu启动的一些概念

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起来,调度器和各自的运行队列准备就绪,多个任务就会均衡到各个处理器,开始了并发的世界,一切是那么的神奇。

3.支持spin-table情况

了解了关于cpu启动的一些基本概念,下面开始我们的正题,讲解arm64常用的两种cpu启动方式。首先,我们来看一下比较简单的自旋表的方式启动从处理器。

从bootloader说起(以uboot为例):首先,上电后主处理器和从处理器都会启动,执行uboot,从uboot的_start的汇编代码开始执行,主处理器在uboot中欢快的执行后启动内核,进入内核执行,而从处理器会执行到spin_table_secondary_jump中(注意:之前执行的代码,设置的寄存器都是各cpu独立的寄存器)

代码语言:javascript复制
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   //跳转执行
...
代码语言:javascript复制
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中读取设备树的相关节点属性获得,我们来看下如何获得。执行路径为:

代码语言:javascript复制
do_bootm_linux
->boot_prep_linux
 ->image_setup_linux
  ->image_setup_libfdt
  ->arch_fixup_fdt
   ->spin_table_update_dt

在spin_table_update_dt函数中做了几件非常重要的事情:

代码语言:javascript复制
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=%lxn",
   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节点:

代码语言:javascript复制
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呢?

那么我们得回到主处理器流程上来:主处理器设置好了设备树,传递给内核设备树地址之后就要启动内核,启动内核之后,执行初始化工作,执行如下路径:

代码语言:javascript复制
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函数:

代码语言:javascript复制
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 propertyn",
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符号的物理地址保存到这个变量中。

现在还没有看到设置释放地址的地方,继续往下看:

主处理器继续执行如下路径:

代码语言:javascript复制
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

我们来看这个函数:

代码语言:javascript复制
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,所以从处理器还没有进入虚拟地址的世界。

获得释放地址后的从处理器,犹如脱缰的野马,唤醒后直接进入了内核的世界去执行指令,多么的残暴,来到了如下的汇编函数:

代码语言:javascript复制
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之后,继续往下执行,代码路径如下:

代码语言:javascript复制
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函数:

代码语言:javascript复制
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进程,后面多个处理器都被启动起来,都可以调度进程,多进程还会被均衡到多核。

0 人点赞