このページの本文へ

前へ 1 2 次へ

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

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

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

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

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

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)でシステムコールの引数が変更されたのが原因です。

前へ 1 2 次へ

カテゴリートップへ

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