概要
クリティカルセクション(Critical Section、臨界区間)とは、複数のタスクや割り込みハンドラが共有資源(グローバル変数、ペリフェラルレジスタ、データ構造など)にアクセスする処理のうち、同時実行されると問題が生じる区間を指す。この区間では、アトミック性(原子性)を保証する必要があり、実行中に他のコンテキストから割り込まれないようにする保護機構が必要だ。
クリティカルセクションの保護手法は主に3種類ある。(1)割り込み無効化(最もシンプルで低オーバーヘッド)、(2)ミューテックスまたはセマフォ(タスク間排他、割り込みは生きたまま)、(3)アトミック操作(単一変数の読み書きに特化)だ。組み込み開発では保護区間の長さとリアルタイム要件に応じて適切な手法を選ぶことが重要になる。
歴史・背景
「クリティカルセクション」という概念はDijkstraが1965年に提唱した「相互排除問題」に起源を持つ。UNIX初期はシングルプロセッサのみだったため、割り込み禁止だけで相互排除を実現できた。マルチプロセッサ(SMP: Symmetric Multi-Processing)が普及した1980〜90年代以降は、割り込み禁止だけでは不十分になり、スピンロック・ミューテックス・アトミック操作が発展した。
組み込みマイクロコントローラはシングルコアが主流であり、多くの場合は割り込み禁止によるクリティカルセクション保護が今でも有効だ。ただしマルチコアSoC(ARM Cortex-A MPCore、RISC-V Multicore)を採用した組み込みLinux環境では、スピンロック等のマルチコア対応の同期機構が必要になる。
技術仕様
競合状態(Race Condition)が発生する例
クリティカルセクションの保護が必要な典型的なケースを示す。
read-modify-write操作の競合
// グローバルカウンタ(共有資源)
volatile uint32_t event_count = 0;
// タスクAとタスクBが同時に実行した場合
void task_a(void) {
event_count++; // これは1命令ではなく3命令に展開される
// 1. LOAD r0, [event_count] ; r0 = 5
// 2. ADD r0, r0, #1 ; r0 = 6
// 3. STORE [event_count], r0 ; event_count = 6
// ★ LOAD直後に割り込みで別タスクが同じ操作をすると...
}
void task_b_or_isr(void) {
event_count++;
// 同時に実行:
// 1. LOAD r1, [event_count] ; r1 = 5(まだ更新されていない)
// 2. ADD r1, r1, #1 ; r1 = 6
// 3. STORE [event_count], r1 ; event_count = 6 ← 片方の更新が消える!
// ★ 本来は7になるはずが6のまま(更新の消失)
}
非アトミックなデータ構造のアクセス
// 32ビットMCUで64ビット値を使う場合(非アトミック)
typedef struct {
uint32_t high;
uint32_t low;
} Timestamp64;
volatile Timestamp64 timestamp;
void isr_update_timestamp(void) {
timestamp.high = get_high(); // ← ここで割り込まれると...
timestamp.low = get_low(); // highだけ更新された状態を読まれる
}
void task_read_timestamp(void) {
uint32_t h = timestamp.high; // 更新済み
uint32_t l = timestamp.low; // 古い値 → タイムスタンプが壊れている
}
各保護手法の比較
| 手法 | 割り込み禁止(ベアメタル) | FreeRTOS Critical Section | ミューテックス | アトミック操作 |
|---|---|---|---|---|
| 適用対象 | タスク・ISR間 | タスク間(ISR保護も可) | タスク間のみ | 単純変数のみ |
| ISRへの影響 | 禁止(割り込みが遅延) | RTOSにより管理 | なし | なし |
| マルチコア対応 | 不可 | 不可(シングルコア向け) | 可(OS依存) | 可(ハードウェア保証) |
| 最大保護時間 | 最小限(数十〜数百ns) | 短時間推奨 | 長時間可 | ナノ秒 |
| オーバーヘッド | 最小(1〜2命令) | 小(数命令) | 中(コンテキスト切り替え) | 最小 |
ARM Cortex-MのCPSID/CPSIE命令
ARM Cortex-M系では CPSID I(割り込み全無効)と CPSIE I(割り込み全有効)命令でクリティカルセクションを実装する。
// Cortex-Mでの割り込み無効化によるクリティカルセクション(ベアメタル)
static inline uint32_t enter_critical(void) {
uint32_t primask = __get_PRIMASK(); // 現在の割り込み状態を保存
__disable_irq(); // CPSID I命令(全マスク可能割り込みを無効化)
__DSB(); // データ同期バリア(確実に反映される)
__ISB(); // 命令同期バリア
return primask;
}
static inline void exit_critical(uint32_t primask) {
__set_PRIMASK(primask); // 保存した状態に復元(ネスト対応)
// __enable_irq() は無条件に有効化するため、ネストしたクリティカルセクションで問題になる
}
// 使用例(ネスト対応)
void example_function(void) {
uint32_t saved = enter_critical();
// === クリティカルセクション ===
shared_var++;
buffer[write_index] = new_data;
write_index = (write_index + 1) % BUFFER_SIZE;
// === クリティカルセクション終了 ===
exit_critical(saved);
}
BASEPRIを使った部分的な割り込み無効化
Cortex-M3/M4/M7では、BASEPRIレジスタを使って特定の優先度以下の割り込みのみを無効化できる。これにより高優先度の割り込み(タイムクリティカルなもの)は生かしたまま、低優先度の割り込みだけを禁止するという細かい制御が可能だ。
// 優先度5以下の割り込みを無効化(0が最高優先度)
static inline uint32_t enter_partial_critical(uint32_t priority) {
uint32_t old_basepri = __get_BASEPRI();
__set_BASEPRI(priority << (8 - __NVIC_PRIO_BITS));
__DSB();
__ISB();
return old_basepri;
}
動作原理
FreeRTOSのクリティカルセクション
FreeRTOSの taskENTER_CRITICAL() / taskEXIT_CRITICAL() はネスト対応で、内部でBASEPRI(Cortex-M3/M4/M7の場合)またはPRIMASK(Cortex-M0の場合)を操作する。
#include "FreeRTOS.h"
#include "task.h"
volatile uint8_t ring_buf[64];
volatile uint8_t write_idx = 0;
volatile uint8_t read_idx = 0;
// タスクからリングバッファへ書き込む(ISRと共有)
bool ring_buf_push(uint8_t data) {
bool success = false;
taskENTER_CRITICAL();
// クリティカルセクション開始(ISRも含めた全コンテキストから保護)
uint8_t next_write = (write_idx + 1) % sizeof(ring_buf);
if (next_write != read_idx) { // バッファフル確認
ring_buf[write_idx] = data;
write_idx = next_write;
success = true;
}
// クリティカルセクション終了
taskEXIT_CRITICAL();
return success;
}
// ISRからリングバッファを読み出す
bool ring_buf_pop_from_isr(uint8_t *data, BaseType_t *woken) {
// ISR用の専用マクロを使う
UBaseType_t saved = taskENTER_CRITICAL_FROM_ISR();
bool success = false;
if (read_idx != write_idx) {
*data = ring_buf[read_idx];
read_idx = (read_idx + 1) % sizeof(ring_buf);
success = true;
}
taskEXIT_CRITICAL_FROM_ISR(saved);
return success;
}
スピンロック(マルチコア向け)
Linuxカーネルやマルチコア組み込みシステムでは、割り込み禁止だけではコア間の排他を保証できない。スピンロックはアトミックなCAS(Compare-And-Swap)命令でロックを取得し、取得できなければCPUでビジーウェイトする。
// ARMv8のLDXR/STXR命令を使ったスピンロック実装イメージ
typedef volatile uint32_t spinlock_t;
static inline void spin_lock(spinlock_t *lock) {
uint32_t tmp;
__asm__ volatile (
"1: ldaxr %w0, [%1]\n" // アトミックロード(排他モニタセット)
" cbnz %w0, 1b\n" // ロック済みなら再試行
" stxr %w0, %w2, [%1]\n" // アトミックストア試行
" cbnz %w0, 1b\n" // 失敗したら再試行
: "=&r" (tmp)
: "r" (lock), "r" (1)
: "memory"
);
}
static inline void spin_unlock(spinlock_t *lock) {
__asm__ volatile ("stlr wzr, [%0]" : : "r" (lock) : "memory");
}
アトミック操作(C11/GCC組み込み関数)
単一変数の操作にはアトミック操作が最も軽量だ。
#include <stdatomic.h>
atomic_uint event_count = ATOMIC_VAR_INIT(0);
void isr_handler(void) {
atomic_fetch_add(&event_count, 1); // アトミックにインクリメント
}
void task_read(void) {
uint32_t count = atomic_load(&event_count); // アトミックに読み取り
atomic_store(&event_count, 0); // アトミックにリセット
}
// GCC組み込み関数を使う場合(C11標準ではない組み込み向けでも広く使える)
volatile int shared;
int old_val = __atomic_fetch_add(&shared, 1, __ATOMIC_SEQ_CST);
bool success = __atomic_compare_exchange_n(&shared, &old_val, new_val,
false, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST);
用途・ユースケース
リングバッファ(ISR〜タスク間通信)
割り込みハンドラ(ISR)でデータを受信し、タスクで処理するリングバッファはクリティカルセクションの典型的なユースケースだ。
// UARTのISR〜タスク間データ転送
#define BUF_SIZE 256
static uint8_t rx_buffer[BUF_SIZE];
static volatile uint16_t rx_head = 0;
static volatile uint16_t rx_tail = 0;
// ISRで呼ばれる(割り込みコンテキスト)
void UART_RX_IRQHandler(void) {
uint8_t byte = UART->DR;
uint16_t next_head = (rx_head + 1) % BUF_SIZE;
if (next_head != rx_tail) {
rx_buffer[rx_head] = byte;
rx_head = next_head; // volatileへの書き込みはアトミック(1ワード)
}
}
// タスクで呼ばれる(シングルコアなら読み取り専用でクリティカルセクション不要な場合も)
bool uart_read_byte(uint8_t *byte) {
if (rx_tail == rx_head) return false;
*byte = rx_buffer[rx_tail];
rx_tail = (rx_tail + 1) % BUF_SIZE;
return true;
// ★ シングルコアでtailを更新するのがタスクのみなら割り込み禁止不要
// ただし安全のため、複数リーダーがいる場合はprotectする
}
グローバル変数の保護
// 温度センサーの値(ISRとメインループで共有)
static volatile int32_t temperature_raw = 0;
static volatile bool temperature_updated = false;
void ADC_IRQHandler(void) {
// シングルコアでISRが唯一の書き手なら、単純なvolatile書き込みで十分
temperature_raw = ADC->DR;
temperature_updated = true;
}
void main_loop(void) {
if (temperature_updated) {
uint32_t primask = enter_critical(); // readの間に更新が入らないよう保護
int32_t raw = temperature_raw;
temperature_updated = false;
exit_critical(primask);
float celsius = raw * 0.0625f; // 変換はクリティカルセクション外で
update_display(celsius);
}
}
デバイスドライバのレジスタアクセス
複数のGPIOピンをアトミックに操作する場合にも保護が必要だ。例えばread-modify-writeでGPIOレジスタを操作する場合、途中で割り込まれると意図しないピン状態になりうる。
// GPIO複数ピンの同時操作
void gpio_set_pattern(uint32_t pin_mask, uint32_t values) {
uint32_t saved = enter_critical();
uint32_t current = GPIO->ODR;
GPIO->ODR = (current & ~pin_mask) | (values & pin_mask);
exit_critical(saved);
}
実装・開発のポイント
クリティカルセクションは短くする
割り込み禁止の時間が長いとシステムのリアルタイム性が損なわれる。クリティカルセクション内では最小限の操作(変数のコピー、フラグのセット等)のみ行い、重い処理はセクション外で実行する。
// 悪い例:クリティカルセクション内で重い処理
void bad_example(void) {
taskENTER_CRITICAL();
memcpy(large_buffer, src, 4096); // 時間のかかるコピー(NG)
checksum = compute_crc32(large_buffer, 4096); // さらに時間がかかる(NG)
taskEXIT_CRITICAL();
}
// 良い例:保護が必要な最小限の操作のみ
void good_example(void) {
uint8_t local_buf[4096];
uint32_t local_len;
taskENTER_CRITICAL();
local_len = shared_data_len;
memcpy(local_buf, shared_buffer, local_len); // ローカルにコピー
taskEXIT_CRITICAL();
// 重い処理はセクション外で
uint32_t crc = compute_crc32(local_buf, local_len);
process(local_buf, local_len, crc);
}
volatile修飾子の正しい使い方
ISRとタスクで共有する変数には volatile を付けてコンパイラの最適化(変数のキャッシュ、リオーダー)を防ぐ。ただし volatile はアトミック性を保証しないため、クリティカルセクション保護の代わりにはならない。
// volatileとクリティカルセクションの組み合わせ
volatile uint32_t tick_count = 0; // ISRで更新→volatileが必要
void SysTick_Handler(void) {
tick_count++; // 1ワード書き込みはCortex-Mでアトミック(32ビット)
}
uint32_t get_tick(void) {
return tick_count; // 32ビット読み取りはアトミックなのでクリティカルセクション不要
}
// 64ビット変数はアトミックでないため保護が必要
volatile uint64_t tick_64 = 0;
uint64_t get_tick_64(void) {
uint32_t primask = enter_critical(); // 64ビット読み取りを保護
uint64_t val = tick_64;
exit_critical(primask);
return val;
}
ネスト対応の実装
クリティカルセクションはネストする可能性がある(関数が別のクリティカルセクション内から呼ばれる)。ネスト対応のため __get_PRIMASK() で現在状態を保存し、__set_PRIMASK() で復元する方式が安全だ。__disable_irq() と __enable_irq() のペアは非ネストの場合のみ使用すること。
他技術との比較
| 比較項目 | 割り込み禁止 | FreeRTOS Critical Section | ミューテックス | スピンロック |
|---|---|---|---|---|
| 対応コア数 | シングルのみ | シングルのみ | マルチ対応 | マルチ対応 |
| ISR保護 | 完全 | 設定依存(configMAX_SYSCALL_INTERRUPT_PRIORITY以下) | 不可 | 不可 |
| ブロッキング | なし | なし | あり(待機可能) | なし(ビジーウェイト) |
| 使用場面 | 超短い保護(数ns〜数μs) | RTOS環境での短い保護 | 長い排他(ms以上) | SMP環境の短い保護 |
| リアルタイム影響 | 割り込み遅延が生じる | 最大syscall優先度まで影響 | タスクブロックのみ | CPU占有(他コアに影響なし) |
ポーリングとの違い
ポーリングはイベント検出方式であり、クリティカルセクションとは直交する概念だ。ただし割り込み禁止中にフラグをセットし、メインループでポーリングする設計(割り込みドリブンの簡易版)は組み込みでよく使われるパターンであり、クリティカルセクションと組み合わせて使われる。
プリエンプションとの関係
RTOSのプリエンプティブスケジューラは任意のタイミングでタスクを切り替えるため、クリティカルセクションの保護が特に重要だ。FreeRTOSの taskENTER_CRITICAL() はスケジューラの割り込みも禁止することで、タスクスイッチによるクリティカルセクションの中断を防ぐ。