From L3 to seL4 What Have We Learnt in 20 Years of L4 Micorkernels?
Intro
Micorkernel: 最小化内核可以提供的函数。内核只提供一组广义的机制,用户层服务器实现具体OS服务。
现代的代表
seL4,Fiasco.OC,NOVA,OKL4
seL4通过引入了形式化证明方法,通过数学的方式证明了其在微核部分满足从设计到实现的一致性,以及微核上的服务具有互不干扰等属性。
微内核设计
最小化原则:一个操作系统内核的功能只有在将其放在内核态以外会影响整个系统的功能,才能被放置在内核态。
但没有一个L4内核的设计者声称实现了纯微内核,比如说他们都有在内核里的有具体策略的调度器。
IPC
-
同步IPC
避免了二次拷贝以及对其占用空间的开销。
同步IPC是惰性调度、直接进程切换、暂时映射的前置要求。
同步IPC在多核处理器上变得复杂,因为一个服务其无法同时等待client和中断的服务请求。解决办法:引入了异步IPC通知(在seL4中为异步endpoints):发送是非阻塞的并且对于receiver来说是异步的,每一个send具体指向一组掩码(通常一个bit)然后异或运算到一个字段中。
结论:同步IPC要么搭配异步IPC来增强它,要么直接被异步IPC代替。
但有两种形式的IPC违背了最小化原则,作者认为我们应该期待使用基于异步IPC的模型。
-
存储IPC信息的数据结构
短消息:使用寄存器的参数传递方式,缺点:寄存器数目有限,由于体系架构的不同移植变得复杂。
改进:提出了虚拟消息寄存器:思想是与硬件寄存器解耦,使用虚拟的寄存器来实现。实现上,一部分映射到物理寄存器上,一部分存放在每个线程固定地址的内存空间中,需要拷贝,但开销仍在可控范围内。优点:移植容易,相对于长消息的传递开销较小。
长消息:1.在单个通信调用中指定多个缓冲区,这是在接口层面的优化,不需要要求传递的数据在一段连续的虚拟地址空间,同时利用映射并不会引入额外的传输开销,分摊了多次进程间通信带来的上下文切换和特权级切换的性能开销。
2.拷贝次数的优化,只需要拷贝一次,即由内核映射到接收者预留的临时缓冲区,发送者拷贝到内核指定对应的区域即可。
长消息的使用主要是为了对于POSIX这种依赖于任意长度缓冲区域进行数据传递的接口的兼容,但随着发展这种接口被引用传递替代。其它的用处也
缺点:在实际的拷贝中,可能会出现缺页异常,这可能出现在发送者地址空间,也可能发生在接收者地址空间。而缺页异常又需要从内核态回到用户态的页管理程序去处理异常。这种并发显著增加了内核的复杂性:如用户态页处理程序完成处理后,内核需要重新建立原始的系统调用上下文,再去执行通信操作。同时seL4的形式化证明也明确要避免任何形式的内核并发(嵌套异常引入了某种程度的并发)。
结论:放弃长IPC。
OKL4为了兼容早期的微内核,提供了一种新的异步单拷贝批量传输机制,称为通道。应用于将保护边界转换为一个高度多线程的实施应用程序,但每对线程都有一个单独的通信页面,这成本太高了。
-
IPC目的地(通信连接)
最初的L4使用线程作为IPC运算的目标。这是为了避免过多的中间抽象带来的缓存和TLB污染的性能开销。
缺点:1.该模型的信息隐藏性差,多线程服务必须向客户端进程公开其内部结构,比如包含几个进程、每个线程的ID是多少。2.该模型要求线程ID是全局唯一的ID,这被证明会引入潜在的隐蔽信道的危险,导致攻击和信息泄露。
另外大页等机制已经使得间接通信带来的TLB污染的问题缓和了很多。
thread IDs最终被类似于port-like endpoints替代为通信的目标。
-
IPC 超时设定
阻塞式的IPC机制可能会导致DOS攻击。比如,恶意程序不求回应的不停发送请求,由于约会风格的IPC,这样可能会导致其它应用程序无法被服务,除非使用一个监视程序来中止该恶意程序,重新启动服务。
为了避免这样的攻击,IPC运算设置了四个超时设定:一个设置在发送阶段,一个设置在接收阶段,还有两个用来监视发送和接收过程的缺页异常。
虽说timeout 值可以0到无穷大任意设定,超时设定几乎没有用来防御DOS攻击,因为没有一个科学的理论支撑设置什么样的值是合适的。而且由于根据不同的调度策略可能响应时间也是难以稳定的。所以实际上只有0和无穷大两种值在被使用:发送者的发送和接收被设定为无穷大,一个服务器等待请求设置为无穷大但回应设置为0。即这种监督方法反而成了一种用来检测无响应的IPC交互。
再往后发展,timeout直接被简化成了个两个flags来标记时0还是阻塞。
超时也可以用于通过等待从不存在的线程发出的消息来等待超时睡眠,这一功能在实时系统中很有用。
实现方法:dresden使用绝对时间clock,论文中的方法是让用户可以访问计时器(不管是真实的还是虚拟的)。
结论:放弃IPC timeout
-
通信控制
在L4早期的设计中,内核在通信过程中会将发送者的一个“不可伪造的标识符”传递给接收者,来帮助接收者判断是否相应发送者的消息。但是,一个恶意的发送者进程可能用大量消息轰炸一个接收者进程,从而导致DOS攻击。
早期的L4通过clan and chief的机制来解决这个问题。整个系统的进程按照clan的层次结构进行组织,每个clan有一个指定得chief,在clan内部的消息是自由传输的,但跨clan的消息将被重定向到首领,由其来控制消息的流向。这个机制不仅保护了内部的进程,同时也限制了不受信任的进程,即如果存在某个不受信任的进程希望传递敏感信息到外部,是会被首领检测并阻止的。
但这种方式逐渐被放弃。这种方式只有在直接进程切换时增加两个时钟周期,但一旦消息需要发送到外部,那么中间会经过消息的几次重定向,通过一层层的往外发送,每一次转发都会增加两次IPC的调用,显然这样的开销是相当大的,而且恶意的攻击者可以对首领进行攻击来限制整个宗族和外部的通信。而且在实际实现中也是很笨拙的。 对于强制性的访问控制,模型就会退化成每一个进程是一个首领的糟糕状况。
基于此,很多L4实现都不采用该机制,最终被更灵活的权限机制——capability能力替代,问题才得以解决。
应用层的设备驱动----a core feature
将设备驱动放至应用层是至今所有L4 kernel的一个标志。同时完整性验证也坚定的支持着这个机制:如果将一个未经验证的驱动放到内核中,所有的保证都会被消除,而且在现实中验证内核中的所有驱动程序目前是无法做到的。
仍有一小部分驱动放在内核中,比如时钟驱动,以及中断控制器的驱动。
应用层的驱动通过IPC与中断紧密耦合。该模型的细节这些年来也在改变,至今最显著的变化是从同步转移至异步的IPC for 中断的交付。这么做是为了简化驱动,因为同步交付需要模拟虚拟的内核线程来作为中断的来源。
该模型受益于虚拟化硬件驱动的发展以及硬件发展带来的越来越少的中断开销。在X86中,也从TLB tagging中减小了上下文的切换成本。
如今 应用层的设备驱动已经成为主流,在Linux、Windows和MacOS中都有支持,虽说效率没有L4的高,但在现实中,只有很小一部分设备是性能关键型的。
资源管理
对内核中资源的管理,一个进程消耗内核的资源以及未经检查的分配TCB块和页表容易导致DOS服务。
进程层次结构
早期的L4使用Task ID来当作地址空间创建和删除的能力控制。Task ID是有限的,采用先来先服务的策略。但这种方法不够灵活和不够约束,最终被capabilities替代。
递归页面映射
该模型使用σ0来控制当操作系统初始化后来控制所有的页表,是一个page fault handler。
通过IPC一个进程可以map它拥有权限的页面到任何其它的进程。内存对象、copy on write 以及阴影链都是用户级创建的抽象或实现方法。
尽管该模型看起来很优雅,但经验表明,这个模型也有很明显的缺点。
该模型要求以映射数据库的形式进行大量的记录,然后两个恶意程序可以通过递归的方式将同一帧映射到彼此地址空间的不同页面,导致内核消耗大量内存。
现实使用中,25%-50%的内存被这样的映射数据库占用即使没有恶意程序。
递归页面映射被替换为映射总是来自于物理内存帧的范围。但这并不能细粒度的指配和撤销内存。
在基于能力的系统中,映射控制使用授权模型的变体是很容易实现的。
seL4中使用capability对应一个物理页,而不是通过授权访问该帧对应的虚拟页面。
结论:一些L4kernel保持递归页面映射的使用,seL4和OKL4从帧开始生成映射。
内核内存
虽然能力提供了delegation的模型,但这并不能解决资源管理的问题,恶意程序仍然可以拿着权限重复做大量消耗内存的事情,从而造成DOS攻击。
传统的L4有一个固定大小的堆,内核从堆中为数据结构分配内存,然后内核中有一个pager,当堆中数据不够时,可以从用户层内存中借用一部分内存。但这不是解决恶意程序占据内存的方法,只能延缓问题的发生。因此,pager并不被大多数L4内核支持。
该问题本质上是由于共享的内核堆无法将用户进程在内核中隔离。一个很好的解决方案必须提供完全的隔离,但即使在能力系统中,如果有些资源在能力系统之外,则其状态无法推断。
即使从用户区借用内存能够避免DOS问题,但不能实现严格的隔离内核内存,这是性能隔离或实时系统的先决条件。
Liedtke提出了每个进程一个堆的机制。这简化了用户层以失去不用损坏进程的撤销allocation以及直接获取已分配内存的能力。 这是一个trade-off
在seL4中,使用capabilities将整个内核空间设置权限,并将所有的内核对象显式规定,即将内核空间对不同的应用进行隔离,这样就避免了恶意程序对内核空间的DOS攻击。
Time
一个CPU在一个时间里只能执行一个线程,必须被复用。
早期的L4调度算法是硬优先级的round-robin算法,尽管违背了为内核的policy-freedom的原则,但至今也在被使用。所有尝试将调度算法移出内核都失败了。
现在有人提出了能够将多优先级的调度器映射到单优先级的调度器的想法,尽管有希望,但也还不能处理在实时应用社区中的广度,比如EDF。
现在有些人认为一个适用于所有目的的单一、通用的内核的概念已经没有那么重要了,现在是习惯于插件的时代。但seL4的形式化证明带来了这样的一种愿望。因为一个policy-free的调度方法还是和以往一样值得期待。
结论:至今仍未被解决。
多核
早期的x86多核处理器有很高的跨核通信开销以及没有共享缓存,所以标准的方法是使用每个处理器一个队列的方式。
现在多核处理器有较低的跨核通信开销并且有共享的L2cache
为了避免并发,意味着使用一个big kernel lock 或者多内核空间的方法。
现在正在探索的方式是clustered multikernel,这是一个大锁内核核多核的混合体。
微内核实现
Strict process orientation and virtual TCB array
早期的L4:每个线程有对应的内核栈,并固定在其TCB块的上面。
所有的TCB被分配在一个稀疏的、虚拟地址寻址的数组中,由线程ID来索引,这样在TCB中可以快速查找目的TCB,而且不需要检查ID的有效性,因为若访问到了没有映射的TCB,则会触发缺页异常,然后中止IPC。若没有触发缺页异常,则只需要比较caller提供的一个值和对应TCB中的值是否相等来确认找到的TCB是否为目的TCB。
代价:这么多内核栈会占据每个线程的内存开销,也会占用缓存的占用空间。 virtual TCB也会增加内核虚拟地址的使用,增加TLB的占用,但是避免了查找表的额外缓存占用。
具有单一页面大小和未标记tlb的处理器除了分组数据结构以最小化所接触的页面数量之外,几乎没有机会进行优化。但RISC处理器有大页和tagged TLB改变了这个tradeoff。
当内核空间的使用变得很紧张时,这种设计需要重新考虑。
早期的单个内核栈的证明这减少的对内核空间的使用同时也提高了IPC的表现。
同时也有人分析了虚拟TCB和物理TCB比较,在IPC上几乎没有区别,物理TCB但在TLB上有更好的表现,即虚拟地址的TCB没有好处。
结论:采用单内核以及物理地址的TCB
惰性调度
基于假设:IPC相关的线程的阻塞状态会很快结束。
在L4同步的IPC模型下,现成的状态经常在预备和阻塞交替,会发生频繁的调度队列操作,这些额外执行的代码和访问的数据会在通信过程中引入TLB未命中、缓存未命中的情况。
方案:当线程在IPC操作中阻塞时,内核会在TCB中更新其状态,但会将线程保留在就绪队列中。而由调度器来遍历就绪队列来找到真正可运行的线程。
缺点:增大了调度器的复杂性和性能开销,并且在形式化证明中发现惰性调度的WCET下界跟系统中的线程数有关。
新方案:Benno 调度:ready队列只包含所有可运行的线程除了正在运行的线程。因此,当正在运行的线程在IPC中被阻塞时,ready队列不需要修改,而在时钟处理时,内核讲可以运行的不被阻塞的放入ready队列。这也意味着不再有唤醒队列,但是endpoint的等待队列需要严格的维护。
尽管平均表现和惰性调度差不多,但WCET有一个很好的界限。
结论:惰性调度被benno调度替代。
直接进程切换
不可移植性
不可移植性被替换为一部分的可移植代码。
放弃 不标准的calling convention
放弃 为了性能而使用汇编语言