x86アーキテクチャシステムコールの進化

ここここなど 、システムコールについて多くのことが言われています 。 システムコールは、OSのカーネル関数を呼び出す方法であることは既にご存じでしょう。 しかし、x86-64アーキテクチャの例を使用して、このシステムコールの特別な点、存在する実装、およびそのパフォーマンスをさらに深く掘り下げたいと思いました。 これらの質問への回答にも興味がある場合は、catへようこそ。







システムコール



モニターに何かを表示したり、デバイスに書き込んだり、ファイルから読み取ったりするたびに、OSのカーネルを使用する必要があります。 ハードウェアとの通信を担当するのはOSカーネルであり、割り込み、プロセッサモード、タスクスイッチングで動作するため、プログラムユーザーがオペレーティングシステム全体の動作を圧倒できないように、メモリ空間ユーザー空間 (ユーザーを実行するために設計されたメモリ領域)に分割することが決定されましたプログラム)およびカーネルスペース、OSカーネルメモリへのユーザーアクセスも拒否します。 この分離は、 セグメント化メモリ保護を使用するハードウェアのx86ファミリで実装されます 。 しかし、ユーザープログラムは何らかの形でカーネルと通信する必要があります。このため、 システムコールの概念が発明されました。







システムコールは、ユーザー空間プログラムをカーネル空間に呼び出す方法です。 外部から見ると、これは独自の呼び出し規約を持つ通常の関数の呼び出しのように見えるかもしれませんが、実際には、プロセッサは呼び出し命令で関数が呼び出されたときよりも少し多くのアクションを実行します。 たとえば、x86アーキテクチャでは、システムコール中に少なくとも特権レベルの増加が発生し、ユーザーセグメントがカーネルセグメントに置き換えられ、IPレジスタがシステムコールハンドラーに設定されます。







システムコールは関数でラップされ、Linuxのlibc.soやWindowsのntdll.dllなど、アプリケーション開発者が対話するさまざまなライブラリに隠されているため、プログラマは通常、システムコールを直接操作しません。







理論的には、0で除算しても、例外を使用してシステムコールを実装できます。主なことは、制御をカーネルに転送することです。 例外の実装の実際の例を検討してください。







システムコールを実装する方法



無効な命令実行。



以前、80386に戻ると、システムコールを行う最速の方法でした。 このため、通常は意味のない誤ったLOCK NOP命令が使用され、その後、プロセッサが無効な命令ハンドラを呼び出しました。 これは20年以上前であり、彼らは、この方法はMicrosoft Corporationでシステムコールを処理したと言います。 誤った命令のハンドラーは、意図した目的に使用されるようになりました。







コールゲート



異なる特権レベルのコードセグメントにアクセスするために、インテルはゲート記述子と呼ばれる特殊な記述子セットを開発しました。 このような記述子には4つのタイプがあります。









x86でシステムコールを実装することが計画されたのはコールゲートであったため、コールゲートにのみ興味があります。







コールゲートは、 call farまたはjmp far命令を使用して実装され、OSカーネルによって構成されたコールゲート記述子をパラメーターとして受け取ります。 保護リングの任意のレベルと16ビットコードに切り替えることができるため、これはかなり柔軟なメカニズムです。 コールゲートは、割り込みよりも生産性が高いと考えられています。 この方法は、OS / 2およびWindows 95 で使用されていました 。Linux を使用する不便さのため、このメカニズムは実装されませんでした。 時間が経つにつれて、システムコール(sysenter / sysexit)の実装がより生産的で使いやすくなったため、使用が完全に中止されました。







Linuxシステムコール



Linux OSのx86-64アーキテクチャでシステムコールを行う方法はいくつかあります。









各システムコールの実装には独自の特性がありますが、一般的に、Linuxハンドラーはほぼ同じ構造を持っています。









各システムコールを詳しく見てみましょう。







int 80h



最初、x86アーキテクチャでは、Linuxはソフトウェア割り込み128を使用してシステムコールを行いました。 システムコール番号を示すために、ユーザーはシステムコール番号をeaxに設定し、そのパラメーターはレジスタebxecxedxesiediebpに順番に配置されます。 次に、 int 80h命令が呼び出され 、プログラムによって割り込みが発生します。 プロセッサーは、カーネルの初期化中にLinuxカーネルによってインストールされた割り込みハンドラーを呼び出します。 x86-64では、割り込み呼び出しは下位互換性のためにx32モードエミュレーション中にのみ使用されます。







原則として、 高度なモードで命令を使用することを禁止する人はいません。 ただし、32ビットコールテーブルが使用され使用されるすべてのアドレスは32ビットアドレス空間に配置する必要があることを理解する必要があります。 SYSTEM V ABI [4]§3.5.1によれば、仮想アドレスがリンク段階で既知であり、2GBに配置されているプログラムで 、デフォルトで小さなメモリモデルが使用され、既知の文字はすべて32ビットアドレス空間にあります。 静的にコンパイルされたプログラムはこの定義に適しており、 int 80hを使用できます。 段階的な割り込み操作については、 stackoverflowで詳しく説明しています。







カーネルでは、この割り込みのハンドラーはentry_INT80_compat関数であり、 arch / x86 / entry / entry_64_compat.Sにあります







int 80hの呼び出し例
section .text global _start _start: mov edx,len mov ecx,msg mov ebx,1 ; file descriptor (stdout) mov eax,4 ; system call number (sys_write) int 0x80 ; call kernel mov eax,1 ; system call number (sys_exit) int 0x80 ; call kernel section .data msg db 'Hello, world!',0xa len equ $ - msg
      
      





コンパイル:







 nasm -f elf main.s -o main32.o ld -melf_i386 main32.o -o a32.out
      
      





または拡張モードで(プログラムは静的にコンパイルされるように動作します)







 nasm -f elf64 main.s -o main.o ld main.o -o a.out
      
      





sysenter / sysexit



しばらくして、x86-64がなかった場合でも、Intelは特別なシステムコール命令を作成することでシステムコールを高速化できるため、割り込みコストの一部を回避できることに気付きました。 そのため、いくつかのsysenter / sysexit命令が登場しました 。 ハードウェアレベルでは、sysenter命令を実行するときに、記述子の有効性の多くのチェックが省略され、特権のレベルに応じたチェックが行われるため、加速が達成されます[3]§6.1。 命令は、それを呼び出すプログラムがフラットメモリモデルを使用するという事実にも依存しています。 Intelアーキテクチャでは、命令は互換モードと拡張モードの両方で有効ですが、AMDの場合、この拡張モードの命令は不明なオペコードを除外します[3]。 したがって、 sysenter / sysexitのペアは現在、互換モードでのみ使用されます。







カーネルでは、この命令のハンドラーはentry_SYSENTER_compat関数であり、 arch / x86 / entry / entry_64_compat.Sにあります







Sysenterコールの例
 section .text global _start _start: mov edx,len ;message length mov ecx,msg ;message to write mov ebx,1 ;file descriptor (stdout) mov eax,4 ;system call number (sys_write) push continue_l push ecx push edx push ebp mov ebp,esp sysenter hlt ; dumb instructions that is going to be skipped continue_l: mov eax,1 ;system call number (sys_exit) mov ebx,0 push ecx push edx push ebp mov ebp,esp sysenter section .data msg db 'Hello, world!',0xa len equ $ - msg
      
      





コンパイル:







 nasm -f elf main.s -o main.o ld main.o -melf_i386 -o a.out
      
      





命令はインテルのアーキテクチャの実装で有効であるという事実にもかかわらず、高度なモードでは、このシステムコールはほとんど動作しません。 これは、現在のスタック値がebpレジスタに格納されており、メモリモデルに関係なく、トップアドレスが32ビットアドレス空間の外側にあるためです。 これは、Linuxがスタックをスペースの正規アドレスの下半分の最後にマップするためです。







Linuxカーネル開発者は、システムコールABIが変更される可能性があるため、ハードシステムプログラミングに対してユーザーに警告します。 Androidはこのアドバイスに従わなかったため、Linuxは下位互換性を維持するためにパッチをロールバックする必要がありました。 vDSOを使用してシステムコールを正しく実装します。これについては後で説明します。







syscall / sysret



AMDはAMD64と呼ばれるx86-64アーキテクチャを開発したため、独自のシステムコールを作成することにしました。 この命令は、IA-32アーキテクチャ用のsysenter / sysexitの類似物として、AMDによって開発されました。 AMDは、命令がアドバンストモードと互換モードの両方で実装されていることを確認しましたが、Intelは互換モードでこの命令をサポートしないことにしました。 これにも関わらず、Linuxには、x32およびx64の各モードに対して2つのハンドラーがあります。 この命令のハンドラーは、x64のentry_SYSCALL_64およびx32のentry_SYSCALL_compat関数であり、それぞれarch / x86 / entry / entry_64.Sおよびarch / x86 / entry / entry_64_compat.Sにあります。







システムコールの手順をより詳細に理解することに興味がある人のために、Intel擬似コードはIntelマニュアル[0](4.3)に記載されています。







シスコールコールの例
 section .text global _start _start: mov rdx,len ;message length mov rsi,msg ;message to write mov rdi,1 ;file descriptor (stdout) mov rax,1 ;system call number (sys_write) syscall mov rax,60 ;system call number (sys_exit) syscall section .data msg db 'Hello, world!',0xa len equ $ - msg
      
      





編集







 nasm -f elf64 main.s -o main.o ld main.o -o a.out
      
      





32ビットシステムコールの呼び出し例

次の例をテストするには、構成CONFIG_IA32_EMULATION = yのカーネルとAMDコンピューターが必要です。 Intelコンピューターがある場合は、仮想マシンで例を実行できます。 Linuxは警告なしでこのシステムコールのABIを変更できるため、再度お知らせします。vDSOを介して互換モードでシステムコールを実行する方がより適切です。







 section .text global _start _start: mov edx,len ;message length mov ebp,msg ;message to write mov ebx,1 ;file descriptor (stdout) mov eax,4 ;system call number (sys_write) push continue_l push ecx push edx push ebp syscall hlt continue_l: mov eax,1 ;system call number (sys_exit) mov ebx,0 push ecx push edx push ebp syscall section .data msg db 'Hello, world!',0xa len equ $ - msg
      
      





コンパイル:







 nasm -f elf main.s -o main.o ld main.o -melf_i386 -o a.out
      
      





AMDがIntel sysenter命令をx86-64アーキテクチャに拡張する代わりに命令を開発することにした理由は明らかではありません。







vsyscall



ユーザー空間からカーネル空間に切り替えると、コンテキストが切り替わりますが、これは最も安価な操作ではありません。 したがって、システムコールのパフォーマンスを向上させるために、ユーザー空間でそれらを処理することが決定されました。 このため、カーネルスペースをユーザースペースにマッピングするために8 MBのメモリが予約されました。 x86アーキテクチャ向けに、一般的に使用される読み取り専用呼び出しの3つの実装がこのメモリに配置されました:gettimeofday、time、getcpu。







時間の経過とともに、 vsyscallには重大な欠点があることが明らかになりました。 アドレス空間の固定位置はセキュリティ上の脆弱性であり、割り当てられたメモリの量に柔軟性がないため、カーネルの表示領域の拡大に悪影響を与える可能性があります。







例が機能するためには、カーネルがvsyscallをサポートしている必要があります: CONFIG_X86_VSYSCALL_EMULATION = y







Vsyscall呼び出しの例
 #include <sys/time.h> #include <stdio.h> #define VSYSCALL_ADDR 0xffffffffff600000UL int main() { // Offsets in x86-64 // 0: gettimeofday // 1024: time // 2048: getcpu int (*f)(struct timeval *, struct timezone *); struct timeval tm; unsigned long addrOffset = 0; f = (void*)VSYSCALL_ADDR + addrOffset; f(&tm, NULL); printf("%d:%d\n", tm.tv_sec, tm.tv_usec); }
      
      





コンパイル:







 gcc main.c
      
      





Linuxは互換モードでvsyscallを表示しません。







現時点では、下位互換性を維持するために、Linuxカーネルはvsyscallエミュレーションを提供してます。 エミュレーションは、セキュリティホールにパッチを当ててパフォーマンスを低下させるように設計されています。







エミュレーションは2つの方法で実装できます。







最初の方法は、関数アドレスをsyscallシステムコールに置き換えることです。 この場合、x86-64でのgettimeofday関数の仮想システムコールは次のとおりです。







 movq $0x60, %rax syscall ret
      
      





0x60はgettimeofdayシステムコールのコードです。







2番目の方法はもう少し興味深いです。 vsyscall関数が呼び出されると、 ページフォールト例外がスローされます。これはLinuxによって処理されます。 OSは、 vsyscallでの命令の実行が原因でエラーが発生したことを認識し、 emulate_vsyscall仮想システムコールハンドラー (arch / x86 / entry / vsyscall / vsyscall_64.c)に制御を渡します。







vsyscallの実装は、 vsyscallカーネルパラメーターを使用して制御できますvsyscall=none



パラメーターを使用して仮想システムコールを無効にするか、syscall syscall=native



命令を使用して実装を指定するか、 Page fault vsyscall=emulate



を使用します。







vDSO(仮想動的共有オブジェクト)



vsyscallの主な欠点を修正するために、動的に接続されたライブラリの表示という形でシステムコールを実装し、 ASLRテクノロジを適用することが提案されました。 「long」モードでは、ライブラリはlinux-vdso.so.1と呼ばれ、互換モードでは、 linux-gate.so.1と呼ばれます。 ライブラリは、静的にコンパイルされた場合でも、プロセスごとに自動的にロードされます。 libcライブラリの動的リンクの場合は、 ldd



ユーティリティを使用して、アプリケーションの依存関係を確認できます。







また、vDSOは、互換モードなどで最も効率的なシステムコールメソッドの選択として使用されます。







共有機能のリストは、 マニュアルに記載されています







VDSO呼び出しの例
 #include <sys/time.h> #include <dlfcn.h> #include <stdio.h> #include <assert.h> #if defined __x86_64__ #define VDSO_NAME "linux-vdso.so.1" #else #define VDSO_NAME "linux-gate.so.1" #endif int main() { int (*f)(struct timeval *, struct timezone *); struct timeval tm = {0}; void *vdso = dlopen(VDSO_NAME, RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD); assert(vdso && "vdso not found"); f = dlsym(vdso, "__vdso_gettimeofday"); assert(f); f(&tm, NULL); printf("%d:%d\n", tm.tv_sec, tm.tv_usec); }
      
      





コンパイル:







 gcc -ldl main.c
      
      





互換モードの場合:







 gcc -ldl -m32 main.c -o a32.elf
      
      





補助ベクトルAT_SYSINFO_EHDRからライブラリアドレスを抽出し、共有オブジェクトを解析することにより、vDSO関数を探すのが最適です。 補助ベクトルからvDSOを解析する例は、カーネルソースコードにあります:tools / testing / selftests / vDSO / parse_vdso.c







または、興味がある場合は、掘り下げて、glibcでvDSOがどのように解析されるかを確認できます。







  1. ヘルパーベクトルの解析:elf / dl-sysdep.c
  2. 共有ライブラリ解析:elf / setup-vdso.h
  3. 関数値の設定:sysdeps / unix / sysv / linux / x86_64 / init-first.c、sysdeps / unix / sysv / linux / x86 / gettimeofday.c、sysdeps / unix / sysv / linux / x86 / time.c


System V ABI AMD64 [4]によれば、 syscall命令を使用して呼び出しを行う必要があります 。 実際には、vDSOを介した呼び出しがこの命令に追加されます。 int 80hおよびvsyscallの形式のシステムコールのサポートは、後方互換性のために残りました。







システムコールパフォーマンスの比較



システムコールの速度をテストすると、すべてがあいまいになります。 x86アーキテクチャでは、単一の命令の実行は、キャッシュ内の命令の存在、パ​​イプラインの負荷、このアーキテクチャの遅延テーブルなど、多くの要因の影響を受けます[2]。 したがって、コードセクションの実行速度を判断することは非常に困難です。 Intelには、コードセグメント用の特別な時間測定ガイドさえあります[1]。 しかし、問題は、ユーザー空間からカーネルオブジェクトを呼び出す必要があるため、ドキュメントに従って時間を測定できないことです。







したがって、 clock_gettimeを使用して時間を測定し、 gettimeofday呼び出しのパフォーマンスをテストすることが決定されました。これは、システム呼び出しのすべての実装に含まれているためです。 異なるプロセッサでは、時間が異なる場合がありますが、一般に、相対的な結果は似ているはずです。







プログラムは数回起動され、その結果、最小実行時間が取られました。

int 80hsysenterおよびvDSO-32のテストは互換モードで実行れました。







テストプログラム
 #include <sys/time.h> #include <time.h> #include <stdio.h> #include <stdlib.h> #include <assert.h> #include <syscall.h> #include <dlfcn.h> #include <limits.h> #define min(a,b) ((a) < (b)) ? (a) : (b) #define GIGA 1000000000 #define difftime(start, end) (end.tv_sec - start.tv_sec) * GIGA + end.tv_nsec - start.tv_nsec static struct timeval g_timespec; #if defined __x86_64__ static inline int test_syscall() { register long int result asm ("rax"); asm volatile ( "lea %[p0], %%rdi \n\t" "mov $0, %%rsi \n\t" "mov %[sysnum], %%rax \n\t" "syscall \n\t" : "=r"(result) : [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec) : "rcx", "rsi"); return result; } #endif static inline int test_int80h() { register int result asm ("eax"); asm volatile ( "lea %[p0], %%ebx \n\t" "mov $0, %%ecx \n\t" "mov %[sysnum], %%eax \n\t" "int $0x80 \n\t" : "=r"(result) : [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec) : "ebx", "ecx"); return result; } int (*g_f)(struct timeval *, struct timezone *); static void prepare_vdso() { void *vdso = dlopen("linux-vdso.so.1", RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD); if (!vdso) { vdso = dlopen("linux-gate.so.1", RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD); } assert(vdso && "vdso not found"); g_f = dlsym(vdso, "__vdso_gettimeofday"); } static int test_g_f() { return g_f(&g_timespec, 0); } #define VSYSCALL_ADDR 0xffffffffff600000UL static void prepare_vsyscall() { g_f = (void*)VSYSCALL_ADDR; } static inline int test_sysenter() { register int result asm ("eax"); asm volatile ( "lea %[p0], %%ebx \n\t" "mov $0, %%ecx \n\t" "mov %[sysnum], %%eax \n\t" "push $cont_label%=\n\t" "push %%ecx \n\t" "push %%edx \n\t" "push %%ebp \n\t" "mov %%esp, %%ebp \n\t" "sysenter \n\t" "cont_label%=: \n\t" : "=r"(result) : [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec) : "ebx", "esp"); return result; } #ifdef TEST_SYSCALL #define TEST_PREPARE() #define TEST_PROC_CALL() test_syscall() #elif defined TEST_VDSO #define TEST_PREPARE() prepare_vdso() #define TEST_PROC_CALL() test_g_f() #elif defined TEST_VSYSCALL #define TEST_PREPARE() prepare_vsyscall() #define TEST_PROC_CALL() test_g_f() #elif defined TEST_INT80H #define TEST_PREPARE() #define TEST_PROC_CALL() test_int80h() #elif defined TEST_SYSENTER #define TEST_PREPARE() #define TEST_PROC_CALL() test_sysenter() #else #error Choose test #endif static inline unsigned long test() { unsigned long result = ULONG_MAX; struct timespec start = {0}, end = {0}; int rt, rt2, rt3; for (int i = 0; i < 1000; ++i) { rt = clock_gettime(CLOCK_MONOTONIC, &start); rt3 = TEST_PROC_CALL(); rt2 = clock_gettime(CLOCK_MONOTONIC, &end); assert(rt == 0); assert(rt2 == 0); assert(rt3 == 0); result = min(difftime(start, end), result); } return result; } int main() { TEST_PREPARE(); // prepare calls int a = TEST_PROC_CALL(); assert(a == 0); a = TEST_PROC_CALL(); assert(a == 0); a = TEST_PROC_CALL(); assert(a == 0); unsigned long result = test(); printf("%lu\n", result); }
      
      





コンパイル:







 gcc -O2 -DTEST_SYSCALL time_test.c -o test_syscall gcc -O2 -DTEST_VDSO -ldl time_test.c -o test_vdso gcc -O2 -DTEST_VSYSCALL time_test.c -o test_vsyscall #m32 gcc -O2 -DTEST_VDSO -ldl -m32 time_test.c -o test_vdso_32 gcc -O2 -DTEST_INT80H -m32 time_test.c -o test_int80 gcc -O2 -DTEST_SYSENTER -m32 time_test.c -o test_sysenter
      
      





システムについて

cat /proc/cpuinfo | grep "model name" -m 1



cat /proc/cpuinfo | grep "model name" -m 1



-Intel®Core(TM)i7-5500U CPU @ 2.40GHz

uname -r



-4.14.13-1-ARCH







結果表







実装 時間(ns)
int 80h 498
sysenter 338
シスコール 278
vsyscallエミュレート 692
vsyscallネイティブ 278
vDSO 37
vDSO-32 51


ご覧のとおり、システムコールの新しい実装はそれぞれ、以前の実装よりも生産性が高く、vsysvallはカウントされません。これはエミュレーションであるためです。 おそらく既に推測したように、vsyscallが意図したものである場合、呼び出し時間はvDSOに似ています。







現在のすべてのパフォーマンス比較は、メルトダウンの脆弱性を修正するKPTIパッチで行われています。







ボーナス: KPTIなしのシステムコールパフォーマンス



KPTIパッチは、メルトダウンの脆弱性を修正するために特別に開発されました。 ご存知のように、このパッチはOSのパフォーマンスを低下させます。 KPTIをオフ(pti =オフ)にしてパフォーマンスをチェックしましょう。







パッチをオフにした結果の表







実装 時間(ns) パッチ後の実行時間を増やす(ns) パッチ後のパフォーマンス低下(t1 - t0) / t0 * 100%



int 80h 317 181 57%
sysenter 150 188 125%
シスコール 103 175 170%
vsyscallエミュレート 496 196 40%
vsyscallネイティブ 103 175 170%
vDSO 37 0 0%
vDSO-32 51 0 0%


パッチが約180 nsかかり始めた後、平均してカーネルモードへの移行とその逆の移行。 より多くの時間、明らかにこれはTLBキャッシュをフラッシュする価格です。







このタイプの呼び出しではカーネルモードへの移行がないため、vDSOを介したシステムコールのパフォーマンスは低下していません。したがって、TLBキャッシュをフラッシュする理由はありません。







さらに読むために



       Linux (  , ): https://0xax.gitbooks.io/linux-insides/content/SysCall/syscall-3.html   Linux: https://www.win.tue.nl/~aeb/linux/lk/lk-4.html   ,  1: https://lwn.net/Articles/604287/   ,  2: https://lwn.net/Articles/604515/
      
      





参照資料



[0] Intel 64およびIA-32アーキテクチャ開発者マニュアル:Vol。 2B

[1] コード実行時間のベンチマーク方法...

[2] AMDおよびIntel x86プロセッサーの命令レイテンシーとスループット

[3] AMD64 Architecture Programmer's Manual Volume 2:システムプログラミング

[4] System V ABI AMD64








All Articles