最近在做一个市电监测类设备,整套通信链路刚跑通:前端是一块私有串口协议的计量采集模块,主控用ESP32,对外通过WiFi提供Modbus从站服务,上位机可以直接读到电压、电流、功率这些电气参数,还能拿到断电、来电、电压暂降这类市电事件记录。看着不复杂,但从硬件选型、协议设计、事件处理到现场联调,中间确实踩了不少坑。这篇文章把整套方案从头到尾捋一遍,包括为什么选这个架构、私有串口协议怎么定、Modbus寄存器怎么映射、市电事件怎么处理,以及最后联调阶段那些容易让人折腾半天的细节,给准备做类似项目的朋友一个参考。
这个架构适合的场景很明确:现场没有现成的RS485总线,但WiFi覆盖是现成的;上位机或者网关希望用标准Modbus协议来读取数据;设备需要记录市电相关的瞬态事件。如果你正在做的项目正好符合这几条,那这篇应该能帮你省不少时间。
1. 市电监测项目为什么要做成“Modbus从站 + WiFi + 私有串口”
1.1 现场需求到底有哪些
做市电监测,首先要搞清楚要采集什么、要上报什么。我接触到的绝大多数项目,核心需求集中在三块。
第一块是稳态电气参数:电压有效值、电流有效值、有功功率、频率。这些数据是上位机做能耗统计和状态监控的基础,通常周期性刷新,比如每秒更新一次就行。
第二块是市电事件:断电、来电、过压、欠压、电压暂降、频率异常。这类事件的特点是突发性强、持续时间短,不能靠上位机轮询去“碰运气”发现,必须由设备端自己检测并记录下来,等主站来读。
第三块是事件记录的可追溯性:每个事件要有类型、发生时间、必要时还有持续时长。也就是说设备内部得维护一个小型的事件日志,断电重启之后数据不能丢。
这三块需求放在一起,就决定了设备形态:前端必须有能检测市电状态的硬件,主控必须能跑协议栈和逻辑处理,存储上还得留一块非易失区域给事件日志。
1.2 通信方案对比:为什么不是RS485或以太网
现场没有485总线,重新布线成本太高,尤其配电柜这种地方,走线麻烦还不安全。以太网在一些老旧厂房里也不一定覆盖到设备安装位置。WiFi反而是最现实的选项——现场本来就有无线网络,设备只要能连上去,上位机就能通过网络访问。
但光有WiFi还不够,上层还得跑应用协议。Modbus在这类场景里几乎是事实标准,工控屏、组态软件、能源管理平台基本都支持。让设备直接作为Modbus从站接入,上位机那边不需要开发专门的驱动,组态软件里配一下IP和寄存器地址就能用。
1.3 三层结构的职责划分
这套系统典型的架构是三层:
最底层是市电采集层,也就是前端计量模块。它直接接触强电,负责把电压、电流、功率等参数算出来。很多计量芯片和外置模块采用自定义串口协议上报数据,这就是标题里“私有串口”的来源。
中间层是主控,也就是ESP32。它的任务有三个:通过私有串口协议周期性获取电气参数;处理市电事件检测逻辑,比如断电、来电、过压欠压;对外提供Modbus从站服务,维护寄存器映射表,响应主站请求。
最上层是客户端,包括上位机软件、触摸屏或者边缘网关。它们通过Modbus TCP协议,读取设备寄存器里的实时数据和事件记录。
这个三层结构的好处是每一层都可以独立替换。前端计量模块换成别的品牌,只要串口协议能解析,主控代码改一小块就行;上位机从组态软件换成自研系统,只要走标准Modbus协议,设备端完全不用动。
2. 硬件基础:主控、市电检测与断电缓冲这三件事怎么搭
2.1 主控选型:为什么选了ESP32而不是其他方案
主控的选择,核心看三点:WiFi协议栈成熟度、外设资源、开发效率。ESP32在这三个维度上表现都不错,双核240MHz跑Modbus协议栈和业务逻辑绰绰有余,UART资源也够用,外接计量模块不冲突。
ESP8266我也试过,跑WiFi协议栈没问题,但只有一个UART可用的时候,接计量模块再想留一个调试口就很紧张。而且内存资源比较吃紧,Modbus TCP要处理多客户端会话,加上WiFi协议栈占用的内存,容易出现内存碎片。ESP32的性价比虽然比ESP8266高一些,但换来的是开发体验和稳定性,值这个差价。
如果你用的主控是STM32加外置WiFi模块,比如ESP-01S或者W5500,也不是不行,但得自己处理AT指令交互或者TCP协议栈,工作量会大不少。ESP32的优势在于单芯片搞定,省去了主控和WiFi模块之间的通信链路设计。
2.2 市电检测电路:隔离方案与检测逻辑
市电检测首先要解决安全隔离问题。最常用的方案有两种。
一种是利用计量模块内部的隔离,比如HLW8032、BL0942这类方案,芯片内部有隔离机制,通过UART输出参数时已经是安全侧的数据。主控只需要处理串口接收,不需要直接接触强电。
另一种是自建检测电路,用交流变压器降压后用ADC采样,或者用光耦做过零检测。这种方案成本低,但电路设计和安全间距得自己把控,走线、爬电距离、光耦隔离等级的选型都要注意,新手容易在这个环节出问题。
我在实际项目里用的是计量模块加独立的光耦过零检测电路,双保险。计量模块提供电压电流功率数据,光耦电路做精确的过零检测,用于判断断电和频率异常。断电检测这个功能很关键——市电一旦断开,计量模块自然就收不到数据了,但光耦的输出电平变化是即时的,可以用来触发中断,让主控立刻进入事件处理流程。
2.3 断电缓冲电路:掉电保存的几十毫秒窗口
市电事件处理里,断电瞬间往往是时间窗口最紧的环节。市电从正常到完全掉电,主控依靠储能电容能撑多少时间,直接决定了事件保存策略怎么写。
理论上,掉电保持时间的估算公式是:t = C × ΔV / I。假设主控加外设总电流是80mA,允许电压从5V跌落到4.5V,那么要保持50ms,需要的电容容值大约是:C = 0.08A × 0.05s / 0.5V = 8000μF,也就是8000微法级别。这个容值不算夸张,用一个4700μF加一个3300μF并联就够。
但这里有个实测中容易忽略的点:WiFi模块发射瞬间峰值电流很大,ESP32在WiFi发射时电流可能冲到300mA以上。如果储能电容的ESR偏高,瞬时大电流会导致电压跌落超过预期,MCU可能在保存事件的过程中就复位了。解决办法是并联一个100μF左右的低ESR陶瓷电容加上若干钽电容,同时尽量让断电保存操作避开WiFi发射窗口。
还有一点,Flash写入是有寿命的,如果每次断电都往Flash写事件,反复断电次数多了Flash容易坏。后面会详细说怎么用分层存储的思路来解决这个问题。
3. 私有串口协议:前端采集模块和主控之间如何“讲同一门语言”
3.1 为什么会有私有串口协议
真正做项目的时候,你会发现市面上很多计量模块、采集前端,给用户的接口就是串口,但协议是厂商自己定义的。这些私有协议往往没有公开文档,需要申请或逆向分析才能拿到协议格式。
另一种情况是自己做采集板。当你的前端MCU要把电压、电流、功率、事件标志打包发给主控时,自己定义一套简单可靠的帧格式,比套用Modbus RTU在效率和资源占用上更有优势。尤其前端MCU性能有限,跑标准Modbus协议栈是负担,私有协议的代码量可以压得很小。
3.2 帧格式怎么设计才够用
我自己的帧格式是这么定的:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定为0xAA 0x55,用于同步 |
| 地址 | 1 | 前端模块地址,0x01 |
| 命令 | 1 | 0x10读参数,0x20读事件 |
| 长度 | 1 | 数据域长度 |
| 数据 | N | 实际数据 |
| 校验 | 2 | CRC16,低字节在前 |
| 帧尾 | 1 | 0x0A |
帧头用两个字节而不是一个,是为了降低误同步概率。如果只用一个字节,串口上出现相同字节噪声时,接收端很容易被误导。两个固定字节组合,误同步的概率大幅下降。
长度字段一定要加,这能帮助接收端判断什么时候一帧数据结束了。配合帧间隔判断,双管齐下,解析更稳。
校验我选了CRC16而不是累加和。计量数据对准确性要求高,万一某个字节在传输中出错,累加和这种弱校验可能发现不了,CRC16基本能覆盖绝大多数的错误场景。
3.3 状态机解析和帧间隔判断
串口收到的是一堆字节流,不可能等整帧到齐再处理,也不能每收到一个字节就从头开始重新拼。正确做法是用状态机解析,逐字节状态迁移。
状态机的大致逻辑是:空闲状态下等待帧头第一个字节;收到0xAA后进入等待第二个字节状态;收到0x55则进入接收地址和命令字段;以此类推。每个状态对应一个偏移位置,解析到数据域时按长度字段读取。只要中间某个字节不符合预期,状态机立刻回到空闲,丢弃这一帧,等待下一个帧头。
帧结束判断上,我参考了Modbus RTU的3.5个字符时间间隔思路。串口通信双方波特率一致的情况下,一帧数据内部相邻字节的间隔不会超过特定时间,超过这个时间就说明这帧数据结束了。以9600波特率为例,一个字符约1.04ms,3.5个字符间隔大约3.6ms。这个时间可以用定时器来测,但更简单的做法是,收到长度字段后按字节数等待,达到预期长度就认为帧结束。两者配合,抗干扰能力最好。
3.4 超时重发与市电瞬断的影响
前端模块和主控之间通常采用查询-应答模式,主控发一条读命令,前端回一帧数据。如果前端没响应,主控需要超时重发。
超时时间设置建议在20ms到50ms之间。太短了,前端还在处理命令,主控就等不及重发,串口上全是重复指令;太长了,实时数据刷新周期被拉长,上位机看到的电压电流曲线不平滑。
重发次数一般设3次。超过3次还没响应,基本可以认为前端模块异常或者通信链路断了,这时候主控应该置一个通信故障事件,而不是一直重试浪费CPU。
市电瞬断对串口的影响有点隐蔽。市电跌落瞬间,计量模块的电源电压也可能跟着抖动,模块输出的串口数据可能缺字节或者出现半帧。解析端这边要做的是不把残帧当有效帧处理——校验不过就丢弃,等下一帧重试。绝对不能因为收到半帧就记录下来,否则Modbus寄存器里会出现瞬时跳变的毛刺数据,上位机那边会以为是真实的电压波动。
4. Modbus从站映射与WiFi通信:上位机拿到数据的那条路
4.1 为什么直接跑Modbus TCP而不是Modbus RTU over TCP
一开始我也纠结过,设备对外提供的是Modbus TCP好,还是把RTU帧封装到TCP里好。实际测试下来,直接跑Modbus TCP是最省事的。
原因在于Modbus TCP自己带了MBAP头,包含事务处理标识符和长度字段,应用层不需要再做CRC校验,错误处理比RTU简单得多。RTU方式要到CRC计算和帧定界,在TCP这种可靠传输协议里属于重复劳动,而且实现得不仔细还会引起兼容性问题。
更重要的是,现代上位机软件和组态系统对Modbus TCP支持得非常完善,配置一个设备连接只需要填IP地址和端口号,端口号默认502。如果走RTU over TCP,很多组态软件反而不认。
4.2 协议栈选型:移植还是自研
Modbus TCP从站的实现,有三种路线。
第一种是移植libmodbus,这是业界使用最广的开源库,支持RTU和TCP模式。如果你用的是Linux系统,libmodbus是首选。但ESP32跑的是FreeRTOS,移植libmodbus需要注意线程安全和套接字阻塞问题,工作量不小。
第二种是用Arduino生态的Modbus库,比如ModbusIP这类库。优点是代码量小,API简单,跑个Demo很快。缺点是功能相对基础,多客户端并发处理、异常响应码这些扩展能力弱一些。
第三种是自己实现。Modbus TCP从站的协议栈其实并不复杂:监听502端口,接收请求帧,解析出地址、功能码和数据,查寄存器表,构造响应。这个流程用C语言实现大概四五百行,如果对协议细节比较熟,自己写反而更可控。
我最后用的是自研方案,原因很简单:Modbus寄存器表要跟业务逻辑深度绑定,比如事件计数清零、时间戳更新这些操作,需要在协议栈代码里直接调业务函数。用现成库的话,还得在回调函数里做一堆类型转换和判断,反而绕。
4.3 寄存器映射表设计
寄存器映射是Modbus从站的核心设计文档,相当于把设备的所有数据点翻译成一张表格。我的寄存器表分了三组区域:
| 地址范围 | 寄存器类型 | 内容说明 |
|---|---|---|
| 0x0000 - 0x0009 | 保持寄存器 | 电压、电流、功率、频率等实时数据 |
| 0x0010 - 0x001F | 保持寄存器 | 事件类型、事件时间戳、事件序号 |
| 0x0020 - 0x0023 | 保持寄存器 | 系统配置:从站地址、波特率、对时时间戳 |
我把所有数据都放在保持寄存器里,功能码统一用03(读保持寄存器)和06(写单个寄存器)。这样做的好处是上位机配置简单,不用区分哪些寄存器支持读、哪些支持写。有些设备把实时数据放在输入寄存器(功能码04),把配置放在保持寄存器(功能码03),这样更规范一点,但对上位机来说要多配一组寄存器区,反而麻烦。
32位的数据,比如功率值,要用两个寄存器拼接。这里必须统一字节序,我采用的约定是:高16位在前(大端模式),也就是寄存器N存高字,寄存器N+1存低字。这个约定必须在文档里写清楚,联调时很多数据对不上,90%是字节序问题。
4.4 WiFi连接管理与多客户端并发
WiFi这块,我设备实际工作用的是STA模式,也就是连接现场的无线网络。为了方便现场调试,我还保留了一个AP模式,设备自带热点,直接用电脑连接热点来配置和读取数据。两个模式可以共存,ESP32支持同时启用STA和AP。
断线重连这里有个容易踩的坑:如果设备一直连不上WiFi,主控不能无限阻塞在连接过程里。我的做法是启动后先尝试连接,超时10秒就切换到AP模式待命。AP模式下如果有人通过串口或调试页面手动触发重新扫描,再切回STA模式重连。这样既保证了设备不会因为网络问题变成“砖头”,又能在正常情况下稳定上报数据。
重连策略上,我用的是指数退避方式:第一次重连失败后等1秒,第二次等2秒,第三次等4秒,最多等30秒。这个策略避免设备在WiFi信号弱的时候疯狂扫描、耗电又占用天线资源。
Modbus TCP是支持多客户端同时连接的。现场很常见的场景是:组态软件在上位机上一直以1秒一次的频率轮询实时数据,同时调试人员拿着Modbus Poll工具在查看事件日志。我的设备端维护了两个TCP槽位,能同时处理两个客户端的请求。客户端异常断开时要能及时回收连接资源,否则槽位被占满后新客户端连接不进来。
5. 市电事件处理:断电、来电、暂降这些瞬间该怎么记录
5.1 事件类型与阈值设置
市电事件处理,第一步是明确哪些事件要记录、触发阈值是多少。我定义了几类常用事件:
| 事件类型 | 编码 | 触发条件 |
|---|---|---|
| 断电 | 0x01 | 市电电压低于断电判定阈值,持续超过500ms |
| 来电 | 0x02 | 市电电压恢复正常,持续超过3秒 |
| 过压 | 0x03 | 电压有效值超过额定值10%,持续1秒 |
| 欠压 | 0x04 | 电压有效值低于额定值10%,持续1秒 |
| 暂降 | 0x05 | 电压有效值瞬时跌至额定值80%以下,持续20ms以上 |
| 频率异常 | 0x06 | 频率超出50±0.5Hz范围,持续1秒 |
阈值设置的逻辑要避免“误报”和“漏报”的矛盾。如果断电判定阈值设得太灵敏,市电轻微跌落就触发断电事件,设备会频繁记录无效事件;设得太迟钝,真正的断电可能被漏掉。我的做法是加持续时间过滤:瞬时抖动不给记录,只有持续超过一定时间才判定为有效事件。这个“去抖”时间参数可以在Modbus寄存器里配,现场调试时可以动态调整。
5.2 断电瞬间的保存流程
断电事件是整个系统里最考验设计的一环。市电一断开,主控靠储能电容还能工作几十毫秒,在这段时间里要完成事件记录和保存。流程必须精打细算:
检测到断电触发中断后,主控先读取RTC时间戳,把事件类型、时间戳、事件序号组装成一条完整记录。然后判断这条事件是真的“新事件”还是重复上电后的重复标记,如果是新事件,写入事件队列。
Flash写入一条事件记录,需要考虑两个问题:一是写Flash耗时,即使调库函数,擦除和编程时间加起来可能达到几十毫秒;二是Flash擦写寿命通常只有一万到十万次,频繁掉电写入会加速损耗。我采用了混合存储策略:断电瞬间先把事件写入RTC RAM区域,这里没有擦写寿命限制,写入速度极快。设备正常运行时,再把RTC RAM中的事件批量搬运到Flash。这样既保证了断电事件的可靠性,又减少了Flash擦写次数。
如果你用的主控没有RTC RAM,也可以用外部EEPROM。EEPROM写一个字节大概几毫秒,比Flash快很多,而且寿命通常也更高。但要做好写入失败检测,掉电过程中电压不稳可能导致写入不完整。
5.3 时间戳获取与掉电后的对时问题
事件记录如果没有时间戳,价值就大打折扣。设备正常联网工作时,我通过NTP服务器对时,每天凌晨自动校准一次RTC,误差能控制在几百毫秒内。
问题出在断电场景:设备断电后RTC如果靠电容或电池保持供电,时间还能继续走;如果没有后备电源,RTC时间在设备完全掉电后会丢失或者走偏。来电后设备重新上电,第一时间必须重新对时,否则后续记录的事件时间戳都是不准的。
我的方案是双保险:设备内置RTC,用一块CR2032纽扣电池做后备电源;同时在上位机侧做一层纠错逻辑,设备每次触发的来电事件,会带上一个“设备本地时间”和“上电后NTP同步时间”两个字段。上位机拿到数据后,根据两个字段的差值对历史事件时间戳做偏移修正。这样一来,即使断电时间很长导致RTC偏差,最终落库的事件时间也能校准到秒级。
5.4 事件队列和来电补报机制
事件日志用了环形缓冲区设计,每个事件记录固定占用8个字节:2字节类型、4字节时间戳、2字节序号。环形缓冲区一共能存64条记录,满了之后新事件覆盖最旧的一条。
这里有个关键设计:事件序号是单调递增的,重启后从Flash中的最后序号+1继续。上位机可以根据序号发现是否有事件被覆盖掉。如果上位机发现64条记录外的序号缺失,就知道中间有事件被覆盖了,至少能知道“有缺失”这件事本身。
来电补报机制是整个事件的最终闭环。市电恢复后,设备重新上电初始化,首先读取Flash中的事件日志,把断电事件状态置为“待上报”。这个待上报标志会映射到Modbus寄存器的某个位。上位机轮询到这个标志后,主动读取事件日志区域,读完以后往事件确认寄存器写一个确认命令,设备才清除待上报状态。这套一读一确认的流程能确保断电事件不会因为上位机刚好离线而永久丢失。
6. 联调环节:Modbus工具的使用方法与现场踩坑记录
6.1 Modbus Poll和Modbus Slave的调试技巧
联调最常用的两个工具是Modbus Poll和Modbus Slave。很多初学者容易搞混两者的角色,其实用途很清晰:Modbus Poll模拟主站,用来读取和写入从站寄存器;Modbus Slave模拟从站,用来模拟一个Modbus设备,测试主站软件的读取是否正确。
调试这套设备时,我开发到一半先用Modbus Poll直接连接ESP32的IP地址和502端口,读取寄存器值。如果部分寄存器读不出来,先用Modbus Slave模拟一个从站,把同样的IP端口指向Modbus Slave,排查是不是设备端协议栈有bug,还是上位机配置有问题。这样把问题定位分成两段,效率高很多。
Modbus Poll还有一个好用的功能是连续轮询,可以设置轮询间隔,比如1000毫秒读一次。联调时我习惯把轮询间隔设成500毫秒,这样能更快发现寄存器值跳变或者通信中断的问题。如果500毫秒间隔下数据连续几轮没有更新,基本能判断通信链路出现了异常。
6.2 字节序、从站地址、端口冲突这几个典型坑
第一个坑是32位数据的字节序。功率值占两个寄存器,设备端按大端发送,上位机如果配置成了小端解析,读数就会是一堆乱码。这类问题排查起来特别耗时,因为上位机界面只显示一个数值,根本看不出是哪一段出了问题。我的经验是偏僻先把一个已知值写入寄存器,比如0x12345678,再用Modbus Poll读出来看字节顺序是否一致。
第二个坑是从站地址冲突。多个Modbus设备在同一个网络上时,如果地址重复,主站请求就会同时被多个设备响应,导致通信完全错乱。处理方案是设备上支持通过WiFi配置页面修改从站地址,并且把地址存储在Flash里,避免出厂地址固定后现场无法更改。
第三个坑是端口占用。Modbus TCP默认端口502在Linux系统上需要root权限绑定,如果主控端跑的是非root进程,监听502会失败。有时候设备跑着跑着502端口被其他进程占用,从站就悄悄失联了。联调时先确认下端口监听状态,能省很多排查时间。
6.3 几个实测中的反直觉现象
联调过程中有几个现象,当时印象很深。
一个是WiFi休眠导致的连接断开。ESP32默认开启了WiFi省电模式,功耗是低了,但长时间不通信后Modbus TCP连接会被路由器或者对端主动断开。表现为数据停了十几秒,重新轮询又能读到值。最后解决方式是在代码里直接禁用了WiFi省电模式,牺牲一点功耗换通信稳定性。
另一个是断电事件的时间戳比预期晚了几个小时。排查后发现设备上电后NTP对时虽然成功了,但因为上电瞬间天线信号弱,NTP请求走的是备用服务器,返回的时间戳有偏差。后来我在代码里加了多服务器轮询和单次对时结果的合理性判断——如果校准后的时间和本地RTC差超过30秒,就丢弃这次校准结果,下次再试。
还有一个是Modbus寄存器值出现周期性毛刺。用示波器抓串口波形才发现,前端计量模块在电压瞬变时输出的功率值会短时间跳变。原因是计量芯片的数据更新周期和主控采样周期不同步。最后在主控端加了一阶低通滤波,寄存器读数稳定了很多。
整套系统做完,回头看最关键的还是链路意识。从市电检测到串口采集,再到Modbus映射和WiFi传输,任何一环出了问题,上位机拿到的都是不准确的数据。调试Modbus从站我一直习惯用Modbus Poll和Modbus Slave双开,分别验证协议栈两端,哪边出错一目了然。后面如果想扩展到多台设备,可以再加一个设备发现机制,让上位机自动扫描局域网内的所有从站。目前这套方案在现场已经稳定跑了一段时间,实时性、断电事件上报的可靠性都能满足要求。