組み込み開発を学び始めると、「レジスタ」「ROM」「RAM」「スタック」「ヒープ」といった言葉が次々に登場します。
レジスタ操作を覚えると、GPIOを制御してLEDを点灯できるようになります。
しかし、安定して動くプログラムを書くには、プログラムや変数がマイコンのどこに保存され、実行中にメモリがどう使われるかも理解しておく必要があります。
この記事では、組み込み初心者に向けて、次の疑問を順番に解消します。
- ROMとRAMは何が違うのか
- スタックとヒープは何に使われるのか
- グローバル変数、ローカル変数、定数はどこに置かれるのか
- 組み込み開発でヒープが避けられることがあるのはなぜか
- メモリ不足をどうやって調べるのか
先にレジスタの仕組みを確認したい方は、こちらの動画もご覧ください。
▶ 【初心者向け】マイコンのレジスタとは?仕組みとレジスタ操作を図解
組み込みにおけるメモリ管理とは
メモリ管理とは、限られた記憶領域を「何に・どれだけ・いつまで使うか」を把握し、正しく扱うことです。
PC向けアプリでは、数GB以上のメモリを使えることも珍しくありません。
一方、小さなマイコンでは、RAMが数KBから数百KBしかない場合があります。
そのため、わずかな配列や再帰呼び出しが原因でメモリ不足になることもあります。
しかも、組み込み機器には長時間の連続動作やリアルタイム性が求められます。
「たまに失敗する」「再起動すれば直る」では済まない製品も多いため、
メモリの使用量と寿命を意識する必要があります。
まず知りたいROMとRAMの違い
マイコンのメモリを大きく分けると、ROM系の領域とRAMに分かれます。
| 項目 | ROM系(主にFlash) | RAM |
|---|---|---|
| 主な用途 | プログラム、定数、初期値 | 実行中の変数、スタック、ヒープ |
| 電源OFF後 | 内容が残る | 内容が消える |
| 書き換え | RAMより遅く、回数に制限がある | 高速で繰り返し読み書きできる |
| 容量の傾向 | 比較的大きい | 比較的小さい |
ROMとは
ROMは「Read Only Memory」の略です。
ただし、現在のマイコンでは、完全な読み出し専用メモリではなく、書き換え可能なFlashメモリにプログラムを保存するのが一般的です。
電源を切っても内容が消えないため、次のようなデータを置きます。
- CPUが実行するプログラム
- 変更しない定数
- 文字列リテラル
- RAMへコピーする変数の初期値
RAMとは
RAMは、プログラムの実行中に変化するデータを置く作業領域です。電源を切ると内容は消えます。
主に次のものが置かれます。
- 書き換える変数
- 関数のローカル変数
- 関数の戻り先などの呼び出し情報
- 動的に確保したデータ
ROMに余裕があっても、RAMが不足すればプログラムは正常に動きません。
組み込み開発では、両方の使用量を別々に確認することが重要です。
プログラムのメモリ領域
C言語のプログラムは、一般に次の領域へ分けて配置されます。
アドレス(高位)
┌─────────────────────┐
│ スタック │ 関数呼び出しで使用
│ ↓ │
│ │ 空き領域
│ ↑ │
│ ヒープ │ 動的メモリ確保で使用
├─────────────────────┤
│ .bss │ 初期値なし/0初期化の静的変数
│ .data │ 初期値ありの静的変数
├─────────────────────┤
│ .rodata │ 読み取り専用データ・定数
│ .text │ プログラムコード
└─────────────────────┘
アドレス(低位)
これは理解のための典型例です。
実際の並び、アドレスの増減方向、領域名はCPU、コンパイラ、リンカスクリプトによって異なります。
text領域
.textには、CPUが実行する機械語の命令が置かれます。通常はFlash上に配置されます。
rodata領域
.rodataには、文字列リテラルや読み取り専用の定数が置かれます。多くの場合、Flashを利用します。
static const char message[] = "Hello";
ただし、constを付ければ必ずFlashだけを使うとは限りません。
配置は処理系や宣言方法、最適化、リンカ設定によって変わるため、最終的にはmapファイルなどで確認します。
data領域
.dataには、0以外の初期値を持つグローバル変数やstatic変数が置かれます。
int counter = 10;
static int mode = 1;
実行中の変数本体はRAMにあります。
一方、その初期値は電源投入前にも保持する必要があるため、Flashにも保存されます。
起動時にスタートアップ処理が初期値をFlashからRAMへコピーします。
つまり、.dataの変数はRAMとFlashの両方を消費する点に注意が必要です。
bss領域
.bssには、初期値を指定していない、または0で初期化するグローバル変数やstatic変数が置かれます。
int error_count;
static uint8_t receive_buffer[256];
起動時にRAM上の対象領域を0で埋めるため、通常は配列全体の初期値をFlashに保存する必要がありません。
ただし、変数本体の分だけRAMは消費します。
スタックとは
スタックは、関数を呼び出すときに一時的な情報を保存する領域です。
後から積んだものを先に取り出す「LIFO(Last In, First Out)」方式で使われます。
一般に、次のような情報が置かれます。
- ローカル変数
- 関数の引数の一部
- 戻り先アドレス
- 退避したCPUレジスタ
void blink(void)
{
int count = 3; // 一般にはスタックを使用する候補
uint8_t work[100]; // 大きな配列はスタックを圧迫する
}
関数を抜けると、その呼び出しで使った領域はまとめて解放されます。
そのため高速で、確保と解放のタイミングも予測しやすいのが特徴です。
ただし、最適化によって変数がCPUレジスタだけに置かれたり、消去されたりする場合があります。「ローカル変数は必ずスタック」とは限りません。
スタックオーバーフローとは
用意されたスタック領域を使い切り、別のメモリ領域を破壊してしまう状態です。
主な原因は次のとおりです。
- 関数内に大きな配列を置く
- 関数の呼び出し階層が深い
- 再帰処理を繰り返す
- 割り込みが多重に発生する
- RTOSの各タスクに割り当てたスタックが小さい
症状は、突然のリセット、HardFault、変数の化け、特定条件だけでの暴走などです。
原因箇所と故障箇所が離れることもあり、調査が難しい不具合になります。
ヒープとは
ヒープは、プログラムの実行中に必要なサイズを指定して確保する領域です。
C言語ではmalloc()、calloc()、free()などを使います。
uint8_t *buffer = malloc(256);
if (buffer != NULL) {
/* bufferを使用 */
free(buffer);
buffer = NULL;
}
必要な期間だけ柔軟にメモリを使える一方、管理を誤ると不具合につながります。
ヒープで起きる代表的な問題
メモリリーク
確保した領域をfree()せず、再利用できないメモリが増え続ける問題です。
断片化(フラグメンテーション)
確保と解放を繰り返すことで空き領域が細切れになり、空き容量の合計は足りていても、大きな連続領域を確保できなくなる問題です。
二重解放
同じ領域を2回free()し、管理情報などを破壊する問題です。
解放後の参照free()した領域を再び読み書きする問題です。
確保失敗の見落としmalloc()が失敗してNULLを返したのに、そのまま参照する問題です。
組み込みではmallocを使わない方がよい?
「組み込みではmalloc()禁止」と一律に決められるわけではありません。
ただし、長時間動作や厳しいリアルタイム性が必要なシステムでは、次の理由から実行中の動的確保を避ける設計があります。
- 確保にかかる時間を一定にしにくい
- 断片化により、長時間後だけ失敗する可能性がある
- 最大メモリ使用量を見積もりにくい
- 解放忘れなどの不具合が入りやすい
代わりに、固定長配列、静的確保、メモリプール、起動時にまとめて確保する方法などが使われます。
重要なのは、「絶対に使わない」ことではなく、製品の要求と使用するメモリアロケータの性質を理解し、失敗時の処理まで設計することです。
スタックとヒープの違い
| 項目 | スタック | ヒープ |
|---|---|---|
| 主な用途 | 関数の一時データ | 実行中にサイズや寿命を決めるデータ |
| 確保・解放 | 関数呼び出しに伴い自動 | プログラムが明示的に管理 |
| 速度 | 一般に速い | 管理処理が必要 |
| 容量 | あらかじめ制限される | ヒープ領域の範囲内 |
| 主な危険 | スタックオーバーフロー | リーク、断片化、二重解放 |
| 予測しやすさ | 比較的高い | 使用方法によって低くなる |
「小さければスタック、大きければヒープ」とサイズだけで決めるのではなく、必要な寿命、最大使用量、リアルタイム性、所有者を基準に選びます。
変数はどこに置かれる?
次のコードを例に見てみましょう。
int global_count; // .bssの候補
int threshold = 100; // .dataの候補
static const char name[] = "LED"; // .rodataなどの候補
void control(void)
{
int state = 0; // スタックまたはレジスタの候補
static int call_count; // .bssの候補
uint8_t *buf = malloc(32); // buf自体はスタック等、確保先はヒープ
if (buf != NULL) {
free(buf);
}
}
| データ | 主な配置先 |
|---|---|
| プログラムの命令 | .text(主にFlash) |
| 文字列・読み取り専用定数 | .rodata(主にFlash) |
| 初期値ありのグローバル/static変数 | .data(実体はRAM、初期値はFlash) |
| 初期値なし・0初期化のグローバル/static変数 | .bss(RAM) |
| 自動変数・関数呼び出し情報 | スタックまたはCPUレジスタなど |
malloc()で確保した領域 | ヒープ |
これは代表例であり、厳密な配置はコンパイラ、最適化、ABI、リンカスクリプトによって決まります。
レジスタとメモリの関係
CPUレジスタは、CPU内部にある非常に高速な小容量の記憶場所です。
演算の途中結果、アドレス、スタック位置などの保持に使われます。
一方、組み込みで「GPIOレジスタ」と呼ばれるものは、周辺回路を制御するためのハードウェアレジスタです。
多くのマイコンでは、これらがメモリマップドI/Oとしてアドレス空間に割り当てられています。
#define GPIO_OUT (*(volatile uint32_t *)0x40020014U)
GPIO_OUT |= (1U << 5);
このコードは通常のメモリアドレスへ書き込む形に見えますが、そのアドレスにはRAMではなくGPIO周辺回路のレジスタが割り当てられています。
volatileは、その読み書きがプログラムの外部要因で変化したり、アクセス自体に意味があったりすることをコンパイラへ伝えるために使います。
ただし、volatileだけで排他制御や処理順序の問題がすべて解決するわけではありません。
マイコンのメモリマップとは
メモリマップは、CPUのアドレス空間のどこに何が割り当てられているかを示した地図です。
CPUのアドレス空間
┌────────────────────┐
│ 周辺回路レジスタ │ GPIO、UART、タイマなど
├────────────────────┤
│ SRAM │ 変数、スタック、ヒープ
├────────────────────┤
│ Flash │ プログラム、定数
├────────────────────┤
│ その他 │ システム制御領域など
└────────────────────┘
実際の開始アドレスとサイズはマイコンごとに異なります。
データシートやリファレンスマニュアルの「Memory map」を確認しましょう。
電源投入からmain関数までに行われること
初心者はmain()からプログラムが始まるように感じますが、その前にスタートアップ処理があります。代表的な流れは次のとおりです。
- CPUがリセットベクタから起動する
- スタックポインタを初期化する
.dataの初期値をFlashからRAMへコピーする.bssを0で初期化する- クロックやC/C++ランタイムを初期化する
main()を呼び出す
この流れを知ると、「初期値あり変数がFlashとRAMの両方を使う理由」や、「グローバル変数が最初から0になっている理由」を理解しやすくなります。
組み込みで起きやすいメモリ関連の不具合
1. 配列の範囲外アクセス
uint8_t data[10];
data[10] = 1; // 有効な添字は0~9
隣にある変数や管理情報を壊す可能性があります。C言語は通常、自動で境界を検査しません。
2. ポインタの初期化忘れ
不定なアドレスへ書き込むと、RAM、周辺レジスタ、禁止領域などを破壊する可能性があります。
3. バッファサイズの計算ミス
終端文字、構造体のパディング、データ型のサイズ、受信データの最大長を見落とすと、オーバーフローにつながります。
4. 割り込みとメイン処理の競合
両方から同じデータへアクセスすると、更新の途中に割り込まれて不整合が生じることがあります。volatileの有無だけでなく、アクセスの原子性、クリティカルセクション、ロックなどを検討します。
5. RTOSタスクのスタック不足
RTOSでは、タスクごとにスタックを持つ構成が一般的です。
通常動作では足りていても、深い呼び出しや割り込み、エラー処理が重なったときだけ不足する場合があります。
メモリ使用量を確認する方法
感覚だけで判断せず、ビルド結果と実行時の両方を確認します。
size情報を見る
ビルド時に表示される.text、.data、.bssなどのサイズを確認します。
GNU系ツールチェーンでは、ELFファイルに対してsizeコマンドを利用できます。
arm-none-eabi-size firmware.elf
RAM使用量を概算するときは、少なくとも.dataと.bssを確認します。
ただし、これだけでは実行時の最大スタック量や最大ヒープ量までは分かりません。
mapファイルを見る
mapファイルには、各関数や変数がどの領域へ配置され、どれだけのサイズを使っているかが記録されます。
特に確認したい項目は次のとおりです。
- サイズの大きな配列
- 意図せずRAMに置かれた定数
- 使用量の大きなライブラリ
- 各セクションの開始・終了アドレス
- スタックとヒープの予約量
実行時の最大使用量を測る
スタック領域を特定の値で埋めておき、どこまで書き換わったかを調べる「スタック・ハイウォーターマーク」がよく使われます。RTOSによっては確認用APIも用意されています。
さらに、MPU、コンパイラの警告、静的解析、デバッガのウォッチポイント、AddressSanitizerを利用できる環境でのホストテストなども有効です。
初心者が押さえるべきメモリ設計のポイント
- 大きな配列を置く前に、必要な最大サイズを計算する
- ローカルの大配列でスタックを圧迫しない
- 動的確保を使う場合は、所有者と解放箇所を明確にする
malloc()の失敗を必ず処理する- 再帰処理は最大の深さを説明できる場合だけ使う
- ビルドごとにROMとRAMの使用量を確認する
- mapファイルで実際の配置を確認する
- 最悪条件でのスタック使用量を測る
- 割り込みやDMAが同じバッファへ触れる可能性を考える
- メモリ不足時の動作を設計する
よくある質問
ROMとFlashは同じですか?
厳密には同じではありません。ROMは不揮発性の読み出し中心のメモリを表す広い言い方として使われます。現在の多くのマイコンでは、プログラム保存用の不揮発性メモリとして書き換え可能なFlashが使われています。
RAMが多ければプログラムも大きくできますか?
プログラムコードは主にFlashへ置かれるため、コードの大きさにはFlash容量が関係します。
一方、実行時の変数、スタック、ヒープにはRAMが必要です。
どちらか一方に空きがあっても、もう一方が不足すれば実装できません。
ローカル変数はすべてスタックに置かれますか?
いいえ。最適化によりCPUレジスタへ置かれたり、不要と判断されて消えたりすることがあります。staticを付けたローカル変数は、自動変数とは異なり、一般に.dataや.bssへ配置されます。
constを付ければRAMを使いませんか?
必ずしもそうではありません。
CPUやコンパイラ、宣言、アクセス方法、リンカ設定によって配置が変わります。
mapファイルで確認するのが確実です。
スタックとヒープはどちらが速いですか?
一般にはスタックの確保・解放の方が単純で高速です。
ただし、最適化やメモリアロケータ、キャッシュ、システム構成によって実際の性能は変わります。
速度だけでなく、データの寿命と予測可能性を基準に選びましょう。
組み込みでmallocを使ってはいけませんか?
禁止とは限りません。断片化、実行時間、確保失敗、最大使用量を管理できるかが判断基準です。
リアルタイム性や長期安定性が重要な製品では、固定長のメモリプールや静的確保が選ばれることがあります。
まとめ
組み込みのメモリ管理では、次の違いを押さえることが第一歩です。
- Flash(ROM系):プログラムや定数を保存し、電源を切っても残る
- RAM:実行中の変数、スタック、ヒープなどに使い、電源を切ると消える
- スタック:関数呼び出しに伴う一時データを管理する
- ヒープ:実行中に必要なサイズのメモリを動的に確保する
- .data/.bss:グローバル変数や
static変数などを置く - 周辺レジスタ:メモリマップドI/Oとしてアドレス空間に配置されることが多い
レジスタ操作が「マイコンの周辺回路をどう動かすか」の学習なら、メモリ管理は「そのプログラムとデータをどう安全に置くか」の学習です。
まずは自分のプロジェクトのFlashとRAMの容量を調べ、ビルド後のsize情報とmapファイルを確認してみましょう。メモリを数字で見られるようになると、原因不明に見えた不具合を設計段階から減らせるようになります。

