STM32串口DMA传输与ADC采集工程实战 - 第4节

4.1 为什么先讲单次采集?

很多初学者一上来就盯着 DMA、连续转换、多通道这些东西看。我个人习惯是——先把单次阻塞采集跑通。为什么?

你想想看,ADC 最基础的任务就是把模拟电压变成数字量。如果连单次采集都搞不定,后面那些高级功能全是空中楼阁。我在项目里见过不少工程师,DMA 配置得花里胡哨,结果发现基准电压没接对,读出来的数据全是乱的。

所以这一章,咱们就老老实实把单次采集的每一步走一遍。从 CubeMX 配置到代码实现,再到电压换算,全部手把手过。

本章核心目标: 用阻塞方式读取一次 ADC 值,并正确换算为实际电压。

4.2 HAL 库 ADC 初始化流程

STM32 的 ADC 外设,说白了就是一个逐次逼近型转换器。HAL 库帮我们封装好了底层寄存器操作,但初始化流程还是有固定套路的。

4.2.1 CubeMX 配置要点

打开 CubeMX,选好芯片型号。我以 STM32F103C8T6 为例,这个芯片在市面上很常见。

  1. 选择 ADC 通道:在 Pinout 视图里找到 ADC1,把 IN0 设置为模拟输入模式。IN0 对应 PA0 引脚。
  2. 配置时钟:ADC 的时钟源一般选 PCLK2 的 2 分频或 4 分频。F103 的 ADC 最高支持 14MHz,别超了。
  3. 参数设置:在 Parameter Settings 里,把 Scan Conversion Mode 设为 Disabled,Continuous Conversion Mode 设为 Disabled。这就是单次模式。
我的习惯: 每次新建工程,我都会先检查 ADC 的时钟树。曾经有一次我忘了配置分频系数,ADC 跑在 18MHz 上,结果转换结果一直不稳定。嗯,从那以后我再也不敢跳过时钟检查了。

4.2.2 初始化代码结构

CubeMX 生成的初始化代码通常在 MX_ADC1_Init() 函数里。咱们来看看核心部分:

static void MX_ADC1_Init(void)
{
  ADC_ChannelConfTypeDef sConfig = {0};

  hadc1.Instance = ADC1;
  hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE;
  hadc1.Init.ContinuousConvMode = DISABLE;
  hadc1.Init.DiscontinuousConvMode = DISABLE;
  hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;
  hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
  hadc1.Init.NbrOfConversion = 1;

  if (HAL_ADC_Init(&hadc1) != HAL_OK)
  {
    Error_Handler();
  }

  sConfig.Channel = ADC_CHANNEL_0;
  sConfig.Rank = ADC_REGULAR_RANK_1;
  sConfig.SamplingTime = ADC_SAMPLETIME_1CYCLE_5;

  if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK)
  {
    Error_Handler();
  }
}

这里有几个关键参数:

参数 值 说明
ScanConvMode DISABLE 单通道模式,不扫描
ContinuousConvMode DISABLE 单次转换,转完就停
ExternalTrigConv SOFTWARE_START 软件触发启动
DataAlign RIGHT 数据右对齐,方便计算
SamplingTime 1.5 周期 采样时间,根据信号源阻抗调整
注意: 采样时间不是越长越好。我见过有人把采样时间设成 239.5 个周期,结果转换速度慢得离谱。对于普通电压信号,1.5 或 7.5 个周期就够用了。

4.3 阻塞式单通道采集

初始化完成后,怎么触发一次转换?HAL 库提供了两个函数:

  • HAL_ADC_Start():启动 ADC 转换
  • HAL_ADC_PollForConversion():等待转换完成

阻塞式采集的代码很简单:

uint32_t adc_value = 0;

HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
{
  adc_value = HAL_ADC_GetValue(&hadc1);
}
HAL_ADC_Stop(&hadc1);

这段代码的逻辑:

  1. 调用 HAL_ADC_Start() 启动转换
  2. 用 HAL_ADC_PollForConversion() 等待,超时设为 100 毫秒
  3. 转换完成后,用 HAL_ADC_GetValue() 读取结果
  4. 最后调用 HAL_ADC_Stop() 停止 ADC
避坑指南: 我曾经在循环里反复调用 HAL_ADC_Start() 和 HAL_ADC_Stop(),结果发现每次启动都有几十微秒的延迟。后来我改成只在需要时启动,采集完立刻停止,效率高了不少。

4.4 数据读取与电压换算

读到的 adc_value 是一个 12 位的数字量,范围是 0~4095。怎么换算成实际电压?

公式很简单:

电压值 = (ADC值 / 4096) × 参考电压

为什么是 4096 而不是 4095?因为 12 位 ADC 的分辨率是 2^12 = 4096 个量化等级。虽然最大值是 4095,但计算时要用 4096 做分母。

代码实现:

float voltage = 0.0f;
uint32_t adc_value = 0;

HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
{
  adc_value = HAL_ADC_GetValue(&hadc1);
  voltage = (float)adc_value / 4096.0f * 3.3f;
}
HAL_ADC_Stop(&hadc1);

这里假设参考电压是 3.3V。如果你的板子用的是内部参考电压,或者外部基准源,记得改这个值。

重要: 参考电压必须用万用表实测。我在一个项目里直接用了 3.3V 的理论值,结果发现板子上的 VDDA 实际只有 3.28V。虽然只差 0.02V,但换算出来的电压值始终偏小。后来我改成用实测值,问题就解决了。

4.5 完整示例代码

把上面的内容拼起来,就是一个完整的单次采集函数:

float ADC_GetVoltage(void)
{
  uint32_t adc_value = 0;
  float voltage = 0.0f;

  HAL_ADC_Start(&hadc1);
  if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK)
  {
    adc_value = HAL_ADC_GetValue(&hadc1);
    voltage = (float)adc_value / 4096.0f * 3.3f;
  }
  HAL_ADC_Stop(&hadc1);

  return voltage;
}

在主函数里调用:

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_ADC1_Init();

  while (1)
  {
    float v = ADC_GetVoltage();
    HAL_Delay(1000);
  }
}

4.6 知识结构图

下面这张图总结了单次采集的完整流程:

ADC 单次阻塞采集流程 1. CubeMX 配置 ADC 参数 2. MX_ADC1_Init() 初始化 3. HAL_ADC_Start() 启动 4. PollForConversion 等待 5. GetValue + 电压换算 关键参数 • 分辨率:12 位 • 范围:0 ~ 4095 • 参考电压:3.3V

4.7 常见问题与排查

跑完代码,如果发现读数不对,别慌。我列几个常见问题:

现象 可能原因 解决方法
读数始终为 0 ADC 未启动,或通道配置错误 检查 HAL_ADC_Start() 是否调用,确认通道号
读数始终为 4095 输入引脚悬空,或参考电压异常 用万用表测引脚电压,检查 VDDA
读数跳变很大 采样时间太短,或电源噪声大 增大采样时间,加滤波电容
换算电压偏小 参考电压实际值低于理论值 用万用表实测 VDDA,修改代码
最后提醒: 调试 ADC 时,手里最好有个可调电源或者电位器。给 PA0 输入一个已知电压,比如 1.65V,然后看读回来的值对不对。这是最直接的验证方法。