bsp_adc 深度解析:ADC1 DMA 采样与多任务协作

Babel36acl 嵌入式实战 无~ 6 次阅读 预计阅读时间: 18 分钟 发布于 1 天前 最后更新于 12 分钟前 3982 字


bsp_adc 深度解析:ADC1 DMA 采样与多任务协作

系列文章 S4 · 2026-05-29

1. 概述

本篇文章深入分析 bsp_adc 模块的实现——基于 STM32F103 的 ADC1 单通道 DMA 循环采样,专为功率测量链路的源头(分流电压采样)设计。这不是一篇 HAL 库 API 的罗列,而是围绕「采样触发 — DMA 搬运 — ISR 通知 — 上下文保护 — 错误恢复 — 数据上送」全链路的实战拆解。

所有对外接口已脱敏为 GENERIC_DEVICE_CONTROLLER 命名风格,内部保留 STM32F103、ADC1、DMA 等真实硬件细节。

2. 硬件连接与设计约束

  • ADC 外设:ADC1
  • 采样通道:ADC1_IN4 → GPIO PA4(模拟输入)
  • 采样对象:分流电阻两端电压(shunt voltage),后续经 Service 层换算为电流 → 功率
  • 转换结果:12 位右对齐,原始 ADC counts(0–4095)
  • 触发方式:软件触发(HAL_ADC_Start_DMA),DMA 循环模式持续搬运
  • DMA 通道:DMA1 Channel1(ADC1 的固定映射)
  • 缓冲区:16 × uint16_t 环形缓冲,DMA 循环写

3. 模块架构:Context 对象模式

整个 bsp_adc 模块不依赖全局变量,而是通过一个 Context 结构体封装所有运行时状态。这是嵌入式 C 中一种轻量级的「伪 OOP」模式,特别适合裸机或 RTOS 混合环境。

/* bsp_adc.h */
typedef struct {
    ADC_HandleTypeDef    hadc;            /* HAL ADC 句柄(大对象,放在末尾避免栈溢出) */
    DMA_HandleTypeDef    hdma_adc;        /* DMA 句柄                                    */
    volatile uint16_t    dma_buf[16];     /* DMA 循环缓冲区    16 样本                   */
    volatile uint8_t     buf_idx;         /* 当前可读的最新样本索引(ISR 中更新)         */
    uint32_t             last_sample_raw; /* 上一次读取的原始 ADC 值                      */
    uint32_t             sum;             /* 累加和(供均值计算,暂未启用)                */
    uint8_t              initialized;     /* 初始化标志                                    */
} bsp_adc_ctxt_t;

关键设计决策:dma_bufbuf_idxvolatile 声明,保证 ISR 与主循环间数据可见性。hadchdma_adc 虽然体积大(~100+ bytes),但作为结构体成员而非指针,省去了动态分配和生命周期管理。

4. 初始化流程

GENERIC_DEVICE_CONTROLLER_Status bsp_adc_init(bsp_adc_ctxt_t *ctxt)
{
    GPIO_InitTypeDef  gpio_init = {0};

    if (ctxt == NULL) return GENERIC_DEVICE_CONTROLLER_ERROR;

    /* ---- GPIO: PA4 analog ---- */
    __HAL_RCC_GPIOA_CLK_ENABLE();
    gpio_init.Pin  = GPIO_PIN_4;
    gpio_init.Mode = GPIO_MODE_ANALOG;
    HAL_GPIO_Init(GPIOA, &gpio_init);

    /* ---- ADC1 clock ---- */
    __HAL_RCC_ADC1_CLK_ENABLE();
    __HAL_RCC_DMA1_CLK_ENABLE();

    /* ---- ADC1 handle ---- */
    ctxt->hadc.Instance           = ADC1;
    ctxt->hadc.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4;   /* ≤ 14 MHz on 72 MHz */
    ctxt->hadc.Init.Resolution    = ADC_RESOLUTION_12B;
    ctxt->hadc.Init.ScanConvMode  = ADC_SCAN_DISABLE;            /* 单通道 */
    ctxt->hadc.Init.ContinuousConvMode = DISABLE;                 /* 由 DMA 触发转换 */
    ctxt->hadc.Init.DiscontinuousConvMode = DISABLE;
    ctxt->hadc.Init.ExternalTrigConv = ADC_SOFTWARE_START;
    ctxt->hadc.Init.DataAlign      = ADC_DATAALIGN_RIGHT;
    ctxt->hadc.Init.NbrOfConversion = 1;
    HAL_ADC_Init(&ctxt->hadc);

    /* ---- ADC channel config: IN4 ---- */
    ADC_ChannelConfTypeDef ch_cfg = {0};
    ch_cfg.Channel      = ADC_CHANNEL_4;
    ch_cfg.Rank         = ADC_REGULAR_RANK_1;
    ch_cfg.SamplingTime = ADC_SAMPLETIME_55CYCLES_5;  /* ~1 µs per sample @72MHz */
    HAL_ADC_ConfigChannel(&ctxt->hadc, &ch_cfg);

    /* ---- DMA: circular mode ---- */
    ctxt->hdma_adc.Instance                 = DMA1_Channel1;
    ctxt->hdma_adc.Init.Direction           = DMA_PERIPH_TO_MEMORY;
    ctxt->hdma_adc.Init.PeriphInc           = DMA_PINC_DISABLE;
    ctxt->hdma_adc.Init.MemInc              = DMA_MINC_ENABLE;
    ctxt->hdma_adc.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
    ctxt->hdma_adc.Init.MemDataAlignment    = DMA_MDATAALIGN_HALFWORD;
    ctxt->hdma_adc.Init.Mode                = DMA_CIRCULAR;       /* 循环模式! */
    ctxt->hdma_adc.Init.Priority            = DMA_PRIORITY_HIGH;
    HAL_DMA_Init(&ctxt->hdma_adc);

    __HAL_LINKDMA(&ctxt->hadc, DMA_Handle, ctxt->hdma_adc);

    /* ---- DMA interrupt ---- */
    HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 5, 0);
    HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn);

    /* ---- ADC interrupt ---- */
    HAL_NVIC_SetPriority(ADC1_IRQn, 5, 0);
    HAL_NVIC_EnableIRQ(ADC1_IRQn);

    ctxt->initialized = 1;
    return GENERIC_DEVICE_CONTROLLER_OK;
}

几点值得注意:

  • 预分频选择 ADC_CLOCK_SYNC_PCLK_DIV4:APB2 为 72 MHz → ADC 时钟 18 MHz(≤ 14 MHz 可更可靠,这里留有余量)。
  • ContinuousConvMode = DISABLE 但配合 DMA 循环模式,效果等价于连续转换——每次 DMA 传输完成触发下一次转换。
  • 采样时间 55.5 周期,每样本约 1 µs,16 个缓冲约 16 µs 填满一轮。
  • DMA 中断优先级 5,ADC 中断优先级 5——ADC 中断用于错误处理,DMA 中断用于半满/全满通知(本模块未开启,后续可扩展)。

5. 启动采样与 DMA 循环

GENERIC_DEVICE_CONTROLLER_Status bsp_adc_start(bsp_adc_ctxt_t *ctxt)
{
    if (ctxt == NULL || !ctxt->initialized)
        return GENERIC_DEVICE_CONTROLLER_ERROR;

    ctxt->buf_idx = 0;
    ctxt->sum     = 0;

    HAL_StatusTypeDef hal_ret = HAL_ADC_Start_DMA(
        &ctxt->hadc,
        (uint32_t *)ctxt->dma_buf,
        16
    );

    return (hal_ret == HAL_OK)
        ? GENERIC_DEVICE_CONTROLLER_OK
        : GENERIC_DEVICE_CONTROLLER_BUSY;
}

HAL_ADC_Start_DMA 内部做了这三件事:

  1. 使能 ADC 并启动一次软件转换
  2. 配置 DMA 从 ADC DR 寄存器搬运到 dma_buf
  3. DMA 在 16 次传输后产生传输完成中断(半满/全满均可配置)

由于是 DMA_CIRCULAR,DMA 填满 16 个元素后会自动绕回地址 0 继续写,无需人工干预。这意味着 dma_buf[0..15] 始终是最近的 16 个 ADC 样本。

6. 数据读取:Critical Section 保护

GENERIC_DEVICE_CONTROLLER_Status bsp_adc_read_raw(bsp_adc_ctxt_t *ctxt,
                                                    uint32_t *raw_out)
{
    uint32_t raw;
    uint8_t  idx;

    if (ctxt == NULL || raw_out == NULL)
        return GENERIC_DEVICE_CONTROLLER_ERROR;

    /* ---- Enter critical section: disable IRQ with PRIMASK ---- */
    uint32_t primask = __get_PRIMASK();
    __disable_irq();

    idx = ctxt->buf_idx;
    raw = ctxt->dma_buf[idx];

    ctxt->last_sample_raw = raw;

    /* ---- Exit critical section ---- */
    if (!primask) {
        __enable_irq();
    }

    *raw_out = raw;
    return GENERIC_DEVICE_CONTROLLER_OK;
}

关于 PRIMASK 关键区:

  • 为什么不用关 DMA 或 ADC 中断?——因为 buf_idx 由 DMA 传输完成 ISR 更新。如果读取时不屏蔽中断,ISR 可能在我们读 buf_idxdma_buf[idx] 之间改变 buf_idx,导致读到过期数据。
  • 为什么不用 mutex?——ISR 中不能获取 mutex。PRIMASK 关中断是 Cortex-M3 上最轻量的互斥手段,仅阻塞 ISR 几个 CPU 周期。
  • 保存恢复__get_PRIMASK() 保存调用前的状态,防止嵌套关中断导致意外开启。

7. DMA 传输完成通知:ISR → Task 唤醒

/* 通知回调类型(在 bsp_adc.h 中声明) */
typedef void (*bsp_adc_notify_cb_t)(bsp_adc_ctxt_t *ctxt, uint8_t flags);
/* flags: 1 = half transfer, 2 = full transfer */

static bsp_adc_notify_cb_t s_notify_cb = NULL;

void bsp_adc_register_notify_cb(bsp_adc_notify_cb_t cb)
{
    s_notify_cb = cb;
}

/* HAL 的 DMA 传输完成回调 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    /* 从 hadc 反推 context(依赖 container_of 或结构体首地址一致) */
    bsp_adc_ctxt_t *ctxt = (bsp_adc_ctxt_t *)hadc;

    /* 更新最新样本索引:buf_idx 始终指向最后写入的位置 */
    /* DMA 当前传输位置可由 __HAL_DMA_GET_COUNTER 获取,但为简洁此处用溢出指示 */
    ctxt->buf_idx = (ctxt->buf_idx + 1) & 0x0F;

    /* 通知注册的回调(通常唤醒 AdcTask) */
    if (s_notify_cb) {
        s_notify_cb(ctxt, 2);  /* full transfer */
    }
}

这里有一个巧妙的强制转换:(bsp_adc_ctxt_t *)hadc 之所以成立,是因为 hadcbsp_adc_ctxt_t 的第一个成员——结构体地址就是 hadc 的地址。这种手法在 HAL 库回调中非常常见。

通知回调的典型用法:

/* 在 AdcTask 中注册 */
static void on_adc_data_ready(bsp_adc_ctxt_t *ctxt, uint8_t flags)
{
    (void)flags;
    /* 发信号量/通知给 AdcTask,使其从等待中恢复 */
    osSemaphoreRelease(adc_sem);
}

/* AdcTask 主循环 */
void AdcTask(void *arg)
{
    bsp_adc_ctxt_t *adc = (bsp_adc_ctxt_t *)arg;
    uint32_t raw;

    while (1) {
        osSemaphoreAcquire(adc_sem, osWaitForever);  /* 等待通知 */
        bsp_adc_read_raw(adc, &raw);
        /* raw 上送给 Service 层做 mV 换算 */
        service_power_feed_raw(raw);
    }
}

8. 错误恢复:HAL_ADC_ErrorCallback

void HAL_ADC_ErrorCallback(ADC_HandleTypeDef *hadc)
{
    bsp_adc_ctxt_t *ctxt = (bsp_adc_ctxt_t *)hadc;
    uint32_t        error_code = HAL_ADC_GetError(hadc);

    if (error_code & HAL_ADC_ERROR_DMA) {
        /* DMA 传输出错,最常见的场景是总线争用或 DMA 配置被意外覆盖 */

        /* 1. 停止 ADC + DMA */
        HAL_ADC_Stop_DMA(hadc);

        /* 2. 清除硬件挂起标志位 */
        __HAL_ADC_CLEAR_FLAG(hadc, ADC_FLAG_EOC);
        __HAL_ADC_CLEAR_FLAG(hadc, ADC_FLAG_OVR);

        /* 3. 关键!清除 HAL 层的 ADC_STATE_BUSY 标志 */
        /*    否则下次 Start_DMA 会返回 HAL_BUSY */
        hadc->State = HAL_ADC_STATE_REGULAR_READY;

        /* 4. 重新启动 */
        ctxt->buf_idx = 0;
        HAL_ADC_Start_DMA(hadc, (uint32_t *)ctxt->dma_buf, 16);

        /* 5. 通知上层发生了恢复 */
        if (s_notify_cb) {
            s_notify_cb(ctxt, 0xFF);  /* 错误标志 */
        }
    }
}

为什么需要清除 hadc->State

HAL 库中 HAL_ADC_Start_DMA 入口会检查 hadc->State 是否为 HAL_ADC_STATE_REGULAR_READY。当 DMA 错误触发 error callback 后,HAL 状态机停留在 HAL_ADC_STATE_BUSY,如果不手动复位,下次调用 HAL_ADC_Start_DMA 会直接返回 HAL_BUSY,ADC 模块永久失效。

这个 Bug 在 STM32 HAL 的早期版本中容易踩到,而且错误不明显——系统 ADC 读数会恒为 0,排查时第一反应往往怀疑硬件而非状态机。手动清除 hadc->State 是生产环境中的标准修复手法。

9. 停止与反初始化

GENERIC_DEVICE_CONTROLLER_Status bsp_adc_stop(bsp_adc_ctxt_t *ctxt)
{
    if (ctxt == NULL) return GENERIC_DEVICE_CONTROLLER_ERROR;

    uint32_t primask = __get_PRIMASK();
    __disable_irq();

    HAL_ADC_Stop_DMA(&ctxt->hadc);

    if (!primask) __enable_irq();

    ctxt->buf_idx    = 0;
    ctxt->initialized = 0;
    return GENERIC_DEVICE_CONTROLLER_OK;
}

GENERIC_DEVICE_CONTROLLER_Status bsp_adc_deinit(bsp_adc_ctxt_t *ctxt)
{
    if (ctxt == NULL) return GENERIC_DEVICE_CONTROLLER_ERROR;

    bsp_adc_stop(ctxt);

    HAL_ADC_DeInit(&ctxt->hadc);
    HAL_DMA_DeInit(&ctxt->hdma_adc);

    HAL_NVIC_DisableIRQ(DMA1_Channel1_IRQn);
    HAL_NVIC_DisableIRQ(ADC1_IRQn);

    memset(ctxt, 0, sizeof(bsp_adc_ctxt_t));
    return GENERIC_DEVICE_CONTROLLER_OK;
}

10. 上層使用示例:Service 层的功率计算

/* service_power.c —— 位于 Service 层,非 BSP 模块 */

static bsp_adc_ctxt_t s_adc_ctxt;

void service_power_init(void)
{
    bsp_adc_init(&s_adc_ctxt);
    bsp_adc_register_notify_cb(on_adc_data_ready);
    bsp_adc_start(&s_adc_ctxt);
}

static void on_adc_data_ready(bsp_adc_ctxt_t *ctxt, uint8_t flags)
{
    (void)flags;
    osSemaphoreRelease(g_adc_sem);
}

/* 在 AdcTask 中被调用 */
void service_power_feed_raw(uint32_t raw_counts)
{
    /* BSP 层只输出原始 ADC counts,mV 换算在 Service 层完成 */
    /* 当前为占位——硬件校准系数待标定 */
    uint32_t voltage_mv = (raw_counts * 3300UL) / 4095UL;

    packer_power_limit_feed_shunt_mv(voltage_mv);
}

注意这里 BSP 层不做单位换算——它只返回原始 counts。mV 转换公式 (counts × 3300) / 4095 目前是占位实现,实际板上会有分压电阻比例系数、ADC 参考电压偏移等校准参数,由 Service 层在板级配置中维护。

11. 4 点滑动平均(在 packer 层)

/* packer_power_limit.c —— 4 点滑动平均,不在 BSP 中 */

static uint32_t s_ma_buf[4];
static uint8_t  s_ma_idx = 0;

void packer_power_limit_feed_shunt_mv(uint32_t mv)
{
    uint32_t sum = 0;

    s_ma_buf[s_ma_idx] = mv;
    s_ma_idx = (s_ma_idx + 1) & 0x03;

    for (int i = 0; i < 4; i++) {
        sum += s_ma_buf[i];
    }

    uint32_t avg_mv = sum / 4;

    /* 用 avg_mv 计算电流 = avg_mv / shunt_resistance_mOhm */
    /* 然后计算功率 = current_mA * bus_voltage_mV / 1000000 */
    power_limit_update(avg_mv);
}

4 点滑动平均是一个极为轻量的数字滤波方案,在 DMA 已提供 16 样本硬件缓冲的基础上再做一层软件平均,进一步抑制随机噪声。放在 packer 层而非 BSP 层,体现了「BSP 保持纯净」的分层哲学——BSP 只管「怎么采」,不管「怎么算」。

12. 数据流全景

┌──────────────────────────────────────────────────────────┐
│                   数据流链路总览                         │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  PA4 (shunt)                                             │
│    │                                                     │
│    ▼                                                     │
│  ADC1 (12-bit, 55.5 cycles)                              │
│    │                                                     │
│    ▼  DMA 搬运                                           │
│  dma_buf[16]  (循环覆盖)                                 │
│    │                                                     │
│    ├── DMA_IT_TC ──→ HAL_ADC_ConvCpltCallback            │
│    │                  → 更新 buf_idx                     │
│    │                  → 调用 notify_cb                   │
│    │                    → osSemaphoreRelease(adc_sem)    │
│    │                                                      │
│    ▼  AdcTask 被唤醒                                      │
│  bsp_adc_read_raw()  (PRIMASK 保护)                      │
│    │                                                     │
│    ▼                                                     │
│  service_power_feed_raw(raw_counts)                      │
│    │  mV = (counts × 3300) / 4095                       │
│    ▼                                                     │
│  packer_power_limit_feed_shunt_mv(mv)                    │
│    │  4-point moving average                             │
│    │  → I = V / R_shunt                                  │
│    │  → P = I × V_bus                                    │
│    ▼                                                     │
│  power_limit_update()                                    │
│                                                          │
└──────────────────────────────────────────────────────────┘

错误恢复路径:
  HAL_ADC_ErrorCallback → Stop_DMA → 清 State → Restart

13. 常见问题与排查指南

现象 可能原因 排查点
ADC 读数恒为 0 DMA 未搬运;HAL State 卡在 BUSY 检查 DMA 中断是否使能;检查 ErrorCallback 是否重置 State
ADC 读数恒为 4095 PA4 浮空;GPIO 未配为 Analog 万用表量 PA4 电平;检查 GPIO_MODE_ANALOG
采样值跳跃 采样时间过短;参考电压不稳 增大 SamplingTime;检查 VREF 引脚
DMA 中断不触发 NVIC 优先级分组冲突;中断标志未清 检查 HAL_NVIC_SetPriority 调用;检查 HAL_DMA_IRQHandler 是否被调用
重新启动返回 HAL_BUSY ErrorCallback 未清除 hadc->State 在 ErrorCallback 中加 hadc->State = HAL_ADC_STATE_REGULAR_READY

14. 总结

bsp_adc 模块的核心理念可以概括为:

  • Context 模式 —— 无全局变量,所有状态通过指针传递
  • DMA 循环采样 —— 硬件自动化,零 CPU 开销搬运数据
  • PRIMASK 关键区 —— ISR 与主循环共享数据的安全访问
  • 回调通知 —— ISR 到 Task 的轻量级信号传递
  • 自动错误恢复 —— 即使在 DMA 异常后也能透明恢复
  • 分层清晰 —— BSP 只出 counts,Service 做换算,Packer 做滤波与算法

这种设计让 BSP 层保持硬件驱动的高内聚性,上层逻辑可以灵活替换标定系数或滤波策略而不影响采样链路。下一篇 S5 将深入 ADC 多通道序列采样与注入通道的应用。欢迎留言讨论。

此作者没有提供个人介绍。
最后更新于 2026-05-30