简介:这是一份面向嵌入式学习者与电子爱好者的STM32实战项目资料,围绕简易智能电表展开,适合具备一定单片机基础、希望深入理解电力参数采集与计量原理的读者。项目以STM32为核心,通过电压电流传感器与片内ADC采集实时数据,结合定时器与数学运算单元完成功率、功率因数及累积用电量的计算,并涉及温度漂移校准等工程细节。资源包共440个文件,约199.59MB,涵盖C源码、头文件、工程配置、原理图与PCB设计文档、PDF说明及日志等,完整呈现从硬件接口到软件逻辑的实现链路。已有1365人学习下载,读者可借助代码与原理图理解系统架构、外设初始化流程及电力参数计算逻辑,并在此基础上进行二次开发或功能扩展,是学习STM32应用与电力监测设计的实用参考案例。
1. 从一块计量芯片开始:为什么STM32做智能电表不是“玩具”
很多人第一次听到“基于STM32的简易智能电表”,脑子里浮现的是实验室里那种用面包板搭出来的东西——一个分流电阻、一个运放、一个ADC,读几个数就完事了。但真正做过的人知道,电表的核心难点从来不在“读电压电流”,而在“怎么把交流信号在强干扰环境下算准”。STM32能做的事,比你想的多得多:它内置的12位ADC配合DMA,可以在一个工频周期内采到足够多的点,用软件做均方根运算,精度做到1%以内完全可行。这套方案适合谁?适合想从“点灯调串口”进阶到“真实信号链”的嵌入式开发者,适合需要做能耗监测毕设的学生,也适合想给自家配电箱加个数据采集节点的动手派。它不涉及强电计量认证,但足够让你把ADC采样、定时器触发、串口通信、上位机显示这条链路完整跑通一遍。接下来我把选型、接线、代码、标定和踩坑全部拆开讲。
2. 硬件选型与信号链设计:从互感器到STM32的ADC引脚
2.1 为什么用互感器而不是分流电阻
分流电阻方案简单,但有个致命问题:不隔离。市电的火线直接连到你的采样电路上,调试时手一碰就可能出事。互感器方案虽然多花十几块钱,但把强电和弱电彻底隔开了。电流通道用开口式电流互感器,变比常见的是1000:1或2000:1,输出是毫安级交流电流,需要并一个采样电阻转成电压。电压通道用电压互感器,变比通常也是1000:1左右,初级串一个限流电阻接到火线和零线之间。
选型时注意三个参数:额定输入电流、变比、线性范围。比如一个100A/50mA的电流互感器,变比2000:1,你在次级并一个20Ω的采样电阻,满量程时次级电流50mA,采样电阻上得到1V交流有效值。这个电压正好落在STM32的ADC输入范围内(0到3.3V),但注意是交流,有正有负,所以必须加偏置电路把信号抬到1.65V附近。
2.2 偏置与滤波电路的具体参数
偏置电路用两个等值电阻分压得到1.65V,比如两个10kΩ电阻从3.3V分压,中间节点接一个10μF电容滤波。互感器次级一端接这个偏置点,另一端接采样电阻到地,采样电阻两端就是叠加在1.65V上的交流信号。这里有个细节:偏置点的输出阻抗要低,否则会被采样电阻拉偏。两个10kΩ分压的等效输出阻抗是5kΩ,而采样电阻只有20Ω,并联后偏置点几乎不变,所以没问题。
抗混叠滤波是必须的。STM32的ADC采样率如果设成1kHz,而信号里混有几十kHz的开关噪声,就会发生混叠,算出来的有效值完全不对。在ADC引脚前加一个RC低通,截止频率设在1kHz左右。比如1kΩ电阻加100nF电容,截止频率约1.6kHz。这个电阻同时起到限流保护作用,防止引脚过压。
2.3 STM32型号选择与ADC配置要点
不是所有STM32都适合。F103系列是经典选择,12位ADC,最高1μs转换时间,配合DMA可以做到无CPU干预的连续采样。G0系列更便宜,但ADC精度和稳定性稍逊。F4系列性能过剩,做电表没必要。我一般选F103C8T6,资源够用,资料多,踩坑时容易找到答案。
ADC配置有几个关键点:第一,用定时器触发采样,不要用软件循环。TIM2或TIM3设成PWM模式,更新事件触发ADC,这样采样间隔严格等距,算有效值才准。第二,开启DMA循环模式,让ADC数据自动搬到内存数组,CPU只负责算。第三,采样时间设长一点,比如55.5个ADC周期,让采样保持电容充分充电,否则高阻抗信号源会导致读数偏低。
// TIM3 配置为 1kHz 更新事件,触发 ADC void TIM3_Config(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseInitTypeDef tim; // 72MHz / (71+1) = 1MHz 计数频率 tim.TIM_Prescaler = 71; // 1000 个计数后更新,即 1kHz 触发频率 tim.TIM_Period = 999; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &tim); // 主输出触发,用于触发 ADC TIM_SelectOutputTrigger(TIM3, TIM_TRGO_Update); TIM_Cmd(TIM3, ENABLE); }这段代码把TIM3设成1kHz的更新事件源。预分频72-1把72MHz降到1MHz,周期1000-1让计数器每1000个计数溢出一次,正好1kHz。TIM_SelectOutputTrigger把更新事件输出到TRGO线,ADC配置成外部触发源为TIM3_TRGO时,每次更新事件自动启动一次转换。这样采样率严格等于1kHz,工频50Hz的一个周期内采20个点,算有效值足够。
// ADC1 配置:外部触发 + DMA 循环 void ADC_Config(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; // 电流、电压通道 gpio.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpio); DMA_InitTypeDef dma; DMA_DeInit(DMA1_Channel1); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = 40; // 20 点电流 + 20 点电压 dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel1, &dma); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitTypeDef adc; adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = ENABLE; // 扫描两个通道 adc.ADC_ContinuousConvMode = DISABLE; // 由外部触发 adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 2; ADC_Init(ADC1, &adc); // 通道0 采样时间 55.5 周期,通道1 同理 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 首次启动,之后由 TIM3 触发 }DMA缓冲区大小40,因为扫描两个通道各20个点。循环模式让DMA自动覆盖旧数据,CPU随时可以读最近40个值。采样时间55.5周期是F103的最长选项,对应约0.77μs,加上转换时间总共约1.2μs,远小于1ms的采样间隔,不会丢点。校准步骤不能省,否则零点漂移可能达到十几个LSB。
3. 有效值算法与标定:从ADC原始值到真实功率
3.1 均方根运算的定点实现
STM32F103没有硬件浮点,用浮点算RMS会拖慢速度,但1kHz采样率下每20ms算一次,时间充裕。我一般用float,代码可读性好,算一次RMS大概几十微秒,完全不影响。公式很简单:先减去偏置对应的ADC值,然后平方、累加、除N、开方。
偏置值怎么确定?在无输入时读ADC,理论上应该是1.65V对应的ADC值,即2048左右。但实际有偏差,所以上电时先采一组空载数据,算平均作为偏置。这个步骤叫零点校准,不做的话小电流时误差巨大。
// 计算 RMS,输入为 ADC 原始数组,返回有效值(单位:ADC 计数) float Calc_RMS(uint16_t *buf, uint16_t len, uint16_t offset) { float sum = 0.0f; for (uint16_t i = 0; i < len; i++) { float v = (float)buf[i] - (float)offset; // 去掉直流偏置 sum += v * v; } return sqrtf(sum / (float)len); }这个函数对20个点算一次,得到的是以ADC计数为单位的有效值。要转成实际电压,需要标定系数。比如你输入一个已知的1A交流电流,读到RMS为100,那么系数就是1A/100=0.01A per count。电压通道同理。
3.2 功率计算与相位补偿
有功功率不是简单的电压RMS乘电流RMS,而是瞬时电压乘瞬时电流的平均值。如果电压和电流通道是同时采样的,直接对应相乘再平均就行。但STM32的ADC是顺序扫描两个通道,通道0和通道1之间有转换时间差,大约1.2μs。在50Hz下,1.2μs对应0.0216度相位差,功率因数接近1时影响极小,但功率因数低时误差会放大。
补偿方法有两种:硬件上用两个ADC同时采样,但F103只有一个ADC;软件上在算功率时对电流数组做插值偏移。我一般先不补偿,实测功率因数为0.5时误差约2%,要求不高可以接受。如果要补偿,把电流数组整体前移或后移一个点,看哪个方向误差小。
// 计算有功功率,单位:ADC 计数平方 float Calc_Power(uint16_t *vbuf, uint16_t *ibuf, uint16_t len, uint16_t voff, uint16_t ioff) { float sum = 0.0f; for (uint16_t i = 0; i < len; i++) { float v = (float)vbuf[i] - (float)voff; float c = (float)ibuf[i] - (float)ioff; sum += v * c; } return sum / (float)len; }注意vbuf和ibuf是分开的两个数组,各存20个点。DMA缓冲区里是交替存放的,需要先拆开。拆的时候按顺序取,通道0在前通道1在后,所以偶数索引是通道0,奇数索引是通道1。
3.3 标定步骤与系数存储
标定需要至少两个点:空载和满载。空载时读RMS,应该接近0,如果不为0就记下作为偏置修正。满载时加一个已知负载,比如一个100W白炽灯,读电压RMS和电流RMS,算出实际系数。电压系数 = 实际电压 / 电压RMS读数,电流系数 = 实际电流 / 电流RMS读数。
系数存在Flash里,掉电不丢。STM32F103的Flash页大小1KB,选最后一页存几个float。写之前先擦除,写之后校验。注意Flash写操作会阻塞CPU,不要在采样中断里做。
// 从 Flash 读取标定系数 void Load_Calib(float *vcoef, float *icoef) { uint32_t addr = 0x0801FC00; // 最后一页起始地址 *vcoef = *(float*)addr; *icoef = *(float*)(addr + 4); // 简单校验:系数应该在合理范围内 if (*vcoef < 0.001f || *vcoef > 10.0f) *vcoef = 0.1f; if (*icoef < 0.001f || *icoef > 10.0f) *icoef = 0.01f; }地址0x0801FC00是F103C8T6的最后一页,具体型号不同地址可能不同,查数据手册确认。校验逻辑防止Flash为空时读出0xFFFFFFFF导致计算出错。
4. 避坑与排查:那些让读数跳变的血泪经验
4.1 读数随温度漂移十几毫安
现象:空载时电流读数在0到30mA之间缓慢摆动,用手摸互感器或PCB,读数明显变化。原因:偏置分压电阻的温漂和互感器本身的温度特性。普通碳膜电阻温漂几百ppm,两个电阻漂移方向不一致就会让偏置点移动。解决:换用金属膜电阻,温漂50ppm以内;偏置点加一个TL431基准,比电阻分压稳得多。如果不想加器件,至少把两个分压电阻用同批次同阻值的,让温漂方向一致。
4.2 功率因数为负
现象:接感性负载时功率显示为负值,但明明在耗电。原因:电流互感器的极性接反了,或者电压通道的采样电阻接在了偏置点的另一侧。解决:把电流互感器次级的两根线对调,或者在软件里把电流数组取反。判断方法:接一个纯电阻负载,功率应该为正;如果为负,就是极性反了。
4.3 ADC读数周期性跳变
现象:电压RMS每秒钟跳一次,幅度约1%。原因:DMA缓冲区和计算数组冲突。如果DMA正在写缓冲区,CPU同时读同一块内存,就会读到半新半旧的数据。解决:用双缓冲。DMA写缓冲区A时,CPU算缓冲区B,算完交换。或者简单点,在DMA传输完成中断里把数据拷到另一个数组再算。F103的DMA有传输完成中断,配置一下就行。
4.4 串口发送时读数异常
现象:每次通过串口发送数据时,电流读数会跳一下。原因:串口发送是阻塞的,如果用了while(USART_GetFlagStatus(...))等待,CPU被占住,采样中断响应延迟,导致采样点不均匀。解决:串口用DMA发送,或者用中断发送,别用阻塞轮询。另外,串口发送的数据量别太大,115200波特率下发几十个字节要几毫秒,这期间如果采样中断被延迟,RMS计算就会出错。
4.5 上电瞬间读数满量程
现象:刚上电时电流显示满量程,过几秒才恢复正常。原因:偏置电路的10μF电容充电需要时间,上电时偏置点还没到1.65V,ADC读到的全是接近0或接近3.3V的值。解决:上电后延时500ms再开始采样,或者在初始化时先不启动ADC,等电容充好电再启动。软件上也可以在前100个采样周期内不更新显示,等偏置稳定。
5. 进阶技巧:用滑动平均和分段标定把精度再提一档
5.1 滑动平均滤波的窗口选择
单次RMS计算用的是20个点,工频周期正好,但噪声仍然存在。滑动平均用最近N次RMS结果做平均,N取4到8比较合适。N太小滤波效果不明显,N太大响应变慢,负载突变时读数要好几秒才跟上。我一般取5,兼顾稳定性和响应速度。
#define RMS_WINDOW 5 float rms_history[RMS_WINDOW]; uint8_t rms_idx = 0; float Smooth_RMS(float new_rms) { rms_history[rms_idx] = new_rms; rms_idx = (rms_idx + 1) % RMS_WINDOW; float sum = 0.0f; for (uint8_t i = 0; i < RMS_WINDOW; i++) { sum += rms_history[i]; } return sum / (float)RMS_WINDOW; }这个函数每次调用返回最近5次RMS的平均值。注意数组初始化为0,上电后前几次读数会偏低,跑满5次后就正常了。可以在初始化时用第一次RMS值填满整个数组,避免上电爬升。
5.2 分段标定解决非线性
互感器在小电流时线性度差,在额定电流附近最好。如果只用一个标定系数,小电流时误差可能到5%。分段标定把量程分成三段:0到10%、10%到50%、50%到100%,每段单独算系数。实现时先判断当前RMS落在哪一段,再用对应系数。
| 量程段 | 电流范围 | 标定系数来源 | 典型误差 |
|---|---|---|---|
| 低段 | 0-10% | 用5%负载标定 | ±2% |
| 中段 | 10-50% | 用30%负载标定 | ±1% |
| 高段 | 50-100% | 用80%负载标定 | ±0.5% |
标定时每段加对应负载,记录RMS读数和实际值,算系数存Flash。代码里用if-else判断分段,切换系数。注意分段边界要有迟滞,否则在边界附近读数会来回跳。
5.3 用定时器输入捕获测频率
电表还需要测频率,判断电网是否正常。STM32的定时器输入捕获可以测工频。把电压信号经过比较器整形成方波,接到TIM的捕获引脚,测两个上升沿之间的时间,倒数就是频率。F103的TIM2到TIM5都支持输入捕获,配置成上升沿捕获,开捕获中断,在中断里读CCR值算差值。
// TIM4 输入捕获测频率 void TIM4_Capture_Init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_6; // TIM4_CH1 gpio.GPIO_Mode = GPIO_Mode_IPD; GPIO_Init(GPIOB, &gpio); TIM_TimeBaseInitTypeDef tim; tim.TIM_Prescaler = 71; // 1MHz 计数 tim.TIM_Period = 0xFFFF; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &tim); TIM_ICInitTypeDef ic; ic.TIM_Channel = TIM_Channel_1; ic.TIM_ICPolarity = TIM_ICPolarity_Rising; ic.TIM_ICSelection = TIM_ICSelection_DirectTI; ic.TIM_ICPrescaler = TIM_ICPSC_DIV1; ic.TIM_ICFilter = 0x0F; // 滤波,防抖动 TIM_ICInit(TIM4, &ic); TIM_ITConfig(TIM4, TIM_IT_CC1, ENABLE); TIM_Cmd(TIM4, ENABLE); }中断里读CCR1,和上次的值做差,如果差值为正就是周期,倒数就是频率。注意计数器溢出,差值要处理16位回绕。滤波参数0x0F是最大滤波,能滤掉高频毛刺,但会引入一点延迟,测频率够用。
5.4 一个我常犯的错误
早期做这个方案时,我总想把所有功能塞进一个中断里:采样、算RMS、算功率、发串口,全在定时器中断里做。结果中断执行时间超过采样间隔,丢了采样点,读数乱跳。后来改成中断只做数据搬运,主循环算RMS和功率,串口用DMA发,才稳定下来。教训是:中断里只做最紧急的事,计算和通信放到主循环。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取