前回の記事では、CPUのアドレス空間にRAMや周辺回路のレジスタなどが配置されるメモリマップドI/Oについて解説しました。
では、プログラムで宣言した変数や配列は、RAMのどこに保存されるのでしょうか。
プログラムがRAMを使用する仕組みを理解するときに重要になるのが、スタック(Stack)とヒープ(Heap)です。
スタックは主に関数呼び出しやローカル変数などに使われ、ヒープはプログラム実行中に必要なサイズのメモリを動的に確保するときに使われます。
この記事では、スタックとヒープの違いから、関数を呼び出したときのスタックの動き、malloc()やnewによるヒープの利用、スタックオーバーフローやメモリリークなど、組み込み開発で重要になるメモリの基礎を解説します。
スタックとヒープとは?
スタックとヒープは、どちらもプログラムが実行中にRAMを利用するための領域です。
ただし、メモリを確保・解放する方法が大きく異なります。
| スタック | ヒープ | |
|---|---|---|
| 主な用途 | 関数呼び出し、ローカル変数など | 動的に確保するデータ |
| 確保 | 基本的に自動 | プログラムから要求 |
| 解放 | 基本的に自動 | 明示的な解放が必要な場合がある |
| 管理 | 規則的 | 動的 |
| 主な問題 | スタックオーバーフロー | メモリ不足、リーク、断片化など |
上図のように、スタックとヒープはどちらもRAMを使用しますが、目的や管理方法が異なります。
スタックとは?
スタック(Stack)は、関数を呼び出したときの情報やローカル変数などを一時的に保存するために使われる領域です。
たとえば次の関数を考えてみます。
void func() {
int value = 10;
}
valueは関数内で宣言されたローカル変数です。
このようなローカル変数は一般にスタック上へ配置されることがあります。
ただし、コンパイラの最適化によってCPUレジスタだけで扱われるなど、必ず物理的にスタックへ置かれるとは限りません。
関数を呼び出すとスタックはどう動く?
スタックという名前のとおり、データを積み重ねるように使用します。
たとえばmain()からfuncA()を呼び、その中からfuncB()を呼び出すとします。
main() ↓ funcA() ↓ funcB()
概念的には、関数を呼び出すたびに必要な情報がスタックへ積まれていきます。
┌─────────────────┐ │ funcB の情報 │ ← 新しく積まれる ├─────────────────┤ │ funcA の情報 │ ├─────────────────┤ │ main の情報 │ └─────────────────┘
関数から戻ると、その関数のために使用していた領域は不要になり、スタックは以前の状態へ戻ります。
スタックフレームとは?
関数呼び出しのためにスタック上へ確保される領域を、スタックフレームと呼ぶことがあります。
スタックフレームには、CPUアーキテクチャやコンパイラなどによって、次のような情報が含まれる場合があります。
- ローカル変数
- 戻り先に関する情報
- 退避したCPUレジスタ
- 関数の引数の一部
- 一時的なデータ
関数を呼び出すたびに同じ大きさの領域が使われるわけではなく、その関数が必要とする情報によってスタック使用量は変化します。
スタックはLIFOで動く
スタックはLIFO(Last In, First Out)という考え方で説明されます。
日本語では「後入れ先出し」です。
① Aを積む A ② Bを積む B A ③ Cを積む C B A ④ 取り出す C ← 最初に取り出される B A
最後に積んだものから先に取り出されるため、関数呼び出しとの相性がよい仕組みです。
スタックポインタとは?
CPUには、現在のスタック位置を管理するためのスタックポインタ(Stack Pointer:SP)があります。
関数呼び出しなどによってスタックを使用すると、スタックポインタが変化します。
関数から戻るとスタックポインタも戻り、使用していた領域を再び利用できるようになります。
このため通常のローカル変数では、プログラマがfree()のような処理を書かなくてもスタック領域が自動的に再利用されます。
スタックオーバーフローとは?
スタックとして利用できる領域には限りがあります。
スタックを使いすぎて、用意された領域を超えてしまうことをスタックオーバーフロー(Stack Overflow)と呼びます。
たとえば次のような処理では、スタック使用量が大きくなる可能性があります。
- 大きなローカル配列を作る
- 関数を非常に深く呼び出す
- 再帰処理を繰り返す
- 1つの関数内で大量のローカルデータを使用する
void func() {
uint8_t buffer[10000];
}
PCでは気にならないサイズでも、RAMが限られたマイコンでは大きな負担になる場合があります。
再帰処理とスタック
スタックを理解するときに分かりやすい例が再帰呼び出しです。
void func() {
func();
}
この関数には終了条件がないため、自分自身を繰り返し呼び出します。
func() ↓ func() ↓ func() ↓ func() ↓ ...
関数呼び出しの情報が積み重なり続け、最終的に利用可能なスタック領域を使い切る可能性があります。
ヒープとは?
ヒープ(Heap)は、プログラム実行中に必要なサイズのメモリを動的に確保するために使われる領域です。
Cではmalloc()など、C++ではnewなどを使って動的にメモリを確保できます。
int *data;
data = (int *)malloc(sizeof(int) * 10);
この例では、整数10個分の領域を実行中に確保しています。
必要なサイズを実行時に決められるのが、ヒープを使う大きな特徴です。
malloc()で確保したメモリはfree()する
malloc()などで確保した領域は、不要になったらfree()で解放します。
int *data;
data = (int *)malloc(sizeof(int) * 10);
if (data != NULL) {
// dataを使用する
free(data);
}
malloc()はメモリ確保に失敗するとNULLを返すため、失敗する可能性も考慮する必要があります。
C++のnewとdelete
C++ではnewを使って動的にオブジェクトや配列を確保することもできます。
int *data = new int[10];
// dataを使用する
delete[] data;
new[]で確保した配列はdelete[]で解放します。
なお、標準C++の通常のnewは確保に失敗すると例外を送出するのが基本で、malloc()のように単純にNULLを返すものとして扱うことはできません。実際の組み込み環境では、例外設定やライブラリ構成も確認する必要があります。
メモリリークとは?
ヒープを使うときに注意したい問題の1つがメモリリーク(Memory Leak)です。
動的に確保したメモリが不要になったにもかかわらず解放せず、その領域を再利用できなくなる状態です。
void loop() {
int *data = (int *)malloc(100);
// free(data); がない
}
このような処理を繰り返すと、確保可能なヒープが徐々に減っていく可能性があります。
長時間連続して動作する組み込み機器では、メモリリークが時間の経過とともに不具合として現れることがあります。
ヒープの断片化とは?
ヒープでは、メモリの確保と解放を繰り返すことで断片化(Fragmentation)が問題になることがあります。
たとえば、ヒープが次のような状態になったとします。
使用中 | 空き | 使用中 | 空き | 使用中 | 空き
空き容量の合計は十分に見えても、大きな連続領域が存在しなければ、大きなメモリブロックを確保できない場合があります。
これがヒープの断片化です。
スタックとヒープは本当に向かい合って伸びるの?
スタックとヒープを説明する図では、ヒープが低いアドレスから高いアドレスへ、スタックが高いアドレスから低いアドレスへ伸びるように描かれることがあります。
これは仕組みを理解するための代表的なイメージですが、すべてのマイコンや実行環境が必ずこの配置になるわけではありません。
実際の配置はCPUアーキテクチャ、リンカスクリプト、OS・RTOS、SDKなどによって異なります。
そのため「スタックは必ず上から下、ヒープは必ず下から上」と暗記するより、それぞれが異なる方法で管理されるメモリ領域だと理解することが重要です。
グローバル変数はスタック?ヒープ?
ここまで読むと、「ではグローバル変数はどこに保存されるの?」という疑問が出てきます。
int count = 10;
void setup() {
}
void loop() {
}
このcountのようなグローバル変数は、通常、スタックや動的に確保するヒープとは別の静的記憶領域として扱われます。
初期値を持つデータ、初期値を持たないデータなどは、実行ファイルやリンカの構成に応じて.dataや.bssなどのセクションとして管理されます。
プログラムコードはどこにある?
プログラムそのものの機械語も、スタックやヒープに通常保存するわけではありません。
マイコンでは一般に、プログラムコードはフラッシュメモリなどへ格納されます。
ここまでを単純化すると、次のように考えられます。
| 種類 | 主な用途 |
|---|---|
| プログラム領域 | 実行するコード |
| .data / .bss など | グローバル変数、static変数など |
| ヒープ | 動的に確保するデータ |
| スタック | 関数呼び出し、ローカル変数など |
組み込み開発ではスタックサイズが重要
PC向けソフトウェアと比較して、マイコンでは使用できるRAMが限られています。
そのため、スタックをどれだけ使用しているかを意識することが重要です。
特に次のようなコードには注意します。
- 大きなローカル配列
- 大きな構造体のローカル変数
- 深い関数呼び出し
- 再帰処理
- スタックサイズの小さいRTOSタスク
RTOSではタスクごとにスタックを持つことがある
ESP32などで利用されるFreeRTOSのようなRTOSでは、各タスクにスタック領域が割り当てられます。
そのためシステム全体のRAMに空きがあったとしても、あるタスクに割り当てたスタックを使い切れば、そのタスクでスタックオーバーフローが発生する可能性があります。
「RAM全体が余っているからスタックも大丈夫」とは限らない点が重要です。
ESP32-C3ではスタックとヒープをどう考える?
KUMITATE-C3に搭載されているESP32-C3では、Arduino環境を使用していても内部ではFreeRTOSが利用されています。
そのため、ESP32-C3でより高度なプログラムを作るようになると、ヒープの空き容量だけでなく、タスクごとのスタック使用量も重要になってきます。
Wi-Fi、Bluetooth、通信処理、大きなJSONデータ、画像データなどを扱うプログラムではメモリ使用量が増えるため、メモリ不足が不具合の原因になることもあります。
組み込みでは動的メモリ確保を使ってはいけない?
「組み込みではmalloc()やnewを使ってはいけない」と聞くことがありますが、常に禁止というわけではありません。
重要なのは、システムに求められる信頼性や実行時間、メモリ容量などを考えて使い分けることです。
たとえば長期間連続動作し、メモリ使用量を予測可能にしたいシステムでは、実行中の動的確保を減らしたり、起動時だけ確保したり、固定サイズのメモリプールを使用したりする設計があります。
一方、ESP32を使った一般的なアプリケーションでは、ライブラリ内部を含めて動的メモリが利用されることも珍しくありません。
「使う・使わない」ではなく、どれだけ使い、いつ確保し、いつ解放するのかを把握することが重要です。
メモリの問題は再現しにくいことがある
スタックオーバーフローやヒープの問題は、プログラムを書いた直後には発生せず、特定条件や長時間動作したあとに現れることがあります。
たとえばメモリリークなら、起動直後は正常でも、数時間・数日と動作するうちに利用可能なメモリが減っていく場合があります。
そのため実際の製品開発では、単に「動いた」で終わらせず、長時間動作や異常系も含めてメモリ使用状況を確認することが重要です。
スタックとヒープの違いを整理しよう
| 項目 | スタック | ヒープ |
|---|---|---|
| 確保 | 関数呼び出しなどに伴い自動的に管理 | 必要に応じて動的に確保 |
| 解放 | 関数終了などに伴い自動 | 明示的な解放が必要な場合がある |
| 速度 | 一般に管理が単純で高速 | 管理処理が必要 |
| サイズ | あらかじめ制限されることが多い | 利用可能なヒープ領域の範囲 |
| 代表例 | ローカル変数、関数呼び出し情報 | malloc、newによる動的データ |
| 代表的な問題 | スタックオーバーフロー | リーク、断片化、確保失敗 |
まとめ
スタックとヒープは、どちらもプログラムがRAMを利用するための重要な仕組みです。
- スタックは主に関数呼び出しやローカル変数などに使われる
- 関数から戻るとスタック領域は自動的に再利用される
- スタックを使いすぎるとスタックオーバーフローが発生する
- ヒープは実行中に必要なメモリを動的に確保するために使われる
- malloc()で確保した領域は不要になったらfree()する
- 動的メモリではメモリリークや断片化に注意する
- グローバル変数やstatic変数は通常、スタックや動的ヒープとは別に管理される
- RTOSではタスクごとにスタックが用意されることがある
スタックとヒープを理解すると、「変数を宣言したらRAMのどこかに置かれる」という漠然とした理解から、プログラムが実際にどのようにメモリを使用して動いているのかを考えられるようになります。
メモリ不足や突然のクラッシュを調査するときにも、スタックとヒープの知識は重要になります。

