简介:基于STM32的简易智能电表项目资源,面向嵌入式学习者和电力监测开发人员,演示如何用STM32实时采集电流电压,计算功率、功率因数,并累计用电量。资源包共440个文件、约199.59MB,主要包含C源码(stm32f10x_adc.c、stm32f10x_tim.c、lcd.c等)、H头文件、原理图与PCB文件(schdoc/pcbdoc)、PDF说明文档及编译中间文件,覆盖设计文档、硬件连线与工程代码。已有1368人学习/下载。压缩包内代码覆盖时钟与GPIO配置、ADC采样、定时器辅助积分计算、LCD显示等关键逻辑,并涉及功率因数相位处理与电能累积校准思路;原理图则直观呈现微控制器与电流/电压传感器、显示模块的接法。对于打算深入STM32开发或电力计量领域的读者,这是一份不错的实战案例,能帮助理解从模拟信号采集到数字计算与显示的完整流程,也可作为智能电表项目的设计起点。
1. 基于STM32的简易智能电表:从宿舍限电到设备计费都能用的计量方案
如果你只想给一个小型充电桩、宿舍床位或者工作室设备做用电统计,并不需要直接买一个成品智能电表。拿一块常见的 STM32F103 最小系统板,配合电流互感器、电阻分压和继电器,就能在几天内搭出一个能测电压、电流、功率,还能累计电量的计量设备。这个项目里最核心的问题不是“怎么点亮屏幕”,而是把220V市电安全地变换成单片机可以处理的模拟信号,再在固件里完成有效值计算、电能积分和误差修正。整套方案适合拿来练手,也适合作为智能插座、能耗监测节点的改造基础。下面直接按硬件链路、固件实现、烧录避坑和二次开发四个部分拆开讲。
2. 硬件设计拆解:从220V交流到ADC采集的完整链路
很多第一次接触电表项目的开发者,第一反应是“用一个电压互感器把220V变成3.3V以下”。方向对,但没有考虑ADC输入范围和直流偏置,结果样机一上电,采样值一直顶到4095。这一章的思路是先把每级信号幅度算清楚,再决定选型。
2.1 系统框图与器件选型:F103最小板 + 电流互感器 + 分压电阻
整个系统的信号流可以概括为:市电经过电流互感器和电阻分压后,变成两个幅值在0~3.3V以内的小信号,送入STM32的ADC1;单片机每20毫秒采集一组波形点,计算出电压有效值和电流有效值,再互相相乘得到有功功率;功率随时间积分得到电能;最后把数值显示在OLED上,并控制继电器开断负载。
器件选型上,我倾向于用一块STM32F103C8T6最小系统板,而不是直接画核心板。原因很简单:项目调试期经常需要插拔SWD、换个晶振或者飞线改采样电阻,最小板坏了就换,成本低且不容易把主控焊坏。电流采样用常规的穿心式电流互感器,规格可以按你的负载范围选择,常见的有5A/5mA和30A/30mA两种档位;电压采样不用买昂贵的电压传感器,用两个高阻值电阻分压即可;显示部分用0.96寸IIC接口OLED;控制负载用5V继电器,驱动级加三极管和续流二极管。
下面是这套方案里比较典型的选型表:
| 功能模块 | 选型建议 | 关键参数 |
|---|---|---|
| 主控 | STM32F103C8T6 | 72MHz主频,ADC 12bit,PA0/PA1等引脚可用 |
| 电流采样 | 穿心式电流互感器 30A/30mA | 次级接33Ω采样电阻,额定电流下输出约0.99V有效值 |
| 电压采样 | 2个120kΩ与1个1kΩ电阻分压 | 把220V有效值衰减到约1.3V以内的交流小信号 |
| 显示 | SSD1306 0.96寸OLED | IIC地址多为0x3C或0x3D |
| 继电器驱动 | S8050三极管 + 5V继电器 | 线圈必须并联续流二极管 |
| 供电 | 220V转5V AC-DC模块 + AMS1117-3.3 | 后级用线性稳压给MCU提供3.3V |
2.2 电流和电压采样电路:先把信号幅度算到ADC安全区
电压采样看起来像是“两个电阻把220V分下来”,但交流信号是正负变化的,直接接到单片机上会出现负电压,即使接在ADC引脚内部也会对芯片产生损坏。我这里采用的办法是:先用两个120kΩ电阻串联作为分压上臂,再用一个1kΩ电阻作为下臂。市电电压有效值220V时,峰值约311V,分压后峰值约为1.29V。这个信号仍然有正负,所以要在ADC输入脚叠加一个1.65V直流的偏置电压,把波形整体抬到约0.36V到2.94V的范围内。
偏置电路实现起来也不复杂:用两个10kΩ电阻把3.3V分压成1.65V,接到运算放大器同相输入端;运放输出端再接一个跟随器,推动ADC输入。运放我习惯选通用型轨到轨单电源运放,低成本且能接近0V输出。在ADC输入脚到地之间加一个0.1μF电容,起到高频滤波作用,这也能明显减小ADC读数抖动。
电流互感器部分相对简单。以30A/30mA规格为例,一次侧穿一根负载线,二次侧经过33Ω采样电阻接地。当一次侧流过额定30A电流时,二次侧输出30mA,采样电阻两端电压幅值约为0.99V有效值。同样,这个交流电压也要先经过偏置电路抬升到1.65V上下,再送入ADC的另一路通道。这里有一个容易被忽略的细节:直流通路不能开路,如果采样电阻悬空,二次侧会产生很高的感应电压,容易击穿后级运放。
我一般会在电压通道和电流通道各接一个运算放大器做成电压跟随器,而不是直接并联电阻到MCU引脚。这样做的目的是降低ADC输入阻抗变化对采样电阻分压比的影响,避免因为单片机引脚等效电容而让波形发生相位偏移。
2.3 电源、继电器和显示:把3.3V控制域和5V外设隔离清楚
整个系统有两种电压域:MCU和外设的3.3V,继电器模块需要5V。我的建议是AC-DC模块输出5V后,先给继电器供电,再经过AMS1117-3.3给MCU供电。这样做的好处是,三极管驱动继电器时即使瞬间电流波动较大,也不会直接影响到MCU的供电。
继电器驱动电路建议给MCU的GPIO加一个下拉电阻,防止上电瞬间GPIO输出高电平导致继电器误动作。我在调试阶段犯过这个问题:程序刚下载进去,GPIO还没初始化,继电器已经吸合了。后续改进是在GPIO和基极之间串一个1kΩ电阻,再在基极并一个10kΩ下拉电阻,让默认状态确定为低电平。
显示部分使用IIC接口的OLED屏,占用PA9和PA10以外的两个引脚即可,通常用PB6和PB7的I2C1外设。OLED屏只需要VCC、GND、SCL、SDA四根线,非常适合在飞线阶段使用。按键可以只做两个:一个负责切换显示页面,一个负责清零累计电能。按键部分不需要额外芯片,用GPIO加上拉输入模式就行。
3. 固件代码模块详解:从ADC采集到电能积分的实现思路
硬件搭好以后,真正决定电表准不准的,是固件里的采样策略和计算算法。这个项目的固件建议直接使用STM32CubeMX生成初始化工程,再在应用层叠加计算逻辑。下面我把最核心的代码拆成三个模块讲。
3.1 CubeMX初始化:ADC通道、定时器和串口的配置
CubeMX里需要做的配置比较固定,我列一下最关键的几项:首先把时钟配置成外部8MHz晶振倍频到72MHz;ADC1开启两个通道,PA0作为电压采样输入,PA1作为电流采样输入;采样时间建议选239.5个周期,这样能通过采样电容把前级阻抗带来的误差压到较低水平;USART1打开,用于串口调试和协议输出;I2C1打开,用于与OLED屏通信。
ADC初始化代码一般长这样:
void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); }这段代码把ADC1配置为单次转换模式,每次启动后只采集一个通道。这样配置的好处是切换通道时不会出现连续DMA采集带来的缓冲区覆盖问题,代码逻辑清晰,适合电表这类多通道轮询场景。缺点是需要手动切换通道,采样速度不够高,但对于50Hz交流采样来说已经够用。如果后续你打算做更高精度的计量,再改成双通道DMA循环采集也不迟。
实际读取一个通道数据时,我用一个通用函数封装:
uint16_t adc_read_channel(uint32_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = channel; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 5) != HAL_OK) { HAL_ADC_Stop(&hadc1); return 0x0000; } uint16_t value = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return value; }这组函数的参数含义很直接:channel选择ADC通道,例如ADC_CHANNEL_0对应PA0电压采样,ADC_CHANNEL_1对应PA1电流采样。PollForConversion里的5是超时时间,单位是毫秒,如果转换没完成就返回错误。读取时需要注意:配置通道后先启动ADC,转换完成后立即停止,避免下一次配置通道时上一次转换还没结束而产生脏数据。
3.2 电量计算:RMS有效值、功率和电能累加
电表不能直接拿瞬时值当成有效值。交流信号是正弦波,如果只是简单算平均值,对于纯正弦波会得到0,毫无意义。所以固件里必须做均方根计算,也就是对一个完整周期内所有采样点先平方,再求平均,最后开根号。这也是这个项目里最核心的算法。
下面的函数实现了RMS计算:
float calc_rms(uint16_t *adc_buf, uint32_t sample_count) { float sum_square = 0.0f; float voltage, voltage_ac; for (uint32_t i = 0; i < sample_count; i++) { voltage = (float)adc_buf[i] * 3.3f / 4095.0f; voltage_ac = voltage - 1.65f; sum_square += voltage_ac * voltage_ac; } return sqrtf(sum_square / sample_count); }这段代码的关键在于先将ADC原始值换算成实际电压值,然后减掉1.65V直流偏置,最后对交流分量做平方累加。3.3f / 4095.0f是ADC参考电压与12位分辨率之间的换算系数。采样点数越多,RMS结果越接近真实值。我一般每个周期采128个点,也就是20毫秒内用ADC连续转换128次,然后对这128个点求RMS。
有了电压有效值和电流有效值后,有功功率按近似公式计算:
float calc_power(float voltage_rms, float current_rms) { return voltage_rms * current_rms; }这个公式假设负载是纯阻性,电流和电压相位一致。实际上电机、开关电源这类负载会有明显相位差,会看到功率计算值偏低。这里我留了一个补偿入口:后续通过电流采样点序列整体移位,相当于对相位差做提前量校正。你可以在代码里加一个PHASE_SHIFT常量,把电流数组整体偏移几个采样点,再和电压数组对应相乘。
电能累加用定时器实现。我让定时器每秒钟产生一次中断,在中断回调里读取当前功率,然后累加:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM7) { float power_w = calc_current_power(); // 每秒积一次,转化成kWh需要除以3600和1000 g_meter.energy_kwh += power_w / 3600.0f / 1000.0f; } }电能累加的公式是:energy_kwh += power_w / 3600000.0f。因为功率单位是瓦,时间单位是秒,1瓦秒等于1焦耳,1千瓦时等于3600000焦耳。如果你把积分周期改成100毫秒,分母也要跟着变,改成power_w * 0.1f / 3600000.0f。这个细节是电量越走越快或越走越慢最常见的原因,后面避坑章节会专门提到。
3.3 显示、按键和串口协议:把计量结果变成可交互的页面
应用层不需要太复杂。OLED屏分四个区域:第一行显示电压,第二行显示电流,第三行显示功率,第四行显示累计电量。按键按下时切换显示模式,例如显示本小时的用电量、当天的用电量等。在STM32中,显示字符串的代码建议用snprintf格式化:
void lcd_update_page(void) { char line1[16]; char line2[16]; snprintf(line1, sizeof(line1), "U:%5.1fV I:%5.3fA", g_meter.voltage_rms, g_meter.current_rms); snprintf(line2, sizeof(line2), "P:%5.1fW E:%.3fkWh", g_meter.power_w, g_meter.energy_kwh); OLED_ShowString(0, 0, line1); OLED_ShowString(0, 2, line2); OLED_Refresh(); }这里的%.1f表示保留一位小数,%.3f表示保留三位小数,显示电流时保留小数点后三位,可以看清小电流设备的工作状态。OLED刷新频率不用太高,每秒刷新两到三次就足够,频繁刷新反而容易让人感觉闪烁。
串口协议我采用的是最简单的ASCII帧。外部设备比如上位机发送一帧AT+METER?\r\n,单片机解析到这条指令后回复一组文本:
sprintf(buf, "V=220.1,I=0.520,P=114.5,E=12.345\r\n");这种方式很适合后期接ESP8266模块,把数据转发到云平台或者局域网监控系统。因为你只需要在中断里判断帧头和结尾,不需要解析复杂二进制协议,调试起来也直观。
4. 下载烧录与调试避坑:从BOOT配置到标定校准的五个常见问题
这一章把电表项目最常见的五类翻车现场集中整理出来。每条都按“现象、原因、解决”三个步骤来描述,方便你排查时对照。
4.1 烧录不进去:先查BOOT引脚和SWD接线
现象是程序第一次调试时,Keil里点击下载按钮后提示找不到芯片,或者连接超时。很多人第一反应是换个ST-Link驱动,实际上多半是BOOT引脚状态不对。STM32F103有BOOT0和BOOT1两个引脚,BOOT0接地可以正常使用SWD下载,如果BOOT0被悬空或者拉高,芯片会进入ISP模式,此时SWD调试口可能需要重新上电才能识别。
解决办法是先检查四根线:SWDIO、SWCLK、GND、3.3V,确认没有接触不良。然后确认BOOT0通过一个10kΩ电阻接地。如果还是没有连接,把BOOT0跳到1,接上串口模块的PA9和PA10,用串口ISP工具烧录一个测试固件,再跳回0继续调试。虽然麻烦,但这是排除硬件问题最快的一个路线。
另外要注意,STM32最小系统板的3.3V和GND必须确认和ST-Link共地,否则SWD虽然有信号,但电平参考点不对,始终会出现连接不稳定。
4.2 ADC读数剧烈跳动:必须是足够长的采样窗口
现象是OLED上电压显示值不稳定,在217V到224V之间来回跳,而用万用表测量市电明明很稳定。原因有两层:第一种是采样点数太少,你只采了32个点,每个点之间的相位间隔不均匀,导致RMS计算结果含有较大噪声;第二种是采样窗口没有覆盖完整的50Hz周期,导致在波形不同位置重复采样时结果不一致。
解决方法是把采样点数提高到128或者256,并且确保总采样时间尽量接近20毫秒的整数倍。同时在ADC输入引脚到地之间接0.1μF电容,把高频噪声滤掉。如果你的主频和时间基准足够准确,建议在定时器溢出中断里触发一次DMA采集,这样能保证采样点均匀分布在一个完整周期内。
这组处理后,电压波动范围通常能控制在±1V以内。
4.3 空载电流不归零:偏置和零漂一定得标定
让我有点意外的一次经历是,接上电表但没接任何负载,电流居然显示0.23A。查到最后发现是运放输出偏置电压并不是精确的1.65V,实际可能只有1.58V或者1.72V,而这些偏差在RMS计算中无法被完全抵消。
解决办法是开机后做一个“零点采集”:先确保负载断开,连续采集1000个电流采样点,计算出一个背景RMS值zero_offset,之后每次真实计算的RMS都减去这个值。如果减完以后出现了负数,就强制为0。这个操作在代码里就是一行:
current_rms = current_rms > zero_offset ? current_rms - zero_offset : 0.0f;这个技巧在低电流场景下非常关键,否则你测电风扇这类几十瓦的小负载时,误差会被放大到不可接受的程度。
4.4 功率偏低的隐藏原因:电流互感器相位滞后
现象是在电表后面接一个纯电阻的加热器,用万用表量电压,用钳表量电流,算出来是2000W,电表只显示1700W。按普通误差来说,不该差这么多。排查后发现问题出在互感器本身:电流互感器二次侧输出电流与一次侧电流之间存在相位差,虽然电压和电流都是有效值,但乘法计算时如果没对准相位,乘积会偏低。
解决方式是给电流数组增加一个移位补偿。比如你已经采集了一组电流数据和一组电压数据,确定电流滞后电压几百微秒,就通过代码把电流数组整体向后移动若干个采样点。移位量可以在程序里用一个常量配置,然后在纯阻性负载下反复试几次。对于常用的微型电流互感器,这个角度通常在0.7度到2度之间。换算成128个点的采样数组,相位补偿大概就是移动一两个点。
以下代码示意了如何做循环移位:
void current_phase_compensate(uint16_t *adc_buf, uint16_t len, uint16_t shift) { uint16_t temp[256]; for (uint16_t i = 0; i < len - shift; i++) { temp[i] = adc_buf[i + shift]; } for (uint16_t i = 0; i < shift; i++) { temp[len - shift + i] = adc_buf[i]; } memcpy(adc_buf, temp, len * sizeof(uint16_t)); }这个移位操作会消耗一点CPU时间,但放在每次采样完成后执行一次,对整体性能影响不大。
4.5 电量累计误差大:标定系数应该这样校准
现象是电表本身数值稳定,但和家里的标准计费电能表对比,累计电量在一天后偏大或者偏小几个百分点。原因在于分压电阻、采样电阻和互感器变比都不是绝对精确的,你必须用标准仪器做一次标定。
最实用的标定方法是:给电表接一个确定功率的负载,比如一个功率相对稳定的白炽灯或者加热器,同时用标准功率计输出理论功率值。对比串口日志里的测量功率和真实功率,把真实功率除以测量功率,得到一个修正系数。然后把修正系数乘到电压或电流的RMS计算里,重新验证一遍。
我通常会在代码里把修正系数单独定义成宏,方便随时修改:
#define VOLTAGE_CAL 1.015f #define CURRENT_CAL 0.982f这两个数值分别来自电压通道和电流通道的校准。标定完成后,再把负载换成不同功率大小验证一次,确保修正系数在多个点都接近一致,才能确认误差来源于系统增益而非非线性。
5. 二次开发扩展:把电表升级成远程抄表节点或智能插座控制器
基础电表跑通以后,这个项目真正有价值的地方在于改造空间。我建议从三个方向选一个继续做:第一个是加远程通信,用ESP8266模块通过串口转发数据到云平台,把设备变成可远程查看的能耗监测节点。改造时注意ESP8266模组和STM32共用3.3V供电,但串口电平要确认一致,最好用带电平转换的模块,避免通信乱码。
第二个方向是改成超限断电控制。在定时器中断里判断功率值,如果检测到功率超过设定阈值且持续超过三秒,就拉低继电器控制引脚,断开负载,同时通过OLED显示告警状态。这里为了防抖,我一般设置一个over_power_count计数器,只有连续多次超过阈值才动作,避免电机启动瞬间误跳闸。
第三个方向是增加掉电存储。虽然看起来只是多写一两个函数,但实际体验差异很大。基础版本断电后电量清零,再次上电只能从0开始累计,完全没有电表该有的“累计”概念。可以引入外部EEPROM,比如IIC接口的AT24C02,在每次电能累加值变化超过0.01kWh时写入一次,同时把当前电压、继电器状态一起保存。
代码上,我习惯在掉电前把数据写入EEPROM。STM32F103可以用一个RTC闹钟或者外部电压检测引脚配合上电初始化来实现。如果不想加外部芯片,也可以用内部Flash的末尾几页存储,但需要自己处理擦写寿命和扇区对齐问题,简单项目建议直接用EEPROM。
改造时还有一个容易忽略的点:220V高压部分一定不要用手直接接触。调试时我习惯把AC-DC模块、继电器和采样电阻放在一个隔离的测试盒里,MCU板和传感器之间通过长线连接,方便在桌上拨弄程序而不用总是靠近市电。因为没有隔离变压器,示波器探头直接夹在电源线上也可能导致短路甚至损坏仪器,这部分需要格外留意。
从那以后,我每次给这种带市电的项目改代码,都会先用串口把原始ADC值打印出来,观察一个完整周期的波形是否稳定,确认无误后再让继电器动作。这次做简易智能电表的最大体会是:计量精度不是光靠硬件堆出来的,固件里的采样策略、相位补偿和标定系数才是决定它能不能被当作一个“电表”来用的关键。希望这篇拆解能帮你的项目少走一段弯路。
本文还有配套的精品资源,点击获取