Linux内核深度解析之进程管理丨内含赠书福利

孜然Sun

2.1 进程

Linux内核把进程称为任务(task),进程的虚拟地址空间分为用户虚拟地址空间和内核虚拟地址空间,所有进程共享内核虚拟地址空间,每个进程有独立的用户虚拟地址空间。

进程有两种特殊形式:没有用户虚拟地址空间的进程称为内核线程,共享用户虚拟地址空间的进程称为用户线程,通常在不会引起混淆的情况下把用户线程简称为线程。共享同一个用户虚拟地址空间的所有用户线程组成一个线程组。

C标准库的进程术语和Linux内核的进程术语的对应关系如表2.1所示。

表2.1 进程术语的对应关系

C标准库的进程术语 对应的Linux内核的进程术语
包含多个线程的进程 线程组
只有一个线程的进程 进程或任务
线程 共享用户虚拟地址空间的进程

结构体task_struct是进程描述符,其主要成员如表2.2所示。

表2.2 进程描述符task_struct的主要成员

成员 说明
volatile long state; 进程的状态
void *stack; 指向内核栈
pid_t pid; 全局的进程号
pid_t tgid; 全局的线程组标识符
struct pid_link pids[PIDTYPE_MAX]; 进程号,进程组标识符和会话标识符
struct task_struct __rcu *real_parent;
struct task_struct __rcu *parent;
real_parent指向真实的父进程
parent指向父进程:如果进程被另一个进程(通常是调试器)使用系统调用ptrace跟踪,那么父进程是跟踪进程,否则和real_parent相同
struct task_struct *group_leader; 指向线程组的组长
const struct cred __rcu *real_cred;
const struct cred __rcu *cred;
real_cred指向主体和真实客体证书,cred指向有效客体证书。通常情况下,cred和real_cred指向相同的证书,但是cred可以被临时改变
char comm[TASK_COMM_LEN]; 进程名称
int prio, static_prio, normal_prio;
unsigned int rt_priority;
unsigned int policy;
调度策略和优先级
cpumask_t cpus_allowed 允许进程在哪些处理器上运行
struct mm_struct *mm,*active_mm; 指向内存描述符
进程:mm和active_mm指向同一个内存描述符
内核线程:mm是空指针,当内核线程运行时,active_mm指向从进程借用的内存描述符
struct fs_struct *fs; 文件系统信息,主要是进程的根目录和当前工作目录
struct files_struct *files; 打开文件表
struct nsproxy *nsproxy; 命名空间
struct signal_struct *signal;
struct sighand_struct *sighand;
sigset_t blocked, real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
信号处理
(结构体signal_struct比较混乱,里面包含很多和信号无关的成员,没有人愿意整理)
struct sysv_sem sysvsem;
struct sysv_shm sysvshm;
UNIX系统5信号量和共享内存

2.2 命名空间

和虚拟机相比,容器是一种轻量级的虚拟化技术,直接使用宿主机的内核,使用命名空间隔离资源。Linux内核提供的命名空间如表2.3所示。

表2.3 命名空间

命名空间 隔离资源
控制组(cgroup) 控制组根目录
进程间通信(IPC) UNIX系统5进程间通信和POSIX消息队列
网络(network) 网络协议栈
挂载(mount) 挂载点
进程号(PID) 进程号
用户(user) 用户标识符和组标识符
UNIX分时系统(UNIX Timesharing System,UTS) 主机名和网络信息服务(NIS)域名

可以使用以下两种方法创建新的命名空间。

(1)调用clone创建子进程时,使用标志位控制子进程是共享父进程的命名空间还是创建新的命名空间。

(2)调用unshare创建新的命名空间,不和已存在的任何其他进程共享命名空间。

进程也可以使用系统调用setns,绑定到一个已经存在的命名空间。

如图2.1所示,进程描述符的成员“nsproxy”指向一个命名空间代理,命名空间代理包含除了用户以外的所有其他命名空间的地址。如果父进程和子进程共享除了用户以外的所有其他命名空间,那么它们共享一个命名空间代理。

图2.1 进程的命名空间

本节只介绍进程号命名空间。

进程号命名空间用来隔离进程号,对应的结构体是pid_namespace。每个进程号命名空间独立分配进程号。进程号命名空间按层次组织成一棵树,初始进程号命名空间是树的根,对应全局变量init_pid_ns,所有进程默认属于初始进程号命名空间。

创建进程时,从进程所属的进程号命名空间到初始进程号命名空间都会分配进程号。如图2.2所示,假设某个进程属于进程号命名空间b,b的父命名空间是a,a的父命名空间是初始进程号命名空间,从b到初始的每一级命名空间依次分配进程号10、20和30。

图2.2 进程号命名空间

2.3 进程标识符

进程有以下标识符。

(1)进程标识符:进程所属的进程号命名空间到根的每层命名空间,都会给进程分配一个标识符。

(2)线程组标识符:多个共享用户虚拟地址空间的进程组成一个线程组,线程组中的主进程称为组长,线程组标识符就是组长的进程标识符。当调用系统调用clone传入标志CLONE_THREAD以创建新进程时,新进程和当前进程属于一个线程组。

进程描述符的成员tgid存放线程组标识符,成员group_leader指向组长的进程描述符。

(3)进程组标识符:多个进程可以组成一个进程组,进程组标识符是组长的进程标识符。进程可以使用系统调用setpgid创建或者加入一个进程组。会话和进程组被设计用来支持shell作业控制,shell为执行单一命令或者管道的进程创建一个进程组。进程组简化了向进程组的所有成员发送信号的操作。

(4)会话标识符:多个进程组可以组成一个会话。当进程调用系统调用setsid的时候,创建一个新的会话,会话标识符是该进程的进程标识符。创建会话的进程是会话的首进程。

Linux是多用户操作系统,用户登录时会创建一个会话,用户启动的所有进程都属于这个会话。登录shell是会话首进程,它所使用的终端就是会话的控制终端,会话首进程通常也被称为控制进程。当用户退出登录时,所有属于这个会话的进程都将被终止。

假设某个进程属于进程号命名空间b,b的父命名空间是a,a的父命名空间是初始进程号命名空间,从b到初始的每一级命名空间分配的进程号依次是10、20和30。进程标识符数据结构如图2.3所示,进程描述符的相关成员如下。

(1)成员pid存储全局进程号,即初始进程号命名空间分配的进程号30。

(2)成员pids[PIDTYPE_PID].pid指向结构体pid,存放3个命名空间分配的进程号。

(3)成员pids[PIDTYPE_PGID].pid指向进程组组长的结构体pid(限于篇幅,图2.3中没画出)。

(4)成员pids[PIDTYPE_SID].pid指向会话首进程的结构体pid(限于篇幅,图2.3中没画出)。

进程标识符结构体pid的成员如下。

(1)成员count是引用计数。

(2)成员level是进程所属的进程号命名空间的层次。

(3)数组numbers的元素个数是成员level的值加上1,3个元素依次存放初始命名空间、a和b三个命名空间分配的进程号。numbers[i].nr是进程号命名空间分配的进程号,numbers[i].ns指向进程号命名空间的结构体pid_namespace,numbers[i].pid_chain用来把进程加入进程号散列表pid_hash,根据进程号和命名空间计算散列值。

图2.3 进程标识符数据结构

2.4 进程关系

进程1分叉生成进程2,进程1称为父进程,进程2称为子进程。

进程1多次分叉生成进程2和进程3,进程2和进程3的关系是兄弟关系。

如图2.4所示,一个进程的所有子进程被链接在一条子进程链表上,头节点是父进程的成员children,链表节点是子进程的成员sibling。子进程的成员real_parent指向父进程的进程描述符,成员parent用来干什么呢?如果子进程被某个进程(通常是调试器)使用系统调用ptrace跟踪,那么成员parent指向跟踪者的进程描述符,否则成员parent也指向父进程的进程描述符。

如图2.5所示,进程管理子系统把所有进程链接在一条进程链表上,头节点是0号线程的成员tasks,链表节点是每个进程的成员tasks。对于线程组,只把组长加入进程链表。

图2.4 父子进程

图2.5 进程和线程链表

一个线程组的所有线程链接在一条线程链表上,头节点是组长的成员thread_group,链表节点是线程的成员thread_group。线程的成员group_leader指向组长的进程描述符,成员tgid是线程组标识符,成员pid存放自己的进程标识符。

2.5 启动程序

当我们在shell进程里面执行命令“/sbin/hello.elf &”以启动程序“hello”时,shell进程首先创建子进程,然后子进程装载程序“hello.elf”,其代码如下:

ret = fork();
if (ret > 0) {
   /* 父进程继续执行 */
} else if (ret == 0) {
    /* 子进程装载程序 */
    ret = execve(filename, argv, envp);
} else {
   /* 创建子进程失败 */
}

下面描述创建新进程和装载程序的过程。

2.5.1 创建新进程

在Linux内核中,新进程是从一个已经存在的进程复制出来的。内核使用静态数据构造出0号内核线程,0号内核线程分叉生成1号内核线程和2号内核线程(kthreadd线程)。1号内核线程完成初始化以后装载用户程序,变成1号进程,其他进程都是1号进程或者它的子孙进程分叉生成的;其他内核线程是kthreadd线程分叉生成的。

3个系统调用可以用来创建新的进程。

(1)fork(分叉):子进程是父进程的一个副本,采用了写时复制的技术。

(2)vfork:用于创建子进程,之后子进程立即调用execve以装载新程序的情况。为了避免复制物理页,父进程会睡眠等待子进程装载新程序。现在fork采用了写时复制的技术,vfork失去了速度优势,已经被废弃。

(3)clone(克隆):可以精确地控制子进程和父进程共享哪些资源。这个系统调用的主要用处是可供pthread库用来创建线程。

clone是功能最齐全的函数,参数多,使用复杂,fork是clone的简化函数。

我们先介绍Linux内核定义系统调用的独特方式,以系统调用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种情况。

(1)新进程是进程p的子进程。

(2)如果clone传入标志位CLONE_PARENT,那么新进程和进程p拥有同一个父进程,是兄弟关系。

(3)如果clone传入标志位CLONE_THREAD,那么新进程和进程p属于同一个线程组。

创建新进程的3个系统调用在文件“kernel/fork.c”中,它们把工作委托给函数_do_fork。

1.函数_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);

参数如下。

(1)参数clone_flags是克隆标志,最低字节指定了进程退出时发给父进程的信号,创建线程时,该参数的最低字节是0,表示线程退出时不需要向父进程发送信号。

(2)参数stack_start只在创建线程时有意义,用来指定新线程的用户栈的起始地址。

(3)参数stack_size只在创建线程时有意义,用来指定新线程的用户栈的长度。这个参数已经废弃。

(4)参数parent_tidptr只在创建线程时有意义,如果参数clone_flags指定了标志位CLONE_PARENT_SETTID,那么调用线程需要把新线程的进程标识符写到参数parent_tidptr指定的位置,也就是新线程保存自己的进程标识符的位置。

(5)参数child_tidptr只在创建线程时有意义,存放新线程保存自己的进程标识符的位置。如果参数clone_flags指定了标志位CLONE_CHILD_CLEARTID,那么线程退出时需要清除自己的进程标识符。如果参数clone_flags指定了标志位CLONE_CHILD_SETTID,那么新线程第一次被调度时需要把自己的进程标识符写到参数child_tidptr指定的位置。

(6)参数tls只在创建线程时有意义,如果参数clone_flags指定了标志位CLONE_SETTLS,那么参数tls指定新线程的线程本地存储的地址。

如图2.6所示,函数_do_fork的执行流程如下。

图2.6 函数_do_fork的执行流程

(1)调用函数copy_process以创建新进程。

(2)如果参数clone_flags设置了标志CLONE_PARENT_SETTID,那么把新线程的进程标识符写到参数parent_tidptr指定的位置。

(3)调用函数wake_up_new_task以唤醒新进程。

(4)如果是系统调用vfork,那么当前进程等待子进程装载程序。

2.函数copy_process

创建新进程的主要工作由函数copy_process实现,其执行流程如图2.7所示。

图2.7 函数copy_process的执行流程

(1)检查标志:以下标志组合是非法的。

1)同时设置CLONE_NEWNS和CLONE_FS,即新进程属于新的挂载命名空间,同时和当前进程共享文件系统信息。

2)同时设置CLONE_NEWUSER和CLONE_FS,即新进程属于新的用户命名空间,同时和当前进程共享文件系统信息。

3)设置CLONE_THREAD,未设置CLONE_SIGHAND,即新进程和当前进程属于同一个线程组,但是不共享信号处理程序。

4)设置CLONE_SIGHAND,未设置CLONE_VM,即新进程和当前进程共享信号处理程序,但是不共享虚拟内存。

5)新进程想要和当前进程成为兄弟进程,并且当前进程是某个进程号命名空间中的1号进程。这种标志组合是非法的,说明1号进程不存在兄弟进程。

6)新进程和当前进程属于同一个线程组,同时新进程属于不同的用户命名空间或者进程号命名空间。这种标志组合是非法的,说明同一个线程组的所有线程必须属于相同的用户命名空间和进程号命名空间。

(2)函数dup_task_struct:函数dup_task_struct为新进程的进程描述符分配内存,把当前进程的进程描述符复制一份,为新进程分配内核栈。

如图2.8所示,进程描述符的成员stack指向内核栈。

图2.8 进程的内核栈

内核栈的定义如下:

<include/linux/sched.h>
union thread_union {
#ifndef CONFIG_THREAD_INFO_IN_TASK
     struct thread_info thread_info;
#endif
     unsigned long stack[THREAD_SIZE/sizeof(long)];
};

内核栈有两种布局。

1)结构体thread_info占用内核栈的空间,在内核栈顶部,成员task指向进程描述符。

2)结构体thread_info没有占用内核栈的空间,是进程描述符的第一个成员。

两种布局的区别是结构体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是缺陷 */
};

1)flags:底层标志,常用的标志是_TIF_SIGPENDING和_TIF_NEED_RESCHED,前者表示进程有需要处理的信号,后者表示调度器需要重新调度进程。

2)addr_limit:进程可以访问的地址空间的上限。对于进程,它的值是用户地址空间的上限;对于内核线程,它的值是内核地址空间的上限。

3)preempt_count:抢占计数器。

(3)检查用户的进程数量限制:如果拥有当前进程的用户创建的进程数量达到或者超过限制,并且用户不是根用户,也没有忽略资源限制的权限(CAP_SYS_RESOURCE)和系统管理权限(CAP_SYS_ADMIN),那么不允许创建新进程。

if (atomic_read(&p->real_cred->user->processes) >=
          task_rlimit(p, RLIMIT_NPROC)) {
     if (p->real_cred->user != INIT_USER &&
          !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
          goto bad_fork_free;
     }

(4)函数copy_creds:函数copy_creds负责复制或共享证书,证书存放进程的用户标识符、组标识符和访问权限。

如果设置了标志CLONE_THREAD,即新进程和当前进程属于同一个线程组,那么新进程和当前进程共享证书,如图2.9所示。

图2.9 线程共享证书

否则,子进程复制当前进程的证书,如果设置了标志CLONE_NEWUSER,那么需要为新进程创建新的用户命名空间,新的用户命名空间是当前进程的用户命名空间的子命名空间。

最后把用户的进程数量统计值加1。

(5)检查线程数量限制:如果线程数量达到允许的线程最大数量,那么不允许创建新进程。

全局变量nr_threads 存放当前的线程数量;max_threads存放允许创建的线程最大数量,默认值是MAX_THREADS。

if (nr_threads >= max_threads)
     goto bad_fork_cleanup_count;

(6)函数sched_fork:函数sched_fork为新进程设置调度器相关的参数,其主要代码如下。

kernel/sched/core.c
1   int sched_fork(unsigned long clone_flags, struct task_struct *p)
2   {
3    unsigned long flags;
4    int cpu = get_cpu();
5  
6    __sched_fork(clone_flags, p);
7    p->state = TASK_NEW;
8  
9    p->prio = current->normal_prio;
10  
11   if (unlikely(p->sched_reset_on_fork)) {
12        if (task_has_dl_policy(p) || task_has_rt_policy(p)) {
13             p->policy = SCHED_NORMAL;
14             p->static_prio = NICE_TO_PRIO(0);
15             p->rt_priority = 0;
16        } else if (PRIO_TO_NICE(p->static_prio) < 0)
17             p->static_prio = NICE_TO_PRIO(0);
18   
19        p->prio = p->normal_prio = __normal_prio(p);
20        set_load_weight(p);
21   
22        p->sched_reset_on_fork = 0;
23   }
24   
25   if (dl_prio(p->prio)) {
26        put_cpu();
27        return -EAGAIN;
28   } else if (rt_prio(p->prio)) {
29        p->sched_class = &rt_sched_class;
30   } else {
31        p->sched_class = &fair_sched_class;
32   }
33   
34   init_entity_runnable_average(&p->se);
35   
36   raw_spin_lock_irqsave(&p->pi_lock, flags);
37   __set_task_cpu(p, cpu);
38   if (p->sched_class->task_fork)
39        p->sched_class->task_fork(p);
40   raw_spin_unlock_irqrestore(&p->pi_lock, flags);
41   
42   …
43   #if defined(CONFIG_SMP)
44    p->on_cpu = 0;
45   #endif
46    init_task_preempt_count(p);
47    …
48   
49    put_cpu();
50    return 0;
51   }

第6行代码,调用函数__sched_fork以执行基本设置。

第7行代码,把新进程的状态设置为TASK_NEW。

第9行代码,把新进程的调度优先级设置为当前进程的正常优先级。为什么不设置为当前进程的调度优先级?因为当前进程可能因为占有实时互斥锁而被临时提升了优先级。

第11~23行代码,如果当前进程使用sched_setscheduler设置调度策略和相关参数时设置了标志SCHED_RESET_ON_FORK,要求创建新进程时把新进程的调度策略和优先级设置为默认值,那么处理如下。

第25~32行代码,根据新进程的调度优先级设置调度类。

第37行代码,调用函数__set_task_cpu,设置新进程在哪个处理器上,如果开启公平组调度和实时组调度,那么还需要设置新进程属于哪个公平运行队列和哪个实时运行队列。

第38行和第39行代码,执行调度类的task_fork方法。

第46行代码,初始化新进程的抢占计数器,在抢占式内核中设置为2,在非抢占式内核中设置为0。因为在抢占式内核中,如果函数schedule()在调度进程时选中了新进程,那么调用函数rq_unlock_irq()和sched_preempt_enable_no_resched()时会把新进程的抢占计数减两次。

(7)复制或者共享资源如下。

1)UNIX系统5信号量。只有属于同一个线程组的线程之间才会共享UNIX系统5信号量。函数copy_semundo处理UNIX系统5信号量的共享问题,其代码如下:

kernel/fork.c
1   int copy_semundo(unsigned long clone_flags, struct task_struct *tsk)
2   {
3   struct sem_undo_list *undo_list;
4   int error;
5   
6   if (clone_flags & CLONE_SYSVSEM) {
7        error = get_undo_list(&undo_list);
8        if (error)
9             return error;
10       atomic_inc(&undo_list->refcnt);
11       tsk->sysvsem.undo_list = undo_list;
12  } else
13       tsk->sysvsem.undo_list = NULL;
14   
15  return 0;
16  }

第6~11行代码,如果调用者传入标志CLONE_SYSVSEM,表示共享UNIX系统5信号量,那么新进程和当前进程共享UNIX系统5信号量的撤销请求链表,对应结构体sem_undo_list,把计数加1。当进程退出时,内核需要把信号量的计数值加上该进程曾经减去的数值。

否则,在第12行和第13行代码中,新进程的UNIX系统5信号量的撤销请求链表是空的。

2)打开文件表。只有属于同一个线程组的线程之间才会共享打开文件表。函数copy_files复制或者共享打开文件表,其代码如下:

kernel/fork.c
1   static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
2   {
3    struct files_struct *oldf, *newf;
4    int error = 0;
5   
6    oldf = current->files;
7    if (!oldf)   /* 后台进程可能没有打开文件表 */
8         goto out;
9   
10   if (clone_flags & CLONE_FILES) {
11        atomic_inc(&oldf->count);
12        goto out;
13   }
14   
15   newf = dup_fd(oldf, &error);
16   if (!newf)
17        goto out;
18   
19   tsk->files = newf;
20   error = 0;
21  out:
22   return error;
23  }

第10~13行代码,如果调用者传入标志CLONE_FILES,表示共享打开文件表,那么新进程和当前进程共享打开文件表的结构体files_struct,把计数加1。

否则,在第15行代码中,新进程把当前进程的打开文件表复制一份。

3)文件系统信息。进程的文件系统信息包括根目录、当前工作目录和文件模式创建掩码。只有属于同一个线程组的线程之间才会共享文件系统信息。

函数copy_fs复制或者共享文件系统信息,其代码如下:

kernel/fork.c
1   static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
2   {
3    struct fs_struct *fs = current->fs;
4    if (clone_flags & CLONE_FS) {
5         spin_lock(&fs->lock);
6         if (fs->in_exec) {
7              spin_unlock(&fs->lock);
8              return -EAGAIN;
9         }
10        fs->users++;
11        spin_unlock(&fs->lock);
12        return 0;
13   }
14   tsk->fs = copy_fs_struct(fs);
15   if (!tsk->fs)
16         return -ENOMEM;
17   return 0;
18  }

第4~13行代码,如果调用者传入标志CLONE_FS,表示共享文件系统信息,那么新进程和当前进程共享文件系统信息的结构体fs_struct,把计数users加1。

否则,在第14行代码中,新进程把当前进程的文件系统信息复制一份。

4)信号处理程序。只有属于同一个线程组的线程之间才会共享信号处理程序。函数copy_sighand复制或者共享信号处理程序,其代码如下:

kernel/fork.c
1   static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
2   {
3    struct sighand_struct *sig;
4   
5    if (clone_flags & CLONE_SIGHAND) {
6         atomic_inc(&current->sighand->count);
7         return 0;
8    }
9    sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
10   rcu_assign_pointer(tsk->sighand, sig);
11   if (!sig)
12        return -ENOMEM;
13   
14   atomic_set(&sig->count, 1);
15   memcpy(sig->action, current->sighand->action, sizeof(sig->action));
16   return 0;
17  }

第5~8行代码,如果调用者传入标志CLONE_SIGHAND,表示共享信号处理程序,那么新进程和当前进程共享信号处理程序的结构体sighand_struct,把计数加1。

否则,在第9~15行代码中,新进程把当前进程的信号处理程序复制一份。

5)信号结构体。只有属于同一个线程组的线程之间才会共享信号结构体。函数copy_signal复制或共享信号结构体,其代码如下:

kernel/fork.c
1   static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
2   {
3    struct signal_struct *sig;
4   
5    if (clone_flags & CLONE_THREAD)
6         return 0;
7   
8    sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
9    tsk->signal = sig;
10   if (!sig)
11        return -ENOMEM;
12   
13   sig->nr_threads = 1;
14   atomic_set(&sig->live, 1);
15   atomic_set(&sig->sigcnt, 1);
16   
17   …
18   task_lock(current->group_leader);
19   memcpy(sig->rlim, current->signal->rlim, sizeof sig->rlim);
20   task_unlock(current->group_leader);
21   …
22   return 0;
23  }

第5行代码,如果调用者传入标志CLONE_THREAD,表示创建线程,那么新进程和当前进程共享信号结构体signal_struct。

否则,在第8~20行代码中,为新进程分配信号结构体,然后初始化,继承当前进程的资源限制。

6)虚拟内存。只有属于同一个线程组的线程之间才会共享虚拟内存。函数copy_mm复制或共享虚拟内存,其主要代码如下:

kernel/fork.c
1   static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
2   {
3    struct mm_struct *mm, *oldmm;
4    int retval;
5   
6    …
7    tsk->mm = NULL;
8    tsk->active_mm = NULL;
9   
10   oldmm = current->mm;
11   if (!oldmm)
12        return 0;
13   
14   …
15   if (clone_flags & CLONE_VM) {
16        mmget(oldmm);
17        mm = oldmm;
18        goto good_mm;
19   }
20   
21   retval = -ENOMEM;
22   mm = dup_mm(tsk);
23   if (!mm)
24        goto fail_nomem;
25   
26  good_mm:
27   tsk->mm = mm;
28   tsk->active_mm = mm;
29   return 0;
30   
31   fail_nomem:
32    return retval;
33   }

第15~19行代码,如果调用者传入标志CLONE_VM,表示共享虚拟内存,那么新进程和当前进程共享内存描述符mm_struct,把计数mm_users加1。

否则,在第22~28行代码中,新进程复制当前进程的虚拟内存。

7)命名空间。函数copy_namespaces创建或共享命名空间,其代码如下:

kernel/fork.c
1   int copy_namespaces(unsigned long flags, struct task_struct *tsk)
2   {
3    struct nsproxy *old_ns = tsk->nsproxy;
4    struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
5    struct nsproxy *new_ns;
6   
7    if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
8                   CLONE_NEWPID | CLONE_NEWNET |
9                   CLONE_NEWCGROUP)))) {
10         get_nsproxy(old_ns);
11         return 0;
12   }
13   
14   if (!ns_capable(user_ns, CAP_SYS_ADMIN))
15        return -EPERM;
16   
17   if ((flags & (CLONE_NEWIPC | CLONE_SYSVSEM)) ==
18        (CLONE_NEWIPC | CLONE_SYSVSEM))
19        return -EINVAL;
20   
21   new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
22   if (IS_ERR(new_ns))
23        return  PTR_ERR(new_ns);
24   
25   tsk->nsproxy = new_ns;
26   return 0;
27  }

第7~12行代码,如果共享除了用户以外的所有其他命名空间,那么新进程和当前进程共享命名空间代理结构体nsproxy,把计数加1。

第14行和第15行代码,如果进程没有系统管理权限,那么不允许创建新的命名空间。

第17~19行代码,如果既要求创建新的进程间通信命名空间,又要求共享UNIX系统5信号量,那么这种要求是不合理的。

第21行代码,创建新的命名空间代理,然后创建或者共享命名空间。

8)I/O上下文。函数copy_io创建或者共享I/O上下文,其代码如下:

kernel/fork.c
1   static int copy_io(unsigned long clone_flags, struct task_struct *tsk)
2   {
3   #ifdef CONFIG_BLOCK
4    struct io_context *ioc = current->io_context;
5    struct io_context *new_ioc;
6   
7    if (!ioc)
8         return 0;
9   
10   if (clone_flags & CLONE_IO) {
11        ioc_task_link(ioc);
12        tsk->io_context = ioc;
13   } else if (ioprio_valid(ioc->ioprio)) {
14        new_ioc = get_task_io_context(tsk, GFP_KERNEL, NUMA_NO_NODE);
15        if (unlikely(!new_ioc))
16             return -ENOMEM;
17   
18        new_ioc->ioprio = ioc->ioprio;
19        put_io_context(new_ioc);
20   }
21  #endif
22   return 0;
23  }

第10~12行代码,如果调用者传入标志CLONE_IO,表示共享I/O上下文,那么共享I/O上下文的结构体io_context,把计数nr_tasks加1。

否则,在第13~20行代码中,创建新的I/O上下文,然后初始化,继承当前进程的I/O优先级。

9)复制寄存器值。调用函数copy_thread_tls复制当前进程的寄存器值,并且修改一部分寄存器值。如图2.10所示,进程有两处用来保存寄存器值:从用户模式切换到内核模式时,把用户模式的各种寄存器保存在内核栈底部的结构体pt_regs中;进程调度器调度进程时,切换出去的进程把寄存器值保存在进程描述符的成员thread中。因为不同处理器架构的寄存器不同,所以各种处理器架构需要自己定义结构体pt_regs和thread_struct,实现函数copy_thread_tls。

图2.10 进程保存寄存器值处

ARM64架构的函数copy_thread_tls把主要工作委托给函数copy_thread,函数copy_thread的代码如下:

arch/arm64/kernel/process.c
1    int copy_thread(unsigned long clone_flags, unsigned long stack_start,
2          unsigned long stk_sz, struct task_struct *p)
3    {
4    struct pt_regs *childregs = task_pt_regs(p);
5
6    memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context));
7
8    if (likely(!(p->flags & PF_KTHREAD))) {  /* 用户进程 */
9         *childregs = *current_pt_regs();
10        childregs->regs[0] = 0;
11
12        /*
13         * 从寄存器tpidr_el0读取当前线程的线程本地存储的地址,
14         * 因为它可能和保存的值不一致
15         */
16         *task_user_tls(p) = read_sysreg(tpidr_el0);
17
18         if (stack_start) {
19              if (is_compat_thread(task_thread_info(p)))
20                   childregs->compat_sp = stack_start;
21              else
22                   childregs->sp = stack_start;
23         }
24
25         /*
26          * 如果把线程本地存储的地址传给系统调用clone的第4个参数,
27          * 那么新线程将使用它
28          */
29         if (clone_flags & CLONE_SETTLS)
30              p->thread.tp_value = childregs->regs[3];
31    } else {  /* 内核线程 */
32         memset(childregs, 0, sizeof(struct pt_regs));
33        childregs->pstate = PSR_MODE_EL1h;
34        if (IS_ENABLED(CONFIG_ARM64_UAO) &&
35           cpus_have_const_cap(ARM64_HAS_UAO))
36             childregs->pstate |= PSR_UAO_BIT;
37        p->thread.cpu_context.x19 = stack_start;
38        p->thread.cpu_context.x20 = stk_sz;
39   }
40   p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
41   p->thread.cpu_context.sp = (unsigned long)childregs;
42
43   …
44   return 0;
45  }

执行过程如下。

第6行代码,把新进程的进程描述符的成员thread.cpu_context清零,在调度进程时切换出去的进程使用这个成员保存通用寄存器的值。

第8~30行代码,如果是用户进程,其处理过程如下。

第31~39行代码,如果是内核线程,其处理过程如下。

第40行代码,把子进程的程序计数器设置为函数ret_from_fork。当子进程被调度时,从函数ret_from_fork开始执行。

第41行代码,把子进程的栈指针寄存器SP_EL1设置为内核栈底部pt_regs结构体的起始位置。

(8)设置进程号和进程关系。函数copy_process的最后部分为新进程设置进程号和进程关系,其主要代码如下:

1    if (pid != &init_struct_pid) {
2         pid = alloc_pid(p->nsproxy->pid_ns_for_children);
3         if (IS_ERR(pid)) {
4              retval = PTR_ERR(pid);
5              goto bad_fork_cleanup_thread;
6         }
7    }
8   
9    …
10   
11   p->pid = pid_nr(pid);
12   if (clone_flags & CLONE_THREAD) {
13        p->exit_signal = -1;
14        p->group_leader = current->group_leader;
15        p->tgid = current->tgid;
16   } else {
17        if (clone_flags & CLONE_PARENT)
18             p->exit_signal = current->group_leader->exit_signal;
19        else
20             p->exit_signal = (clone_flags & CSIGNAL);
21        p->group_leader = p;
22        p->tgid = p->pid;
23   }
24   
25   …
26   cgroup_threadgroup_change_begin(current);
27   retval = cgroup_can_fork(p);
28   if (retval)
29        goto bad_fork_free_pid;
30   
31   write_lock_irq(&tasklist_lock);
32   
33   if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
34        p->real_parent = current->real_parent;
35        p->parent_exec_id = current->parent_exec_id;
36   } else {
37        p->real_parent = current;
38        p->parent_exec_id = current->self_exec_id;
39   }
40   
41   …
42   spin_lock(&current->sighand->siglock);
43   …
44   if (likely(p->pid)) {
45        …
46        init_task_pid(p, PIDTYPE_PID, pid);
47        if (thread_group_leader(p)) {
48             init_task_pid(p, PIDTYPE_PGID, task_pgrp(current));
49             init_task_pid(p, PIDTYPE_SID, task_session(current));
50   
51             if (is_child_reaper(pid)) {
52                  ns_of_pid(pid)->child_reaper = p;
53                  p->signal->flags |= SIGNAL_UNKILLABLE;
54             }
55   
56             p->signal->leader_pid = pid;
57             p->signal->tty = tty_kref_get(current->signal->tty);
58             p->signal->has_child_subreaper = p->real_parent->signal-> has_child_subreaper ||
59                                   p->real_parent->signal->is_child_subreaper;
60             list_add_tail(&p->sibling, &p->real_parent->children);
61             list_add_tail_rcu(&p->tasks, &init_task.tasks);
62             attach_pid(p, PIDTYPE_PGID);
63             attach_pid(p, PIDTYPE_SID);
64             __this_cpu_inc(process_counts);
65        } else {
66             current->signal->nr_threads++;
67             atomic_inc(&current->signal->live);
68             atomic_inc(&current->signal->sigcnt);
69             list_add_tail_rcu(&p->thread_group,
70                         &p->group_leader->thread_group);
71             list_add_tail_rcu(&p->thread_node,
72                         &p->signal->thread_head);
73        }
74        attach_pid(p, PIDTYPE_PID);
75        nr_threads++;
76   }
77   
78   total_forks++;
79   spin_unlock(&current->sighand->siglock);
80   …
81   write_unlock_irq(&tasklist_lock);
82   
83   proc_fork_connector(p);
84   cgroup_post_fork(p);
85   cgroup_threadgroup_change_end(current);
86   …
87   return p;

第1~7行代码,为新进程分配进程号。从新进程所属的进程号命名空间一直到根,每层进程号命名空间为新进程分配一个进程号。

pid等于init_struct_pid的地址,这是什么意思呢?在内核初始化时,引导处理器为每个从处理器分叉生成一个空闲线程(参考函数idle_threads_init),所有处理器的空闲线程使用进程号0,全局变量init_struct_pid存放空闲线程的进程号。

第12~23行代码,分情况设置新进程退出时发送给父进程的信号,设置新进程所属的线程组。

1)第12~15行代码,如果是创建线程,那么把新线程的成员exit_signal设置为−1,新线程退出时不需要发送信号给父进程;因为新线程和当前线程属于同一个线程组,所以成员group_leader指向同一个组长,成员tgid存放组长的进程号。

2)第16~23行代码,如果是创建进程,执行过程如下。

第27~29行代码,控制组的进程数控制器检查是否允许创建新进程:从当前进程所属的控制组一直到控制组层级的根,如果其中一个控制组的进程数量大于或等于限制,那么不允许使用fork和clone创建新进程。

控制组(cgroup)的进程数(PIDs)控制器:用来限制控制组及其子控制组中的进程使用fork和clone创建的新进程的数量,如果进程p所属的控制组到控制组层级的根,其中有一个控制组的进程数量大于或等于限制,那么不允许进程p使用fork和clone创建新进程。

第33~39行代码,为新进程设置父进程。

第46行代码,把新进程的成员pids[PIDTYPE_PID].pid指向第2行代码分配的进程号结构体。

第47~64行代码,如果是创建新进程,执行下面的处理过程。

第65~73行代码,如果是创建线程,执行下面的处理过程。

第74行代码,把新进程添加到进程号结构体的进程链表中。

第75行代码,把线程计数值加1。

第83行代码,调用函数proc_fork_connector,通过进程事件连接器向用户空间通告进程事件PROC_EVENT_FORK。进程可以通过进程事件连接器监视进程事件:创建协议号为NETLINK_CONNECTOR的netlink套接字,然后绑定到多播组CN_IDX_PROC。

3.唤醒新进程

函数wake_up_new_task负责唤醒刚刚创建的新进程,其代码如下:

kernel/sched/core.c
1   void wake_up_new_task(struct task_struct *p)
2   {
3    struct rq_flags rf;
4    struct rq *rq;
5   
6    raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
7    p->state = TASK_RUNNING;
8   #ifdef CONFIG_SMP
9    __set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0));
10  #endif
11   rq = __task_rq_lock(p, &rf);
12   update_rq_clock(rq);
13   post_init_entity_util_avg(&p->se);
14   
15   activate_task(rq, p, ENQUEUE_NOCLOCK);
16   p->on_rq = TASK_ON_RQ_QUEUED;
17   …
18   check_preempt_curr(rq, p, WF_FORK);
19  #ifdef CONFIG_SMP
20   if (p->sched_class->task_woken) {
21        rq_unpin_lock(rq, &rf);
22        p->sched_class->task_woken(rq, p);
23        rq_repin_lock(rq, &rf);
24   }
25  #endif
26   task_rq_unlock(rq, p, &rf);
27  }

第7行代码,把新进程的状态从TASK_NEW切换到TASK_RUNNING。

第9行代码,在SMP系统上,创建新进程是执行负载均衡的绝佳时机,为新进程选择一个负载最轻的处理器。

第11行代码,锁住运行队列。

第12行代码,更新运行队列的时钟。

第13行代码,根据公平运行队列的平均负载统计值,推算新进程的平均负载统计值。

第15行代码,把新进程插入运行队列。

第18行代码,检查新进程是否可以抢占当前进程。

第22行代码,在SMP系统上,调用调度类的task_woken方法。

第26行代码,释放运行队列的锁。

4.新进程第一次运行

新进程第一次运行,是从函数ret_from_fork开始执行。函数ret_from_fork是由各种处理器架构自定义的函数,ARM64架构定义的ret_from_fork函数如下:

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的执行过程如下。

第4行代码,调用函数schedule_tail,为上一个进程执行清理操作。

第8行和第9行代码,如果寄存器x19的值是0,说明当前进程是用户进程,那么使用寄存器x28存放当前进程的thread_info结构体的地址,然后跳转到标号ret_to_user返回用户模式。

第6行和第7行代码,如果寄存器x19的值不是0,说明当前进程是内核线程,那么调用线程函数。

函数schedule_tail负责为上一个进程执行清理操作,是新进程第一次运行时必须最先做的事情,其代码如下:

kernel/sched/core.c
1   asmlinkage __visible void schedule_tail(struct task_struct *prev)
2    __releases(rq->lock)
3   {
4    struct rq *rq;
5   
6    rq = finish_task_switch(prev);
7    balance_callback(rq);
8    preempt_enable();
9   
10   if (current->set_child_tid)
11        put_user(task_pid_vnr(current), current->set_child_tid);
12   }

函数schedule_tail的执行过程如下。

第6行代码,调用函数finish_task_switch(),为上一个进程执行清理操作,参考2.8.6节。

第7行代码,执行运行队列的所有负载均衡回调函数。

第8行代码,开启内核抢占。

第10行和第11行代码,如果pthread库在调用clone()创建线程时设置了标志位CLONE_CHILD_SETTID,那么新进程把自己的进程标识符写到指定位置。

2.5.2 装载程序

当调度器调度新进程时,新进程从函数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,其执行流程如图2.11所示。

图2.11 装载程序的执行流程

(1)调用函数do_open_execat打开可执行文件。

(2)调用函数sched_exec。装载程序是一次很好的实现处理器负载均衡的机会,因为此时进程在内存和缓存中的数据是最少的。选择负载最轻的处理器,然后唤醒当前处理器上的迁移线程,当前进程睡眠等待迁移线程把自己迁移到目标处理器。

(3)调用函数bprm_mm_init创建新的内存描述符,分配临时的用户栈。

如图2.12所示,临时用户栈的长度是一页,虚拟地址范围是[STACK_TOP_MAX−页长度,STACK_TOP_MAX),bprm->p指向在栈底保留一个字长(指针长度)后的位置。

(4)调用函数prepare_binprm设置进程证书,然后读文件的前面128字节到缓冲区。

(5)依次把文件名称、环境字符串和参数字符串压到用户栈,如图2.13所示。

图2.12 临时用户栈

图2.13 把文件名称、环境和参数压到用户栈

(6)调用函数exec_binprm。函数exec_binprm调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别正在装载的程序为止。

1.二进制格式

在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个函数。

(1)load_binary用来加载普通程序。

(2)load_shlib用来加载共享库。

(3)core_dump用来在进程异常退出时生成核心转储文件。程序员使用调试器(例如GDB)分析核心转储文件以找出原因。min_coredump指定核心转储文件的最小长度。

每种二进制格式必须使用函数register_binfmt向内核注册。

下面介绍常用的二进制格式:ELF格式和脚本格式。

2.装载ELF程序

(1)ELF文件:ELF(Executable and Linkable Format)是可执行与可链接格式,主要有以下4种类型。

如图2.14所示,ELF文件分成4个部分:ELF首部、程序首部表(program header table)、节(section)和节首部表(section header table)。实际上,一个文件不一定包含全部内容,而且它们的位置也不一定像图2.14中这样安排,只有ELF首部的位置是固定的,其余各部分的位置和大小由ELF首部的成员决定。

图2.14 ELF文件的格式

程序首部表就是我们所说的段表(segment table),段(segment)是从运行的角度描述,节(section)是从链接的角度描述,一个段包含一个或多个节。在不会混淆的情况下,我们通常把节称为段,例如代码段(text section),不称为代码节。

32位ELF文件和64位ELF文件的差别很小,本书只介绍64位ELF文件的格式。

ELF首部的成员及说明如表2.4所示。

表2.4 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; 节名称字符串表在节首部表中的索引

程序首部表中每条表项的成员及说明如表2.5所示。

表2.5 程序首部表中每条表项的成员及说明

程序首部表中每条表项的成员 说明
 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; 段的对齐值

节首部表中每条表项的成员及说明如表2.6所示。

表2.6 节首部表中每条表项的成员及说明

节首部表中每条表项的成员 说明
 Elf64_Word sh_name; 节名称在节名称字符串表中的偏移
 Elf64_Word sh_type; 节的类型
 Elf64_Xword sh_flags; 节的属性
 Elf64_Addr sh_addr; 节在执行时的虚拟地址
 Elf64_Off sh_offset; 节的文件偏移
 Elf64_Xword sh_size; 节的长度
 Elf64_Word sh_link; 引用另一个节首部表表项,指定该表项的索引
 Elf64_Word sh_info; 附加的节信息
 Elf64_Xword sh_addralign; 节的对齐值
 Elf64_Xword sh_entsize; 如果节包含一个表项长度固定的表,例如符号表,那么这个成员存放表项的长度

重要的节及说明如表2.7所示。

表2.7 重要的节及说明

节名称 说明
.text 代码节(也称文本节),通常称为代码段,包含程序的机器指令
.data 数据节,通常称为数据段,包含已经初始化的数据,程序在运行期间可以修改
.rodata 只读数据
.bss 没有初始化的数据,在程序开始运行前用零填充(bss的全称是“Block Started by Symbol”,表示以符号开始的块)
.interp 保存解释器的名称,通常是动态链接器,即ld共享库
.shstrtab 节名称字符串表
.symtab 符号表。符号包括函数和全局变量,符号名称存放在字符串表中,符号表存储符号名称在字符串表里面的偏移。可以执行命令“readelf --symbols <ELF文件的名称>”查看符号表
.strtab 字符串表,存放符号表需要的所有字符串
.init 程序初始化时执行的机器指令
.fini 程序结束时执行的机器指令
.dynamic 存放动态链接信息,包含程序依赖的所有动态库,这是动态链接器需要的信息。可以执行命令“readelf --dynamic <ELF文件的名称>”来查看
.dynsym 存放动态符号表,包含需要动态链接的所有符号,即程序所引用的动态库里面的函数和全局变量,这是动态链接器需要的信息。可以执行命令“readelf --dyn-syms <ELF文件的名称>”查看动态符号表
.dynstr 这个节存放一个字符串表,包含动态链接需要的所有字符串,即动态库的名称、函数名称和全局变量的名称。“.dynamic”节不直接存储动态库的名称,而是存储库名称在该字符串表里面的偏移。动态符号表不直接存储符号名称,而是存储符号名称在该字符串表里面的偏移

可以使用程序“readelf”查看ELF文件的信息。

1)查看ELF首部:readelf -h <ELF文件的名称>。

2)查看程序首部表:readelf -l <ELF文件的名称>。

3)查看节首部表:readelf -S <ELF文件的名称>。

(2)代码实现:内核中负责解析ELF程序的源文件,如表2.8所示。

表2.8 解析ELF程序的源文件

源文件 说明
fs/binfmt_elf.c 解析64位ELF程序,和处理器架构无关
fs/compat_binfmt_elf.c 在64位内核中解析32位ELF程序,和处理器架构无关。注意:该源文件首先对一些数据类型和函数重命名,然后包含源文件“binfmt_elf.c”

如图2.15所示,源文件“fs/binfmt_elf.c”定义的函数load_elf_binary负责装载ELF程序,主要步骤如下。

图2.15 装载ELF程序

1)检查ELF首部。检查前4字节是不是ELF魔幻数,检查是不是可执行文件或者共享库,检查处理器架构。

2)读取程序首部表。

3)在程序首部表中查找解释器段,如果程序需要链接动态库,那么存在解释器段,从解释器段读取解释器的文件名称,打开文件,然后读取ELF首部。

4)检查解释器的ELF首部,读取解释器的程序首部表。

5)调用函数flush_old_exec终止线程组中的所有其他线程,释放旧的用户虚拟地址空间,关闭那些设置了“执行execve时关闭”标志的文件。

6)调用函数setup_new_exec。函数setup_new_exec调用函数arch_pick_mmap_layout以设置内存映射的布局,在堆和栈之间有一个内存映射区域,传统方案是内存映射区域向栈的方向扩展,另一种方案是内存映射区域向堆的方向扩展,从两种方案中选择一种。然后把进程的名称设置为目标程序的名称,设置用户虚拟地址空间的大小。

7)以前调用函数bprm_mm_init创建了临时的用户栈,现在调用函数set_arg_pages把用户栈定下来,更新用户栈的标志位和访问权限,把用户栈移动到最终的位置,并且扩大用户栈。

8)把所有可加载段映射到进程的虚拟地址空间。

9)调用函数setbrk把未初始化数据段映射到进程的用户虚拟地址空间,并且设置堆的起始虚拟地址,然后调用函数padzero用零填充未初始化数据段。

10)得到程序的入口。如果程序有解释器段,那么把解释器程序中的所有可加载段映射到进程的用户虚拟地址空间,程序入口是解释器程序的入口,否则就是目标程序自身的入口。

11)调用函数create_elf_tables依次把传递ELF解释器信息的辅助向量、环境指针数组envp、参数指针数组argv和参数个数argc压到进程的用户栈。

12)调用函数start_thread设置结构体pt_regs中的程序计数器和栈指针寄存器。当进程从用户模式切换到内核模式时,内核把用户模式的各种寄存器保存在内核栈底部的结构体pt_regs中。因为不同处理器架构的寄存器不同,所以各种处理器架构必须自定义结构体pt_regs和函数start_thread,ARM64架构定义的函数start_thread如下:

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;                 /*设置用户栈指针 */
}

3.装载脚本程序

脚本程序的主要特征是:前两字节是“#!”,后面是解释程序的名称和参数。解释程序用来解释执行脚本程序。

如图2.16所示,源文件“fs/binfmt_script.c”定义的函数load_script负责装载脚本程序,主要步骤如下。

图2.16 装载脚本程序

(1)检查前两字节是不是脚本程序的标识符。

(2)解析出解释程序的名称和参数。

(3)从用户栈删除第一个参数,然后依次把脚本程序的文件名称、传给解释程序的参数和解释程序的名称压到用户栈。

(4)调用函数open_exec打开解释程序文件。

(5)调用函数prepare_binprm设置进程证书,然后读取解释程序文件的前128字节到缓冲区。

(6)调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别解释程序为止。

2.6 进程退出

进程退出分两种情况:进程主动退出和终止进程。

Linux内核提供了以下两个使进程主动退出的系统调用。

(1)exit用来使一个线程退出。

void exit(int status);

(2)Linux私有的系统调用exit_group用来使一个线程组的所有线程退出。

void exit_group(int status);

glibc库封装了库函数exit、_exit和_Exit用来使一个进程退出,这些库函数调用系统调用exit_group。库函数exit和_exit的区别是exit会执行由进程使用atexit和on_exit注册的函数。

注意:我们编写用户程序时调用的函数exit,是glibc库的函数exit,不是系统调用exit。

终止进程是通过给进程发送信号实现的,Linux内核提供了发送信号的系统调用。

(1)kill用来发送信号给进程或者进程组。

int kill(pid_t pid, int sig);

(2)tkill用来发送信号给线程,参数tid是线程标识符。

int tkill(int tid, int sig);

(3)tgkill用来发送信号给线程,参数tgid是线程组标识符,参数tid是线程标识符。

int tgkill(int tgid, int tid, int sig);

tkill和tgkill是Linux私有的系统调用,tkill已经废弃,被tgkill取代。

当进程退出的时候,根据父进程是否关注子进程退出事件,处理存在如下差异。

(1)如果父进程关注子进程退出事件,那么进程退出时释放各种资源,只留下一个空的进程描述符,变成僵尸进程,发送信号SIGCHLD(CHLD是child的缩写)通知父进程,父进程在查询进程终止的原因以后回收子进程的进程描述符。

(2)如果父进程不关注子进程退出事件,那么进程退出时释放各种资源,释放进程描述符,自动消失。

进程默认关注子进程退出事件,如果不想关注,可以使用系统调用sigaction针对信号SIGCHLD设置标志SA_NOCLDWAIT(CLD是child的缩写),以指示子进程退出时不要变成僵尸进程,或者设置忽略信号SIGCHLD。

怎么查询子进程终止的原因?Linux内核提供了3个系统调用来等待子进程的状态改变,状态改变包括:子进程终止,信号SIGSTOP使子进程停止执行,或者信号SIGCONT使子进程继续执行。这3个系统调用如下。

(1)pid_t waitpid(pid_t pid, int *wstatus, int options);

(2)int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

(3)pid_t wait4(pid_t pid, int *wstatus, int options, struct rusage *rusage);

注意:wait4已经废弃,新的程序应该使用waitpid和waitid。

子进程退出以后需要父进程回收进程描述符,如果父进程先退出,子进程成为“孤儿”,谁来为子进程回收进程描述符呢?父进程退出时需要给子进程寻找一个“领养者”,按照下面的顺序选择领养“孤儿”的进程。

(1)如果进程属于一个线程组,且该线程组还有其他线程,那么选择任意一个线程。

(2)选择最亲近的充当“替补领养者”的祖先进程。进程可以使用系统调用prctl(PR_SET_CHILD_SUBREAPER)把自己设置为“替补领养者”(subreaper)。

(3)选择进程所属的进程号命名空间中的1号进程。

2.6.1 线程组退出

系统调用exit_group实现线程组退出,执行流程如图2.17所示,把主要工作委托给函数do_group_exit,执行流程如下。

图2.17 线程组退出的执行流程

(1)如果线程组正在退出,那么从信号结构体的成员group_exit_code取出退出码。

(2)如果线程组未处于正在退出的状态,并且线程组至少有两个线程,那么处理如下。

1)关中断并申请锁。

2)如果线程组正在退出,那么从信号结构体的成员group_exit_code取出退出码。

3)如果线程组未处于正在退出的状态,那么处理如下。

4)释放锁并开中断。

(3)当前线程调用函数do_exit以退出。

假设一个线程组有两个线程,称为线程1和线程2,线程1调用exit_group使线程组退出,线程1的执行过程如下。

(1)把退出码保存在信号结构体的成员group_exit_code中,传递给线程2。

(2)给线程组设置正在退出的标志。

(3)向线程2发送杀死信号,然后唤醒线程2,让线程2处理杀死信号。

(4)线程1调用函数do_exit以退出。

线程2退出的执行流程如图2.18所示,线程2准备返回用户模式的时候,发现收到了杀死信号,于是处理杀死信号,调用函数do_group_exit,函数do_group_exit的执行过程如下。

图2.18 线程2退出的执行流程

(1)因为线程组处于正在退出的状态,所以线程2从信号结构体的成员group_exit_code取出退出码。

(2)线程2调用函数do_exit以退出。

线程2可能在以下3种情况下准备返回用户模式。

(1)执行完系统调用。

(2)被中断抢占,中断处理程序执行完。

(3)执行指令时生成异常,异常处理程序执行完。

函数do_exit的执行过程如下。

(1)释放各种资源,把资源对应的数据结构的引用计数减一,如果引用计数变成0,那么释放数据结构。

(2)调用函数exit_notify,先为成为“孤儿”的子进程选择“领养者”,然后把自己的死讯通知父进程。

(3)把进程状态设置为死亡(TASK_DEAD)。

(4)最后一次调用函数__schedule以调度进程。

死亡进程最后一次调用函数__schedule调度进程时,进程调度器做了如下特殊处理。

kernel/sched/core.c
__schedule() -> context_switch() -> finish_task_switch()
1   static struct rq *finish_task_switch(struct task_struct *prev)
2    __releases(rq->lock)
3   {
4    …
5    prev_state = prev->state;
6    …
7    if (unlikely(prev_state == TASK_DEAD)) {
8         if (prev->sched_class->task_dead)
9              prev->sched_class->task_dead(prev);
10        …
11        put_task_stack(prev);
12        put_task_struct(prev);
13   }
14   …
15  }

第8行和第9行代码,执行调度类的task_dead方法。

第11行代码,如果结构体thread_info放在进程描述符里面,而不是放在内核栈的顶部,那么释放进程的内核栈。

第12行代码,把进程描述符的引用计数减1,如果引用计数变为0,那么释放进程描述符。

2.6.2 终止进程

系统调用kill(源文件“kernel/signal.c”)负责向线程组或者进程组发送信号,执行流程如图2.19所示。

(1)如果参数pid大于0,那么调用函数kill_pid_info来向线程pid所属的线程组发送信号。

(2)如果参数pid等于0,那么向当前进程组发送信号。

(3)如果参数pid小于−1,那么向组长标识符为-pid的进程组发送信号。

(4)如果参数pid等于−1,那么向除了1号进程和当前线程组以外的所有线程组发送信号。

函数kill_pid_info负责向线程组发送信号,执行流程如图2.20所示,函数check_kill_permission检查当前进程是否有权限发送信号,函数__send_signal负责发送信号。

图2.19 系统调用kill的执行流程

图2.20 向线程组发送信号的执行流程

函数__send_signal的主要代码如下:

kernel/signal.c
1   static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
2              int group, int from_ancestor_ns)
3   {
4    struct sigpending *pending;
5    struct sigqueue *q;
6    int override_rlimit;
7    int ret = 0, result;
8   
9    …
10   result = TRACE_SIGNAL_IGNORED;
11   if (!prepare_signal(sig, t,
12             from_ancestor_ns || (info == SEND_SIG_FORCED)))
13        goto ret;
14   
15   pending = group ? &t->signal->shared_pending : &t->pending;
16   
17   result = TRACE_SIGNAL_ALREADY_PENDING;
18   if (legacy_queue(pending, sig))
19        goto ret;
20   
21   …
22   if (sig < SIGRTMIN)
23        override_rlimit = (is_si_special(info) || info->si_code >= 0);
24   else
25        override_rlimit = 0;
26   
27   q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
28        override_rlimit);
29   if (q) {
30        list_add_tail(&q->list, &pending->list);
31        …
32   } else if (!is_si_special(info)) {
33        …
34   }
35   
36  out_set:
37   signalfd_notify(t, sig);
38   sigaddset(&pending->signal, sig);
39   complete_signal(sig, t, group);
40  ret:
41   …
42   return ret;
43  }

第11~13行代码,如果目标线程忽略信号,那么没必要发送信号。

第15行代码,确定把信号添加到哪个信号队列和集合。线程组有一个共享的信号队列和集合,每个线程有一个私有的信号队列和集合。如果向线程组发送信号,那么应该把信号添加到线程组共享的信号队列和集合中;如果向线程发送信号,那么应该把信号添加到线程私有的信号队列和集合中。

第18行代码,如果是传统信号,并且信号集合已经包含同一个信号,那么没必要重复发送信号。

第22~25行代码,判断分配信号队列节点时是否可以忽略信号队列长度的限制:对于传统信号,如果是特殊的信号信息,或者信号的编码大于0,那么允许忽略;如果是实时信号,那么不允许忽略。

第27行和第28行代码,分配一个信号队列节点。

第29行和第30行代码,如果分配信号队列节点成功,那么把它添加到信号队列中。

第37行代码,如果某个进程正在通过信号文件描述符(signalfd)监听信号,那么通知进程。signalfd是进程创建用来接收信号的文件描述符,进程可以使用select或poll监听信号文件描述符。

第38行代码,把信号添加到信号集合中。

第39行代码,调用函数complete_signal:如果向线程组发送信号,那么需要在线程组中查找一个没有屏蔽信号的线程,唤醒它,让它处理信号。

上一节已经介绍过,当线程准备从内核模式返回用户模式时,检查是否收到信号,如果收到信号,那么处理信号。

2.6.3 查询子进程终止原因

系统调用waitid的原型如下:

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

参数idtype指定标识符类型,支持以下取值。

(1)P_ALL:表示等待任意子进程,忽略参数id。

(2)P_PID:表示等待进程号为id的子进程。

(3)P_PGID:表示等待进程组标识符是id的任意子进程。

参数options是选项,取值是0或者以下标志的组合。

(1)WEXITED:等待退出的子进程。

(2)WSTOPPED:等待收到信号SIGSTOP并停止执行的子进程。

(3)WCONTINUED:等待收到信号SIGCONT并继续执行的子进程。

(4)WNOHANG:如果没有子进程退出,立即返回。

(5)WNOWAIT:让子进程处于僵尸状态,以后可以再次查询状态信息。

系统调用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的取值如下。

(1)大于0,表示等待进程号为pid的子进程。

(2)等于0,表示等待和调用进程属于同一个进程组的任意子进程。

(3)等于-1,表示等待任意子进程。

(4)小于-1,表示等待进程组标识符是pid的绝对值的任意子进程。

参数options是选项,取值是0或者以下标志的组合。

(1)WNOHANG:如果没有子进程退出,立即返回。

(2)WUNTRACED:如果子进程停止执行,但是不被ptrace跟踪,那么立即返回。

(3)WCONTINUED:等待收到信号SIGCONT并继续执行的子进程。

以下选项是Linux私有的,和使用clone创建子进程一起使用。

(1)__WCLONE:只等待克隆的子进程。

(2)__WALL:等待所有子进程。

(3)__WNOTHREAD:不等待相同线程组中其他线程的子进程。

系统调用waitpid、waitid和wait4把主要工作委托给函数do_wait,函数do_wait的执行流程如图2.21所示,遍历当前线程组的每个线程,针对每个线程遍历它的每个子进程,如果是僵尸进程,调用函数eligible_child来判断是不是符合等待条件的子进程,如果符合等待条件,调用函数wait_task_zombie进行处理。

图2.21 函数do_wait的执行流程

函数wait_task_zombie的执行流程如下。

(1)如果调用者没有传入标志WEXITED,说明调用者不想等待退出的子进程,那么直接返回。

(2)如果调用者传入标志WNOWAIT,表示调用者想让子进程处于僵尸状态,以后可以再次查询子进程的状态信息,那么只读取进程的状态信息,从线程的成员exit_code读取退出码。

(3)如果调用者没有传入标志WNOWAIT,处理如下。

1)读取进程的状态信息。如果线程组处于正在退出的状态,从线程组的信号结构体的成员group_exit_code读取退出码;如果只是一个线程退出,那么从线程的成员exit_code读取退出码。

2)把状态切换到死亡,释放进程描述符。


本文节选自《Linux内核深度解析》第2章,作者余华兵。更多详情:https://www.epubit.com/book/detail/39747


{-:-} 福利

从今天起至5月12日,你随时可以在**异步社区**本文评论区,留下你在Linux学习过程中的思考和困惑。我会邀请作者进行解答。被选中问题的用户将会获赠《Linux内核深度解析》的纸书,帮助你成为Linux内核管理的高手。