单纯会C的语法是做不了事儿的。
作为底层语言,还得熟悉其运行的操作系统,熟悉需要解决的问题,然后才可以用C语言去设计对应的“数据结构和算法”,从而解决问题。
对于操作系统来说,进程是其核心概念之一。围绕着进程,有一系列的系统API(Linux下又称系统调用,system call),每个API又对应着内核里的一系列复杂的机制,来管理进程的整个生命周期:创建、调度、运行、退出。
以下关于CPU寄存器的,以x86 32位为例。
1,进程的创建----fork()系统调用
在Linux命令行输入man fork,可以看到关于fork的介绍,需要包含unistd.h头文件,函数原型:pid_t fork(void);
fork()执行一次,返回两次,子进程返回0,父进程返回子进程的pid(进程号)。
#include
#include
#include
int main()
{
pid_t pid = fork();
//在该行之后,就是两个进程了,之后的代码位于不同的“进程位面”了,要互相通信,就得依赖OS提供的“进程间通信”(IPC)机制
if (0 == pid) {
printf("child process\n");
exit(0);
} else {
printf("father process, and child process id: %d\n", pid);
}
return 0;
}
fork依照父进程为模版,创建了子进程,在fork之前的数据被“复制”了一份给子进程。然后,父子两个就位于不同的“进程位面”了。它们的数据是独立的,可以在不同进程里有不同值,实际对应的内存也是不一样的,所以pid可以在子进程为0、在父进程为子进程的pid。
Linux有API getpid()获取自己的进程号,getppid()获取父进程的进程号。但因为一个进程可以创建多个子进程,所以没有获取子进程号的API,这需要父进程在fork的返回值中获取。
进程之间,与同一进程的各个线程之间不同,前者的内存是独立的,而后者是共享的。进程之间通信,需要使用IPC机制,例如发信号signal、共享内存、管道pipe、网络socket等,现在多使用AF_UNIX域的socketpair,详见《UNIX环境高阶编程》,暂不展开。
2,fork系统调用在内核的大概原理
fork()系统调用,在用户态的使用非常简单,但在内核里则复杂很多。在内核里,进程是由task_struct结构体描述的,一个简单的task_struct如下(仅表示原理,实际内核的复杂的多):
struct task_struct {
pid_t pid; //进程id
pid_t ppid; //父进程id
struct list_head child_list; //子进程队列
struct list_head brother_list; //兄弟进程队列,表头是父进程的child_list
struct list_head sched_list; //调度队列,实际内核用红黑树,提高效率
struct mm* mm;//内存管理结构(内存页表)的指针
这两个是最关键的进程运行的上下文,执行进程切换时必需,
unsigned long esp; //进程的内核堆栈指针
unsigned long eip; //进程的内核指令指针
};
有了task_struct,就可以实现fork系统调用了,内核里对应的就叫sys_fork吧:
1,进入内核态之后,用户态的上下文就都压栈在内核栈上了,
2,申请子进程的task_struct,
3,申请子进程的内存页表,并且和父进程的内容设置成一样的,并且设置为只读。
当父子任何一个进程需要写内存时,触发缺页中断。在缺页中断的处理程序中,对要写的内存页申请一份新的COPY替换掉写进程的对应页,这就是“写时复制”。
4,申请个新的pid作为子进程的pid,设置到子进程的task_struct中,依次填充其他的数据项,
5,设置子进程的esp,与父进程一样
6,设置子进程的eip,该地址是子进程第一次执行时的地址。
并把保存在“子进程内核栈”上的、用户态eax寄存器对应的、内存,设置为0,以保证fork返回用户态后,返回值为0。
7,父进程把保存在“内核栈”上的、用户态eax寄存器对应的、内存,设置为子进程的pid,以保证fork返回用户态后,返回值为子进程的pid。
8,子进程的task_struct通过其sched_list变量被挂上调度器,而且调度优先级排在父进程前面,保证子进程先于父进程执行。
这样,子进程如果开始就执行execve系统调用,加载新的程序,就不会触发写时复制了。而父进程先执行,那么“几乎肯定”要写内存,执行“写时复制”。然并卵,子进程很大概率不接着执行原代码,而是execve了,白复制了:浪费感情,哦,浪费CPU和内存。
这类情况,在shell里执行命令时,就是这么发生的。shell先fork一个子进程,然后加载你输入的命令,并执行。
9,做完上面几步后,就完成了子进程的创建了。但是,我们还在父进程的上下文里,如果直接返回用户态,那么父进程就会接着运行。为了实现第8条需要的、让子进程先运行的目标,我们执行进程调度函数:schedule();
选择权交给调度器了,接下来哪个进程先运行,我们也不知道。但是,在没有“其他改变调度优先级的因素”的情况,肯定子进程先于父进程运行。
如果发生了这类因素,那就顺其自然吧,毕竟我们无法判断这因素到底对用户多重要。
用户是上帝,惹不起:(
3,fork系统调用在用户态的大概步骤
因为不需要传递参数,只要把fork的系统调用号放入对应的寄存器(一般是eax),然后
int $0x80,使用CPU的软件中断,进入内核态,剩下的事儿,就是Linus大神的事儿了。
系统调用号,在不同版本的Linux上不大一样。
我的是ubuntu 16.04,在/usr/include/asm-generic/unistd.h文件里可以查到,都是以__NR_开头。
4,在内核里获取当前进程的task_struct,直接使用current宏就行。
printk("current->pid: %d\n", current->pid );
因为内核态时,堆栈和thread_info是绑定在连续的几个内存页上的(最早是4K的一个页,现在记得是8K,两个页,不知道新版有没变化),只要把esp寄存器的最低N位清0就是thread_info的指针,然后thread_info里存有task_struct的指针。
getpid()函数在内核对应的pid_t sys_getpid()的实现,可以这么写:
pid_t sys_getpid()
{
return current->pid;
}
关于fork的,就先说到这里了。
具体怎么使用,详见man手册。
具体怎么实现的,详见Linux内核源码。