組み込み開発において、C言語が長年主役であり続ける最大の理由は、「メモリ(物理的な場所)とプログラムの記述が、嘘偽りなく直結しているから」 です。今回は、C言語の構文がメモリ上でどう具体化されるかを解読し、ハードウェアを支配するための基礎を固めます。
前回の第2回では、変数の宣言方法によってデータがどこに配置されるか(Flash、SRAM、Stack)を学びました。今回は、そのメモリ上でデータがどのように並んでいるかに焦点を当てます。
📍 連載トップページ
1. 組み込みにC言語が選ばれる理由:変数 = メモリ
PCでのプログラミングでは、変数は単なる「データの入れ物」という抽象的な存在ですが、組み込みでは 「メモリ上の特定の座標そのもの」 です。
1-1. C言語が組み込みに最適な理由
C言語が組み込み開発で圧倒的に選ばれているのは、以下の特性があるからです:
- メモリ配置の透明性: コードの記述順とメモリへの並び順が一致し、予測可能である。
- 低レイヤへのアクセス: ポインタを使用して、特定の番地を直接読み書きできる。
- オーバーヘッドの少なさ: 高級言語でありながら、CPUの命令と1対1に近い形で対応している。
この「物理的な実体感」を理解することが、ハードウェアを直接操るエンジニアへの第一歩となります。
1-2. 他の言語との比較
比較のため、他の言語と比べてC言語の特性を見てみましょう:
| 言語 | メモリの透明性 | ハードウェアアクセス | 実行速度 | 組み込み向き |
|---|---|---|---|---|
| C言語 | ◎ 完全に制御可能 | ◎ 直接アクセス可能 | ◎ 高速 | ◎ 最適 |
| C++ | ○ 制御可能(抽象化あり) | ○ 可能(複雑) | ○ やや遅い | ○ 可能 |
| Rust | ○ 制御可能(所有権で安全性重視) | ○ 可能(unsafeで低レイヤ操作) |
◎ 高速 | ○ 採用が増加中 |
| Python | × 抽象化されている | × 困難 | △ 遅い | × 不向き |
| アセンブリ | ◎ 完全制御 | ◎ 直接制御 | ◎ 最速 | △ 記述が煩雑 |
C言語は、「ハードウェアに近い」と「書きやすい」の絶妙なバランスを実現しています。
補足:Rustが組み込みで注目される理由
Rustはno_std環境で動作でき、所有権・借用の仕組みによりメモリ安全性を高められます。一方で、レジスタの直接操作や割り込み処理ではunsafeが必要になる場面もあり、低レイヤの理解は引き続き重要です。導入時の学習コストは高めですが、安全性を重視する現場で採用が増えています。 興味があれば、実際にSTM32F401でRustを使ってLチカとRTT出力を試した記事もどうぞ:Rustを使ったマイコン開発!STM32F401でLチカとRTT出力をやってみた
補足:C++が組み込みで使われるケース
近年、一部の高性能マイコンではC++も使われます。ただし、仮想関数や例外処理などの機能は、実行時のオーバーヘッドが大きいため、組み込みでは慎重に使う必要があります。C言語のコア機能に、C++のクラス機能だけを追加する「C with Classes」というスタイルが一般的です。
2. 実践:Memoryビューで「C言語の裏側」を先に見る
この章では、まず先に実験コードを動かしてメモリを観察します。そのうえで次章以降で「なぜそう並ぶのか」を解き明かす流れにします。
先に用語だけ:パディングとは?
パディングは、構造体の途中や末尾にコンパイラが自動で入れる「調整用の余白バイト」です。
目的は、CPUが読み書きしやすい位置(アライメント)にデータをそろえることです。
今回のMemoryビューでは、00として見える部分がその例です(値そのものに意味はありません)。
この章の進め方(3ステップ)
- 2-2で「同じ表示を出す」
- 2-3で「AA BB CC DD / 00の並び」を見る
- 3章以降で「なぜそうなるか」を理解する
最初は意味を完璧に理解しなくてOKです。まずは「同じ表示を再現できる」ことを目標にしましょう。
2-1. 実験用コードの準備
この実験では STM32F401系(本記事ではSTM32F401RE) を使用します。
まず、以下の状態まで準備してから進めてください:
- STM32CubeIDEでプロジェクトをビルドする
- マイコン(STM32F401)へ書き込む(Flash)
- デバッグモードで起動する(F11 または Debug)
以降の手順は、「書き込み済みで、デバッグモードで停止している状態」を前提に説明します。
以下のコードを main.c に追加して、実際にメモリレイアウトを観察してみましょう:
/* USER CODE BEGIN PTD */
// メモリの並びを観察するための構造体
typedef struct {
uint8_t id; // 1バイト
// ここに3バイトの「隙間(パディング)」ができるはず
uint32_t value; // 4バイト
uint8_t flag; // 1バイト
// ここにも3バイトの「隙間」ができるはず
} __attribute__((aligned(4))) MemoryMapTest_t;
/* USER CODE END PTD */
/* USER CODE BEGIN PV */
// 1. 配列:データが隙間なく並んでいることを確認する
volatile uint8_t test_array[4] = {0xAA, 0xBB, 0xCC, 0xDD};
// 2. 構造体:パディング(隙間)を観測する
volatile MemoryMapTest_t test_struct = {0x01, 0x12345678, 0xFF};
/* USER CODE END PV */
int main(void) {
HAL_Init();
/* USER CODE BEGIN 2 */
// 住所をExpressionsビューで確認するためのダミー参照
volatile uint8_t* p_array = (uint8_t*)test_array;
volatile MemoryMapTest_t* p_struct = (MemoryMapTest_t*)&test_struct;
(void)p_array;
(void)p_struct;
/* USER CODE END 2 */
while (1) {
HAL_Delay(1000);
}
}
コードのポイント:test_array と p_array の違い
ここで2種類の変数が登場しますが、それぞれ役割が異なります:
| 変数名 | 型 | 役割 | 格納される内容 |
|---|---|---|---|
test_array |
volatile uint8_t[4] |
データ本体 | 実際の値 {0xAA, 0xBB, 0xCC, 0xDD} が格納されている |
p_array |
volatile uint8_t* |
アドレスを記録する変数 | test_array の先頭アドレス(例:0x20000000)が格納されている |
test_struct |
volatile MemoryMapTest_t |
データ本体 | 実際の構造体の値が格納されている |
p_struct |
volatile MemoryMapTest_t* |
アドレスを記録する変数 | test_struct のアドレス(例:0x20000004)が格納されている |
なぜアドレスを記録する変数を用意するのか?
実は、test_array や test_struct だけでも確認はできます。しかし、アドレスを記録する変数を経由した方が、デバッガでアドレスを確認しやすいという実用的な理由があります:
// ❌ この方法でもアドレスは確認できるが...
&test_array // Expressionsビューに追加すると、アドレスが表示される
&test_struct // 同様
// ✅ アドレスを記録する変数を使う方が見やすい
p_array // 「この変数の値 = test_arrayのアドレス」と明確
p_struct // 「この変数の値 = test_structのアドレス」と明確
具体例:メモリ上での配置
メモリアドレス 内容
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
0x20000000: AA BB CC DD ← test_array[4] の実体がここに格納
0x20000004: 01 00 00 00 ... ← test_struct の実体がここに格納
...
0x2000xxxx: 00 00 00 00 ← p_array の実体(値は 0x20000000 という番地)
0x2000yyyy: 04 00 00 00 ← p_struct の実体(値は 0x20000004 という番地)
つまり、p_array 自体もメモリのどこかに存在し、その値として test_array のアドレス(番地)を保持しているということです。
補足:
*記号の意味(後の回で詳しく解説)
volatile uint8_t*の*は「この変数にはアドレス(番地)を格納しますよ」という宣言です。詳しくはポインタの回(第5回以降)で解説しますが、今は「アドレスを入れる箱」という理解で十分です。
(void)p_array;の意味
これは「この変数を使っていますよ」とコンパイラに伝えるためのダミーコードです。実際には何も処理しませんが、コンパイラの警告(“unused variable”)を抑制します。デバッガで観察するためだけに変数を用意した場合によく使われるテクニックです。
2-2. デバッガでの確認手順
この操作は、episode02で確認した手順と同じです。必要に応じて、第2回:変数が住む場所を見つける ― Flash、RAM、Stackの使い分け ― を見ながら進めてください。
まずはここだけできればOK(初級者向け)
p_arrayとp_structを追加して、0x20000000と0x20000004が見えたら成功&test_arrayと&test_structは慣れてからでOK
- デバッグ開始: F11キーまたは「Debug」ボタンでデバッグを開始
- Expressionsビューを開く: Window → Show View → Expressions
- アドレスを確認: 以下のexpressionを追加
p_array← これが最重要(test_arrayのアドレスが値として表示される)p_struct← これが最重要(test_structのアドレスが値として表示される)&test_array← 参考(p_arrayと同じアドレスが表示される)&test_struct← 参考(p_structと同じアドレスが表示される)test_array← 配列の中身を展開して見たいときtest_struct← 構造体の中身を展開して見たいとき
- Memoryビューを開く: Window → Show View → Memory
- アドレスを入力:
p_arrayやp_structのValue列に表示されたアドレスをMemoryビューに入力
ここで迷いやすいポイント
- Memoryビューに入れるのは
p_array/p_structの Value列の値test_arrayやtest_structの展開表示は「中身確認用」で、アドレス入力用ではない- 値が見つからないときは、1回
Resume→Pauseして再表示すると安定します
実際にデバッガで確認すると、Expressionsビューには以下のように表示されます:
画像の見方(まずはこの順でOK):
p_arrayのValueを見る(0x20000000 <test_array>)p_structのValueを見る(0x20000004 <test_struct>)test_arrayを展開してAA BB CC DDを確認するtest_structを展開してid = 1/value = 0x12345678/flag = 0xFFを確認する
迷ったらこの対応表だけ見ればOK:
| 見る項目 | 何がわかる |
|---|---|
p_array |
test_array の先頭番地 |
p_struct |
test_struct の先頭番地 |
test_array(展開) |
配列の中身(AA BB CC DD) |
test_struct(展開) |
構造体の中身(id / value / flag) |
要点:
test_array/test_structは「中身そのもの」p_array/p_structは「その中身がある場所(番地)」- 次のMemoryビューでは、
p_array/p_structの番地を使って中身を見に行く
この画面で各変数のアドレスと値を確認したら、次はMemoryビューで実際のバイト配置を見ていきます。
2-3. Memoryビューで実際のバイト配置を観察
p_array の値(0x20000000)をMemoryビューに入力すると、その番地に格納されているtest_arrayの実際のデータが以下のように表示されます:
この画面では、メモリの内容が16進数のバイト単位で表示されています。左端が「Address(アドレス)」、その右側に実際のデータが4バイトずつ区切られて表示されます。
この画面から読み取れること:
- Address 20000000:
AA BB CC DDと並んでいる → これがtest_arrayの実際のデータ - その直後(+4バイト目から):
01 00 00 00 78 56 34 12 FF 00 00 00→ これがtest_structの実際のデータ - データが隙間なく詰まっている: 配列も構造体も、宣言した順にメモリ上に配置されている
重要な理解
p_arrayは「test_arrayがどこにあるか」という番地(0x20000000)を記録している変数- その番地をMemoryビューに入力することで、
test_arrayの実際の中身(AA BB CC DD)を見ることができる- つまり、Memoryビューは「指定した番地に何が入っているか」を見るツール
観察の順番(迷ったらこの順)
- まず
AA BB CC DDを見つける(配列)- 次に
01 00 00 00 ... FF 00 00 00を確認(パディング)- 最後に
78 56 34 12を確認(リトルエンディアン)
では、この画面を見ながら、3つの重要な観察ポイントを確認していきましょう。
① 配列の連続性
確認内容: test_array の4バイトが隙間なく並んでいるか。
Memoryビューでの表示(Address 20000000):
Address +0 +1 +2 +3
20000000: AA BB CC DD
上の画像の一番上の行を見てください。AA, BB, CC, DD が一切の隙間なく並んでいることがわかります。
② 構造体のパディング
確認内容: test_struct で、01 や FF の後ろに 00 で埋まった隙間がどこにあるか。
Memoryビューでの表示(同じく Address 20000000の行):
画像をもう一度見てください。Address 20000000の行は、16バイト(0x10バイト分)を表示しています:
Address +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
20000000: AA BB CC DD 01 00 00 00 78 56 34 12 FF 00 00 00
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
test_array id+パディング value(LE) flag+パディング
詳しく見ると:
- +0~+3:
AA BB CC DD→test_array[4]の実体 - +4:
01→test_struct.idの値 - +5~+7:
00 00 00→ パディング(自動挿入された隙間) - +8~+B:
78 56 34 12→test_struct.valueの値(リトルエンディアンで0x12345678) - +C:
FF→test_struct.flagの値 - +D~+F:
00 00 00→ パディング(構造体全体を4の倍数にするための隙間)
③ リトルエンディアン
確認内容: 0x12345678 が 78 56 34 12 と逆順に並んでいるか。
上の画像の +8~+B の部分を見てください:78 56 34 12 と並んでいます。
これは、私たちが 0x12345678 と書いたデータが、メモリ上ではバイトの順序が逆になって格納されていることを示しています。
STM32(ARM Cortex-M)はリトルエンディアン方式を採用しており、以下のルールでデータを格納します:
- 下位バイトを下位アドレスに配置
- 上位バイトを上位アドレスに配置
0x12345678 の格納方法(リトルエンディアン):
Address 値 意味
+8(+0): 78 ← 最下位バイト(LSB: Least Significant Byte)
+9(+1): 56
+A(+2): 34
+B(+3): 12 ← 最上位バイト(MSB: Most Significant Byte)
ビッグエンディアンとの違い
一部のCPU(古いMotorola系など)ではビッグエンディアン(上位バイトを下位アドレスに配置)を採用しています。同じデータでも並び順が逆になるため、異なるCPU間でバイナリデータをやり取りする際は注意が必要です。
2-3節のまとめ:
Memoryビューで実際のバイト配置を見ることで、以下の3点を確認できました:
- 配列は隙間なく連続している(AA BB CC DD)
- 構造体にはパディングが挿入される(01の後に00 00 00、FFの後に00 00 00)
- データはリトルエンディアンで格納される(0x12345678 が 78 56 34 12)
✅ 2章のチェックポイント
- Expressionsビューで
p_arrayとp_structの値を確認できた - Memoryビューで
AA BB CC DDを見つけられた -
01の後ろとFFの後ろの00をパディングとして確認できた -
0x12345678が78 56 34 12で並ぶ理由を説明できる
次章から「なぜこの並びになるのか」を配列→構造体→volatileの順で解剖していきます。
3. 配列の本質:メモリ上の「連続性」
3-1. 配列 = 隙間のない連続配置
配列は、同じ型のデータがメモリ上で一切の隙間なく並んでいる状態を保証します。これはC言語の規格で厳密に定められており、この事実がポインタ演算の根拠となります。
ここだけ先に覚える
配列は「同じ型を隙間なく並べた領域」です。だから先頭アドレス + 番号 × 型サイズで位置が決まります。
2-3節で確認した test_array をMemoryビューで見ると、次のように隙間なく並んでいます。
Address +0 +1 +2 +3
20000000: AA BB CC DD
3-2. 配列の連続性が重要な理由
Q: なぜ「連続している」ことが重要なのでしょうか?
A: それは、計算だけで次の要素のアドレスを正確に求められるからです。
たとえば、test_array[0] のアドレスが 0x20000000 だとわかっていれば:
test_array[1]は0x20000000 + (1 × 1バイト) = 0x20000001test_array[2]は0x20000000 + (2 × 1バイト) = 0x20000002test_array[3]は0x20000000 + (3 × 1バイト) = 0x20000003
という具合に、**「開始アドレス + インデックス × 型のサイズ」**という単純な計算だけで、どの要素にも瞬時にアクセスできます。
ここでつまずきやすいポイント
test_array[0]のアドレスは、配列全体の先頭アドレスと同じです。迷ったら「0番は先頭」と覚えてOKです。
3-3. 異なる型のサイズでの確認
今回の実験コードでは配列は uint8_t ですが、同じ2-3節の test_struct.value を見ると、uint32_t が4バイト単位で配置されることがわかります。
Memoryビューの表示を見直すと:
Address 20000000の行(test_structは+4から開始):
+4: id (1バイト: 01)
+5~+7: パディング (3バイト: 00 00 00)
+8~+B: value (4バイト: 78 56 34 12)
+C: flag (1バイト: FF)
+D~+F: パディング (3バイト: 00 00 00)
uint32_t という型は、マイコンに対して「ここから4バイト分をひと塊として、リトルエンディアンで数値として読み取れ」という指示を出しているイメージです。
このように、型サイズが「何バイト刻みでアドレスが進むか」を決めるのがC言語の基本です。
3-4. この連続性があるから高速処理が可能
この「連続している」という物理的な事実があるからこそ、マイコンは大量のデータを高速かつ正確に処理できるのです。
特に、DMA(Direct Memory Access)という仕組みでは、「この開始アドレスから、このサイズ分を一気に転送せよ」という命令を出すだけで、CPUに負担をかけずに大量データを転送できます。この仕組みは、配列が連続していることを前提にしています。
DMAについては第10回で詳しく解説します
DMA(Direct Memory Access)は、CPUを経由せずにメモリ間やメモリ-周辺機器間でデータを転送する仕組みです。配列の連続性があるからこそ、高速なデータ転送が可能になります。
✅ 3章のチェックポイント
- 配列が連続配置である理由を説明できる
-
先頭 + インデックス × 型サイズの計算を使える -
uint8_tとuint32_tでアドレスの進み方が違う理由を理解した
4. 構造体のレイアウト:パディングとアライメント
4-1. 構造体 = 異なる型のグループ化
2章の MemoryMapTest_t のように、構造体を使うと異なる型のデータを1つのグループとして扱えます。ただし、メモリ上では**「パディング(隙間)」**という隠れた要素が現れます。
2章で見えた 00 00 00 の一部が、まさにこのパディングです。
ここだけ先に覚える
構造体は「宣言順に並ぶ」が、CPU都合で途中に隙間(パディング)が入ることがあります。
4-2. Memoryビューで見る「無駄な隙間」の実態
2-3節で確認した test_struct のメモリ配置を見ると、以下のように表示されます:
Address +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F
20000000: AA BB CC DD 01 00 00 00 78 56 34 12 FF 00 00 00
^^ ~~~~~~~~~~ ~~~~~~~~~~~~~~ ^^ ~~~~~~~~~~
id パディング value (LE) flag パディング
(test_struct は Address 20000000 の +4 バイト目から始まっています)
id(1バイト):01- パディング(3バイト):
00 00 00(自動挿入された隙間) value(4バイト):78 56 34 12(リトルエンディアンで0x12345678)flag(1バイト):FF- パディング(3バイト):
00 00 00(構造体全体を4の倍数にするための隙間)
実際の構造体のサイズを確認すると:
sizeof(MemoryMapTest_t) = 12バイト
// 内訳:1バイト(id) + 3バイト(パディング) + 4バイト(value) + 1バイト(flag) + 3バイト(パディング) = 12バイト
4-3. なぜ隙間ができるのか? ― アライメントの制約
Q: なぜコンパイラは、わざわざ無駄な隙間を作るのでしょうか?
A: それは、CPUが効率よくデータを読み書きできる位置に配置するためです。
ここでつまずきやすいポイント
「パディング=無駄」と見えますが、実際はCPUが速く安全に読み書きするための必要コストです。
32bit CPU(Cortex-M4など)には、以下のような物理的制約があります:
| データ型 | サイズ | 推奨アライメント | 理由 |
|---|---|---|---|
uint8_t |
1バイト | 任意のアドレス | 1バイト単位で読み書き可能 |
uint16_t |
2バイト | 2の倍数のアドレス | 2バイト境界で最速 |
uint32_t |
4バイト | 4の倍数のアドレス | 4バイト境界で最速 |
具体例:
uint32_tを0x20000001のような中途半端なアドレスに配置すると、CPUは2回の読み取り命令を実行する必要があります(最悪の場合)。- しかし、
0x20000004のような4の倍数のアドレスに配置すれば、1回の命令で読み取れます。
第2回で見たスタックの消費(24バイト)も、実は今回学んだアライメントに従って、4バイト単位で管理情報が詰め込まれた結果なのです。
コンパイラは、この性能差を考慮して、わざと「隙間(パディング)」を挟んで、各メンバを効率の良い位置に配置します。
今回のコードでは __attribute__((aligned(4))) を付けているため、構造体自体の配置境界も4バイトにそろえられ、観察結果がより安定します。
補足:アライメント違反が起こすとどうなる?
STM32のCortex-M4では、アライメント違反(例:4バイト境界でない位置からuint32_tを読む)でもプログラムは動作します。ただし、余分なクロックサイクルが必要になり、実行速度が低下します。一部のCPU(古いARMコアなど)では、アライメント違反時にハードウェア例外(フォルト)が発生してプログラムが停止することもあります。
4-4. RAM容量節約のテクニック ― メンバの並び順を工夫する
構造体のメンバを**「サイズの大きい順」**に並べるだけで、パディングを最小限に抑え、RAM容量を節約できることがあります。
改善前(12バイト):
typedef struct {
uint8_t id; // 1バイト
// ★ 3バイトのパディング
uint32_t value; // 4バイト
uint8_t flag; // 1バイト
// ★ 3バイトのパディング
} MemoryMapTest_t; // 合計12バイト
改善後(8バイト):
typedef struct {
uint32_t value; // 4バイト(最大サイズを先頭に)
uint8_t id; // 1バイト
uint8_t flag; // 1バイト
// ★ 2バイトのパディング(構造体全体を4の倍数にするため)
} MemoryMapTest_Optimized_t; // 合計8バイト
メモリレイアウトの比較:
// 改善前(12バイト)- 実際の配置例
Address +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B
2000xxxx: 01 00 00 00 78 56 34 12 FF 00 00 00
id パディング value flag パディング
// 改善後(8バイト)- メンバを並び替えた場合
Address +0 +1 +2 +3 +4 +5 +6 +7
2000xxxx: 78 56 34 12 01 FF 00 00
value id flag パディング
4バイトの節約に成功しました!リソースの限られた組み込み開発では、このような工夫が必須の知識です。
💡 実践的なヒント
大きな構造体を大量に使う場合(例:1000個の構造体配列)、1構造体あたり4バイトの差は4 × 1000 = 4000バイト = 約4KBの差になります。RAMが64KBしかないマイコンでは、これは無視できない差です。
✅ 4章のチェックポイント
-
idの後に3バイト空く理由を説明できる -
sizeof(MemoryMapTest_t) = 12の内訳を言える - メンバ順序の変更でサイズが縮む理由を理解した
5. volatile の意味:コンパイラへの警告(伏線)
2章の実験コードでも、変数宣言に付けている volatile が観察の前提になっていました。ここでその意味を整理します。
ここだけ先に覚える
volatileは「毎回メモリを見に行ってください」という指示です。
ハードウェアや割り込みで値が変わる変数に使います。
5-1. コンパイラの最適化とは?
通常、コンパイラは「この変数はプログラムの中で値が変わっていないから、メモリを見に行かずにレジスタの値を使い回そう」と最適化します。
例:最適化される可能性があるコード
int counter = 0;
counter++;
counter++;
int result = counter; // ← コンパイラは「2」と直接書いてもいいと判断するかも
このような最適化は、通常のプログラムでは実行速度を向上させる良い機能です。
5-2. 組み込みでの問題:「外側」からの変化
しかし、組み込みでは 「プログラムの外側(ハードウェアや割り込み)」で値が変わる ことが頻繁にあります。
例:GPIOレジスタ(第4回で詳しく解説)
uint32_t* gpio_input = (uint32_t*)0x40020010; // GPIOの入力レジスタ
int value1 = *gpio_input; // 1回目の読み取り:ボタンが押されていない → 0
// ← ここでボタンが押される
int value2 = *gpio_input; // 2回目の読み取り:ボタンが押されている → 1
コンパイラが最適化すると、「同じアドレスを2回読んでいるから、1回目の値を使いまわそう」と判断し、value2 も 0 になってしまう可能性があります。
5-3. volatile の役割:最適化の抑制
volatileの役割: 「この変数はいつの間にか値が変わる可能性があるから、毎回必ずメモリまで読みに行ってくれ」とコンパイラに命令します。
volatile uint32_t* gpio_input = (uint32_t*)0x40020010;
int value1 = *gpio_input; // 必ずメモリから読む
int value2 = *gpio_input; // 再度メモリから読む(最適化されない)
ここでつまずきやすいポイント
volatileは「スレッド安全」や「排他制御」を保証するものではありません。
役割はあくまで「最適化で読み飛ばされないようにする」ことです。
5-4. いつ volatile を使うべきか?
以下のような場合に volatile が必要です:
| ケース | 理由 | 例 |
|---|---|---|
| ハードウェアレジスタ | ハードウェアが値を変える | GPIO、UART、タイマーなどのレジスタ |
| 割り込みで共有する変数 | 割り込みハンドラが値を変える | フラグ、カウンタなど |
| マルチスレッド環境 | 他のスレッドが値を変える | RTOS使用時の共有変数 |
| デバッグ時の観察 | 最適化で変数が消えるのを防ぐ | 今回の実験コード |
重要な注意点:
過剰な volatile は最適化を妨げ、実行速度を低下させます。必要な場所にだけ正しく使うのがプロの技です。
これがなぜ重要になるのか、次回の「レジスタ操作(第4回)」でその真価が明らかになります。
✅ 5章のチェックポイント
-
volatileが必要な場面を3つ以上挙げられる - 「最適化の抑制」が目的だと説明できる
- 何でも
volatileにしない理由を説明できる
6. まとめ:C言語が「組み込みの標準語」である理由
本稿では、C言語の変数・配列・構造体がメモリ上でどのように配置されるかを、実際のアドレスを観察しながら学びました。
6-1. 重要ポイントの復習
-
変数 = メモリ
C言語のすべての変数は、物理的なメモリアドレスと1対1で対応している。これが「ハードウェアに近い」と言われる所以です。 -
配列 = 連続
配列の要素は隙間なく連続配置される。この連続性があるからこそ、計算で次のデータにアクセスでき、DMAなどの高速転送が可能になります。 -
構造体 = レイアウト
CPUの都合(アライメント)で、見えない「パディング」が挿入される。メンバの並び順を工夫することで、RAM容量を節約できます。 -
volatile の役割
現実のメモリ状態を常に反映させるための、ハードウェア制御に必須のキーワード。過剰な使用は避け、必要な場所にのみ使用します。
6-2. C言語が組み込みで選ばれ続ける本質的理由
今回学んだ内容は、C言語が組み込み開発で50年以上も使われ続けている理由そのものです:
- 予測可能性: コードを書けば、メモリ上でどう並ぶかが明確
- 直接性: ポインタで特定アドレスに直接アクセスできる
- 効率性: CPUの特性(アライメント)を活かした最適化が可能
- 制御性: volatile などで、コンパイラの振る舞いを細かく制御できる
これらの特性は、「限られたリソースで確実に動く」という組み込みシステムの要求に完璧にマッチしています。
6-3. 次回への展望
次回の第4回では、いよいよハードウェアレジスタの操作に入ります。今回学んだ「アドレス」「volatile」「構造体のレイアウト」という知識が、すべて繋がります。
学ぶこと:
- 周辺機器レジスタ(GPIO、UART、タイマーなど)の実体とは?
- なぜ「特定のアドレスに値を書く」だけでハードウェアが動くのか?
- volatile が本領を発揮する場面
- 構造体を使ったレジスタ定義の実例
メモリマップの知識を武器に、ハードウェアを直接支配する世界へ踏み出しましょう。
参考資料
公式ドキュメント
本記事で使用している技術仕様は、STマイクロエレクトロニクス社の公式ドキュメントに基づいています:
-
STM32F401xD/xE データシート(PDF)
STM32F401REの電気的特性、ピン配置、メモリ構成などの基本仕様が記載されています。 -
STM32F4xx リファレンスマニュアル(PDF)
周辺機器の詳細な使い方、レジスタの設定方法などを解説した技術資料(1700ページ超)。次回以降で詳しく解説します。
C言語の規格に関する情報
- ISO/IEC 9899(C言語規格)
配列の連続配置、アライメント、volatile の動作などは、C言語の規格で厳密に定められています。
次回予告
第4回:レジスタ操作の基本 ― メモリマップドI/Oでハードウェアを動かす ―
次回は、今回学んだメモリの知識を使って、実際にハードウェアを動かします。
学ぶこと:
- 周辺機器レジスタの実体:GPIOレジスタ、UARTレジスタなどの物理的な正体
- メモリマップドI/O:なぜ「特定のアドレスに値を書く」だけでLEDが光るのか?
- ビット操作の実践:特定のビットだけを操作する技術
- HALライブラリの裏側:
HAL_GPIO_WritePin()が実際に何をしているのか
構造体とvolatileの知識が、すべて繋がる瞬間を体験しましょう。
💡 この記事で学んだこと(SEO対策・FAQ)
- Q: C言語が組み込み開発で選ばれる理由は?
A: メモリ配置の透明性、低レイヤへの直接アクセス、実行速度の高さという3つの特性が、リソースの限られた組み込みシステムに最適だからです。 - Q: 配列の連続性はなぜ重要?
A: 「開始アドレス + インデックス × 型のサイズ」という単純な計算でどの要素にもアクセスでき、DMAなどの高速転送の前提となるからです。 - Q: sizeof で計算したサイズと、実際のメモリ使用量が違うのはなぜ?
A: それこそがパディングの影響です。構造体のメンバ順序によってサイズは変化します。アライメントを考慮してメンバを並び替えることで、RAM容量を節約できます。 - Q: 全ての変数に volatile を付ければ安全?
A: いいえ。過剰な volatile は最適化を妨げ、実行速度を低下させます。ハードウェアレジスタや割り込みで共有する変数など、必要な場所にだけ正しく使うのがプロの技です。 - Q: リトルエンディアンとビッグエンディアンの違いは?
A: バイトの並び順が逆です。STM32(リトルエンディアン)では 0x12345678 が 78 56 34 12 と格納されます。異なるCPU間でバイナリデータをやり取りする際は注意が必要です。 - Q: 構造体のメンバの並び順を変えるだけで本当にメモリが節約できる?
A: はい。大きな型を先頭に配置することで、パディングを最小化できます。大量の構造体を使う場合、数KB単位でRAMを節約できることもあります。