【入門編】バイナリの肥大化を防げ!GCCのCOMDAT折り畳みとセクション除去オプションの深層 – 実行環境・ランタイム・コンパイラ生産性向上バイブル

こんにちは!日々の開発、本当にお疲れ様です。
C言語でプログラムを書いていると、ふとこんな疑問を持ったことはありませんか?

「数行の小さな関数しか書いていないのに、生成されたバイナリがやけに重い……」
「使っていないはずのライブラリの関数が、どうして最終的な実行ファイルに含まれているんだろう?」

組み込み開発でマイコンのROM容量に頭を悩ませている方や、コンテナイメージを極限まで軽量化したいSREの方なら、一度は直面する悩みだと思います。

今回は、GCC(およびClang)が持つ「セクション分割(-ffunction-sections / -fdata-sections)」と「未参照セクションの削除(–gc-sections)」という、バイナリサイズ削減の究極の奥義を徹底解説します。

これをマスターすれば、あなたのプログラムは無駄な脂肪をきれいにそぎ落とした「アスリートボディ」に生まれ変わりますよ。さあ、一緒にコンパイラの内部の世界へ飛び込みましょう!

—

1. なぜバイナリは太るのか? リンカのデフォルトの挙動を知る

私たちが普段何気なく使っている `gcc main.c` というコマンド。この裏側で、コンパイラとリンカはどのような仕事をしているのでしょうか。

デフォルトでは「ファイル単位」でしか捨てられない

コンパイラのデフォルト設定では、C言語のソースファイル(`.c`)単位でオブジェクトファイル(`.o`)が生成されます。そしてリンカは、そのオブジェクトファイルをリンクする際、「ファイルの中に1つでも使われている関数があれば、そのファイル全体を取り込む」という単位で処理を行います。

例えば、以下のような巨大な便利関数ライブラリ `math_utils.c` を想像してください。

// math_utils.c
include

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a – b; }
// …他にも使わない重い処理が100個あるとする…

このうち、あなたの `main.c` が `add` 関数しか呼び出していなかったとしても、デフォルトの挙動では `math_utils.c` 内の `sub` や、その他の使っていない関数も含めたすべてのコードが最終的な実行ファイルに混入してしまいます。これこそが、バイナリが肥大化する隠れた原因です。

—

2. 解決の鍵:COMDATとセクション分離の魔法

この無駄を根絶するために、GCCにはコンパイル時にコード片を細かく切り分ける機能が備わっています。それが以下の2つのフラグです。

  • `-ffunction-sections`: すべての関数を個別の独立したセクション(`.text.関数名`)に分割する
  • `-fdata-sections`: すべてのグローバル変数を個別の独立したセクション(`.data.変数名` や `.bss.変数名`)に分割する

そして、これによって細切れになったセクション群に対し、リンカ(GNU ld または lld)の以下のオプションを使います。

  • `-Wl,–gc-sections`: どこからも参照されていない(Garbage Collectionの対象となる)セクションを、リンク時に綺麗に消し去る

この3点セットを適用することで、「実際にコードから呼ばれている関数・使われているデータ」だけを外科手術のように正確に抽出し、残りを完全に切り捨てることが可能になります。

—

3. 実践!どれほどサイズが削れるか試してみよう

百聞は一見にしかず。実際に手を動かして、その効果をその目で確かめてみましょう。ここではLinux環境(GCC)をベースに解説します。

ステップ1:検証用ファイルの準備

まずは、使わない関数がたくさん詰まったファイルを用意します。

// helper.c
include

// 今回のmainから呼ばれる関数
void used_function(void) {
printf(“I am used!\n”);
}

// どこからも呼ばれない、無駄に大きな配列と関数
char huge_unused_data[1024 1024]; // 1MBのゴミデータ

void unused_function(void) {
printf(“I am never called, but I take up space.\n”);
}

そして、呼び出し元の `main.c` です。

// main.c
extern void used_function(void);

int main(void) {
used_function();
return 0;
}

ステップ2:通常ビルドと最適化ビルドの比較

まずは、通常の何も考えないビルドを行ってファイルサイズを計測します。

通常のビルド
gcc main.c helper.c -o normal_binary

ファイルサイズの確認
ls -lh normal_binary

おそらく、たったこれだけのコードでも数KB〜十数KBのサイズになります(環境によりますが、`huge_unused_data` がBSSセクションとして乗るためサイズ感を確認してみましょう)。

次に、本日の主役である「セクション分割 & ゴミ取りオプション」を適用してビルドします。

セクション分割(-ffunction-sections, -fdata-sections)を行い、
リンカに未使用セクションの削除(-Wl,–gc-sections)を指示するビルド
gcc -ffunction-sections -fdata-sections main.c helper.c -Wl,–gc-sections -o optimized_binary

最適化されたバイナリのサイズ確認
ls -lh optimized_binary

バイナリのサイズを `ls -l` や `size` コマンドで見比べてみてください。不要な `unused_function` や `huge_unused_data` がきれいに消去され、実行ファイルがスリムダウンしていることが確認できます!

—

4. なぜこれが実務で強力なのか?(アーキテクトからの知見)

「なんだ、ただサイズが小さくなるだけか」と思ったそこのあなた。実はこの設定、サイズ削減以外にも開発現場において計り知れないメリットをもたらします。

1. 静的解析やセキュリティ監査の精度向上
使っていないレガシーなコードやデバッグ用コードがバイナリに含まれていると、万が一リバースエンジニアリングされた際に脆弱性の温床になります。不要なコードをビルド時に排除することは、アタックサーフェス(攻撃対象領域)を狭めるセキュアコーディングの第一歩です。
2. LTO(Link Time Optimization)との相乗効果
`-ffunction-sections` は、プログラム全体を最適化するLTOと組み合わせることで真価を発揮します。関数単位で最適化のスコープが明確になるため、コンパイラがインライン展開やデッドコード削除の判断をしやすくなります。

—

5. 導入時の注意点(ここだけは押さえて!)

魔法のようなオプションですが、いくつか実務上の注意点があります。

  • C++の仮想関数やテンプレート

C++ではデフォルトで同等の最適化が効いているケースが多いですが、ポリモーフィズムやリフレクション的アプローチ(動的なシンボルルックアップなど)を使う場合、リンカが「使われていない」と誤認して必要なコードを削ってしまうことがあります(特にプラグイン機構や動的ロードを行う場合)。その際は `__attribute__((used))` やリンカスクリプトの `KEEP()` を用いて、削除してほしくないセクションを明示的に保護する必要があります。

  • ビルド時間への影響

セクションが細かくなるため、リンク時のリンカの負荷がごく僅かに上がります。ただし、現代のPCスペックや `lld` などの高速リンカを使っていれば体感できるほどの差はありません。安心して導入してください。

—

まとめ

いかがでしたでしょうか?
今回紹介した `-ffunction-sections`、`-fdata-sections`、そして `–gc-sections` は、C/C++開発において「知っているか・いないか」でプロダクトの品質に圧倒的な差がつくプロフェッショナル向けのテクニックです。

「無駄を削ぎ落とし、本当に必要なものだけを動かす」
この美学をビルドシステムに組み込むことで、あなたの作るソフトウェアはより堅牢で、洗練されたものになります。

ぜひ、明日のビルドスクリプトやCMakeLists.txtにこの魔法を仕込んでみてください。毎日のコーディングが、もっと楽しく、もっとエキサイティングになりますよ!

タイトルとURLをコピーしました