Chapter 08 · ARM 嵌入式 C 开发

DMA 直接内存访问

理解 DMA 通道仲裁与三种传输方向,掌握双缓冲技术,实现 UART/ADC/SPI 的零拷贝高性能数据传输。

课程进度80%

关键概念

DMA(直接内存访问)
Direct Memory Access,一种让外设与内存直接交换数据,绕过 CPU 的机制。例如 UART 收到数据,DMA 自动把数据从 UART 的 DR 寄存器复制到 RAM 缓冲区,无需 CPU 介入。CPU 只需配置好 DMA,等传输完成后处理结果,期间可以做其他工作。
DMA 控制器结构
STM32F4 有 2 个 DMA 控制器(DMA1 和 DMA2),每个有 8 个 Stream(通道),每个 Stream 有 8 个 Request(请求来源选择)。每个 Stream 独立工作,有自己的 FIFO 缓冲。DMA2 可以访问 AHB 总线,因此 SPI1/ADC 等挂在 APB2 的外设通常用 DMA2。
三种传输方向
P→M(外设到内存):ADC 采集结果 → RAM 数组(最常见)。M→P(内存到外设):RAM 数组 → UART 发送寄存器(批量发送)。M→M(内存到内存):RAM 区域之间的快速复制,类似 memcpy 但由硬件完成,仅 DMA2 支持。
循环模式 vs 普通模式
普通模式(Normal):传输完指定数量后停止,产生传输完成中断(TC)。循环模式(Circular):传输完后自动重置计数器重新开始,地址回到起点,适合连续采集(ADC)或连续发送(DAC 波形)。
DMA 中断标志
每个 DMA Stream 有 5 个状态标志:FEIF(FIFO 错误)、DMEIF(直接模式错误)、TEIF(传输错误)、HTIF(半传输,传输了一半)、TCIF(传输完成)。半传输中断在双缓冲场景中非常有用——前半 buffer 满时通知 CPU 处理,同时 DMA 继续填充后半。
双缓冲模式(Double Buffer)
DMA 交替使用两个缓冲区(Buffer0 和 Buffer1)。当 DMA 填充 Buffer0 时,CPU 处理 Buffer1;当 DMA 切换到 Buffer1 时,CPU 处理 Buffer0。实现真正的零延迟流式处理,适合音频、高速数据采集等场景。STM32F4 DMA 支持硬件双缓冲(DBM 位)。
DMA 与 Cache 一致性
STM32F4 的 Cortex-M4 没有数据 Cache,DMA 写入 SRAM 后 CPU 读到的直接是最新值,无需手动刷 Cache。但在 STM32H7(Cortex-M7,有 D-Cache)上,DMA 写入的缓冲区必须放在非 Cache 区域,或在读取前执行 SCB_InvalidateDCache_by_Addr(),否则 CPU 读到的是 Cache 中的旧值。

DMA 通道分配图

STM32F4 DMA 请求映射(常用部分) ═══════════════════════════════════════════════════════════════ DMA1(主要服务 APB1 外设) Stream │ CH0 │ CH1 │ CH2 │ CH3 │ CH4 ───────┼──────────┼──────────┼──────────┼──────────┼──────── Str0 │ SPI3_RX │ │ SPI3_RX │ SPI2_RX │ UART5_RX Str1 │ │ UART3_RX │ TIM7_UP │ UART3_TX │ UART3_TX Str2 │ SPI3_RX │ TIM7_UP │ │ TIM4_CH1 │ Str3 │ SPI2_RX │ TIM2_UP │ I2C3_RX │ │ UART3_TX Str4 │ SPI2_TX │ │ TIM3_CH4 │ UART4_TX │ Str5 │ │ USART2_RX│ │ TIM3_CH2 │ I2C1_RX Str6 │ │ USART2_TX│ │ TIM3_CH1 │ UART5_TX Str7 │ │ │ I2C2_TX │ UART5_TX │ TIM3_UP DMA2(可访问 AHB,支持 M→M) Stream │ CH0 │ CH3 │ CH4 │ CH5 ───────┼──────────────┼────────────┼────────────┼────────── Str0 │ ADC1 │ SPI1_RX │ ADC1 │ SPI6_TX Str1 │ SAI1_A │ USART6_RX │ SPI4_TX │ SPI6_RX Str2 │ TIM8_CH1 │ SPI1_RX │ USART1_RX │ SPI5_TX Str3 │ SAI1_A │ │ SPI1_TX │ SPI5_TX Str4 │ ADC1 │ SPI1_TX │ SPI4_RX │ Str5 │ SPI6_TX │ USART6_TX │ USART1_RX │ SPI5_RX Str6 │ TIM1_CH1 │ SDIO │ SDIO │ USART6_TX Str7 │ │ │ USART1_TX │ DCMI 使用时查 Reference Manual 的 DMA Request Mapping 表,选对 Stream+Channel!

DMA 内存到内存快速复制

/**
 * DMA2 M→M 快速内存复制(比 memcpy 更高效,CPU 可同时运行)
 * 注意:仅 DMA2 支持 M2M,且两端地址必须是 4 字节对齐(word传输时)
 */
DMA_HandleTypeDef hdma_m2m;

HAL_StatusTypeDef DMA_MemCopy(uint32_t *dst, uint32_t *src, uint32_t words) {
  hdma_m2m.Instance                 = DMA2_Stream0;
  hdma_m2m.Init.Channel             = DMA_CHANNEL_0;
  hdma_m2m.Init.Direction           = DMA_MEMORY_TO_MEMORY;
  hdma_m2m.Init.PeriphInc           = DMA_PINC_ENABLE;   /* 源地址自增 */
  hdma_m2m.Init.MemInc              = DMA_MINC_ENABLE;   /* 目标地址自增 */
  hdma_m2m.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD;
  hdma_m2m.Init.MemDataAlignment    = DMA_MDATAALIGN_WORD;
  hdma_m2m.Init.Mode                = DMA_NORMAL;
  hdma_m2m.Init.Priority            = DMA_PRIORITY_HIGH;
  hdma_m2m.Init.FIFOMode            = DMA_FIFOMODE_ENABLE;
  hdma_m2m.Init.FIFOThreshold       = DMA_FIFO_THRESHOLD_FULL;
  hdma_m2m.Init.MemBurst            = DMA_MBURST_INC4;
  hdma_m2m.Init.PeriphBurst         = DMA_PBURST_INC4;
  HAL_DMA_Init(&hdma_m2m);

  return HAL_DMA_Start_IT(&hdma_m2m,
                           (uint32_t)src,   /* 源地址 */
                           (uint32_t)dst,   /* 目标地址 */
                           words);           /* 传输 N 个 word */
}

UART DMA 发送(非阻塞)

/* UART1 DMA 发送:CPU 只提交请求,DMA 搬运数据,TX 完成后回调 */
volatile uint8_t uart_tx_busy = 0;

void UART_DMA_Send(uint8_t *data, uint16_t len) {
  while (uart_tx_busy);  /* 等待上次发送完成(可改为超时) */
  uart_tx_busy = 1;
  HAL_UART_Transmit_DMA(&huart1, data, len);
}

/* 发送完成回调,清除忙标志 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
  if (huart->Instance == USART1) {
    uart_tx_busy = 0;
  }
}

/* 使用示例:发送 JSON 字符串 */
static uint8_t tx_buf[128];
void Report_Sensor(float temp, float humi) {
  int len = snprintf((char*)tx_buf, sizeof(tx_buf),
                     "{\"t\":%.1f,\"h\":%.1f}\r\n", temp, humi);
  UART_DMA_Send(tx_buf, len);
  /* 函数立即返回,DMA 在后台发送,CPU 可以干别的 */
}

SPI DMA 高速数据读取

/**
 * SPI1 DMA 全双工收发(同时 TX 和 RX)
 * 适合高速读取 Flash/ADC,提高总线利用率
 */
uint8_t spi_tx_buf[256];
uint8_t spi_rx_buf[256];

void SPI_DMA_Transfer(uint8_t *tx, uint8_t *rx, uint16_t len) {
  W25Q_CS_LOW();
  /* 同时启动 TX DMA 和 RX DMA */
  HAL_SPI_TransmitReceive_DMA(&hspi1, tx, rx, len);
  /* DMA 完成后触发 HAL_SPI_TxRxCpltCallback */
}

void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
  if (hspi->Instance == SPI1) {
    W25Q_CS_HIGH();
    /* 处理 spi_rx_buf 中的数据 */
  }
}

双缓冲 ADC 采集(音频级应用)

双缓冲工作流程(DMA 硬件双缓冲模式 DBM) 时间线 ────────────────────────────────────────────────────► DMA: [ 填充 buf0 ][ 填充 buf1 ][ 填充 buf0 ][ 填充 buf1 ] ↑ ↑ ↑ 切换到buf1 切换到buf0 切换到buf1 HT中断 TC中断 HT中断 │ │ CPU: [处理 buf0] [处理 buf1] [处理 buf0] 关键:CPU 处理时间必须小于 DMA 填充一个 buf 的时间! 否则 CPU 还没处理完,DMA 已经开始覆盖同一块 buf → 数据损坏 实现方式: ① HAL DBM(硬件双缓冲):配置 DMA M0AR/M1AR,设置 DBM=1 ② 软件双缓冲:DMA 循环 + HTIF/TCIF 中断,软件切换处理指针
#define BUF_SIZE  512
uint16_t adc_buf0[BUF_SIZE];   /* 双缓冲 */
uint16_t adc_buf1[BUF_SIZE];
volatile uint8_t buf_ready = 0;  /* 0=buf0就绪,1=buf1就绪 */

void ADC_DoubleBuffer_Init(void) {
  /* 启动 ADC DMA,使用 HAL 双缓冲(注:需要直接操作 DMA 寄存器) */
  HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf0, BUF_SIZE * 2);
  /* 使能半传输中断 */
  __HAL_DMA_ENABLE_IT(hadc1.DMA_Handle, DMA_IT_HT);
}

/* 半传输中断:DMA 已填充 buf0(前半),CPU 开始处理 buf0 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) {
  buf_ready = 0;  /* buf0 就绪 */
}

/* 传输完成中断:DMA 已填充 buf1(后半),CPU 开始处理 buf1 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) {
  buf_ready = 1;  /* buf1 就绪 */
}

void main_process(void) {
  while (1) {
    uint16_t *process_buf = (buf_ready == 0) ? adc_buf0 : adc_buf1;
    /* 在 DMA 填充另一半期间,处理当前这半 */
    Process_ADC_Data(process_buf, BUF_SIZE);
  }
}
DMA FIFO 与 Burst 传输

DMA FIFO 是 DMA 内部的缓冲,可以将多个小传输合并成一次 Burst 传输到 AHB 总线,减少总线仲裁次数,提高带宽利用率。FIFO 阈值有 1/4、1/2、3/4、Full 四种,阈值越大,延迟越高但效率越好。Burst 大小(MBURST/PBURST)必须与 FIFO 阈值匹配,否则会产生 FIFO 错误(FEIF)。若 FIFO 模式关闭(直接模式),每次外设请求触发一次单次传输。

DMA 使用最容易犯的错误

① 使用局部变量作为 DMA 缓冲:局部变量在栈上,函数返回后被回收,DMA 继续写入会破坏栈数据,导致极难定位的 HardFault。DMA 缓冲必须声明为全局或 static 变量。
② Stream/Channel 选错:必须查 Reference Manual 的 DMA Request Mapping 表,用错了 DMA 请求会导致外设触发时 DMA 无响应。
③ 缓冲区地址未对齐:字传输(Word)要求 4 字节对齐,半字(Half-Word)要求 2 字节对齐,否则触发总线错误。