今回は、変数の宣言方法によって、データがどこに配置されるかを実際のアドレスで確認していきます。前回学んだ「すべてがアドレスで管理されている」という原則をもとに、Flash、SRAM、Stackという3つの主要なメモリ領域の違いと使い分けを理解することが目標です。
前回の第1回では、プログラムカウンタ(PC)を使って実行中の命令の位置を追跡しました。今回は、データがどこに保存されるかに着目します。
📍 連載トップページ
1. STM32のメモリマップとは? ― マイコン内部の地図を読む
1-1. すべてのリソースはアドレスで管理されている
前回、マイコンはすべてのリソース(命令、データ、周辺機器)を「アドレス」という座標で管理していることを学びました。しかし、このアドレス空間は無秩序に配置されているわけではありません。
実際には、メモリマップ(Memory Map) という設計図に従って、用途ごとに領域が分割されています。これは、街の住所が「千代田区」「新宿区」などの区画に分かれているのと同じです。
1-2. STM32F401のメモリマップ構造
STM32F401(多くのSTM32開発ボードで使われているマイコン)のアドレス空間は、以下のように区画整理されています。
私たちが適当に決めているわけではなく、チップの設計段階で「どの住所に何を置くか」は厳格に決められています。これは、STマイクロエレクトロニクス社が公開しているリファレンスマニュアルに詳細に記載されています。
STM32F4xxのリファレンスマニュアルに記載されているメモリマップ。この設計図がすべての「住所」の根拠となる。
この図から、以下の主要な区画が読み取れます:
- Code領域(0x0800 0000 〜): Flashメモリ。プログラムや定数が入る「保存用の街」。電源を切っても内容が消えません。
- SRAM領域(0x2000 0000 〜): 変数などが置かれる「作業用の街」。高速に読み書きできますが、電源を切ると消えます。
- Peripherals領域(0x4000 0000 〜): GPIOやタイマーなど、ハードウェアを動かす「スイッチ」が並ぶ街。特定のアドレスに値を書き込むことでハードウェアが動作します。
主要な領域の詳細
| アドレス範囲 | 領域名 | 物理メモリの種類 | 主な用途 | 特徴 |
|---|---|---|---|---|
| 0x0800 0000 〜 | Flash領域 | 不揮発性メモリ | プログラムコード、定数データ | 電源を切っても消えない、書き込み回数に制限あり |
| 0x2000 0000 〜 | SRAM領域 | 揮発性メモリ | 変数、動的データ | 高速な読み書き、電源を切ると消える |
| 0x4000 0000 〜 | 周辺機器レジスタ | メモリマップドI/O | GPIO、UART、タイマーなどの制御 | 特定アドレスへの書き込みでハードウェアが動作 |
メモリマップドI/Oとは?
周辺機器(GPIO、UART、タイマーなど)の制御レジスタが、メモリアドレス空間の一部に配置される仕組みです。メモリに値を書き込むのと同じ方法でハードウェアを制御できるため、プログラムがシンプルになります。詳細は後の回で解説します。
1-3. 不揮発性と揮発性の違い
-
不揮発性メモリ(Non-Volatile Memory):電源を切っても内容が保持されるメモリです。FlashメモリやEEPROMがこれに該当します。プログラムコードや、変更されない設定値を保存するのに適しています。書き込み回数に制限があり(数万〜数十万回)、書き込み速度も遅いです。
-
揮発性メモリ(Volatile Memory):電源を切ると内容が消えるメモリです。SRAMやDRAMがこれに該当します。実行中に頻繁に変更される変数やデータを保存するのに適しています。読み書きが非常に高速で、書き込み回数の制限もありません。
身近な例で理解する:
スマートフォンのストレージ(内蔵SSD)は不揮発性、RAM(メモリ)は揮発性です。アプリをインストールするとストレージに保存され、電源を切っても消えません。しかし、アプリ実行中のデータはRAMに一時的に置かれ、電源を切ると消えます。
1-4. なぜ領域を分けるのか?
異なる物理特性を持つメモリを適材適所で使い分けることで、以下のメリットがあります:
- 効率性:高速だが高価なSRAMは必要最小限に、安価で大容量のFlashを主記憶に使う
- 信頼性:書き換え頻度の高いデータはSRAM、保存すべきデータはFlashに配置
- 安全性:プログラムコードをFlashに置くことで、バグによる意図しない書き換えを防ぐ
2. C言語の変数宣言とメモリ配置の関係
2-1. 変数宣言とメモリセクションの対応表
C言語で変数を宣言する際、どこに宣言するかとどんな修飾子をつけるかで、その変数がメモリ上のどの領域(どのセクション)に配置されるかが決まります。
以下の表は、変数宣言とメモリ配置の対応関係をまとめたものです:
| 宣言方法 | セクション | 配置先メモリ | アドレス例 | 寿命 | 用途 |
|---|---|---|---|---|---|
const 型 変数名 |
.rodata | Flash | 0x0800… | プログラム実行中ずっと存在 | 変更されない定数データ |
| グローバル変数(初期値あり) | .data | Flash → SRAM | 0x2000… | プログラム実行中ずっと存在 | 複数の関数で共有する変数 |
| グローバル変数(初期値なし) | .bss | SRAM | 0x2000… | プログラム実行中ずっと存在 | バッファなど大きな領域 |
| ローカル変数(関数内) | (Stack) | SRAM | 0x2001…(高位) | 関数実行中のみ存在 | 一時的な計算用データ |
重要なポイント:
グローバル変数でも、.dataセクション(初期値あり)はFlashとSRAMの両方を使用します。一方、.bssセクション(初期値なし)はSRAMのみを使用するため、Flashメモリを節約できます。
2-1-1. セクションとは? ― .data と .bss の意味
プログラムをコンパイルすると、変数やコードはセクション(Section) という単位にグループ分けされます。セクションとは、「似た性質を持つデータのまとまり」のことです。
リンカ(複数のファイルを1つの実行可能ファイルにまとめる専用プログラム)は、このセクション単位でメモリ配置を決定します。
セクション名の由来:
.data、.bss、.textなどのセクション名は、UNIX系OSの伝統的な命名規則に由来します。ピリオド(.)で始まる名前は「システムが管理する特別な領域」を示す慣習です。
主要なセクションの種類:
| セクション名 | 内容 | 配置先メモリ | 特徴 |
|---|---|---|---|
| .text | プログラムコード(機械語命令) | Flash | 実行される命令そのもの |
| .rodata | 読み取り専用データ(const定数、文字列リテラルなど) | Flash | 変更されないデータ |
| .data | 初期値ありグローバル変数 | Flash → SRAM | 起動時にFlashからSRAMへコピー |
| .bss | 初期値なし/ゼロ初期化グローバル変数 | SRAM | 起動時にゼロクリア |
グローバル変数が分類される理由:
グローバル変数は、初期値の有無でさらに.dataと.bssに分類されます:
.dataセクション(初期値ありグローバル変数)
int counter = 10; // 初期値あり → .dataセクション
uint32_t flag = 0x1234; // 初期値あり → .dataセクション
これらの変数は以下のような流れで配置されます:
- コンパイル時:初期値(10、0x1234)がFlashメモリに保存される
- プログラム起動時:Flashから初期値を読み出してSRAMにコピー
- 実行中:SRAM上の値を読み書き
なぜ2重管理?
Flashは不揮発性なので初期値を永続保存できますが、書き換えが遅いです。プログラム実行中は高速なSRAM上で変数を操作し、電源を入れ直したときにFlashから初期値を復元する、という仕組みです。
.bssセクション(初期値なしグローバル変数)
int counter; // 初期値なし → .bssセクション(自動的に0になる)
uint32_t buffer[256]; // 初期値なし → .bssセクション(すべて0)
これらの変数は以下のような流れで配置されます:
- コンパイル時:初期値を保存する必要がない(すべて0と決まっている)
- プログラム起動時:SRAM上の領域をゼロクリア(0で埋める)
- 実行中:SRAM上の値を読み書き
なぜ.bssと.dataを分けるのか?
初期値が全て0の変数は、わざわざFlashに0を保存する必要がありません。「起動時に0にする」というルールだけ決めておけば、Flashの容量を節約できます。大きな配列などでは、この差が顕著に現れます。
実例:メモリ使用量の違い
// ケース1:初期値あり(.dataセクション)
uint8_t large_array_data[1024] = {1, 2, 3, ...};
// → Flash に 1024バイト使用、SRAM にも 1024バイト使用
// ケース2:初期値なし(.bssセクション)
uint8_t large_array_bss[1024];
// → Flash は 0バイト使用、SRAM に 1024バイト使用(起動時に0クリア)
組み込みシステムでは、Flashの容量が限られているため、大きなバッファなどは.bssセクションに配置することでFlash容量を節約できます。
起動処理でのセクション初期化:
STM32のプログラムは、main()関数が呼ばれる前に、スタートアップコード(startup_stm32f4xx.sなど)で以下の処理を自動的に行います:
.dataセクション:Flashに保存された初期値をSRAMへコピー.bssセクション:SRAM上の領域を0でクリア- Stackポインタの初期化
main()関数の呼び出し
これにより、プログラマは初期化を意識せずにmain()から処理を開始できます。
2-2. 実験用コードの準備
以下のコードを追加して、それぞれの変数がどこに配置されるかを確認します:
/* グローバル領域:プログラム全体から見える住人たち */
const uint32_t flash_const = 0xDEADBEEF; // .rodataセクション → Flash領域に配置
volatile uint32_t ram_global_init = 0x12345678; // .dataセクション → Flash→SRAM(初期値あり)
volatile uint32_t ram_global_uninit; // .bssセクション → SRAM(初期値なし、自動的に0)
/* 関数内のローカル変数:一時的な住人 */
void stack_test_function(uint32_t depth) {
// Stack領域:関数が呼ばれたときだけ現れる「ローカル変数」
volatile uint32_t stack_local = depth;
stack_local = stack_local;
if (depth > 0) {
stack_test_function(depth - 1); // 再帰呼び出しでスタックを伸ばす
}
}
/* メイン関数 */
int main(void) {
HAL_Init(); // STM32の初期化
// flash_constのアドレスを取得(最適化除け)
volatile const uint32_t* p_flash_const = &flash_const;
// スタックの住所を確認するために関数を呼び出し
stack_test_function(3);
while(1) {
ram_global_init++; // .dataセクションの変数をインクリメント
ram_global_uninit++; // .bssセクションの変数をインクリメント
HAL_Delay(1000); // 1秒待機
}
}
コードのポイント:
このコード例では、変数の宣言方法によってメモリ配置がどう変わるかを観察できます。各要素の役割を以下の表にまとめました。
| コード要素 | 役割と配置先 |
|---|---|
const |
Flash領域(0x08…)に配置される定数を宣言します。電源を切っても値が保持されます。 |
volatile |
デバッガで変数を確実に観察できるよう、コンパイラの最適化を抑制します。詳細は第3回で解説します。 |
ram_global_init = 0x12345678 |
初期値あり → .dataセクション(Flash容量を使用) |
ram_global_uninit |
初期値なし → .bssセクション(Flash容量を節約) |
stack_test_function(depth - 1) |
再帰呼び出しにより、Stackが段階的に深くなっていく様子を観察できます。depth = 3で呼ぶと、3段階の関数呼び出しが重なり、各階層でローカル変数stack_localが異なるアドレスに配置されます。 |
volatile const uint32_t*p_flash_const = &flash_const; |
変数のアドレスを取得してポインタ変数に格納することで、コンパイラによる最適化を防ぎます。ポインタの詳細は後の回で解説します。 |
このコードを実際にデバッグ実行して、各変数がどのメモリ領域(Flash、SRAM、Stack)に配置されるかを確認してみましょう。具体的な確認手順と観察結果については、次の第3節で解説します。
補足:constとvolatileの使い分け
flash_constにはvolatileをつけていません。これは確実にFlash領域に配置するためです。一方、ram_global_initやstack_localにはvolatileをつけて、SRAM上での観察を可能にしています。修飾子の詳しい使い分けは第3回で扱います。
3. デバッガでメモリアドレスを確認する方法
3-1. Expressionsビューの使い方
STM32CubeIDEのデバッガには、変数の値だけでなくアドレスも確認できる「Expressions」ビューがあります。
手順:
- デバッグを開始し、
stack_test_function()内のブレークポイントで停止させます。 - Window → Show View → Expressions を選択します。
- Add new expression ボタンをクリックし、以下を入力します:
&flash_const(const変数のアドレス)&ram_global_init(.dataセクションのグローバル変数のアドレス)&ram_global_uninit(.bssセクションのグローバル変数のアドレス)&stack_local(ローカル変数のアドレス)
&演算子とは?
C言語の&は「アドレス演算子」と呼ばれ、変数が格納されているメモリアドレスを取得します。例えば、int x = 10;という変数があったとき、xは値の10を、&xはxが保存されているアドレス(例:0x20000100)を返します。
3-2. アドレスの観察結果
Expressionsビューで確認すると、以下のような結果が得られます:
| 変数名 | アドレス | 配置領域 | セクション |
|---|---|---|---|
&flash_const |
0x0800 17a8 | Flash領域 | .rodata |
&ram_global_init |
0x2000 0000 | SRAM領域 | .data(初期値あり) |
&ram_global_uninit |
0x2000 002c | SRAM領域 | .bss(初期値なし) |
&stack_local |
0x2001 7fe4 | SRAM領域(Stack) | - |
(※ 実際のアドレスは環境により異なります)
Expressionsビューでのアドレス確認。変数の宣言方法(属性)により異なるメモリ領域へ配置される。
3-3. アドレスから分かること
観察した変数のアドレスから、以下のことが分かります:
-
Flash領域(0x08…):flash_const
constで宣言した変数は、Flashメモリ(.rodataセクション)に配置されます。電源を切っても値が保持され、実行中の書き換えはできません。 -
SRAM領域(0x2000…):ram_global_init(.dataセクション)
初期値ありグローバル変数は、SRAMの低位アドレスに配置されます。プログラム起動時に、Flashに保存された初期値がSRAMにコピーされます(詳細は2-1-1を参照)。プログラム実行中ずっと同じアドレスに存在し、高速に読み書きできます。 -
SRAM領域(0x2000…):ram_global_uninit(.bssセクション)
初期値なしグローバル変数は、SRAMの低位アドレスに配置されます。プログラム起動時に自動的に0でクリアされます(詳細は2-1-1を参照)。ram_global_initの直後のアドレスに配置されることが多いです。 -
Stack領域(0x2001…):stack_local
ローカル変数は、SRAMの高位アドレス(Stack領域)に配置されます。関数stack_test_function(depth)が呼ばれるたびに確保され、関数が終了すると自動的に解放されます。再帰呼び出しでは、各階層ごとに異なるアドレスにstack_localが確保されます。
重要な違い:
グローバル変数(ram_global_init、ram_global_uninit)は永続的に存在し、main関数の while(1) ループで値をインクリメントし続けることができます。一方、ローカル変数(stack_local)は関数終了時に消滅するため、値を保持し続けることはできません。
4. Stack(スタック)の仕組みを理解する
4-1. Stackとは何か?
Stack(スタック) は、関数の実行に必要な一時的なデータを保存するための領域です。「積み重ねる」という意味の通り、データを後入れ先出し(LIFO: Last In, First Out)で管理します。
身近な例で言えば、本の山積みと同じです。一番上に置いた本を最初に取り出し、下の本にアクセスするには上の本を順番に取り除く必要があります。
4-2. Stackの動作原理
関数が呼ばれると、以下の手順でStackが使われます:
- 関数呼び出し時:ローカル変数用の領域をStackに確保(Push)
- 関数実行中:ローカル変数をStack上で読み書き
- 関数終了時:確保した領域を解放(Pop)
一般的な例:
void main(void) {
int a = 10; // Stack上に確保
func(); // func()が呼ばれる
// ← func()が終了すると、func()内のローカル変数は解放される
a = a + 1; // aはまだStack上に存在
}
void func(void) {
int b = 20; // Stack上に確保(aの上に積まれる)
b = b + 1;
} // ← 関数終了でbが解放される
実際のコード例(stack_test_function):
int main(void) {
HAL_Init();
volatile const uint32_t* p_flash_const = &flash_const; // Flash領域の変数を参照
stack_test_function(3); // この関数を再帰的に呼ぶ(深さ3)
// ← 関数終了で stack_local は解放される
while(1) {
ram_global_init++; // グローバル変数は解放されない
ram_global_uninit++;
HAL_Delay(1000);
}
}
void stack_test_function(uint32_t depth) {
volatile uint32_t stack_local = depth; // Stack上に確保(例:0x2001 7FF0)
stack_local = stack_local; // 最適化除け
if (depth > 0) { // ★ブレークポイント推奨★
stack_test_function(depth - 1); // 再帰呼び出し
}
} // ← ここで stack_local が解放される
再帰呼び出しとStackの成長:
stack_test_function(3) を呼び出すと、以下のように関数が入れ子で呼ばれます:
stack_test_function(3)が呼ばれる →stack_local = 3をStack上に確保(例:0x2001 7FF0)stack_test_function(2)が呼ばれる →stack_local = 2をStack上に確保(例:0x2001 7FE8)stack_test_function(1)が呼ばれる →stack_local = 1をStack上に確保(例:0x2001 7FE0)stack_test_function(0)が呼ばれる →stack_local = 0をStack上に確保(例:0x2001 7FD8)depth > 0が偽なので再帰終了、順番に関数が終了していく
重要な疑問:なぜ同じアドレスを書き換えないのか?
ここで重要な疑問が生じます。「なぜ同じstack_localのアドレスを使い回さず、毎回新しいアドレスに確保するのか?」という点です。
答えは、すべての階層の関数が同時に実行中だからです。stack_test_function(3) は stack_test_function(2) を呼び出した後も終了していません。stack_test_function(2) が戻ってくるのを待っている状態です。
もし同じアドレスを使い回すと、以下の問題が発生します:
stack_test_function(3) の stack_local = 3 ← 0x2001 7FF0に保存
↓ 関数呼び出し
stack_test_function(2) の stack_local = 2 ← もし同じ0x2001 7FF0を使うと...
↓ 値が上書きされて 3 → 2 に!
↓ 関数が戻るとき
stack_test_function(3) に戻る
↓ しかし stack_local の値は 2 に書き換わっている!
↓ さらに深刻なのは「戻りアドレス」も破壊される
→ プログラムが正しく戻れず暴走
Stackは「入れ子構造」を保持する仕組み
関数呼び出しは入れ子(ネスト)構造になるため、各階層の情報を同時に保持する必要があります:
- 各階層のローカル変数:
stack_local = 3,2,1,0がそれぞれ異なるアドレスに共存 - 各階層の戻りアドレス:関数が終了したときに「どこに戻るか」の情報
- 各階層のレジスタ退避:CPUのレジスタの値を一時保存
これらすべてが、階層ごとに異なるアドレスに保存されるため、Stackは呼び出しの深さに応じて成長(アドレスが減少)していきます。
デバッガで stack_local のアドレスを確認すると、SRAM領域の高位アドレス(例:0x2001 7FF0)から始まり、再帰呼び出しごとにアドレスが減少(Stackが成長)していることが分かります。一方、ram_global_init は低位アドレス(例:0x2000 0010)に固定的に配置されています。
実測:「スタック」が伸びる様子を物理的に観測する
グローバル変数とは異なり、ローカル変数が住む Stack(スタック)領域 には「実行するたびに場所が変わる」「下に向かって伸びる」という独自のルールがあります。再帰関数 stack_test_function を使って、関数が呼ばれるたびに住所がどう変化するかを実測した結果がこちらです。
実測:関数呼び出しとアドレスの変化
| 呼び出し階層 | depth の値 |
&stack_local のアドレス |
変化量 |
|---|---|---|---|
| 1回目 | 3 | 0x20017fe4 | (基準) |
| 2回目 | 2 | 0x20017fcc | -24バイト ($0x18$) |
| 3回目 | 1 | 0x20017fb4 | -24バイト ($0x18$) |
| 4回目 | 0 | 0x20017f9c | -24バイト ($0x18$) |
図4. スタック領域の観測。関数が深く呼ばれるたびに、アドレスが「下(小さい方)」へ向かって移動していることがわかります。
この数値からわかること
-
スタックは下へ伸びる:アドレスが
0x...fe4→0x...fccと減少しているのがわかります。これは、スタックがRAMの末尾から先頭に向かって消費されている物理的な証拠です。 -
1回のリソース消費量(24バイトの謎):このマイコン(Cortex-M4)では、今回の関数を1回呼ぶごとに 24バイト のメモリを消費しています。
**「4バイトの変数なのに、なぜ24バイト?」**という疑問を持った方は鋭い観察力です!残りの20バイトは、関数が正常に動作するための「見えない管理情報」です:
- ローカル変数
stack_local:4バイト(目に見える部分) - 戻りアドレス(Return Address):4バイト ― 「この関数が終わったら、どこに戻るか」を記録したメモ
- レジスタ退避領域:16バイト ― CPUが使っていたレジスタの値を一時保存する場所(関数から戻るときに復元)
これは、関数を呼び出すたびにCPUが自動的にStackに「付箋」を貼っているようなものです。「この関数を呼ぶ前の状態」を完全に記録しておくことで、関数が終了したときに元の場所に確実に戻れるようにしています。
- ローカル変数
-
無限ループの恐怖:もし終了条件(
if (depth > 0))を忘れて呼び出し続けると、住所はどんどん小さくなり、やがて0x20000000付近にあるグローバル変数の領域を破壊します。これが 「スタックオーバーフロー」 の正体です。
4-3. Stack Pointer(SP)レジスタ
CPUは、Stack Pointer(SP) という特殊なレジスタで、現在のStackの「頂上」を指し示しています。関数が呼ばれるたびにSPが減少し(Stackは高位アドレスから低位アドレスに向かって成長)、関数が終了するとSPが増加します。
デバッガの「Registers」ビューで sp の値を確認すると、関数の呼び出しに伴ってSPが変化する様子が観察できます。
補足:Stack Overflow(スタックオーバーフロー)
Stackの領域は有限です。今回の例ではdepth = 3と浅い再帰ですが、これをstack_test_function(1000)のように深くしすぎると、Stack領域を使い果たして「Stack Overflow」が発生し、プログラムが暴走します。また、大きな配列をローカル変数として宣言しすぎた場合も同様です。デバッガでStack Pointer(SP)の値を監視することで、Stackの使用状況を把握できます。
5. Memoryビューでバイト配列を直接観察する
5-1. なぜMemoryビューが必要なのか?
前述の「Expressions」ビューは、変数の値を人間が読みやすい形式で表示してくれます。しかし、物理メモリ上でデータがどのように保存されているかを理解するには、Memoryビューで生のバイト列を直接観察する必要があります。
特に、STM32(ARMアーキテクチャ)のリトルエンディアン(Little Endian) という特性を理解することは、バイナリデータを扱う際や、ハードウェアレジスタを直接操作する際に重要です。Memoryビューを使えば、この特性を視覚的に確認できます。
5-2. Memoryビューの使い方
手順:
main()関数内のstack_test_function(3);の行にブレークポイントを設定して停止します。- Window → Show View → Memory を選択します。
- Memoryビューの上部のアドレス入力欄に
&flash_constを入力します。
補足:
アドレス入力欄には、具体的なアドレス(例:0x080017a8)だけでなく、変数名を使った式(&flash_const)も直接入力できます。これにより、わざわざExpressionsビューでアドレスを確認する手間が省けます。
5-3. デバッグ時の注意:リトルエンディアンによる見た目の違い
Memoryビューでアドレス 0x0800 17a8 を見ると、以下のように表示されます:
Address | +0 +1 +2 +3 | +4 +5 +6 +7 |
-----------+-------------+-------------+
0x080017A8 | EF BE AD DE | ?? ?? ?? ?? |
flash_const には 0xDEADBEEF という値を代入したはずなのに、Memoryビューでは EF BE AD DE とバイトの順序が逆転して表示されています。実はこれは正常です。
なぜ逆転して見えるのか?
STM32(ARMアーキテクチャ)は、リトルエンディアン(Little Endian) という方式で数値をメモリに保存します。これは「下位バイトを先に保存する」ルールです:
- プログラム上の値:
0xDEADBEEF - バイト分解:
DEADBEEF(最上位バイトから順に) - メモリ上の並び順:
EFBEADDE← 逆転して見える!
なぜこのような仕組みなのか?
リトルエンディアンでは、下位バイトが小さいアドレスに配置されます:
| アドレス | 格納されるバイト | 意味 |
|---|---|---|
| 0x080017A8 | EF |
最下位バイト(Least Significant Byte) |
| 0x080017A9 | BE |
下位から2番目 |
| 0x080017AA | AD |
下位から3番目 |
| 0x080017AB | DE |
最上位バイト(Most Significant Byte) |
この方式により、8ビット、16ビット、32ビットのデータを同じアドレスから読み出せるという利点があります(詳細は後の回で解説します)。
デバッガでの確認ポイント:
- Expressionsビュー:
flash_const→0xDEADBEEFと正常に表示される(CPUが自動的に変換) - Memoryビュー:物理メモリそのままを見るので
EF BE AD DEと逆順に見える
重要な結論:
プログラム(C言語)で変数を読み書きするときは、CPUが自動的に変換してくれるので気にする必要はありません。ただし、Memoryビューでデバッグするときは、バイト順が逆転して見えることを覚えておきましょう。これはバグではなく、STM32の正常な動作です。
Memoryビューによる物理メモリの参照。リトルエンディアン方式でバイトが逆順に格納されている様子が確認できる(プログラム上は 0xDEADBEEF だが、メモリ上は EF BE AD DE)。
5-4. Flash領域の観察
Flash領域に配置された定数は、電源を切っても値が保持されます。また、プログラム実行中は読み取り専用のため、誤って書き換えることはできません。
MemoryビューでFlash領域(0x08…)とSRAM領域(0x20…)を比較すると、以下の違いが観察できます:
- Flash領域:プログラムを再度書き込まない限り、値は永久に固定
- SRAM領域:プログラム実行中に値が変化し、電源を切ると消える
デバッガのMemoryビューで &ram_global_init のアドレスも観察してみると、while(1) ループでインクリメントされるたびに値が変化する様子を確認できます。
6. 実践:各領域の使い分けを体験する
6-1. 実験:定数をFlashに配置する
既に使用している flash_const を使って、定数データがFlashに配置されることを確認しましょう:
/* グローバル領域 */
const uint32_t flash_const = 0xDEADBEEF; // Flash領域に配置される定数
const char message[] = "Hello, STM32!"; // 文字列定数もFlash領域に配置
int main(void) {
HAL_Init();
// 定数のアドレスを取得(最適化除け)
volatile const uint32_t* p_flash_const = &flash_const;
const char *ptr = message; // 文字列の先頭アドレスを指すポインタ
stack_test_function(3); // ★ブレークポイント推奨★
while(1) {
ram_global_init++;
HAL_Delay(1000);
}
}
デバッガで確認すべきポイント:
Expressionsビューで以下を入力して確認します:
&flash_const→0x08...番地(Flash領域)p_flash_const→0x08...番地(flash_constのアドレスを保持)&message→0x08...番地(Flash領域)message[0]→'H'(0x48)
ポインタについて:
&flash_constは「flash_const変数のアドレス」を取得する式です。それをポインタ変数p_flash_constに格納することで、「どこに何があるか」という情報を保存できます。ポインタの詳細な仕組みと使い方は、第4回以降で詳しく解説します。今は「アドレスを扱う変数」という認識で十分です。
定数データは、プログラムコードと同じFlash領域に保存されるため、電源を切っても消えません。組み込みシステムでは、設定値やメッセージテーブルなどをFlashに置くことで、限られたSRAM領域を有効活用できます。
6-2. 実験:グローバル変数とローカル変数の比較
uint32_t global_counter = 0; // SRAM(.data)に配置
void increment(void) {
uint32_t local_counter = 0; // Stack領域に配置
global_counter++; // ★ブレークポイント推奨★
local_counter++;
// デバッガで確認:
// global_counter のアドレスは 0x2000... で固定
// local_counter のアドレスは 0x2001... で関数呼び出しごとに変化する可能性
}
int main(void) {
HAL_Init();
increment(); // 1回目:global_counter=1, local_counter=1
increment(); // 2回目:global_counter=2, local_counter=1(リセット)
while(1) {
ram_global_init++; // main内で別のグローバル変数も更新
HAL_Delay(1000);
}
}
この関数を2回呼び出すと:
global_counterは 1 → 2 と増加(SRAM上で値が保持される)local_counterは毎回 0 → 1 にリセット(関数呼び出しごとに新しく確保される)
Expressionsビューで両方の変数のアドレスを確認すると、以下のような違いが観察できます:
| 変数名 | アドレス | 特徴 |
|---|---|---|
global_counter |
0x2000 0XXX(固定) | プログラム実行中ずっと同じアドレス |
local_counter |
0x2001 XXXX(可変) | 関数呼び出しごとにStack上の位置が変わる可能性 |
7. まとめ:変数の「住所」を理解する
本稿では、C言語の変数宣言とメモリ配置の関係を、実際のアドレスを観察しながら学びました。
7-1. 重要ポイントの復習
-
Flash領域(0x08…)
.textセクション:プログラムコード(機械語命令).rodataセクション:const修飾子をつけた定数が配置される- 電源を切っても消えない(不揮発性)
- 実行中は読み取り専用
-
SRAM領域(0x20…)
.dataセクション:初期値ありグローバル変数(起動時にFlashからコピー).bssセクション:初期値なしグローバル変数(起動時にゼロクリア)- 高速な読み書きが可能
- 電源を切ると消える(揮発性)
-
Stack領域(0x2001…)
- ローカル変数が配置される(セクション分類なし)
- 関数呼び出しごとに自動的に確保・解放される
- SRAM領域の高位アドレスに配置
Flash容量の節約テクニック:
大きなバッファなどは、初期値を指定せずに宣言することで.bssセクションに配置され、Flash容量を節約できます(詳細は2-1-1を参照)。
7-2. デバッガの活用
- Expressionsビュー:変数のアドレスを手軽に確認
- Memoryビュー:物理メモリを直接観察(リトルエンディアンに注意)
- Registersビュー:Stack Pointer(SP)の変化を監視
7-3. 次回への展望
今回、以下の変数を実際に配置して観察しました:
const uint32_t flash_const = 0xDEADBEEF; // .rodata → Flash(0x08...)
volatile uint32_t ram_global_init = 0x12345678;// .data → Flash→SRAM(0x2000...)
volatile uint32_t ram_global_uninit; // .bss → SRAM(0x2000...、ゼロ初期化)
volatile uint32_t stack_local = depth; // Stack → SRAM(0x2001...)
各変数がいつ、どこのアドレスに存在するのかという空間と時間の概念を理解することが、複雑なポインタ操作を安全に行うための基礎となります。
特に重要なポイント:
- 空間(Where):Flash、SRAM低位(.data/.bss)、SRAM高位(Stack)のどこに配置されるか
- 時間(When):プログラム全体で存在するか、関数実行中のみ存在するか
- 初期化(How):Flashからコピーされるか、ゼロクリアされるか、未初期化か
次回は、C言語がメモリをどのように表現するかを深掘りします。変数・配列・構造体が物理メモリ上でどう並ぶのか、そしてなぜC言語が組み込み開発に向いているのかという本質的な理由を、実際のメモリレイアウトを観察しながら理解していきます。
参考資料
公式ドキュメント
本記事で使用しているメモリマップや技術仕様は、STマイクロエレクトロニクス社の公式ドキュメントに基づいています:
- STM32F401xD/xE データシート(PDF)
STM32F401REの電気的特性、ピン配置、メモリ構成などの基本仕様が記載されています。
補足:データシートとリファレンスマニュアルの違い
- データシート(Datasheet):チップの基本仕様(メモリサイズ、動作電圧、ピン配置など)をまとめた資料
- リファレンスマニュアル(Reference Manual):周辺機器の詳細な使い方、レジスタの設定方法などを解説した技術資料(通常1000ページ超)
組み込み開発では、データシートで全体像を把握し、リファレンスマニュアルで詳細を確認する、という使い分けが一般的です。
次回予告
第3回:Cはメモリをどう表現するか ― 変数・配列・構造体の物理的な姿 ―
次回は、C言語の3つの基本要素(変数・配列・構造体)がメモリ上でどのように表現されるかを探求します。
学ぶこと:
- 変数=メモリ:C言語のすべての変数は、物理的なメモリアドレスと1対1で対応している
- 配列=連続:配列要素が隙間なく連続配置される仕組みと、その効率性
- 構造体=レイアウト:複数の型をまとめたとき、メモリ上でどう並ぶのか
- volatile の意味:なぜこの修飾子が組み込み開発で重要なのか(次回以降で深掘りする伏線)
実際のアドレスを表示して確認することで、C言語が「ハードウェアに近い」「組み込みに向く」と言われる理由を体感的に理解します。
💡 この記事で学んだこと
- STM32のメモリマップ(Flash/SRAM/周辺機器レジスタ)の構造とリファレンスマニュアルの読み方
- セクション(.text/.rodata/.data/.bss)の概念とそれぞれの役割
- 変数宣言(const/グローバル/ローカル)とメモリ配置の対応関係
- .dataセクションと.bssセクションの違い(初期値の有無とFlash使用量)
- constとvolatile修飾子の使い分け(Flash配置 vs SRAM配置)
- グローバル変数とローカル変数の寿命の違い(永続 vs 一時的)
- Stackの仕組みと関数呼び出し時のメモリ管理(Push/Pop)
- デバッガ(Expressions/Memoryビュー)を使った実アドレスの確認方法
- Memoryビューでのデバッグ時の注意点(リトルエンディアンによりバイトが逆順に見える)
- 変数のアドレスを取得する方法(&演算子)と最適化の回避