有位天才少年曾说过:Talk is cheap, let's do it. 我承认被这句话触动了。
这是我在gitee上的项目,有感兴趣的朋友可以fork出来自己玩。
凹凸满/linux-0.11-dev
https://gitee.com/hope2hope/linux-0.11-guest (GuestOS image)
目前已经做了一下工作:
在较新的ubuntu平台,用较新的GCC编译0.11的代码,然后在bochs2.6.9中跑起来,具体做了哪些适配工作,请查看AdaptionListForNewerGCC.txt
分支描述:
- hdBoot-realMode
1.1 该分支主要是将系统改为从硬盘启动,引导程序(bootsect.s和setup.s),放置在硬盘引导区的前5个扇区中(现代硬盘的前1M空间都是引导区),OS放在硬盘的第一个分区,FS在硬盘的第二分区,注意0.11的FS目前只支持64M磁盘(后面会改的).
1.2 系统的加载是在实模式下加载的,所以对于OS>1M的kernel是有问题的,下一个分支就解决这个问题,在保护模式下加载系统,大小随意(可以任性喽呵呵)。
1.3 老的代码根文件系统的设备号放在引导区第一扇区的508和509字节处,但是对于硬盘来说,第一扇区的最后66个字节是用来存储分区表和引导区标识符的,所以这里把ROOT_DEV放在了444和445两个字节处。
1.4 新建一个MakeOSImage.c文件,用于将引导程序(bootsect.s和setup.s)放在硬盘的前五个扇区,将OS放在硬盘1M开始地址处。
2. hdBoot-protectMode-keepLoader
2.1 该分支实现在保护模式下加载OS code这样就不会受到实地址模式下1M寻址空间的限制,可以加载理论上最大4G的kernel。
2.2 具体实现步骤是:在实地址模式下,先从硬盘第一分区加载32K的kernel code代码,放在0x10000处,然后将该32K kernel code搬运到0x0000内存起始地址处, 随后开启保护模式并跳转到0x0000地址处执行对剩下kernel的加载,由于这时已经是保护模式了,可以32为寻址了,所以就任性了呵呵。 qkdny.c这个代码就是负责搬运剩余的kernel的,因为它是链接在head.s后的,所以系统运行后不会被覆盖的,一直会保留到系统shutdown.
2.3 后面会再出一个版本将qkdny.c放在kernel的开始4k内,这样当kernel完全加载完后,开始初始化内核目录表的时候,就会覆盖loader(head.s的一部分+qkdny.c),这样就可以节省 一部分内存了,不过对于现代内存容量来说就可以忽略了,这里仅仅是个practice,主要是想学习下GCC 和 LD。
3. hdBoot-protectMode-eraseLoader
该分支把加载代码编译成整好占用开始的4K地址空间,这样当代码加载完后,初始化目录表就会覆盖掉加载代码。
4. support4GAddrSpacePerProcess
该分支实现了根据内存的实际大小动态初始化内核目录表和页表,从而支持内核对4G内存的寻址,将目录表放在内存开始出0x0000 页表占用4M从地址1M开始,内核代码被放置在了5M地址开始处,能够成功管理<64M的内存了,对于>64M的内存会出错,因为我们知道该内核是所有进程 共用一个目录表且地址空间是64M大小,起始地址是nr*64M,所以当物理内存>64M后,进程1的地址空间和内核空间就重叠了,会有地址冲突错误的, 所以下面支持每个进程都有自己独立的4G寻址空间,都有自己独立的目录表。 本来打算该分支实现的,但工作量有点大,而且实现的方式有两种,所以就不在此分支上开发了,后面会开两个分支用两种方式实现进程4G寻址。
这两种实现方式的详细过程,请参考boot/head.s里的注释,有详细的阐述。
5. 4GaddressingForProcess-byPartOfReservedKernelSpace
5.1 实现每个进程都有独立的4G线性地址空间。
5.2 内核的线性地址空间是从低地址0x00开始的,其大小可以通过系统参数动态分配。
关于内核线性地址空间大小的分配详解head.s中关于系统参数KERNEL_LINEAR_ADDR_SPACE的介绍。
5.3 在内核线性地址空间预留一部分空间,用于映射>内核线性地址空间的物理内存,进而实现内核对整个内存的管理。
地址重映射的处理有两种方式:
5.3.1 内核代码在使用物理内存的时候,要先判断该物理地址是否超出自己的实地址映射范围了,如果超出就要用保留空间remap。
优点:出错了好排查
缺点:有点繁琐,而且对于有些特殊设备使用物理内存要格外注意别忘了添加remap检查,详解对pipe的处理(pipe.c)。
目前实现了该remap方式。
5.3.2 在general protection 异常处理方法里实现内存的映射,这样就不用根据设备的增减动态的加入映射代码。
实现的思路:
1. 首先判定read/write beyond limit异常是否发生在内核态,如果是内核太就映射,否则任由其发展哈哈
2. 由于在内核态处理该异常,所以栈不需要切换,用的还是内核栈,和普通的函数调用一样,没区别。
3. 调用remap函数完成映射,并将新的线性地址放置到出错EIP指令的oprand位置处,也就是更新出错指令的操作数。
这里尤其要注意:千万不要修改原来存储物理地址变量的值,因为你根本就不清楚后面的context是否要用到该物理地址,如果此时你贸然把该变量的值重置为remap后的线性地址,后面context再用到该变量就有问题(这里是个难点)。
4. return出错指令继续执行。
逻辑很清楚也很简单吧,但实现起来你懂的,到现在真是深有感触啊。
首先general protection处理的异常码有很多,你得区分read/write beyond limit的异常码。 其次也是最头疼的,就是有的时候bochsout.txt文件的log里明明报的是read/write limit异常,但程序呢没有进入general protection 异常,也就是如何确保这种异常一定能被捕获到,这是最关键的。
6. support-SMP
6.1 打通了BSP与APs之间的通信链路,主要是通过INIT-SIPI-IPI完成对APs的初始化,包括AP的GDT,IDT,段寄存器使其指向内核段,通过使用同步原语实现各个AP顺序初始化,具体实现可以查看该分支,为了初始化AP,在实地址和保护模式之间的跳转那是相当的tricky哈哈。
下一步打算在主存区为每个AP分配一页的内核栈用于响应BSP发来的各种中断,为将进程调度到指定的AP上运行做准备。
6.2 BSP根据AP的负载情况,调度进程到指定的AP上运行已经调试成功,代码里有详细的注释(本来逻辑上看BSP调度AP运行进程相对会简单点,但实现起来也是复杂的很),下一步将实现AP自主调度进程运行,这里主要涉及到磁盘块的同步(尤其是磁盘块的高速缓冲区的同步),开启AP的timer,也要实现对硬盘请求的同步等等,比BSP调度AP的方式要复杂一点。
yeah, OS一旦上瘾,真的会让人欲罢不能哈哈。
下图可见:Hello World程序在CPU1上顺利执行了。
登录shell初始化过程中fork的所有进程也是dipatch到AP上执行的。
![0278e3206c0548c7c723d630aafba207.png](https://i-blog.csdnimg.cn/blog_migrate/ce938e3d9636707a9a8bf6165d0d6986.jpeg)
6.3 实现AP自主调用进程,不需要BSP参与调度。
目前已经实现了将BSP的8253 timer 替换为APIC timer ,为开启AP的APIC timer做准备,进而实现AP自身的timer,具体实现看分支6:support-SMP
6.4 实现了磁盘块和内存块在多进程并发下的同步问题
为实现BSP和AP能够自主调度进程运行,打下坚实的基础,^_^。
今天主要解决了三个巨坑,主要都是进程fork的时候一些需要考虑的并发问题,例如写保护异常的处理,进程复制过程中运行状态的设置等等,带入并发执行的上下文都有大问题。细节看分支6.
6.5 实现AP自主调用进程,不需要BSP调度。
6.5.1 已经实现将BSP上的8253 timer替换为APIC timer,为AP开启APIC timer做好准备。
6.5.2 终于实现BSP和AP自主调度进程运行了
历史性的一天啊,里面有太多太多值得回味的地方,逻辑层的山路十八弯啊,太多大坑和tricky值得你去填和思考,
详细实现和感悟,请看实现里的注释吧。
![3635c83d6df7edad66a353b40ad41ab1.png](https://i-blog.csdnimg.cn/blog_migrate/2fc9b49916bb6a02b222c11445943cd0.jpeg)
7. support-KVM
7.1 成功地将OS从bochs移植到QEMU了
也是挺苦逼的,一个APIC base address是否能relocate就耽误了近三天时间哎
![d0b8136e37478d498420006df736aea7.png](https://i-blog.csdnimg.cn/blog_migrate/da068bc77fec044c31e2e741a676f895.jpeg)
7.2 vm-entry成功,终于进入non-root vm状态了。
喜大普奔啊,guest vmcs的初始化搞了整整一个星期啊,东西真的是太多了,现在有一个经验,feature的难度与关联寄存器的个数绝对是正比关系,vmx这个feature的关联寄存器实在是太多了,一不小心就vm-exit了。
执行完vmlaunch命令后,终于跳入guest_idle_loop函数,在VM状态下执行了。
![3ba69ef16a6204ad59c32cbdc584ab22.png](https://i-blog.csdnimg.cn/blog_migrate/ba7b94a01f4151d087363245fd728357.jpeg)
7.3 完成基于EPT和VPID的内存虚拟化工作。
内存虚拟化真的是太复杂了,而且里面还有好几个大坑,debug的过程能让你怀疑人生,其中一个搞了两天才发现并搞定,Guest通用寄存器值的备份问题,真的是太坑了,这个疏忽代价有点大,详细看代码里的注释吧。
![b5d0795e047523d3c97b53d58fe12429.png](https://i-blog.csdnimg.cn/blog_migrate/f5397e593884c9833b7fa61ae59b6ccf.jpeg)
![50d977e42422e8781ea81efb79725997.png](https://i-blog.csdnimg.cn/blog_migrate/055010220dbaccb18fecddb63a2823da.jpeg)
7.4 重要进展GuestOS 中终于能执行printk打印功能了
注意: 这里只实现了在内核态printk的打印功能,在用户态的打印printf里面有个大坑在7.5节会详细介绍。
版本7.3中虽然完成了内存的虚拟化工作,但是由于GuestOS是共享HostOS的code和data的,所以在GuestVM中执行printk操作不会出现光标定位和srollup或scrolldown混乱的错误,因为这些参数是共享的,7.4版本是加载了GuestOS自己的image,执行自己的内核代码了,所以当这时在GuestOS中执行printk操作就有问题的因为screen cursor已经不是开始位置了,有以下两种解决问题的方法:
- 设置这些跟屏幕滚动和光标定位的参数为共享参数.
- 设置Guest-vmcs的I/O bitmap的有关屏幕定位的端口为1,这样一旦写该端口就触发VM-EXIT,到host kernel环境下,代替GuestOS 执行print操作.
方式1比较麻烦,关键是要加锁同步;这里使用方式2,并在共享区为每个VM分配一页自定义的VM-EXIT-INFO信息,根据exit-reason及其相应的info-structure来处理。
例如 有关打印的VM-EXIT-INFO中就会存储要打印的字符数和printbuf指针,这样在Host内核态执行guest_printk()方法来打印GuestOS中要打印的内容.
7.5 在VM中,终于实现task0与task1的切换。
在VM中,要实现进程切换需要突破两大玄关,首先要将丹田之气散之任脉... hh不好意思跳戏了。
7.5.1 新老进程上下文的备份和还原必须自己实现。
这里提3个关键点:
- 要将老进程导致进程切换的下一条指令的地址备份到task_struct.tss.eip中。
当老进程再次被调度执行的话,应该从导致task_switch指令的下一条指令开始执行了,如果继续执行task_switch指令的话会陷入死循环了。
2. 进程切换完后一定要将老进程的tss.status(desc_type)设置为available(9), 这样下次再调度老进程执行的时候不会报GP错误,如果此时老进程的tss.desc_type=11(busy)的话就报GP异常了,根本无法触发VM-EXIT进入VMM中完成进程的切换。
这个问题搞了整整一天,上下文的切换,某个细节遗漏了,真的能搞死你。
3. 如果被调度的进程不是第一次运行的话(意味着被调度的进程处于内核态,不能再用iret指令一次性从栈上还原ss,esp,eflags,cs,eip内容了),那么这个时候一定要注意:先将被调度进程的内核栈esp-=4, 然后再将被调度进程的eip设置到去该esp栈顶处。
因为,一旦将esp寄存器的值设置为被调度进程的esp后,当你执行ret指令返回的时候,它首先会popl eip的,所以上面的操作明白了吧。
7.5.2 Guest-CR3还有个备份目录表结构(这里称之为Guest-CR3-shadow),用于控制对EPT-page-structure转换后得到的实际物理页的访问。
详解请看我的另一篇文章: 关于QEMU中EPT的实现机制。
这个问题搞了我整整一周,本来内存管理就挺复杂的,为了实现内存虚拟化在此基础上又搞个EPT(4层映射),关键QEMU还在里面加了私货哎,一言难尽啊,感兴趣的看文章吧。
7.5.3 关于GuestOS中,用户态下实现printf的打印功能
这里面有个巨坑,为了解决这个问题耗费了一天时间,关键是GDB有的时候太不给力,打了断点了(b和hb都搞了),死活就是不起作用,尼玛只有自己手工计算要打印内容的实际物理地址了。
开过代码的朋友都应该知道,内核态是如何打印用户态的string的,就是通过get-fs-byte这个宏定义,因为进入系统调用后会将fs设置为指向LDT的ds的,所以这样内核态就可以访问用户态的数据了。
但是printf函数也是类似printk的实现机制,通过设置io_map触发VM-EXIT进入VMM,由VMM获取要打印内容的地址,进行打印的,所以这个时候传给VMM的print_buf的地址就要注意了: 一定要加上段的基地址(printk要加上内核ds.base,printf要加上用户态ds.base)。
printk里没加ds.base也能work well,那是因为内核的基地址是从0x00开始的,如果不是从0x00开始的话也会出现打印的内容不对的情况。
详细的实现过程,请看ttyio.c的tty_write函数里的comments吧,里面有详细的推到过程。
![0f659c065180b2fa641e50d613a5c453.png](https://i-blog.csdnimg.cn/blog_migrate/52e11428b90567ff4246117326272936.jpeg)
如上图所示:task0和task1可以成功地进行调度执行了,根文件系统也加载成功了,最后两行内容(GuestOS:开头)就是用户态下的printf打印的。
这里出个小问题,图上最开始的4行,为什么是相同的linear-addr映射到不同的guest-phy-addr,最终映射到不同的phy-addr?这个问题搞懂了,内存映射和管理对你就没秘密可言了^_^。
![1f3bc1a4f183e56de205cc8523bf120f.png](https://i-blog.csdnimg.cn/blog_migrate/4784a283d4c20a55a43b3e5742f1b42b.jpeg)
如上图所示,GuestOS最终停留在task1用户态的loop循环中。
7.5.4 终于成功的在自己写的GuestOS运行了第一个HelloWord应用程序了
又是历史性的一天,在VM中,自己写的GuestOS中终于成功运行了第一条hello world应用程序,里面太多的弯弯绕,留待后面详细解释。
石锤了,qemu中为什么要为每个Guest-cr3创建一个guest-cr3-shadow,就是为了销毁一个guest process做准备的;通过一次vm-exit销毁该进程 占用的所有ept物理页,尼玛效率啊。 当我们在GuestOS中通过free_page_tables销毁一个进程的时候,还要记得把对应的ept-page-structure中ept-page表项页释放了,不然的话新建的进程就会用老进程的代码跑了,想想看是不是这样,而在VM中的GuestOS是无法释放ept表项的,所以要vm-exit到VMM做这些事 ,guest-cr3-shadow给了我们一次性释放进程占用的所有ept表项的能力。
![a6937664a544d6226b6eba1eab410577.png](https://i-blog.csdnimg.cn/blog_migrate/62ab79606a095b5f6cb46e500ddf9ae4.jpeg)
如上图所示,久违的helloworld终于来了想哭啊
![2a90ead7cfc96e52330f129a2ea532a6.png](https://i-blog.csdnimg.cn/blog_migrate/a57a36a2446b3559570e913105dddd4c.jpeg)
7.6 HostOS和GuestOS终于能够相安无事愉快的在一起工作了
今天是真正历史性的一天,排除一个VM task_switch中隐藏的大bug后,系统终于能够很稳定的工作了,HostOS终于能够成功的将GuestOS调度到指定的AP上并成功运行了,至此内核虚拟化的工作算是彻底走通了,有一个感受任何事没有验证过千万别想当然了,之前7.5.4成功地在BSP上运行GuestOS了,想当然的以为BSP将GuestOS调度AP上运行也不会有问题,其实问题多了去了.
下面就来讲讲遇到的大问题有哪些:
7.6.1 GuestOS中对HD的访问
由于QEMU在processor reset和init后会将BSP.lint0设置为ExtINT(Directly connect to 8259A PIC), 所以运行在AP上的GuestOS要想收到来自8259A的HD_INTR,有两种方式:
- 通过BSP中转后,传递给AP
具体的实现方式是:BSP收到HDINTR后向相应的AP发送IPI中断,AP通过该中断调用hdread/hdwrite方法响应,这里千万不要在BSP中完成hd-read/write操作,因为HostOS和GuestOS都有各自独立的文件系统,而且GuestOS运行在基于EPT虚拟化后的内存里的,所以HostOS如何读写GuestOS中的FS是个棘手问题,所以还是有GuestOS来完成HD-read/write比较好。
这种实现方式最大的好处是: 通过加锁机制,可以实现多个(N>=2,运行在不同AP 上)GusetOS对HD的并发访问。因为所有AP发送来的HD-request/response都要汇总到BSP,有BSP统一管理,这样就方便加锁进行同步了。
2. 关闭BSP中断,将ap.lint0连接到8259A PIC这样AP上的GuestOS就可以直接HD-request/reponse了,但是Intel文档规定了所有的processor只有一个可以directly connect to ExtINT,所以在多个GuestOS并发访问HD的时候,不仅要加锁同步,还要不停的切换ap.lint0,更为低效的是一旦一个ap.lint0连接到ExtINT后都会各自维护一个独立的hd-request list,这样只有等该ap完成了所有hd-request list后才回释放锁给其他ap使用,这样效率就太低了,而不是像上面方法那样由BSP维护一个所有processor共享的hd-request list.
这两种方式代码里都实现了, 由方法init_apic_lint0和IPI中断号HD_IPI_INTR_NO切入代码去看就非常明白了。
7.6.2 AP在运行GuestOS的时候老是莫名其妙的pending
每次在AP中debug GuestOS的时候当delete所有break后,运行continue指令老是随机卡死在某些代码处,但是ctrl+c后输入c回车又能运行一段程序,反复这样几次最终也能成功运行a.out这个helloworld程序,感觉是缺了heart-beat,后来抱着试一试的心态(哈哈有点像电视广告里的大妈现身说法)加入vmx-preemption timer这个功能后GuestOS就能连续运行代码了。这里实在是不知道为什么会这样,难道VM中必须要加这个功能,定时的在VM和VMM之间来回的切换才能保证VM不pending.
7..6.3 AP开启apic-timer后偶尔会导致死锁的问题
我们知道qemu是不支持apic-base-addr relocate的,又因为本内核空间是分配在1G的低地址空间的,所以每次访问apic-registers都要先进行apic-base-addr remap然后才能访问,
每个AP开启apic-timer后会定时执行do_timer中的send_EOI方法,该方法是加锁实现将apic-base-addr映射到8K~136K空间随机的某个页,这样processor之间会进行频繁的锁竞争,从而导致整个系统不是很稳定,偶尔会出现死锁的情况。
现在将不同的processor.apic-base-addr映射到不同的linear-addr从而就不需要锁竞争了,大大改善了系统的稳定性。
7.6.4 VM task_switch里隐藏的一个大bug
任务切换隐藏了一个很深的monsterbug,还是与FS段的设置与备份相关,以前的在实现多核任务调度的过程中也遇到这个问题。
造成这个bug的根本原因还是任务系统调用后会将FS设置为0x17指向用户态,这样在内核态就可以访问用户态的数据了(还记得get_fs_byte吗)。
当任务执行fsread/write进入内核态其FS被system_call.s设置为0x17,当要访问HD时,在发出HD_read/write后会设置状态为interruptable_state并等待HD_intr(本版本是BSP发送IPI通知AP), 然后执行schedule调度其他任务执行,这时会发生task_switch触发VM-EXIT进入VMM去进行新老任务的备份和设置,这时老任务的FS段肯定是被设置为0x17并保存在exit_reason_task_switch->old_task_tss.fs中的.
但是当再次调度老任务的时候,这里并没有还原老任的fs所以造成任务在内核太访问用户态数据时出错, 太佩服自己了,通过纯代码逻辑推理在中断和任务切换等各种导致VM-EXIT的嵌套中,最终还是被我发现了(借我借我借我一双慧眼吧O(∩_∩)O哈哈~)。
其实主要还是这种情况很少出现,出现了想debug但悲催的是老是跟不进去,在此再次吐槽下GDB在虚拟化里的调试有点太不稳定了,连hb中断有时也跟不进去。
以上都是耗时比较长的大问题,还有n多小问题就不列举了,感兴趣的可以看代码里的注释,都是泪啊。
![6b02bc060e45e74d9da565b7a295be7e.png](https://i-blog.csdnimg.cn/blog_migrate/7cd775fa8a29c5a92809ef70aa6a0c6f.jpeg)
总之:要开发一个稳定OS真的是太不容易了。
这仅仅是个DEMO,不过kernel最核心的东西基本上都有了,想学习内核尤其是KVM运行原理及如何实现的朋友可以看看。
下面终于可以写写散文了哈哈。