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的架构。

http://52.42.173.140/?attachment_id=126

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