
简介这份资源是面向嵌入式初学者与电子设计爱好者的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 或者中断读取然后做多次采样取平均滤掉一部分噪声。显示用 LCD16024 位或 8 位并口模式都行。这个工程用的是 4 位模式省 IO 口。蜂鸣器接在另一个 GPIO 上通断测试时响。提示Proteus 里 ADC 的参考电压默认跟 VDD 走如果你在仿真里发现读数整体偏大或偏小先检查电源属性里的电压设置别急着改代码。2.2 Proteus 原理图里的关键元件打开 Proteus 工程你会看到几个核心元件。STM32F103C8 是主控晶振电路用 8MHz 外部晶振加两个 22pF 电容复位电路是 10k 上拉加 100nF 电容。这些跟实物最小系统一样没什么特别的。模拟部分要注意的是分压电阻的取值。假设你要测 0~30V分压比 1:10那么上电阻取 90k下电阻取 10k。这样输入 30V 时下电阻上的电压是 3VADC 能安全采集。但 Proteus 里理想电阻不会发热实物上 90k 电阻在 30V 下功耗是 10mW用 0805 封装就够了。量程切换部分这个工程用了两个继电器来切换粗调和细调。继电器线圈由 S8050 三极管驱动基极接 STM32 的 GPIO集电极接继电器线圈发射极接地。继电器触点接分压电阻。仿真里继电器动作有延迟代码里切换量程后要加一小段延时再采样否则会读到切换瞬间的抖动值。LCD1602 的接线要注意 RW 引脚接地还是接 GPIO。这个工程里 RW 接地只写不读省一个 IO。数据线 D4~D7 接 STM32 的 PB4~PB7RS 接 PB8EN 接 PB9。背光通过一个限流电阻接 5V。蜂鸣器用的是一个有源蜂鸣器接在 PA8 上高电平响。通断测试时如果测到的电阻小于某个阈值比如 50 欧姆就拉高 PA8。2.3 从原理图到代码的映射关系看原理图的时候最好拿张纸把每个引脚对应的功能记下来。比如STM32 引脚功能连接对象PA0ADC_IN0分压网络输出PA1GPIO 输出量程切换继电器 1PA2GPIO 输出量程切换继电器 2PA8GPIO 输出蜂鸣器PB4~PB7GPIO 输出LCD1602 数据线 D4~D7PB8GPIO 输出LCD1602 RSPB9GPIO 输出LCD1602 ENPA9/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 可能采不到。解决把上电延时加到 50msLCD_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 改成了 5VADC 读数会跟着变。仿真里最好保持 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 换成 OLEDLCD1602 只能显示两行字符信息量有限。换成 0.96 寸 OLEDSSD1306之后可以同时显示电压、量程、通断状态还能画一个简单的条形图。驱动方式从并口变成 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 的时候刚好采到了抖动区间。从那以后我每次改量程切换的延时都会在实物上用示波器确认触点稳定时间再也不敢凭感觉写延时了。希望这个仿真工程能帮你把数字万用表的原理跑通少烧几块板子。本文还有配套的精品资源点击获取