本文へスキップ

公式配布のkit20_gr-peachが今のe2 studioで通らない: mbed OSごと捨ててベアメタルに移した

43 分で読めます
目次

画像処理マイコンカーキット用の公式配布プログラム kit20_gr-peach は、e2 studio のプロジェクトとして配られている。ところがこれは配布当時の古い e2 studio でないとビルドが通らない。今の e2 studio (2025-10) に取り込むとインポート自体は成功するが、2015 年のツールチェインを探しに行って何も見つからず、エラーゼロのまま何も生成せずに終わる。後で実際のログを載せる。

土台を作り直すしかなくなったので、ついでに mbed OS も丸ごと捨てて、iodefine.h でレジスタを直接叩くベアメタルに移した。この記事はその全記録で、同じところで詰まった人が、この記事だけ読んで最後まで行けることを目標に書いている。

コードは公開している。

https://github.com/takushio2525/mcr_camera_2026base

登場する3つのプロジェクト

話がややこしいのは、似たものが3つあるからだ。先に整理しておく。

入手元 ツール ビルドの仕組み mbed OS
kit20_gr-peach 公式配布 e2 studio GNU ARM Eclipse プラグイン(サードパーティ) あり
MCR_CCLASS_2.38m-s 自作 mbed Studio mbed CLI(.lib + mbed_app.json あり
mcr_camera_2026base 自作 e2 studio Renesas 純正ツールチェイン なし

公式配布は最初から e2 studio 用なので、この移行は「IDE の乗り換え」ではない。実際に動かした軸は mbed OS を使うか捨てるかのほうだ。自分は途中で mbed Studio に寄り道しているが、最終的に上の3番目に落ち着いた。

なぜ公式配布そのままでは進めないのか

理由1: ビルド定義が旧 e2 studio 専用のプラグインに縛られている

配布プロジェクトの .cproject を開くと、ビルド設定の識別子が軒並みこの名前空間で書かれている。

ilg.gnuarmeclipse.managedbuild.cross.*

ilgGNU ARM Eclipse(後の GNU MCU Eclipse、現 Eclipse Embedded CDT)というサードパーティ製プラグインのもので、Renesas 純正ではない。配布当時の e2 studio はこれを同梱していたが、今の e2 studio には入っていない。今の e2 studio が新規プロジェクトで作る .cprojectcom.renesas.cdt.managedbuild.gcc.rz.* で、名前空間ごと別物だ。

実際に何が起きるか

推測で書くのは危ないので、e2 studio 2025-10 (25.10.0) に実際にインポートしてビルドを回した。ヘッドレスビルドなのでコマンドラインから再現できる。

e2studioc.exe --launcher.suppressErrors -nosplash ^
  -application org.eclipse.cdt.managedbuilder.core.headlessbuild ^
  -data <ワークスペース> -import <配布プロジェクト> ^
  -cleanBuild "kit20_gr-peach/Debug"

結果は予想と違った。インポートは成功する。 そのうえで、こうなる。

make -r all
実行できません

エラー: PATH でプログラム "make" が見つかりません
PATH=[C:\Program Files\GNU Tools ARM Embedded\4.9 2015q3\bin\;
      C:\Program Files (x86)\GNU Tools ARM Embedded\4.9 2015q3\bin\;
      C:\Renesas\e2_studio\eclipse\..\Utilities]

Build Finished. 0 errors, 0 warnings. (took 58ms)

注目すべきは PATH の中身だ。GNU Tools ARM Embedded 4.9 2015q3 —— 2015 年のツールチェインを見に行っている。当然そんなものは入っていない。旧プラグインが持っていた既定のツールチェインパスに解決されてしまい、実体が無いのでツールが1つも見つからない。make すら無い(今の e2 studio の makeeclipse\plugins\com.renesas.ide.exttools.gnumake...\mk\ にあり、この PATH には入らない)。

そして一番たちが悪いのがここだ。

0 errors, 0 warnings で終わり、終了コードは 0 になる。

何もビルドしていないのに成功扱いになる。.elf は生成されていないのに、ログだけ見ると通ったように見える。CI に載せていたら気づかずに素通りする類の失敗だ。

つまり「ビルド定義が読めなくてエラーになる」のではなく、古いツールチェインを探しに行って何も見つからず、黙って空振りする。個別のコンパイルエラーを潰す作業ですらない。

最初に立てた仮説は外れていた

正直に書いておく。この記事の初稿では、原因を別のものだと書いていた。

配布物の .cprojectilg.gnuarmeclipse.* で埋まっていること、そのプラグインが今の e2 studio に無いこと。この2つが揃っていたので、「ビルド定義そのものが解釈できないからエラーになる」と考えた。しかも実際にビルドすると、ログにこういう行が出る。

Managed Build system manifest file error: Unable to resolve the category identifier
ilg.gnuarmeclipse.managedbuild.cross.optionCategory.c.linker.misc in the option
com.renesas.cdt.managedbuild.gcc.rz.option.linker.cpp.nosys.

ilg.gnuarmeclipse が解決できない、と書いてある。仮説どおりに見えた。

間違いだった。 気づいたのは、対照実験として自作プロジェクトのほうもビルドしたときだ。同じ行が出た。 正常にビルドが通って .elf まで生成されているプロジェクトで、である。

よく読むと、解決できないと言われているのは com.renesas.cdt.managedbuild.gcc.rz.option.linker.cpp.nosys という 今の e2 studio 自身のオプションが参照している ilg のカテゴリ ID だ。つまり Renesas 側のプラグイン定義に古い参照が残っているだけで、どのプロジェクトをビルドしても出る。配布物とは何の関係もなかった。

真犯人は地味な PATH の1行のほうだった。

この手の「それっぽいエラーメッセージ」は、探している答えに形が似ているほど信じてしまう。動いている側でも同じ現象が起きないかを確かめるだけで防げる。仮説を裏づける証拠を探すより、仮説を殺す証拠を探すほうが速い。

配布物側の設定を具体的に読むとこうなっている。

項目 配布プロジェクトの値
ツールチェイン名 GNU Tools for ARM Embedded Processors
CPU cortex-a9 / アーキテクチャ armv7-a
命令セット ARM(Thumb ではない)
FPU / Float ABI vfpv3 / hard
最適化 Debug 構成 -O2 相当 / Release 構成 -Os 相当

値そのものは今でも普通に指定できるものばかりだ。通らない理由は設定値ではなく、設定を書いてある器のほうにある。

理由2: mbed OS がサービス終了している

配布プロジェクトには mbed OS 一式が同梱されていて、ファイル数で見ると圧倒的にそこが占めている。

内訳 ファイル数
mbed OS 本体 692
mbed-gr-libs(GR-PEACH 向けドライバ群) 423
MCR 固有ライブラリ 5
ユーザーが書くコード 1(約1,700行)

自分で触る範囲は1ファイルで、残り1,100本超は動かせない土台だった。その土台の供給元が終了している以上、古い e2 studio を探してきて延命しても、寿命を後ろにずらすだけになる。

理由3: 文字コードが CP932 + CRLF

細かいが実際に効く。配布ソースは CP932(Shift_JIS)で、改行は CRLF。今の e2 studio は既定が UTF-8 なので、そのまま開くとコメントが化ける。移すなら最初に変換しておくほうがいい。

iconv -f CP932 -t UTF-8 kit20_gr-peach.cpp | tr -d '\r' > main_utf8.cpp

何を捨てて、何を残したか

全部を書き直したわけではない。カメラの映像を取り込む部分は残した。ここが一番厄介で、かつ mbed OS への依存が薄かったからだ。

配布物が使っていたもの 移行後
Ticker(1ms 周期割り込み) OSTM0 + GIC を直接設定
DigitalIn / DigitalOut / BusIn(LED・スイッチ) GPIO レジスタを直接操作する Onboard
Serial(デバッグ出力) SCIF2 を直接設定
DisplayBase(VDC5 カメラ取り込み) そのまま流用
MTU2 の PWM 生成 元からレジスタ直叩き。ほぼそのまま
mbed OS の起動処理 generate/start.S + 自前の初期化

意外だったのは、モーターとサーボの PWM は配布物の時点で既にレジスタを直接叩いていたことだ。mbed の PwmOut ではなく MTU2 を自前で設定している。ここは移行の対象ですらなかった。

残した VDC5 ドライバを mbed 無しでコンパイルさせる

DisplayBase とその下の VDC5 / VDEC ドライバは Renesas 提供のもので、mbed-gr-libs に入っている。これを mbed OS 抜きでビルドするには、ドライバが #include している mbed のヘッダを、最小限の中身で用意してやればいい。実際に書いたのは3本だけだった。

mbed_assert.h は、アサートを握り潰すだけの4行で足りる。

#ifndef MBED_ASSERT_H
#define MBED_ASSERT_H
#define MBED_ASSERT(expr) ((void)0)
#endif // MBED_ASSERT_H

pinmap.h は、ドライバが型として要求する PinName の enum をダミーで定義したもの(226行)。r_typedefs.h は Renesas 側が元から持っている型定義ヘッダで、これはそのまま持ってくる。

この3本を置くだけで、Renesas のドライバ群は mbed 無しでコンパイルが通る。逆に言えば、mbed OS への依存はこの程度しかなかった。ドライバ本体には一切手を入れていない。


移行後のビルド設定

ここから先が新環境の話になる。組込みはツールチェインのバージョン差だけでビルドが通らなくなるので、まずこの組み合わせを再現するのが一番早い。

項目
ターゲット GR-PEACH(Renesas RZ/A1H, ARM Cortex-A9)。.cprojectのデバイス名はR7S721001
IDE Renesas e2 studio 2025-10 (25.10.0)(ビルド構成HardwareDebug
CDTツールチェイン GCC for Renesas RZ(com.renesas.cdt.managedbuild.gcc.rz.toolchain.debug.update
.cprojectの登録値 toolchain.id = gcc-arm-embedded / toolchain.version = 13.3.1.arm-13-24
コンパイラ Arm GNU Toolchain 13.3.Rel1 (Build arm-13.24) / arm-none-eabi-gcc 13.3.1 20240614
標準ライブラリ newlib(prebuilt、--specs=rdimon.specs
CPU・命令セット -mcpu=cortex-a9 -marm -mlittle-endian
FPU / Float ABI -mfpu=vfpv3-d16 -mfloat-abi=hard
最適化・デバッグ情報 -O0 / -g -gdwarf-4
言語 C++17以上(設定ヘッダでinline constを使うため)
実行方式 XIP(SPIフラッシュ上のコードを直接実行)+ MMU / L1キャッシュ有効

配布物との差でひとつ注意がある。FPU の指定が配布物は vfpv3、こちらは vfpv3-d16 になっている。RZ/A1H の FPU はレジスタ16本なので -d16 が実態に合う。float ABI が hard である点は両者で一致しているので、ここを softfp にすると呼び出し規約が変わって噛み合わなくなる。

ひとつ反省を書いておくと、e2 studio 本体のバージョンはリポジトリのどこにも残らない.settings/e2studio_project.prefs が持っているのは構成 ID だけで、製品バージョンは記録されない。配布物が「古い e2 studio でないと通らない」という状態になったのも、突き詰めれば誰もバージョンを書き残していないからだ。

プロジェクトファイルから分からない以上、インストール先を直接読むしかない。ここに書いてある。

C:\Renesas\e2_studio\eclipse\.eclipseproduct
name=Renesas e2 studio Platform
id=com.renesas.platform
version=25.10.0

この 25.10.0 が製品リリースの 2025-10 に対応する。GUI からなら「ヘルプ → e2 studio について」でも見られる。コンパイラのほうは実体を叩けば出る。

$ arm-none-eabi-gcc --version
arm-none-eabi-gcc.exe (Arm GNU Toolchain 13.3.Rel1 (Build arm-13.24)) 13.3.1 20240614

ビルド番号の arm-13.24 が、.cproject に登録されている toolchain.version = 13.3.1.arm-13-24 と対応している。この2つが一致していることを確認してから移行を始めたほうがいい。 自分は最後に確認する羽目になった。


第1部: コンパイルが通るまで

プロジェクトは「移植」ではなく新規に作る

配布プロジェクトを今の e2 studio に取り込んで直す、という方向は最初に捨てた。前述のとおりビルド定義が旧プラグインの名前空間で書かれていて、個別のエラーを潰す作業にすらならないからだ。

代わりに、e2 studioの新規プロジェクトウィザードで「GCC for Renesas RZ」の実行可能プロジェクトを作り、デバイスにR7S721001を指定して、生成されたgenerate/一式を土台にする。実際、最初のコミットに入っているのはこれだけだった。

.cproject / .project / .settings/     ← IDEの設定
generate/iodefine.h                   ← レジスタ定義(iodefines/ に周辺別ヘッダ40本)
generate/start.S                      ← リセットからmain()までの起動コード
generate/vect_table.S                 ← 例外ベクタ表(.fvectorsセクション)
generate/vects.c                      ← 周辺割り込みの可搬ベクタ表(.rvectors)
generate/inthandler.c                 ← 割り込みハンドラの実体(中身は空)
generate/interrupt_handlers.h         ← 上記の宣言
generate/hwsetup.c                    ← HardwareSetup()(中身は空)
generate/linker_script.ld             ← リンカスクリプト
src/mcr_camera_2026base.cpp           ← main()

空のファイルが多いのがポイントで、inthandler.cINT_Excep_IRQ()hwsetup.cHardwareSetup()も、生成直後は本当に{}だけだ。ここに何を書くかが移行作業そのものになる。

ビルド構成にはHardwareDebugを使い、プロジェクトのソースフォルダはsrcgenerateの2つだけにした(.cproject<sourceEntries>)。この2つがそのままインクルードパスになる、というのが後で効いてくる。

ビルドの流れ

ビルド設定は.cprojectにしか残らない

e2 studioのGUIで設定した内容は.cprojectのXMLに<option>要素として書かれ、ビルド時にHardwareDebug/配下のsubdir.mkへ展開される。手元ではHardwareDebug/.gitignoreに入れてしまったが、それ以前のコミットに生成物が残っていたので、実際に叩かれたコマンドラインを履歴から復元できた

C++ソース1本あたりのコンパイルは以下になる(ワークスペースの絶対パスは伏せた)。

arm-none-eabi-g++ \
  -mcpu=cortex-a9 -marm -mlittle-endian -mfloat-abi=hard -mfpu=vfpv3-d16 \
  -O0 -g -gdwarf-4 \
  -fmessage-length=0 -fsigned-char -ffunction-sections -fdata-sections \
  -fno-strict-aliasing -fabi-version=0 -fdiagnostics-parseable-fixits \
  -Wnull-dereference -Wstack-usage=100 \
  -I"<ワークスペース>/mcr_camera_2026base/generate" \
  -I"<ワークスペース>/mcr_camera_2026base/src" \
  -MMD -MP -MF"src/foo.d" -MT"src/foo.o" -c -o "src/foo.o" "../src/foo.cpp"

見落としやすいものを3つ挙げておく。

  • -ffunction-sections -fdata-sections: 関数・変数ごとに別セクションへ切り出す。リンク時の--gc-sectionsとセットで効く。後述の.init_array事件の前提条件がここにある
  • -Wstack-usage=100: 1フレーム100バイトを超えると警告が出る。ローカル配列を気軽に置けない設定になっている。ベアメタルなので妥当だが、知らずに書くと警告まみれになる
  • -O0: デバッグ構成なので最適化なし。XIPで実行するとこれがそのまま速度に効く(後述)

インクルードパスは2本しかなく、defineはゼロ

上のコマンドを見ると、-Igeneratesrcの2本だけで、-Dによる定義は1つも無い。.cprojectにもインクルードパスの<option>は空のまま登録されている。つまりプロジェクトのソースフォルダがそのままインクルードパスになる仕様に完全に乗っている

おかげで#include "iodefine.h"generate/)も#include "drivers/Camera.h"src/)も同じように書けて、自分のコードを書いている限りは何も困らない。

困るのはmbed-gr-libs由来のVDC5/DVDECドライバを持ち込んだときだった。あちら側はvdc5/src/r_vdc5.c#include "r_vdc5.h"と書き、include/src/に同名ヘッダが別々に置かれている前提の構成になっている。インクルードパスが2本しかないこの環境では、当然どれも解決できない。

このときに選んだのは、必要なヘッダを、それを必要とするディレクトリすべてに複製するという力技だった。実際にリポジトリを調べるとこうなっている。

# ハッシュの先頭8桁だけ表示
$ find src/drivers/video -name 'r_vdc5.h' \
    | while read f; do echo "$(md5 -q "$f" | cut -c1-8)  $f"; done
9acb4fcd  src/drivers/video/r_vdc5.h
9acb4fcd  src/drivers/video/video_decoder/r_vdc5.h
9acb4fcd  src/drivers/video/include/r_vdc5.h
9acb4fcd  src/drivers/video/src/r_vdc5.h
9acb4fcd  src/drivers/video/vdc5/include/r_vdc5.h
9acb4fcd  src/drivers/video/vdc5/src/r_vdc5.h

ハッシュが全部同じ、つまり中身が完全に同一のファイルが6箇所にある。r_typedefs.hは5箇所、lcd_panel.hlvds_pll_calc.hは3〜4箇所。

正直に書くと、これは良い解ではない。本来は.cprojectのインクルードパスにsrc/drivers/video/vdc5/includeなどを足すべきで、そうすればヘッダは1つで済む。ただ、GUIで設定を足すと「別環境で再現するときに何をどう設定すればいいか」が.cprojectの中に埋もれてしまうという事情もあった。ヘッダの複製はリポジトリを見れば一目で分かるという点だけは勝っている。同じ状況に来た人は、まずインクルードパスを足す側を検討したほうがいい

潰したコンパイルエラー

ソースを持ち込んでいく過程で出たものを、原因と対策の形で残しておく。

エラー 原因 対策
core_ca.hIRQn_Typeが未定義 / GIC_DISTRIBUTOR_BASEが未定義 CMSISのcore_ca.hはGIC操作関数の宣言でこれらを要求するが、デバイス固有ヘッダが供給する前提になっている。mbedを外したので供給元が消えた 自作のrz_a1h_addr.hに最小定義を置く(下記)
ff.cbool / true / falseが未定義 FatFsは.cなのでCとしてコンパイルされる。C++なら組み込みのboolが使える #include <stdbool.h>を1行足す
SDCard.cppinitCardV1() / initCardV2()が多重定義 移植の過程で同じ実装を2箇所に書いてしまっていた 重複を削除
追加したソースがビルドされない サブディレクトリを増やすとHardwareDebug/配下のsubdir.mk / sources.mkの更新が必要になる src/drivers/fatfs用のsubdir.mkを作り、makefilesources.mkにも登録した

1つめの最小定義はこれだけで済んだ。GIC自体はinitGIC()で直接レジスタを叩いているので、CMSISのGIC_xxx()関数は使っていない。コンパイルを通すためだけの定義だと分かるようにコメントを残してある。

/* ---- core_ca.h 向けデバイスパラメータ ---- */
#define __CA_REV        0x0000U   /* Cortex-A9 r0p0 */
#define __FPU_PRESENT   1U        /* VFPv3-D16 搭載 */
#define __GIC_PRESENT   1U
#define __TIM_PRESENT   1U
#define __L2C_PRESENT   0U        /* L2C は今回有効化しない */

/* ---- GIC アドレス (core_ca.h の GIC 関数が参照) ---- */
#define GIC_DISTRIBUTOR_BASE   (0xE8201000UL)
#define GIC_INTERFACE_BASE     (0xE8202000UL)

/* ---- IRQn_Type: core_ca.h の GIC 関数シグネチャで使用 ---- */
typedef int32_t IRQn_Type;

リンクの設定を読む(ここが後で全部効く)

コンパイルより重要だったのがリンクのコマンドラインだった。生成されたmakefileにはこう書かれている。

arm-none-eabi-g++ \
  -mcpu=cortex-a9 -marm -mlittle-endian -mfloat-abi=hard -mfpu=vfpv3-d16 \
  -O0 -g -gdwarf-4 -ffunction-sections -fdata-sections \
  -o "mcr_camera_2026base.elf" $(OBJS) \
  -T "<ワークスペース>/mcr_camera_2026base/generate/linker_script.ld" \
  -Wl,--start-group -Wl,--end-group \
  -nostartfiles \
  -Xlinker --gc-sections \
  -Wl,-Map,"mcr_camera_2026base.map" \
  -Wl,-e_PowerON_Reset \
  --specs=rdimon.specs

読み方は以下のとおり。

  • -T .../linker_script.ld: メモリ配置はすべてこのファイルが決める。第2部はほぼこの1ファイルの話になる
  • -nostartfiles: crt0を一切リンクしない。つまり__libc_init_array()を呼ぶ人がいない。この1語がグローバルコンストラクタ事件の原因になる
  • -Xlinker --gc-sections: どこからも参照されていないセクションを捨てる。-ffunction-sectionsと組んでコードサイズを削るための設定だが、KEEP()で守っていないセクションも一緒に消える
  • -Wl,-e_PowerON_Reset: エントリポイントはstart.Sのラベル。mainではない
  • --specs=rdimon.specs: newlibのセミホスティング版を使う
  • -Wl,-Map,...: マップファイルを出す。リンカスクリプトを直したかどうかを確認できる唯一の手段がこれで、第2部の実測値は全部ここから読んだ

書き込み

生成された.elfobjcopyでバイナリにして、DAPLinkがUSBマスストレージとして見せるドライブへドラッグ&ドロップする。

arm-none-eabi-objcopy -O binary --gap-fill 0xff \
  "mcr_camera_2026base.elf" "mcr_camera_2026base.bin"

この「.binをドラッグ&ドロップで書ける」ことが、次のメモリ配置の話の出発点になる。書いた先はSPIフラッシュなので、プログラムがSPIフラッシュ上にあることを前提にリンカスクリプトを組み直さないといけない


第2部: メモリ配置

リンカスクリプトが決めるメモリ配置

生成直後のリンカスクリプトは「全部RAM」

e2 studioが最初に吐くリンカスクリプトは、こういう構成になっていた(現在はlinker_script_ram.ldという名前で保存してある)。

MEMORY {
    CSx    : ORIGIN = 0x00000000, LENGTH = 0x18000000
    SPIBSC : ORIGIN = 0x18000000, LENGTH = 0x08000000
    RAM    : ORIGIN = 0x20000000, LENGTH = 0x00500000
}
SECTIONS
{
	.fvectors 0x20020000 : AT (0x20020000) { KEEP(*(.fvectors)) } > RAM
	.text     0x20020100 : AT (0x20020100) { *(.text) } > RAM
	/* ... .rodata / .tors などもすべて > RAM ... */
	.data     0x20060100 : AT (_mdata) { ... } > RAM
	.bss                 : { ... } > RAM
	.stack    0x20061100 (NOLOAD) : AT (0x20061100) { _stack = .; } > RAM
}

SPIBSCというリージョン名だけは定義されているが、実際にはどのセクションも割り当てられておらず、コードもデータも全部SRAMに載る。つまりデバッガでSRAMへ転送しないと動かないし、電源を切れば消える。DAPLinkの.binドラッグ&ドロップは使えない。

配置そのものにも引っかかるところがある。.data0x20060100で、そのわずか4KB上の0x20061100.stackだ。スタックは上端から下向きに伸びるので、.data.bssが4KBを超えた瞬間に踏み合う配置になっている。生成されたテンプレートをそのまま信用するのは危険だ、と最初に気づく箇所でもあった。

SPIフラッシュ起動へ書き換える

やることは3つある。

  1. MEMORYを、実際のアドレス空間に合わせて切り直す
  2. コードと読み取り専用データをフラッシュ側(> SFLASH)へ移す
  3. .dataだけは「フラッシュに置いて、起動時にSRAMへコピーする」形にする
MEMORY {
    BOOT_LOADER : ORIGIN = 0x18000000, LENGTH = 0x00004000
    SFLASH      : ORIGIN = 0x18004000, LENGTH = 0x07FFC000
    RAM         : ORIGIN = 0x20020000, LENGTH = 0x00500000
}

先頭16KBをBOOT_LOADERとして切り出しているのは、GR-PEACHのSPIフラッシュ起動に必要なブートローダを置くためだ。mbed由来のmbed_sf_boot.cが持っているバイト列を、セクション属性でここへ流し込む。

const char boot_loader[] __attribute__((section(".boot_loader"), used)) = { /* ... */ };
.boot : { KEEP(*(.boot_loader)) } > BOOT_LOADER

KEEP()が要るのは、この配列がCコードのどこからも参照されないからだ。--gc-sectionsが有効なので、KEEP()が無ければ丸ごと捨てられる

そして.dataだけは扱いが違う。

    /* コンストラクタ/デストラクタの後ろで _mdata を確定させる */
    .tors : { /* ... */ . = ALIGN(2); _mdata = .; } > SFLASH

    /* 初期化済みデータ (SFLASHからRAMへコピーされる) */
    .data 0x20040000 : AT (_mdata)
    {
        _data = .;
        *(.data)
        *(.data.*)
        _edata = .;
    } > RAM

.data 0x20040000が実行時アドレス(VMA)で、AT (_mdata)がロード時アドレス(LMA)だ。リンカは.dataの中身をフラッシュ側の_mdataに置き、シンボル解決はSRAM側の0x20040000で行う。この2つを繋ぐのは誰かというと、起動コードが自分でやる。

/* load data section from ROM to RAM */
    ldr    a3, .LD1        /* _mdata  : ロード時アドレス */
    ldr    a1, .LD2        /* _data   : 実行時アドレス */
    ldr    a2, .LD3        /* _edata  : 実行時アドレスの終端 */
    cmp    a2, a1
    beq    .next2
    .start:
    ldrb    a4, [a3]
    strb    a4, [a1]
    add     a3, a3, #1
    add     a1, a1, #1
    cmp     a1, a2
    bne     .start
    .next2:

.textのほうはコピーしない。フラッシュ上のコードをそのまま実行する(XIP)。コピーするものとしないものの線引きが、そのままリンカスクリプトの> SFLASH> RAMの割り振りになっている

実測: .mapが唯一の答え合わせ

git履歴に残っていた.mapから、実際の配置を表にした。計測時点は2026-02-27のビルド(SPIフラッシュ起動へ移行済み、MMU有効化と後述の.init_array修正はまだ入っていない状態)。

セクション 実行時アドレス ロード時アドレス サイズ
.boot 0x18000000 同左 0x3100(12,544 B)
.fvectors 0x18004000 同左 0x40(64 B)
.text 0x18004040 同左 0x23b88(146,312 B)
.rvectors 0x18027bc8 同左 0x92c(2,348 B)
.rodata 0x18028648 同左 0x7758(30,552 B)
.tors 0x1802ff70 同左 0x0(0 B)
.data 0x20020000 0x1802ff70 0x6ec(1,772 B)
NC_BSS 0x20020700 0x18030680 0x12c00(76,800 B)
.bss 0x20033300 0xf620(62,976 B)
.stack 0x20900000 0x0

読み取れることが3つある。

1. .dataだけロード時アドレスが違う。 実行時は0x20020000(SRAM)、ロード時は0x1802ff70(フラッシュ)。狙いどおり分離できている。

2. .torsが0バイト。 コンストラクタを集めるはずのセクションが空だった。これが第2部の山場になる。

3. NC_BSSが変なところにいて、しかもロード時アドレスを持っている。 これはVDC5が映像を書き込むフレームバッファで、Camera.cppでセクション属性を付けて確保している。

static uint8_t s_frameBufA[CAM_VIDEO_BUFFER_STRIDE * CAM_PIXEL_VW]
    __attribute__((section("NC_BSS"), aligned(32)));

160×120のYCbCr422を2面確保しているので160 × 2 × 120 × 2 = 76,800バイト、表のサイズとぴったり合う。この時点のリンカスクリプトにはNC_BSSを受け取る記述がなかったので、リンカが行き場のないセクション(オーファンセクション)として.dataの直後に置いた。しかもNOLOAD指定が無いのでロード対象になり、中身がゼロの76,800バイトがそのまま.binに載っていた

実際、この.binのサイズは275,072バイトだった。最後のロード対象セクションの終端0x18030680 + 0x12c00 = 0x18043280から先頭0x18000000を引くと0x43280 = 275,072で、ぴったり一致する。.binの28%がゼロ埋めのフレームバッファだったことになる。

対策として、現在のリンカスクリプトには専用の出力セクションを足してある。

    /* NonCacheable BSS (SRAM の NC ミラー 0x60020000 に配置) */
    .nc_bss (NOLOAD) :
    {
        __nc_bss_start = .;
        *(.NC_BSS*)
        . = ALIGN(32);
        __nc_bss_end = .;
    } > NC_RAM

ここで注意しておきたいことがある。ソース側のセクション名はNC_BSSで、リンカスクリプトの収集パターンは.NC_BSS*と先頭にドットが付いている。この2つが噛み合っているかは.mapを見ないと分からない。修正後の.mapはリポジトリに残っていない(HardwareDebug/をgit管理から外した)ので、自分もまだ確認できていない。リンカスクリプトを直したら必ず.mapを取り直す、というのがここでの教訓になる。

start.SがSRAMを組み立てる順番

リンカが決めた配置を実際に成立させるのはstart.Sだ。アドレスを決めるのがリンカスクリプト、そのとおりに初期化するのが起動コードという分担になっている。

起動シーケンス

生成されたstart.Sがやっているのは、上から順に以下の4つ。

  1. __bss_start____bss_end__を1バイトずつ0で埋める
  2. VFPを有効化する(FPEXC.EN = 1)。-mfloat-abi=hardでビルドしているので必須
  3. .dataをフラッシュからSRAMへコピーする(前述)
  4. _stackをスタックポインタに設定する

このうち4番だけは、生成されたままでは足りなかった。生成直後は_stackを1本のSPに入れるだけで、IRQモード用のSPが不定のまま残る。Cで書いた割り込みハンドラがローカル変数を1個使った瞬間に死ぬ。

/* initialise stack pointer */
     .Lstack:
     .word    _stack
     ldr    r11, .Lstack
     cmp    r11, #0
     beq    exit

     /* Initialize IRQ mode stack */
     msr    cpsr_c, #0xD2     /* IRQモードへ (IRQ/FIQ禁止) */
     mov    sp, r11           /* IRQスタックの上端 = _stack */
     sub    r11, r11, #0x1000 /* 4KB確保して、残りをSVCへ回す */

     /* Initialize SVC mode stack */
     msr    cpsr_c, #0xD3     /* SVCモードへ */
     mov    sp, r11

_stackはリンカスクリプトで0x20900000に固定してある。ここから下向きに4KBがIRQ用、その下がSVC(通常実行)用になる。

ひとつ補足しておくと、この0x20900000MEMORYブロックのRAMリージョン(0x20040000から0x004C0000、つまり0x20500000まで)の外側にある。それでもリンクが通るのは.stackセクションのサイズが0だからで、RZ/A1Hの内蔵SRAMは0x20000000から10MBあるので実アドレスとしては有効な範囲に収まっている。意図してこうしているならいいが、リージョン宣言だけを見てスタック位置を判断すると読み違える箇所ではある。

VBARを設定しないと例外で固まる

.fvectors(例外ベクタ表)は0x18004000にある。Cortex-A9は「ベクタ表がどこにあるか」をVBAR(Vector Base Address Register)で知るので、教えないと既定の0x00000000付近へ飛んでフリーズする。生成されたstart.Sにはこの2命令が無かった。

/* set vector table address to VBAR */
    ldr r0, =vector_table
    mcr p15, 0, r0, c12, c0, 0

vector_tablevect_table.S.fvectorsセクションの先頭に置いているラベルで、中身はLDR pc, =...が8本並ぶだけの表だ。

    .section .fvectors, "ax"
    .arm
vector_table:
    LDR pc, =_PowerON_Reset            @ +0x00 : Reset
    LDR pc, =INT_Excep_UndefinedInst   @ +0x04
    LDR pc, =INT_Excep_SWI             @ +0x08
    LDR pc, =INT_Excep_PREFETCH_ABORT  @ +0x0c
    LDR pc, =INT_Excep_DATA_ABORT      @ +0x10
    LDR pc, =INT_Excep_Reserved        @ +0x14
    LDR pc, =INT_Excep_IRQ             @ +0x18
    LDR pc, =INT_Excep_FIQ             @ +0x1c

.mapで見た.fvectorsのサイズは0x40(64バイト)だった。命令8本で32バイト、残り32バイトがリテラルプール(=で参照している飛び先アドレスの実体)になっている。

MMUを入れると地図が2つ増える

キャッシュを有効にするため、mbed OSのsystem_RZ_A1H.cを移植してSystemInit()を用意した。この時点でリンカスクリプトに2つのリージョンが増える。

MEMORY {
    BOOT_LOADER : ORIGIN = 0x18000000, LENGTH = 0x00004000
    SFLASH      : ORIGIN = 0x18004000, LENGTH = 0x07FFC000
    TTB_MEM     : ORIGIN = 0x20000000, LENGTH = 0x00004000  /* MMU 翻訳テーブル */
    NC_RAM      : ORIGIN = 0x60020000, LENGTH = 0x00020000  /* NC SRAM ミラー */
    RAM         : ORIGIN = 0x20040000, LENGTH = 0x004C0000  /* キャッシュ有効 SRAM */
}
  • TTB_MEM: MMUの翻訳テーブル。16KBかつ16KB境界に置く必要があるので、専用リージョンを切ってALIGN(0x4000)NOLOADで確保する。NOLOADにしておかないと16KBのゼロがフラッシュに載る
  • NC_RAM: 0x60020000。RZ/A1Hは同じ内蔵SRAMを0x20000000(キャッシュ有効)と0x60000000(非キャッシュ)の2つのアドレスから見られる。VDC5がDMAで書いた映像をCPUが古いキャッシュ経由で読まないよう、フレームバッファだけこちら側に置く
  • RAMの開始が0x20020000から0x20040000へ移っているのは、NC_RAMと物理的に重ならないようにするため。NC_RAM0x60020000から128KBなので、実体としては0x200200000x2003FFFFのSRAMを指している。キャッシュ有効側のRAMはその直後から始めないと、フレームバッファと通常の変数が同じSRAMを取り合うことになる

翻訳テーブルの中身はmmu_rzA1H.cで1MB単位のセクション記述子として作る。属性の割り当てはこうなった。

領域 サイズ 属性
0x18000000 SPI Flash I/O 0 64MB Normal, キャッシュ可, 実行可, RO
0x1C000000 SPI Flash I/O 1 64MB Normal, キャッシュ可, 実行可, RO
0x20000000 内蔵SRAM 10MB Normal, WB/WA, RW
0x3FE00000 SPIマルチI/O 1MB Device, RW
0x3FF00000 BSC 1MB Device, RW
0x60000000 SRAM NCミラー 1MB Normal, 非キャッシュ, RW
0xE8000000 ペリフェラルBASE0 3MB Device, RW
0xFCF00000 ペリフェラルBASE1 49MB Device, RW
上記以外 フォルト(未マップ)

SystemInit()の呼び出し順序にも制約がある。

void SystemInit(void)
{
    __FPU_Enable();              /* 1. VFPv3 有効化 */
    CPG.SYSCR3 = 0x0F;           /* 2. SRAM 全バンクの書き込み許可 */
    __set_TLBIALL(0);            /* 3. TLB 全無効化 */
    __set_BPIALL(0);             /* 4. 分岐予測配列を無効化 */
    __DSB(); __ISB();
    __set_ICIALLU(0);            /* 5. 命令キャッシュ無効化 */
    __DSB(); __ISB();
    L1C_InvalidateDCacheAll();   /* 6. データキャッシュ全無効化 */
    MMU_CreateTranslationTable();/* 7. 翻訳テーブル作成 */
    MMU_Enable();                /* 8. MMU 有効化 (SCTLR.M / SCTLR.AFE) */
    L1C_EnableCaches();          /* 9. L1 I-cache + D-cache 有効化 */
    L1C_EnableBTAC();            /* 10. BTAC 有効化 */
}

この関数の中でシリアル出力をしてはいけない。キャッシュとMMUの切り替え中にペリフェラルを触ると挙動が不定になる。初期化ログは戻ってから出す。mbed OSがやっていたRZ_A1_InitClock() / RZ_A1_InitBus()はブートローダが設定済みなので省いた。

山場: .init_arrayが捨てられていた

移行の中で一番わかりにくかったのがこれだった。

グローバルコンストラクタが1つも走らなかった経路

きっかけは、各モジュールを共通インターフェースIModuleに揃えて、調整値をConfig構造体でコンストラクタに注入するリファクタをしたことだった。このModule設計の考え方自体は別記事にまとめている(Arduino向けの解説だが規則は同じ)。

Servo        g_servo       (SERVO_CONFIG);
LineDetector g_lineDetector(LINE_DETECTOR_CONFIG);

構造としては綺麗になった。そしてサーボのPWM出力が0になり、ライン検出の閾値が全部0になった

真因は3つの条件が重なったところにあった。

  1. start.SHardwareSetup()の後にmain()を直接呼び、__libc_init_array()を呼ばない(第1部で見た-nostartfiles
  2. arm-none-eabi-gcc 13.3.1は、グローバルC++コンストラクタを.ctorsではなく.init_arrayのほうへ出力する
  3. e2 studio生成のリンカスクリプトは.ctorsしか収集していなかった

.mapにその全部が残っていた。

Discarded input sections
 ...
 .init_array    0x00000000        0x4 ./src/drivers/Camera.o
 .init_array    0x00000000        0x4 ./src/drivers/Serial.o
 .init_array    0x00000000        0x4 ./src/mcr_camera_2026base.o
 .init_array    0x00000000        0x4 .../libstdc++.a(eh_alloc.o)
 ...
.tors           0x1802ff70        0x0
                0x1802ff70                        __ctors = .
 *(.ctors)
                0x1802ff70                        __ctors_end = .

コンパイラは各オブジェクトに4バイトの.init_array(コンストラクタへの関数ポインタ1個)をちゃんと出している。それが、「捨てられた入力セクション」に並んでいる。リンカスクリプトに受け皿が無く、どこからも参照されていないので、--gc-sectionsが回収していった。結果として.ctorsだけを集める.torsは0バイトになる。

つまりグローバルコンストラクタが1つも実行されていない_configメンバはBSSのゼロのまま。サーボはPWM値0(ペリフェラルのロック)、LineDetectorは全閾値0(検出の全崩壊)になる。

厄介なのは、ベアメタル化した直後にはこの不具合が出なかったことだ。リファクタ前は各ドライバがstatic constしか持たず、コンストラクタの中身が空だったので、走らなくても症状が出ない。「動いていた」のではなく「壊れていることが観測できなかった」だけだった。

対策はリンカスクリプトに.init_arrayの収集を足し、main()の冒頭で手動で回すこと。

    .tors :
    {
        __CTOR_LIST__ = .;
        . = ALIGN(2);
        __ctors = .;
        *(.ctors)
        __ctors_end = .;
        __CTOR_END__ = .;

        /* GCC 13 用 .init_array 収集 (グローバル C++ ctor の実体) */
        . = ALIGN(4);
        PROVIDE(__init_array_start = .);
        KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*)))
        KEEP(*(.init_array))
        PROVIDE(__init_array_end = .);

        /* 対称性のため .fini_array も収集 (現状未使用だが定義しておく) */
        . = ALIGN(4);
        PROVIDE(__fini_array_start = .);
        KEEP(*(SORT_BY_INIT_PRIORITY(.fini_array.*)))
        KEEP(*(.fini_array))
        PROVIDE(__fini_array_end = .);

        /* ... .dtors ... */
        . = ALIGN(2);
        _mdata = .;
    } > SFLASH

KEEP()が要る理由は.boot_loaderと同じで、参照されないセクションを--gc-sectionsから守るためだ。SORT_BY_INIT_PRIORITYinit_priority属性付きのコンストラクタを優先度順に並べるためのもので、今回は使っていないが標準的な書き方に合わせてある。

extern "C" {
  typedef void (*ctor_fn)(void);
  extern ctor_fn __init_array_start[];
  extern ctor_fn __init_array_end[];
}

static void runGlobalConstructors(void)
{
  for (ctor_fn *p = __init_array_start; p < __init_array_end; ++p) {
    (*p)();
  }
}

int main(void)
{
  runGlobalConstructors();   // ← 何よりも先に呼ぶ
  SystemInit();
  /* ... */
}

これでビルドすると、今度はリンクエラーが出る。グローバルコンストラクタを有効にするとGCCが__dso_handle__cxa_atexit()のDSOハンドル)、_fini__cxa_atexitを参照しはじめるが、newlib stubsもダイナミックローダも無い環境ではどれも意味を持たない。リンクを通すためのダミーを置く。

extern "C" {
  void *__dso_handle = (void *)&__dso_handle;
  void _fini(void) {}
  int  __cxa_atexit(void (*)(void *), void *, void *) { return 0; }
}

これが「最新のツールチェーンに載せ替える」ことの本体だったと思う。コンパイラはコンストラクタを.init_arrayに出しているのに、リンカスクリプトは.ctorsだけを集める古い前提のまま。ビルドは通るしリンクエラーも出ない。警告も出ない。静かに何も初期化されないだけ。古い資産を新しいIDEに持っていくとき、一番に疑うべきはこの手の「無言で仕様が変わっている箇所」だと学んだ。

生成ディレクトリに手を入れるということ

ここまででgenerate/の4ファイルに手を入れたことになる。e2 studioでコード生成をやり直すと、これらは全部上書きされる

ファイル 手動修正の内容
linker_script.ld .init_array / .fini_arrayの収集、BOOT_LOADERTTB_MEMNC_RAMリージョン、.dataのLMA分離
start.S VBAR設定、IRQモード用スタック4KBの確保
inthandler.c INT_Excep_IRQ()のディスパッチャ実装、動的ハンドラテーブル
interrupt_handlers.h 個別ハンドラから__attribute__((interrupt))を外す

このうちinterrupt_handlers.hだけは機械的な置換で済むので、再適用スクリプトをリポジトリに置いてある。手修正の内容そのものより「再生成後にどう戻すか」を残すほうが効く

$content = $content.Replace("__attribute__((interrupt, used))", "__attribute__((used))")

# 上位の基本例外にだけ interrupt を戻す
$exceptions = "UndefinedInst", "SWI", "PREFETCH_ABORT", "DATA_ABORT", "IRQ", "FIQ"
foreach ($ex in $exceptions) {
    $content = $content.Replace(
        "void INT_Excep_${ex}(void) __attribute__((used));",
        "void INT_Excep_${ex}(void) __attribute__((interrupt, used));")
}

第3部: mbedが決めていた「順序」が消える

ビルドが通ってメモリ配置が固まっても、まだ動かない。ここからはmbedのブートコードが暗黙に決めていた初期化の順序を、1つずつ再発見する作業になる。前掲の起動シーケンス図の★が、実際に順序を間違えて壊した箇所だ。

割り込みが1回も来ない

INT_Excep_IRQ()が空関数

e2 studioが生成するgenerate/inthandler.cINT_Excep_IRQ()は、生成直後は中身が空だ。Cortex-A9のGIC(Generic Interrupt Controller)は、割り込み要因IDをICCIARから読んで、処理後にICCEOIRへEOIを書き戻すところまでソフト側の仕事になっている。空のままだとハンドラが呼ばれないだけでなく、GIC側の要求もクリアされない。

// VDC5 等を後から挿すための動的ハンドラテーブル
void (*g_irq_handlers[256])(uint32_t) = {0};

void INT_Excep_IRQ(void) __attribute__((interrupt("IRQ")));
void INT_Excep_IRQ(void) {
  volatile uint32_t *ICCIAR  = (volatile uint32_t *)0xE820200C;
  volatile uint32_t *ICCEOIR = (volatile uint32_t *)0xE8202010;

  // 割り込み要因を取得&GICへACK
  uint32_t irq = *ICCIAR;
  uint32_t id  = irq & 0x3FF;

  if (id < 256 && g_irq_handlers[id] != 0) {
    g_irq_handlers[id](0);
  } else if (id < 1020) {
    if (RelocatableVectors[id] != 0) {
      RelocatableVectors[id]();
    }
  }

  // GICへEOI (End of Interrupt) を通知
  *ICCEOIR = irq;
}

動的テーブルに載っていない割り込みはRelocatableVectors[id]にフォールバックする。これはvects.c.rvectorsセクションに置いている表で、.mapで見たサイズは0x92c(2,348バイト)、つまり587エントリだった。OSTM0はこちらの経路で、ID 134のエントリであるINT_Excep_OSTMI0()に届く。

ハンドラから戻る瞬間にクラッシュする

生成されるinterrupt_handlers.hは、個別のペリフェラルハンドラにも__attribute__((interrupt, used))を付けてくる。この属性が付いた関数を、ARM向けGCCは通常のbx lrではなく、割り込み専用の戻り命令subs pc, lr, #4でコンパイルする。

このプロジェクトでは最上位のINT_Excep_IRQ()がすでにその特殊な戻り処理を担っていて、INT_Excep_OSTMI0()などはただのC関数として呼ばれる。普通に呼ばれた関数が割り込み用の復帰をやるので、PCが不正なアドレスへ飛ぶ。

対策は、上位の基本例外(IRQ/FIQ/SWI/アボート系)だけinterruptを残し、個別ハンドラはusedだけにすること。前掲のPowerShellスクリプトがそれを再適用する。

周期が10倍ずれる

割り込みは来るようになった。が、点滅周期が合わない。1ms割り込みの前提でトグル間隔を組んだのに、LEDは10秒周期で光っていた。

トグル回数のほうを直しても合わず、「OSTM0のクロックが想定より10倍速いのだろう」と考えてコンペアマッチ値を10倍にした。当時のコメントがその判断を残している。

- // 1ms = 33333 カウント
- #define OSTM0_CMP_1MS 33333
+ // 1ms = 333333 カウント (実測で33333が0.1ms相当だったため10倍に変更)
+ #define OSTM0_CMP_1MS 333333

これは誤診だった。真因はタイマー稼働中にOSTMnCMPへ書いても、その値がハードウェアに無視されることだった。実際に効いていたのは自分が書いた値ではないので、観測した周期から逆算する推論はどちらへ転んでも当たらない。停止させてから書けば33333(P0φ=33.33MHzで1ms)で正しい。

OSTMnTEが0になるまで待つループを入れたら、今度はそれが無限ループになってまた止まった。最終的にはループを消し、initOSTM0()の中を「停止(OSTMnTT = 0x01)→ CMP書き込み → モード設定 → GIC設定 → カウント開始(OSTMnTS = 0x01)」の一方通行にすることで解決している。「止まっている状態でしかCMPを書かない」を待ちループではなく順序で保証するほうが素直だった。

この直後にもう2つある。

  • 割り込みが1回しか来ない: OSTMnCTL = 0x00はカウント開始時のみの単発動作。周期割り込みは0x01
  • エッジ割り込みを取りこぼす: GICの既定はレベルトリガだが、OSTM0はパルス状のエッジ割り込みを出す。ICDICFR8の該当2bitに0b10を書いてエッジトリガにする

GICのレジスタ添字はIRQ IDから機械的に決まるので、計算式ごとコメントに残しておくと後で効く。OSTM0はID 134なので、

ICDISERn / ICDICERn / ICDICPRn : 1 IRQ = 1 bit  → 添字 134/32 = 4,  ビット 134%32 = 6
ICDICFRn                       : 1 IRQ = 2 bit  → 添字 134/16 = 8,  シフト (134%16)*2 = 12
ICDIPRn / ICDIPTRn             : 1 IRQ = 8 bit  → 添字 134/4  = 33, シフト (134%4)*8 = 16

レジスタ幅とクロックソースで死ぬ

シリアルを実装したら、LED点滅を含む全部が止まった。原因はモジュールストップ解除の1行。

CPG.STBCR4 &= ~(1 << 14);   // ✗ STBCR4は8bitレジスタ。0xFFに切り詰められて解除されない
CPG.STBCR4 &= ~(1 << 5);    // ○ SCIF2はbit5

8bitレジスタに対してbit14のマスクを書いたので、~(1 << 14)(= 0xFFFFBFFF)が代入時に0xFFへ切り詰められ、どのビットも落ちない。モジュールストップが解除されないままSCIF2のレジスタを触り、バスエラーで全停止していた。レジスタ幅を確認せずにビット位置を書くと、コンパイラも実行時も何も言わずに死ぬ

ボーレートも1回外した。SCIFのボーレートジェネレータが使うのはP0φの33.33MHzではなくP1φの66.67MHzだ。取り違えると実効値が約2倍ずれて文字化けする。

$$ B = \frac{P1\phi}{8 \times (\mathtt{SCBRR} + 1)} = \frac{66666666}{8 \times 36} = 231481 \fallingdotseq 230400,\mathrm{bps} $$

SCEMR = 0x0081BGDM=1, ABCS=1で除算比8)、SCBRR = 35で誤差0.47%。ちなみにこれはmbedのserial_baud()が使うDL = (P1CLK + 4*baud) / (8*baud) - 1と同じ値になる。mbedのソースは移植先の答え合わせに使える、というのは移行中ずっと効いた考え方だった。

GICを有効化する順序

カメラ(VDC5/DVDEC)を実装したら、初期化完了のログを出した直後に完全にハングするようになった。

原因はGICをグローバルに有効化するタイミングだった。カメラ初期化中にVDC5割り込み(VSYNC/VFIELD, GIC ID 75〜97)がレベルトリガとして登録されるが、GICが未有効なので発火しない。WaitVsync()はタイムアウトで抜けている。その後にGICを有効化してCPSIE iを実行した瞬間、溜まっていたレベルトリガ割り込みが一斉に発火する。VDC5は優先度0x28でOSTM0の0x80より高いので、OSTM0を横取りし続けてCPUが割り込みから戻れなくなる。

対策はGICのグローバル有効化をinitGIC()に切り出し、カメラ初期化より前に実行すること。mbedではブートコードがGIC/IRQを先に有効化しているので、この問題自体が存在しない。

XIPだと割り込みの中でループを回せない

「タイマー開始」の後に何も出力されなくなる、という症状も出た。第1部と第2部で見たとおり、このプロジェクトはSPIフラッシュ上のコードを直接実行する。SPIBSCのプリフェッチバッファは32バイトしかなく、ループの後方分岐でバッファが無効化されるため、反復のたびにSPIフラッシュから命令を再フェッチすることになる。最適化が-O0なのも効いてくる。

その結果、imageCopy()の内側ループ(1回あたり9,600バイトのコピー)だけで推定7〜8msかかる。1ms割り込みの中で1msを超える処理をすると、コールバック実行中に次の割り込みが保留され、終了と同時に再発火する。CPUが100%割り込みに占有されてメインループが一切回らない。

同じ罠がカメラ初期化にもあった。6,000,000カウントのビジーウェイトを「約200ms」のつもりで置いていたら、XIP環境では実効2〜5MHz相当となり30秒以上かかっていた。ここはVideo_Startを先に呼んでVsync割り込みを出し、Vsyncを12回数える(NTSC 60Hz × 12 ≒ 200ms)方式に変えて、CPUの実行速度に依存しないようにした。

フレーム処理のほうは、1回の割り込みで1ステップだけ進める形に割った。

void Camera::updateInput(SystemData& sys)
{
  // 4ステップ完了後は、次のVfieldが来るまで即リターンする
  // (待ち時間がそのままメインループのCPU時間になる)
  if (frameStep_ > 3)
  {
    if (s_vfieldCount > 0) { s_vfieldCount = 0; frameStep_ = 0; frameReady_ = false; }
    else                   { return; }
  }

  switch (frameStep_++)
  {
  case 0: capturedField_ = (int)s_vfieldToggle; imageCopy(0); break; // 前半コピー
  case 1: imageCopy(1);                                       break; // 後半コピー
  case 2: extractBrightness(0);                               break; // 輝度抽出(前半)
  case 3: extractBrightness(1); frameReady_ = true; frameCount_++;   // 輝度抽出(後半)
          break;
  default: break;
  }
}

なおこの7〜8msは、L1命令キャッシュを有効化する前に見積もった値だ。キャッシュを入れた後も4ステップ分割が必要な状態のままなので制約は残っていると考えているが、実機で処理時間を測り直していない。ここは正直に未検証と書いておく。


検証: ホストで回すものと、実機でしか分からないもの

ベアメタルは実機に載せないと何も分からない、と思い込んでいたが、線を引けばかなりの部分がMacで回る

境界はシンプルで、レジスタを触らない層はホストでテストする。モーター・サーボ・エンコーダの計算部分と、初期化の戻り値契約がそれにあたる。レジスタ定義はtests/mock/iodefine.hにモックを置き、インクルードパスの優先順で差し替える。

include_directories(
  ${CMAKE_CURRENT_SOURCE_DIR}/mock   # モックレジスタを最優先
  ${CMAKE_CURRENT_SOURCE_DIR}/../src
)

実際に手元のMacで回した結果はこう。

$ cmake -S tests -B build && cmake --build build -j8
$ ./build/run_tests
[==========] 55 tests from 5 test suites ran. (0 ms total)
[  PASSED  ] 55 tests.

$ ctest --test-dir build
100% tests passed, 0 tests failed out of 55
Total Test time (real) =   0.21 sec

計測環境はmacOS 26.4(arm64)、Apple clang 17.0.0、CMake 4.3.3、GoogleTest v1.14.0。GoogleTestはFetchContentで取ってくるので、初回だけconfigureとビルドに時間を持っていかれる。

実機向けのビルドそのものも、同じヘッドレスビルドで確認できる。移行後のプロジェクトは今の e2 studio で通る。

e2studioc.exe --launcher.suppressErrors -nosplash ^
  -application org.eclipse.cdt.managedbuilder.core.headlessbuild ^
  -data <ワークスペース> -import <プロジェクト> ^
  -cleanBuild "mcr_camera_2026base/HardwareDebug"
Building target: mcr_camera_2026base.elf
arm-none-eabi-size --format=berkeley "mcr_camera_2026base.elf"
   text	   data	    bss	    dec	    hex	filename
 252976	  78616	3315064	3646656	 37a4c0	mcr_camera_2026base.elf

Build Finished. 0 errors, 28 warnings. (took 5s.646ms)

配布物のときと違って .elf が生成されているところが要点だ。前述のとおり、あちらは 0 errors でも成果物が出ない。ビルドの成否は終了コードやエラー件数ではなく、出力ファイルが在るかどうかで判定したほうがいい

残っている28件の警告は全部同じ種類で、generate/interrupt_handlers.h__attribute__((interrupt)) に対する FP registers might be clobbered despite 'interrupt' attribute だ。あとはリンカの LOAD segment with RWX permissions が1件。どちらも既知で、動作に影響していない。

ここに至るのにも一段階あった。最初はモーター1個のテストにカメラのHALが丸ごと必要でビルドできなかった。SystemData.hCameraData型のためだけにCamera.hをincludeし、その先のpinmap.hが実機のレジスタ定義(INTC)を要求していたからだ。ハードウェア非依存の宣言をCameraData.hへ切り出して解決した。ホストでテストしたいなら、データ型の宣言とハードウェアアクセスを別ファイルに割るところから始まる

逆に、ホストで検証できなかったものははっきりしている。

ホストで回せる 実機でしか分からない
走行ロジックの計算、PWMデューティの算出 割り込みの実周期、ISRの実行時間
初期化の戻り値契約(enabledの伝播) XIPのフェッチ遅延、キャッシュの効き
ライン検出の閾値計算 GICの発火順序と優先度の競合
リンカスクリプトが意図どおりに配置したか.mapは実機不要だがビルド環境が要る)

初期化の契約はこう書いてある。init()の戻り値をどこか1箇所でenabledに落とす、という約束をヘルパに閉じ込めた。

inline bool initModule(IModule& mod)
{
    const bool ok = mod.init();
    mod.enabled   = ok;
    return ok;
}

これを入れる前はmain()が全てのinit()の戻り値を捨てていて、enabled = falseを書く箇所が1つも存在しなかった。SD未挿入でもSDLogger::updateOutput()が毎周期空振りしていたし、カメラ未接続だとCamera::init()while(1);でそのままハングしていた。ハードウェアに一切触らないヘルパなので、この契約はホストのテストで検証できる。


ライセンスと注意事項

移行そのものより先に踏むと面倒なので、ここに固めておく。

公式配布物(プログラム・解説マニュアル)

配布プログラムの readme.txt と解説マニュアルの冒頭には、どちらも転載・複製には文書による事前の承諾が必要である旨が明記されている。マニュアルの著作権はジャパンマイコンカーラリー実行委員会、プログラムの配布元は株式会社日立ドキュメントソリューションズ。

つまり、配布ソースをそのまま自分のリポジトリに置いて公開するのは避けたほうがいい。この記事も、配布物のコードとマニュアル本文は一切載せていない。参照はファイル名・関数名・章番号にとどめてある。

移植の記録を公開したいなら、次のように切り分けるのが無難だ。

  • 公開してよい: 自分が書いたコード、自分が踏んだエラーメッセージ、.map の実測値、判断の理由
  • 公開を避ける: 配布ソースの中身、マニュアルの本文・図

Renesas 提供のドライバ(VDC5 / VDEC / DisplayBase)

カメラ取り込みに使っている src/drivers/video/ 以下は Renesas 提供のコードで、mbed-gr-libs 由来のものだ。各ファイルのヘッダに次の趣旨の制限が付いている。

本ソフトウェアは Renesas Electronics が提供するものであり、Renesas 製品との使用のみを意図している。それ以外の用途は許諾されない。

GR-PEACH は RZ/A1H を積んだ Renesas 製品なので、用途としては範囲内にある。mbed-gr-libs 自体も公開配布されているので、同じ条件のまま持ち回ること自体は通例の範囲だ。

ただし注意すべき点がひとつある。自作コードと同じディレクトリツリーに混ぜて置くと、どこからが他社のコードか分からなくなる。自分のリポジトリは78ファイルがこの区分に該当するのに、当初は README にも NOTICE にも何も書いていなかった。仕様書を読んだ人が全部自作だと誤解する状態になっていて、これは後から直した。

持ち込むなら最低限これだけはやっておきたい。

  • リポジトリのルートに NOTICE を置き、どのディレクトリが第三者提供かを書く
  • 元ファイルのライセンスヘッダを消さない(消すと出所が追えなくなる)
  • 自作のシム(mbed_assert.h など)には自作である旨をコメントで書く

mbed OS のライセンス

mbed OS 本体と mbed-gr-libs は Apache License 2.0 で配布されている。今回は mbed OS を丸ごと外したので最終的な成果物には残っていないが、移行の途中段階でリポジトリにコミットしてしまうと履歴には残る。パブリックリポジトリなら、その時点の条件を満たす必要がある。

実機を動かすときの注意

ソフト側の話ではないが、この移行の途中で一番危ないのはここだった。

  • モーターの配線を繋いだままビルドを試さない。 初期化順序を間違えている段階では PWM のデューティが不定になる。MTU2 がスタンバイのまま PWM レジスタへ書き込むとサーボが暴れる
  • サーボは可動範囲の中心を確認してから電源を入れる。 角度指令の符号が逆だと機構を突き当てて壊す
  • 走行させる前に、まずタイヤを浮かせた状態でモーターの回転方向を確認する

自分は初期化順序の問題(initOSTM0() を Motor/Servo の初期化より前に置くとサーボ挙動が異常になる)を、机上でサーボを鳴かせて気づいた。車体に載せる前で助かった類の話だ。

同じ移行をする人へのチェックリスト

順番に潰していけば、少なくとも自分が溶かした分は短縮できるはず。

着手前(配布物とライセンス)

  • 配布プロジェクトの.cprojectを開き、ビルド設定の識別子がilg.gnuarmeclipse.*com.renesas.*かを確認する(前者なら今のe2 studioでは通らない。インポートは成功するので油断しない)
  • ビルドの成否を出力ファイルの有無で判定する。エラー0件・終了コード0でも何も生成されていないことがある
  • エラーメッセージを犯人だと決める前に、動いている側でも同じ行が出ないか確かめる(対照実験)
  • 配布ソースの文字コードを確認する(CP932ならUTF-8へ変換してから読む)
  • 配布物のreadmeとマニュアルの利用条件を読む。転載・複製の可否は移行方法より先に決める
  • 流用する第三者提供のドライバを一覧化し、NOTICEに出所を書く
  • 元ファイルのライセンスヘッダを消していないか確認する

ビルド設定

  • e2 studio本体のバージョンを記録する。C:\Renesas\e2_studio\eclipse\.eclipseproductversion= を読む(プロジェクトファイルには残らない)
  • .cprojectのツールチェインID・バージョン・CPU/FPUオプションを表にして固定する
  • HardwareDebug/の生成makefilesubdir.mkを一度開いて、実際のコマンドラインを読む
  • インクルードパスが何本あるか確認する(既定はソースフォルダのみ。外部ライブラリを持ち込むなら足りない)
  • リンクオプションの-nostartfiles--gc-sectionsの存在を認識しておく
  • ソースのサブディレクトリを増やしたらsubdir.mk / sources.mk / makefileの更新が要る

メモリ配置

  • 生成されたリンカスクリプトが「全部RAM」になっていないか確認する
  • フラッシュ起動にするなら.dataAT()でLMA分離し、start.Sのコピーループと対応させる
  • Cから参照されないセクション(ブートローダ、ベクタ表、.init_array)はKEEP()で守る
  • リンカスクリプトが.init_arrayを収集しているか確認する(.ctorsだけでは足りない)
  • main()冒頭でグローバルctorを手動実行し、__dso_handle/_fini/__cxa_atexitのダミーを置く
  • section属性の名前とリンカスクリプトの収集パターンが一致しているか、.mapで確認する
  • 大きなバッファを置くセクションに(NOLOAD)が付いているか確認する(付いていないと.binが太る)
  • start.SでVBARを設定し、IRQモード用スタックを確保する
  • _stackの位置がMEMORYのリージョン内かどうかを確認する
  • リンカスクリプトを直したら必ず.mapを取り直して読む

順序と実行時

  • INT_Excep_IRQ()にICCIAR読み→ハンドラ呼び出し→ICCEOIR書き戻しを実装する
  • 個別ハンドラから__attribute__((interrupt))を外し、上位の基本例外にだけ残す
  • generate/への手修正は、再生成後に再適用するスクリプトとセットで残す
  • GICのグローバル有効化を、割り込みを出すペリフェラルの初期化より前に置く
  • 周期タイマーは停止中にCMPを書き、モードレジスタを単発ではなく周期に設定する
  • レジスタ幅を確認してからビット位置を書く(8bitレジスタにbit14は書けない)
  • ボーレート計算のクロックソースを確認する(RZ/A1HのSCIFはP1φ)
  • XIPならビジーウェイトのカウント値を信用しない。時間はハードウェアのイベントで測る
  • 1ms割り込みの中に1msを超える処理を置かない。超えるならステップ分割する
  • データ型の宣言とハードウェアアクセスをファイルごと分け、ホスト側テストを回せるようにする

実機に載せる前

  • モーターを接続したまま初期化順序の検証をしない(タイヤを浮かせるか結線を外す)
  • サーボの中心位置と可動範囲を、電源投入前に手で確認する
  • initOSTM0()をMotor/Servoの初期化より後に置く(先に置くとMTU2スタンバイ中にPWMを書いて暴れる)

まとめ

きっかけは前向きなものではなかった。公式配布のプロジェクトが古い e2 studio でないと通らず、ビルド定義の器ごと作り直すしかなかったというだけだ。ただ、どうせ土台を作るなら mbed OS も外してしまおう、と決めたのは結果的に正しかったと思う。1,100本を超える見えない土台の上でデバッグするより、自分で書いた分だけを疑えるほうが速い。

外してみて分かったのは、mbed OS への依存は思ったより薄かったことだ。カメラの VDC5 ドライバは3本のシムを置くだけでそのまま通ったし、モーターとサーボの PWM は配布物の時点で既にレジスタ直叩きだった。実際に自分で書き直したのは、1ms 周期割り込み・GPIO・シリアルと、その全部を支える起動処理とメモリ配置だけだった。

移行の作業量は「新しいIDEでプロジェクトを作る」ところにはほとんどなく、ビルド設定を1行ずつ確定させることと、メモリ配置を組み直すことに集中していた。そしてその2つは繋がっている。-nostartfiles--gc-sectionsというビルドオプション2つが、リンカスクリプトに.init_arrayの受け皿が無いという事実と噛み合って、グローバルコンストラクタが1つも走らない状態を作っていた。

診断についても同じことが言えた。ilg.gnuarmeclipse が解決できないというエラーは、自分が立てた仮説にあまりに形が似ていたので、そのまま原因だと信じてしまった。動いている側をビルドして同じ行が出るのを見るまで、疑いもしなかった。 証拠が仮説を裏づけているように見えるときほど、その証拠が「失敗しているときにしか出ない」ことを確かめたほうがいい。

.mapはその全部を最初から記録していた。.torsが0バイトであることも、.init_arrayが捨てられていることも、フレームバッファがフラッシュ側に76,800バイト載っていることも、ビルドした日から書いてあった。読んでいなかっただけだ。

古い資産を最新環境へ持っていくなら、「ビルドが通った」を到達点だと思わないほうがいい。次に見るべきは.mapで、そこには自分が意図した配置と実際の配置の差が全部出ている。