組み込みCでヒープが禁止される理由――malloc/freeは本当に危険なのか

組み込みソフトウェアの開発では、社内規約によってヒープの使用が禁止されていることがあります。

これに対して、次のような説明を見かけることがあります。

解放忘れやメモリ断片化によって、領域の確保に失敗することがあるため、組み込みではヒープを使用しないところが多い。

この説明は、方向性としては間違っていません。

しかし、組み込みでヒープが避けられる理由は、解放忘れやメモリ断片化だけではありません。リアルタイム性や長期安定性、メモリ使用量の予測可能性なども関係しています。

今回は、C言語におけるヒープの基本から、組み込みソフトウェアで禁止される理由まで整理します。

ヒープとは

ヒープとは、プログラムの実行中に、必要な大きさのメモリを動的に確保するための領域です。

C言語では、主に次の関数を使用します。

  • malloc
  • calloc
  • realloc
  • free

例えば、100個の整数を格納する領域は、次のように確保できます。

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *data = malloc(sizeof(int) * 100);

    if (data == NULL) {
        /* メモリの確保に失敗した */
        return EXIT_FAILURE;
    }

    /* dataを使用する処理 */

    free(data);
    data = NULL;

    return EXIT_SUCCESS;
}

必要なメモリサイズを実行時に決められることが、ヒープを利用する大きな利点です。

一方で、malloc による確保は必ず成功するわけではありません。また、確保した領域が不要になったら、基本的には free で解放する必要があります。

静的領域、スタック、ヒープの違い

C言語のプログラムで使われるメモリは、大まかに次のように分類できます。

静的領域

グローバル変数や、static を付けた変数が配置される領域です。

static unsigned char buffer[1024];

プログラムの開始から終了まで存在します。必要な容量を事前に計算しやすい反面、実行中にサイズを変更することはできません。

スタック

関数内の自動変数や、関数呼び出しに必要な情報などが置かれる領域です。

void func(void)
{
    unsigned char buffer[128];
}

通常は関数に入ると確保され、関数から戻ると自動的に解放されます。

ただし、大きな配列を置いたり、再帰呼び出しを繰り返したりすると、スタックオーバーフローを起こす可能性があります。

ヒープ

プログラムの実行中に必要な領域を確保し、任意のタイミングで解放するための領域です。

サイズや寿命を柔軟に決められる一方で、管理を誤るとメモリリークや不正アクセスにつながります。

なお、「空いているRAMがすべてヒープになる」とは限りません。組み込み環境では、ヒープの配置やサイズがリンカスクリプトやCランタイムの実装によって決められていることがあります。

また、RTOSが提供するメモリ確保機能は、標準Cライブラリの malloc/free とは別の仕組みになっている場合があります。

ヒープを安全に使うための基本

ヒープを使用する場合は、最低限、次の点を考える必要があります。

  • メモリの確保失敗を処理する
  • 誰が解放するのかを明確にする
  • 二重解放を防ぐ
  • 解放後のメモリを使用しない
  • 解放忘れを防ぐ
  • 配列の範囲外にアクセスしない
  • 割り込み処理や複数スレッドからの利用に注意する

mallocの戻り値を確認する

malloc は、メモリを確保できなかった場合に NULL を返します。

したがって、戻り値の確認が必要です。

int *data = malloc(sizeof(int) * count);

if (data == NULL) {
    /* 確保失敗時の処理 */
}

ただし、単に NULL を確認すれば安全というわけではありません。

組み込み機器では、確保に失敗したときに何をするのかも設計する必要があります。

  • 処理を中断する
  • 機能を縮退させる
  • エラーを記録する
  • システムを安全な状態へ移行する
  • ウォッチドッグなどを利用して再起動する

製品の性質によって、適切な動作は異なります。

メモリの所有者を決める

動的に確保したメモリについては、「誰が解放するのか」を明確にする必要があります。

例えば、関数が確保した領域を呼び出し元に返す場合、呼び出し元が解放するというルールを決めます。

このルールが曖昧だと、次のような問題が発生します。

  • 誰も解放せず、メモリリークになる
  • 複数箇所から解放して、二重解放になる
  • 解放済みの領域を使ってしまう

特に規模の大きなプログラムでは、コード上のルールだけでなく、APIの仕様書にも所有権を記載した方が安全です。

reallocは一時変数で受ける

realloc の結果を、元のポインタへ直接代入するコードには注意が必要です。

ptr = realloc(ptr, new_size);

realloc が失敗すると NULL が返されます。しかし、元の領域は解放されず、そのまま残ります。

直接代入すると元のポインタを失い、残っている領域を解放できなくなる可能性があります。

次のように一時変数で受ける方が安全です。

void *tmp = realloc(ptr, new_size);

if (tmp != NULL) {
    ptr = tmp;
} else {
    /* ptrは引き続き有効 */
}

あえてfreeしない設計もある

malloc したら、必ず free しなければならない」と説明されることがあります。

基本的な学習ルールとしては正しいのですが、実際の設計では、意図的に個別の free を行わないこともあります。

起動時に確保し、その後は使い続ける

組み込み機器では、初期化中に必要なメモリをすべて確保し、通常運転に入った後は新しい確保や解放を行わない設計があります。

確保されたメモリは、電源が切られるか、システムが再起動するまで使い続けます。

この方式には、通常運転中の断片化や確保時間の変動を避けやすいという利点があります。

アリーナ方式でまとめて破棄する

アリーナ方式では、ある処理に必要なメモリを専用の領域から順番に確保します。

個々の領域は解放せず、その処理が終わったときにアリーナ全体をまとめて破棄します。

個別の free が不要になるため、所有権や解放順序を単純化できます。

プロセス終了時にOSへ任せる

OS上で動く一般的なアプリケーションでは、プロセス終了時に、そのプロセスが使用していたメモリをOSが回収します。

そのため、終了直前にすべてのメモリを個別に解放しない設計もあります。

ただし、長時間動作するプログラムや、同じ処理を繰り返すプログラムでは、この考え方を安易に適用できません。

解放忘れとの違い

重要なのは、次の2つを区別することです。

解放する必要があるのに、忘れてしまった

設計上、プログラムの動作中は解放する必要がない

前者はメモリリークです。

後者は、メモリの寿命を意図的にプログラムやシステムの寿命と一致させた設計です。

単に「free が書かれていない」という理由だけで、必ずしもメモリリークとは判断できません。メモリの用途と寿命を確認する必要があります。

メモリ断片化が起きる仕組み

動的な確保と解放を繰り返すと、使用中の領域の間に小さな空き領域が残ることがあります。

例えば、最初にA、B、Cという3つの領域を確保したとします。

[   A   ][   B   ][   C   ][        空き        ]

その後、Bだけを解放します。

[   A   ][ 空き  ][   C   ][        空き        ]

空き容量は増えましたが、空き領域が2か所に分かれています。

ここで、それぞれの空き領域よりも大きな連続領域を要求すると、空き容量の合計が足りていても確保に失敗する可能性があります。

これが外部断片化です。

外部断片化

外部断片化とは、空き領域が小さく分散してしまい、大きな連続領域を確保できなくなる状態です。

例えば、合計で1,000バイトの空きがあったとしても、それが200バイトずつ5か所に分かれていれば、500バイトの連続領域を確保できない可能性があります。

内部断片化

アロケータは、要求されたサイズをそのまま割り当てるとは限りません。

アライメントや管理単位などの都合によって、要求したサイズより大きな領域が割り当てられることがあります。その結果、確保された領域の内部に、利用されない部分が生じます。

これを内部断片化と呼びます。

断片化の程度は実装によって異なる

動的確保を使ったからといって、必ず深刻な断片化が発生するわけではありません。

影響の大きさは、次の要素によって変わります。

  • 確保するサイズ
  • 確保と解放の順序
  • 同時に存在するオブジェクトの寿命
  • 使用するアロケータのアルゴリズム
  • システムの連続運転時間

そのため、「ヒープを使うと必ず断片化して停止する」という説明も正確ではありません。

問題は、製品に必要な動作期間を通して、断片化を含む最悪条件を許容できるかどうかです。

組み込みで問題になるのは断片化だけではない

組み込みソフトウェアでヒープが禁止される理由として、メモリリークや断片化がよく挙げられます。

しかし、特にリアルタイム性や安全性が求められる製品では、別の問題も重要です。

実行時間を予測しにくい

一般的なアロケータは、空き領域を検索したり、領域を分割・結合したりします。

そのため、mallocfree の実行時間が、毎回同じになるとは限りません。

通常のPCアプリケーションでは問題にならない程度の時間差でも、決められた時刻までに処理を完了しなければならないリアルタイムシステムでは問題になることがあります。

平均的に高速であることと、最悪実行時間を保証できることは別の話です。

排他制御によって待たされる可能性がある

複数のタスクやスレッドから同じヒープを利用する場合、内部で排他制御が行われることがあります。

その結果、別のタスクが処理を終えるまで待たされる可能性があります。

また、割り込み処理からの動的メモリ確保が禁止されている環境もあります。使用するCライブラリやRTOSの仕様を確認する必要があります。

確保失敗を完全には排除できない

ヒープには上限があります。

さらに、断片化が発生する方式では、空き容量の合計だけを見ても、次の確保が成功するか判断できない場合があります。

確保失敗への対処をすべての呼び出し箇所で設計する必要があり、ソフトウェアの検証も複雑になります。

長時間動作後にだけ問題が現れる

メモリリークや断片化の問題は、起動直後には現れないことがあります。

数日、数か月、場合によっては数年動作した後にだけ発生する不具合は、再現や原因調査が困難です。

長期間の連続運転が要求される製品ほど、このリスクを慎重に評価する必要があります。

最悪時のメモリ使用量を説明しにくい

固定長の静的領域であれば、必要な容量をビルド時に計算しやすくなります。

一方、実行経路によって確保量が変化するプログラムでは、最悪時にどれだけメモリを使用するのか、確保に失敗しないことをどう保証するのかを説明する必要があります。

安全性が重視される製品では、「通常は問題なく動く」だけでは不十分です。最悪条件でも安全に動作することを示さなければなりません。

ヒープを使わないための方法

ヒープを禁止する場合でも、必要なデータをどこかに保持しなければなりません。

代表的な代替手段には、次のようなものがあります。

静的配列

最大サイズが事前に分かっている場合は、静的な配列を用意できます。

#define BUFFER_SIZE 1024

static unsigned char buffer[BUFFER_SIZE];

使用量を予測しやすい一方、実際には少量しか使わない場合でも、最大サイズ分の領域を常に消費します。

固定サイズのメモリプール

同じ大きさの領域をあらかじめ複数用意し、必要になったら未使用の領域を貸し出す方式です。

[使用中][空き][使用中][空き][空き]

固定サイズで管理するため、外部断片化を避けやすく、確保と解放にかかる時間も予測しやすくなります。

ただし、用意した個数を使い切ると、それ以上は確保できません。そのため、必要な最大個数を設計段階で決める必要があります。

オブジェクトプール

特定の構造体をあらかじめ一定数用意しておく方式です。

#define OBJECT_COUNT 16

static struct object objects[OBJECT_COUNT];

通信パケット、メッセージ、タスク管理情報など、同じ型のデータを繰り返し使用する処理に向いています。

リングバッファ

通信データやログなど、順番に追加・取り出しを行うデータにはリングバッファが利用できます。

固定容量で動作するため、メモリ使用量を予測しやすい方式です。

起動時だけ動的確保する

初期化中は動的確保を許可し、通常運転へ移行した後は禁止する方法もあります。

これにより、設定に応じた柔軟な初期化を行いつつ、通常運転中の確保失敗や断片化のリスクを抑えられます。

組み込みではヒープを使ってはいけないのか

組み込みソフトウェアだからといって、必ずヒープを禁止しなければならないわけではありません。

例えば、次のような環境では動的メモリ確保が使われることがあります。

  • Linuxを搭載した組み込み機器
  • 比較的多くのRAMを持つ機器
  • プロセスの再起動や復旧が可能なシステム
  • 厳密なリアルタイム性を要求されない機能
  • 使用するアロケータの特性を十分に評価できる環境
  • 起動時だけ動的確保を行うシステム

反対に、次のような環境では禁止または制限されやすくなります。

  • RAM容量が非常に小さい
  • ハードリアルタイム性が要求される
  • 長期間、再起動せずに動作する
  • 確保失敗からの復旧が難しい
  • 人命や設備に関わる安全性が求められる
  • 最悪実行時間や最大メモリ使用量の証明が必要

つまり、判断基準は「組み込みかどうか」だけではありません。

製品の要求に対して、動的メモリ確保の時間と容量を十分に管理できるかどうかが重要です。

「組み込みではヒープ禁止が多い」は正しいのか

冒頭のコメントにあった「解放忘れやメモリ断片化による確保失敗があるため、使用禁止のところが多い」という説明は、問題点の一部を正しく捉えています。

ただし、次の点には補足が必要です。

第一に、組み込み分野は非常に広いため、ヒープ禁止がどの程度一般的かは、製品や業界によって異なります。

小規模なマイコン製品と、Linuxを搭載した高性能な組み込み機器を、同じ基準で語ることはできません。

第二に、禁止される理由は解放忘れや断片化だけではありません。

組み込み開発では、次の点も重要です。

  • 実行時間の予測可能性
  • 確保失敗時の安全性
  • 長期間動作した場合の安定性
  • 最大メモリ使用量の解析
  • テストや検証のしやすさ

そのため、元の説明は「方向性としては合っているが、理由の説明としては不十分」と評価するのが妥当でしょう。

まとめ

ヒープは、プログラムの実行中に必要なサイズのメモリを確保できる便利な仕組みです。

一方、組み込みソフトウェアでは、次のような理由から使用を禁止または制限することがあります。

  • メモリリークを防ぐため
  • 二重解放や解放後使用を防ぐため
  • メモリ断片化を避けるため
  • 確保失敗を予測しやすくするため
  • 処理時間を一定に近づけるため
  • 最大メモリ使用量を解析しやすくするため
  • 長期間の安定動作を保証しやすくするため

ただし、ヒープそのものが常に危険というわけではありません。

重要なのは、製品の要求と使用するメモリアロケータの特性を理解し、時間と容量の最悪条件を管理できるかどうかです。

「組み込みだからヒープを使わない」のではなく、「この製品では何を保証する必要があり、そのためにどのメモリ管理方式を選ぶのか」と考えることが大切です。

KUMITATE

ESP32を、読むだけでなく
実際に動かして学びませんか?

クミタテは、組み込み開発を実機とオンライン教材で学べる 学習プラットフォームです。ESP32、電子回路、基板設計などの 教材を自分のペースで学習できます。

  • 無料で閲覧できる学習コンテンツあり
  • 実際に手を動かせるサンプルを掲載
  • Googleアカウントで登録可能
クミタテに無料登録する
スポンサーリンク