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のソースコードは規模も大きいため、ローカルにダウンロードするだけでも大変です。 コードを追いかけるときには次のようなサイトを使ってみてください。
- github.com上のリポジトリ: https://github.com/torvalds/linux
- クロスリファレンス: http://lxr.free-electrons.com/ident
システムコール関数は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を使うには、公式サイトからインストーラをダウンロードしてください。 使い方はこちらのサイトが詳しいです。 stracd
やtruss
のように、新規プロセスの監視もできますし、実行中のアプリケーションにアタッチすることもできます。 特定のAPIにブレークポイントを設定し、デバッガーのように使うこともできます。
このツールの使い方のポイントとしては、左上のAPI Filterで目的とするAPIをチェックすることです。 API名で個別に選択もできますが、今回のファイル入出力であれば、「Data Access and Storage」にチェックを行うと表示されます。 全部のAPIを設定すると多すぎて目的のAPI呼び出しを探すのが困難になります。
まとめと次回予告
前回までとは異なり、今回は座学な内容が多い回でした。 システムコールはプログラムが機能を提供するうえで、なくてはならない存在です。 大切なことは、「特権モードを必要とする操作をしたいときにシステムコールが必要」ということです。
システムコール呼び出しの実装をアセンブリコードまで探索しました。 また、Linuxを例にカーネル側のコードまで見てみました。 今回はカーネルとの接点のところでどうしても必要だったために、アセンブリコードと、カーネル側のC言語のコードを紹介しましたが、今回以外の連載ではこのようにアセンブリコードまで見ていくことは基本的にありません。
本文中では触れませんでしたが、Go言語のランタイムを実装しているruntime
パッケージの中でも、Go言語自身が必要とするスレッドの起動、プロセスの終了、メモリ確保などのシステムコールを呼んでいます。 興味のある方は、どこで呼び出しているか探してみてください。
次回は、ソケット通信まわりのAPIを見ていきます。
注釈
この連載の記事
-
第20回
プログラミング+
Go言語とコンテナ -
第19回
プログラミング+
Go言語のメモリ管理 -
第18回
プログラミング+
Go言語と並列処理(3) -
第17回
プログラミング+
Go言語と並列処理(2) -
第16回
プログラミング+
Go言語と並列処理 -
第15回
プログラミング+
Go言語で知るプロセス(3) -
第14回
プログラミング+
Go言語で知るプロセス(2) -
第13回
プログラミング+
Go言語で知るプロセス(1) -
第12回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(3) -
第11回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(2) -
第10回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(1) - この連載の一覧へ