本記事は Craft Egg Advent Calendar 2022の14日目の記事です。
はじめに
株式会社Craft Eggでバックエンドエンジニアをしております高川です!
今回は、アプリケーションエンジニアが普段の業務で意識しない、いわゆるOSレベル・低レイヤに関することを書いていこうと思います。
「なぜプログラムが動くのか」という内容は調べればたくさん出てくるので、この記事では、便利なデバッガツールがどのようにして動作中のプログラムを止めて、変数の値などを提示しているのかについて書きます。
全く知識がない人でも理解ができるように噛み砕いて説明していきたいと思うので、難しいから敬遠していたという方も読んでみていただけると嬉しいです。
※なお、本記事では、C言語で書かれコンパイルされた実行ファイルでの挙動をベースにして内容を書きますので、他の言語では異なる場合もあります(基本的な考え方は一緒です)が、ご了承ください。CPUによっても命令コードなどが変わりますので、その差分に関してもお手柔らかにお願いします。
プログラムが動くってどういうこと?
冒頭で書かないと言いましたが、書かないとわからないと思うので、できるだけわかりやすく簡潔に書きます。
プログラムを動かす時の主役はバイナリ・メモリ・CPUの3つです。
- バイナリ
- C言語など人が理解しやすいコードで書かれたものをビルドして、CPUが解釈できる状態になったファイル
- ビルドでは、C言語がアセンブリ言語に変換され、アセンブリ言語がCPUが解釈できる機械語(0と1)に変換されます
- 中身は0と1の羅列で、0と1の組み合わせで命令が表現されている
- Windows10などで32ビット版や64ビット版がありますが、0と1の組み合わせを1セット32個で表現するか1セット64個で表現するかの違いです
- C言語など人が理解しやすいコードで書かれたものをビルドして、CPUが解釈できる状態になったファイル
- メモリ
- よく作業台と比喩されます
- バイナリのデータ(0と1の羅列)をメモリ上に広げて、CPUがそれを見れる状態にする
- CPUが計算した結果をメモリ上に一時保存して後から参照できるようにする
- バイナリのデータや計算結果を一時保存する位置が細かく管理されていて、その位置をアドレスと呼びます
- CPU
- メモリ上に展開された0と1のデータを元に、命令を読み取って、それに従った演算を行う
- 演算結果や処理状態を一時的に保持するためのレジスタがいくつも存在する
- 次に実行する命令の位置(アドレス)を保存している「プログラムカウンタレジスタ」
- 他にもありますが、基本的にはデータを一時的に保持するために扱われるので割愛
これらを踏まえると「プログラムが動く」=「CPUがメモリ上に展開されたバイナリの命令を一つずつ実行していく」という風に言い換えることができます。
デバッガツールが動作する原理
では、どのようにして、デバッガツールはプログラムを途中で止めたり、変数の状態を提示するのか説明します。
プログラムを止める
CPUがメモリ上に展開されたバイナリの命令を読んでいき、プログラムが徐々に実行されていきます。
プログラムの実行中にCPUが「int3命令(0xCCCC)」と呼ばれる命令を読み込んだとき、処理が一時的に停止します。これがいわゆるデバッガツールのブレイクポイントです。OSの専門用語を使うなら「SIGTRAPの発生」です。
変数の状態の提示
CPUの各種レジスタには、変数のアドレスや次に実行すべき命令のアドレス、算術結果、関数の戻りアドレスなどさまざまな値が格納されています。止まったタイミングのレジスタが保持している情報を使ってメモリを辿っていくことで、変数の値を取得することができ、デバッガツールではそれが目に見える形で表示されます。
プログラムを再度動かす
ブレイクポイントを仕込むということは、元々別の命令があったアドレスにint3命令を上書きするということです。そのまま上書きすると元々あった命令が消えてしまいます。また、int3を実行したことになるため、プログラムカウンタレジスタも次の命令を差してしまいます。
命令A
命令B
命令C
命令A
int3
命令C ←int3を読みこんで停止した時のプログラムカウンタレジスタは命令Cをみている
命令A
命令B ←プログラムカウンタレジスタが命令Cを見るようにする
命令C
そのため、int3命令を上書きする前に元々の命令をメモリかレジスタにコピーして一時的に退避させたり、プログラムが一時的に停止した後にプログラムカウンタレジスタも元々の命令のアドレスを見るようにする必要があります。
動きをまとめると
- ブレイクポイントを設定したいアドレスからint3の命令サイズ分だけメモリかレジスタにコピーして退避させる
- 対象のアドレスにint3を上書き
- プログラムが実行され、CPUが命令を実行していく
- int3を読み込み、処理が止まる
- int3で上書きした部分の命令を元に戻す
- int3で上書きした部分の命令を実行するために、プログラムカウンタレジスタの位置を命令サイズ分だけ戻す
「int3を設定→止まる→命令を戻す→プログラムカウンタレジスタも戻す→次の命令にint3を設定→止まる→...」という風に繰り返していくと、1命令ずつ実行する「ステップ実行」が実現できます。
コードベースで1行ずつステップ実行する場合は、行の始まりのアドレスをうまく取得して、int3をそのアドレスに設定すれば実現できるはずです。(すみません、ここはよく把握してないのですが多分あってるはず)
まとめ
今回は、専門知識を持ってない人でも極力理解してもらえるように噛み砕きまくって書いてみました。
(本当は図も入れたり、具体的なコードなども載せられたらよかったのですが時間が…)
書くネタがなくて、学生時代の知識を掘り起こしてきましたが、低レイヤはやっぱり面白いですね。