引言:
很久没更新博客了,来混一篇(啊不是)。啊对了,关于我的资源列表,本意不是付费,只是为了给我自己一个存储的地方罢了,所以大家如果对资源有需求的,直接私信我即可。
Linux内核源码剖析,在前面的一篇文章中有对其整体框架大致说清楚了,所以我也没有太在意其内在细节,在看完xv6源码之后,我对0.11版本还是有一些执念,感觉有一些不兼容。果然,让我发现了一些端倪,后来在网上查资料,才知道现在Linux操作系统的内存管理基本都是在0.99版本之后进行改进的,原始的0.11版本背负了太多历史包袱,导致其繁杂冗余,但是也没办法,当初能写出该系统也是在资源有限的前提下的。
对于文件系统和驱动部分,此处不再赘述,其中,常见的设备驱动如块设备和字符设备驱动在前面的博客有介绍,此处不做概述。文件系统我个人是绝对它应该是独立于操作系统的,特别是在现在VFS Virtual File System虚拟文件系统的盛行下,深入细节的讨论文件系统的源码貌似有些不合时宜,因此,此处也不做讲解。
此处以主函数main()的运行过程来分析,已知在主函数之前的head.S文件中,设置了GDT、一个PD和四个PT(LDT、IDT和TSS就不掺和了)。其中,四个PT共存储了16MB的地址,GDT表中也存有kernel code segment和kernel data segment的基址的段界限,分别是0x0和16MB。下面来进入到主函数运行流程。
mem_init函数的主要作用是为了将主内存部分以4KB页帧为划分,构建一个mem_map[]数组,进行位映射,当取得某一空闲页帧的时候对应位+1,释放-1,当位为0的时候释放页,子进程复制父进程的页表时位+1。
sched_init函数初始化了0号进程,同时设置了其LDT和TSS段描述符到GDT中,0号进程的进程描述符task_struct是自定义的INIT_TASK,其中LDT为{ 0X9F, 0XC0FA00 }, { 0x9F, 0xC0F200 },即代码长和数据长为640K,基址为0x0,易知,该地址和内核各段重合。但是此时0号进程只是一个指令流,而非实体,所以其地址位置在哪里无关大雅(1号进程也一样)。
move_to_user_mode函数模拟中断进入内核返回的假象,然后iret返回任务0调用fork函数,系统调用sys_fork、copy_process函数。最后到达copy_mem函数,得到old_code_base和old_data_base段基址,可知0号任务的段基址是0x0,此后的进程段基址为nr + 0x4000000,即它的进程虚拟地址空间为0x0-0x4000000 64MB大小,并在GDT中设置新的段基址,最后调用copy_page_table(old, new, data_limit),作用其实就是把页表进行复制到子进程的页表。
此处采用了写时复制
copy-write技术,只有当对其页帧进行写的时候才会复制出一个页帧供子进程写。
在copy_page_table函数中,根据线性地址得到PDE索引、PTE索引的方法,从而进行复制页帧地址。在Linux 0.11版本中只有一个PD表,就是0x0000上的页目录表,直接用寄存器获取其地址即可,在该函数中获得from_dir和to_dir目录项指针,然后获得from_page_table索引和to_page_table索引,并分配目的进程的页表内存,再填入*to_dir的地址到页目录表中。从此处可知,进程的页表和页目录项的地址不是操作系统主动分配的,而是根据线性地址来分配的。如果是在内核空间,仅需要复制头640KB,否则复制1个页表中的所有1024项。
在其中有一个get_free_page函数,此处要深究一下。该函数的主要作用就是去mem_map[]中寻找空闲页帧,如果有则分配并返回该页帧的起始地址。此处的mem_map[]是对所有页帧的一个管理结构,或者说用仓库来概括更好,在分配到页帧后便写入到*to_dio页目录表项中,注意,此处并不是真的把数据写入到页帧中了,而是说提前占了一个位置,而且此处PDE存储的也是物理地址,这一点要格外注意。
由此可知,在该版本中页目录表只有一个,在
0x0000,其有1024项,同时其前三项为3个页表,分别管理者16MB的空间,这是物理内存的。在fork函数中可知,获得的PDT是根据线性地址索引获取的,,这个好像也没啥用?!是的,没用,但是在分配页表的时候就有用的,因为要把分配页帧的页表的物理地址填入该页目录项,之后填入页帧地址,页帧地址明面上是物理地址,但是具体如何分配我们继续来看。
在子进程获取父进程的虚拟地址空间之后,进入到execve函数节点,根据系统调用,进入到do_execve函数中,该函数的作用有释放原程序代码段和数据段所对应的内存页表指定的内存块及页表本身,通过调用free_page_table,在该函数中也是先根据线性地址得到PDT,然后获得PT和PTE地址,因为子进程是共享其父进程的进程地址空间的,因此不可能真的把空间清0(喧宾夺主?不可不可),只能说把页表清0,然后释放页表所占内存页面;并修改为自身的代码段基址和数据段基址,调用change_ldt函数。最后调用eip[0]=ex.a_entry,即在该进程地址空间中的入口点地址。
在
Linux 0.11版本中,虚拟地址 ->(GDT和LDT)-> 线性地址 ->(PD和PT)-> 物理地址
那么在把该进程的地址空间清0了,如果调用其代码和数据呢?缺页中断。在该程序进入入口点地址后,处理器根据该进程的描述符表得到线性地址,然后在mem_map[]中分配页表和页帧,再把数据加载进入即可,一般是根据current_executable属性来获取的,当该值为0的时候,表明进程刚开始设置,需要内存。可见该函数先斩后奏的原则。
void do_no_page(unsigned long error_code, unsigned long address) {
if (! (page = get_free_page())) {
/* */
}
for(i = 0; i < 4; block ++, i ++) {
nr[i] = bmap(current_executable.block);
}
// 读设备上一个页面的数据(4个逻辑块)到指定物理地址page处
bread_page(page, current_executable->i_dev, nr);
if (put_page(page, address)) {
return ;
}
free_page(page);
com();
}
/**
* 把一物理内存页面映射到指定的线性地址处
*/
unsigned long put_page(unsigned long page, unsigned long address) {
unsigned long tmp, *page_table;
// 计算获得PTE
page_table = (unsigned long *)((address >> 20) & 0xffc);
unsigned long tmp;
if (! (tmp = get_free_page())) {
return 0;
}
*page_table = tmp | 7;
page_table = (unsigned long *)tmp;
// 在页表中设定指定地址的物理内存页面的页表项内容,每个页表有1024项
page_table[(address >> 12 & 0x3ff)] = page | 7;
return page;
}
每个进程占据着64MB大小的虚拟地址空间,组成4GB线性地址空间的大小,最后通过分页机制进行转换为物理地址。
这种机制历史负担很重,在分页后还是不断的使用了全局段描述符表、局部段描述符表、任务状态描述符表和中断向量描述符表,每次获取地址都要进行两次转换,耗费了很多时间,不过还好的是在Linux 0.99版本及以后都被调整了过来,完全在分页机制下运行,所有进程的段虚拟地址空间都是0x0,这个时候就相当于一个进程自己独占4GB虚拟地址空间,也是现在大部分操作系统的一个内存管理机制现状。后面再出一栏讲述Linux 0.99,先看看Linux 0.12。
在Linux 0.12中,相对于Linux 0.11版本,多了几个特性:
selectpty伪终端EFA/VGA的虚拟控制台没啥好解释了这个,交换分区好像现在用的也不多。
这个版本我还没咋看,不过内存管理是实实在在的大变了的,其他部分加了一些硬件驱动和文件系统,可以一看。
三个启动文件基本没太大变化,唯一变化的可能就是因为内存管理的变化导致在初始化的GDT中有8个段描述符,分别是NULL descriptor、not used、kernel 1GB code at 0xC0000000、kernel 1GB data at 0xC0000000、user 3GB code at 0x00000000、user 3GB data at 0x00000000、not used、not used。其中可知,内核代码段和内核数据段的地址都为0xC0000000,即地址为3G的地方,而用户代码段和用户数据段的地址都为0x00000000,即地址为0的地方,可根据该结构构建一个模型图,内核在高地址位置进行映射即可(越来越像现代的内存架构啦!)
另一个变化的地方在于页表和页目录表,有5张表,分别是_swapper_pg_dir交换页目录表、_pg0可映射0-4MB空间、_empty_bad_page、_empty_bad_page_table、_empty_zero_page存放启动参数。其中_swapper_pg_dir第一项和第3072项存放的是_pg0的地址,即在_pg0中用0和0xC0000000(3072/4=768)均来映射物理地址0-4MB来进行访问物理内存。
内核的虚拟地址初始是
0xC0000000
Linux 0.99版本的入口函数是kernel_start,因此跳转到该函数运行
extern "C" void start_kernel(void) {
memory_end = (1<<20) + (EXT_MEM_K<<10); // 1MB+扩展内存
memory_end &= 0xfffff000;
if ((unsigned long)&end >= (1024*1024)) { // end表示内核被加载后端的第一个地址
memory_start = (unsigned long) &end;
low_memory_start = 4096;
} else {
memory_start = 1024*1024;
low_memory_start = (unsigned long) &end;
}
memory_start = paging_init(memory_start,memory_end);
/* */
}
在这个函数结束开始,之前的_pg0页表就不用了。
paging_init的作用主要是初始化内核页表,此处令pg_dir=swapper_pg_dir,在循环中,令tmp=*(pg_dir + 768),可知768为地址0xC0000000的页目录表的索引项,找到其地址,当地址不存在时,在start_mem处分配一张页表,并对其进行初始化,将页表保存的页的地址从address=0开始,直到memory_end结束,memory_end为物理地址。
当
0xC0000000访问物理内存时,选择swapper_pg_dir为页目录表,然后根据11000000即第768项的值为页表地址,再根据页表项地址得到页基址0x00000000。
此处的高768页目录项是被所有进程所共享的,当创建进程的时候会直接把该页目录表项复制过去到其进程的页目录表项中去即可。
unsigned long paging_init(unsigned long start_mem, unsigned long end_mem)
{
unsigned long * pg_dir; // 页目录表
unsigned long * pg_table; // 页表
unsigned long tmp; // 临时
unsigned long address; // 地址
memset((void *) 0, 0, 4096); // 第一个页是受保护的
start_mem += 4095;
start_mem &= 0xfffff000;
address = 0;
pg_dir = swapper_pg_dir; // 页目录表等于swapper_pg_dir
while (address < end_mem) {
tmp = *(pg_dir + 768); /* at virtual addr 0xC0000000 */
if (!tmp) {
tmp = start_mem | PAGE_TABLE;
*(pg_dir + 768) = tmp;
start_mem += 4096;
}
*pg_dir = tmp; /* also map it in at 0x0000000 for init */
pg_dir++;
pg_table = (unsigned long *) (tmp & 0xfffff000);
for (tmp = 0 ; tmp < 1024 ; tmp++,pg_table++) {
if (address && address < end_mem)
*pg_table = address | PAGE_SHARED; // 初始化所有页表
else
*pg_table = 0;
address += 4096;
}
}
invalidate();
return start_mem;
}
而在mem_init中,令mem_map=start_mem,由此内存管理分配开始。
在while (start_mem < end_mem)对start_mem到end_mem的每一页都做了一个位分配标记,并且建立了空闲链表free_page_list,让前一个空闲页中存放的值为其前一个空闲页的地址,而最后的空闲页的存放的值为free_page_list链表头。当然,此处也考虑到了内核空间:代码段区间、数据段区间和保留段区间,对这些区间不放入空闲链表,并进行计数统计。
之前存在了一个问题,就是在页目录表的第
768项中进行映射物理内存,岂不是将内核后的空间到mem_end的空间也做了映射?
其实正常情况下是无法映射到那里的,当内核的虚拟地址空间为0xC0000000时,碍于其在物理地址空间的大小,是不可能映射到物理地址高位的内存区域的,因为内核只有这么大,那么它映射的也只有那么大。
在mem_map[MAP_NR(start_mem)] = 0中,将其内存空余部分的位全部初始化为0,供进程分配内存使用,可见在__get_free_page函数中调用了REMOVE_FROM_MEM_PAGE。
#define REMOVE_FROM_MEM_QUEUE(queue,nr) \
cli(); \// 关中断
if ((result = queue) != 0) { \
if (!(result & 0xfff) && result < high_memory) { \
queue = *(unsigned long *) result; \
if (!mem_map[MAP_NR(result)]) { \ // mem_map有位置
mem_map[MAP_NR(result)] = 1; \ // 置为1
nr--; \ // 减去1
last_free_pages[index = (index + 1) & (NR_LAST_FREE_PAGES - 1)] = result; \
restore_flags(flag); \
return result; \
} \
printk("Free page %08x has mem_map = %d\n", \
result,mem_map[MAP_NR(result)]); \
} else \
printk("Result = 0x%08x - memory map destroyed\n", result); \
queue = 0; \
nr = 0; \
} else if (nr) { \
printk(#nr " is %d, but " #queue " is empty\n",nr); \
nr = 0; \
} \
restore_flags(flag)
void mem_init(unsigned long start_low_mem,
unsigned long start_mem, unsigned long end_mem)
{
int codepages = 0; // 代码段页个数
int reservedpages = 0; // 保留区页个数
int datapages = 0; // 数据段页个数
unsigned long tmp;
unsigned short * p;
extern int etext; // 代码段结束的第一个地址
cli(); // 关中断
end_mem &= 0xfffff000; //
high_memory = end_mem; // 高位地址
start_mem += 0x0000000f;
start_mem &= 0xfffffff0;
tmp = MAP_NR(end_mem); // 页面基地址
mem_map = (unsigned short *) start_mem; // mem_map指针存放的是start_mem的值,即地址的值
p = mem_map + tmp;
start_mem = (unsigned long) p;
while (p > mem_map)
*--p = MAP_PAGE_RESERVED; // 保留区域
start_low_mem += 0x00000fff;
start_low_mem &= 0xfffff000;
start_mem += 0x00000fff;
start_mem &= 0xfffff000;
while (start_low_mem < 0xA0000) {
mem_map[MAP_NR(start_low_mem)] = 0;
start_low_mem += 4096;
}
while (start_mem < end_mem) { // 初始化内存页
mem_map[MAP_NR(start_mem)] = 0;
start_mem += 4096;
}
sound_mem_init(); // 声卡驱动
free_page_list = 0; // 空闲页链表
nr_free_pages = 0; // 空闲页个数
for (tmp = 0 ; tmp < end_mem ; tmp += 4096) { // 从地址0开始
if (mem_map[MAP_NR(tmp)]) { // 已经被占用
if (tmp >= 0xA0000 && tmp < 0x100000)
reservedpages++;
else if (tmp < (unsigned long) &etext)
codepages++;
else
datapages++;
continue;
}
*(unsigned long *) tmp = free_page_list; // 形成一个链表结构
free_page_list = tmp;
nr_free_pages++;
}
tmp = nr_free_pages << PAGE_SHIFT;
printk("Memory: %dk/%dk available (%dk kernel code, %dk reserved, %dk data)\n",
tmp >> 10,
end_mem >> 10,
codepages << 2,
reservedpages << 2,
datapages << 2);
return;
}
是在知乎上看到的问题,我把我的回答也迁移过来了。
其实现代操作系统内核几乎都是从1.x版本开始推进的,特别是内存管理部分和arch目录的出现。
在0.99版本之前,内核的内存管理是传统操作系统书上讲解的那样,也就是逻辑地址,线性地址和物理地址的划分,设备驱动程序也没有和别的代码有很大地区别开来,处理器主要还是以Intelx86处理器为主。
在0.99版本中,可能是考虑到64MB内存无法满足那个时候膨胀的可执行文件的要求,对内存管理进行了重构,每个进程有4GB的虚拟地址空间,线性地址貌似消失了,只有逻辑地址到物理地址的转化,各种段描述符基址全部被初始化成了0x0,线性地址变得没有意义了,分段模型也用处不大了已经。
在1.0版本中,逐渐引入了各种指令集架构,新建了arch目录,并且把硬件驱动和其他软件管理分离开来独自变成了一个drivers目录。
在后面的改进中,除了几次大改进,很多都是没啥太大变化,可以忽略不看,比如在arch加入了某个指令集架构的相关配置文件,在drivers驱动目录加入了支持某个驱动的代码,fs文件系统目录引入了某个文件系统或者提高性能代码的修改等等。
从Linux内核的发展轨迹来看,Linux在0.99版本之前主要还是linus写的,一个人写的代码耦合性比较强,所以那之前的代码我们倒也能看得懂,但是自0.99版本之后,上百位开发者加入其中,这个Linux系统就不是常人能看懂的了,而且其他的版本,更是鬼神莫测,就不要在意能不能看懂了dT-Tb
Linux0.11的代码能看懂最好,若对操作系统知道足够深刻,便会感叹于其中的神奇。
当然,两万行代码,看起来也是很折磨人的,慢慢来吧。