便于分析,我选择的是Linux 0.11版本的源码。
1、system_call源码及分析
首先分析system call系统调用,在linux-3.10.1, x86 64位的系统下,系统调用的入口地址保存在MSR寄存器中,通过rdmsrl(MSR_LSTAR,ksystem_call);便可获得系统调用的入口地址,然后对该入口地址进行解析得到入口函数为system_call,具体的函数实现在/linux-3.10.1/arch/x86/kernel/entry_64.S文件中。
Entry_64.S为一个汇编文件,即system_call函数是由汇编语言实现的,在ENTRY(system_call)与END(system_call)之间有很对其他的函数定义和调用,与C语言程序的结构不同,因此system_call以及它内部包含的所有的内核函数的符号信息都保存在kallsyms文件系统中,因此根据内核栈中的返回地址查询kallsyms得到的内核符号可能只是真正调用函数内部的一个中间函数。
.align 2
bad_sys_call: #系统调用如果出错返回-1
movl $-1,%eax
iret
.align 2
reschedule: #对任务重新调度,把shedule函数返回地址ret_from_sys_call压入栈
pushl $ret_from_sys_call
// 执行schedule
jmp _schedule
.align 2
_system_call:
// 比较参数,不合法的参数直接返回中断,错误码是-1
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
// 寄存器压栈,保存现场和用户传递的参数
//ds和es段指向内核数据段,fs指向局部数据段
push %ds #数据段寄存器入栈
push %es #附加段寄存器入栈
push %fs #fs段寄存器入栈
// 执行系统调用的函数时用户传入的三个参数,右到左,ebx是第一个参数
pushl %edx
pushl %ecx #将%ebx,%ecx,%edx 作为_system_call的参数入栈
pushl %ebx
// 0x10是内核数据段的选择子
movl $0x10,%edx #将es,ds指向内核数据段
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # 将fs指向用户数据段
mov %dx,%fs
// 根据参数,从系统表格里找到对应的函数,每个函数地址4个字节
call _sys_call_table(,%eax,4)
// 系统调用的返回值,压栈保存,因为下面需要用eax
pushl %eax
// 把当前进程的数据结构地址赋值给eax
movl _current,%eax
// 判断当前进程状态,0是就绪状态,即判断当前进程是否可以继续执行
cmpl $0,state(%eax)
//如果不是就绪状态就执行进程调度程序
jne reschedule
// 查看时间片是否用完,如果是也执行进程调度程序
cmpl $0,counter(%eax) # counter
je reschedule
//系统调用返回处
ret_from_sys_call:
movl _current,%eax #将当前进程的数据结构地址复制给eax
// 判断当前执行的进程是不是0号进程
cmpl _task,%eax
// 是的话跳转到3f
je 3f
cmpw $0x0f,CS(%esp) # esp是指向战队内部特点个位置的指针CS是代码段指针
#如果进程A用户态的cs不是普通用户代码段跳转
jne 3f
cmpw $0x17,OLDSS(%esp) # 如果进程A用户态堆栈不在用户数据段中跳转
jne 3f
// 此时eax为当前任务地址,将当前进程信号位放入ebx,将当前进程的受阻塞的信号位放入ecx
movl signal(%eax),%ebx
movl blocked(%eax),%ecx
// 对block变量的值取反,即没有屏蔽的为变成1,表示需要处理的信号
notl %ecx
// 把收到的信号signal和没有屏蔽的信号,得到需要处理的信号,放到ecx中
andl %ebx,%ecx
/*
如果ecx等于0,则cf等于1,否则cf是0
从低位到高位扫描ecx,把等于第一个是1的位置写到ecx中,即第一位是1则位置是0
*/
bsfl %ecx,%ecx
// cf=1即ecx是0则跳转到3f,代表没有需要处理的信号则跳转
je 3f
// 把ebx的第ecx位清0,并把1移到CF,处理了该信号,清0
btrl %ecx,%ebx
movl %ebx,signal(%eax)
// 当前需要处理的信号加1,因为ecx保存的是位置,位置是0开始的,信号是1-32
incl %ecx
// 将要处理的信号压栈
pushl %ecx
// 执行信号处理函数
call _do_signal
popl %eax #返回值存入eax
popl %eax #返回中断处理程序
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
Iret
2、init/main.c分析与流程图
main.c是BIOS各种初始化后进入的第一个C主程序,其作用简单的讲就是进行各种外围硬件的初始化,然后fork第一个进程,然后开始执行第一个程序bash。
首先进入main后,进行了一系列的初始化工作,使得各个模块处于可用状态。这个初始化过程中加载了文件系统,创建了task0的ldt,gdt表项,并手动移到用户模式运行task0.紧接着task0 fork出进程1,运行init函数,我们也称这个进程为init进程。在init进程中,会首先执行/binsh,参数取自/etc/rc,然后退出,随后fork一个新的进程来运行/bin/sh,以此作为用户登录shell。这个过程是一个死循环。task0之后只会执行pause,在没有进程可运行时就会调用task0,因此称它为idle进程。它又会检查是否有可运行进程,如果没有就继续回到自身。它也是一个死循环。
首先大致分析出init/main.c函数的处理流程图,如下图所示:
以下对源代码进行分析,由于代码较多,我选取了其中比较有代表性的一部分进行阐述,如下所示:
(1)进行各种外围硬件的初始化:
void main(void)/* This really IS void, no error here. */
{
ROOT_DEV = ORIG_ROOT_DEV; /*跟设备号*/
drive_info = DRIVE_INFO; /*磁盘参数信息*/
memory_end = (1<<20) + (EXT_MEM_K<<10); /*内存1Mb+(扩展内存K)*1024字节*/
memory_end &= 0xfffff000; /*忽略最多不大于4Kb的内存*/
if (memory_end > 16*1024*1024) /*内存大于16Mb 按照16Mb计算*/
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024) /*内存大于12Mb 则设置缓存区末端4Mb*/
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024) /*内存大于6Mb 则设置缓存区末端2Mb*/
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024; /*否则设置缓存区末端1Mb*/
main_memory_start = buffer_memory_end; /*主内存开始地址设置为缓冲区末端*/
/*Makefile文件中定义了内存虚拟盘符号RAMDISK,则初始化虚拟盘*/
#ifdef RAMDISK
main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
#endif
(2)内核初始化主要工作:
mem_init(main_memory_start,memory_end); /*主存区初始化*/
trap_init(); /*陷阱门(硬件中断向量)初始化*/
blk_dev_init(); /*块设备初始化*/
chr_dev_init(); /*字符设备初始化*/
tty_init(); /*tty初始化*/
time_init(); /*设置开机启动时间*/
sched_init(); /*调度程序初始化*/
buffer_init(buffer_memory_end); /*缓冲管理初始化,建内存链表*/
hd_init(); /*硬盘初始化*/
floppy_init(); /*软盘初始化*/
sti(); /*所有初始化完毕,开启中断*/
(3)切换为用户模式:
move_to_user_mode(); /*移到用户模式下执行*/
(4)在子进程中执行init函数(因此这个子进程也被成为init进程)。
if (!fork()) { /* we count on this going ok */
init(); /*在新建的子进程(任务1)中执行*/
}
init的主要任务就是对将要执行shell环境初始化,并执行shell。前面通过move_to_user_mode()在用户态执行task0,之前已经手动为task0设置了ldt,gdt,并进行了加载,然后通过iret指令转移到它开始运行。
(5)在父进程中都将以任务0的身份运行。
for(;;) pause();
pause()系统调用会把任务0转换成可中断等待状态,再执行调度函数。但是调度函数只要发现系统中没有其他任务可以执行时就会切换到任务0,而不依赖于任务0的状态。
注意,对于任何其他任务,pause()将意味着我们必须等待接收到一个信号才会返回就绪态,但任务0 是唯一例外情况。因为任务0在任何空闲时间里都会被激活(当没有其他任务在运行时),因此对于任务0 pause()仅意味着我们返回来查看是否其他任务可以运行,如果没有的话我们就回到这里,一直循环执行pause()。
(6)init函数
void init(void)
{
int pid,i; /*读取硬盘参数包含分区表信息并加载虚拟盘和安装根文件系统设备*/
setup((void *) &drive_info); /*drive_info是两个硬盘参数表*/
(void) open("/dev/tty0",O_RDWR,0); /*读写方式访问/dev/tty0 */
(void) dup(0); /*stdout*/
(void) dup(0); /*stderr*/
/*打印缓冲区数据块数和总字节数,每块是1024字节,以及主内存空闲内存字节数*/
printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS,
NR_BUFFERS*BLOCK_SIZE);
printf("Free mem: %d bytes\n\r",memory_end-main_memory_start);
if (!(pid=fork())) { /*创建子进程1*/
close(0); /*关闭stdin*/
if (open("/etc/rc",O_RDONLY,0)) /*以只读方式打开/etc/rc*/
_exit(1);
execve("/bin/sh",argv_rc,envp_rc); /*进程替换为shell*/
_exit(2);
}
if (pid>0)
while (pid != wait(&i)) /*等待子进程1结束,空循环*/
/* 如果执行到这里,说明刚创建的子进程的执行已停止或终止了。下面循环中首先再创建一个子进程.如果出错,则显示“初始化程序创建子进程失败”的信息并继续执行。对于所创建的子进程关闭所有以前还遗留的句柄(stdin, stdout, stderr),新创建一个会话并设置进程组号,然后重新打开/dev/tty0 作为stdin,并复制成stdout 和stderr。再次执行系统解释程序/bin/sh。但这次执行所选用的参数和环境数组另选了一套。然后父进程再次运行wait()等待。如果子进程又停止了执行,则在标准输出上显示出错信息“子进程pid 停止了运行,返回码是i”,然后继续重试下去…,形成“大”死循环*/
while (1) {
if ((pid=fork())<0) { /*创建新子进程*/
printf("Fork failed in init\r\n");
continue;
}
if (!pid) { /*创建子进程2*/
close(0);close(1);close(2); /*关闭stdin, stdout,stderr*/
setsid(); /*创建新一期会话期*/
(void) open("/dev/tty0",O_RDWR,0); /*读写方式访问/dev/tty0(stdin) */
(void) dup(0); /*stdout(复制)*/
(void) dup(0); /*stderr(复制)*/
_exit(execve("/bin/sh",argv,envp));
}
while (1)
if (pid == wait(&i)) /*等待子进程2结束*/
break;
printf("\n\rchild %d died with code %04x\n\r",pid,i);
sync(); /*刷新缓冲区*/
}
父进程等待其退出,我们发现最外面的while循环没有退出指令,也就是它是一个死循环。一个/bin/sh结束后,会另外创建一个进程来执行它。
_exit(0);/* NOTE! _exit, not exit() */ /*_exit()是系统调用,exit是库函数调用*/
}
三、总结:
对几个问题有一些思考和分析,如下所示:
(1)init函数中的fork:
这个fork满有意思,老子(父进程)生了儿子(子进程),就是希望儿子来完成自己的梦想,而这个老子,基本上也就退休了,充满爱意的一直循环等待着儿子完成梦想,再次回到他身边。
另外需要注意:因为fork新进程是通过复制他的父进程的代码和数据段来完成的,所以为了保证栈中没有复制不想要的信息,我们需要在第一次fork操作之前清除第一任务的堆栈。也就是说:任务0最好不要在调用之前调用任何函数来使用它的堆栈。
因此,在任务0中运行main .c代码之后,不能调用作为函数的fork的代码。
(2)关于进程0:
进程0是一个特殊的进程,它是所有其它进程的祖先进程,所有其它的进程都是fork通过系统调用,复制进程0或者其后代进程产生的。但是进程0却不是通过fork调用产生的。进程0的代码就是内核system模块的代码,所以可以认为系统一启动进程0就开始运行。但是此时并不是真正的进程0,应为此时gdt中还没有设置tss和ldt描述符,直到sched_init()中才设置了tss和ldt并且把tss加载到tr寄存器,所以在此时应该算是进入真正的进程0,之前可以认为是进程0的初始化设置,可以说进程0是手动设置的。