STM32智能电表实战:从互感器信号调理到RMS有效值计算与WIFI远程监控
简介一套基于STM32F103的智能电表交流电压电流检测完整方案面向单片机学习者、嵌入式爱好者和电子类专业学生尤其适合用于毕业设计、课程设计或工程预研。系统利用TV1005M电压互感器和TA1005M电流互感器采集交流信号配合WiFi模块与手机APP实现电压、电流、功率、电量及计时时间的实时显示与查看同时具备功率超过200W自动断电及手动控制继电器功能。资源包含84个文件核心为C语言源程序36个.h、34个.c并附有原理图PDF、器件清单Excel、APK、hex固件、工程配置文件等压缩包仅952KB体量小巧便于下载与归档既利于阅读修改也方便直接烧录。目前已有607人学习下载。整套代码带详细备注器件清单一对一PCB文档完整可直接作为实际项目参考帮助读者理解互感器采样电路、电量积分算法和WiFi通信协议。1. 从互感器到手机屏一个能落地的交流电参数监测方案做嵌入式这几年我拆过不少工业电表和智能断路器发现它们内部的核心架构惊人地一致一颗MCU加上电压电流互感器再配合一个通信模块就构成了绝大多数用电监测设备的基本盘。这套基于STM32F103的智能电表方案也是同样的思路它用TV1005M电压互感器和TA1005M电流互感器分别采集交流电压和电流经信号调理后送入STM32内置ADC再通过WIFI模块把电压、电流、功率、电量实时推送到手机APP同时带有一路继电器输出功率超过200W自动断电低于阈值时可由手机手动控制通断。这个项目最值得借鉴的不是某一颗芯片用了什么高级外设而是完整的信号链路处理从互感器的选型匹配、调理电路参数计算到离散RMS算法补偿、WIFI协议帧设计再到继电器保护逻辑的状态机实现。对正在做嵌入式测量类项目的开发者来说这套代码里关于零点偏移校准和有效值计算的细节比单纯看 datasheet 要直观得多。即使你手头没有完全一样的硬件把信号调理和采样算法抽出来稍作修改就能移植到其他MCU平台上。2. TV1005M与TA1005M的信号链设计从互感器输出到ADC采样2.1 为什么选TV1005M和TA1005M小封装与线性度的折中TV1005M是毫安级电流型的电压互感器原边额定输入电流2mA副边输出电流也是毫安级典型变比是1:1但实际使用中必须在原边串联限流电阻把被测交流电压转换成2mA左右的电流才能送入互感器原边。TA1005M则是穿心式电流互感器原边穿线一次副边输出电流与一次侧电流成比例关系额定输出电流通常设计在几十毫安量级。选择这两个型号的核心原因是它们都工作在工频50Hz频率特性刚好覆盖电网频段而且封装小、价格低、PCB占板面积小适合做单相电表。相比直接电阻分压采样互感器提供了电气隔离这对触及220V交流电的设备来说是安全底线。另外一个容易被忽略的点是互感器副边输出是电流信号电流信号抗干扰能力强于电压信号长距离走线到ADC输入端时不容易被空间耦合噪声污染。2.2 信号调理电路把毫安级电流变成ADC可读的电压STM32的ADC输入范围是0~3.3V而互感器输出的是双极性交流电流信号因此调理电路需要完成两件事电流转电压I-V转换以及电平抬高到ADC量程中心点1.65V偏置。常见做法是在互感器副边并联一个采样电阻电流流过电阻产生压降然后用运放搭建加法电路把交流信号叠加到1.65V直流偏置上。以TA1005M为例假设副边额定输出电流为20mA取采样电阻R50Ω则额定压降为1V。电压互感器TV1005M副边同样通过采样电阻将2mA电流转为电压取R470Ω时压降约0.94V。运放电路采用同相放大结构增益约1.5倍左右将±1V的交流信号放大到±1.5V叠加1.65V偏置后输入ADC的电压范围约为0.15V~3.15V留出了足够的裕量不会削顶也不会超出ADC量程。// 信号调理电路的偏置电压计算 #define ADC_VREF 3.3f // ADC参考电压 #define DC_BIAS 1.65f // 偏置电压ADC_VREF/2 #define SAMPLE_RES_CT 50.0f // 电流互感器采样电阻 50Ω #define SAMPLE_RES_VT 470.0f // 电压互感器采样电阻 470Ω #define OP_GAIN 1.5f // 运放放大增益 // 计算额定输入时送入ADC的电压峰值 float calc_adc_input_peak(float ct_secondary_current, float vt_secondary_current) { float ct_voltage ct_secondary_current * SAMPLE_RES_CT * OP_GAIN; float vt_voltage vt_secondary_current * SAMPLE_RES_VT * OP_GAIN; // 交流信号叠加偏置后的包络范围 float ct_upper DC_BIAS ct_voltage; float ct_lower DC_BIAS - ct_voltage; return (ct_upper ct_lower) ? ct_upper : ct_lower; // 返回上限值 }这段代码反映了调理电路的核心参数约束SAMPLE_RES_CT和SAMPLE_RES_VT决定了I-V转换比例OP_GAIN决定了信号放大倍数。调整这三个参数时必须保证DC_BIAS 信号峰值不超过3.3V否则ADC采样会削顶导致RMS计算值偏小。下限也不能低于0V否则负半周被截断。校准参数存放在main.c的CALIBRATION_PARAM结构体中修改硬件后需要重新标定。2.3 STM32 ADC采样配置多通道循环与DMA搬运交流信号有效值计算要求在一个周期内均匀采样20ms的工频周期内至少采64个点才能保证RMS误差在工程可接受范围。STM32F103的ADC最大采样速率约1MHz远高于需求所以真正的瓶颈在于定时器触发和DMA搬运的配合而不是ADC本身的速度。// 定时器2触发ADC采样20kHz采样率每周期400个点 void ADC_Init_WithTimerTrigger(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; DMA_InitTypeDef DMA_InitStructure; // 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // PA1接电压信号PA2接电流信号 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); // ADC1配置独立模式右对齐规则组 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode ENABLE; // 扫描模式 ADC_InitStructure.ADC_ContinuousConvMode DISABLE; // 定时器触发 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 2; ADC_Init(ADC1, ADC_InitStructure); // 通道采样顺序电压通道0电流通道1 ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_71Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 2, ADC_SampleTime_71Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); // 定时器2更新事件触发ADC72MHz/360020kHz TIM_InitStructure.TIM_Period 3600 - 1; TIM_InitStructure.TIM_Prescaler 1 - 1; TIM_InitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_InitStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); TIM_Cmd(TIM2, ENABLE); // DMA循环搬运到缓冲区 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize 400 * 2; // 400点 x 2通道 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); }这里采用了定时器TRGO事件触发ADC注入转换DMA以循环模式搬运结果MCU的CPU完全不参与采样过程中的数据搬运。采样率20kHz意味着工频50Hz下每周期采样400点RMS计算精度足够。DMA_BufferSize必须设为采样点数乘以通道数否则DMA搬运的边界会错误。ADC_SampleTime_71Cycles5对应约1μs的采样时间对50Hz信号的源阻抗要求不苛刻但若信号调理输出阻抗偏高应适当增加采样时间。3. 离散RMS算法与校正补偿让测量数据逼近真值3.1 平均值整流与真有效值为什么不能简单取平均很多低成本电表方案用整流桥配合均值滤波得到有效值但这种方式只对标准正弦波有效。当负载是开关电源、调光灯、变频器等非线性设备时电流波形发生畸变峰值因数变化均值法误差可超过10%。本项目采用离散RMS算法对采样点逐点做平方、累加、平均、开方从数学定义上保证任何波形都能正确计算有效值。这个差异在实际负载测试中体现得相当明显——纯阻性负载下两者可能只差1%但接上电脑电源后误差立刻拉开。离散RMS公式为对N个采样点x(i)RMS sqrt( (1/N) * Σx(i)² )。实际工程中还需要剔除直流偏置分量。由于信号调理电路把交流信号叠加在1.65V偏置上软件里必须先减去这个偏置值再做平方累加否则直流分量会叠加进RMS结果造成正误差。3.2 直流偏置校准与RMS计算实现// 全局采样缓冲区voltage_points和current_points分别存储原始ADC值 #define SAMPLE_COUNT 400 uint16_t voltage_points[SAMPLE_COUNT]; uint16_t current_points[SAMPLE_COUNT]; // 计算一路通道的RMS值adc_offset为ADC原始值形式的直流偏置 float calc_rms(uint16_t *samples, uint16_t adc_offset, float adc_to_eng) { uint32_t sum_sq 0; for (int i 0; i SAMPLE_COUNT; i) { int32_t val (int32_t)samples[i] - adc_offset; // 去掉直流偏置 sum_sq (uint32_t)(val * val); // 平方累加注意溢出 } float mean_sq (float)sum_sq / SAMPLE_COUNT; float rms_adc sqrtf(mean_sq); // ADC码域有效值 return rms_adc * adc_to_eng; // 标定到实际工程值 }代码中adc_offset是零点偏置的ADC码值理论上为204812位ADC3.3/2。实际上由于运放失调电压和电阻误差这个值和理论值有偏差需要在系统上电时通过校准得到。val * val使用了32位整数运算12位ADC最大值4095平方后约1678万在32位int范围内安全但若改用16位MCU就要强制类型转换。adc_to_eng是把ADC码值转换为实际电压或电流值的比例系数在标定阶段确定。开方使用了sqrtf对Cortex-M3来说FPU不是标配但STM32F103的软件浮点开方速度在接受范围内只需要在每次采样周期50ms计算一次性能压力不大。3.3 增益校准与相位误差电压电流通道的同步问题互感器的幅值误差和相位误差是系统误差的主要来源。幅值误差通过增益系数修正用一个已知精度的标准源或者万用表作为参考测量本系统的读数两者之比即为修正系数。相位误差影响的是功率计算的准确性——如果电压通道和电流通道之间存在相位差有功功率P UIcos(φ)中的φ就不再是真实的负载阻抗角导致功率偏小或偏大。相位校准的方法是在纯阻性负载下进行此时理论上功率因数为1φ0如果计算出的功率小于U*I说明两路采样之间存在相位差。软件上可以通过滑动采样窗口来补偿即对电压或电流序列进行少量点的移位。在20kHz采样率下一个采样点对应0.9°的相位通常移位1~2个点就能把相位误差修正到0.5°以内。这个“软件移相”的做法比调整硬件滤波电容更精准、更可调。3.4 功率与电量计算积分累加与溢出处理有功功率的计算不依赖于电压和电流有效值相乘而是用瞬时功率的均值P (1/N) * Σv(i)*i(i)。这是因为电压和电流的采样点是一一对应的瞬时电压乘瞬时电流再取平均天然包含了功率因数的影响不需要额外测相位。这个计算方法在线性负载和开关电源负载下都成立是IEC标准中定义的功率测量方法。// 有功功率计算瞬时功率平均法 float calc_active_power_direct(uint16_t *voltage_samples, uint16_t *current_samples, uint16_t v_offset, uint16_t i_offset, float v_scale, float i_scale) { float power_sum 0.0f; for (int i 0; i SAMPLE_COUNT; i) { int32_t v (int32_t)voltage_samples[i] - v_offset; int32_t c (int32_t)current_samples[i] - i_offset; power_sum (float)v * c; } float power_adc power_sum / SAMPLE_COUNT; // 换算到工程值电压缩放系数 * 电流缩放系数 return power_adc * v_scale * i_scale; }电量计算则是在时间轴上积分本项目以1秒为周期累加功率// 电量累加每秒调用一次需要传入本次功率 uint32_t energy_accumulate(float power_w, uint32_t *accumulator_uwh) { *accumulator_uwh (uint32_t)(power_w * 1000.0f); // 单位mWs // 每3600000 mWs 1 Wh溢出到整数Wh return *accumulator_uwh / 3600000u; }energy_accumulate使用了毫瓦秒作为中间单位避免浮点累加带来的精度损失。accumulator_uwh是32位无符号整数按2000W最大功率计算可以累计约536小时才溢出而代码中一般在溢出前会将其清零并上报。如果预期连续运行时间更长建议改成uint64_t或定期将Wh值存入Flash/NVM。4. WIFI链路与继电器控制200W阈值背后的状态机设计4.1 数据帧协议嵌入式端与APP的JSON交互本项目通过WIFI模块典型型号如ESP8266/ESP-01S与手机APP通信这里采用串口透传方式STM32通过USART向WIFI模块发送AT指令或直接透传数据。在协议设计上数据帧采用JSON格式的三个核心字段电压v、电流i、功率p以及状态字段继电器状态relay、计时时间timer。JSON格式通信的优点是可读性强、调试方便、APP端解析简单代价是帧长度略大。对于每秒上报一次的电表数据来说几百字节的负载完全不是问题。// 向WIFI模块发送JSON数据帧 void wifi_send_telemetry(float voltage, float current, float power, uint8_t relay_state, uint32_t timer_sec) { char frame[128]; int len snprintf(frame, sizeof(frame), {\v\:%.1f,\i\:%.2f,\p\:%.1f,\relay\:%d,\timer\:%lu}\r\n, voltage, current, power, relay_state, (unsigned long)timer_sec); // 通过USART2发送 HAL_UART_Transmit(huart2, (uint8_t *)frame, len, 100); }这个帧格式中v精确到0.1Vi精确到0.01Ap精确到0.1W足够家庭用电监测的精度需求。需要特别注意的是timer字段%lu对应unsigned long在STM32上32位如果计时时间超过24小时用32位无符号数按秒计数也会溢出所以实际代码在上报时会做归一化处理把秒数转为HH:MM:SS格式的字符串或拆分为时分秒三个字段。snprintf的用法确保帧长度不超过缓冲区防止堆栈溢出。4.2 继电器控制逻辑手动模式与自动保护共存继电器控制是这个项目里状态逻辑最值得玩味的部分。硬件上继电器驱动由STM32的一个GPIO控制但简单的“功率超200W就断开”这个动作背后需要处理几个边界条件开机时负载本身就是感性负载如电机的启动冲击瞬时功率可能远超过200W用户手动开启继电器后负载从零到稳定的过渡期间功率可能短暂越限功率在200W附近波动时继电器不能频繁通断。#define POWER_THRESHOLD 200.0f // 功率阈值 #define HYSTERESIS_BAND 20.0f // 回差滞环180W以下才允许重新合闸 #define OVER_POWER_DELAY_MS 2000 // 越限持续2秒才判定 typedef enum { RELAY_STATE_OFF 0, RELAY_STATE_ON 1, RELAY_STATE_FAULT 2 } RelayState; RelayState relay_state RELAY_STATE_OFF; uint16_t over_power_counter 0; void relay_control_update(float power_w, uint8_t manual_cmd) { // 手动命令优先但自动保护优先于手动开启 if (relay_state RELAY_STATE_ON) { // 持续越限检测 if (power_w POWER_THRESHOLD) { over_power_counter; if (over_power_counter OVER_POWER_DELAY_MS / 100) { relay_set(RELAY_STATE_OFF); over_power_counter 0; } } else { over_power_counter 0; } // 手动关闭优先级最高 if (manual_cmd 0) { relay_set(RELAY_STATE_OFF); } } else if (relay_state RELAY_STATE_OFF) { // 手动开启但需要满足功率未越限条件 if (manual_cmd 1) { if (power_w POWER_THRESHOLD - HYSTERESIS_BAND) { relay_set(RELAY_STATE_ON); } } } }这段实现里最关键的是HYSTERESIS_BAND滞环变量。如果没有回差功率在198W和202W之间抖动时继电器会反复吸合释放触点寿命急剧缩短甚至产生电火花干扰采样电路。加入20W回差后高于200W断开低于180W才允许重新合闸形成了一个稳定的死区。over_power_counter计数器实现了去抖逻辑功率超过200W必须持续2秒才真正触发保护这避免了电机启动瞬间的误动作。4.3 计时功能与连接状态处理计时功能的设计约束是计时只在手机APP连接WIFI模块期间有效。从这个角度反推计时本质上是一个“可清零、可暂停、可读取”的计数器由APP发送开始/暂停/清零命令设备端只负责计时和上报。// 计时状态机 typedef enum { TIMER_STOPPED 0, TIMER_RUNNING 1 } TimerState; TimerState timer_state TIMER_STOPPED; uint32_t timer_cnt 0; void timer_tick_1s(void) { if (timer_state TIMER_RUNNING) { timer_cnt; } }需要注意的是timer_tick_1s函数依赖于系统时基。在裸机环境中可以用SysTick中断每秒调用一次如果使用了FreeRTOS可以用软件定时器。这里隐含的一个坑是如果WIFI模块断连计时应立即暂停还是继续累计按摘要描述“手机和WiFi模块连接后手机上显示计时时间”合理的做法是断连时暂停计时重新连接后从暂停值继续APP端显示断连期间的时长缺口。5. 校正三步走零偏、增益、相位补偿的实操方法拿到这套源程序后建议的调试顺序不是直接下载烧录看效果而是按信号链路的顺序分级验证。第一步是零输入校准——不接入任何负载互感器原边悬空或短接此时理论上的电压电流读数都应为零。实际读到的非零值主要由调理电路的运放失调电压和偏置电阻误差引起。在源代码中找到CALIBRATION_OFFSET_VOLT和CALIBRATION_OFFSET_CURR两个宏定义将实测的零输入ADC均值填入即可。// 零输入校准采集1000个点的平均值作为偏移 uint16_t calibration_zero_offset(ADC_HandleTypeDef *hadc, uint32_t channel) { uint32_t sum 0; for (int i 0; i 1000; i) { HAL_ADC_Start(hadc); HAL_ADC_PollForConversion(hadc, 10); sum HAL_ADC_GetValue(hadc); } return (uint16_t)(sum / 1000); }第二步是增益校准。使用一个已知精度的交流电源或直接用电网电压用万用表实测当前电压值作为参考把系统读数调整到与万用表一致。这一步需要计算修正系数修正系数 参考值 / 当前显示值。把这个系数乘到adc_to_eng缩放因子上。注意这里有个容易犯的错误电压和电流通道必须独立校准不能共用一个系数因为两个通道的采样电阻和运放增益不一致。更好一些的做法是把校准系数存入Flash后在开机时读取这样每次校准不需要重新编译源码。// Flash保存校准参数结构体 typedef struct { uint16_t magic; // 魔数用于校验是否有有效校准数据 float v_offset_raw; // 电压零偏 float i_offset_raw; // 电流零偏 float v_gain_coeff; // 电压增益系数 float i_gain_coeff; // 电流增益系数 int8_t phase_shift_points; // 相位补偿点数可正可负 } CalibrationData; CalibrationData cal { 0xA5A5, 2048.0f, 2048.0f, 1.0f, 1.0f, 0 }; // 相位补偿对电压序列进行移位 void apply_phase_shift(uint16_t *samples, int8_t shift, uint16_t len) { if (shift 0) return; uint16_t temp_buffer[16]; // 最多移16个点 if (shift 0) { // 整体右移前面的点用0填充 memcpy(temp_buffer, samples, shift * 2); memmove(samples shift, samples, (len - shift) * 2); memcpy(samples, temp_buffer, shift * 2); } else { // 整体左移 shift -shift; memcpy(temp_buffer, samples len - shift, shift * 2); memmove(samples shift, samples, (len - shift) * 2); memcpy(samples len - shift, temp_buffer, shift * 2); } }第三步是相位补偿验证。在纯阻性负载如白炽灯或电暖器下功率应为UI的乘积若计算功率小于UI说明两通道存在相位差需要调整phase_shift_points。判断相位滞后方向的方法是在纯阻性负载下如果功率值偏小且电流波形显示的峰值位置落后于电压波形说明电流通道滞后于电压通道应将电流序列整体前移也就是把电压序列后移。在20kHz采样率下一个采样点约等于0.9°。最后检查继电器保护是否正常工作接入一个可调负载从低功率缓慢升高到200W以上观察是否在2秒延时后触发断电再降低到180W以下手动合闸确认状态能恢复。这里特别建议用示波器观察一次越限动作时继电器的控制波形——如果存在触点抖动说明滞环宽度或去抖时间不够把HYSTERESIS_BAND从20W加到30W再观察。本文还有配套的精品资源点击获取