资讯动态

STM32智能电表开发全解析:从ADC采样到掉电保存

发布时间:2026/10/19 7:59:10 来源:尧图企业网站定制
简介基于STM32的简易智能电表是一份面向嵌入式开发者和电力监测爱好者的完整工程资料以家庭用电场景为核心解决用电功率、用电量及功率因数等参数的实时采集与计量。资源共440个文件压缩包大小约199.59MB文件类型以C/H源码、原理图、PCB设计、PDF说明和调试日志为主能够从软件驱动与硬件连接两个维度还原开发过程。目前已有1368人学习下载。源码中给出STM32的时钟配置、GPIO初始化、ADC采样、定时器中断以及基于积分的电能计量逻辑原理图和PCB文件则标注了电流电压传感器、主控芯片与显示模块的接口关系便于对照实物理解完整信号链路。对希望掌握嵌入式开发或电力监测方法的读者而言这份项目模板既能用于学习拆解也能为后续二次开发提供基础框架。1. 简易智能电表一块板子把电压、电流、电量同时读出来拿到这个基于STM32的简易智能电表工程时我第一反应是这不就是一个带LCD显示的电压采集器吗把板子完整跑起来才发现它把计量类产品的通用链路都走了一遍。从互感器把220V交流缩小到ADC能采的小信号到均方根算法算出有效值再到每秒钟把电压、电流、功率和累计电量刷新到LCD上顺带通过串口把数据吐给上位机。工程基于STM32F103C8T6代码量不大但采样、计算、存储、显示、通信拆得很清楚。想入门嵌入式仪表开发的新手或者准备用电能计量做毕业设计、做产品原型的工程师都能从这份源码里找到能直接抄的模板。2. 架构设计从220V高压到串口数据的完整链路电表功能听着不复杂真正动手做才会发现它横跨模拟前端、数字采样、数值运算、非易失存储和人机交互五个环节。任何一个环节的关联没理清后面写代码就会乱。这一章先把硬件连接和软件节奏定下来把“一个数据从互感器进来到串口发出去”的完整路径理清后面的代码才有依附。2.1 硬件链路互感器、分压网络与STM32外设分配ADC直接采220V交流是行不通的。STM32的ADC输入范围是0到3.3V220V有效值对应约311V的峰值直接接上去轻则采不到有效数据重则把芯片打坏。常见做法是先用电网电源变压器隔离再把电压和电流分别变成ADC能接受的小幅值信号。电压采样这一路我采用的是电阻分压。220V交流经过1MΩ和1kΩ电阻串联分压分压比约为1/1001311V峰值在分压点上只剩约0.31V。这个信号还带负半周而单极性ADC采不了负电压所以要叠加上1.65V的直流偏置把整个波形抬高到ADC量程中间。1.65V可以靠两个10kΩ电阻对3.3V分压再经过电压跟随器得到也可以用DAC直接生成工程里前一种更常见成本低、实现稳定。电流这一路需要互感器。以常见的1000:1电流互感器为例初级流过1A电流时次级只有1mA在次级并联一个10Ω采样电阻电阻两端得到10mV电压。10mV太小直接进ADC精度会很差一般会再接一级运算放大器放大到几百毫伏同样叠加1.65V偏置后进ADC。互感器把强电和弱电隔离开调试时的人身安全也有保障。外设分配在工程里是这样规划的外设用途关键配置ADC1电压、电流双通道采集12位、采样时间239.5周期TIM3为ADC提供采样触发156.25us触发一次20ms采128点DMA1搬运ADC转换结果通道1循环模式USART1向上位机上报数据115200、8N1LCD1602显示电压、电流、功率、电量4线或8线数据接口这里最值得留意的是TIM3触发ADC这个设计。我没有让ADC自由运行而是把TIM3的更新事件配置成ADC外部触发源定时器每156.25us触发一次启动转换。一个工频周期是20ms正好采128个点。采完一批由DMA搬走CPU在转换期间继续干别的事这就是嵌入式里很典型的“定时器ADCDMA”组合功耗低且采样时刻精确可控。把采样时刻固定下来还有个额外好处每个周期采的点都在同样的相位上后面算均方根、算功率都不会因为相位错位产生周期性的读数波动这个特点在第3章会详细展开。2.2 软件状态机采样、计算、显示、通信按什么节奏跑硬件链路定好后软件要回答的问题是每个时刻CPU该干什么。我把整个程序拆成三个状态等待采样完成、数据计算、结果输出。采样部分全部在中断里静默完成主循环只处理“数据已经就绪”这件事。中断里做的工作是搬运数据不碰浮点。voltage_buffer和current_buffer各128个uint16_tDMA把ADC转换结果连续搬进缓冲。当TIM3触发满128次后DMA传输完成中断置一个标志位主循环看到标志位才开始算。这样做的原因很直接如果在中断里做浮点均方根会占掉大量CPU时间一旦串口打印或者LCD刷新打断它采样节拍就会乱。主循环的框架非常朴素while (1) { if (rms_ready_flag) // DMA搬完128个点后由中断置位 { rms_ready_flag 0; measure_calc_rms(); // 计算电压、电流有效值 measure_update_power(); // 用瞬时值算功率按秒累计电量 storage_save_if_needed();// 电量变化达到阈值时写Flash display_refresh(); // 刷新LCD comm_send_report(); // 串口上报一行数据 } key_scan(); // 按键扫描在空余时进行 }rms_ready_flag是volatile类型在DMA传输完成中断里置1主循环清零后执行计算。按键扫描放在同一轮循环里是因为按键对实时性要求最低几十毫秒的延迟完全感觉不到。LCD刷新也没必要每采样周期都做128点大约20ms刷新一次人眼已经看不出闪烁。这套状态划分其实回答了计量设备里最常见的一个问题为什么计算不能集中在主循环里。采样必须严格按时间节拍走不能因为某次串口发送卡住就丢点计算和显示则可以容忍延迟。把采样放在中断和DMA里计算放在主循环里就同时兼顾了“实时采集”和“复杂运算”。关于中断优先级我把DMA的传输完成中断设为高于串口中断避免串口上报时丢掉采样点。相比之下USART中断优先级低一点没有关系通信数据错过一帧重发就是但采样点错过一个周期均方根结果就会出缺口。这是我在这类项目里吃过几次亏之后才定下来的固定顺序。3. 核心模块拆解有效值计算与掉电保存的每一行代码架构理顺之后最核心的代码集中在两个地方一是从原始AD值算出电压、电流、功率二是把电量可靠地存进Flash。这一章把这两段算法一行行拆开直接看代码比看任何框图都清楚。3.1 整周期采样与均方根算法为什么必须采满20毫秒工程里计算电压有效值用的是标准均方根公式但前提是采样必须是整周期。如果只采10ms或者任意一段非整周期采样会造成截断算出来的有效值会在真实值附近来回抖动这就是为什么采样循环固定采满128点、正好覆盖一个20ms工频周期。在看代码之前先说明一个背景ADC采到的每个点是12位数字量范围0到4095。交流信号被抬高了1.65V偏置所以静止时读数在2048附近正半周往上走负半周往下走。为了计算交流有效值要先把采样序列看作一个整体按下面的公式处理#define SAMPLE_NUM 128 // 返回采样序列的交流均方根值单位是ADC码值 static float calc_ac_rms(uint16_t *buf, uint16_t points) { uint32_t sum 0; uint16_t i; for (i 0; i points; i) { sum (uint32_t)buf[i] * buf[i]; } // 先求平均值再开方等价于每个点平方后再求平均 return sqrtf((float)sum / points); }这段代码的关键在sum的类型和开方时机。uint32_t能存到约42亿(uint32_t)buf[i] * buf[i]最大是4095乘4095约1677万乘上128个点也只有约21亿没超uint32_t上限所以累加不会溢出。开方用sqrtf并链接数学库libm如果工程里忘了勾选Use MicroLIB或者没加math库链接时会报sqrtf未定义这是新手最容易翻车的地方。代码里的参数也可以按需调整。SAMPLE_NUM设成128是配合50Hz电网的整周期如果当地是60Hz工频一个周期约16.7ms同样156.25us采样间隔采不满128点需要把TIM3的触发间隔改成130us左右或者把SAMPLE_NUM调整为与工频整除的数量。工程里这两处参数最好集中放在一个配置头文件里方便换运行环境时统一改。3.2 直流偏置去除与零点校准把偏置从AD值里抠出去ADC采到的每个AD值是“直流偏置交流瞬时值”叠加的结果偏置本身不是信号必须从有效值里扣除。常见做法有两种一是固定减一个理论偏置2048二是对当前采样序列求算术平均作为实际偏置。固定减2048简单但容易翻车因为电源纹波、运放失调都会让实际偏置偏离理论值所以我一般对每个周期单独估计一次偏置。// 用当前采样序列的平均值估计直流偏置 static float estimate_dc_offset(uint16_t *buf, uint16_t points) { uint32_t sum 0; uint16_t i; for (i 0; i points; i) { sum buf[i]; } return (float)sum / points; }对整周期采样来说交流信号在一个周期内的平均值恰好为零所以序列均值就等于直流偏置这个估计在理论上就是准确的。算完偏置以后交流有效值计算变成两步先用3.1节算出的含偏置均方根值再把偏置的影响从均方根层面减去。严格来说交流有效值应该用每个点先减偏置再平方平均但工程里为了省掉每个点都做一次浮点减法通常用含偏置均方根减去偏置的近似公式再乘一个校准系数把误差兜回来。标定的时候用一个已知电压源比对就能把这个系数定准。标定系数我会单独放在一个宏或配置结构体里#define V_REF_SCALE 1001.0f // 电压分压比1M 1k 对 220V 分压 #define V_ADC_REF 3.3f // ADC参考电压 #define V_SCALE (V_ADC_REF / 4096.0f * V_REF_SCALE) // 电压换算AD有效值扣偏置后直接乘上比例系数得到真实伏特数 float volt (rms_ad - dc_offset) * V_SCALE;参数说明V_ADC_REF是参考电压4096是12位ADC的满码值V_REF_SCALE是分压比。如果硬件上参考电压不是3.3V比如用了外部2.5V基准这里要同步改。实测电压偏高或偏低不一定是代码问题很大概率是分压电阻精度不够1MΩ电阻误差1%换算到电压就是2V以上误差这也是标定系数存在的意义。电流通道完全复用同一套算法把V_SCALE换成电流通道的互感器变比和放大倍数组成的I_SCALE就行。功率则在每个采样周期算出瞬时电压与瞬时电流的乘积并累加再取平均得到有功功率这个平均过程实际上已经把电压电流的相位差考虑进去了。3.3 电能累计与Flash掉电保存算出数字还要存得住功率算出之后电能累计就是纯粹的积分。工程里用定时器实现每秒触发一次累计每秒把当前功率除以3600加到电量累加器里这样电量单位是Wh。浮点累加长期运行会有精度损失我的做法是等累加的增量累积到0.01度电时才真正写入存储介质减少写入次数也能让Flash寿命更久。// 每秒被调用一次按当前功率累加电量 void energy_update(float power_w, float elapsed_second) { float delta_wh power_w * elapsed_second / 3600.0f; energy_wh delta_wh; // 每累计满0.01度10Wh就标记需要存储 if (energy_wh - stored_energy_wh 10.0f) { storage_save(energy_wh); stored_energy_wh energy_wh; } }写入Flash不能说写就写。STM32内部Flash在写入前必须先擦除擦除操作是整块页进行的而且写Flash期间CPU不能被中断打断否则高优先级中断一来Flash控制器状态就乱了轻则写失败重则触发HardFault复位。常见做法是把电量保存放到临界区执行或者关闭全局中断再写写完恢复。为了避免频繁擦写同一页导致Flash提前报废我会用一个简单的磨损均衡在Flash页里按4字节对齐的槽位顺序写每次写一个新槽位一轮写满后再擦除整页从头开始。这样页的擦除次数被分散程序也就不会在每次电量变化时都去执行耗时最长的擦除动作。代价是多占几十个字节的Flash空间对容量来说完全可以接受。// 掉电保存接口实际上是把电量值写入Flash的空槽 void storage_save(float energy) { __disable_irq(); // 进入临界区防止写Flash被打断 flash_write_word(slot_addr, energy); // 写入一个槽位 __enable_irq(); }使用时要留意flash_write_word内部要处理越界。当slot_addr超过页末尾时先执行页面擦除再把slot_addr指回页首这一整套逻辑往往会在工程里专门建一个storage模块不和其他业务代码混在一起。第5章我会讲一个因为忘记关中断写Flash导致整机复位的真实踩坑案例那一次排查几乎把整个驱动代码翻了一遍。4. 工程搭建与运行从Keil打开到串口出数据看完核心算法下一步就是把工程跑起来。这一章按我实际的运行顺序来先把工程文件结构讲明白再讲编译烧录最后讲怎样用串口数据验证板子是否正常工作。4.1 目录结构与关键文件先看懂工程再动手拿到源码包解压以后第一步不是急着点编译而是把目录结构过一遍。工程采用的是HAL库加标准目录布局App目录下放业务代码Core目录放芯片启动和时钟初始化Drivers目录放HAL库MDK-ARM目录是Keil的工程所在地。路径职责Core/Src/main.c系统入口初始化外设并启动主循环Core/Src/tim.c定时器配置生成156.25us触发信号Core/Src/adc.cADC与DMA配置采集电压、电流通道App/measure.c有效值计算、功率计算、电量累计App/storage.cFlash磨损均衡与电量掉电保存App/display.cLCD1602显示驱动与页面切换App/comm.c串口帧组装、校验与发送MDK-ARM/Keil工程文件直接用MDK打开main.c里的初始化顺序一般是配置系统时钟初始化GPIO、ADC、DMA、定时器、串口、LCD和Flash。初始化完毕后进入第2章讲的那个主循环。如果烧录后LCD一直点不亮先检查main.c里LCD的初始化函数是否在循环前被调用我曾经因为把初始化放在了循环后面导致LCD上电延迟好几秒才能稳定显示。4.2 编译、烧录与上电验证跑通第一块板子用Keil MDK5打开MDK-ARM目录下的工程文件第一次打开有两个地方必须确认。一是Options for Target里的芯片型号工程默认是STM32F103C8T6如果你的板子是其他型号选择对应的Device并且在C/C选项卡里检查宏定义是否匹配。二是Debug选项卡里的下载器配置用ST-Link就选ST-Link Debugger在Settings里确认连接正常Flash Download里勾选Reset and Run这样烧录后芯片自动运行。编译烧录的完整动作是这样# 1. 打开工程后先编译一次确认没有语法错误 # Keil中点击 Build快捷键 F7 # 2. 连接ST-Link到板子的SWD调试口 # 注意四根线SWDIO、SWCLK、GND、3.3V接反会识别不到芯片 # 3. 点击 Load/F8 下载固件 # 成功后再次点击Reset按钮板子开始运行这里最常出现的问题是ST-Link识别不到芯片。SWD接口线序接反、板子没有独立供电、调试口被代码复用为普通GPIO都会导致连接失败。排查时先在设备管理器里看ST-Link是否被识别为串口设备再用万用表确认板子3.3V供电正常最后检查代码里是否把SWD引脚重新配置成了别的功能如果有下载时会报Flash Download failed。提示下载前先确认工程里勾选了Use MicroLIB。这个配置会影响printf和浮点库的链接行为没勾选时如果代码里用了printf链接阶段很容易报错。上电后的正常现象是LCD背光亮起第一行显示实时电压和电流第二行显示功率和累计电量用USB转串口连接USART1引脚串口助手每秒收到一行数据。如果看到LCD有显示但数据全为零多半是采样通道接错或者偏置电压没供上检查硬件连接比反复改代码更有效。4.3 串口协议联调与电压标定用数据说话串口上报采用最朴素的文本协议每一行是一条独立记录便于直接用串口助手观察也方便上位机按行解析。帧格式如下字段含义示例V电压有效值单位VV,220.5I电流有效值单位AI,0.32P有功功率单位WP,70.6E累计电量单位WhE,12.34comm.c里组帧发送的实现很短核心是格式化字符串再整包发出void comm_send_report(float v, float i, float p, float e) { char line[64]; int len sprintf(line, V,%.1f,I,%.3f,P,%.1f,E,%.2f\r\n, v, i, p, e); HAL_UART_Transmit(huart1, (uint8_t *)line, len, 100); }HAL_UART_Transmit的最后一个参数是超时时间单位毫秒。串口波特率115200时64字节的帧大约5毫秒能发完100毫秒的超时足够。如果数据量增加或者波特率降到9600超时时间要相应调大否则发送函数会返回超时错误主循环被阻塞采样的标志位就得不到及时处理。标定是串口数据真正可信的最后一关。先用万用表测量220V插座的实际电压比如测到223.5V再看串口上报的电压值。如果显示215.3V说明分压或者系数偏小。把V_SCALE乘以223.5/215.3重新编译烧录读数就会贴合实际。这个比例修正方法同样适用于电流通道电流通道需要一个精度较高的纯阻性负载作为基准源。5. 实战避坑计量不准、串口乱码与复位重启代码能跑只是第一步真正折磨人的是那些看起来在正常运行、但数据偶发异常的现象。这一章记录我在跑这个工程时踩过的几个坑每一条都花了半天以上排查写出来给后面的人省点时间。5.1 电流互感器空载时读数跳变现象插座上什么都没接串口上报的电流却在0.02A到0.08A之间来回跳功率跟着出现零点几瓦的抖。接上负载后读数反而正常。原因互感器的次级开路或者采样电阻太小励磁电流带来的噪声叠加在采样信号上更普遍的是偏置电压和零点没有校准ADC采到的小幅噪声被当作信号计算进了有效值。解决给电流通道做死区处理当AD有效值换算出的电流低于0.02A时直接归零同时对通道做一次空载校准把空载时算出的偏置AD值记录下来后续计算统一减去这个偏移。死区阈值要结合互感器的实际噪声水平调整我一般先看空载时上报值的最大抖动幅度再用两倍的余量作为死区门槛。5.2 串口输出乱码现象串口助手收到的数据是乱的偶尔一行正常下一行中间就多出几个符号板子重启后更严重。原因波特率不匹配是最常见的原因。工程里波特率配置在115200串口助手却设置成9600当然会乱。还有一种情况是STM32的外部晶振没有起振或者频率不对HAL库按外部晶振计算出的系统时钟和实际不符导致串口实际发出波特率偏差过大。解决先确认串口助手和工程代码的波特率一致。若一致仍乱码检查板子上的晶振是8MHz还是其他频率并核对工程里HSE_VALUE宏是否一致。临时验证时可以把时钟源切换到内部HSI再用一份确定能跑的串口测试代码对比输出逐步缩小排查范围。5.3 写入Flash导致程序卡死现象电量累计到0.01度程序执行存储时立刻复位复位后电量数据丢失串口上电日志重来一遍。原因写Flash期间发生了中断或者写地址超过了扇区范围。Flash擦写在持续时间内要求CPU保持稳定任何中断进入都可能导致控制状态错乱触发HardFault复位。解决在storage_save里关闭全局中断写完再打开。同时检查扇区地址计算确保写地址落在合法Flash范围内并预留一个扇区专门存放电量数据避免和代码段重叠。这个问题排查时可以临时在写Flash代码前后加GPIO翻转用逻辑分析仪确认写入窗口内确实没有中断闯入。5.4 电压采样噪声导致功率抖动现象接一个稳定负载功率显示在小范围内波动但电压和电流单独看幅度都很稳定误差只有一两个字。原因电压和电流的采样时刻没有严格对齐。DMA搬运在时间上会偏一点或者两个通道进ADC的时间顺序不够紧密导致电压和电流采样点对应的不是同一时刻瞬时功率计算出来后抖动被放大。解决确认ADC的两个通道配置在同一个注入组让电压和电流采样尽量背靠背完成同时把瞬时功率的平均窗口拉长到一秒。功率是瞬时功率在一段工频周期上的平均窗口足够长之后相位偏差带来的影响会被平均掉读数自然稳定下来。5.5 按键清零后电量掉电又恢复现象长按按键把累计电量清零LCD也显示为0但断电再上电之前的电量值又回来了。原因清零操作只改了RAM里的电量累加量没有同步更新Flash里已保存的电量值重新上电后系统从Flash恢复了旧值。解决清零动作要走与正常保存相同的存储接口。在RAM里把电量置0的同时调用storage_save(0)把0值写入Flash当前槽位。这里还要考虑一个顺序问题先存0再清RAM否则存储回调会读到刚被清零的RAM值把它覆盖到Flash里效果上反而达不到清零目的。6. 二次开发把本地上报改成WiFi远程抄表跑通这个工程后最容易想到的改造就是加一个无线通信模块把电表数据从串口搬到远端平台。我给这份工程加过ESP8266模块改造思路不算复杂新增一个wifi.c把comm.c里上报的那一行字符直接用另一个串口透传给ESP8266再把ESP8266收到的下行数据解析出来执行控制命令。// wifi.c把已经组好的上报帧推给ESP8266透传 void wifi_send_line(char *data, int len) { // 透传模式下ESP8266会把串口收到的数据直接发到远端 HAL_UART_Transmit(huart2, (uint8_t *)data, len, 200); }前提是ESP8266用AT指令切换到了透传模式相关的AT指令序列可以放在wifi_init里执行。如果需要走MQTT协议还要先在模组上完成MQTT连接再把数据发布到对应主题上。我实际改造时遇到一个很隐蔽的问题ESP8266串口的波特率和原有USART1共用了同一个时钟配置改了其中一路另一路也跟着变最后换了一个支持独立波特率配置的串口方案问题才彻底解决。另一个更务实的改造方向是换掉内部ADC方案改用带串口输出的电能计量芯片来做计量。这类芯片内部集成高精度ADC和自动校准MCU只需要通过串口读寄存器计量精度和稳定性都会上一个台阶。改动范围主要集中在App层把measure.c里计算有效值的函数替换为芯片寄存器的读取解析显示和通信模块完全不用动。从那以后我每次做类似改造都强制自己先画一遍数据流再动代码把采样、计算、上报这条链路的接口先列清楚再开始写新模块。这样一个原则帮我避开了大量低级返工。希望这份拆解能帮你把这个电表工程跑顺也能让改代码的时候少走几步弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑