简介:STM32内部温度传感器结合ADC与DMA的实战工程包,面向嵌入式初学者和STM32开发者,演示从ADC通道配置、DMA数据传输到USART1串口输出的完整流程,适合需要掌握低CPU占用数据采集方法的场景。压缩包共196个文件,约4.57MB,包含C源码、头文件、STM32标准库工程文件、编译中间文件及hex文件,其中.c/.h文件对应ADC、DMA、USART等驱动实现,.uvprojx/.uvoptx为Keil工程配置,hex可直接烧录验证。已有4937人学习下载,配套工程在F103C8T6上实现内部温度传感器输出电压读取,并经DMA自动搬运数据,再由串口打印温度值,同时提供中断回调处理与转换完成事件管理,可帮助快速理解库函数下多外设协同设计思路。资源目录结构完整,适合对照源码逐行学习或直接移植到相近项目。
1. 用DMA搬内部温度数据,是温控类STM32项目最省心的起点
STM32 内部温度传感器经常被当成“ADC 入门玩具”,实际上它是验证 DMA 链路最合适的小信号源:不需要外接导线,不会因为接地环路引入额外噪声,而且采样值会和芯片负载、电源电压一起变化。用轮询方式读它当然可以,但系统里一旦还有显示、通信、按键,轮询要么让温度刷新变慢,要么打断其他时序。DMA 方式实现后,ADC 完成一次转换就自动把数据写进内存,CPU 只在一个完整转换周期后去消费最新温度,这时才谈得上“采样不占 CPU”。
这篇以 STM32F1 / F4 系列的 HAL 库为主线,把内部温度传感器接到 ADC1 专用通道,用 DMA 的连续请求让数据自动流动,然后做滤波、温度换算和串口输出。适合刚把HAL_ADC_Start_DMA跑通,想在温控、功耗监测里用内部温度做芯片结温预估的开发者。你会看到采样周期、DMA 连续请求、校准值读取和滤波之间如何互相影响。
2. 内部温度传感器采样原理:专用通道、参考电压和采样周期
2.1 内部温度传感器对应哪个 ADC 通道
内部温度传感器不是普通引脚输入,它和带隙参考电压一起挂在模拟开关后面。F1 系列对应ADC1_IN16,F2 / F4 系列对应ADC1_IN18,G0 系列可能是ADC1_IN9,具体要查参考手册里的“Temperature sensor”章节。这个通道只能由 ADC1 访问,ADC2 和 ADC3 读不到。
使用前必须先使能内部传感器开关:
__HAL_RCC_ADC1_CLK_ENABLE(); // 打开 ADC1 时钟 HAL_ADCEx_EnableVREFINT(); // 使能内部参考电压 HAL_ADCEx_EnableInternalSensor(); // 使能内部温度传感器F1 系列 HAL 库里不一定有HAL_ADCEx_EnableInternalSensor,需要直接操作寄存器:
ADC1->CR2 |= ADC_CR2_TSVREFE; // 同时使能内部参考电压和温度传感器这段代码要注意顺序:先打开 ADC1 时钟,再使能内部参考电压,最后使能温度传感器。反过来操作做时,部分系列第一次采样会读到 0 或旧通道残留值。内部温度传感器返回的仍是相对于VREF+的 ADC 码,不是绝对电压,所以VREF+不稳定时读数也会漂。
2.2 采样周期选多少才不是白采
内部温度传感器的输出阻抗比外部传感器高,参考手册通常要求采样时间至少 10µs。如果 ADC 时钟是 14MHz,10µs 对应约 140 个 ADC 时钟周期,那么 28.5 周期的采样时间根本不够,至少要选 71.5 或 239.5 周期。ADC 总转换时间大约是采样周期加上 12 位逐次逼近的 12 个周期,下表按 14MHz ADC 时钟计算:
| ADC 时钟 | 采样周期配置 | 采样时间 | 总转换时间(12 位) |
|---|---|---|---|
| 14MHz | 1.5 周期 | 0.107µs | 0.964µs |
| 14MHz | 13.5 周期 | 0.964µs | 1.821µs |
| 14MHz | 28.5 周期 | 2.036µs | 2.893µs |
| 14MHz | 71.5 周期 | 5.107µs | 5.964µs |
| 14MHz | 239.5 周期 | 17.107µs | 17.964µs |
温度信号本身变化很慢,不需要追求 1Msps 的采样率。打开 DMA 连续请求后每秒采 50 次已经足够,所以推荐先选 239.5 周期。这个值同时会降低开关电容充放电不充分造成的 adc 数据漂移。
2.3 温度换算公式和出厂校准值
F2 / F4 / F7 系列在出厂时把 30°C 和 110°C 对应的 ADC 原始值烧录在系统存储区,直接读取:
uint16_t cal30 = *(uint16_t*)0x1FFF7A2C; // 30°C 时的 12 位 ADC 原始值 uint16_t cal110 = *(uint16_t*)0x1FFF7A2E; // 110°C 时的 12 位 ADC 原始值换算公式是线性插值:
temp = (110 - 30) * (adc_raw - cal30) / (cal110 - cal30) + 30;F1 系列没有这两个校准寄存器,只能使用数据手册典型值:V25 = 1.43V,Avg_Slope = 4.3mV/°C。换算时要把 ADC 码转回实际电压,并且必须传入真实的VREF+电压:
float vsense = adc_raw * vref / 4095.0f; temp = (1.43f - vsense) / 0.0043f + 25.0f;| 系列 | 内部通道 | 校准方式 | 典型精度 |
|---|---|---|---|
| F1 | ADC1_IN16 | 无存储校准值,用 V25 和斜率 | 约 ±3°C,VREF 不稳会更大 |
| F4 / F2 | ADC1_IN18 | 0x1FFF7A2C / 0x1FFF7A2E | 约 ±2°C |
| G0 等 | 查参考手册 | 各型号不同 | 需按手册确认 |
F4 系公式里先乘 80 再除,中间值最大约 327600,用int32_t就能装下,不需要浮点。读取校准寄存器时建议加volatile,防止编译器把两次指针解引用合并成一次优化。
2.4 DMA 连续请求控制的是“每个转换完成都搬”
CubeMX 里有一项DMA Continuous Requests,它和 ADC 的Continuous Conversion Mode是两个不同层面的开关。ADC 连续转换决定的是“转换结束后是否立刻开始下一次转换”,DMA 连续请求决定的是“每个转换结束是否都触发 DMA 搬运”。
关闭 DMA 连续请求时,即使 ADC 在连续转换,DMA 也可能只响应一次请求,然后停在旧值上。打开后,DMA 跟着每次 EOC 事件走,才能实现真正的自动刷新。这里容易踩的坑是把 DMA 模式设成Normal,数据搬完一轮就停;温度只有第一次正确,后面全是旧数组。用内部温度传感器时,建议设置 ADC 连续转换、DMA 连续请求Enable、DMA 模式Circular,这样启动一次就一直采样。
3. 用 CubeMX 配置 STM32 ADC 内部温度传感器和 DMA 的最小工程
3.1 CubeMX 里需要动的一串开关
在 CubeMX 的 Pinout 视图选择ADC1,在右侧勾选IN16或IN18。注意是“ADC1”下的内部通道,不是ADC1_INP16这种外部输入。如果 Pinout 图上原本被占用的引脚自动变成了ADC1_IN16,把它点回Reset_State,因为内部温度传感器不占物理引脚。
接着按下面顺序操作:
- 将 ADC1 的
Continuous Conversion Mode设为Enable。 - 将
DMA Continuous Requests设为Enable。 - 在 DMA Settings 里添加
ADC1,方向选Peripheral To Memory,模式选Circular。 Memory Increment选择Enable,目的是让 DMA 把连续转换结果按顺序写进数组。- 回到 Parameter Settings,把
Sampling Time选到 239.5 周期(F4 或 F1 中对应宏可能不同,单位都是 ADC 时钟周期)。
关键参数推荐值如下表:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| Clock Prescaler | PCLK2 / 4 或 / 6 | 让 ADC 时钟落在器件允许范围内,F1 建议 ≤ 14MHz |
| Resolution | 12 位 | 内部温度传感器不需要更高分辨率 |
| Scan Conversion Mode | F1 需Enable | F1 的 HAL 驱动在扫描模式下才能选内部通道 rank |
| Continuous Conversion Mode | Enable | 保证温度数据持续更新 |
| DMA Continuous Requests | Enable | 每次转换完成后都触发 DMA 请求 |
| DMA Mode | Circular | DMA 循环搬运,不需要反复手动启动 |
3.2 生成的初始化代码为什么长这样
CubeMX 会生成MX_ADC1_Init,核心结构如下:
static void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; // F1 单通道也必须开启扫描 hadc1.Init.ContinuousConvMode = ENABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; hadc1.Init.DMAContinuousRequests = ENABLE; hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; HAL_ADC_Init(&hadc1); }参数说明:ScanConvMode在 F1 系列上必须保持ADC_SCAN_ENABLE,否则内部温度传感器通道不会进入转换序列,读出来始终是 0。NbrOfConversion = 1表示只有一个 rank,变量名看起来像“通道数”,实际含义是“转换序列里的 rank 数量”。EOCSelection设为ADC_EOC_SINGLE_CONV,这样 DMA 请求在每次单次转换结束就触发,而不是等整个序列结束。
随后会有通道配置:
sConfig.Channel = ADC_CHANNEL_TEMPSENSOR; // 内部温度传感器通道 sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // 采样周期 239.5 HAL_ADC_ConfigChannel(&hadc1, &sConfig);这里的ADC_CHANNEL_TEMPSENSOR是库替你做好的宏,F1 展开后是ADC_CHANNEL_16,F4 展开后是ADC_CHANNEL_18。如果自己用寄存器写,要注意这个宏不是所有旧固件库都有。
3.3 启动 DMA 转换的标准调用顺序
在main函数里,常见做法是:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_UART4_Init(); // 如果有串口输出 static uint16_t adc_buf[16] = {0}; // 必须全局或 static HAL_ADCEx_EnableVREFINT(); HAL_ADCEx_EnableInternalSensor(); HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 16); while (1) { // 主循环只消费温度结果 } }HAL_ADC_Start_DMA的参数解释:第一个是 ADC 句柄,第二个是 DMA 写入的目标地址,第三个是Data Length,单位是“半字”而不是字节。因为 12 位 ADC 结果放在 16 位半字里,所以长度16表示目标数组有 16 个元素,不是 32 字节。如果填成sizeof(adc_buf),DMA 会多写一倍数据,直接越界。
注意不要把adc_buf定义成main里的普通局部变量。DMA 在后台持续写这个地址,函数退出后栈空间会被复用,轻则数据被覆盖,重则硬件错误。static或者全局数组都行。
3.4 国产兼容芯片上常见的 DMA 数据紊乱
如果你用的是 GD32E230 或 APM32 这类兼容 STM32 引脚和库函数的芯片,可能会遇到“启动后温度值乱跳、通道错位”的现象。这不是 ADC 或 DMA 协议本身的问题,而是这些芯片的 DMA 请求映射在连续转换模式下和 ST 原厂不完全一致。
用 GD32E230 的常见规避方法是:把DMAContinuousRequests关掉,改用 ADC 转换结束中断,在中断回调里手动复制当前ADCx->DR,或者把 DMA 从Circular改成Normal后每次回调重新HAL_ADC_Start_DMA。这类芯片上优先保证功能正确,再考虑要不要省 CPU 中断。排查时先确认 DMA 中断标志位是否清除,以及NVIC里 ADC 中断优先级是否比 DMA 高。
4. 从 ADC 原始值到温度:DMA 回调、滤波和串口 DMA 输出
4.1 用 DMA 传输完成回调锁存数据
启动 DMA 后,温度数据在后台持续更新。主循环不能直接读adc_buf[0],否则可能读到一半的暂态值。更稳妥的做法是在 DMA 回调里做一次整体拷贝:
#define ADC_BUF_SIZE 8 __IO uint16_t adc_buf[ADC_BUF_SIZE]; uint16_t temp_raw_copy[ADC_BUF_SIZE]; volatile uint8_t adc_ready = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance != ADC1) return; for (int i = 0; i < ADC_BUF_SIZE; i++) { temp_raw_copy[i] = adc_buf[i]; } adc_ready = 1; }__IO在 STM32 HAL 里就是volatile,提醒编译器每次都从内存读取,不要优化到寄存器缓存里。回调函数里做 8 次半字拷贝非常快,ADC 下一次转换完成前足够执行完。adc_ready置 1 后,主循环可以每隔一段时间消费一次,不需要每次都执行滤波。
这里要注意:如果 DMA 配置成Circular,回调函数会在每 8 次转换完成后触发一次,而不是每次转换都触发。若想要每次转换都有回调,把 DMA 目标数组长度设为 1,或者使用HAL_ADC_ConvHalfCpltCallback。温度信号是慢变量,8 点一组完全够用。
4.2 温度换算要区分 F1 和 F4
转换函数写成分装形式,便于以后移植其他系列:
/* F1 内部温度换算:使用 V25 和 Avg_Slope */ float convert_temp_f1(uint16_t raw, float vref) { float vsense = raw * vref / 4095.0f; return (1.43f - vsense) / 0.0043f + 25.0f; } /* F4 内部温度换算:使用出厂校准值 */ int16_t convert_temp_f4(uint16_t raw) { int16_t cal30 = *(volatile int16_t *)0x1FFF7A2C; int16_t cal110 = *(volatile int16_t *)0x1FFF7A2E; return (110 - 30) * (raw - cal30) / (cal110 - cal30) + 30; }参数说明:F1 版本里的vref是实际的VREF+电压。如果直接用 3.3V 计算,而电源实际是 3.28V,温度值会产生大约 1°C 以上的偏差。F4 版本不关心VREF+具体是多少,因为出厂校准值也是 12 位 ADC 码,比值关系消掉了参考电压误差。
F4 版本返回值是“实际温度 × 10”:
int16_t temp = convert_temp_f4(adc_value); // temp = 312 表示 31.2°C printf 时用 temp / 10 和 temp % 10 拆整数和小数。4.3 C 语言 ADC 值滤波函数:滑动平均和中值
内部温度传感器受电源噪声和 PCB 布局影响,单次读数跳几个 LSB 很正常。常用的 C 语言 ADC 值滤波函数有两种。
滑动平均:
#define FILTER_LEN 8 static uint16_t history_buf[FILTER_LEN]; static uint8_t history_idx = 0; static uint32_t history_sum = 0; uint16_t adc_sliding_filter(uint16_t new_sample) { history_sum -= history_buf[history_idx]; history_buf[history_idx] = new_sample; history_sum += new_sample; history_idx = (history_idx + 1) % FILTER_LEN; return (uint16_t)(history_sum / FILTER_LEN); }滑动平均的思路是维护一个固定窗口的和,每来一个新采样,先减去最旧值,再加上新值,最后取平均。注意素组初始化时history_sum必须是 0,history_buf最好清零,否则前 8 次输出会被垃圾值污染。history_idx指向最旧的数据位置,用取模让它循环覆盖。
中值滤波:
#define MEDIAN_N 5 uint16_t adc_median_filter(uint16_t sample) { static uint16_t ring[MEDIAN_N]; static uint8_t idx = 0; ring[idx] = sample; idx = (idx + 1) % MEDIAN_N; uint16_t sort_buf[MEDIAN_N]; for (int i = 0; i < MEDIAN_N; i++) { sort_buf[i] = ring[i]; } for (int i = 0; i < MEDIAN_N - 1; i++) { for (int j = i + 1; j < MEDIAN_N; j++) { if (sort_buf[i] > sort_buf[j]) { uint16_t tmp = sort_buf[i]; sort_buf[i] = sort_buf[j]; sort_buf[j] = tmp; } } } return sort_buf[MEDIAN_N / 2]; }中值滤波对尖峰毛刺抑制更强,但每次要复制和排序,CPU 开销高一些。温度变化慢,用 5 点中值足够。如果目标是降低白噪声,滑动平均效果更好。
| 滤波方式 | 延迟 | 毛刺抑制 | 适用场景 |
|---|---|---|---|
| 滑动平均 | 窗口一半长度 | 一般 | 采样率较高、噪声均匀 |
| 中值滤波 | 取决于窗口排序 | 强 | 电机启停、电磁干扰尖峰 |
| 先中值再平均 | 更高 | 强 | 温度值既要平滑又要抗尖峰 |
建议在 DMA 回调里只拷贝,在主循环或定时器里做滤波和换算。不要在 DMA 中断里做排序,否则会拉长中断时间,影响其他实时任务。
4.4 串口 DMA 输出温度值
温度算出来后,通常要发到串口监控。既然用了 DMA,发送端也应该用 DMA,避免主循环被阻塞:
char uart_tx_buf[64]; volatile uint8_t uart_tx_busy = 0; void send_temperature(int16_t temp) { int int_part = temp / 10; int frac_part = temp % 10; int len = snprintf(uart_tx_buf, sizeof(uart_tx_buf), "t=%d.%d C\r\n", int_part, frac_part); if (!uart_tx_busy) { uart_tx_busy = 1; HAL_UART_Transmit_DMA(&huart4, (uint8_t*)uart_tx_buf, len); } } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == UART4) { uart_tx_busy = 0; } }HAL_UART_Transmit_DMA是异步发送,第三个参数是字节数。uart_tx_busy防止上一次 DMA 还没发完,下一次snprintf就改写缓冲区,这是串口 DMA 最常出现的乱码原因。如果发送频率超过 DMA 传输速度,剩余数据会被丢弃;温度场景下每秒一次,不会有问题。
如果想彻底避免丢数据,可以把uart_tx_buf做成环形缓冲区,发送完成回调里从队列取下一帧再发。这里不展开,先保证单帧不覆盖。
5. 校验内部温度结果的 3 个实用技巧
5.1 用环境温度做单点偏移修正
出厂校准值已经比直接用典型值准确很多,但每块板子的电源、布局、外壳散热不同,仍可能差 1~2°C。F4 系列可以在常温下用温度计读取环境温度,和内部温度传感器输出比较,得到cal_offset:
int16_t calibration_offset = measured_temp - reported_temp; int16_t corrected_temp = reported_temp + calibration_offset;把calibration_offset存在内部 Flash 或 EEPROM 模拟区,重上电后读取。注意环境温度计要贴近 MCU,不要放在电解电容旁边,否则修的是电容温度。
5.2 用调试器区分 DMA 错位和真实噪声
数值跳变不一定来自噪声,很可能是 DMA 配置问题。在调试器里暂停运行,直接查看adc_buf数组里各元素。如果这些数值在 5 个 LSB 内波动,说明是噪声;如果出现整段数值接近满量程 4095,或者从第 N 个元素开始全是 0,先检查 DMA 循环模式和DMAContinuousRequests。
F1 系列上还要确认ScanConvMode是否打开。单通道扫描模式下,adc_buf[0]和adc_buf[1]可能是两个不同 rank 的结果;当NbrOfConversion = 1时,多余的数组元素是上一次循环残留值,不该被解析成温度。
5.3 采样周期不是越大越好,但别小于数据手册要求
内部温度传感器采样时间不足,表现为读数明显偏低且随风速变化剧烈。你可以在 CubeMX 里把采样周期从 239.5 改成 13.5,观察adc_buf平均值是否掉。如果掉了 20 个 LSB 以上,说明采样时间不够,开关电容还没稳定就开始比较。反过来,如果两个采样周期读数差异很小,选更短的那个也不会带来明显精度损失,但温度响应会快一点。
一个客观验证:把调试器连接好,先记录 20 秒平均温度;再用热风枪吹 MCU 外壳,观察温度曲线是否在几秒后平滑上升。如果温度瞬间跳变几十度,多半是通道配置错了,采到了相邻引脚电平而不是内部传感器。
本文还有配套的精品资源,点击获取