前回の第9回で「ISRに重い処理を書いてはいけない」と学びました。では、「どうしても大量のデータを転送しなければならない」ときはどうするか?
その答えが DMA(Direct Memory Access) です。
DMAは「CPUを介さずにデータを動かす」ハードウェアです。CPUが寝ている間に転送が終わります。
📍 連載トップページ
✅ この記事でできるようになること
- DMAが「なぜ存在するか」をバス構造の観点から説明できる
- STM32のDMAストリーム・チャネル対応表を読んで設定できる
- UART TX をDMA化し、CPUを解放した状態で送信できる
- DMA転送完了コールバックを実装し、次の転送を安全に始められる
- バッファ再利用のタイミングミスがなぜ壊れるかを説明できる
目次
🔌 DMAとは何か
DMAは「CPUとは別の、データ搬送専用ハードウェア」
DMA(Direct Memory Access) は、CPU とは独立して存在するハードウェアモジュールです。「メモリの機能」でも「CPUの機能」でもなく、バス上に接続された独立したデータ転送エンジンです。
まずCPUがないとどうなるかを整理します。
通常(DMAなし)のデータ転送
たとえば「RAMに保存した文字列をUARTで送る」という操作を考えます。
【通常の転送:CPUが全部やる】
RAM(文字列バッファ)
│
│ LDR命令(CPUがRAMから1バイト読む)
▼
CPU(レジスタにデータを持つ)
│
│ STR命令(CPUがUARTのDRレジスタに1バイト書く)
▼
UART DR レジスタ → 物理的に送信
1バイト送るごとに「LDR → STR」というCPUの命令実行が必要です。100バイト送るなら、CPUは100回この処理を繰り返します。その間CPUは他の何もできません。
DMAありの場合
【DMAを使った転送:CPUは頼むだけ】
CPU「DMAよ、このアドレスから100バイト、UARTのDRに送っておいて」
│
│(CPUは即座に次の仕事へ)
▼
DMAコントローラ(独立して動く)
│
├─ RAM → DMA → UART DR(1バイト目)
├─ RAM → DMA → UART DR(2バイト目)
├─ ……(CPUは関与しない)
└─ RAM → DMA → UART DR(100バイト目)完了!
│
└─ 完了割り込みをCPUに通知
CPUは「転送の開始を依頼する」だけで、あとは自由に動けます。
DMAはすべてのマイコンにあるの?
ありません。 マイコンによって搭載有無が大きく異なります。
| マイコン | DMA |
|---|---|
| Arduino UNO(AVR ATmega328P) | ❌ なし |
| Arduino Mega(AVR ATmega2560) | ❌ なし |
| STM32F401RE | ✅ DMA1・DMA2(各8ストリーム) |
| ESP32 | ✅ あり(一部ペリフェラル) |
| Renesas RA / RX | ✅ あり |
| PIC32 | ✅ あり |
Arduino UNO が「ブロッキング送信しかできない」理由のひとつが、DMAを持たないことです。STM32のような32ビットマイコンでは、DMAは当然のように搭載されています。
「ペリフェラル」とは何か
DMAの説明で出てくる「ペリフェラル(Peripheral)」とは、CPU・メモリ以外のハードウェアモジュールの総称です。
| ペリフェラル | 役割 |
|---|---|
| UART / USART | シリアル通信(PCとのやり取り) |
| SPI | センサー・SDカードとの高速通信 |
| I2C | センサー・ディスプレイとの通信 |
| ADC | アナログ電圧をデジタル値に変換 |
| DAC | デジタル値をアナログ電圧に変換 |
| TIM | タイマ・PWM生成 |
これらはすべて「特定のレジスタに読み書きすることで操作する」ハードウェアです。DMAは「RAM上のバッファ」と「これらペリフェラルのレジスタ」の間でデータを自動的に搬送します。
「CPUを介さない」の本当の意味
CPUが「介する」とはどういうことか、ハードウェアレベルで見ます。
マイコンの内部では、CPU・DMA・RAM・ペリフェラルすべてが AHBバス(高性能バス) という「共有道路」でつながっています。
Cortex-M4"] DMA["DMA1"] end BUS(["AHBバスマトリクス"]) SRAM["SRAM"] UART["UART2
ペリフェラル"] CPU --> BUS DMA --> BUS BUS --> SRAM BUS --> UART
CPUもDMAも、同じバスに接続されたバスマスターです。どちらもRAMやペリフェラルにアクセスできます。
「CPUを介さない」とは「CPUがバスを占有してLDR/STRを繰り返すのではなく、DMAがバスを使ってデータを移動する」ということです。CPUはその間バスを使わなくてよいので、演算や制御ロジックに専念できます。
DMAはCPUの「部下」ではなく、同じバスを共有する独立したハードウェアモジュールです。「CPUが寝ている間にDMAが動く」というより、「CPUとDMAが並列に異なる仕事をしている」 と理解するのが正確です。
- CPU → 制御ロジック・演算・判断
- DMA → データの単純搬送
この役割分担が、組み込みシステムの効率を大幅に上げます。
CPUは「DMAに仕事を頼む」だけで、あとは自由に動けます。
DMA転送の3要素
| 要素 | 内容 | 例(UART送信) |
|---|---|---|
| 転送元(Source) | どこから読むか | RAMのバッファ |
| 転送先(Destination) | どこへ書くか | USART2->DR レジスタ |
| 転送数(Count) | 何個転送するか | 送信バイト数 |
さらに、それぞれにアドレスのインクリメント有無を指定します。
| Source | Destination | |
|---|---|---|
| UART TX | ✅ インクリメント(次のバイトへ進む) | ❌ 固定(DR レジスタは常に同じアドレス) |
| ADC → RAM | ❌ 固定(DRレジスタ) | ✅ インクリメント(次の配列要素へ) |
⚡ なぜDMAが必要か
割り込みを使っても「CPU占有」は残る
第8・9回で学んだ割り込みによるUART送信では、UARTが1バイト送り終わるたびに割り込みが発生し、CPUが次のバイトをDRレジスタに書くという動作を繰り返します。
バイト1送信完了 → 割り込み → CPUがバイト2をDRに書く
バイト2送信完了 → 割り込み → CPUがバイト3をDRに書く
……(送信バイト数ぶん繰り返す)
100バイト送るなら100回の割り込みが発生し、CPUはその都度ISRを実行します。
DMAなら割り込みは「完了時の1回だけ」
CPUが「このバッファのN バイトをUART DRに送って」とDMAに依頼
↓
DMAが自律的に1バイトずつ転送(CPUは他の仕事ができる)
↓
転送完了 → DMAが割り込みを1回発生させる
↓
CPUが「次のデータを用意」するだけ
DMAは「CPUを暇にする」ための仕組みです。CPUが転送を監視しなくていい分、その時間でセンサー読み取り・制御演算・表示更新など「本当にCPUが必要な仕事」ができます。
割り込み → 「イベント駆動でCPUを呼ぶ」 DMA → 「CPUを呼ばずにデータを動かす」
両者は対立ではなく、組み合わせて使うものです。
🏗 STM32のDMAアーキテクチャ
DMA1 と DMA2
STM32F401RE には DMA1(8ストリーム)と DMA2(8ストリーム)があります。
- DMA1:APB1ペリフェラル(USART2, I2C1, SPI2 など)を担当
- DMA2:APB2ペリフェラル(USART1, SPI1, ADC1 など)+メモリ間転送
ストリームとチャネル
各DMAには8本の ストリーム(Stream0〜7) があり、それぞれ8つの チャネル(Channel0〜7) から1つを選択します。チャネルはどのペリフェラルのDMA要求を受け付けるかを決めます。
| DMA | ストリーム | チャネル | ペリフェラル |
|---|---|---|---|
| DMA1 | Stream6 | Channel4 | USART2_TX ← 今回使う |
| DMA1 | Stream5 | Channel4 | USART2_RX |
| DMA1 | Stream0 | Channel1 | I2C1_RX |
| DMA2 | Stream7 | Channel4 | USART1_TX |
ストリーム・チャネルの対応はマイコンごとに異なります。STM32F401の場合は RM0368(Reference Manual) の DMA chapter にある “Table: DMA1 request mapping” で確認してください。CubeMXで設定すると自動的に正しい値が入ります。
DMA転送モード
| モード | 説明 | 用途 |
|---|---|---|
| Normal | 指定個数を転送して停止 | UART送信(1回きり) |
| Circular | バッファを使い回し連続転送 | ADCサンプリング、オーディオ |
| Memory-to-Memory | RAM間コピー | memcpyの代替(DMA2のみ対応。DMA1は不可) |
🛠 UART TX をDMA化する(実践)
CubeMX設定
- USART2 → Mode: Asynchronous(Baud: 115200)
- DMA Settings タブ → Add →
USART2_TX- Direction: Memory To Peripheral
- Mode: Normal
- Increment: Memory ✅ / Peripheral ❌
- NVIC Settings → DMA1 stream6 global interrupt → Enable ✅
生成されるコード
CubeMXが生成する初期化コードの要点:
/* main.c の MX_DMA_Init() から抜粋 */
hdma_usart2_tx.Instance = DMA1_Stream6;
hdma_usart2_tx.Init.Channel = DMA_CHANNEL_4;
hdma_usart2_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_usart2_tx.Init.PeriphInc = DMA_PINC_DISABLE; /* DR固定 */
hdma_usart2_tx.Init.MemInc = DMA_MINC_ENABLE; /* バッファを順番に */
hdma_usart2_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart2_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart2_tx.Init.Mode = DMA_NORMAL;
hdma_usart2_tx.Init.Priority = DMA_PRIORITY_LOW;
実装:DMA送信
/* グローバルバッファ(DMA転送中はRAMに残っていなければならない)*/
static uint8_t g_tx_buf[64];
volatile bool g_tx_busy = false; /* <stdbool.h> */
/* DMA送信を開始する */
void uart_send_dma(const char *str)
{
uint16_t len = strlen(str);
if (len > sizeof(g_tx_buf)) len = sizeof(g_tx_buf);
/* 転送中なら待つ(またはエラー処理) */
while (g_tx_busy); /* ← ポーリング(下記補足参照)*/
memcpy(g_tx_buf, str, len);
g_tx_busy = true;
HAL_UART_Transmit_DMA(&huart2, g_tx_buf, len);
/* ← ここでCPUは戻ってくる。転送はDMAが勝手にやる */
}
/* 転送完了コールバック(DMA割り込みから呼ばれる)*/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART2)
{
g_tx_busy = false; /* 次の送信を許可 */
}
}
/* main の while(1) */
while (1)
{
char msg[32];
snprintf(msg, sizeof(msg), "tick=%lu\r\n", g_tim2_tick);
uart_send_dma(msg); /* CPU はここで即座に戻る */
/* CPUは他の仕事ができる */
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
HAL_Delay(500);
}
while (g_tx_busy) は前の転送が終わるまでCPUを足止めするポーリングです。送信頻度が高い場合はDMAの非ブロッキングの恩恵が失われます。実運用では**「ビジーなら送信をスキップ、またはエラー返却」**という設計を検討しましょう。
/* 非ブロッキングを維持する設計 */
if (g_tx_busy) return HAL_BUSY; /* 呼び出し元に判断を委ねる */
HAL_UART_Transmit() がブロッキング(送信完了まで待つ)なのに対し、HAL_UART_Transmit_DMA() は非ブロッキングです。関数を呼んだ瞬間に制御が戻り、DMAがバックグラウンドで転送を進めます。
このため 「転送完了前にバッファを再利用しない」 ことが重要です(詳細は落とし穴セクションで)。
動作確認:DWT CYCCNT で測る
/* DMA送信の開始〜コールバックまでのCPU使用時間を計測 */
uint32_t t_start = DWT->CYCCNT;
HAL_UART_Transmit_DMA(&huart2, g_tx_buf, len);
uint32_t t_launch = DWT->CYCCNT - t_start;
/* t_launch ≈ 数十〜数百サイクル(コールバックではなく "起動" だけの時間)*/
/* 比較:ブロッキング版 */
t_start = DWT->CYCCNT;
HAL_UART_Transmit(&huart2, g_tx_buf, len, HAL_MAX_DELAY);
uint32_t t_blocking = DWT->CYCCNT - t_start;
/* t_blocking ≈ 転送バイト数 × 86.8µs × 84 cycles/µs(全部CPUを占有)*/
64バイト送信時の比較例:
| 方式 | CPU占有時間 |
|---|---|
HAL_UART_Transmit()(ブロッキング) |
≈ 47,000サイクル(≈ 560µs) |
HAL_UART_Transmit_DMA()(DMA) |
≈ 200サイクル(≈ 2.4µs) |
ブロッキング版の占有サイクル数は送信バイト数に比例します:
t_{\text{blocking}} \approx N_{\text{bytes}} \times \frac{10\,\text{bits}}{f_{\text{baud}}} \times f_{\text{CPU}}DMA方式ではこの占有時間が「起動コスト」の \approx 200\,\text{cycles} だけに圧縮され、 N_{\text{bytes}} に依存しなくなります。
📊 スループットとレイテンシ
DMAが「速い」と感じる理由を整理します。
スループット(単位時間あたりの転送量)
DMAを使っても使わなくても、UARTの物理的な転送速度は変わりません。115200bps は 115200bps です。
DMAが上げるのはスループットではなく、CPUが他の仕事に使える時間です。
レイテンシ(応答の速さ)
DMAを使うと、UART送信の「待ち時間」がCPUの目線からはほぼゼロになります。
【ブロッキング】
CPUが送信に拘束 ─────────────────────────── 解放
↑送信開始 ↑送信完了
【DMA】
CPUが起動指示 ─ 解放
↑ DMA転送完了
↑割り込みでコールバック
この差が「レイテンシの改善」です。CPUはすぐに次のタスクに移れます。
🔀 バス競合
AHBバスマトリクス
STM32F4はAHB(Advanced High-performance Bus)バスマトリクスという構造を持ちます。CPUとDMAは独立したバスマスターとして存在し、同じバスを共有します。
Cortex-M4"] DMA["DMA1"] BUS(["AHBバスマトリクス"]) SRAM["SRAM"] Flash["Flash"] APB["APB1/APB2
ペリフェラル"] CPU --> BUS DMA --> BUS BUS --> SRAM BUS --> Flash BUS --> APB
競合が起きるとき
CPUとDMAが同時に同じメモリにアクセスしようとすると、一方が待たされます。
CPU: g_tx_buf(SRAM上)を読もうとする
DMA: g_tx_buf(SRAM上)からデータを読み込み中
→ バスアービトレーション発生 → どちらかが1サイクル待つ
バスアービトレーションとは「同時にバスを使おうとした複数のマスターの調停(交通整理)」です。これをバス競合(Bus Contention) と呼びます。
STM32F4のAHBバスマトリクスは「1本の道路」ではなく**「立体交差のある高速道路網」**です。CPUがFlashから命令を読んでいる間に、DMAがSRAMにアクセスしても競合しません。FlashとSRAMへのバス経路が別レーンになっているからです。
競合が起きるのは、CPUとDMAが同時にSRAMにアクセスした場合だけです。DMAがUART送信でSRAMからデータを読み出す最中に、CPUも同じSRAMを読み書きしようとしたときに限り、バスアービトレーションが発生します。
バス競合は避けられませんが、影響は通常小さい(数サイクル〜数十サイクルのストール)です。UART DMAのような低速な周期的転送では、ほとんど問題になりません。
競合が顕著になるのは、DMAが高帯域で連続転送しているとき(高速ADC・オーディオ・カメラなど)です。その場合は転送バッファを内部SRAMとCCM(コアカップルドメモリ)に分けるなどの工夫が必要です。
⚠️ よくある落とし穴
落とし穴1:転送完了前にバッファを上書きする
/* ❌ 転送中なのにバッファを書き換える */
uint8_t tx_buf[64];
HAL_UART_Transmit_DMA(&huart2, tx_buf, 32);
/* DMAがtx_bufを読んでいる最中に… */
memset(tx_buf, 0, 64); /* ← バッファを破壊! */
DMAはまだ古いバッファを読んでいるのに、CPUが上書きしてしまいます。送信データが壊れます。
/* ✅ コールバックで完了を確認してから次の操作 */
volatile uint8_t g_tx_done = 1;
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
g_tx_done = 1;
}
/* 送信前に完了フラグを確認 */
while (!g_tx_done); /* 前の転送が終わるまで待つ */
g_tx_done = 0;
memcpy(tx_buf, new_data, len);
HAL_UART_Transmit_DMA(&huart2, tx_buf, len);
落とし穴2:ローカル変数をDMAバッファにする
/* ❌ スタック上のローカル変数をDMAバッファにする */
void send_message(void)
{
char buf[32] = "Hello DMA!\r\n";
HAL_UART_Transmit_DMA(&huart2, (uint8_t*)buf, 12);
/* ← 関数が return するとスタックが解放される。
DMAはまだその「元スタック」領域を読んでいる! */
}
DMAバッファはグローバル変数またはstaticローカル変数に置く必要があります。
/* ✅ staticで関数スコープを超えて生き続ける */
void send_message(void)
{
static char buf[32];
snprintf(buf, sizeof(buf), "Hello DMA!\r\n");
HAL_UART_Transmit_DMA(&huart2, (uint8_t*)buf, 12);
}
落とし穴3:DMAバッファへの volatile の誤用
DMAが書き込むバッファ(受信バッファなど)は volatile を付けたくなりますが、HALを使う場合は注意が必要です。
/* 微妙な例:volatile uint8_t がDMAバッファの場合 */
volatile uint8_t rx_buf[64];
/* HAL_UART_Receive_DMA() にvolatileポインタを渡すと
コンパイラ警告が出る場合がある(HALがvolatileを期待しないため)*/
HALのDMAドライバは内部でキャッシュ管理をするため、アプリ側では volatile を付けず、代わりにアクセスのタイミングをコールバックで制御する方が安全です。
落とし穴4:D-Cacheとコヒーレンシ問題
D-Cache(データキャッシュ)を持つマイコン全般で発生する問題です。STM32F7/H7だけでなく、Cortex-M7以上を採用したNXP i.MX RT・Renesas RZ・各社の高性能ラインナップも同様です。
DMAはキャッシュを経由せずRAMに直接アクセスするため、CPUのキャッシュとRAMの内容がずれる(コヒーレンシ問題) が起きます。今回使用するSTM32F401はD-Cacheを持たないため発生しませんが、D-Cacheを持つマイコンでDMAを使う場合は必ず対処が必要です。
RX方向(DMA → RAM → CPU)で壊れるケース
DMA が rx_buf にデータをRAMへ書く(キャッシュを通らない)
↓
CPUが rx_buf を読もうとする
→ キャッシュに古いデータ(前回値やゼロ)が残っている
→ RAMではなくキャッシュを読む ← 壊れる
症状: バッファが全ゼロ、または前回の受信データのまま変わらない。printf を挿入すると「直る」(メモリアクセスがキャッシュをフラッシュするため再現しなくなる)。
TX方向(CPU → RAM → DMA)で壊れるケース
CPUが tx_buf に新データを書く
→ write-backキャッシュなのでRAMにはまだ書かれない
↓
DMAが tx_buf をRAMから読んで送信する → 古いデータが送られる ← 壊れる
症状: 送信データが前回と同じ、またはゴミデータ。
対処法:SCB_CleanDCache / SCB_InvalidateDCache
/* ---- RX完了後:キャッシュを破棄してRAMの最新値を読む ---- */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, sizeof(rx_buf));
process_received_data(rx_buf); /* これ以降は最新データ */
}
/* ---- TX開始前:キャッシュの内容をRAMに書き出す ---- */
void uart_send_dma(uint8_t *data, uint16_t len)
{
memcpy(tx_buf, data, len);
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, sizeof(tx_buf));
HAL_UART_Transmit_DMA(&huart2, tx_buf, len); /* DMAはRAMの最新データを読む */
}
Cortex-M7のL1データキャッシュはキャッシュライン1本=32バイトです。キャッシュの操作は常にこの32バイト単位で行われるため、SCB_InvalidateDCache_by_Addr / SCB_CleanDCache_by_Addr も32バイト境界・32バイト倍数の範囲にしか適用できません。
バッファアドレスが32バイト境界にない場合、隣接する変数のキャッシュまで巻き込んで破棄され、全く別の場所が壊れます。
/* DMAバッファは必ず32バイトアライン */
__attribute__((aligned(32))) uint8_t rx_buf[64];
__attribute__((aligned(32))) uint8_t tx_buf[64];
| 関数 | 動作 | 使うタイミング |
|---|---|---|
SCB_CleanDCache_by_Addr() |
キャッシュ→RAMに書き出す(Flush) | DMA送信開始前(CPU書き→DMA読み) |
SCB_InvalidateDCache_by_Addr() |
キャッシュを無効化してRAMから読み直す | DMA受信完了後(DMA書き→CPU読み) |
| MPU非キャッシュ領域を定義 | DMAバッファをキャッシュ対象外アドレスに配置 | バッファが多い場合の根本解決 |
まとめ
今回学んだこと:
| 概念 | 内容 |
|---|---|
| DMAの役割 | CPUを介さずRAM↔ペリフェラル間でデータを転送する |
| DMA1 Stream6 Ch4 | NUCLEO-F401RE で USART2_TX を担当 |
| HAL_UART_Transmit_DMA() | 非ブロッキング。CPUはすぐに制御を取り戻す |
| TxCpltCallback | 転送完了後に呼ばれる。次の送信はここで許可する |
| バス競合 | CPU/DMAが同一メモリに同時アクセスすると発生。低速転送では影響は小さい |
| バッファ管理 | 転送中のバッファを上書き禁止。グローバル/staticに配置 |
| D-Cacheコヒーレンシ | D-Cacheを持つマイコンではSCB_CleanDCache(TX前)・SCB_InvalidateDCache(RX後)が必要。32バイトアライン必須 |
DMAを使いこなすと、CPUの仕事に「重要度の格差」をつけられます。
- CPUしかできない仕事:制御演算・判断・状態遷移
- DMAに任せていい仕事:単純なデータの搬送
「CPUが1バイトずつ運ぶ」から「DMAが全部やる、CPUは本来の仕事だけ」に変わったとき、組み込みシステムの設計思想が一段上のステージに上がります。
次回は 「リンカスクリプトとmapファイル(裏側を見る)」 です。「.text・.data・.bss はどこに置かれるか」「mapファイルでRAM消費を追う方法」を学びます。
次回予告
🚀 第11回:リンカスクリプトとmapファイル(裏側を見る)
.text・.data・.bss はどこに置かれるか。リンカスクリプトの読み方、mapファイルでRAM/Flash消費を把握する方法、スタック・ヒープの配置を理解します。
よくある質問(FAQ)
Q. DMAを使うとUART送信が「速くなる」のですか?
物理的な転送速度(bps)は変わりません。速くなるのはCPUが他の処理に使える時間です。送信に拘束されていた時間がゼロに近づくので、システム全体のレスポンスが向上します。
Q. HAL_UART_Transmit_DMA() を連続して呼べますか?
前の転送が完了する前に呼ぶとエラーになります(HAL_BUSY が返ります)。HAL_UART_TxCpltCallback() でフラグを立て、送信前に完了確認するのが正しいパターンです。
Q. Circular モードはどんなときに使いますか?
ADCのサンプリングやオーディオ出力のように連続してデータを流し続けるときに使います。Circularモードでは転送が完了するとDMAが自動的に先頭に戻り、HalfCpltCallback(半分完了)と CpltCallback(全体完了)でダブルバッファ処理ができます。
Q. DMAはUART以外にも使えますか?
はい。SPI・I2C・ADC・DAC・タイマなど、ほぼすべてのペリフェラルがDMAをサポートしています。特にSPI(高速)とADC(高頻度サンプリング)ではDMAなしの運用は現実的ではありません。
UART送信のDMA化を習得したら、次はDMA受信が待っています。HAL_UART_Receive_DMA() を使えばCPUが干渉することなく連続受信が可能になります。
さらにCircularモードを使えばバッファを自動的に使い回す連続転送が実現でき、ADCの高速サンプリングやオーディオ処理で真価を発揮します。「DMAはUART送信だけ」ではありません。DMAの可能性はまだまだ続きます。