C语言之所以适合写操作系统,在于其简单的内存布局:1.所有全局变量都用常量初始化。2.不需要运行时状态。3.也不需要在main()函数之前运行额外的初始化代码。操作系统的初始化很复杂。在用C语言编写的内核main()函数运行之前,操作系统需要运行一段非常复杂的汇编代码来完成内核的内存初始化。这段汇编代码包含了很多重要的内核全局数据,是内核作者精心定制的,不能由编译器自动生成。对于内核程序员来说,编译器做的事情越少越好,但也不能像汇编C语言适合写操作系统那么少。我想这与DennisRich发明它来写Unix的目的不无关系:不好用的地方都做了优化。1970年,我们不会回想起DennisRich是如何在改变cc编译器代码的同时,改变了Unix系统的代码。这里说说C语言和操作系统的内存布局。1、C语言的内存布局。C语言编译连接后的可执行文件分为:1)代码段(.text),2)只读数据段(.rodata),3)数据段(.data),4)堆(heap),5)堆栈(stack),其中只有前三个需要保存在文件中,后两个是进程运行过程中动态变化的临时数据,不需要保存在文件中。代码段的权限为只读+可执行,只读数据段的权限为只读,数据段、堆、栈的权限均为可读可写,但不能运行。如果系统内核发现进程的内存权限不对,那么就是段错误:信号是SIGSEGV。*("你好")=1;这种代码肯定是“segmentfault”,因为常量字符串位于只读数据段,其内容不可写。通过缓冲区溢出覆盖堆栈返回地址的黑客代码也会被系统内核发现运行地址不在代码段内,所以也是段错误。2.内核的内存布局。内核的内存布局包括这些重要的全局数据:1)内核页表是内核的虚拟内存和物理内存之间的映射。在启用分页机制之前,需要设置内核页表的前几页:至少内核代码所在的内存空间必须映射到页表,否则在分页时会直接出错机制已启用。在32位机器上,是由页目录-页表组成的二级数组:页目录中的每一项记录了每一个页表的物理地址,页表中的每一项记录了每一个页表的物理地址记忆页。64位机器上的页表结构比较复杂,有intel手册:没仔细看,有兴趣的可以看看。一个内存页有4096字节,所以物理地址的最低12位全为0,用来记录每一页的读写权限。页目录中每一项的最低12位用于记录对应的整个页表的读写权限。一张页表记录1024页,每页4096字节,所以一张页表管理4M物理内存。2)中断向量表存放了各种硬件中断和int0x80软件中断的处理函数,也叫中断服务程序(irq)。int0x80softwareinterrupt是linux系统调用的中断号。当然,在64位机器上,直接使用syscall汇编指令即可。syscall的软件中断机制是intel在64位上新创的进入CPUring0特权级的指令。使用方法与之前的int指令有很大不同。我怀疑Intel的CPU研发也有KPI,难怪Linus大牛经常吐槽Intel的CPU设计。在一个版本中增加一条新的指令纯粹是系统软件开发者的问题。中断向量表也是一个256项的数组,每一项都是一个中断的函数指针。中断被触发后,CPU依靠这个数组找到对应的中断处理程序。3)全局描述符表描述了内核的内存布局,每一项8字节,共256项。但实际上,你只需要使用前5项:0x0,未使用,0x8,内核代码段,0x10,内核数据段,内核堆栈段,它们具有相同的权限,可以共享一项。0x20,任务门的描述项,0x28,局部描述符表的描述项。siska内核demo的内存布局是因为每一项都是8字节,所以地址是8的倍数。4)进程使用局部描述符表。因为进程和内核的权限不同,进程的段选择符在局部描述符表中:内核的段选择符是0x8,进程的段选择符是0xf。段寄存器CS、DS、SS在保护模式下都成为段选择器,真正的内存地址在GDT表中。在16位实模式下,它们只存放实段的内存地址。5)任务门CPU把每个进程都看作一个任务,所以需要任务门的描述结构来切换进程。它是104个字节。但是Linux系统的进程切换是软切换:taskgate的描述结构只在系统初始化的时候加载一次,具体进程切换时只切换页表和内核栈,然后CPU可以被愚弄。Reloadthetaskgate的时间消耗比较大,而软切换的时间消耗比较小。intel的这种设计也是Linus不喜欢的设计之一。6)系统调用表也是一个大数组,它的每一项也是一个函数指针。系统调用的入口点是int0x80软件中断(64位机器上的syscall指令)。进入内核后,每个数字对应一个系统调用。open()、close()、write()、read(),这些系统调用都有自己的编号,这些编号就是系统调用表的数组索引。如果open()的系统调用号是i,那么open()在内核中实际运行的就是这行代码:syscall_table[i]();7)物理内存管理数组物理内存的管理结构是一个大的一维数组。假设物理内存为4G,一个内存页为4K,那么这个数组的元素个数为1024x1024,1M。数组的每一项记录了一个物理内存页的状态。如果每一项都是4字节,那么管理效率就是:(4096-4)/4096。管理数据占用的字节越多,物理内存的浪费就越大。get_free_pages()函数通过查看这个数组来分配物理内存页。因为内核是一个高并发的环境,所以在这个管理结构中必须有一个自旋锁来控制多个CPU的并发访问。自旋锁+引用计数至少是8个字节,所以这个数组也是一种内存浪费。如果多个线程想要共享内存,那么只要将同一个物理内存页映射到这些线程的页表中,然后增加物理内存页的引用计数即可:这就是内核中共享内存的本质。8)进程的页表和内核栈进程的页表和内核栈不属于内核的全局数据,而是附属于进程的局部数据。内核在调度进程时,将页目录基地址寄存器cr3和栈寄存器rsp切换到进程的页表和内核栈。不同进程之所以有自己的虚拟内存空间,互不干扰,是因为每个进程的页表不同。进程间共享内存,就像线程间共享内存一样,将同一个物理内存页映射到各自的页表中。
