STM32串口DMA传输与ADC采集工程实战 - 第4节
4.1 为什么先讲单次采集?
很多初学者一上来就盯着 DMA、连续转换、多通道这些东西看。我个人习惯是——先把单次阻塞采集跑通。为什么?
你想想看,ADC 最基础的任务就是把模拟电压变成数字量。如果连单次采集都搞不定,后面那些高级功能全是空中楼阁。我在项目里见过不少工程师,DMA 配置得花里胡哨,结果发现基准电压没接对,读出来的数据全是乱的。
所以这一章,咱们就老老实实把单次采集的每一步走一遍。从 CubeMX 配置到代码实现,再到电压换算,全部手把手过。
本章核心目标: 用阻塞方式读取一次 ADC 值,并正确换算为实际电压。
4.2 HAL 库 ADC 初始化流程
STM32 的 ADC 外设,说白了就是一个逐次逼近型转换器。HAL 库帮我们封装好了底层寄存器操作,但初始化流程还是有固定套路的。
4.2.1 CubeMX 配置要点
打开 CubeMX,选好芯片型号。我以 STM32F103C8T6 为例,这个芯片在市面上很常见。
- 选择 ADC 通道:在 Pinout 视图里找到 ADC1,把 IN0 设置为模拟输入模式。IN0 对应 PA0 引脚。
- 配置时钟:ADC 的时钟源一般选 PCLK2 的 2 分频或 4 分频。F103 的 ADC 最高支持 14MHz,别超了。
- 参数设置:在 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);
这段代码的逻辑:
- 调用
HAL_ADC_Start()启动转换 - 用
HAL_ADC_PollForConversion()等待,超时设为 100 毫秒 - 转换完成后,用
HAL_ADC_GetValue()读取结果 - 最后调用
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 知识结构图
下面这张图总结了单次采集的完整流程:
4.7 常见问题与排查
跑完代码,如果发现读数不对,别慌。我列几个常见问题:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 读数始终为 0 | ADC 未启动,或通道配置错误 | 检查 HAL_ADC_Start() 是否调用,确认通道号 |
| 读数始终为 4095 | 输入引脚悬空,或参考电压异常 | 用万用表测引脚电压,检查 VDDA |
| 读数跳变很大 | 采样时间太短,或电源噪声大 | 增大采样时间,加滤波电容 |
| 换算电压偏小 | 参考电压实际值低于理论值 | 用万用表实测 VDDA,修改代码 |
最后提醒: 调试 ADC 时,手里最好有个可调电源或者电位器。给 PA0 输入一个已知电压,比如 1.65V,然后看读回来的值对不对。这是最直接的验证方法。