【Linux v0.11内核源码解析】进程初始化和创建
时光荏苒,流年似水,大学已经过去了两年,逝去的时光、走过的脚步,蕴含着成长的足迹。然而,作为一名计算机专业的学生,就所学专业知识相关,细细思考,却无多少脚踏实地的建树。每至夜深,思绪飘动,总感自己只是东一榔头,西一棒槌,没有做一些细致的整理,一段时间贪玩,便落下手头已经做一半的东西。
本期,我希望自己能耐心做完这个系列。能在明年二月之前,完成Linux操作系统内核的源码解析,由于我之前也没有系统学习过操作系统,现正值在进行该专业课的学习,再结合自己阅读相关书籍,查阅网上的一些资料,逐步去完成这个系列。这其中一定也会有一些问题或错误,欢迎大家批评指出,进行讨论。
另外来点碎碎念,学习内核源码,各种查阅资料,讲解视频,耗费我好多时间,写到最后感觉自己要疯了,最终还是啃下来了,呜呜呜呜。可能越往后,写的越啰嗦巴托,后续也会纠错,欢迎大家批评指正,同时我也希望我能把这个系列做完!
一、为什么?如何开始?
为什么要去剖析操作系统内核源码?
该段后续我会抽留出来,放入这个系列的引入中
随着计算机系统的发展,现在操作系统也在不断发展和完善,能够解决很多问题。可以说,现代的操作系统已经是一个功能极度完善且非常复杂的系统软件。
而对于我们这些从事计算机、互联网行业的开发者,我认为学习操作系统可以帮助我们更好的理解操作系统为用户程序提供了一个什么样的对外模型,且我们作为程序员可以如何充分利用它。
剖析操作系统内核源码,可以帮助去理解初期操作系统是如何设计的,其设计理念和思维方式是怎样的。这其中其实也蕴含着一些OOP的思想,去看看c语音如何实现OOP
如何更好的去梳理Linux操作系统的核心代码?
- 首先我在开始之前,大致读过一遍书《现代操作系统》,由于我们课程书籍是这个,另外网上也有看到强烈推荐《操作系统真象还原》这本书,我后续也会打算入手学习
- 现在的Linux源代码非常庞大,其中,Linux内核的源代码大小为160万行,涉及到大量、非常复杂的数据结构和算法,所以其实去分析和学习起来非常麻烦。我这里只关注其最核心的部分,选择的也是最早期的,大家可以在https://elixir.bootlin.com/这个网站上进行,非常方便,可选择对应版本,之后版本内可以看到全部的源码,在内部也可以方便的进行索引和导航,能够快速定位各个代码段、函数再哪里使用等。
- 这里也比较推荐清华大学老师向勇和陈渝授课的《操作系统 - 清华大学》和李治军老师的《操作系统 - 哈工大》
当然,饭要一口一口的吃,如果翻到我了,可以先从我这里浅尝一口,我一定会尽量清晰精简的完成这顿“开胃菜”~
二、进程描述符(进程的主要抽象)
在操作系统中,实际进程抽象成了一个task_struct的结构体,对进程所有出现的元素进行封装,即进程控制块(PCB)。所以每一个进程就是一个该成员。下面是对task_truct
的介绍(对应Linux-v0.11版本,后续不再提示)
/include/linux/sched.h
//....
struct task_struct {// 进程描述符
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */// 进程的运行状态
long counter;// 时间片的计数值,检索链表的时候,最大的先运行
long priority;// 优先级,牵扯到进程的调度方法,
long signal;// 信号
struct sigaction sigaction[32];// 信号位图
long blocked; /* bitmap of masked signals */ // 阻塞和非阻塞的状态
/* various fields */
int exit_code; // 退出码
// 开始码、结束码等。。(这里大家可以评论补充,,这里没有全部查阅到)
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
// 用户id,有效用户id,最终id
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
// 警告
long alarm;
// 用户态运行时间,内核态运行时间,子进程的用户态运行时间,子进程的内核态运行时间
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;//是否使用了协处理器
/* file system info */
int tty; /* -1 if no tty, so it must be signed */// 是否打开了控制台
unsigned short umask;
struct m_inode * pwd;// 路径
struct m_inode * root;// 根
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];// 打开哪些文件
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];// LDT局部描述
/* tss for this task */
struct tss_struct tss;// tss段,cpu在运行时,要保存的一些临时结果,在内核版本不断演化过程中也增加了很多功能
};
//...
补充:时间片counter计算方式(x86): counter = counter/2+priority
下图帮助理解进程
三、 进程的状态
在Linux的内核中,主要是分时技术进行多进程调度。下面是Linux中定义的5种进程状态,即第二部分task_struct结构体中的state用来表示
#define TASK_RUNNING 0 //运行状态
#define TASK_INTERRUPTIBLE 1 //可中断睡眠状态
#define TASK_UNINTERRUPTIBLE 2 //不可中断睡眠状态
#define TASK_ZOMBIE 3 //僵死状态
#define TASK_STOPPED 4 //暂停状态
四、进程的初始化(重点)
4.1系统启动
这里所描述的进程的初始化,意指进程创建最初是怎么开始的。我们首先需要关注一下main.c
,由于其中内容较多,这里我会省略大部分代码,只保留与本节相关的部分
/init/main.c
// ...
void main(void) /* This really IS void, no error here. */
{
// ...各类初始化
sched_init();// 进行进程调度初始化
// ...缓冲区、硬盘、软皮初始化
move_to_user_mode(); // 从内核的初始化状态切换到用户模式。
// 创建0号进程,运行最初的应用程序
if (!fork()) {
init();
}
for(;;) pause();
}
// ...
4.2 进程调度初始化简介
上述代码我保留了sched_init();
这一行,这里我们简单分析介绍一下,请看下发,含个人解释
/kernel/sched.c
// ...
void sched_init(void)
{
int i;
struct desc_struct * p;
if (sizeof(struct sigaction) != 16)//检查结构体大小
panic("Struct sigaction MUST be 16 bytes");
// 设置tss,ldt的描述符,并将其写入全局描述符表(GDT)
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
p = gdt+2+FIRST_TSS_ENTRY;
for(i=1;i<NR_TASKS;i++) {//初始化GDT中的TSS和LDT条目,从1号进程到64号进程进行遍历
task[i] = NULL;// 清空task指针数组(即可以理解是)
p->a=p->b=0;
p++;
p->a=p->b=0;
p++;
}
/* Clear NT, so that we won't have troubles with that later on */
__asm__("pushfl ; andl $0xffffbfff,(%esp) ; popfl");// 清除 NT(Nested Task)标志
ltr(0);// 加载任务寄存器TR,指向GDT中第一个TSS的位置。
lldt(0);// 加载局部描述符表LDT,也指向GDT中第一个LDT的位置。
// 禁用中断,设置一些寄存器
outb_p(0x36,0x43); /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40); /* LSB */
outb(LATCH >> 8 , 0x40); /* MSB */
// 设置时间的中断门
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
// 设置系统调用中断门
set_system_gate(0x80,&system_call);
}
// ...
其中这里面提到的tss,ldt是指系统级别的,可以参照下图理解:
**重点:**注意上面代码的最后一行 set_system_gate(0x80,&system_call);
,设置中断函数,传入了system_call系统调用,因为进程的切换、调度、创建都是系统调用完成的。
4.3 创建0号进程
我们可以注意到,在main.c
中,创建0号进程前有一行,用来从内核初始化状态切换到用户态的代码,move_to_user_mode();
。
了解其原因前我们知道:
-
内核态:不可抢占
-
用户态:可以抢占
而在mone_to_user_mode()
之前,进行初始化时,属于内核状态,是因为linux为了保证自身的初始化的正常。而在这之后切换到用户态,切换之后便可以进行创建进程。
在内核初始化的过程中,会手动创建0号进程,0号进程是所有进程的父进程,其他所有进程都是由这个0号进程复制过去的。
创建0号进程成功后,执行init()
,其函数内部功能:
- 打开标准输入 输出 错误的控制台句柄
- 创建1号进程,如果创建成功,则在1号进程中
- 首先打开"/etc/rc"文件(补充:etc是配置文件目录,系统启动时如果要打印一些信息,可以放到rc文件中,例如对操作系统二次开发,公司logo等可放入此。)
- 执行shell程序"/bin/sh"
这里由于再放源代码的话,篇幅过长,所以我没有放init()
的源码,感兴趣的朋友可以自行去查看,也比较易懂
**最后注意:**0号进程不可能结束,它会在没有其他进程的调用的时候调用,只会执行for(;;) pause();
,即上面main.c
最后一行
五、进程的创建(重点)
我们的进程创建主要是fork。
5.1进程创建的主要步骤
主要可以分为下面几步骤
- 在task链表中找一个进程空位存放当前的进程
- 创建一个task_struct
- 设置task_struct
5.2 源码分析
下面请看VCR源码!(附带个人注释)
fork.c
#include <errno.h>
//头文件引用
#include <linux/sched.h>
#include <linux/kernel.h>
#include <asm/segment.h>
#include <asm/system.h>
extern void write_verify(unsigned long address);
long last_pid=0;// 用于保存上一个进程的PID,初始化为0,每次创建新进程时递增。
void verify_area(void * addr,int size)
{// 用于验证指定内存区域的可写性
unsigned long start;
start = (unsigned long) addr;// 将传入的地址转换为unsigned long类型
size += start & 0xfff;// 将size加上start的低12位(页面内偏移),以确保size包含整个最后一页
start &= 0xfffff000;// 将start的低12位清零,以获得页面的起始地址
start += get_base(current->ldt[2]);// 将start加上当前进程的数据段基地址
while (size>0) {// 循环处理每一页的内存区域
size -= 4096;// 每次处理一页(4KB)
write_verify(start);// 调用write_verify函数
start += 4096;// 将start指向下一页
}
}
int copy_mem(int nr,struct task_struct * p)
{// 复制父进程的内存空间到子进程中
// 定义旧进程和新进程的数据段和代码段信息
unsigned long old_data_base,new_data_base,data_limit;
unsigned long old_code_base,new_code_base,code_limit;
// 获取代码段和数据段的限制
code_limit=get_limit(0x0f);
data_limit=get_limit(0x17);
// 获取当前进程的代码段和数据段的基地址
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
if (old_data_base != old_code_base)// 如果数据段和代码段的基地址不相等,发生错误
panic("We don't support separate I&D");
if (data_limit < code_limit)// 小于
panic("Bad data_limit");
new_data_base = new_code_base = nr * 0x4000000; // 计算新进程的数据段和代码段的基地址
p->start_code = new_code_base;
设置新进程的LDT表中的代码段和数据段的基地址
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {// 将old_data_base数据拷贝到new_data_base,拷贝大小data_limit
free_page_tables(new_data_base,data_limit);//拷贝失败,释放页
return -ENOMEM;
}
return 0;//拷贝成功返回0
}
/*
* Ok, this is the main fork-routine. It copies the system process
* information (task[nr]) and sets up the necessary registers. It
* also copies the data segment in it's entirety.
*/
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
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; // 将当前子进程放到整体进程链表中
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE;// 设置进程状态为不可被中断
p->pid = last_pid;// 即当前要创建进程的进程号
p->father = current->pid; // 设置父进程
p->counter = p->priority; // 设置该进程的时间片值等于其优先级的值
p->signal = 0; // 信号位图置0
p->alarm = 0;// 报警定时值(滴答数)
p->leader = 0; /* process leadership doesn't inherit */
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;
p->tss.eflags = eflags;
p->tss.eax = 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;
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);
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)// 如果当前进程使用了协处理器,那就设置创建进程的协处理器
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
if (copy_mem(nr,p)) {// 进行老进程向新进程代码段、数据段(LDT)的拷贝
task[nr] = NULL;// 如果拷贝失败 释放
free_page((long) p);// 释放空间
return -EAGAIN; // 返回错误码
}
for (i=0; i<NR_OPEN;i++) // NR_OPEN
if (f=p->filp[i]) //如果创建当前进程的父进程打开了文件
f->f_count++; // 则子进程也打开了该文件,文件数+1
if (current->pwd) //如果打开了当前路径
current->pwd->i_count++;
if (current->root) // 如果打开了根
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss)); // 设置TSS进程的状态描述符
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt)); // 设置LDT局部描述符
p->state = TASK_RUNNING; /* do this last, just in case */ // 给程序的状态标志位改为可运行状态
return last_pid; // 返回创建的新进程进程号
}
int find_empty_process(void)
{
int i;
repeat://首先寻找一个pid值
if ((++last_pid)<0) last_pid=1;// 自加再用
for(i=0 ; i<NR_TASKS ; i++)// 遍历检索
if (task[i] && task[i]->pid == last_pid) goto repeat;//如果为空并且到最后一位,就重新去找
for(i=1 ; i<NR_TASKS ; i++)// 去task数组去寻找可用的位置
if (!task[i])
return i;
return -EAGAIN;// 没有返回错误码
}
5.3 进程创建的本质
进程的创建就是对0号进程或者当前进程的复制
-
0号进程复制->结构体的复制->把task[0]对应的task_struct复制给新创建的task_struct
-
对于栈堆的拷贝,当进程做创建的时候要复制原有的栈堆
进程的创建本质就是进行系统调用,请看
/kernel/system_call.s
.align 2
_sys_fork:
call _find_empty_process //调用find_empty_process 找一个进程空位
// 测试以及用了一些通用寄存器
testl %eax,%eax
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process // 从old进程中复制出一个新进程
addl $20,%esp
1: ret
其中find_empty_process
所在位置也是在fork.c
主要流程:
-
给当前要创建的进程分配一个进程号(find_empty_process)
-
创建一个子进程的task_struct结构体
struct task_struct *p;//声明一个task_struct指针 p = (struct task_struct *) get_free_page();//分配一个空白页 ########下面给出现代操作系统写法,使用kallock动态内存分配函数################ struct task_struct *p;//声明一个task_struct指针 p = (struct task_struct *) kalloc(sizeof(task_struct));//分配一个空白页
-
将当前的子进程放入到整体进程链表中
task[nr] = p;
-
设置创建的task_struct结构体
如果当前进程使用了协处理器,那就设置创建进程的协处理器
if (last_task_used_math == current) __asm__("clts ; fnsave %0"::"m" (p->tss.i387));
int copy_mem(int nr,struct task_struct * p)
进行老进程向新进程代码段、数据段(LDT)的拷贝如果父进程打开了某个文件,那么子进程也同样打开了这个文件,所以将文件的打开计数+1
for (i=0; i<NR_OPEN;i++) if (f=p->filp[i]) f->f_count++; if (current->pwd) current->pwd->i_count++; if (current->root) current->root->i_count++; if (current->executable) current->executable->i_count++;
设置进程两个段,并且结合刚才拷贝过来的,组装成一个进程
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss)); set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt)); //给程序的状态标志位改为可运行状态 p->state = TASK_RUNNING; /* do this last, just in case */ //返回新创建进程的PID return last_pid;
5.4 补充个流程图
六、对v0.11版本fork.c代码的改进建议
其实关于最初0.11版本中进行进程创建的fork.c进行阅读及相关其他部分进行理解后,我觉得属于易学的部分。
一些小小的改进建议
- 设置进程任务状态TSS段部分的数据我认为分装成结构体,传入进行复制,能提升一定的可读性(这段有点长)。其实后面的大版本之后,对其做了这样的变动
- 分页机制部分其实有局限性,不过也是由于当时的硬件环境。
七、参考资料
- https://elixir.bootlin.com/linux/0.11/source/kernel
- Linux内核源码进程原理分析 - Lion Long的文章 - 知乎
https://zhuanlan.zhihu.com/p/609002496 - 【非常好的Linux内核视频 - Linux内核精讲】 https://www.bilibili.com/video/BV1tQ4y1d7mo/?share_source=copy_web&vd_source=4ff1f28ff03d2ec1a8c8585c750d8fcd