前回の第5回で、ポインタの正体は「型付きアドレス」だと腹落ちしました。宣言の *・演算子の *・キャスト・-> 演算子――正しい使い方はマスターしました。
今回はその裏側です。ポインタを間違えたとき、何が起きるのか?
HardFault・未定義動作(UB)・静かなメモリ破壊——「なんとなく動いていたのに、急に壊れた」「デバッガを動かしたら止まった」「リリースビルドだけ再現する」——組み込みあるあるの根っこには、大抵ポインタの事故が潜んでいます。
壊れ方を知ることが、最強の学習法です。 今回は地雷を「わざと」踏んで、デバッガで観察します。
📍 連載トップページ
✅ この記事でできるようになること
- NULLデリファレンスがHardFaultを引き起こす仕組みを説明できる
- ダングリングポインタが「静かに壊れる」理由を理解できる
- 「ローカル変数のアドレスを返す」がなぜ危険かをスタック図で説明できる
- 配列外アクセスがなぜコンパイルエラーにならないか理解できる
- C言語の未定義動作(UB)の概念と、最適化との関係を把握できる
- デバッガでHardFaultの発生箇所とFault Statusレジスタを読める
今回の実験環境とお約束
今回は「壊すコード」を書きます。いくつかの事故パターンは デバッグビルド(-O0)では再現せず、リリースビルド(-O2)だけで壊れる ものがあります。
ビルド構成の切り替え方(STM32CubeIDE)
Debug と Release の2つのビルド構成を持つのは、Eclipseベースの IDE(STM32CubeIDE もその一つ)では一般的な仕組みです。Visual Studio や Keil MDK など他のIDEも同様の概念を持っています。STM32CubeIDE ではデフォルトでこの2構成が用意されています。
デバッグ実行する構成の切り替え: ビルド後に 緑の虫アイコン▼ をクリックし、使いたい構成の .elf ファイルを選択します。
Debug/project.elf ← Debug ビルドの成果物(デバッガ使用時はこちら)
Release/project.elf ← Release ビルドの成果物
⚠️ 注意: Release ビルドは最適化により変数がレジスタに置かれることが多く、デバッガで変数ウォッチが正しく表示されない場合があります。「壊れる挙動」の確認には使えますが、ステップ実行はあてにならないことを覚えておきましょう。
最適化レベルで何が違うのか
「最適化」とはコンパイラが 「同じ動作をより速く・小さく」するためにコードを書き換えること です。レベルが上がるほど、書いたCコードと生成された機械語の対応が「別物」になっていきます。
各レベルの概要
| フラグ | CubeIDE表記 | 主な効果 |
|---|---|---|
-O0 |
Optimize for debug | 最適化なし。書いたCコードがほぼそのまま機械語になる |
-O1 |
Optimize | 副作用のない計算の削除、単純なインライン展開 |
-O2 |
Optimize more | ループ展開、関数のインライン化、不要な変数のレジスタ化 |
-O3 |
Optimize most | より積極的なインライン・ベクトル化(組み込みでは稀) |
-Os |
Optimize for size | コードサイズ優先(-O2 の一部を無効化) |
-O0 と -O2 で何が変わるか
具体的に見てみましょう。次の単純なCコードを例にします:
int loop_sum(void) {
int sum = 0;
for (int i = 0; i < 4; i++) {
sum += i;
}
return sum;
}
-O0(最適化なし)が生成する機械語のイメージ:
// for ループを忠実に機械語に変換する
// i を RAM に確保 → ループのたびに RAM を読み書き → 比較 → 分岐
mov r3, #0 // sum = 0
mov r2, #0 // i = 0
loop:
add r3, r3, r2 // sum += i
add r2, r2, #1 // i++
cmp r2, #4 // i < 4 ?
blt loop // 分岐
mov r0, r3 // return sum
-O2(最適化あり)が生成する機械語のイメージ:
// コンパイラが「sum は常に 6 になる」と計算済みで、
// ループそのものが丸ごと消える
mov r0, #6 // return 6(コンパイル時に計算済み)
ループが 1命令 に消えました。これが最適化の力です。
最適化がバグの出方を変える仕組み
ここが今回最も重要なポイントです。「デバッグビルドでは動くのに、リリースビルドで壊れる」という現象が生まれる理由を、2つのケースで理解しましょう。
ケース①:コンパイラが変数をメモリからレジスタに「引っ越し」させる
まず登場人物を整理します。今回の例は「タイマ割り込みが来たら flag を 1 にセットし、メインループはそれを待って処理を進める」という典型的な構造です。
// ── グローバル変数 ──────────────────────────────
uint32_t flag = 0; // 割り込みとメインループが共有するフラグ
// ── 割り込みハンドラ(タイマが一定時間ごとに自動で呼ぶ)──
void TIM2_IRQHandler(void) {
flag = 1; // ← ここで RAM 上の flag を 1 に書き換える
}
// ── メインループ ───────────────────────────────
int main(void) {
// ... 初期化 ...
while (flag == 0) {
// flag が 1 になるまでここで待つ(つもり)
}
// flag == 1 になったら処理を続ける
do_something();
}
これが -O0 と -O2 でどう違うかを、CPUの動きで見てみましょう。
まず、CPUには「レジスタ」という超高速の作業メモ帳があります(r0〜r12 など)。RAM より100倍以上速いですが、本数が少ない(十数個)です。コンパイラは「どの値をレジスタに持っておくか」を自分で決めます。
[Inside CPU] super fast [Outside CPU] slow, large
+---------------------+ +---------------------------+
| Registers | | RAM |
| r0 = 0 |<------>| 0x20000000: flag = 0 |
| r1 = ... | | 0x20000004: ... |
+---------------------+ +---------------------------+
CPU の作業メモ帳 実際にデータが置かれる場所
(少ない・超高速) (大容量・CPUより低速)
-O0 の場合(最適化なし):
while ループの 1 周ごとに:
1. RAM から flag を読み込む → r0 = RAM[flag] (= 0 か 1 か確認)
2. r0 == 0 ? → Yes ならループ継続
3. 割り込みが入り TIM2_IRQHandler が flag を 1 に書き換える
4. 次の周で RAM を読み直す → r0 = 1
5. r0 == 0 ? → No → ループを抜ける ✅
-O2 の場合(最適化あり):
コンパイラは while (flag == 0) のループ内を分析します。「このループの中には flag を書き換えるコードが一切ない」と判断し、次のように最適化します:
最初に1回だけ:
r0 = RAM[flag] (= 0)
以降ずっと:
r0 == 0 ? → Yes → ループ継続 ← RAM をもう見ない。r0 だけ見る
r0 == 0 ? → Yes → ループ継続
r0 == 0 ? → Yes → ループ継続
...(永遠に終わらない)
割り込みが入って RAM 上の flag が 1 になっても、メインループは RAM を見ていません。r0 は最初に読んだ 0 のまま。結果、無限ループになります。
-O0 ではループのたびに RAM を読み直すので正しく動きます。-O2 では RAM を読まなくなるので壊れます。これが「デバッグビルドでは動く」現象の正体です。
修正方法: flag に volatile を付けます。
volatile uint32_t flag = 0; // ← volatile を追加するだけ
volatile は「この変数はコンパイラが知らない外部要因(割り込みなど)で変わる可能性がある。必ず毎回メモリを読め」という命令です。これで -O2 でも毎回 RAM を読み直すようになります。
ケース②:コンパイラが「絶対に起きない」と判断した分岐を丸ごと削除する
int32_t x = INT32_MAX; // x = 2,147,483,647(最大値)
x = x + 1; // ① 符号付き整数のオーバーフロー → UB
if (x < 0) { // ② x は負になる?
error_handler();
}
コンパイラはC言語の仕様書に従って、次のように「推論」します:
C仕様:「符号付き整数はオーバーフローしない(と定義されている)」
↓
コンパイラ:「① の x + 1 はオーバーフローしない(なぜなら仕様上UBだから)」
↓
コンパイラ:「INT32_MAX + 1 がオーバーフローしないなら、結果は正の数のはず」
↓
コンパイラ:「② の x < 0 は絶対に false になる → この if ブランチは不要」
↓
コンパイラ:error_handler() の呼び出しを丸ごと削除
-O0 では「とりあえず書いた通りに実行」するため error_handler() が呼ばれます。しかし -O2 では、コンパイラが「論理的に不要」と判断してそのコードを消してしまいます。
同じCコードなのに、最適化レベルで動作が真逆になります。
💡 2つのケースに共通するメッセージ:
コンパイラは「正しく書かれたCコード」という前提で最適化します。
volatile なしで外部から変わる変数を使う・UBを含むコードを書く――これらはその前提を裏切る行為です。その結果、デバッグビルドでは動くのに、リリースビルドで壊れるという最も厄介なバグが生まれます。
各実験では次の条件を明示します:
| 記号 | 意味 |
|---|---|
| 💥 即クラッシュ | 実行するとほぼ確実にHardFaultや暴走が起きる |
| 🕵️ 静かに壊れる | 動いているように見えても、内部でデータが壊れている |
| 🎲 UB(未定義動作) | コンパイラ次第で結果が変わる、最も厄介なパターン |
実験コードはすべて main.c の main() 関数内か、独立した関数として記述します。STM32CubeIDEのデバッガ(ST-Link)で観察しながら進めましょう。
💥 事故1:NULLデリファレンス
何が起きるか
NULLポインタ(0x00000000 を指すポインタ)を間接参照しようとすると、STM32はハードウェアレベルで異常を検出し、HardFault例外 が発生します。
💬 HardFault例外とは?
Cortex-M4 CPUが「許可されていないメモリアクセス」や「不正な命令」を検出したとき、自動的に呼び出される緊急ハンドラです。PCのOSで言う「セグメンテーション違反(Segfault)」に近いものです。OSのないマイコンでは、デフォルトでHardFault_Handlerという無限ループに入り、プログラムが完全に止まります。
// ❌ パターン1:NULLポインタへの書き込み(環境によってはフォルトしないことがある)
uint32_t* ptr = NULL;
*ptr = 42;
// 💥 パターン2:NULLの関数ポインタを呼ぶ(確実にHardFaultになる)
void (*fn)(void) = NULL;
fn(); // アドレス 0 にジャンプ → 無効命令 → 確実にHardFault
なぜHardFaultになるのか
STM32(Cortex-M4)のメモリマップでは、アドレス 0x00000000 付近はフラッシュ(または未使用領域)です。アドレス 0 への書き込みは、CPUの MPU(Memory Protection Unit) またはバスフォルトによって弾かれます。
💬 MPU(Memory Protection Unit)とは?
「このアドレス範囲には書き込み禁止」というルールを CPU に設定できるハードウェア機能です。STM32F401 はMPUを搭載していますが、デフォルトでは無効です。MPUが無効でも、アドレス 0 番台への書き込みはバスエラー(BusFault)として検出されHardFaultに格上げされます。
Cortex-M4 フォルト発生の流れ:
ptr = 0x00000000
↓
*ptr = 42 // アドレス0に書き込み命令
↓
バス or MPUがアクセス違反を検出
↓
HardFault例外ハンドラへジャンプ
↓
デフォルトハンドラ: 無限ループ(while(1))
デバッガで観察する
main.c を開き、/* USER CODE BEGIN 2 */ と /* USER CODE END 2 */ の間に次のコードを追加します。この場所はCubeMXが再生成しても消えない「ユーザーコード保護ゾーン」です。
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
/* USER CODE BEGIN 2 */
/* 実験1:NULLデリファレンス(確実にHardFaultを起こす方法) */
void (*fn)(void) = NULL; // NULLの関数ポインタを作る
fn(); // ← この行にブレークポイントを置いてF6で1ステップ実行
/* USER CODE END 2 */
while (1)
{
}
}
💬 なぜ関数ポインタを使うのか?
*(uint32_t*)NULL = 値;という書き込みは、STM32F401 ではアドレス 0 がフラッシュの別名としてマップされているため、書き込みが無視されて HardFault が起きないことがあります。一方、NULL の関数ポインタを呼び出す(アドレス 0 へジャンプする)と、CPU が無効な命令を実行しようとして確実に HardFault が発生します。
デバッグ手順:
fn();の行にブレークポイントを置く(行番号左をダブルクリック → 青丸 ●)F11または虫アイコンでデバッグ実行- ブレークポイントで停止したら
F6(ステップオーバー)を1回押す - デバッガが自動的に
HardFault_Handlerに飛んで停止する
実行すると、STM32CubeIDEのデバッガが HardFault_Handler で停止します。
HardFault_Handler で自動停止した画面。左の「Debug」ビューでコールスタックが確認できる
HardFault が起きたことを確認する
💬 SCB レジスタで原因を読む:
デバッガ上部の 「Expressions」タブ → 「新しい式を追加する」 に次のアドレスを入力します。HardFault_Handler で止まっている間は値が赤くハイライトされます。*(uint32_t*)0xE000ED28 *(uint32_t*)0xE000ED2CNULL 関数ポインタ呼び出し(
fn())のとき、実際に表示される値はこうなります:
レジスタ アドレス 実測値(10進) 実測値(16進) 意味 CFSR 0xE000ED28131072 0x00020000INVSTATE:Thumb ではない命令を実行しようとした HFSR 0xE000ED2C1073741824 0x40000000FORCED:下位フォルトが HardFault に格上げされた ![]()
Expressions ビューで HardFault 発生時の SCB レジスタ値を確認。ピンクのハイライトが値が変化した行
💡 HardFault は「格上げ」されたエラーです:
HardFault は単独で発生するエラーではなく、多くの場合 BusFault / MemManage Fault / UsageFault が検出されて HardFault にエスカレーションされたものです。HFSR のFORCEDビット(0x40000000)がまさにそれを示しています——「下位フォルトが発生し、HardFault に格上げされた」という意味です。今回の INVSTATE は UsageFault の一種で、それが HardFault に格上げされています。
なぜ INVSTATE になるのか?
ARMプロセッサには、命令セット(CPUが解釈できる機械語の種類)が2種類あります。
モード 命令幅 特徴 ARM モード 32ビット固定 旧来の命令セット。高性能だがコードサイズが大きい Thumb モード 16/32ビット混在 ARM モードを圧縮した命令セット。省メモリで高効率 Cortex-M シリーズ(STM32 が使う CPU)は Thumb モードしか持っていません。ARM モードは削除されています。
CPU シリーズ 主な用途 ARM モード Thumb モード Cortex-M(STM32、nRF5xなど) マイコン・組み込み ❌ なし ✅ のみ Cortex-A(Raspberry Pi、スマホなど) Linux・高性能アプリ ✅ あり ✅ あり Cortex-R(車載・ストレージ制御など) リアルタイム高信頼 ✅ あり ✅ あり AVR(Arduino Uno の ATmega) マイコン — — (独自命令セット) Cortex-M はコードサイズと消費電力を最小化するため、意図的に ARM モードを省いています。その代わり Thumb-2 という拡張命令セットを持ち、32ビット命令も使えるので性能面のデメリットはほぼありません。
では、どうやって「今から Thumb で実行する」と CPU に伝えるのか? 関数ポインタのアドレスの最下位ビット(LSB)で区別します。
アドレス 0x08000001 → LSB = 1 → Thumb モードで実行(正常) アドレス 0x08000000 → LSB = 0 → ARM モードで実行(Cortex-M は非対応!)通常のコンパイラは関数ポインタの LSB を自動的に 1 にセットします。しかし
NULL(=0x00000000)は LSB が 0 のため、「ARM モードで実行しろ」という命令になります。Cortex-M は ARM モードを持たないため、INVSTATE(Invalid State)フォルトが発生し、HardFault に格上げされます。
⚠️ 実際の組み込み開発では
NULLチェックを省略したコードは爆弾を抱えたまま動いています。「動いている間は大丈夫」ですが、初期化前の関数ポインタや未設定のコールバックを呼び出した瞬間に爆発します。HALライブラリのコールバックは __weak 属性で空の関数が定義されているのは、この事故を防ぐ設計です。
防ぎ方
// ✅ ポインタを使う前に必ずNULLチェック
if (ptr != NULL) {
*ptr = 42;
}
// ✅ 初期化時にNULLで明示的に「未設定」を表す
typedef void (*callback_t)(void);
callback_t on_complete = NULL; // 意図的なNULL
// 登録前に呼ばない
if (on_complete != NULL) {
on_complete();
}
NULL って何者?
NULL は「どこも指していないポインタ」を表す目印です。実態は ただの 0 です。
// NULL を使った書き方
uint32_t* ptr = NULL;
// 実はこれと同じ意味
uint32_t* ptr = 0;
どちらも「ptr はどこも指していない」という状態を表します。
よくある使い方: ポインタを「まだ使えない状態」で初期化しておき、使う前に NULL チェックする。
uint32_t* ptr = NULL; // ← 「まだ何も指していない」と明示
// ... あとで ptr にアドレスを入れる処理 ...
if (ptr != NULL) {
*ptr = 42; // ← NULL チェックしてから使う
}
この習慣だけで、NULLデリファレンス事故の大半は防げます。
🕵️ 事故2:ダングリングポインタ
「宙ぶらりんポインタ」とは
ダングリングポインタ(dangling pointer)は、「かつて有効だったが、今は無効な場所を指しているポインタ」です。
💡 組み込みでは
mallocよりも「ローカル変数のアドレスを返す」パターンの方がよく踏みます。 次の事故3(スタック寿命)はまさにその典型で、ダングリングポインタの一種です。
最も典型的な例(PC向けコードの場合):
uint32_t* ptr = (uint32_t*)malloc(4); // メモリ確保
*ptr = 100;
free(ptr); // メモリ解放
// ↓ ptr はまだ同じアドレスを指している(ダングリング!)
*ptr = 200; // 🕵️ 解放済みのメモリに書き込む
静かに壊れる理由
free() した後も ptr の値(アドレス)は変わりません。書き込みはメモリ上のどこかに成功してしまいます。結果:
- 今すぐ壊れないこともある(解放後すぐに再確保されていない)
- 別の変数や別の malloc 管理構造が壊れる
- ずっと後になって謎のバグとして顕在化する
これが 「静かに壊れる」 理由です。
STM32での現実
組み込みでは malloc/free を使うことは少ないですが、同様のパターンが別の形で現れます:
💬 なぜベアメタル組み込みでは malloc/free を避けるのか?
① メモリの断片化(フラグメンテーション)が起きる:確保・解放を繰り返すと空き領域が細切れになり、大きなメモリが確保できなくなる。
② ヒープサイズが固定:マイコンの RAM は数十〜数百 KB しかなく、ヒープを使い切ると malloc が NULL を返す。
③ リアルタイム性が崩れる:malloc の実行時間は不定で、割り込み応答の遅延になる。 組み込みでは「必要なメモリをコンパイル時に確保し、動的に増減しない」設計が基本です。
// ❌ グローバルポインタがスタック上のローカル変数を指していた
static uint32_t* g_sensor_ptr;
void init_sensor(void) {
uint32_t local_val = 0; // ← スタックに置かれる
g_sensor_ptr = &local_val; // ← グローバルにアドレスを保存
} // ← ここで local_val の寿命が終わる(スタックフレーム消滅)
void read_sensor(void) {
uint32_t val = *g_sensor_ptr; // 🕵️ すでに無効なアドレスを読む
}
これは次の「スタック寿命」とも関係します。
🕵️ 事故3:スタック寿命(ローカル変数のアドレスを返す)
最も踏みやすい地雷のひとつ
試し方: main.c に関数を2つ書いて、/* USER CODE BEGIN 2 */ の中から呼び出すだけです。
/* ---- main.c の上部(USER CODE BEGIN 0 の中)に書く ---- */
// ❌ ローカル変数のアドレスを返す関数(わざと壊すコード)
uint32_t* get_value(void) {
uint32_t result = 42;
return &result; // ⚠️ result はこの関数が終わるとスタックから消える
}
/* ---- main() の中(USER CODE BEGIN 2)に書く ---- */
uint32_t* ptr = get_value(); // ptr は無効なアドレスを指している
uint32_t val = *ptr; // 🕵️ スタックの「ゴミ」を読む(42 が返るとは限らない)
ビルドすると GCC が警告を出します:
warning: function returns address of local variable [-Wreturn-local-addr]
デバッガでの確認手順:
uint32_t* ptr = get_value();の行にブレークポイントを置く- ステップ実行(F6)で
get_value()を呼び出した直後に止める - Variables ビューで
ptrの値(アドレス)を確認しておく - もう一度 F6 で
val = *ptrを実行する valの値が42ではない(ゴミが読める)ことを確認する
スタック図で見る
💬 スタックフレームとは?
関数が呼び出されるたびに、その関数専用のメモリ領域(ローカル変数・戻りアドレスなど)がスタックの先頭に積まれます。この領域を「スタックフレーム」と呼びます。関数がreturnすると、そのフレームは「不要」としてスタックから取り除かれ(SP が元に戻るだけで、データは消えずに残ります)、次の関数呼び出しで上書きされます。
【get_value() 実行中】
Stack (RAM) 説明
+----------------------+
| result = 42 | <-- get_value のフレーム(SP はここを指す)
| return address | SP = スタックポインタ
+----------------------+
| main_loop のフレーム |
+----------------------+
【get_value() が return した後】
Stack (RAM) 説明
+----------------------+
| 42 (残骸) | <-- フレームは「解放済み」だが値はまだ残っている
| ... | 次の関数呼び出しで上書きされる
+----------------------+ <-- SP はここに戻った
| main_loop のフレーム |
+----------------------+
ptr はまだ旧 result のアドレスを指している → これがダングリング!
関数が返った後、そのスタックフレームは「解放済み」です。次に別の関数が呼ばれると、同じ領域が上書きされます。
val が 42 ではなく 536969216(ゴミ値)になっている。get_value() のスタックフレームはすでに別のデータで上書きされた証拠
GCCの警告を活用する
GCC はこのパターンを検出できます:
warning: function returns address of local variable [-Wreturn-local-addr]
STM32CubeIDEのビルドオプションで -Wall -Wextra を有効にしておくと、このような警告がコンパイル時に出ます。警告は全部潰す という習慣が、事故を未然に防ぎます。
正しいパターン
// ✅ パターン1:静的変数を使う(寿命がプログラム全体)
uint32_t* get_value_safe(void) {
static uint32_t result = 42; // static → Flash/RAM の静的領域に置かれる
return &result; // ← 寿命はプログラム全体なので安全
}
// ✅ パターン2:呼び出し元のバッファに書く(最も組み込みらしい)
void get_value_ptr(uint32_t* out) {
*out = 42; // 呼び出し元のスタックに書く
}
// 呼び出し
uint32_t val;
get_value_ptr(&val);
⚠️ static の副作用:再入可能性(リエントラント)を失う
static 変数はプログラム全体で 1つだけ 存在します。同じ関数が 割り込みハンドラからも呼ばれる 構成では、result を2つのコンテキストが同時に触る「競合」が起きます:
// main ループで get_value_safe() を実行中に TIM 割り込みが入り、 // 割り込みハンドラ内でも get_value_safe() を呼ぶと… // → static result を同時に書き換える → 値が化ける
結論: 割り込みから呼ばれる可能性がある関数では パターン2(引数で受け渡し) を使うのが組み込みの鉄則です。static は「割り込みから絶対に呼ばれない」と確信できる場所にだけ使いましょう。リエントラント(再入可能)の問題は第9回の割り込みアンチパターンでさらに深く扱います。
💥 事故4:配列外アクセス
Cには境界チェックがない
uint32_t buf[4] = {10, 20, 30, 40};
// インデックス 0〜3 が有効
buf[4] = 99; // 💥 範囲外(buf[4]は存在しない)
buf[-1] = 99; // 💥 負のインデックス
💬
buf[4]と*(buf+4)は同じ:
C ではbuf[i]は*(buf + i)の省略形です。buf[4]は「buf の先頭アドレスから 4 要素分(= 16バイト)進んだ場所を読み書きする」という意味で、コンパイラは範囲が有効かどうかを確認しません。
PythonやJavaなら IndexError が出ます。CはOSがないマイコンでも動けるよう、実行時の境界チェックを省略 しています。これがCの強さであり、危険さです。
💬 境界チェックとは?
配列には「有効なインデックスの範囲」があります。uint32_t buf[4]なら0〜3が有効範囲で、それを超えた4や-1は無効です。この「有効範囲の端(境界)を超えていないか確認する処理」が境界チェックです。Python や Java はアクセスのたびに自動でこれを行い、超えていたらエラーにします。C はこのチェックを一切行いません——プログラマが自分で気をつける必要があります。
スタック上の変数を破壊する
💬 「スタック上」とは?
関数の中で宣言したローカル変数は、自動的にスタック(RAM の一領域)に置かれます。これを「スタック上に置かれる」と言います。staticを付けない普通のローカル変数はすべてこれです。スタック上の変数はメモリ上で隣り合って並ぶため、配列の境界を超えて書き込むと隣の変数まで上書きしてしまいます。void example(void) { uint32_t buf[4]; // スタック上に置かれる(ローカル変数) uint32_t canary; // buf のすぐ隣に置かれる(同じくローカル変数) }
配列がスタック上に置かれているとき、配列外への書き込みは 隣のスタック変数 や 戻りアドレス を破壊します:
💬 戻りアドレスとは?
関数を呼び出すとき、CPU は「呼び出し元のどこに戻ればよいか」をスタックに保存します。これが戻りアドレスです。returnしたとき、CPU はこのアドレスにジャンプします。配列外アクセスでここが書き換わると、return後に全く無関係なアドレスにジャンプして暴走します(PCのスタックオーバーフロー攻撃もこれを悪用します)。
試し方: /* USER CODE BEGIN 0 */ に関数を書いて、/* USER CODE BEGIN 2 */ から呼び出します。
/* ---- USER CODE BEGIN 0 に書く ---- */
void bad_function(void) {
uint32_t buf[4]; // スタック上に配置(4要素 = インデックス 0〜3 が有効)
uint32_t canary = 0xCAFEBABE; // buf のすぐ隣に配置される番人変数
for (int i = 0; i <= 4; i++) { // ← <= 4 が間違い!(< 4 が正しい)
buf[i] = 0xDEAD; // i=4 のとき、canary を上書きしてしまう
}
// canary の値を確認 → 0xCAFEBABE のまま? それとも 0xDEAD に変わった?
volatile uint32_t check = canary;
(void)check;
}
/* ---- USER CODE BEGIN 2 に書く ---- */
bad_function();
デバッガで観察する
bad_function() の中でブレークポイントを使って止め、Variables ビューと Memory ビューで変化を確認します。
注目すべきポイント:canary の値
ループ実行後、Variables ビューで canary を確認します。初期値 0xCAFEBABE のままなら buf の範囲内に収まっています。0 や別の値に変わっていたら、buf[4] の書き込みが canary を上書きした証拠です。
実際に観察した結果:
i = 4 ← ループが i=4 まで回った(本来は i<4 で止まるべき)
buf[0] = 57005 (= 0xDEAD)
buf[1] = 57005 (= 0xDEAD)
buf[2] = 57005 (= 0xDEAD)
buf[3] = 57005 (= 0xDEAD)
canary = 57005 (= 0xDEAD) ← 0xCAFEBABE から上書きされた!(事故の証拠)
canary が buf と同じ 0xDEAD に変わっています。buf[4] への書き込みが、隣にあった canary を上書きした証拠です。デバッガではピンクのハイライトで「値が変化した変数」が示されます。
canary がピンクでハイライト。0xCAFEBABE から 57005(0xDEAD)に書き換えられた——配列の範囲外書き込みが隣の変数を破壊した瞬間
何が起きているのか
ループが配列の範囲を1つ超えたせいで、隣にあった
canaryの値を勝手に書き換えてしまった。 コンパイルエラーも警告も出ない。
buf は要素が4つなので、有効なアドレスは buf[0]〜buf[3] の4つだけです。各要素は 4バイトなので、メモリ上はこう並んでいます:
アドレス 変数 ループ実行後の値
0x20017fd8 buf[0] 0xDEAD (i=0 で書いた)
0x20017fdc buf[1] 0xDEAD (i=1 で書いた)
0x20017fe0 buf[2] 0xDEAD (i=2 で書いた)
0x20017fe4 buf[3] 0xDEAD (i=3 で書いた)
0x20017fe8 canary 0xDEAD (i=4 で書いた) ← ここが問題!
↑
buf[4] と同じアドレス
buf[4] というのは 「buf の先頭から 4×4=16 バイト先のアドレス」 を指します。C はそこが canary の場所だろうと何だろうとお構いなしに書き込みます。結果として canary が 0xCAFEBABE から 0xDEAD に書き換わりました。
現実のコードでは canary の位置に重要な変数がある
今回は実験用の canary が壊れただけですが、実際のコードではその場所に:
- センサーの読み値
- 通信バッファ
- モーターの出力値
- 戻りアドレス(
return先のアドレス)
などが置かれています。これらが 0xDEAD に書き換わっても、コンパイルエラーも警告も出ません。「なぜかモーターが暴走する」「return したら HardFault になる」という原因追跡が非常に難しいバグになります。
Memory ビューでは buf の先頭アドレス(0x20017fd8)周辺に 0xDEAD(リトルエンディアンで AD DE 00 00)が並んでいるのが確認できます。
Memory ビューで 0xDEAD(ADDE0000)が buf の範囲を超えて書き込まれている
防ぎ方:配列サイズを定数で管理
#define BUF_SIZE 4
uint32_t buf[BUF_SIZE];
for (int i = 0; i < BUF_SIZE; i++) { // ← < BUF_SIZE(≦ではない!)
buf[i] = 0;
}
🎲 事故5:未定義動作(Undefined Behavior)
UBとは何か
C言語の仕様書には「 未定義動作(Undefined Behavior / UB)」という概念があります。
一言で言うと、 「C言語の仕様書が『このコードを実行したらどうなるか、知らない』と明言している操作」 のことです。
なぜそんなものがあるのか?
C言語はもともと「あらゆるCPUで動く」ことを目的に設計されました。CPUによってできること・できないことが違うため、「結果を保証できない操作」を仕様上 “未定義” にしておくことで、コンパイラが各CPUに最適な判断を下せるようにしています。
重要なのは、UBはエラーにならない点です。
普通のバグ:コンパイルエラーや実行時クラッシュが出る → すぐ気づける
UB :エラーが出ないこともある → 気づかないまま出荷される
UBが発生したコードは、コンパイラや最適化レベルによって結果がバラバラです:
| 状況 | 結果 |
|---|---|
-O0 でたまたま期待通りの動作 |
→ 「動いた!」と勘違いする |
-O2 でクラッシュ |
→ 「なぜリリースビルドだけ壊れる?」 |
-O2 で関連コードが丸ごと削除 |
→ 最も危険。エラーも出ず、処理がなかったことになる |
最後の「コードが丸ごと消える」ケースが、組み込みで最も危険なUBです。コンパイラは「UBが起きない前提」でコードを解析するため、「UBの後の処理は絶対に実行されない」と結論して削除することがあります。
代表的なUBパターン
UB-1:初期化されていないポインタの使用
uint32_t* ptr; // ← 初期化されていない(ゴミアドレスが入っている)
uint32_t val = *ptr; // 🎲 UB:何が起きるか全く予測できない
ptr にはスタック上の「ゴミ」が入っています。0x00000000(NULL)ならHardFaultで気づけますが、たまたま有効なアドレスに見える値が入っていると、どこか別のメモリを読み書きします。
UB-2:符号付き整数のオーバーフロー
int32_t x = INT32_MAX; // 2147483647(32ビット符号付き整数の最大値)
x = x + 1; // 🎲 UB:符号付き整数のオーバーフローはUB
💬 INT32_MAX とは?
<stdint.h>で定義されている定数で、int32_tが表現できる最大値2,147,483,647(約21億)です。これに 1 を足すと本来なら負の数になるはずですが、C 仕様ではその動作は「未定義」とされています。
💬 ラップアラウンドとは?
数値が上限を超えたとき、ぐるっと回って下限(0)に戻る動作のことです。符号なし整数(uint32_t)は C 仕様でラップアラウンドすることが保証されています(UINT32_MAX + 1 = 0)。一方、符号付き整数(int32_t)はラップアラウンドが保証されておらず、UB になります。
符号付き整数のオーバーフローはUBです(符号なし uint32_t はラップアラウンドするため UB にならない)。GCCは 「符号付き整数はオーバーフローしない」という前提で最適化 することがあります。
UB-3:volatileなし・最適化・ハードウェアレジスタ
これは第5回でも触れた重要なテーマです:
// ❌ volatile なしでレジスタを読む
uint32_t* reg = (uint32_t*)0x40020014; // GPIOA ODR
*reg = 0x01;
while (*reg != 0x00) { // ← コンパイラが「この条件は絶対に変わらない」と判断
// ... 何もしない ...
}
// → -O2 でコンパイルすると、ループが丸ごと削除される可能性がある
コンパイラは「自分のコードの中で *reg を書き換えている処理がない」と分析し、while の条件を 定数 false と判断してループを削除することがあります。volatile はこれを防ぎます。
💬 volatile とは?
「このメモリは、コンパイラが知らないタイミングで外部から変わることがある」とコンパイラに伝えるキーワードです。volatileを付けると、コンパイラはその変数をレジスタにキャッシュせず、アクセスのたびに必ずメモリを読み書きします。ハードウェアレジスタや割り込みで書き換わる変数には必ず付けます。
🎲 事故6:最適化による予期しない動作変化
💬 事故5との違い:
事故5は「C言語の仕様書が明確にUBと定める操作(符号付き整数のオーバーフロー・未初期化ポインタなど)」です。事故6は「C仕様上は正しいコードだが、volatileの欠如などにより、最適化によって意図と異なる動作になるパターン」です。どちらも「デバッグビルドでは動く・リリースビルドで壊れる」という同じ症状を示すため、セットで覚えておきましょう。
デバッグビルドは通るのにリリースビルドで壊れる
組み込み開発で最も厄介な「再現が難しいバグ」の多くは、最適化レベルの違いで発生します。
例:共有変数とvolatileの欠如(割り込みの伏線)
// ❌ 割り込みハンドラと main ループが共有する変数に volatile がない
uint32_t flag = 0; // volatile がない!
// 割り込みハンドラ(TIM2_IRQHandler など)
void TIM2_IRQHandler(void) {
flag = 1; // 割り込みでセット
}
// main ループ
while (flag == 0) {
// 何かを待っている
}
// → -O2 では flag を一度だけレジスタに読み込んで使い回す
// → 割り込みで flag が 1 になっても、レジスタにはまだ 0 が入っている
// → 無限ループになる
-O0 ではメモリから毎回読み直すので気づきません。-O2 にした途端、無限ループになります。
正しい書き方
// ✅ volatile を付けてコンパイラに「毎回メモリを読め」と指示
volatile uint32_t flag = 0;
volatile の詳細は第12回(最適化とアセンブラ)でアセンブラレベルで解説します。今は「割り込みやハードウェアが触る変数には volatile を付ける」と覚えておきましょう。
例:スタック破壊後の動作変化
void stack_corruption(void) {
uint32_t buf[4];
// -O0: buf と他の変数の間にパディングが入ることがある
// -O2: 最適化で変数が消えたり、レイアウトが変わる
// → 同じコードでも「壊れる変数」が変わる
buf[4] = 0xDEAD; // スタック破壊
}
最適化レベルによってスタックのレイアウトが変わるため、デバッグビルドで大丈夫だったのにリリースビルドで別の場所が壊れる という現象が起きます。
🔬 実践:壊すコード + デバッガで観察
実際に実験プロジェクトを作り、各事故パターンをデバッガで観察してみましょう。
プロジェクトの準備
STM32CubeIDEで新規プロジェクト(NUCLEO-F401RE)を作成し、main.c の main() 関数に実験コードを追加します。
重要: Optimization を -O0(デバッグビルド)に設定しておきます。
Project → Properties → C/C++ Build → Settings → Tool Settings → MCU GCC Compiler → Optimization
💬 ブレークポイントの置き方:
STM32CubeIDEのエディタで、止めたい行の行番号の左側をダブルクリックすると青い丸 ● が表示されます。これがブレークポイントです。デバッグ実行中(虫アイコン)にその行に来ると自動で停止します。その後F6で1行ずつステップ実行できます。
実験コード全体
実験は1つずつコメントアウトを外して実行してください。各実験の詳しい確認手順はそれぞれの事故セクションに書いてあります。
| 実験 | コメントアウトを外す箇所 | 確認すること |
|---|---|---|
| 実験1:NULLデリファレンス | fn の2行 |
HardFault_Handler でデバッガが止まる |
| 実験2:ダングリングポインタ | —(本回では省略) | 本回の事故2セクションで解説した内容で確認 |
| 実験3:スタック寿命 | そのまま有効 | Variables ビューで dummy_read がゴミ値 |
| 実験4:配列外アクセス | そのまま有効 | Variables ビューで canary が 0xCAFEBABE から変化 |
/* 実験用の関数 ※ 実際のプロダクトには絶対に含めないこと */
/* 実験3:ローカル変数のアドレスを返す(コンパイラ警告が出る) */
uint32_t* dangerous_get_ptr(void) {
uint32_t local = 0x12345678;
return &local; // ⚠️ warning: function returns address of local variable
}
/* 実験4:配列外アクセス(-O0 で実行すること。-O2 ではレイアウトが変わり結果が異なる)*/
void array_overflow_demo(void) {
uint32_t buf[4] = {0xAAAA, 0xBBBB, 0xCCCC, 0xDDDD};
uint32_t canary = 0xCAFEBABE; // buf のすぐ隣に配置される(可能性がある)
/* buf のアドレスをデバッガで確認してから実行すること */
buf[4] = 0xDEAD; // 境界外書き込み(= *(buf+4) への書き込み)
/* canary の値が変わっているか確認 */
(void)canary;
}
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
/* ---- 実験1:NULLデリファレンス ---- */
/*
void (*fn)(void) = NULL;
fn(); // → HardFault_Handler でデバッガが止まる
*/
/* ---- 実験3:スタック寿命 ---- */
uint32_t* stale_ptr = dangerous_get_ptr();
/*
* ここで stale_ptr は無効。
* デバッガで stale_ptr の指すアドレスと、
* その内容が "0x12345678" ではなくなっていることを確認
*/
volatile uint32_t dummy_read = *stale_ptr; // ゴミを読む
(void)dummy_read;
/* ---- 実験4:配列外アクセス ---- */
array_overflow_demo();
/* デバッガで canary の変化を確認 */
while (1) {
}
}
🛡️ 事故を防ぐための習慣まとめ
| 事故パターン | 予防策 |
|---|---|
| NULLデリファレンス | ポインタ使用前に != NULL チェック |
| ダングリングポインタ | free() 後は ptr = NULL で無効化 |
| スタック寿命切れ | ローカル変数のアドレスを返さない。static か引数で受け渡し |
| 配列外アクセス | < SIZE(≦ではない)。#define で定数管理 |
| 未定義動作 | -Wall -Wextra でコンパイル警告を全部潰す |
| 最適化による誤動作 | ハードウェアや割り込みが触る変数には volatile を付ける |
コンパイラ警告を味方にする
STM32CubeIDEのコンパイラオプションに以下を追加してください:
-Wall -Wextra -Wpointer-arith -Wstrict-prototypes
Project → Properties → C/C++ Build → Settings → MCU GCC Compiler → Miscellaneous → Other flags
これだけで、今回紹介した事故の多くが ビルド時に警告として検出 されます。
静的解析ツールも活用する
コンパイラ警告で検出できないパターンには、静的解析ツールが有効です。cppcheck は組み込みC向けの無料ツールで、ヌルポインタ参照・配列外アクセス・未初期化変数などをコンパイルせずに検出します。
cppcheck --enable=all --inconclusive src/
CI/CD に組み込めば、コードレビュー前に自動でチェックできます。
まとめ
今回は「壊れ方」を6種類体験しました:
- NULLデリファレンス → HardFault。
SCB->CFSRでフォルト種別が分かる - ダングリングポインタ → 静かに別の変数を壊す。free後は NULL 代入
- スタック寿命 → ローカル変数のアドレスを返すな。GCCが警告を出す
- 配列外アクセス → Cに境界チェックはない。
< SIZEを徹底する - 未定義動作 → コンパイラが「存在しないコード」にしてしまう
- 最適化での悪化 → デバッグで気づけないバグの温床。
volatileが鍵
「壊れ方を知る」ことで、バグを見たとき「あ、これはあのパターンだ」とピンとくるようになります。 この感覚が、つよつよエンジニアとの差です。
「デバッグビルドで動くコードは正しいコードではない」
-O0での動作はあくまで「最適化がないと動く」にすぎません。volatileの漏れや UB を含むコードは、リリースビルドや別のコンパイラで突然壊れます。動作確認はデバッグビルドだけで終わらせず、リリースビルドでも確認する習慣をつけましょう。
次回からは 「時間の世界」 に入ります。クロック・タイマ・実行時間の計測——組み込みは「空間」だけでなく「時間」も支配しなければなりません。
次回予告
⏱️ 第7回:時間の世界(計測文化の入り口)
「1秒待つ」ってCPUは何サイクル使っているのか? DWT CYCCNTで実行時間を測る方法を学び、「計測しないと議論できない」組み込みエンジニアの鉄則を身につけます。