linux2.6の場合のExceptionと`int $0x80`(system call)の挙動

linux2.6の場合のExceptionとint $0x80の挙動

前の記事でxv6ではどうなのかを述べた。今回はlinux kernel(2.6.11)の場合のexceptionとint $0x80の挙動についてまとめる。今回も長いので、I/O APIC, local APICがからんだinterruptは後回しにしたい。

referenceは前の記事を参考にしてね。

IDTの初期設定

x86(32-bit)では、arch/i386/kernel/head.Sのstartup_32がprotected modeのentrypointになっており、registerの設定をいろいろした後に、start_kernelに飛ぶ。この関数は、init/main.cにある。

  • 最初にarch/i386/kernel/head.SIDTを初期化して、とりあえずロードまで済ませている。

  • start_kenel -> trap_initのset_**_gateIDTのentryを設定する。sys_call(0x80)とexceptionの設定

  • start_kernel -> trap_init -> cpu_initで再度IDTをロード

  • start_kernel -> init_IRQにて、IDTのentryをinterrupt gateに初期化する。syscall(0x80)の部分はtrap_initで設定されているのでskipしていることに注意。

// init_IRQ in arch/i386/kernel/i8259.c
  // init_IRQ
  // skip
    for (i = 0; i < (NR_VECTORS - FIRST_EXTERNAL_VECTOR); i++) {
        if (i >= NR_IRQS)
            break;
    // void set_intr_gate(unsigned int n, void *addr)
    // // _set_gate(idt_table+n,14,0,addr,__KERNEL_CS);
        if (FIRST_EXTERNAL_VECTOR + i != SYSCALL_VECTOR) 
            set_intr_gate(FIRST_EXTERNAL_VECTOR + i, interrupt[i]);
    }
  // skip

ここで、interruptarch/i386/kernel/entry.Sで定義されているcallbackを要素に持つ配列。次回以降で詳しくみていく。

exception

divide_error exception(#0)を例に取る。 User landから呼ばれるので%csのRPL(CPL)は3である。

hardware側の処理 ~ do_divide_errorまで

前節で、start_kenel -> trap_init -> set_trap_gate(0,&divide_error);と設定した1

hardware側の処理の概要は、http://cstmize.hatenablog.jp/entry/2019/03/20/xv6%E3%81%AE%E5%A0%B4%E5%90%88%E3%81%AEException%E3%81%A8Interrupt%E3%81%AE%E6%8C%99%E5%8B%95 で書いたが、ここでは、TSSの設定だけ見る。

// start_kernel -> trap_init -> cpu_init(void)
  // skip
  // t->esp0 = current->thread->esp0;
    load_esp0(t, thread);
    // #define set_tss_desc(cpu,addr) __set_tss_desc(cpu, GDT_ENTRY_TSS, addr) GDT_ENTRY_TSS=GDT_ENTRY_KERNEL_BASE + 4
    set_tss_desc(cpu,t);
    load_TR_desc(); // #define load_TR_desc() __asm__ __volatile__("ltr %%ax"::"a" (GDT_ENTRY_TSS*8))

なので、TSSにloadされるesp0はprocのstack(thread_info)内にあることがわかる。

例外が発生した場合、権限周りは大丈夫として、スタックにレジスタが積まれる。後ほどみていくが、struct pt_regsが形成されていく。

hareware側では

struct pt_regs {
    long ebx;
    long ecx;
    long edx;
    long esi;
    long edi;
    long ebp;
    long eax;
    int  xds;
    int  xes; // ES
    long orig_eax; // ORIG_EAX
  // these below register is stored by hardware automatically(the previous values)
    long eip;
    int  xcs;
    long eflags;
    long esp;
    int  xss;
};

でregisterがpushされた結果、long eip;まで自動的にstackがgrow downする。(下に伸びるので、struct pt_regsの要素はxss(stack segment)の方向から構築されていく。ESORIG_EAXはconstantとして指定されているが、すぐ下のコードに出てくる。

その後、arch/i386/kernel/entryにて

ENTRY(divide_error)
    pushl $0 // pt_regs.orig_eax
    pushl $do_divide_error  // pt_regs.xes
    ALIGN
error_code:
    pushl %ds // pt_regs.xds
    pushl %eax // pt_regs.eax
    xorl %eax, %eax // reset %eax = 0
    pushl %ebp // pt_regs.ebp
    pushl %edi // pt_regs.edi
    pushl %esi // pt_regs.esi
    pushl %edx // pt_regs.edx
    decl %eax // %eax = -1
    pushl %ecx // pt_regs.ecx
    pushl %ebx // pt_regs.ebx
    # cld instruction to clear the direction flag eflags.DF
    cld
    # NOW, %esp(current stack pointer) is at &pt_regs, which type is `struct pt_regs`.
    movl %es, %ecx # %ecx <= %es # TODO: why?
    movl ES(%esp), %edi     # get the function address($do_divide_error)
    movl ORIG_EAX(%esp), %edx # set the error code in %edx
    # pt_regs.orig_eax <= -1  to separate from syscall instruction(0x80)
    movl %eax, ORIG_EAX(%esp)
    # %ecx <= 0x20(%esp) # TODO: why?
    movl %ecx, ES(%esp)
    # designate user data segment in %ds, %es
    movl $(__USER_DS), %ecx
    movl %ecx, %ds
    movl %ecx, %es
    movl %esp,%eax # set the stack pointer(&pt_regs) in %edx
    // call do_divide_error
    call *%edi
    jmp ret_from_exception

と処理が走る。個々でやっていることは、struct pt_regsの構築とsegment registerの設定だ。 %ediにはdo_divide_error関数へのpointerが入っているので、call *%edido_divide_errorが動く。 これが終わったら、ret_from_exceptionに移動する。

Note) xv6では、vectors.plの

#   vector0:
#     pushl $0
#     pushl $0
#     jmp alltraps

および、trapasm.Sの https://github.com/mit-pdos/xv6-public/blob/b818915f793cd20c5d1e24f668534a9d690f3cc8/trapasm.S#L4-L20 に対応する。struct pt_regsstruct trapframeに対応していることが見て取れる。

do_divide_error

do_divide_errorそのものはコードになくて、arch/i386/kernel/traps.cにてDO_VM86_ERROR_INFO( 0, SIGFPE, "divide error", divide_error, FPE_INTDIV, regs->eip)というマクロで一般化されているので、ばらしてみる:

// #define fastcall __attribute__((regparm(3)))
fastcall void do_divide_error(struct pt_regs * regs, long error_code) {
    siginfo_t info;
    info.si_signo = signr;
    info.si_errno = 0;
    info.si_code = sicode;
    info.si_addr = (void __user *)siaddr;
    if (notify_die(DIE_TRAP, str, regs, error_code, trapnr, signr) == NOTIFY_STOP)
        return;
    do_trap(trapnr, signr, str, 1, regs, error_code, &info);
}

雰囲気的には、do_trap関数を呼ぶようだ。更にこの関数を除くと、regs->xcsは3(exception呼び出し前のCPL=3)なので、trap_signallabelに遷移する...

fastcallとはなんぞや? これは、関数の引数とregisterを直接対応させることができる。regparmはGCC拡張で、

On x86-32 targets, the regparm attribute causes the compiler to pass arguments number one to number if they are of integral type in registers EAX, EDX, and ECX instead of on the stack. Functions that take a variable number of arguments continue to be passed all of their arguments on the stack. https://gcc.gnu.org/onlinedocs/gcc/x86-Function-Attributes.html

とある。通常は、関数呼び出しの前にスタックに積んで置く準備をする必要があるが、今回の場合、

 movl ES(%esp), %edi     # get the function address($do_divide_error)
    movl %esp,%eax # set the stack pointer(&pt_regs) in %edx

の用に、%eax, %ediをセットしているので、これらが、(struct pt_regs * regs, long error_code)に対応する。stack取り出さないで、registerを直接使うという点でfastcallなんだろう。

notify_diekprobe_exceptions_notifyがcallbackとして呼ばれ、notify_dieの第一引数で戻り値が決定するようだ。今回はDIE_TRAPで、NOTIFY_DONEとなるので、条件分岐はfalseとなる。したがって、どのみちdo_trapが呼ばれる。

do_trap

infoはstruct siginfoでmemberが.si_signo: SIGFPE, .si_code: FPE_INTDIVとなる。force_sig_info -> specific_send_sig_info -> send_signalと続くようだ。user processにSIGFPEを投げるのが仕事っぽい。

struct siginfoはuser landでも出てくる構造体で、sigactionでキャッチするSIGNALを設定してstruct siginfoで受け取れるようにできる。ココらへんは The Linux Programming Interfaceのch21.4あたりで詳しく解説されている。

do_trapが終わったので、ret_from_exceptionを見る。

ret_from_exception

ret_from_exceptionについては、Understanding the Linux Kernelのch4.6が圧倒的に詳しいので、2つだけこの記事で触れる。

1つ目が一番最初のところ。

ret_from_exception:
    preempt_stop   # #define preempt_stop       cli

exceptionの場合、trap gateが入口になる事があるため、eflags.IF=ONのママの場合がある。このため、exceptionの部分だけ、cli命令が付加されている。以降、iret命令が呼ばれるまで2はinterrupt disableの状態が続く。

2つ目がexceptionの一番最後のところ。最終的にrestore_allラベルに飛んで、User landに戻る。

restore_all:
    RESTORE_ALL

#define RESTORE_ALL    \
    RESTORE_REGS \
    addl $4, %esp;  \
1: iret;       \
.section .fixup,"ax";   \
2: sti;        \
    movl $(__USER_DS), %edx; \
    movl %edx, %ds; \
    movl %edx, %es; \
    movl $11,%eax;  \
    call do_exit;    \
.previous;       \ # swaps current section (code) with previous section (data)
.section __ex_table,"a";\
    .align 4;   \
    .long 1b,2b; \
.previous # // swaps curent section (data) with previous section (code)

RESTORE_REGSをバラすと、

restore_all:
    popl %ebx;
    popl %ecx;
    popl %edx;
    popl %esi;
    popl %edi;
    popl %ebp;
    popl %eax;
    popl %ds;
    popl %es;
    addl $4, %esp;
    iret;

再びstruct pt_regsを見る:

struct pt_regs {
    long ebx;
    long ecx;
    long edx;
    long esi;
    long edi;
    long ebp;
    long eax;
    int  xds;
    int  xes; // ES
    // pop above value
    long orig_eax; // addl $4, %esp;
    long eip;
    int  xcs;
    long eflags;
    long esp;
    int  xss;
};

irethttp://cstmize.hatenablog.jp/entry/2019/03/20/xv6%E3%81%AE%E5%A0%B4%E5%90%88%E3%81%AEException%E3%81%A8Interrupt%E3%81%AE%E6%8C%99%E5%8B%95 の最後で触れているが、hardware側で%eipやら%espやら%ssやらを復元していく。%eipはexceptionが生じた直後のinstruction pointerだったから、exceptionの処理終了後はここに飛んで作業を再開する。(ただ、SIGFPEは"重い"signalなので、このuser processはabortして死ぬことになるだろう)

Note) .section .fixupはよくわかんないので、TODOに回す。

Note) xv6では、 https://github.com/mit-pdos/xv6-public/blob/b818915f793cd20c5d1e24f668534a9d690f3cc8/trapasm.S#L21-L32 に対応する。同様にiret命令があることがポイント。

int 0x80

つづいてsoftware-generated なinterruptが発生した場合どうなっているのかをみていく。まず、set_system_gate(SYSCALL_VECTOR,&system_call);がstart_kernel -> trap_initで設定されていることに注意する。

syscall interruptが発生した時、hardware側で権限周りを確認後、registerをstackにpushする。その後、arch/i386/kernel/entry.S内のENTRY(system_call)からソフトウェア的な処理がスタートする。

%eaxにはsystem callのnumberを保存しているので、call *sys_call_table(,%eax,4)が呼ばれ、所要の関数に飛ぶ。この処理が終われば、syscall_exitラベル、restore_allラベルと進んでいきiretでUser spaceに引き戻される。

ただし、sys_execveの場合は、最終的にuser processに飛んだままになるかもしれない(処理が帰ってこない)ので、違う記事にて処理を追いたい。(xv6では、http://cstmize.hatenablog.jp/entry/2019/03/14/GDT%E3%81%A8IDT%E5%91%A8%E8%BE%BA%E3%81%AE%E7%90%86%E8%A7%A3%28xv6%E3%82%92%E4%BE%8B%E3%81%AB%29 の記事の"forkからinitまで"のフローに簡単にまとめている)

syscallの詳細はUnderstanding Linux kernelのch10にあるみたいだが、まだ読んでないので読んだらまとめたい。

exception中のinterruptの挙動

exception中にinterruptが生じるか否かは実はexceptionの種類によって異なる。interrupt gateの場合はeflags.IFがOFFになるので、手動でONにしない限り、interruptは発生しない。しかし、trap gateの場合はeflags.IFフラグはexceptionの前後でそのまま3。(TSSにあるeflagsエントリはch.7.2.1によれば、EFLAGS register field — State of the EFAGS register prior to the task switch.なので、前の状態を保存するためのもの)

Note) Trap GateとInterrupt Gateの形式的な違いは、Descriptorのtype[43:40]が0b1110だったらinterrupt Gateで、0b1111だったらTrap Gateであるという部分だけである。

divide_errorの場合、したのようにtrap_gate(type: 0b1111とセットされてる)なので、IFはクリアされない。set_system_gateもtypeが0x1111なのでIFはクリアされない。

// arch/i386/kernel/traps.c
// void __init trap_init(void)
    set_trap_gate(0,&divide_error);
    set_intr_gate(1,&debug);
    set_intr_gate(2,&nmi);
    set_system_intr_gate(3, &int3);
    set_system_gate(4,&overflow);
    set_system_gate(5,&bounds);
    set_trap_gate(6,&invalid_op);
    set_trap_gate(7,&device_not_available);
    set_task_gate(8,GDT_ENTRY_DOUBLEFAULT_TSS); 
    set_trap_gate(9,&coprocessor_segment_overrun);
    set_trap_gate(10,&invalid_TSS);
    set_trap_gate(11,&segment_not_present);
    set_trap_gate(12,&stack_segment);
    set_trap_gate(13,&general_protection);
    set_intr_gate(14,&page_fault);
    set_trap_gate(15,&spurious_interrupt_bug);
    set_trap_gate(16,&coprocessor_error);
    set_trap_gate(17,&alignment_check);
  // in x86, yes
#ifdef CONFIG_X86_MCE
    set_trap_gate(18,&machine_check);
#endif
    set_trap_gate(19,&simd_coprocessor_error);

    set_system_gate(SYSCALL_VECTOR,&system_call); // SYSCALL_VECTOR=0x80

なので、例えば、int $0x80が発生中にinterruptが生じる場合ことももちろんある。例えば、

[syscall開始 -> [local APIC timerが0に -> interrupt開始 -> 処理.. -> interrupt終了 ] -> syscall処理の続行 -> syscallの終了]

とか。

local APIC timerが0になるprocessは別プロセスであってもよく、その場合は、interruptを開始するprocessはexceptionが起こったprocessと別になる。

みたいなことが起こりうる。

exception中のschedulingについて

こちらも起こりうる。arch/i386/kernel/entry.Sの中の ret_from_exception -> resume_userspace -> work_pending -> check thread_info.flags & _TIF_NEED_RESCHED -> work_resched -> call schedule() となり、scheduleはprocess switchのentry pointだから、現在exceptionの処理中にもかかわらず、別のprocessに移動することが有りうる。

_TIF_NEED_RESCHEDがいつ付加されるかは、自分がまだ把握しきっていないので、別の機会に。

Note) ちなみに、上2つの例のように、exceptionやinterruptなどKernel Modeにいるときに別のprocessに移動できる状態のことをpreemptというみたい。通常はexceptionがinterruptが起こったときは発生前のprocessと同じprocessで作業する(exceptionの場合は、stack pointerを先頭に持ってくるのだったし、別で触れるが、external deviceによるinterruptは別途違うstack pointerに切り替えて作業する。)のだが、上記のようにそうならないパターンがいくつかある4

exceptionの入れ子について

exceptionの最中にexceptionが起こった場合は、Double Fault Exception (#DF)に分類され、abortとなる。(同じexceptionが重なることはないというところがポイント)

interruptの最中にexceptionは起こるか?

こちらも起こりうるが、これはinterruptについて解説するときにまとめるのが良いだろう。


  1. _set_gate(idt_table+n: GDT's index, 15: Trap Gate Descriptor, 0: dpl, divide_error: offset, __KERNEL_CS: segment selector);となっている。

  2. 厳密に言えば、call命令で呼び出される関数内でeflags.IFがONになることはあるがassembly内では終了処理中はdisableのまま。

  3. ch.6.12.1.2に When accessing an exception- or interrupt-handling procedure through an interrupt gate, the processor clears the IF flag to prevent other interrupts from interfering with the current interrupt handler. A subsequent IRET instruction restores the IF flag to its value in the saved contents of the EFLAGS register on the stack. Accessing a handler procedure through a trap gate does not affect the IF flag.とある。

  4. Understanding Linux Kernel のch.1の"Reentrant Kernels"の4つの箇条書されたやつがそう。