由linux0.11进程调度小窥内存分段机制(转)
内存分段机制的一个主要应用在于实现操作系统的多任务,它为应用程序提供了两个关键抽象:一个独立的逻辑控制流,一个私有的地址空间。本文将针对进程的创建和调度进行分析和实验,从而更深刻的理解分段机制。有关调试环境的建立见前文:从linux0.11引导代码小窥内存分段机制
进程调度初始化(sched_init函数)
在引导代码执行结束后,执行序列将跳转到main函数,执行一系列的初始化工作,其中就有对任务0的初始化过程,其代码包含在kernel/sched.c中的sched_init函数中:
void sched_init(void) { int i; struct desc_struct * p; if (sizeof(struct sigaction) != 16) panic("Struct sigaction MUST be 16 bytes"); /*建立第0号任务的TSS,LDT描述符表项 */
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++) { task[i] = NULL; 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"); ltr(0); /*将任务0的TSS加载到任务寄存器tr*/ lldt(0); /*将局部描述符表加载到局部描述符表寄存器*/ 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); } |
set_tss_desc函数在include\asm\system.h中定义:
/*对8字节的描述符各个字节进行设置 */
#define _set_tssldt_desc(n,addr,type) \ __asm__ ("movw $104,%1\n\t" \ "movw %%ax,%2\n\t" \ "rorl $16,%%eax\n\t" \ "movb %%al,%3\n\t" \ "movb $" type ",%4\n\t" \ "movb $0x00,%5\n\t" \ "movb %%ah,%6\n\t" \ "rorl $16,%%eax" \ ::"a" (addr), "m" (*(n)), "m" (*(n+2)), "m" (*(n+4)), \ "m" (*(n+5)), "m" (*(n+6)), "m" (*(n+7)) \ ) /*0x89为TSS描述符的属性,0x82为LDT描述符的属性 */
#define set_tss_desc(n,addr) _set_tssldt_desc(((char *) (n)),addr,"0x89") #define set_ldt_desc(n,addr) _set_tssldt_desc(((char *) (n)),addr,"0x82") |
上面那段汇编代码即对GDT一个8字节描述符表项的各个字节进行设置,设置完毕后每个描述符的内容如下表:
系统段 描述符 |
7 |
6 |
5 |
4 |
3 |
2 |
1 |
0 |
addr高8位 |
0x0089或0x0082 |
addr低24位 |
0x0068(段界限) |
0x0089表示可用的386TSS(0x9),0x0082表示可用的LDT(0x2)。
init_task为全局变量,其初始化为INIT_TASK,INIT_TASK宏定义如下:
#define INIT_TASK \ /* state etc */ { 0,15,15, \ /* signals */ 0,{{},},0, \ /* ec,brk... */ 0,0,0,0,0,0, \ /* pid etc.. */ 0,-1,0,0,0, \ /* uid etc */ 0,0,0,0,0,0, \ /* alarm */ 0,0,0,0,0,0, \ /* math */ 0, \ /* fs info */ -1,0022,NULL,NULL,NULL,0, \ /* filp */ {NULL,}, \ {0,0}, \ /* ldt */ {0x {0x }, \ /*tss*/ {0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\ 0,0,0,0,0,0,0,0, \ 0,0,0x17,0x17,0x17,0x17,0x17,0x17, \ _LDT(0),0x80000000, \ {} \ }, \ } |
在这段代码中我们关心的是ldt和tss的设置,每个任务的ldt表有3个表项,第一项未使用,第二项为code段,第三项为data 段。在任务0的描述符表设置完毕,这两个字段的地址也就成为任务0的TSS0描述符和LDT0描述符的内容。
ltr和lldt函数用于将描述符表项在GDT表中的索引加载到相应寄存器中,以任务0为例,在执行完这两个函数之后,tr寄存器中的值为4*8 = 0x20,ldt寄存器中的值为5*8 = 0x28。
下面对sched_init函数进行调试验证上述内容。首先在内核编译后产生的System.map文件中找到该函数的地址:0x72bc。启动bochsdbg,在0x72bc处设置断点,命令行如下:
<bochs:1> b 0x72bc
<bochs:2> c
(0) Breakpoint 1, 0x72bc in ?? ()
Next at t=16800742
(0) [0x000072bc] 0008:000072bc (unk. ctxt): push ebp ; 55
<bochs:3> u /50
……
000072ce: ( ): mov word ptr ds:0x5cd8, 0x68 ;
800
000072d7: ( ): mov word ptr ds:0x5cda, ax ;
000072dd: ( ): ror eax, 0x10 ; c
000072e0: ( ): mov byte ptr ds:0x5cdc, al ; 8805dc
000072e6: ( ): mov byte ptr ds:0x5cdd, 0x89 ; c605dd
000072ed: ( ): mov byte ptr ds:0x5cde, 0x0 ; c605de
000072fa: ( ): ror eax, 0x10 ; c
000072fd: ( ): add eax, 0xffffffe8 ;
00007300: ( ): mov word ptr ds:0x5ce0, 0x68 ;
800
00007309: ( ): mov word ptr ds:0x5ce2, ax ;
00007312: ( ): mov byte ptr ds:0x5ce4, al ; 8805e
00007318: ( ): mov byte ptr ds:0x5ce5, 0x82 ; c605e
00007326: ( ): mov byte ptr ds:0x5ce7, ah ; 8825e
……
00007382: ( ): mov eax, 0x28 ; b828000000
00007387: ( ): lldt ax ;
……
<bochs:5> b 0x7387
<bochs:6> c
(0) Breakpoint 2, 0x
Next at t=16801469
(0) [0x00007387] 0008:00007387 (unk. ctxt): lldt ax ;
<bochs:7> dump_cpu
……
cs:s=0x8, dl=0x7ff, dh=0xc
ss:s=0x10, dl=0xfff, dh=0xc09300, valid=7
ds:s=0x10, dl=0xfff, dh=0xc09200, valid=7
es:s=0x10, dl=0xfff, dh=0xc09300, valid=5
fs:s=0x10, dl=0xfff, dh=0xc09300, valid=1
gs:s=0x10, dl=0xfff, dh=0xc09300, valid=1
ldtr:s=0x28, dl=0x84640068, dh=0x8201, valid=1
tr:s=0x20, dl=0x
gdtr:base=0x5cb8, limit=0x7ff
idtr:base=0x54b8, limit=0x7ff
……
0x000072d7 – 0x
启动任务0(move_to_user_mode宏)
在完成一系列的初始化工作之后,内核将切换到用户模式任务0中继续执行。其代码即宏move_to_user_mode,代码如下:
#define move_to_user_mode() \ __asm__ ("movl %%esp,%%eax\n\t" \ "pushl $0x17\n\t" \ "pushl %%eax\n\t" \ "pushfl\n\t" \ "pushl $0x "pushl $ "iret\n" \ /*切换到任务0,开始执行其指令序列 */
"1:\tmovl $0x17,%%eax\n\t" \ "movw %%ax,%%ds\n\t" \ "movw %%ax,%%es\n\t" \ "movw %%ax,%%fs\n\t" \ "movw %%ax,%%gs" \ :::"ax") |
在进程0的LDT表中设置的代码段和数据段的基址都为0,这与内核代码段和数据段的基址一致,在压栈过程中,压入的返回地址就是内核代码执行序列的地址,与之前内核执行序列的最关键的区别在于:段寄存器中的选择符为任务0独有LDT表的索引,而不再是指向GDT 表的索引。LDT表的基址通过ldlt寄存器来查找:在初始化或任务切换过程中,把描述符对应任务LDT的描述符的选择子装入LDTR,处理器根据装入LDTR可见部分的选择子,从GDT中取出对应的描述符,并把LDT的基地址、界限和属性等信息保存到LDTR的不可见的高速缓冲寄存器中。
下面对move_to_user_mode宏进行调试验证。首先在Systemp.map文件中找到main函数地址0x
<bochs:1> b 0x
<bochs:2> c
(0) Breakpoint 1, 0x
Next at t=16769622
(0) [0x
<bochs:3> u /70
……
00006753: ( ): mov eax, esp ; 89e0
00006755: ( ): push 0x17 ;
00006757: ( ): push eax ; 50
00006758: ( ): pushfd ;
00006759: ( ): push 0xf ;
0000675b: ( ): push 0x6761 ; 6861670000
00006760: ( ): iretd ; cf
00006761: ( ): mov eax, 0x17 ; b817000000
00006766: ( ): mov ds, ax ; 668ed8
00006769: ( ): mov es, ax ; 668ec0
00006772: ( ): add esp, 0xc ;
……
<bochs:4> b 0x6761
<bochs:5> c
(0) Breakpoint 1, 0x
Next at t=16878984
(0) [0x00006761]
00
<bochs:6> dump_cpu
……
eip:0x6761
cs:s=0xf, dl=0x
ss:s=0x17, dl=0x
ds:s=0x0, dl=0x0, dh=0x0, valid=0
es:s=0x0, dl=0x0, dh=0x0, valid=0
fs:s=0x0, dl=0x0, dh=0x0, valid=0
gs:s=0x0, dl=0x0, dh=0x0, valid=0
ldtr:s=0x28, dl=0x84640068, dh=0x8201, valid=1
tr:s=0x20, dl=0x
gdtr:base=0x5cb8, limit=0x7ff
idtr:base=0x54b8, limit=0x7ff
……
这些调试信息有3个地方值得注意。首先是eip寄存器的指针指向iretd的下一条指令地址处。其次是此时的代码段描述符为0xf,bit0到bit1位表示特权级为3,bit2位TI字段位1,表示高13位组成的的指1是指向LDT表的第1项索引(从0开始)。最后是ldtr的值:s=0x28表示该描述符在GDT表的位置为0x28/8=5,8字节描述符的值为0x00 0x0082 0x018464 0x0068,即dl与dh 的组合,它表示LDT表的地址为0x00018464(线性地址),查看内存可知代码段和数据段的基址和段限长,命令行如下:
<bochs:7> xp /6 0x018464
[bochs]:
0x00018464 <bogus+ 0>: 0x00000000 0x00000000 0x
0x
0x00018474 <bogus+ 16>: 0x
这些值即之前分析到的init_task.task.ldt所设置的值。
创建子进程(fork函数)
fork函数是一个系统调用,用于创建子进程。Linux中所有进程都是进程0的子进程。关于对系统函数的调用过程将在以后的文章进行阐述。这里仅对其辅助函数进行分析,这些函数位于kernel/fork.c中。copy_process函数用于创建并复制父进程的代码段和数据段以及环境,代码如下:
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的某些字段的初始化代码 */
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++; /*设置子进程在GDT表的TSS和LDT描述符 */
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 */ return last_pid; } |
copy_men函数将设置新任务的代码和数据段基址、限长并复制页表。代码如下:
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; /*取得任务0的LDT表中的代码段和数据段的基址和段限长 */
code_limit=get_limit(0x 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; 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)) { free_page_tables(new_data_base,data_limit); return -ENOMEM; } return 0; } |
Linux0.11内核将整个
进程调度(schedule函数)
schedule函数实现进程调度,位于kernel/sched.h中,代码如下:
void schedule(void) { int i,next,c; struct task_struct ** p; /* check alarm, wake up any interruptible tasks that have got a signal */ /*对所有进程进行检测,唤醒任何一个已经得到信号的任务 */
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p) { if ((*p)->alarm && (*p)->alarm < jiffies) { (*p)->signal |= (1<<(SIGALRM-1)); (*p)->alarm = 0; } if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) && (*p)->state==TASK_INTERRUPTIBLE) (*p)->state=TASK_RUNNING; } /* this is the scheduler proper: */ /*根据进程的时间片和优先权来选择随后要执行的任务 */
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; } /*cpu切换到新进程执行 */
switch_to(next); } |
switch_to宏代码完成cpu切换任务的工作,这也是我们研究内存分段机制的重点,它的代码位于include/linux/sched.h中,代码如下:
#define switch_to(n) {\ struct {long a,b;} __tmp; \ __asm__("cmpl %%ecx,_current\n\t" \ "je "movw %%dx,%1\n\t" \ "xchgl %%ecx,_current\n\t" \ "ljmp %0\n\t" \ /*此处完成任务切换*/ "cmpl %%ecx,_last_task_used_math\n\t" \ "jne "clts\n" \ "1:" \ ::"m" (*&__tmp.a),"m" (*&__tmp.b), \ "d" (_TSS(n)),"c" ((long) task[n])); \ } |
任务切换的具体操作见下图:
图1:任务切换操作示意图(摘自Linux内核完全注释)
接下来将通过调试验证切换过程,首先在System.map文件中找到schedul的地址:0x6b
<bochs:1> b 0x6b
<bochs:2> c
(0) Breakpoint 1, 0x6b
Next at t=16886214
(0) [0x00006b
<bochs:3> u /100
……
c
7d: ( ): jmp far ss:[ebp+0xfffffff8] ; ff6df8
……
<bochs:4> b 0x
<bochs:5> c
(0) Breakpoint 2, 0x
Next at t=16886886
(0) [0x
0x
<bochs:6> dump_cpu
……
ebp:0x
……
eip:0x
……
ldtr:s=0x28, dl=0x84640068, dh=0x8201, valid=1
tr:s=0x20, dl=0x
gdtr:base=0x5cb8, limit=0x7ff
idtr:base=0x54b8, limit=0x7ff
……
<bochs:7> print-stack
00019148 [00019148] 0003
00019150 [00019150] 0ffc
00019154 [00019154]
00019158 [00019158] 0030
00019160 [00019160] 6ca4
00019164 [00019164] 743b
00019168 [00019168] 0003
00019170 [00019170] 1fae4
00019174 [00019174] 0017
00019178 [00019178] 0017
00019180 [00019180]
00019184 [00019184]
%ebp寄存器的值为0x
<bochs:8> x /2 0x5ce8
[bochs]:
0x00005ce8 <bogus+ 0>: 0xf2e80068 0x000089ff
这段调试信息告诉我们:这个描述符是一个基地址为0x00fff2e8,段限长为0x68的可用的386TSS描述符。因此cpu将自动进行进程切换。Cpu将取出从地址0x00fff2e8开始的0x68个字节内容对接下来要执行的进程tss的设置:
<bochs:9> x /26 0x00fff2e8
[bochs]:
0x00fff2e8 <bogus+ 0>: 0x00000000 0x01000000 0x00000010
0x00000000
0x00fff
0x00000000
0x00fff308 <bogus+ 32>: 0x
0x0003e400
0x00fff318 <bogus+ 48>: 0x00000021 0x00000003 0x
0x
0x00fff328 <bogus+ 64>: 0x00000000 0x00000ffc 0x00000017
0x
0x00fff338 <bogus+ 80>: 0x00000017 0x00000017 0x00000017
0x00000017
0x00fff348 <bogus+ 96>: 0x00000038 0x80000000
对这些调试信息按照tss字段的顺序排列得出下表:
任 |
BIT31—BIT16 |
BIT15—BIT1 |
BIT0 |
Offset |
Data |
0000000000000000 |
链接字段 |
0 |
0x00000000
|
||
ESP0 |
4 |
0x01000000
|
|||
0000000000000000 |
SS0 |
8 |
0x00000010
|
||
ESP1 |
0CH |
0x00000000 |
|||
0000000000000000 |
SS1 |
10H |
0x00000000 |
||
ESP2 |
14H |
0x00000000 |
|||
0000000000000000 |
SS2 |
18H |
0x00000000 |
||
CR3 |
1CH |
0x00000000
|
|||
EIP |
20H |
0x
|
|||
EFLAGS |
24H |
0x00000616
|
|||
EAX |
28H |
0x00000000
|
|||
ECX |
2CH |
0x0003e400 |
|||
EDX |
30H |
0x00000021
|
|||
EBX |
34H |
0x00000003
|
|||
ESP |
38H |
0x
|
|||
EBP |
3CH |
0x |
|||
ESI |
40H |
0x00000000
|
|||
EDI |
44H |
0x00000ffc
|
|||
0000000000000000 |
ES |
48H |
0x00000017
|
||
0000000000000000 |
CS |
4CH |
0x |
||
0000000000000000 |
SS |
50H |
0x00000017
|
||
0000000000000000 |
DS |
54H |
0x00000017
|
||
0000000000000000 |
FS |
58H |
0x00000017
|
||
0000000000000000 |
GS |
5CH |
0x00000017
|
||
0000000000000000 |
LDTR |
60H |
0x00000038
|
||
I/O许可位图偏移 |
000000000000000 |
T |
64H |
0x80000000
|
切换后的各个寄存器将按照以上表对应的值进行赋值,继续调试,来观察一下这个切换过程,命令行如下:
<bochs:10> n
Next at t=16886887
(0) [0x
<bochs:11> dump_cpu
eax:0x0
ebx:0x3
ecx:0x3e400
edx:0x21
ebp:0x
esi:0x0
edi:0xffc
esp:0x
eflags:0x616
eip:0x
cs:s=0xf, dl=0x
ss:s=0x17, dl=0x
ds:s=0x17, dl=0x
es:s=0x17, dl=0x
fs:s=0x17, dl=0x
gs:s=0x17, dl=0x
ldtr:s=0x38, dl=0xf2d00068, dh=0x82ff, valid=1
tr:s=0x30, dl=0xf2e80068, dh=0x89ff, valid=1
gdtr:base=0x5cb8, limit=0x7ff
idtr:base=0x54b8, limit=0x7ff
dr0:0x0
dr1:0x0
dr2:0x0
dr3:0x0
dr6:0xffff0ff0
dr7:0x400
tr3:0x0
tr4:0x0
tr5:0x0
tr6:0x0
tr7:0x0
cr0:0x8000001b
cr1:0x0
cr2:0x0
cr3:0x0
cr4:0x0
inhibit_mask:0
done
这个切换过程也就一目了然了:6个段寄存器的低三位都为1,表明这个段描述符是局部描述符表的索引;局部描述符表的地址由全局描述符表提供,即0x5cb8 + 0x38地址处的描述符;%eip中的地址作为切换后的新进程的执行序列;通用寄存器的值从切换进程的tss结构中取得;ldtr的值由cpu自动加载,从tss结构中取得。
后记
终于完成了内存分段机制的分析,对于内存分段机制的理解实际上可以看成怎么将一个二维数组映射成一位数组,如果需要通过进程的局部描述符表来寻址,则可以把分段机制看成怎么将一个三维数组映射成一位数组,就是这么简单!(如果说错了,不要怪我^-^)