【実務・中級編】バイナリサイズを極限まで削る!GCCでのサイズ最適化と不要シンボル削除術 – 実行環境・ランタイム・コンパイラ生産性向上バイブル

こんにちは。テックリードの私だ。
今日のテーマは、組み込み開発やリソース制約の厳しいエッジデバイス、あるいはDockerコンテナのイメージサイズを限界まで削ぎ落とすための「GCCによる極限のバイナリサイズ削減術」についてだ。

「とりあえず `-Os` をつけておけばいいんでしょ?」
もし君がそう思っているなら、今日からその認識をアップデートしてほしい。`-Os` はスタートラインに過ぎない。コンパイラ内部、リンカ、そしてバイナリの構造そのものを理解し、不要な肉をそぎ落とすことで、バイナリサイズはさらに30%以上削減できる。

今回は、マニュアルのコピペではない、実戦の現場で培った「真のサイズ最適化ノウハウ」をすべて公開しよう。

—

1. なぜ「サイズ最適化」が現代の開発でも重要なのか?

「今の時代、ストレージやメモリは安価だ」という意見は、組み込み開発やIoT、あるいはクラウドネイティブなコンテナ最適化の現場においては幻想だ。

  • キャッシュヒット率の向上: コードサイズが小さくなれば、CPUの命令キャッシュ(Instruction Cache)に収まりやすくなり、結果的に実行速度も向上する。
  • フラッシュメモリのコスト削減: 量産フェーズにおいて、1MBの差が数百万台規模で数十〜数百万円のハードウェアコストの差になる。
  • OTA(Over-The-Air)アップデートの成功率: 通信帯域が細い環境や、セキュアブートのデュアルバンク領域に収めるためには、1バイトのオーバーも許されない。

GCCとClangが裏側で何をやっているのか、そのメカニズムに踏み込みながら実践的なテクニックを見ていこう。

—

2. コンパイル・リンク最適化の真髄(Makefile / CMake構成例)

まずは、GCCのポテンシャルを極限まで引き出すためのビルド設定だ。ここではモダンなCプロジェクトを想定し、CMakeのベストプラクティス構成例を示す。

以下の設定は、単にフラグを並べただけではない。「デバッグ情報を分離しつつ、未使用のコードとシンボルを徹底的に物理消去する」ための実践的かつ極限のアーキテクチャだ。

CMakeLists.txt のベストプラクティス構成例

cmake_minimum_required(VERSION 3.20)
project(ExtremeEmbedded C)

C言語の規格設定(環境に合わせて変更)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

リリースビルドにおける極限サイズ最適化フラグの定義
set(CMAKE_C_FLAGS_RELEASE
“${CMAKE_C_FLAGS_RELEASE} \
-Os \
-flto \
-ffunction-sections \
-fdata-sections \
-fno-asynchronous-unwind-tables \
-fno-unwind-tables \
-fno-exceptions \
-fno-stack-protector \
-s”
)

実行ファイルの設定
add_executable(firmware
src/main.c
src/sensor.c
src/protocol.c
)

リンカフラグの設定(未使用セクションの完全除去とLTOの適用)
target_link_options(firmware PRIVATE
“LINKER:–gc-sections”
“LINKER:-s”
-flto
)

最適化の恩恵を最大化するための追加設定
if(CMAKE_BUILD_TYPE STREQUAL “Release”)
# デバッグシンボルを完全に排除(必要に応じて別ファイルに退避させること)
set(CMAKE_EXE_LINKER_FLAGS “${CMAKE_EXE_LINKER_FLAGS} -Wl,–strip-all”)
endif()

各フラグの内部挙動と採用理由

1. `-Os` (Optimize for size):
速度優先の `-O3` と異なり、コードサイズを増やすインライン展開(Loop Unrollingなど)を抑制しつつ、サイズ効率の良い命令列を選択する。
2. `-flto` (Link-Time Optimization):
通常のコンパイルではファイル単位でしか最適化できないが、LTOはリンク時(最終バイナリ生成時)に全ファイルをまたいで解析するため、使われていない関数や変数をグローバルレベルで発見し消去できる。
3. `-ffunction-sections` / `-fdata-sections`:
通常、GCCはソースファイル単位でセクション(`.text`など)を生成する。このフラグを立てると、「関数ごと」「変数ごと」に独立したセクション(例: `.text.my_func`)に分離する。これが次のリンカ設定と組み合わさることで真価を発揮する。
4. `–gc-sections` (Garbage Collection of Sections):
上記で細分化されたセクションのうち、どこからも参照されていない(デッドコードな)セクションをリンカが完全に削ぎ落とす。これがないと、使っていないライブラリ関数までバイナリに含まれてしまう。
5. `-fno-asynchronous-unwind-tables` / `-fno-unwind-tables`:
例外処理やスタックトレースバック用のメタデータを破棄する。C言語環境で `abort()` 後のバックトレースが不要な組み込みシステムでは、これだけで数KB〜数十KBの削減になる。
6. `-s`:
最終的なバイナリからシンボルテーブルとリロケーション情報を抹消する。

—

3. `strip` コマンドの極意:不要なメタデータの物理的排除

ビルド直後のバイナリには、デヴァッガー用のシンボル名、関数名、ローカル変数の情報など、ターゲットデバイスにとっては「ゴミ」でしかないデバッグ情報が大量に残っている。これを `strip` コマンドで外科手術のように綺麗に剥ぎ取る。

実践的な strip コマンドのパイプライン

単に `strip firmware` と叩くだけでは不十分だ。より踏み込んだセクション単位の削除を行おう。

1. 現在のバイナリサイズを確認
$ size firmware
text data bss dec hex filename
45210 1204 512 46926 b74e firmware

2. すべてのデバッグ情報とシンボルを完全に削除
$ strip –strip-all –remove-section=.comment –remove-section=.note.gnu.gold-version firmware

3. 削除後のサイズを確認
$ size firmware
text data bss dec hex firmware
31004 412 512 31928 7cc8 firmware

ここでポイントなのは、`–remove-section` を使って `.comment`(コンパイラのバージョン情報など)や `.note.` といった、実行に全く寄与しないメタデータセクション自体をターゲットから物理的に消去することだ。

—

4. チーム開発におけるサイズ回帰を防ぐ自動化ルール(CI/CD連携)

サイズ最適化において最も恐ろしいのは、「いつの間にか誰かが肥大化したサードパーティライブラリや重い機能をマージし、気づいた時には数KBオーバーしていた」という事態だ。

これを防ぐためには、PR(Pull Request)の段階でバイナリサイズの増減を自動検知し、許容値を超えたらビルドを落とす仕組みをCIパイプラインに組み込む必要がある。

以下に、GitHub Actionsで使える実践的なワークフロー設定を示す。

`.github/workflows/size_check.yml`

name: Binary Size Guard

on:
pull_request:
branches: [ main, develop ]

jobs:
check-size:
runs-on: ubuntu-latest
steps:

  • name: Checkout Repository

uses: actions/checkout@v3

  • name: Install Toolchain (e.g., ARM Embedded GCC)

run: |
sudo apt-get update
sudo apt-get install -y gcc-arm-none-eabi binutils-arm-none-eabi cmake

  • name: Configure and Build Release Binary

run: |
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
make firmware

  • name: Measure Binary Size

id: measure
run: |
# セクションごとのサイズ詳細を出力しつつ、ROM使用量(text + data)を変数に格納
SIZE=$(arm-none-eabi-size build/firmware | awk ‘NR==2 {print $1 + $2}’)
echo “rom_size=$SIZE” >> $GITHUB_OUTPUT
echo “=== 測定されたROM使用量: $SIZE bytes ===”

# 詳細な内訳を表示
arm-none-eabi-objdump -h build/firmware

  • name: Size Threshold Validation

run: |
# 許容上限サイズを定義(例: 32,768 bytes = 32KB)
MAX_LIMIT=32768
CURRENT_SIZE=${{ steps.measure.outputs.rom_size }}

if [ “$CURRENT_SIZE” -gt “$MAX_LIMIT” ]; then
echo “::error::バイナリサイズが制限値を超過しています!制限: $MAX_LIMIT bytes, 現在: $CURRENT_SIZE bytes”
exit 1
else
echo “::notice::サイズチェック合格 (現在: $CURRENT_SIZE bytes / 制限: $MAX_LIMIT bytes)”
fi

このCIルールを導入しておけば、開発者は常に「自分のコードがどれだけROMを消費しているか」を意識せざるを得なくなり、チーム全体のコード品質と意識が劇的に向上する。

—

5. テックリードからの実践アドバイス:さらに削るためのマニアックな手法

もし上記ですらまだサイズが大きすぎるという極限の状況に直面しているなら、以下の奥の手を検討してほしい。

1. `printf` の軽量化:
標準ライブラリの `printf` は浮動小数点(`%f`)や多彩なフォーマットに対応しているため、リンクすると一気にコードが肥大化する。自前でミニマルなフォーマット関数(あるいは `puts` や専用のロガー)を実装し、標準IOのリンクを断ち切るだけで数KB〜十数KB削れる。
2. グローバル変数の初期化の見直し:
不要な初期値(`int a = 0;` など)をコード内に持たせると、データセクション(`.data`)のサイズが増える。初期値がゼロのものはすべてBSS領域(`.bss`)に落ちるようにし、フラッシュ上のROM容量を圧迫しないように設計する。
3. Mapファイルの解析:
`-Wl,-Map=firmware.map` をリンカに渡し、どの関数やライブラリがどれだけの容量を食っているかを `bloaty` などのツールで可視化せよ。「敵を知る」ことが、サイズ削減の第一歩だ。

—

結びにかえて

バイナリサイズとの戦いは、ハードウェアの制約と知恵で競い合うエンジニアリングの醍醐味の一つだ。
「動けばいいや」という妥協を捨て、コンパイラの挙動を手のうちに入れれば、君が書くコードはより美しく、洗練されたものになるはずだ。

今日のビルドから、さっそく `-flto` とセクション単位の最適化を試してみてほしい。その成果にきっと驚くはずだ。

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