組み込みまするβ

組み込みソフトウェア初学者向けブログ

割り込み処理でprintfが危険な理由

組み込みシステムではデバッグやリアルタイム動作のログ等で、今でもprintfがよく使用されます。 このprintfを割り込みコンテキストで実行すると危険な場合があるのですが、 そのことを意識せずに使用してしまっているコードを見ることがあり、 注意喚起のために危険性を解説する記事を書きました。

組み込みシステムの標準入出力

組み込みシステムには標準入出力となる画面やキーボード等が無い場合も多いため、 ホストPCとターゲットとなる組み込みシステムをシリアルケーブルやUSBケーブルで接続し、 TeraTerm等のターミナルソフトでログ表示や簡易シェルを標準入出力の代替として利用していると思います。

そして危険となる要因は割り込みなのですが、よく使われるUARTでの実装を例に説明していきます。

出力先となる画面が無いと所謂printfは使えないと考える方もいると思いますが、 シリアルケーブルを介してホストPCのターミナルソフトと通信したり、 Ethernetが使えればtelnet、キャラクタ型LCDに出力したりと対応ができないわけではありません。 他にもシステム設計時から考慮する必要はありますが、出力したい文字列をDPRAMを介して上位CPUに渡し、 上位CPUがアクセス可能なUART等から出力するという構成も考えられます。 組み込みシステムには決まった構成というものはないので臨機応変に実現方法を検討することが重要です。

printfからUARTへ送信する

まずはUARTへ送信する専用のprintfとしてmy_printfを作成しましょう。 問題となる対象ではないため、便宜上vsprintfやvsnprintfを利用して簡単に作成することにします。

#include <stdio.h>
#include <stdarg.h>
#include <string.h>

int my_printf(const char* format, ...)
{
    char s[256];
    size_t len;
    va_list arg;

    va_start(arg, format);
    vsnprintf(s, 256, format, arg);
    len = strlen(s);
    my_write(s, len);
    va_end(arg);

    return len;
}

次に文字列を出力するmy_writeを実装します。 このmy_writeで標準出力の代わりとなるUARTへデータを書き込みます。 バッファを渡して書き込む方法やデータを1つずつ書き込む方法もありますが、 割り込み効率からバッファ渡しの書き込み方法を採用しました。

Uartへデータを書き込む

void my_write(const char* str, size_t count)
{
    while (Uart_write(str, count));
}

シリアルデータ送信の仕組み

UART送信の概説として、送信データレジスタにデータを書き込むと、 送信シフトレジスタに転送され、パラシリされて1ビットずつTXD端子から送信されるようになっています。

この送信データレジスタはデータを1つしか格納できないタイプやFIFO構造になってるタイプもあります。 しかしながらprintfで数十文字の出力要求を考えるとFIFO構造タイプであっても格納容量としては足りないため、 ドライバ内部で(固定長のキュー等)バッファを設けて送信データをバッファリングします。 そして送信データレジスタの空きを契機に割り込みを発生させ、 割り込み処理でバッファリングしているデータを送信データレジスタに書き込みます。

小規模組み込みシステムではメモリ等のハードウェアリソースが乏しいため、 メモリの断片化抑制やメモリを食いつぶさないようにバッファは固定長での作成が一般的です。

ではUART_writeの実装を見てみましょう。 送信バッファとして利用しているキューは割り込み処理でも通常処理(非割り込み処理)でもアクセスするため排他制御が必要です。 このため、アクセス前にまずUARTの割り込みを禁止します(UARTデバイスによっては送信割り込みのみを禁止にすることも可能)。 そして送信キューに空きがあれば書き込みたいデータを格納し、割り込みを再び許可状態にして戻ります。

UART書き込み処理

/**
 * @param  data_buff       data buffer
 * @param  data_count      number of data
 * @retval 0               success
 * @retval !=0             failure
 */
int Uart_write(const uint8_t data_buff[], const size_t data_count)
{
    int rc = 1;

    /**
    * 前提条件:割り込み禁止期間は割り込み最小間隔より十分短い時間とする
    */
    interrupt_disable();

    /* 送信キューの利用可能(空き)サイズがデータ数以上か */
    if (Queue_availableSize(txQueue) >= data_count) {
        for (size_t i = 0; i < data_count; i++) {
            /* 送信キューにデータをプッシュする */
            Queue_push(txQueue, data_buff[i]);
        }
        rc = 0;
    }

    interrupt_enable();

    return rc;
}

次に送信割り込み処理の実装ですが、これは送信データレジスタに空きができたのを契機に実行される割り込み関数です。 送信キューに有効なデータがあれば、それを取り出して送信データレジスタへ書き込みます。 簡略化していますが、これがUART送信処理の一般的な実装の1つです。

送信割り込み処理

void Uart_interruptTx(void)
{
    /**
    * 送信キューにデータが無ければ戻る(早期リターン)
    * UART仕様によっては、ここで送信割り込みを禁止してもよい
    */
    if (Queue_empty(txQueue)) { return; }

    /* 送信キューの先頭データを送信データレジスタに書き込む */
    WRITE_REG_UART_TXDATA(kBASE_ADDR, Queue_front(txQueue));

    /* 使用した送信キューの先頭データを捨てる */
    Queue_pop(txQueue);
}

なぜ危険なのか?

最初に戻って割り込み処理中にprintfを実行すると何故危険なのでしょうか? (ここで言う割り込み処理とは、もちろんUART以外の割り込み処理を指しています)

まず、一般的に多重割り込みを有効にしていなければ、 割り込み処理中は他の割り込み処理に割り込まれることはありません(稀にハードウェアで支援してくれるCPUもありますが)。 ということを理解した上でprintfから追っていくと、

my_printf -> my_write -> Uart_write

と呼び出していきますが、 my_write関数はUart_writeが成功するまで戻らない実装になっています。

送信キューに空きがある場合は成功しますが、空きが無い場合はどうなってしまうでしょうか。 送信キューに空きが無い場合、送信データレジスタの空きを契機に割り込みが発生すれば、 送信割り込み処理によって送信キューのデータが徐々に減っていくので送信キューに空きができますが、 割り込み処理中のために(他の割り込み含め)UARTの割り込みはブロックされてしまうのです。

つまり、割り込み処理中では待っても待っても(送信データレジスタは時間経過で空きますが)他の割り込みを受け付けないため、 送信キューに空きができることもなく、結果的に割り込み処理内でハングアップを引き起こしてしまうことになります。

無限待ち実装について

my_writeが成功するまで無限待ちになっていることが問題だと思われるかもしれません。 では書き込みできなかったら戻るように実装すればよいでしょうか。 そのような実装にした場合、表示したいメッセージが歯抜けになったり、 何も表示されないメッセージも出てくるでしょうから、 エラーログとして利用している場合は問題が発生しても気が付かない事も出てきます。 また、トレースログでも間が抜けてしまうと本来の意味がなくなってしまう等、困ることの方が多いと考えられるため、 書き込めるまで無限待ちの実装にしている場合が多いのです。

メモリが潤沢にあれば大きなサイズのキューを設けて実質ポーリング不要にしたり、 必要に応じて動的にメモリを確保することもできますが、 小規模組み込みシステムでは限られたメモリで実装しなければなりません。 これは小規模組み込みシステムを開発する上で受け入れなければならないトレードオフで、 その代わりに小型、低消費電力、専用による使いやすさ、低コスト化等が得られるということです。

問題に気がつきにくい理由

この問題に気がつきにくいのは、必ずハングアップするわけではなく、 printf実行時に送信キューの空き容量が十分であれば発生しないからです。

ですが割り込み処理中のprintfでハングアップしてしまう理由は、 異常時の割り込み発生頻度が設計時の想定(前提条件)を超えてしまうからでしょう。

まとめ

結局「割り込みコンテキストでprintfを使用しない」というのが安全で、そのような設計ルールを設けるとよいと思います。 当然ですが、これは(非割り込みの)通常コンテキスト割り込みコンテキストを意識してコーディングできることが前提条件となります。

なお、割り込みコンテキストでprintfを使用しなくても、UARTの送信性能以上に文字を出力させようとすることも問題です。 出力したい文字データが発生してもUARTドライバ内の送信キューがフルになるまではバッファリングして吸収できますが、 送信性能以上に出力するような動作が続けば、ハングアップまでいかないとしてもリアルタイム性を維持できなくなることもあります。 送信量及び頻度によっては処理の遅れが波のように発生する現象を引き起こすことにもなりますので、 経験の浅い技術者にとっては解決が難しい問題になるでしょう。

バッファの空きができるまで待つかキャンセルするか(優先度を導入して低優先ならばキャンセルする等)、 組み込みシステムのハードウェアリソースは多種多様なので1つの実装が常に正解となる事はありません。 とはいえ、担当者がどのような実装になっているかを理解している必要はありますので、 実装を確認して起こりえる問題を認識しておきましょう(その実装での安全な利用方法を理解しておく)。

こんな実装でも問題を作ってしまう!?

以下のコードを見てください。 これは割り込み処理ではありませんが、先の説明と同様の問題があるコードです。 この例では問題箇所を抽出しているのでわかりやすいですが、実際のコードから問題となるコードを見つけ出すのは難しいものです。

{
    ...
    interrupt_disable_all(); /* 全割り込み禁止 */
    UserLogic_reset();
    interrupt_enable_all(); /* 全割り込み許可 */
}

void UserLogic_reset(void)
{
    my_printf("TRACE:UserLogic_reset()\r\n"); /* トレースログ */
    ...
    ...
}