前回の第8回で、TIM2割り込みを実装し「ISR設計の3原則」を学びました。

  1. 短く ── 次の割り込みまでに確実に終わらせる
  2. 副作用を最小に ── フラグ通知に徹する
  3. 共有変数を保護する ── volatileとクリティカルセクション

では、この原則を破ったら実際にどうなるかを見ていきましょう。

理論で「NG」と言われても腑に落ちないことがあります。でも壊れる瞬間を目撃すれば、二度と忘れません。

今回は「わざと壊す」体験を通じて、割り込み設計の本当の理解を手に入れます。

📌 この記事のアンチパターンはすべてのマイコンに共通する

コード例は STM32 で書きますが、今回紹介する壊れ方はマイコンの種類を問いません。 Arduino(AVR)・ESP32・Renesas・PIC・どの石でも、ISR でブロッキング処理を呼べば詰まり、volatile を忘れれば最適化バグが起き、フラグをクリアしなければ無限ループします。

「STM32での話」ではなく、「割り込みを持つすべてのマイコンでの鉄則」 として読んでください。


📖 前回の記事

第8回:割り込みの仕組みを完全理解 ― ベクタ・NVIC・コンテキスト保存とタイマ割り込み実装 ―

📍 連載トップページ

【全13回連載】ポインタの先にある組み込みの世界


✅ この記事でできるようになること

  • ISR内でprintf(UART送信)するとなぜデッドロックするかを説明できる
  • volatileを付け忘れると最適化ビルドでどう壊れるかを再現できる
  • ISRに重い処理を書いたときのタイミング破綻を計測で確認できる
  • 複数変数の非アトミック更新による共有変数破壊を再現できる
  • 各アンチパターンに対する正しい設計パターンを説明できる

目次

  1. アンチパターン1:ISRでprintf(UART送信)する
  2. アンチパターン2:volatileを付け忘れる
  3. アンチパターン3:ISRに重い処理を書く
  4. アンチパターン4:複数変数を非アトミックに更新する
  5. アンチパターン5:割り込みフラグのクリアを忘れる
  6. アンチパターン6:__disable_irq() を長時間かける
  7. 正しい設計パターンのまとめ
  8. まとめ

💣 アンチパターン1:ISRでprintf(UART送信)する

症状:main()が応答しなくなる、または動作が不定になる

デバッグのつもりで書いた一行が、システムを完全に壊します。

/* ❌ 最もよくある致命的ミス */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;

    printf("tick = %lu\r\n", g_tim2_tick);   /* ← これが地雷 */
}

なぜ壊れるか ── デッドロックが本質

printfHAL_UART_Transmit() → UART送信完了フラグ(TXE)をポーリングしながら待ち続ける、という流れが問題の根本です。

TIM2 ISR 実行中
  ↓ printf → HAL_UART_Transmit() 呼び出し
  ↓ TXEフラグが立つのをポーリングで待ち続ける(ブロッキング)
  ↓ この間、SysTick が発火しようとする
  ↓ TIM2 ISR と同優先度以下の割り込みは保留される
    ※高優先度割り込みのみプリエンプション可能
  ↓ HAL_GetTick() のカウンタが更新されない
  ↓ main() が HAL_Delay() を呼んでいた場合
  ↓ カウンタが増えないので永遠に待ち続ける → 完全フリーズ

「時間がかかる」ことより、SysTick を止めることで HAL_Delay() が返ってこなくなる点が致命的です。

なお厳密には、割り込み優先度の設定や HAL の実装によっては完全なフリーズにならないケースもあります。しかし ISR 内でブロッキング I/O を呼ぶ設計自体が破綻要因であり、「やってはいけない」と覚えて問題ありません。

送信時間そのものも問題で、"tick = 1000\r\n"(14文字)では:

T_{\text{total}} = 14 \times \frac{10 \text{ bits}}{115{,}200 \text{ bps}} \approx 1{,}215 \, \mu\text{s} = 1.2 \text{ ms}

1ms 周期に対して ISR が 1.2ms 占有するため、main() もほぼ動きません。デッドロック+CPU占有の二重苦です。

⚠️ SysTick も巻き込まれる――HAL_Delay() は ISR 内で呼んではいけない

ISRが1ms以上実行し続けると、SysTick(HALのミリ秒カウンタ)も正常に動作できなくなります。HAL_Delay()HAL_GetTick() が信頼できなくなり、他のすべての時間依存処理が壊れます。

さらに深刻なのは、ISR内で直接 HAL_Delay() を呼んだ場合です。HAL_Delay() は SysTick 割り込みによるカウンタ更新を待ち続けます。もし TIM2 ISR の優先度が SysTick より高い場合、TIM2 ISR が実行中は SysTick 割り込みが割り込めず、カウンタが一切更新されません。結果として HAL_Delay() は永遠に待ち続け、システムが完全にフリーズします。

/* ❌ ISR内でHAL_Delay() → 絶対にNG */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    HAL_Delay(10);   /* ← SysTickが割り込めず永久ループ */
}

HAL関数のうち「安全に呼べるもの」は HAL_GPIO_WritePin() など単純なレジスタ書き込みで完結するものだけです。

実際に壊れる様子

/* 壊れるコード:ISR内でprintf */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
    printf("tick=%lu\r\n", g_tim2_tick);
}

/* main内で1秒後のtick値を確認 */
HAL_Delay(1000);
/* ← HAL_Delay が正常に動かないため、ここに到達しない or 大幅に遅延 */
printf("1sec tick = %lu\r\n", g_tim2_tick);
/* 本来 1000 になるはずが、はるかに小さい値になる */

DWT CYCCNT で計測すると、ISR の実行時間が 1ms = 84,000サイクル を大幅に超えていることが確認できます。

正しいやり方

/* ✅ ISRではフラグだけ立て、出力はmainでやる */
volatile uint32_t g_tim2_tick = 0;
volatile uint8_t  g_debug_flag = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
    g_debug_flag = 1;   /* mainへの通知だけ */
}

/* main側 */
while (1)
{
    if (g_debug_flag)
    {
        g_debug_flag = 0;
        printf("tick=%lu\r\n", g_tim2_tick);   /* ← mainで出力 */
    }
}
✅ デバッグ出力の代替手段

ISR内のデバッグには以下が有効です:

手段 特徴
LED/GPIOトグル オシロで波形確認、ゼロオーバーヘッド
DWT CYCCNT 実行時間をサイクル単位で計測
SWO(ITM) シリアルワイヤ出力、非同期でCPU負荷ほぼゼロ
グローバル変数 ISRで値を保存し、main側でprintf

💣 アンチパターン2:volatileを付け忘れる

症状:-O0では動くが、-O2に変えた瞬間にハングする

これは非常に典型的な「最適化バグ」です。デバッグビルドでは完璧に動くのに、リリースビルドにした途端に動かなくなります。(コンパイラ最適化の詳細は第6回で解説しています)

/* ❌ volatileなし */
uint32_t g_tim2_tick = 0;   /* volatile がない! */

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
}

int main(void)
{
    /* TIM2初期化・スタート(省略)*/

    /* 1000カウント待つつもりのコード */
    while (g_tim2_tick < 1000)
    {
        /* 何もしない */
    }
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);   /* ← 永遠に到達しない */
}

なぜ壊れるか

-O2 最適化でコンパイラが生成するコードを疑似的に示すと:

/* -O0 のとき(最適化なし)*/
.loop:
    LDR  R0, [g_tim2_tick]   /* 毎回RAMから読む */
    CMP  R0, #1000
    BLT  .loop               /* 1000未満ならループ */

/* -O2 のとき(最適化あり、volatileなし)*/
    LDR  R0, [g_tim2_tick]   /* 最初の1回だけRAMから読む */
.loop:
    CMP  R0, #1000           /* レジスタの値で比較(RAMを見ない!)*/
    BLT  .loop               /* R0 は永遠に変わらないので無限ループ */

コンパイラは「このループ内で g_tim2_tick を変更するコードがない」と静的解析で判断し、RAM への再アクセスを省略します。ISR がいくら RAM を書き換えても、main はレジスタにキャッシュした古い値しか見ません。

壊れ方を実際に確認する

/* 実験:-O0と-O2で動作の違いを確認 */

/* ① -O0でビルド → 正常に動く(LEDが点灯する)*/
/* ② -O2でビルド → while()から抜けられずLEDが点灯しない */

この差を見れば volatile の意味が体に染みます。

正しいやり方

/* ✅ ISRと共有する変数には必ずvolatileを付ける */
volatile uint32_t g_tim2_tick = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
}

int main(void)
{
    while (g_tim2_tick < 1000)   /* volatileがあれば毎回RAMから読む */
    {
        /* 何もしない */
    }
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);   /* 正常に到達 */
}
💡 volatileが必要な変数の見分け方

「ISR・DMA・ハードウェアレジスタ・他のスレッドが書き換える可能性がある変数」にはすべて volatile が必要です。

  • ✅ ISR から書き込まれるフラグ・カウンタ
  • ✅ DMA が書き込むバッファ
  • ✅ ペリフェラルレジスタ(TIM2->SR など)── これはヘッダで __IO(= volatile)定義済み
  • ❌ ISR・DMA・ハードウェアが一切触らないローカル変数や純粋な計算結果 → 不要

volatile の乱用(不要な変数に付ける)もNG です。コンパイラの最適化を阻害し、パフォーマンスが低下します。


💣 アンチパターン3:ISRに時間のかかる処理を書く

症状:割り込み周期が守れない、データが不定期にしか更新されない

AP1(ブロッキングI/O)とは異なり、デッドロックは起きません。しかし実行時間が周期を超えれば結果は同じです。

/* ❌ ISR内でI2C読み取りと演算をやる(printfがなくても壊れる)*/
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;

    /* センサーをI2C経由で読む(数百µs〜数ms かかる) */
    int16_t raw = read_sensor_i2c();

    /* 移動平均フィルタ(配列操作、数µs〜数十µs) */
    float filtered = moving_average(raw, filter_buf, FILTER_SIZE);

    g_sensor_val = (uint32_t)filtered;
}

なぜ壊れるか ── タイミングが崩壊する

通常時は周期内に収まることもありますが、I2C通信はバス競合・クロックストレッチ・センサーの応答遅延で実行時間が跳ね上がります。

【通常時】
read_sensor_i2c()  ≈ 400µs
moving_average()   ≈   5µs
ISR合計            ≈ 405µs  ← 1ms周期に収まる(一見OK)

【バス遅延発生時】
read_sensor_i2c()  ≈ 2,000µs(クロックストレッチ発生)
moving_average()   ≈     5µs
ISR合計            ≈ 2,005µs ≫ 1,000µs(1ms周期)

実行時間が周期を超えた瞬間から、ISR終了と同時に次の割り込みが即座に発火します。CPUがほぼ常時ISR内にいる状態になり、main()がほとんど動かなくなります。

AP1との違いは「HAL_Delay() が壊れるデッドロック」は起きないことですが、割り込みシステムの時間的信頼性が崩壊する点は同じです。

正しいやり方:フラグ通知パターン

/* ✅ ISRは「今やること」を知らせるだけ。仕事はmainに任せる */
volatile uint8_t g_sensor_read_request = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_sensor_read_request = 1;   /* フラグを立てるだけ */
}

int main(void)
{
    while (1)
    {
        if (g_sensor_read_request)
        {
            g_sensor_read_request = 0;

            /* 重い処理はすべてmain側で */
            int16_t raw = read_sensor_i2c();
            float filtered = moving_average(raw, filter_buf, FILTER_SIZE);
            printf("sensor=%.2f\r\n", filtered);
        }
    }
}
📌 ISRの黄金律

ISRがやるべきことは「何が起きたか」を記録することだけ。「何をするか」はmainが決める。

具体的には:

  • ✅ フラグ・カウンタをインクリメント
  • ✅ リングバッファにデータを1個積む
  • ✅ GPIOを1回トグルする
  • ❌ I2C/SPI通信
  • ❌ 浮動小数演算を大量にする
  • ❌ メモリ確保(malloc)
  • ❌ printf / UART送信

ISRの実行時間を計測する習慣

第7回・第8回で学んだ DWT CYCCNT をISRにも適用します。

volatile uint32_t g_isr_max_cycles = 0;

void TIM2_IRQHandler(void)
{
    uint32_t t0 = DWT->CYCCNT;
    TIM2->SR &= ~TIM_SR_UIF;

    /* ... ISRの処理 ... */

    uint32_t cycles = DWT->CYCCNT - t0;
    if (cycles > g_isr_max_cycles)
        g_isr_max_cycles = cycles;   /* 最大値を記録 */
}

g_isr_max_cycles をデバッガで確認し、割り込み周期(サイクル数)の10%以下に収まっているかを目安にしてください。1ms周期(84,000サイクル)なら、ISRは8,400サイクル以下が目標です。


💣 アンチパターン4:複数変数を非アトミックに更新する

症状:データが部分的に更新された「壊れた状態」を読んでしまう

第8回で「アラインされた uint32_t の読み書きはアトミック」と学びました。しかし複数の変数を「まとまった状態」として扱う場合は話が変わります。

💡 「アラインされた uint32_t の読み書きはアトミック」とは

アライン(aligned) とは、変数のアドレスがそのサイズの倍数に配置されていること。uint32_t(4バイト)なら、アドレスが 4 の倍数(0x20000000, 0x20000004 …)であることを指します。通常のグローバル変数はコンパイラが自動でアラインします。

アトミック(atomic) とは、「途中で分割されない」操作のこと。アラインされた uint32_t への代入は CPU が単一の STR 命令(1バスサイクル)で完結するため、その途中に割り込みが入っても「書き込み途中の壊れた値」が読み出されることはありません。

対して uint64_t(8バイト)は 2 回の STR 命令に分かれます。1 回目と 2 回目の間に割り込みが入ると、上位 32bit だけ新しく下位 32bit は古いという「半壊した値」が生まれます。これがアトミックでない状態です。

/* ❌ タイムスタンプ付きセンサー値を非アトミックに更新 */
volatile uint32_t g_timestamp = 0;
volatile uint32_t g_sensor_val = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_timestamp  = HAL_GetTick();
    g_sensor_val = read_sensor();
}

/* main側:2変数をセットで読もうとしている */
uint32_t ts  = g_timestamp;
uint32_t val = g_sensor_val;
/* ts と val が「対応していない」ペアになる可能性がある */

なぜ壊れるか

競合はmain側の2回の読み出しの間にISRが割り込んだとき起きます。

main実行中:
  ts = g_timestamp;        ← 古い値(N回目のISR結果)を読む
  [TIM2割り込み発生]
    g_timestamp  = 新しい値(N+1回目)
    g_sensor_val = 新しいセンサー値(N+1回目)
  [ISR return → mainへ戻る]
  val = g_sensor_val;      ← 新しい値(N+1回目)を読む

結果:
  ts  = N回目のタイムスタンプ  ← 古い
  val = N+1回目のセンサー値    ← 新しい
  → 対応していないペアとして処理されてしまう!

複数変数を「セット」として扱う場合、2回の読み出しの間にISRが入ると部分的に更新されたデータを読んでしまいます。この種のバグは再現性が低く(ISRのタイミング次第で起きたり起きなかったりする)、デバッグが非常に困難です。

正しいやり方:クリティカルセクション

/* ✅ 複数変数の更新をクリティカルセクションで保護 */
volatile uint32_t g_timestamp = 0;
volatile uint32_t g_sensor_val = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;

    uint32_t ts  = HAL_GetTick();
    uint32_t val = read_sensor();

    /* 割り込みを一時禁止して「まとめて」書く */
    __disable_irq();
    g_timestamp  = ts;
    g_sensor_val = val;
    __enable_irq();
}

/* main側も同様に保護して読む */
uint32_t ts, val;
__disable_irq();
ts  = g_timestamp;
val = g_sensor_val;
__enable_irq();

ダブルバッファパターン(より高度)

クリティカルセクションを避けたい場合、ダブルバッファ(二重バッファ)が有効です:

/* ✅ ダブルバッファによる無ロック読み書き */
typedef struct {
    uint32_t timestamp;
    uint32_t sensor_val;
} SensorData;

volatile SensorData g_buf[2];
volatile uint8_t    g_write_idx = 0;   /* ISRが書くバッファのインデックス */

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;

    uint8_t w = g_write_idx;
    g_buf[w].timestamp  = HAL_GetTick();
    g_buf[w].sensor_val = read_sensor();
    g_write_idx ^= 1;   /* 書き込みバッファを切り替え */
}

/* main側:読み取りバッファは書き込みバッファの反対側 */
uint8_t r = g_write_idx ^ 1;   /* 今ISRが書いていない方を読む */
uint32_t ts  = g_buf[r].timestamp;
uint32_t val = g_buf[r].sensor_val;
⚠️ クリティカルセクションの注意点

__disable_irq() 中は SysTick も止まります:

  • ❌ クリティカルセクション内で HAL_Delay() を呼ぶ
  • ❌ 数ms以上の処理をクリティカルセクションに入れる
  • ✅ 数µs以内の変数コピーのみ
  • __disable_irq()__enable_irq() は必ずペアで使う

💣 アンチパターン5:割り込みフラグのクリアを忘れる

症状:main()が一切実行されない(割り込みが止まらない)

第8回でも触れましたが、最もよくある「なぜか動かない」の原因です。

/* ❌ フラグクリアなし */
void TIM2_IRQHandler(void)
{
    /* TIM2->SR &= ~TIM_SR_UIF; ← これを忘れた! */
    g_tim2_tick++;
}

なぜ壊れるか

TIM2 UIF フラグが立つ
  ↓
NVIC が TIM2_IRQHandler を呼ぶ
  ↓
ISR 実行(フラグはまだ立ったまま)
  ↓
ISR return
  ↓
NVIC が「まだフラグが立っている」と判断
  ↓
即座に TIM2_IRQHandler を再度呼ぶ
  ↓
(無限ループ → main() には戻れない)

正しいやり方

/* ✅ ISRの最初に必ずフラグをクリアする */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;   /* ← 最初にクリア(理由は第8回参照)*/
    g_tim2_tick++;
}
💡 「最後にクリア」より「最初にクリア」が安全な理由

フラグクリア命令を書いても、STM32ではバスを経由してハードウェアに反映されるまで数サイクルの遅延があります。ISRの末尾でクリアすると、return 直後にNVICがフラグを再検出して再入するリスクがあります。特に84MHzの高速クロック環境では発生しやすいため、ISRの冒頭でクリアするのが鉄則です。


💣 アンチパターン6:__disable_irq() を長時間かける

症状:割り込み精度が劣化する、SysTick関連の処理が壊れる

クリティカルセクションが必要なのは分かった——でも「念のため長めにかけておこう」という判断は禁物です。

/* ❌ 不必要に長いクリティカルセクション */
__disable_irq();

/* センサー読み取り(数百µs) */
int16_t raw = read_sensor_i2c();

/* フィルタ処理(数µs〜数十µs) */
float filtered = moving_average(raw, filter_buf, FILTER_SIZE);

/* 変数更新 */
g_sensor_val = (uint32_t)filtered;

__enable_irq();

なぜ壊れるか

__disable_irq() 中は すべての割り込みが保留されます。

__disable_irq() 開始
  TIM2 割り込みが来た → Pending として記録される(実行されない)
  SysTick も止まる → HAL_GetTick() の値が更新されない
  I2C転送 ≈ 400µs(この間割り込みなし)
__enable_irq() 終了
  → まとめて割り込みが処理される(バースト処理)
  → TIM2の1ms周期が乱れる
  → HAL_Delay() の精度が悪化

正しいやり方:最小限のクリティカルセクション

/* ✅ 変数コピーの瞬間だけ保護する */
int16_t raw = read_sensor_i2c();       /* クリティカルセクション外 */
float filtered = moving_average(raw, filter_buf, FILTER_SIZE);  /* 外 */

/* 書き込みの瞬間だけ保護(数サイクル)*/
__disable_irq();
g_sensor_val = (uint32_t)filtered;
__enable_irq();
操作 クリティカルセクション内か?
I2C / SPI 通信 ❌ 外でやる
浮動小数演算 ❌ 外でやる
printf / UART送信 ❌ 外でやる
複数変数の「まとめてコピー」 ✅ 内でやる(数µs以内)
uint32_t 1変数の読み書き ✅/❌ アトミックなので不要なことが多い

✅ 正しい設計パターンのまとめ

ここまで見てきたアンチパターンをまとめると、正しいISR設計の全体像が見えてきます。

フラグ通知パターン(最も基本)

/* ISRとmainの役割分担の基本形 */

/* 共有変数(すべてvolatile)*/
volatile uint8_t  g_flag_1ms  = 0;
volatile uint8_t  g_flag_10ms = 0;
volatile uint32_t g_tick      = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;   /* フラグクリア(最初に!)*/
    g_tick++;

    /* 周期フラグを立てる */
    g_flag_1ms = 1;
    if (g_tick % 10 == 0) g_flag_10ms = 1;
}

int main(void)
{
    /* ... 初期化 ... */

    while (1)
    {
        /* 1ms タスク */
        if (g_flag_1ms)
        {
            g_flag_1ms = 0;
            task_1ms();   /* 軽い処理 */
        }

        /* 10ms タスク */
        if (g_flag_10ms)
        {
            g_flag_10ms = 0;
            task_10ms();  /* 少し重い処理(I2Cなど)*/
        }

        /* バックグラウンドタスク */
        task_background();   /* いつでもよい処理 */
    }
}

リングバッファパターン(データ転送用)

割り込みで連続するデータ(UART受信・ADCサンプリングなど)を扱う場合:

/* リングバッファ */
#define RING_BUF_SIZE 64

typedef struct {
    uint8_t  buf[RING_BUF_SIZE];
    uint16_t head;   /* ISRが書く */
    uint16_t tail;   /* mainが読む */
} RingBuf;

volatile RingBuf g_uart_rx;

/* UART受信ISR */
void USART2_IRQHandler(void)
{
    if (USART2->SR & USART_SR_RXNE)
    {
        uint8_t data = USART2->DR;
        uint16_t next = (g_uart_rx.head + 1) % RING_BUF_SIZE;
        if (next != g_uart_rx.tail)   /* バッファが満杯でなければ */
        {
            g_uart_rx.buf[g_uart_rx.head] = data;
            g_uart_rx.head = next;
        }
    }
}

/* main側:データを1バイト取り出す */
uint8_t ring_read(RingBuf *rb, uint8_t *out)
{
    if (rb->head == rb->tail) return 0;   /* 空 */
    *out = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 1;
}
💡 head/tailのアトミック性――なぜクリティカルセクション不要か

上記のリングバッファでは、head は ISR のみが書き、tail は main のみが書きます。書き手が一人に限定されているため、クリティカルセクションは不要です。これを “Single Producer Single Consumer(SPSC)” パターンと呼びます。

なぜ uint16_t の書き込みが安全か?

Cortex-M4 アーキテクチャでは、メモリ境界にアライン(整列)された 8bit・16bit・32bit 変数への単一の代入命令(STRBSTRHSTR)は、ハードウェアレベルで一度に完結します。途中で割り込まれることがないため、「書き込み途中の半壊した値」を読み出し側が見ることは原理的にありません。

通常のグローバル変数・構造体メンバはコンパイラが自動でアラインするため、この保証が成り立ちます。ただし __attribute__((packed)) でアラインを崩した場合はこの限りではありません。


まとめ

今回「わざと壊した」6つのアンチパターンと、その対処法:

# アンチパターン 症状 対処
1 ISRでprintf デッドロック・タイミング崩壊 mainで出力、ISRはフラグのみ
2 volatile忘れ -O2でハング(最適化バグ) ISR共有変数に必ずvolatile
3 ISRに重い処理 他割り込みの遅延・main停止 ISRは軽く、重い処理はmainへ
4 非アトミック更新 データの不整合(再現困難) クリティカルセクションで保護
5 フラグクリア忘れ ISRから抜けられない無限ループ ISR冒頭で必ずクリア
6 長いdisable_irq 割り込み精度の劣化 変数コピーの瞬間だけ保護
📌 割り込み設計の本質

今回挙げた6つのアンチパターンに共通するのは、**「ISR を通常の関数のように扱っている」**ことです。ISR は非同期に割り込んでくる特殊な実行コンテキストであり、通常の関数と同じ感覚では設計できません。

割り込みが「怖い」のは、「いつ」来るかわからない非同期性にあります。しかしルールを守れば制御できます。

  • ISRは「通知係」、mainは「実行係」 に徹する
  • 共有変数は volatile で守り、複数変数は クリティカルセクション で守る
  • 計測する ── ISRの実行時間を DWT で確認し、周期の10%以下に収める

割り込みは「便利な関数呼び出し」ではなく、「非同期に割り込んでくる別世界の処理」です。その感覚を持った上で設計する——それがつよつよエンジニアへの分水嶺です。

次回は 「DMAという発想(CPUを暇にする)」 です。割り込みより上位の概念——「CPUがやらなくていい仕事をハードウェアに任せる」技術を学びます。


次回予告

🚀 第10回:DMAという発想(CPUを暇にする)

UART送信を「CPUが1バイトずつ送る」から「DMAが全部やる」に変える。スループット・レイテンシ・バス競合の直感を身につけ、組み込みの「真の並列処理」を理解します。


よくある質問(FAQ)

Q. ISR内でmalloc(動的メモリ確保)してもいいですか?

NG です。malloc は内部でヒープロックを取得します。main側でも malloc を呼んでいる場合、ISRとmainが同じロックを取得しようとしてデッドロックします。また実行時間が不定で、タイミング保証ができません。ISRに必要なバッファは静的に確保してください。

Q. ISR内でHAL関数を使うのはいいですか?

基本的に避けてください。安全性の見極めは「内部で待ち処理(ポーリング・タイムアウト待ち)を持つかどうか」で判断します。待ちのある関数はブロッキングとなり ISR 内では使えません。例外は HAL_GPIO_WritePin()HAL_GPIO_TogglePin() など、単純なレジスタ書き込みで完結するものです。

根本的な理由は、多くの HAL 関数が再入可能(Reentrant)ではないことにあります。ISR から呼び出す関数は、どこから・いつ呼ばれても安全なリエントラントな実装である必要があります。HAL はその保証を持たないため、ISR 内からは原則使用しません。

Q. 優先度の設定を間違えるとどうなりますか?

代表的な事故は「SysTick(優先度0)と同じ優先度のISRを設定して競合させる」ことです。プリエンプションが起きず、SysTickとTIM2が同優先度で競合すると、どちらが先に実行されるかが不定になります。また、意図せず高優先度を設定したISRが低優先度のISRを常にプリエンプションし続け、低優先度側がまったく実行されない「優先度逆転」も起きます。

Q. __disable_irq() を入れ子(ネスト)で使うとどうなりますか?

__disable_irq() / __enable_irq() をそのまま使うと、既に割り込み禁止状態の関数から呼ばれたときに問題が起きます。

/* ❌ ネストに弱いクリティカルセクション */
void some_function(void)
{
    __disable_irq();   /* 既に禁止状態かもしれない */
    /* ... */
    __enable_irq();    /* 元の状態に関わらず無条件に許可してしまう! */
}

呼び出し元がすでに __disable_irq() をかけていた場合、この関数が __enable_irq() を実行した瞬間に割り込みが許可されてしまい、意図しないタイミングで割り込みが入ります。

堅牢な書き方:__get_PRIMASK() で状態を保存・復元する

/* ✅ 元の割り込み状態を保存して復元する */
void some_function(void)
{
    uint32_t primask = __get_PRIMASK();   /* 現在のPRIMASK状態を保存 */
    __disable_irq();

    /* ... クリティカルセクション ... */

    __set_PRIMASK(primask);   /* 元の状態に戻す(既に禁止なら禁止のまま)*/
}

この連載ではシンプルさを優先して __disable_irq() / __enable_irq() を使っていますが、ライブラリや再利用する関数を書く場合は __get_PRIMASK() を使うのがプロの作法です。FreeRTOS の taskENTER_CRITICAL() もこの考え方に基づいています。

Q. FreeRTOSを使う場合、これらのルールは変わりますか?

基本は同じですが、FreeRTOSでは taskENTER_CRITICAL() / taskEXIT_CRITICAL() を使います(__disable_irq() の直接使用は避ける)。また、ISRからキューやセマフォを操作する場合は FromISR サフィックス付きのAPI(xQueueSendFromISR()など)を使う必要があります。


関連記事