linux0.11源码分析- 页中断

498 阅读9分钟

实模式下使用段基址+偏移地址来寻址,因为实模式下应用可以无阻碍的访问任何内存地址,所以有了保护模式,保护模式下,地址线也扩展到32位,可以访问到4GB内存地址,我为了管理内存,把内存划分成为页为单位,1M以下的地址不使用页管理,通常1页大小是4KB,那么每个进程的数据都可以离散的分散在内存页中,需要哪个就可以加载哪个到内存中,不用把整个进程数据全部加载进内存。如果这个时候CPU需要访问某个进程的内存(在页中),发现该内存所在的页不存在,就会发出页中断。发生页中断时进程是不感知的,等到系统处理好中断后,进程又继续执行,好像啥事也没发生一样。

page_fault

该异常主要分两种情况处理:

  • 一是由于缺页(当CPU 发现对应页目录项或页表项的存在位(P)标志为0)引起的页异常中断,通过调用do_no_page(error_code, address)来处理;

  • 二是由页写保护(当前进程没有访问指定页面的权限)引起的页异常,此时调用页写保护处理函数do_wp_page(error_code, address)进行处理。其中的出错码(error_code)是由CPU 自动产生并压入堆栈的,出现异常时访问的线性地址是从控制寄存器CR2 中取得的。CR2 是专门用来存放页出错时的线性地址。

对于页异常处理中断,CPU 提供了两项信息用来诊断页异常和从中恢复运行

  • 放在堆栈上的出错码。

该出错码指出了异常是由于页不存在引起的还是违反了访问权限引起的: - 位 2(U/S) - 0 表示在超级用户模式下执行,1 表示在用户模式下执行; - 位 1(W/R) - 0 表示读操作,1 表示写操作; - 位 0(P) - 0 表示页不存在,1 表示页级保护

  • CR2(控制寄存器2)。CPU 将造成异常的用于访问的线性地址存放在CR2 中。异常处理程序可以使用这个地址来定位相应的页目录和页表项。如果在页异常处理程序执行期间允许发生另一个页异常,那么处理程序应该将CR2 压入堆栈中。

控制寄存器 mm/page.s

page_fault:
	xchgl %eax,(%esp)       # 取出错码到eax
	pushl %ecx
	pushl %edx
	push %ds
	push %es
	push %fs
	movl $0x10,%edx         # 置内核数据段选择符 RPL=0,TI=0,index = 2,表示内核权限,在GDT中的第3个段描述符,第一个是null,第二个是代码段,第三个数数据段
	mov %dx,%ds
	mov %dx,%es
	mov %dx,%fs
	movl %cr2,%edx          # 取引起页面异常的线性地址
	pushl %edx              # 将该线性地址和出错码压入栈中,作为将调用函数的参数
	pushl %eax
	testl $1,%eax           # 测试页存在标志P(为0),如果不是缺页引起的异常则跳转
	jne 1f
	call do_no_page         # 调用缺页处理函数
	jmp 2f
1:	call do_wp_page         # 调用写保护处理函数
2:	addl $8,%esp            # 丢弃压入栈的两个参数,弹出栈中寄存器并退出中断。
	pop %fs
	pop %es
	pop %ds
	popl %edx
	popl %ecx
	popl %eax
	iret

do_no_page

mm/memory.c

进程的内容还没有加载到内存,访问的时候导致缺页异常,参数address就是缺页错误的线性地址。

void do_no_page(unsigned long error_code,unsigned long address)
{
	int nr[4];
	unsigned long tmp;
	unsigned long page;
	int block,i;

	address &= 0xfffff000; //获取页地址
	tmp = address - current->start_code; //tmp表示距离代码段的偏移,因为代码段就在进程的首地址
    
        //executable 是进程的i 节点结构。该值为0,表明进程刚开始设置需要内存
        //tmp大于等于end_data说明是访问堆或者栈的空间时发生的缺页,直接申请一页
	if (!current->executable || tmp >= current->end_data) {
		get_empty_page(address);
		return;
	}
        //查看其他进程有没有一样的一页,如果有,直接共享 
	if (share_page(tmp))
		return; //共享成功,返回
	if (!(page = get_free_page())) //获取一个内存页
		oom();
        
    /* remember that 1 block is used for header */
    // 因为块设备上存放的执行文件映象第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);
    // 在读设备逻辑块操作时,可能会出现这样一种情况,即在执行文件中的读取页面位
    // 置可能离文件尾不到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();
}

do_no_page是访问不存在页面处理函数。页异常中断处理过程中调用的函数。函数参数error_code和address是进程在访问页面时由CPU因缺页产生异常而自动生成。该函数首先尝试与已加载的相同文件进行页面共享,或者只是由于进程动态申请内存页面而只需映射一页物理内存即可。若共享操作不成功,那么只能从相应文件中读入所缺的数据页面到指定线性地址处。

get_empty_page

void get_empty_page(unsigned long address)
{
	unsigned long tmp;

    // 如果不能取得有一空闲页面,或者不能将所取页面放置到指定地址处,则显示内存不够信息。
	if (!(tmp=get_free_page()) || !put_page(tmp,address)) {
		free_page(tmp);		/* 0 is ok - ignored */
		oom();
	}
}

get_free_page

get_free_page就是在mem_map数组中找到一个值为0的位置,因为数组的每一项代表主存中的每一页,所以可以根据数组中的位置乘以4KB就可以得到一个相对内存位置,再加上低端内存地址(LOW_MEM)就可以得出物理地址,然后把刚才申请的一页的内存全部初始化为0。把申请的地址返回给调用者。如果申请不到就返回0。

unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");

__asm__("std ; repne ; scasb\n\t"       // 置方向位,al(0)与对应每个页面的(di)内容比较
	"jne 1f\n\t"                    // 如果没有等于0的字节,则跳转结束(返回0).
	"movb $1,1(%%edi)\n\t"          // 1 => [1+edi],将对应页面内存映像bit位置1.
	"sall $12,%%ecx\n\t"            // 页面数*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"               // 将edi所指内存清零(反方向,即将该页面清零)
	"movl %%edx,%%eax\n"            // 将页面起始地址->eax(返回值)
	"1:"
	:"=a" (__res)
	:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
	"D" (mem_map+PAGING_PAGES-1)
	);
return __res;           // 返回空闲物理页面地址(若无空闲页面则返回0).
}

要想看详细的代码分析,就看下面的一行一行分析,你可以选择看或者不看😵

一行一行代码分析:

汇编代码执行前寄存器的状态:

  • register unsigned long __res;定义一个寄存器变量,如果想指定哪个寄存器就可以register unsigned long __res asm("ax");
  • "=a" (__res)表示结果输出到__res,即eax寄存器中。
  • "0" (0) 第一个0表示用上一次的约束,即用eax寄存器,第二个0是把值0给eax"c" (PAGING_PAGES)表示把PAGING_PAGES给ecx "D" (mem_map+PAGING_PAGES-1)表示em_map+PAGING_PAGES-1给edi

开始执行寄存器中的代码:

  • std指令将DF标志置1,DF=0表示正向操作,DF=1表示反向操作。cld指令将DF标志清零,正向操作是指传送操作的方向是从内存区域的低地址端到高地址端,反向操作则正好相反。

  • SCASBSCASWSCASD 指令分别将 AL/AX/EAX 中的值与 EDI 寻址的一个字节 / 字 / 双字进行比较。这些指令可用于在字符串或数组中寻找一个数值。结合 REPE(或 REPZ)前缀,当 ECX > 0 且 AL/AX/EAX 的值等于内存中每个连续的值时,不断扫描字符串或数组。REPNE 前缀也能实现扫描,直到 AL/AX/EAX 与某个内存数值相等或者 ECX = 0。

repne ; scasb:重复执行scasb指令,scasb指令的作用就是把al中的值(现在是0)与es:edi中的值比较,edi中初始化的值是mem_map的最后一项的地址,因为使用了std,每次scasb后,edi都会自动减去1(因为这里用的是scasb,b表示byte),并且修改标志寄存器中的一些标志位。

repne:要想停下来要么是ecx中的循环次数等于0,要么是ZF位等于1。

  • jne 1f向前跳转到标号1处,标号1没有执行代码,直接返回。
  • movb $1,1(%%edi)表示edi + 1的位置给1,
  • sall $12,%%ecx:ecx之前存的是主存总页数(linux0.11最大支持16MB,内存规划是内核、缓冲区、虚拟盘、主存、高端内存),现在是页面数 * 4KB表示主内存的字节数。
  • movl %%ecx,%%edx:主内存的字节数 -> edx
  • movl $1024,%%ecx:1024 -> ecx
  • leal 4092(%%edx),%%edi:由于依照4字节对齐,所以每项占用4字节,取当前物理页最后一项4096 = 4096-4 = 4092,
  • rep ; stosl:stosl指令相当于将EAX中的值保存到ES:EDI指向的地址中,若设置了EFLAGS中的方向位置位(即在stosl指令前使用STD指令)则EDI自减4,否则(使用CLD指令)EDI自增4,从ecx+4092处开始,反方向,步进4,反复1024次,将该物理页1024项所有填入eax寄存器的值(0)。
  • movl %%edx,%%eax:将该物理页面起始地址放入eax寄存器中,用于返回

put_page

把线性地址address映射到物理地址page

unsigned long put_page(unsigned long page,unsigned long address)
{
	unsigned long tmp, *page_table;

    // 首先判断参数给定物理内存页面page的有效性。如果该页面位置低于LOW_MEM(1MB)
    // 或超出系统实际含有内存高端HIGH_MEMORY,则发出警告。LOW_MEM是主内存区可能
   
	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);

	page_table = (unsigned long *) ((address>>20) & 0xffc); //页目录
	if ((*page_table)&1) //PDE中数据中的P位为1
		page_table = (unsigned long *) (0xfffff000 & *page_table); //页表地址
	else {
		if (!(tmp=get_free_page()))
			return 0;
		*page_table = tmp|7;
		page_table = (unsigned long *) tmp;
	}
	page_table[(address>>12) & 0x3ff] = page | 7; //修改页表标志位
	return page;
}

关于二级页表的知识,这里

page_table = (unsigned long *) ((address>>20) & 0xffc);:按照我的理解,这里的右移20位应该是这样的,首先addres>>22表示的是页目录中的索引,每个PDE或者PTE占据4个字节,注意这里是 此时的页目录位于地址0,所以只要知道索引就可以算出PDE的地址,即(addres>>22) * 4 ,就变成了 addres>>20,这个地址就是指向了一个PDE。 目录只有10位,那么能表示的目录个数只有2^10=1024个,每个表项占据4字节,那么地址就是从0x000-0xFFC,即0-4092,与上0xffc就表示把地址限制在0-4092范围内。

*page_table表示取出PDE得数据,(*page_table)&1检查PDE中得P位是不是1。

page_table = (unsigned long *) (0xfffff000 & *page_table);:此时指向的就是PTE了。 page_table[(address>>12) & 0x3ff] = page | 7;:设置PTE的值。

free_page

释放物理地址addr开始的1页面内存。

void free_page(unsigned long addr)
{
    // 首先判断参数给定的物理地址addr的合理性。如果物理地址addr小于内存低端(1MB)
    // 则表示在内核程序或高速缓冲中,对此不予处理。如果物理地址addr>=系统所含物
    // 理内存最高端,则显示出错信息并且内核停止工作。
	if (addr < LOW_MEM) return;
	if (addr >= HIGH_MEMORY)
		panic("trying to free nonexistent page");
    // 如果对参数addr验证通过,那么就根据这个物理地址换算出从内存低端开始记起的
    // 内存页面号。页面号 = (addr - LOW_MEM)/4096.可见页面号从0号开始记起。此时
    // addr中存放着页面号。如果该页面号对应的页面映射字节不等于0,则减1返回。此
    // 时该映射字节值应该为0,表示页面已释放。如果对应页面字节原本就是0,表示该
    // 物理页面本来就是空闲的,说明内核代码出问题。于是显示出错信息并停机。
	addr -= LOW_MEM;
	addr >>= 12;  //转换页面号
	if (mem_map[addr]--) return;  // 页面被使用次数减1,因为共享页面时,会加1
	mem_map[addr]=0;
	panic("trying to free free page");
}

share_page

share_page 的代码分析看这里

do_wp_page

void do_wp_page(unsigned long error_code,unsigned long address)
{
	un_wp_page((unsigned long *)
		(((address>>10) & 0xffc) + (0xfffff000 &
		*((unsigned long *) ((address>>20) &0xffc)))));

}

((address>>10) & 0xffc :表示的就是页表中的地址

(address>>20) &0xffc :计算指定线性地址在目录表中的偏移地址

un_wp_page

取消共享页,如果该页只被引用一次,那么就简单的把该页表项的R/W标志位置位1,然后刷新TLB缓存就可以了。如果该页被共享了,先申请一个空页,把页表项的值用新申请的页的地址替换掉,然后把原来的页数据复制到新申请的页中。

void un_wp_page(unsigned long * table_entry)
{
	unsigned long old_page,new_page;
	old_page = 0xfffff000 & *table_entry; // 页表项地址
	if (old_page >= LOW_MEM && mem_map[MAP_NR(old_page)]==1) {  //主内存中的页没有被共享
		*table_entry |= 2; // R/W = 1
		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; //新的页 US=1 RW=1 P=1
	invalidate(); // 刷新高速缓存
	copy_page(old_page,new_page); //复制一页数据
}	

copy_page

从from处复制1页内存到to处(4K字节)。

#define copy_page(from,to) \
__asm__("cld ; rep ; movsl"::"S" (from),"D" (to),"c" (1024))


// 刷新页变换高速缓冲
#define invalidate() \
__asm__("movl %%eax,%%cr3"::"a" (0))

控制寄存器

参考:

英特尔® 64 位和 IA-32 架构开发人员手册:卷 3A

x86 Instruction Set Reference SCAS/SCASB/SCASW/SCASD

x86 Instruction Set Reference REP/REPE/REPZ/REPNE/REPNZ