vmlinuxのヒミツ2 -- vmlinuxに特有のセクション その3 - exception table

執筆者 : 箕浦 真


いろいろとvmlinux特有のELFセクションを見てきたが、今回は__ex_tableセクションを取り上げる。これはexception tableの略だ。カーネルの中でexceptionといえば、例外処理が思い浮かぶが、関係あるのだろうか。

1. __ex_tableセクション

結論から言えば、このexceptionは、特定状況下の例外を指す。実は、このセクションについてはLinuxの付属文書に詳しく説明があって、これを読めば大元の背景などがわかるのだが (ただ、ちょっと古い)、本稿では実際のコードがどうなっているのか、という方向からこのexception tableを見ていきたい。なお、引用した、あるいはリンクを張ったソースコード (など) は、RHEL 8系で採用されている4.18.0のupstream版に基づく。

いつものように、定義を見てみよう。

        EXCEPTION_TABLE(16) :text = 0x9090

EXCEPTION_TABLE()は、include/asm-generic/vmlinux.lds.hで定義されたマクロである。

#define EXCEPTION_TABLE(align)                                          \
        . = ALIGN(align);                                               \
        __ex_table : AT(ADDR(__ex_table) - LOAD_OFFSET) {               \
                __start___ex_table = .;                                 \
                KEEP(*(__ex_table))                                     \
                __stop___ex_table = .;                                  \
        }

例によって、最初と最後にシンボルが付与されているので、これをキーにソースツリーを検索する。すると、kernel/extable.cというファイルの中に、sort_extable()とかsearch_extable()とかいった関数の引数として参照されていることがわかる。

2. sort_extable()search_extable()

sort_extable()は、lib/extable.cというファイルに定義されている。後で出てくるように、exception tableは、二分探索法により検索されるため、事前にソートしておく必要があり、ここでソートしている。sort()関数は、Cのqsort(3)関数と同じような汎用のソート関数である。

void sort_extable(struct exception_table_entry *start,
                  struct exception_table_entry *finish)
{
        sort(start, finish - start, sizeof(struct exception_table_entry),
             cmp_ex_sort, swap_ex);
}

これを見ると、__ex_tableセクションは、struct exception_table_entryの配列であることがわかる。この構造体は、このように3つの整数で構成されている。

struct exception_table_entry {
        int insn, fixup, handler;
};

整数3つではあるが、これは相対アドレスを保持するようになっていて、実質的には3つのアドレスを持っている。たとえば、insnこのように参照されている。

static inline unsigned long ex_to_insn(const struct exception_table_entry *x)
{
        return (unsigned long)&x->insn + x->insn;
}

ソート時にはこの相対アドレスが正しく同じ絶対アドレスを指すように、整数値を調整している。このような面倒なことをしてでもテーブルサイズを圧縮したかったのだと考えられる。

なお、insnは、カーネルやコンパイラーのような低レイヤーの世界でよく見かける略語で、instruction、機械語命令を意味する。

ところで、vmlinuxの__ex_tableセクションに対するsort_extable()は、実際には通常実行されない。実は、コンパイル時にvmlinuxができたところでこのファイル自体をバイナリレベルで弄って、ソートしてしまうためだ。「vmlinuxの」と断ったのは、vmlinuxに含まれないモジュール部分は、モジュールロード時にソートしているためである。

search_extable()という関数も、同じlib/extable.cというファイルに定義されているbsearch()は、sort()と同じような汎用の関数で、二分探索を行う。検索鍵 (value) は、絶対アドレス表記のinsnである。

search_extable()は、RHEL 8の場合はsearch_kernel_exception_table()search_module_extables()search_bpf_extables()の3箇所から呼ばれている。Exception tableに、その名の通り本体 (vmlinux) 用、モジュール用、BPF用と3種類ある、ということを示す。いずれも、kernel/extable.cのsearch_exception_tables()から呼ばれている。RHEL 8のこの辺りのコードは、新しいupstream Linuxからバックポートされており、素のv4.18.0にはBPFのexception tableはなく、またvmlinuxのexception tableのみを検索する関数はない。いずれにしても、Linuxの他の部分から呼ばれるのは、search_exception_tables()である。

3. Exception tableの中身

struct exception_table_entryの中身は、(相対アドレスに圧縮された) 3つのアドレスであるが、これがどんな内容なのか見てみよう。整数値から絶対アドレスを計算し、さらにシンボルテーブルから近くのシンボルを検索してそこからの相対アドレスにしてみた。

insn fixup handler
do_fast_syscall_32+81 __softirqentry_text_end+22 ex_handler_uaccess
perf_callchain_user+303 __softirqentry_text_end+34 ex_handler_uaccess
perf_callchain_user+318 __softirqentry_text_end+46 ex_handler_uaccess
perf_callchain_user+533 __softirqentry_text_end+58 ex_handler_uaccess
perf_callchain_user+553 __softirqentry_text_end+71 ex_handler_uaccess
start_thread_common.constprop.5+62 start_thread_common.constprop.5+64 ex_handler_clear_fs
start_thread_common.constprop.5+78 start_thread_common.constprop.5+80 ex_handler_clear_fs
start_thread_common.constprop.5+82 __softirqentry_text_end+84 ex_handler_default
start_thread_common.constprop.5+84 __softirqentry_text_end+91 ex_handler_default
__switch_to+433 __softirqentry_text_end+98 ex_handler_default
__switch_to+623 __switch_to+625 ex_handler_clear_fs
__switch_to+876 __switch_to+878 ex_handler_clear_fs
__switch_to+878 __switch_to+880 ex_handler_clear_fs
__switch_to+945 __switch_to+947 ex_handler_clear_fs
__switch_to+964 __softirqentry_text_end+105 ex_handler_default
__switch_to+971 __softirqentry_text_end+112 ex_handler_default
__switch_to+1031 __switch_to+1033 ex_handler_clear_fs
do_arch_prctl_64+337 do_arch_prctl_64+339 ex_handler_clear_fs
setup_sigcontext+28 setup_sigcontext+32 ex_handler_ext
setup_sigcontext+36 setup_sigcontext+40 ex_handler_ext
(以下略)

これは、手元のRocky Linux 8.5 (4.18.0-348.23.1.el8_5.x86_64) の、ソート済みのvmlinuxのexception table先頭20エントリである。insnでソートしてあるので、ここだけ見るとinsnは規則的に見えるかもしれないが、これは実際にはバラバラ、fixupは、insnの少し後のパターンと__softirqentry_text_end+??というパターンがある。そして、handlerex_handler_で始まる何種類かのシンボルになっていることがわかる1。 このうち、__softirqentry_text_endは、vmlinux.lds.hに定義があり.softirqentry.textセクションの末尾である。このSOFTIRQENTRY_TEXTマクロが使われているところを調べると、vmlinuxの.textセクションの一部で、以下のような並びになっているとわかる。

    /* Text and read-only data */
    .text :  AT(ADDR(.text) - LOAD_OFFSET) {
        _text = .;
        _stext = .;
        /* bootstrapping code */
        HEAD_TEXT
        TEXT_TEXT
        SCHED_TEXT
// 中略
        SOFTIRQENTRY_TEXT
        *(.fixup)
        *(.gnu.warning)
// 以下略

.softirqentry.textの次は、.fixupセクションである。つまり、__softirqentry_text_end+??というパターンは、.fixupセクションにかき集めてリンクされた部分、とわかる。

なお.softirqentry.text.fixupも、.cpuidle.textセクションと同様、リンク前の*.oファイルには存在するが、vmlinuxからは削除されている。

4. insnを覗く

do_fast_syscall_32+81はピンポイントで1つの命令を指すので、そのあたりを逆アセンブルしてみよう。前回触れたように、環境によって起動時にバイナリパッチが適用されているため、多少の違いがあったり、余計なNOPが入っていたりする。

0xffffffff88c045a0 <do_fast_syscall_32>:        nopl   0x0(%rax,%rax,1) [FTRACE NOP]
/* 略 */
0xffffffff88c045e2 <do_fast_syscall_32+66>:     stac
0xffffffff88c045e5 <do_fast_syscall_32+69>:     lfence
0xffffffff88c045e8 <do_fast_syscall_32+72>:     xor    %eax,%eax
0xffffffff88c045ea <do_fast_syscall_32+74>:     mov    0x98(%rbx),%rdx
0xffffffff88c045f1 <do_fast_syscall_32+81>:     mov    (%edx),%edx          // ★
0xffffffff88c045f4 <do_fast_syscall_32+84>:     clac
0xffffffff88c045f7 <do_fast_syscall_32+87>:     mov    %edx,0x20(%rbx)
0xffffffff88c045fa <do_fast_syscall_32+90>:     test   %eax,%eax

この部分のCソースコードは以下だ (#ifを展開した)。

/* Returns 0 to return using IRET or 1 to return using SYSEXIT/SYSRETL. */
__visible long do_fast_syscall_32(struct pt_regs *regs)
{
/* 略 */

        if (__get_user(*(u32 *)&regs->bp, (u32 __user __force *)(unsigned long)(u32)regs->sp)) {
                /* User code screwed up. */
/* 略 */
        }

%rbxは、do_fast_syscall_32()の引数であるregsのコピーで、0x98(%rbx)regs->spを、0x20(%rbx)でregs->bpを意味する。

do_fast_syscall_32+81にあるのは何の変哲もないロード命令に見えるが、実はここで例外が発生する可能性を想定しておかなければならない。regs->spは、ユーザープログラムから渡された値であって、アドレスとしては不正な値である可能性があり、またそうでなくともユーザー空間のアドレスであるのでページアウトされている可能性がある。これらの場合、CPUはページフォルト (#PF) とか一般保護違反 (#GP) とかいう例外を発生させる。

実は、ここで例外が発生すると、(exception tableを検索した上で、例外発生アドレスがinsnに一致するので) handlerであるex_handler_uaccess()呼ばれるように、例外処理ルーチンが作られている。ex_handler_uacess()を見てみよう。この関数は、upstream Linuxの4.18にはない (代わりに、ex_handle_default()が使われる。見ての通り、この時点では名前が違うだけ) ので、以下に引用する。

__visible bool ex_handler_uaccess(const struct exception_table_entry *fixup,
                                  struct pt_regs *regs, int trapnr)
{
        regs->ip = ex_fixup_addr(fixup);
        return true;
}

regs->ipとは、例外処理からの戻りアドレスだ。そこに、exception tableエントリのfixupを (絶対アドレスに変換した上で) 書いて、真を返している。

fixupアドレスである__softirqentry_text_end+22には何が書かれているのだろう。

0xffffffff898002ec <__softirqentry_text_end+22>:        mov    $0xfffffff2,%eax
0xffffffff898002f1 <__softirqentry_text_end+27>:        xor    %edx,%edx
0xffffffff898002f3 <__softirqentry_text_end+29>:        jmpq   0xffffffff88c045f4 <do_fast_syscall_32+84>

%eaxに入れているのは-14だが、これは実は-EFAULTである。さらに、%edxに0を入れてdo_fast_syscall_32+84にジャンプしている。

再度、do_fast_syscall_32()の逆アセンブルリストを引用する。

0xffffffff88c045a0 <do_fast_syscall_32>:        nopl   0x0(%rax,%rax,1) [FTRACE NOP]
/* 略 */
0xffffffff88c045e2 <do_fast_syscall_32+66>:     stac
0xffffffff88c045e5 <do_fast_syscall_32+69>:     lfence
0xffffffff88c045e8 <do_fast_syscall_32+72>:     xor    %eax,%eax
0xffffffff88c045ea <do_fast_syscall_32+74>:     mov    0x98(%rbx),%rdx
0xffffffff88c045f1 <do_fast_syscall_32+81>:     mov    (%edx),%edx
0xffffffff88c045f4 <do_fast_syscall_32+84>:     clac                        // ★
0xffffffff88c045f7 <do_fast_syscall_32+87>:     mov    %edx,0x20(%rbx)
0xffffffff88c045fa <do_fast_syscall_32+90>:     test   %eax,%eax

+84は、例外が発生する可能性があるロード命令の次の命令だ。つまり、何事もなくロード命令が完了すれば、%eax+72にあるxorで0クリアされたままなので、引用部最後のtestの結果Zフラグが立つが、例外が発生してhandlerが呼ばれると、例外処理ルーチンからの戻りアドレスが__softirqentry_text_end+22に書き換えられるためそこへ戻り、%eax%edxを設定後、まるで何事もなかったかのように+84に飛んでくる、ということだ。%eax-EFAULTなので、test命令はZフラグをクリアする。Cコードを見ればわかるが、fixupで返している-EFAULTは無視し、非0であるという情報のみを用いており、-EFAULTdo_fast_syscall_32()側で別途設定している。

なお、do_fast_syscall_32()の中ではユーザー空間のメモリにアクセスするため、SMAP拡張が有効な場合、前後をstac/clac命令ではさむ必要がある。また、ユーザーから与えられたポインターの先にアクセスする前に、投機実行を抑制するためのlfence命令も入っている。

5. マクロを展開してみる

__get_user()は実はマクロである。これを展開してみよう。なお、stac/clacやlfence関連、マクロ展開の結果意味のなくなるキャストなどは省略し、改行やインデントなどは適宜見やすいよう整形した。また定数のswitch文も人手で最適化した。

        if ({int __gu_err; 
             unsigned long __gu_val;
             __gu_err = 0;
             asm volatile("1:        movl %2,%k1\n"
                          "2:\n"
                          ".section .fixup,\"ax\"\n"
                          "3:        mov %3,%0\n"
                          "  xorl %k1,%k1\n"
                          "  jmp 2b\n"
                          ".previous\n"
                          " .pushsection \"__ex_table\",\"a\"\n"
                          " .balign 4\n"
                          " .long (1b) - .\n"
                          " .long (3b) - .\n"
                          " .long (ex_handler_uaccess) - .\n"
                          " .popsection\n"
                                                  : "=r" (__gu_err), "=r"(__gu_val)
                                                  : "m" (__m((u32*)regs->sp)), "i" (-EFAULT), "0" (__gu_err))
             *(u32 *)&regs->bp = (u32)__gu_val;
             __gu_err;}) {
                /* User code screwed up. */

かなりわかり辛いが、.section.pushsection/.popsectionなどでセクションを変更していることはわかる (.previousについてはアセンブラーのマニュアル参照。ここで.section.previousは、.pushsection.popsectionと同じ意味で使用している)。大半はインラインアセンブラーなので、最初に掲げた文書にならって、gccの出力するアセンブラーコードを見てみた方がわかりやすそうだ。ただし、-gオプションの出力するデバッグ情報や、stac/clacなどは省略した。

        xorl    %eax, %eax              ;; __gu_err = 0
        movq    152(%rbx), %rdx         ;; %rdx = regs->sp
        
1:      movl (%edx),%edx                ;; %edx = *%edx
2:
.section .fixup,"ax"                    ;; .fixupセクション
3:      mov $-14,%eax                   ;; __gu_err = -EFAULT
        xorl %edx,%edx                  ;; %edx = 0
        jmp 2b
.previous
 .pushsection "__ex_table","a"          ;; __ex_tableセクション
 .balign 4
 .long (1b) - .                         ;; insn (ラベル1の相対アドレス)
 .long (3b) - .                         ;; fixup (ラベル3の相対アドレス)
 .long (ex_handler_uaccess) - .         ;; handler
 .popsection                            ;; もとのセクション

        movl    %edx, 32(%rbx)          ;; regs->bp = %edx
        testl   %eax, %eax

.fixup__ex_tableセクションがどのように書かれているかがわかりやすくなり、またその前後の部分は先程の逆アセンブルリストの内容と一致している。

6. exception tableを参照する場面

do_fast_syscall_32+81でアクセスするのは、ユーザー空間のアドレスであるのでページアウトされている可能性がある、と書いたが、以上にはページアウトされていたときにどうにかする、というコードは登場しない。冒頭に、「特定状況下の例外」と書いたように、「通常状況下の例外」は、exception tableの外で処理される。ユーザー空間のメモリがページアウトされているのは通常のことで、do_fast_syscall_32+81でページフォルトが発生しても、通常はページインが実行され、何事もなかったかのようにdo_fast_syscall_32+81のmov命令がやり直される。また、このアドレスが不正なアドレスであった場合、通常はアクセスしようとしたプロセスにSegmentation Faultシグナル (SIGSEGV) が送られて、そいつがコアダンプする。

do_fast_syscall_32+81実行時には、通常そのアドレスに対するアクセスがあるとSIGSEGVが送られるような事態になった時にも、シグナルを送ってはいけないということが、仕様 (Single Unix Specification/POSIXやLinux Standard Base) に規定されている。つまり、返り値として-1などエラーを表す値を返し、errnoEFAULTをセットする必要がある。これを実施しているのが、上記のexception tableのエントリである。

Exception tableを検索してhandlerを呼ぶのは、fixup_exception()というところだ。以下に引用する2

int fixup_exception(struct pt_regs *regs, int trapnr)
{
        const struct exception_table_entry *e;
        ex_handler_t handler;

        e = search_exception_tables(regs->ip);
        if (!e)
                return 0;

        handler = ex_fixup_handler(e);
        return handler(e, regs, trapnr);
}

引数として、例外発生時のレジスターの内容を格納したstruct pt_regs (ポインター渡し) と、例外の番号を受け取っている。

例外処理は、arch/x86/entry/entry_64.S経由で主にarch/x86/kernel/traps.cで処理されるが、ページフォルト例外に関してはarch/x86/mm/fault.cのdo_page_fault()で処理される。この辺りで空間にマップされたメモリの情報を取得し、それが存在しなければ、bad_area()などを経由して例外が発生した時ユーザープロセスを実行中 ((error_code & X86_PF_USER))ではなかった (すなわちカーネルモードであった) 場合にno_context()中でfixup_exception()が呼ばれている。

その他、traps.cの中にもいくつかの場合にfixup_exception()を呼ぶ場面がある。たとえば、一般保護違反例外を処理するdo_general_protection()では、いくつかの条件 (これらはinsnで特定できない条件なので、exception tableではハンドルできない) で特別な処理をした後、例外発生時にカーネルモードであればfixup_exception()を呼び (fixup_exception()に失敗すると異常終了)、ユーザーモードであればSIGSEGVを送るような処理が書かれている。

全く余談になるが、他のUnix系OSでもシステムコールで渡されたアドレスに対してEFAULTを返す、という処理は、ちょっと変わっている。たとえば、Solarisからフォークしたillumosでは (ということはおそらくSolarisでも)、実行中のスレッド情報を保存している構造体kthread_tt_lofaultというメンバーがあり3、ユーザー空間とのやりとり (copyin/copyout) の際にfixupのような処理を行うジャンプ先アドレスを格納している。EFAULTを返す状況になると、例外処理の戻り先がそこになるわけだ。Copyin/copyoutはスレッドのコンテキストで行なわれるので、t_lofaultもスレッド単位で制御できればよい。FreeBSDやNetBSDでも似たような方式を取っている。EFAULT時に、exception tableを検索する必要がないので性能的にはよさそうだが、そもそもシステムコールがEFAULTを返すような状況は普通はそのプログラムのバグなので、性能は問題にならない、というのがLinuxの判断なのだろう。Linuxのexception tableは、ページフォルト例外以外の例外も扱える利点がある。

7. おわりに

その1では初期化にかかわるもの、その2では起動時バイナリパッチに関わるもの、そして今回と、3回にわたって、vmlinuxに特有のELFセクションをいくつか取り上げてきたが、どうだっただろうか。Linuxはこのような細かなhackの塊である、という一端を垣間見ていただけたのではないかと思う。実は、ELFセクションの活用どころか、コンパイル手順にいたるまで興味深いテクニックが活用されていて、調べていると飽きない。次の機会も、そうしたhackの例を取り上げられれば、と思う。


  1. handlerex_handler_refcountになっているエントリに関しては、insn__noinstr_text_end+?? (実際にはvmlinuxからは削除されている.text..refcountセクションのアドレス)、fixupがバラバラ、という感じになっている。ここでは紹介しないが、これは参照カウントがオーバーフローして誤ってオブジェクトが解放されることを防ぐためのコードである。

  2. CONFIG_PNPBIOSの部分は省略した。オンボードのシリアルポートなどで利用されたが、ACPIで同等の処理ができるため64bit環境では使われない。

  3. lofaultが何を指すのか、手元の古いSolaris Internalsなどを見てもわからなかった。