最近一直在学习linux内核源码,总结一下
https://github.com/xiaozhang8tuo/linux-kernel-0.11 一份带注释的源码,学习用。
fork的黑科技,它到底做了个啥,源码级分析linux内核的内存管理
先导知识,操作系统:实模式,保护模式,分段,分页,逻辑地址,线性地址,物理地址
fork直观上的感觉做的就是复制进程,而复制的细节先要了解linux的内存管理机制
linux下的内存管理
总体管理
除去低端内存(1MB),其余的物理内存页1MB-4GB,由 mem_map
管理
mem_map
static unsigned char mem_map [ PAGING_PAGES ] = {
0,}; // ( ((15\*1024\*1024)>>12) = 3840 = PAGING_PAGES )
内存页的申请和释放
对物理内存页的申请和释放由 get_free_table
和 free_page
,可以看到释放操作只是 mem_map[addr]–, 速度非常快,再次申请时才会倒序的查找mem_map中的空闲物理页,并在分配时进行清空操作。
get_free_table
// 获取物理地址的首个(实际上是最后1个)空闲页面,并标记为已使用。如果没有空闲页面,就返回0
// 在主内存区中取空闲物理页面(从后往前找)。如果没有可用物理内存页面,则返回0
// 输入: %1(ax=0)
// %2(LOW_MEM) 内存字节位图管理的起始位置
// %3(cx=PAGING_PAGES)
// %4(edi = mem_map+PAGING_PAGES-1)
// 输出:返回%0(ax=物理页面起始地址)
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");
__asm__("std ; repne ; scasb\n\t" // 置方向位,al(0)与对应每个页面的(di)内容比较 循环比较,找出mem_map[i]==0的页. 每比较一次ecx--
"jne 1f\n\t" // 没有找到mem_map[i]==0的页跳出,返回0
"movb $1,1(%%edi)\n\t" // 找到了,但是此时已经edi指针已经自动减少为下一次比较做准备了 1(%%edi)即回到mem_map[i]=0的位置。这里把它置为1 mem_map[i]=1
"sall $12,%%ecx\n\t" // 左移12位, 页面数*4K = 相对页面起始地址
"addl %2,%%ecx\n\t" // 再加上低端内存地址,得到页面实际物理起始地址
"movl %%ecx,%%edx\n\t" // 页面的实际起始地址->edx
"movl $1024,%%ecx\n\t" // 寄存器ecx置1024
"leal 4092(%%edx),%%edi\n\t" // 将4092+edx的位置->edi
"rep ; stosl\n\t" // stosl store EAX at address ES:(E)DI ,页面内存清零
"movl %%edx,%%eax\n" // 返回页面的起始地址->eax
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
:"di","cx","dx");
return __res;
}
free_page
/* * 释放物理地址'addr'处的一页内存。用于函数free_page_tables * */
// 释放物理地址addr开始的1页面内存
// 物理地址1MB以下的内存空间用于内核程序和缓冲,不作为分配页面的内存空间。因此参数addr需要大于1MB
void free_page(unsigned long addr)
{
if (addr < LOW_MEM) return;
if (addr >= HIGH_MEMORY)
panic("trying to free nonexistent page");
addr -= LOW_MEM;
addr >>= 12;
if (mem_map[addr]--) return;
mem_map[addr]=0;
panic("trying to free free page");
}
页表的复制和释放(写时复制怎么实现的-共享计数)
free_page_tables
先看释放,一释放就是释放一个页表
// 释放指定线性地址和长度内存对应的页目录项和页表项
// 参数:from-起始线性基地址:size-释放的字节长度。
int free_page_tables(unsigned long from,unsigned long size)
{
unsigned long *pg_table;
unsigned long * dir, nr;
if (from & 0x3fffff)
panic("free_page_tables called with wrong alignment");
if (!from)
panic("Trying to free up swapper memory space");
// 然后计算参数size给出的长度所占的页目录项数(4MB的进位整数倍),也即所占页表数。
// 因为1个页表可管理4MB物理内存,所以这里用右移22位的方式把需要复制的内存长度值
// 除以4MB。其中加上0x3fffff(即4MB-1)用于得到进位整数倍结果,即除操作若有余数
// 则进1。例如,如果原size=4.01MB,那么可得到结果size=2。
size = (size + 0x3fffff) >> 22;
// 接着计算给出的线性基地址对应的起始目录项。对应的目录项号from>>22。因为每项占4字节,并且由于
// 页目录表从物理地址0开始存放,因此实际目录项指针=目录项号<<2,也即(from>>20)
// 与上0xfc确保目录项指针范围有效,即用于屏蔽目录项指针最后2位。
// 因为只移动了20位,因此最后2位是页表项索引的内容,应屏蔽掉。
dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */ //指向页目录项
// 此时size是释放的页表个数,即页目录项数,而dir是起始目录项指针。现在开始循环
// 操作页目录项,依次释放每个页表中的页表项。如果当前目录项无效(P位=0),表示该
// 目录项没有使用(对应的页表不存在),则继续处理下一个目录项。否则从目录项中取出
// 页表地址pg_table,并对该页表中的1024个表项进行处理,释放有效页表项(P位=l)
// 对应的物理内存页面。然后把该页表项清零,并继续处理下一页表项。当一个页表所有
// 表项都处理完毕就释放该页表自身占据的内存页面,并继续处理下一页目录项。最后刷新
// 页变换高速缓冲,并返回0。
for ( ; size-->0 ; dir++) {
if (!(1 & *dir))
continue;
pg_table = (unsigned long *) (0xfffff000 & *dir);//页目录项高20位即页表项指针
for (nr=0 ; nr<1024 ; nr++) {
if (1 & *pg_table)
free_page(0xfffff000 & *pg_table);
*pg_table = 0;//该页表项置空,不再指向某一具体的物理地址
pg_table++; //处理下一个页表项
}
free_page(0xfffff000 & *dir);//页目录项所指的页表页面释放
*dir = 0; //页目录项置空,处理下一个页目录项
}
invalidate();//刷新高速缓存
return 0;
}
copy_page_tables
再看拷贝,也就知道了fork为什么快,因为只是 开辟二级页表 + 二级页表项指向和父进程页表项相同的物理地址。需要注意的是,通过mem_map[this_page]++来进行共享计数,并设置了写保护(只读)。之后再需要进行写操作时(见write_verify
函数),会尝试解除写保护,此时如果该页在和其他人共享(mem_map[this_page] > 1),才会进行真正的页深拷贝。
// 下面是内存管理mm中最为复杂的程序之一。它通过只复制内存页面
// 来拷贝一定范围内线性地址中的内容。
// 注意! 我们并不复制任何内存块---内存块的地址需要是4MB的倍数(正好
// 一个页目录项对应的内存长度),因为这样处理可使函数很简单。不管怎
// 样,它仅被fork使用。
//
// 注意2! 当from=0时,说明是在为第一次fork()调用复制内核空间。
// 此时我们就不想复制整个页目录项对应的内存,因为这样做会导致内存严
// 重浪费-我们只须复制开头160个页面-对应640kB。即使是复制这些
// 页面也已经超出我们的需求,但这不会占用更多的内存一在低1Mb内存
// 范围内我们不执行写时复制操作,所以这些页面可以与内核共享。因此这
// 是nr=xxxx的特殊情况(nr在程序中指页面数)。
// 复制页目录表项和页表项。
// 复制指定线性地址和长度内存对应的页目录项和页表项,从而被复制的页目录和页表对应
// 的原物理内存页面区被两套页表映射而共享使用。复制时,需申请新页面来存放新页表,
// 原物理内存区将被共享。此后两个进程(父进程和其子进程)将共享内存区,直到有一个
// 进程执行写操作时,内核才会为写操作进程分配新的内存页(写时复制机制)
// 参数from、to是线性地址,size是需要复制(共享)的内存长度,单位是字节。
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和目的地址to的有效性。源地址和目的地址都需要在4MB
// 内存边界地址上。否则出错死机。作这样的要求是因为一个页表的1024项可管理4MB内存。
if ((from&0x3fffff) || (to&0x3fffff))
panic("copy_page_tables called with wrong alignment");
// 源地址from和目的地址to只有满足这个要求才能保证从一个页表的第1项开始复制页表
// 项,并且新页表的最初所有项都是有效的。然后取得源地址和目的地址的起始目录项指针
// (from_dir和to_dir)。再根据参数给出的长度size计算要复制的内存块占用的页表数
// (即目录项数)。参见前面对114、115行的解释。
from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
to_dir = (unsigned long *) ((to>>20) & 0xffc); //页目录项指针
size = ((unsigned) (size+0x3fffff)) >> 22; //size=页表数(目录项数)
// 在得到了源起始目录项指针from dir和目的起始目录项指针to dir以及需要复制的页表
// 个数size后,下面开始对每个页目录项依次申请1页内存来保存对应的页表,并且开始
// 页表项复制操作。
for( ; size-->0 ; from_dir++,to_dir++) {
// 如果目的目录项指定的页表已经存在(P=1),则出错死机。
if (1 & *to_dir)
panic("copy_page_tables: already exist");
//如果源目录项无效,即指定的页表不存在(P=0),则继续循环处理下一个页目录项。
if (!(1 & *from_dir))
continue;
// 在验证了当前源目录项和目的项正常之后,我们取源目录项中页表地址from_page_table。
// 为了保存目的目录项对应的页表,需要在主内存区中申请1页空闲内存页。如果取空闲页面
// 函数get_free_page返回0,则说明没有申请到空闲内存页面,可能是内存不够。于是返
// 回-1值退出。
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */
// 否则我们设置目的目录项信息,把最后3位置位,即当前目的目录项“或”上7,表示对应
// 页表映射的内存页面是用户级的,并且可读写、存在(Usr,R/W,Present)。(如果U/S
// 位是0,则RW就没有作用。如果U/S是1,而R/W是0,那么运行在用户层的代码就只能
// 读页面。如果U/S和R/W都置位,则就有读写的权限)。
*to_dir = ((unsigned long) to_page_table) | 7;
// 然后针对当前处理的页目录项对应的页表,设置需要复制的页面项数。
// 如果是在内核空间,则仅需复制头160页对应的页表项(nr=160),对应于开始640KB物理内存。
// 否则需要复制一个页表中的所有1024个页表项(nr=1024),可映射4MB物理内存。
nr = (from==0)?0xA0:1024;
// 此时对于当前页表,开始循环复制指定的nr个内存页面表项。先取出源页表项内容,如果
// 当前源页面没有使用,则不用复制该表项,继续处理下一项。否则复位页表项中RW标志
// (位1置0),即让页表项对应的内存页面只读。然后将该页表项复制到目的页表中。
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table;
if (!(1 & this_page))
continue;
this_page &= ~2;//当前页表(页目录项)设置为只读 把第二位设为0
*to_page_table = this_page;
// 如果该页表项所指物理页面的地址在1MB以上,则需要设置内存页面映射数组mem_map[],
// 于是计算页面号,并以它为索引在页面映射数组相应项中增加引用次数。而对于位于B
// 以下的页面,说明是内核页面,因此不需要对mem_map进行设置。因为mem_map仅用
// 于管理主内存区中的页面使用情况。因此对于内核移动到任务0中并且调用fork()创建
// 任务1时,用于运行init(),由于此时复制的页面还仍然都在内核代码区域,因此以下
// 判断中的语句不会执行,任务0的页面仍然可以随时读写。只有当调用fork的父进程
// 代码处于主内存区(页面位置大于1MB)时才会执行。这种情况需要在进程调用execve(),
// 并装载执行了新程序代码时才会出现。
if (this_page > LOW_MEM) {
// 令源页表项所指内存页也为只读。因为现在开始有两个进程共用内存区了。
// 若其中1个进程需要进行写操作,则可以通过页异常写保护处理为执行写操作的进程分配
// 1页新空闲页面,也即进行写时复制(copy on write)操作。
*from_page_table = this_page;//令源页表项也只读
this_page -= LOW_MEM; //就是MAP_NR 根据页表起始地址算页号
this_page >>= 12;
mem_map[this_page]++;//共享计数
}
}
}
invalidate(); //刷新页变换高速缓冲
return 0;
}
映射物理地址和线性地址
put_page
线性地址address和通过get_free_page
获得的物理地址是怎么关联起来的呢,桥梁就是通过put_page绑定的
unsigned long put_page(unsigned long page,unsigned long address)
{
unsigned long tmp, *page_table;
// 首先判断参数给定物理内存页面page的有效性。如果该页面位置低于L0WME(1MB)或
// 超出系统实际含有内存高端HIGH_MEMORY,则发出警告。LOW_MEM是主内存区可能有的最
// 小起始位置。当系统物理内存小于或等于6MB时,主内存区起始于LOW_MEM处。再查看一
// 下该page页面是否是已经申请的页面,即判断其在内存页面映射字节图mem_map中相
// 应字节是否已经置位。若没有则需发出警告。
if (page < LOW_MEM || page >= HIGH_MEMORY)
printk("Trying to put page %p at %p\n",page,address);
if (mem_map[(page-LOW_MEM)>>12] != 1)
printk("mem_map disagrees with %p at %p\n",page,address);
// 然后根据参数指定的线性地址address计算其在页目录表中对应的目录项指针,并从中取得
// 二级页表地址。如果该目录项有效(P=1),即指定的页表在内存中,则从中取得指定页表
// 地址放到page_table变量中。否则就申请一空闲页面给页表使用,并在对应目录项中置相
// 应标志(7-User、U/S、R/W)。然后将该页表地址放到page_table变量中。
page_table = (unsigned long *) ((address>>20) & 0xffc);
if ((*page_table)&1)
page_table = (unsigned long *) (0xfffff000 & *page_table);//此时page_table指向二级页表
else {
if (!(tmp=get_free_page()))
return 0;
*page_table = tmp|7;// 页目录项 中 填入该页表地址
page_table = (unsigned long *) tmp;// page_table指向刚分配的页表
}
// 最后在找到的页表page_table中设置相关页表项内容,即把物理页面page的地址填入表
// 项同时置位3个标志(U/S、WR、P)。该页表项在页表中的索引值等于线性地址位21-
// 位12组成的10比特的值。每个页表共可有1024项(0-0x3ff)。
page_table[(address>>12) & 0x3ff] = page | 7;
/* no need for invalidate */
return page;
}
写检查(写时复制怎么实现的-写保护)
在每次写数据时都会进行写检查。写时复制在有了读写标志位,实现起来就很清楚了。如果没有和人共享该页就直接设置可写,否则开辟新页,深拷贝旧页内容。
un_wp_page
void un_wp_page(unsigned long * table_entry)
{
unsigned long old_page,new_page;
// 首先取参数指定的页表项中物理页面位置(地址)并判断该页面是否是共享页面。如果原
// 页面地址大于内存低瑞LOW_MEM(表示在主内存区中),并且其在页面映射字节图数组中
// 值为1(表示页面仅被引用1次,页面没有被共享),则在该页面的页表项中置RW标志
// (可写),并刷新页变换高速缓冲,然后返回。即如果该内存页面此时只被一个进程使用,
// 并且不是内核中的进程,就直接把属性改为可写即可,不用再重新申请一个新页面。
old_page = 0xfffff000 & *table_entry;
if (old_page >= LOW_MEM && mem_map[MAP_NR(old_page)]==1) {
//非共享
*table_entry |= 2; //设置为可写
invalidate();
return;
}
// 否则就需要在主内存区内申请一页空闲页面给执行写操作的进程单独使用,取消页面共享。
// 如果原页面大于内存低端(则意味着mem_map[]>1,页面是共享的),则将原页面的页
// 面映射字节数组值递减1。然后将指定页表项内容更新为新页面地址,并置可读写等标志
// (U/S、R/W、P)。在刷新页变换高速缓冲之后,最后将原页面内容复削到新页面上。
if (!(new_page=get_free_page()))
oom();
if (old_page >= LOW_MEM)
mem_map[MAP_NR(old_page)]--;//共享数-1
*table_entry = new_page | 7; //可读写,存在
invalidate();
copy_page(old_page,new_page);
}
write_verify
void write_verify(unsigned long address)
{
unsigned long page;
// 首先取指定线性地址对应的页目录项,根据目录项中的存在位(P)判断目录项对应的页表
// 是否存在(存在位P=1?),若不存在(P=0)则返回。这样处理是因为对于不存在的页面没
// 有共享和写时复制可言,并且若程序对此不存在的页面执行写操作时,系统就会因为缺页异
// 常而去执行do_no_page,并为这个地方使用put_page函数映射一个物理页面。 (page此时是页表的物理地址)
if (!( (page = *((unsigned long *) ((address>>20) & 0xffc)) )&1))
return;
// 接着程序从目录项中取页表地址,加上指定页面在页表中的页表项偏移值,得对应地址的页
// 表项指针。在该表项中包含着给定线性地址对应的物理页面。
page &= 0xfffff000;
page += ((address>>10) & 0xffc); //此时page(是页表项)是指针(指向某一物理页的起始地址),后面可以通过un_wp_page,改变其指向让其指向其他(新)页的起始地址
// 然后判断该页表项中的位1(R/W)、位0(P)标志。如果该页面不可写(R/W=0)且存在,
// 那么就执行共享检验和复制页面操作(写时复制)。否则什么也不做,直接退出。
if ((3 & *(unsigned long *) page) == 1) /* non-writeable, present */
un_wp_page((unsigned long *) page); // 在这里page的指向可能就变了(传入传出) *page = 新页的起始地址,page
return;
}
缺页中断
try_to_share
/* * try_to_share() checks the page at address "address" in the task "p", * to see if it exists, and if it is clean. If so, share it with the current * task. * * NOTE! This assumes we have checked that p != current, and that they * share the same executable. * try_to_share在任务p中检查位于地址address处的页面,看页面是否存在,是否干净。 * 如果是干净的话,就与当前任务共享。 * 注意,这里我们已假定p!=当前任务,并且它们共享同一个执行程序。 */
// 尝试对当前进程指定地址处的页面进行共享处理。
// 当前进程与进程p是同一执行代码,也可以认为 [当前进程是由p进程执行fork操作产生的]
// 进程,因此它们的代码内容一样。如果未对数据段内容作过修改那么数据段内容也应一样。
//
// 参数address是进程中的逻辑地址,即是当前进程欲与p进程共享页面的逻辑页面地址,
// 进程p是将被共享页面的进程。如果p进程address处的页面存在并且没有被修改过的话,
// 就让当前进程与p进程共享之。同时还需要险证指定的地址处是否已经申请了页面,若是
// 则出错,死机。返回:1页面共享处理成功:0失败。
static int try_to_share(unsigned long address, struct task_struct * p)
{
unsigned long from;
unsigned long to;
unsigned long from_page;
unsigned long to_page;
unsigned long phys_addr;
// 首先分别求得指定进程p中和当前进程中逻辑地址address对应的页目录项。为了计算方便
// 先求出指定逻辑地址address处的'逻辑'页目录项号,即以进程空间(0~64MB)算出的页
// 目录项号。该'逻辑'页目录项号加上进程p在CPU 4G线性空间中起始地址对应的页目录项,
// 即得到进程p中地址address处页面所对应的4G线性空间中的实际页目录项from_page
// 而'逻辑'页目录项号加上当前进程CPU4G线性空间中起始地址对应的页目录项,即可最后
// 得到当前进程中地址address处页面所对应的4G线性空间中的实际页目录项to_page
from_page = to_page = ((address>>20) & 0xffc);
from_page += ((p->start_code>>20) & 0xffc); //p进程目录项
to_page += ((current->start_code>>20) & 0xffc); //当前进程目录项 逻辑地址和线性地址就是这么转化的
// 在得到p进程和当前进程address对应的目录项后,下面分别对进程p和当前进程进行处理。
// 下面首先对p进程的表项进行操作。目标是取得p进程中address对应的物理内存页面地址,
// 并且该物理页面存在,而且干净(没有被修改过,不脏)。
/* is there a page-directory at from? */
from = *(unsigned long *) from_page; // 取目录项内容。如果该目录项无效(P=0),表示目录项对应的二级页表不存在,于是返回
if (!(from & 1))
return 0;
from &= 0xfffff000; // 否则取该目录项对应页表地址from,从而计算出逻辑地址address对应的页表项指针,并取出该页表项内容临时保存在phys_addr中。
from_page = from + ((address>>10) & 0xffc); // 页表地址+页表项偏移量
phys_addr = *(unsigned long *) from_page; // 页表项(放着页对应的物理地址)
/* is the page clean and present? */ //物理页面存在且干净吗
// 接着看看页表项映射的物理页面是否存在并且干净。0x41对应页表项中的D(Dirty)和
// P(Present)标志。如果页面不干净或无效则返回。然后我们从该表项中取出物理页面地址
// 再保存在phys_addr中。最后我们再检查一下这个物理页面地址的有效性,即它不应该超过
// 机器最大物理地址值,也不应该小于内存低端(1MB)。
if ((phys_addr & 0x41) != 0x01)
return 0;
phys_addr &= 0xfffff000;
if (phys_addr >= HIGH_MEMORY || phys_addr < LOW_MEM)
return 0;
// 下面首先对当前进程的表项进行操作。目标是取得当前进程中address对应的页表项地址,
// 并且该页表项还没有映射物理页面,即其P=0。
// 首先取当前进程页目录项内容to。如果该目录项无效(P=0),即目录项对应的二级页表
// 不存在,则申请一空闲页面来存放页表,并更新目录项to_page内容,让其指向该内存页面。
to = *(unsigned long *) to_page;
if (!(to & 1))
if (to = get_free_page())
*(unsigned long *) to_page = to | 7;
else
oom();
// 否则取目录项中的页表地址-->to,加上页表项索值<<2,即页表项在表中偏移地址,得到
// 页表项地址to_page。针对该页表项,如果此时我们检查出其对应的物理页面已经存在,
// 即页表项的存在位P=1, 则说明原本我们想共享进程p中对应的物理页面,但现在我们自己
// 已经占有了(映射有)物理页面。于是说明内核出错,死机。
to &= 0xfffff000;
to_page = to + ((address>>10) & 0xffc);
if (1 & *(unsigned long *) to_page) // 自己已经有了就不能再和别人共享了
panic("try_to_share: to_page already exists");
// 在找到了进程p中逻辑地址address处对应的干净且存在的物理页面,而且也确定了当前
// 进程中逻辑地址ddress所对应的二级页表项地址之后,我们现在对他们进行共享处理。
// 方法很简单,就是首先对p进程的页表项进行修改,设置其写保护(R/=0,只读)标志,
// 然后让当前进程复制p进程的这个页表项。此时当前进程逻辑地址address处页面即被
// 映射到p进程逻辑地址address处页面映射的物理页面上。
/* share them: write-protect */
*(unsigned long *) from_page &= ~2;//只读(写保护)
*(unsigned long *) to_page = *(unsigned long *) from_page;//共享
invalidate();
// 随后刷新页变换高速缓冲。计算所操作物理页面的页面号,并将对应页面映射字节数组项中
// 的引用递增1。最后返回1,表示共享处理成功。
phys_addr -= LOW_MEM;
phys_addr >>= 12;
mem_map[phys_addr]++;//从LOW_MEM之后的页作为起始页
return 1;
}
do_no_page
缺页中断,进程想访问内存时,操作系统是怎么处理的。
进程想访问自己的逻辑地址x,对于操作系统来说是要访问某个线性地址x+start_code,如果这个线性地址在代码段或者数据段,就有可能有共享的可能。
再如果有其他相同执行文件的进程先于该进程启动 (启动多个shell,不可能每启动一个shell就为其真的分配一块物理内存),那么共享访问可以极大的提高效率。
否则是进程堆栈要用的,即独特的数据,无法共享,就真的需要分配了。
逻辑地址可以被视为相对于某个起始地址(通常是程序的加载地址)的偏移量。逻辑地址其实某种程度就是线性地址,即线性地址减去一个起始地址之后的偏移量。这个起始地址通常称为程序的基址(base address)。逻辑地址与线性地址之间的关系可以通过以下步骤来理解:
程序编译:程序在编译时生成的是相对地址,即逻辑地址。这些地址是相对于程序的基址(通常是0)的。
程序加载:当程序被加载到内存中时,操作系统会选择一个线性地址作为程序的加载基址。这个基址是程序代码段和数据段在内存中的起始点。
地址转换:进程在执行时生成逻辑地址。操作系统需要将这些逻辑地址转换为线性地址。这个转换通常通过将逻辑地址与基址(或称为加载地址)相加来完成。
基址偏移:逻辑地址可以被视为从基址开始的偏移量。当程序引用一个变量或常量时,它实际上是在使用相对于基址的偏移量。
分页机制:在分页系统中,线性地址空间被划分为页,每个页可以独立地映射到物理内存中的帧。这意味着线性地址空间可以是连续的,但物理地址空间可以是不连续的。
页表:操作系统使用页表来维护虚拟地址(线性地址)到物理地址的映射。页表项定义了每个虚拟页对应的物理帧。
连续性:操作系统确保每个进程的线性地址空间是连续的,即使物理地址空间可能包含碎片。这是通过虚拟内存技术和页表机制实现的。
因此,逻辑地址通常是指程序中的地址,它们是相对于程序基址的偏移量。操作系统负责将这些逻辑地址转换为线性地址,然后通过页表将线性地址映射到物理地址。这种转换确保了每个进程都有一个连续的虚拟地址空间,而物理内存的使用可以是分散的。
void do_no_page(unsigned long error_code,unsigned long address/*逻辑地址*/)
{
int nr[4];
unsigned long tmp;
unsigned long page;
int block,i;
// 首先取线性空间中指定地址address处页面地址。从而可算出指定线性地址在进程空间中
// 相对于进程基址的偏移长度值p,即对应的逻辑地址。
address &= 0xfffff000; //address处缺页页面线性地址
tmp = address - current->start_code; //两个线性地址相减得到缺页页面对应的逻辑地址 即start_code记录的也是线性地址 逻辑地址和线性地址就是这么转化的
// 若当前进程的executable节点指针空,或者指定地址超出(代码+数据)长度,则申请
// 一页物理内存,并映射到指定的线性地址处。executable是进程正在运行的执行文件的i
// 节点结构。由于任务0和任务1的代码在内核中,因此任务0、任务1以及任务1派生的
// 没有调用过execve的所有任务的executable都为0。若该值为0,或者参数指定的线性
// 地址超出代码加数据长度,则表明进程在申请新的内存页面存放堆或栈中数据。因此直接
// 调用取空闲页面函数get_empty_page为进程申请一页物理内存并映射到指定线性地址
// 处。进程任务结构字段start_code是线性地址空间中进程代码段地址,字段end_data
// 是代码加数据长度。对于Linux0.11内核,它的代码段和数据段起始基址相同。
if (!current->executable || tmp >= current->end_data) {
//end_code记录的是逻辑地址
get_empty_page(address);
return;
}
// 否则说明所缺页面在进程执行文件范围内,于是就尝试共享页面操作,若成功则退出,
// 若不成功就只能申请一页物理内存页面page,然后从设备上读取执行文件中的相应页面并
// 放置(映射)到进程页面逻辑地址tmp处
if (share_page(tmp)) // 申请一页物理内存
return;
if (!(page = get_free_page()))
oom();
/* remember that 1 block is used for header */
// 记住,(程序)头要使用1个数据块*/
// 因为块设备上存放的执行文件映像第1块数据是程序头结构,因此在读取该文件时需要跳过
// 第1块数据。所以需要首先计算缺页所在的数据块号。因为每块数据长度为BLOCK_SIZE=
// 1KB,因此一页内存可存放4个数据块。进程逻辑地址tmp即除以数据块大小再加1即可得出
// 缺少的页面在执行映像文件中的起始块号block。根据这个块号和执行文件的i节点,我们
// 就可以从映射位图中找到对应块设备中对应的设备逻辑块号(保存在nr[]数组中)。利用
// bread_page即可把这4个逻辑块读入到物理页面page中。
block = 1 + tmp/BLOCK_SIZE; // 执行文件中起始数据块号 (加载代码段, 据此可知, 跑多个可执行程序实际用的是一个代码段(只读所以疯狂共享))
for (i=0 ; i<4 ; block++,i++)
nr[i] = bmap(current->executable,block); // 设备上对应的逻辑块号
bread_page(page,current->executable->i_dev,nr); // 读设备上4个逻辑块
// 在读设备逻辑块操作时,可能会出现这样一种情况,即在执行文件中的读取页面位置可能离
// 文件尾不到1个页面的长度。因此就可能读入一些无用的信息。下面的操作就是把这部分超
// 出执行文件end data以后的部分清零处理。
i = tmp + 4096 - current->end_data;
tmp = page + 4096;
while (i-- > 0) {
tmp--;
*(char *)tmp = 0;
}
// 最后把引起缺页异常的一页物理页面映射到指定线性地址address处。若操作成功就返回。
// 否则就释放内存页,显示内存不够。
if (put_page(page,address)) //页映射到对应的线性地址,内部操作就是与或对应的标志位,高效
return;
free_page(page);
oom();
}
fork
了解了内存管理之后,fork变得很清晰。关于内存分配的函数copy_mem
即 copy_page_tables
,进行一次浅拷贝,对共享页添加了写保护。
另外fork会对复制来的进程结构内容进行一些修改,作为新进程的任务结构,原进程打开文件数的共享计数++,随后在GDT表中设置新任务TSS段和LDT段描述符项,具体细节在copy_process
。
copy_mem
// 复制内存页表。
// 参数nr是新任务号:p是新任务数据结构指针。该函数为新任务在线性地址空间中设置代码
// 段和数据段基址、限长,并复制页表。由于Linux系统采用了写时复制(copy on write)
// 技术,因此这里仅为新进程设置自己的页目录表项和页表项,而没有实际为新进程分配物理
// 内存页面。此时新进程与其父进程共享所有内存页面。操作成功返回0,否则返回出错号。
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;
// 首先取当前进程局部描述符表中代码段描述符和数据段描述符项中的段限长(字节数)。
// 0x0F是代码段选择符:0x17是数据段选择符。然后取当前进程代码段和数据段在线性地址
// 空间中的基地址。由于Liux0.11内核还不支持代码和数据段分立的情况,因此这里需要
// 检查代码段和数据段基址和限长是否都分别相同。否则内核显示出错信息,并停止运行。
// get_limit 和 get_base 定义在include/1inux/sched.h
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");
// 然后设置创建中的新进程在线性地址空间中的基地址等于(64MB*其任务号),并用该值
// 设置新进程局部描述符表中段描述符中的基地址。接着设置新进程的页目录表项和页表项,
// 即复制当前进程(父进程)的页目录表项和页表项。此时子进程共享父进程的内存页面。
// 正常情况下copy_page_tables返回0,否则表示出错,则释放刚申请的页表项。
new_data_base = new_code_base = nr * 0x4000000;//64MB * nr 为线性空间的基地址
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
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}
return 0;
}
copy_process
/* 下面是主要的fork子程序。它复制系统进程信息(task[n]) 并且设置必要的寄存器。还整个的复制制数据段 */
// 复制进程。
// 该函数的参数是进入系统调用中断处理过程system_call.s开始,直到调用本系统调用处理
// 过程system call.s第322行和调用本函数前时system call.s第317行逐步压入栈的
// 各寄存器的值。这些在system_call.s程序中逐步压入栈的值(参数)包括:
// CPU执行中断指令压入的用户栈地址ss和esp、标志寄存器eflags和返回地址cs和eip:
// 在刚进入system_cal1时压入栈的段寄存器ds、es、fs和edx、ecx、ebx;
// 调用sys_call_table中sys_fork函数时压入栈的返回地址(用参数none表示);
// 在调用copy_process()之前压入栈的gs、esi、edi、ebp和eax(nr)值。
// 其中参数nr是调用find_empty_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;
// 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。然后将新任务
// 结构指针放入任务数组的nr项中。其中nr为任务号,由前面find_empty_process返回。
// 接着把当前进程任务结构内容复制到刚申请到的内存页面p开始处。
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack 注意!这样做不会复削超级用户堆栈(只复制进程结构)*/
// 随后对复制来的进程结构内容进行一些修改,作为新进程的任务结构。先将新进程的状态
// 置为不可中断等待状态,以防止内核调度其执行。然后设置新进程的进程号pid和父进程
// 号father,并初始化进程运行时间片值等于其priority值(一般为15个嘀嗒)。接着
// 复位新进程的信号位图、报警定时值、会话(session)领导标志leader、进程及其子
// 进程在内核和用户态运行时间统计值,还设置进程开始运行的系统时间start_time。
p->state = TASK_UNINTERRUPTIBLE; // 不可中断等待状态
p->pid = last_pid; // 新进程号,由find_empty_process得到
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分配了1页新
// 内存,所以(PAGE_SIZE+(long)p)让esp0正好指向该页顶端。ss0:esp0用作程序
// 在内核态执行时的栈。另外,在第3章中我们已经知道,每个任务在GDT表中都有两个
// 段描述符,一个是任务的TSS段描述符,另一个是任务的LDT表段描述符。下面_LDT(nr)
// 语句就是把GDT中本任务LDT段描述符的选择符保存在本任务的TSS段中。当CPU执行
// 切换任务时,会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中。
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; // fork返回时新进程返回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; // 段寄存器仅16位有效
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); // 任务局部表描述符的选择符(LDT描述符在GDT中)
p->tss.trace_bitmap = 0x80000000; // 高16位有效
// 如果当前任务使用了协处理器,就保存其上下文。汇编指令clts用于清除控制寄存器CRO
// 中的任务已交换(TS)标志。每当发生任务切换,CPU都会设置该标志。该标志用于管理
// 数学协处理器:如果该标志置位,那么每个ESC指令都会被捕获(异常7)。如果协处理
// 器存在标志MP也同时置位的话,那么WAIT指令也会捕获。因此,如果任务切换发生在一
// 个ESC指令开始执行之后,则协处理器中的内容就可能需要在执行新的ESC指令之前保存
// 起来。捕获处理句柄会保存协处理器的内容并复位TS标志。指令fnsave用于把协处理器
// 的所有状态保存到目的操作数指定的内存区域中(tss.1387)·
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
//接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址
//和限长,并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为
//该新任务分配的用于任务结构的内存页。
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
// 如果父进程中有文件是打开的,则将对应文件的打开次数增1。因为这里创建的子进程
// 会与父进程共享这些打开的文件。将当前进程(父进程)的pwd,root和executable
// 引用次数均增1。与上面同样的道理,子进程也引用了这些i节点。
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段描述符项。这两个段的限长均被设置成104
// 字节。set_tss_desc和set_ldt_desc的定义参见include/asm/system.h文件
// “gdt+(nr<<1)+FIRST_TSS_ENTRY”是任务nr的TSS描述符项在全局
// 表中的地址。因为每个任务占用GDT表中2项,因此上式中要包括(nr<<1)。
// 程序然后把新进程设置成就绪态。另外在任务切换时,任务寄存器tr由CPU自动加载。
// 最后返回新进程号。
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;
}
总结
1 逻辑地址:站在进程角度
2 线性地址:站在操作系统角度
3 物理地址:站在内存角度
更加体现了中间层思想在操作系统中的应用。
1 2之间的转化看start_code,操作系统对进程的虚拟化
2 3之间的转化看分页,分段,页表,页访问属性,put_page将两者联系在一起,操作系统对内存的虚拟化
文章评论