1. ADC单通道电压采集原理与工程实现

在嵌入式系统中,模拟信号到数字信号的转换是感知物理世界的基础环节。STM32系列微控制器集成的ADC(Analog-to-Digital Converter)模块,为开发者提供了高精度、低功耗、配置灵活的模数转换能力。本节聚焦于 单通道连续电压采集 这一最常用场景,从硬件原理、寄存器映射、时钟配置到HAL库驱动层封装,完整还原一个可复用、可调试、可移植的工程实践路径。

1.1 STM32 ADC核心架构解析

STM32F103系列(以江协科技开发板所用型号为代表)集成的是 12位逐次逼近型(SAR)ADC ,其本质是一个高速、高精度的“电子天平”。它不依赖外部精密电阻网络或电容阵列,而是通过内部DAC(数字-模拟转换器)配合单个高速比较器,采用二分法(Bisection Method)在极短时间内完成一次转换。

其工作流程可抽象为以下四步闭环:

  1. 采样保持(Sample & Hold) :ADC输入引脚通过内部采样开关连接至采样电容(Capacitor),电容在指定采样周期内充电至当前模拟电压值;
  2. 参考建立(Reference Setup) :内部参考电压(VREF+,通常为3.3V)被加载至DAC的基准端;
  3. 逐次比较(Successive Approximation) :DAC从最高位(MSB,第11位)开始,依次尝试输出一个中间电压(如VREF+/2 = 1.65V),比较器判断采样电容电压是否大于该DAC输出。若大于,则该位为1;否则为0。此过程重复12次,覆盖所有位;
  4. 结果锁存(Result Latching) :12次比较完成后,最终的12位数字量被锁存至规则数据寄存器(ADC_DR)或注入数据寄存器(ADC_JDRx)中,等待CPU读取。

这种架构决定了其关键参数:
- 分辨率(Resolution) :12位 → 理论上可区分 $2^{12} = 4096$ 个离散电平,对应0~4095的数字量;
- 满量程电压(Full-Scale Range, FSR) :由VREF+决定,开发板默认为3.3V;
- 量化误差(Quantization Error) :理论最小分辨电压(LSB)为 $3.3V / 4095 \approx 0.806mV$;
- 转换时间(Conversion Time) :取决于ADC时钟(ADCCLK)频率与采样周期(Sampling Time)之和,典型值在几微秒量级。

理解这一底层机制至关重要——它解释了为何ADC读数永远是一个整数,为何需要校准,以及为何采样时间设置不当会导致读数偏低(电容未充满)或噪声增大(采样时间过长易引入干扰)。

1.2 开发板硬件接口与信号源选择

江协科技STM32F103C8T6开发板提供了10个可用的ADC外部通道引脚,均映射至GPIOA端口。其中, PA0(ADC1_IN0) 是最常用于入门实验的通道,因其位置醒目且无复用冲突。本例中使用的“可调电位器”即是一个典型的三端可变电阻器,其结构如下:

      +3.3V (VCC)
         |
         |
     ┌───┐
     │   │ 电位器主体(总阻值通常为10kΩ)
     └───┘
         |
         ├─→ 中间滑动端(Wiper)→ 连接至 PA0
         |
      GND (0V)

当旋钮转动时,滑动端在VCC与GND之间线性移动,从而在PA0引脚上产生一个0V至3.3V之间连续可变的模拟电压。这是一种理想的教学信号源,因为它直观、稳定、无高频噪声,能清晰验证ADC的线性度与分辨率。

需特别注意的硬件约束:
- 输入电压范围 :ADC输入引脚的绝对最大额定值为VDDA + 0.3V(通常为3.6V)。若待测信号可能超过3.3V,必须添加分压电路或钳位二极管进行保护;
- 输入阻抗匹配 :ADC内部采样开关与采样电容构成一个RC网络。若信号源内阻过高(>10kΩ),则采样电容无法在指定采样周期内充分充电,导致读数偏低。电位器本身内阻较低,故无需额外处理;
- 电源去耦 :开发板已为ADC模块(VDDA/VSSA)配备了独立的滤波电容,这是保证转换精度的前提。切勿省略或使用劣质电容。

1.3 ADC时钟树配置与性能权衡

ADC并非独立运行,其核心时钟ADCCLK来源于APB2总线时钟(PCLK2)。在STM32F103中,PCLK2默认由系统时钟(SYSCLK)经2分频得到。假设系统时钟为72MHz,则PCLK2为36MHz。根据STM32F103参考手册规定,ADCCLK的最大允许频率为14MHz。因此,必须通过 ADC预分频器(ADC Prescaler) 对PCLK2进行再次分频。

在STM32CubeMX或手动配置中,这一分频系数通常设为 6 (即PCLK2/6),此时ADCCLK = 36MHz / 6 = 6MHz,完全满足时序要求,并为后续的采样与转换留出了充足的裕量。

这一配置直接决定了ADC的 最大采样速率 。一个完整的ADC转换周期包含:
- 采样时间(Sampling Time) :由用户软件配置,可选1.5、7.5、13.5、28.5、41.5、55.5、71.5或239.5个ADCCLK周期;
- 转换时间(Conversion Time) :固定为12.5个ADCCLK周期(12位SAR)。

因此,若选择最短的采样时间1.5个周期,则单次转换总时间为14个ADCCLK周期。在6MHz ADCCLK下,单次转换耗时约为2.33μs,理论最大采样速率为429ksps。但在实际应用中,尤其是使用OLED显示等慢速外设时,我们并不追求极限速度,而是优先保证精度与稳定性,故常选用7.5或13.5个周期的采样时间。

1.4 HAL库ADC初始化流程详解

HAL库将复杂的寄存器操作封装为清晰的结构体与函数,极大提升了开发效率。其初始化流程严格遵循“配置先行、使能后置”的原则。

1.4.1 ADC句柄与基础参数配置

首先定义一个全局的ADC句柄结构体,用于存储ADC实例的状态与配置信息:

ADC_HandleTypeDef hadc1;

接着,在 MX_ADC1_Init() 函数中,完成核心参数的填充:

// 1. 基础配置:指定ADC实例、分辨率、数据对齐方式、扫描模式
hadc1.Instance = ADC1; // 指向硬件寄存器基地址
hadc1.Init.Resolution = ADC_RESOLUTION_12B; // 12位分辨率
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 数据右对齐,低位补零,便于直接计算
hadc1.Init.ScanConvMode = DISABLE; // 单通道模式,禁用扫描,仅转换一个通道
hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; // 转换结束标志为单次转换完成
hadc1.Init.ContinuousConvMode = DISABLE; // 非连续模式,每次需手动触发
hadc1.Init.NbrOfConversion = 1; // 转换序列长度为1(单通道)
hadc1.Init.DiscontinuousConvMode = DISABLE; // 禁用间断模式
hadc1.Init.NbrOfDiscConversion = 0; // 间断模式转换数为0
hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 外部触发源为软件启动
hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE; // 无边沿触发
hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV6; // 同步时钟,PCLK2分频6
hadc1.Init.DMAContinuousRequests = DISABLE; // 禁用DMA,简化逻辑
hadc1.Init.SamplingTimeCommon1 = ADC_SAMPLETIME_13CYCLES_5; // 公共采样时间13.5周期
hadc1.Init.SamplingTimeCommon2 = ADC_SAMPLETIME_13CYCLES_5; // 公共采样时间13.5周期
hadc1.Init.OversamplingMode = DISABLE; // 禁用过采样

其中, SamplingTimeCommon1 SamplingTimeCommon2 是HAL库v1.12.0之后引入的新概念,用于统一管理同一ADC实例下所有通道的采样时间,取代了旧版中为每个通道单独配置的方式,大幅简化了多通道配置。

1.4.2 通道配置与校准

在基础配置之后,必须为具体的ADC通道(此处为PA0,即ADC1_IN0)进行注册:

// 2. 通道配置:指定通道号、采样时间(若与公共时间不同则在此覆盖)
ADC_ChannelConfTypeDef sConfig = {0};
sConfig.Channel = ADC_CHANNEL_0; // PA0对应的通道号
sConfig.Rank = ADC_RANK_CHANNEL_NUMBER; // 该通道在序列中的位置(单通道下为1)
sConfig.SamplingTime = ADC_SAMPLETIME_COMMON_1; // 使用公共采样时间1
sConfig.SingleDiff = ADC_SINGLE_ENDED; // 单端输入,非差分
sConfig.OffsetNumber = ADC_OFFSET_NONE; // 不使用偏移校准
sConfig.Offset = 0; // 偏移值为0
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) {
    Error_Handler(); // 配置失败处理
}

最关键的一步是 ADC校准(Calibration) 。由于制造工艺差异,每个芯片的ADC内部DAC存在微小偏差,这会导致系统性的增益与偏移误差。HAL库提供了标准的校准函数:

// 3. 执行ADC校准
if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_CALIB_OFFSET, ADC_SINGLE_ENDED) != HAL_OK) {
    Error_Handler();
}

此函数会自动执行一系列内部操作,包括断开外部输入、连接内部校准源、运行校准算法,并将结果写入ADC的校准寄存器。 校准必须在ADC使能之前、且在系统上电后的首次初始化时执行一次 。跳过此步骤,ADC读数将存在不可忽略的系统误差(可能高达几十个LSB)。

1.4.3 ADC使能与状态检查

完成所有配置与校准后,方可使能ADC外设:

// 4. 使能ADC
if (HAL_ADC_Start(&hadc1) != HAL_OK) {
    Error_Handler();
}

// 5. 可选:检查ADC是否就绪
if (HAL_IS_BIT_SET(HAL_ADC_GetState(&hadc1), HAL_ADC_STATE_READY) == RESET) {
    // ADC尚未准备好,可加入超时等待或错误处理
}

HAL_ADC_Start() 函数本质上是置位ADC_CR2寄存器的ADON位,启动ADC模拟电路。此时,ADC已处于待命状态,随时可以接受软件触发。

1.5 单次转换与数据处理的主循环实现

在主循环( main() 函数的 while(1) 中),我们采用最简单、最可控的 轮询(Polling)模式 来执行单次转换。这种方式虽不适用于高实时性场景,但对于教学演示与低速数据采集而言,逻辑最为清晰,易于调试。

1.5.1 轮询转换与结果读取
uint32_t adc_value = 0;
float voltage = 0.0f;

// 触发一次软件转换
if (HAL_ADC_Start(&hadc1) == HAL_OK) {
    // 等待转换完成,超时时间为100ms(足够长)
    if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK) {
        // 读取12位转换结果
        adc_value = HAL_ADC_GetValue(&hadc1);
        // 将数字量转换为实际电压值(单位:V)
        voltage = (float)adc_value * (3.3f / 4095.0f);
    }
}

HAL_ADC_PollForConversion() 函数内部会持续查询ADC_SR寄存器的EOC(End of Conversion)标志位,直至其被硬件置位,表示转换已完成。此函数的第二个参数为超时时间(单位为毫秒),设置一个合理的超时值(如100ms)是防止程序因硬件故障而无限阻塞的关键安全措施。

1.5.2 电压值的格式化与OLED显示

原始的浮点电压值(如 3.123456f )无法直接送入OLED显示缓冲区。我们需要将其拆解为整数部分与小数部分,并分别转换为ASCII字符。这是一个典型的嵌入式字符串处理问题。

// 假设OLED显示函数为 OLED_ShowNum(x, y, num, len, size)
// 其中num为要显示的整数,len为显示位数,size为字体大小

// 1. 提取整数部分(V)
uint8_t volt_integer = (uint8_t)voltage;

// 2. 提取小数部分(0.XX V),并四舍五入到百分位
uint8_t volt_decimal = (uint8_t)((voltage - volt_integer) * 100.0f + 0.5f);
// 防止小数部分溢出(如3.999V -> 3.100V,应修正为4.00V)
if (volt_decimal >= 100) {
    volt_decimal = 0;
    volt_integer++;
}

// 3. 在OLED上显示
OLED_ShowNum(0, 0, volt_integer, 1, 16); // 显示整数位,如"3"
OLED_ShowString(16, 0, "."); // 显示小数点
OLED_ShowNum(24, 0, volt_decimal, 2, 16); // 显示两位小数,如"12"
OLED_ShowString(56, 0, "V"); // 显示单位

此段代码的核心在于 + 0.5f 的四舍五入技巧。例如,当 voltage = 3.126f 时, (3.126 - 3) * 100 = 12.6 ,加上0.5后为13.1,强制类型转换为 uint8_t 时截断小数,得到 13 。这比简单的 * 100 再取整更为精确。

此外,必须加入对 volt_decimal >= 100 的边界检查。这是因为在浮点运算中,由于精度损失, 3.999f 经过计算后可能变为 4.000f ,但 4.000f - 3 *100 可能得到 100.0001f ,取整后为 100 ,这显然超出了两位小数的表示范围。此时,应将小数部分归零,并将整数部分加1。

1.6 常见问题排查与实战经验

在将上述代码部署到实际开发板时,工程师常会遇到一些看似诡异、实则有迹可循的问题。以下是我在多个项目中踩过的坑与对应的解决方案。

1.6.1 读数恒为0或4095

这是最典型的硬件连接错误。首先,用万用表测量PA0引脚对地电压,确认其确实在0~3.3V之间变化。若电压正常,问题必在软件配置。

  • 检查GPIO初始化 :PA0必须被配置为 ANALOG 模式,而非 INPUT AF 。在STM32CubeMX中,需在Pinout视图中将PA0的GPIO mode设置为 Analog ;在手动配置中,需执行 GPIOA->CRL &= ~(0xF << (0*4)); ,清除其模式位。
  • 检查ADC时钟 :确认RCC_APB2ENR寄存器的ADC1EN位已被置位( __HAL_RCC_ADC1_CLK_ENABLE(); )。若时钟未开启,ADC所有寄存器读写均无效。
  • 检查ADC使能状态 :在 HAL_ADC_Start() 后,立即调用 HAL_ADC_GetState() ,确认返回值包含 HAL_ADC_STATE_READY 。若为 HAL_ADC_STATE_BUSY HAL_ADC_STATE_ERROR ,说明初始化流程存在致命错误。
1.6.2 读数跳变剧烈、无规律

这表明采样受到了严重噪声干扰。

  • 检查电源质量 :用示波器观察VDDA引脚,确认其纹波小于10mV。若纹波过大,需检查开发板电源滤波电容是否虚焊或失效。
  • 检查信号源内阻 :若使用高内阻传感器(如某些气体传感器),必须将采样时间( SamplingTimeCommon1 )设置为最大值( 239.5 周期),以确保采样电容有足够时间充电。
  • 检查PCB走线 :ADC输入引脚应远离高速数字信号线(如USB、SPI、LCD数据线),并尽可能短。在PCB设计中,应为其铺设独立的地平面。
1.6.3 读数线性度差(如0V读作50,3.3V读作4000)

这指向ADC校准失败或参考电压异常。

  • 强制重新校准 :在 HAL_ADC_Start() 之前,务必调用 HAL_ADCEx_Calibration_Start() 。我曾在一个项目中因疏忽遗漏此行,导致所有ADC通道读数系统性偏低约3%。
  • 验证VREF+电压 :用高精度万用表测量开发板上的VREF+测试点,确认其稳定在3.3V±1%。若电压偏低,需检查LDO稳压芯片或其外围电路。

1.7 从单通道到多通道的演进路径

掌握单通道采集后,向多通道扩展是自然的下一步。HAL库对此提供了优雅的支持。

1.7.1 规则组(Regular Group)多通道配置

规则组是ADC的主转换序列,支持最多16个通道。其配置核心在于 ScanConvMode NbrOfConversion

hadc1.Init.ScanConvMode = ENABLE; // 启用扫描模式
hadc1.Init.NbrOfConversion = 3; // 总共转换3个通道

// 在通道配置循环中,为每个通道指定Rank
sConfig.Rank = 1; sConfig.Channel = ADC_CHANNEL_0; HAL_ADC_ConfigChannel(&hadc1, &sConfig);
sConfig.Rank = 2; sConfig.Channel = ADC_CHANNEL_1; HAL_ADC_ConfigChannel(&hadc1, &sConfig);
sConfig.Rank = 3; sConfig.Channel = ADC_CHANNEL_2; HAL_ADC_ConfigChannel(&hadc1, &sConfig);

转换完成后, HAL_ADC_GetValue() 将返回最后一个通道(Rank=3)的结果。若需获取全部结果,必须启用DMA或使用注入组(Injected Group)。

1.7.2 注入组(Injected Group)与规则组协同

注入组的设计初衷是为高优先级、突发性的测量服务(如过压保护)。它拥有自己独立的4个通道寄存器(JDR1-JDR4)和4个转换完成标志位(JEOC)。其优势在于:
- 可在规则组转换过程中被“注入”执行,且不打断规则组;
- 每个注入通道的结果被独立锁存,互不覆盖。

一个典型的工业应用是:规则组以1kHz频率连续采集温度、湿度、压力;当检测到某路电压超过阈值时,注入组立即启动,采集该路电压的精确值并触发告警。这种混合模式充分利用了ADC的双轨并行能力。

2. 工程实践:构建一个鲁棒的ADC采集模块

一个真正可用于产品的ADC模块,不应仅仅是几个API的堆砌,而应具备状态管理、错误恢复、数据滤波等工业级特性。下面是一个精简但功能完备的模块化实现。

2.1 模块头文件(adc_module.h)

#ifndef ADC_MODULE_H
#define ADC_MODULE_H

#include "stm32f1xx_hal.h"

typedef struct {
    uint32_t raw_value;     // 原始12位ADC值
    float voltage_v;        // 计算出的电压值(V)
    uint32_t timestamp_ms;  // 采样时间戳(ms)
    uint8_t is_valid;       // 数据有效性标志
} adc_sample_t;

// 初始化ADC模块
HAL_StatusTypeDef ADC_Module_Init(void);

// 执行一次单通道采集
HAL_StatusTypeDef ADC_Module_Sample(uint32_t *p_raw_value, float *p_voltage_v);

// 获取最近一次有效采样
HAL_StatusTypeDef ADC_Module_GetLastSample(adc_sample_t *p_sample);

// 清除所有状态
void ADC_Module_Reset(void);

#endif /* ADC_MODULE_H */

2.2 模块实现文件(adc_module.c)

#include "adc_module.h"
#include "main.h" // 包含hadc1句柄声明

static adc_sample_t last_sample = {0};
static uint32_t last_timestamp = 0;

HAL_StatusTypeDef ADC_Module_Init(void) {
    // 1. 校准ADC
    if (HAL_ADCEx_Calibration_Start(&hadc1, ADC_CALIB_OFFSET, ADC_SINGLE_ENDED) != HAL_OK) {
        return HAL_ERROR;
    }
    // 2. 启动ADC
    if (HAL_ADC_Start(&hadc1) != HAL_OK) {
        return HAL_ERROR;
    }
    return HAL_OK;
}

HAL_StatusTypeDef ADC_Module_Sample(uint32_t *p_raw_value, float *p_voltage_v) {
    uint32_t value = 0;
    HAL_StatusTypeDef status = HAL_OK;

    // 1. 触发转换
    if (HAL_ADC_Start(&hadc1) != HAL_OK) {
        status = HAL_ERROR;
        goto exit;
    }

    // 2. 等待转换完成(带超时)
    if (HAL_ADC_PollForConversion(&hadc1, 10) != HAL_OK) { // 10ms超时
        status = HAL_TIMEOUT;
        goto exit;
    }

    // 3. 读取结果
    value = HAL_ADC_GetValue(&hadc1);

    // 4. 数据有效性检查(防异常值)
    if (value > 4095U) {
        status = HAL_ERROR;
        goto exit;
    }

    // 5. 计算电压
    float volt = (float)value * (3.3f / 4095.0f);

    // 6. 更新全局状态
    last_sample.raw_value = value;
    last_sample.voltage_v = volt;
    last_sample.timestamp_ms = HAL_GetTick();
    last_sample.is_valid = 1;

    // 7. 输出参数
    if (p_raw_value) *p_raw_value = value;
    if (p_voltage_v) *p_voltage_v = volt;

exit:
    // 无论成功与否,都停止ADC以降低功耗
    HAL_ADC_Stop(&hadc1);
    return status;
}

HAL_StatusTypeDef ADC_Module_GetLastSample(adc_sample_t *p_sample) {
    if (!p_sample || !last_sample.is_valid) {
        return HAL_ERROR;
    }
    *p_sample = last_sample;
    return HAL_OK;
}

void ADC_Module_Reset(void) {
    last_sample = (adc_sample_t){0};
}

此模块将ADC的所有细节封装起来,对外只暴露简洁的API。 ADC_Module_Sample() 函数集成了超时处理、有效性检查与功耗管理( HAL_ADC_Stop() ),使其成为一个即插即用的组件。在主循环中,只需调用 ADC_Module_Sample(&raw, &volt) 即可获得可靠的数据,大大降低了上层应用的复杂度。

3. 结语:从原理到实践的闭环

ADC看似是一个简单的外设,但其背后交织着模拟电路、数字逻辑、时钟系统与软件架构的复杂知识。本文从一个电位器的旋转开始,层层剥茧,揭示了12位SAR ADC如何将一个连续的物理量转化为离散的数字世界;从寄存器位域的设置,到HAL库结构体的填充,再到主循环中一次 HAL_ADC_PollForConversion() 的调用,完成了从理论到代码的完整映射。

在实际项目中,我曾用此套方法快速定位并解决了一个棘手问题:一款手持设备的电池电量显示忽高忽低。通过本文所述的排查流程,我们发现是PCB上ADC输入走线恰好与LCD背光驱动线平行,产生了严重的串扰。最终,通过在ADC输入端增加一个100nF陶瓷电容进行滤波,并将走线改为垂直交叉,问题迎刃而解。

技术的精进,从来不是靠死记硬背API,而是源于对每一个比特、每一个时钟周期的敬畏与理解。当你下次再看到OLED屏幕上那个跳动的电压数字时,希望你脑海中浮现的,不仅是 HAL_ADC_GetValue() 这个函数,更是那枚在硅片深处、以二分法不断叩问电压真值的微小“天平”。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐