Linux操作系统分析学习心得
学习收获
通过课程对linux操作系统分析的学习,我收获了很多针对linux系统分析的知识。课程从用户的角度对linux的环境及其使用进行了简单的介绍,然后通过linux操作系统源代码分析了linux操作系统与底层硬件、上层应用之间的结构关系、系统调用关系,熟悉了Linux操作系统的配置、安装过程,掌握了linux操作系统的内核关键技术,例如中断和异常处理、地址空间管理、内存分配技术、进程管理、进程切换关键代码、进程间通信机制、文件系统管理等内容,最后还介绍了虚拟机和容器等前沿技术与发展情况,开阔了视野。
学习课程的过程有一种见微知著的感觉,课程资料以小见大地让人领略了linux源码的分析与学习的方法,为以后的学习工作中更深入地学习linux操作系统提供了工具和手段。
linux中的进程管理
通过课程由浅入深的教学,以及参考了教学书籍和课件的资料,下面简要总结进程的描述和创建原理。
进程的描述
操作系统内核中最核心的功能是进程管理,谈到进程管理就要涉及到进程的描述。进程的描述有提纲挈领的作用,它可以把内存管理、文件系统、进程间通信等内容串起来。Linux内核中的进程是非常复杂的,在操作系统原理中可知,我们通过进程控制块PCB描述进程,为了管理进程,内核要描述进程的结构,我们也称其为进程描述符,进程描述符直接或间接提供了进程相关的所有信息。
在Linux内核中是struct task_struct来描述进程,以下代码摘录了struct task_struct 数据结构的一部分,具体见 include/linux/sched.h 文件。
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
/*
* For reasons of header soup (see current_thread_info()), this
* must be the first element of task_struct.
*/
struct thread_info thread_info;
#endif
/* -1 unrunnable, 0 runnable, >0 stopped: */
volatile long state;
/*
* This begins the randomizable portion of task_struct. Only
* scheduling-critical items should be added above here.
*/
randomized_struct_fields_start
void *stack;
...
/* CPU-specific state of this task: */
struct thread_struct thread;
/*
* WARNING: on x86, 'thread_struct' contains a variable-sized
* structure. It *MUST* be at the end of 'task_struct'.
*
* Do not put anything below here!
*/
完整的代码中的一些变量含义如下:其中state是进程状态,stack是堆栈等,还有比如进程的状态、进程双向链表的管理,以及控制台tty、文件系统fs的描述、进程打开文件的文件描述符files、内存管理的描述mm,还有进程间通信的信号signal的描述等。
进程的状态
操作系统原理中的进程有就绪态、运行态、阻塞态这3种基本状态,实际的Linux内核管理进程状态与三种状态并不一致。
-
使用fork()系统调用来创建一个新进程时状态为 TASK_RUNNING
-
调度器选择新创建的进程运行时,切换到运行态同样为 TASK_RUNNING
-
在Linux内核中,当进程是TASK_RUNNING状态时,它是可运行的,也就是就绪态,是否在运行取决于它有没有获得CPU的控制权
- 如果在CPU中实际执行着,进程状态就是运行态
- 如果被内核调度出去了,在等待队列里就是就绪态
-
正在运行的进程,调用用户态库函数 exit() 会陷入内核执行内核函数 do_exit() 终止进程,会进入 tsk->exit_state 进程的终止状态
-
tsk->exit_state状态的进程一般叫作僵尸进程,Linux内核会在适当的时候把僵尸进程给处理掉,处理掉之后进程描述符被释放了,该进程才从Linux系统里消失
-
阻塞态也有两种:TASK_INTERRUPTIBLE 和 TASK_UNINTERRUPTIBLE
- 如果事件发生或者资源可用,进程被唤醒并被放到运行队列上
- 如果阻塞的条件没有了就进入就绪态,调度器选择到它时就进入运行态
进程链表
Linux内核系统用一个双向链表的数据结构 struct list_head tasks 来管理进程,struct list_head tasks把所有的进程用双向链表链起来,而且还会头尾相连把所有的进程用双向循环链表链起来,数据结构具体内容如下,详见 include/linux/types.h
struct list_head tasks;//task_struct中定义的
// struct list_head数据结构具体
struct list_head {
struct list_head *next, *prev;
};
进程的父子兄弟关系
进程的描述符 struct task_struct 数据结构中的如下代码展示进程间父子关系的记录方式:
/* Real parent process: */
struct task_struct __rcu *real_parent;
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
* Children/sibling form the list of natural child
*/
struct list_head children;
struct list_head sibling;
struct task_struct *group_lead
- real_parent、parent:记录当前进程的父进程
- structlist_head children:记录当前进程的子进程的双向链表
- structlist_head sibling:记录当前进程的兄弟进程的双向链表
进程描述符总结
进程描述符中还有文件系统相关的数据结构、打开的文件描述符,有和信号处理相关以及和pipe管道相关的内容;其中,进程状态、堆栈、保存进程上下文CPU状态的thread(ip和sp等)是比较关键的,另外还有文件系统、信号、内存、进程空间等,这些在进程描述符里面有相应的结构体变量或指针,包含或指向其中的具体内容
要研究Linux内核的某一部分的特定内容,进程描述符可以起到提纲挈领的作用。进程描述符为我们进一步深入研究Linux内核提供了基础,下面可以进一步了解系统的某一方面。比如进程是怎么创建起来的,在系统中可以按相同的方式创建好多个进程,这就需要理解进程之间如何调度切换等,逐渐理解整个系统的工作机制,最终就能从整体上准确把握Linux内核的运作机制。
进程的创建
通过对进程描述符的介绍,我们了解了进程描述符的内容、包括进程状态转换、双向循环链表、thread等,下面可以了解进程创建的源头和过程。
Linux内核中进程的初始化
Linux内核0号进程的初始化,见 init/main.c 代码的 set_task_stack_end_magic(&init_task); ,init_task为第一个进程(0号进程)的进程描述符结构体变量:
struct task_struct init_task
= {
#ifdef CONFIG_THREAD_INFO_IN_TA
.thread_info = INIT_THREAD_INFO
.stack_refcount = REFCOUNT_IN
#endif
.state = 0,
.stack = init_stack,
.usage = REFCOUNT_INIT(2),
.flags = PF_KTHREAD,
.prio = MAX_PRIO - 20,
它的初始化是通过硬编码方式固定下来的。除此之外,所有其他进程的初始化都是通过do_fork复制父进程的方式初始化的。
1号和2号进程的创建是 start_kernel 初始化到最后,由 rest_ init 通过 kernel_thread 创建两个内核线程:
- kernel_init :负责用户态的进程 init 启动,是所有用户进程的祖先
- kthreadd:内核线程,是所有内核线程的祖先,负责管理所有内核线程
-
noinline void __ref rest_init(void) {… pid = kernel_thread(kernel_init, NULL, CLONE_FS); … pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES); …}
在系统启动时,除了0号进程的初始化过程是代码显式创建的,1号init进程的创建实际上是复制0号进程,根据1号进程的需要修改了进程pid等,然后再加载一个init可执行程序,后续会具体介绍加载可执行程序的过程;同样2号kthreadd内核线程也是通过复制0号进程来创建的。
通过如下 kernel/fork.c 中kernel_thread代码可以看到1号进程和2号进程最终都是通过_do_fork 创建的,用户态通过系统调用fork创建一个进程最终也是通过 _do_fork来完成的:
/*
* Create a kernel thread.
*/
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
struct kernel_clone_args args = {
.flags = ((flags | CLONE_VM | CLONE_UNTRACED) & ~CSIGNAL),
.exit_signal = (flags & CSIGNAL),
.stack = (unsigned long)fn,
.stack_size = (unsigned long)arg,
};
return _do_fork(&args);
}
_do_fork
_do_fork 创建的进程是复制产生一个子进程,并对复制的进程描述符做一些修改。在进程调度时,新创建的子进程处于就绪状态有机会被调度执行。为了讲解子进程从哪里执行,我们首先了解用户态创建进程,可以参考如下示例代码:
int main(int argc, char * argv[])
{
int pid;
/* fork another process */
pid = fork();
if (pid < 0)
{
/* error occurred */
}
else if (pid == 0)
{
/* child process */
}
else
{
/* parent process */
}
}
代码中用到了库函数的 fork(),是用户态创建一个子进程的系统调用API接口。fork系统调用把当前进程又复制了一个子进程,也就一个进程变成了两个进程,两个进程执行相同的代码,只是fork系统调用在父进程和子进程中的返回值不同。fork之后,父子进程的执行顺序和调度算法密切相关,多次执行可以看到父子进程的执行顺序并不是确定的。
进程创建的主要过程
在 Linux 系统中,进程创建的核心目标是复制当前进程(父进程)的信息以创建一个新进程(子进程)。这一过程主要通过 _do_fork 函数实现。尽管父子进程的大部分信息是相同的,但某些关键信息必须保持独立,以确保两个进程能够正确运行。以下是创建进程的框架分析:
-
复制进程描述符
task_struct:父进程的
task_struct结构体被复制到子进程中。task_struct包含了进程的所有关键信息,如进程状态、资源、文件描述符等。复制后的子进程
task_struct需要进行修改,以确保父子进程具有独立的特性,例如:- 进程标识符(PID) :子进程必须有唯一的 PID。
- 内核堆栈:子进程需要独立的内核堆栈,以避免父子进程之间的堆栈冲突。
- 执行上下文:
thread数据结构记录了进程执行上下文的关键信息,如指令指针和栈顶寄存器。子进程的thread数据结构需要独立设置,以确保子进程从正确的位置开始执行。
-
修改子进程的
task_struct:子进程的
task_struct在多个地方被修改,以反映其独立的特性。这包括但不限于:- 设置子进程的 PID。
- 初始化子进程的内核堆栈。
- 配置子进程的
thread数据结构,确保其指令指针和栈顶寄存器指向正确的地址。
-
链接到系统链表:
子进程的
task_struct需要被链接到内核的进程管理链表中,以便内核能够管理和调度新进程。 -
返回到用户态:
fork系统调用在父子进程中分别返回到用户态。尽管父子进程的大部分信息是相同的,但内核堆栈中的某些信息(如返回地址)可能不同,以确保父子进程能够正确返回到用户态。
在分析 fork 创建子进程的过程中,特别需要注意的是,复制父进程资源时采用了写时复制(Copy-On-Write, COW)技术。在创建子进程时,只有当父子进程尝试修改共享资源时,才会实际复制这些资源。因此父子进程在未修改资源之前可以共享相同的内存存储空间,这大大提高了效率。
/*
* Create a kernel thread.
*/
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
return _do_fork(&args);
}
SYSCALL_DEFINE0(fork)
{
return _do_fork(&args);
}
SYSCALL_DEFINE0(vfork)
{
return _do_fork(&args);
}
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
{
return _do_fork(&args)
}
从代码中可以看出,fork、vfork 和 clone 这三个系统调用,以及 do_fork 和 kernel_thread 内核函数都可以创建新进程,且它们最终都通过 _do_fork 函数实现,只是传递的参数不同。为了避免重复,我们直接从 _do_fork 函数入手进行分析,具体代码见 kernel/fork.c 。
long _do_fork(struct kernel_clone_args *args)
{
//复制进程描述符和执行时所需的其他数据结构
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
wake_up_new_task(p);//将子进程添加到就绪队列
return nr;//返回子进程pid(父进程中fork返回值为子进程的pid)
}
_do_fork 函数主要完成了调用 copy_process() 复制父进程、获得pid、调用 wake_up_new_task 将子进程加入就绪队列等待调度执行等 。
copy_process() 函数是创建进程的核心代码,主要完成了以下任务:
- 调用
dup_task_struct复制父进程的task_struct描述符。 - 进行信息检查和初始化。
- 将进程状态设置为
TASK_RUNNING(子进程进入就绪态)。 - 采用写时复制技术逐一复制其他进程资源。
- 调用
copy_thread_tls初始化子进程的内核栈。 - 设置子进程的 PID 等。
其中,dup_task_struct 和 copy_thread_tls 是最关键的部分,完整代码见 kernel/fork.c。
static __latent_entropy struct task_struct *copy_process(
struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args)
{
//复制进程描述符task_struct、创建内核堆栈等
p = dup_task_struct(current, node);
/* copy all the process information */
shm_init_task(p);
…
// 初始化子进程内核栈和thread
retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p,
args->tls);
…
return p;//返回被创建的子进程描述符指针
}
进程创建的原理还有进程关键上下文和系统调用的内核堆栈等,子进程创建好了进程描述符、内核堆栈等,就可以通过 wake_up_new_task(p) 将子进程添加到就绪队列,使之有机会被调度执行,进程的创建工作就完成了,子进程就可以等待调度执行,子进程的执行从设定的 ret_from_fork 开始。
总结来说,进程的创建过程大致是父进程通过fork系统调用进入内核_do_fork函数,如下图所示:
复制进程描述符及相关进程资源(采用写时复制技术)、分配子进程的内核堆栈并对内核堆栈和thread等进程关键上下文进行初始化,最后将子进程放入就绪队列,fork系统调用返回;而子进程则在被调度执行时根据设置的内核堆栈和thread等进程关键上下文开始执行。
参考资料
- 《庖丁解牛Linux操作系统分析》 gitee.com/mengning997…
- 《linux操作系统分析课程资料》 gitee.com/mengning997…