1 内核初始化

内核初始化分为汇编语言部分和C语言部分。

1.1 汇编语言部分

ARM64架构的内核的入口是标号_head,直接跳转到标号stext。

arch/arm64/kernel/head.S 1 _head: 2 #ifdef CONFIG_EFI 3 add x13, x18, #0x16 4 b stext 5 #else 6 b stext // 跳转到内核起始位置 7 .long0 // 保留 8 #endif

配置宏CONFIG_EFI表示提供UEFI运行时支持,UEFI(Unified Extensible Firmware Interface)是统一的可扩展固件接口,用于取代BIOS。

标号stext开始的代码如下:

arch/arm64/kernel/head.S 1 ENTRY(stext) 2 bl preserve_boot_args 3 bl el2_setup // 降级到异常级别1, 寄存器w0存放cpu_boot_mode 4 adrp x23, __PHYS_OFFSET 5 and x23, x23, MIN_KIMG_ALIGN - 1 // KASLR偏移,默认值是0 6 bl set_cpu_boot_mode_flag 7 bl __create_page_tables 8 /* 9 * 下面调用设置处理器的代码,请看文件“arch/arm64/mm/proc.S” 10 * 了解细节。 11 * 返回的时候,处理器已经为开启内存管理单元做好准备, 12 * 转换控制寄存器已经设置好。 13 */ 14 bl __cpu_setup // 初始化处理器 15 b __primary_switch 16 ENDPROC(stext)

第2行代码,调用函数preserve_boot_args,把引导程序传递的4个参数保存在全局数组boot_args中。

第3行代码,调用函数el2_setup:如果处理器当前的异常级别是2,判断是否需要降级到异常级别1。

第6行代码,调用函数set_cpu_boot_mode_flag,根据处理器进入内核时的异常级别设置数组__boot_cpu_mode[2]。__boot_cpu_mode[0]的初始值是BOOT_CPU_MODE_EL2,__boot_cpu_mode[1]的初始值是BOOT_CPU_MODE_EL1。如果异常级别是1,那么把__boot_cpu_mode[0]设置为BOOT_CPU_MODE_EL1;如果异常级别是2,那么把__boot_cpu_mode[1]设置为BOOT_CPU_MODE_EL2。

第7行代码,调用函数__create_page_tables,创建页表映射。

第14行代码,调用函数__cpu_setup,为开启处理器的内存管理单元做准备,初始化处理器。

第15行代码,调用函数__primary_switch,为主处理器开启内存管理单元,搭建C语言执行环境,进入C语言部分的入口函数start_kernel。

1.函数el2_setup

图1.4 普通的异常级别切换

linux内核深度解析怎么样(内核如何初始化)(1)

图1.5 支持虚拟化的异常级别切换

常用的开源虚拟机管理软件是QEMU,QEMU支持KVM虚拟机。使用QEMU创建一个KVM虚拟机,和KVM的交互过程如下。

(1)打开KVM字符设备文件。

fd = open("/dev/kvm", O_RDWR);

(2)创建一个虚拟机,QEMU进程得到一个关联到虚拟机的文件描述符。

vmfd = ioctl(fd, KVM_CREATE_VM, 0);

(3)QEMU为虚拟机模拟多个处理器,每个虚拟处理器就是一个线程,调用KVM提供的命令KVM_CREATE_VCPU,KVM为每个虚拟处理器创建一个kvm_vcpu结构体,QEMU进程得到一个关联到虚拟处理器的文件描述符。

vcpu_fd = ioctl(vmfd, KVM_CREATE_VCPU, 0);

从QEMU切换到客户操作系统的过程如下。

(1)QEMU进程调用“ioctl(vcpu_fd, KVM_RUN, 0)”,陷入到内核。

(2)KVM执行命令KVM_RUN,从异常级别1切换到异常级别2。

(3)KVM首先把调用进程的所有寄存器保存在kvm_vcpu结构体中,然后把所有寄存器设置为客户操作系统的寄存器值,最后从异常级别2返回到异常级别1,执行客户操作系统。

如图1.6所示,为了提高切换速度,ARM64架构引入了虚拟化宿主扩展,在异常级别2执行宿主操作系统的内核,从QEMU切换到客户操作系统的时候,KVM不再需要先从异常级别1切换到异常级别2。

linux内核深度解析怎么样(内核如何初始化)(2)

图1.6 支持虚拟化宿主扩展的异常级别切换

2.函数__create_page_tables

函数__create_page_tables的主要工作如下。

(1)创建恒等映射(identity mapping)。

(2)为内核镜像创建映射。

恒等映射的特点是虚拟地址和物理地址相同,是为了在开启处理器的内存管理单元的一瞬间能够平滑过渡。函数__enable_mmu负责开启内存管理单元,内核把函数__enable_mmu附近的代码放在恒等映射代码节(.idmap.text)里面,恒等映射代码节的起始地址存放在全局变量__idmap_text_start中,结束地址存放在全局变量__idmap_text_end中。

恒等映射是为恒等映射代码节创建的映射,idmap_pg_dir是恒等映射的页全局目录(即第一级页表)的起始地址。

在内核的页表中为内核镜像创建映射,内核镜像的起始地址是_text,结束地址是_end,swapper_pg_dir是内核的页全局目录的起始地址。

3.函数__primary_switch

函数__primary_switch的主要执行流程如下。

(1)调用函数__enable_mmu以开启内存管理单元。

(2)调用函数__primary_switched。

函数__enable_mmu的主要执行流程如下。

(1)把转换表基准寄存器0(TTBR0_EL1)设置为恒等映射的页全局目录的起始物理地址。

(2)把转换表基准寄存器1(TTBR1_EL1)设置为内核的页全局目录的起始物理地址。

(3)设置系统控制寄存器(SCTLR_EL1),开启内存管理单元,以后执行程序时内存管理单元将会把虚拟地址转换成物理地址。

函数__primary_switched的执行流程如下。

(1)把当前异常级别的栈指针寄存器设置为0号线程内核栈的顶部(init_thread_union THREAD_SIZE)。

(2)把异常级别0的栈指针寄存器(SP_EL0)设置为0号线程的结构体thread_info的地址(init_task.thread_info)。

(3)把向量基准地址寄存器(VBAR_EL1)设置为异常向量表的起始地址(vectors)。

(4)计算内核镜像的起始虚拟地址(kimage_vaddr)和物理地址的差值,保存在全局变量kimage_voffset中。

(5)用0初始化内核的未初始化数据段。

(6)调用C语言函数start_kernel。

1.2 C语言部分

内核初始化的C语言部分入口是函数start_kernel,函数start_kernel首先初始化基础设施,即初始化内核的各个子系统,然后调用函数rest_init。函数rest_init的执行流程如下。

(1)创建1号线程,即init线程,线程函数是kernel_init。

(2)创建2号线程,即kthreadd线程,负责创建内核线程。

(3)0号线程最终变成空闲线程。

init线程继续初始化,执行的主要操作如下。

(1)SMP_prepare_cpus():在启动从处理器以前执行准备工作。

(2)do_pre_smp_initcalls():执行必须在初始化SMP系统以前执行的早期初始化,即使用宏early_initcall注册的初始化函数。

(3)smp_init():初始化SMP系统,启动所有从处理器。

(4)do_initcalls():执行级别0~7的初始化。

(5)打开控制台的字符设备文件“/dev/console”,文件描述符0、1和2分别是标准输入、标准输出和标准错误,都是控制台的字符设备文件。

(6)prepare_namespace():挂载根文件系统,后面装载init程序时需要从存储设备上的文件系统中读文件。

(7)free_initmem():释放初始化代码和数据占用的内存。

(8)装载init程序(U-Boot程序可以传递内核参数“init=”以指定init程序),从内核线程转换成用户空间的init进程。

级别0~7的初始化,是指使用以下宏注册的初始化函数:

include/linux/init.h #define pure_initcall(fn) __define_initcall(fn, 0) #define core_initcall(fn) __define_initcall(fn, 1) #define core_initcall_sync(fn) __define_initcall(fn, 1s) #define postcore_initcall(fn) __define_initcall(fn, 2) #define postcore_initcall_sync(fn) __define_initcall(fn, 2s) #define arch_initcall(fn) __define_initcall(fn, 3) #define arch_initcall_sync(fn) __define_initcall(fn, 3s) #define subsys_initcall(fn) __define_initcall(fn, 4) #define subsys_initcall_sync(fn) __define_initcall(fn, 4s) #define fs_initcall(fn) __define_initcall(fn, 5) #define fs_initcall_sync(fn) __define_initcall(fn, 5s) #define rootfs_initcall(fn) __define_initcall(fn, rootfs) #define device_initcall(fn) __define_initcall(fn, 6) #define device_initcall_sync(fn) __define_initcall(fn, 6s) #define late_initcall(fn) __define_initcall(fn, 7) #define late_initcall_sync(fn) __define_initcall(fn, 7s)

1.3 SMP系统的引导

对称多处理器(Symmetric Multi-Processor,SMP)系统包含多个处理器,并且每个处理器的地位平等。在启动过程中,处理器的地位不是平等的,0号处理器称为引导处理器,负责执行引导程序和初始化内核;其他处理器称为从处理器,等待引导处理器完成初始化。引导处理器初始化内核以后,启动从处理器。

引导处理器启动从处理器的方法有3种。

(1)自旋表(spin-table)。

(2)电源状态协调接口(Power State Coordination Interface,PSCI)。

(3)ACPI停车协议(parking-protocol),ACPI是高级配置与电源接口(Advanced Configuration and Power Interface)。

引导处理器怎么获取从处理器的启动方法呢?读者可以参考函数cpu_read_enable_method,获取方法如下。

(1)不支持ACPI的情况:引导处理器从扁平设备树二进制文件中“cpu”节点的属性“enable-method”读取从处理器的启动方法,可选的方法是自旋表或者PSCI。

(2)支持ACPI的情况:如果固定ACPI描述表(Fixed ACPI Description Table,FADT)设置了允许PSCI的引导标志,那么使用PSCI,否则使用ACPI停车协议。

假设使用自旋表启动方法,编译U-Boot程序时需要开启配置宏CONFIG_ARMV8_SPIN_TABLE。如图1.7所示,SMP系统的引导过程如下。

linux内核深度解析怎么样(内核如何初始化)(3)

图1.7 ARM64架构下SMP系统的自旋表引导过程

(1)从处理器的第一个关卡是U-Boot程序中的函数spin_table_secondary_jump,从处理器睡眠等待,被唤醒后,检查全局变量spin_table_cpu_release_addr的值是不是0,如果是0,继续睡眠等待。引导处理器将会把全局变量spin_table_cpu_release_addr的值设置为一个函数的地址。

(2)U-Boot程序:引导处理器执行函数boot_prep_linux,为执行内核做准备工作,其中一项准备工作是调用函数spin_table_update_dt,修改扁平设备树二进制文件如下。

1)为每个处理器的“cpu”节点插入一个属性“cpu-release-addr”,把属性值设置为全局变量spin_table_cpu_release_addr的地址,称为处理器放行地址。

2)在内存保留区域(memory reserve map,对应扁平设备树源文件的字段“/memreserve/”)添加全局变量spin_table_cpu_release_addr的地址。

(3)引导处理器在内核函数smp_cpu_setup中,首先调用函数cpu_read_enable_method以获取从处理器的启动方法,然后调用函数smp_spin_table_cpu_init,从扁平设备树二进制文件中“cpu”节点的属性“cpu-release-addr”得到从处理器的放行地址。

(4)引导处理器执行内核函数smp_spin_table_cpu_prepare,针对每个从处理器,把放行地址设置为函数secondary_holding_pen,然后唤醒从处理器。

(5)从处理器被唤醒,执行函数secondary_holding_pen,这个函数设置了第二个关卡,当引导处理器把全局变量secondary_holding_pen_release设置为从处理器的编号时,才会放行。

(6)引导处理器完成内核的初始化,启动所有从处理器,针对每个从处理器,调用函数smp_spin_table_cpu_boot,把全局变量secondary_holding_pen_release设置为从处理器的编号。

(7)从处理器发现引导处理器把全局变量secondary_holding_pen_release设置为自己的编号,通过第二个关卡,执行函数secondary_startup。

(8)从处理器执行函数__secondary_switched:把向量基准地址寄存器(VBAR_EL1)设置为异常向量表的起始地址,设置栈指针寄存器,调用C语言部分的入口函数secondary_start_kernel。

(9)从处理器执行C语言部分的入口函数secondary_start_kernel。

下面是扁平设备树源文件的一个片段,可以看到每个处理器对应一个“cpu”节点,属性“enable-method”指定启动方法,属性“cpu-release-addr”指定放行地址。需要通过字段“/memreserve/”把放行地址设置为内存保留区域,两个参数分别是起始地址和长度。

/memreserve/ 0x80000000 0x00010000; /{ … cpus { #address-cells = <2>; #size-cells = <0>; cpu@0 { device_type = "cpu"; compatible = "arm,armv8"; reg = <0x0 0x0>; enable-method = "spin-table"; cpu-release-addr = <0x0 0x8000fff8>; next-level-cache = <&L2_0>; }; cpu@1 { device_type = "cpu"; compatible = "arm,armv8"; reg = <0x0 0x1>; enable-method = "spin-table"; cpu-release-addr = <0x0 0x8000fff8>; next-level-cache = <&L2_0>; }; L2_0: l2-cache0 { compatible = "cache"; }; }; … };

linux内核深度解析怎么样(内核如何初始化)(4)

Linux内核深度解析

京东网上商城 点击京东购书满100减20元

编辑推荐:

本书基于4.x版本的Linux内核,介绍了Linux内核的若干关键子系统的技术原理。本书主要内容包括内核的引导过程、内核管理和调度进程的技术原理、内核管理虚拟内存和物理内存的技术原理、内核处理异常和中断的技术原理,以及系统调用的实现方式等。

,