当前位置: 首页 > 科技观察

如何用C语言写操作系统

时间:2023-03-18 23:15:21 科技观察

刚开始学C语言的时候,觉得除了在命令行打印点东西外,没有别的用处。但是,我一直听说Linux系统是用C语言写的。总之就是觉得C语言的名字和实际不符,心理落差太大了。那么,我们就来说说C语言是如何编写操作系统的。C语言几乎是编写操作系统的唯一语言,因为它可以手动管理内存,而可读性不如汇编。1、C语言的全局内存模型是最简单的。C语言有指针,通过指针可以很小心的管理内存。同时,C语言不依赖运行时状态,对内存管理模型的要求也很简单:所有全局数据都用常量初始化,在main()函数运行前不需要初始化代码。intg_a=1;intmain(){printf("g_a:%d\n",g_a);return0;}上面代码中g_a是一个全局变量,它的初始化应该在main()之前完成函数运行:可以在编译阶段进行初始化,也可以在main()函数之前运行一段初始化代码。C语言对g_a的初始化是在编译阶段。编译器在生成.o文件的数据段时,会直接将g_a对应的数据初始化为1。全局数组和全局结构体的初始化也是用“常量”来初始化的:虽然这样不是很直观,但是确实是用常量初始化的。如上图,虽然test_file_ops结构体中填入了函数的地址,看起来是一个变量,但实际上:编译器在生成.o文件的时候,是知道哪个函数放在文件的哪个字节的。当链接器生成一个可执行文件时,它不仅知道哪个函数放在哪个字节,也知道它会被加载到哪个内存地址。因此,这个结构中看似“变量”的内存地址,实际上是一个常量。C程序员不需要关注具体的值,但是编译器会计算出来。因此,C语言的内存模型可以在main()函数之前的编译阶段就确定下来。操作系统在运行程序时,只需要将文件载入内存,然后跳转到main函数即可。它不需要关心运行时状态。但是,C++无法做到这一点。2.C++的全局内存模型依赖于运行时状态。如果C++给你写了一个动态创建机制,那么在main()函数运行之前,必须运行初始化代码,至少要构造CRuntimeClass的类图:否则,到哪里去找类名对应的构造函数呢?C++动态创建的demo代码,比如这3张图:动态创建代码,1所谓动态创建就是在接收到类名字符串后,创建对应的类对象。当然new“Object”不能用来创建Object类的对象,因为“Object”是字符串常量,不是编译前的代码。所以C++需要一个静态函数,而这个静态函数里面只有一段代码:returnnewObject();因为每一个可以动态创建的类都需要这样一段代码,所以写成上图中的静态函数,并通过宏将其添加为每个类的静态成员函数。但是,要在收到类名字符串后找到这个函数,必须要有类图。每一种OOP语言都有一个巨大的RuntimeClass类图,就是做这件事的类图,它是由各个类的RuntimeClass全局静态对象组成的一个链表。给每个类添加一个RuntimeClass的静态对象,它的构造函数会在运行时自动挂在类图的链表上,如下图红框所示。动态创建代码,2由于RuntimeClass对象是一个全局静态对象,它的构造函数当然必须在main()函数之前调用!那么C++编译器框架是如何保证这一点的呢?只能在可执行文件的main()函数之前加一个.init段,这样程序的入口就在.init段,而不是main函数所在的.text段。但是在Linux系统中,绝对不允许编译器在程序员之前篡改内存!这就是Linux之父抱怨C++的原因:因为他觉得自己的能力受到质疑,他觉得C++编译器认为他不能很好地管理内存。但是,C编译器永远不会这么想,C语言认为每个程序员都是大牛,应该管理自己的内存动态创建代码,3这段代码的运行效果:如效果图所示,3个RuntimeClass的初始化在main开始之前,因为他们是全局静态对象。所以C++看到的程序入口并不是真正的入口,必须在main()之前进行内存初始化。但C的入口才是真正的入口:它做你想让它做的事,只要你把代码写对了。每个敢于编写操作系统的C程序员可能都认为他可以正确编写代码。因此,C语言几乎是系统程序员唯一推荐的语言。3.操作系统怎么写?我们先论证一下用C语言编写的操作系统的存在性和唯一性,然后给它一个构造性的证明。操作系统是最接近硬件的软件。它和编译器是一种递归关系:编译器运行在操作系统之上,操作系统是用编程语言编写的,编程语言又是编译器编译出来的。操作系统、编译器、编程语言的关系操作系统大致分为这四个模块:进程管理、内存管理、设备管理、网络子系统。进程管理和内存管理是操作系统的核心模块。操作系统要运行起来,进程和内存管理是必须的,其他的模块以后可以一一添加。内存管理模式是操作系统运行的关键:主要是分段和分页。4、内存的分段内存的分段就是把内存分成代码段、数据段、堆栈段,并赋予不同的权限进行管理。具有可读(R)和可执行(X)权限的代码段。数据段和堆栈段具有可读(R)和可写(W)权限。数据段和堆栈段的区别在于:数据段是从低位向高位增长,而堆栈段是从高位向低位增长。它们之间未使用的区域是堆和栈可以增长的空间。所谓栈段其实就是指栈,堆紧挨着数据段。代码段的内存地址应该放在段寄存器CS中。数据段的内存地址应该放在段寄存器DS中。堆栈段的内存地址应该放在段寄存器SS中。这3个寄存器不能在用户代码中使用,但内核代码可以。内核在初始化的时候,加载哪个内存地址到哪个段寄存器,就会把哪个地址当作哪个段。这种机制是由Intel的CPU设计保证的。在16位机器上,只能使用分段模式,即所谓的实模式。段地址+偏移量的访问方式,最大访问1M内存,在实模式下是唯一的方式:CS:IP是代码的运行位置,SS:SP是栈的位置,DS:SI和ES:DI用于数据传递的源和目标位置。32位机之后,Intel增加了保护模式:保护模式可以在分段的基础上分页,也可以只分段。5、内存分页分页机制只有在CPU进入保护模式后才能启用。页大小一般为4096字节(2^12),所以页基地址的0-11位为0。这12位0在页表中用于对每一页进行权限控制:读、写、执行、页面错误等32位页表项需要在启用分页之前对内存进行分段。在32位机器上,所有段通常都映射到0-4G的虚拟空间。这时候代码段、数据段、堆栈段的基地址就没有用了。CS、DS、SS段寄存器主要用于权限控制,称为段选择器。段选择符是一个等差数列,间隔为8??。没有使用0号,代码段为0x8,数据段为0x10,堆栈段为0x18。它们对应的内存地址、内存范围、内存权限都必须写在全局描述符表(GDT)中。GDT:全局描述符表。在启用分段之前,需要将GDT表加载到CPU的专用寄存器中。使用的指令是LGDT:这也是一种特殊的指令,只能在内核中使用,一般只在初始化时使用。这里还需要加载中断向量表(IDT):中断描述符表。中断向量表是用来处理硬件中断的函数指针,也就是所谓的中断服务程序(irq)。在开启分段之前,先给它预留一个内存位置,以后再设置。加载GDT和IDT后,开启A20地址线,CPU可以访问1M以上的内存地址。然后,打开内存的分段模式。接下来是Linuxbootloader中著名的程序集:ljmp$8,$0跳转到代码段的首码首码的偏移量为0,代码段的选择器为8。然后就是设置内核页表,然后开启分页机制。内核页表至少分为2级。在64位机器上有很多层级,而在32位机器上只有2个层级:页目录和页表。但是每一层的表项都差不多,都是页基地址+访问权限。页表中填写的内存地址就是物理内存的地址。当一个进程访问内存时,虚拟地址被内存管理单元(MMU)转换为物理地址,然后发送到CPU的地址总线,然后内存数据从数据总线传输到CPU的寄存器中。中央处理器。32位机虚拟地址到物理地址的计算:高10位决定页目录位置,中间10位决定页表位置,后12位决定偏移量:paddr=dir[vaddr>>22][(vaddr>>12)&0x3ff][vaddr&0x3ff]。(64位机,没仔细看intel的手册,有兴趣的可以自己看)分页机制下,一行movrax,(rdx),hardwareandtheoperatingsystem其实是做了一个很多东西。设置页表后,将页表的基地址加载到CPU的cr3寄存器:页目录基地址寄存器。然后,您可以跳转到内核C代码的main()函数。因为页表已经设置好了,接下来就可以用C语言来写了。上面说的都是汇编代码的内容。6、内核子系统的初始化进入C语言的main()函数后,首先是对各个内核子系统的初始化:1)缺页中断时进程访问的虚拟地址当相应的物理内存页做时不存在,则由pagefault中断处理:合理的pagefault会申请新的物理内存页,不合理的pagefault会给进程segmentfault。Segmentationfault,会导致进程被操作系统的信号机制杀死。2)时钟中断操作系统的调度节拍,由硬件时钟每1毫秒发送一次。3)系统调用是用户程序与操作系统之间的唯一接口。write()系统调用就是其中之一,它是printf()函数的底层机制。4)控制台内核打印日志的必要模块,是内核printk()函数的底层机制,也是用户shell控制台的底层机制。键盘驱动和VGA驱动一般放在console模块中,为系统提供最基本的输入输出支持。5)进程管理这是内核的核心模块。折腾了这么多,就是让用户的多个进程切换fork()系统调用,exit()系统调用,wait()系统调用,getpid()系统调用,kill()系统调用,都属于这个模块。6)内存管理也是内核的一个核心模块,整个操作系统都是围绕内存管理展开的。kmalloc()函数、kfree()函数、get_free_pages()函数、brk()系统调用,都属于这个模块。brk()系统调用是设置用户进程数据段的结束位置,即堆内存的结束位置,是malloc()和free()函数的底层机制。get_free_pages()函数,内核分配物理内存页的函数。7)文件系统在基于Unix的操作系统上,一切皆文件。这是从C语言之父DennisRich那里继承下来的设计理念。open()、close()、read()、write(),这四个系统调用都属于文件系统。execve()系统调用,虽然属于进程管理,但是由于需要加载可执行文件,所以对文件系统的依赖比较大。8)网络子系统TCP/IP协议栈+NetFilter+网卡驱动,这三个是网络子系统的内容。Linux网络子系统的作者是AlanCox,AlanCox。整个互联网的基础就在于这个子系统。TCP、UDP、IP、ICMP、ARP、DNS等,这些网络协议都在这个模块中。9)各种设备的驱动鼠标、显卡、U盘、硬盘等,大部分设备的驱动都属于这部分。大致分为:块设备、字符设备、网络设备。硬盘是块设备,它的最小访问单元是扇区,每个扇区为512字节。字符设备可以按字节访问,显示器就是典型的字符设备。网络设备,网卡是典型的网络设备,它也属于网络子系统。7、0号进程的创建0号进程,在操作系统中称为空闲进程,是CPU空闲时运行的进程。各种内核子系统初始化完成后,操作系统会创建进程0作为后续所有进程的模板。在进程的数据结构中,主要有几项:1)EIP,用户态的代码地址,2)ESP,用户态的栈地址,3)ESP0,内核态的栈地址,4)cr3,物理页表Address,5)pid,进程号,6)ppid,父进程号,7)brk,用户代码数据段结束,8)代码段位置,data用户态段和栈段,可用于检测段错误,防止缓冲区溢出攻击。9)信号图,处理过程的信号机制。10)进程的段选择器,内核和用户进程的段选择器是不同的,因为内核最高权限ring0,用户进程最低权限ring3。加载进程的数据到CPU的任务寄存器,然后降权到ring3,执行中断返回,然后进入用户态:此时的进程是一个空闲进程,它的代码只有一行:暂停();即,运行pause()系统调用:如果是其他进程,它会调度其他进程运行;如果没有其他进程,它会运行功耗最低的暂停指令,以降低CPU功耗。OS内核整体流程的最后就是fork唯一的No.1init进程,然后为用户启动shell或图形界面。不管是shell还是图形界面,本质上都是用户进程。