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

POSIXとC言語の標準規格

前の節では、各OSにおいてGo言語のシステムコールがどう実装されているかを見てきました。 ここから先は、システムコールによって呼び出される側です。つまりOSの世界の話になります。

でもその前に、ここでGo言語に限らないシステムコール一般の話をしておきます。

POSIXという名前を聞いたことがある人は多いでしょう。 POSIX(Portable Operating System Interface)は、OS間で共通のシステムコールを決めることで、アプリケーションの移植性を高めるために作られたIEEE規格です。 最終的にOSに仕事をお願いするのはシステムコールですが、POSIXで定められているのはシステムコールそのものではなく、システムコールを呼び出すためのインタフェース(Go言語の用語とは違いますが、広い意味で)です。 具体的にはC言語の関数名と引数、返り値が定義されています。

たとえばファイル入出力は、POSIXでは5つの基本のシステムコールで構成されていて、そのためのC言語の関数はopen()read()write()close()lseek()です。

これらの関数は、C言語における低レベルな共通インタフェースとして用意されていますが、通常のプログラミングで直接扱うことはほとんどありません。

実をいうと、C言語の国際規格(ISO/IEC 9899:1999やISO/IEC 9899:2011)にはこのあたりの関数は存在しません。 C言語の参考書に書かれているのは、fopen()fwrite()などの頭にfがついた関数です。 これらは、処理の高速化のためにバッファリングを行うなど、少し使いやすくした高級なインタフェースを提供していたり5、POSIXでないWindowsでも同じように書けるようになっています。

Go言語におけるsyscallの各関数も、このシステムコールの呼び出し口です。 それぞれ先頭を小文字にすれば、C言語の関数と同じ名前になっています。 呼び出し時に与える情報の意味、引数の順序、返り値なども、引数の型はGo言語特有のものを使っていますが、基本的には同じです。

そして、やはりGo言語でもsyscallの関数を直接使うことはなく、基本的にはos.File構造体とそのメソッドを使ってプログラミングをします。 syscall以下の関数を使う方法は、Go言語のドキュメントにはほとんどありません。 使う必要がある場合はC言語用の情報を参照する必要があります。

システムコールより内側の世界

Go言語におけるシステムコールは、syscallパッケージの関数として実装されていました。 その実装を追っていくと、SYSCALLのような特別な命令に行きつきました。 では、SYSCALLが呼ばれるとマシンの中では何が起こるのでしょうか。

それを知るために、今度はシステムコールから呼ばれるカーネル側のコードを見てみることにましょう。 参考にするのはLinuxのソースコードです。 ここから先に出てくるコードはGo言語ではなくC言語になります。

Linuxのソースコードは規模も大きいため、ローカルにダウンロードするだけでも大変です。 コードを追いかけるときには次のようなサイトを使ってみてください。

システムコール関数はSYSCALL_DEFINExマクロで定義される

Linuxにおけるwriteシステムコールは次のファイルで定義されています。

実際に呼ばれるコードは、SYSCALL_DEFINExマクロ( x の部分には0-6の数値が入る)を使って定義されています。 writeシステムコールの定義部分は次のようになっています。

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
   size_t, count)
{
   /* 実際の定義 */
}

このマクロを展開すると、次のようなsys_write()という関数の定義になります。

asmlinkage long sys_write(unsigned int fd,
   const char __user *buf,
   size_t count)

先頭についているasmlinkageは、引数をCPUのレジスタ経由で渡すようにするためのフラグです。

通常の関数呼び出しでは、呼び出し側と呼ばれる側が、スタックメモリ上で隣接したメモリブロックに、それぞれのスコープに含まれるローカル変数を格納します。 関数の引数も、このスタックメモリを使って渡されます。

一方、ユーザモードのアプリケーションと特権モードのカーネル間で呼び出されるSYSCALL命令は、関数呼び出しのように呼び出されますが、呼び出し側と呼ばれる側とで環境がまったく別物です。 そもそも、ユーザモード領域とカーネル領域とではスタックメモリも別に用意されています。

asmlinkageフラグを付けることで、引数がすべてCPUのレジスタ経由で渡されるようになるため、スタックを使わずに情報が渡せるようになります。

CPUが関数を実行できるようにするまで

このsys_write()関数は、Linuxカーネルのビルド時に自動生成される、sys_call_tableという配列に格納されています。 配列のインデックスがシステムコールの番号になっています。

この配列から必要な情報を取り出して呼び出すのはdo_syscall_64()関数です。

__visible void do_syscall_64(struct pt_regs *regs)
{
   ...(省略)...
   regs->ax = sys_call_table[nr & __SYSCALL_MASK](
   regs->di, regs->si, regs->dx,
   regs->r10, regs->r8, regs->r9);
   ...(省略)...
}

この関数を呼び出しているのは、次のアセンブリコードで定義されている、entry_SYSCALL_64()という関数です。

entry_SYSCALL_64()の中では、レジスタを構造体に退避してカーネルモード用のスタックに付け替えたりしながら、次のようにdo_syscall_64()を呼び出しています (条件が合えばdo_syscall_64()を飛ばして、直接sys_call_tableを呼び出すこともしています)。

call do_syscall_64

entry_SYSCALL_64()関数を呼び出すのは、みなさんが触っているコンピュータのCPUそのものです。 以下のsyscall_init()関数の中で、CPUから呼び出されるように登録しています。

wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

ここではwrmsrl命令を使って、entry_SYSCALL_64()関数のポインタを、CPUの特別なレジスタ(IA32_LSTAR)に登録しています。 CPUはSYSCALL命令が呼ばれると、このレジスタの関数を呼び出します。

ここまで、実際に呼ばれる関数から逆方向にシステムコールの接点までたどってきました。 これでGo言語のアプリケーションコードからカーネル内の関数定義まで線がつながりました!

Go言語のシステムコールとPOSIX

ここまで、各種OSにおけるGo言語のシステムコールの実装がどうなっているかをざっと眺め、 それを受けるOS内のコードについてもLinuxを例に解説してきました。 ここで、いままで見た話をまとめておきましょう。

システムコール関数 Linux FreeBSD macOS Windows
syscall.Open() openatシステムコールを呼び出す openシステムコールを呼び出す Win32 APIのCreateFile()を呼び出す
syscall.Read() readシステムコールを呼び出す Win32 APIのReadFile()を呼び出す
syscall.Write() writeシステムコールを呼び出す Win32 APIのWriteFile()を呼び出す
syscall.Close() closeシステムコールを呼び出す Win32 APIのCloseHandle()を呼び出す
syscall.Seek() lseekシステムコールを呼び出す Win32 APIのSetFilePointer()を呼び出す

ただし、同じシステムコールでもOSごとに番号が異なりますし、Linuxに関してはプロセッサの種類によっても番号が変わります。

システム
コール
Linux
(x86)
Linux
(x64)
Linux
(arm64)
FreeBSD
(x86/x64/arm)
macOS
(x64/arm64)
open 5 2
5 5
openat 295 257 56 499
read 3 0 63 3 3
write 4 1 64 4 4
close 6 3 57 6 6
lseek 19 8 62 478 199

POSIXのコンセプトは、「OS間のポータビリティを維持する」です。そう考えると、本来はC言語の関数をそのまま使うべきであり、システムコールを自前で番号指定して呼び出すのは推奨されることではありません。 しかしGo言語は、ポータビリティを自力で頑張るというトレードオフを選択しました6。 それによりクロスコンパイルが容易になり、他のOSで動くバイナリが簡単に作成できるようになっています。

システムコールのモニタリング

アプリケーションが存在し、意味のある結果を生み出すために、システムコールが必要不可欠という話は最初に紹介しました。 ちょっとしたメモリ確保、スレッドの起動など、ファイルアクセス、ネットワークアクセスなど、さまざまな機能の実現にシステムコールが利用されます。 そのため、中途半端にログを出力するぐらいであれば、システムコールの呼び出し状況をモニターするほうが、アプリケーションがどのように動作しているかを確実に知るための手助けになります。

なお、Go言語製アプリケーションは、main()関数呼び出しの前の初期化シーケンスの中でも大量のシステムコール呼び出しを行います。 特に、シグナルハンドラの初期化で大量に呼び出されます。 起動済みのプロセスに後からアタッチする方法であれば問題はありませんが、モニタリングツールからアプリケーションを起動するときは、その点に注意してください。

Linux

Linuxではstraceコマンドを使います。以下のどれかのコマンドで大抵のディストリビューションではインストールできるでしょう。

$ apt install strace ⏎
$ yum install strace ⏎
$ emerge strace ⏎
$ pacman -S strace

使い方は簡単です。

# ツール経由で起動
$ strace ./実行ファイル ⏎
# 実行中のアプリケーションにアタッチ
$ strace -p プロセスID

FreeBSD

FreeBSDにはtrussコマンドが付いてきます。 使い方はstraceと同じく簡単です。

# ツール経由で起動
$ truss ./実行ファイル ⏎
# 実行中のアプリケーションにアタッチ
$ truss -p プロセスID

macOS

macOSにはFreeBSDのtrussに似たdtrussコマンドがインストールされています。 DTraceという仕組みを使ったツールです。sudoが必要ですが、基本的にFreeBSDのtrussと一緒です。

# ツール経由で起動
$ sudo dtruss ./実行ファイル ⏎
# 実行中のアプリケーションにアタッチ
$ sudo dtruss -p プロセスID

ですが、残念ながらこのように簡単にはいきません。 まずはmacOS 10.11(El Capitan)から、System Integrity Protection(略称SIP)と呼ばれるセキュリティ機構が有効化され、DTraceが動きません。 この機能を停止する必要があります。 当然、セキュリティリスクが上がりますので、各自の責任で設定してください。

それ以外に、おそらくGoのバグですが、ツール経由で起動する方法ではエラー(panic)が発生します。 Go言語だけで書かれたコードのビルドで使われるコンパイラモードではエラーになるため、次のように"C"(大文字のC)をimport文に追加してください。

import (
   "C"
   "os"
)

Windows

WindowsでAPI呼び出しなどのプロセスのイベントの監視を行うツールとしては、純正ツールのProcess Monitorが一般的には有名です。 これ以外の高性能なサードパーティ製の無料のツールとしてはAPI Monitorがあります。 API Monitorを使うには、公式サイトからインストーラをダウンロードしてください。 使い方はこちらのサイトが詳しいです。 stracdtrussのように、新規プロセスの監視もできますし、実行中のアプリケーションにアタッチすることもできます。 特定のAPIにブレークポイントを設定し、デバッガーのように使うこともできます。

このツールの使い方のポイントとしては、左上のAPI Filterで目的とするAPIをチェックすることです。 API名で個別に選択もできますが、今回のファイル入出力であれば、「Data Access and Storage」にチェックを行うと表示されます。 全部のAPIを設定すると多すぎて目的のAPI呼び出しを探すのが困難になります。

まとめと次回予告

前回までとは異なり、今回は座学な内容が多い回でした。 システムコールはプログラムが機能を提供するうえで、なくてはならない存在です。 大切なことは、「特権モードを必要とする操作をしたいときにシステムコールが必要」ということです。

システムコール呼び出しの実装をアセンブリコードまで探索しました。 また、Linuxを例にカーネル側のコードまで見てみました。 今回はカーネルとの接点のところでどうしても必要だったために、アセンブリコードと、カーネル側のC言語のコードを紹介しましたが、今回以外の連載ではこのようにアセンブリコードまで見ていくことは基本的にありません。

本文中では触れませんでしたが、Go言語のランタイムを実装しているruntimeパッケージの中でも、Go言語自身が必要とするスレッドの起動、プロセスの終了、メモリ確保などのシステムコールを呼んでいます。 興味のある方は、どこで呼び出しているか探してみてください。

次回は、ソケット通信まわりのAPIを見ていきます。

注釈

  1. 高級と言ってもC言語なので、後発の各種言語のAPIに比べると低レベルで使いにくいと感じる人は多いでしょう。
  2. 余談ですが、Go言語の1.7のリリースが予定より少し遅れたのは、macOSの10.12(Sierra)でシステムコールの引数が変更されたのが原因です。

■関連記事