切换到用户态模式 1 2 3 4 5 6 7 8 9 10 11 12 void main (void ) { ... move_to_user_mode(); if (!fork()) { init(); } for (;;) pause(); }
切换到用户态模式,并在一个新的进程中做一个最终的初始化 init。这个 init 函数会创建出一个进程,设置终端的标准 IO,并且再创建出一个执行 shell 程序的进程用来接受用户的命令。
move_to_user_mode() 改变代码的特权级,从内核态转变为用户态。
一旦转变为了用户态,就会一直处于用户态的模式,除非发生了中断,比如用户发出了系统调用的中断指令,那么此时将会从用户态陷入内核态,不过当中断处理程序执行完之后,又会通过中断返回指令从内核态回到用户态。
内核态与用户态的本质-特权级 特权级检查主要是检查段选择子的特定结构
这一切都源于 CPU 的保护机制,CPU 为了配合操作系统完成保护机制这一特性,分别设计了分段保护机制 与分页保护机制 。
将 cr0 寄存器的 PE 位开启时,就开启了保护模式,也即开启了分段保护机制 ;将 cr0 寄存器的 PG 位开启时,就开启了分页模式,也即开启了分页保护机制 。
有关特权级的保护,实际上属于分段保护机制的一种。具体怎么保护的呢?
举个简单的例子,我们正在执行一串跳转代码,也就是要跳到另一处内存地址执行,一般都会涉及到 jmp、call 和 中断。拿 jmp 跳转来举例。
如果是短跳转,即 jmp xxx,这不会涉及到段的变换,也就没有特权级的检查。
如果是长跳转,即 jmp yyy:xxx,yyy 表示会跳转到的另一个段选择子结构。此时就会触发特权级的检查:
首先是正在执行的那一串跳转代码,是由 cs:eip 指向,cs 是代码段寄存器,存着段选择子。
这里面的低端两位,此时表示 CPL ,也就是当前所处的特权级 ,假如我们现在这个时刻,CS 寄存器的后两位为 3,二进制就是 11,就表示是当前处理器处于用户态这个特权级。
然后是要跳转到的内存地址,yyy:xxx,yyy 也是段选择子。
这个结构仍然是一样的段选择子结构,只不过这里的低端两位,表示 RPL ,也就是请求特权级 ,表示我想请求的特权级是什么。同时,CPU 会拿这个段选择子去全局描述符表中寻找段描述符,从中找到段基址。
这里的 DPL ,表示目标代码段特权级 ,也就是即将要跳转过去的那个段的特权级。
系统会将这三个特权级检查。
检查的规则很多,但是绝大多数情况下,会要求 CPL 必须等于 DPL ,才能实现跳转。说白了就是只能用户态跳用户态,内核态跳内核态 。
这只是代码段跳转的检查,还有数据段的特权级检查,最终效果是处于内核态的代码可以访问任何特权级的数据段,处于用户态的代码则只可以访问用户态的数据段 ,这也就实现了内存数据读写的保护。
总结一下就是:代码跳转只能同特权级跳转,数据访问只能高特权级访问低特权级。
特权级转换的方式 上面说代码跳转只能同特权级跳,但是如果现在处于内核态,又怎么会跳到用户态呢?
Intel 设计了好多种特权级转换的方式,中断 和中断返回 就是其中的一种。
处于用户态的程序,通过触发中断,可以进入内核态,之后再通过中断返回,又可以恢复为用户态 。
也就是刚刚那幅图:
系统调用 就是这样,用户通过 int 0x80 中断指令触发中断,CPU 切换至内核态,执行中断处理程序,之后中断程序返回,又从内核态切换回用户态。
如果当前代码,本身就处在内核态,并不是由一个用户态程序通过中断而切换到的内核态,那怎么回到原来的用户态呢?答案还是,通过中断返回。
没有中断也能中断返回,中断和中断返回既可以配套使用,又可以单独使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void main(void) { ... move_to_user_mode(); ... } #define move_to_user_mode() \ _asm { \ _asm mov eax,esp \ _asm push 00000017h \ _asm push eax \ _asm pushfd \ _asm push 0000000fh \ _asm push offset l1 \ _asm iretd /* 执行中断返回指令*/ \ _asm l1: mov eax,17h \ _asm mov ds,ax \ _asm mov es,ax \ _asm mov fs,ax \ _asm mov gs,ax \ }
可以看到,直接执行了中断返回指令 iretd。
上面的五次压栈操作是为什么呢?因为中断返回理论上就是应该和中断配合使用的,而此时并不是真的发生了中断到这里,所以我们得假装发生了中断 才行。
中断发生时,CPU 会自动帮我们压栈;中断返回时,CPU 又会帮忙吧压栈的这些值反序 赋值给相应的寄存器。
执行 iretd 指令时,硬件会按顺序将刚刚压入栈中的数据,分别赋值给 SS、ESP、EFLAGS、CS、EIP 这几个寄存器,这就感觉像是正确返回了一样,让其误以为这是通过中断进来的 。
压入栈的 CS 和 EIP 代表中断发生前代码所处的位置,中断返回就能继续去那边执行。
SS 和 ESP 表示中断发生前栈的位置,返回后恢复栈。
再看看代码1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #define move_to_user_mode() \ _asm { \ _asm mov eax,esp \ _asm push 00000017h \ ; 给 SS 赋值 _asm push eax \ ; ESP _asm pushfd \ ; EFLAGS _asm push 0000000fh \ ; 给 CS 赋值 _asm push offset l1 \ ; EIP _asm iretd /* 执行中断返回指令*/ \ _asm l1: mov eax,17h \ _asm mov ds,ax \ _asm mov es,ax \ _asm mov fs,ax \ _asm mov gs,ax \ }
以 CS 为例,被赋值成了 0x0000000f,用二进制表示就是 0000000000001111
最后两位是 11 表示特权级为 3 ,用户态。而刚刚也说了,CS 寄存器里的特权级,表示 CPL,即处理器特权级。
所以通过 iretd 返回后,CS 的值就变成了这个,当前处理器特权级,也就变成了用户态特权级。
除了改变特权级之外 除了改变特权级之外,还做了什么事呢?
再来看看段选择子的结构:
刚刚说了 CS 寄存器为 0000000000001111 ,最后两位表示用户态。
倒数第三位 TI 表示,全面的描述符索引,是从 GDT(全局描述符表) 还是从 LDT(局部描述符表) 中取,1 表示 LDT,0 表示 GDT ,这里是从局部描述符表里取。
而如今的 GDT 和 LDT 表被设计成了这样:
接着往下看
1 2 3 4 5 6 7 8 9 10 11 12 13 #define move_to_user_mode() \ _asm { \ ... _asm push 00000017h \ ; 给 SS 赋值 ... _asm push offset l1 \ ; EIP _asm iretd /* 执行中断返回指令*/ \ _asm l1: mov eax,17h \ _asm mov ds,ax \ _asm mov es,ax \ _asm mov fs,ax \ _asm mov gs,ax \ }
将 EIP 赋值为 l1,使得 CPU 跳转到那,执行 l1 处的代码,这里 ss 和 ds 都被赋值成了 0x17 ,对应的二进制是 10111 ,可以发现是用户态特权级,对应的描述符是 LDT。
兜兜转转记住一句话就行:数据访问只能高特权级访问低特权级,代码跳转只能同特权级跳转,要想实现特权级转换,可以通过中断和中断返回来实现。
总结一下 move_to_user_mode() 干的事:通过伪造一个中断和中断返回,巧妙地从内核态切换到了用户态。
进程调度 先说说进程调度的本质是什么:
假如有两段代码被加载在内存中,进程调度就是让 CPU 一会去程序 1 位置处运行一段时间,一会又去进程 2 位置处运行一段时间。
怎么实现在几个程序之间来回切换呢?可以由一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。
这个不受任何程序控制的,第三方的不可抗力,就是由定时器触发的时钟中断 (sched_init)。
而那个特殊的程序,就是进程调度函数 了。
我们用 tast_struct 结构来支持这个流程,用来记录各个流程的信息,比如 CPU 上一次执行到哪里了。
上下文环境 每个程序最终的本质就是执行指令。这个过程会涉及寄存器 ,内存 和外设端口 。
内存 :内存还有可能设计成相互错开的,互不干扰,比如进程 1 你就用 01K 的内存空间,进程 2 就用 1K2K 的内存空间,咱谁也别影响谁。
虽然有点浪费空间,而且对程序员十分不友好,但起码还是能实现的。
寄存器 :寄存器总共就那几个,肯定做不到互不干扰,有可能程序 1 往 eax 里写入了一个值,程序 2 又往 eax 里写值就会出错。
所以最稳妥的做法就是,每次切换进程时,都把当前这些寄存器的值存到一个地方,以便之后切换回来的时候恢复。
Linux 0.11 就是这样做的,每个进程的结构 task_struct 里面,有一个叫 tss 的结构,存储的就是 CPU 这些寄存器 的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 struct task_struct { ... struct tss_struct tss ; } struct tss_struct { long back_link; long esp0; long ss0; long esp1; long ss1; long esp2; long ss2; long cr3; long eip; long eflags; long eax,ecx,edx,ebx; long esp; long ebp; long esi; long edi; long es; long cs; long ss; long ds; long fs; long gs; long ldt; long trace_bitmap; struct i387_struct i387 ; };
这里有个细节:
tss 结构里有一个 cr3 ,表示 cr3 寄存器里存的值,而 cr3 寄存器又是指向页目录表首地址的。
当 cr3 不同,指向的页目录表就不同,整个页表结构也就不同,线性地址到物理地址的映射关系也就不同。
也就是说,通过 cr3 就完全可以不需要之前所说的不同程序通过存储在不同的内存空间以互不干扰,而是建立不同的映射关系,由操作系统来建立不同的页目录表并替换 cr3 寄存器即可。
这也可以理解为,保存了内存映射的上下文信息 。
当然 Linux 0.11 并不是通过替换 cr3 寄存器来实现内存互不干扰的,它的实现更为简单,这是后话了。
所以保存上下文就可以理解成:app点击一个按钮进入一个新的界面,也要保存你是在哪个屏幕跳过来的等等信息,以便你点击返回的时候能正确跳回,如果不存肯定就无法正确跳回了。
运行时间信息 如何判断一个进程该让出 CPU,切换到下一个进程呢?
答案是给进程一个属性,叫剩余时间片 ,每触发一次时钟中断,都 -1 ,如果减到 0,就触发切换进程的操作。
在 Linux 0.11 里,这个属性就是 counter 。
1 2 3 4 5 struct task_struct { ... long counter; ... }
而他的用法也非常简单,就是每次中断都判断一下是否到 0 了。
1 2 3 4 5 6 7 void do_timer (long cpl) { ... if ((--current->counter)>0 ) return ; schedule(); }
如果还没到 0,就直接返回,相当于这次时钟中断什么也没做,仅仅是给当前进程的时间片属性做了 -1 操作。
如果已经到 0 了,就触发进程调度 ,选择下一个进程并使 CPU 跳转到那里运行。
进程调度的逻辑就是在 schedule 函数里,怎么调,我们先不管。
“优先级” counter 应该初始化为多少呢?
需要有个属性来记录这个值,这个值越大,counter 就越大,那么每次轮到这个进程时,在 CPU 中运行的时间就越长,也就是这个进程比其他进程得到了更多 CPU 运行的时间。
1 2 3 4 5 6 struct task_struct { ... long counter; long priority; ... }
每次一个进程初始化时,都会把 counter 赋值为 priority。
进程状态 我们总要不断优化以适应不同场景的用户需求。
假设有这样一个场景,一个进程中有一个读取硬盘的操作,发起请求后,要等好久才能得到硬盘的终端信号。这个时候该进程占着 CPU 也没啥用,就会主动放弃 CPU 的执行权,把自己标记为等待中。把 CPU 的执行权限留给其他进程。
这个等待中的状态用 state 属性记录,通俗一点就是 state 记录了进程的状态 。
1 2 3 4 struct task_struct { long state; ... }
这个进程状态在 linux 0.11 里有五种
1 2 3 4 5 #define TASK_RUNNING 0 #define TASK_INTERRUPTIBLE 1 #define TASK_UNINTERRUPTIBLE 2 #define TASK_ZOMBIE 3 #define TASK_STOPPED 4
现在已经可以完成简单的进程调度任务了,有表示状态的 state ,表示剩余时间片的 counter ,表示优先级的 priority ,和表示上下文信息的 tss 。当然还有其他字段,到时候再说。
看看 Linux 0.11 进程结构的全部
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 struct task_struct { long state; long counter; long priority; long signal; struct sigaction sigaction [32]; long blocked; int exit_code; unsigned long start_code,end_code,end_data,brk,start_stack; long pid,father,pgrp,session,leader; unsigned short uid,euid,suid; unsigned short gid,egid,sgid; long alarm; long utime,stime,cutime,cstime,start_time; unsigned short used_math; int tty; 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 ]; struct desc_struct ldt [3]; struct tss_struct tss ; };
总结 这一节主要讲了创建新进程的过程,先补充了进程调度的本质 是进程之间的来回切换 ,然后说了触发进程调度的方式 是通过时钟中断 ,再通过进程调度函数 跳至其他进程;进程调度的过程中,会用tast_struct 结构来记录整个过程,之后的很多属性都会保存在这个结构体里;会通过 tss 来保存寄存器的值以保证上下文环境一致;会以counter 减为 0 的条件触发切换进程的操作;会通过 priority 记录 counter 的初始值来表示多个进程之间的”优先级”(数越大说明占用CPU的时间越长,”优先级”也就越高);会用 state 记录进程的状态。
进程调度的全过程 在 /kernel/sched.c
的 sched_init 中,程序开启了定时器,每隔一段时间就会给 CPU 发起一个中断信号。
1 2 3 4 5 6 7 8 9 10 11 12 #define LATCH (1193180/HZ) void sched_init (void ) { ... outb_p(0x36 ,0x43 ); outb_p(LATCH & 0xff , 0x40 ); outb(LATCH >> 8 , 0x40 ); set_intr_gate(0x20 ,&timer_interrupt); outb(inb_p(0x21 )&~0x01 ,0x21 ); set_system_gate(0x80 ,&system_call); ... }
时间间隔被设置成了 10 ms,也就是 100 Hz。
中断号被设置成了 0x20 ,中断处理函数是 timer_interrupt
来看看 timer_interrupt 部分源码
1 2 3 4 5 6 7 8 9 10 #/kernel/system_call.s _timer_interrupt: ... // 增加系统滴答数 incl _jiffies ; 自增1 ... // 调用函数 do_timer call _do_timer ...
jiffies 自增1,调用函数 do_timer
看看 do_timer 部分源码
1 2 3 4 5 6 7 8 9 10 11 #/kernel/sched.c void do_timer (long cpl) { ... if ((--current->counter)>0 ) return ; current->counter=0 ; if (!cpl) return ; schedule(); }
如果上一个进程时间不为 0 ,直接返回,否则置当前进程 counter 为 0 ,然后判断当前特权级,为 0 (内核态)直接返回,不为 0 就调度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #/kernel/sched.c void schedule (void ) { int i, next, c; struct task_struct ** p ; ... while (1 ) { c = -1 ; next = 0 ; i = NR_TASKS; p = &task[NR_TASKS]; while (--i) { if (!*--p) continue ; if ((*p)->state == TASK_RUNNING && (*p)->counter > c) c = (*p)->counter, next = i; } if (c) break ; for (p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p) (*p)->counter = ((*p)->counter >> 1 ) + (*p)->priority; } switch_to(next); }
分成几部分看
1 2 3 4 5 6 while (--i) { if (!*--p) continue ; if ((*p)->state == TASK_RUNNING && (*p)->counter > c) c = (*p)->counter, next = i; }
这部分是遍历找出正在运行状态 的且 counter 最大 的进程号 next。
1 2 3 4 5 if (c) break ;for (p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p) (*p)->counter = ((*p)->counter >> 1 ) + (*p)->priority;
如果系统有正在运行状态的进程 counter 不为 0 ,或者系统中没有一个正在运行的任务存在(c = -1),退出循环。
如果系统所有正在运行状态的进程 counter 为 0,就将所有进程的 counter 重新赋值为 counter/2 + priority
,然后再回到 while 循环。
最后拿到一个进程号 next,调用函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #define switch_to(n) {\ struct {long a,b;} __tmp; \ __asm__("cmpl %%ecx,_current\n\t" \ "je 1f\n\t" \ "movw %%dx,%1\n\t" \ "xchgl %%ecx,_current\n\t" \ "ljmp %0\n\t" \ "cmpl %%ecx,_last_task_used_math\n\t" \ "jne 1f\n\t" \ "clts\n" \ "1:" \ ::"m" (*&__tmp.a),"m" (*&__tmp.b), \ "d" (_TSS(n)),"c" ((long) task[n])); \ }
这是进程切换最最底层的代码,看不懂没关系,只要知道最终主要干了一件事:ljmp 到新进程的 tss 段处。
什么意思?
CPU 规定,如果 ljmp 指令后面跟的是 tss 段,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程 的 tss 信息加载到各个寄存器。
简单来说就是:保存当前进程上下文,恢复下一个进程的上下文,跳过去
总结:定时器每 10 ms会触发一次时钟中断信号,这个信号会使 CPU 查找中断向量表,触发中断处理函数 timer_interrupt ,timer_interrupt 又会调用函数 do_timer,do_timer 函数会判断上一个进程 counter 是否为 0 ,如果不为 0 ,直接返回,反之设置当前进程 counter 为 0 ,且如果特权级不在内核态,调用 schedule ,进行进程调度。schedule 函数会找到正在运行 的且 counter 最大 的进程号,并将其放入 switch_to 函数,switch_to 会保存当前进程的上下文,同时使 CPU 跳转到这个进程的偏移地址处。
fork() 系统调用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 static _inline _syscall0(int,fork) #define _syscall0(type,name) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name)); \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }
把它转换成稍微能看得懂的样子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #define _syscall0(type,name) \ type name(void) \ { \ volatile long __res; \ _asm { \ _asm mov eax,__NR_##name \ _asm int 80h \ _asm mov __res,eax \ } \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }
把宏定义都展开,其实就相当于定义了一个函数 。
1 2 3 4 5 6 7 8 9 10 11 12 int fork(void) { volatile long __res; _asm { _asm mov eax,__NR_fork _asm int 80h _asm mov __res,eax } if (__res >= 0) return (void) __res; errno = -__res; return -1; }
内敛汇编看不懂,关注结果就行
最关键的是这里触发了 int 0x80 的系统调用,eax 参数是 __NR_fork,值为 2
之前在 sched_init 有定义 int 0x80 的系统中断
1 set_system_gate(0x80, &system_call);
再看看 system_call
1 2 3 4 5 #/kernel/system_call.s _system_call: ... call [_sys_call_table + eax*4] ...
eax 被赋值为 2 ,也就是找到 sys_call_table 里下标为 2 的函数,然后跳过去。
接着看 sys_call_table 有啥
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #/include/linux/sys.h fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link, sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod, sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount, sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm, sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access, sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir, sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid, sys_setregid };
可以看到第二项对应的就是 sys_fork,这也印证了为啥做 pwn 题时要把 eax 改成对应的值就能调用对应的函数,是为了去 sys_call_table[] 里找下标,然后对应函数。
收回,再看看 sys_fork,具体干了啥,之后再讲
1 2 3 4 5 6 7 8 9 10 11 12 13 #/kernel/system_call.s _sys_fork: call _find_empty_process testl %eax,%eax js 1f push %gs pushl %esi pushl %edi pushl %ebp pushl %eax call _copy_process addl $20,%esp 1: ret
再补充一些,刚刚定义 fork 函数的系统调用模板函数中,用的是 syscall0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static _inline _syscall0(int,fork) #define _syscall0(type,name) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name)); \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }
这个 0 其实表示的就是参数的个数,也就是 sys_fork 函数是不需要任何参数的。
可以在/include/unistd.h
中找到定义,简化如下:
1 2 3 4 #define _syscall0(type,name) #define _syscall1(type,name,atype,a) #define _syscall2(type,name,atype,a,btype,b) #define _syscall3(type,name,atype,a,btype,b,ctype,c)
这些参数会放在哪里呢?
答案是 execve ,在之后创建进程 1 和进程 2 的过程中,execve 都会和 fork 函数一起出现。
1 2 3 4 5 6 7 8 9 void init (void ) { ... if (!(pid=fork())) { ... execve("/bin/sh" ,argv_rc,envp_rc); ... } }
然后我们再来细看一下 syscall3
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 execve("/bin/sh",argv_rc,envp_rc); _syscall3(int,execve,const char *,file,char **,argv,char **,envp) #define _syscall3(type,name,atype,a,btype,b,ctype,c) \ type name(atype a,btype b,ctype c) { \ volatile long __res; \ _asm { \ _asm mov eax,__NR_##name \ _asm mov ebx,a \ _asm mov ecx,b \ _asm mov edx,c \ _asm int 80h \ _asm mov __res,eax\ } \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }
可以看出,a 放入了 ebx,b 放入了 ecx,c 放入了 edx。
再细看一下 system_call
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 _system_call: cmpl $nr_system_calls-1,%eax ja bad_sys_call push %ds push %es push %fs pushl %edx pushl %ecx # push %ebx,%ecx,%edx as parameters pushl %ebx # to the system call movl $0x10,%edx # set up ds,es to kernel space mov %dx,%ds mov %dx,%es movl $0x17,%edx # fs points to local data space mov %dx,%fs call _sys_call_table(,%eax,4) pushl %eax movl _current,%eax cmpl $0,state(%eax) # state jne reschedule cmpl $0,counter(%eax) # counter je reschedule ret_from_sys_call: movl _current,%eax # task[0] cannot have signals cmpl _task,%eax je 3f cmpw $0x0f,CS(%esp) # was old code segment supervisor ? jne 3f cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ? jne 3f movl signal(%eax),%ebx movl blocked(%eax),%ecx notl %ecx andl %ebx,%ecx bsfl %ecx,%ecx je 3f btrl %ecx,%ebx movl %ebx,signal(%eax) incl %ecx pushl %ecx call _do_signal popl %eax 3: popl %eax popl %ebx popl %ecx popl %edx pop %fs pop %es pop %ds iret
我们只关注压栈的情况,之前有说过,触发中断时,CPU 会往栈中压入一些数据
system_call 是通过 int 0x80 这个软中断实现的,属于特权级发生变化,且没有错误码情况的中断,所以栈会被提前压入 SS、ESP、EFLAGS、CS、EIP 。
之后 system_call 函数本身也会压入一些值,ds、es、fs、edx、ecx、ebx、eax 。
现在来看一下栈中寄存器的位置,作者也很贴心的给出了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /* * Stack layout in 'ret_from_system_call': * * 0(%esp) - %eax * 4(%esp) - %ebx * 8(%esp) - %ecx * C(%esp) - %edx * 10(%esp) - %fs * 14(%esp) - %es * 18(%esp) - %ds * 1C(%esp) - %eip * 20(%esp) - %cs * 24(%esp) - %eflags * 28(%esp) - %oldesp * 2C(%esp) - %oldss */
之后,中断处理程序如果有需要,就可以从这里取出它想要的值。
比如 sys_execve 这个中断处理程序,就取走了位于 0x1c 处的值
1 2 3 4 5 6 7 EIP = 0x1C _sys_execve: lea EIP(%esp),%eax pushl %eax call _do_execve addl $4,%esp ret
之后的 do_execve 函数,又取走了 filename,argv,envp 等参数,这个之后再说。
总结:最重要的是调用了 int 0x80 的系统调用,将 eax 赋成特定的值,就能调用特定的函数
sys_fork() 1 2 3 4 5 6 7 8 9 10 11 12 13 #/kernel/sysctem_call.s _sys_fork: call _find_empty_process testl %eax,%eax js 1f push %gs pushl %esi pushl %edi pushl %ebp pushl %eax call _copy_process addl $20,%esp 1: ret
调用了两个函数:find_empty_process、copy_process
find_empty_process 之前有说过,进程是以 task_struct 的结构存储在数组 task[64] 中
这个函数就是为了找到空闲的进程槽位,然后在槽位准备存一个新的进程的结构 task_struct
来具体看看代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #/kernel/fork.c long last_pid = 0 ;int find_empty_process (void ) { int i; repeat: if ((++last_pid)<0 ) last_pid=1 ; for (i=0 ; i<64 ; i++) if (task[i] && task[i]->pid == last_pid) goto repeat; for (i=1 ; i<64 ; i++) if (!task[i]) return i; return -EAGAIN; }
逻辑很清楚了,首先判断 last_pid +1 是否小于0,小于 0 说明超出了进程号的整数表示范围,并重新赋值为 1;第一个 for 循环遍历整个进程数组,判断 last_pid 是否被占用,被占用了就回去重新找下一个,没被占用就返回这个进程号;下一个 for 循环,再次遍历找到这个 task 中的空闲项并返回。
最终,这个函数会返回 task[] 数组的索引,表示找到了一个空闲项 ,并在里面加入新进程。
由于我们现在只有 0 号进程,且 task[] 除了 0 号索引位置,其他地方都是空的,所以这个函数运行完,last_pid 就是 1,也就是新进程被分配的 pid 就是 1,即将要加入的 task[] 数组的索引位置,也是 1。
怎么构造新的进程结构,并塞入 task[1] 中呢?就要看下一个函数了
copy_process 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 #/kernel/fork.c 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; p->state = TASK_UNINTERRUPTIBLE; p->pid = last_pid; p->father = current->pid; 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; 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)) { task[nr] = NULL ; free_page((long ) p); return -EAGAIN; } 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; return last_pid; }
之前说过,在内存初始化时,mem_init 会用一个 mem_map[] 数组来记录内存的使用次数
首先 get_free_page 会在主内存申请一个空闲页面,就是遍历 mem_map[] 这个数组,找出值为 0 的项,就表示找到了空闲的一页内存。然后把该项置为 1,表示该页已经被使用一次。最后,算出这个页的内存起始地址,返回给 task_struct 结构的 p 。
这样,一个新的进程就有了属于自己的内存空间。
然后会把这个 p 放入进程管理结构 task[] 中。
*p = *current
,就是把当前进程,也就是 0 号进程的 task_struct 的全部值都复制给即将创建的进程 p ,目前它们两者就完全一样了。
最后内存的布局效果就是这样:
现在进程 1 和进程 0 是完全复制的状态,但后面的赋值操作是将一些值个性化处理,这里列举一部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 int copy_process (int nr, ...) { ... p->state = TASK_UNINTERRUPTIBLE; p->pid = last_pid; p->counter = p->priority; .. p->tss.edx = edx; p->tss.ebx = ebx; p->tss.esp = esp; ... p->tss.esp0 = PAGE_SIZE + (long ) p; p->tss.ss0 = 0x10 ; ... }
不一样的值,一部分是 state ,pid ,counter 这种进程的元信息 ,另一部分是 tss 里面保存的各种寄存器的信息,即上下文 。
这里有两个寄存器的值的赋值有些特殊,就是 ss0 和 esp0,表示 0 特权级也就是内核态时的 ss:esp
p->tss.esp0 = PAGE_SIZE + (long) p
指的是将内核态的栈顶指针 esp 指向新分配的这 1 页内存的顶端,之后的每个进程都会这样设置
总结一下 find_empty_process 和 copy_process 前半部分干了啥
调用了两个函数,首先会在 task 数组中找到空闲项,然后再在内存中找到空闲的一页内存,返回指针 p 并存入 task 数组的这个空闲项,再将进程 0 的结构都复制给新进程 p,再对 p 的值进行个性化修改。
接着往下看(后半部分)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 ); new_code_base = nr * 0x4000000 ; new_data_base = nr * 0x4000000 ; set_base(p->ldt[1 ],new_code_base); set_base(p->ldt[2 ],new_data_base); old_code_base = get_base(current->ldt[1 ]); old_data_base = get_base(current->ldt[2 ]); copy_page_tables(old_data_base,new_data_base,data_limit); return 0 ; }
其实就是新进程 LDT 表项的赋值,以及页表的拷贝 。
LDT 的赋值 之前有说过,程序员给出的逻辑地址转换成物理地址要经过几个步骤:
现在已经开启了分页,分页的具体转化如下(感觉图中应该是 13 M)
再将这个图简化和 GDT 表一起看
可以看到进程 0 的 LDT 的代码段和数据段,段基址都是 0 ,段限长时 640k 。而进程 1 ,也就是我们现在正在 fork 的进程,其代码段和数据段还没有设置。
所以第一步,局部描述符表 LDT 的赋值,就是给上图中两个未设置的代码段和数据段赋值。
其中的段限长 ,直接取自进程 0 设置好的,即 640 k。
1 2 code_limit = get_limit(0x0f ); data_limit = get_limit(0x17 );
段基址,取决于当前是几号进程,也就是 nr 的值
1 new_data_base = new_code_base = nr * 0x4000000
0x4000000 等于 64M,也就是说今后每个进程都会在线性地址空间中占用 64M 的空间,且相邻。
接着设置新进程局部描述符表中段描述符中的基地址。
1 2 set_base(p->ldt[1 ],new_code_base); set_base(p->ldt[2 ],new_data_base);
设置完成后的效果图
这即是段表的设置。
页表的复制 刚刚讲完了段表的赋值,现在来讲讲页表的复制,也就是最后的 copy_page_tables 函数
1 2 3 4 5 int copy_mem (int nr,struct task_struct * p) { ... copy_page_tables(old_data_base,new_data_base,data_limit) }
进程 0 有一个页目录表和四个页表,将线性地址空间的 0-16M 原封不动映射到了物理地址空间的 0-16M。新的进程,也需要一套映射关系的页表。来看看页表是怎么建立的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 #mm/memory.c /* * Well, here is one of the most complicated functions in mm. It * copies a range of linerar addresses by copying only the pages. * Let's hope this is bug-free, 'cause this one I don't want to debug :-) */ int copy_page_tables(unsigned long from,unsigned long to,long size) { unsigned long * from_page_table; unsigned long * to_page_table; unsigned long this_page; unsigned long * from_dir, * to_dir; unsigned long nr; from_dir = (unsigned long *) ((from>>20) & 0xffc); to_dir = (unsigned long *) ((to>>20) & 0xffc); size = ((unsigned) (size+0x3fffff)) >> 22; for( ; size-->0 ; from_dir++,to_dir++) { if (!(1 & *from_dir)) continue; from_page_table = (unsigned long *) (0xfffff000 & *from_dir); to_page_table = (unsigned long *) get_free_page() *to_dir = ((unsigned long) to_page_table) | 7; nr = (from==0)?0xA0:1024; for ( ; nr-- > 0 ; from_page_table++,to_page_table++) { this_page = *from_page_table; if (!(1 & this_page)) continue; this_page &= ~2; *to_page_table = this_page; if (this_page > LOW_MEM) { *from_page_table = this_page; this_page -= LOW_MEM; this_page >>= 12; mem_map[this_page]++; } } } invalidate(); return 0; }
现在进程 0 的线性地址空间是 064M,进程 1 的线性地址空间是 64M128M。我们现在要造一个进程 1 的页表,使得进程 1 和进程 0 最终被映射到的物理空间都是 0 - 64M ,这样进程 1 才能顺利运行起来。
总之,最终的效果就是:
假设现在正在运行进程 0,代码中给出一个虚拟地址 0x03,由于进程 0 的 LDT 中代码段基址是 0,所以线性地址也是 0x03,最终由进程 0 页表映射到物理地址 0x03 处。
假设现在正在运行进程 1,代码中给出一个虚拟地址 0x03,由于进程 1 的 LDT 中代码段基址是 64M,所以线性地址是 64M + 3,最终由进程 1 页表映射到物理地址也同样是 0x03 处。
即进程 0 和进程 1 目前共同映射物理内存的前 640K 的空间。
如何将不同地址通过不同页表映射到相同物理地址空间?举个例子
刚刚的进程 0 的线性地址 0x03 用二进制表示是:
0000000000_0000000000_000000000011
刚刚的进程 1 的线性地址 64M + 0x03 用二进制表示是:
0000010000_0000000000_000000000011
根据分页机制的转化规则,前 10 位表示页目录项,中间 10 位表示页表项,后 12 位表页内偏移。
进程 0 要找的是页目录项 0 中的第 0 号页表
进程 1 要找的是页目录项 16 中的第 0 号页表
那只要让这俩最终找到的两个页表里的数据一模一样即可。
由于代码非常绕,所以我们只要知道结果就行。
这里做一个小补充,先放一张页表结构图
其中 RW 位表示读写状态,0 表示只读(或可执行),1表示可读写(或可执行)。当然,在内核态也就是 0 特权级时,这个标志位是没用的。
然后我们看看下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int copy_page_tables (unsigned long from,unsigned long to,long size) { ... for ( ; size-->0 ; from_dir++,to_dir++) { ... for ( ; nr-- > 0 ; from_page_table++,to_page_table++) { ... this_page &= ~2 ; ... if (this_page > LOW_MEM) { *from_page_table = this_page; ... } } } ... }
~2 表示取反,2 用二进制表示是 10,取反就是 01,其目的是把 this_page 也就是当前的页表的 RW 位置零,也就是是把该页变成只读 。
而 *from_page_table = this_page 表示又把源页表也变成只读 。
也就是说,经过 fork 创建出的新进程,其页表项都是只读的,而且页表项的只读,导致源进程的页表项也是只读。
这个就是写时复制 的基础,新老进程一开始共享同一个物理内存空间,如果只有读,那就相安无事,但如果任何一方有写操作,由于页面是只读的,将触发缺页中断,然后就会分配一块新的物理内存给产生写操作的那个进程,此时这一块内存就不再共享了。
总结 总结一下 copy_process 函数做的三件事,把整个进程的数据结构个性化的从进程 0 复制给了进程 1。
复制了 task_struct ,并对一些值做了个性化修改。
LDT 的复制和改造,使得进程 0 和进程 1 分别映射到了不同的线性地址空间。
死循环 1 2 3 4 void main (void ) { ... for (;;) pause(); }
如果没有任何任务可以运行,操作系统会一直陷入这个死循环。