「Arduinoはわかるけど、なぜ動くのかが分からない」——そんな壁にぶつかった方向けの連載があります。
NUCLEO-F401RE + STM32CubeIDE を使い、レジスタを直接叩きながら「アドレス・ビット・ポインタ」の世界観を体得していく全13回連載のPhase 0〜1(第0回〜第6回)のポイントをまとめました。
この連載が目指すもの
「なんとなく動く」から卒業し、「なぜ動くか」を理解する
扱う技術:
- レジスタ直接操作(CMSISレベル)
- ポインタ=型付きアドレスという本質理解
- デバッガでメモリを覗く実践習慣
- 壊すコードで学ぶ方法論
HALは「土台だけ」。実際の操作は原則レジスタ直叩きです。なぜなら、レジスタを触ることでアドレス・ビット・時間の感覚が身につくからです。
必要なもの
| 機材 | 説明 | 費用目安 |
|---|---|---|
| NUCLEO-F401RE | ST-Link内蔵、84MHz Cortex-M4 | 約1,500〜2,000円 |
| STM32CubeIDE | 無料、Windows/Mac/Linux対応 | 無料 |
| ジャンパ線・LED・抵抗 | 手持ちで可 | 〜数百円 |
第0回:なぜ組み込みは難しく見えるのか
組み込みの難しさは「前提の違い」だけです。
普段のPC向けプログラミングはOSが様々なことを守ってくれます:
- 仮想メモリ(物理アドレスを隠蔽)
- メモリ保護(不正アクセスを検出)
- マルチタスク(時間分割で並列動作)
組み込み(ベアメタル)ではOSがいない。このシンプルな違いが全てです。
理解の2軸
組み込みプログラミングは、次の2軸で整理できます:
軸1:メモリという「場所」を意識する
// このメモリの場所に値を書くと...
#define GPIOA_BSRR (*(volatile uint32_t*)(0x40020000 + 0x18))
GPIOA_BSRR = (1 << 5); // LEDが光る!
普通の変数への代入と見た目は同じですが、電気信号が変化します。
軸2:実行の「時間」を管理する
volatile uint32_t tick_count = 0;
void TIM2_IRQHandler(void) {
tick_count++; // メインループを中断して実行される
TIM2->SR &= ~TIM_SR_UIF;
}
「いつ」「どの順番で」処理が実行されるかを意識しないと、データ競合が起きます。
難しいのではなく、「OSという保護の層がないだけ」。逆に言えば、何が起きているかが直接見える。
第1回:マイコンは"アドレスの世界"
キーワード:「すべてはアドレス」
STM32のアドレス空間はシンプルです:
| アドレス範囲 | 領域 | 用途 |
|---|---|---|
0x0800_0000〜 |
Flash | プログラムコード・定数(不揮発) |
0x2000_0000〜 |
SRAM | 変数・動的データ(揮発) |
0x4000_0000〜 |
周辺機器レジスタ | GPIO・UART・タイマー制御 |
周辺機器の制御もただの「メモリ書き込み」です(メモリマップドI/O):
// 0x40020014番地に値を書くと、GPIOAの5番ピンがHIGHになる
*(uint32_t*)0x40020014 = 0x0020;
実践:デバッガで覗く
STM32CubeIDEのデバッガでできること:
-
Registers ビュー:PC(プログラムカウンタ)がFlashアドレス(
0x0800xxxx)を指している様子 - Memory ビュー:変数の値をバイト単位で直接観察
- SFRs ビュー:GPIO/UART等の周辺機器レジスタをビット単位で監視・書き換え
CPUの動作は「フェッチ→デコード→エグゼキュート」の繰り返し。PCが指すアドレスから命令を読み、実行し、PCを進める——これだけです。
デバッガでPCの値を見れば、今CPUがどこにいるか一目瞭然。この習慣が後のデバッグ力を大きく左右します。
第2回:変数が住む場所を見つける(Flash / RAM / Stack)
変数の宣言方法によって配置先が決まります:
| 宣言方法 | セクション | 配置先 | 寿命 |
|---|---|---|---|
const 型 変数名 |
.rodata | Flash | 永続 |
| グローバル変数(初期値あり) | .data | Flash → SRAM | 永続 |
| グローバル変数(初期値なし) | .bss | SRAM | 永続(ゼロ初期化) |
| ローカル変数 | Stack | SRAM(高位) | 関数実行中のみ |
.data と .bss の違いは重要
uint32_t flag = 0x1234; // .data → Flash に初期値保存 → SRAM にコピー
uint32_t buffer[256]; // .bss → Flash は 0バイト使用 → SRAM でゼロクリア
大きなバッファは初期値なしで宣言すると、Flash容量を節約できます。
スタックは下に向かって伸びる
再帰関数 stack_test_function(3) を呼んだときの実測値:
呼び出し階層 depth &stack_local
1回目 3 0x20017fe4 (基準)
2回目 2 0x20017fcc (-24バイト)
3回目 1 0x20017fb4 (-24バイト)
4回目 0 0x20017f9c (-24バイト)
1回の関数呼び出しで24バイト消費している理由:
- ローカル変数(4バイト)
- 戻りアドレス(4バイト)
- レジスタ退避領域(16バイト)
スタックが増え続けると .bss/.data 領域と衝突します——これがスタックオーバーフローの正体です。
第3回:C言語はメモリをどう表現するか(配列・構造体・volatile)
配列 = 連続したメモリ
uint8_t arr[4] = {0xAA, 0xBB, 0xCC, 0xDD};
// Memoryビューで確認すると:
// アドレス+0: AA
// アドレス+1: BB
// アドレス+2: CC
// アドレス+3: DD
配列の先頭アドレスに要素サイズを掛けたオフセットで任意の要素にアクセスできる——これがポインタ演算の基礎です。
構造体 = パディングが入る
アライメント制約(CPUが効率よく読めるよう、型のサイズの倍数アドレスに配置する)により、隙間が入ります:
struct BadLayout {
uint8_t a; // 1バイト
// ← 3バイトのパディング(uint32_tを4バイト境界に合わせるため)
uint32_t b; // 4バイト
uint8_t c; // 1バイト
// ← 3バイトのパディング
};
// sizeof(BadLayout) = 12バイト(実データは6バイトなのに!)
struct GoodLayout {
uint32_t b; // 4バイト
uint8_t a; // 1バイト
uint8_t c; // 1バイト
// ← 2バイトのパディング
};
// sizeof(GoodLayout) = 8バイト ← メンバ並び替えで節約
大きい型から小さい型の順に並べると、パディングを最小化できます。
volatile は「コンパイラへの命令」
// volatile なし
uint32_t flag = 0;
while(flag == 0) {} // コンパイラ「flagは変わらない」と判断 → 無限ループに最適化される危険
// volatile あり
volatile uint32_t flag = 0;
while(flag == 0) {} // 毎回メモリから読むことを保証
volatile が必要な場面:
- 割り込みハンドラとメインループで共有する変数
- ハードウェアレジスタへのアクセス
- DMAが書き換える変数
第4回:ビット操作の作法とBSRRの思想
「レジスタはビット集合」——これが組み込みの核心です。
マスク・シフトの基本
特定のビットだけを安全に変更するには:
// PA5を出力モード(01b)に設定する
// MODER レジスタのbit[11:10] を変更
// ❌ 危険:他のビットを破壊する可能性
GPIOA->MODER = 0x00000400;
// ✅ 安全:対象ビットだけを変更(Read-Modify-Write)
GPIOA->MODER &= ~(0x3UL << (5 * 2)); // クリア
GPIOA->MODER |= (0x1UL << (5 * 2)); // セット
RMW問題
Read-Modify-Write(読んで・変えて・書く)は割り込み間でデータ競合が起きる可能性があります:
// ❌ ODRに直接書くと、読み書きの間に割り込みが入ると他ビットを破壊する
GPIOA->ODR |= (1 << 5); // Read → 割り込み → Modify → Write の隙間がある
BSRRの思想(STM32の美しい設計)
STM32のGPIOにはBSRR(Bit Set/Reset Register) というアトミック操作専用レジスタがあります:
// BSRR: 上位16bit = リセット、下位16bit = セット
// 書き込みのみ有効 → 読み返す必要がない → アトミック操作を保証
GPIOA->BSRR = (1UL << 5); // PA5 を HIGH(アトミック)
GPIOA->BSRR = (1UL << (5 + 16)); // PA5 を LOW(アトミック)
「読まずに書ける設計」がRMW問題を根本的に回避します。
実測:HAL vs レジスタ直叩きの速度差
DWT CYCCNTによる計測結果(84MHz / -O0):
| 方法 | サイクル数 | 実時間換算 |
|---|---|---|
HAL_GPIO_WritePin() |
約20〜30サイクル | 〜350ns |
GPIOA->BSRR = ... |
1〜2サイクル | 〜12ns |
リアルタイム制御では、この差が重要です。
第5回:ポインタ=型付きアドレス
ポインタは怖くない。「型付きアドレス」というだけです。
3つの記号を分解して理解する
uint32_t x = 100; // 変数宣言(値)
uint32_t *ptr = &x; // ポインタ変数宣言(アドレスを入れる箱)
// &x = xのアドレスを取得
*ptr = 200; // 間接参照(そのアドレスの中身を読み書き)
重要:宣言の * と演算子の * は意味が違います。
キャスト式の読み方
*(uint32_t*)0x40020018 = (1UL << 5);
これを内側から読む:
-
0x40020018← アドレス値(数値) -
(uint32_t*)← 「このアドレスを uint32_t へのポインタとして扱う」とコンパイラに宣言 -
*← そのアドレスの中身に書き込む
これで GPIOA->BSRR に直接値を書き込んでいます。
CMSISヘッダが何をしているか
// stm32f4xx.h の中(簡略化)
typedef struct {
volatile uint32_t MODER; // offset: 0x00
volatile uint32_t OTYPER; // offset: 0x04
volatile uint32_t OSPEEDR; // offset: 0x08
volatile uint32_t PUPDR; // offset: 0x0C
volatile uint32_t IDR; // offset: 0x10
volatile uint32_t ODR; // offset: 0x14
volatile uint32_t BSRR; // offset: 0x18
// ...
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *) 0x40020000UL)
GPIOA->BSRR は *(uint32_t*)(0x40020000 + 0x18) と完全に等価です。CMSISヘッダは「ポインタと構造体でアドレスを人間に読みやすくラップしたもの」です。
第6回:ポインタ事故大全(壊し方を知って強くなる)
「なぜ壊れるか」を知ることが最強の防衛策です。
事故1:NULLデリファレンス → HardFault
uint32_t *ptr = NULL;
*ptr = 100; // ← HardFaultが発生
STM32ではアドレス0への書き込みがHardFaultを引き起こします。デバッガで止まった場所と Fault Status レジスタ(CFSR) を確認することで、どの種類のFaultか判別できます。
事故2:ダングリングポインタ(静かに壊れる)
uint32_t* get_ptr(void) {
uint32_t local = 100;
return &local; // ← 関数が終わるとlocalはスタックから解放される
}
uint32_t *p = get_ptr();
*p = 200; // ← スタックの「跡地」を書き換えている。静かにメモリを破壊
関数が戻った後もそのアドレスは存在しますが、次の関数呼び出しで上書きされます。コンパイルは通る、HardFaultも起きない——最も発見しにくい事故です。
事故3:配列外アクセス
uint8_t buf[4] = {0};
buf[10] = 0xFF; // コンパイルエラーにならない!隣の変数を破壊する
Cは配列の境界チェックをしません。デバッグビルドとリリースビルドで挙動が変わることもあります。
事故4:未定義動作(UB)と最適化の罠
// signed int のオーバーフローはUB(コンパイラが「起きない」と仮定して最適化)
int x = INT_MAX;
x++; // UB → コンパイラが前提を変えて最適化する可能性
デバッグビルド(-O0)では再現しないが、リリースビルド(-O2)でだけ壊れる——こういったバグの根っこに大抵UBがあります。
事故を防ぐ習慣まとめ
// 1. ポインタは必ず初期化
uint32_t *ptr = NULL;
// 2. ポインタ使用前にNULLチェック
if (ptr != NULL) { *ptr = value; }
// 3. ローカル変数のアドレスを返さない
// 4. 配列アクセス前にサイズチェック
if (index < ARRAY_SIZE) { arr[index] = val; }
// 5. 割り込みと共有する変数にはvolatile
volatile uint32_t shared_flag = 0;
Phase 1(第1〜6回)の全体像
Phase 1: 空間の世界
第1回 アドレスの世界
└─ CPUは状態機械。すべてはアドレス
└─ 第2回 変数の住所
└─ Flash(.rodata/.data) / SRAM(.bss/Stack)
└─ 第3回 C言語のメモリ表現
└─ 配列=連続 / 構造体=パディング / volatile
└─ 第4回 ビット操作
└─ マスク/シフト / RMW問題 / BSRR
└─ 第5回 ポインタ
└─ 型付きアドレス / キャスト / CMSIS解読
└─ 第6回 ポインタ事故
└─ NULL / ダングリング / UB / 最適化
すべてが「アドレス」という1本の軸で繋がっています。
次のPhase:時間の世界(第7回〜)
Phase 1で「空間(メモリ)」を制したら、Phase 2は**「時間」**です:
-
第7回:DWT CYCCNTによる実行時間計測(計測しないと議論できない)
https://electwork.net/posts/stm32-episode07/ -
第8回:割り込みの仕組み(NVIC・ベクタテーブル・コンテキスト保存)
https://electwork.net/posts/stm32-episode08/ - 第9回:割り込み設計アンチパターン集(ISRでprintfするな、など)
おわりに
各回は「実際にデバッガで観察する」実践形式で、コードを動かすだけでなく**「なぜそう動くのか」を実測値で確認**することを重視しています。
本記事で触れたコードやデバッガ操作の詳細(スクリーンショット付き)は、各回の記事で丁寧に解説しています。
連載トップページと各回の記事はこちらです:
| 回 | タイトル | URL |
|---|---|---|
| 第0回 | なぜ組み込みは難しく見えるのか | https://electwork.net/posts/stm32-episode00/ |
| 第1回 | マイコンは"アドレスの世界" | https://electwork.net/posts/stm32-episode01/ |
| 第2回 | 変数が住む場所を見つける | https://electwork.net/posts/stm32-episode02/ |
| 第3回 | Cはメモリをどう表現するか | https://electwork.net/posts/stm32-episode03/ |
| 第4回 | ビットの世界(レジスタ操作) | https://electwork.net/posts/stm32-episode04/ |
| 第5回 | ポインタ=住所(型付きアドレス) | https://electwork.net/posts/stm32-episode05/ |
| 第6回 | ポインタ事故大全 | https://electwork.net/posts/stm32-episode06/ |