0x24_LinuxKernel_进程(一)进程的管理(生命周期、进程表示)

https://github.com/carloscn/blog/issues/8

0x24_LinuxKernel_进程(一)进程的管理(生命周期、进程表示)

在UNIX操作系统下运行的应用程序、服务器以及其他程序都被称为进程。每个进程都在CPU的虚拟内存上分配地址空间。每个进程的地址空间都是独立的,因此进程和进程不会意识对方的存在,觉得自己是CPU上唯一运行的进程。从处理器的角度,系统上有几个CPU,就最多能运行几个进程。但Linux是一个多任务的系统,内核为了支持多任务需要在不同的进程之间切换,这样从时间维度的划分造成了多进程同时运行的假象。

内核借助CPU的帮助,负责进程切换,欺骗进程独占了CPU。这就需要在切换进程之前保存进程的所有状态及相关要素,并将进程置于IDLE状态。在进程切换回来之前,这些资源和信息需要恢复到CPU和内存上面。这些资源被成为进程上下文

内核除了要保存进程一些必要信息之外。还需要合理分配每一个进程的时间片,通常重要的进程得到CPU的时间多一点,次要的进程时间少一点。这个时间分配的过程成为调度

本文对于进程的表述的结构如下所示:

1. 用户空间定义

这部分介绍一些从用户空间视角来观摩进程的概念。

1.1 进程树

Linux对进程的表述采用了一种层次结构,每个进程都依赖于一个父进程。内核启动init程序作为第一个进程,该进程负责进一步的系统初始化操作,并显示视提示符和登陆界面。因此init进程被称为进程树的所有的进程都直接或者间接起源自该进程。如下使用pstree程序的输出所示。

这个树形结构的进程上面和新进程的创建方式密切相关。UNIX操作系统有两种创建新进程的机制分别是fork()exec()

fork()可以创建当前进程的一个副本且有独立的PID号码(父进程和子进程只有PID号码不同)。Linux使用了一个中所周的的技术来使fork更高效,那就是写时复制(copy on write)操作。

exec()将一个新程序加载到当前的内存中执行,旧程序的内存页面被逐出,其内容被替换为新数据,然后开始执行新的程序。

1.2 线程

除了重量级进程(有时候也成为UNIX进程),还有一个轻量级进程(线程)。本质上,一个进程包含若干个线程,这些线程使用着同样的地址空间,共享同样的数据和资源,因此除了使用同步的方法保护共享的资源之外,没有额外的通信机制了。这也是线程和进程的差别。

Linux使用clone()的方法创建线程。其工作方式类似于fork,但启用了精准的检查,以确认那些资源与父进程共享、哪些资源为线程独立创建。

1.3 用户空间进程编程

linux提供一些关于进程的系统调用,例如fork()getpid()getppid()wait()waitpid(),可以创建进程,对父进程和子进程流程的一些控制。

1.3.1 fork进程

https://github.com/carloscn/clab/blob/master/linux/test_pid/test_pid.c

两个进程会竞争一个终端输出: 但是后台是被fork了两个进程:

1.3.2 zombie进程

fork进程之后,子进程的处理程序中直接退出,而父进程处理程序中没有任何等待子进程的操作。这时候子进程就会成为僵尸进程。僵尸进程会被init 0收养,等待父进程退出之后,僵尸进程会被init 0进程释放掉。 https://github.com/carloscn/clab/blob/master/linux/test_pid/test_pid_zombie.c

子进程退出之后,它成为僵尸进程。

父进程使用waitpid可以观测到子进程是否已经退出,以及时回收进程资源。

2. 进程的管理和调度

2.1 进程优先级

进程优先级粗暴分为实时进程和非实时进程:

  • **硬实时进程:**有严格的时间限制/Linux不支持硬实时/一些linux旁支版本RTLinux支持硬实时,主要原因是调度器没有做进内核中,内核作为一个独立的进程处理一些不仅要的任务。

  • Linux的任务优先满足吞吐量,所以弱化了进程调度。但是这些年人们也在降低内核延迟上面做了很多研究,比如提出可抢占机制、实时互斥内核锁还有完全公平调度器。

  • 软实时进程:类似与写CD这种工作种类的进程,就算是写进程被暂时中断也不会造成宕机之类的风险操作。

  • 普通进程: 没有具体的时间约束限制,但是会分配优先级来区分重要性。

进程优先级模型转换为时间片长度,优先级越高的占用的时间片越多。这种方案被称为抢占式多任务处理(preemptive multitasking),即各个进程都被分配到一定的时间片用来执行。时间到了之后,内核会从当前进程收回控制权,让不同的进程运行。抢占的时候所有的CPU寄存器的内容和页表都会保存起来,因此其结果并不会因为进程的切换而丢失。

2.2 进程的生命周期

2.2.1 状态机

一个进程是有状态机可以表示的,我们可以分为:**运行(running)、等待(waiting)、休眠(sleeping)和终止(stopped) **四个状态,如图所示:

状态机的切换是调度器来完成,切换条件可以解释为:

① Running -> Sleeping:如果进程必须等待事件,则状态变为“运行”直接切换到“睡眠”,但这个过程是不可逆的。 ② Running -> Waiting: 调度器决定从进程收回资源的时候,过程状态从R变为W。 ③ Sleeping -> Waiting:正如①所说,Sleeping没有办法直接变回Running,必须有一个中间状态waiting。 ④ Waiting -> Running: CPU此时把资源分配给其他进程。在调度器授予CPU时间之前,进程一直保持waiting的状态。在分配CPU之后,其状态变为Running。

不在于周期范围内的进程:“僵尸进程(zombie)”:子进程被KILL信号(SIGTERM和SIGKILL)杀死,父进程没有调用wait4()。正常流程是,子进程被KILL,父进程调用wait4系统调用,通知内核子进程已死。处于僵尸进程状态进程:

  • 还会在进程表中(ps/top能刷出进程)

  • 占用很少的资源

  • 重启后才能刷掉该进程

2.2.2 抢占式多任务处理

  • 进程执行分为内核态和用户态,最大区别在于,内存地址区域访问划分不同。

  • 进内核态方法一:如果用户态进程进入内核态:访问共享数据,文件系统空间,必须通过系统调用,才能进入到内核态。

  • 进内核态方法二:中断触发进入内核态。

  • 中断触发和系统调用可以使用户态进入到内核态,但用户态是主动调用的,中断是外部触发的。

    • 用户态 < 核心态 < 中断

  • 进程抢占层次:

    • 普通进程总会被抢占

    • 进程处于内核态(或者普通进程处于系统调用期间)无法被任何进程抢占,但中断可以抢占内核态,因此要求中断不能占用太长时间

    • 中断可以暂停用户态进程,甚至和内核进程都可以被暂定

    • 内核抢占(kernel preemption)被加入到内核配置中,允许进程在紧急状态下抢占用户进程或者内核进程。优点:减少等待时间,让进程更“平滑”的执行;缺点:增加内核复杂程度。

2.3 进程的表示

Linux有一套自己对于进程管理的方式,还有调度器,调度器可以理解为真正去执行和指挥进程如何运行的东西,而管理方式可以说是调度器去管理进程的一个笔记计划表(task_struct)在include/sched.h中,这里有如何管理进程,进程命名,编号法等等。

2.3.1 task结构体

-Linux 通常把process當做是taskPCB (processing control block) 通常也稱為 struct tast_struct

https://elixir.bootlin.com/linux/v3.19.8/source/include/linux/sched.h#L1274

成员非常多,弄清楚费劲,可以分类:

  • 状态和执行信息,如待决信号、进程的二进制格式种类、进程PID、父进程地址、优先级和CPU时间。

  • 分配的虚拟内存信息。

  • 身份凭据,如用户ID、组ID和权限等。

  • 使用的文件(包括程序代码的二进制文件)

  • 线程信息记录,CPU的运行时间数据。

  • 与其他进程通信有关的信息。

  • 该进程所用的信号处理,用于响应到来的信号。

2.3.2 进程状态机

  • TASK_RUNNING: 处于可运行状态,未必处于CPU cover时间也有可能是等待调度器的状态。

  • TASK_INTERRUPTIBLE: 针对某时间或者其他资源的睡眠进程设置的。

  • TASK_UNINTERRUPTIBLE:用于内核指示而停用的睡眠进程。不能由外部唤醒,只能由内核亲自唤醒。

  • TASK_STOPPED:进程特意停止运行,例如,由调度器暂停。

  • TASK_TRACED:本来不是进程状态,用于停止的进程(ptrace机制)与常规停止进程区分开。

  • EXIT_ZOMBIE:僵尸状态

  • EXIT_DEAD:wait系统调用已发出,解除僵尸状态。

2.3.3 进程资源限制

  • rlim_cur: 进程当前的资源限制,成为软限制(soft limit)

  • rlim_max:该限制的最大容许值, 硬限制(hard limit)

用户进程通过setrlimit()系统调用来增减当前限制,最大值不能超过rlim_max;getrlimits()用于检查当前限制。那么设定的资源是什么呢?

cat /proc/self/limits来查看当前系统设定进程的资源限制。

Linux系统启动的时候会设定好当前资源限制的属性,在include/asm-generic/resource.h中定义进程的资源限制,在linux启动的时候通过init进程完成配置。

在init_task.h中挂载该数组。

2.3.4 进程类型

进程是由(二进制代码应用程序)、(单线程)、分配给应用程序的资源(内存、文件)。新进程是使用(fork)或者(exec)系统调用产生的。

  • fork生成当前进程的副本,称为子进程,复制为两份一样的独立的进程,资源是分开的,资源都是copy的两份,不再系统上做任何关联。

  • exec从一个可执行二进制加载另一个应用程序,来替代当前运行的进程。exec不是创建新的进程,首先使用fork复制一份旧的程序,然后调用exec在系统上创建一个应用程序。

  • 旧版本的clone调用,原理和fork一致,可以共享父进程的一些资源,但属于线程范畴

2.3.5 命名空间

命名空间是解决进程之间权限访问问题的一种设计机制。Linux的全局管理特性,比如PID、UID和系统调用uname返回系统的信息都是全局调用。因此就导致资源和重用的问题,在虚拟化中亟需解决。把全局资源通过命名空间抽象出来,划分不同的命名空间对应不同的资源分配,可实现虚拟化环境。

父命名空间生成多个子命名空间,子命名空间有映射关系到父命名空间。

a) 命名空间的数据结构

创建新进程的使用使用fork可以建立一个新的命名空间,所以在fork的时候需要标记创建新的命名空间的类别。

在task_struct里面有有关于命名空间的定义:

struct task_struct里面挂的是stcut nsproxy的指针,只要挂上不同命名空间的指针,就算是赋予不同的命名空间了。

NOTE: 命名空间的支持必须在编译的时候启动 General setup -> Namespaces support,而且必须逐一指定需要支持的命名空间。如果内核编译的时候没有指定命名空间的支持,默认的命名空间的作用则类似于不启用命名空间,所有的属性相当于全局的。

b) UTS命名空间和用户命名空间

b.1) UTS命名空间

UTS命名空间是Linux内核Namespace(命名空间)的一个子系统,主要用来完成对容器HOSTNAME和domain的隔离,同时保存内核名称、版本、以及底层体系结构类型等信息。

就一个名字和kref,引用计数器,可以跟踪内核中有多少地方使用了struct uts_namespace的实例。

b.2) 用户命名空间

用户命名空间在数据结构管理方面类似于UTS:在要求创建新的用户命名空间时,则生成当前用户命名空间的一份副本,并关联到当前进程的nsproxy实例。但用户命名空间自身的表示要稍微复杂一些:

2.3.6 进程ID号

我们耳熟能详的PID(process id)在其命名空间中唯一的标识号码,我们也可以通过PID找到一个进程,对进程进行操作。在进程的领域,并不是只有PID,也有其他很多的ID类型。这些概念延伸至进程组。

Linux对于PID是要进行管理的,而管理手段对PID进行数据结构的表述,这些数据结构一只脚踏入task_struct中,还有一只脚在命名空间映射,而且对于繁琐的PID的数据结构查找,Linux内核也提供了若干辅助函数用于扫描PID的数据结构。

PID的分配,首要保证的是PID在命名空间上的唯一性,内核提供了这样的方法,alloc_pidmapfree_pidmap这样的方法可以分配和释放PID。

a) 进程组的TGID

(1)进程组

也称之为作业,BSD与1980年前后向UNIX中增加的一个新特性,代表一个或多个进程的集合。每个进程都属于一个进程组,在waitpid函数和kill函数的参数中都曾经使用到,操作系统设计的进程组的概念,是为了简化对多个进程的管理。

当父进程创建子进程的时候,默认子进程与父进程属于同一个进程组,进程组ID等于进程组第一个进程ID(组长进程)。所以,组长进程标识:其进程组ID等于其进程ID.

组长进程可以创建一个进程组,创建该进程组的进程,然后终止,只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。

(2)kill发送给进程组

使用 kill -n -pgid 可以将信号 n 发送到进程组 pgid 中的所有进程。例如命令 kill -9 -4115 表示杀死进程组 4115 中的所有进程。

每个进程除了PID之外还有TGID(进程组的ID),组长(group leader)的PID = TGID。

在userspace提供setpgidgetpgrpgetpgid的系统调用。

这部分可以做一个实验,https://github.com/carloscn/clab/blob/master/linux/test_pid/test_process_grp.c

b) 管理PID

除了pidtgid还需要其他成员来管理PID,如下结构体:

c) 管理PID函数

生成唯一的PID:

d) 进程关系

除了ID链接之外,内核还负责管理建立在UNIX进程创建模型之上的“家族关系”。术语如下:

  • 如果是进程A分支形成了B,那么A成为B的父进程,而B成为A的子进程。

  • 如果A分支形成了B1,B2,B3,...,Bn,这些Bx称为兄弟关系。

  • children是链表表头,该链表保存所有进程的子进程;

  • sibling用于将兄弟进程彼此联系起来。

task_struct之间的互相联系可以表现如图所示:

3. 进程管理相关的系统调用

在这部分,我们来讨论forkexec的系统调用实现。这部分其实在# 13_ARMv8_内存管理(一)-内存管理要素arrow-up-right 有提及,但是是从内存管理的角度来看(内存分页技术在fork上面提供了写时复制功能,为其效率提升做了很大的贡献)。在这一节我们来了解一下fork如何实现的。

同时,我们把fork的系统调用实现,归纳到# 0x21_LinuxKernel_内核活动(一)之系统调用arrow-up-right 的“可用系统调用”章节中去。

3.1 进程复制

在Linux系统中,不仅仅只有一个fork系统调用,还有其他系统调用,例如vforkclonefork进程依赖于写时复制技术的实现。forkvforkclone调用的内核函数分别是sys_forksys_vforksys_clone函数。三个函数最终也都调用了do_fork函数,这个函数内部大多数工作都是由copy_process复制进程的内核函数完成的。

进程内部至少包含一个线程,因此进程复制中比较重要的处理过程就是对于线程的复制。每个线程都有自己独立的栈空间,因此这部分要很谨慎小心的处理。

进程复制涉及方方面面,比如对于进程一些共享信息的处理(内存),shmat这样的系统调用就强调了在进程复制之前,复制后的行为。

3.1.1 写时复制

关于进程的系统调用分为:

  • fork():属于重量级调用,因为其建立了一个完整的进程副本,然后作为子进程去执行。为了减少工作量,linux内核使用了写时复制的的技术。由于写时复制技术的实现,vfork速度方面不再有优势,应避免使用vfork。

  • vfork():类似于fork(),但是不会创建一个进程副本,父进程和子进程之间共享一份数据。好处就是节约了大量的CPU,而坏处是,父进程或者子进程之间任意一个成员修改数据都能影响到对方。vfork的设计用于子进程形成之后立即执行execve系统调用加载新程序的情形。在子进程退出或者加载新程序之前,内核保证父进程处于阻塞状态

  • clone():产生线程,可以对父子进程之间的共享、复制进行精准控制。

写时复制(Copy-on-write, COW)技术,防止了父进程被复制之后建立真的数据,避免了使用大量的内存,复制时操作耗费更多的时间。如果复制之后执行exec立即加载程序,那么负面效应更加严重,甚至复制都是多余的,因为exec会把新数据的内存替换到当前进程的内存位置,然后运行。

写时复制技术在底层实现的是在fork的时候,只复制父进程的页表,而不是复制真正的数据,而且父子进程不允许修改彼此的物理页(共享内存的物理页除外), 这种实现很简单,通过标记页表为只读属性,无论父子进程在尝试访问内存的时候,由于访问了只读属性的页面,处理器会向内核报告访问段错误,接着在段错误的处理函数中开始真正的复制数据。

COW机制使得内核可能尽可能的延迟内存的复制,更重要的是,实际上在很多情况下不需要复制,这样节约了大量的时间。

3.1.2 执行系统调用

do_fork

do_fork:https://elixir.bootlin.com/linux/v3.19.8/source/kernel/fork.c#L1626

NPTL(Native Poxsix Threads Library)库线程需要实现这个两个参数。

参数
描述

clone_flags

与clone()参数flags相同, 用来控制进程复制过的一些属性信息, 描述你需要从父进程继承那些资源。该标志位的4个字节分为两部分。最低的一个字节为子进程结束时发送给父进程的信号代码,通常为SIGCHLD;剩余的三个字节则是各种clone标志的组合(本文所涉及的标志含义详见下表),也就是若干个标志之间的或运算。通过clone标志可以有选择的对父进程的资源进行复制;

stack_start

与clone()参数stack_start相同, 子进程用户态堆栈的地址

regs

是一个指向了寄存器集合的指针, 其中以原始形式, 保存了调用的参数, 该参数使用的数据类型是特定体系结构的struct pt_regs,其中按照系统调用执行时寄存器在内核栈上的存储顺序, 保存了所有的寄存器, 即指向内核态堆栈通用寄存器值的指针,通用寄存器的值是在从用户态切换到内核态时被保存到内核态堆栈中的(指向pt_regs结构体的指针。当系统发生系统调用,即用户进程从用户态切换到内核态时,该结构体保存通用寄存器中的值,并被存放于内核态的堆栈中)

stack_size

用户状态下栈的大小, 该参数通常是不必要的, 总被设置为0

parent_tidptr

与clone的ptid参数相同, 父进程在用户态下pid的地址,该参数在CLONE_PARENT_SETTID标志被设定时有意义

child_tidptr

与clone的ctid参数相同, 子进程在用户太下pid的地址,该参数在CLONE_CHILD_SETTID标志被设定时有意义

linux-4.2之后选择引入一个新的CONFIG_HAVE_COPY_THREAD_TLS,和一个新的COPY_THREAD_TLS接受TLS参数为额外的长整型(系统调用参数大小)的争论。改变sys_clone的TLS参数unsigned long,并传递到copy_thread_tls。新版本的系统中clone的TLS设置标识会通过TLS参数传递, 因此_do_fork替代了老版本的do_fork。

sys_fork

从设计层次来看,sys_fork是架构级定义(在arch/xxx/kernel目录下),调用linux/kernel下实现的do_fork实现。

早期内核2.4.31版本都在自己的架构目录上实现:

新版本例如4.1.15版本的内核把sys_fork已经去掉,换成:

我们可以看到唯一使用的标志是SIGCHLD。这意味着在子进程终止后将发送信号SIGCHLD信号通知父进程。由于写时复制(COW)技术,最初父子进程的栈地址相同,但是如果操作栈地址闭并写入数据,则COW机制会为每个进程分别创建一个新的栈副本。如果do_fork成功,则新建进程的pid作为系统调用的结果返回,否则返回错误码。

sys_vfork

早期内核2.4.31版本都在自己的架构目录上实现:

同样,新版本例如4.1.15版本的内核把sys_vfork已经去掉,换成:

可以看到sys_vfork的实现与sys_fork只是略微不同, 前者使用了额外的标志CLONE_VFORK | CLONE_VM

sys_clone

早期内核2.4.31版本都在自己的架构目录上实现:

同样,新版本例如4.1.15版本的内核把sys_clone已经去掉,换成:

我们可以看到sys_clone的标识不再是硬编码的,而是通过各个寄存器参数传递到系统调用,因而我们需要提取这些参数。其次,clone也不再复制进程的栈,而是可以指定新的栈地址,在生成线程时,可能需要这样做,线程可能与父进程共享地址空间, 但是线程自身的栈可能在另外一个地址空间。 另外,还指令了用户空间的两个指针(parent_tidptr和child_tidptr), 用于与线程库通信。

3.1.3 进程的生命周期

下图是一个进程的生命周期和相应的探针点:

Unix 进程生成分为两个阶段:

  • 父进程调用 fork() 系统调用。kernel 创建一个父进程的副本, 包括地址空间(在 copy-on-write 模式下),打开的文件,分配一个新的 PID。 如果 fork() 调用成功,这个将返回在两个进程的上下文中,这个有同一个指令指针(PC 指针是一样的) 在子进程中随后的代码通常用来关闭文件,重置信号等。

  • 子进程调用 execve() 系统调用,这个将使用新的 based 传递给 execve() 来替换掉进程的地址空间。

当调用 exit() 系统调用,子进程将结束。 但是,进程也可以会被 killed,当 kernel 出现不正确的条件(引发 kernel oops) 或者机器错误。 如果父进程像等待子进程结束,这个可以调用 wait() 系统调用(或者 waitid()), wait() 调用将收到进程的退出码,随后关联的 task_struct 将被销毁。 如果父进程不像等待子进程,子进程退出后,这个将被作为僵尸进程。 父进程可能会收到 kernel 发送的 SIGCHLD 信号。

3.1.4 do_fork的实现

do_fork overview

do_fork无论是最新版还是老的linux版本,都是内核级的实现,这部分给架构级的sys_fork函数调用。从这里可以看出,这部分已经不是和平台相关的代码了,纯属内核内部的软件逻辑。kernel/fork.c

  • do_fork以调用copy_process开始,后者执行新进程的实际工作(收尾工作)。我们暂时不去理会copy_process内部做了什么。

  • 确定PID,这部分涉及两种逻辑。有无创建新的命名空间,fork如果创建了新的命名空间,则调用pid_nr_ns;如果没有创建命名空间只在局部PID获取即可task_pid_vnr

  • 如果进程使用ptrace监控新的进程,创建进程之后还要发送SIGSTOP信号,以便调试器检查其数据。

  • 关于调度方面,子进程使用wake_up_new_task唤醒。

  • 在fork进程的时候为了防止父进程修改数据,父进程需要阻塞,通过完成机制丰富设计。

copy_process

copy_processdo_fork中核心函数,任务是完成父进程的复制功能,这里面必须包含三个系统调用的请求处理fork\vfork\clone

这个复制过程比较复杂,我们大部分过程略过,具体解析参考《深入Linux内核架构》P55-P61,原版参考P66-P75。

thread问题

父进程的PCB实例只有一个成员不同:新进程分配了一个新的内核态栈,task_struct->stack。通常栈和thread_info保存在一个联合体中,thread_info保存了线程所需要的所有特定处理器的底层信息。

关系可以看:

在内核的某个特定组件使用了过多的栈空间的时候,内核栈就会溢出到thread_info上,这可能会出现严重的故障。在这种情况下,调用栈回溯的时候就会导致错误的信息出现,因此内核提供了kstack_end函数,用于判断给出的地址是否位于栈的有效部分。

Linux 並沒有特定的data structure來標示thread or process,thread與process都使用process的PCB。

3.2 内核线程

在linux系统中, 我们接触最多的莫过于用户空间的任务,像用户线程或用户进程,因为他们太活跃了,也太耀眼了以至于我们感受不到内核线程的存在,但是内核线程却在背后默默地付出着,如内存回收,脏页回写,处理大量的软中断等,如果没有内核线程那么linux世界是那么的可怕!在进入我们真正的主题之前,我们需要知道一下事实:

  • 内核线程永远运行于内核态绝不会跑到用户态去执行。

  • 由于内核线程运行于内核态,所有它的权限很高,请注意这里说的是权限很高并不意味着它的优先级高,所有他可以直接做到操作页表,维护cache, 读写系统寄存器等操作。

  • 内核线性是没有地址空间的概念,准确的来说是没有用户地址空间的概念,使用的是所有进程共享的内核地址空间,但是调度的时候会借用前一个进程的地址空间。

  • 内核线程并没有什么特别神秘的地方,他和普通的用户任务一样参与系统调度,也可以被迁移到任何cpu上运行。

  • 每个cpu都有自己的idle进程,实质上也是内核线程,但是他们比较特殊,一来是被静态创建,二来他们的优先级最低,cpu上没有其他进程运行的时候idle进程才运行。

  • 除了初始化阶段0号内核线程和kthreadd本身,其他所有的内核线程都是被kthreadd内核线程来间接创建。

我们知道linux所有任务的祖先是0号进程,然后0号进程创建了天字第一号的1号init进程,init进程是所有用户任务的祖先,而内核线程同样也有自己的祖先那就是kthreadd内核线程他的pid是2,我们通过top命令可以观察到:红色方框都是父进程为2号进程的内核线程,绿色方框为kthreadd,他的父进程为0号进程。

这里面有一个知识点惰性TLB(lazy TLB),把task_struct里面的mm_struct设定为空指针将成为惰性TLB进程。假设内核线程之后运行的进程与之前是同一个,在这种情况下,内核并不需要修改用户空间的地址表,地址表转换后备缓冲器(TLB)中的信息仍然有效。只有在内核线程执行的进程是与此前不同的用户层才需要切换,清除TLB数据。

创建内核线程的辅助方法是,kthread_create

该函数创建一个新的内核线程,命名为namefmt。最初线程是挺值得,需要使用wake_up_process启动它。词汇会调用kthreadfn给出线程函数。

另一个备选方案是通过kthread_run宏来代替。

使用ps fax可以输出方括号的进程为内核线程所属进程,与普通进程区分。

3.3 启动新程序

Linux提供execve系统调用启动新的程序,通过新代码替换现存程序。

3.3.1 execve实现

该系统调用的入口节点是sys_execve函数,早期内核2.4.31版本都在自己的架构目录上实现:

  • 首先要打开可执行文件,内核找到相关的inode生成一个文件描述符;

  • bprm_init处理几个管理型的任务,包括mm_struct初始化,mm_init用于栈创建;

  • prepare_binprm:用于提供一些父进程相关的值UID和GID;

  • 剩下是处理参数的列表,环境文件名等。Linux支持执行文件的各种不同组织,标准格式是ELF。

  • search_binary_handler用于do_execve结束之后查找一个适当的二进制格式,用于执行特定的文件。

    • 释放原始进程的所有资源。

    • 将应用程序映射到虚拟地址空间中。必须考虑下列段的处理:

      • text段包含程序的可执行代码。start_codeend_code指定该段在地址空间驻留区域。

      • 预先初始化数据(在编译阶段指定了具体值的变量)位于start_dataend_data之间。

      • 堆空间用于动态内存分配。start_brkbrk指定边界。

      • 栈位置start_stack定义。几乎所有的寄存机栈都是自动向下增长的。唯一的例外是PA-RISC。对于栈反向增长,体系结构相关部分的实现必须告知内核,可以通过设定STACK_GROWSUP完成。

      • 程序的环境变量和参数也需要映射到虚拟空间。arg_startarg_end之间,还有env_endenv_start

除了ELF格式,还有几种Linux支持的二进制格式,这里列举作为参考:

3.3.2 解释二进制

在linux内核中,每种二进制都表示下列数据结构的实例:

  • load_binary:用于加载普通程序

  • load_shlib:用于加载共享库

  • core_dump:用于在程序错的情况下输出内存转储。内存转储随后可以使用调试器,例如gdb分析,以便解决问题。

  • min_coredump是生成内存转储时,内存转储文件长度的下界。

注意每种二进制格式首先必须使用resgister_binfmt像内核注册。该函数的目的是向一个链表增加一种新的二进制格式。

3.3.3 退出进程

进程必须exit系统调用终止。这使得内核有机会将该进程的资源释放回系统。该调用入口点是sys_exit函数,需要一个错误码作为其参数。很快退出进程调度委托给do_exit。简言之,该函数的实现就是将各个引用计数器-1。

4. 参考文献

最后更新于