「ビルド」で何が起きているのか - ソースコードが0と1の命令列に変わる4つのステップ
組み込みソフト開発では、C言語で書いたソースコードをそのままマイコンで実行することはできません。
ソースコードをCPUが解釈できる機械語に変換して、マイコンが動作できる形式にする「ビルド」という工程が途中で必要です。
本記事では、このビルドという工程の内部で何が起きているのかを4つのステップに分解して取り上げます。普段は意識せずにボタン一つで実行しがちな工程ですが、その中身を知っておくと不具合が起きたときに対応が変わってくるでしょう。
組み込みソフト開発における「ビルド」とは何か
ビルドとは、C言語などで書かれたソースコードを、CPUが直接実行できる機械語に変換する一連の処理です。PC向けのアプリケーション開発でもビルドは行いますが、組み込みソフトウェア開発では特有の事情があります。
それは、開発に使うPC(ホスト)と、プログラムを実行するマイコン(ターゲット)とでCPUのアーキテクチャが異なるという点です。PC上のコンパイラがそのまま生成する機械語はPC向けのものであり、Arm Cortex-Mなどのマイコンでは動作しません。
そのため、組み込み開発ではホスト上で動作しながらターゲット向けの機械語を生成するクロスコンパイラを使います。この点を十分に意識していないと、ビルドは通ったのにマイコン上で動かないという状況に陥ることがあるため、実務では最初につまずきやすい部分です。
ソースコードが機械語になるまでの4つのステップ
ビルドは、以下の4つのステップにより構成されています。
- プリプロセス:コンパイル前の下準備
- コンパイル:C言語をアセンブリ言語へ変換
- アセンブル:アセンブリ言語を機械語へ変換
- リンク:オブジェクトを結合しメモリ配置を決定
各ステップで何が起きているかを順に解説します。
プリプロセス - コンパイル前の下準備
#includeで指定されたヘッダファイルを展開し、#defineのマクロを置き換え、コンパイルに渡す1枚のソースファイルに整える。プリプロセスが担っているのは、こうしたコンパイル前の下準備です。
組み込み開発では、この段階で#ifdefによる条件コンパイルが活躍する場面が多くあります。同じソースコードを複数のターゲットボードで共有しつつ、ボードごとのピン配置やクロック設定の違いを#ifdefで切り替えるといった使い方です。
なお、ターゲットが増えるほどこの分岐も増えていきます。プリプロセスの段階で意図したコードが残っているかどうかを確認しておきましょう。
コンパイル – C言語をアセンブリ言語に変換
コンパイルは、C言語のソースコードをターゲットCPU向けのアセンブリ言語に変換する工程です。変数の演算や条件分岐といった処理が、CPUの命令セットに沿った形で分解されていきます。
組み込み開発でこの工程が特に重要な理由は、最適化レベルを選択できる点です。コンパイラには-O2(実行速度優先)や-Os(サイズ優先)といったオプションがあり、同じソースコードでも生成されるバイナリのサイズが大きく変わることも珍しくありませんし、実行速度が大きく変わることも珍しくありません。
たとえば、フラッシュROMが64KBしかないマイコンで、最適化オプションを変えてコンパイルした途端にROMに収まらなくなった、という場面は現場では実際に起こり得ます。一般的に処理性能と、サイズにはトレードオフの関係が成り立ちます。
アセンブル - アセンブリ言語を機械語へ変換
アセンブルは、コンパイルで生成されたアセンブリ言語を、CPUが直接解釈できる機械語に変換する工程です。この工程を経て、オブジェクトファイル(.oファイル)が生成されます。
ビルド工程で、クロス開発の特徴が出るのがこの段階です。Arm Cortex-M向けであればArm命令セットの機械語が出力されますが、別のCPUを対象にすれば当然中身は全く変わります。開発環境を新しく構築した直後に、ターゲット設定を確認せずビルドを行って、マイコンに書き込んだのに一切反応がない、という出来事は実際にある話です。
また、意外と盲点になりやすいのが、開発環境を新しく構築した直後やターゲットボードを切り替えたタイミングです。アセンブラがどのアーキテクチャ向けに動いているかは、そのようなタイミングで一度確認しておいても損はありません。
リンク - オブジェクトを結合しメモリ配置を決定する
PC開発であれば、gccにソースファイルを渡すだけでコンパイルからリンクまで一括で完了し、メモリ配置を意識する必要はありません。一方、組み込みソフトウェア開発におけるリンクがPC開発と異なるのは、複数のオブジェクトファイル(.oファイル)とライブラリを結合した上で、メモリの配置まで開発者が指定する点です。
このメモリ配置は、リンカスクリプトにより記述する必要があります。プログラムコードの.textセクションはROMへ、変数を格納する.dataや.bssセクションはRAMへ、というのが基本的な配置です。リンカスクリプトの記述を誤ると、変数がROM上に配置されてしまって書き換えができない、といった不具合が起きます。
加えて、初期値を持つグローバル変数(.dataセクション)には特有の事情があります。初期値自体はROMに保存しておく一方、変数は書き換え可能なためRAM上への配置が必要です。
そのため、システム起動時にROMからRAMへ初期値をコピーする処理が走ります。スタートアップコードの中に、このコピー処理を自分で書かなければならないケースもあるため、この段階でリンカスクリプトと併せて確認しておく必要があります。
ビルドの成果物はどうやってマイコンに届くのか
リンクが完了すると、ELF(Executable and Linkable Format)ファイルが生成されます。ELFファイルにはデバッグ情報やセクション構成も含まれていますが、マイコンのフラッシュメモリに書き込むのは機械語部分だけで十分です。そこで、ELFファイルからHEXファイルやBINファイルといった、書き込み用のフォーマットへ変換する工程が入ります。
HEXファイルはアドレス情報付きのテキスト形式で、BINファイルは純粋なバイナリデータのみの構成です。書き込みツールやマイコンの仕様によってどちらを使うかが決まるため、ここで選択を誤ると書き込み時にエラーが出て先に進めない、という場面に遭遇することがあります。
このようにして変換されたファイルは、JTAGデバッガや専用の書き込みツールを介してフラッシュメモリに転送されます。ビルドの完了とマイコンへの書き込みは別の工程なので、「ビルドが通った=マイコンで動く」ではないことは押さえておくべきポイントです。
ビルド設定がマイコンの動作品質を左右する
ビルドの各設定は、マイコン上での動作品質に直接影響します。最適化レベル一つでコードの実行速度やサイズが変わる上、リンカスクリプトのメモリ配置が間違っていれば変数の書き換えすらできない状態になり得ます。設定はビルド時に行いますが、影響が表面化するのは実行時である点が厄介なところです。
これらの問題に対処する上で身に付けておきたいのは、不具合がどの段階で起きたかを切り分ける視点でしょう。プリプロセスの段階で#ifdefの分岐が意図した通りになっているか、コンパイル時の最適化が想定外の挙動を生んでいないかなど、これらの確認を段階ごとに入れるだけでも、原因特定にかかる時間は大きく変わってきます。
ビルドの全体像を把握しておくことが、結果的に開発のスピードにも影響するポイントです。ビルドは通っているものの、マイコン上で正しく意図通りに動作するとは限らない、ということを念頭に置いておきましょう。

