☰
Proteus仿真STM32 ADC采样全为0?从时钟、引脚、EOC三大配置彻底排查
2026/10/5 5:21:41 网站建设 项目流程

说实话,我第一次在Proteus 8.15里跑STM32F103C8的ADC例程时,也被"采样值永远为0"这个现象折磨了半天。虚拟串口打印出来的电压值纹丝不动,始终是0.00V,当时我第一反应也是"是不是芯片模型坏了",甚至想把库里所有STM32型号都换一遍。冷静下来之后才意识到:Proteus里的STM32模型对寄存器配置非常较真,ADC采样为0绝大多数不是芯片问题,而是三个配置环节出了问题——ADC时钟、模拟输入引脚模式、软件触发与EOC等待。

这篇文章我就把自己排查的过程完整捋一遍,顺手附上一套可以直接运行的例程和最终的排错清单。如果你也遇到过类似现象,建议按这个顺序检查,大概率能省下不少折腾时间。

1. 我在Proteus里复现"ADC全为0"的现场

1.1 搭建最小测试环境

先把环境说清楚,方便你对号入座。我使用的是Proteus 8.15 Professional,芯片选择的是库里的STM32F103C8,配套8MHz晶振、两个20pF负载电容、10k上拉复位电路,电源部分把VDDA和VREF+都接到了3.3V,VSSA接GND,BOOT0下拉到地。

信号源这边,我用了一个POT-HG电位器,两端分别接到3.3V和GND,中间抽头接到PA0——也就是ADC1的通道0。另外接了一个Virtual Terminal虚拟串口,方便在仿真里看打印结果,串口映射用的是USART1的PA9和PA10。

我最初测试的代码逻辑很简单:上电后初始化ADC1、配置PA0为模拟输入、执行一次软件触发转换,然后把ADC_DR寄存器里的原始值换算成电压值,通过串口打印出来。结果运行起来之后,虚拟终端上每一行都是0.000V。

这种"全0"结果特别迷惑人,因为它既不是乱码也不是随机跳动,而是稳定地显示0。当时我试过换芯片型号、删除重建原理图、把串口改成LCD显示,现象都一模一样。后来我才明白,问题不在前端显示,而在ADC外设本身根本没有正确工作。

1.2 现象复现与初步判断

在继续排查之前,我先做了一件非常重要的事:在代码里加了一个LED闪烁逻辑,确认程序确实在正常执行。如果连LED都不闪,那问题远不止ADC一处,得先查电源、晶振、复位,以及Proteus是否真的加载了正确固件。

LED正常闪烁之后,我把怀疑范围缩小到了ADC外设配置。此时我用了Proteus的调试功能,在代码里添加了变量来观察ADC1->DR寄存器的值,并逐步确认:

  • RCC的APB2外设时钟是否使能了ADC1和GPIOA;
  • ADC1->CR2的ADON位是否置1;
  • ADC1->SR的EOC标志位是否置1;
  • 最终ADC1->DR的值是否为0。

排查结果显示,问题出在配置顺序和细节上。以下三个配置是最常见的"全0元凶",我逐个拆开讲。

2. 先查时钟:ADC没拿到12MHz,后面全是徒劳

2.1 ADC时钟从哪里来,为什么必须控制在14MHz以内

STM32F103系列里,ADC外设挂在APB2总线上,它的时钟源是PCLK2,再经过ADC预分频器分频后生成ADCCLK。也就是说,ADCCLK = PCLK2 / 分频系数,分频系数可选2、4、6、8。

这里有一个硬性约束:ADC的时钟频率不能超过14MHz。STM32F103的ADC是逐次逼近型结构,内部采样保持和比较逻辑的时序都基于ADCCLK,频率一旦超限,转换结果可能完全不可靠。Proteus的STM32模型对这一点模拟得相当严格,配置超限时转换过程干脆不启动,DR寄存器保持复位值0,表现出来就是"ADC采样全为0"。

2.2 正确分频的代码写法与验证方法

默认情况下,STM32F103如果使用外部8MHz晶振,经过PLL倍频后SYSCLK是72MHz,APB2预分频配置为1分频,所以PCLK2也是72MHz。要让ADCCLK不超过14MHz,至少要用6分频,72/6=12MHz,正好满足要求。这也是标准外设库例程里普遍写ADC_PCLK2_Div6的原因。

我建议在ADC初始化前,先明确配置ADC时钟:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6);

注意,RCC_ADCCLKConfig的作用仅仅是设置ADC预分频器,并不负责PCLK2本身的分频。如果你偷懒没有调用RCC_PCLK2Config,或者工程里的SystemInit压根没有把时钟配到72MHz,那你实际得到的PCLK2可能是别的值。比如改用内部HSI 8MHz时钟时,PCLK2默认也就是8MHz左右,这时用6分频得到约1.33MHz,虽然工作正常但采样速度很慢;更怕的是有人用了Div2,结果ADCCLK接近4MHz,也未必会全0,但配合其他问题就容易出幺蛾子。

所以我的建议是:在初始化之前调用一次RCC_GetClocksFreq,把当前PCLK2的值打印出来,心里有数。

RCC_ClocksTypeDef RCC_Clocks; RCC_GetClocksFreq(&RCC_Clocks); // 用串口打印 RCC_Clocks.PCLK2_Frequency // 若打印 72000000,ADC分频用 Div6 最合适

还有一个细节不能漏:Proteus里晶振组件的频率要和MCU属性里的时钟设置一致。如果晶振组件是4MHz,而代码按8MHz外部晶振初始化,SystemInit很可能卡在等待HSE起振的循环里,程序根本跑不到ADC初始化。这个坑我放到后面第5部分集中说,因为它经常和"全0"现象同时出现。

3. 再看引脚:模拟输入模式和通道编号对不上就白读

3.1 引脚复用到AIN模式的重要性

很多新手容易忽略GPIO这一层,以为ADC通道配置好之后,引脚就会自动变成模拟输入。实际不是这样。

STM32的引脚是有复用功能的。PA0这个引脚,既可以做普通IO,也可以复用成TIM2_CH1、WKUP,还可以作为ADC1_IN0。它到底以什么身份工作,取决于GPIO控制寄存器CRL里的模式位。如果想让ADC正确采集到外部模拟电压,必须把该引脚配置成模拟输入模式,也就是GPIO_Mode_AIN。

如果你忘了配置GPIO,或者只配置了通用输入浮空模式,在真实芯片上ADC采样通常还能读出个不稳定值,但在Proteus的仿真模型里,模拟电压根本不会正确进入采样网络,结果就是DR寄存器的值一直停在0。这一步对仿真尤其严格,简直像考试里的"必答项"。

正确代码如下:

GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure);

别忘了使能GPIOA的时钟。它和ADC1的时钟都挂在APB2上,可以一起打开:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE);

3.2 原理图端最容易忽略的接线细节

代码配置没问题,但原理图接错线,照样全0。这里列几个我处理过的高频踩坑点:

通道和引脚对应关系记错。STM32F103C8的ADC1有10个外部通道,通道编号和引脚的对应关系是固定的。PA0对应通道0,PA1对应通道1,以此类推推到PA7对应通道7;PB0对应通道8,PB1对应通道9。代码里ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ...)显然配的是PA0,如果信号源接在PA1上,读出来的只会是另一个悬空通道的值,通常就是0。

信号源没有形成完整回路。用DVSRC直流电压源给PA0提供2.5V时,必须把电源的负极和STM32的GND接到同一个地网络。很多人只看到"输出电压有2.5V",但实际上信号源的参考地和芯片GND没有连通,等效于PA0悬空,读值当然不对。Proteus里的GND端子不止一个,放的时候要确保它们都在同一条电源网络里。

VREF+引脚悬空或接错。这个在STM32F103C8仿真模型里非常常见。芯片的VREF+决定了ADC转换的满量程电压,必须接3.3V。如果VREF+悬空或者接到GND,ADC内部的参考电压就不正常,结果是无论如何都读不出有效值。完整的电源接法应该是:VDDA接3.3V,VREF+接3.3V,VSSA接GND,同时VDD和VSS也要接好。

电位器接线不对。用POT-HG电位器时,1脚和3脚分别接3.3V和GND,中间抽头接PA0。如果只把两端接了3.3V,中间抽头通过一个电阻接地,那抽头电压就被固定在上拉状态,转动旋钮也不会有变化。更极端的情况是电位器两端都没接,中间抽头悬空,读值会是随机或0。

我在排查时就发现,自己犯的恰恰是第2个坑:信号源的正端接到了PA0,但信号源的负端没有和GND连通,导致PA0的电压始终悬浮。把地线补上之后,ADC值立刻随电压变化了。仿真里面"接地"这种理所当然的步骤,反而最容易被忽略。

4. 最后看时序:软件触发后不等EOC拿到的就是0

4.1 ADC转换不是瞬间完成的

第三个配置问题出在时序上。ADC从触发到转换完成需要时间,不是写一条指令就能立刻读出结果的。

STM32F103的逐次逼近型ADC,完成一次规则通道转换需要若干个ADCCLK周期,具体数量取决于采样时间和转换位数。例如,采样时间选择239.5周期,加上12位转换本身的12.5个周期,总共约252个ADCCLK。当ADCCLK是12MHz时,一次转换大约耗时21微秒。

在真实芯片上,如果你触发转换后立刻去读DR寄存器,读到的可能是上一轮的残留值;如果上一轮根本没完成,DR就是复位值0。Proteus模型对时序的模拟更保守,第一次转换尚未完成时,DR寄存器常常保持0,于是很多人就误以为芯片坏了。

4.2 等待EOC的完整初始化流程

正确的做法是:软件触发转换之后,必须等待ADC状态寄存器SR里的EOC标志位置1,再读取DR。完整流程应该是:

// 1. 使能ADC1时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 2. 配置PA0为模拟输入 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 配置ADC1 ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); // 4. 配置规则组通道0,采样时间选最长档 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); // 5. 使能ADC并校准 ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); // 6. 软件触发,等待EOC,再读取DR ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); uint16_t adc_value = ADC_GetConversionValue(ADC1);

这段代码的顺序不能乱。尤其要注意校准步骤,虽然跳过校准不一定导致全0,但仿真模型会保留这套流程,如果校准没有完成就触发转换,结果很容易不正常。

另外,ADC_ContinuousConvMode如果改成ENABLE,也就是连续转换模式,只需要触发一次就会不停转换。这时你依然要等第一次EOC置位才能读DR。很多人连续转换模式下直接循环读DR,读出来的第一帧数据就是0,然后把后面每次触发逻辑全打乱,排查起来很费劲。

4.3 单步调试时容易被卡住的实操坑

这里分享一个我踩过的坑:在Proteus里单步调试代码时,如果一步步执行到ADC_SoftwareStartConvCmd,再按单步执行进入while等待EOC的循环,程序会一直卡在里面,看起来像是死锁。

原因很简单:单步执行时仿真时间几乎不推进,而ADC转换需要几十微秒的仿真时间。你在等待循环里单步执行,EOC永远不会置位。解决办法有两个:

  • 直接把断点打在while等待EOC之后的那一行,然后用全速运行Run到断点,让仿真时间推进足够长;
  • 或者干脆在读取ADC之前加一个短延时(比如1毫秒),再用while (!EOC);做保护。

从工程习惯上讲,我还是更推荐"等待EOC"这种方式,因为它最规范,不会因为改了系统时钟就失效。但调试时了解这个时序特性,能省很多时间。

5. 差点误导我的另一个大坑:Proteus加载了旧HEX

5.1 程序文件路径不一致的典型症状

这是最隐蔽、最容易让人把配置查一遍又一遍的问题。

Proteus里的STM32组件不是一个"自动运行固件"的黑盒子,它需要你显式指定目标HEX文件。如果你在Keil里改了代码,编译生成了新的demo.hex,但Proteus组件属性里的Program File还指向桌面上的old.hex,那么仿真运行的仍然是旧代码。

症状非常明显:无论你怎么改ADC配置、GPIO模式、等待逻辑,仿真结果完全不变,还是老样子全0。因为程序文件压根没变,你改的东西根本没被加载。

我在排查到最后一步时才发现,Keil编译输出目录默认是工程目录下的Objects文件夹,而Proteus里的Program File还指向我几天前复制到桌面的hex。重新选择正确的hex路径之后,ADC值立刻正常了。

5.2 先用LED和串口证明代码真的在跑

避免被这个问题坑,最有效的办法是:在确认ADC问题之前,先给工程加一个"活证据"。

比如在main函数开头把某个GPIO翻转起来,接一个LED,让它在仿真里闪烁;或者直接在ADC初始化之前,通过串口打印一个固定的标识字符串。如果仿真运行时LED正常闪、标识正常打印,说明当前加载的代码确实是最新的;如果这些都没有,先检查HEX路径再说。

具体操作是:双击原理图中的STM32F103C8,打开Edit Component对话框,在Program File一栏重新Browse你的Keil输出HEX。顺便检查Advanced Properties里的Clock Frequency,确保它与代码初始化时的外部晶振频率一致,比如都填8MHz。

这里顺带提一句:如果晶振频率设置不一致,SystemInit在等待HSE就绪时可能超时卡死,程序跑不起来,ADC自然全0。这个现象和HEX路径错乱叠加在一起,非常容易让人误判为"ADC配置有问题"。

我当时用了一个简单的排查顺序:先看LED闪烁、再看串口版本号、最后才去翻ADC寄存器。事实证明,这个顺序让我少走了很多弯路。仿真里"程序根本没跑起来"和"ADC配置错了"完全是两码事,先把前者排除,后者才有意义。

6. 一套能直接抄的ADC读取例程与Proteus侧设置

6.1 代码清单与关键注释

这里给出一段完整的轮询方式读取ADC1通道0的例程,方便你直接复现。代码基于标准外设库,HAL库的思路也完全一样,重点在于前面说的三步:时钟分频正确、GPIO模拟输入、触发后等待EOC。

#include "stm32f10x.h" #include "stm32f10x_gpio.h" #include "stm32f10x_rcc.h" #include "stm32f10x_adc.h" #include "stm32f10x_usart.h" // 简单的串口初始化,PA9=TX void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } void USART1_SendByte(uint8_t byte) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, byte); } void USART1_SendString(char* str) { while (*str) USART1_SendByte((uint8_t)*str++); } uint16_t ADC1_ReadChannel0(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); return ADC_GetConversionValue(ADC1); } int main(void) { uint16_t adc_value = 0; char buffer[32]; USART1_Init(); USART1_SendString("STM32F103C8 ADC Test Start\r\n"); // 使能GPIOA和ADC1时钟,ADC时钟 = PCLK2 / 6 = 72MHz / 6 = 12MHz RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // PA0模拟输入 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // ADC1配置:单通道、单次转换、软件触发 ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); // 规则通道0,采样时间选最长档,降低外部高阻影响 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); // 校准 ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); while (1) { adc_value = ADC1_ReadChannel0(); int volt_mv = (int)((uint32_t)adc_value * 3300UL / 4095UL); sprintf(buffer, "ADC=%d, Voltage=%d.%03dV\r\n", adc_value, volt_mv / 1000, volt_mv % 1000); USART1_SendString(buffer); // 简单延时,避免虚拟终端刷新太快 for (volatile uint32_t i = 0; i < 2000000; i++); } }

这份代码在Proteus中的效果是:如果PA0接的电压为0V,ADC输出接近0;接3.3V时输出接近4095;接1.65V时,ADC值约2047,电压显示约1.65V。

6.2 Proteus侧需要做的设置

原理图侧有几个关键点,建议按下面核对:

  • 芯片型号选STM32F103C8,双击后在Program File中加载上述代码编译出的HEX;
  • 晶振组件频率设为8MHz,MCU属性里的Clock Frequency也设为8MHz;
  • VDDA和VREF+接到3.3V,VSSA接GND;
  • 电位器POT-HG两端分别接到3.3V和GND,中间抽头接PA0;
  • 想观察串口输出,放一个Virtual Terminal组件,RX接PA9,TX接PA10可选,波特率设为115200;
  • 仿真运行后改变电位器抽头位置或DVSRC电压值,观察虚拟终端打印的ADC原始值和电压值。

如果一切正常,你会看到ADC值随输入电压线性变化。如果仍然全0,进入下一步的定位清单。

7. 最终定位清单:从现象到根因的检查顺序

7.1 七步定位表

我在这次排查之后,总结了一张表格,之后每次遇到Proteus STM32 ADC全0问题,都是按这个顺序走一遍:

检查项可能原因确认方法解决方案
程序是否运行HEX路径错误、晶振频率属性不匹配LED是否闪烁、串口是否打印版本号重新加载最新HEX,设置Clock Frequency为8M
系统时钟SystemInit卡死或PCLK2频率与预期不符串口打印RCC_Clocks结构体检查晶振组件、调RCC_PCLK2Config和RCC_ADCCLKConfig
ADC时钟分频ADCCLK超过14MHz或分频过小计算PCLK2/分频系数72MHz下使用Div6或Div8
GPIO模式PA0未配置为模拟输入,GPIOA时钟未使能单步查看GPIOA->CRL寄存器配置GPIO_Mode_AIN,使能RCC_APB2Periph_GPIOA
通道编号信号源接错引脚、代码通道和引脚不对应对照通道表确认PA0=Channel0修改接线或ADC_RegularChannelConfig参数
VREF/电源VREF+悬空、VDDA未接、信号源不共地用电压探针查看PA0实际电压VREF+接3.3V,信号源地与STM32 GND相连
触发与等待未软件触发、触发后没等EOC、单步中变量看不全调试窗口查看ADC1->SR的EOC位按标准流程触发并while(EOC==RESET)等待

7.2 用Proteus调试窗口快速判断问题在哪个环节

除了串口打印,Proteus的调试窗口也能帮上大忙。运行仿真后,在Debug菜单中打开外设寄存器查看界面,或者在代码中加Watch变量直接观察ADC1->SR、ADC1->CR2和ADC1->DR三个关键寄存器的值。

判断逻辑很简单:

  • 如果ADC1->CR2的ADON位一直为0,说明ADC_Cmd(ADC1, ENABLE)没生效,先查RCC时钟使能和ADC_Init调用;
  • 如果ADON=1但EOC一直为0,说明转换没完成,重点查ADCCLK分频和软件触发语句是否执行,以及在单步调试时有没有留出足够的仿真时间;
  • 如果EOC=1但DR=0,重点查GPIO模拟输入模式、引脚接线、VREF参考电压、信号源回路;
  • 如果DR有值但电压不随电源变化,重点查通道编号与引脚的对应关系。

这套判断方法不依赖示波器,也不用反复改代码,适合在Proteus里快速定位。我后来真实调试板子时也用过类似的寄存器级排查思路,一次就锁定了问题,比盲改代码高效得多。

从我那次踩坑到现在,前后折腾了大半天。回头看,真正的问题在于把"仿真现象"直接等同于"芯片坏"或者"ADC外设不可用",却没有按外设的时钟、引脚、触发时序逐一排查。Proteus对STM32模型虽然有些地方比真实芯片更严格,但这反而逼着我把ADC的工作流程弄清楚了。以后再遇到采样全0,我不会再急着换芯片,而是先问自己三个问题:时钟对吗?引脚配了吗?EOC等了吗?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询