在Proteus 8.15里拖一个STM32F103C8,写了一段自觉没什么毛病的ADC采样代码,运行起来却发现ADC值死活是0,电位器从这头拧到那头,读数纹丝不动,连寄存器里的DR都是干干净净的0。这个场景我见过太多次了,不少人的第一反应是怀疑仿真模型坏了,或者芯片选型有问题,甚至有人直接把Proteus卸载换平台。但根据我这些年折腾仿真和实际板子的经验,十有八九不是模型的问题,而是三个看起来不起眼的配置没做对。
这篇文章就把我踩过的坑、排查的思路和能直接复现的完整工程都整理出来,包括ADC时钟、GPIO模式、软件触发这些最容易出错的地方。适合正在用Proteus做STM32仿真的学生、初学ADC采集的嵌入式爱好者,以及那些被“仿真读数为0”折磨到怀疑人生的朋友。
1. 先搞清楚仿真环境,再决定从哪下手
1.1 现象描述:ADC采出0和采出乱码是两回事
在Proteus 8.15里,STM32F103C8的ADC问题通常表现为两种:一是ADC数值恒为0,不管输入电压怎么变都是0,这种最常见;二是数值乱跳或者是一个固定不变的奇怪数字,比如4095、2047之类的。这两种问题其实指向的配置方向完全不一样。
恒为0,绝大多数时候是外设链路没打通,信号根本没有进入ADC模块,要么是时钟没给到,要么是触发信号没到,要么是引脚模式把模拟信号挡在了门外。而数值乱跳或固定高值,则更多指向采样时间太短、基准电压悬空、信号源阻抗过大这类问题。
我遇到的大多数提问都集中在“恒为0”这条上,所以这篇文章主要围绕这个现象展开。不过在章节里也会把采样时间、电源配置这些隐藏雷点一起讲清楚,因为这三者经常纠缠在一起。
1.2 别急着换芯片,先按“电源—时钟—引脚—触发—读取”的顺序排查
仿真和真实开发板不一样,真实板子出问题,你会怀疑焊接、怀疑引脚接触、怀疑芯片本身有暗伤。但Proteus里任何元件都是理想模型,芯片不会烧坏,焊接也不会虚焊。
所以我处理这类问题的固定思路是:先检查电源有没有给对,再看芯片时钟配置是否和仿真参数一致,然后检查GPIO模式是否为模拟输入,接着确认ADC触发有没有执行,最后才看读取方式。这个顺序不是随便排的,因为前面任何一环断了,后面的检查都是白做。
比如时钟没配置好,ADC模块压根不工作,你后面检查GPIO配置再正确也没用。又比如GPIO没配成模拟输入,就算软件触发正常,模拟信号也进不去。所以按链路顺序走,能省很多时间。
2. 配置一:ADC工作时钟没喂饱,采样电路直接罢工
2.1 ADC时钟从哪来,为什么频率不对会得到0
在STM32F103上,ADC外设挂在APB2总线上,也就是PCLK2。这颗芯片的ADC模块内部时钟最高只能跑到14MHz,如果超过这个频率,ADC内部的逐次逼近比较器就无法保证正确转换,结果值会变得不可靠,严重时就会输出0。
APB2总线如果按72MHz主频运行,就必须通过RCC_ADCCLKConfig设置ADC预分频,至少要6分频才能把72MHz降到12MHz。反过来,如果主频没那么高,比如只有36MHz,还照抄网上的6分频,那ADC时钟就只有6MHz,转换时间拉长但结果一般不会为0。
在Proteus仿真中,很多人直接用一个现成的工程模板,把SystemInit、GPIO、USART都粘过来,唯独漏了RCC_ADCCLKConfig这句话。此时ADC模块虽然已经被ADC_Cmd使能了,但没有时钟可用的外设是一个“幽灵设备”,寄存器状态不会被更新,读到的永远是复位值0。
2.2 在Proteus里确认主频的土办法,以及晶振属性怎么核对
Proteus的STM32模型非常看重芯片属性里设置的晶振频率。双击原理图中的芯片,打开Edit Component对话框,Crystal Frequency这一项默认可能是4MHz或8MHz,具体取决于你放置的库版本。
问题就出在这里:如果整份代码里SystemInit是按照外部8MHz晶振来计算PLL倍频系数的,但Proteus里芯片属性还停留在4MHz,那芯片实际运行的系统频率和你以为的频率就会不一致,最后算出来的ADC时钟也可能跑偏。虽然仿真器大多数时候不会像真机一样直接死机,但ADC模块对时钟敏感,表现成持续输出0并不奇怪。
所以我现在的习惯是:放置芯片后第一件事就是双击,把Crystal Frequency改成8MHz,然后在代码里用宏定义HSE_VALUE为8000000,两边对齐。如果你用的是HSI内部时钟,也要去代码里确认,别让仿真参数和工程设置打架。
2.3 时钟配置的代码到底该怎么写,检查点是什么
这是一段我在仿真项目里常用的RCC初始化代码,可以直接参考:
void RCC_Configuration(void) { SystemInit(); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); }其中RCC_PCLK2_Div6的意思是,把PCLK2作为输入,6分频后送给ADC。假设PCLK2 = 72MHz,那么ADC时钟 = 72 / 6 = 12MHz,处于STM32F103允许的14MHz上限之内。
检查的时候,建议在仿真暂停后,打开Debug菜单下的Watch Window,手动添加RCC->CFGR这个寄存器,查看ADCPRE字段,也就是位15到14。如果是00,说明没分频,这时候ADC时钟就等于PCLK2,妥妥超频了;如果是10,则代表6分频,符合预期。这一招在Proteus里看寄存器状态非常直观,比靠猜强多了。
3. 配置二:GPIO引脚没设成模拟输入,模拟信号被“拒之门外”
3.1 GPIO_Mode_AIN到底是什么,为什么少了它会读0
STM32的GPIO引脚有多种工作模式,比如浮空输入、上拉输入、下拉输入、推挽输出、复用功能输出,以及模拟输入。很多人写ADC代码时,其实知道要把引脚配成输入,但习惯性写成了GPIO_Mode_IN_FLOATING或GPIO_Mode_IPU,结果ADC读数一直不对。
模拟输入和普通数字输入的区别在于,模拟输入模式下,引脚内部与数字输入通路断开,外部模拟电压直接连接到ADC内部的采样保持电容上;而数字输入模式则会把信号先经过施密特触发器等数字电路,这会让模拟电压变得不可用,甚至被钳位到逻辑电平。
在真实芯片里,配错了GPIO模式,ADC也可能读到0或读到固定值。而在Proteus里面,模型的模拟开关特性会更严格,数字输入通路一打开,模拟信号就进不了ADC采样保持电路,最终结果就是DR寄存器输出恒0。
3.2 正确配置方式,以及在Proteus里检查引脚状态的技巧
以PA0作为ADC通道为例,初始化代码应该这样写:
GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure);注意,这里不要再设置GPIO_Speed,模拟输入模式不关心速度这个属性。
除了代码,Proteus的原理图也值得检查一遍。有些人习惯直接从旧工程把芯片复制过来,芯片引脚上可能残留了一些“虚拟激励”,比如蓝色或红色的那类方波信号源接在PA0上。这些激励会强行向引脚灌数字电平,ADC自然读不到电位器的电压。
检查方法很简单:原理图上选中PA0,看是否有额外的激励源连入。如果有,断开;如果不想大改,可以直接删除激励源。还有一种情况是PA0和PA1不小心在网络上短接了,造成互相干扰,也要排查。
3.3 初始化顺序的隐患:先开时钟再配置GPIO,顺序别搞反
这是仿真中比较隐蔽的一种错误。如果先执行GPIO_Init,回头才去调用RCC_APB2PeriphClockCmd使能GPIOA时钟,那GPIO_Init内部操作的是还没开启时钟的外设寄存器,配置不会生效,引脚保持复位状态。
复位状态下STM32的GPIO引脚通常是浮空输入,但ADC扫描时,模拟信号路径并未被正确激活,最终结果一样是0。所以代码里务必保证:先调用RCC时钟使能函数,再初始化GPIO,再初始化ADC。
我见过有人把GPIO初始化放在一个外设初始化函数里,把RCC配置放在另一个函数里,然后按照错误的函数调用顺序执行,代码编译毫无问题,但仿真就是不出数。这种问题在真实芯片上可能偶尔还能碰运气,但在Proteus里非常干脆,不给你任何机会。
4. 配置三:采样时间太短或软件触发没执行,结果永远是0
4.1 采样周期到底怎么算,设多大才能在仿真里不出虚数
很多初学者只知道要配置ADC_SampleTime,但并不知道这个值的含义。STM32F103的12位ADC完成一次转换,内部时间由两部分组成:采样阶段加上转换阶段。转换阶段固定需要12.5个ADC时钟周期,而采样阶段则通过寄存器配置。
标准外设库里提供了这些选项:
| 采样时间宏 | 采样阶段周期数 | 12MHz时钟下的采样时长 |
|---|---|---|
| ADC_SampleTime_1Cycles5 | 1.5 | 0.125us |
| ADC_SampleTime_7Cycles5 | 7.5 | 0.625us |
| ADC_SampleTime_13Cycles5 | 13.5 | 1.125us |
| ADC_SampleTime_28Cycles5 | 28.5 | 2.375us |
| ADC_SampleTime_41Cycles5 | 41.5 | 3.458us |
| ADC_SampleTime_55Cycles5 | 55.5 | 4.625us |
| ADC_SampleTime_71Cycles5 | 71.5 | 5.958us |
| ADC_SampleTime_239Cycles5 | 239.5 | 19.958us |
在Proteus里,如果你选的是ADC_SampleTime_1Cycles5,采样时间只有0.125微秒。采样保持电容还来不及充满电,电压就被送去比较了,结果自然是0或偏差极大。这不是芯片坏了,是时间没给够。
所以我建议在仿真阶段直接选ADC_SampleTime_55Cycles5或71Cycles5,先把链路跑通,再去考虑优化采样速度。如果你接的是一个高阻抗信号源,比如10k电位器的抽头,等效源阻抗可能有几k,那就更需要足够长的采样周期。真实硬件选型时还要结合源阻抗做RC匹配计算,但仿真阶段用长采样时间是最稳的。
4.2 软件触发的三个必要环节,少一个都会读0
STM32F103的ADC规则组转换可以配置为外部触发或软件触发。如果不用定时器、PWM这类硬件触发,最简单的就是软件触发。软件触发看似简单,但其中有三步缺一不可。
第一步是调用ADC_Cmd使能ADC模块,这时候ADC从复位状态进入工作状态。第二步是调用ADC_SoftwareStartConvCmd(ADC1, ENABLE),向ADC注入一个启动转换的信号。第三步是等待转换完成,通常用while循环查询ADC_FLAG_EOC标志。
很多人只写了第一步,忘了第二步。结果是ADC模块是活的,但从未收到启动信号,转换流程根本没跑起来,DR寄存器保持复位值0,表现就是采样总为0。
我自己以前也犯过这种错误,以为ADC_Cmd就是启动转换。实际上,ADC_Cmd只是电源开关,真正按下“开始”按钮的是SoftwareStartConvCmd。
4.3 规则组和注入组的区别,以及连续转换模式的坑
STM32的ADC分成规则组和注入组。规则组就是常规的转换序列,适合大多数场景;注入组像“优先级更高的中断”,适合需要抢占同步的场景。如果代码里用的是ADC_InitStructure.ADC_Mode = ADC_Mode_Independent,默认操作的是规则组。
问题在于有些示例代码为了演示注入组功能,会额外配置ADC_InjectedChannelConfig,主循环里读的却是规则组的DR寄存器。由于注入组的数据存储在ADC_JDR里,规则组DR一直没更新,读出来永远是0。
另一种常见坑是连续转换模式。如果你把ADC_ContinuousConvMode设为ENABLE,ADC会不断转换并更新DR,这本是好事。但如果同时把ADC_ScanConvMode配成了ENABLE,而通道序列设置又不完整,Proteus中也可能出现转换完成后数据并未正确更新的情况。我的建议很直接:第一次调通时,全部用“单次转换、单通道、软件触发”的组合,等运行正常了再进阶到扫描、连续、DMA这些模式。
5. 完整复现:一个能在Proteus 8.15下跑通的ADC采样工程
5.1 原理图搭建要点:电源、晶振、电位器一个都不能少
下面直接给出一份我实测可以跑通的原理图配置清单。首先是芯片选择:从Proteus元件库中放置STM32F103C8。双击芯片,确认Crystal Frequency设置为8MHz,这是后面代码能正确计算系统时钟的基础。
然后是电源部分。Proteus默认的Power Terminal如果不修改,电压是5V,但STM32F103C8的工作电压是2.0V到3.6V,典型为3.3V。所以放置Power符号后,一定要双击,在String栏填入“+3.3V”。如果懒得改名字,也可以直接用仿真模型里的专门3.3V电源符号。这里特别提醒:5V直接进来虽然仿真不会烧芯片模型,但ADC的参考电压和相关比较器逻辑会出现诡异问题,读数偏移甚至为0。
晶振部分,在芯片的OSC_IN和OSC_OUT引脚上挂一个8MHz晶振,两端分别接20pF左右的电容到地。虽然仿真模型对晶振电路不太敏感,但维持这个习惯总没坏处。
ADC输入信号源,我习惯放一个POT-HG,也就是带手柄的电位器元件。三端分别接+3.3V、GND和PA0。PA0对应ADC1的通道0。
5.2 标准外设库完整代码,带详细注释直接抄
下面给出一份可以直接放进工程的完整代码,使用标准外设库。我这里没有用STM32CubeMX,因为很多Proteus用户的工程模板还是标准库风格,CubeMX生成HAL库代码的点点点操作反而容易在移植时出问题。
#include "stm32f10x.h" #include <stdio.h> void RCC_Configuration(void) { SystemInit(); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); } void GPIO_Configuration(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); } void USART1_Configuration(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; 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_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } void ADC_Configuration(void) { 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); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); } uint16_t Read_ADC_Value(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; RCC_Configuration(); GPIO_Configuration(); USART1_Configuration(); ADC_Configuration(); while (1) { adc_value = Read_ADC_Value(); printf("ADC value = %d, voltage = %d mV\r\n", adc_value, (adc_value * 3300) / 4095); Delay(200); } }为了在Proteus的虚拟终端上看到输出,注意在工程里实现fputc重定向,优先把调试串口初始化和printf的底层输出对准USART1。如果嫌麻烦,也可以直接读全局变量,在仿真暂停时去看变量值。
5.3 在Proteus中接线与运行的详细步骤,连LED都能看到效果
打开Proteus后,按照前文搭建原理图。放置好POT-HG后,右下角可以看到三个引脚,通常中间是抽头。将抽头接入PA0,上端接+3.3V,下端接GND。
运行仿真后,双击POT-HG元件,会弹出属性窗口,其中的Resistance属性可以调整,还能直接用键盘方向键微调。或者实时用鼠标拖动电位器上的手柄,观察抽头输出电压的变化。
如果你想更直观看到效果,还可以在虚拟终端(Virtual Terminal)上观察串口输出。放置Virtual Terminal时,记得把RXD接到单片机的TX(PA9),TXD接到单片机的RX(PA10),否则数据发不出去或显示乱码。
我常用的一个辅助验证方式是给PA0再挂一个虚拟电压表,这样可以看到当前输入电压是多少,然后把它和串口打印出来的数值做对比。两者应该一致,误差不超过几个mV。
5.4 预期结果与精度对照,数值对了才算真的通
合理配置完成后,仿真运行,串口输出的数值应该随电位器转动手感呈现线性变化。这里列一组参考值,方便你快速判断是否正常:
| 输入电压 | 理论ADC值(12位) | 收到的典型输出 |
|---|---|---|
| 0V | 0 | 0 |
| 1.65V | 2047 | 2046-2048 |
| 3.3V | 4095 | 4094-4095 |
如果数值接近这个表,说明三个核心配置都正确,剩下的问题就是功能优化了。如果数值虽然变化,但整体偏差较大,再进一步检查参考电压和信号源阻抗。
6. 排查实录:除了三要素,这些坑我也帮你们踩过了
6.1 坑1:Proteus默认电源5V带STM32,ADC读数诡异
这是个非常经典的坑。Proteus中的Power Terminal默认是5V,如果你直接把默认电源符号拖到STM32的VDD上,芯片能跑,程序也能跑,但ADC的参考电压会受到影响。出现的情况是,电位器电压明明在变,ADC读值也变,但最大值只有不到3000,或者整个曲线被压缩。
我的排查经历是,花了半小时质疑代码,最后看了一眼原理图上的电源网络标签,发现标的是VCC而不是+3.3V。改过来后,一切恢复正常。仿真中电源乱标不会有任何报错,它只会让结果偏离预期,这也是仿真调试中比较难发现的一类问题。
保险起见,我建议在STM32的每个电源引脚旁边都放上0.1uF的退耦电容,仿真中虽然不起实质作用,但能让你养成好习惯,以后画板子也不会忘。
6.2 坑2:晶振频率不一致,系统时钟自动跑偏
我见过一个工程,代码里SystemInit默认HSE = 8MHz,但Proteus里芯片默认4MHz,结果ADC时钟计算出来完全不在预期范围。更郁闷的是,这种问题在你更换Proteus版本,或从别人那里拷贝工程时特别容易复现。
还有一次,我把芯片属性里的Crystal Frequency改成了8MHz,却忘了代码里HSE_VALUE这个宏可能还是4MHz,两边没有对齐。虽然搭配不同的倍频系数,最终实际主频恰好落在某个值,但ADC模块的转换时序就是不对。
解决办法很直接:打开stm32f10x.h,确认HSE_VALUE宏定义。如果你所有项目都固定使用8MHz外部晶振,就把这个宏设为8000000,并且Proteus芯片属性也改成8MHz,保证两边一致。
6.3 坑3:仿真运行中看寄存器和变量,总看到旧值
Proteus的调试功能里可以打开Watch Window观察寄存器,但有个细节:如果仿真正在运行,Watch Window的数据可能没有实时刷新,你看到的DR值还是几秒前的旧数据。这会让人误以为ADC没在工作。
最好的办法是,在主循环里加一个全局变量adc_value,然后让仿真单步执行或者暂停,暂停后去Variables窗口查看这个变量的当前值。如果你用的是Debug优化模式,还要注意变量别被编译器优化掉,给变量加volatile修饰是最稳妥的。
如果你使用的是printf输出,还要注意串口数据在虚拟终端里的显示是滚动式的,如果打印频率太快,终端被刷屏,你看到的可能不是最新数据。我一般把延时控制在200ms以上,方便观察。
6.4 常见问题速查表:一眼定位问题
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| ADC读数恒为0 | ADC时钟未配置或过高 | 检查RCC_ADCCLKConfig和PCLK2分频 |
| ADC读数恒为0 | GPIO模式不是AIN | 改成GPIO_Mode_AIN |
| ADC读数恒为0 | 软件触发未执行 | 调用ADC_SoftwareStartConvCmd |
| ADC读数恒为0 | 规则组未配置,误用注入组 | 只操作ADC1的DR和规则组通道 |
| 读数波动大 | 采样时间太短 | 使用55.5或71.5周期采样 |
| 读数接近4095 | 引脚悬空或接线错误 | 检查电位器接法,确保抽头连PA0 |
| 数据变化但范围不对 | 电源电压默认5V超过芯片规格 | 将Power Terminal改为+3.3V |
| 数值呈非线性 | 信号源阻抗过大 | 加电压跟随器或减小信号源内阻 |
| 串口输出乱码 | 波特率或虚拟终端接线不对 | 检查USART配置和RXD/TXD交叉连接 |
| 寄存器不变 | 仿真运行中未暂停 | 暂停后查看,或使用全局变量观察 |
这张表我自己贴在工位上了,每次怀疑ADC有问题,先对照一遍,比翻手册还快。
我个人在实际操作中的体会是,Proteus仿真虽然和真实板子有差异,但它对“配置链完整性”的检查反而比真机更严格。真实芯片可能因为外部电路寄生参数而产生“碰巧能用”的表象,仿真模型则一板一眼,任何一环断了都会明确告诉你。所以遇到ADC采样总为0,真不是芯片的问题,而是你的配置链上某个环节松动。
最后再分享一个小技巧:在Proteus里遇到ADC读数异常,可以先不接程序,直接在原理图上用电压表测PA0的直流电压,确认信号源本身没问题。然后再在代码里加一个临时变量,把配置寄存器的关键字段读出来,通过串口打印或Watch Window确认。这种“剥洋葱”的定位方式,能让你在十分钟内定位到问题,而不是瞎猜乱改。