- 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_ANYvsCLBR_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と出る