こんにちは。テックリードの私だ。
組み込み開発、IoTデバイス、あるいはコンテナイメージの軽量化が死活問題となるモダンなC言語プロジェクトにおいて、「バイナリサイズの肥大化」は常にエンジニアの頭を悩ませる。
「数個の小さなユーティリティ関数を追加しただけなのに、なぜビルド成果物が数百KBも膨らむのか?」
「デバッグシンボルを削れば小さくなるが、それでは現場でのクラッシュ解析(CoreDump解析)が立ち行かなくなる……」
このようなジレンマに直面したとき、多くの開発者は「C言語の構造上、仕方がない」と諦めがちだ。しかし、それはGCCとGNU Binutils(リンカ)の内部挙動、そしてCOMDAT(Common Data Area)とセクションの概念を正しく理解していないがために起きている悲劇にすぎない。
今回は、コンパイラとリンカの隠されたポテンシャルを極限まで引き出し、安全かつ劇的にバイナリをスリム化するプロフェッショナルなビルド最適化術を徹底解説しよう。
—
1. なぜバイナリは肥大化するのか?:リンカのデフォルト挙動の罠
まず、コンパイルとリンクのパイプラインで何が起きているかを知る必要がある。
通常、GCCはソースファイル(`.c`)ごとにオブジェクトファイル(`.o`)を生成する。この際、デフォルトの挙動では、ファイル内のすべての関数やグローバル変数が、それぞれ `.text` や `.data` という単一の巨大なセクションにまとめて詰め込まれる。
リンカ(`ld`)のデフォルトの仕事は、各オブジェクトファイルから `.text` セクションを集め、1つの `.text` セクションに結合することだ。ここで致命的な問題が発生する。
> リンカは、オブジェクトファイル内の「関数単位」ではなく、「セクション単位」でしか不要なコードを判定できない。
つまり、たった1つの小さなヘルパー関数を使うために巨大なサードパーティ製ライブラリ(例えば数学関数やJSONパーサー)のオブジェクトファイルをリンクした場合、そのファイルに含まれる「一度も呼ばれていない他の数十・数百の関数」までもが、容赦なく最終バイナリに同梱されてしまうのだ。これがバイナリ肥大化の根本原因である。
—
2. 解決の核心:`-ffunction-sections` と `–gc-sections` の連携
この問題を打破するのが、コンパイラのセクション分割オプションと、リンカのガベージコレクション機能の組み合わせだ。
魔法のコンパイルフラグ
- `-ffunction-sections`: すべての関数を個別のセクション(`.text.関数名`)に分離して出力する。
- `-fdata-sections`: すべてのグローバル変数を個別のセクション(`.data.変数名` または `.bss.変数名`)に分離して出力する。
リンカフラグ
- `-Wl,–gc-sections`: リンカに対し、参照されていない(デッドコードな)セクションを最終バイナリから完全に排除するよう指示する。
これにより、リンカは「どの関数が実際にエントリーポイント(`main`など)から辿れるか(コールグラフの構築)」を解析し、参照のルートから外れた孤立セクションを容赦なく切り捨てることができるようになる。
—
3. 実践! CMakeによるモダンな最適化ビルド構成
実務の現場では、手動でgccコマンドを叩くことはない。ビルドシステムとして広く使われている CMake を用いて、この最適化をプロジェクト全体に美しく適用するベストプラクティス構成を見ていこう。
以下の `CMakeLists.txt` は、リリースビルド時にのみ厳格なセクション分離とガベージコレクションを適用しつつ、デバッグ時との挙動の乖離を防ぐための洗練された設定例だ。
cmake_minimum_required(VERSION 3.22)
project(EmbeddedCoreOptimizer C)
C言語の標準規格を指定(C17を使用)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
ターゲット(実行ファイル)の定義
add_executable(firmware_core
src/main.c
src/logger.c
src/unused_heavy_module.c # あえて含めるが、未使用なら消えるべきモジュール
src/sensor_driver.c
)
—————————————————————————
プロフェッショナル・ビルドフラグ設定
—————————————————————————
デバッグビルド時の設定(最適化なし、シンボル保持)
target_compile_options(firmware_core PRIVATE
$<$
)
リリースビルド時における極限のサイズ最適化とセクション分離
target_compile_options(firmware_core PRIVATE
$<$
-Os # 実行速度よりもバイナリサイズを最優先して最適化
-ffunction-sections # 関数ごとに独立したセクション (.text.xxx) を生成
-fdata-sections # データごとに独立したセクション (.data.xxx / .bss.xxx) を生成
-flto # リンク時最適化 (Link Time Optimization) とのシナジーを高める
>
)
リンカフラグの設定
target_link_options(firmware_core PRIVATE
$<$
-Wl,–gc-sections # 参照されていないセクションを容赦なく削除(ガベージコレクション)
-Wl,–strip-all # すべてのシンボル情報を削除(完全なプロダクション用。デバッグ不可に注意)
$<$
>
)
この設定がもたらす実務上の利益
1. LTO (`-flto`) との相乗効果: 関数がセクション単位にバラバラになることで、LTOが関数間のインライン展開や死活コード解析をより精密に行えるようになる。
2. 環境依存の吸収: CMakeのジェネレータ式 (`$
—
4. チーム開発における暗黙の罠と「安全装置」のルール化
さて、ここからが腕の見せ所だ。`-ffunction-sections` と `–gc-sections` は強力だが、C言語の動的な性質やリフレクション(関数ポインタや割り込みベクタテーブル等)が存在する環境では、「意図せず必要なコードまで削り取られてしまう」という深刻なバグを引き起こすことがある。
特に組み込み開発におけるハードウェア割り込みハンドラや、OSの動的ロード関数などは、コード上のコールグラフから「参照されていない」と誤認されやすい。
罠の回避策:`__attribute__((used))` の徹底
チーム開発において、ライブラリの公開APIや、外部から動的に呼ばれるコールバック関数がガベージコレクションの犠牲にならないよう、共通ヘッダーでマクロを定義し、コードレビューの必須ルールに組み込むべきだ。
emdedded_macro.h
ifndef EMBEDDED_MACRO_H
define EMBEDDED_MACRO_H
/
- @brief ガベージコレクション(–gc-sections)による誤削除を防ぐためのマクロ
- 外部から直接参照されないが、割り込みベクタやプラグイン機構、
- またはデバッグ用に絶対に残しておきたい関数や変数に付与する。
/
if defined(__GNUC__) || defined(__clang__)
#define KEEP_SECTION __attribute__((used)) __attribute__((visibility(“default”)))
else
#define KEEP_SECTION
endif
endif // EMBEDDED_MACRO_H
これをチームメンバー全員が共通認識として持ち、以下のように記述するルールを徹底する。
include “embedded_macro.h”
// 例:ハードウェアタイマーの割り込みハンドラ
// 通常のmain関数からのコールグラフに含まれないため、–gc-sectionsの標的になりやすい
KEEP_SECTION void TIM2_IRQHandler(void) {
// 割り込み処理
}
—
5. 開発効率をブーストするVS Code / CLI環境の極意
テックリードとして、チーム全体の開発スピードを落とさないためのローカル環境設定も共有しておこう。
1. バイナリサイズの内訳を秒速で可視化するCLIコマンド
「どの関数がどれだけの容量を食っているか」を一目で暴くためには、Binutilsの `nm` と `size`、そして `llvm-size`(Clangの場合)を組み合わせたカスタムシェルスクリプトをプロジェクトの `scripts/` 配下に仕込んでおくと便利だ。
!/bin/bash
scripts/analyze_size.sh
バイナリのセクション別サイズと、オブジェクトごとのフットプリントを詳細出力する
TARGET=”build/Release/firmware_core”
if [ ! -f “$TARGET” ]; then
echo “Error: Target binary not found. Build Release first.”
exit 1
fi
echo “=== 1. セクション全体のサマリ == ”
size -A -x “$TARGET”
echo -e “\n=== 2. 容量を食っている上位20個のシンボル(関数・変数) ===”
nmでシンボルサイズを抽出し、大きい順にソートして上位20件を表示
nm –print-size –size-sort –radix=d “$TARGET” | tail -n 20
これを叩けば、誰がどのコードで容量を圧迫しているのかが一目瞭然となり、コードレビューでの議論が極めてロジカルになる。
2. VS Codeでコンパイルエラー・警告を逃さない設定 (`settings.json`)
C/C++拡張機能を使用している場合、コンパイラオプションの差異によるインテリセンスの破綻を防ぐため、CMake Tools拡張と連携させつつ、以下のように設定しておく。
{
// CMakeで生成されたコンパイルコマンドデータベースをC/C++拡張に自動読み込みさせる
“C_Cpp.default.compileCommands”: “${workspaceFolder}/build/Release/compile_commands.json”,
// 未使用コードのグレーアウト表示(セクション削除対象の予兆を感じ取るため)
“C_Cpp.dimInactiveRegions”: true,
// 開発中のコード品質を担保する静的解析の強化
“C_Cpp.codeAnalysis.runAutomatically”: true
}
—
結び:プロフェッショナルなエンジニアリングとは
バイナリサイズ削減は、単なる「容量の節約」ではない。
不要なコードが排除されたバイナリは、キャッシュヒット率の向上、メモリフットプリントの削減、そして何より「コードベース全体の設計の健全性」を証明するバロメーターとなる。
`-ffunction-sections`、`-fdata-sections`、そして `–gc-sections`。
この3つの鍵をビルドパイプラインに正しく組み込み、チーム全体でセクションのライフサイクルをコントロールできるようになれば、あなたのプロジェクトの品質は次のステージへと確実に飛躍するだろう。
さあ、今すぐ既存の `CMakeLists.txt` を開き、プロダクションビルドのセクション最適化を有効化してみてほしい。驚くほどの軽量化があなたを待っている。