【内核】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;
下面这个图清晰的表现了节头表如何映射到各个节地址:
程序头表的结构和寻址也如出一辙。程序头表中的每一个 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:解析内核文件
执行kexec_load()
传入的参数为:
info.entry
:修改于arm64_load_other_segments()
,指向purgatory
的起始地址。info.nr_segments
:program 段的数量info.segment
:指向 program 段的起始地址info.kexec_flags
:标志位图
STEP-2:加载内核段数据
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 的执行,切换到新的内核地址上去。下面是该命令的逻辑:
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 |
具体的调用关系如图:
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);
}