九、完善堆内系统调用:Linux系统调用、printf的内部实现、malloc的内部实现

系统调用介绍

间接调用

Linux系统调用是用中断门来实现的,通过 int 0x80 来实现。在之前要在寄存器eax 中写入子功能号。syscall的原型是 int syscall(int number,...),所有的系统调用都可以通过一个函数完成。函数syscall()并不是由操作系统提供的,它是由C运行库 glibc 提供的。syscall属于“间接”的,它是库函数实现了对系统调用接口的封装。系统调用的本质就是:提供子功能号给eax和参数给要求寄存器,然后int 0x80即可。函数 syscall( ) 是将这些封装在函数实现内部。如:syscall(SYS_getpid)//SYS_getpid 是个宏,表示20

直接调用

直接的系统调用是利用操作系统提供的一系列宏函数:_syscall[x](现在已经不使用了)。它是一系列的宏函数,用_syscallX来表示,X表示系统调用中参数的个数,原型为:
_syscallX(type,name,type1,arg1,type2,arg2,...)
Linux实现了7个不同的宏,分布是_syscall[0-6],因此对于参数个数不同的系统调用,要调用不同的宏来完成。其中 type 是系统调用的返回值类型,name是系统调用的名称(字符串),最后会转换为数值型的子功能号。typeN 和 argN配对出现,表示变量的类型和变量名。
在Linux中系统调用是用寄存器来传递参数的,参数需要按照从左到右的顺序存入不同的寄存器中。其中,eax用来保存子功能号,ebx保存第一个参数,ecx保存第2个参数,edx保存第3个参数,esi保存第4个参数,edi保存第5个参数。

系统调用的实现

一个系统调用分为两个部分,一部分是给用户进程的接口函数,属于用户空间。另一部分是与之对应的内核具体实现,属于内核空间,此部分完成的是功能需求,即我们一直所说的系统调用子功能处理函数。为了区分,内核空间的函数名要在用户空间的函数名前面加 “ sys_” 。所以当我们实现时候,是要在用户空间通过 int 0x80,产生中断进入中断入口程序,然后再次进入真正的中断处理函数中。所以我们要编写中断向量号 0x80 的入口程序,即在 idt 中断描述符表中写好中断程序的选择子和偏移地址。

九、完善堆内系统调用:Linux系统调用、printf的内部实现、malloc的内部实现_第1张图片

增加 0x80 中断向量号

 make_idt_desc(&idt[0x80], IDT_DESC_ATTR_DPL3, syscall_handler);


注意:这个中断描述符的DPL=3,这样用户进程的int 中断才能进去,之前的时钟中断、键盘中断等的中断描述符的DPL都是0特权级的,其实用3特权级就可以了。

实现系统调用接口

Linux中是实现 _syscall[0-6]进行系统调用。我们实现_syscall[0-3]这几个够用即可。
只写三个参数的系统调用,其他的和这个类似。大括号括住的宏函数最后一个语句的值会作为大括号代码块的返回值,并且要在最后一个语句后添加分号;
/* 三个参数的系统调用 */
#define _syscall3(NUMBER, ARG1, ARG2, ARG3) ({		       \
   int retval;						       \
   asm volatile (					       \
      "int $0x80"					       \
      : "=a" (retval)					       \
      : "a" (NUMBER), "b" (ARG1), "c" (ARG2), "d" (ARG3)       \
      : "memory"					       \
   );							       \
   retval;						       \
})

增加0x80号中断处理例程

CPU首先进入中断入口程序,然后作为主调函数:压入参数push,call 调用函数的地址,add esp xxx。因为系统调用参数的不一样,有时候是3个参数,有时候是一个,但是我们调用函数时候都统一在栈中压入3个参数。在调用中断处理函数时候,eax中储存的是子功能号,因此我们利用这个特性去调用子功能号函数。事先我们将这些子功能号函数都写好,然后将地址都分别填入 syscall_table[] 数组中,然后 call [syscall_table+eax*4]即可。 

extern syscall_table
section .text
global syscall_handler
syscall_handler:
;1 保存上下文环境
   push 0			    ; 压入0, 使栈中格式统一
   push ds
   push es
   push fs
   push gs
   pushad			  			   		
   push 0x80			    ; 此位置压入0x80也是为了保持统一的栈格式
;2 为系统调用子功能传入参数
   push edx			    ; 系统调用中第3个参数
   push ecx			    ; 系统调用中第2个参数
   push ebx			    ; 系统调用中第1个参数
;3 调用子功能处理函数
   call [syscall_table + eax*4]	    ; 编译器会在栈中根据C函数声明匹配正确数量的参数
   add esp, 12			    ; 跨过上面的三个参数
;4 将call调用后的返回值存入待当前内核栈中eax的位置
   mov [esp + 8*4], eax	
   jmp intr_exit		    ; intr_exit返回,恢复上下文

然后我们是通过用户进程来调用系统调用的,系统调用封装在C语言的库函数中,比如 
uint32_t getpid()
{
   return _syscall0(SYS_GETPID)
}
通过 getpid()将调用系统调用接口,_syscall[0-3](xxx),函数参数是子功能号和所需参数。因此要提前分配好函数的子功能号并且实现好子功能号中断函数。

总结

1,为将要实现的系统调用分配子功能号 SYSCALL_NR
2,增加C语言函数用户接口
3,编写好子功能号中断函数并且将函数地址写入syscall_table[]中。

printf的内部实现

可变参数的原理

比如 printf(const char* format,...),参数是可变参数。这种可变参数看似可变,本质上还是静态的,参数都由编译器在编译时候被确定下来。因为 format 的格式一般为:
“hello %d%s”等,这样我们就根据 % 来确定参数,后面参数的数量和 % 个数想匹配,每找到一个 % ,就去栈中找一个参数。
Linux中对可变参数专门定义了三个宏函数:
#define va_start(v,l)
#define va_end(v)
#define ca_arg(v,l)
man 3 stdarg 查看详细功能:可变参数是指已经被压入栈中的1个或多个参数,参数个数未知。
va_start(ap,v):ap是指向可变参数(压入栈中)的指针变量,v是可变参数(压入栈中)的第一个参数,比如printf()来说,v是指 format 。
va_arg(ap,t):t是可变参数类型,函数功能是指针 ap 指向栈中下一个参数的地址并返回其值。 
va_end(ap):将指针变量ap 置为 null。

系统调用write

printf()函数是“格式化” “输出” 函数,但是真正起到“格式化”作用的是 vsprintf 函数,真正起 “输出”作用的是 write 系统调用。

man 2 write:

size_t write(int fd,const void *bf,size_t count); //将buf中的 count 个字符写到文件描述符 fd 指向的文件中。
因为write() 是系统调用,所以我们按照三部曲编写好write。

实现printf

int printf(const char* farmat,...);   //format是格式化字符串,里面包含 “%类型字符”。
printf 是 vsprintf 和 write 的封装,write 已经完成,现在要完成vsprintf、可变参数的三个宏函数和转换函数 itoa。

man vsprintf: 函数功能:ap指向可变参数,以字符格式format中的符号“%”为标记,将format 中除“%类型字符”以往的内容复制到str,把“%类型字符”替换成具体参数后写入str中对应“%字符类型”的位置。如format为”it is %x“,最后会被替换成”it is F“;
int vsprintf(char* str,const char* format,va_list ap);
可变参数的三个宏:注意:字符串压入栈是压入首地址,所以压入栈的是个指针,&v就是二级指针,ap是一级指针,所以要将 &v 转化为一级指针。
#define va_start(ap, v) ap = (va_list)&v  // ap指向第一个固定参数v:v 是format字符串的指针:char*,所以ap是二级指针,要转换为一级指针:char*
#define va_arg(ap, t) *((t*)(ap += 4))	  // ap指向下一个参数并返回其值
#define va_end(ap) ap = NULL		  // 清除ap
转换函数itoa:整型 value 按照进制 base 转换为字符型并存入 buf 中。
//用二级指针的用意:在 buf 中字符写到哪里是由缓冲区指针决定的,所以我们更新缓冲区指针就要用二级指针。比如这个函数被调用了两次,每次存入一个字符,那么第一次存入后字符后,缓冲区指针就要往后面更新一个,然后第二次调用时才能正确存入下一个位置。
static void itoa(uint32_t value, char** buf_ptr_addr, uint8_t base) {  
   uint32_t m = value % base;	    // 求模,最先掉下来的是最低位   
   uint32_t i = value / base;	    // 取整
   if (i) {			    // 如果倍数不为0则递归调用。
      itoa(i, buf_ptr_addr, base);
   }
   if (m < 10) {      // 如果余数是0~9
      *((*buf_ptr_addr)++) = m + '0';	  // 将数字0~9转换为字符'0'~'9'
   } else {	      // 否则余数是A~F
      *((*buf_ptr_addr)++) = m - 10 + 'A'; // 将数字A~F转换为字符'A'~'F'
   }
}
格式化函数vsprintf:将 format 中的 ”%类型“ 替换成后面栈中对应的内容然后写入 str 中。
/* 将参数ap按照格式format输出到字符串str,并返回替换后str长度 */
uint32_t vsprintf(char* str, const char* format, va_list ap) {
   char* buf_ptr = str;
   const char* index_ptr = format;
   char index_char = *index_ptr;
   int32_t arg_int;
   while(index_char) {
      if (index_char != '%') {
	 *(buf_ptr++) = index_char;
	 index_char = *(++index_ptr);
	 continue;
      }
      index_char = *(++index_ptr);	 // 得到%后面的字符
      switch(index_char) {
	 case 'x':
	    arg_int = va_arg(ap, int);
	    itoa(arg_int, &buf_ptr, 16); 
	    index_char = *(++index_ptr); // 跳过格式字符并更新index_char
	    break;
      }
   }
   return strlen(str);
}

malloc的内部实现

arena和内存块描述符

arena: 是由一大块内存(一般都是一页)被划分成很多”小内存块“的内存仓库。arena是个提供内存分配的数据结构,分为两部分:一部分是元信息,12字节,用来描述自己内存池中空闲内存块数量、内存块描述符指针。另一部分就是内存池区域,这里面无数的小内存块。

//arena元信息
struct arena {
   struct mem_block_desc* desc;	 // 此arena关联的mem_block_desc
   uint32_t cnt;
   bool large;		 //large为ture时,cnt表示的是页框数。否则cnt表示空闲mem_block数量 
};

//内存块:里面只有一个链表元素,虽然每一个内存规格的sizof(struck mem_block)=8,比如:64 byte 的内存块,我们把一页内存划分很多64 byte 的内存块后,虽然这个结构的大小为8,只占了64字节的前小部分,但是我们使用索引来定位每个小内存块的起始地址,得到起始地址即可。
struct mem_block {
   struct list_elem free_elem;
};

九、完善堆内系统调用:Linux系统调用、printf的内部实现、malloc的内部实现_第2张图片

内存块描述符:分别为每一种规格的内存块建立一个内存块描述符,即 mem_block_desc,记录了内存块规格大小,以及位于所有同类arena中空闲内存块链表。内存块规格有多少种,内存块描述符就有多少种,因此各种内存块描述符的区别就是 block_size 不同,free_list中指向的内存规格不同。

/* 内存块描述符 */
struct mem_block_desc {
   uint32_t block_size;		 // 内存块大小
   uint32_t blocks_per_arena;	 // 本arena中可容纳此mem_block的数量.
   struct list free_list;	 // 目前可用的mem_block链表
};

我们申请的内存块大小都是基于以2为底的指数方程来划分的。arena一般大小为一页4KB,开头的元信息要占12字节,所以 2048*2 是不可能的,申请2048字节时候,只能用一页内存。1024*3是可能的。所以我们规定最高申请了1024字节,当大于1048时候,统一按页为单位来申请。比如申请 1111个字节,直接分配一页给它即可。所以我们的内存块是16、32、64、128、256、512、1024 这7中规格的。对于小规格内存 arena 来说,大小是一页大小。对于要求分配的1024字节的,此时就没有arena了,我们直接分配一个内存页即可。需要注意的是:arena并不是提前就准备好了的,起先并没有准备,申请时候看到没有相应的arena才准备的。
总结:arena 只是一个内存仓库,并不直接对外提供内存分配,只有内存块描述符才对外提供内存分配。内存块描述符把相同规格的arena 汇聚在了一起,作为统一的分配入口。所以内存块描述符与arena是一对多的关系,每个arena都要与唯一的内存块描述符关联起来。

内存块描述符初始化

//内存块描述符:arena中的内存块数量:(4KB-元信息)/内存块大小 

//我们要定义内核内存块描述符和用户内存块描述符,首先定义内核内存块描述符,供内核线程程序申请

struct mem_block_desc k_block_descs[DESC_CNT];
void block_desc_init(struct mem_block_desc* desc_array)
{
	uint16_t desc_idx, block_size = 16;
	/* 初始化每个mem_block_desc描述符 */
	for (desc_idx = 0; desc_idx < DESC_CNT; desc_idx++)
	{
		desc_array[desc_idx].block_size = block_size;  //从16字节开始
		/* 初始化arena中的内存块数量 */
		desc_array[desc_idx].blocks_per_arena = (PG_SIZE - sizeof(struct arena)) / block_size;
		list_init(&desc_array[desc_idx].free_list);
		block_size *= 2;         // 更新为下一个规格内存块
	}
}

用户的用户内存块描述符要在线程或者进程的 task_struct 的内容里面添加 u_block_decs[DESC_CNT] ,然后初始化,以便供用户代码申请。

实现sys_malloc

//我们只是通过索引内存块的起始地址即可。虽然每个 mem_block 只有8字节大小,但是我们只通过索引得到它的起始地址。
/* 返回arena中第idx个内存块的地址 */
static struct mem_block* arena2block(struct arena* a, uint32_t idx)
{
	return (struct mem_block*)((uint32_t)a + sizeof(struct arena) + idx * (a->desc->block_size));
}
//4K=1000,arena都是以4K为单位的
static struct arena* block2arena(struct mem_block* b)
{
	return (struct arena*)((uint32_t)b & 0xfffff000);
}
//sys_malloc:在堆中申请size字节的内存
//实现:首先确定是用户进程还是内核线程才能确定是在哪个物理池中申请页。然后如果size>1024,就直接在相应物理池中分配页即可。size<1024,就检查内核/用户内存块描述符中的链表,如果为空,则在相应的内存池中申请arena,然后将内存块加入链表队列中。如果不为空,则直接在队列中弹出,然后转化为相应的内存块地址即可。
void* sys_malloc(uint32_t size)
{
	enum pool_flags PF;
	struct pool* mem_pool;
	uint32_t pool_size;
	struct mem_block_desc* descs;
	struct task_struct* cur_thread = running_thread();
	/* 判断用哪个内存池,然后确定用哪个内存块描述符表和从哪个内存池申请内存*/
	if (cur_thread->pgdir == NULL)
	{     // 若为内核线程,用内核内存块描述符表和在内核内存池中申请内存
		PF = PF_KERNEL;
		pool_size = kernel_pool.pool_size;
		mem_pool = &kernel_pool;
		descs = k_block_descs;
	}
	else
	{		// 用户进程,则用的用户内存块描述表和在用户内存池中申请内存
		PF = PF_USER;
		pool_size = user_pool.pool_size;
		mem_pool = &user_pool;
		descs = cur_thread->u_block_desc;
	}
	/* 若申请的内存不在内存池容量范围内则直接返回NULL */
	if (!(size > 0 && size < pool_size))
	{
		return NULL;
	}
	struct arena* a;
	struct mem_block* b;
	lock_acquire(&mem_pool->lock);
	/* 超过最大内存块1024, 就分配页框 */
	if (size > 1024)
	{
		uint32_t page_cnt = DIV_ROUND_UP(size + sizeof(struct arena), PG_SIZE);    // 向上取整需要的页框数
		a = malloc_page(PF, page_cnt);
		if (a != NULL)
		{
			memset(a, 0, page_cnt * PG_SIZE);	 // 将分配的内存清0  
			 /* 对于分配的大块页框,将desc置为NULL, cnt置为页框数,large置为true */
			a->desc = NULL;
			a->cnt = page_cnt;
			a->large = true;
			lock_release(&mem_pool->lock);
			return (void*)(a + 1);		 // 跨过arena大小,把剩下的内存返回
		}
		else
		{
			lock_release(&mem_pool->lock);
			return NULL;
		}
	}
	else
	{    // 若申请的内存小于等于1024,可在各种规格的mem_block_desc中去适配
		uint8_t desc_idx;
		/* 从内存块描述符中匹配合适的内存块规格 */
		for (desc_idx = 0; desc_idx < DESC_CNT; desc_idx++)
		{
			if (size <= descs[desc_idx].block_size)
			{  // 从小往大后,找到后退出
				break;
			}
		}
		/* 若mem_block_desc的free_list中已经没有可用的mem_block,
		 * 就创建新的arena提供mem_block */
		if (list_empty(&descs[desc_idx].free_list))
		{
			a = malloc_page(PF, 1);       // 分配1页框做为arena
			if (a == NULL)
			{
				lock_release(&mem_pool->lock);
				return NULL;
			}
			memset(a, 0, PG_SIZE);
			/* 对于分配的小块内存,将desc置为相应内存块描述符,
			 * cnt置为此arena可用的内存块数,large置为false */
			a->desc = &descs[desc_idx];
			a->large = false;
			a->cnt = descs[desc_idx].blocks_per_arena;
			uint32_t block_idx;
			enum intr_status old_status = intr_disable();
			/* 开始将arena拆分成内存块,并添加到内存块描述符的free_list中 */
			for (block_idx = 0; block_idx < descs[desc_idx].blocks_per_arena; block_idx++)
			{
				b = arena2block(a, block_idx);
				ASSERT(!elem_find(&a->desc->free_list, &b->free_elem));
				list_append(&a->desc->free_list, &b->free_elem);
			}
			intr_set_status(old_status);
		}
		/* 开始分配内存块 */
		b = elem2entry(struct mem_block, free_elem, list_pop(&(descs[desc_idx].free_list)));
		memset(b, 0, descs[desc_idx].block_size);
		a = block2arena(b);  // 获取内存块b所在的arena
		a->cnt--;		   // 将此arena中的空闲内存块数减1
		lock_release(&mem_pool->lock);
		return (void*)b;
	}
}

内存释放

mfree_page的内部实现

首先我们实现清理整个页表,然后在其基础上实现释放内存块。

我们分配内存操作:

1,在虚拟地址池中分配虚拟地址

2,在物理内存池中分配物理地址

3,填好页表完成映射

那么释放内存就是反操作;

1,在物理地址池中释放物理页地址,4K为单位的。物理地址可以不连续,需要一页一页的释放

2,在将物理地址释放后就可以释放页表了,在pte中将 p 位置0

3,在虚拟地址池中释放虚拟地址,虚拟地址是连续,可以连续释放

注意:我们只清零pte,不清零pde。一个页表是4K,一个页表项是4B。清零一个PTE是清理了4K,清零一个PDE是清理了4K。当我们要清理一个PDE时,我们还要循环检测每一个PTE的 P 位,所以为了不麻烦,我们就不清理这个。

//释放物理地址:清理物理地址就是把该物理地址对应的位图置0即可。物理地址是单独的

/* 将物理地址pg_phy_addr回收到物理内存池 */
void pfree(uint32_t pg_phy_addr)
{
	struct pool* mem_pool;
	uint32_t bit_idx = 0;
	if (pg_phy_addr >= user_pool.phy_addr_start)
	{     // 用户物理内存池
		mem_pool = &user_pool;
		bit_idx = (pg_phy_addr - user_pool.phy_addr_start) / PG_SIZE;
	}
	else
	{	  // 内核物理内存池
		mem_pool = &kernel_pool;
		bit_idx = (pg_phy_addr - kernel_pool.phy_addr_start) / PG_SIZE;
	}
	bitmap_set(&mem_pool->pool_bitmap, bit_idx, 0);	 // 将位图中该位清0
}
//置零PTE:找到该虚拟地址的PTE指针,然后将p为置0,然后更新tlb缓存,即把页表中该虚拟地址的pte重新写入tlb。

static void page_table_pte_remove(uint32_t vaddr)
{
	uint32_t* pte = pte_ptr(vaddr);
	*pte &= ~PG_P_1;	// 将页表项pte的P位置0
	asm volatile ("invlpg %0"::"m" (vaddr) : "memory");    //更新tlb
}

//释放虚拟地址:找到相应虚拟地址的位图,然后将其置0即可,虚拟地址是连续的。

static void vaddr_remove(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt)
{
	uint32_t bit_idx_start = 0, vaddr = (uint32_t)_vaddr, cnt = 0;
	if (pf == PF_KERNEL)
	{  // 内核虚拟内存池
		bit_idx_start = (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;
		while (cnt < pg_cnt)
		{
			bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 0);
		}
	}
	else
	{  // 用户虚拟内存池
		struct task_struct* cur_thread = running_thread();
		bit_idx_start = (vaddr - cur_thread->userprog_vaddr.vaddr_start) / PG_SIZE;
		while (cnt < pg_cnt)
		{
			bitmap_set(&cur_thread->userprog_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 0);
		}
	}
}

free的内部实现

mfree_page只释放页框级别的内存块,现在我们实现小内存块的释放。
sys_free()是free对应的内核功能函数,是内存释放的统一接口,即可以释放页框级别的内存和小内存块。对于页框的大内存称为释放,把虚拟地址和物理地址的位图置0和PTE的P位置零。对于小内存块称为回收,将arena中的内存重新放回到内存块描述符中的 free_list 中。
//实现:对于大内存:就是之前我们申请了大于1024的内存,是之前申请了n 个页框,得到ptr是跳过元信息的地址,首先确定是内核还是用户物理池,然后转化为页框地址,然后整页释放
//对于小内存,ptr就是之前小内存块的地址而不是页框地址,首先确定是内核还是用户物理池,将里面的列表元素回收,然后检查这个 arena 是否都空了,空的话回收这个arena页框。
void sys_free(void* ptr) //对于小内存来说,ptr一定内存块的起始地址
{
	ASSERT(ptr != NULL);
	if (ptr != NULL)
	{
		enum pool_flags PF;
		struct pool* mem_pool;
		/* 判断是线程还是进程 */
		if (running_thread()->pgdir == NULL)
		{
			ASSERT((uint32_t)ptr >= K_HEAP_START);
			PF = PF_KERNEL;
			mem_pool = &kernel_pool;
		}
		else
		{
			PF = PF_USER;
			mem_pool = &user_pool;
		}
		lock_acquire(&mem_pool->lock);
		struct mem_block* b = ptr;
		struct arena* a = block2arena(b);	     // 把mem_block转换成arena,获取元信息
		ASSERT(a->large == 0 || a->large == 1);
		if (a->desc == NULL && a->large == true)
		{ // 大于1024的内存
			mfree_page(PF, a, a->cnt);
		}
		else
		{	 // 小于等于1024的内存块
	   /* 先将内存块回收到free_list */
			list_append(&a->desc->free_list, &b->free_elem);
			/* 再判断此arena中的内存块是否都是空闲,如果是就释放arena */
			if (++a->cnt == a->desc->blocks_per_arena)
			{
				uint32_t block_idx;
				for (block_idx = 0; block_idx < a->desc->blocks_per_arena; block_idx++)
				{
					struct mem_block*  b = arena2block(a, block_idx);
					ASSERT(elem_find(&a->desc->free_list, &b->free_elem));
					list_remove(&b->free_elem);
				}
				mfree_page(PF, a, 1);
			}
		}
		lock_release(&mem_pool->lock);
	}
}

实现malloc和free

以上已经把中断处理函数都编写完成,下面直接系统调用三剑客:
1,确定系统子功能号:SYS_MALLOC、SYS_FREE
2,编写用户调用函数:void malloc(uint32_t size) { return (void*)_syscall1(SYS_MALLOC,size)}
3,编写中断处理函数,并添加到syscall_table[SYS_MALLOC]、syscall_table[SYS_FREE]

你可能感兴趣的:(操作系统)