- 1. vmlinuxのELFセクション
- 2. .parainstructionsセクション
- 3. .altinstructions、.altinstr_replacementセクション
- 4. apply_paravirt()、apply_alternative()
- 5. 休憩
執筆者 : 箕浦 真
1. vmlinuxのELFセクション
前回記事では、vmlinuxのinitセグメントに含まれる、.percpu
、.init.text
、.init.data
などのセグメントについて調べた。今回はその続きで、そのinitセグメントに含まれている.parainstructions
、.altinstructions
、.altinstr_replacement
の各セクションを取り上げる。これらは、初期化時にLinuxコード自体を書き換えることで、環境に応じた最適化を行う仕組みで使われている。
2. .parainstructions
セクション
.parainstructions
セクションの定義は、x86依存部にある。
/* * start address and size of operations which during runtime * can be patched with virtualization friendly instructions or * baremetal native ones. Think page table operations. * Details in paravirt_types.h */ . = ALIGN(8); .parainstructions : AT(ADDR(.parainstructions) - LOAD_OFFSET) { __parainstructions = .; *(.parainstructions) __parainstructions_end = .; }
いつものように、先頭と末尾にシンボルが付与され、その間には、オブジェクトファイル (*.o
) の同名のセクションが集められる。
コメントには、ベアメタル用のネイティブ命令を、仮想化に適した命令に、実行時に書き換える、というような趣旨が書かれている。筆者は、paravirtualization (準仮想化、PV) という言葉をXenが登場した時に初めて聞いたと思う。IntelアーキテクチャはPopekとGoldbergの要件を満たしていなかったため、仮想化にはゲストに手を入れる必要があった。この手法による仮想化を指して準仮想化と呼んでいた。Intel VT-xやAMD SVMにより完全仮想化が実現できるようになった現在でも、完全仮想化ではオーバーヘッドが大きい部分のみゲストに手を入れることで、効率を上げる手法が用いられている。Virtioなどの仮想的なデバイスもこの手法の一つであるが、CPUの制御レジスターのアクセスやキャッシュ制御などにもPVの手法が用いられる。
さてこの.parainstructions
というセクションにコードを配置するマクロは、<asm/paravirt.h>、<asm/paravirt_types.h>の2箇所に定義されている。前者はアセンブラーコードから呼ばれるもの、後者はCコードから呼ばれるものである。
2.1. PV_SITE
マクロ
このうち、前者は、
#define _PVSITE(ptype, clobbers, ops, word, algn) \ 771:; \ ops; \ 772:; \ .pushsection .parainstructions,"a"; \ .align algn; \ word 771b; \ .byte ptype; \ .byte 772b-771b; \ .short clobbers; \ .popsection
アセンブラーコードから呼ばれるマクロである。訳がわからない。
.pushsection
、.popsection
というのはアセンブラーディレクティブである。.pushsection
は、今までのセクションをどこかに覚えておいて、以降のコードを指定のセクション、この場合は.parainstructions
にリンクする。.popsection
は、以降のコードを覚えておいた元のセクションに戻す。push〜popという言葉から分かるように、入れ子にしてスタック的に利用できる。
771:
、772:
というのは、GNUアセンブラーのローカルシンボルというやつだ。ソースコード中に何度出てきても良いので、マクロで使うのに最適だ。参照するときには、771f
、771b
のようにfやbを付ける。それぞれ、直後の771
、直前の771
を指す。上記引用コードでは、どちらもbなので、直前すなわち上記に登場した771
や772
を指すことになる。直前とは言っても、.pushsection
でセクションが変わっているので、リンクされるのは離れたところになる。
64bit (x86_64) では、このPV_SITE
マクロは
#define PARA_SITE(ptype, clobbers, ops) _PVSITE(ptype, clobbers, ops, .quad, 8)
という別なマクロから呼び出されるので、常に
771:; ops; 772:; .pushsection .parainstructions,"a"; .align 8; .quad 771b; .byte ptype; .byte 772b-771b; .short clobbers; .popsection
という形で使用されることになる。.quad
、.byte
、.short
は、指定の数値をそれぞれ64bit、8bit、16bitでそのまま出力する。依然としてよくわからないが、ptype
は8bitの数値、clobbers
は16bitの数値らしい。
PARA_SITEは、同じファイルに定義されている幾つかのマクロから呼び出されている。たとえば以下だ。
#define INTERRUPT_RETURN \ PARA_SITE(PARA_PATCH(pv_cpu_ops, PV_CPU_iret), CLBR_NONE, \ ANNOTATE_RETPOLINE_SAFE; \ jmp PARA_INDIRECT(pv_cpu_ops+PV_CPU_iret);)
その他のマクロも含めて展開してみよう*1。
#define INTERRUPT_RETURN \ 771:; \ jmp *pv_cpu_ops+PV_CPU_iret(%rip); \ 772:; \ .pushsection .parainstructions,"a"; \ .align 8; \ .quad 771b; \ .byte (PARAVIRT_PATCH_pv_cpu_ops+PV_CPU_iret)/8; \ .byte 772b-771b; \ .short 0; \ .popsection
まだ分からない。PARAVIRT_PATCH_pv_cpu_ops
というのはマクロで、arch/x86/kernel/asm-offsets.cからコンパイル時に自動生成される<generated/asm-offsets.h>というファイルの中で定義されていて、offsetof(struct paravirt_patch_template, pv_cpu_ops)
である24という値を持つ。同様に、PV_CPU_iret
は、offsetof(struct pv_cpu_ops, iret)
で、240という値を持つ。offsetof
は、Cのコード中では使えるが、アセンブラーコード中では使えないため、コンパイル時にCコンパイラーに値を計算させてマクロの形でアセンブラーコードで参照できるようにしている。
struct paravirt_patch_template
のpv_cpu_ops
というメンバーは、struct pv_cpu_ops
なので、結局struct paravirt_patch_template
の先頭からのオフセットを8 (ポインターのサイズ) で割った値がここに格納されることになる。
#define INTERRUPT_RETURN \ 771:; \ jmp *pv_cpu_ops+(offsetof(struct pv_cpu_ops, iret))(%rip); \ 772:; \ .pushsection .parainstructions,"a"; \ .align 8; \ .quad 771b; \ .byte (offsetof(struct paravirt_patch_template, pv_cpu_ops)+offsetof(struct pv_cpu_ops, iret))/8; \ .byte 772b-771b; \ .short 0; \ .popsection
struct pv_cpu_ops
は、関数ポインターの集合である*2。ここにある関数群は、仮想環境においてエミュレートにコストがかかるなどの理由で別な実装にすることができる*3。
アセンブラーコード中にINTERRUPT_RETURN
が書かれると、その場にはpv_cpu_ops.iret
の指すアドレス (通常はnative_iret
、Xen環境ではxen_iret
) にジャンプする間接分岐命令が書かれる。これはちゃんと動作するが、それに加えて、.parainstructions
セクションには
- ジャンプ命令のアドレス
- struct paravirt_patch_templateの先頭からiretへのオフセット (8バイト単位、すなわちstruct paravirt_patch_templateを関数ポインターの配列と見なしたときのインデックス)
- ジャンプ命令のサイズ
- 0
が書かれる、ということになる。実は、これはstruct paravirt_patch_site
という構造体として定義されている。
/* These all sit in the .parainstructions section to tell us what to patch. */ struct paravirt_patch_site { u8 *instr; /* original instructions */ u8 instrtype; /* type of this instruction */ u8 len; /* length of original instruction */ u16 clobbers; /* what registers you may clobber */ };
.parainstructions
セクションに書かれたこのstruct paravirt_patch_site
の配列は、apply_paravirt()
で参照され (引数は、(__parainstructions, __parainstructions_end)
、pv_init_ops.patch
(ベアメタル環境やKVMゲストではnative_patch()
) からiret
の場合はparavirt_patch_default()
→ paravirt_patch_jmp()
と呼ばれて (間接分岐ではなく) 直接 (相対) 分岐命令に置き換えられる。このパッチ自体はなくても動作するが、他のメモリを参照する必要のある間接分岐命令より、直接分岐命令の方が速い。Xenサポートによりベアメタル環境で性能が低下するのを嫌って、実行前に動的にパッチを当てよう、という考え方だ。
2.2.paravirt_alt
マクロ
/* * Generate some code, and mark it as patchable by the * apply_paravirt() alternate instruction patcher. */ #define _paravirt_alt(insn_string, type, clobber) \ "771:\n\t" insn_string "\n" "772:\n" \ ".pushsection .parainstructions,\"a\"\n" \ _ASM_ALIGN "\n" \ _ASM_PTR " 771b\n" \ " .byte " type "\n" \ " .byte 772b-771b\n" \ " .short " clobber "\n" \ ".popsection\n" /* Generate patchable code, with the default asm parameters. */ #define paravirt_alt(insn_string) \ _paravirt_alt(insn_string, "%c[paravirt_typenum]", "%c[paravirt_clobber]")
またしても暗号だ。ただ、同じ.parainstructions
セクションが使われていて、そこに生成されるのは同じstruct paravirt_patch_site
の構造を持っていることがなんとなくわかる*4。
このparavirt_alt
マクロを追いかけると、ものすごい勢いでマクロが使われた上で、最終的にPVOP_CALLn()
、PVOP_VCALLn()
、PVOP_CALLEEn()
、PVOP_VCALLEEn()
という4種類、nの部分に0から4の数字が入って合わせて20のマクロが定義され、Cのコードから呼び出されているとわかる。0から4の数字は引数の数だ。
nが0の場合の定義は、
#define PVOP_CALL0(rettype, op) \ __PVOP_CALL(rettype, op, "", "") #define PVOP_VCALL0(op) \ __PVOP_VCALL(op, "", "") #define PVOP_CALLEE0(rettype, op) \ __PVOP_CALLEESAVE(rettype, op, "", "") #define PVOP_VCALLEE0(op) \ __PVOP_VCALLEESAVE(op, "", "")
となっている。Vが付くか付かないかの違いは、呼び出し元を見てみると返り値の有無 (Vが付かない方は、rettype
型の返り値を持つ) のようだ。EEの有無はなんだろう。CALLEESAVEはcallee-save、つまり、被呼び出し側がレジスター値を保存する (caller:呼び出し側から見れば、レジスターの値が関数呼び出し前後で変わらない)、という意味だろうか。
例として、PVOP_CALL1
が使われている、write_cr0()
がどのように展開されるか見てみよう。
static inline void write_cr0(unsigned long x) { PVOP_VCALL1(pv_cpu_ops.write_cr0, x); }
このPVOP_VCALL1
の部分は、以下のように展開される。文字列は読みやすいよう適宜連結・改行しており、アセンブラー用のPV_SITE
のとき同様ANNOTATE_RETPOLINE_SAFE
は無視した。
({ unsigned long __edi = __edi, __esi = __esi, __edx = __edx, __ecx = __ecx, __eax = __eax; ((void)pv_cpu_ops.write_cr0); asm volatile("771: call *%c[paravirt_opptr]; 772: .pushsection .parainstructions,\"a\" .balign 8 .quad 771b .byte %c[paravirt_typenum] .byte 772b-771b .short %c[paravirt_clobber] .popsection" : "=D" (__edi), "=S" (__esi), "=d" (__edx), "=c" (__ecx), "+r" (asm("rsp")) : [paravirt_typenum] "i" ((offsetof(struct paravirt_patch_template, pv_cpu_ops.write_cr0) / sizeof(void *))), [paravirt_opptr] "i" (&(pv_cpu_ops.write_cr0)), [paravirt_clobber] "i" (CLBR_ANY), "D" ((unsigned long)(x)) : "memory", "cc", "rax", "r8", "r9", "r10", "r11"); });
gccのインラインアセンブラーについて詳しくはマニュアルを参照してほしいが (リンク先は、RHEL8系の標準コンパイラーであるgcc-8.5のマニュアル)、asm
の括弧の中は、
( AssemblerTemplate : OutputOperands [ : InputOperands [ : Clobbers ] ])
である。最初にレジスター名っぽい変数が定義されているが、これはOutput Operands項で同名 (実際には64bit環境ではrdi
、rsi
、…) のレジスターに割り付けることが指定されている (6.45.3.4 Constraints for Particular Machinesのx86 familyの部分参照)。=
はそこに何かが書かれる (出力となる) ことを、+
は入力・出力両方に使われることを示す。また、InputOperandsの部分では、「[アセンブラーシンボル] "制約" (C左辺値) 」という形でパターン置換が行われている。"i"
という制約 (Constraint) は、整数の定数であることが求められる。AssemblerTemplate側で%c[paravirt_opptr]
などとなっているcは、定数であることを示す。Clobbersには、asmの中で上書きされるレジスターなど ("memory"
はどこかのメモリ、"cc"
はeflagsレジスターを示す) を列挙する。
((void)pv_cpu_ops.write_cr0)
は、ただの関数ポインターが(void)
で書かれているだけで、コンパイラーが無視する。(CONFIG_PARAVIRT_DEBUG
が定義されているときにはBUG_ON(pv_cpu_ops.write_cr0 == 0)
と、なんとなく意味がありそうなコードになる)。
結局のところ、PV_SITE
の時と同様、その場には間接call命令が、.parainstructions
にはstruct paravirt_patch_site
が、それぞれ置かれそうな感じだ。
PVOP_VCALLEE1{との違いはなんだろう。
PVOP_VCALLEE1`が使われているarch_local_irq_restore()はどうだろうか。
static inline notrace void arch_local_irq_restore(unsigned long f) { PVOP_VCALLEE1(pv_irq_ops.restore_fl, f); }
このPVOP_VCALLEE1の部分は、
({ unsigned long __edi = __edi, __esi = __esi, __edx = __edx, __ecx = __ecx, __eax = __eax; ((void)pv_irq_ops.restore_fl.func); asm volatile("771: call *%c[paravirt_opptr]; 772: .pushsection .parainstructions,\"a\" .balign 8 .quad 771b .byte %c[paravirt_typenum] .byte 772b-771b .short %c[paravirt_clobber] .popsection" : "=a" (__eax), "+r" (asm("rsp")) : [paravirt_typenum] "i" ((offsetof(struct paravirt_patch_template, pv_irq_ops.restore_fl.func) / sizeof(void *))), [paravirt_opptr] "i" (&(pv_irq_ops.restore_fl.func)), [paravirt_clobber] "i" (CLBR_RAX), "D" ((unsigned long)(f)) : "memory", "cc" ); });
と展開される。PVOP_VCALL1
、PVOP_VCALLEE1
に渡された2つの引数の違いを除けば、両者の差は
- OutputOperands (
"=D" (__edi), "=S" (__esi), "=d" (__edx), "=c" (__ecx)
vs"=a" (__eax)
- InputOperandsのうち
paravirt_clobber
(CLBR_ANY
vsCLBR_RAX
) - Clobbers (
PVOP_VCALL1
には"rax", "r8", "r9", "r10", "r11"
が追加)
ということになる。
x86_64で関数を呼び出す際に、どのようなレジスターを使って良いかは、System V Application Binary Interface AMD64 Architecture Processor Supplementという文書に規定されている。20ページのFigure 3.4のPreserved across function callsでNoとされているレジスターは、関数呼び出し前後で値が変わっている可能性がある (caller-save)。PVOP_VCALL1
では、rdi、rsi、rdx、rcx (以上OutputOperands)、rax、r8、r9、r10、r11 (以上Clobbers) と、asmの中でcaller-saveレジスターを全て列挙しており、asmの中でこれらが破壊されるので、必要ならasmの前後で保存するように、とコンパイラーに指示するためのものである。合わせてstruct paravirt_patch_site
の中のclobbers
には、CLBR_ANY
が指定されている*5。PVOP_CALLn
、PVOP_VCALLn
は、呼び出される側がCで書かれていてcaller-saveレジスターが保存されない場合に使われ、PVOP_CALLEEn
、PVOP_VCALLEEn
の方は、呼び出される側がアセンブリ言語で書かれているためcaller-saveレジスターも含めて保存してくれる、という使い分けだと考えられる。
.parainstructions
セクションの働きは、PV_SITE
マクロの時と (当然ながら) 同じである。 write_cr0()
のpv_cpu_ops.write_cr0
は、INTERRUPT_RETURN
で使われたparavirt_patch_jmp()
とよく似たparavirt_patch_call()
で、関節分岐命令が直接分岐命令に置き換えられる。
一方、arch_local_irq_restore()
のpv_irq_ops.restore_fl
の方は、ちょっと複雑だ。ベアメタル環境やKVMゲストでは、native_patch()
に並ぶPATCH_SITE
マクロにの先頭のエントリに一致するため、start_pv_irq_ops_restore_fl
というラベルのついたアドレスからend_pv_irq_ops_restore_fl
というアドレスまでの内容で置き換えられる。この2つのラベルは、この辺りで定義されているマクロを用いてここで定義されている。つまり、ベアメタル環境やKVMゲストでは、pushq %rdi ; popfq
という命令に置き換えられる。つまり、分岐命令がなくなる*6。この命令は、ベアメタルまたはKVM環境でのrestore_fl実装であるnative_restore_fl()
をインライン展開したもの、と言える。
3. .altinstructions
、.altinstr_replacement
セクション
.altinstructions
は、alternative instructionsという仕組みで使われるセクションである。例えば、ここ10年くらい (Intelの場合) のCPUには、PCID (Process Context Identifier) という機能が追加されていて*7、コンテキストスイッチ時のTLBフラッシュコストを下げることができる。この機能を利用するには、スイッチ時にCPUの制御レジスターの特定の部分にIDを書いておいたり、CPUのプロセスコンテキスト数はLinuxの許容するプロセス数より小さいためにうまいこと制御してあげたりする必要がある。こうした新機能は、CPUIDという機械語命令でfeature bitを調べることにより、実行環境のCPUにその機能があるかどうかを知ることができる。LinuxではCPUを初期化する部分でCPUIDのfeature bitをキャッシュしているが、スイッチの都度これを参照して処理を変えるよりは、初期化時にコードをPCID機能の有無に応じて自己書き換えして条件分岐を少なくするのが効果的である。これがalternative instructionsという仕組みであり、実際に、コンテキストスイッチのコードの一部は、この機能を用いてコードを自己書き換えしている。
また、CPUID feature bitと同様のやり方で、Linux内部でfeature bit的なものを定義していることもある。例えばXen PV環境では、X86_FEATURE_XENPV
というfeature bit的なものをセットして、Xen PV環境であるか否かをif (boot_cpu_has(X86_FEATURE_XENPV)) { /* Xen PV環境特有の処理 */ }
のように判定している部分がある。
さらに、最近ではCPU投機的実行に関連する脆弱性に絡んでalternative instructionsの機能が大活躍している。特定の不具合 (例: Spectre-v1、MDS、…) に応じてfeature bit的なものを定義しておき、alternative instructionsの機能で緩和策を有効にしたりスキップしたりしている。
さて、.altinstructions
、altinstr_replacement
の2つのセクションも.parainstructions
と同様、Cコードから配置する場合とアセンブラーコードから配置する場合の2つの定義がある。とはいえCコードの場合もインラインアセンブラーが前提になっている。すでに「その1」より長くなってしまったので、ここではシンプルなアセンブラーコードの方のみを見てみよう。
.macro ALTERNATIVE oldinstr, newinstr, feature 140: \oldinstr 141: .skip -(((144f-143f)-(141b-140b)) > 0) * ((144f-143f)-(141b-140b)),0x90 142: .pushsection .altinstructions,"a" altinstruction_entry 140b,143f,\feature,142b-140b,144f-143f,142b-141b .popsection .pushsection .altinstr_replacement,"ax" 143: \newinstr 144: .popsection .endm
ここでは、Cプリプロセッサーのマクロではなく、アセンブラーのマクロ機能が使われている。.macro
で始まる行で、ALTERNATIVE
という名前で、引数が3つあることがわかる。ALTERNATIVE
と書かれた部分が、.endmまでの間の行に置き換えられる。また中程に登場するaltinstruction_entry
は、別なマクロの呼び出しだ。
.macro altinstruction_entry orig alt feature orig_len alt_len pad_len .long \orig - . .long \alt - . .word \feature .byte \orig_len .byte \alt_len .byte \pad_len .endm
.altinstruction
セクションの定義はarch/x86/kernel/vmlinux.lds.S
の中にある。
/* * struct alt_inst entries. From the header (alternative.h): * "Alternative instructions for different CPU types or capabilities" * Think locking instructions on spinlocks. */ . = ALIGN(8); .altinstructions : AT(ADDR(.altinstructions) - LOAD_OFFSET) { __alt_instructions = .; *(.altinstructions) __alt_instructions_end = .; }
いつものように、先頭と末尾にシンボルがあるので、これをキーに検索すると、apply_alternatives(__alt_instructions, __alt_instructions_end);
という記述が見つかる。apply_alternatives()
の定義はその少し上にある。また、.altinstructions
にリンクされる部分 (altinstruction_entry
マクロで吐かれるもの) は、struct alt_instr
であるとわかる。
apply_alternative()
は、その少し前に定義されている。start
(__alt_instructions
) からend
(__alt_instructions_end
) までstruct alt_instr *a
を増やしながらループする。CPUのCPUID features bitのうち、a->cpuid
に書かれたbitが1
であれば、a->instr_offset
からa->instrlen
バイトを、a->repl_offset
からa->replacementlen
に置き換える。
もし後者の方が短かければ、残りをno operationで埋める。no operationといっても、余ったサイズやCPUのマイクロアーキテクチャによって色々と用意されているのが面白いところだ。
また、相対分岐命令は、.altinstr_replacement
セクションに置かれてしまうことから、飛び先がそこからの相対アドレスになってしまう。これを、ここで直している点も面白い。
4. apply_paravirt()
、apply_alternative()
2通りの自己書換を見てきたが、この書き換えを実際に行うapply_paravirt()
、apply_alternative()
はどこから呼ばれているのだろうか。
1つは、init/main.cのstart_kernel()
→ arch/x86/kernel/cpu/bug.cのcheck_bugs()
→ arch/x86/kernel/alternative.cのalternative_instructions()
である。どちらもここから呼ばれている。これはカーネル初期化の段階で実行されるので、.parainstructions
、.altinstructions
、.altinstr_replacement
いずれのセクションもその後解放してしまっても良いとわかる。
もう一つ、arch/x86/kernel/module.cのmodule_finalize()
というところから呼ばれている。これは、モジュールロード時に実行されるコードで、モジュールの中でもparavirtualization patchやalternative instructionsが使える、ということを意味する。カーネル初期化後にfree_init_pages()
で解放されるのはvmlinux (本体) のセクションのみで、モジュールの中の各セクションはモジュールの中に含まれるので、モジュールロード時に参照できる。
ところで、どちらもapply_alternative()
とapply_paravirt()
の間にalternatives_smp_module_add()
という呼び出しがあるのにお気づきだろうか。これは、やはり自己書き換えに用いられる。また.smp_locks
セクションと関係がある。ご興味の向きには、読んでみると面白い。実は、alternative instructionsの仕組みは、これが元祖と言える。古くからのユーザーの皆さんは、かつて各ディストリビューションではSMP向けカーネルとUP向けカーネルを別のパッケージにして、選択するようになっていたのをご記憶かもしれない。今ではこうしてSMP向けにコンパイルされていても、UP向けカーネルと同等の性能が得られる工夫がなされている。
5. 休憩
initセグメントからは外れるが、__ex_table
セクションも面白いので、是非取り上げたかったのだが力尽きてしまった。次の機会に書いてみたい。
*1:省略したANNOTATE_RETPOLINE_SAFEは、Linuxコンパイル時に各種の静的チェックを行うobjtoolというプログラムに対する指示である。objtoolが、ここで使われる間接分岐命令を検出して、retpolineを使うべし、と警告を出すのを防ぐ。
*2:iretのように、実際には関数ではないものもある。
*3:なお、pv_cpu_opsを置き換えている仮想環境はXenのみである。
*4:_ASM_ALIGNと_ASM_PTRは<asm/asm.h>で定義されている
*5:ただ、このclobbersが何に必要なのかはよく分からなかった。
*6:Xen環境のpatch関数paravirt_patch_default()ではrestore_flを特別扱いしていないため、paravirt_patch_call()で相対分岐命令に置き換えられる。
*7:lscpuコマンドのFlags:項にもpcidと出る