内核启动过程分析

kernel_entry→start_kernel()

vmlinux原始内核启动:内核的初始启动入口是位于arch/loongarch/kernel/head.S中的kernel_entry → start_kernel()

vmlinuxz压缩版内核启动:在解压前真正的执行入口是arch/loongarch/boot/compressed/head.S中的start →kernel_entry→start_kernel()

  • 执行decompress_kernel()进行自解压,解压内容释放到内存里面形成一个原始内核。

第一入口:kernel_entry

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
44
45
46
47
48
SYM_CODE_START(kernel_entry)                    # kernel entry point

/* We might not get launched at the address the kernel is linked to,
so we jump there. */
la.abs t0, 0f
jirl zero, t0, 0
0:
la t0, __bss_start # clear .bss
PTR_S zero, t0, 0
la t1, __bss_stop - LONGSIZE
1:
PTR_ADDIU t0, t0, LONGSIZE
PTR_S zero, t0, 0
bne t0, t1, 1b

la t0, fw_arg0
PTR_S a0, t0, 0 # firmware arguments
la t0, fw_arg1
PTR_S a1, t0, 0
la t0, fw_arg2
PTR_S a2, t0, 0
la t0, fw_arg3
PTR_S a3, t0, 0

/* Config direct window and set PG */
PTR_LI t0, 0xa0000011
csrwr t0, LOONGARCH_CSR_DMWIN0
PTR_LI t0, 0x80000001
csrwr t0, LOONGARCH_CSR_DMWIN1
/* Enable PG */
li.w t0, 0xb0 # PLV=0, IE=0, PG=1
csrwr t0, LOONGARCH_CSR_CRMD

/* KScratch3 used for percpu base, initialized as 0 */
csrwr zero, PERCPU_BASE_KS
/* GPR21 used for percpu base (runtime), initialized as 0 */
or x0, zero, zero

la tp, init_thread_union
/* Set the SP after an empty pt_regs. */
PTR_LI sp, (_THREAD_SIZE - 32 - PT_SIZE)
PTR_ADDU sp, sp, tp
set_saved_sp sp, t0, t1
PTR_ADDIU sp, sp, -4 * SZREG # init stack pointer

b start_kernel

SYM_CODE_END(kernel_entry)
  1. 通过一个循环来清零.bss 段中的全局数据;
  2. 将 a0~a3 寄存器中的值保存到 fw_arg0~fw_arg3四个内存变量,这四个变量包含 BIOS 或者引导程序传递给内核的参数;
  3. 配置DMWIN0和DMWIN1映射窗口地址;
  4. 打开PG=1;
  5. 进行CPU类型相关的初始化;
  6. 使用init_thread_union的地址来初始化GP寄存器,GP是全局指针;
  7. 初始化SP寄存器,SP是堆栈指针;
  8. 最后的 b start_kernel 是跳转到第二入口处继续执行,第二入口即 start_kernel()函数;

处理器0的Status寄存器

  • IE:全局中断使能位,为 1 表示开中断,为 0 表示关中断;
  • EXL:异常级别指示,为 1 表示 CPU 处于异常模式,异常模式表示发生了除复位、NMI和 Cache 错误以外的某种异常。
  • ERL:错误级别指示,为 1 表示 CPU 处于错误模式,错误模式表示发生了复位、NMI或者 Cache 错误之类的某种异常。
  • KSU:特权模式位:为 0 表示 CPU 处于核心态(内核态),为 1 表示 CPU 处于管理态,为 2 表示 CPU 处于用户态,为 3 表示未定义。核心态权限最高,可以执行任意指令(特权指令和非特权令),可以访问任意地址空间(核心空间、管理空间和用户空间);管理态权限居中,不能执行特权指令,能访问管理地址空间和用户地址空间;用户态权限最低,不能执行特权指令,只能访问用户地址空间。另外,当 EXL 或者 ERL 置位时,不管 KSU 如何取值,CPU 自动处于核心态。
    • UX:为 1 表示启用 64 位用户地址空间段;
    • SX:为 1 表示启用 64 位管理地址空间段;
    • KX:为 1 表示启用 64 位核心地址空间段;
  • IM7~IM0:中断掩码位,MIPS 在 CPU 层面一共有 8 个中断源,分别有 8 个掩码位与之对应,为 1 的位表示允许该中断触发,为 0 的位表示禁止该中断触发;
  • NMI:为 1 表示发生了 NMI(不可屏蔽中断);
  • SR:为 1 表示发生了软件复位;BEV:控制异常向量的入口,为 1 表示使用启动时异常向量入口,为 0 表示使用运行时异常向量入口;
  • PX:为 1 表示在用户态使能 64 位操作数指令(如 daddu、dsubu 等);
  • FR:浮点协处理器模式切换,为 1 表示有 32 个双精度浮点寄存器可用,为 0 表示只有16 个双精度浮点寄存器可用;
  • CU3~CU0:标识四个协处理器是否可用,协处理器 0(CP0)是系统控制协处理器,在所有 MIPS 处理器上总是可用的;协处理器 1(CP1)通常是浮点协处理器(FPU),在所有龙芯处理器上总是可用的;协处理器 2(CP2)在龙芯 3 号上总是可用的,表示多媒体指令协处理器。

龙芯 3 号总是使用 64 位内核,所以 setup_c0_status_pri 实际上就是设置当前模式为内核态模(KSU),启用内核的 64 位地址段访问能力(KX),启用系统控制协处理器(CU0),启用多媒体指令协处理器(CU2),清除异常状态并禁止中断(清零 EXL、ERL、IE)。BEV等位保持 BIOS 设置的原值(内核尚未建立运行时异常向量)。

init_thread_union相关

在 Linux 中,进程和线程都是运行的程序实体,进程有独立的地址空间,若干个线程共享同一个地址空间;也就是说,线程是一种特殊的进程。Linux 中线程的容器并不是进程,而是线程组。例如:一个运行中的多线程程序是一个线程组,里面包含多个线程;一个运行中的单线程程序也是一个线程组,里面包含一个线程。单线程程序的那个唯一线程,就是一般意义上的进程。
内核本身也可以视为一个特殊的进程,它可以派生出很多共享地址空间的内核线程,因此这个拥有许多线程的内核又可以视为一个特殊的线程组。

线程相关的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};

struct task_struct init_task = INIT_TASK(init_task);
union thread_union init_thread_union = { INIT_THREAD_INFO(init_task) };

#define init_thread_info (init_thread_union.thread_info)
#define init_stack (init_thread_union.stack)

unsigned long kernelsp[NR_CPUS];

每一个进程(包括普通进程和内核线程)用一个进程描述符 task_struct 表示;每一个进程都有一个体系结构相关的线程信息描述符,即 thread_info;每一个进程都有一个内核态栈,用于处理异常、中断或者系统调用。Linux 内核为每个进程分配一个大小为 THREAD_SIZE的内存区(大小通常就是一个页面),把 thread_info 和内核栈放在一起,即 thread_union。

thread_union 地址从低处开始往上是 thread_info,从高处开始往下是内核栈,task_struct 中的stack 指针指向 thread_union。这里的 init_task 就是 Linux 中 0 号进程的 task_struct,0 号进程一开始就是内核自身,在完成启动初始化以后,变身为 Idle 进程(空闲进程)。

第二入口:start_kernel()

init/main.c

1
2
3
4
asmlinkage __visible void __init __no_sanitize_address start_kernel(void)
{
......
}

虽然这棵树很庞大,但我们大致可以将整个 start_kernel()的过程分为三个大的阶段:关中断单线程阶段(从 start_kernel()头部开始直到 local_irq_enable()结束);开中断单线程阶段(从local_irq_enable()开始直到 rest_init()前夕);开中断多线程阶段(rest_init()的整个过程)。

第一阶段:关中断单线程阶段

启动初期的初始化过程必须关中断进行(中断处理的基础设施尚未准备好),所以start_kernel()开始执行不久之后就通过 local_irq_disable()来关闭中断。

boot_cpu_init(),这个函数是设置启动 CPU(通常是 0 号 CPU)的存在性状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void __init boot_cpu_init(void)
{
int cpu = smp_processor_id();

/* Mark the boot cpu "present", "online" etc for SMP and UP case */
set_cpu_online(cpu, true);
set_cpu_active(cpu, true);
set_cpu_present(cpu, true);
set_cpu_possible(cpu, true);

#ifdef CONFIG_SMP
__boot_cpu_id = cpu;
#endif
}

一个逻辑 CPU 有四种存在性状态:possible,表示物理上有可能存在;present,表示物理上确实存在;online,表示已经在线;active,表示已经在线并且处于活动状态。possible 和 present 的区别跟 CPU 物理热插拔有关,如果物理上移除一个 CPU,present 数目就会减少一个。present 和online 的区别是CPU 逻辑热插拔,在不改变硬件的情况下,可以对 /sys/devices/system/cpu/cpuN/online 写 0来关闭一个 CPU,写 1 则重新打开。online 和 active 非常相似,前者表示这个 CPU 可以调度任务了,后者表示可以往这个 CPU 迁移任务了。两者的区别在于,在通过逻辑热插拔关闭一个 CPU 的过程中,被关闭的 CPU 首先必须退出 active 状态,然后才能退出 online 状态。

整个 boot_cpu_init()的功能,就是将启动核(在龙芯上面就是 0 号核)的状态设置成 possible 的,present 的,online 的并且是 active 的。

然后是一个重要函数 setup_arch(),这是根据体系结构进行相关的初始化,LOONGARCH的setup_arch()定义在 arch/loongarch/kernel/setup.c 中。

接下来的 trap_init()异常初始化,这个函数都是体系结构相关的并且非常重要。

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
void __init setup_arch(char **cmdline_p)
{
cpu_probe();
early_init();

#ifdef CONFIG_EARLY_PRINTK
setup_early_printk();
#endif
bootcmdline_init(cmdline_p);

init_initrd();
platform_init();
finalize_initrd();
cpu_report();

arch_mem_init(cmdline_p);

resource_init();
#ifdef CONFIG_SMP
plat_smp_setup();
#endif
prefill_possible_map();

cpu_cache_init();
paging_init();
boot_cpu_trap_init();
}

setup_command_line(),建立内核命令行参数。内核命令行参数可以写在启动配置文件(boot.cfg 或 grub.cfg)中,由 BIOS 或者启动器(BootLoader,如 Grub)传递给内核;或缺省参数。

setup_nr_cpu_ids(),它获取 cpu_possible_mask 中的最大 CPU 编号(所有 possible 状态的逻辑 CPU 的最大编号),并将其赋值给全局变量 nr_cpu_ids。

setup_per_cpu_areas(),建立每 CPU 变量区,每 CPU 变量用 DEFINE_PER_CPU(type, name)语句定义,在功能上等价于用 type name[NR_CPUS]定义一个数组。

smp_prepare_boot_cpu()是一个体系结构相关的函数,在 LOONGARCH 上主要是把 0 号逻辑 CPU设成 possible 的和 online 的,该函数在功能上和 boot_cpu_init()有所重复。

接下来的 trap_init()异常初始化,这个函数都是体系结构相关的并且非常重要。

随后的 mm_init()是内存管理初始化。体系结构相关的内存管理部分已经在 setup_arch()里面完成(其中会将 BIOS 传递的固件内存分布图转换成 BootMem 内存分布图),这里主要是调用mem_init()建立内存分布图(将 BootMem 内存分布图转换为伙伴系统的内存分布图,对其中的每个可用的页帧调用set_page_count()将其引用计数设为0),调用kmem_cache_init()完成 SLAB 内存对象管理器的初始化,以及调用 vmalloc_init()完成非连续内存区管理器的初始化。

sched_init(),调度器初始化,完成以后主核就可以进行任务调度了。sched_init()会通过for_each_possible_cpu()迭代器在初始化每个 CPU 的运行队列(运行队列 rq 用于进程组织和调度),其中包括将 CPU 负载水平(即 rq->cpu_load[]数组,记录了最近 5 个时钟节拍内的CPU 平均负载水平)初值设为 0。接下来,sched_init()里面还有几个比较重要的步骤就是对init_task 的操作:init_task 的大部分成员字段已经通过 INIT_TASK()在定义的时候就初始化好了,这里只需调用 set_load_weight()设置 init_task 的负荷权重(负荷权重跟基于优先级的进程调度有关,详见第 6 章),将其调度类设为 fair_sched_class(公平调度类,使用 CFS 调度策略对其进行调度),再调用 init_idle()将内核自己进程化(准备工作完成后,调度类会被重新设置为 idle_sched_class,使用专门的 IDLE 调度策略)。从现在开始内核也是一个“进程”了,即零号进程。

1
2
3
4
5
6
/*
* Set up the scheduler prior starting any interrupts (such as the
* timer interrupt). Full topology setup happens at smp_init()
* time - but meanwhile we still have a functioning scheduler.
*/
sched_init();

rcu_init(),RCU 是一种内核同步原语,全称 Read-Copy-Update(读-复制-更新),和自旋锁、信号量、读写锁等同步原语有类似的 API,但 RCU 本身并不是锁。

early_irq_init(),初始化中断描述符。中断描述符就是 irq_desc[NR_IRQS]数组,包含了每个中断号(IRQ)的芯片数据 irq_data 和中断处理程序 irqaction 等各种信息。本函数只是设置缺省信息,比如芯片数据都设成 no_irq_chip ,中断处理程序都设成handle_bad_irq()。真正有意义的信息由后面体系结构相关的 init_IRQ()函数完成。

init_timers(),基本定时器初始化;hrtimers_init(),高分辨率定时器初始化。

softirq_init(),软中断初始化。软中断和硬中断的概念来自于早期内核中的“上半部”和“下半部”。上半部是中断处理里面非常紧急、必须立即完成的那部分工作;下半部是不那么紧急,可以延迟完成的那部分工作。软中断在概念上基本上就是继承自下半部。当前内核中定义了 11 种软中断(优先级从高到低):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
IRQ_POLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */

NR_SOFTIRQS
};

SCHED_SOFTIRQ 在之前的 sched_init()里面进行初始化,RCU_SOFTIRQ 在之前的rcu_init()里面进行初始化,TIMER_SOFTIRQ 和 HRTIMER_SOFTIRQ 在之前的 init_timers(和 hrtimers_init()中进行初始化,本函数主要完成 HI_SOFTIRQ 和 TASKLET_SOFTIRQ 的初始化,其他类型的软中断分布在各自的子系统里面完成。

timekeeping_init(),timekeeping 的意思是系统时间维护。该函数的作用主要是初始化各种时间相关的变量,如 jiffies,xtime 等等。Jiffies 记录了系统启动以来所经历的节拍数,而xtime 记录的时间可以精确到纳秒。随后的 time_init()是一个体系结构相关的函数,会进一步初始化计时系统。

PerfEvents 和 OProfile 是 Linux 内核中的两种性能剖析工具,perf_event_init()和profileinit()分别完成其初始化。

中断有关的初始化都已经完成,现在可以开中断了。开中断的函数是 local_irq_enable(),对于 LOONGARCH 来讲就是设置协处理器 0 中 Status 寄存器的 IE 位。

第二阶段:开中断单线程阶段

第二阶段中断已经打开,所以虽然现在内核还是以单线程的方式执行,但是一旦产生断就会切换控制流。因此,这一阶段除了按顺序执行代码流程以外,还可能以交错方式执行中断处理的代码。

console_init(),控制台初始化。

numa_policy_init(),NUMA 内存分配策略初始化。

calibrate_delay(),用于计算 loops_per_jiffy 的值。loops_per_jiffy 的含义是每个时钟节拍对应的空循环数,这个值用于以后实现各种 delay()类的忙等函数。

fork_init(),Linux 用 fork()系统调用来创建新进程。本函数的作用是初始化 fork()所用到的一些数据结构,如创建名为”task_struct”的 SLAB 内存对象缓存,将最大线程数设置为MAXTHREADS,等等。

signals_init(),跟信号相关的数据结构初始化。信号之于进程,好比中断之于内核,用于打断当前的执行流程,去完成一些更重要的工作。

cgroup_init(),CGroup 全称 Contol Group,即控制组,是内核一种控制资源分配的机制。本函数完成控制组相关数据结构的初始化,并且创建相应的 sysfs 和 procfs 节点。

现在,所有调度有关的子系统已经全部初始化完成,接下来可以创建新的内核线程,以并发的方式继续内核启动了。因为显卡尚未初始化,所以第二阶段显示器上依然没有输出信息。

第三阶段:开中断多线程阶段

rest_init(),顾名思义,第三阶段就是余下的初始化工作任务。函数 rest_init()的主要工作是通过 kernel_thread()创建了 1 号进程 kernel_init 和 2 号进程 kthreadd(实际上是两个内核线程)。1 号进程的执行体函数是 kernel_init(),它完成接下来的大部分初始化工作。2 号进程则是除 0、1、2 号进程以外其他所有内核线程的祖先(如果 1号进程在运行过程中需要创建新的内核线程,会委托 2 号进程来创建)。

1 号进程和 2 号进程创建以后,内核自己的初始化工作就基本完成了。但是别忘了,内核自己是 0 号进程,因此它也有必须持续进行的“工作”。内核初始化的最后一步是执行 cpu_startup_entry(),而后者的主要工作是调用cpu_idle_loop()。从名字可以看出,0 号进程现在成了空闲进程(即 IDLE 进程),它的工作就是“休息”(如果别的进程有事要做,就调度别的进程,反之意味着系统空闲,回到零号进程)。顺着调用链追踪下去,可以发现 0 号进程的核心过程是循环执行 arch_cpu_idle(),而具体到 LOONGARCH 处理器,则是 cpu_wait()。cpu_wait()可以有多种实现,一般就是执行 WAIT指令进入节能状态。

1 号进程与 2 号进程会派生很多新的内核线程来完成各种内核功能。在 SMP 系统上,1号进程会打开所有辅核,让后面的内核启动真正并行起来。包括显卡驱动在内的各种设备驱动都在 1 号进程里面完成,因此第三阶段除了起始点以外的的大部分时间是有显示信息输出的。

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
44
45
46
47
48
noinline void __ref rest_init(void)
{
struct task_struct *tsk;
int pid;

rcu_scheduler_starting();
/*
* We need to spawn init first so that it obtains pid 1, however
* the init task will end up wanting to create kthreads, which, if
* we schedule it before we create kthreadd, will OOPS.
*/
pid = kernel_thread(kernel_init, NULL, CLONE_FS);
/*
* Pin init on the boot CPU. Task migration is not properly working
* until sched_init_smp() has been run. It will set the allowed
* CPUs for init to the non isolated CPUs.
*/
rcu_read_lock();
tsk = find_task_by_pid_ns(pid, &init_pid_ns);
tsk->flags |= PF_NO_SETAFFINITY;
set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id()));
rcu_read_unlock();

numa_default_policy();
pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
rcu_read_lock();
kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
rcu_read_unlock();

/*
* Enable might_sleep() and smp_processor_id() checks.
* They cannot be enabled earlier because with CONFIG_PREEMPTION=y
* kernel_thread() would trigger might_sleep() splats. With
* CONFIG_PREEMPT_VOLUNTARY=y the init task might have scheduled
* already, but it's stuck on the kthreadd_done completion.
*/
system_state = SYSTEM_SCHEDULING;

complete(&kthreadd_done);

/*
* The boot idle thread must execute schedule()
* at least once to get things moving:
*/
schedule_preempt_disabled();
/* Call into cpu_idle with preempt disabled */
cpu_startup_entry(CPUHP_ONLINE);
}