基于ARM64架构的Linux4.x内核一书,作者余华兵。系列文章主要用于记录Linux内核的大部分机制及参数的总结说明当我们在shell进程里面执行命令"/sbin/hello.elf &"
以启动程序“hello”时,shell进程首先创建子进程,然后子进程装载程序“hello.elf”,其代码如下:
ret = fork();
if (ret > 0) {
/* 父进程继续执行 */
} else if (ret == 0) {
/* 子进程装载程序 */
ret = execve(filename, argv, envp);
} else {
/* 创建子进程失败 */
}
在Linux内核中,新进程是从一个已经存在的进程复制出来的。内核使用静态数据构造出0号内核线程,0号内核线程分叉生成1号内核线程和2号内核线程(kthreadd线程)。1号内核线程完成初始化以后装载用户程序,变成1号进程,其他进程都是1号进程或者它的子孙进程分叉生成的;其他内核线程是kthreadd线程分叉生成的。
3个系统调用可以用来创建新的进程:
clone是功能最齐全的函数,参数多,使用复杂,fork是clone的简化函数。
系统调用fork内核定义如下:
SYSCALL_DEFINE0(fork)
展开后是:
asmlinkage long sys_fork(void)
“SYSCALL_DEFINE”后面的数字表示系统调用的参数个数,“SYSCALL_DEFINE0”表示系统调用没有参数,“SYSCALL_DEFINE6”表示系统调用有6个参数,如果参数超过6个,使用宏“SYSCALL_DEFINEx”。
“asmlinkage”表示这个C语言函数可以被汇编代码调用。如果使用C++编译器,“asmlinkage”被定义为extern “C”;如果使用C编译器,“asmlinkage”是空的宏。
系统调用的函数名称以“sys_”开头。
创建新进程的进程p和生成的新进程的关系有3种情况。
创建新进程的3个系统调用在文件“kernel/fork.c”中,它们把工作委托给函数_do_fork
。
函数do_fork
原型如下:
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls);
参数如下:
copy_process
的执行流程如下:copy_process
实现检查标志:以下标志组合是非法的
函数dup_task_struct:函数dup_task_struct为新进程的进程描述符分配内存,把当前进程的进程描述符复制一份,为新进程分配内核栈。
进程描述的成员stack指向内核栈:
内核栈定义如下:
#include
union thread_union {
#ifndef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
内核栈有两种布局:
两种布局的区别是结构体thread_info的位置不同。如果选择第二种布局,需要打开配置宏CONFIG_THREAD_INFO_IN_TASK。ARM64架构使用第二种内核栈布局。第二种内核栈布局的好处是:thread_info结构体作为进程描述符的第一个成员,它的地址和进程描述符的地址相同。当进程在内核模式运行时,ARM64架构的内核使用用户栈指针寄存器SP_EL0存放当前进程的thread_info结构体的地址,通过这个寄存器既可以得到thread_info结构体的地址,也可以得到进程描述符的地址。
内核栈的长度是THREAD_SIZE,它由各种处理器架构自己定义,ARM64架构定义的内核栈长度是16KB。
结构体thread_info存放汇编代码需要直接访问的底层数据,由各种处理器架构定义,ARM64架构定义的结构体如下:
<arch/arm64/include/asm/thread_info.h>
struct thread_info {
unsigned long flags; /*底层标志位*/
mm_segment_t addr_limit; /*地址限制 */
#ifdef CONFIG_ARM64_SW_TTBR0_PAN
u64 ttbr0; /* 保存的寄存器 TTBR0_EL1 */
#endif
int preempt_count; /* 0表示可抢占,小于0是缺陷 */
};
检查用户的进程数量限制:如果拥有当前进程的用户创建的进程数量达到或者超过限制,并且用户不是根用户,也没有忽略资源限制的权限(CAP_SYS_RESOURCE)和系统管理权限(CAP_SYS_ADMIN),那么不允许创建新进程。
函数copy_creds:函数copy_creds负责复制或共享证书,证书存放进程的用户标识符、组标识符和访问权限。
如果设置了标志CLONE_THREAD,即新进程和当前进程属于同一个线程组,那么新进程和当前进程共享证书。
检查线程数量限制:如果线程数量达到允许的线程最大数量,那么不允许创建新进程。
全局变量nr_threads 存放当前的线程数量;max_threads存放允许创建的线程最大数量,默认值是MAX_THREADS。
函数sched_fork:函数sched_fork为新进程设置调度器相关的参数。
复制或者共享资源如下
设置进程号和进程关系。函数copy_process的最后部分为新进程设置进程号和进程关系。
arch/arm64/kernel/entry.S
1 tsk .req x28 //当前进程的thread_info结构体的地址
2
3 ENTRY(ret_from_fork)
4 bl schedule_tail
5 cbz x19, 1f /* 如果寄存器x19的值是0,说明当前进程是用户进程,那么跳转到标号1 */
6 mov x0, x20 /* 内核线程:x19存放线程函数的地址,x20存放线程函数的参数 */
7 blr x19 /* 调用线程函数 */
8 1: get_thread_info tsk /* 用户进程:x28 = sp_el0 = 当前进程的thread_info结构体的地址 */
9 b ret_to_user /* 返回用户模式 */
10 ENDPROC(ret_from_fork)
在介绍函数copy_thread时,说过:如果新进程是内核线程,寄存器x19存放线程函数的地址,寄存器x20存放线程函数的参数;如果新进程是用户进程,寄存器x19的值是0。当调度器调度新进程时,新进程从函数ret_from_fork开始执行,然后从系统调用fork返回用户空间,返回值是0。接着新进程使用系统调用execve装载程序。
Linux内核提供了两个装载程序的系统调用:
int execve(const char *filename, char *const argv[], char *const envp[]);
int execveat(int dirfd, const char *pathname, char *const argv[], char *const envp[], int flags);
两个系统调用的主要区别是:如果路径名是相对的,那么execve解释为相对调用进程的当前工作目录,而execveat解释为相对文件描述符dirfd指向的目录。如果路径名是绝对的,那么execveat忽略参数dirfd。
参数argv是传给新程序的参数指针数组,数组的每个元素存放一个参数字符串的地址,argv[0]应该指向要装载的程序的名称。
参数envp是传给新程序的环境指针数组,数组的每个元素存放一个环境字符串的地址,环境字符串的形式是“键=值”。
argv和envp都必须在数组的末尾包含一个空指针。
如果程序的main函数被定义为下面的形式,参数指针数组和环境指针数组可以被程序的main函数访问:
int main(int argc, char *argv[], char *envp[])
可是,POSIX.1标准没有规定main函数的第3个参数。根据POSIX.1标准,应该借助外部变量environ访问环境指针数组。
两个系统调用最终都调用函数do_execveat_common,其执行流程如下:
调用函数do_open_execat打开可执行文件。
调用函数sched_exec。装载程序是一次很好的实现处理器负载均衡的机会,因为此时进程在内存和缓存中的数据是最少的。选择负载最轻的处理器,然后唤醒当前处理器上的迁移线程,当前进程睡眠等待迁移线程把自己迁移到目标处理器。
调用函数bprm_mm_init创建新的内存描述符,分配临时的用户栈。
临时用户栈的长度是一页,虚拟地址范围是[STACK_TOP_MAX−页长度,STACK_TOP_MAX),bprm->p指向在栈底保留一个字长(指针长度)后的位置:
调用函数prepare_binprm设置进程证书,然后读文件的前面128字节到缓冲区。
调用函数exec_binprm。函数exec_binprm调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别正在装载的程序为止。
二进制格式
在Linux内核中,每种二进制格式都表示为下面的数据结构的一个实例:
include/linux/binfmts.h
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* 核心转储文件的最小长度 */
}
每种二进制格式必须提供下面3个函数:
每种二进制格式必须使用函数register_binfmt向内核注册。
下面介绍常用的二进制格式:ELF格式和脚本格式。
装载ELF程序
ELF文件:ELF(Executable and Linkable Format)是可执行与可链接格式,主要有以下4种类型:
ELF文件分成4个部分:ELF首部、程序首部表(program header table)、节(section)和节首部表(section header table)。实际上,一个文件不一定包含全部内容,而且它们的位置也不一定像下面中这样安排,只有ELF首部的位置是固定的,其余各部分的位置和大小由ELF首部的成员决定:
程序首部表就是我们所说的段表(segment table),段(segment)是从运行的角度描述,节(section)是从链接的角度描述,一个段包含一个或多个节。在不会混淆的情况下,我们通常把节称为段,例如代码段(text section),不称为代码节。
32位ELF文件和64位ELF文件的差别很小,这里只介绍64位ELF文件的格式:
ELF首部的成员 | 说明 |
---|---|
unsigned char e_ident[EI_NIDENT]; | 16字节的魔幻数 前4字节是ELF文件的标识符,第1字节是0x7F(即删除的ASCII编码),第2~4字节是ELF 第5字节表示ELF文件类别,1表示32位ELF文件,2表示64位ELF文件 第6字节表示字节序第7字节表示版本第8字节表示应用二进制接口(ABI)的类型 其他字节暂时不需要,用0填充 |
Elf64_Half e_type; | ELF文件类型,1表示可重定位文件(目标文件),2表示可执行文件,3表示动态库,4表示核心转储文件 |
Elf64_Half e_machine; | 机器类别,例如EM_ARM(40)表示ARM 32位,EM_AARCH64(183)表示ARM 64位 |
Elf64_Word e_version; | 版本,用来区分不同的ELF变体,目前的规范只定义了版本1 |
Elf64_Addr e_entry; | 程序入口的虚拟地址 |
Elf64_Off e_phoff; | 程序首部表的文件偏移 |
Elf64_Off e_shoff; | 节首部表的文件偏移 |
Elf64_Word e_flags; | 处理器特定的标志 |
Elf64_Half e_ehsize; | ELF首部的长度 |
Elf64_Half e_phentsize; | 程序首部表中表项的长度,单位是字节 |
Elf64_Half e_phnum; | 程序首部表中表项的数量 |
Elf64_Half e_shentsize; | 节首部表中表项的长度,单位是字节 |
Elf64_Half e_shnum; | 节首部表中表项的数量 |
Elf64_Half e_shstrndx; | 节名称字符串表在节首部表中的索引 |
程序首部表中每条表项的成员及说明:
程序首部表中每条表项的成员 | 程序首部表中每条表项的成员 |
---|---|
Elf64_Word p_type; | 段的类型,常见的段类型如下。 (1)可加载段(PT_LOAD,类型值为1)——表示一个需要从二进制文件映射到虚拟地址空间的段,例如程序的代码和数据 (2)解释器段(PT_INTERP,类型值为3)——指定把可执行文件映射到虚拟地址空间以后必须调用的解释器,解释器负责链接动态库和解析没有解析的符号。解释器通常是动态链接器,即ld共享库,负责把程序依赖的动态库映射到虚拟地址空间 |
Elf64_Word p_flags; | 段的标志,常用的3个权限标志是读、写和执行 |
Elf64_Off p_offset; | 段在文件中的偏移 |
Elf64_Addr p_vaddr; | 段的虚拟地址 |
Elf64_Addr p_paddr; | 段的物理地址 |
Elf64_Xword p_filesz; | 段在文件中的长度 |
Elf64_Xword p_memsz; | 段在内存中的长度 |
Elf64_Xword p_align; | 段的对齐值 |
节首部表中每条表项的成员及说明:
节首部表中每条表项的成员 | 说明 |
---|---|
Elf64_Word sh_name; | 节名称在节名称字符串表中的偏移 |
Elf64_Word sh_type; | 节的类型 |
Elf64_Xword sh_flags; | 节的属性 |
lf64_Addr sh_addr; | 节在执行时的虚拟地址 |
Elf64_Off sh_offset; | 节的文件偏移 |
Elf64_Xword sh_size; | 节的长度 |
Elf64_Word sh_link; | 引用另一个节首部表表项,指定该表项的索引 |
Elf64_Word sh_info; | 附加的节信息 |
lf64_Xword sh_addralign; | 节的对齐值 |
Elf64_Xword sh_entsize; | 如果节包含一个表项长度固定的表,例如符号表,那么这个成员存放表项的长度 |
重要的节及说明如表:
节名称 | 说明 |
---|---|
.text | 代码节(也称文本节),通常称为代码段,包含程序的机器指令 |
.data | 数据节,通常称为数据段,包含已经初始化的数据,程序在运行期间可以修改 |
.rodata | 只读数据 |
.bss | 没有初始化的数据,在程序开始运行前用零填充(bss的全称是“Block Started by Symbol”,表示以符号开始的块) |
.interp | 保存解释器的名称,通常是动态链接器,即ld共享库 |
.shstrtab | 节名称字符串表 |
.symtab | 符号表。符号包括函数和全局变量,符号名称存放在字符串表中,符号表存储符号名称在字符串表里面的偏移。可以执行命令“readelf --symbols |
.strtab | 字符串表,存放符号表需要的所有字符串 |
.init | 程序初始化时执行的机器指令 |
.fini | 程序结束时执行的机器指令 |
.dynamic | 存放动态链接信息,包含程序依赖的所有动态库,这是动态链接器需要的信息。可以执行命令“readelf --dynamic |
.dynsym | 存放动态符号表,包含需要动态链接的所有符号,即程序所引用的动态库里面的函数和全局变量,这是动态链接器需要的信息。可以执行命令“readelf --dyn-syms |
.dynstr | 这个节存放一个字符串表,包含动态链接需要的所有字符串,即动态库的名称、函数名称和全局变量的名称。“.dynamic”节不直接存储动态库的名称,而是存储库名称在该字符串表里面的偏移。动态符号表不直接存储符号名称,而是存储符号名称在该字符串表里面的偏移 |
可以使用程序“readelf”查看ELF文件的信息。
1)查看ELF首部:readelf -h
2)查看程序首部表:readelf -l
3)查看节首部表:readelf -S
内核负责解析ELF程序源文件:
源文件 | 说明 |
---|---|
fs/binfmt_elf.c | 解析64位ELF程序,和处理器架构无关 |
fs/compat_binfmt_elf.c | 在64位内核中解析32位ELF程序,和处理器架构无关。注意:该源文件首先对一些数据类型和函数重命名,然后包含源文件“binfmt_elf.c” |
arch/arm64/include/asm/processor.h
static inline void start_thread_common(struct pt_regs *regs, unsigned long pc)
{
memset(regs, 0, sizeof(*regs));
regs->syscallno = ~0UL;
regs->pc = pc; /* 把程序计数器设置为程序的入口 */
}
static inline void start_thread(struct pt_regs *regs, unsigned long pc,
unsigned long sp)
{
start_thread_common(regs, pc);
regs->pstate = PSR_MODE_EL0t; /* 把处理器状态设置为0,其中异常级别是0 */
regs->sp = sp; /*设置用户栈指针 */
}
进程退出分两种情况:进程主动退出和终止进程。
Linux内核提供了以下两个使进程主动退出的系统调用。
void exit(int status);
void exit_group(int status);
glibc库封装了库函数exit、_exit和_Exit用来使一个进程退出,这些库函数调用系统调用exit_group。库函数exit和_exit的区别是exit会执行由进程使用atexit和on_exit注册的函数。
注意:我们编写用户程序时调用的函数exit,是glibc库的函数exit,不是系统调用exit。
终止进程是通过给进程发送信号实现的,Linux内核提供了发送信号的系统调用。
int kill(pid_t pid, int sig);
int tkill(int tid, int sig);
int tgkill(int tgid, int tid, int sig);
tkill和tgkill是Linux私有的系统调用,tkill已经废弃,被tgkill取代。
当进程退出的时候,根据父进程是否关注子进程退出事件,处理存在如下差异。
进程默认关注子进程退出事件,如果不想关注,可以使用系统调用sigaction针对信号SIGCHLD设置标志SA_NOCLDWAIT(CLD是child的缩写),以指示子进程退出时不要变成僵尸进程,或者设置忽略信号SIGCHLD。
怎么查询子进程终止的原因?Linux内核提供了3个系统调用来等待子进程的状态改变,状态改变包括:子进程终止,信号SIGSTOP使子进程停止执行,或者信号SIGCONT使子进程继续执行。这3个系统调用如下。
注意:wait4已经废弃,新的程序应该使用waitpid和waitid。
子进程退出以后需要父进程回收进程描述符,如果父进程先退出,子进程成为“孤儿”,谁来为子进程回收进程描述符呢?父进程退出时需要给子进程寻找一个“领养者”,按照下面的顺序选择领养“孤儿”的进程。
系统调用exit_group实现线程组退出,执行流程如图2.17所示,把主要工作委托给函数do_group_exit,执行流程如下:
假设一个线程组有两个线程,称为线程1和线程2,线程1调用exit_group使线程组退出,线程1的执行过程如下:
线程2退出的执行流程如下所示,线程2准备返回用户模式的时候,发现收到了杀死信号,于是处理杀死信号,调用函数do_group_exit,函数do_group_exit的执行过程如下:
线程2可能在以下3种情况下准备返回用户模式:
函数do_exit的执行过程如下:
系统调用kill(源文件“kernel/signal.c”)负责向线程组或者进程组发送信号,执行流程如下:
函数kill_pid_info负责向线程组发送信号,执行流程如下所示,函数check_kill_permission检查当前进程是否有权限发送信号,函数__send_signal负责发送信号:
系统调用waitid的原型如下:
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
参数idtype指定标识符类型,支持以下取值:
参数options是选项,取值是0或者以下标志的组合:
系统调用waitpid的原型是:
pid_t waitpid(pid_t pid, int *wstatus, int options);
系统调用wait4的原型是:
pid_t wait4(pid_t pid, int *wstatus, int options,struct rusage *rusage);
参数pid的取值如下。
参数options是选项,取值是0或者以下标志的组合。
以下选项是Linux私有的,和使用clone创建子进程一起使用。
系统调用waitpid、waitid和wait4把主要工作委托给函数do_wait,函数do_wait的执行流程如下所示,遍历当前线程组的每个线程,针对每个线程遍历它的每个子进程,如果是僵尸进程,调用函数eligible_child来判断是不是符合等待条件的子进程,如果符合等待条件,调用函数wait_task_zombie进行处理。
函数wait_task_zombie的执行流程如下。