☰
STM32F103数字万用表Proteus仿真:ADC采样与量程切换实战
2026/10/20 14:56:31 网站建设 项目流程

简介:这份资源是面向嵌入式初学者与电子设计爱好者的Proteus仿真工程,围绕基于STM32F103的数字万用表展开,帮助读者在虚拟环境中完成从电路搭建到固件调试的完整实践。压缩包共1020个文件,以558个C源文件与243个头文件为核心,配合51个汇编文件、28个链接脚本及23个目标文件,另有13个Proteus工程文件、Keil工程配置与少量说明文档,整体约14.29MB,结构完整便于直接打开仿真。内容覆盖STM32F103的ADC采集、DAC参考电压、LCD显示驱动、测量算法与按键交互等关键环节,并涉及HAL/LL库与Keil uVision的配合使用。已有3088人学习下载,适合作为课程设计或自学练手项目,读者可借此理解数字万用表的测量原理、外设配置流程与Proteus联调思路,快速积累嵌入式软硬件协同开发经验。

1. 从一块“测不准”的板子说起:这个仿真项目到底能干什么

去年帮一个学弟看毕设,他焊了一块万用表板子,上电之后液晶屏跳数跳得像心电图,测一节干电池能从 0.8V 飘到 1.9V。折腾了三天,最后发现是 ADC 参考电压的走线被模拟地干扰了。这种“玄学”问题在实物上排查成本极高,但如果先在 Proteus 里把 STM32F103 和外围电路跑一遍,很多坑在电脑上就能提前踩完。这份资源就是干这个的:一个基于 STM32F103 的数字万用表仿真工程,包含 Proteus 原理图、Keil 工程源码和可运行的 hex 文件,能测直流电压、电阻和通断,适合正在做嵌入式课程设计、想搞懂 ADC 采样与量程切换、又不想一上来就烧板子的开发者。

它不是一个“玩具级”的闪烁 LED 工程。整个系统围绕 STM32F103C8T6 的最小系统展开,用 ADC1 的通道采集分压后的电压信号,通过继电器或模拟开关切换量程,LCD1602 负责显示,蜂鸣器做通断提示。你拿到手之后,既可以直接在 Proteus 里点运行看波形和读数,也可以把源码拖进 Keil 里改量程、改显示格式、加串口输出。对于想理解“模拟信号怎么变成屏幕上数字”的人来说,这个闭环足够小,小到你能在一周内吃透每一行代码;又足够完整,完整到能写进简历里的项目经历。

2. 系统架构与 Proteus 原理图:信号从表笔到屏幕的完整链路

2.1 整体框图与信号流

先把这个万用表的信号链路理清楚,不然后面看代码会迷路。整个系统可以拆成四段:输入保护与分压、量程切换、ADC 采样、显示与交互。

输入部分从两根表笔开始。红表笔接被测点,黑表笔接地。因为 STM32 的 ADC 引脚耐压只有 3.3V,而万用表可能要测 10V 甚至更高,所以前面必须有一个分压网络。这个工程里用的是电阻分压,把输入电压按比例缩小到 ADC 能接受的范围。分压比决定了量程,比如 1:1 对应 3.3V 量程,1:10 对应 33V 量程。

量程切换用继电器或者 CD4051 这类模拟开关来实现。STM32 输出 GPIO 控制开关的通断,把不同的分压电阻接入电路。代码里根据当前量程选择对应的分压比,最后反算回真实电压。

ADC 采样是核心。STM32F103 的 ADC1 是 12 位逐次逼近型,参考电压取 VDDA(通常 3.3V)。采样值 0~4095 对应 0~3.3V。代码里用 DMA 或者中断读取,然后做多次采样取平均,滤掉一部分噪声。

显示用 LCD1602,4 位或 8 位并口模式都行。这个工程用的是 4 位模式,省 IO 口。蜂鸣器接在另一个 GPIO 上,通断测试时响。

提示:Proteus 里 ADC 的参考电压默认跟 VDD 走,如果你在仿真里发现读数整体偏大或偏小,先检查电源属性里的电压设置,别急着改代码。

2.2 Proteus 原理图里的关键元件

打开 Proteus 工程,你会看到几个核心元件。STM32F103C8 是主控,晶振电路用 8MHz 外部晶振加两个 22pF 电容,复位电路是 10k 上拉加 100nF 电容。这些跟实物最小系统一样,没什么特别的。

模拟部分要注意的是分压电阻的取值。假设你要测 0~30V,分压比 1:10,那么上电阻取 90k,下电阻取 10k。这样输入 30V 时,下电阻上的电压是 3V,ADC 能安全采集。但 Proteus 里理想电阻不会发热,实物上 90k 电阻在 30V 下功耗是 10mW,用 0805 封装就够了。

量程切换部分,这个工程用了两个继电器来切换粗调和细调。继电器线圈由 S8050 三极管驱动,基极接 STM32 的 GPIO,集电极接继电器线圈,发射极接地。继电器触点接分压电阻。仿真里继电器动作有延迟,代码里切换量程后要加一小段延时再采样,否则会读到切换瞬间的抖动值。

LCD1602 的接线要注意 RW 引脚接地还是接 GPIO。这个工程里 RW 接地,只写不读,省一个 IO。数据线 D4~D7 接 STM32 的 PB4~PB7,RS 接 PB8,EN 接 PB9。背光通过一个限流电阻接 5V。

蜂鸣器用的是一个有源蜂鸣器,接在 PA8 上,高电平响。通断测试时,如果测到的电阻小于某个阈值(比如 50 欧姆),就拉高 PA8。

2.3 从原理图到代码的映射关系

看原理图的时候,最好拿张纸把每个引脚对应的功能记下来。比如:

STM32 引脚功能连接对象
PA0ADC_IN0分压网络输出
PA1GPIO 输出量程切换继电器 1
PA2GPIO 输出量程切换继电器 2
PA8GPIO 输出蜂鸣器
PB4~PB7GPIO 输出LCD1602 数据线 D4~D7
PB8GPIO 输出LCD1602 RS
PB9GPIO 输出LCD1602 EN
PA9/PA10USART1预留串口调试

这张表是你后面改代码的“地图”。比如你想加一个自动量程功能,就要在 PA1、PA2 的控制逻辑里做文章;想换 OLED 屏,就要把 PB4~PB9 的驱动换掉。

3. 核心代码拆解:ADC 采样、量程切换与 LCD 显示

3.1 ADC 初始化与多次采样滤波

先看 ADC 的配置。这个工程用的是规则通道单次转换加软件触发,没有用 DMA,因为采样率要求不高,一次转换几微秒,轮询就够了。

// adc.c #include "stm32f10x.h" void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; // 使能 GPIOA 和 ADC1 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); // ADC 时钟 = 72MHz / 6 = 12MHz // PA0 配置为模拟输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); // ADC1 配置 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,采样时间 55.5 个周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); // ADC 校准,这一步不能省 ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); } // 读取一次 ADC 值,做 16 次平均 u16 ADC_ReadAverage(void) { u32 sum = 0; u8 i; for (i = 0; i < 16; i++) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); sum += ADC_GetConversionValue(ADC1); } return (u16)(sum / 16); }

这段代码有几个点值得说。第一,ADC 时钟不能超过 14MHz,这里用 PCLK2 六分频得到 12MHz,是安全的。第二,校准步骤很多人会漏掉,不校准的话零点漂移可能达到几十个 LSB。第三,采样时间选了 55.5 个周期,因为分压电阻的等效输出阻抗大概在 10k 左右,采样时间太短的话采样电容充不满,读数会偏小。

16 次平均是一种最简单的数字滤波。如果你发现读数还是跳,可以改成去掉最大值和最小值再平均,或者用一阶低通滤波。但在这个仿真工程里,16 次平均已经能把波动压到 ±2 个 LSB 以内。

3.2 量程切换逻辑与电压反算

量程切换的核心是一个状态机。代码里定义了两个量程档位:低压档和高压档。低压档分压比 1:1,测 0~3.3V;高压档分压比 1:10,测 0~33V。

// range.c #include "stm32f10x.h" #include "range.h" #define RELAY1_PIN GPIO_Pin_1 #define RELAY2_PIN GPIO_Pin_2 static u8 current_range = RANGE_LOW; // 默认低压档 void Range_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = RELAY1_PIN | RELAY2_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); Range_Set(RANGE_LOW); } void Range_Set(u8 range) { current_range = range; if (range == RANGE_LOW) { GPIO_ResetBits(GPIOA, RELAY1_PIN); GPIO_SetBits(GPIOA, RELAY2_PIN); } else { GPIO_SetBits(GPIOA, RELAY1_PIN); GPIO_ResetBits(GPIOA, RELAY2_PIN); } // 继电器动作需要时间,延时 10ms 再采样 for (volatile u32 i = 0; i < 720000; i++); } // 根据 ADC 值和当前量程反算真实电压,单位 mV u32 Range_GetVoltage_mV(u16 adc_value) { u32 voltage_mV; // ADC 值转引脚电压:3.3V / 4096 * adc_value,再转 mV voltage_mV = (u32)adc_value * 3300 / 4096; if (current_range == RANGE_HIGH) { voltage_mV *= 10; // 高压档分压比 1:10 } return voltage_mV; }

这里有个细节:继电器切换后加了一个空循环延时。在 Proteus 里,这个延时可以短一点,但实物上继电器吸合时间通常在 5~10ms,所以代码里留了 10ms 左右的余量。如果你用模拟开关代替继电器,这个延时可以去掉。

电压反算的公式很简单,但要注意整数除法的精度。adc_value * 3300 / 4096这个顺序先乘后除,能保留更多有效位。如果写成adc_value / 4096 * 3300,整数除法会把小数部分全部丢掉,误差很大。

3.3 LCD1602 驱动与显示格式

LCD1602 用 4 位模式驱动,初始化序列不能错。这个工程里的驱动代码是标准写法,但有几个地方容易翻车。

// lcd1602.c #include "lcd1602.h" #include "delay.h" #define LCD_RS GPIO_Pin_8 #define LCD_EN GPIO_Pin_9 #define LCD_D4 GPIO_Pin_4 #define LCD_D5 GPIO_Pin_5 #define LCD_D6 GPIO_Pin_6 #define LCD_D7 GPIO_Pin_7 #define LCD_PORT GPIOB static void LCD_WriteNibble(u8 nibble, u8 rs) { if (rs) GPIO_SetBits(LCD_PORT, LCD_RS); else GPIO_ResetBits(LCD_PORT, LCD_RS); // 先把 D4~D7 清零,再写入 GPIO_ResetBits(LCD_PORT, LCD_D4 | LCD_D5 | LCD_D6 | LCD_D7); if (nibble & 0x01) GPIO_SetBits(LCD_PORT, LCD_D4); if (nibble & 0x02) GPIO_SetBits(LCD_PORT, LCD_D5); if (nibble & 0x04) GPIO_SetBits(LCD_PORT, LCD_D6); if (nibble & 0x08) GPIO_SetBits(LCD_PORT, LCD_D7); // 产生使能脉冲 GPIO_SetBits(LCD_PORT, LCD_EN); Delay_us(1); GPIO_ResetBits(LCD_PORT, LCD_EN); Delay_us(50); } void LCD_WriteCmd(u8 cmd) { LCD_WriteNibble(cmd >> 4, 0); LCD_WriteNibble(cmd & 0x0F, 0); } void LCD_WriteData(u8 data) { LCD_WriteNibble(data >> 4, 1); LCD_WriteNibble(data & 0x0F, 1); } void LCD_Init(void) { // GPIO 初始化省略 Delay_ms(15); LCD_WriteNibble(0x03, 0); Delay_ms(5); LCD_WriteNibble(0x03, 0); Delay_ms(1); LCD_WriteNibble(0x03, 0); LCD_WriteNibble(0x02, 0); // 切换到 4 位模式 LCD_WriteCmd(0x28); // 4 位,2 行,5x7 点阵 LCD_WriteCmd(0x0C); // 开显示,关光标 LCD_WriteCmd(0x06); // 地址自动加 1 LCD_WriteCmd(0x01); // 清屏 Delay_ms(2); } void LCD_ShowVoltage(u32 mV) { char buf[16]; // 格式化成 "12.34V" 或 "1234mV" if (mV >= 1000) { sprintf(buf, "%d.%02dV", mV / 1000, (mV % 1000) / 10); } else { sprintf(buf, "%dmV", mV); } LCD_WriteCmd(0x80); // 第一行首地址 LCD_ShowString(buf); }

LCD 初始化里那个0x03, 0x03, 0x03, 0x02的序列是 HD44780 兼容芯片的固定套路,不能改。很多人只发一次 0x03 就切 4 位模式,结果 LCD 不亮或者显示乱码。另外,LCD_WriteNibble里先清零再置位,是为了避免上一次的数据残留影响本次写入。

显示格式上,sprintf在单片机里比较占空间,如果 Flash 不够用,可以自己写一个简单的整数转字符串函数。但 STM32F103C8T6 有 64KB Flash,放一个sprintf绰绰有余。

4. 避坑与排查:仿真跑不通时先看这几个地方

4.1 现象:Proteus 里 LCD 一片空白,连光标都没有

原因通常是初始化时序不对。Proteus 的 LCD 模型对时序比较敏感,Delay_ms(15)如果实际延时不够,LCD 内部复位没完成,后续命令全部无效。另外,检查LCD_EN的脉冲宽度,太窄的话 Proteus 可能采不到。

解决:把上电延时加到 50ms,LCD_EN的高电平时间用示波器看至少 1us。如果还不行,把 4 位初始化的0x03序列多发两次。

4.2 现象:ADC 读数一直是 0 或者 4095

先看 PA0 引脚上有没有电压。在 Proteus 里可以加一个电压探针,直接看分压网络输出。如果电压正常但 ADC 读 0,检查 ADC 时钟使能了没有,RCC_APB2PeriphClockCmd里有没有漏掉RCC_APB2Periph_ADC1。如果读 4095,通常是参考电压设错了,或者输入电压超过了 VDDA。

还有一个隐蔽的坑:Proteus 里 STM32 模型的 ADC 参考电压默认是 3.3V,但如果你在电源属性里把 VDD 改成了 5V,ADC 读数会跟着变。仿真里最好保持 3.3V。

4.3 现象:量程切换后读数不变

先确认继电器有没有动作。Proteus 里继电器线圈通电后,触点会有一个动画效果,能看到弹片吸合。如果继电器不动作,检查三极管基极电阻是不是太大,导致基极电流不够。S8050 的放大倍数按 100 算,继电器线圈电流 30mA 的话,基极电流至少要 0.3mA,基极电阻取 10k 时,3.3V 除以 10k 只有 0.33mA,勉强够用,但最好取 4.7k。

如果继电器动作了但读数不变,检查分压电阻有没有接对。高压档的分压电阻如果虚焊或者参数写错,反算出来的电压就会跟低压档一样。

4.4 现象:通断测试时蜂鸣器一直响

通断测试的逻辑是测电阻,但代码里如果只判断了 ADC 值小于某个阈值,而没有判断量程,就会出问题。比如在高压档下,即使表笔悬空,分压网络也会有一个固定的输出,可能刚好落在阈值以下。

解决:通断测试前先强制切到低压档,并且把阈值设得低一点,比如 ADC 值小于 50 才认为导通。另外,蜂鸣器响的时候最好在 LCD 上显示“短路”或“导通”,方便确认状态。

4.5 现象:Keil 编译报错 “undefined symbol ADC_Init”

这种错误通常是头文件没包含或者库文件没加。检查stm32f10x_adc.c有没有加到工程里,stm32f10x_conf.h里#define _ADC有没有打开。如果是用标准外设库,还要确认USE_STDPERIPH_DRIVER这个宏在 Keil 的 C/C++ 选项里定义了。

5. 二次开发与改造:从能跑到好用,我一般会动这几个地方

5.1 加自动量程

现在的量程切换是手动的,代码里写死了current_range。要改成自动量程,思路很简单:先切到高压档测一次,如果 ADC 值小于满量程的 10%,就切到低压档再测一次。这样小电压下能获得更高的分辨率。

u32 AutoRange_Measure(void) { u16 adc_val; u32 voltage_mV; Range_Set(RANGE_HIGH); Delay_ms(15); adc_val = ADC_ReadAverage(); // 如果高压档下读数小于满量程的 10%,说明信号很小,切低压档 if (adc_val < 409) { Range_Set(RANGE_LOW); Delay_ms(15); adc_val = ADC_ReadAverage(); } voltage_mV = Range_GetVoltage_mV(adc_val); return voltage_mV; }

这个逻辑在仿真里跑没问题,但实物上要注意:如果被测电压刚好在切换阈值附近,继电器会来回跳。加一个迟滞区间,比如小于 8% 才切低压档,大于 12% 才切回高压档。

5.2 把 LCD1602 换成 OLED

LCD1602 只能显示两行字符,信息量有限。换成 0.96 寸 OLED(SSD1306)之后,可以同时显示电压、量程、通断状态,还能画一个简单的条形图。驱动方式从并口变成 I2C,只需要两根线。

改造步骤:先把 PB4~PB9 的 GPIO 初始化删掉,换成 I2C 的 SCL 和 SDA 配置。然后把LCD_ShowVoltage换成 OLED 的显示函数。SSD1306 的驱动网上很多,找一个能用的移植过来就行。注意 OLED 的供电是 3.3V,别接 5V。

5.3 加串口输出和上位机

STM32F103 的 USART1 接在 PA9 和 PA10 上,这个工程里已经预留了。你可以在主循环里加一句printf,把电压值通过串口发到电脑上。Keil 里需要重定向fputc函数。

int fputc(int ch, FILE *f) { USART_SendData(USART1, (u8)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); return ch; }

然后在主循环里:

printf("Voltage: %d mV\r\n", voltage_mV);

上位机用串口助手或者自己写一个 Python 脚本,用pyserial读数据,再用matplotlib画实时曲线。这样你就能看到电压随时间的变化,比 LCD 上的数字直观得多。

5.4 校准与精度提升

仿真里的 ADC 是理想的,但实物上会有增益误差和偏移误差。校准的方法是:在低压档下,分别输入一个已知的精确电压(比如 1.000V 和 2.000V),记录 ADC 读数,然后算出一个线性方程y = kx + b,把 k 和 b 写进代码里做补偿。

// 校准参数,根据实测填写 #define CAL_K 1.0023f #define CAL_B -5.0f u32 Range_GetVoltage_mV_Calibrated(u16 adc_value) { float voltage_mV; voltage_mV = (float)adc_value * 3300.0f / 4096.0f; voltage_mV = voltage_mV * CAL_K + CAL_B; if (current_range == RANGE_HIGH) { voltage_mV *= 10.0f; } return (u32)voltage_mV; }

校准的时候要注意,分压电阻的精度直接影响高压档的准确度。用 1% 精度的金属膜电阻,比碳膜电阻靠谱得多。如果要求更高,可以用一个精密基准源(比如 TL431)来校准。

5.5 一个我踩过的坑

有一次我把量程切换的延时从 10ms 改成了 1ms,想着仿真里继电器动作快,结果实物上读数偶尔会跳到满量程。后来用示波器看,继电器触点在吸合瞬间有抖动,1ms 的时候刚好采到了抖动区间。从那以后我每次改量程切换的延时,都会在实物上用示波器确认触点稳定时间,再也不敢凭感觉写延时了。

希望这个仿真工程能帮你把数字万用表的原理跑通,少烧几块板子。

本文还有配套的精品资源,点击获取

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

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

立即咨询