【テクニカル・上級編】組込み開発でのGCC活用術:リンカスクリプトとセクション配置の深い話 – 実行環境・ランタイム・コンパイラ生産性向上バイブル

組込み開発の深淵:GCCリンカスクリプトとセクション配置のアーキテクチャ設計

組込みシステムの開発において、メモリマップの制御は製品の生死を分ける生死線である。
数キロバイトから数十メガバイトの限られたSRAM、Flash ROM、そして外部SDRAMの空間へ、コード(`.text`)やデータ(`.data`, `.bss`)をどう配置するか。この物理と論理の境界線を自在に操る技術こそ、真の低レイヤエンジニアと単なるAPIコーダーを分かつリトマス試験紙である。

世の入門書やネットの海には「`__attribute__((section(…)))`を使えば特定の領域に置けます」といった、表面的な記述しか転がっていない。しかし、現代の複雑化したSoC、マルチコア、安全基準(ISO 26262等)が要求する高信頼性システムにおいては、それだけでは到底太刀打ちできない。ビルドのたびにメモリ断片化(Fragmentation)に怯え、リンカの暗黙の結合順序に泣かされる日々から脱却するためには、GCCとGNU ldの内部メカニズム、そしてそれをCI/CDパイプラインで完全に再現・担保するインフラストラクチャの構築が不可欠だ。

本稿では、リンカスクリプトの深淵を覗き込み、カスタムセクションの完全制御、デバイスドライバの自動登録メカニズム、そしてDockerとCI/CDを用いたゼロコンフリクトなビルドパイプラインの構築手法を、一切の妥協なく解説する。

—

1. 内部アーキテクチャの理解:リンカがメモリを割り当てる瞬間

GCCがソースコードをコンパイルし、オブジェクトファイル(`.o`)を生成した時点では、まだアドレスは確定していない。各シンボルは「どのセクションの先頭から何バイト目か」という相対位置(Relocation info)として保持されているだけだ。

これらを結合し、最終的なバイナリ(ELF)のメモリマップを決定するのが GNU linker (`ld`) である。
リンカは リンカスクリプト(Linker Script, `.ld`) に記述された設計図に従い、入力セクション(Input Sections)を集約し、出力セクション(Output Sections)としてメモリ領域(MEMORY)にマッピングしていく。

メモリ制約環境におけるリンカスクリプトの基本構造

以下は、厳格なメモリ制約を持つマイコン(例: 32KB Flash, 8KB SRAM)を想定した、実戦的なリンカスクリプトの骨子である。

/ メモリ領域の物理的定義 /
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 8K
}

/ セクションのマッピング定義 /
SECTIONS
{
/ コード領域はFlashの先頭から配置 /
.text :
{
KEEP((.isr_vector)) / 割り込みベクタテーブルは絶対に削除・並び替えさせない /
(.text) / 通常のプログラムコード /
(.text.) / 最適化で分割された関数群 /
(.rodata) / 読み取り専用定数データ /
(.rodata.)
} > FLASH

/ 初期化済みデータはFlashに置かれ、起動時にSRAMへコピーされる /
.data :
{
_sdata = .; / SRAM側のデータ領域開始シンボル /
(.data)
(.data.)
_edata = .; / SRAM側のデータ領域終了シンボル /
} > SRAM AT > FLASH / VMAはSRAM, LMAはFlashに指定 /

/ 未初期化データ(BSS)はSRAMに配置し、起動時にゼロクリアする /
.bss :
{
_sbss = .; / BSS領域開始 /
(.bss)
(.bss.)
(COMMON)
_ebss = .; / BSS領域終了 /
} > SRAM
}

ここで重要なのが VMA(Virtual Memory Address) と LMA(Load Memory Address) の概念だ。
`.data` セクションのように「コード上は初期値を持つが、実行時は書き換え可能なSRAM上に存在すべき変数」の場合、LMA(ROM上の焼き付け位置)とVMA(実行時の参照位置)が乖離する。上のスクリプトの `> SRAM AT > FLASH` という記述こそが、その乖離をリンカに指示する魔法の呪文である。

—

2. 実践:属性(`__attribute__`)とカスタムセクションによるハードウェア制御

特定のメモリ領域(例えば、高速なTCM-RAM、不揮発性のFRAM、DMA転送用の非キャッシュ領域)に特定のデータを配置したい場合、`__attribute__((section(…)))` を駆使する。

しかし、単にセクションを指定するだけでは、リンカスクリプト側で受け皿を用意していない場合、予期せぬエラーになるか、最悪の場合 `.text` や `.data` に勝手にマージされてしまう。

応用例:高速実行が必要なクリティカル関数をTCM(Tightly Coupled Memory)に配置する

/

  • 組み込み用マクロ定義:
  • 機能を特定のカスタムセクション “.tcm_text” に強制配置し、さらにインライン展開や最適化を制御

/
define __tcm_code __attribute__((section(“.tcm_text”))) __attribute__((noinline))

/

  • 非常にシビアなタイミングが要求されるモータ制御の割り込みハンドラ
  • レイテンシを最小化するため、通常のFlashではなくゼロウェイトでアクセス可能なTCM上に配置する

/
__tcm_code void Critical_Motor_Control_Handler(void)
{
/ ハードウェアレジスタの直接操作 /
REG_MOTOR_PWM = CALCULATE_PWM_DUTY();
}

このCコードをビルドするためには、リンカスクリプト側で `.tcm_text` の受入先を明示的に定義しなければならない。

MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 30K
TCM_RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 2K / ゼロウェイトRAM /
SRAM (rwx) : ORIGIN = 0x20000800, LENGTH = 6K
}

SECTIONS
{
.text : { (.text) } > FLASH

/ TCM領域へのマッピング(LMAはFlash、VMAはTCM_RAM) /
.tcm_text :
{
_stcm_text = .;
(.tcm_text)
(.tcm_text.)
_etcm_text = .;
} > TCM_RAM AT > FLASH
}

スタートアップルーチン(アセンブリまたはCの初期化コード)において、`_stcm_text`, `_etcm_text` などのシンボルを利用して、CPU起動直後にFlashからTCM_RAMへこのコードブロックをmemcpyする処理を実装する必要がある点に注意せよ。

—

3. 高度なテクニック:リンカスクリプトによるデバイスドライバの「自動登録」

デザインパターンにおいて、コンポーネントの登録はしばしば動的なリストや配列で行われる。しかし、動的メモリ確保(`malloc`)が許されない、あるいは極力避けたい組込みリアルタイムOS(RTOS)環境では、「コンパイル時に静的配列へドライバを並べる」 テクニックが多用される。

GCCのリンカスクリプトとセクション配置を極めると、ソースコード側で一切の初期化リストを手動編集することなく、特定のセクションに置いた構造体をリンカが自動的に連続配置し、配列としてイテレートするシステムを構築できる。

ドライバ自動登録のアーキテクチャ

1. データ構造の定義: 登録するドライバのインターフェースを共通化する。
2. カスタムセクションへの配置: マクロを使い、各ドライバのインスタンス構造体を `.driver_registry` セクションに飛ばす。
3. リンカスクリプトでの束ね上げ: `.driver_registry` セクションを連続して並べ、開始と終了のシンボルを付与する。
4. ランタイムでの走査: 開始〜終了シンボル間のメモリアドレスをポインタとして読み出し、順次初期化関数を叩く。

実装コード例(C言語)

/ ドライバの共通インターフェース定義 /
typedef struct {
const char name;
int (init)(void);
void (poll)(void);
} Driver_t;

/ ドライバ自動登録用マクロ /
define REGISTER_DRIVER(drv_name, init_func, poll_func) \
static const Driver_t __driver_

drv_name __attribute__((section(“.driver_registry”), used)) = { \

.name = #drv_name, \
.init = init_func, \
.poll = poll_func \
}

/ 実際のドライバ実装例(UARTドライバ) /
static int uart_init(void) { / 初期化処理 / return 0; }
static void uart_poll(void) { / ポーリング処理 / }

REGISTER_DRIVER(uart_driver, uart_init, uart_poll);

/ 別のドライバ(SPIドライバ) /
static int spi_init(void) { / 初期化処理 / return 0; }
static void spi_poll(void) { / ポーリング処理 / }

REGISTER_DRIVER(spi_driver, spi_init, spi_poll);

(※ `used` 属性により、参照されていないと誤認されてオプティマイザにコードが削除(Dead Strip)されるのを防ぐ)

リンカスクリプト側の設定

SECTIONS
{
.text : { (.text) } > FLASH

/ ドライバ群を連続したセクションとして結合し、境界シンボルを定義 /
.driver_registry :
{
__driver_list_start = .;
KEEP((.driver_registry))
__driver_list_end = .;
} > FLASH
}

ランタイム側でのイテレーション

extern const Driver_t __driver_list_start;
extern const Driver_t __driver_list_end;

void System_Init_All_Drivers(void)
{
/ ポインタ演算により、セクションに並んだ構造体配列を走査する /
const Driver_t drv = &__driver_list_start;

while (drv < &__driver_list_end) { if (drv->init != NULL) {
drv->init();
}
drv++;
}
}

このアーキテクチャの強みは、「新しいドライバファイルを追加しても、メインの初期化ファイルを一切書き換える必要がない」点にある。ファイルをビルド対象に含め、マクロで宣言するだけで、リンカが自動的に全ドライバをリストに組み込んでくれる。これは大規模な組込みプラットフォーム開発において、モジュール間の結合度を劇的に下げる最高峰の設計パターンである。

—

4. インフラストラクチャの極み:DockerとCI/CDによる完全自動検証パイプライン

これほど高度なリンカスクリプトのカスタマイズやメモリ配置を行っていると、最大のボトルネックになるのが 「開発者のローカル環境依存(環境差異によるビルド破綻)」 である。
「俺のローカルではビルド通るのに、CI(GitHub Actions等)落ちるんだが?」という不毛な議論を根絶するため、GCCツールチェーン、リンカ、ビルドスクリプトをDockerコンテナに完全に閉じ込め、バージョン管理と完全同期させなければならない。

究極のコンパイルコンテナ構築(Dockerfile)

決定論的(Deterministic)なビルド環境を作るためのベースイメージ
FROM ubuntu:22.04 AS builder

非対話モードの設定と、必要なビルドツールの最小限インストール
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y –no-install-recommends \
wget \
bzip2 \
make \
cmake \
git \
python3 \
&& rm -rf /var/lib/apt/lists/

ARM Embedded GCC ツールチェーンの特定バージョンを固定ダウンロード(再現性の担保)
ENV GCC_VERSION=12.2.rel1
ENV GCC_URL=https://developer.arm.com/-/media/Files/downloads/gnu/12.2.rel1/binrel/arm-gnu-toolchain-12.2.rel1-x86_64-arm-none-eabi.tar.xz

RUN wget -q ${GCC_URL} -O /tmp/gcc.tar.xz && \
mkdir -p /opt/gcc-arm && \
tar -xf /tmp/gcc.tar.xz -C /opt/gcc-arm –strip-components=1 && \
rm /tmp/gcc.tar.xz

パスの通し込み
ENV PATH=”/opt/gcc-arm/bin:${PATH}”

WORKDIR /workspace

メモリ消費量を自動監視するCI/CD自動化スクリプト

リンカスクリプトをいじると、意図せずメモリ溢れ(Overflow)を起こすリスクが常につきまとう。これを人間が目視で確認するなどプロフェッショナルの仕事ではない。
ELFファイルが生成された瞬間に、Pythonスクリプトを用いてメモリ使用率をパースし、閾値を超えていればCIを強制失敗させる自動化スクリプト(`check_memory.py`)をパイプラインに組み込む。

!/usr/bin/env python3
import subprocess
import sys
import re

def get_memory_usage(elf_path):
# arm-none-eabi-size コマンドを叩いてセクションサイズを取得
cmd = [“arm-none-eabi-size”, elf_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)

lines = result.stdout.strip().split(‘\n’)
# 出力フォーマット例: text data bss dec hex filename
# 12345 128 512 12985 32b9 firmware.elf
parts = re.split(r’\s+’, lines[1].strip())

text_size = int(parts[0])
data_size = int(parts[1])
bss_size = int(parts[2])

flash_used = text_size + data_size
sram_used = data_size + bss_size

return flash_used, sram_used

def main():
elf_file = sys.argv[1]

# 制限値の定義(Flash: 32KB, SRAM: 8KB)
MAX_FLASH = 32 1024
MAX_SRAM = 8 1024

flash_used, sram_used = get_memory_usage(elf_file)

print(f”=== Memory Usage Analysis ===”)
print(f”FLASH Usage: {flash_used} / {MAX_FLASH} bytes ({(flash_used/MAX_FLASH)100:.2f}%)”)
print(f”SRAM Usage: {sram_used} / {MAX_SRAM} bytes ({(sram_used/MAX_SRAM)100:.2f}%)”)

failed = False
if flash_used > MAX_FLASH:
print(“ERROR: FLASH memory overflowed!”, file=sys.stderr)
failed = True

if sram_used > MAX_SRAM:
print(“ERROR: SRAM memory overflowed!”, file=sys.stderr)
failed = True

if failed:
sys.exit(1)
else:
print(“SUCCESS: Memory constraints satisfied.”)
sys.exit(0)

if __name__ == “__main__”:
main()

GitHub Actionsワークフロー設定(`.github/workflows/build.yml`)

name: Embedded Firmware Build & Verify

on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest

steps:
# リポジトリのチェックアウト

  • name: Checkout Repository

uses: actions/checkout@v3

# Dockerコンテナのビルド(ツールチェーン環境の完全再現)

  • name: Build Docker Environment

run: docker build -t embedded-builder:latest .

# コンテナ内でビルド実行

  • name: Run Compilation inside Docker

run: |
docker run –rm -v ${{ github.workspace }}:/workspace embedded-builder:latest \
make clean all

# メモリ使用量チェックスクリプトの実行(CIのゲートキーパー)

  • name: Verify Memory Constraints

run: |
docker run –rm -v ${{ github.workspace }}:/workspace embedded-builder:latest \
python3 scripts/check_memory.py build/firmware.elf

—

結び:制約を支配する者が、システムを制す

組込み開発におけるリンカスクリプトのチューニングとセクション配置の制御は、単なる「コンパイルを通すための作業」ではない。それは、ハードウェアの物理的特性(メモリの速度、容量、揮発性)とソフトウェアの論理的構造(データ構造、実行レイテンシ、モジュール性)を極限の効率で結合させる、アーキテクチャ設計そのものである。

`__attribute__` による配置制御、リンカスクリプトによる自動レジストリ、そしてDockerとPythonスクリプトによるCI/CDパイプラインでの完全なガードレール。これらを網羅したシステムを構築したとき、あなたはもはや「マイコンのメモリ不足におびえるプログラマー」ではなく、「ハードウェアの限界をコードの美しさで突破する、真の低レイヤアーキテクト」の領域に到達している。

妥協なき設計を貫き、最高峰のファームウェアを世に送り出し続けよ。

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