简介:这是一套基于Proteus仿真环境与STM32F103微控制器实现的数字万用表完整工程,适合嵌入式初学者、单片机课程设计及硬件开发人员参考。项目覆盖ADC采集、DAC参考电压生成、LCD显示驱动、按键交互、电源管理及测量算法等多个核心模块,整体方案包含STM32固件源码、Keil工程文件与Proteus仿真电路图,可直接对照学习电压、电流、电阻测量从信号采集到数值显示的全链路实现。资源包共1020个文件,以C语言源文件(558个)、头文件(243个)为主,同时包含Proteus仿真项目文件、Keil工程配置、链接脚本、编译输出及硬件相关描述文档,整体压缩包约14.29MB。目前已有3088人学习下载,对理解STM32外设配置、嵌入式测量算法设计以及Proteus虚拟调试流程均有较高参考价值,也适合作为课程设计的起步模板。
1. Proteus仿真里的STM32数字万用表:不焊板子也能把量程切换跑明白
把 STM32F103 做成数字万用表,听起来像是个硬件活,但在 Proteus 仿真里,这件事真正考验的是量程切换逻辑和 ADC 采样链路的设计。我拿到这套工程后第一感受是:它没有把时间浪费在焊接和飞线上,而是把电压档、电流档、LCD 显示、按键交互全部放进仿真环境里,跑通了从信号输入到屏幕数值输出的完整闭环。对准备做真机验证的开发者来说,这套设计可以直接当固件蓝本;对刚接触 STM32 的人,它也是理解 ADC、DMA、量程判断三个核心点的一个现成载体。下面按架构、电路、代码、避坑、改造的顺序,把它拆开讲清楚。
2. 系统架构与模块分工:从输入信号到屏幕数值的一整条链路
2.1 信号链路:分压、采样、ADC、显示的前后级关系
数字万用表的核心不在于单片机有多快,而在于信号进来之后,每一级都处理得干净。这套仿真工程的信号链路是典型的五级结构:输入端子经过档位选择(分压电阻网络或采样电阻),把大电压或大电流变换成 0~3.3V 范围内的 ADC 可测信号;接着信号进入 STM32F103 的 ADC 通道;ADC 转换完成后,由 DMA 自动搬进内存缓冲区;主程序对原始值做滤波和标定,换算成实际电压或电流;最后送 LCD1602 显示数值和当前档位。
这五级之间是层层依赖的关系。档位切换如果切换不到位,ADC 读到的值就全是偏的;ADC 采样如果不用 DMA,主循环就会被不断打断。我在这个工程里最注意的就是每两级之间的接口定义:档位选择用 GPIO 控制模拟开关或继电器模型,分压比确定后,ADC 原始值与实际工程量之间只差一个线性换算公式。这样后续调试时,问题要么出在模拟前端,要么出在软件标定,不会互相混淆。
2.2 技术选型依据:STM32F103 在这类仿真工程里的比较优势
为什么是 STM32F103 而不是 C51 或者 Arduino?我拆过不少 Proteus 仿真工程,C51 在教材例子里常见,但面对 12 位 ADC 加 LCD 驱动加按键状态机的场景,它的外设资源吃紧,代码组织起来也费劲。STM32F103 的优势在于:ADC 分辨率有 12 位,配合 DMA 可以不占 CPU 持续采样;定时器资源丰富,可以用 TIM2 的输出触发 ADC 采样,实现定时采集;GPIO 翻转速度快,档位切换时分压网络的稳定时间不会因为 GPIO 输出慢而拉长。
Proteus 对 STM32F103 的器件模型支持也比较完整。ADC 模块、DMA、USART、定时器都能在仿真里正常跑,加载 HEX 文件后可以直接观察外设行为。这一点比某些只支持裸机运行的仿真芯片要好用。我在仿真时习惯把虚拟示波器挂在 ADC 输入引脚上,可以直接看到分压后的信号波形,这在真实硬件上反而要动烙铁才能量到。工程选用 STM32F103C8 或 R6 型号都可以,Proteus 里的引脚和外设布局基本通用。
2.3 工程文件布局:仿真图、固件、外围模块怎么分配
拿到这套资源时,先不要急着打开 DSN 文件,建议按文件类型分三块看。第一块是 Proteus 仿真工程,包含原理图和虚拟仪器的连接;第二块是 Keil 固件工程,包含 STM32 的标准外设库、用户代码和编译输出;第三块是文档说明,一般会记录量程参数、接线方式、已知问题。
用户代码部分我习惯按功能拆文件。ADC 相关的初始化放在 adc.c,LCD1602 的驱动单独放 lcd.c,按键扫描和去抖放 key.c,量程切换和数值标定放 meter.c,主循环只在 main.c 里做状态调度。这样拆的好处是:想改显示格式,不用动 ADC 代码;想加一个量程,只需要动分压网络和一个 case 分支。这套工程的整体逻辑并不复杂,但文件切分直接决定了二次开发的成本。
3. 硬件电路设计:分压网络、采样电阻与量程切换
3.1 电压档的分压电阻网络与量程比
电压测量的关键在于把不同的输入幅度映射到 ADC 的满量程范围附近。这套工程的电压档位常见是四档:200mV、2V、20V、200V。前两档可以直接或经过 1:1 分压后接入 ADC,后两档必须靠电阻分压做衰减,否则输入电压超过 ADC 的 3.3V 参考电压,读数会直接饱和。
以 20V 档为例,分压比取 0.1,那么 ADC 输入电压最高是 2V,留出裕量。典型的分压网络是用 R1=90kΩ 和 R2=10kΩ 串联,R2 两端接 ADC 输入,分压比 R2/(R1+R2)=0.1。200V 档则取分压比 0.01,可以用两个电阻网络级联实现,比如先用 100:1 分压,再经过一级 1:1 缓冲。注意电阻功率和输入阻抗的问题,仿真里不怎么体现,但真机做的时候会很明显。
下表是这套设计里常用的一组分压参数(Resistor 精度按 1% 设计):
| 档位 | 分压比 | 典型电阻组合 | ADC 满量程对应输入 |
|---|---|---|---|
| 200mV | 1:1 | 直接接入 | 200mV |
| 2V | 1:1 | R1=R2=100kΩ | 2V |
| 20V | 0.1 | R1=90kΩ R2=10kΩ | 20V |
| 200V | 0.01 | R1=990kΩ R2=10kΩ | 200V |
分压电阻网络的接入点要尽量靠近 ADC 引脚,中间不要走太长的连线。仿真环境里也要保持这个习惯,因为 Proteus 的线路阻抗虽然是理想模型,但调试起来分段排查会更方便。
3.2 电流档采样电阻与 I/V 变换电路
电流测量不能直接进 ADC,必须先经过采样电阻变成电压。这套设计里常见的是 200mA 和 2A 两档。200mA 档用 1Ω 采样电阻,满量程压降 200mV;2A 档用 0.1Ω 采样电阻,满量程压降也是 200mV。这个压降信号可以直接进 ADC 的 200mV 档,也可以经过运放放大后再进 ADC,推荐后者,因为信号幅值更大,抗干扰更好。
Proteus 里常用的运放模型是 LM358 或 OP07,接成同相放大或仪表放大结构。我在这个工程里一般把采样电阻两端电压先做差,再放大 10 倍,送到 ADC 通道。注意运放的供电必须满足输入输出电压范围,LM358 在 3.3V 单电源下输出摆幅有限,建议用 5V 给运放供电,ADC 输入再通过电阻限幅到 3.3V 以内。
电流档切换和电压档共用同一套 GPIO 控制逻辑。切换采样电阻时,一定要保证模拟开关先断开再闭合,避免两个采样电阻并联导致测量分流。真机上这个过程会产生瞬态过冲,仿真里虽然看不到,但代码里我仍然保留了几毫秒的切换延时。
3.3 显示、供电与仿真里的虚拟仪器配套
显示部分用的 LCD1602,接 4 线数据模式,节省 GPIO。RS、RW、EN 分别接三个普通引脚,数据线接 PB8~PB11。背光直接接 5V 电源,Proteus 里用高电平点亮。按键这边安排了三到四个:一个 MODE 键切换电压/电流模式,两个量程加减键,外加一个自动量程开关。
供电结构上,STM32F103 用 3.3V,LCD1602 和运放用 5V。Proteus 里可以用两个电压源分别供电,注意共地。虚拟仪器方面,电压档测试用 DC Voltmeter 或信号发生器,电流档测试直接串一个 DC Ammeter,比较方便的是在信号源回路里放一个可变电阻,手动调整电流大小。工程里还保留了一个虚拟示波器接口,可以观察 ADC 输入引脚的波形是否干净。
4. 核心代码实现:ADC采样、量程判断与数值显示
4.1 ADC 初始化与 DMA 搬运:把采集从 CPU 中解放出来
模拟前端把电压降到了 ADC 可测范围,接下来就是 STM32 的 ADC 模块干活。我在这套工程里用的方式是定时器触发 ADC 采样,DMA 自动搬运,主循环只负责取值和计算。
void ADC_InitConfig(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_InitStructure.TIM_Period = 999; TIM_InitStructure.TIM_Prescaler = 71; TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); TIM_Cmd(TIM2, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; 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_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)) ; ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)) ; }这段初始化的关键在两点。第一是 ADC 的触发源选为 TIM2 的 Update 事件,TIM2 预分频 71、周期 999,在 72MHz 主频下差不多每 1ms 触发一次采样,这个节奏适合万用表这种需要稳定刷新率的场景。第二是 DMA 使能在校准之前完成,避免采样值搬运通道还没准备好。ADC 采样时间选 239.5 个周期,是为了让内部采样电容充分充电,尤其在高阻抗分压网络中,这个参数直接影响读数稳定度。
DMA 的配置代码一般放在 ADC 初始化的后面,循环模式,单通道,外设地址指向 ADC1 数据寄存器,内存地址指向一个 16 位数组。连续采样平均值能明显减少杂波,实测跳字也少很多。
4.2 量程自动切换与软件滤波:从粗读到精读
量程切换的逻辑是这个工程最值得看的部分。自动量程的思路是先读当前档位的 ADC 值,如果接近满量程,就升一档;如果远低于满量程的十分之一,就降一档。为了避免频繁振荡,每次切换后必须等采样稳定再判断。
typedef enum { RANGE_200MV = 0, RANGE_2V, RANGE_20V, RANGE_200V } MeterRange; MeterRange currentRange = RANGE_2V; void AutoRangeUpdate(uint16_t adcValue) { if (adcValue > 3800) { if (currentRange < RANGE_200V) { currentRange++; SwitchRelay(currentRange); DelayMs(50); } } else if (adcValue < 400) { if (currentRange > RANGE_200MV) { currentRange--; SwitchRelay(currentRange); DelayMs(50); } } } float GetDisplayValue(void) { uint16_t adcValue = GetFilteredADCValue(); float result = 0.0f; switch (currentRange) { case RANGE_200MV: result = adcValue * 200.0f / 4095.0f; break; case RANGE_2V: result = adcValue * 2.0f / 4095.0f; break; case RANGE_20V: result = adcValue * 20.0f / 4095.0f; break; case RANGE_200V: result = adcValue * 200.0f / 4095.0f; break; default: result = 0.0f; break; } return result; }阈值选 3800 和 400 是考虑到 12 位 ADC 在满量程 4095 的 95% 和 10% 处切换。这样做的原因为了大电压进来时尽快升档,小电压进来时不至于立刻降档,留出一定的滞回区间。切换量程后加 50ms 延时,是等继电器模型和分压网络完成过渡,如果省掉这一步,很容易看到显示值先冲到满量程又跳回正常值的情况。
软件滤波部分我用的是滑动平均,取最近 16 次采样的平均值作为当前值。ADC 的原始抖动在万用表这种应用里是不可避免的,尤其是最小量程档,一点噪声干扰都会反映在最后一位数字上。滑动平均会带来约 16ms 的延迟,在静态测量场景下完全可接受。
4.3 LCD1602 显示驱动:小数点和单位怎么算
显示是用户直接感知的部分,数值格式必须跟着档位走。200mV 档要显示三位小数,2V 档也是三位小数,20V 档一位小数,200V 档也是三位小数比较合适。单位符号跟随档位切换,V 和 A 需要在 MODE 切换时更新。
void Meter_DisplayValue(float value, MeterRange range, uint8_t mode) { char buffer[16]; uint8_t dotPos = 3; switch (range) { case RANGE_200MV: dotPos = 3; break; case RANGE_2V: dotPos = 3; break; case RANGE_20V: dotPos = 1; break; case RANGE_200V: dotPos = 3; break; default: dotPos = 3; break; } sprintf(buffer, "%c%c%.*f", mode ? 'A' : 'V', ' ', dotPos, value); LCD1602_SetCursor(0, 1); LCD1602_WriteString(buffer); if (mode) { LCD1602_WriteString("A "); } else { LCD1602_WriteString("V "); } }sprintf 在 STM32 上要留意浮点支持。Keil 里默认的 MicroLib 是支持浮点打印的,但如果用的是完整 C 库,需要检查固件工程有没有开启相应选项。我在实际调试时遇到过 sprintf 浮点打出来的全是空格的情况,后来确认是库配置问题,换成 MicroLib 就好了。
显示刷新的频率不需要太快,50ms 一刷就够。LCD1602 本身写指令和数据都需要时序脉冲,EN 引脚高电平保持时间不能太短,Proteus 里如果出现花屏,多半是延时不够。常见做法是每个操作之间加 1~2us 的延时,写指令之前先检测忙标志位。
4.4 按键与档位锁定:手动量程的逻辑实现
自动量程不是万用表的全部,实际使用中经常要锁定到某个固定档位,防止测量点电压抖动导致频繁换挡。按键扫描部分我做了一个简单的状态机:MODE 键切换电压和电流模式,UP 和 DOWN 键直接加减档位,挡量程为手动状态时不再执行自动换挡。
按键去抖仍然用延时消抖法,按下后等 10ms 再读一次引脚电平,确认有效。Proteus 仿真里按键模型和真实按键行为基本一致,去抖逻辑可以完整复现。要注意的是,在仿真中如果按键扫描放在主循环里,而主循环里又有 sprintf 这类耗时操作,按键响应会变迟钝。解决方法是按键扫描放在定时器中断里,或者把主循环的执行时间控制在一定范围内。
手动档位时,显示的档位要和实际继电器状态保持一致。我在这里定义了一个全局状态标志,任何切换都走同一个 SwitchRelay 函数,保证 GPIO 输出、继电器状态、显示单位三者同步。
5. 仿真避坑指南:五个容易翻车的典型问题
5.1 现象:ADC 读数始终是 4095,调分压电阻也没有变化
原因:ADC 输入引脚配置不正确,或者 PA0 被复用成了别的功能。另一个常见原因是 ADC 的校准没有完成就开始了转换,读数总线冲突。解决:检查 GPIOA 时钟和 ADC1 时钟是否都使能了,再看 GPIO 配置是不是GPIO_Mode_AN。如果校准代码里死等标志位,先用示波器确认 ADC 引脚电压确实在范围内,再查校准流程。
5.2 现象:LCD1602 花屏,字符乱跳,尤其是刚上电的时候
原因:Proteus 的 LCD 模型对初始化时序比较敏感,STM32 GPIO 翻转速度远快于普通 51 单片机,初始化指令之间没加足够延时,LCD 内部状态机没跟上。解决:在 LCD 初始化函数的开头加 100ms 延时,每次写指令后加 5ms 延时。如果还花屏,试用 8 线模式对比,确认是时序问题还是接线问题。
5.3 现象:自动量程在边界来回切换,显示数值反复跳变
原因:量程切换的阈值太窄,且切换后没有等 ADC 采样稳定就继续判断。分压网络切换的瞬间,ADC 输入端会有一个过渡电压,被采集到之后就触发了反向切换。解决:加大滞回区间,比如升档阈值 3800、降档阈值 400,不要用同一个阈值。切换操作后强制延时 100ms,并且清空滑动平均缓冲区,防止旧数据污染新档位的读数。
5.4 现象:电流档读数明显偏小,而且随着电流增大偏差越来越大
原因:采样电阻选得太大或者分压网络里串入了测量回路的电阻。Proteus 仿真里虽然导线电阻是零,但如果用了一个可变电阻模拟负载,没有把电流表的负载也考虑进去,就会产生分流。解决:观察采样电阻两端的压降是否和理论一致,建议把采样电阻换成更小的值,或者改用 4 线开尔文接法采样电阻两端电压。
5.5 现象:仿真运行非常慢,LED 刷屏一样闪,单步调试时程序卡在中断里
原因:定时器触发 ADC 采样的频率太高,DMA 中断请求太频繁,Proteus 在每次仿真步进时都要处理大量外设事件。解决:把 TIM2 的触发频率降到 500Hz 左右,也就是把预分频或周期调大一倍。人眼对万用表数值刷新率的需求本来就不高,200ms 刷新一次也完全够用。
6. 二次开发思路:从电压表到多功能测量平台
6.1 增加电阻测量功能
电阻测量的原理其实不复杂:给目标电阻串一个已知电阻,再接一个恒压源,ADC 采目标电阻两端的电压,代入分压公式反推电阻值。关键在于激励电流要能自动切换档位,比如测量小电阻时用大电流,测量大电阻时用小电流。这样只需要在现有分压网络前面加一个激励电压切换电路,代码里在 MODE 循环中新增一个 R 档。
6.2 用串口把测量结果发到虚拟终端
现有工程已经跑通了数据链路,加 USART 输出非常顺手。在电压、电流、电阻三种模式各加一个标志位,每次刷新记录时把数值和单位拼成一行字符串,通过 USART1 发送。Proteus 里挂一个 Virtual Terminal 就能实时看到数据流。这样还能让整套仿真变成"采集-处理-上报"的完整示范,后续接上位机,数据已经备好。
6.3 验证方法:仿真和数据手册交叉标定
做完改动之后,验证流程应该是:给定一个标准输入信号,查看 ADC 原始值和 LCD 显示值,计算误差。不同档位要分别测满量程、半量程、接近空量程三种情况。误差如果超过 2%,先查分压电阻参数,再查标定公式中的比例因子。手动档和自动档要分别过一遍,重点观察切换瞬间是否有跳变或者卡顿。每次改完代码,都重新编译生成 HEX 文件,再加载到 Proteus 里做回归测试,这样能避免今天改好了电压档、明天发现电流档回不来的尴尬。
这套工程最值得学的其实不是某一个函数,而是它把"模拟前端+数字采集+人机交互"切成三层的结构。从那以后,我每次拿到新的仿真工程,都会强制自己先画一遍信号链路再动手改代码,确认每一级接口的输入输出范围,再去做二次开发。仿真里跑不通的,拿真板子大概率也费劲。希望这篇拆解能帮你把 Proteus + STM32 数字万用表这套工程看懂、跑通,再改成自己想要的样子。
本文还有配套的精品资源,点击获取