低レイヤ③
割り込み
以前、/dev/input/eventNとかls -lのcデバイスファイル(キャラクタデバイス)の話を書きましたが、 その部分の深堀となります。
割り込み(インタラプト)とは
CPUは基本的に、「フェッチ→デコード→実行」のサイクルを、ひたすら順番通りに繰り返してるだけの存在。 という話があったと思います。 でもキーボードやマウスは、人間が「いつ押すか」なんて誰にもわからないタイミングで動くよね? CPUが今何をしていようと(別のプログラムの計算中だろうと)、キーが押された瞬間には「反応してほしい」よね。
これを実現するのが割り込み(interrupt)という仕組みです。 キーボードのコントローラ(キーが押されたことを検知する小さな電子回路)は、キーが押されると、CPUに向かって「電気信号」を送る。 CPUはこの信号を検知すると、今実行してる命令列を(次の命令の場所を覚えたまま)いったん脇に置いて、あらかじめ登録されている「割り込みハンドラ」という専用のプログラムに実行を切り替える。 ハンドラの処理が終わったら、さっき脇に置いていた場所から元の処理を再開する。
デバイスドライバとは
割り込みが来たとして、CPU自身は「キーボードのAキーが押されたのか、Bキーなのか」なんて、生の電気信号を見ても解釈できない。 そこで登場するのがデバイスドライバ。 デバイスドライバは、カーネルの一部として動く、特定のハードウェア専用の「通訳」プログラム。 キーボードコントローラが送ってくる生の信号(スキャンコードと呼ばれる、キーごとに割り振られた番号)を受け取って、それを「これはAキーが押された/離された、というイベントだ」という、OS内部で扱いやすい形式に変換する。
メーカーやモデルによって「信号の送り方」「制御コマンドの形式」が全部違うので、 デバイスドライバは都度必要になる。 が、 Linuxではほとんどの一般的なUSBキーボード/マウスはusbhid(USB Human Interface Device)という汎用ドライバでカバーされていて、これのおかげでほぼどんなキーボード/マウスを挿しても、追加のドライバをインストールしなくても動く。
イベント ドライバが解釈した「Aキーが押された」「マウスが5px右に動いた」「左ボタンがクリックされた」といった1つ1つの出来事が、イベント。 /dev/input/eventNは、イベントを決まったバイナリ形式(struct input_event:タイムスタンプ、イベントの種類、コード、値)で流し続けている。 このイベントは、カーネルの中だけで完結するんじゃなくて、そこからさらに上のレイヤーに流れていく。 Linuxで言うと、evdevという仕組みを通して/dev/input/eventNに流れたイベントを、X11やWaylandといったディスプレイサーバー(画面表示を統括するソフト)が読み取って、「今どのウィンドウがフォーカスされてるか」を見て、そのウィンドウを持ってるアプリに「キー入力がありました」と伝える。 アプリはそれを受け取って、初めて「文字を1つ画面に追加する」といった実際の処理をする。
まり全体の流れは キーを押す(物理的な接点が繋がる) → キーボードコントローラが信号を生成 → CPUに割り込みが発生 → デバイスドライバが信号を解釈してイベント化 → /dev/input/eventN経由でイベントが流れる → ディスプレイサーバーがイベントを受け取ってフォーカス中のウィンドウに配送 → アプリがイベントを処理して画面に反映
コマンド
- cat /proc/interrupts # 割り込みごとの発生回数の一覧(キーボード/マウス/タイマーなど、種類別)
- watch -n 0.5 cat /proc/interrupts # 0.5秒おきに再表示。キーを押すたびに該当行の数字が増えるのが見える
- ls /dev/input/ # eventN, mouseN などのデバイスファイル一覧
- cat /proc/bus/input/devices # 各入力デバイスの詳細
- sudo evtest # 対象デバイスを選ぶと、押した/動かした内容がリアルタイムで表示される(要インストールの場合あり)
- sudo cat /dev/input/eventN # 生のイベントバイナリデータが流れてくるのが見える(バイナリなので文字化けするが、キー入力に反応して出力が増えるのはわかる)
- lsmod | grep hid # usbhidなど、入力デバイス関連のカーネルモジュールが読み込まれているか確認
画面
次は出力。見てみよう。
フレームバッファ 画面に何かを映すという行為の根っこにあるのが、フレームバッファ(framebuffer)というメモリ領域。 画面は結局「小さな色のついた点(ピクセル)の集まり」でしかない。 だから「今、画面に何を表示すべきか」という情報は、「各ピクセルの色(RGBの数値)を、上から順番に、左から右に並べたただの数値の配列」として、メモリ上のどこかに保存されている。 これがフレームバッファ。 フレームバッファは「画面の見た目をそのまま数値化して並べた、特別な使われ方をするメモリ領域」。
ディスプレイ側のハードウェアは、この領域を一定間隔(リフレッシュレート、例えば60Hzなら1秒間に60回)で先頭から読み出して、その数値をそのまま画面上のピクセルの色として反映する。 つまり「画面が動いて見える」というのは、実際にはフレームバッファの中身が高速に書き換わり続けていて、それをディスプレイが定期的に読み出して映してるだけ、という仕組み。 Linuxでは/dev/fb0という、まさにこのフレームバッファに直接アクセスできるデバイスファイルが伝統的に存在していた(今はGPU経由の仕組みが主流だけど、名残として今も存在してる環境が多い)。
GPU
GPUはこのCPUとは設計思想が根本的に違う、もう1つの専用プロセッサ。 CPUは「複雑な処理を、少数の強力なコアで、順番に賢くこなす」のが得意な設計。 GPUは「単純な計算(この1ピクセルの色を求める、みたいな)を、何千個もの小さなコアで、一斉に並列にこなす」のが得意な設計。 画面は数百万ピクセルあるけど、それぞれのピクセルの色計算はお互いほぼ独立してるから、この「大量の単純作業を同時にやる」という性質と、GPUの設計が完璧に噛み合う。
GPUには専用のメモリ(VRAM)が別途搭載されてることが多い(ゲーミングPCの「グラボ」)。 CPUのメインメモリとは別に、GPUが高速にアクセスできる専用領域を持つことで、大量のテクスチャ・頂点データをやり取りする速度を稼いでる。 ノートPCなどでよくある「内蔵GPU(iGPU)」は、専用のVRAMを持たず、システムメモリの一部を間借りして使うタイプで、専用GPU(dGPU)より非力だけど省電力・低コスト。 らしい。
レンダリング レンダリングは、「抽象的なデータ(3Dモデルの頂点座標、色、テクスチャ、あるいは2DのUI部品の配置情報など)」を、「最終的な、フレームバッファに書き込むべき具体的なピクセルの色」に変換する一連の処理のこと。 頂点処理(3D空間上の座標を、画面上の2D座標に変換する) → ラスタライズ(その形を、実際にどのピクセルが覆われるかに分解する) → ピクセルシェーディング(覆われた各ピクセルについて、光の当たり方やテクスチャを考慮して最終的な色を計算する) → 出来上がった色をフレームバッファに書き込む。 この一連の処理を、画面のピクセル数×毎秒60回(60fpsの場合)というとんでもない回数こなす必要があるから、さっき言ったGPUの並列性がここで生きてくる。
コマンド
- lspci -k | grep -A3 VGA # 搭載されているGPUの情報とドライバ名
- ls /dev/dri/ # DRM(Direct Rendering Manager)経由のGPUデバイスファイル(card0, renderD128など)
- glxinfo | grep "OpenGL renderer" # 実際に描画に使われているGPU/ドライバの確認(mesa-utilsが必要な場合あり)
- nvidia-smi # NVIDIA GPUの場合、使用率・VRAM使用量・温度などをリアルタイム表示 →私の環境だとありませんでした。泣
- xrandr # 接続中のディスプレイ、解像度、リフレッシュレート一覧
- cat /proc/fb # 従来型フレームバッファデバイスの情報(存在する場合)
- glxgears # 簡単なOpenGL描画テスト。FPSがターミナルに出力される
仮想化
仮想化。。よく聞きますね。。
VM(仮想マシン) これまでの話は基本、「1台の物理マシンの上で、1つのOSが、複数のプロセスを動かしている」という前提です。 仮想化は、これをもう1段階上から見た話で、「1台の物理マシンの上で、複数の"独立したコンピュータそのもの"を動かす」という技術。 です。
VMは、CPU・メモリ・ディスク・ネットワークカードといったハードウェア一式を、ソフトウェアで丸ごとエミュレート(模倣)したもの。 VMの中で動くOS(ゲストOS)から見ると、「自分は本物の物理マシンの上で動いている」としか見えない。 ブートシーケンス(ファームウェア→ブートローダー→カーネル)を、VMの中でもそのままもう一度ゼロからやり直す。 つまりVMを1つ起動するというのは、「もう1台のパソコンを、ソフトウェアの中に丸ごと作って、電源を入れる」ことに近い。
ハイパーバイザ このVMを作って動かす、管理者的なソフトウェアがハイパーバイザ(仮想マシンモニタ、VMMとも呼ばれる)。 大きく2種類ある。 タイプ1(ベアメタル型) OSを挟まず、ハードウェアの上に直接インストールされるタイプ(VMware ESXi、Xenなど)。
タイプ2(ホスト型) 普通のOS(Windows/Linux)の上に、1つのアプリケーションとしてインストールされるタイプ(VirtualBox、VMware Workstationなど)。
LinuxのKVMはちょっと特殊で、「Linuxカーネル自体にハイパーバイザ機能を組み込む」という方式(カーネルモジュールとして動く)で、実質タイプ1に近い性能を、既存のLinuxの上で実現している。
ハイパーバイザが全部ソフトウェアだけで「ゲストOSの特権命令(本来ハードウェアに直接触るはずの命令)を横取りして処理する」のは、本来ものすごく遅い。 そこで現代のCPUには、Intel VT-x/AMD-Vというハードウェア支援機能が組み込まれていて、CPU自体が「これは仮想化されたゲストの命令だ」と認識して、効率よくハイパーバイザに処理を委ねる仕組みを持っている。 これのおかげで、今のVMはほぼネイティブに近い速度で動く。KVM(Kernel-based Virtual Machine)は、まさにこのVT-x/AMD-Vを直接使うことで高速化してる仕組み。
コンテナ 同じ「複数の隔離された環境を1台のマシンで動かす」という目的でも、VMとコンテナ(Docker等)ではアプローチが異なる。
VM: ゲストOSごとに、まるまる別のカーネルを持つ。 仮想CPU・仮想メモリ・仮想ディスクという、ハードウェアレベルの隔離。だから重い(それぞれが完全なOSを一から起動するのでGB単位のディスク・メモリを消費し、起動にも数十秒~分単位かかる)反面、隔離が非常に強力(別のカーネルが動いてるので、あるVMが暴走・侵害されても、他のVMやホストには理屈上届きにくい)。
コンテナ: ホストのカーネルを1つだけ、みんなで共有する。 Linuxカーネルのネームスペース(namespace)機能で、 PIDネームスペース(自分のコンテナの中では自分がPID1に見える)、ネットワークネームスペース(自分専用のネットワークインターフェースを持ってるように見える)、マウントネームスペース(自分専用のファイルシステムのように見える)などを組み合わせて、「同じカーネルの上で動いてるだけなのに、まるで別のマシンにいるかのような視界」を作り出してる。 cgroups(control groups)という仕組みで、「このコンテナはCPU30%まで、メモリ512MBまで」といった資源の上限も管理する。 「docker.sockを握るとホストroot相当」という話も、「コンテナは結局、同じカーネルを共有してる普通のプロセスに、ネームスペースとcgroupsで見た目の隔離をかけてるだけ」という構造のため。
Unikernel VMともコンテナとも違う、もう1つの発想がUnikernel。 普通のOSは「いろんなアプリを動かせるように」、あらゆる種類のドライバ・ファイルシステム・シェル・ユーザー管理機能などを全部積んだ、汎用的な作りになっている。 Unikernelはこの発想を全部捨てて、「たった1つの特定のアプリケーションを動かすためだけに必要な、最小限のOS機能だけを、そのアプリのコードと一緒にコンパイルして、1つの起動可能なイメージにまとめてしまう」という考え方。
コマンド
- egrep -c '(vmx|svm)' /proc/cpuinfo # CPUが仮想化支援機能(Intel VT-x/AMD-V)を持ってるか。0以外なら対応
- lsmod | grep kvm # KVMカーネルモジュールが読み込まれてるか確認
- systemd-detect-virt # 自分が今VMの中で動いてるか、何のハイパーバイザ上かを検出
- docker ps # 動いてるコンテナ一覧
- docker stats # 各コンテナのCPU/メモリ使用量をリアルタイム表示
- cat /proc/1/cgroup # 自分のプロセスがどのcgroup階層に属してるか
- ls /sys/fs/cgroup/ # cgroupファイルシステムの中身
- cat /proc/self/status | grep NSpid # PIDネームスペースの様子(コンテナ内で実行すると違いが見える)
- sudo unshare --pid --fork --mount-proc bash #Dockerを使わずに、コンテナの正体である「ネームスペース」を自分の手で作る。
スタックとデバッガ
スタック/ヒープ領域 どっかで触れてるけど!もう少し今回関連する「プロセスのアドレス空間の中で、実際にどう配置されてるか」って視点からかく! 仮想アドレス空間を、もう少し詳しく見ると、大まかにこういう区画に分かれているnです プログラムのコード自体(text)、初期化済みのグローバル変数(data)、未初期化のグローバル変数(bss)、そしてヒープとスタック。 ヒープは低いアドレス側から上に向かって伸びていき(確保するたびに増えていく)、スタックは逆に、高いアドレス側から下に向かって伸びていく(関数呼び出しが深くなるたびに増えていく)。 この2つは、間に十分な空きを挟んで、お互い向かい合う形で配置されてる。 理屈上、ヒープが際限なく増え続けるか、関数呼び出しが際限なく深くなる(無限再帰など)と、この2つがついには衝突する可能性がある。
コールスタック スタックは、ただの1つの領域なんじゃなくて、関数が呼ばれるたびに、その関数専用の区画(スタックフレーム)が積み重なっていく構造になっている。 関数Aが関数Bを呼ぶと、Bのための新しいフレームが積まれる。そのフレームには、Bの引数、Bのローカル変数、Bを呼び出す前のAの状態(後述するレジスタや戻り先アドレス)などが入っている。 実行中のプログラムがどれだけ深く関数を呼び込んでいるか(main() → functionA() → functionB() → …)、その連鎖全体を指してコールスタックと呼ぶ。 デバッガで見る「バックトレース(backtrace)」というのは、まさにこのコールスタックを、今積まれてるフレームの下から(あるいは上から)順番にそのまま表示してるのもの。 プログラムがクラッシュした時に出てくる「スタックトレース」も全く同じ実体で、「クラッシュした瞬間、どの関数がどの関数から呼ばれて、そのまた大元は…」という呼び出しの連鎖を、そのままコールスタックを読み上げて教えてくれている。
戻りアドレス 関数Aが関数Bを呼び出す時、CPUはcallという命令を実行する。 この命令は裏で自動的に「Aに戻ってきた時、次に実行すべき命令のアドレス」を、スタックにこっそり積んでいる。これが戻りアドレス。 Bの処理が終わってret(return)命令が実行されると、CPUはこの戻りアドレスをスタックから読み出して、そこにジャンプすることで、何事もなかったかのようにAの続きが実行される。
戻りアドレスは特別な保護された何かじゃなくて、スタックという普通のメモリ領域の中に置かれた、ただの数値データ。 もし関数のローカル変数(スタック上のバッファ)にサイズを超えるデータを書き込んでしまうと、そのはみ出した分がすぐ近くにある戻りアドレスを上書きしてしまう可能性がある。 書き換えられた戻りアドレスに従ってCPUがジャンプしてしまう、というのが古典的なスタックオーバーフロー攻撃の正体で、「戻りアドレスもただのデータであり、書き換え可能な位置にある」という、今回の話がまさにその土台になっている。
ブレークポイントとデバッガの基礎 ここまでの全ての話(レジスタ、プログラムカウンタ、スタック、機械語)は、デバッガを使えばその場で覗ける。 覗いてもわかる気がしないけどw デバッガ(Linuxだとgdbが定番)は、動いてるプログラムを任意の場所で一時停止させて、その瞬間のレジスタの値、スタックの中身、変数の値を自由に確認できるツール。 その「任意の場所で止める」ための仕掛けがブレークポイント。 ブレークポイントの仕組みは、意外とシンプルで、 デバッガは、止めたい場所の命令のバイトを、こっそりINT3という特殊な1バイトの命令(x86では0xCC)に一時的に書き換えてしまう。 CPUの実行がその場所まで進んでINT3を実行すると、CPUは強制的にトラップ(SIGTRAPというシグナル)を発生させる。 このシグナルを、デバッガがptrace()というシステムコール経由で捕まえて(第12話でrootによるキーロガーの文脈で少し触れた、あのptrace)、元の命令バイトをこっそり元に戻し、ユーザーに操作を返す。
ハードウェアブレークポイントという方式もあって、こちらはCPU自体が持ってる専用のデバッグ用レジスタ(x86だとDR0~DR3)に「このアドレスを監視して」と登録しておくやり方。命令を書き換える必要がなく、しかも「この変数が"読み書きされたら"止める」といった、実行位置じゃなくてメモリアクセス自体を監視することもできる。
コマンド
- gdb ./program # デバッガ起動
- break main # main関数にブレークポイントを設置
- break file.c:42 # 特定のファイルの特定行にブレークポイント
- run # プログラムを実行開始(ブレークポイントで自動的に止まる)
- next / step # 1行実行(next=関数呼び出しは飛び越す、step=中に入る)
- bt # バックトレース。今のコールスタックを表示
- info frame # 現在のスタックフレームの詳細
- info registers # 現在のレジスタの値(第9話のレジスタの話がそのまま見える)
- x/8xg $rsp # スタックポインタ($rsp)から8つ、8バイト単位で生のメモリを16進表示
- disas # 現在の関数を逆アセンブル表示(第8話のobjdump -dのgdb版)
- continue # 停止を解除して実行再開
さて、、、 ここまで頭にぶち込んで、 Binary hacks rebooted 再挑戦せるかぁ。。。