引言

在嵌入式开发中,DMA(直接内存访问)是提升 ADC 连续采样效率的关键技术。然而,在 Arduino 环境下,开发者常因缓冲区对齐问题遭遇 HardFault,程序崩溃且难以调试。本文将以 STM32F103 为例,剖析 DMA 传输的对齐机制,并给出从原理到实践的完整解决方案。

1. 问题现象与初步定位

某项目使用 Arduino IDE 编写代码,通过 DMA 将 ADC 连续采样的数据存入缓冲区,并在主循环中处理。代码在初始化后立即触发 HardFault,进入 HardFault_Handler 死循环。初步检查代码逻辑无误,DMA 配置正确,但问题依旧。

典型代码如下:

#define ADC_BUF_SIZE 1024
uint16_t adc_buffer[ADC_BUF_SIZE];  // 缓冲区定义

void setup() {
    // ADC 和 DMA 初始化
    adc_dma_init((uint32_t)adc_buffer);
}

2. 深入原理:DMA 与缓冲区对齐

2.1 Cortex-M 内核的 DMA 对齐要求

STM32 的 DMA 控制器在传输数据时,要求源地址和目标地址必须与传输数据宽度对齐。例如,使用半字(16位)传输时,地址需 2 字节对齐;使用字(32位)传输时,地址需 4 字节对齐。若地址未对齐,DMA 控制器可能产生总线错误,进而触发 HardFault。

2.2 Arduino 环境下的内存分配

在 Arduino 中,全局变量通常被放置在 .bss.data 段,链接脚本默认对齐为 4 字节。但某些情况下,如使用 malloc 动态分配或特定编译器优化,可能导致缓冲区地址仅 2 字节对齐。此外,若缓冲区被定义为结构体成员或通过指针传递,对齐属性可能丢失。

2.3 HardFault 的触发机制

当 DMA 访问未对齐地址时,AHB 总线会返回错误,Cortex-M 内核将该错误转换为 HardFault。调试时,可通过查看 HFSR(HardFault Status Register)和 MMFSR(MemManage Fault Status Register)确认是否为总线错误。

3. 排查步骤:定位对齐问题

3.1 检查缓冲区地址

在初始化前打印或调试查看缓冲区地址:

Serial.print("Buffer address: 0x");
Serial.println((uint32_t)adc_buffer, HEX);

若地址末位不是 0、4、8、C(即非 4 的倍数),则存在对齐问题。

3.2 使用故障寄存器辅助诊断

HardFault_Handler 中读取 HFSRMMFSR

void HardFault_Handler(void) {
    volatile uint32_t hfsr = SCB->HFSR;
    volatile uint32_t mmfsr = SCB->MMFSR;
    // 打印或记录错误信息
    while(1);
}

MMFSRMSTKERRMUNSTKERR 位被置位,表明入栈或出栈时发生总线错误,与 DMA 访问相关。

3.3 检查链接脚本

Arduino 的链接脚本通常位于硬件平台目录下,检查 ALIGN 指令是否确保 .bss 段 4 字节对齐。若自定义链接脚本,需显式声明。

4. 解决方案:强制对齐缓冲区

4.1 使用 __attribute__((aligned(4)))

最简单的方法是在缓冲区定义时指定对齐属性:

#define ADC_BUF_SIZE 1024
uint16_t adc_buffer[ADC_BUF_SIZE] __attribute__((aligned(4)));

这确保缓冲区首地址为 4 的倍数,满足 DMA 字传输要求。

4.2 使用联合体或结构体封装

通过联合体强制对齐:

union ADC_Buffer {
    uint16_t data[ADC_BUF_SIZE];
    uint32_t align;  // 强制 4 字节对齐
} adc_buffer;

4.3 动态分配时的对齐处理

若使用 malloc,需自定义对齐分配器,或使用 aligned_alloc(C11):

uint16_t* buf = (uint16_t*)aligned_alloc(4, ADC_BUF_SIZE * sizeof(uint16_t));

5. 完整代码示例

以下为修复后的 ADC DMA 连续采样代码,包含对齐处理及错误检查:

#include <Arduino.h>

#define ADC_BUF_SIZE 1024

// 强制 4 字节对齐
uint16_t adc_buffer[ADC_BUF_SIZE] __attribute__((aligned(4)));

void adc_dma_init(uint32_t buf_addr) {
    // 启用时钟
    RCC->AHBENR |= RCC_AHBENR_DMA1EN;
    RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;

    // 配置 ADC1:连续转换,扫描模式,右对齐
    ADC1->CR2 = ADC_CR2_ADON | ADC_CR2_CONT | ADC_CR2_DMA;
    ADC1->CR1 = 0;  // 单通道
    ADC1->SQR3 = 0; // 通道0

    // 配置 DMA1 通道1
    DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR;
    DMA1_Channel1->CMAR = buf_addr;
    DMA1_Channel1->CNDTR = ADC_BUF_SIZE;
    DMA1_Channel1->CCR = DMA_CCR_MEM2MEM_DIS | DMA_CCR_PL_LOW |
                         DMA_CCR_MSIZE_16BIT | DMA_CCR_PSIZE_16BIT |
                         DMA_CCR_MINC_EN | DMA_CCR_CIRC_EN | DMA_CCR_DIR;

    // 使能 DMA 和 ADC
    DMA1_Channel1->CCR |= DMA_CCR_EN;
    ADC1->CR2 |= ADC_CR2_ADON;
    ADC1->CR2 |= ADC_CR2_SWSTART;
}

void setup() {
    Serial.begin(115200);
    delay(100);

    // 打印缓冲区地址,确认对齐
    Serial.print("Buffer address: 0x");
    Serial.println((uint32_t)adc_buffer, HEX);

    if ((uint32_t)adc_buffer % 4 != 0) {
        Serial.println("Error: Buffer not aligned!");
        while(1);
    }

    adc_dma_init((uint32_t)adc_buffer);
    Serial.println("DMA ADC started.");
}

void loop() {
    // 读取最新采样值(例如最后一个)
    uint16_t value = adc_buffer[ADC_BUF_SIZE - 1];
    Serial.println(value);
    delay(100);
}

6. 注意事项

  • 对齐粒度:根据 DMA 传输宽度选择对齐值,16位传输需 2 字节对齐,32位需 4 字节对齐。建议统一使用 4 字节对齐以兼容。
  • 缓冲区大小:确保缓冲区大小是传输宽度的整数倍,避免 DMA 越界。
  • 缓存一致性:若使用带缓存的内核(如 Cortex-M7),需考虑 D-Cache 和 DMA 的一致性,使用 SCB_CleanDCache 或配置 MPU。
  • 调试技巧:在 HardFault 处理函数中打印寄存器值,或使用 __asm__("BKPT #0") 触发断点,便于在调试器中定位。
  • 平台差异:不同 Arduino 板卡(如 Due、Zero)的 DMA 实现不同,对齐要求可能变化,需查阅对应参考手册。

7. 总结

缓冲区对齐是 DMA 编程中易被忽略的细节,但一旦触发 HardFault,排查成本极高。通过理解 Cortex-M 的 DMA 对齐机制,结合属性定义和链接脚本调整,可彻底避免此类问题。建议在项目初始化时加入地址检查,防患于未然。希望本文能帮助开发者少走弯路,高效完成嵌入式开发。