泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

泰晓Linux知识星球:1300+知识点,520+用户
请稍侯

RISC-V SMP Linux boot process

sugarfillet 创作于 2024/07/16

Corrector: TinyCorrect v0.1 - [spaces] Author: sugarfillet sugarfillet@yeah.net Date: 2023/01/30 Revisor: Falcon falcon@tinylab.org Project: RISC-V Linux 内核剖析 Proposal: RISC-V Linux SMP 技术调研与分析 Sponsor: PLCT Lab, ISCAS

前言

这篇文章 描述了 RISC-V 平台的固件启动过程,其中指出在 M 模式下的 U-Boot SPL 和 opensbi 确认了唯一的 boot_hart,S 模式下,其他 hart 挂起在 opensbi 中执行 wfi 指令,U-Boot proper 运行在 boot_hart 中,加载并移交系统控制权给 Linux。本文在此基础上,继续分析在 Linux 中的 SMP 启动过程。

说明

  • 本文的 Linux 版本采用 Linux v6.0
  • 本文着重分析 SMP 的启动过程,涉及到的其他内容:内存管理、调度原理,点到为止

内核入口 _start_kernel

运行在 boot hart 上的 U-Boot proper 在加载 Linux 时为其传递两个参数,a0 存储启动 hart(boot_hart),a1 存储 dtb 的物理地址。Linux 启动时调用 _start_kernel 进行当前 hart 的基本初始化并调用 start_kernel() C 函数,其关键流程如下:

  • 清零 IE IP 寄存器以关闭中断
  • 设置 gp 寄存器
  • 通过 a0 传递的 boot_hart 保存在 boot_cpu_hartid 变量中
  • setup_vm 以 a1 传递的 dtb 地址做初期的虚拟内存配置
  • relocate_enable_mmu 执行开启 mmu 情况下的内核重定位
  • setup_trap_vector 用于设置当前 hart 的中断向量表
  • 设置线程指针(init_task)和栈指针,并调用 start_kernel 函数执行后续的启动代码
// arch/riscv/kernel/head.S : 196

_start_kernel:
        csrw CSR_IE, zero
        csrw CSR_IP, zero
        ...
        /* Save hart ID and DTB physical address */
        mv s0, a0
        mv s1, a1

        la a2, boot_cpu_hartid
        REG_S a0, (a2)
        ...
        mv a0, s1
        call setup_vm
        ...
        la a0, early_pg_dir
        call relocate_enable_mmu
        ...
        call setup_trap_vector
            CSR_TVEC handle_exception

        la tp, init_task
        la sp, init_thread_union + THREAD_SIZE
        ...
        tail start_kernel

在分析 start_kernel 函数中的 SMP 启动流程之前,我们先了解下 cpu 在启动过程中的几个状态。

cpu 状态

Linux 中对 cpu 状态通过几组 bitmap – cpu_*_mask 来表示,每个 bitmap 中的一位则表示一个 cpu 是否在当前状态:

  • cpu_possible_mask 代表可以被系统使用的 cpu,一般来说,就是在 dts 文件中定义的 cpu 节点
  • cpu_present_mask 代表已经被系统接管的 cpu
  • cpu_online_mask 代表可以被调度器使用的 cpu
  • cpu_active_mask 表示在开启 cpu hotplug 的系统中,调度器在收到 cpu 资源变更的事件时对 cpu 状态的定义
    • 比如,某个 cpu 下线,调度器需要把当前 cpu 的执行上下文迁移到其他 cpu 上去做,则会把当前 cpu 从 cpu_active_mask 中移除

关键代码如下:

// include/linux/cpumask.h : 97

#define cpu_possible_mask ((const struct cpumask *)&__cpu_possible_mask)
#define cpu_online_mask   ((const struct cpumask *)&__cpu_online_mask)
#define cpu_present_mask  ((const struct cpumask *)&__cpu_present_mask)
#define cpu_active_mask   ((const struct cpumask *)&__cpu_active_mask)

以上对于 cpu 状态的描述可能过于简单,下文以 cpu 状态的变化为线索来观察 SMP 的启动过程,同时加深对上述状态的理解。

start_kernel

这里先简单罗列下 start_kernel() 函数中与 SMP 启动相关的关键函数,我们一个一个来分析:

// init/main.c : 936

start_kernel()
    smp_setup_processor_id();
    boot_cpu_init();
    setup_arch();
        setup_smp();
    ...
    smp_prepare_boot_cpu();
    boot_cpu_hotplug_init();
    ...
    sched_init();
    ...
    arch_call_rest_init();
        rest_init();

smp_setup_processor_id()_start_kernel 中赋值的 boot_cpu_hartid 存储到 __cpuid_to_hartid_map[0] 中,__cpuid_to_hartid_map 数组用来存储 cpuid 与 hartid 的映射。关键代码如下:

// init/main.c : 772

smp_setup_processor_id();
    __cpuid_to_hartid_map[0] = boot_cpu_hartid;

boot_cpu_init() 函数通过 struct task_info * (init_task)->cpu 获取当前 boot cpu id,默认为 0,并将四个 cpu_*_mask 中第 0 个 bit 置位,表示 boot cpu 是可被调度器使用的。关键代码如下:

// kernel/cpu.c : 2667

boot_cpu_init()
    int cpu = smp_processor_id();
        raw_smp_processor_id()
            (current_thread_info()->cpu)
            (struct task_info *)riscv_current_is_tp->cpu // 0
    //  Mark the boot cpu "present", "online" etc
    set_cpu_online(cpu, true);
    set_cpu_active(cpu, true);
    set_cpu_present(cpu, true);
    set_cpu_possible(cpu, true);

    __boot_cpu_id = cpu;

setup_arch() 中调用 setup_smp() 函数,此函数遍历 dts 中的每个 cpu 节点并获取 hartid,并与 __cpuid_to_hartid_map[] 进行映射,这里需要注意的是 __cpuid_to_hartid_map[0] 总是为 boot_cpu_hartid;映射完成之后,对每个 cpuid 设置 cpu_ops 操作集,在开启 SBI_EXT_HSM 拓展的情况下,设置 cpu_ops[cpuid] 为 cpu_ops_sbi,最后把对应的 cpu 在 cpu_possbile_mask 中对应的 bit 置位,意味着这些 cpu 对与系统来说是可见的。

cpu_ops_sbi 中声明 SMP 启动以及热插拔过程的函数列表,比如,在把非 boot cpu 设置为 present 之前会调用 sbi_cpu_prepare 函数;sbi_cpu_start 函数用于在指定 cpu 上执行 idle 线程。关键代码如下:

// arch/riscv/kernel/smpboot.c : 73

setup_smp()
    for_each_of_cpu_node
        riscv_of_processor_hartid(dn, &hart)
        cpuid_to_hartid_map(cpuid) = hart
        early_map_cpu_to_node(cpuid, of_node_to_nid(dn));
        cpuid++
    for (cpuid = 1; cpuid < nr_cpu_ids; cpuid++)
        cpu_set_ops(cpuid);
            cpu_ops[cpuid] = &cpu_ops_sbi;
        set_cpu_possible(cpuid, true);

// arch/riscv/kernel/cpu_ops_sbi.c : 120

const struct cpu_operations cpu_ops_sbi = {
        .name           = "sbi",
        .cpu_prepare    = sbi_cpu_prepare,
        .cpu_start      = sbi_cpu_start,
#ifdef CONFIG_HOTPLUG_CPU
        .cpu_disable    = sbi_cpu_disable,
        .cpu_stop       = sbi_cpu_stop,
        .cpu_is_stopped = sbi_cpu_is_stopped,
#endif
};

smp_prepare_boot_cpu() 函数调用 init_cpu_topology() 函数解析 dts 中描述的 package/cluster/core/thread 的 cpu 相关信息,并记录到 struct cpu_topology cpu_topology[NR_CPUS] 中。

boot_cpu_hotplug_init() 函数设置 boot_cpu 到 cpus_booted_once_mask 中标志 boot_cpu 已启动,并设置 percpu 变量 cpuhp_state.state 为 CPUHP_ONLINE,此操作涉及 cpu 热插拔(cpuhp)相关内容,下文会讲到。

// kernel/cpu.c : 2690

void __init boot_cpu_hotplug_init(void)
{
#ifdef CONFIG_SMP
        cpumask_set_cpu(smp_processor_id(), &cpus_booted_once_mask);
#endif
        this_cpu_write(cpuhp_state.state, CPUHP_ONLINE);
}

0/1/2 号线程

_start_kernel 汇编函数中,通过如下两个指令设置线程指针和栈指针构造了 C 语言的执行环境,但此时的上下文(init_task)并不具备可被调度的能力,所以需要对该线程做进一步的初始化。

        la tp, init_task
        la sp, init_thread_union + THREAD_SIZE

sched_init() 负责调度相关的初始化,其中会调用 idle_init() 将当前的执行上下文设置为 0 号线程 (idle),其中最有代表性的一个设置就是 0 号线程有了名字 - swapper。之后调用 idle_thread_set_boot_cpu() 函数将当前线程保存在 boot cpu 的 idle_threads 变量中。关键代码如下:

// kernel/sched/core.c : 9613

sched_init()

    init_idle(current, smp_processor_id()) // set up an idle thread for a given CPU
        __sched_fork(0, idle);
        idle->__state = TASK_RUNNING
        idle->flags |= PF_IDLE | PF_KTHREAD | PF_NO_SETAFFINITY;
        kthread_set_per_cpu(idle, cpu)
            kthread->cpu = cpu
        idle->sched_class = &idle_sched_class;
        sprintf(idle->comm, "%s/%d", INIT_TASK_COMM, cpu); // set idle->comm is "swapper"

    idle_thread_set_boot_cpu();

start_kernel 经历一系列的初始化后,会在结尾处会调用 rest_init(),此函数先后创建 1 号线程(init)和 2 号线程 (kthreadd):

  1. init 线程执行 kernel_init() 函数用于执行进一步的初始化操作并最终加载 init 用户态程序
  2. kthreadd 线程负责执行 kthreadd() 函数用来初始化其他内核子线程。

由于 init 线程执行过程中需要创建内核线程,所以需要在 kthreadd 线程创建完成之后才能执行,而此代码同步需求通过 kthreadd_done 完成变量来实现。

此刻 idle/init/kthreadd 三个线程都已创建,并且系统开启调度,其中 idle 线程为调度优先级最低的任务,被调度到会通过 cpu_startup_entry(CPUHP_ONLINE) 进入 idle 循环中, 而 kthreadd 线程更多的是用来服务 init 线程中的内核线程创建需求,所以后续的启动流程以 init 线程为主。

// init/main.c : 681

rest_init()

    pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
        wait_for_completion(&kthreadd_done);
        kernel_init_freeable();

    pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);

    system_state = SYSTEM_SCHEDULING;

    complete(&kthreadd_done);

    schedule_preempt_disabled() // scheule once
        schedule()
    cpu_startup_entry(CPUHP_ONLINE); // idle loop
        while (1)
            do_idle();

cpu 热插拔状态

除了上文提到的的 cpu 四种状态,linux 中也引入 cpu 热插拔状态用来细化管理 cpu 状态从 present 状态到 online 状态的切换。

cpu 热插拔机制通过 cpuhp_state 枚举来定义 cpu 热插拔状态,并在 cpuhp_hp_states 数组中为每个状态绑定在 cpu 启动或者关闭时需要执行的回调函数,在 cpu 启动过程中,会依次执行该状态之前的所有状态绑定的启动回调函数,在 cpu 关闭时执行对应的关闭回调函数。cpu 热插拔状态又进一步划分三个阶段来实现在不同的启动或者关闭阶段对热插拔 cpu 的回调函数的调用:

  • PREPARE 阶段从 CPUHP_OFFLINE 开始到 BRINGUP_CPU 结束,此状态段由 boot cpu 来调用回调函数,可参考下文的 cpu_up() 函数
  • STARTING 阶段从 BRINGUP_CPU + 1 开始到 CPUHP_AP_ONLINE_IDLE - 1 结束,此状态段有非 boot cpu 的早期启动代码来调用回调函数,可参考下文的 smp_callin() 函数
  • ONLINE 阶段从 CPUHP_AP_ONLINE_IDLE 开始到 CPUHP_ONLINE 结束,此状态段有非 boot cpu 的热插拔线程来调用回调函数,可参考热插拔线程的线程函数 cpuhp_thread_fun()

关键代码如下:

// include/linux/cpuhotplug.h : 57

enum cpuhp_state {
    CPUHP_OFFLINE = 0, // PREPARE section invoked on a control CPU
    CPUHP_OFFLINE + 1,
    ...
    BRINGUP_CPU,
    CPUHP_AP_IDLE_DEAD, // STARTING section invoked on the hotplugged CPU in low level
    ...
    CPUHP_AP_ONLINE,
    CPUHP_TEARDOWN_CPU,
    CPUHP_AP_ONLINE_IDLE, // Online section invoked on the hotplugged CPU from the hotplug thread
    ...
    CPUHP_ONLINE
};

// kernel/cpu.c : 1565

static struct cpuhp_step cpuhp_hp_states[] =
        ...
        [CPUHP_CREATE_THREADS]= {
                .name                   = "threads:prepare",
                .startup.single         = smpboot_create_threads,
                .teardown.single        = NULL,
                .cant_stop              = true,
        },

        ...
        [CPUHP_BRINGUP_CPU] = {
                .name                   = "cpu:bringup",
                .startup.single         = bringup_cpu,
                .teardown.single        = finish_cpu,
                .cant_stop              = true,
        },
        ...
}

kernel_init

在 0/1/2 号线程创建之后,boot cpu 上主要执行 init 线程的线程函数 – kernel_init(),其中有关 SMP 启动相关流程的代码主要集中 kernel_init_freeable() 函数中,此函数中与 SMP 启动相关的关键过程如下:

smp_prepare_cpus() 函数对非 boot cpu 执行 cpu_ops.cpu_prepare() 操作,并将 cpu 的状态设置为 present。RISC-V 架构下的 cpu_prepare() 相对简单,只是简单判断 cpu_ops.cpu_start 函数是否定义。

smp_init() 函数首先调用 idle_threads_init() 为所有的非 boot cpu 创建 idle 线程,并将线程绑定到对应 cpu 的 idle_threads 变量,之后调用 cpuhp_threads_init() 为 online 的 cpu 创建 cpuhp 线程(目前这里只有 boot cpu 是 online 的),cpuhp 线程的线程函数为 cpuhp_thread_fun,负责执行热插拔 ONLINE 阶段的启动回调函数,最后调用 bringup_nonboot_cpus() 对处于 present 状态的非 boot cpu 执行 cpu_up(cpu, CPUHP_ONLINE) 设置 cpu 热插拔状态为 CPUHP_ONLINE,经过热插拔的三个阶段后,最终执行 idle 线程,具体的实现会在下文中展开。

关键代码如下:

// init/main.c : 1600

kernel_init_freeable()
    smp_prepare_cpus(setup_max_cpus);
        cpu_ops[cpuid]->cpu_prepare(cpuid); // sbi_cpu_prepare
        set_cpu_present(cpuid, true);

    smp_init()
        idle_threads_init();
            for_each_possible_cpu(cpu)
                idle_init(cpu) // 创建 idle 线程

        cpuhp_threads_init();
            cpuhp_init_state() // 初始化热插拔线程唤醒的完成变量
            smpboot_register_percpu_thread(&cpuhp_threads) // 为所有 online cpu 创建 cpuhp 线程

        bringup_nonboot_cpus(setup_max_cpus) // Bringing up secondary CPUs
            for_each_present_cpu(cpu) {
                    cpu_up(cpu, CPUHP_ONLINE)

cpu_up() 函数调用 cpuhp_invoke_callback_range() 来依次执行热插拔 PREPARE 阶段中的状态的启动回调函数,值得关注的两个状态及其启动回调函数为:CPUHP_CREATE_THREADS - smpboot_create_threads(),此函数用来为目标 cpu 创建 cpuhp 内核线程。CPUHP_BRINGUP_CPU - bringup_cpu(),此函数为 PREPARE 阶段调用的最后一个启动回调函数,其具体的实现过程如下:

  • 获取当前 cpu 的 idle 线程后,调用 __cpu_up() 函数执行 cpu_ops[cpu]->cpu_start()sbi_cpu_start(),后者调用 sbi 的 HSM 拓展在目标 cpu 上开始执行 secondary_start_sbi 汇编函数,同时传递 idle 线程指针和栈顶指针,之后等待目标 cpu 初始化过程中释放 cpu_running 完成变量
  • 调用 bringup_wait_for_ap() 函数等待目标 cpu 释放 &st->done_up 完成变量,继而激活目标 cpu 的热插拔线程来执行 ONLINE 段中的启动回调函数

在以上两个完成变量得到释放后,在 boot cpu 上的 cpu_up() 函数以此路径 – bringup_nonboot_cpus => smp_init => kernel_init_freeable => kernel_init 返回使得 boot cpu 的 init 线程可以继续执行,最终加载并运行 init 用户态程序。

关键代码如下:

// kernel/cpu.c : 1395

cpu_up()
    struct cpuhp_cpu_state *st = per_cpu_ptr(&cpuhp_state, cpu);
    cpuhp_set_state(cpu, st, target);
        st->target = target;
    target = min((int)target, CPUHP_BRINGUP_CPU);  // do PREPARE section
    cpuhp_up_callbacks(cpu, st, target);
        cpuhp_invoke_callback_range(true, cpu, st, target);
            while (cpuhp_next_state(bringup, &state, st, target))
            cpuhp_invoke_callback(cpu, state, bringup, NULL, NULL);

// kernel/cpu.c : 589

bringup_cpu()
    tidle = idle_thread_get(cpu)
    __cpu_up(cpu, idle);
        start_secondary_cpu(cpu, tidle);
            cpu_ops[cpu]->cpu_start(cpu, tidle); // sbi_cpu_start
        wait_for_completion_timeout(&cpu_running)   // wait for cpu_running
    return bringup_wait_for_ap(cpu);
        wait_for_ap_thread(st, true);               // wait for &st->done_up
        kthread_unpark(st->thread);
        cpuhp_kick_ap(cpu, st, st->target);

// arch/riscv/kernel/cpu_ops_sbi.c : 65

static int sbi_cpu_start(unsigned int cpuid, struct task_struct *tidle)
{
        unsigned long boot_addr = __pa_symbol(secondary_start_sbi); // boot_addr
        unsigned long hartid = cpuid_to_hartid_map(cpuid);
        unsigned long hsm_data;
        struct sbi_hart_boot_data *bdata = &per_cpu(boot_data, cpuid); // per_cpu boot_data

        /* Make sure tidle is updated */
        smp_mb();
        bdata->task_ptr = tidle;
        bdata->stack_ptr = task_stack_page(tidle) + THREAD_SIZE; // setup stack_ptr by tidle
        /* Make sure boot data is updated */
        smp_mb();
        hsm_data = __pa(bdata);
        return sbi_hsm_hart_start(hartid, boot_addr, hsm_data);  // call HSM SBI_EXT_HSM_HART_START
}

secondary_start_sbi

boot cpu 调用 sbi_cpu_start() 函数让非 boot cpu 执行 secondary_start_sbi 汇编函数,其中设置 tp 和 sp 为 sbi_hsm_hart_start() 传递的线程指针和栈顶指针,相当于为当前 cpu 的代码执行创建了 C 语言环境,则可正常调用 smp_callin() C 函数。关键代码如下:

// arch/riscv/kernel/head.S : 131

secondary_start_sbi:
        ...
        /* a0 contains the hartid & a1 contains boot data */
        li a2, SBI_HART_BOOT_TASK_PTR_OFFSET
        add a2, a2, a1
        REG_L tp, (a2)   // 设置 tp
        li a3, SBI_HART_BOOT_STACK_PTR_OFFSET
        add a3, a3, a1
        REG_L sp, (a3)  // 设置 sp

        ...

        tail smp_callin

smp_callin() 作为非 boot cpu 的所执行的第一个 C 函数,执行如下内容:

  • notify_cpu_starting() 函数依次执行热插拔状态的 STARTING 段内状态的启动回调函数
  • set_cpu_online() 函数设置 cpu 状态为 online
  • 释放完成变量 cpu_running 用于唤醒 __cpu_up() 函数,使得 boot cpu 上的 init 线程得以继续执行
  • complete_ap_thread() 释放 &st->done_up 完成变量唤醒 bringup_wait_for_ap() 函数,从而激活目标 cpu 的热插拔线程来执行 ONLINE 段中的启动回调函数
  • 最后执行 do_idle() 进入低功耗的 idle 循环,在不考虑 cpuidle framework 的情况下,会执行架构级的 idle 函数,此函数在 RISC-V 架构中为 wfi 指令

关键代码如下:

// arch/riscv/kernel/smpboot.c : 155

smp_callin()
    notify_cpu_starting(curr_cpuid);
        target = min((int)st->target, CPUHP_AP_ONLINE); // do STARTING section
        cpuhp_invoke_callback_range_nofail(true, cpu, st, target);

    set_cpu_online(curr_cpuid, 1);          // set online bitmap

    complete(&cpu_running); // wakeup __cpu_up()

    cpu_startup_entry(CPUHP_AP_ONLINE_IDLE);
        cpuhp_online_idle()
            st->state = CPUHP_AP_ONLINE_IDLE;
            complete_ap_thread(st, true); // wakeup bringup_wait_for_ap()
        while (1)
            do_idle()
                cpuidle_idle_call
                   if (cpuidle_not_available(drv, dev)) // if no cpuidle framework
                   default_idle_call()
                     arch_cpu_idle() // wfi

小结

最后我们从 cpu 状态和热插拔状态变化的角度对 SMP 的启动流程总结如下:

start_kernel() 中,setup_smp() 函数负责解析 hart 资源,建立 hartid 与 cpuid 的映射,为每个 cpu 设置 cpu_ops 操作集,将 cpu 设置为 possible 状态。

在 init 线程的 smp_prepare_cpus() 过程中调用 cpu_ops[]->cpu_prepare,将非 boot cpu 设置为 present 状态;后续的 smp_init() 过程中为非 boot cpu 分配 idle 线程结构,并通过 cpu_up() 执行热插拔 PREPARE 阶段的启动回调函数,此阶段结尾的启动回调函数 bringup_cpu() 调用 sbi 的 HSM 拓展接口让非 boot cpu 开始执行代码。

非 boot cpu 执行热插拔 STARTING 阶段的启动回调函数,将 cpu 设置为 online 状态,唤醒 boot cpu 上的 init 线程,唤醒当前 cpu 上热插拔线程执行热插拔的 ONLINE 阶段的启动回调函数,最终进入 idle 循环。

BP (boot cpu)                         AP (noboot cpus)
------------------------------------------------------
setup_smp()                           [possible]

|

.cpu_prepare()                        [present]

|

cpu_up()
  PREPARE
  .cpu_start()    sbi_hart_start >    smp_callin()
                                        STARTING
                                        [online]
  wait             < cpu_running        complele()
  wait             < st->done_up        complete_ap_thread()

|                                     |

kernel_init()...                      cpuhp/1
                                        ONLINE
                                      |

                                      do_idle()

参考资料



Read Album:

Read Related:

Read Latest: