x86 Linux上で sysenter 命令を使ってシステムコールを呼び出す方法

2023/01/03に公開

x86 Linux上で sysenter 命令を使ってシステムコールを呼び出す方法

x86(32bit) 環境で、どのようにシステムコールが呼ばれているのかを調べてみました。

int 80h

昔からあるシステムコールの呼び方で、EAX レジスタにシステムコールの番号をセットした後、ソフトウェア割り込みの int 80h をコールするとシステムコールが呼ばれます。これはどのバージョンの Linux でも使えます。ただし、ソフトウェア割り込みは遅いのが欠点です。

sysenter

最近の Intel のプロセッサはすべて sysenter というシステムコール専用の命令が用意されています。この命令は int 80h に比べるとかなりシンプルで、予め設定されたシステムコールハンドラのアドレスを CS:EIPSS:ESP にセットして、特権モードに切り替えて実行を再開するのみです。詳しくは 64ビットCPU(AMD64+EM64T)でアセンブラ int 2E/sysenter/syscall考察 が参考になります。

さてここでのポイントは、sysenter 命令はユーザーモード空間での実行アドレスをスタックやレジスタに退避しないということです。通常の call 命令や int 80h 命令の場合は、スタックに CS:EIPpush してから jmp します。sysenter ではどこにも保存されません。では sysexit はどのように戻っているのでしょうか。sysexit

  1. ESP ← ECX
  2. EIP ← EDX
  3. CS ← (MSR に書かれたセレクタ + 16) OR 3
  4. SS ← CS + 8

的なことをやって ring 3 に戻ります。 sysenter で戻りアドレスを保存しないのにどうやって sysexitESPEIP を復元しているのでしょうか。

答えは、「sysenter を呼び出すのは決められたサブルーチンに限る」です。少なくとも Linux ではユーザープログラムから直接 sysenter を呼び出すことはありません。戻るときは、カーネルがその「決められたサブルーチン」の値を直接指定して戻ります。そこから改めてユーザープログラムに ret します。

ここで、アセンブリで直接システムコールを呼び出すサンプルプログラムを見てみましょう。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
  unsigned int gs10;
  pid_t pid, pid10;
  FILE *fp;
  char buf[256];

  __asm__ volatile("movl %%gs:0x10, %%eax" : "=a"(gs10));
  printf("syscall point = %p\n", gs10);
  pid = getpid();
  __asm__ volatile("\t"
                   "movl $20, %%eax\n\t"
                   "call *%%gs:0x10\n\t"
                   : "=a"(pid10));
  printf("pid = %u, pid10 = %u\n", pid, pid10);

  fp = fopen("/proc/self/maps", "r");
  while (fgets(buf, sizeof(buf), fp) != NULL) {
    fputs(buf, stdout);
  }
  fclose(fp);
}

実行例は次のようになります。

$ ./a.out 
syscall point = 0xb775a414
pid = 13734, pid10 = 13734
08048000-08049000 r-xp 00000000 fd:00 812670     /home/yuryu/src/a.out
08049000-0804a000 r--p 00000000 fd:00 812670     /home/yuryu/src/a.out
0804a000-0804b000 rw-p 00001000 fd:00 812670     /home/yuryu/src/a.out
09370000-09391000 rw-p 00000000 00:00 0          [heap]
b7568000-b7569000 rw-p 00000000 00:00 0 
b7569000-b7721000 r-xp 00000000 fd:00 391110     /usr/lib/libc-2.18.so
b7721000-b7723000 r--p 001b8000 fd:00 391110     /usr/lib/libc-2.18.so
b7723000-b7724000 rw-p 001ba000 fd:00 391110     /usr/lib/libc-2.18.so
b7724000-b7727000 rw-p 00000000 00:00 0 
b7738000-b773b000 rw-p 00000000 00:00 0 
b773b000-b775a000 r-xp 00000000 fd:00 397102     /usr/lib/ld-2.18.so
b775a000-b775b000 r-xp 00000000 00:00 0          [vdso]
b775b000-b775c000 r--p 0001f000 fd:00 397102     /usr/lib/ld-2.18.so
b775c000-b775d000 rw-p 00020000 fd:00 397102     /usr/lib/ld-2.18.so
bfed0000-bfef1000 rw-p 00000000 00:00 0          [stack]

gs:[10h] に保存されているアドレスに対して call すると、システムコールが実行されます。この例の場合、0xb775a414 が保存されていますが、これはちょうど "vdso" と呼ばれている領域が該当します。この vdso というのは、カーネルがユーザープログラムから使えるように、共有ライブラリとして自動的にリンクしている領域です。

gs:[10h] に含まれているコードを見てみましょう。

$ gdb ./a.out 
GNU gdb (GDB) Fedora 7.7.1-13.fc20
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i686-redhat-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./a.out...done.
(gdb) start
Temporary breakpoint 1 at 0x804856c: file gscall.c, line 12.
Starting program: /home/yuryu/src/a.out 
 
Temporary breakpoint 1, main () at gscall.c:12
12  __asm__ volatile (
Missing separate debuginfos, use: debuginfo-install glibc-2.18-12.fc20.i686
(gdb) n
16  printf("syscall point = %p\n", gs10);
(gdb) 
syscall point = 0xb7ffd414
17  pid = getpid();
(gdb) disas 0xb7ffd414
Dump of assembler code for function __kernel_vsyscall:
   0xb7ffd414 <+0>: push   %ecx
   0xb7ffd415 <+1>: push   %edx
   0xb7ffd416 <+2>: push   %ebp
   0xb7ffd417 <+3>: mov    %esp,%ebp
   0xb7ffd419 <+5>: sysenter 
   0xb7ffd41b <+7>: nop
   0xb7ffd41c <+8>: nop
   0xb7ffd41d <+9>: nop
   0xb7ffd41e <+10>: nop
   0xb7ffd41f <+11>: nop
   0xb7ffd420 <+12>: nop
   0xb7ffd421 <+13>: nop
   0xb7ffd422 <+14>: int    $0x80
   0xb7ffd424 <+16>: pop    %ebp
   0xb7ffd425 <+17>: pop    %edx
   0xb7ffd426 <+18>: pop    %ecx
   0xb7ffd427 <+19>: ret    
End of assembler dump.

レジスタを保存して sysenter しているコードが見つかりました。EDXECX を保存しているのは、上述の通り sysexit で使用するからです。ebp を保存しているのは、システムコールからユーザースタックにアクセスすることがあるからです。 nop の後に int 80h が見えますが、これはシステムコールをリスタートする時に使うもので、通常はここを通らずその後の pop ebp から実行が再開されます。

この vdso のアドレスはカーネルが知っているので、カーネルは決め打ちで EDXECX をセットすることができます。どこに戻るかの情報は thread_info 構造体の sysenter_return に入ってます。これがシステムコールハンドラで ECX にセットされます。

struct thread_info {
 struct task_struct *task;  /* main task structure */
 struct exec_domain *exec_domain; /* execution domain */
 __u32   flags;  /* low level flags */
 __u32   status;  /* thread synchronous flags */
 __u32   cpu;  /* current CPU */
 int   saved_preempt_count;
 mm_segment_t  addr_limit;
 struct restart_block    restart_block;
 void __user  *sysenter_return;
 unsigned int  sig_on_uaccess_error:1;
 unsigned int  uaccess_err:1; /* uaccess failed */
};

最後の謎は gs:[10h] って何?というところですが、Linux の x86 では gs は thread local storage を指しているそうです。で、ここに glibc が起動時に vdso のこのアドレスを探してきて保存しているようでした。

というわけで sysenter って int 80h のようなノリで使うものじゃ無いっぽいですね。 Windows も同じような仕組みだったのでしょうか。 x64 で使用している syscall 命令は、RCXRIP を保存するので、ユーザープログラムのどこからでも直接システムコールが呼べるみたいです。

参考文献

Discussion