ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 Proteus仿真ADC采集电压表:从电路搭建到代码实现全攻略

STM32 Proteus仿真ADC采集电压表:从电路搭建到代码实现全攻略 第一次在 Proteus 里把 STM32 的 ADC 采集跑通时我盯着屏幕上的电压数值跟着滑动变阻器的旋钮一起变化那种“原来数据手册上的那些公式真的是这样工作的”感觉比照着文档空想要清晰太多。ADC 是模拟世界和数字世界之间的桥梁但很多初学者卡住的地方恰恰是不知道桥这头和那头分别怎么接、代码里怎么读、读出来的数字又该怎么换算成电压。Proteus 仿真提供了非常合适的验证环境你不需要焊接、万用表、示波器只要放一个 STM32F103C8、一个滑动变阻器、一块 LCD1602就能看到从滑动变阻器抽头到 MCU 引脚再到电压显示的一整条链路。这篇文章就把这套完整工程拆开讲清楚电路怎么搭、CubeMX 怎么配置 ADC、代码里的采样与换算逻辑是什么以及仿真过程中最容易被卡住的坑怎么排除。我尽量把“为什么这样做”也一起写出来而不是只丢出一堆配置和代码。适合三类读者刚接触 STM32 的初学者准备做课程设计或毕业设计的同学以及需要快速验证某个传感器采集方案的在职开发。根据我的经验在 Proteus 里跑通的这套结构换到真实板子上通常只需要做少量调整而且你已经知道该测哪些关键点。1. 为什么我坚持先仿真再做板这套 ADC 电压表到底在验证什么可能有人觉得不过是读一个电位器电压再显示出来直接拿开发板做不就行了何必绕一圈用 Proteus。我的看法是ADC 这个外设看起来简单实际上坑并不少。电源、参考电压、引脚模式、采样时间、数据对齐方式任何一环配置错读出来的数值都会不对。如果你直接上硬件一旦数据不对你需要排查的变量太多是电路没焊好、芯片有问题、还是程序逻辑错了通常很难一眼看出来。仿真先跑通的价值就是先排除那些与硬件接线、电气连接相关的变量让你把注意力集中在外设配置和代码逻辑上。这套工程本质上在验证一个完整的“采集-转换-显示”闭环滑动变阻器把电压改变后ADC 外设负责把模拟电压量化成数字量处理器读取这个数字量通过公式还原成真实电压最后送到 LCD 或虚拟终端显示。这个闭环是后续所有传感器项目的基本骨架。你后面做光敏电阻、热敏电阻、MQ 气体传感器、电位器式角度传感器本质上都还是在重复这套流程只不过前端电路从“滑动变阻器分压”换成了“传感器放大/分压电路”。所以我给刚上手的朋友的建议是不必急着在开发板上从头折腾。先在 Proteus 里把这条链路跑明白弄清楚 ADC 原始值和实际电压之间的换算关系再回到真实板子上改你会省下大量反复烧录、拔线、怀疑人生的时间。真实硬件和仿真当然有差异比如 Proteus 里不会出现接触不良、不会真的烧芯片但反过来在仿真里你更容易看清信号是怎么走的这对建立整个系统的概念模型特别有帮助。另外这套工程对于课程设计和毕业设计也有实用价值。很多题目本质上都是“采集某个模拟量然后显示或上报”。你只要把前端传感器电路按需替换代码里采集、滤波、格式化的部分几乎不用大改直接复用即可。这比我当年从零写一套要顺得多这也是我把整套文件结构整理出来的原因。2. 仿真电路搭建滑动变阻器到 PA0 的连接与时钟设置2.1 元件清单与 Proteus 中的具体型号在 Proteus 8 中新建工程时我习惯直接搜索器件名并添加到原理图。这套工程用到的关键元件如下元件Proteus 搜索关键字数量作用STM32F103C8STM32F103C81主控芯片滑动变阻器POT-HG 或 POT-LIN1产生 0~3.3V 可调电压LCD1602LM016L 或 LCD16021显示 ADC 原始值和电压值虚拟终端VIRTUAL TERMINAL1串口输出调试信息8MHz 晶振CRYSTAL1外部高速时钟20pF 电容CAP2晶振负载电容100nF 电容CAP若干芯片电源去耦10uF 电容CAP-ELEC1电源滤波1kΩ 电阻RES1LCD 对比度设置10kΩ 电阻RES1复位电路上拉或 LCD 背光保护STM32F103C8 是 48 脚封装Flash 64KBRAM 20KB带 2 个 12 位 ADC最多支持 10 个外部通道。Proteus 对它的 VSM 模型已经比较成熟可以加载 Keil 生成的 .hex 文件直接仿真外设行为接近真实芯片。滑动变阻器我推荐用 POT-HG它是三端线性电位器模拟出来就是一个可以手动调节的分压器。POT-LIN 也可以但实际拖动滑块时手感不太一样Proteus 里区别不太大选哪个都能用。2.2 电位器三根引脚的接法0 到 3.3V 可调电压的计算滑动变阻器在电路里的正确接法是把两端的固定端分别接到 3.3V 和 GND中间抽头接到 MCU 的 PA0 引脚。这样调节滑块时PA0 上的电压会在 0V 到 3.3V 之间线性变化。具体电气关系是一个简单的分压公式V_out V_ref × R_下 / (R_上 R_下)其中 R_上 是 3.3V 到抽头之间的电阻R_下 是抽头到 GND 之间的电阻。电位器总阻值通常选 10kΩ当滑块滑到最上端时抽头电压是 3.3V滑到最下端时是 0V中间位置大约 1.65V。这正好覆盖 STM32 ADC 的单端输入范围。我见过不少初学者犯一个错误把电位器接成“可变电阻”也就是只用了两个引脚。比如一端接 3.3V、抽头接 PA0、另一端悬空。这种接法调出来的电压并不稳定因为抽头与 GND 之间没有确定通路MCU 引脚的输入阻抗会直接影响分压结果。在 Proteus 仿真里模型可能还能给个看起来合理的值但在真实电路上你会看到一个随手指触摸变化的飘忽读数。所以一定要把电位器三个引脚都用上让它老老实实做一个分压器。还有一个容易被忽略的点STM32F103 的 ADC 输入模拟电压范围是 0 到 VDDAVDDA 最大 3.3V不是 5V。很多人习惯用 USB 供电板上就是 5V如果轴头直接接 PA0ADC 满量程参考是 3.3V5V 直接超量程内部等效电路很可能把引脚钳位甚至损坏。在仿真里这一步也许不会立刻爆炸但到了真实板子上就是烧芯片的风险。所以我在这套工程里把电位器的电源明确接到 3.3V 网络而不是直接在 Proteus 里拖一根默认的 5V。2.3 Proteus 里 MCU 时钟属性的设置常见坑在 Proteus 里双击 STM32F103C8 芯片会弹出一个属性对话框。关键的两项是“Program File”也就是要加载的 .hex 文件路径以及“Clock Frequency”。很多人默认填写 72MHz以为这就是系统主频但这里的 Clock Frequency 更准确的用法是告诉仿真模型外部时钟源的频率。如果你用的是 STM32F103C8 配合外部 8MHz 晶振CubeMX 里通过 PLL 倍频到 72MHz那么这里的 Clock Frequency 应该填 8MHz而不是 72MHz。这个细节直接决定了仿真模型内部计算的时钟是否与你程序里的期望一致。比如你在 CubeMX 里打开外部高速晶振 HSE配置 PLL 把 8MHz 倍频到 72MHz但 Proteus 模型里填的 Clock Frequency 却是 72MHz那么虚拟终端收到的串口波特率就会和设置值对不上出现乱码。如果选择用内部 HSI 时钟而不外接晶振Proteus 模型里一般也按 8MHz 来填。我自己的习惯是在 CubeMX 里使用 HSE 外部 8MHz 晶振Proteus 原理图上放一个同频晶振属性也填 8MHz这样最接近真实板子的启动流程也比 HSI 方案更容易排查时钟相关的问题。别小看这个属性我周围至少有三个人在这上面卡过半天问题表现都是串口乱码或者 ADC 采样时间异常。3. 从原始采样值到电压ADC 配置、换算与滤波代码3.1 CubeMX 里的 ADC 配置按这套参数来我在 STM32CubeMX 里的操作路径是点击 PA0 引脚把它复用为 ADC1_IN0然后在左侧列表选择 ADC1开启 IN0 Single-ended 通道。Parameter Settings 里的关键配置我直接列出来参数设置值为什么Clock PrescalerPCLK2 / 6 或 / 8让 ADC 时钟不超过 14MHz这是 STM32F103 的数据手册上限Resolution12 bits默认 12 位分辨率满量程对应 4095Data AlignmentRight alignment右对齐直接读寄存器就能得到无符号数值Scan Conversion ModeDisabled只采集一个通道不需要扫描模式Continuous Conversion ModeDisabled用轮询方式读每次启动单次转换End of Conversion SelectionSingle conversion单次转换结束即置 EOC 标志Sampling Time55.5 Cycles比较保守的采样时间适合高阻信号源采样时间我选了 55.5 Cycles不是随便选的。采样时间越长采样电容就有越充足的时间充到输入电压。对滑动变阻器这种低阻抗源来说短采样时间其实也够用但考虑到后续你可能会把同一个 ADC 引脚换成光敏电阻或热敏电阻它们的源阻抗比电位器高得多55.5 Cycles 能明显减少采样误差。更重要的是这个参数对初学者来说越保守越好至少不会因为采样时间太短而看到数值明显偏低。ADC 分辨率的理解也值得说清楚。12 位 ADC 的分辨率是 4095 个阶梯模拟输入 0V 对应 03.3V 对应 4095。也就是说每变化 3.3/4095 ≈ 0.8mV数字量就加 1。这个精度对于显示到小数点后两位的电压表完全够用了。3.2 HAL 库轮询读取 ADC 的代码CubeMX 生成工程后核心代码逻辑在 main.c 里。我先封装一个简单的 ADC 读取函数#include adc.h uint16_t ADC_Read(void) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { return (uint16_t)HAL_ADC_GetValue(hadc1); } return 0; }这段代码的逻辑很直接启动 ADC等待转换完成超时时间是 10ms然后从数据寄存器拿值。如果转换超时就返回 0避免影响主循环。为什么用 PollForConversion 而不是直接在 Start 之后读寄存器因为数据手册要求读取前必须确认 EOC 标志被置位否则你读到的可能是上一次转换的旧数据Proteus 仿真里这种情况不明显但真实硬件上遇到时会很困惑。完整的主循环代码如下int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_USART1_UART_Init(); LCD_Init(); uint16_t adc_raw 0; float voltage 0.0f; while (1) { adc_raw ADC_Read(); voltage (float)adc_raw * 3.3f / 4095.0f; char line[16]; sprintf(line, Raw:%4d, adc_raw); LCD_SetCursor(0, 0); LCD_WriteString(line); sprintf(line, Volt:%.2fV, (double)voltage); LCD_SetCursor(0, 1); LCD_WriteString(line); HAL_Delay(200); } }主循环里每 200ms 刷新一次显示避免 LCD 刷新太快造成视觉闪烁。读一次 ADC分别显示原始值和换算后的电压值两行各司其职方便对照。3.3 为什么换算公式的分母是 4095不是 4096这里有一个很多人会纠结的细节电压换算公式到底怎么写。我经常看到有人写voltage adc_raw * 3.3f / 4096.0f也有写 4095 的。哪个对从 STM32 的采样原理看ADC 满量程即参考电压对应的输出数字并不是 4096而是 4095。12 位 ADC 的数字范围是 0 到 4095一共 4096 个码值。0 V 对应 0V_ref 对应 4095。所以更准确的换算应该是voltage adc_raw × V_ref / 4095当 adc_raw 4095 时得到的就是 3.3V逻辑上完全闭合。如果分母用 4096那么满量程读数换算出来只有 3.299V虽然差距微乎其微但在高精度测量场景下这种系统性偏差是没必要的。我自己在实际代码里习惯写成#define ADC_VREF 3.3f和#define ADC_RES 4095.0f然后把换算公式固定成voltage (float)adc_raw * ADC_VREF / ADC_RES;至少后续维护时一眼能看懂。3.4 滤波让 LCD 上的电压不再乱跳Proteus 里仿真电位器时数值通常比较稳定但一旦你把这套代码烧到真实板子上或者把前端换成传感器ADC 读数出现抖动几乎是必然的。机械电位器的触点、电磁干扰、电源噪声都会让相邻几次采样值出现小幅波动LCD 上最后一位小数频繁跳动看起来很烦。最常用的轻量级方案是滑动平均滤波。做法很简单连续采 8 次求平均值再拿去换算电压。代码实现如下uint16_t ADC_Read_Filtered(void) { uint32_t sum 0; uint8_t i; for (i 0; i 8; i) { sum ADC_Read(); HAL_Delay(1); } return (uint16_t)(sum / 8); }为什么是 8 次而不是 100 次因为均值滤波的本质是牺牲响应速度换取稳定性。8 次采样在 200ms 刷新周期内完全可以接受手感上调节电位器时电压变化还是比较跟手。如果滤波窗口太长你转半天旋钮LCD 上的数字才慢悠悠爬上去那种体验在调试的时候非常痛苦。4. 电压输出方案LCD1602 四线驱动与虚拟终端联调4.1 LCD1602 四线模式接线与初始化LCD1602 是经典的字符液晶模块Proteus 里常用的型号是 LM016L。我用四线模式把数据引脚从 8 根压缩到 4 根节省 GPIO。接线关系如下LCD 引脚STM32 GPIO说明RSPB0寄存器选择EPB1使能信号D4PB4数据线D5PB5数据线D6PB6数据线D7PB7数据线RWGND只写不读直接接地VO1kΩ 电阻到 GND对比度调节有两点容易踩坑。第一VO 脚对比度脚不能直接接 3.3V否则 LCD 上会出现一串黑色方块或者干脆什么都看不见。Proteus 仿真模型里我习惯在 VO 和 GND 之间接一个 1kΩ 电阻简单有效。第二RW 脚直接接地是因为我们只需要向 LCD 写数据不需要从它那里读状态这样可以少占一个 IO。如果想更稳妥地判断 LCD 忙状态可以把 RW 接到 MCU 去读 BF 标志但四线模式下会多几个设置步骤对仿真用途来说没必要。初始化流程我用的是一个标准序列。先给 LCD 发 3 个 0x03让它切换到四线模式然后配置显示参数最后清屏。核心代码如下void LCD_Init(void) { HAL_Delay(50); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x03); HAL_Delay(5); LCD_WriteNibble(0x02); // 进入4位模式 LCD_WriteCommand(0x28); // 2行显示5x7字体 LCD_WriteCommand(0x0C); // 开显示关光标 LCD_WriteCommand(0x06); // 写入后地址自动1 LCD_WriteCommand(0x01); // 清屏 HAL_Delay(2); }其中LCD_WriteNibble负责把高四位送到数据线上然后拉一个 E 脉冲。写命令和写数据的区别只在于 RS 电平命令时 RS 为 0数据时 RS 为 1。这个初始化序列是所有 1602 基础操作的地基只要这段对了后面写入字符串就只是重复的时序操作。4.2 用 sprintf 格式化电压显示在 main 循环里我用 sprintf 把数值转成字符串然后送到 LCD。这比手动把数字拆成 ASCII 码要快得多代码也更易读。Keil MDK 环境下为了让 sprintf 支持浮点数%f最好勾选 Keil 里的 MicroLIB。MicroLIB 会裁剪标准 C 库中很多用不到的功能同时保留浮点格式化能力工程体积会明显小很多在虚拟终端上做 printf 重定向也需要它。显示逻辑可以这样设计第一行显示Raw:xxxx第二行显示Volt:x.xxV。这样调试时你能同时看到 ADC 原始值和转换后的电压一旦数值不对立刻能判断问题出在采集还是换算。如果你只想显示电压第二行完全足够但做实验阶段我始终建议两行都显示尤其是排查故障时原始值能提供更多信息。4.3 虚拟终端比 LCD 更好用的调试窗口Proteus 里的 VIRTUAL TERMINAL 元件是我几乎每次仿真都会放的。它相当于一个虚拟串口助手可以实时显示 MCU 通过 USART 发出来的文本而且不需要任何 USB 转串口硬件。接线很简单把 MCU 的 PA9USART1_TX连接到虚拟终端的 RX 引脚波特率设置在虚拟终端属性里与代码中配置一致。我在 CubeMX 里把 USART1 配置为 115200bps8 位数据无校验1 停止位然后在代码里重定向 printf#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }配合 MicroLIB你在主循环里写printf(raw%d, voltage%.2fV\r\n, adc_raw, (double)voltage);虚拟终端就会持续滚动输出每一轮的采样结果。我一直觉得LCD 负责“最终展示”虚拟终端负责“过程调试”。实际项目里你不会把原始采样值、寄存器和各种滤波中间量都打到用户界面上但在仿真阶段这些东西越透明越好。虚拟终端就是那扇透明的窗户。5. ADC 仿真出现异常时的完整排查链路从 0 值到乱码这一节写几个我在 Proteus 仿真 STM32 ADC 过程中常见的问题以及对应的排查顺序。这些经验都是我实际踩过或看别人反复踩过的不涉及玄学全部从原理出发。5.1 LCD 一直显示 0.00V问题出在哪首先看 LCD 第一行的 Raw 值。如果 Raw 一直是 0说明 MCU 读到的数字就是 0不是显示函数写错了。这时按顺序查三处第一电位器的中心抽头是否确实连到了 PA0。Proteus 里元件引脚密密麻麻有时候看着连上了实际上虚拟导线只连到了元件边框上。选中导线看看高亮的是不是同一个网络这个动作很笨但很有效。第二CubeMX 里 PA0 是否被配置成了 ADC1_IN0。如果你在 CubeMX 里不小心把 PA0 设成 GPIO Output代码里根本没初始化 ADC 外设读出来的自然是 0。第三ADC_Read 函数是否真的被调用。有些人写了传感器函数但主循环里忘记调用只在初始化时读了一次后面显示函数反复显示同一个缓存值看起来也会像“一直是 0”。仿真不会报错只能靠查代码逻辑。如果 Raw 不是 0 而电压显示 0那问题就从 ADC 部分转移到显示部分重点查 sprintf 格式化和 LCD 写入顺序。5.2 数值一直停在 4095 或 3.30V如果 Raw 一直都是 4095说明 PA0 检测到的输入电压一直高于 V_ref。最常见的三个原因电位器中心抽头误接到了 VCC 那一端中间抽头却和电源短接等等再就是电位器上下两端接反了滑块怎么调都停在 3.3V 一端还有一种可能是 PA0 引脚在 CubeMX 里没有被配置为模拟输入而是保留成了默认的浮空输入状态某些 Proteus 模型会把这种引脚内部上拉导致 ADC 始终读到满量程。排查时先把电位器滑块拖到中间位置然后用电压探针直接看 PA0 网络上的电压值。Proteus 有电压探针工具放一个上去就能实时显示该点电压。如果 PA0 电压正常在 0~3.3V 之间但 Raw 还是 4095那说明 MCU 根本没有正确读取引脚模拟电压回到 CubeMX 配置检查。5.3 数值乱跳显示不稳定Proteus 仿真过程里ADC 读数一般不会像真实硬件那样明显抖动但如果你把滑块拖到一个位置数值前后差了几十个 LSB还是要重视。首先检查电位器元件选型我遇到过一次误用了 POT-G 这种旋转编码器模型仿真表现完全不是线性分压数值乱跳很正常。换成 POT-HG 或者 POT-LIN 就好了。其次是采样间隔。如果主循环里没有任何延时ADC 被全速不停地读LCD 也不停地刷新数值跳动会被无限放大。我给主循环加了 200ms 延时并且对采样值做了 8 次滑动平均效果立竿见影。另外MCU 和电位器共地也值得检查。Proteus 里如果电位器的 GND 端和 STM32 的 GND 不在同一个网络仿真电压参考就会错乱出现各种奇怪读数。5.4 Keil 生成的 .hex 加载到 Proteus 后完全没反应这个问题出现频率最高。首先在 Keil MDK 里确认 Output 选项卡中的 Create HEX File 已勾选。默认情况下 Keil 只生成 axf不生成 hex所以你必须在编译前打开这个开关。编译完成后去 MDK-ARM 文件夹下看生成的 .hex 文件路径然后在 Proteus 里双击 STM32F103C8把 Program File 指到那个 .hex 文件。还需要检查芯片启动模式。STM32F103C8 有 BOOT0 和 BOOT1 引脚在 Proteus 里它们也会显示出来。正常从主 Flash 启动时BOOT0 接地BOOT1 接地或悬空具体看 CubeMX 生成的默认配置。如果 BOOT0 被拉高芯片会进入系统存储器 Bootloader你烧进去的用户程序根本不会运行仿真画面就会卡住不动。5.5 虚拟终端出现乱码乱码的本质是波特率对不上。先在代码里确认 USART1 的波特率设置再在 Proteus 虚拟终端属性里设置完全相同的波特率、数据位、停止位。如果两边一致还是乱码回到时钟设置查 Proteus 里 STM32 的 Clock Frequency 和 CubeMX 配置是否匹配。我前面重点强调过外部晶振 8MHz 就填 8MHz不是填 72MHz。很多人串口乱码查了半天最后发现是这里填错了。6. 工程文件结构、复用思路与一点个人习惯完整的工程文件整理出来大概是这样的结构STM32_ADC_Proteus/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── adc.h │ │ └── lcd.h │ └── Src/ │ ├── main.c │ ├── adc.c │ ├── lcd.c │ └── stm32f1xx_it.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ ├── MDK-ARM/ │ ├── STM32_ADC_Proteus.uvprojx │ ├── STM32_ADC_Proteus.uvopt │ └── STM32_ADC_Proteus.hex ├── Proteus/ │ └── ADC_LCD.pdsprj └── README.mdCore 目录是 STM32CubeMX 生成的代码包括 ADC、GPIO、USART 的初始化。Drivers 是 HAL 库文件。MDK-ARM 目录里最重要的两个文件一个是 Keil 工程文件.uvprojx另一个是编译输出的.hex。Proteus 目录下是仿真工程。我会在 README 里写清楚CubeMX 版本、Keil 版本、芯片型号、时钟配置、Proteus 版本以及如何用相同版本复现。别小看这些记录三个星期后回来看自己写的东西如果没注释连你自己都可能想不起来为什么这么配。复用思路也很直接。这套工程的“采集-转换-显示”框架是通用的。你要测温度把前端从滑动变阻器改成 NTC 热敏电阻和分压电路公式里加上查表或 B 值计算你要测光照把滑动变阻器换成光敏电阻模拟电压会随光照变化你要测电池电压通过电阻分压把电压降到 3.3V 以内剩下的代码和显示部分完全不用改。这也是为什么我一直强调把 ADC 读取和电压换算单独封装成函数不要让它们散落在主循环里因为后面这些功能都是公用的。个人的一个小习惯是把参考电压和分辨率定义成宏而不是直接写在公式里。比如#define ADC_VREF 3.3f #define ADC_RES 4095.0f这样每次换板子换芯片时只需要改这两行。有人会为了一点点浮点性能损失纠结但这个项目里这点开销完全无关紧要可读性远比省几个周期重要。如果你在做一个需要高实时性的项目可以把换算改成查表法但那是另一个话题了。最后再补一句我在实际调试中的心得做这种模拟采集的仿真和实板养成“先看原始值再看换算值”的习惯非常有用。很多看似吓人的问题追到Raw那一层立刻就能定位。这也是我在这套工程里把Raw和Volt同时显示在LCD上的原因不只是为了给文章加内容而是它真的能帮你少绕很多弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进