bsp_adc 深度解析:ADC1 DMA 采样与多任务协作
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_buf 与 buf_idx 以 volatile 声明,保证 ISR 与主循环间数据可见性。hadc 和 hdma_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 内部做了这三件事:
- 使能 ADC 并启动一次软件转换
- 配置 DMA 从 ADC DR 寄存器搬运到
dma_buf - 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_idx和dma_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 之所以成立,是因为 hadc 是 bsp_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 多通道序列采样与注入通道的应用。欢迎留言讨论。
Comments NOTHING