僕はこれまでそれとなくプログラムを書き、それとなくエンジニアリングしてきましたが、そろそろ基礎を理解していないツケが回ってきているような成長具合になってきたので、この際腰を据えて低レイヤーを学んでみよう、というのが今回のモチベーションです。
ずっと師匠(僕にエンジニアリングを叩き込んでくれた人)には「おい、低レイヤーの知識をお粗末にするなよ」と言われ続けてきたのですがどうしても勉強する気になれず放置してきたんですね。なんと愚かな稚魚でしょう。
今回の知識を整理するに当たって主に「プログラムはなぜ動くのか」を読み込みました。
参考:プログラムはなぜ動くのか
この書籍から得た知見を基に、知識を整理し共有したいと思います。
自分の書いているコードがどのように実行されているのかを自信を持って説明できない僕のような方に、少しでも参考になれば幸いです。
まずプログラムが動く環境とは
Windows用のプログラムがMacで動作しない理由は動作環境が違うから。
動作環境が違うとは?
なぜ環境が違えばアプリケーションは動かない?
OSとハードウェアの組み合わせでアプリケーションの動作環境が決まる
OSとハードウェアがプログラムの動作環境を決定している。
そしてプログラムの動作環境としてハードウェアを考える場合にはCPUの種類が大事になってくる
ハードウェア(特にCPU)
CPUはそのCPU固有のマシン語しか解釈できないのでCPUの種類が違えば、解釈できるマシン語の種類も違ってくるということ(例えば、x86系、MIPS、SPARC)
C言語などを使って作成したソースコードはコンパイルすることでマシン語であるネイティブ・コードになり、多くのアプリケーションはソースコードではなくネイティブ・コードの形で提供される。
OS
CPUの種類ごとにマシン語が違うように、OSの種類ごとにアプリケーションからOSへの命令の仕方が違うので、アプリケーションはOSの種類ごとに専用のものを作らなければいけない。
アプリケーションからOSへの命令のやり方を定めたものをAPIと呼ぶ。提供されるのはキー入力、キー出力、マウス入力、ディスプレイ出力のように周辺装置と入出力を行う機能など。
OSごとにそのAPIが異なるので、同じアプリケーションを他のOS用に作り変えるにはAPIの部分を書き換えなければいけない。APIはOSが同じならどのハードウェアでも基本的に同じ。
ソースファイルから実行可能ファイルができるまでの過程
では本題ともいえるプログラムがどう実行されるかに触れていく
コンピュータはネイティブコードしか実行できない
なんらかのプログラミング言語で書かれたプログラムのことをソースコードと呼ぶ。
CPUが解釈実行できるのはネイティブコードだけなので、ソースコードはネイティブコードに翻訳されなければいけない。
逆に言えば、どんなプログラミング言語もネイティブコードに翻訳されれば全て同じ言語(マシン語)になる。
ちなみにネイティブコードは数値が羅列されたもの。
コンパイラがソースコードを翻訳する
ソースコードに対して字句解析、構文解、意味解析をしてネイティブコードにするのがコンパイラ。
プログラミング言語の種類に応じて専用のものが必要。
CPUの種類が異なればネイティブコードの種類も異なる。なのでコンパイラはプログラミング言語の種類だけでなくCPUの種類に応じて専用のものが必要になる。
例えばx86系CPU用のCコンパイラとPowerPCというCPU用のCコンパイラは別のもの→同じソースコードを異なるCPU用のネイティブコードに翻訳することができるからこれはある意味便利!
コンパイルの次にリンクという処理が必要
コンパイル後に生成されるのはEXEファイルではなく拡張子が「.obj」のオブジェクト・ファイル(sample.c→sample.obj)
ネイティブコードのファイルのままでは実行できない→未完成の状態だから。
実行可能なEXEファイルを得るにはコンパイルに続けて「リンク」という処理が必要。
プログラムの中に標準関数が呼ばれていたとしてもその実体はファイル自体には書かれていない。だからそのオブジェクトファイルをリンクさせなければいけない。
複数のオブジェクトファイルを結合して1つのEXEファイルを生成する処理がリンクであり、リンクを行うプログラムのことをリンカーと呼ぶ。
ライブラリ・ファイル
ライブラリ・ファイルは、プログラムやアプリケーションが共通の機能を効率的に使用するために利用されるファイルのこと。
ファイルには、プログラムの実行に必要なコードやデータが含まれており、複数のプログラムから共有して使用されることがある。
ライブラリには大きく分けて、動的ライブラリ(DLLなど)と静的ライブラリ(LIBなど)がある。
動的ライブラリ(DLL):プログラムの実行時に必要に応じてロードされるため、リソースの有効利用が可能
静的ライブラリ(LIB):プログラムのコンパイル時にコードが直接組み込まれるため、実行時に追加の読み込みが不要になる
インポートライブラリ
オブジェクト・ファイルの実体ではなく、必要な関数がどのDLLファイルにあるか、とDLLファイルが格納されているフォルダの情報だけ記憶しているライブラリ・ファイルのこと
通常、.libファイル形式で存在し、リンカーがDLLファイルの正確な場所やメソッドをプログラムに結びつける際に使用される。
これにより、プログラムはコンパイル時にDLLの具体的な内容を知らなくても、実行時にDLLファイルから必要なコードをロードできるようになる。
EXEファイルが実行される仕組み
そもそもの疑問
ネイティブコードはプログラムの中に記述された変数を読み書きする際にメモリー・アドレスを参照する命令を実行したり、関数を呼び出す際に処理内容が格納されたメモリー・アドレスにプログラムの流れを移す命令を実行する。
そうなるとEXEファイルの中で変数や関数のメモリー・アドレスの値はどうやって示されているのか?
答え
実はEXEファイルの中では変数や関数に仮のメモリー・アドレスが与えられている。
プログラム実行時に仮のメモリーアドレスが実際のメモリーアドレスに変換される。
EXEファイルの先頭にメモリーアドレスの変換が必要な部分を示す再配置情報を付与する。
再配置情報は変数や関数の相対アドレスになっている。
ソースコードの中では変数や関数がバラバラに記述されていたとしてもリンク後のEXEファイルの中では変数と関数がそれぞれ連続して並んだグループにまとめられるようになっている(←これ地味に感動した)
メモリ上にロードされたプログラムは4つの領域から構成されている
メモリー上のプログラムは変数のための領域、関数のための領域、スタックのための領域、ヒープのための領域という4つのグループから構成されている。
スタックとヒープはプログラム実行時に確保されるという点で似ているが、メモリーの使い方には違いがある。
スタックにデータの格納とクリーンアップを行うコードはコンパイラによって自動的に生成されるのでプログラマが意識する必要はない。
それに対して、ヒープのためのメモリ領域はプログラマが明示的に確保と解放を行う。
Cで言うとmalloc()で確保、free()で解放。ヒープを解放しないとメモリーリークが起きる。
(あのエラー文の赤文字を思い出した、、。)
まとめ
これまでの流れを簡潔にまとめる。
1. コンパイル
ソースコードの作成: プログラマが特定のプログラミング言語でソースファイルを記述する
コンパイル: コンパイラはソースファイル(.cなど)を機械語のオブジェクトファイル(.objなど)に変換する。
この段階でソースコード内の文法的エラーや型の不一致などの静的なエラーがチェックされる。
2. リンク
オブジェクトファイルのリンク: 複数のオブジェクトファイルとライブラリがリンカーによって結合され、実行可能ファイル(.exe、.outなど)が作成される。
リンカは、プログラムが必要とする関数や変数の参照を解決し、必要なコードとデータを一つのファイルにまとめる。
この時に仮のメモリーアドレスも決められる。
ちなみに「ビルド」はこのコンパイルとリンクを続けて行うという意味。
3. 実行
ローディング: 実行可能ファイルがオペレーティングシステムによってメモリにロードされる。
この段階で必要なライブラリが動的にリンクされる場合がある。
実行: プログラムの実行が開始され、オペレーティングシステムがCPUに命令を送り、プログラムコードが処理される。
この一連のプロセスを通じて、ソースコードは最終的にユーザーが実行できる形式のプログラムとして具現化される。
最後に
低レイヤーについて何も知らなくてもそれなりに動いてしまう時代で、それなりにコードを書いてエンジニアを名乗っていた自分にとってはとても大事な一冊となりました。
低レイヤー入門として「プログラムはなぜ動くのか」を読んで良かったと思っています。
読む前はものすごく複雑で難しい領域なのだろうと思って敬遠していたのですが、一つ一つ丁寧に理解していくと「このだるい問題はこう解決して楽にしよう」の連続だったので読み進めるうちにどんどん楽しくなりました。
この書籍だけでは書き切れていない低レイヤーのより詳しい部分も読んでいきたいです。吐きながら。