このページの本文へ

前へ 1 2 次へ

Goならわかるシステムプログラミング 第5回

Goから見たシステムコール

2016年11月16日 09時00分更新

文● 渋川よしき、編集● 鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

今回は、システムコールそのものについて深く掘り下げていきます。

  • システムコールとは何者で、ないとどうなるのか?
  • システムコールを呼び出すコード(Go言語アプリケーション側)を探索しよう
  • システムコールから、実際にOSカーネル内で仕事をする関数が呼び出されるまでのステップは?
  • システムコールの呼び出しをモニターするツールの紹介

とはいえ、Goでアプリケーションプログラムを書くほとんどの人は、直接システムコールを呼び出すコードを書くわけではないでしょう。 また、OSを改造してシステムコールを自分で作成することもまれでしょう。

そのため、今回の記事にはサンプルコードを使ったハンズオンはあまりなく、手を動かしてコード化するネタはありません。 この連載のテーマは「プログラマの視点から、具体的で役に立ちそうな低レイヤーの情報を提供する」ことですが、今回に限っては座学的な内容です。

謝辞

今回の執筆にあたっては、小泉守義氏の多大なる協力のおかげで、当初目的としていた内容を無事に書きあげることができました。 小泉氏には、WindowsでAPI呼び出しのモニタリングを劇的に改善するAPI Monitorという神ツール(@moriyoshi曰く)を紹介してもらいました。 何より、macOSにおけるDTraceとGo製アプリケーションの相性問題の分析をしていただきました。 ここで感謝申し上げたいと思います。

システムコールとは何か?

「システムコール」という言葉は、これまでの連載でも何度か登場していますが、詳しい説明はしてきませんでした。 システムコールの正体は「特権モードでOSの機能を呼ぶ」ことです。

CPUの動作モード

OSが行う仕事には、最初の連載で簡単に紹介したように、各種資源(メモリ、CPU時間、ストレージなど)の管理と、外部入出力機能の提供(ネットワーク、ファイル読み書き、プロセス間通信)があります。

アプリケーションプロセスが、行儀よく他のプロセスに配慮しつつ、権限上許されないことを自分で節制しながら自分の仕事を行うのであれば、OSは基本的に外部入出力機能の提供だけすれば済むでしょう。 実際、Windowsを例にすると、設定しだいではWindows 3.0まではアプリケーションプロセスが他のプロセスのメモリ空間にも自由にアクセスできました。 Windows 3.1になっても、各プロセスが外部ファイルの読み込み待ちをする際に自分でCPUの処理を休止し、他のプロセスに処理を回していました。

しかし、プロセスはバグがあって想定外の暴れ方をすることがあります。また、悪意あるプロセスが他のプロセスのメモリを書き換えたり、処理を回さないでコンピュータ全体を停止させることが可能というのは問題です。 そこで現在は、プロセスは自分のことだけに集中し、メモリ管理や時間管理などはプロセスの外からOSがすべて行う方式が主流となっています。 そのぶんOSの仕事は増えてしまいますが、ハードウェアであるCPUでもさまざまな仕組みが用意されていて、OSの仕事を裏で支えています。

そのようなCPUの仕組みの一つとして、CPUの動作モードがあります。 動作モードが用意されているCPUでは、実行してよいハードウェアとしての機能がソフトウェアの種類に応じて制限されており、それを動作モードによって区別しているのです。

サポートしている動作モードの種類はCPUによって異なります。 Intel系CPUでは4種類のモードを使用することができますが1、ほとんどのOSで使われているのは、OSが動作する特権モードと、一般的なアプリケーションが動作するユーザモードの2種類です。

特権モードでは、CPUの機能が基本的にはすべて使えます。 OSは、配下のすべてのプロセスのために資源を管理したり、必要に応じて取り上げたり(ユーザの操作による強制終了や、メモリ資源がなくなりそうなときのOOMキラーなど)する必要があるため、通常のプロセスよりも強い特権モードで動作します。 いっぽう、ユーザモードでは、そうした機能をCPUレベルで利用できないようになっています。

OSの機能も、アプリケーションの機能も、バイナリレベルで見れば同じようなアセンブリコードですが、CPUの動作モードが異なるわけです。

ここでは大ざっぱに「機能の差」としていますが、正確には少し異なります。 特権モードでしか使えない機能もありますが、例えば「メモリアクセス」は、機能としては特権モード、ユーザモードの両方で使えます。 ただし、領域ごとの制約が異なります。 CPUの持つメモリ管理ユニットはメモリの領域ごとに「読み込みができる」「書き込みができる」「実行ができる」といった設定をモードごとに設定できます。 そのためOSのカーネルは、安全性を上げるために「特権モードでしか実行できないプログラム」「ユーザモードでも書き込みができるメモリ領域」といった制約を細かく設定しています。

システムコールでモードの壁を越える

とはいえ、通常のアプリケーションでも、メモリ割り当てやファイル入出力、インターネット通信などの機能が必要になることは多々あります。 むしろ、それらをまったく利用しないアプリケーションでは意味がないでしょう。 そこで必要になるのがシステムコールです。 多くのOSでは、システムコールを介して、特権モードでのみ許されている機能をユーザモードのアプリケーションから利用できるようにしているのです。

システムコールの仕組みは何種類かありますが、現在主流の64ビットのx86系CPUでは、通常の関数呼び出し(アセンブリ命令のCALL)と似たSYSCALL命令を使って呼び出し、戻るときも通常の関数からの戻り(アセンブリ命令のRET)に近い、SYSRET命令を使います。 ARMの場合はSVC命令(スーパーバイザーコールの略)が使われます2。 これらの命令を使うと、OSが提供する関数を呼び出しますが、呼ばれた側では特権モードで動作します。 そのため、ユーザモードでは直接行えない、メモリ割り当てやファイル入出力、インターネット通信などの機能を実行することができます。

通常の関数呼び出しと似ていると説明しましたが、これも正確な表現ではありません。 他の場所で定義された処理の塊(プロシージャ)を呼ぶ、という概念レベルでは似ていますが、次のページの「システムコールより内側の世界」で説明する通り、呼べる関数は1つだけで好きな関数を自由に呼び出すことはできません。 特権モードになれるといっても使える機能自体は制約されていますし、その呼ばれた関数内で呼び出したアプリケーションの身元確認をすることで安全を守っています。

システムコールがないとどうなるか?

システムコールがなくてもCPUの命令のほとんどは使えます。 たとえば、高度な計算を高速に行うにはCPUのSIMD(SSEやAVX)という種類の命令を使いますが、その実行はユーザモードでも可能です。 ですが、システムコールがなければ、それらの計算がすべてムダになってしまいます。

  • システムコールがなければ、計算した結果を画面に出力することはできません。 ターミナルからプログラムを実行している場合も、IDEから実行している場合も、出力先は別のプロセスです。 特権モードのみで許可されるプロセス間通信の機能を使わなければ、結果を伝達することはできません。
  • システムコールがなければ、計算した結果をファイルに保存することはできません。 特権モードのみで許可されるファイルの入出力機能を使わなければ、結果をファイルに保存して、他のプログラムから読むことはできません。
  • システムコールがなければ、計算した結果を共有メモリに書き出すこともできません。 共有メモリ機能を使えば、他のプログラムから計算結果を参照することができますが、特権モードでなければ共有メモリを作成することができません。
  • システムコールがなければ、計算した結果を外部のウェブサービスなどに送信することもできません。 外部のウェブサービスなどへの通信も、カーネルが提供する機能がなければ利用できません。

GUIのウインドウを開いて表示するときも、どこかの段階で必ずシステムコールが必要となります。 何も出力ができないので、プロセスにできることは電力を消費して熱を発生させるぐらいでしょう。

実際は、出力するどころか、まともな計算を行うこともできません。 そもそも計算に必要なデータを外部から読み込むこともできません。 また、計算に必要なメモリを確保することもできません。 計算を開始するためのプロセスを生み出すこともできませんし、終了することもできません。 システムコールがなければプログラムを起動することもできません。

Go言語におけるシステムコールの実装

第2回から第4回までの連載では、ファイル入出力に関係するいくつかの関数や構造体、そしてインタフェースについてとりあげました。 たとえば、ファイルの構造体(os.File)は次の4つのインタフェースを満たしていました。

  • io.Reader
  • io.Writer
  • io.Seeker
  • io.Closer

これらのインタフェースの内部では、最終的に syscall パッケージで定義された関数を呼び出します。 そのうちファイルの読み書きで使われるのは次の5つの関数です。

システムコール関数 機能
func syscall.Open(path string, mode int, perm uint32) ファイルを開く(作成も含む)
func syscall.Read(fd int, p []byte) ファイルから読み込みを行う
func syscall.Write(fd int, p []byte) ファイルに書き込みを行う
func syscall.Close(fd int) ファイルを閉じる
func syscall.Seek(fd int, offset int64, whence int) ファイルの書き込み/読み込み位置を移動する

各OSにおけるシステムコールの実装を見てみよう

では、実際にシステムコールの内部ではどのような処理が行われるのでしょうか。 syscall.Open()関数を例に、各OSにおける実装を見ていきましょう。

まずはデバッガーでシステムコールを呼び出しているところまで下りていきます。 次のコードを使ってos.Create()の中を覗いてみましょう。 デバッガーの使い方は第1回の記事を参照してください。

package main
import (
    "os"
)
func main() {
    file, err := os.Create("test.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    file.Write([]byte("system call example\n"))
}

すると、os.Create()os.OpenFile()を使いやすくする便利関数として次のように定義されていることがわかります。

func Create(name string) (*File, error) {
    return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666)
}

次はos.OpenFile()の中身を見ていきますが、OSがWindowsかそうでないかで呼ばれる関数が変わります。

macOSにおけるシステムコールの実装(syscall.Open

LinuxやmacOSの場合は、os.OpenFile()からfile_unix.goというファイルに定義されている実装が呼ばれます。 ファイルのモードフラグを変更するかどうかの判定コードなどもありますが、一番大切なのは次の行です。

r, e = syscall.Open(name, flag|syscall.O_CLOEXEC, syscallMode(perm))

ここから先はLinuxとmacOSとで処理が分岐します。

まずはmacOSの処理を見てみましょう。

syscall.Open()関数からは、macOSであればzsyscall_darwin_amd64.goの中のOpen()関数が呼び出されます。だんだんCPUに近い世界になってきましたね。

この関数のmacOSにおける実装は次のようになっています (コメントによると、この関数はGo言語の処理系に含まれるツールによって自動生成されます)。

func Open(path string, mode int, perm uint32) (fd int, err error) {
    var _p0 *byte
    _p0, err = BytePtrFromString(path)
    if err != nil {
        return
    }
    r0, _, e1 := Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))
    use(unsafe.Pointer(_p0))
    fd = int(r0)
    if e1 != 0 {
        err = errnoErr(e1)
    }
    return
}

この関数では、まずGo言語形式の文字列をC言語形式の文字列(先頭要素へのポインタ)に変換しています。 これは、システムコールに渡せるのが「数値」だけだからです。 それから、真ん中付近で、Syscall()関数を呼び出しています。

OSに対してシステムコール経由で仕事をお願いするときには、どんな処理をしてほしいかを番号で指定します。 「5番の処理を実行してほしい」などとお願いするわけです。 SYS_OPENは、そのための番号として各OS用のヘッダーファイルなどから自動生成された定数です。 名前の先頭がzsysnum_になっている各OS用のファイルで定義されています。

Syscall()の中身は、macOSの場合、asm_darwin_amd64.sというGo言語の低レベルアセンブリ言語で書かれたコードで定義されています。

TEXT    ·Syscall(SB),NOSPLIT,$0-56
        CALL    runtime·entersyscall(SB)
        MOVQ    a1+8(FP), DI
        MOVQ    a2+16(FP), SI
        MOVQ    a3+24(FP), DX
        MOVQ    $0, R10
        MOVQ    $0, R8
        MOVQ    $0, R9
        MOVQ    trap+0(FP), AX  // syscall entry
        ADDQ    $0x2000000, AX
        SYSCALL
        JCC     ok
        MOVQ    $-1, r1+32(FP)
        MOVQ    $0, r2+40(FP)
        MOVQ    AX, err+48(FP)
        CALL    runtime·exitsyscall(SB)
        RET

暗号めいた文字列が並んでいますが、真ん中よりも少し下にSYSCALLという命令があるのがわかります (_arm.sとか_arm64.sのようなARM系のコードを見るとSVC命令になっています)。 ここが境界線となっていて、ここから先は、OS側のコードに処理が渡ります。 残念ながらデバッガーではこのSYSCALLの内側を覗くことはできませんが、 このSYSCALL(もしくはSVC)の中で、runtimeパッケージのentersyscall()関数とexistsyscall()関数が呼び出されます。

entersyscall()関数は、現在実行中のOSスレッドが時間のかかるシステムコールでブロックされていることを示すマークをつけます。 existsyscall()関数は、そのマークを外します。 Go言語では、実行しなければならないタスクが多くあるのにシステムコールのブロックなどで動けるスレッドが不足すると、OSに依頼して新しい作業用スレッドを作成します。 スレッド作成は重い処理なので、これを必要になるまで行わないようにするために、これらの関数を使います。 これにより実行効率の面でメリットがありますが、Go言語内部の実行モデルに関係するものであり、他のプログラミング言語には見られないGo言語ならではの特徴といえるでしょう。

なお、これらのスレッド関係の処理を行わないRawSyscall()という関数もあります。 ファイル読み書きやネットワークアクセスの場合、物理的にヘッドなどを動かしたり、数100ミリ秒から数秒程度のレスポンス待ちが発生する可能性が高く、重い処理です。 メモリ確保もスワップが発生するとファイル読み書きと同じぐらいコストがかかります。 それ以外の、短時間で終わることが見込まれる処理の場合には、こちらのRawSyscall()が使われます。

LinuxにおけるGoのシステムコール実装(syscall.Open

Linuxの場合は、途中まではmacOSと一緒ですが、syscall_linux.goで専用のsyscall.Open()が定義されています。 その中で、指定されたディレクトリ内にファイルを作成するopenat()という関数を使い3open()システムコールを実現しています。

func Open(path string, mode int, perm uint32) (fd int, err error) {
    return openat(_AT_FDCWD, path, mode|O_LARGEFILE, perm)
}

openat()関数は、CPUアーキテクチャごとに、zsyscall_linux_amd64.goなどのファイルの中で定義されています。このコードも自動生成されています。

func openat(dirfd int, path string, flags int, mode uint32) (fd int, err error) {
    var _p0 *byte
    _p0, err = BytePtrFromString(path)
    if err != nil {
        return
    }
    r0, _, e1 := Syscall6(SYS_OPENAT, uintptr(dirfd), uintptr(unsafe.Pointer(_p0)), uintptr(flags), uintptr(mode), 0, 0)
    use(unsafe.Pointer(_p0))
    fd = int(r0)
    if e1 != 0 {
        err = errnoErr(e1)
    }
    return
}

Syscall()では3個までしかパラメータを渡せず、それ以上のパラメータが必要な場合はSyscall6()Syscall9()を使います。 ここで必要な引数は4つなので、6つパラメータを渡せるSyscall6()を使っています。 余った引数には0を渡しています。

最後のSyscall()は、ほぼmacOSと同じなので省略します。

WindowsにおけるGoのシステムコール実装(syscall.Open

Windowsは、これまで紹介したOSとはまったく実装が異なっています。 Windowsも内部ではシステムコールを呼び出しているはず4ですが、Windowsは内部のコードを公開していないため、システムコールを直接呼び出せません。 Go言語も、Windowsだけはkernel32.dlluser32.dllshell32.dllなどのDLLをロードして、Microsoftが公開しているWin32 API呼び出しを行うことでOSの機能を利用しています。 そのためGo言語では、他のOSについては共有ライブラリの読み込みを言語機能としては提供していませんが、Windowsだけは標準ライブラリを使ってDLLをロードできるようになっています。

第2回で紹介したように、POSIX系OSではファイルもソケットも同じ仲間です。どちらもファイルディスクリプタを使ってまとめて扱えます。 Windowsの場合には、ファイルはハンドルと呼ばれる識別子を使って操作します。 ハンドルは32ビット整数で、ハンドルを使って管理される仲間には、ウィンドウやボタン、フォントがあります。

Windowsのsyscall.Open()の実装では、Windows用のフラグに各種パラメータを置き換えた後に、次の関数を呼び出しています。

h, e := CreateFile(pathp, access, sharemode, sa, createmode, FILE_ATTRIBUTE_NORMAL, 0)

この関数はzsyscall_windows.goの中で定義されています。

func CreateFile(name *uint16, access uint32, mode uint32, 
                sa *SecurityAttributes, createmode uint32,
                attrs uint32, templatefile int32) (handle Handle, err error) {
    r0, _, e1 := Syscall9(procCreateFileW.Addr(), 7, 
                          uintptr(unsafe.Pointer(name)), 
                          uintptr(access), uintptr(mode),
                          uintptr(unsafe.Pointer(sa)), uintptr(createmode), 
                          uintptr(attrs), uintptr(templatefile), 0, 0)
    handle = Handle(r0)
    if handle == InvalidHandle {
        if e1 != 0 {
            err = error(e1)
        } else {
            err = EINVAL
        }
    }
    return
}

他のOSではSyscall()系の関数に渡す最初の引数は数値でしたが、WindowsではAPIの関数ポインタを渡しています。 この外部APIのアクセス用オブジェクトは次のように初期化されています。

modkernel32 = NewLazyDLL(sysdll.Add("kernel32.dll"))
procCreateFileW = modkernel32.NewProc("CreateFileW")

これらの処理の中では、LoadLibraryExGetProcAddressといったWindows APIを使って、必要な関数のアドレスを取得してきます。

CreateFile()はWindowsのCreateFile APIのユニコード対応版のCreateFileWを利用しています。

runtimeパッケージ内のsyscall_windows.goSyscall9()の実体があります。 ここではシステムコール呼び出しではなく、C言語形式でAPIを呼び出しています。

//go:linkname syscall_Syscall9 syscall.Syscall9
func syscall_Syscall9(fn, nargs, a1, a2, a3, a4, a5, a6, a7, a8, a9 uintptr) 
      (r1, r2, err uintptr) {
    c := &getg().m.syscall
    c.fn = fn
    c.n = nargs
    c.args = uintptr(noescape(unsafe.Pointer(&a1)))
    cgocall(asmstdcallAddr, unsafe.Pointer(c))
    return c.r1, c.r2, c.err
}

注釈

  1. https://ja.wikipedia.org/wiki/リングプロテクション
  2. 16ビット時代から32ビット世代の前半では、ソフトウェア割り込みという仕組みを使って実装されていましたが、まず割り込みベクタからOSの機能を探し(MS-DOSは21h、Windowsは2eh、Linuxは80hの番地)、その後OS側で命令のリストから呼び出したい関数を選ぶという2段階のテーブル走査が必要でした。ARMも32ビットではソフトウェア割り込みのSWI命令を使っています。32ビット時代の後半のx86系CPUでは、SYSENTER/SYSEXITというより高速な仕組みが導入されました。
  3. openatシステムコールはPOSIX標準ではないため、Linux以外ではサポートされていませんが、標準化の提案がされています。 http://www.tutorialspoint.com/unix_system_calls/openat.htm
  4. http://www.codeguru.com/cpp/w-p/system/devicedriverdevelopment/article.php/c8035/How-Do-Windows-NT-System-Calls-REALLY-Work.htm

前へ 1 2 次へ

カテゴリートップへ

この連載の記事
ピックアップ