【内核】kernel 热升级-1:kexec 机制

内核热升级是指,预先准备好需要升级的内核镜像文件,在秒级时间内,完成内核切换,追求用户服务进程无感知。

欧拉操作系统提供了一套比较成熟的解决方案,该解决方案提供了用户态程序内核态程序两部分:

kexec -e 执行代码追踪

用户态通过reboot系统调用,传入LINUX_REBOOT_CMD_KEXEC参数,触发热升级流程,其核心还在于内核态的处理。

// 用户态  file: kexec.c
my_exec()
    |-> reboot(LINUX_REBOOT_CMD_KEXEC);
// 内核态 file: kernel/reboot.c
SYSCALL_DEFINE(reboot, ...)
    case LINUX_REBOOT_CMD_KEXEC:
		|-> kernel_kexec();		// file: kernel/kexec_core.c   

ELF 文件的内部结构

在分析kexec -l前,有必要来研究一下 ELF 形式的文件内部结构。

ELF 文件主要分为两大部分,ELF 头和程序节段。其中程序节段分别被节头表程序头表所指向。

详见此博客:【内核】ELF 文件执行流程

实际的 ELF 文件,除 ELF 头外,其他部分常有不同。其他部分(主要是两个头表)的声明和定义,是在 ELF 头中确定的。ELF 头的代码结构如下:

#define EI_NIDENT 16
typedef struct {
    unsigned char e_ident[EI_NIDENT];  	// 16 字节 ELF 文件声明,由固定信息组成,用来表示是 ELF 文件
    Elf32_Half e_type;  		// 标识 elf 文件类型: 0. 未知, 1. 可重定位文件, 2. 可执行文件, 3. 共享目标文件, 4. core 文件
    Elf32_Half e_machine;  		// 程序运行的硬件体系结构,80386 体系为 3
    Elf32_Word e_version;  		// 文件版本号
    Elf32_Addr e_entry;  		// 程序入口地址
    Elf32_Off e_phoff;  		// Program header table 在文件中的偏移量(字节数
    Elf32_Off e_shoff;  		// Section header table 在文件中的偏移量(字节数
    Elf32_Word e_flags;  		// 文件标识符,IA32 汇编为 0
    Elf32_Half e_ehsize;  		// ELF header 的字节数
    Elf32_Half e_phentsize;  		// Program header table 中每个条目的字节数
    Elf32_Half e_phnum;  		// Program header table 中条目数
    Elf32_Half e_shentsize;  		// Section header table 中每个条目的字节数
    Elf32_Half e_shnum;  		// Section header table 中条目数
    Elf32_Half e_shstrndx;  		// 包含节名称的字符串表是第几个节
} Elf32_Ehdr;

由上可知,从 ELF 头可以定位到 程序头表节头表 的位置中。

节头表 中的每个条目 Section Header 都描述了 ELF 文件中 Sections 区域中一个节的信息,结构如下:

typedef struct {  
    Elf32_Word sh_name;		// 节区名,是节区头部字符串表节区(Section Header String Table Section)的索引,名字是一个 NULL 结尾的字符串  
    Elf32_Word sh_type;  	// 该节类型
    Elf32_Word sh_flags;  	// 节区标志
    Elf32_Addr sh_addr;  	// 如果节区将出现在进程的内存映像中,此成员给出节区的第一个字节应处的位置,否则,此字段为 0
    Elf32_Off sh_offset;  	// 该节区首个字节的偏移
    Elf32_Word sh_size;  	// 该节长度
    Elf32_Word sh_link;  	// 该节头部表索引,具体内容依赖于节类型
    Elf32_Word sh_info;  	// 节头部表附加信息,具体内容依赖于节类型
    Elf32_Word sh_addralign;  	// 地址对齐约束
    Elf32_Word sh_entsize;  	// 该节固定表项长度
} Elf32_Shdr;

下面这个图清晰的表现了节头表如何映射到各个节地址:

image

程序头表的结构和寻址也如出一辙。程序头表中的每一个 Program Header 是与程序执行直接相关的,他描述了一个即将被载入内存的段在文件中的位置、大小以及它被载入内存后所在的位置和大小。结构如下所示:

typedef struct {
    Elf32_Word p_type;  	// 当前 Program header 所描述的段的类型
    Elf32_Off  p_offset;  	// 该段首地址在文件中的偏移量(字节数)
    Elf32_Addr p_vaddr;  	// 该段被载入内存后,首个字节的虚拟地址
    Elf32_Addr p_paddr;  	// 该段被载入内存后,首个字节的物理地址(对于使用虚拟地址的系统来说,该项为 0)
    Elf32_Word p_filesz;  	// 段长度(字节数)
    Elf32_Word p_memsz;  	// 段在内存中的长度
    Elf32_Word p_flags;  	// 段标志位
    Elf32_Word p_align;  	// 段在文件内和内存中的对齐方式
} Elf32_Phdr;

程序头表描述了可执行文件中有哪几个段,每个段需要被载入到内存的哪个位置。于是,通过 ELF header 中的字段,找到 Program Header Table,然后读取每个 Program Header,将对应的段载入到内存指定的位置,然后跳转,即可实现 ELF 可执行文件的执行了。

kexec 原理分析:kexec 加载

kexec -l 命令会触发内核加载动作,最终使要快速切换的新内核,加载到内存中。

kexec 处理内核加载机制分为两个阶段:解析内核文件(用户态)和加载内核段数据(内核态),下文分别描述这两个过程。

STEP-1:解析内核文件
sequenceDiagram participant kexec.c participant kexec_arm64.c participant kexec_syscall.h Note left of kexec.c : kexec -l kexec.c ->> kexec.c : main():解析参数 kexec.c ->> kexec.c : my_load():核心函数,以下过程皆在此函数中 kexec.c ->> kexec.c : slurp_decompress_file():解压内核文件 kexec.c ->> kexec_arm64.c : file_type[i].probe():调用对应内核镜像的 probe() 函数,执行校验 kexec.c ->> kexec_arm64.c : file_type[i].load():调用对应内核镜像的 load() 函数 kexec_arm64.c->> kexec_arm64.c : elf_arm64_load():此函数为 elf 格式的 load 函数 kexec_arm64.c->> kexec_arm64.c : build_elf_exec_info():解析 elf 文件头 kexec_arm64.c->> kexec_arm64.c : arm64_process_image_header():解析 elf 文件 program 头表 kexec_arm64.c->> kexec_arm64.c : elf_exec_load():加载解析 elf 文件的 program segment kexec_arm64.c->> kexec_arm64.c : arm64_load_other_segments():加载其他段,例如传递给新内核的参数、purgatory 炼狱空间 kexec_arm64.c->> kexec.c : result kexec.c ->> kexec.c : 系统调用前的若干校验 kexec.c ->> kexec_syscall.h : 系统调用 kexec_load()

执行kexec_load()传入的参数为:

  • info.entry:修改于arm64_load_other_segments(),指向purgatory的起始地址。
  • info.nr_segments:program 段的数量
  • info.segment:指向 program 段的起始地址
  • info.kexec_flags:标志位图
STEP-2:加载内核段数据
sequenceDiagram participant my_load() participant kexec participant kexec_core autonumber my_load() ->> kexec : kexec_load():系统调用 kexec ->> kexec : do_kexec_load():主要处理函数,以下过程皆在此函数中 kexec ->> kexec : kimage_alloc_init():初始化函数,提取用户传入的镜像数据 Note right of kexec : control_code_page 在这里被分配 kexec ->> kexec : machine_kexec_prepare() Loop foreach nr_segments: kexec ->> kexec_core: kimage_load_segment():将段数据分配到内存页中 alt type == DEFAULT kexec_core ->> kexec_core : kimage_load_normal_segment() else type == CRASH<br/>type == QUICK kexec_core ->> kexec_core : kimage_load_special_segment() end end kexec ->> kexec : kimage_terminate(image) kexec ->> kexec : 将 image 写入 dest_image kexec ->> my_load() : result=0

kexec_load()执行之后,image 的一个状态:

  • image->start:修改于kimage_alloc_init(),指向purgatory的起始地址。
  • image->nr_segments:修改于kimage_alloc_init(),即 program 段的数量。
  • image->segment:修改于kimage_alloc_init(),即 program 段起始地址。
  • image->control_code_page:刚刚初始化
  • image->entry:修改于kimage_load_normal_segment(),存一个地址,指向 segment 的实际地址,entry页实际上是程序头表
  • image->last_entry:修改于kimage_load_normal_segment()永远指向新 entry 页的末尾。

kexec 原理分析:kexec 执行

kexec -e 命令会触发 kexec 的执行,切换到新的内核地址上去。下面是该命令的逻辑:

sequenceDiagram participant kexec.c participant reboot.c participant kexec_core participant machine_kexec participant cpu_reset.S participant relocate_kernel.S Note left of kexec.c : kexec -e kexec.c ->> kexec.c : my_exec() kexec.c ->> reboot.c : reboot(LINUX_REBOOT_CMD_KEXEC) reboot.c ->> kexec_core : kernel_kexec() activate kexec_core kexec_core ->> kexec_core : kernel_restart_prepare()<br/>内核重启准备工作 kexec_core ->> kexec_core : migrate_to_reboot_cpu()<br/>将任务迁移到重启的特定 CPU 上 kexec_core ->> kexec_core : cpu_hotplug_enable()<br/>重新启用 CPU 热插拔功能 kexec_core ->> kexec_core : machine_shutdown() <br/>关闭机器,触发硬件重启 Note right of kexec_core : cpu_park 是在此处陷入 kexec_core ->> machine_kexec : machine_kexec(kexec_image)<br/>进行kexec模式重启 deactivate kexec_core machine_kexec ->> machine_kexec : 将 arm64_relocate_new_kernel 代码<br/>拷贝到 reboot_code_buffer,即<br/>control_page 起始处 Note right of machine_kexec : 这里更新 control_code_page machine_kexec ->> machine_kexec : 准备工作:flush machine_kexec ->> cpu_reset.S : cpu_soft_restart()<br/>传入control_code_page 地址 Note right of cpu_reset.S : 跳转到 control_code_page cpu_reset.S ->> relocate_kernel.S : arm64_relocate_new_kernel Note right of relocate_kernel.S : 准备进入 purgatory 空间 relocate_kernel.S ->> relocate_kernel.S : 跳转到 kimage->start 开始执行

kexec 在内核加载阶段,于内存中创建了一张 控制表 control_code_page,用于存放重定向新内核地址的控制代码。这段控制代码名为arm64_relocate_new_kernel,位于/arch/arm64/kernel/relocate_kernel.S汇编文件中。

sys_reboot 系统调用简要分析

为了研究 kexec -e 内核切换时调用的 reboot 流程与正常系统 reboot 的区别,需要对 sys_reboot 系统调用有一个代码上的认识。

sys_reboot 系统调用实现于kernel/reboot.c文件中,函数签名如下:

SYSCALL_DEFINE4(reboot, int, magic1, int, magic2, unsigned int, cmd, void __user *, arg) {
	...
}

第三个参数 cmd 为调用此 sys_call 时传入的参数,表示重启方式,内核中定义了以下若干种重启方式:

 /*      
   2:  * Commands accepted by the _reboot() system call.
   3:  *
   4:  * RESTART     Restart system using default command and mode.
   5:  * HALT        Stop OS and give system control to ROM monitor, if any.
   6:  * CAD_ON      Ctrl-Alt-Del sequence causes RESTART command.
   7:  * CAD_OFF     Ctrl-Alt-Del sequence sends SIGINT to init task.
   8:  * POWER_OFF   Stop OS and remove all power from system, if possible.
   9:  * RESTART2    Restart system using given command string.
  10:  * SW_SUSPEND  Suspend system using software suspend if compiled in.
  11:  * KEXEC       Restart system using a previously loaded Linux kernel
  12:  */
  13:         
  14: #define LINUX_REBOOT_CMD_RESTART        0x01234567
  15: #define LINUX_REBOOT_CMD_HALT           0xCDEF0123
  16: #define LINUX_REBOOT_CMD_CAD_ON         0x89ABCDEF
  17: #define LINUX_REBOOT_CMD_CAD_OFF        0x00000000
  18: #define LINUX_REBOOT_CMD_POWER_OFF      0x4321FEDC
  19: #define LINUX_REBOOT_CMD_RESTART2       0xA1B2C3D4
  20: #define LINUX_REBOOT_CMD_SW_SUSPEND     0xD000FCE2
  21: #define LINUX_REBOOT_CMD_KEXEC          0x4558454

解释如下:

方式 魔数 说明
RESTART 0x01234567 正常的重启,也是我们平时使用的重启。执行该动作后,系统会重新启动。
HALT 0xCDEF0123 停止操作系统,然后把控制权交给其它代码(如果有的话)。具体的表现形式,依赖于系统的具体实现。
CAD_ON 0x89ABCDEF 开启:通过Ctrl-Alt-Del组合按键触发重启(RESTART)动作
CAD_OFF 0x00000000 禁止:通过Ctrl-Alt-Del组合按键触发重启(RESTART)动作
POWER_OFF 0x4321FEDC 正常的关机。执行该动作后,系统会停止操作系统,并去除所有的供电。
RESTART2 0xA1B2C3D4 重启的另一种方式。可以在重启时,携带一个字符串类型的cmd,该cmd会在重启前,发送给任意一个关心重启事件的进程,同时会传递给最终执行重启动作的machine相关的代码。内核并没有规定该cmd的形式,完全由具体的machine自行定义。
SW_SUSPEND 0xD000FCE2 Hibernate操作
KEXEC 0x4558454 Kexec操作,重启并执行已经加载好的其它Kernel Image

具体的调用关系如图:

image

kernel_restart、kernel_halt 和 kernel_power_off 分别代表内核重启、内核停机和内核下电,这三个函数的实现过程大致相同,分别是:

  • kernel_xxxx_prepare():执行前的准备工作
    • blocking_notifier_call_chain():向关心reboot事件的进程,发送SYS_RESTART、SYS_HALT或者SYS_POWER_OFF事件。对RESTART来说,还要将cmd参数一并发送出去。
    • 将系统状态设置为相应的状态(SYS_RESTART、SYS_HALT或SYS_POWER_OFF)。
    • usermodehelper_disable():禁止User mode helper。
    • device_shutdown():关闭所有的设备。
  • migrate_to_reboot_cpu():将当前的进程 迁移到 reboot cpu 上
    • 该函数执行后,只有 reboot CPU 在运行了
  • syscore_shutdown():将系统核心回调函数列表一一唤起
  • pr_emerg():打印对应日志
  • kmsg_dump():同上,留下临别遗言
  • machine_restart()/machine_halt()/machine_power_off():执行重启/停机/下电(此过程基于不同的硬件架构,默认以 ARM 架构为例)
    • 禁用中断
    • 停CPU
    • 各自处理逻辑

machine_restart()/machine_halt()/machine_power_off()代码很简单,罗列此处,对比观摩。

// Restart 函数要求:在 主CPU 重置系统时,从CPU 需要停下当前的任何工作;并且还需要提供一种机制,可以将所有的 从CPU 同时拉起
// 这样就保证了 CPU 任务的一致性,避免出现新环境已经起来了还有 CPU 运行古早任务的情况
void machine_restart(char *cmd)
{
	local_irq_disable();		// 禁用中断
	smp_send_stop();			// 停 从CPU

	if (efi_enabled(EFI_RUNTIME_SERVICES)) 
        efi_reboot(reboot_mode, NULL);

	// 不同架构下的 restart 过程,执行到这里一般就结束了,不会继续往下走了
	if (arm_pm_restart)
		arm_pm_restart(reboot_mode, cmd);
	else
		do_kernel_restart(cmd);

	// 若执行到这里,说明出了大问题,Reboot 失败
	printk("Reboot failed -- System halted\n");
	while (1);
}
// Halt 停机只要求 从CPU 停机即可
// 停机的过程十分简单粗暴:先禁用中断,再停下当前任务,再 while(1),如此三板斧,大罗神仙也难救回来
void machine_halt(void)
{
	local_irq_disable();
	smp_send_stop();
	while (1);
}
// 下电函数仅在 Halt 函数的基础上加了一条逻辑:下电时把停机的 CPU 带走
void machine_power_off(void)
{
	local_irq_disable();
	smp_send_stop();
	if (pm_power_off)
		pm_power_off();
}

对比上面调用关系的图,kernel_kexec()与前三者不同的是,kernel_kexec()在调用machine_shundown()之前,并没有关闭系统核心(syscore)。这是因为,在后续切换新内核的过程中,需要就内核的系统核心保持运行,以提供必要的支持和服务(内存管理)。

machine_shundown()与上面三个 machine 函数区别较大,其在内核 kexec 最初的实现中,有这样一段耐人寻味的描述:

/*
 * Called by kexec, immediately prior to machine_kexec().
 *
 * This must completely disable all secondary CPUs; simply causing those CPUs
 * to execute e.g. a RAM-based pin loop is not sufficient. This allows the
 * kexec'd kernel to use any and all RAM as it sees fit, without having to
 * avoid any code or data used by any SW CPU pin loop. The CPU hotplug
 * functionality embodied in smpt_shutdown_nonboot_cpus() to achieve this.
 */
void machine_shutdown(void)
{
	smp_shutdown_nonboot_cpus(reboot_cpu);
}
posted @ 2023-12-18 17:53  _hong  阅读(884)  评论(0编辑  收藏  举报