🐠

lscpuの池を泳ぎ、kernelの海に潜る

2024/10/05に公開

はじめまして、inox-ee です。
アウトプット活動を頑張ろうと、zennをおもむろに開きました。

月1くらいで投稿できるといいな…

ふとした気付き

本日のネタは lscpu です。

先日libvirtでVMを立てて遊んでいた際、ふと実行したlscpuの結果を見てあることに気が付きました。

# VMが立っている場合
$ lscpu
...
Vulnerabilities:
...
  Itlb multihit:         KVM: Mitigation: Split huge pages
...
# VMが無い場合
$ lscpu
...
Vulnerabilities:
...
  Itlb multihit:         KVM: Mitigation: VMX disabled
...

上記の通り、VMの有無によって Itlb miltihit なる脆弱性のMitigationの結果が変わっていますね。

まぁ分からなくはないですが、CPU脆弱性対応がプロセスの有無により変わるのは不思議です。せっかくなのでどういうロジックでこのMitigationを判定しているか見ていきましょう。

iTLB multihit 脆弱性とは

まずはこの脆弱性について調べてみることに。検索するとちゃんとドキュメントがヒットしました。iTLB multihit — The Linux Kernel documentation

曰く、

iTLB multihit is an erratum where some processors may incur a machine check error, possibly resulting in an unrecoverable CPU lockup, when an instruction fetch hits multiple entries in the instruction TLB.

iTLBマルチヒットとは、命令フェッチが命令TLBの複数のエントリにヒットした場合、一部のプロセッサでマシンチェックエラーが発生し、回復不可能なCPUロックアップが発生する可能性があるという不具合である。(DeepL訳)

とのこと。(こういう不具合を erratta というんですね。カタカナ『エラッタ』と聞くと一番に『正誤表』が思い浮かびます。)

TLBってなんだっけ

通常のTLB(Translation Lookaside Buffer)は、主記憶からページテーブルの一部をコピーすることでテーブルウォークの遅さを回避するために用いられます。
TLBのエントリには、仮想アドレスとそれに対応する物理アドレスが記録されており、命令フェッチをはじめとするメモリアクセスが発生した際に主記憶まで行かずともアドレス変換を得ることができます。ページテーブルとTLBの関係は、主記憶とキャッシュメモリの関係に擬えられますね。

ちなみにiTLBの iintruction のようです。逆に dTLB と言われればそれは data を指すみたい。

iTLB multihitとは

一般的なTLBミスは、欲しいアドレス変換がTLB上に記録されていないことを指すでしょう。ミスが続きL1からL2へ深く潜っていくほど実行サイクルが伸びていくといった挙動になります。

これに対し、iTLB multihitで語られるミス(?)は、検索キーとなる仮想アドレスのエントリが複数個TLB上に存在するような状況と言われているようです。可能性としては十分にあり得そうですね。特に、特権ソフトウェアがページング構造を変更しHugepage(2MB, 4MB, 1GB)を使用した際に、iTLBエントリの無効化が遅れ、同じ仮想アドレスでコードフェッチが走ると駄目なよう。マシンチェックエラーが発生し、システムのハングアップにつながるとか。

Mitigationあれこれ

いくつかMitigationが実装されているようです。

Status Description
Not affected The processor is not vulnerable.
KVM: Mitigation: Split huge pages Software changes mitigate this issue.
KVM: Mitigation: VMX unsupported KVM is not vulnerable because Virtual Machine Extensions (VMX) is not supported.
KVM: Mitigation: VMX disabled KVM is not vulnerable because Virtual Machine Extensions (VMX) is disabled.
KVM: Vulnerable The processor is vulnerable, but no mitigation enabled.

書いてある通りですが、VMの有無によってこのMitigationのstatusが変化していたようです。

動かしていたVMについて

ようやく主題の lscpu に話が戻ると思いきや、もう少し調べることがあります。

「VMの有無」= 〇〇

冒頭ではあえて雑に書いていましたが、本ホストに作成していたVMはQemuでした。
正確にはOpenStack(≒ nova)から叩いたlibvirt経由のQemuプロセスですが、本題においてQemuより上はあまり関係ないでしょう。

ここで注視しなければならないのは、QemuのCPUエミュレーションを何で行っているか、ですね。
実際に立ち上がっているQemuのプロセスを見ると、以下の通り。

$ ps aux | grep qemu
root     74725 41.7  1.0 xxx xxx ?     Sl   Sep04 18774:43 qemu-system-x86_64 -enable-kvm -name guest=... -machine ...,accel=kvm,... -cpu ...

アクセラレーターのオプションは -accel: kvm となっていました。これすなわち今回のQemuプロセスは、LinuxのKVMを介しホストCPUが持つ仮想化支援機構を活用していることを意味します。この仮想化支援機構こそが VMX、Virtual Machine Extensionと呼ばれるものになります[1]

VMX周りの挙動/設定を確認する

今回このVMXが槍玉に上がっているので、諸々確認していきましょう。

BIOS/UEFI

詳細は割愛しますが、ちゃんとVMXはenabledになっていました。BIOSで封じられているわけではなさそうです。

cpu flag

そもそもCPUがvmxに対応しているか見ておきます。

$ lscpu | grep vmx
Flags:    ... vmx ...

さすがにありますね。

ログとか

場合により、QemuプロセスがアクセラレーターとしてKVMを使っている気になっているものの、実はTCG(Tiny Code Generator)にフォールバックされており、全てソフトウェア側で仮想化を頑張っているということもあるようです。

しかし、KVMからTCG等にフォールバックされた旨は各種ログを見ても確認できないため、ちゃんとKVMを使っていると考えられます。

Kernel modules

ちゃんとカーネルモジュールも見ておきましょう。lsmod でkvmがいる&使われてる様子が確認できます。

$ lsmod | grep kvm
kvm_intel             368640  91
kvm                  1032192  1 kvm_intel

以上から、ホストに作成したVMすなわちQemuプロセスは、たしかにKVMを介してVMXを利用していることが確認できました。

ちなみにVMが一つもない場合は kvm_intelのUsed byが0になります。

$ lsmod | grep kvm
kvm_intel             368640  0
kvm                  1032192  1 kvm_intel

lscpu の中身へ

あらかたホスト上の情報は得たので、実際にコードを見ていきましょう。

lscpu を覗く

lscpu のソースコードは多分ここ。util-linux/sys-utils/lscpu.c at master · util-linux/util-linux

main 関数のそれっぽい箇所に print_summary 関数がありますね。
lscpu_cxt 構造体から情報を引っ張り、いい感じにprintしているようです。

// sys-utils/lscpu.c

/*
 * default output
 */
static void print_summary(struct lscpu_cxt *cxt)
{
  ...
  /* Section: Vulnerabilities */
  if (cxt->vuls) {
    sec = add_summary_e(tb, NULL, _("Vulnerabilities:"));

    for (i = 0; i < cxt->nvuls; i++) {
      snprintf(field, sizeof(field), hierarchic ?
        _("%s:") : _("Vulnerability %s:"), cxt->vuls[i].name);
      add_summary_s(tb, sec, field, cxt->vuls[i].text);
    }
    sec = NULL;
  }
  ...
}

この変数 cxt は、print_summary 関数が呼ばれる数行前の lscpu_read_vulnerabilities(cxt); にて脆弱性情報が格納されているようです。
C言語は結構忘れてしまいましたが、斜め読みでも何をしたいかは理解し易いですね。

// sys-utils/lscpu-cputype.c

int lscpu_read_vulnerabilities(struct lscpu_cxt *cxt)
{
  ...

  dir = ul_path_opendir(cxt->syscpu, "vulnerabilities");
  if (!dir)
    return 0;

  ...

  rewinddir(dir);
  cxt->vuls = xcalloc(n, sizeof(struct lscpu_vulnerability));

  while (cxt->nvuls < n && (d = xreaddir(dir))) {
    char *str, *p;
    struct lscpu_vulnerability *vu;

    ...
    if (ul_path_readf_string(cxt->syscpu, &str,
      "vulnerabilities/%s", d->d_name) <= 0)
    continue;

    vu = &cxt->vuls[cxt->nvuls++];

    /* Name */
    vu->name = xstrdup(d->d_name);
    *vu->name = toupper(*vu->name);
    strrep(vu->name, '_', ' ');

    /* Description */
    vu->text = str;
    p = (char *) startswith(vu->text, "Mitigation");
    if (p) {
    *p = ';';
    strrem(vu->text, ':');
    }
  }
  closedir(dir);

  ...
}

ここで重要となるのが、lscpu はあくまで cnt->syscpu が指すパスのディレクトリを読み出しているだけ、ということでしょうか。その中身からNameとDescriptionをいい感じにパースし、lscpu_vulnerability 構造体(*vu)に格納しているだけである、ということが理解できました。

ではこの cnt->syscpu が指す先はどこでしょうか。適当に全文検索すると、ヘッダーファイルにたどり着きます。

// sys-utils/lscpu.h

#define _PATH_SYS_SYSTEM "/sys/devices/system"
#define _PATH_SYS_HYP_FEATURES "/sys/hypervisor/properties/features"
#define _PATH_SYS_CPU  _PATH_SYS_SYSTEM "/cpu"
#define _PATH_SYS_NODE  _PATH_SYS_SYSTEM "/node"
#define _PATH_SYS_DMI  "/sys/firmware/dmi/tables/DMI"
#define _PATH_ACPI_PPTT  "/sys/firmware/acpi/tables/PPTT"

ということで /sys/devices/system/cpu と判明しました。sysfsから読んでいたんですね。

sysfs ってなんだっけ

kernel.orgに記載ありました。sysfs - The filesystem for exporting kernel objects — The Linux Kernel documentation

sysfs is a RAM-based filesystem initially based on ramfs. It provides a means to export kernel data structures, their attributes, and the linkages between them to userspace.

sysfs is tied inherently to the kobject infrastructure. Please read Everything you never wanted to know about kobjects, ksets, and ktypes for more information concerning the kobject interface.

案外わかりにくいですね。

もう少し調べると、manにその目的/意図が簡潔に書かれていました。

The sysfs filesystem is a pseudo-filesystem which provides an interface to kernel data structures. (More precisely, the files and directories in sysfs provide a view of the kobject structures defined internally within the kernel.) The files under sysfs provide information about devices, kernel modules, filesystems, and other kernel components.

「デバイス、カーネルモジュール等に関する情報が格納された、カーネルデータ構造を表す疑似(=オンメモリな)ファイルシステム」と読めるでしょうか。成程 lscpu がここから読み出すのが納得出来ますね。

ABIにも記載がありました。kernel.org/doc/Documentation/ABI/testing/sysfs-devices-system-cpu

ということでkernelの該当箇所を読んでいきましょう。

kernel を覗く[2]

sysfsに関する実装がどこにあるか 調べるのが面倒 アテがなかったため、ChatGPTに雑に聞いてみました。

kernel_sysfs_vul
うーむ、すごい。

ということで該当箇所を見に行きましょう。ちなみにファイルは正解でしたが関数(とその内容)は普通に嘘でした。まだまだですね。

// arch/x86/kernel/cpu/bugs.c

static ssize_t itlb_multihit_show_state(char *buf)
{
  if (!boot_cpu_has(X86_FEATURE_MSR_IA32_FEAT_CTL) ||
      !boot_cpu_has(X86_FEATURE_VMX))
    return sysfs_emit(buf, "KVM: Mitigation: VMX unsupported\n");
  else if (!(cr4_read_shadow() & X86_CR4_VMXE))
    return sysfs_emit(buf, "KVM: Mitigation: VMX disabled\n");
  else if (itlb_multihit_kvm_mitigation)
    return sysfs_emit(buf, "KVM: Mitigation: Split huge pages\n");
  else
    return sysfs_emit(buf, "KVM: Vulnerable\n");
}

いよいよ今回の中核が見えてきました。冒頭で話した lscpu の情報と合わせると、

  • qemu-kvmのプロセスが無い時: cr4_read_shadow() & X86_CR4_VMXE = 0
  • qemu-kvmのプロセスがある時: cr4_read_shadow() & X86_CR4_VMXE != 0 かつ itlb_multihit_kvm_mitigation = truthy

と導くことができます。

頑張ってそれぞれ見ていきましょう。

ここからは自分の知識も文献も少なかったため、不正確な情報を含む可能性があります。間違いあればご指摘ください。

cr4_read_shadow()

arch/x86/kernel/cpu/common.c 内に定義されていました。

// arch/x86/kernel/cpu/common.c

/* Read the CR4 shadow. */
unsigned long cr4_read_shadow(void)
{
  return this_cpu_read(cpu_tlbstate.cr4);
}

情報量少ないですね。一つひとつ見ていく必要がありそうです。

CR4 について

これはControl Registerを表しているようです。CPUの文脈で一般的なのは演算に用いられる「汎用レジスタ」ですが、この「制御レジスタ」は制御情報の格納に使われます。

Intel® 64 and IA-32 Architectures Software Developer Manuals にその詳細があったのでいくつか引用します。

The five control registers (CR0 through CR4) determine the operating mode of the processor and the characteristics of the currently executing task.

制御レジスタはCR0~CR4,CR8の5+1種類存在し、プロセッサの動作モードと実行中のタスクの特性を決定します。CR8は64bitモードで利用可能とのこと。

Contains a group of flags that enable several architectural extensions, and indicate operating system or executive support for specific processor capabilities.

その中の一つである CR4 は、OS等からサポートを受けるためのアーキテクチャ拡張の有効化フラグとして機能するレジスタのようです。

intel_x86_control_registers
Intel® 64 and IA-32 Architectures Software Developer Manualsより引用

そして13bit目を見ると、"VMXE" の文字が。まさに VMX-Enabled Bit(bit 13 of CR4) - Enables VMX operation when set. と説明されており、今回の主題の鍵となりそうなbitです。これを頭の片隅に置きつつ、続くkernelのコードを読んでいきましょう。

this_cpu_read()

/include/linux/percpu-defs.h で定義されています。

// /include/linux/percpu-defs.h

#define this_cpu_read(pcp)  __pcpu_size_call_return(this_cpu_read_, pcp)

__pcpu_size_call_return() は数十行上で定義されています。

// /include/linux/percpu-defs.h

#define __pcpu_size_call_return(stem, variable)    \
({         \
  typeof(variable) pscr_ret__;     \
  __verify_pcpu_ptr(&(variable));     \
  switch(sizeof(variable)) {     \
  case 1: pscr_ret__ = stem##1(variable); break;   \
  case 2: pscr_ret__ = stem##2(variable); break;   \
  case 4: pscr_ret__ = stem##4(variable); break;   \
  case 8: pscr_ret__ = stem##8(variable); break;   \
  default:       \
    __bad_size_call_parameter(); break;   \
  }        \
  pscr_ret__;       \
})

(私のC言語力が足らず細かいところはわかりませんが)、this_cpu_read() 関数は変数 pcp のサイズ n に応じ、this_cpu_read_n(pcp) を呼び出しているようです。

// arch/x86/include/asm/percpu.h

// 変数 `CONFIG_USE_X86_SEG_SUPPORT` が定義済みか否かで分岐
#define this_cpu_read_1(pcp)  __raw_cpu_read(volatile, pcp)
...
#define __raw_cpu_read(qual, pcp)     \
({         \
  *(qual __my_cpu_type(pcp) *)__my_cpu_ptr(&(pcp));  \
})
// もしくは
#define this_cpu_read_1(pcp)  percpu_from_op(1, volatile, "mov", pcp)
...
#define percpu_from_op(size, qual, op, _var)    \
({         \
  __pcpu_type_##size pfo_val__;     \
  asm qual (__pcpu_op2_##size(op, __percpu_arg([var]), "%[val]") \
      : [val] __pcpu_reg_##size("=", pfo_val__)   \
      : [var] "m" (__my_cpu_var(_var)));    \
  (typeof(_var))(unsigned long) pfo_val__;   \
})

命令をイイカンジに組み立て、Per-CPU variableなる各CPUごとに定義(格納)されている変数をインラインアセンブリを利用してnバイト読み出す、といったことをしているように見えます。が、だいぶエネルギーが切れてきました…。一旦 this_cpu_read() はここまで。

cpu_tlbstate.cr4

引き続き拠り所となる文献がなかったため、コードと推測で進めていきます。

この cpu_tlbstate は、どうやら arch/x86/mm/init.c で定義されているようです。

// arch/x86/mm/init.c

__visible DEFINE_PER_CPU_ALIGNED(struct tlb_state, cpu_tlbstate) = {
  .loaded_mm = &init_mm,
  .next_asid = 1,
  .cr4 = ~0UL, /* fail hard if we screw up cr4 shadow initialization */
};

このファイルは mm = Memory Management の初期化ファイルでしょう。cpu_tlbstatetlb_state 型の構造体であり、初期化ブロック内にて .cr4 も特定の値が与えられています。ここでは ~0UL となっているため、おそらく Unsigned long 型の1ビット埋めが初期値とされているようですね。ただ、続くコメントに「CR4シャドウレジスタの初期化を誤った場合にhard failする」とあるため、これ自体はinvalidな値だと考えられます。

CR4の初期化はここですね。

// arch/x86/kernel/cpu/common.c

void cr4_init(void)
{
  unsigned long cr4 = __read_cr4();

  if (boot_cpu_has(X86_FEATURE_PCID))
    cr4 |= X86_CR4_PCIDE;
  if (static_branch_likely(&cr_pinning))
    cr4 = (cr4 & ~cr4_pinned_mask) | cr4_pinned_bits;

  __write_cr4(cr4);

  /* Initialize cr4 shadow for this CPU. */
  this_cpu_write(cpu_tlbstate.cr4, cr4);
}

__read_cr4() はその名の通り、 X86_FEATURE_PCID は PCID(Process-Context Identifiers)機能をサポートしているかを確認しています。次の行もCRピンニングが有効かどうかのチェックですね。
最後に、更新された CR4 レジスタの値を書き込み、初期化としています。

X86_CR4_VMXE はどこでレジスタに書き込まれているか?

これまでcr4レジスタへの読み書き実装を見てきましたが、まだ本題の答えには辿り着いていません。次は X86_CR4_VMXE を手がかりにコードリーディングを進めていきます。

雑に全文検索すると以下の実装が見つかります。

// arch/x86/kvm/vmx/vmx.c
static int kvm_cpu_vmxon(u64 vmxon_pointer)
{
  u64 msr;

  cr4_set_bits(X86_CR4_VMXE);

  asm goto("1: vmxon %[vmxon_pointer]\n\t"
      _ASM_EXTABLE(1b, %l[fault])
      : : [vmxon_pointer] "m"(vmxon_pointer)
      : : fault);
  return 0;

  fault:
  WARN_ONCE(1, "VMXON faulted, MSR_IA32_FEAT_CTL (0x3a) = 0x%llx\n",
    rdmsrl_safe(MSR_IA32_FEAT_CTL, &msr) ? 0xdeadbeef : msr);
  cr4_clear_bits(X86_CR4_VMXE);

  return -EFAULT;
}

kvm_cpu_vmxon() では cr4_set_bits(X86_CR4_VMXE) が呼び出され、先述の VMX-Enabled Bit がセットされていることが分かります。これはいよいよ物語の核心に迫ってきたようですね。

kvm_cpu_vmxon() は同ファイルに定義されている vmx_hardware_enable() から呼び出されますが、この関数はKVMのアーキテクチャ固有のメソッドであるため、以下のように kvm_x86_ops 構造体のプロパティとして定義されています。

// arch/x86/kvm/vmx/main.c
struct kvm_x86_ops vt_x86_ops __initdata = {
  ...

  .hardware_enable = vmx_hardware_enable,
  .hardware_disable = vmx_hardware_disable,
  .has_emulated_msr = vmx_has_emulated_msr,

  ...
}

ここから、手がかりがなかなか見つからずGitHub Copilotに聞けども嘘コードしか提示せず、心が折れかけていました…。
が、辛抱強く粘ったところ以下の定義を発見。

// arch/x86/include/asm/kvm_host.h
#define KVM_X86_OP(func) \
 DECLARE_STATIC_CALL(kvm_x86_##func, *(((struct kvm_x86_ops *)0)->func));

これより、先の関数は KVM_X86_OP(hardware_enable)kvm_x86_hardware_enable という形で呼び出されているであろう、と推察できますね。
実際に見つかったコードがこちら。

// arch/x86/kvm/x86.c
int kvm_arch_hardware_enable(void)
{
  struct kvm *kvm;
  struct kvm_vcpu *vcpu;
  unsigned long i;
  int ret;
  u64 local_tsc;
  u64 max_tsc = 0;
  bool stable, backwards_tsc = false;

  ...

  ret = static_call(kvm_x86_hardware_enable)();
  if (ret != 0)
  return ret;

  local_tsc = rdtsc();
  stable = !kvm_check_tsc_unstable();
  list_for_each_entry(kvm, &vm_list, vm_list) {
  kvm_for_each_vcpu(i, vcpu, kvm) {
    ...
  }
  }

  ...
}

kvm_arch_hardware_enable はその名の通りKVMアーキテクチャのハードウェアを有効化させる関数のようですね。
この関数を起点として、またメソッド呼び出しを遡ってみます。
kvm_arch_hardware_enable__hardware_enable_nolockhardware_enable_nolockhardware_enable_all と辿ることができ、最終的に kvm_create_vm() という関数に着地することが確認できました。

kvm_create_vm() 関数とは

以下が kvm_create_vm() の実装となります。その名の通り、新しいKVMインスタンスを作成し初期化する関数ということが読み取れます。

// virt/kvm/kvm_main.c
static struct kvm *kvm_create_vm(unsigned long type, const char *fdname)
{
  struct kvm *kvm = kvm_arch_alloc_vm();

  ...

  r = kvm_arch_init_vm(kvm, type);
  if (r)
    goto out_err_no_arch_destroy_vm;

  r = hardware_enable_all();
  if (r)
    goto out_err_no_disable;

  ...

  r = kvm_init_mmu_notifier(kvm);
  if (r)
    goto out_err_no_mmu_notifier;

  r = kvm_coalesced_mmio_init(kvm);
  if (r < 0)
    goto out_no_coalesced_mmio;

  r = kvm_create_vm_debugfs(kvm, fdname);
  if (r)
    goto out_err_no_debugfs;

  r = kvm_arch_post_init_vm(kvm);
  if (r)
    goto out_err;

  mutex_lock(&kvm_lock);
  list_add(&kvm->vm_list, &vm_list);
  mutex_unlock(&kvm_lock);

  preempt_notifier_inc();
  kvm_init_pm_notifier(kvm);

  return kvm;
...
}

この関数は誰から呼び出されているのでしょうか。
これまでと同様に調べていくと、kvm_creae_vm()kvm_dev_ioctl_create_vm()kvm_dev_ioctl() に辿り着きます。

static long kvm_dev_ioctl(struct file *filp,
     unsigned int ioctl, unsigned long arg)
{
  int r = -EINVAL;

  switch (ioctl) {
  case KVM_GET_API_VERSION:
    if (arg)
    goto out;
    r = KVM_API_VERSION;
    break;
  case KVM_CREATE_VM:
    r = kvm_dev_ioctl_create_vm(arg);
    break;
  case KVM_CHECK_EXTENSION:
    r = kvm_vm_ioctl_check_extension_generic(NULL, arg);
    break;
  case KVM_GET_VCPU_MMAP_SIZE:
    if (arg)
    goto out;
    r = PAGE_SIZE;     /* struct kvm_run */
#ifdef CONFIG_X86
  r += PAGE_SIZE;    /* pio data page */
#endif
#ifdef CONFIG_KVM_MMIO
  r += PAGE_SIZE;    /* coalesced mmio ring page */
#endif
    break;
  default:
    return kvm_arch_dev_ioctl(filp, ioctl, arg);
  }
out:
  return r;
}

これより、kvm_creae_vm()はioctlシステムコール[3]の一つであるKVM_CREATE_VMコマンドが呼ばれた際に実行されるメソッドと解明できました。

KVM API とは

少し道を逸れますが、KVM APIについても触れておきます。[4]

KVMは、KVM APIと呼ばれるioctlのセットを介して操作されます。
KVM APIはファイルディスクリプタを中心に構成されており、/dev/kvmで取得したKVMサブシステムへのハンドルに対して KVM_CREATE_VM ioctlを実行すると、VMファイルディスクリプタが作成される…という流れで実装されています。

今回話題に上がっていた KVM_CREATE_VM コマンドもドキュメントに記載されています。

4.2 KVM_CREATE_VM
Capability:
basic

Architectures:
all

Type:
system ioctl

Parameters:
machine type identifier (KVM_VM_*)

Returns:
a VM fd that can be used to control the new virtual machine.

The new VM has no virtual cpus and no memory. You probably want to use 0 as machine type.

X86:
Supported X86 VM types can be queried via KVM_CAP_VM_TYPES.
...

他に比べてシンプルですね。空のVMを作成し、ファイルディスクリプタを返すコマンドとなっています。

舞台は始まりの地、QEMUへ

誰そ KVM_CREATE_VM を呼ぶ

ここまでの情報から、KVM APIのうち KVM_CREATE_VM が呼ばれることでkernel内の kcm_create_vm() 関数が実行され、その処理の辿り着く先としてCR4の13ビット目であるVMX-Enabled Bitがセットされることが分かりました。

ということで、物語の締めとして、システムコールを呼び出すヌシの実装を読んでいきましょう。

QEMUコードリーディング

啖呵を切ったものの、ここからガッツリ挙動を追いかけると深海に向かってしまうため、浅瀬をちゃぷちゃぷして分かった気になりましょう。
ということで一気に行きます。

  1. system/main.c

なお、これがエントリポイントであるかはだいぶ怪しい。

int main(int argc, char **argv)
{
    qemu_init(argc, argv);
    return qemu_main();
}
  1. system/vl.c

これがエントリポイントであるという説は見かけました(真偽不明)。
vlが何の略かも不明です。

void qemu_init(int argc, char **argv)
{
  ...
  configure_accelerators(argv[0]);
  ...
}

static void configure_accelerators(const char *progname)
{
  ...
  if (!qemu_opts_foreach(qemu_find_opts("accel"),
                         do_configure_accelerator, &init_failed, &error_fatal)) {
    if (!init_failed) {
      error_report("no accelerator found");
    }
    exit(1);
  }
  ...
}

static int do_configure_accelerator(void *opaque, QemuOpts *opts, Error **errp)
{
  ...
  ret = accel_init_machine(accel, current_machine);
  ...
}
  1. qemu/accel/accel-system.c

このあたりでアクセラレータに関する実装が記述されている。

int accel_init_machine(AccelState *accel, MachineState *ms)
{
    AccelClass *acc = ACCEL_GET_CLASS(accel);
    int ret;
    ms->accelerator = accel;
    *(acc->allowed) = true;
    ret = acc->init_machine(ms);
    if (ret < 0) {
        ms->accelerator = NULL;
        *(acc->allowed) = false;
        object_unref(OBJECT(accel));
    } else {
        object_set_accelerator_compat_props(acc->compat_props);
    }
    return ret;
}
  1. accel/kvm/kvm-all.c

KVMをアクセラレータとした際のメイン実装がこのファイル。
kvm_init() 関数でKVMを初期化する際に、kvm_ioctl(s, KVM_CREATE_VM, type) と、本稿で探し求めていた命令が見つかる。

static void kvm_accel_class_init(ObjectClass *oc, void *data)
{
  AccelClass *ac = ACCEL_CLASS(oc);
  ac->name = "KVM";
  ac->init_machine = kvm_init;
  ...
}

static int kvm_init(MachineState *ms)
{
  MachineClass *mc = MACHINE_GET_CLASS(ms);
  ...
  do {
    ret = kvm_ioctl(s, KVM_CREATE_VM, type);
  } while (ret == -EINTR);
  if (ret < 0) {
    fprintf(stderr, "ioctl(KVM_CREATE_VM) failed: %d %s\n", -ret,
            strerror(-ret));

#ifdef TARGET_S390X
    if (ret == -EINVAL) {
      fprintf(stderr,
              "Host kernel setup problem detected. Please verify:\n");
      fprintf(stderr, "- for kernels supporting the switch_amode or"
              " user_mode parameters, whether\n");
      fprintf(stderr,
              "  user space is running in primary address space\n");
      fprintf(stderr,
              "- for kernels supporting the vm.allocate_pgste sysctl, "
              "whether it is enabled\n");
    }
#elif defined(TARGET_PPC)
    if (ret == -EINVAL) {
      fprintf(stderr,
              "PPC KVM module is not loaded. Try modprobe kvm_%s.\n",
              (type == 2) ? "pr" : "hv");
    }
#endif
    goto err;
  }

  s->vmfd = ret;
  ...
}

念の為 kvm_ioctl() 関数を覗くと、ioctlシステムコールを呼んでいることが確認できる。

int kvm_ioctl(KVMState *s, int type, ...)
{
    int ret;
    void *arg;
    va_list ap;

    va_start(ap, type);
    arg = va_arg(ap, void *);
    va_end(ap);

    trace_kvm_ioctl(type, arg);
    ret = ioctl(s->fd, type, arg);
    if (ret == -1) {
        ret = -errno;
    }
    return ret;
}

ということで、KVM_CREATE_VM コマンドを実行するユーザアプリケーションの代表例として、QEMUのソースコードから実際の現場を確認することができました。

調査結果

  • 最新の lscpu コマンドには、CPU脆弱性情報が記載される。
  • 著名な脆弱性の一つであるiTLB_multihitに関する情報は、/sys/devices/system/cpu から読み出される。
  • iTLB_multihitの対応状況は、kernel内に実装された特定条件により決定される。
  • 条件分岐の核であるVMXのon/offは、制御レジスタ CR4 の13ビット目により決定される。
  • x86 KVMの場合、KVM APIとしてユーザスペースからioctlシステムコールの一つである KVM_CREATE_VM を受け付けている。このシステムコールが呼び出された際、kernel内でVM作成の処理が実行され、CR4 の13ビット目がセットされる。
  • たしかに、QEMUでは初期化処理(?)において、当該システムコールを呼び出している。

感想

疲れました…。

C言語の大部分を忘れたことで文法に詰まることも少なからずあり、またkernelは複数のモジュールをサポートすることでドライバが多様化し、実装も分散しているため、かなり根気のいる調査となりました。

ですが、当然ながら得た学びも多いですね。
また、今回は一つのテーマからコードを読み進めていくことを目的としていましたが、「読み解く」レベルまでは到底到達せず、かなりガバガバな理解に留まっている部分も多々あります。とはいえここまで歩を進めることが出来たのは、やはりkernelのコードの洗練さであったり、各関数が適切なスコープと適切なネーミングにより構成されていることが大きいと感じています。これが苦手なんだよな…。

また、GitHub Copilot、ChatGPTともにコードリーディングの助けになりましたね。嘘か本当か怪しい回答ばかりしてくるものですが、前者は具体的なコードを(特に @workspace コマンドを知ってから捗った)、後者はアーキテクチャの確認において強力であったのは間違いないと思います。

そしてなにより、自身の未熟な知識と真偽不明のLLMを抱えながら、複雑かつ大規模なコードに対して自身が組み立てている理解のラインが正しいかを計るには、システムの全体像や予備知識などの前提になぞって進めることが不可欠だと改めて認識しました。kernelやQEMU、CPUについての理解がまだまだ足りないことを痛感しています。実際CPUアーキテクチャの違いとか準仮想化周りのロジックも勘所も全然分かってないからな…。これからも精進していきます。

では今回はこんなところで。

脚注
  1. VMXは果たして一般名称なのでしょうか?Intel製CPUの仮想化支援機構が VT-x、AMD製CPUが _AMD-V_と呼ばれることは間違いないと思いますが、/proc/cpuinfoなどを覗くと、cpu flagにはIntel VT-xがVMX、AMD AMD-Vはsvmと表記が区別されています。 ↩︎

  2. kernelをオンラインで読むのは何が良いんでしょう?自分はThe Linux Kernel Archivesから browse で見ていましたが、会社の先輩に教えていただいた arch - Linux source code (v6.9.7) - Bootlin はかなり読みやすいですね。わざわざローカルにDLしてVSCodeで見なくとも、定義ジャンプや参照ジャンプ出来るのが便利なポイント。 ↩︎

  3. ioctlは、システムコールの一つです。アプリケーションがデバイスドライバを制御したり、通常のデータの読み書きの流れの外で通信したりするために用意されています。詳しくは ioctl based interfaces — The Linux Kernel documentation↩︎

  4. と言いつつここを見ているだけです。The Definitive KVM (Kernel-based Virtual Machine) API Documentation — The Linux Kernel documentation ↩︎

Discussion