Meltdown漏洞分析

Meltdown漏洞分析

Xiao Guangrong[1]       佚名[2]

2018年或许注定就是不平凡的一年,这一年刚开始就爆出来两个硬件设计级别的漏洞,其影响之深令人咋舌。漏洞之一是Meltdown,目前发现Intel CPU和ARM Cortex A75受影响。其次是Spectre,其影响了几乎全部主流CPU包括Intel,AMD,ARM (IBM CPU是否受影响还未知)。漏洞爆出之后,几乎所有媒体都在做铺天盖地的报道,科技公司也在公布各自的解决方案和修正日期。然而因为技术背景参差不齐,有些报道没有说到点子上,令人啼笑皆非。

本文的主要资料来源于各论文[3][4]与以及相关的Blog[1][2],其次补充了这些资料中有所忽略或者是互相冲突的地方。因为篇幅原因,在这一篇文章里主要分析了Meltdown。在后续的文章里再来分析Spectre。

背景知识

在深入分析Meltdown之前,我们需要了解一些背景知识。它包括CPU Cache,CPU指令执行,操作系统地址空间隔离的设计。接下来我们依次来看这些知识点。

CPU Cache

现代处理器执行指令的瓶颈已经不在CPU端,而是在内存访问端。因为CPU的处理速度要远远大于物理内存的访问速度,所以为了减轻CPU等待数据的时间,在现代处理器设计中都设置了多级的cache单元。如下图所示:

它示例了2个CPU,每个CPU有两个Core,每个Core有两个线程的Cache架构。

每一个Core有单独的L1 cache,它由其中的线程所共享,每一个CPU中的所有Core共享同一个L2 cache和L3 cache。

L1 cache最靠近处理器核心,因此它的访问速度也是最快的,当然它的容量也是最小的。CPU访问各级的cache速度和延迟是不一样的,L1 cache的延迟最小,L2 cache其次,L3 cache最慢。

下面是Xeon 5500 Series的各级cache的访问延迟:(根据CPU主频的不同,1个时钟周期代表的时间也不一样,在1GHz主频的CPU下,一个时钟周期大概是1纳秒,在2.1GHz主频的CPU下,访问L1 cache也就2个纳秒)。

 

访问类型 延迟
L1 cache命中 约4个时钟周期
L2 cache 命中 约10个时钟周期
L3 cache命中 约40个时钟周期
访问本地DDR 约60 纳秒
访问远端内存节点DDR 约100纳秒

 

由上表我们可以看到各级内存访问的延迟有很大的差异。CPU访问一块新的内存时,它会首先把包含这块内存的Cache Line大小的内容获取到L3 cache,然后是载入到L2 cache,最后载入到了L1 cache。这个过程需要访问主存储器,因此延迟会很大,大约需要几十纳秒。当下次再读取相同一块数据的时候直接从L1 cache里取数据的话,这个延迟大约只有4个时钟周期。当L1 cache满了并且有新的数据要进来,那么根据cache的置换算法会选择一个cache line置换到L2 cache里,L3 cache也是同样的道理。。

Cache攻击

我们已经知道同一个CPU上的Core共享L2 cache和L3 cache,如果内存已经被缓存到CPU cache里,那么同一个CPU的Core就会用较短的时间取到内存里的内容,否则取内存的时间就会较长。 两者的时间差异非常明显(大约有300个CPU时钟周期),因此攻击者可以利用这个时间差异来进行攻击。

来看下面的示例代码:

1  clflush for user_probe[]; // 把user_probe_addr对应的cache全部都flush掉

2  u8 index = *(u8 *) attacked _mem_addr; // attacked_mem_addr存放被攻击的地址

3  data = user_probe_addr[index * 4096]; // user_probe_addr存放攻击者可以放访问的基地址

user_probe_addr[]是一个攻击者可以访问的,255 * 4096 大小的数组。

 

第1行,把user_probe_addr数组对应的cache全部清除掉。

第2行,我们设法访问到attacked_mem_addr中的内容。 由于CPU权限的保护,我们不能直接取得里面的内容,但是可以利用它来造成我们可以观察到影响。

第3行,我们用访问到的值做偏移,以4096为单位,去访问攻击者有权限访问的数组,这样对应偏移处的内存就可以缓存到CPU cache里。

这样,虽然我们在第2行处拿到的数据不能直接看到,但是它的值对CPU cache已经造成了影响。

接下来可以利用CPU cache来间接拿到这个值。 我们以4096为单位依次访问user_probe_addr对应内存单位的前几个字节,并且测量这该次内存访问的时间,就可以观察到时间差异,如果访问时间短,那么可以推测访内存已经被cache,可以反推出示例代码中的index的值。

在这个例子里,之所以用4096字节做为访问单位是为了避免内存预读带来的影响,因为CPU在每次从主存访问内存的时候,根据局部性原理,有可能将邻将的内存也读进来。 Intel的开发手册上指明 CPU的内存预取不会跨页面,而每个页面的大小是4096。

Meltdown[3]论文中给出了他们所做实验的结果,引用如下:

据此,他们反推出index的值为84。

指令的执行

经典处理器架构使用五级流水线:取指(IF)、译码(ID)、执行(EX)、数据内存访问(MEM)和写回(WB)。

现代处理器在设计上都采用了超标量体系结构(Superscalar Architecture)和乱序执行(Out-of-Order)技术,极大地提高了处理器计算能力。超标量技术能够在一个时钟周期内执行多个指令,实现指令级的并行,有效提高了ILP(Instruction Level Parallelism)指令级的并行效率,同时也增加了整个cache和memory层次结构的实现难度。

在一个支持超标量和乱序执行技术的处理器中,一条指令的执行过程被分解为若干步骤。指令首先进入流水线(pipeline)的前端(Front-End),包括预取(fetch)和译码(decode),经过分发(dispatch)和调度(scheduler)后进入执行单元,最后提交执行结果。所有的指令采用顺序方式(In-Order)通过前端,并采用乱序的方式进行发射,然后乱序执行,最后用顺序方式提交结果。若是一条存储读写指令最终结果更新到LSQ(Load-Store Queue)部件。LSQ部件是指令流水线的一个执行部件,可以理解为存储子系统的最高层,其上接收来自CPU的存储器指令,其下连接着存储器子系统。其主要功能是将来自CPU的存储器请求发送到存储器子系统,并处理其下存储器子系统的应答数据和消息。

如上图所示,在x86微处理器经典架构中,指令从L1指令cache中读取指令,L1指令cache会做指令加载、指令预取、指令预解码,以及分支预测。然后进入Fetch & Decode单元,会把指令解码成macro-ops微操作指令,然后由Dispatch部件分发到Integer Unit或者Float Point Unit。Integer Unit由Integer Scheduler和Execution Unit组成,Execution Unit包含算术逻辑单元(arithmetic-logic unit,ALU)和地址生成单元(address generation unit,AGU),在ALU计算完成之后进入AGU,计算有效地址完毕后,将结果发送到LSQ部件。LSQ部件首先根据处理器系统要求的内存一致性(memory consistency)模型确定访问时序,另外LSQ还需要处理存储器指令间的依赖关系,最后LSQ需要准备L1 cache使用的地址,包括有效地址的计算和虚实地址转换,将地址发送到L1 Data Cache中。

乱序执行

刚才提到了现代的处理器为了提高性能,实现了乱序执行(Out-of-Order,OOO)技术。在古老的处理器设计中,指令在处理器内部执行是严格按照指令编程顺序的,这种处理器叫做顺序执行的处理器。在顺序执行的处理器中,当一条指令需要访问内存的时候,如果所需要的内存数据不在Cache中,那么需要去访问主存储器,访问主存储器的速度是很慢的,那么这时候顺序执行的处理器会停止流水线执行,在数据被读取进来之后,然后流水线才继续工作。这种工作方式大家都知道一定会很慢,因为后面的指令可能不需要等这个内存数据,也不依赖当前指令的结果,在等待的过程中可以先把它们放到流水线上去执行。所以这个有点像在火车站排队买票,正在买票的人发现钱包不见了,正在着急找钱,可是后面的人也必须停下来等,因为不能插队。

1967年Tomasulo提出了一系列的算法来实现指令的动态调整从而实现乱序执行,这个就是著名的Tomasulo算法。这个算法的核心是实现一个叫做寄存器重命名(Register Rename)来消除寄存器数据流之间依赖关系,从而实现指令的并行执行。它在乱序执行的流水线中有两个作用,一是消除指令之间的寄存器读后写相关(Write-after-Read,WAR)和写后写相关(Write-after-Write,WAW),二是当指令执行发生例外或者转移指令猜测错误而取消后面的指令时,可用来保证现场的精确。其思路为当一条指令写一个结果寄存器时不直接写到这个结果寄存器,而是先写到一个中间寄存器过渡,当这条指令提交时再写到结果寄存器中。

通常处理器实现了一个统一的保留站(reservation station),它允许处理器把已经执行的指令的结果保存到这里,然后在最后指令提交的时候会去做寄存器重命名来保证指令顺序的正确性。

如上图所示,经典的X86处理器中的“整数重命名”和“浮点重命名”部件(英文叫做reorder buffer,简称ROB),它会负责寄存器的分配、寄存器重命名以及指令丢弃(retiring)等作用。

x86的指令从L1 指令cache中预取之后,进入前端处理部分(Front-end),这里会做指令的分支预测和指令编码等工作,这里是顺序执行的(in-order)。指令译码的时候会把x86指令变成众多的微指令(uOPs),这些微指令会按照顺序发送到执行引擎(Execution Engine)。执行引擎这边开始乱序执行了。这些指令首先会进入到重命名缓存(ROB)里,然后ROB部件会把这些指令经由调度器单元发生到各个执行单元(Execution Unit,简称EU)里。假设有一条指令需要访问内存,这个EU单元就停止等待了,但是后面的指令不需要停顿下来等这条指令,因为ROB会把后面的指令发送给空闲的EU单元,这样就实现了乱序执行。

如果用高速公路要做比喻的话,多发射的处理器就像多车道一样,汽车不需要按照发车的顺序在高速公路上按顺序执行,它们可以随意超车。一个形象的比喻是,如果一个汽车抛锚了,后面的汽车不需要排队等候这辆汽车,可以超车。

在高速公里的终点设置了一个很大的停车场,所有的指令都必须在停车场里等候,然后停车场里有设置了一个出口,所有指令从这个出口出去的时候必须按照指令原本的顺序,并且指令在出口的时候必须进行写寄存器操作。这样从出口的角度看,指令就是按照原来的逻辑顺序一条一条出去并且写寄存器。

这样从处理器角度看,指令是顺序发车,乱序超车,顺序归队。那么这个停车场就是ROB,这个缓存机制可以称为保留站(reservation station),这种机制就是人们常说的乱序执行。

异常处理

CPU指令在执行的过程过有可能会产生异常,但是我们的处理器是支持乱序执行的,那么有可能异常指令后面的指令都已经执行了,那怎么办?

那么这时候ROB就要起到清道夫的作用了。从之前的介绍我们知道乱序执行的时候,要修改什么东西都通过中间的寄存器暂时记录着,等到在ROB排队出去的时候才真正提交修改,从而维护指令之间的顺序关系。那么当一条指令发生异常的时候,它就会带着异常宝剑来到ROB中排队。ROB按顺序把之前的正常的指令都提交发送出去,当看到这个带着异常宝剑的指令的时候,那么就启动应急预案,把出口封锁了,也就是异常指令和其后面的指令会被丢弃掉,不提交。

但是,为了保证程序执行的正确性,虽然异常指令后面的指令不会提交,可是由于乱序执行机制,后面的一些访存指令已经把物理内存数据预取到cache中了,这就给Meltdown漏洞留下来后面,虽然这些数据会最终被丢弃掉

地址空间

现代的处理器为了实现CPU的进程虚拟化,都采用了分页机制,分页机制保证了每个进程的地址空间的隔离性。分页机制也实现了虚拟地址到物理地址的转换,这个过程需要查询页表,页表可以是多级页表。那么这个页表除了实现虚拟地址到物理地址的转换之外还定义了访问属性,比如这个虚拟页面是只读的还是可写的还是可执行的还是只有特权用户才能访问等等权限。现在的处理器都有这样一个寄存器来存放页表的基地址,在ARM中叫做TTRB寄存,x86叫做CR3寄存器。

每个进程的虚拟地址空间都是一样的, 但是它映射的物理地址是不一样的,所以每一个进程都有自己的页表,在操作系统做进程切换的时候,会把下一个进程的页表的基地址填入到寄存器,从而实现进程地址空间的切换。以外,因为TLB里还缓存着上一个进程的地址映射关系,所以在切换进程的时候需要把TLB对应的部份也清除掉。

当进程在运行的时候不可避免地需要和内核交互,例如系统调用,硬件中断。当陷入到内核后,就需要去访问内核空间,为了避免这种切换带来的性能损失以及TLB刷新,现代OS的设计都把用户空间和内核空间的映射放到了同一张页表里。这两个空间有一个明显的分界线,在Linux Kernel的源码中对应PAGE_OFFSET。如下图所示:

虽然两者是在同一张页表里,但是他们对应的权限不一样,内核空间部份标记为仅在特权层可以访问,而用户空间部份在特权层与非特权层都可以访问。这样就完美地把用户空间和内核空间隔离开来:当进程跑在用户空间时只能访问用户空间的地址映射,而陷入到内核后就即可以访问内核空间也可以访问用户空间。

对应地,页表中的用户空间映射部份只包含本进程可以访问的物理内存映射,而任意的物理内存都有可能会被映射到内核空间部份。

漏洞分析

理解了上述的背景知识以后就可以来看Meltdown是怎么回事了。 我们再回过头看看上面的例子:

1  clflush for user_probe[]; // 把user_probe_addr对应的cache全部都flush掉

2 u8 index = *(u8 *) attacked _mem_addr; // attacked_mem_addr存放被攻击的地址

3  data = user_probe_addr[index * 4096]; // user_probe_addr存放攻击者可以放访问的基地址

如果attached_mem_addr位于内核,我们就可以利用它来读取内核空间的内容。

 

如果CPU顺序执行,在第2行就会发现它访问了一个没有权限地址,产生page fault (缺页异常),进而被内核捕获,第3行就没有机会运行。 不幸的是,CPU会乱序执行,在某些条件满足的情况下,它取得了attacked _mem_addr里的值,并在CPU将该指令标记为异常之前将它传递给了下一条指令,并且随后的指令利用它来触发了内存访问。 在指令提交的阶段CPU发现了异常,再将已经乱序执行指令的结果丢弃掉。 这样虽然没有对指令的正确性造成影响,但是乱序执行产生的CPU cache影响依然还是在那里,并能被利用。

该场景有个前置条件,该条件在Meltdown[3]的论文里没有被提到,但在cyber[1] 的文章指出,attached_mem_addr必须要已经被缓存到了 CPU L1,因为这样才会有可能在CPU将指令标记为异常之前指数据传给后续的指令。 并且cyber 指出,只有attacked_mem_addr已经被缓存到CPU L1 才有可能成功,在L2,L3均不行,其理由是:

“The L1 Cache is a so called VIPT or Virtually Indexed,Physically Tagged cache. This means the data can be looked up by directly using the virtual address of the load request”

“If the requested data was not found in the L1 cache the load must be passed down the cache hierarchy. This is the point where the page tables come into play. The page tables are used to translate the virtual address into a physical address. This is essentially how paging is enabled on x64. It is during this translation that privileges are checked”

这几点理由很值得商榷:

  • AMD的开发手册[10]没有找到L1 cache是VIPT的证据,Intel的开发手册[9]上只能从” L1 Data Cache Context Mode”的描述上推测NetBurst架构的L1 cache是VIPT的。
  • 就算L1 cache 是 VIPT,那也需要获得physical address,必然会用到TLB里的内容或者进行页表的遍历。

那么如何来将要被攻击的内存缓存到L1里呢? 有两种方法:

  • 利用系统调用进入内核。 如果该系统调用的路径访问到了该内存,那么很有可能会将该内存缓存到L1 (在footprint不大于L1大小的情况下)。
  • 其次是利用prefetch指令。 有研究[8]显示,Intel的prefetch指令会完全忽略权限检查,将数据读到cache。

我们知道,如果进程触发了不可修复的page fault,内核会向其发送SIGSEGV信号,阻止其往下继续执行。 所以这里有两种操作方法,其一是创建一个子进程,在子进程中触发上述的代码访问,然后在父进程中去测算user_probe_addr[]的访问时间。 所以每一次探测都需要另起一个新进程,这样会影响效率。

另一种方法是利用Intel的事务内存处理(Intel® Transactional Synchronization Extensions),该机制以事务为单元来对一系列内存操作做原子操作,如果一个事务内的内存操作全部成功完成且没有其它CPU造成内存的竞争,那么就会将该事务对应的结果进行提交,否则将中断该事务。 如果我们将上述代码包含到一个内存事务中,对被攻击地址的访问并不会造成page fault,只会被打断事务。 这样我们可以在不需要生成子进程的条件下持续进行攻击。

这里有一个很有意思的现象,上述代码在第2行处读到的index有时会全为0,不同的资料有不同的解释:

  • cyber[1]给出的解释是: “Fortunately I did not get a slow read suggesting that Intel null’s the result when the access is not allowed”

google zero project[2]给出的解释是: “That (read from kernel address returns all-zeroes) seems to happen for memory that is not sufficiently cached but for which pagetable entries are present, at least after repeated read attempts. For unmapped memory, the kernel address read does not return a result at all.”

  • Meltdown paper[3]给出的解释是: “If the exception is triggered while trying to read from an inaccessible kernel address,the register where the data should be stored,appears to be zeroed out. This is reasonable because if the exception is unhandled,the user space application is terminated,and the value from the inaccessible kernel address could be observed in the register contents stored in the core dump of the crashed process. The direct solution to fix this problem is to zero out the corresponding registers. If the zeroing out of the register is faster than the execution of the sub- sequent instruction (line 5 in Listing 2),the attacker may read a false value in the third step”

这个解释比较有意思,他们首先认为,将读到的内容清零是有必要的,否则读到的内容会保存到这个程序的core dump里。 果真会如此么? Terminate 进程和生产core dump都需要OS去做,软件不可能直接访问到乱序执行所访问的寄存器。

其次他们认为,该问到0是因为值传递给下一条指令的速度要慢于将值清0的操作,所以他们的解决方法是:

“prevent the tran- sient instruction sequence from continuing with a wrong value,i.e.,‘0’,Meltdown retries reading the address until it encounters a value different from ‘0’”,所以他们的示例代码是长这样的:

他们在读到0时现再重试。 然而,如果真的读到清0的数据,retry并不会有机会再被执行到,因为此时很有可能异常已经被捕获。

漏洞修复

在漏洞被报告给相关厂商后,各OS和开源社区开始了修复工作,Linux Kernel采用的是Kernel page-table isolation (KPTI)[6][7],据说Windows和Mac OS的修复也是类似的思路。

在前面的背景知识中看到,当前的OS采用用户空间和内核空间分段的设计,这样使得Kernel和Usersapce使用同一张页表,位于同一个TLB context中,所以CPU在做预取和乱序的时候可以使用TLB中的cache做地址转换,进而获得CPU Cache中的数据,如果我们能够让用户空间不能使用TLB中关于内核地址映射的信息,这样就可以断掉用户空间对Cache中kernel数据的访问。 这也是KPTI的思路。

KPTI将之前OS设计中,每个进程使用一张页表分隔成了两张,kernelspace和userspace使用各自分离的页表。 我们暂且称进程在kernel模式使用的页表称为Kernel页表,相应地进程在用户空间使用的页表被称为用户页表。

具体地来说,当进程运行在用户空间时,使用的是用户页表,当发生中断或者是异常时,需要陷入到内核,进入内核空间后,有一小段内核跳板将页表切换到内核页表,当进程从kernel空间跳回到用户空间时,页表再次被切换回用户页表。

Kernel页表包含了进程用户空间地址的映射和Kernel使用的内存映射,所以Kernel依然可以使用当前进程的内存映射。 用户页表仅仅包含了用户空间的内存映射以及内核跳板的内存映射。

性能影响

从这里可以看到,每一次用户空间到内核空间的切换都需要切换页表,在没有PCID支持的CPU上,切换页表 (reload CR3) 会flush除了global page以外的所有TLB。 然而,即使在支持PCID的情况下,由于引入的中间跳板,性能损失仍然存在。 一位开发者指出在有PCID支持的情况下,通常的workload会有5%的性能损失,某些workload会达到30%。

总结

Meltdown并不需要利用已有的软件缺陷,仅仅只需攻击者和受害者在只有一个地址空间中就会有影响,比如基于container的Docker,Xen的PV guest,等等。 基于硬件虚拟化的VM并不会受其影响。 然而情况不容乐观,接下来要分析的Spectre具有更大范围的破坏力。

虽然这篇文章是以cache为例来描述攻击,但是对体系体构可观察到的影响都可以拿来作为攻击的手段,比如诱发CPU算术单元的繁忙运算后再来观察某条算术指令执行的时间,再如观察不同情况下的电力消耗,等等。

这一次漏洞的影响之大足以被写进教科书,甚至会影响接下来所有硬件和OS的设计,2018年或许是OS,Hardware,Security的新起点。

 

参考资料

[1] Xiao Guangrong: Linux Kernel, KVM/QEMU开发者和维护者

[2] 佚名: 隐世侠客,世人只能在他流传下来的一本武功秘籍中瞻仰其风采

Guest Debug Emulation on KVM

1) 前言

breakpoint和watchpoint对软件开发人员来说都是非常熟悉的词了,它们是调试器的最基本的功能之一。breakpoint用来在指定的程序中设置一个断点,如果程序运行到这个点将会被暂停且会被调试程序(例如gdb)捕捉到。watchpoint用于监视程序对指定内存的更改,如果内存的更改满足捕捉条件,程序将会被暂停并且被调试程序捕捉到。

breakpoint和watchpoint又分为hardware breakpoint, hardware watchpoint和software breakpoint, software watchpoint。前者完全利用硬件提供的debug功能来实现,后者顾名思义,就是软件自己实现这些功能而不依赖于硬件。硬件实现的debug性能会很高但是有自己的局限性,例如在x86平台上,硬件只支持最多4条breakpoint + watchpoint。软件自己实现的debug功能更灵活但性能会很低,例如在x86上, software breakpoint是在断点处插入软中断(int 3指令),software watchpoint 利用single step来实现watchpoint,以至于程序在每运行一条指令后都会去check被监视的内存。

Hardware debug功能在虚拟环境中的模拟很复杂因为virtualization的所有部件都可以使用这个功能,例如guest利用它来调试自己内部的程序,同时在host上面也要用它来调试hypervisor。更为复杂的是,现在hypervisor支持guest debug的功能,即在host上支持对guest进行debug(例如在host上直接调试guest kernel)。如何虚拟hardware debug功能用来同时满足这三种情况将是本文要讨论的主要问题。

2) 硬件背景

在分析hardware debug虚拟化之前,有必要来看一下硬件对debug的支持。以下是x86 64位环境中为debug功能所提供的寄存器:
cpu-debug-arch
在参考Intel 的spec时,需要注意到的是,breakpoint和watchpoint都被统称为breakpoint,在后文中也沿用这一习惯。在图中总共有7个寄存器,其中DR4和DR5不能被软件直接使用,任何指令使用到这两个寄存器将会产生#UD(invalid-opcode exceptions)异常。

DR0~DR3这四个寄存器用来设定要被debug对象(例如断点,内存)的虚拟地址。DR7用来控制DR0~DR3这四个寄存器。它有两个重要的作用,分别如下:

  • 用来控制DR0~DR3的enable和disable。
    DR0~DR3有两种使用模式,分别为local breakpoint和global breakpoint,前者只是用于对当前的进程debug,后者用于对系统的所有进程进行debug,所以硬件进行task switch的时候,如果DR0~DR3是处于local breakpoint,内容将会被清除,而处于global的DR0~DR3的内容将会被保留。DR7有两个位LE (local breakpoint exact enable), GE (global breakpoint exact enable)用于控制全局的local/global breakpoint的使能。而L0~L3分别用来控制DR0~DR3是否处于local模式,类似的G0~G3用于控制DR0~DR3是否处于global 模式。
  • 用来控制DR0~DR3将如何使用。
    RW0~RW3用来指定DR0~DR3的debug条件。有这几种情况:00b表示只debug 指令执行(即为前面所说的breakpoint),01b表示只debug数据写的情况,11b表示debug数据读和写的情况,10b表示只debug IO的读写(需要设置CR4.DE=1)。后面的三种情况对应于上文说的watchpoint。LEN0~LEN3用来表示数据区域的大小,有这几点情况,00b表示1个字节,01b表示2个字节,10b为8个字节(在32位CPU上为非法设置),11b表示四个字节。在这里需要注意的是,如果是debug 指令执行,即RWx位 = 00b时,它的长度必须要为1字节,即00b。另外还有一个位被称为GD(general detect enable),它用于控制对这些debug 寄存器的更改,如果有指令试图更改debug register,且GD=1, 就会触发#DB (debug exception)。

如果CPU有检测到满足的debug事件则会产生#DB(debug exception)。

DR6是一个状态寄存器,用于检测是哪一个事件触发的#DB。其中每个位表示的含义如下:

  • B0~B1用来表示是DR0~DR3中的哪一个寄存器的条件触发了#DB。
  • BD用来判断是该#DB是否是由于更改debug register产生。
  • BS表示该#DB是否由single step产生。single step在前文中提起过,简而言之,single step是指CPU每执行完一条指令后都会产生#DB。它的使能由RFLAGS寄存器的TF位所控制。
  • BT表示该#DB是否由进程切换所导致。(它的使能由TSS.T所控制)。

此外,在前文中曾提到int 3会用来实现software breakpoint。在CPU执行int 3后会产生#BP (breakpoint exception)。

这些就是CPU对debug功能的支持,同时CPU也有针对虚拟化的情况下做一些enhancement,主要有如下几点:

  • CPU对DR7的模拟。从上面的介绍中可以看到,DR7是一个非常关键的寄存器,它用于配置debug的条件。同时更改DR7也是一个消耗CPU时间较多的一个操作。所以CPU支持guest DR7的自动保存和恢复。具体来说,在进入到Guest的时候,自动将VMCS的DR7加载到DR7寄存器,在Guest退出的时候,自动将DR7的内容保存到VMCS的DR7。
  • CPU对DR寄存器访问的捕捉。因为只有DR7可以被自动加载和保存,CPU可以控制是否捕捉对DR寄存器的访问。
  • 因为CPU debug功能会用到两个异常,分别为#DB和 #BP,CPU可以控制是否要捕捉这两个异常。

3)KVM对debug的模拟

到这里,已经介绍完了所有的硬件背景,是时候来看看KVM如何对debug进行模拟了。对debug的模拟无非就是要模拟这两种情况,其一是对DR寄存器的正确模拟,使guest能够正常访问到这些寄存器且这些寄存器中的值需要全部正确。其二是必须要能够debug设置好的条件且在检测到这些条件后guest可以接收到#DB和#BP。

在上文中曾提到使用debug有三种情况,它们分别是guest中使用debug来调试其本身的程序,host上使用debug来调试hypervisor(例如QEMU)和hypervisor上直接调试guest。前二种情况属于常见的情况因此很容易理解。后一种情况是由虚拟环境引入的特例,因此需要hypervisor的支持,需要花一点时间来看看在KVM上如何对这一情况做支持。

3.1)KVM debug guest

为了支持在hypervisor上debug guest, KVM引入了一个新的API,即KVM_SET_GUEST_DEBUG, 它用于enable guest debug的一些功能以及用于设置debug寄存器。它所携带的参数如下:

struct kvm_guest_debug {
__u32 control;
__u32 pad;
struct kvm_guest_debug_arch arch;
};

control用来控制debug的相关功能,它的功能如下:
KVM_GUESTDBG_ENABLE: 用来enable guest debug
KVM_GUESTDBG_SINGLESTEP: 用来enable guest的single-step。此后guest每执行完一条执行都会被退回到hypervisor。
这两个功能在KVM的全平台上都可以使用,在x86上还有以下几个功能:
KVM_GUESTDBG_USE_SW_BP: 使用software breakpoint来debug guest。
KVM_GUESTDBG_USE_HW_BP: 使用hardware breakpoint来debug guest。
KVM_GUESTDBG_INJECT_DB: 往guest注入#DB。
KVM_GUESTDBG_INJECT_BP: 往guest注入#BP。

arch的部份用来表示平台上的debug registers,在x86上为DR0 ~ DR7。

同样,KVM遇到guest debug的事件后会退回到hypervisor,它的退出状态为KVM_EXIT_DEBUG,在exit-info中也有一些域来表示这些事件的信息。如下所示:

struct kvm_debug_exit_arch {
__u32 exception;
__u32 pad;
__u64 pc;
__u64 dr6;
__u64 dr7;
};

里面填充的信息包括导致退回到userspace的异常(#DB or #BP),引起异常的地址,以及异常发生时DR6和DR7这两个寄存器的值。

接下来,按debug使用的各种场景以及各场景下的debug registers访问和debug功能实现,由简入繁地来分析在KVM中是如何虚拟debug的功能的。

3.2)guest中使用debug

这个是一种最基本的情况,guest中的调试器使用debug功能来调试程序。在分析这种情况时,我们需要有这样的一个概念,即hypervisor随时都可能被gdb/perf等调试器所动态调试,这些调试器会给hypervisor发送IPI,然后在IPI的handler里设置debug registers。所以vcpu在enable interrupt的时候,debug registers随时都会被更改,guest需要在安全的情况下(disable interrupt)保存好自己的debug registers信息。

3.2.1) debug registers的访问

因为debug registers随时都会被调试程序所用到,所以一种最简单的实现方式是由guest完全控制debug 功能,因此在进入到guest之前,将guest的DR0~DR6加载到寄存器(DR7由VMCS控制),在guest退出后,将guest的DR0 ~DR6保存起来。这样也不需要捕捉guest对DR registers的访问。

嗯,看上去是可以work,但是却不能这么做。因为这种做法需要在每一次进入到guest以及每次退出guest的时候都需要去恢复和保存debug registers的状态,即使在guest没有使用到debug功能的时候也如此,这样做无疑会给guest vm-enter和vm-exit造成性能上的开销。

那么退而求其次,可以让KVM每次都去捕获debug registers的访问,在写的时候将其保存起来,在读的时候将保存的值返回。这样就可以完全避免在vm-enter和vm-exit时对它们的操作。嗯,这种做法较上一种有了很大的改进,实际上旧版本的KVM上就是这样处理的。但是它的问题是,guest每次访问debug registers的开销会非常大而且调试程序通常会一次访问多个debug registers (例如在设置hardware breakpoint时,就先要将它放入DR0~DR3中的一个,然后再来配置DR7)。所以这样做也不是一种最好的做法。

所以,KVM演变成了今天的做法,即guest在第一次访问DR register的时候,被KVM捕获而退出,在处理退出时,KVM为这种情况设置一个标志且去掉DR register的捕获。当vm-enter的时候,如果检测到这个标志被设置,就会将guest的DR registers恢复,这样就返回到了guest, guest在后续访问DR registers的时候就不会再次被KVM捕获了。这样一直持续到下一次vm-exit (由其它任意事件产生),这时KVM会清除这个标志, 将guest DR register保存起来,最后重新enable 访问DR registers的捕获。因为标志被清除,所以在vm-enter的时候就不需要再次恢复了。下面这个图更清楚地解释了这一个过程。
handle-dr
上面列出了几个关键的点,我们来看看在KVM的代码中是如何处理的。

处理DR-access导致的VM-exit

在arch/x86/kvm/vmx.c的handle_dr()函数中:

5621         if (vcpu->guest_debug == 0) {
5622                 u32 cpu_based_vm_exec_control;
5623
5624                 cpu_based_vm_exec_control = vmcs_read32(CPU_BASED_VM_EXEC_CONTROL);
5625                 cpu_based_vm_exec_control &= ~CPU_BASED_MOV_DR_EXITING;
5626                 vmcs_write32(CPU_BASED_VM_EXEC_CONTROL, cpu_based_vm_exec_control);
5627
5628                 /*
5629                  * No more DR vmexits; force a reload of the debug registers
5630                  * and reenter on this instruction.  The next vmexit will
5631                  * retrieve the full state of the debug registers.
5632                  */
5633                 vcpu->arch.switch_db_regs |= KVM_DEBUGREG_WONT_EXIT;
5634                 return 1;
5635         }

 

在后续的分析可以看到vcpu→guest_debug表示vcpu是否被kvm debug。在当前分析的情况下,它不满足。5624~5626设置不要捕获DR-access,并且在5633行设置了KVM_DEBUGREG_WONT_EXIT这个flag。

在VM-enter的时候,我们可以看到,在arch/x86/kvm/x86.c的vcpu_enter_guest()函数中:

6601         if (unlikely(vcpu->arch.switch_db_regs)) {
6602                 set_debugreg(0, 7);
6603                 set_debugreg(vcpu->arch.eff_db[0], 0);
6604                 set_debugreg(vcpu->arch.eff_db[1], 1);
6605                 set_debugreg(vcpu->arch.eff_db[2], 2);
6606                 set_debugreg(vcpu->arch.eff_db[3], 3);
6607                 set_debugreg(vcpu->arch.dr6, 6);
6608                 vcpu->arch.switch_db_regs &= ~KVM_DEBUGREG_RELOAD;
6609         }
6610
6611         kvm_x86_ops->run(vcpu);

 

因为在退出的时候在 switch_db_regs设置了 KVM_DEBUGREG_WONT_EXIT标志,所以6601行的条件可以满足。虽然DR7是由guest自己维护的,但在6602行还是将它清零了,这是因为在6603~6607行要将guest的debug registers恢复过来,这样就会debug 到错误的地址。当guest再一次退出的时候,在同一个函数中可以看到:

6619         if (unlikely(vcpu->arch.switch_db_regs & KVM_DEBUGREG_WONT_EXIT)) {
6620                 int i;
6621
6622                 WARN_ON(vcpu->guest_debug & KVM_GUESTDBG_USE_HW_BP);
6623                 kvm_x86_ops->sync_dirty_debug_regs(vcpu);
6624                 for (i = 0; i < KVM_NR_DB_REGS; i++)
6625                         vcpu->arch.eff_db[i] = vcpu->arch.db[i];
6626         }

Intel平台的 sync_dirty_debug_regs() callback是位于arch/x86/kvm/vmx.c 的vmx_sync_dirty_debug_regs()函数,代码如下:

 5661 static void vmx_sync_dirty_debug_regs(struct kvm_vcpu *vcpu)
5662 {
5663         u32 cpu_based_vm_exec_control;
5664
5665         get_debugreg(vcpu->arch.db[0], 0);
5666         get_debugreg(vcpu->arch.db[1], 1);
5667         get_debugreg(vcpu->arch.db[2], 2);
5668         get_debugreg(vcpu->arch.db[3], 3);
5669         get_debugreg(vcpu->arch.dr6, 6);
5670         vcpu->arch.dr7 = vmcs_readl(GUEST_DR7);
5671
5672         vcpu->arch.switch_db_regs &= ~KVM_DEBUGREG_WONT_EXIT;
5673
5674         cpu_based_vm_exec_control = vmcs_read32(CPU_BASED_VM_EXEC_CONTROL);
5675         cpu_based_vm_exec_control |= CPU_BASED_MOV_DR_EXITING;
5676         vmcs_write32(CPU_BASED_VM_EXEC_CONTROL, cpu_based_vm_exec_control);
5677 }

5665~ 5670行将guest的debug registers保存起来,并在5672清除了标志,最后5674~ 5676行设置捕捉DR-access。

好了,到这里已经看完了debug registers访问的模拟,再下来如何实现debug的功能。

3.2.2)debug功能的实现

如果guest没有enable debug register (在DR7中没有设置相关的enable位),只需要满足模拟access debug registers就可以了,否则,就必须要把guest的debug寄存器信息加载到硬件。所以,在设置DR7的时候有这样的处理, 在arch/x86/kvm/x86.c中:

 839 static void kvm_update_dr7(struct kvm_vcpu *vcpu)
840 {
841         unsigned long dr7;
842
843         if (vcpu->guest_debug & KVM_GUESTDBG_USE_HW_BP)
844                 dr7 = vcpu->arch.guest_debug_dr7;
845         else
846                 dr7 = vcpu->arch.dr7;
847         kvm_x86_ops->set_dr7(vcpu, dr7);
848         vcpu->arch.switch_db_regs &= ~KVM_DEBUGREG_BP_ENABLED;
849         if (dr7 & DR7_BP_EN_MASK)
850                 vcpu->arch.switch_db_regs |= KVM_DEBUGREG_BP_ENABLED;
851 }

在850行可以看到,如果debug register被enable, 就会往switch_db_regs中设置 KVM_DEBUGREG_BP_ENABLED标志。

我们回过头看看前面列出的代码,在cpu_enter_guest()的6601行,如果switch_db_regs不为零,则会恢复guest的DR registers。就这样,如果guest有enable debug。在每次进入到guest的时候都会去将环境恢复过来。

在这里有一个问题,只有第一次在访问debug register的时候才会有VM-exit,如果update DR7是在随后的指令中才执行的,那么KVM就捕获不到DR7的更新也就失去了更新 KVM_DEBUGREG_BP_ENABLED的时机。这是当前KVM的bug, 修正的patch在这里:
https://lkml.org/lkml/2016/2/26/420
修正的方法很简单,就是在没有捕获DR-access的情况下,vm-exit时都去check DR7是否有enable debug registers。

同时,debug功能需要模拟#DB和#BP,让guest自己去处理这两个异常就好了。

3.3)guest和hyperviosr同时使用debug

现在来看第二种情况,如果hypervisor和guest同时使用了debug功能。在这种情况下,guest和hypervisor是相互独立的,因此只需要在运行在非guest模式时,把debug恢复到hypervisor的状态就好了。所以,如果hypervisor有enable debug, 那么每次在vm-exit后就把debug register的值恢复过来。

在arch/x86/kvm/x86.c的vcpu_enter_guest()函数中,有这样的处理:

6628         /*
6629          * If the guest has used debug registers, at least dr7
6630          * will be disabled while returning to the host.
6631          * If we don’t have active breakpoints in the host, we don’t
6632          * care about the messed up debug address registers. But if
6633          * we have some of them active, restore the old state.
6634          */
6635         if (hw_breakpoint_active())
6636                 hw_breakpoint_restore();

当前进程的debug registers信息保存在它运行的CPU的per-cpu变量里,因此KVM不需要去保存这些信息,直接从per-cpu变量中恢复即可。
在6635行check当前进程是否被debug,如果有,那么在6636行将其恢复过来。在每次vm-enter的时候,都会用VMCS中的DR7更新成guest的DR7,这就是在vm-enter和vm-exit的代码中没有显式地处理guest DR7的原因。

同样在这种情况下,让guest自己去handle #DB和#BP就好了。

3.4) KVM debugs guest

这种情况有一点复杂,这时KVM使用debug功能去对guest进行调试,也就是说guest在运行时,加载到硬件debug registers的值是由KVM来控制的。这时,不应该去应用 guest对debug registers的设置,但是需要满足guest对debug registers的访问。

3.4.1)debug registers的访问

因为guest不能更改硬件上的寄存器,所有guest对debug registers的更改都需要被截获并且存放到另一个区域。为了处理这种情况,KVM有两个保存debug registers的地方,一个用来存放guest对debug registers的设置,姑且称这个地方为guest-debug-storage, 另一个地方用来存放KVM设置的debug信息,称之为km-debug-storage。guest对debug registers的读取和更改都发生在guest-debug-storage中,而hypervisor配置下来的debug寄存器信息都会存放在km-debug-storage并且它最终会被加载到硬件寄存器里。

来看看KVM的代码是如何处理的。首先,在guest 访问debug registers的时候,都会被捕捉,相应处理的代码位于arch/x86/kvm/vmx.c的handle_dr()中:

 5621         if (vcpu->guest_debug == 0) {
……
5635         }
5636
5637         reg = DEBUG_REG_ACCESS_REG(exit_qualification);
5638         if (exit_qualification & TYPE_MOV_FROM_DR) {
5639                 unsigned long val;
5640
5641                 if (kvm_get_dr(vcpu, dr, &val))
5642                         return 1;
5643                 kvm_register_write(vcpu, reg, val);
5644         } else
5645                 if (kvm_set_dr(vcpu, dr, kvm_register_readl(vcpu, reg)))
5646                         return 1;
5647
5648         skip_emulated_instruction(vcpu);
5649         return 1;

5621行的分支我们在前面分析第一种情况的时候已经看过了。因为现在是kvm debugs guest的情况,因此该分支不满足,会执行5637行的操作。5638行对应的是读DR register的操作,另一个分支是写DR的操作。可以看到guest读写DR register是能过kvm_get_dr()和kvm_set_dr()来完成的。
kvm_get_dr()的代码如下:

 901 int kvm_get_dr(struct kvm_vcpu *vcpu, int dr, unsigned long *val)
902 {
903         switch (dr) {
904         case 0 … 3:
905                 *val = vcpu->arch.db[dr];
906                 break;
907         case 4:
908                 /* fall through */
909         case 6:
910                 if (vcpu->guest_debug & KVM_GUESTDBG_USE_HW_BP)
911                         *val = vcpu->arch.dr6;
912                 else
913                         *val = kvm_x86_ops->get_dr6(vcpu);
914                 break;
915         case 5:
916                 /* fall through */
917         default: /* 7 */
918                 *val = vcpu->arch.dr7;
919                 break;
920         }
921         return 0;
922 }

看到这里就明白了,guest-debug-storage就是vcpu→arch.db[0,1,2,3],vcpu→arch.dr6和vcpu→arch.dr7。可以通过kvm_set_dr()再次验证一下,guest对debug的更改也会被反映到这些区域当中。

需要注意到的是在这种情况下handle_dr()并没有disable对DR-access的捕获,因此每一次guest访问debug registers都会产生vm-exit。

接下来看一下从hypervisor下来的debug guest信息是怎么样处理的。KVM_SET_GUEST_DEBUG这个API最终会被kvm_arch_vcpu_ioctl_set_guest_debug()所处理。代码如下:

7181         if (vcpu->guest_debug & KVM_GUESTDBG_USE_HW_BP) {
7182                 for (i = 0; i < KVM_NR_DB_REGS; ++i)
7183                         vcpu->arch.eff_db[i] = dbg->arch.debugreg[i];
7184                 vcpu->arch.guest_debug_dr7 = dbg->arch.debugreg[7];
7185         } else {
7186                 for (i = 0; i < KVM_NR_DB_REGS; i++)
7187                         vcpu->arch.eff_db[i] = vcpu->arch.db[i];
7188         }
7189         kvm_update_dr7(vcpu);

7181行用来处理hypervisor用hardware breakpoint来debug guest的情况。可以看到,从hypervisor下来的debug配置信息都会存放到 vcpu->arch.eff_db[0,1,2,3]和 vcpu->arch.guest_debug_dr7中。可能有人会有疑问,为什么没有对DR6的设置呢?这是因为DR6只是个状态寄存器,不会影响到debug功能。
至此可以看到,km-debug-storage是 vcpu->arch.eff_db[]和 vcpu->arch.guest_debug_dr7。

所以,guest对debug registers的操作与kvm debugs guest的操作互相不影响。接下来看看如何去实现debug功能的。

3.4.2)debug功能的现实

这要从上面所列代码(kvm_arch_vcpu_ioctl_set_guest_debug()函数)的7189行说起。即在KVM_SET_GUEST_DEBUG处理的最后,会调用kvm_update_dr7()来更新DR7。事实上这个函数在前面已经列出来过。在这里再回头来看看:

 839 static void kvm_update_dr7(struct kvm_vcpu *vcpu)
840 {
841         unsigned long dr7;
842
843         if (vcpu->guest_debug & KVM_GUESTDBG_USE_HW_BP)
844                 dr7 = vcpu->arch.guest_debug_dr7;
845         else
846                 dr7 = vcpu->arch.dr7;
847         kvm_x86_ops->set_dr7(vcpu, dr7);
848         vcpu->arch.switch_db_regs &= ~KVM_DEBUGREG_BP_ENABLED;
849         if (dr7 & DR7_BP_EN_MASK)
850                 vcpu->arch.switch_db_regs |= KVM_DEBUGREG_BP_ENABLED;
851 }

在843行可以看到,用来更新到DR7寄存器的值是来自于vcpu→arch.guest_debug_dr7, 也就是位于km-debug-storage中的值。并行在849~850可以看到,如果kvm有enable debug guest, 会将 switch_db_regs上的 KVM_DEBUGREG_BP_ENABLED位设置。根据前面分析可得知,在每次vm-enter时都会去reload guest debug register。再把相关的代码拿出来看看。

在vcpu_enter_guest()函数中:

6601         if (unlikely(vcpu->arch.switch_db_regs)) {
6602                 set_debugreg(0, 7);
6603                 set_debugreg(vcpu->arch.eff_db[0], 0);
6604                 set_debugreg(vcpu->arch.eff_db[1], 1);
6605                 set_debugreg(vcpu->arch.eff_db[2], 2);
6606                 set_debugreg(vcpu->arch.eff_db[3], 3);
6607                 set_debugreg(vcpu->arch.dr6, 6);
6608                 vcpu->arch.switch_db_regs &= ~KVM_DEBUGREG_RELOAD;
6609         }

所以,加载到硬件寄存器的都是从km-debug-storage中来的。注意DR7在上面列出的 kvm_update_dr7()函数中已经更新到了VMCS的dr7里面(通过kvm_x86_ops->set_dr7())。

对于#DB的处理就比较简单了,在这种情况下,只需要将#DB返回给hypervisor即可。

3.5)KVM_GUESTDBG_USE_SW_BP的实现

从前面的分析可以看到,KVM_SET_GUEST_DEBUG还可以控制KVM_GUESTDBG_USE_SW_BP。它的实现就比较简单了,只需要设置捕捉#BP(breakpoint exception), 并且在捕捉到#BP后将它返回给hypervisor就可以了。

3.6)KVM_GUESTDBG_SINGLESTEP的实现

同理,KVM_SET_GUEST_DEBUG也可以enable KVM_GUESTDBG_SINGLESTEP,它的实现也很简单,只需要在guest的RFLAGS寄存器一直设置TF位就可以了。就样进入guest后,guest每执行一条指令都会产生#BP,KVM再把#BP转发到hypervisor。

这种情况下,有一个side-effect就是,guest自己设置的TF位会被丢失,那就是说,如果guest自己设置了TF位后,再去读RFLAGS寄存器后会发现TF被莫名丢失了。

4)小结

本文分析了guest debug功能在KVM上的实现,因为debug registers的使用场景非常的复杂,所以其中用较多的篇幅来分析guest的debug registers以及它的functionality是如何在KVM上面实现的。这部份代码比较晦涩难懂,希望本文可以起一个提纲挈领的作用。

vhost and vhost-user

virtio是一种我们耳熟能详的IO半虚拟化方案,它是guest和host所约定的一种协议用来减少guest IO时的VM-Exit(guest和host的上下文切换)并且使guest和host能并行处理IO来提高throughput和减少latency。

通常来说,virtio的数据都会在guest和hypervisor 间转发, 这就导致了在数据交换的时候有多次的上文切换。例如guest在发包到外部网络的情况下,首先guest需要切换到host kernel,然后host kernel会切换到hyperisor来处理guest的请求,hypervisor通过系统调用将数据包发送到外部网络后切换回host kernel,然后再切换回guest。这样漫长的路径无疑会带来性能上的损失。vhost就在这样的背景下产生了。它是位于host kernel的一个模块,用于和guest直接通信,所以数据交换就在guest和host kernel间进行了。

openvswitch和与之类似的SDN的解决方案可以用来更好地管理云计算资源,在这种情况下,guest就需要和中心的vswitch(它是host上的一个进程)进行数据交换,此时vhost就无能为力了。vhost-user在这种情况下应运而生,它和vhost的设计非常相似,它允许guest和vswitch间直接数据转发。

这个slides是我在team内部sharing的一个简缩版本,用来解释virtio的vring的操作,以及vhost和vhost-user的架构。

vhost and vhost-user

Look Into Userfaultfd

1)    前言

userfaultfd是Linux Kernel在v4.3中加入的新功能。本文的分析将包含它的背景,使用场景以及内部实现。

在分析userfaultfd之前,需要来了解一下什么叫page fault。Page fault也被称为缺页异常。如果CPU不能进行正常的内存访问,如地址没有映射再如访问的权限不够,就会触发这个异常。该异常在触发后,CPU会跳转到内核自定义的入口,然后由内核来修正这个异常,在修正完page fault以后,CPU会重新执行引发该异常的指令。一个经典的应用场景就是memory swap,它把内存的内容交换到存储设备然后该内存可以变为空闲来满足其它的请求,在以后需要用到这部份数据的时候就会触发page fault,内核会分配内存然后从存储设备上把内容读回来。

userfaultfd简单地来说就是通过文件描述符(fd)的机制来将page fault的信息传递给userspace,然后由userspace来决定要往这个地址写的内容。更简单一点就是,传统的page fault由内核独自完成,现在改为由内核和userspace一起控制。

2)    Userfaultfd的背景

Userfaultfd是为了解决QEMU/KVM live migration的问题而出现的,所谓Live migration就是将guest从一端迁移到另一端,而在迁移的过程中guest能够继续提供服务。当前QEMU/KVM Live migration采用的方案是先将guest的内存迁移到对端然后再来检查在迁移的过程中是否有页面发生更改(也称为脏页),如果有,再把脏页传到对端,一直重复这个过程直到没有脏页或者是脏页的数目足够少,脏页全部迁移过去之后就可以把源端的guest关闭掉,然后启动目的端的guest。这种实现有个术语叫precopy,即在目地端的guest运行前,所有的东西都已经copy过去了。

另一种与之相对的方案就是postcopy,即先把目地端的guest跑起来,当guest运行过程中需要访问尚末迁移的内存的时候才把内存从源端读过来。

precopy和postcopy各有自己的优缺点,如precopy较postcopy有较高的吐吞,而postcopy可以在guest workload较高的情况下能够较快地完全迁移的工作。

Userfaultfd就是为了postcopy而准备的,当guest在目地端运行的时候,目的端的kernel不可能知道要往页面里面填充的内容,它需要用户空间的程序去把内容从远端读过来,然后再把这些内容放到guest的内存中。

3)    Userfaultfd API

幸运的是,我们在使用userfaultfd的时候不需要了解诸如precopy/postcopy这样复杂的场景,内核提供了简单的API来供用户空间使用。

3.1) userfaultfd的流程

在分析API之前,我们先来看一下userfaultfd的使用流程,本文末尾也附带了简单的代码来演示userfaultfd,分了方便分析,暂且将userfaultfd的处理分为这几个阶段。

3.1.1) 准备阶段

userfaultfd

如上图所示,该阶段主要是做一些初始化的工作。所有userfaultfd的操作都是基于userfaultfd系统调用所创建的fd,一些配置的操作采用ioctl的方式来完成。UFFDIO_API用来做API的检测,它为了不同版本间的用户空间application和内核的兼容而存在。最终通过UFFDIO_REGISTER注册了一段region,以用来告诉内核该region中所发生的page fault需要userspace的协助。

3.1.2) region访问阶段

userfaultfd2

在这个阶段,程序访问了注册的region,产生page fault进而被kernel所捕获到,在内核的page fault处理函数中,根据fault的地址以及访问的类型生成一条message放入message pool中,然后把自己睡眠以等待用户空间的处理。这个阶段并未涉及到API的调用。

3.1.3) fault的处理阶段

userfaultfd3

这个阶段是由userspace来处理page fault的过程。在userfaultfd system call返回的fd上进行poll()可用来测检message pool里是否有待处理的message。Read()可以将该message读出来,用户空间可以解析读到的message得到fault的信息,比如fault地址,fault类型等等。根据这些信息,用户可空可以调用UFFDIO_COPY或者UFFDIO_ZEROPAGE来往fault address的区域进行填充,该ioctl带有一个参数可用来控制填充完后是否立即唤醒handler。如果是使用batch wakeup的方式,可在batch copy之后单独调用一次UFFDIO_WAKE。

Handler wakeup之后,会继续修正page fault,然后返回到用户空间,用户空间的内存访问会继续下去。

在这里需要注意的是,第三个阶段不一定要在第二个阶段发生后再进行,先将数据copy再来访问也是可以的,或者可以一边访问一边copy(例如postcopy live migration, guest running 和memory migrate同时进行)。

3.2) API介绍

接下来详细介绍userfaultfd的API。

3.2.1) userfaultfd system call

它是我们面临的第一个API,所有后续的操作都是基于这个API,它的原型如下:

SYSCALL_DEFINE1(userfaultfd,int,flags)

它返回一个文件描述符(fd),可以将它视为将文件open后得到的fd,该系统调用只有一个参数,flags,当前它支持两个flag: O_CLOEXEC和O_NONBLOCK,这两个标志我们在Linux里经常经看到,它也是int open(const char *pathname,int flags)的flags参数中的标志。

O_CLOEXEC:表示close-on-exec,即在exec()系统调用将一个新的执行文件替换当前的环境时,有该标志的fd都会主动被关闭掉。

O_NONBLOCK:表示该fd上对应的操作为非阻塞操作。

调用这个系统调用后,我们完成了第一步,接着要在这个fd上做一些ioctl的配置操作才能enable userfaultfd。

3.2.2) UFFDIO_API Ioctl

UFFDIO_API是一个起始操作,所有其它操作必须要在它之后才能进行,它用来验证用户空间程序的API version是否被kernel所支持。

该API所带的参数如下示:

struct uffdio_api {

/* userland asks for an API number and the features to enable */

__u64 api;

/*

* Kernel answers below with the all available features for

* the API,this notifies userland of which events and/or

* which flags for each event are enabled in the current

* kernel,

*

* Note: UFFD_EVENT_PAGEFAULT and UFFD_PAGEFAULT_FLAG_WRITE

* are to be considered implicitly always enabled in all kernels as

* long as the uffdio_api,api requested matches UFFD_API,

*/

#if 0 /* not available yet */

#define UFFD_FEATURE_PAGEFAULT_FLAG_WP           (1<<0)

#define UFFD_FEATURE_EVENT_FORK                       (1<<1)

#endif

__u64 features;

 

__u64 ioctls;

};

api由用户空间写入,表示其支持的API number,当前所唯一支持的API number是UFFD_API,其值为0xaa。

features: 由内核更新,表示内核支持的feature,当前内核总是将它设为0,从注释中可以看到,有两个feature是默认被支持的,它们是UFFD_EVENT_PAGEFAULT和UFFD_PAGEFAULT_FLAG_WRITE,分别表示用来捕获page fault和该page fault是否是对内存的写操作所导致的。

ioctls: 由内核更新,表示内核支持的ioctl,当前内核将它设置为UFFD_API_IOCTLS,定义如下:

#define UFFD_API_IOCTLS                                     \

((__u64)1 << _UFFDIO_REGISTER |                    \

(__u64)1 << _UFFDIO_UNREGISTER |   \

(__u64)1 << _UFFDIO_API)

表示该fd上所支持的ioctl的操作,_UFFDIO_API就是我们在这节分析的UFFDIO_API Ioctl,其它两个我们接下来马上就可以看到。

3.2.3) UFFDIO_REGISTER Ioctl

在和内核协商好了API以及所支持的feature和ioctl后,就需要告诉内核哪一段内存的page fault是需要userspace来控制,这就是UFFDIO_REGISTER要做的事情。

这个ioctl所带的参数为:

struct uffdio_range {

__u64 start;

__u64 len;

};

 

struct uffdio_register {

struct uffdio_range range;

#define UFFDIO_REGISTER_MODE_MISSING         ((__u64)1<<0)

#define UFFDIO_REGISTER_MODE_WP                    ((__u64)1<<1)

__u64 mode;

 

/*

* kernel answers which ioctl commands are available for the

* range,keep at the end as the last 8 bytes aren’t read,

*/

__u64 ioctls;

};

range为用户空间填写,表示要user page fault区域的地址和大小。

mode为用户空间填写,表示要捕捉的模式,有两种模式被定义:

  •  UFFDIO_REGISTER_MODE_MISSING,要捕捉的page fault是因为页表没有映射导致的。
  •  UFFDIO_REGISTER_MODE_WP,要捕捉的page fault是因为在只读的内存上进行写操作所导致的。

当前内核只支持第一种,即UFFDIO_REGISTER_MODE_MISSING。

ioctl由内核来填写,表示内核在该range上所支持的ioctl操作,当前内核支持的操作为:

#define UFFD_API_RANGE_IOCTLS                              \

((__u64)1 << _UFFDIO_WAKE |                 \

(__u64)1 << _UFFDIO_COPY |                   \

(__u64)1 << _UFFDIO_ZEROPAGE)

我们在接下来的分析里会看到这三个ioctl所对应的含义。

3.2.4) UFFDIO_UNREGISTER ioctl

它是UFFDIO_REGISTER的反操作,用来撤消对某段range的捕捉,它所带的参数很简单,就是我们在上面看到的struct uffdio_range,只需要指定该段的起始地址和大小就可以了。

好了,现在到了来看range上的Ioctl操作的时候了,从上面的分析中可以看到,它支持三种类型的操作,我们依次来看他们的含义。

3.2.5) UFFDIO_WAKE ioctl

如果一个page fault需要由userspace来处理,就会把当前进程加入一个等待队列来等待userspace的操作,当userspace操作完了之后就会调用这个ioctl来将其唤配,它所带的参数结构为struct uffdio_range,即将在该range等待的page fault操作唤醒。

3.2.6) UFFDIO_COPY ioctl

这个ioctl用来告诉内核要往指定的range里要填充的内容,它的参数如下:

struct uffdio_copy {

__u64 dst;

__u64 src;

__u64 len;

/*

* There will be a wrprotection flag later that allows to map

* pages wrprotected on the fly,And such a flag will be

* available if the wrprotection ioctl are implemented for the

* range according to the uffdio_register,ioctls,

*/

#define UFFDIO_COPY_MODE_DONTWAKE                     ((__u64)1<<0)

__u64 mode;

 

/*

* “copy” is written by the ioctl and must be at the end: the

* copy_from_user will not read the last 8 bytes,

*/

__s64 copy;

};

dst: 要copy的目的地址。

src: 要copy的源地址。

len: 要copy的数据总大小。

mode: 当前只支持一个flag,即UFFDIO_COPY_MODE_DONTWAKE,表示在copy数据后不需要去唤醒page fault handler, 在需要batch copy的时候,可以设置此标志,等所有数据copy完成之后,再调用一次UFFDIO_WAKE ioctl即可。

copy: 由内核填写,表示已经copy的数据量,当内核一次不能完全所有的copy的时候,它将已经完成的量写入copy中,并且返回-EAGAIN通知userspace将剩余的数据写进去。

3.2.7) UFFDIO_ZEROPAGE ioctl

该ioctl与UFFDIO_COPY ioctl类似,只不过要copy的数据全是0,它的参数结构如下:

struct uffdio_zeropage {

struct uffdio_range range;

#define UFFDIO_ZEROPAGE_MODE_DONTWAKE                    ((__u64)1<<0)

__u64 mode;

 

/*

* “zeropage” is written by the ioctl and must be at the end:

* the copy_from_user will not read the last 8 bytes,

*/

__s64 zeropage;

};

因为要copy的数据全是0,在这里没有必要再从source address中去找数据了,只需要range表示它所操作的目的地址即可,其余的成员和UFFDIO_COPY ioctl类似。

4)    Userfaultfd的实现

在前面的分析中,看到了Userfaultfd的使用流程,接下来我们从代码的层面来分析几个核心的事件和几个关键的API。

4.1) userfaultfd system call

该system call跟文件open的流程很相似,无非是分配fd,然后生成file,再将file和fd关联起来,在这里值的注意的代码在userfaultfd_file_create()函数中:

1279

1280         atomic_set(&ctx->refcount,1);

1281         ctx->flags = flags;

1282         ctx->state = UFFD_STATE_WAIT_API;

1283         ctx->released = false;

1284         ctx->mm = current->mm;

1285         /* prevent the mm struct to be freed */

1286         atomic_inc(&ctx->mm->mm_users);

1287

1288         file = anon_inode_getfile(“[userfaultfd]”,&userfaultfd_fops,ctx,

1289                                   O_RDWR | (flags & UFFD_SHARED_FCNTL_FLAGS));

1290         if (IS_ERR(file)) {

1291                 mmput(ctx->mm);

1292                 kmem_cache_free(userfaultfd_ctx_cachep,ctx);

1293         }

1294 out:

1295         return file;

 

在1288行中,定义了该fd上的所有操作,它们存放在userfaultfd_fops中,而且在这里可以看到,创建的file对应的private field 为ctx,它用来表示userfaultfd的上下文。

4.2) UFFDIO_REGISTER

从userfaultfd_fops顺藤摸瓜可以找到ioctl对应的函数,很容易可以找到该API是在userfaultfd_register()中实现的,该函数的前面一段是用来进行validation的check,有几个细节需要注意一下:

730         ret = -EINVAL;

731         if (!uffdio_register,mode)

732                 goto out;

733         if (uffdio_register,mode & ~(UFFDIO_REGISTER_MODE_MISSING|

734                                      UFFDIO_REGISTER_MODE_WP))

735                 goto out;

736         vm_flags = 0;

737         if (uffdio_register,mode & UFFDIO_REGISTER_MODE_MISSING)

738                 vm_flags |= VM_UFFD_MISSING;

739         if (uffdio_register,mode & UFFDIO_REGISTER_MODE_WP) {

740                 vm_flags |= VM_UFFD_WP;

741                 /*

742                  * FIXME: remove the below error constraint by

743                  * implementing the wprotect tracking mode,

744                  */

745                 ret = -EINVAL;

746                 goto out;

747         }

748

从731-733行可以看到,mode只支持UFFDIO_REGISTER_MODE_MISSING和UFFDIO_REGISTER_MODE_WP且至少要指定一个。

730-747可以看到,UFFDIO_REGISTER_MODE_WP暂时还没有enable (有部份代码的实现但并不完整)。

接下来validate_range()会对range的合法性进行检查,它的代码比较简单,跟踪代码进去可以看到,它要求给定的region是需要页面大小对齐的,这里因为page fault的最小粒度就是PAGE_SIZE。

758         vma = find_vma_prev(mm,start,&prev);

759

760         ret = -ENOMEM;

761         if (!vma)

762                 goto out_unlock;

763

764         /* check that there’s at least one vma in the range */

765         ret = -EINVAL;

766         if (vma->vm_start >= end)

767                 goto out_unlock;

 

这一段的逻辑很简单,就是查看api所指的range是否包含在VMA映射中,如果没有找到VMA则认为是非法。

从这里可以看到,userfaultfd的region 必须要至少包含一个合法的地址区间,所谓合法,就是这个region是需要事先malloc好的或者是在程序加载的时候就已经创建好的,我们并不能期望userfaultfd会为我们分配好内存供我们使用,这也就是示例代码中需要调用posix_memalign()来分配一段内存的原因。

776         found = false;

777         for (cur = vma; cur && cur->vm_start < end; cur = cur->vm_next) {

778                 cond_resched();

779

780                 BUG_ON(!!cur->vm_userfaultfd_ctx,ctx ^

781                        !!(cur->vm_flags & (VM_UFFD_MISSING | VM_UFFD_WP)));

782

783                 /* check not compatible vmas */

784                 ret = -EINVAL;

785                 if (cur->vm_ops)

786                         goto out_unlock;

787

788                 /*

789                  * Check that this vma isn’t already owned by a

790                  * different userfaultfd,We can’t allow more than one

791                  * userfaultfd to own a single vma simultaneously or we

792                  * wouldn’t know which one to deliver the userfaults to,

793                  */

794                 ret = -EBUSY;

795                 if (cur->vm_userfaultfd_ctx,ctx &&

796                     cur->vm_userfaultfd_ctx,ctx != ctx)

797                         goto out_unlock;

798

799                 found = true;

800         }

801         BUG_ON(!found);

这一段代码用来判断这一段range能否允许被userfault,它有两个限制:

  • 785行可以看到,如果有cur->vm_ops操作,则不被允许,直观一点说,就是只有anonymous的memory才被允许,所以hugetlbfs是不支持userfaultfd的。感兴趣的话,可以把示例程序中的’dst’改为一个全局的静态数组,看看会发生什么情况。
  • 795行可以看到,一段region只能被一个userfaultfd注册,那就是说,不允许一个region在多个userfaultfd system call的fd上注册。

接下面的来的代码就是vma是否可以合并,如果region是span一个vma的,就需要把它split。

然后把userfaultfd的信息设置 到VMA上,如下示:

846                 /*

847                  * In the vma_merge() successful mprotect-like case 8:

848                  * the next vma was merged into the current one and

849                  * the current one has not been updated yet,

850                  */

851                 vma->vm_flags = new_flags;

852                 vma->vm_userfaultfd_ctx,ctx = ctx;

从vm_flags中可以找到被捕获的信息, vma->vm_userfaultfd_ctx,ctx可以用来确定userfaultfd的属主。

4.2) page fault handler

这是一个核心的操作,我们以处理小页面的page fault为例来看看它的处理,在mm/memory.c中:

2718         if (!pte_none(*page_table))

2719                 goto release;

2720

2721         /* Deliver the page fault to userland,check inside PT lock */

2722         if (userfaultfd_missing(vma)) {

2723                 pte_unmap_unlock(page_table,ptl);

2724                 mem_cgroup_cancel_charge(page,memcg);

2725                 page_cache_release(page);

2726                 return handle_userfault(vma,address,flags,

2727                                         VM_UFFD_MISSING);

2728         }

从2718行可以看到,pte (PTE是x86的页表映射中的最后一层映射) 没有映射的话才会继续往下走,这符合UFFDIO_REGISTER_MODE_MISSING的语义。

2722行到2726行,如果该vma需要捕足UFFDIO_REGISTER_MODE_MISSING,就会调用handle_userfault()进行处理。

在THP (transparent huge page )中也有类似的处理,感兴趣的可以在其它找到handle_userfault的入口点。

来看看handle_userfault()做了些什么,

316         /*

317          * Handle nowait,not much to do other than tell it to retry

318          * and wait,

319          */

320         ret = VM_FAULT_RETRY;

321         if (flags & FAULT_FLAG_RETRY_NOWAIT)

322                 goto out;

这段代码只是遵守了FAULT_FLAG_RETRY_NOWAIT的语义,之所义在这里单独拿出来分析是因为有必要介绍一下它出现的背景。

FAULT_FLAG_RETRY_NOWAIT的语义是,如果修正这次page fault需要睡眠,比如需要swap page in,那就不需要等待,直接返回即可,可能有人就会有疑问,直接返回的话,page fault没有被fix,这个时候会导致引起fault的指令一直被重试,进而导致CPU一直在做无用的事情。嗯,确实会这样,于是这个标志只用在”伪造”的page fault中。所谓伪造,是指这个page fault并不是由内存访问直接引起的,而是由GUP (get_user_pages())的操作引起的,该操作会遍历进程的页表,如果页表映射有异常,就尝试对它进行修正。

KVM使用这个flag实现了async page fault的功能,它的原理很简单,就是vCPU在访问内存时,如果该内存在host上没有映射且host需要较长的时间才能将内存准备好的时候,直接返回,再把这个等待的操作交给后台线程去处理。它的好处表现在,此时vCPU还是可以响应IO events的。设想一下,如果期间有一个guest 的tick clock触发,这个vCPU响应,然后guest 的scheduler调度一个新的进程来运行,那么之前引起page fault的指令就不会在短时间内再次被触发,于是提高了guest的吞吐。

327         init_waitqueue_func_entry(&uwq,wq,userfaultfd_wake_function);

328         uwq,wq,private = current;

329         uwq,msg = userfault_msg(address,flags,reason);

330         uwq,ctx = ctx;

331

332         return_to_userland = (flags & (FAULT_FLAG_USER|FAULT_FLAG_KILLABLE)) ==

333                 (FAULT_FLAG_USER|FAULT_FLAG_KILLABLE);

334

335         spin_lock(&ctx->fault_pending_wqh,lock);

336         /*

337          * After the __add_wait_queue the uwq is visible to userland

338          * through poll/read(),

339          */

340         __add_wait_queue(&ctx->fault_pending_wqh,&uwq,wq);

341         /*

342          * The smp_mb() after __set_current_state prevents the reads

343          * following the spin_unlock to happen before the list_add in

344          * __add_wait_queue,

345          */

346         set_current_state(return_to_userland ? TASK_INTERRUPTIBLE :

347                           TASK_KILLABLE);

348         spin_unlock(&ctx->fault_pending_wqh,lock);

这段代码初始化好了wait queue,准备把自己投入睡眠,return_to_userland 用来表示该page fault是否由user space触发,需要留心的是,page fault hander把自己加入了ctx->fault_pending_wqh 中。

userfault_msg()用来生成了一个message,这条message会把read()操作读取到,message的格式如下:

/* read() structure */

struct uffd_msg {

__u8          event;

 

__u8          reserved1;

__u16       reserved2;

__u32       reserved3;

 

union {

struct {

__u64       flags;

__u64       address;

} pagefault;

 

struct {

/* unused reserved fields */

__u64       reserved1;

__u64       reserved2;

__u64       reserved3;

} reserved;

} arg;

} __packed;

event表示对应的事情,当前只支持page fault,即UFFD_EVENT_PAGEFAULT。

address表示fault的地址

flags表示fault的原因,当前支持两种,a) UFFD_PAGEFAULT_FLAG_WRITE,该page fault是由于写操作造成的,b) UFFD_PAGEFAULT_FLAG_WP,表示region被写保护。

接着往下看:

350         must_wait = userfaultfd_must_wait(ctx,address,flags,reason);

351         up_read(&mm->mmap_sem);

352

353         if (likely(must_wait && !ACCESS_ONCE(ctx->released) &&

354                    (return_to_userland ? !signal_pending(current) :

355                     !fatal_signal_pending(current)))) {

356                 wake_up_poll(&ctx->fd_wqh,POLLIN);

357                 schedule();

358                 ret |= VM_FAULT_MAJOR;

359         }

userfaultfd_must_wait()用于在释放mmap_sem之前检查fault address对应的页表是否已经被fix好了,因为可能一个thread在访问region的memory,另一个线程正在做UFFDIO_COPY|ZEROPAGE。

如果对应的进程没有被pending的信号,那就将自己投入睡眠了,记得在睡眠之前对poll进行唤醒,因为已经有被pending的message了。

4.3) poll操作

其对应的代码如下:

506         switch (ctx->state) {

507         case UFFD_STATE_WAIT_API:

508                 return POLLERR;

509         case UFFD_STATE_RUNNING:

510                 /*

511                  * poll() never guarantees that read won’t block,

512                  * userfaults can be waken before they’re read(),

513                  */

514                 if (unlikely(!(file->f_flags & O_NONBLOCK)))

515                         return POLLERR;

516                 /*

517                  * lockless access to see if there are pending faults

518                  * __pollwait last action is the add_wait_queue but

519                  * the spin_unlock would allow the waitqueue_active to

520                  * pass above the actual list_add inside

521                  * add_wait_queue critical section,So use a full

522                  * memory barrier to serialize the list_add write of

523                  * add_wait_queue() with the waitqueue_active read

524                  * below,

525                  */

526                 ret = 0;

527                 smp_mb();

528                 if (waitqueue_active(&ctx->fault_pending_wqh))

529                         ret = POLLIN;

530                 return ret;

531         default:

532                 BUG();

533         }

507行用来check userfaultfd的状态机,只有在API ioctl之后才能进行其它操作。

从514行可以看到,仅仅O_NONBLOCK的fd才能被poll。

528行: 如果有page fault handler在等待,通知用户POLLIN,否则返回0继续等待。

4.4) read操作

542

543         /* always take the fd_wqh lock before the fault_pending_wqh lock */

544         spin_lock(&ctx->fd_wqh,lock);

545         __add_wait_queue(&ctx->fd_wqh,&wait);

546         for (;;) {

547                 set_current_state(TASK_INTERRUPTIBLE);

548                 spin_lock(&ctx->fault_pending_wqh,lock);

549                 uwq = find_userfault(ctx);

 

545行准备好自己的wait queue,因为在读不到数据的时候可能需要将自己睡眠,

549行从page fault handler的等待队列中取出正在等待的handler。

 

550                 if (uwq) {

551                         /*

552                          * Use a seqcount to repeat the lockless check

553                          * in wake_userfault() to avoid missing

554                          * wakeups because during the refile both

555                          * waitqueue could become empty if this is the

556                          * only userfault,

557                          */

558                         write_seqcount_begin(&ctx->refile_seq);

559

560                         /*

561                          * The fault_pending_wqh,lock prevents the uwq

562                          * to disappear from under us,

563                          *

564                          * Refile this userfault from

565                          * fault_pending_wqh to fault_wqh,it’s not

566                          * pending anymore after we read it,

567                          *

568                          * Use list_del() by hand (as

569                          * userfaultfd_wake_function also uses

570                          * list_del_init() by hand) to be sure nobody

571                          * changes __remove_wait_queue() to use

572                          * list_del_init() in turn breaking the

573                          * !list_empty_careful() check in

574                          * handle_userfault(),The uwq->wq,task_list

575                          * must never be empty at any time during the

576                          * refile,or the waitqueue could disappear

577                          * from under us,The “wait_queue_head_t”

578                          * parameter of __remove_wait_queue() is unused

579                          * anyway,

580                          */

581                         list_del(&uwq->wq,task_list);

582                         __add_wait_queue(&ctx->fault_wqh,&uwq->wq);

583

584                         write_seqcount_end(&ctx->refile_seq);

585

586                         /* careful to always initialize msg if ret == 0 */

587                         *msg = uwq->msg;

588                         spin_unlock(&ctx->fault_pending_wqh,lock);

589                         ret = 0;

590                         break;

如果取到了数据,把他移动到另一个等待队列中,这样避免下一次read还会读到同一个数据,然后将message取出,返回给用户空间。

592                 spin_unlock(&ctx->fault_pending_wqh,lock);

593                 if (signal_pending(current)) {

594                         ret = -ERESTARTSYS;

595                         break;

596                 }

597                 if (no_wait) {

598                         ret = -EAGAIN;

599                         break;

600                 }

601                 spin_unlock(&ctx->fd_wqh,lock);

602                 schedule();

603                 spin_lock(&ctx->fd_wqh,lock);

 

593行:如果有信号被pending,返回-ERESTARTSYS让用户空间处理完信号后重试。

597行:如果是no blocking的fd,直接返回。

其余情况就将自己投入睡眠。

4.5) UFFDIO_WAKE

UFFDIO_WAKE用于唤醒正在等待的page fault handler,有两种情况:

  • 还没有被read的handler,此时它位于ctx->fault_pending_wqh中。
  • 有被read的,但是尚未唤醒的handler,read()将它移至了ctx->fault_wqh中。

知道了这一点,wake_userfault()的代码就很简单了:

675         /*

676          * Use waitqueue_active because it’s very frequent to

677          * change the address space atomically even if there are no

678          * userfaults yet,So we take the spinlock only when we’re

679          * sure we’ve userfaults to wake,

680          */

681         do {

682                 seq = read_seqcount_begin(&ctx->refile_seq);

683                 need_wakeup = waitqueue_active(&ctx->fault_pending_wqh) ||

684                         waitqueue_active(&ctx->fault_wqh);

685                 cond_resched();

686         } while (read_seqcount_retry(&ctx->refile_seq,seq));

687         if (need_wakeup)

688                 __wake_userfault(ctx,range);

 

它检查的就是这两个队列。

4.6) UFFDIO_COPY

同UFFDIO_ZEROPAGE一样,核心的操作是在mcopy_atomic()中完成的,该函数代码较长但是逻辑很简单,就是一层层遍历页表,如果页表异常则修正它,最后将数据写入到建好的指向的页面,对于ZEROPAGE来说,直接将它指向零页(zero page)。

5:小结

现在看起来userfaultfd的API比较简单,实现逻辑也很清晰。但它进入upstream kernel花了相当长的一段时间,或许讲讲它的开发过程中API的变化和实现方式的变化会更加有意思。把它做为下一个topic?或许吧,如果我还记得。:)

 

PS:

示例代码: userfaultfd.c

在编译之前需要在内核源代码下运行:

make headers_install INSTALL_HDR_PATH=/usr

来将新的内核头文件同步到系统头文件目录下。

Linux Kernel Namespace实现: namespace在proc中的实现 (Part 2)

1)前言

前一篇笔记分析了namespace的生命周期以及其对应的操作, 在其中曾提到每个进程对应的namespace都可以在/prc/$PID/ns下面找到, 可以据此来比较进程是否在同一namespace以及据此来判断加入的目标namespace. 这一节中会来详细分析namespace在proc中的实现.

2) namespace的通用操作

每一个namespace的结构都内嵌了struct ns_common的结构体, 例如 uts namespace:

struct uts_namespace {

struct kref kref;

struct new_utsname name;

struct user_namespace *user_ns;

struct ns_common ns;

};

 

struct ns_common集合了namespace在proc中的所有抽象, 它的定义如下:

struct ns_common {

atomic_long_t stashed;

const struct proc_ns_operations *ops;

unsigned int inum;

};

 

事实上/proc/$PID/ns/下每个文件对应一个namespace, 它是一个符号链接, 会指向一个仅kernel可见的被称为nsfs的文件系统中的一个inode. 本文后面会对这个文件系统进行分析. 在这里stashed正是用来存放这个文件的dentry. 在这里的类型为atomic_long_t 而非 struct dentry是因为更改stashed的操作是lockless (原子) 的.

Inum是一个唯一的proc inode number. 虽然它是从proc 文件系统中分配的inode number, 但仅用在nsfs中, 它被用做nsfs的inode number, 只需要保证这个number在nsfs中唯一就可以了.

Ops对应该namespace的操作, 其定义如下:

struct proc_ns_operations {

const char *name;

int type;

struct ns_common *(*get)(struct task_struct *task);

void (*put)(struct ns_common *ns);

int (*install)(struct nsproxy *nsproxy, struct ns_common *ns);

}

 

name为namespace的名字, type为namespace的类型, 例如user namespace的类型为CLONE_NEWUSER, 它用于在setns()系统调用中用来匹配nstype 参数.

get()用于获得namespace的引用计数, put()执行相反的操作.

Install()用于将进程安装到指定的namesapce里.  @ns将会直接安装到@nsproxy. 它在setns()系统调用中被使用.

Struct nsproxy是一个新的数据结构, 有必要来看看它的定义和使用.

struct nsproxy {

atomic_t count;

struct uts_namespace *uts_ns;

struct ipc_namespace *ipc_ns;

struct mnt_namespace *mnt_ns;

struct pid_namespace *pid_ns_for_children;

struct net          *net_ns;

};

 

而struct nsproxy又是内嵌入在task_struct (用来表示进程) 中. 从它的定义中可以看出, 它是进程所在的namespace的集合, 需要注意的是user namespace比较特殊它并没有包含在struct nsproxy中, 后续在分析user namespace的时候再回过头来看它.

Count表示的是nsproxy的引用计数, 当全部namespace被完整clone的时候, 计用计数加1, 例如fork()系统调用的时候.

Namespace有自己单独的引用计数, 这是因为有时候我们只需要操作某个指定的namespace, 例如unshare()用来分离指定的namespace, 这个时候就需要将当前的nsproxy复制, 新的nsproxy->count初始化为1, 增加没有被更改的namespace的引用计数, 再将要更改的namespace进行更新. 如下图所示:

image001

 

由于struct ns_common 都是内嵌在具体namespace的定义之中, 因此在ns operations里面可以使用container_of() 来将ns转换到具体的namespace定义.

2.1) uts namespace对应的common操作

上面说的都很抽象, 现在以uts namespace为例, 来看看namespace的common操作.

struct uts_namespace init_uts_ns = {

.kref = {

.refcount      = ATOMIC_INIT(2),

},

.name = {

.sysname      = UTS_SYSNAME,

.nodename   = UTS_NODENAME,

.release = UTS_RELEASE,

.version = UTS_VERSION,

.machine      = UTS_MACHINE,

.domainname     = UTS_DOMAINNAME,

},

.user_ns = &init_user_ns,

.ns.inum = PROC_UTS_INIT_INO,

#ifdef CONFIG_UTS_NS

.ns.ops = &utsns_operations,

#endif

};

 

在前面已经看到了uts_namespace的定义. Init_uts_ns是系统中原始的也是第一个uts namespace, 它被关联到系统的Init进程, 系统中的其它进程都是在它的基础上进行创建的.

前面提到过, 每个namespace都包含有自己的引用计数, 在这里可以看到init_uts_ns的引用计数被初始化成2, 这是因为引用计数初始值为1, 而它直接关联到init task中 (静态定义)因此需要再加1.

Name表示系统的UTS信息, 用户空间的uname指令就是从这里把结果取出来的.

对于大多数的namespace而言都会有指针指向user namespace, 这是因为对namespace的操作会涉及到权限检查, 而namespace对应的uid. gid等信息都存放在user namespace中. 在这里可以看到init_uts_ns的user namespace是指向系统的原始user namespace.

接下来就是ns_common的初始化了.  Inum被静态初始化成了PROC_UTS_INIT_INO, 它的定义为:

enum {

PROC_ROOT_INO             = 1,

PROC_IPC_INIT_INO  = 0xEFFFFFFFU,

PROC_UTS_INIT_INO = 0xEFFFFFFEU,

PROC_USER_INIT_INO     = 0xEFFFFFFDU,

PROC_PID_INIT_INO  = 0xEFFFFFFCU,

};

 

在用户空间进行确认一下:

# ll /proc/1/ns/uts

lrwxrwxrwx 1 root root 0 Jul 28 23:11 /proc/1/ns/uts -> uts:[4026531838]

0xEFFFFFFEU对应的十进制就是4026531838.

可能有一个疑问, 这里的inum是静态定义的, 那么在proc分配inum的时候会不会复用这个inum呢? 当然答案是不会, 这是因为proc inum是从PROC_DYNAMIC_FIRST 开始分配的, 它的定义为

#define PROC_DYNAMIC_FIRST 0xF0000000U

所以所有小于PROC_DYNAMIC_FIRST的值都可以拿来做静态定义. Inum分配算法可参考proc_alloc_inum()函数的代码.

接下来看uts对应的operations, 其定义如下:

const struct proc_ns_operations utsns_operations = {

.name           = “uts”,

.type             = CLONE_NEWUTS,

.get        = utsns_get,

.put        = utsns_put,

.install   = utsns_install,

};

 

name和type从字面就可以理解它的含义. 先来看看get操作

static inline void get_uts_ns(struct uts_namespace *ns)

{

kref_get(&ns->kref);

}

 

static struct ns_common *utsns_get(struct task_struct *task)

{

struct uts_namespace *ns = NULL;

struct nsproxy *nsproxy;

 

task_lock(task);

nsproxy = task->nsproxy;

if (nsproxy) {

ns = nsproxy->uts_ns;

get_uts_ns(ns);

}

task_unlock(task);

 

return ns ? &ns->ns : NULL;

}

 

首先lock task_struct为防止并发操作, 其后从nsproxy中取出uts namespace, 再将其引用计数增加.

Put操作就更简单了, 看代码

static inline struct uts_namespace *to_uts_ns(struct ns_common *ns)

{

return container_of(ns, struct uts_namespace, ns);

}

 

static inline void put_uts_ns(struct uts_namespace *ns)

{

kref_put(&ns->kref, free_uts_ns);

}

 

static void utsns_put(struct ns_common *ns)

{

put_uts_ns(to_uts_ns(ns));

}

 

首先将之前说过的方法将ns转换成uts namespace, 然后再将它的引用数数减1. 或许有人在疑问, 为什么这里不需要持用锁了呢? 这是因为get和put都是配套使用的, 在get的时候已经持用引用计数了, 可确定put操作时uts namespace是合法的.

Install的操作如下示:

static int utsns_install(struct nsproxy *nsproxy, struct ns_common *new)

{

struct uts_namespace *ns = to_uts_ns(new);

 

if (!ns_capable(ns->user_ns, CAP_SYS_ADMIN) ||

!ns_capable(current_user_ns(), CAP_SYS_ADMIN))

return -EPERM;

 

get_uts_ns(ns);

put_uts_ns(nsproxy->uts_ns);

nsproxy->uts_ns = ns;

return 0;

}

 

nsproxy是当前进程的nsporxy copy, new表示的是要安装的uts namespace. 首先是权限检查, 这一部份等分析user namespace的时候再来详细研究.

只需要增加要安装的namespace的引用计数, 然后把旧的namespace的引用计数减掉, 再更新到nsproxy中就可以了.

3) /proc/$PID/ns/ 的实现

接下来看看proc下对应的ns目录下的文件的操作.

3.1) 创建/proc/$PID/ns目录

首先来看看ns目录是如何被生成的. “ns”目录对应的操作被定义在

struct pid_entry tgid_base_stuff[]和struct pid_entry tid_base_stuff[]

前者定义了每个进程在/proc/$PID/中所创建的文件, 后面者对应进程的thread所创建的文件, 位于/proc/$PID/task/目录中.

具体看一下ns目录对应的操作:

DIR(“ns”,      S_IRUSR|S_IXUGO, proc_ns_dir_inode_operations, proc_ns_dir_operations)

 

据此可以持到, ns inode对应的操作为proc_ns_dir_operations , 目录对应的操作为proc_ns_dir_operations.

3.2) 读取/proc/$PID/ns目录

通过readdir或者getgents()读取/proc/$PID/ns就可以看到在它下面的所有文件了. 来看看该目录对应的操作:

const struct file_operations proc_ns_dir_operations = {

.read             = generic_read_dir,

.iterate  = proc_ns_dir_readdir,

};

 

最终读取目录的操作都会调用文件系统底层的iterate操作来完成, 来看proc_ns_dir_readdir的实现:

106 static int proc_ns_dir_readdir(struct file *file, struct dir_context *ctx)

107 {

108         struct task_struct *task = get_proc_task(file_inode(file));

109         const struct proc_ns_operations **entry, **last;

110

111         if (!task)

112                 return -ENOENT;

113

114         if (!dir_emit_dots(file, ctx))

115                 goto out;

116         if (ctx->pos >= 2 + ARRAY_SIZE(ns_entries))

117                 goto out;

118         entry = ns_entries + (ctx->pos – 2);

119         last = &ns_entries[ARRAY_SIZE(ns_entries) – 1];

120         while (entry <= last) {

121                 const struct proc_ns_operations *ops = *entry;

122                 if (!proc_fill_cache(file, ctx, ops->name, strlen(ops->name),

123                                      proc_ns_instantiate, task, ops))

124                         break;

125                 ctx->pos++;

126                 entry++;

127         }

128 out:

129         put_task_struct(task);

130         return 0;

131 }

 

114行用来返回”.”和”..”, 这个是每个目录都包含的entry, 分别表示本层目录和上一层目录.

116 行可以看到, 除了”.”和”..”外, 此目录下有ARRAY_SIZE(ns_entries)个文件.

118 行中减2是因为第一项和第二项分别对应为”.”和”..”.

 

最重要的操作在122行, proc_fill_cache()用来创建dentry和inode, 并将Inode的信息写入到ctx中. Dentry的名称长度对应为第三个参数和第四个参数, 也就是ops->name字符和它的长度. Inode的设置在proc_ns_ instantiate这个callback中完成.

由此可见, 在该目录下读出来的内容应该为ns_entries[]数组中的元素的name字段, 来看看这个数组的定义:

static const struct proc_ns_operations *ns_entries[] = {

#ifdef CONFIG_NET_NS

&netns_operations,

#endif

#ifdef CONFIG_UTS_NS

&utsns_operations,

#endif

#ifdef CONFIG_IPC_NS

&ipcns_operations,

#endif

#ifdef CONFIG_PID_NS

&pidns_operations,

#endif

#ifdef CONFIG_USER_NS

&userns_operations,

#endif

&mntns_operations,

};

 

正好对应了每一个namespace的名字.

3.3) /prc/$PID/ns/下的文件的操作

再来看看该目录下文件对应的具体操作, 在前面看到了, proc_ns_ instantiate()用来设置文件对应的inode, 来看看它的代码:

81 static int proc_ns_instantiate(struct inode *dir,

82         struct dentry *dentry, struct task_struct *task, const void *ptr)

83 {

……

92         ei = PROC_I(inode);

93         inode->i_mode = S_IFLNK|S_IRWXUGO;

94         inode->i_op = &proc_ns_link_inode_operations;

95         ei->ns_ops = ns_ops;

……

104 }

 

从这里可以看到, inode对应为S_IFLNK, 也主是说它是一个符号链接, 该文件对应的操作定义在proc_ns_link_inode_operations中:

static const struct inode_operations proc_ns_link_inode_operations = {

.readlink       = proc_ns_readlink,

.follow_link  = proc_ns_follow_link,

.setattr  = proc_setattr,

};

 

readlink用来读取这个符号链接所指向的文件, follow_link用来找到这个链接所指向文件的inode.

Proc_ns_readlink()最终会调用ns_get_name()来获得它所指向的文件的名称, 其代码如下:

int ns_get_name(char *buf, size_t size, struct task_struct *task,

const struct proc_ns_operations *ns_ops)

{

struct ns_common *ns;

int res = -ENOENT;

ns = ns_ops->get(task);

if (ns) {

res = snprintf(buf, size, “%s:[%u]”, ns_ops->name, ns->inum);

ns_ops->put(ns);

}

return res;

}

 

首先它调用get()接口来获得该namespace的引用计数以防止在操作的过程中该namespace无效.

可看到对应的名字名 “ns_ops->name:ns->inum”, 也就是我们用ls –l在目录下看到的符号链接的信息.

最终再调用put()释放它的引用计数.

proc_ns_follow_link()最终会调用ns_get_path()来获得指向文件的inode信息, 这个操作涉及到了nsfs文件系统, 先来看看该文件系统的实现然后再回过来看这个函数.

3) nsfs文件系统

要分析ns在proc的操作, nsfs是一个绕不过去的话题. 这个文件系统在前面的分析中多次被提及, 它是/proc/$PID/ns下面的文件的最终指向, 而且这是一个用户没有办法操作的文件系统, 它也没有挂载点, 只是一个内建于内存中的文件系统.

用mount –bind就可以看到它的存在了, 如下示:

# echo > /tmp/tmp

# mount -o bind /proc/1/ns/uts /tmp/tmp

# mount

……

nsfs on /tmp/tmp type nsfs (rw)

在mount show出来的信息就可以看到/tmp/tmp是在nsfs中的. 可以check一下/proc/filesystems, 可看到它并末出现在里面, 因为并没有调用register_filesystem()将nsfs注册为全局可见的文件系统.

下面来看一下这个文件系统的真身. 它的定义以及初始化如下:

static struct file_system_type nsfs = {

.name = “nsfs”,

.mount = nsfs_mount,

.kill_sb = kill_anon_super,

};

 

void __init nsfs_init(void)

{

nsfs_mnt = kern_mount(&nsfs);

if (IS_ERR(nsfs_mnt))

panic(“can’t set nsfs up\n”);

nsfs_mnt->mnt_sb->s_flags &= ~MS_NOUSER;

}

 

它在kernel内部被mount, 本质上就是生成一个仅kernel可见的vfsmount结构.

在mount的时候, 会调用”struct file_system_type”中的mount这个callback, 该操作在nsfs对应为

static struct dentry *nsfs_mount(struct file_system_type *fs_type,

int flags, const char *dev_name, void *data)

{

return mount_pseudo(fs_type, “nsfs:”, &nsfs_ops,

&ns_dentry_operations, NSFS_MAGIC);

}

 

mount_pseudo()生成文件系统的super block, 并且初始化super block下的根目录, 该根目录对应的名字为第二个参数, 也就是”fsfs:”, super block的操作和dentry的操作分别在第三个和第四个参数中被指定, 最后一个参数是文件系统的Magic Number, 用来唯一标识一个文件系统.

3.1) nsfs的super block操作

先来看super block对应的操作, 它的定义如下:

static const struct super_operations nsfs_ops = {

.statfs = simple_statfs,

.evict_inode = nsfs_evict,

};

 

.statfs这个callback对应statfs系统调用, 它用来返回文件系统的信息, 比如文件系统的magic number, block size等.

 

.evict_inode在inode被destroy前被调用, 它的代码如下:

static void nsfs_evict(struct inode *inode)

{

struct ns_common *ns = inode->i_private;

clear_inode(inode);

ns->ops->put(ns);

}

逻辑很简单, 清空inode并且释放inode关联的namespace的引用计数.

3.2) nsfs的dentry操作

dentry的操作定义如下:

const struct dentry_operations ns_dentry_operations =

{

.d_prune       = ns_prune_dentry,

.d_delete      = always_delete_dentry,

.d_dname     = ns_dname,

}

 

.d_prune: dentry在destroy 前被调用.

.d_delete: dentry的引用计数被完全释放时用来判断要不要把此dentry继续留在cache里. 在nsfs中, 该操作始终返回1, 也就是说, 没有引用计数的dentry都会被及时删除.

.d_dname: 用来获得dentry对应的path name. 在nsfs中, path name的表示为

“namespace name:[namespace inode number]”

ns_prune_dentry()的代码如下:

static void ns_prune_dentry(struct dentry *dentry)

{

struct inode *inode = d_inode(dentry);

if (inode) {

struct ns_common *ns = inode->i_private;

atomic_long_set(&ns->stashed, 0);

}

}

在后面的分析可以看到, ns->stashed实际上就是指向nsfs文件系统中的dentry. 在dentry要destroy 前, 先把这个指向关系清除.

3.3) ns_get_path()函数分析

现在分析完了nsfs的所有背景, 可以回过头来看看ns_get_path()的实现了. 该函数取得/proc/$PID/ns/下的符号链接所对应的实际文件.

46 void *ns_get_path(struct path *path, struct task_struct *task,

47                         const struct proc_ns_operations *ns_ops)

48 {

49         struct vfsmount *mnt = mntget(nsfs_mnt);

50         struct qstr qname = { .name = “”, };

51         struct dentry *dentry;

52         struct inode *inode;

53         struct ns_common *ns;

54         unsigned long d;

55

56 again:

57         ns = ns_ops->get(task);

58         if (!ns) {

59                 mntput(mnt);

60                 return ERR_PTR(-ENOENT);

61         }

62         rcu_read_lock();

63         d = atomic_long_read(&ns->stashed);

64         if (!d)

65                 goto slow;

66         dentry = (struct dentry *)d;

67         if (!lockref_get_not_dead(&dentry->d_lockref))

68                 goto slow;

69         rcu_read_unlock();

70         ns_ops->put(ns);

71 got_it:

72         path->mnt = mnt;

73         path->dentry = dentry;

74         return NULL;

上面的代码是该函数的第一部份, 可以把这部份当成fast path, 在第63行判断dentry是否被cache到了ns->stashed中, 如果被cache就可以直接增加它的lockref, 然后返回.

注意在72行, mnt的信息被指向了nsfs_mnt, 也就是说符号链接直向的是nsfs中的dentry.

另一个值得注意的地方是在这个fast path中, ns_ops->get()和ns_ops->put()都是被配套调用的, 可以推测dentry其实被没有持用namespace的引用计数. 那是如何通过引用/proc/$PID/ns下的文件来保持namespace一直为live呢? 继续看下去.

75 slow:

76         rcu_read_unlock();

77         inode = new_inode_pseudo(mnt->mnt_sb);

78         if (!inode) {

79                 ns_ops->put(ns);

80                 mntput(mnt);

81                 return ERR_PTR(-ENOMEM);

82         }

83         inode->i_ino = ns->inum;

84         inode->i_mtime = inode->i_atime = inode->i_ctime = CURRENT_TIME;

85         inode->i_flags |= S_IMMUTABLE;

86         inode->i_mode = S_IFREG | S_IRUGO;

87         inode->i_fop = &ns_file_operations;

88         inode->i_private = ns;

89

90         dentry = d_alloc_pseudo(mnt->mnt_sb, &qname);

91         if (!dentry) {

92                 iput(inode);

93                 mntput(mnt);

94                 return ERR_PTR(-ENOMEM);

95         }

96         d_instantiate(dentry, inode);

97         dentry->d_fsdata = (void *)ns_ops;

98         d = atomic_long_cmpxchg(&ns->stashed, 0, (unsigned long)dentry);

99         if (d) {

100                 d_delete(dentry);       /* make sure ->d_prune() does nothing */

101                 dput(dentry);

102                 cpu_relax();

103                 goto again;

104         }

105         goto got_it;

106 }

这部份对应的是该函数的slow path. 如果dentry没有被cache或者是lockref成为了dead, 就需要生成新的dentry.

76-88行分配并初始化Inode, 该inode的ino为namespace的inode number, 对应的文件操作为ns_file_operations,  它实际上不支持任何操作.

90-95行分配并初始化dentry,  该dentry对应的名称为qname, 定义在第50行, 实际上为空.

98-104用来将dentry缓存到ns->stashed中, 如果有另一个路径抢在它之前更新了ns->stashed, 通过goto again来重新check.

105行, 如果一切正常, 通过goto got_it 直接返回. 从这里可以看到, 对于新创建的dentry, 并没有ns_ops->put(). 也就是说, namespace的引用计数其实是关联在inode上面的. 回忆之前分析的super block的evict_indoe()操作, 在Inode被销毁前, 会将它持有的ns的引用计数释放掉.

4) 小结

这节分析里涉及到了大量的文件系统的概念, 加大了整理和理解代码的难度. 不管怎么样, namespace在proc的操作以及nsfs文件系统都是namespace的框架, 理解了它们对理解namespace的生命周期是很有帮助的.

Linux Kernel Namespace实现: namespace API介绍 (Part 1)

 

1)前言

随着docker的出现, Linux container这种轻量级虚拟化方案越来越在产业里得到大规模的部署和应用. 而Namespace是Linux Container的基础, 了解namespace的实现对了解container和docker有着关键的作用. 本着知其然亦知其所以然的原则, 这个系列的笔记会对namespace的方方面面做一个详尽的分析.

简而言之, namespace就是一种纯软件的隔离方案. Namespace这个词在学习编程的语言的时候学习过, 在程序设计里, 它指变量或者函数的作用范围. 在Kernel里也有类似的作用, 可以把它理解成操作的可见范围, 例如同一个namespace的对象(即进程)可以看到另一个进程的存在和资源(如进程的pid, 进程间通信, 等等).

这一节会介绍namespace在用户空间的接口, 这样可以方便地建立namespace的全局观.

2) namespace的生命周期

namespace的生存周期如下图所示

 

image001

上图列出了namespace的状态转换以及对应的操作. 据此可知, namespace会经历创建, 加入, 离开以及销毁这几个过程.

2.1) 创建namespace

新的namespace由带有CLONE_NEW*标志的clone() system call所创建. 这些标志包括: CLONE_NEWIPC,CLONE_NEWNET,CLONE_NEWNS,CLONE_NEWPID,CLONE_NEWUTS,CLONE_NEWUSER,这些标志分别表示namespace所隔离的资源:

  • CLONE_NEWPID: 创建一个新的PID namespace. 只有在同一个namespace里的进程才能看到相互的 它直接影 响用户空间类似ps命令的行为.
  • CLONE_NEWNET: 创建一个新的Network namespace, 将网络协议栈进行隔离,包括网络接口,ipv4/ipv6协议栈,路由,iptable规则, 等等.
  • CLONE_NEWNS: 创建一个新的Mount namespace, 它将mount的行为进行隔离,也就是说mount只在同一个mount namespace中可见。BTW,根据它的行为,这里恰当的名称应该是CLONE_NEWMOUNT. 这是历史遗留的原因,因为mount namespace是第一个namespace且当时没有人想到会将这套机制扩展到其它的subsystem, 等它成了API, 想改名字也没有那么容易了。
  • CLONE_NEWUTS: 创建一个新的UTS namespace, 同理, 它用来隔离UTS. UTS包括domain name, host name. 直接影响setdomainname(), sethostname()这类接口的行为.
  • CLONE_NEWIPC: 创建一个新的IPC namespace, 用来隔离进程的IPC通信, 直接影响ipc shared memory, ipc semaphore等接口的行为.
  • CLONE_NEWUSER: 创建一个新的User namespace, 用来隔离用户的uid, gid. 用户在不同的namespace中允许有不同的uid和gid, 例如普通用户可以在子container中拥有root权限。这是一个新的namespace, 在Linux Kernel 3.8中被加入,所以在较老的发行版中,man clone可能看不到这个标志.

2.2) namespace的组织

进程所在的namespace可以在/proc/$PID/ns/中看到. Pid为1是系统的init进程, 它所在的namespace为原始的namespace,如下示:

081315_1626_13.png

其下面的文件依次表示每个namespace, 例如user就表示user namespace. 所有文件均为符号链接, 链接指向$namespace:[$namespace-inode-number], 前半部份为namespace的名称,后半部份的数字表示这个namespace的 inode number. 每个namespace的inode number均不同, 因此, 如果多个进程处于同一个namespace. 在该目录下看到的inode number是一样的,否则可以判定为进程在不同的namespace中。

该链接指向的文件比较特殊,它不能直接访问,事实上指向的文件存放在被称为”nsfs”的文件系统中,该文件系统用户不可见。可以用stat()看到指向文件的inode信息:

081315_1626_14.png

这个文件在后续分析namespace实现的时候再来详细讲解。

再来看看当前shell的namespace:

081315_1626_15.png

可以看到它跟init进程处于同一个namespace里面。

再用unshare来启动一个新的shell

081315_1626_16.png

可以看到新的shell已经运行到了完全新的namespace里面,所有的namespace均和父进程不一样了。

2.3) 加入namespace

加入一个已经存在的namespace中以通过setns() 系统调用来完成。它的原型如下

int setns(int fd, int nstype);

 

第一个参数fd由打开/proc/$PID/ns/下的文件所得到,nstype表示要加入的namespace类型。一般来说,由fd就可以确定namespace的类型了,nstype只是起一个辅助check的作用。如果调用者明确知道fd是由打开相应的namespace文件所得到,可以nstype设为0,来bypass这个check. 相反的,如果fd是由其它组件传递过来的,调用者不知道它是否是open想要的namespace而得到,就可以设置对应nstype来让kernel做check。

util-linux这个包里提供了nsenter的指令, 其提供了一种方式将新创建的进程运行在指定的namespace里面, 它的实现很简单, 就是通过命令行指定要进入的namespace的file, 然后利用setns()指当前的进程放到指定的namespace里面, 再clone()运行指定的执行文件. 我们可以用strace来看看它的运行情况:

# strace nsenter -t 6814 -i -m -n -p -u /bin/bash

execve(“/usr/bin/nsenter”, [“nsenter”, “-t”, “6814”, “-i”, “-m”, “-n”, “-p”, “-u”, “/bin/bash”], [/* 33 vars */]) = 0

brk(0)                                  = 0xb13000

 

……

 

open(“/proc/6814/ns/ipc”, O_RDONLY)     = 3

open(“/proc/6814/ns/uts”, O_RDONLY)     = 4

open(“/proc/6814/ns/net”, O_RDONLY)     = 5

open(“/proc/6814/ns/pid”, O_RDONLY)     = 6

open(“/proc/6814/ns/mnt”, O_RDONLY)     = 7

setns(3, CLONE_NEWIPC)                  = 0

close(3)                                = 0

setns(4, CLONE_NEWUTS)                  = 0

close(4)                                = 0

setns(5, CLONE_NEWNET)                  = 0

close(5)                                = 0

setns(6, CLONE_NEWPID)                  = 0

close(6)                                = 0

setns(7, CLONE_NEWNS)                   = 0

close(7)                                = 0

clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fab236d8a10) = 20034

wait4(20034, [root@xiaohome /]#

 

从上可以看到,  nsenter先获得target进程(-t参数指定)所在的namespace的文件, 然后再调用setns()将当前所在的进程加入到对应的namespace里面, 最后再clone()运行我们指定的二进制文件.

我们来看一个实际的例子, 先打开一个 terminal:

081315_1626_17.png

再打开另一个terminal, 将新的进程加入到第一个terminal创建的namespace

081315_1626_18.png

先通过 ps aux | grep /bin/bash找到我们在第一个terminal里运行的程序, 在这里需要注意新的进程并不是unshare对应的进程. 这里我们找到的pid是2342, 通过proc下的ns文件进行确认, 看到这个进程所在的namespace确实是我们在第一个terminal所创建的namespace.

最后通过nsenter将要运行的进程加入到这个namespace里. 在这里我们在nsenter中并没有使用-U (–user)参数将进程加入到新的user namespace里, 这是因为nsenter的一个bug, 在同时指定user namespace和其它的namepsace里, 它会先加user namespace, 造成在操作其它的namespace时权限不够, 如下示:

081315_1626_19.png

我们在随后分析namespace实现的时候再来详细分析这个bug.

现在这两个进程都在同样的namespace里面了(除了user namespace外), 我们来看看:

081315_1626_20.png

可以看到这两个进程在同一个pid namespace里. 我们同样地可以进行mount, uts等其它namespace的check.

2.4) 离开namespace

unshare()系统调用用于将当前进程和所在的namespace分离并且加入到新创建的namespace之中. Unshare()的原型定义如下:

int unshare(int flags);

flags的定义如下:

CLONE_FILES

使当前进程的文件描述符不再和其它进程share. 例如, 我们可以使用clone(CLONE_FILES)来创建一个新的进程并使这个新的进程share父进程的文件描述符, 随后如果子进程不想再和父进程share这些文件描述符,可以通过unshare(CLONE_FILES)来终止这些share.

CLONE_FS

使当前进程的文件系统信息(包括当前目录, root目录, umask)不再和其它进程进行share. 它通常与clone()配合使用.

CLONE_SYSVSEM

撤消当前进程的undo SYS V信号量并使当前进程的sys V 信息量不再和其它进程share.

CLONE_NEWIPC

通过创建新的ipc namespace来分离与其它进程share的ipc namespace, 并且包含CLONE_SYSVSEM的作用

CLONE_NEWNET, CLONE_NEWUTS, CLONE_NEWUSER, CLONE_NEWNS, CLONE_NEWPID

与CLONE_NEWIPC类似, 分别使当前进程创建新的namespace, 不再与其它进程share net, uts, user, mount, pid namespace.

在这里需要注意的是, unshare不仅退出当前进程所在的namespace而且还会创建新的namespace, 严格说来, unshare也是创建namespace的一种方式.

Unshare程序在前面已经使用过很多次了, 它实际上就是调用unshare()系统调用, 可以strace进程查看:

# strace -o /tmp/log  unshare -p -f /bin/bash

# cat /tmp/log | grep unshare

execve(“/usr/bin/unshare”, [“unshare”, “-p”, “-f”, “/bin/bash”], [/* 27 vars */]) = 0

unshare(CLONE_NEWPID)                   = 0

 

在这里可以看到 –p参数对应的操作是unshare(CLONE_NEWPID).

2.5) 销毁namespace

Linux kernel没有提供特定的接口来销毁namespace, 销毁的操作是自动进行的. 在后面的分析中我们可以看到, 每一次引用namespace就会增加一次引用计数, 直至引用计数为0时会将namespace自动删除.

那在用户空间中, 我们可以open /proc/$PID/ns下的文件来增加引用计数, 还可以通过mount bind的操作来增加计数, 如下所示:

[root@xiaohome ~]# mount –bind /proc/2342/ns/pid /var/log^C

[root@xiaohome ~]# echo > /tmp/pid-ns

[root@xiaohome ~]# mount –bind /proc/2342/ns/pid /tmp/pid-ns

[root@xiaohome ~]# stat /tmp/pid-ns

File: ‘/tmp/pid-ns’

Size: 0           Blocks: 0          IO Block: 4096   regular empty file

Device: 3h/3d   Inode: 4026532392  Links: 1

Access: (0444/-r–r–r–)  Uid: (    0/    root)   Gid: (    0/    root)

Access: 2015-07-28 14:28:13.724408143 +0800

Modify: 2015-07-28 14:28:13.724408143 +0800

Change: 2015-07-28 14:28:13.724408143 +0800

Birth: –

[root@xiaohome ~]# stat -L /proc/2342/ns/pid

File: ‘/proc/2342/ns/pid’

Size: 0           Blocks: 0          IO Block: 4096   regular empty file

Device: 3h/3d   Inode: 4026532392  Links: 1

Access: (0444/-r–r–r–)  Uid: (    0/    root)   Gid: (    0/    root)

Access: 2015-07-28 14:28:13.724408143 +0800

Modify: 2015-07-28 14:28:13.724408143 +0800

Change: 2015-07-28 14:28:13.724408143 +0800

Birth: –

可以看到它们最终对应的是同一个文件.

3) 总结

在这一节里, 我们看到了namespace的生命周期以及各个阶段对应的操作. 这些操作都可在用户空间直接进行, LXC和docker的底层都是基于这些操作. 在进行操作的时候, 有一个基本原则, 那就是只有当前进程才能操作自己所在的namespace, Linux并没有接口来改变另一个进程的namespace.

 

New Home

自从07年开通ericxiao.cublog.cn这个博客开始就养成了记录学习心得的习惯,值得欣慰的是这个习惯一直坚持到了09年。其后因为工作甚忙亦为了生活琐事奔波,平时所学只是用slides草草记录,另一部份原因是由于cublog的改版,强制将我甚为喜欢的主题强制删除,到论坛反馈遭遇野蛮删贴,很是心灰意冷。将分享的习惯一放就是近六年。

现在回过头来看之前的文字不甚唏嘘,幼稚的文字和众多的错误惨不忍睹。坚持自己观点而争执半天的小伙伴也是各奔东西。不管如何,这是修行路上必经的一个过程,很庆幸当时凭着爱好和热情记录下了学习的历程,让我现在有机会能反省能修正。

或许是因为年纪开始大了的缘故,学过的东西变的越来越模糊,也或许是因为要学的东西太多,总结变得尤为重要。开通了一个自己的站点,用来提醒自己不应该放慢脚步,也在一个新的地方继续来“毁”人不倦。

路漫漫其修远兮,吾将上下而求索!

2015.08.13