logo

13.Linux0.12如何创建进程?v3

作者:小牛呼噜噜 ,首发于公众号「小牛呼噜噜

切换到用户态模式

哈喽,大家好呀,我是呼噜噜,在上一篇文章中,我们对进程调度进行了初始化sched_init,本文来讲讲linux内核如何创建进程?

但要想创建进程,我们还需要一些准备工作:

// init/main.c
void main(void) { //内核初始化主程序
    ...
        sched_init();//任务调度初始化!!!
    buffer_init(buffer_memory_end); // 缓冲管理初始化,建内存链表等
    hd_init(); // 硬盘初始化
    floppy_init(); //软驱初始化
    sti(); //开启中断
    move_to_user_mode(); //移到用户模式下执行。
    if (!fork()) {      //永远不会退出,如果退出就死机了。
        init(); //在新建子进程(进程 1)中执行,init() 会启动一个shell
    }
    for(;;)//死循环
        __asm__("int $0x80"::"a" (__NR_pause):"ax");//执行系统调用 pause()
}

其中buffer_init、hd_init、floppy_init我们先忽略这些参数初始化和进程无特别大的关系,等之后的文章我们再细说

sti()就是开启中断,还记得早在boot/setup.s处,为了搬运system模块代码,我们使用cli指令把系统的中断全部关闭了,这里使用sti指令开启中断

//system.h

#define sti() __asm__ ("sti"::)

接着需要move_to_user_mode切换到用户态模式,因为CPU有一些特殊指令和内存随意修改都是非常危险的,Linux引入特权级别,将操作系统划分为用户态与内核态来保证系统的安全性和稳定性

  1. 内核态

内核态是处于操作系统的最核心处,Ring0特权级,拥有操作系统的最高权限,能够控制所有的硬件资源,掌控各种核心数据,并且能够访问内存中的任意地址;由内核态统一管理这些核心资源,减少有限资源的访问和使用冲突;在内核里发生的任何程序异常都是灾难性的,会导致整个操作系统的奔溃

  1. 用户态

用户态,就是我们通常编写程序的地方,处于Ring3特权级,权限较低;这一层次的程序没有对硬件的直接控制权限,也不能直接访问地址的内存。在这种模式下,即使程序发生崩溃也不会影响其他程序,可恢复

而Linux启动的过程都是在内核态进行的,然而当Linux启动后,我们程序员编写的各种程序都是在用户态,这样保证了万一我们编写的程序崩溃,尽可能不会影响到内核的正常运行

用户态程序需要访问内存和I/O端口等其他敏感的资源时,可以通过系统调用,由内核去统一执行相关操作;等执行完操作系统再切换回用户态。这样方便集中管理,减少有限资源的访问和使用冲突

我们来看下move_to_user_mode的源码:

// /include/asm/system.h

#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \ // 保存堆栈指针 eap 到 eax 寄存器中
"pushl $0x17\n\t" \           // 首先将堆栈段选择符(SS)入栈。
    "pushl %%eax\n\t" \           // 然后将保存的堆栈指针值(esp)入栈。
    "pushfl\n\t" \                // 将标志寄存器(eflags)内容入栈。
    "pushl $0x0f\n\t" \           // 将 Task0 代码段选择符(cs)入栈。
    "pushl $1f\n\t" \             // 1f表示将下面标号 1 的偏移地址(eip)入栈
    "iret\n" \                    // 执行中断返回指令,则会跳转到下面标号 1 处
    "1:\tmovl $0x17,%%eax\n\t" \  // 此时开始执行任务 0
    "mov %%ax,%%ds\n\t" \         // 初始化段寄存器指向本局部表的数据段
    "mov %%ax,%%es\n\t" \
    "mov %%ax,%%fs\n\t" \
    "mov %%ax,%%gs" \
    :::"ax")

Linux中特权级改变的我们常用的方式是通过中断和中断返回来实现,会涉及到堆栈切换

  1. 当中断发生时,需要保存被中断的进程的上下文,CPU会自动将相关的寄存器值按序依次压入一个内核态堆栈中。

每个任务(进程)都有一个内核态堆栈,用来完成中断处理中保护现场和恢复现场的工作。这个内核态堆栈与每个任务的任务数据结构放在同一页内,在每个任务的TSS的结构中设置

  1. 当中断返回时,执行iret中断返回指令,CPU又会自动从栈中弹出这些数据到各自的寄存器中,来恢复被中断的进程的上下文

所以这段代码的本质其实就是模拟了一个中断返回,先是手动把对应的数据压入中,即在当前堆栈中构建好中断入栈时的参数:SS、ESP、EFLAGS、CS、EIP

发生中断时堆栈的内容

需要注意的是,0x17、0x0f是段选择符,我们这里还是回顾一下之前的知识,大家别忘了哦

段选择符

段选择符中包含请求特权级RPL(CPL)字段,通过段选择符可以去查找全局描述符表GDT、局部描述符表LDT中对应的项,需要先进行特权级检查;这些项中都包含DPL字段(规定访问该段的权限级别),只有DPL >= max {CPL, RPL},才允许访问

CPL很特殊,跟踪当前CPU正在执行的代码所在段的描述符中DPL的值,总是等于CPU的当前特权级

我们来具体看看段选择符0x17、0x0f,分别表示什么:

  1. 0x17=0b00010 1 11,RPL=3,index=2,TI=1,表示从LDT表中取下标为2的描述符,用户数据段SS
  2. 0x0f=0b00001 1 11,RPL=3,index=1,TI=1,从LDT表中取下标为1描述符,用户代码段CS
  3. 别忘了之前sched_init中lldt(0),这里我们已经设置CPU中lDTR寄存器,让其指向0号进程(task0)的LDT描述符

接着执行中断返回指令iret,自动从栈中弹出这些数据到各自的寄存器,堆栈切换,并且指引CPU跳转到标号1处继续执行程序

中断返回指令iret有2种情况:
1. 当使用IRET指令返回到相同保护级别的任务时,即当前的CS中的DPL和堆栈中的DPL相同时
   -IRET会从堆栈弹出代码段选择子及指令指针分别到CS与IP寄存器
   -并弹出标志寄存器内容到EFLAGS寄存器

2. 当使用IRET指令返回到一个不同的保护级别时
   -IRET不仅会从堆栈弹出以上内容
   -还会弹出堆栈段选择子及堆栈指针分别到SS与SP寄存器

另外这里通过iret指令中断返回,由于寄存器EIP=我们定义的位置1,程序还是在原位置,其实这里就是模拟了一个中断返回,但是在系列操作内部把内核态改为了用户态即将段选择符中特权级从原本的0降权到3

为什么这么麻烦呢?因为x86的设计不允许直接从特权级0跳转到特权级3运行啊,我们就只能通过这种方式来实现内核态与用户态的切换

最后小结一下,执行完move_to_user_mode后,内核态切换到用户态后,此时

  1. 寄存器CS=0号进程的LDT中CS段
  2. 寄存器SS=0号进程的LDT中SS段
  3. 寄存器EIP=我们定义的位置1
  4. 寄存器ESP=SP
  5. 寄存器EFLAGS=EFLAGS
  6. 堆栈切换,此时task0的用户态的堆栈,还是使用内核态的堆栈,但是段的特权级别不一样

执行完move_to_user_mode,成功从内核态切换到用户态后,接下来我们就可以创建进程了

进程创建

Linux中进程的创建还是沿用了Unix的做法,新进程不是新建出来的,而是由一个既有的进程(父进程),通过"fork"来产生,新进程也可被称为子进程,形如"从根上分裂出来"

Linux0.12中,子进程是对父进程各种信息的复制,但并不是完全复制,比如pid、线性空间、物理空间是不一样的

fork调用的最特殊的是,它仅仅被调用一次,却能够返回两次!和我们平时编写的一般函数不太一样

接下来我们慢慢道来

fork

先来看下fork函数的定义:

//   /init/main.c
static inline _syscall0(int,fork)


// /include/unistd.h

#ifdef __LIBRARY__ # 若提前定义__LIBRARY__,则以后内容被包含

...

#define __NR_fork   2

...

#define _syscall0(type,name) \   //库函数扩展汇编宏
type name(void) \
          { \
          long __res; \
__asm__ volatile ("int $0x80" \  // 调用系统中断 0x80
                  : "=a" (__res) \             // 返回值eax(__res)
: "0" (__NR_##name)); \
if (__res >= 0) \
    return (type) __res; \       //成功返回
errno = -__res; \
return -1; \                     //失败返回
}

...

#endif /* __LIBRARY__ */

...

int fork(void);


// /include/linux/sys.h

extern int sys_fork();

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, ... };


//    /kernel/sys_call.s

...

_system_call: // int 0x80
push %ds      # 压栈, 保存原段寄存器值
push %es
push %fs   
pushl %eax      # 保存eax原值
pushl %edx      
pushl %ecx      # push %ebx,%ecx,%edx as parameters
pushl %ebx      # to the system call,  ebx,ecx,edx 中放着系统调用对应的C语言函数的参数
movl $0x10,%edx     # ds,es 指向内核数据段
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx     # fs 指向当前局部数据段(局部描述符表中数据段描述符)
mov %dx,%fs
cmpl _NR_syscalls,%eax  # 判断eax是否超过了最大的系统调用号,调用号如果超出范围的话就跳转!
jae bad_sys_call
call _sys_call_table(,%eax,4)   # 间接调用指定功能C函数!
pushl %eax                      #  把系统调用的返回值入栈!

...

ret_from_sys_call:  #当系统调用执行完毕之后,会执行此处的汇编代码,从而返回用户态
movl _current,%eax  # 取当前任务(进程)数据结构指针->eax
cmpl _task,%eax         # task[0] cannot have signals
...

我们可以发现fork()是个系统调用,系统调用机制非常重要,我们在之前的文章什么是系统调用机制?结合Linux0.12源码图解详细讲解过,这里贴一下系统调用的流程图:

系统调用

执行fork函数:

  1. __NR_fork 2表示系统调用号为2,执行系统中断0x80,需要去IDT中,找到对应的中断服务程序system_call
  2. 而早在sched_initset_system_gate(0x80,&system_call),设置系统调用中断门,将system_call函数地址注册到中断描述符表IDT中第0x80表项中(其实也就是将中断服务程序system_call0x80中断绑定)
  3. 执行fork函数,其实调用系统中断0x80,跳转到绑定的_system_call中,继而去执行sys_call_table跳转表中的第2sys_fork函数

需要注意的是,系统调用涉及到中断和中断返回,且会发生内核态用户态的切换,需要保存与恢复进程上下文

中断发生时,涉及用户态切换到内核态,整个内核代码并没有直接操作取TR寄存器,这是因为CPU会自动读取TR寄存器,而TR寄存器一直都是指向当前任务的TSS。当CPU自动获取到TSS的ss0和esp0字段ss0是任务内核态堆栈的段选择符,esp0是任务内核态堆栈指针(即偏移值);有了这2个字段,我们就能定位到新堆栈(这里就是内核态堆栈)

接着CPU就会自动首先把原用户态堆栈指针ss和esp压入内核态堆栈,随后把标志寄存器eflags段寄存器cs、中断返回地址eip压入内核态堆栈;中断返回时,会自动将压入内核栈的数据依次弹出,返回到各自对应的寄存器中。

需要注意的是,这些操作都是在中断产生时由硬件自动完成的,我们再一起来看看栈切换的示意图:

内核态用户态栈切换

sys_fork函数的源码如下:

//  /kernel/sys_call.s
.align 2
    _sys_fork:
call _find_empty_process # 为新进程取得进程号 last_pid
    testl %eax,%eax          # eax保存的是任务号,即任务数组下标值,若返回负数则退出
    js 1f
    push %gs
    pushl %esi
    pushl %edi
    pushl %ebp
    pushl %eax
    call _copy_process       # 调用 C 函数 copy_process()
    addl $20,%esp            # 丢弃这里所有压栈内容
1:  ret

其中有2个很重要的函数,_find_empty_process_copy_process,我们接下来详细看看

_find_empty_process

find_empty_process主要作用是,为新进程取得不重复的进程号pid,并存入全局变量last_pid;并且函数返回在任务数组task[NR_TASKS]中的任务号(数组项下标)

// /kernel/fork.c
int find_empty_process(void)
{
    int i;

    repeat:
    if ((++last_pid)<0) last_pid=1; //如果 last_pid 增 1 后超出进程号的正数表示范围,则重新从 1 开始
    for(i=0 ; i<NR_TASKS ; i++)//在任务数组中搜索刚设置的pid 号是否已经被任何任务使用。如果被使用则重新获得一个pid号;NR_TASKS=64
        if (task[i] && ((task[i]->pid == last_pid) ||
                (task[i]->pgrp == last_pid)))
            goto repeat;
    for(i=1 ; i<NR_TASKS ; i++)//在任务数组中为新任务寻找一个空闲项,并返回索引。另外last_pid 是一个全局变量,不用返回
        if (!task[i]) 
            return i;
    return -EAGAIN;  //如果任务数组中64个项已经被全部占用,则返回出错码
}

这个函数功能还是比较简单明了的,需要注意的是(++last_pid)<0表示last_pid已经超过long的最大值,溢出了,让其重新从1开始

_copy_process

当我们执行find_empty_process后,返回负数则说明目前任务数组task已满,直接退出;如果能够获取到pid那就调用copy_process复制父进程信息,我们来详细地剖析一下copy_process的源码:

/*
 *  Ok, this is the main fork-routine. 
 *  它复制系统进程信息(task[n])并且设置必要的寄存器。它还整个地复制数据段
 */
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
                 long ebx,long ecx,long edx, long orig_eax, 
                 long fs,long es,long ds,
                 long eip,long cs,long eflags,long esp,long ss)
{
    struct task_struct *p;
    int i;
    struct file *f;

    p = (struct task_struct *) get_free_page();    //为新任务数据结构分配内存(如果分配出错,则返回出错码并退出)。
    if (!p)
        return -EAGAIN;
    task[nr] = p;// 将新任务结构指针放入任务数组中。其中nr 为任务号,由前面find_empty_process()返回
    *p = *current;  //注意!这样做不会复制超级用户的堆栈(只复制当前进程内容)
    p->state = TASK_UNINTERRUPTIBLE; // 将新进程的状态先置为不可中断等待状态。以防止内核调度其执行
    p->pid = last_pid;  // 新进程号。由前面调用find_empty_process()得到。
    p->counter = p->priority; // 运行时间片值(嘀嗒数)
    p->signal = 0;   // 信号位图。
    p->alarm = 0;    // 报警定时值(嘀嗒数)
    p->leader = 0;      /* 进程的领导权是不能继承的 */
    p->utime = p->stime = 0;  //用户态时间和核心态运行时间。
    p->cutime = p->cstime = 0; // 子进程用户态和核心态运行时间
    p->start_time = jiffies;   // 进程开始运行时间(当前时间滴答数)

    //接下来修改任务状态段 TSS 内容
    p->tss.back_link = 0;
    p->tss.esp0 = PAGE_SIZE + (long) p; // 任务内核态栈指针
    p->tss.ss0 = 0x10; // 内核态栈的段选择符(与内核数据段相同)
    p->tss.eip = eip;  // 指令代码指针!!!,需要注意的是:子进程的EIP同父进程一样,所以两次fork返回时,父子进程都从这条指令开始执行
    p->tss.eflags = eflags; // 标志寄存器
    p->tss.eax = 0;         // 这是当fork()返回时新进程会返回0的原因所在
    p->tss.ecx = ecx;
    p->tss.edx = edx;
    p->tss.ebx = ebx;
    p->tss.esp = esp;
    p->tss.ebp = ebp;
    p->tss.esi = esi;
    p->tss.edi = edi;
    p->tss.es = es & 0xffff; // 段寄存器仅 16 位有效
    p->tss.cs = cs & 0xffff;
    p->tss.ss = ss & 0xffff;
    p->tss.ds = ds & 0xffff;
    p->tss.fs = fs & 0xffff;
    p->tss.gs = gs & 0xffff;
    p->tss.ldt = _LDT(nr);   // 任务 LDT 描述符的选择符(LDT 描述符在 GDT 中)
    p->tss.trace_bitmap = 0x80000000;
    if (last_task_used_math == current) //如果当前任务使用了协处理器,就保存其上下文
        __asm__("clts ; fnsave %0 ; frstor %0"::"m" (p->tss.i387));

    //接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长,并复制页表
    if (copy_mem(nr,p)) {    //如果出错(返回值不是 0),则
        task[nr] = NULL; //复位任务数组中相应项
        free_page((long) p);//并释放为新任务分配的用于任务结构的内存页
        return -EAGAIN;
    }
    // 如果父进程中有文件是打开的,则将对应文件的打开次数增1
    for (i=0; i<NR_OPEN;i++)
        if (f=p->filp[i])
            f->f_count++;
    // 将当前进程(父进程)的pwd, root 和executable 引用次数均增1
    if (current->pwd)
        current->pwd->i_count++;
    if (current->root)
        current->root->i_count++;
    if (current->executable)
        current->executable->i_count++;
    if (current->library)
        current->library->i_count++;
    
    // 在GDT 中设置新任务的TSS 和LDT 描述符项,数据从task 结构中取。
    // 请注意,在任务切换时,任务寄存器 TR 会由 CPU 自动加载
    set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
    set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
    p->p_pptr = current; // 设置新进程的父进程指针
    p->p_cptr = 0;       // 复位新进程的最新子进程指针
    p->p_ysptr = 0;      // 复位新进程的比邻年轻兄弟进程指针
    p->p_osptr = current->p_cptr;  // 设置新进程的比邻老兄兄弟进程指针
    if (p->p_osptr)                // 若新进程有老兄兄弟进程,则让其年轻进程兄弟指针指向新进程
        p->p_osptr->p_ysptr = p;
    current->p_cptr = p;           // 让当前进程最新子进程指针指向新进程
    p->state = TASK_RUNNING;    /* 最后再将新任务设置成可运行状态,以防万一 */
    return last_pid; // 返回新进程号(与任务号是不同的)
}

copy_process是一个非常重要的函数,它主要作用就是复制进程信息,具体就是复制系统进程信息(task[n])并且设置必要的寄存器,还整个地复制数据段

首先是通过get_free_page函数,为新任务的数据结构task[n]分配一页空闲的内存,并返回这个页的地址。这块和mem_map数组有关,如果分配出错,则返回出错码并退出。这块暂时不深入讲,等后面内存管理篇再来细讲

接着task[nr] = p,讲新任务(子进程)结构指针p,指向刚刚分配完内存的任务task[n]中,其中nr为任务号,由前面find_empty_process()返回任务数组索引号

紧接着*p = *current,将当前进程(这里是0号进程)的task_strust任务结构,完全复制给新任务(即子进程)

需要注意的是,这里不会复制0号进程的堆栈,只能复制当前进程内容,这是为啥呢?

注意指针的类型:struct task_struct *current,所以只复制task_struct,并没有复制0号进程的堆栈。所以每个任务(进程)在内核态运行时都有自己的内核态堆栈

我们再回顾一下task_union,使用了联合union表示,所属成员共用同一块空间

// /kernel/sched.c
union task_union {
	struct task_struct task;
	char stack[PAGE_SIZE]; // 因为一个任务数据结构与其堆栈放在同一内存页中,所以从堆栈段寄存器ss 可以获得其数据段选择符
};

由于PAGE_SIZE=4096,堆栈正好是一个内存页的大小4KB,又因为union导致任务结构task_structstack堆栈共用同一块空间,所以一个任务的数据结构与其内核态堆栈正好都放在同一内存页上,内核态堆栈在高地址处,这里二者的大小是经过测试的,以保证栈后续增长不会覆盖task_struct task


esp0是在下面设置的:

p->tss.esp0 = PAGE_SIZE + (long) p;// 任务内核态栈指针
p->tss.ss0 = 0x10; // 0x10 就是 10000, 0特权级,GDT,数据段;内核态栈的段选择符(与内核数据段相同)

这里esp0设置为刚分配的页内存的顶端(常指向栈的栈顶),用作偏移值,ss0为内核数据段选择符,因为内核态数据段描述符中的基址为0,所以也是内核态栈的段选择符,ss0:esp0常用于指向程序在内核态执行时的堆栈。

p->state = TASK_UNINTERRUPTIBLE,将新进程的状态先置为不可中断等待状态。此时的新进程刚复制了当前进程的任务结构,但还有许多参数属性没有重新赋值,所以需要防止此刻被任务调度

接下来就是重新设置各个参数,我们就不再细说了,大家仔细看注释,我们这里挑几个关键点再讲讲

p->tss.eip = eip;  // 指令代码指针!!!,需要注意的是:子进程的EIP同父进程一样,所以两次fork返回时,父子进程都从这条指令开始执行
p->tss.eax = 0;    // 这是当 fork()返回时新进程会返回 0 的原因所在

当子进程被调度时,执行的第一条指令是p->tss.eip = eip,其中eip就是上面CPU压入内核栈中的eip,所以子进程的EIP是和父进程一样的。因此fork2次返回的时候,父子进程都从这里的指令开始执行

返回值p->tss.eax = 0这里就是在main()函数中if(!fork()),当fork()返回时,子进程会返回0的原因所在

接着执行copy_mem,复制进程页表来分配线性空间,并复制页表来申请物理内存。这块我们也留待后续讲内存管理再讲,比较复杂

当新任务(子进程)的各项参数都设置好后,执行p->state = TASK_RUNNING让其变成可运行状态,表示可以被任务调度

父进程fork最后返回新创建子进程的新进程号(return last_pid);而子进程中,fork就返回之前设置的0

小结&堆栈变化

fork整体逻辑还是比较复杂的,这里笔者再吐血画一张fork各个阶段堆栈的变化图,从堆栈角度来帮助大家更好地理解:

fork堆栈变化图

需要注意的是,copy_process执行之后,_sys_fork接着执行addl $20,%esp,把栈顶指针esp上移20(栈内的5个项* 4 字节 = 20),刚好把_sys_fork中压入的GS,ESI,EDI,EBP,EAX丢弃

大家可以参考上图,再次重新回顾fork整个流程,重点关注在哪些阶段压入哪些参数到堆栈中,理解各个步骤的关键地方

本文就先到这里啦,涉及到进程,哪怕是0.12版本的,还是比较复杂的,后面我们会进一步讲解进程的调度机制,点赞收藏在看就是对笔者最好的催更

最后感谢大家的阅读,我们下期再见~


参考资料:
https://elixir.bootlin.com/linux/0.12/source/kernel/sched.c
英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A-英特尔®
《Linux内核完全注释5.0》


本文首发于公众号「小牛呼噜噜」,扫码关注,即可品读更多精彩文章