✔零知派(零知开源)是一个专为电子初学者/电子兴趣爱好者设计的开源软硬件平台,在硬件上提供超高性价比STM32系列开发板、物联网控制板。取消了Bootloader程序烧录,让开发重心从 "配置环境" 转移到 "创意实现",极大降低了技术门槛。零知开源编程软件,内置上千个覆盖多场景的示例代码,支持项目源码一键下载,项目文章在线浏览。零知派(零知开源)平台通过软硬件协同创新,让你的创意快速转化为实物,来动手试试吧!
✔访问零知实验室,获取更多实战项目和教程资源吧!
目录
[1.1 硬件清单](#1.1 硬件清单)
[1.2 接线方案表](#1.2 接线方案表)
[1.3 具体接线图](#1.3 具体接线图)
[1.4 接线实物图](#1.4 接线实物图)
[3.1 双层滤波与OCV补偿](#3.1 双层滤波与OCV补偿)
[3.2 融合SOC估算算法](#3.2 融合SOC估算算法)
[3.3 内嵌HTML网页与CSV导出](#3.3 内嵌HTML网页与CSV导出)
[3.4 LVGL监视器界面](#3.4 LVGL监视器界面)
[3.5 INA238 库函数](#3.5 INA238 库函数)
[4.1 操作流程](#4.1 操作流程)
[4.2 视频演示](#4.2 视频演示)
[5.1 寄存器操作](#5.1 寄存器操作)
[5.2 I2C通信协议](#5.2 I2C通信协议)
项目概述
本项目使用零知派ESP32 (主控芯片为ESP-WROOM-32 )和INA238 高精度数字功率监测芯片,实现一套完整的18650锂电池充放电实时监测系统。系统能够精确采集电池的电压、电流和功率数据,通过双层滤波算法 有效抑制ADC噪声,引入OCV内阻动态补偿 技术解决充电状态下电压虚高问题,并采用OCV电压法与库仑计融合算法实现高精度的SOC估算,误差控制在±5%以内。监测数据可通过ST7789彩色显示屏实时展示波形曲线,同时支持Web网页远程监控和CSV格式数据一键导出
项目难点及解决方案
问题描述:充电状态下电压虚高 → OCV内阻动态补偿
**解决方案:**采用欧姆定律建立OCV补偿模型 OCV = Vbus - I × R_int
一、系统接线部分
1.1 硬件清单
| 编号 | 组件名称 | 型号规格 | 数量 | 备注 |
|---|---|---|---|---|
| 1 | 主控板 | 零知派ESP32(ESP-WROOM-32) | 1 | 核心控制板 |
| 2 | 电流传感器 | INA238模块(R015分流电阻) | 1 | TI高精度功率监测芯片 |
| 3 | 显示屏 | ST7789 240×320 TFT彩屏 | 1 | SPI接口 |
| 4 | 散热风扇 | L9110H 直流电机驱动 | 1 | 用于负载/放电演示 |
| 5 | 按键 | 轻触按键 | 1 | 物理控制风扇开关 |
| 6 | 锂电池 | 18650锂电池 | 1 | 待监测目标电池 |
| 7 | 充电模块 | TP4056或其他锂电池充电板 | 1 | 演示充电状态 |
1.2 接线方案表
以下引脚定义严格依据
config.h中的宏定义
| ESP32引脚 | 连接目标 | 引脚功能 | 代码宏定义 |
|---|---|---|---|
| GPIO21 | INA238 SDA | I2C数据线 | I2C_SDA 21 |
| GPIO22 | INA238 SCL | I2C时钟线 | I2C_SCL 22 |
| GPIO25 | 风扇PWM控制端 | LEDC PWM输出 | FAN_PIN 25 |
| GPIO26 | 按键(一端,另一端接GND) | 内部上拉输入低有效 | KEY_PIN 26 |
| GPIO18 | TFT SCK | SPI时钟 | TFT_eSPI库文件定义 |
| GPIO23 | TFT MOSI | SPI主机输出 | TFT_eSPI库文件定义 |
| GPIO19 | TFT MISO | SPI主机输入 | TFT_eSPI库文件定义 |
| GPIO15 | TFT CS | SPI片选 | TFT_eSPI库文件定义 |
| GPIO2 | TFT DC | 数据/命令选择 | TFT_eSPI库文件定义 |
| GPIO4 | TFT RST | 复位 | TFT_eSPI库文件定义 |
注意:ST7789显示屏直插到ESP32扩展板上、无需单独接线
充放电的双向电流检测务必按照以下方式接线:
充电回路
| INA238引脚 | 连接目标 |
|---|---|
| VIN+ | 电池负极 |
| VIN- | 充电板OUT- |
充电时:电流从充电器OUT+流出 -> 电池正极/负极 -> 进入 VIN+ -> 采样电阻 -> 流向 VIN- 。此时电流方向为正 (+),系统识别为充电。
放电回路
| INA238引脚 | 连接目标 |
| VIN+ | 负载/直流电机 VCC正极 |
| VIN- | 负载 GND负极 |
|---|
放电时:电流从充电器OUT-流出 -> 进入 VIN- -> 流向 VIN+ -> 进入负载。此时电流方向为负 (-),系统识别为放电。
1.3 具体接线图
电池负极输出端接分流电阻(R015)的负载侧(VIN+),分流电阻另一端接VIN-并最终到GND

电流必须按照「从电池负极流出,经VIN+ → Rshunt → VIN- 形成回路」的完整路径流过差分感测通道
1.4 接线实物图

VBUS引脚不可悬空,必须正确连接到电源总线正极。INA238通过VBUS引脚直接采样总线电压以完成电压和功率测量,若悬空或接错位置,功率寄存器将无法参与计算,输出持续为零
二、安装与使用部分
2.1 开源平台-输入"INA238" 并搜索-下载代码自动打开

2.2 连接-验证-上传

2.3 调试-串口监视器

三、代码讲解部分
本项目代码采用模块化拆分:BatteryMonitor负责核心算法、DisplayHandler负责LVGL界面、FanController负责风扇PWM控制、WebHandler负责网络服务
以下重点讲解四个核心模块的实现:
3.1 双层滤波与OCV补偿
BatteryMonitor.cpp 是整个系统的核心算法文件,每1000ms执行一次完整的数据采集与处理流水线
cpp
void BatteryMonitor::update() {
unsigned long now = millis();
if (now - _lastMs < SAMPLE_INTERVAL) return;
float dtH = (now - _lastMs) / 3600000.0f;
_lastMs = now;
float rawV = _ina.getBusVoltage();
float rawI = _ina.getCurrent();
float rawP = _ina.getPower();
float avgI = _slidingAvg(rawI);
BattMode m = _detectMode(avgI);
_updateEMA(avgI, rawV, m);
// 库仑计(用原始电流)
if (rawI > CHARGE_THRESH) _d.chargeAh += rawI * dtH;
if (rawI < -CHARGE_THRESH) _d.dischargeAh += (-rawI) * dtH;
float ocv = rawV - _emaI * BATT_R_INT;
ocv = constrain(ocv, 2.50f, 4.25f);
uint8_t soc = _estimateSOC(rawV, m);
float tMin = _estimateTime((float)soc, m);
_d.voltage = rawV;
_d.ocv = ocv;
_d.current = rawI;
_d.emaCurrent = _emaI;
_d.emaVoltage = _emaV;
_d.power = rawP;
_d.soc = soc;
_d.timeEstMin = tMin;
_d.mode = m;
_d.uptimeSec = (now - _startMs) / 1000UL;
if (m == MODE_DISCHARGING && rawV < BATT_MIN_V) {
_sys = SYS_LOW_V;
DEBUG_PRINTLN("[WARN] 低电压保护触发");
}
_lastMode = m;
}
OCV 补偿公式
ocv = rawV - _emaI * BATT_R_INT本质是欧姆定律,参数BATT_R_INT = 0.15Ω为实测等效内阻,用户可根据实际接线校
充电时 _emaI > 0,rawV 包含了线路压降,减去 I×R 得到真实开路电压; 放电时 _emaI < 0,减去负值相当于加上电压,补偿负载拉低的总线电压
3.2 融合SOC估算算法
cpp
uint8_t BatteryMonitor::_estimateSOC(float busV, BattMode m) {
float ocv = busV - _emaI * BATT_R_INT;
ocv = constrain(ocv, 2.50f, 4.25f);
float socV = _ocvToSOC(ocv);
if (_socCoulomb < 0.0f) {
_socCoulomb = socV;
_socAnchorAh = _d.chargeAh - _d.dischargeAh;
} else {
float netAh = (_d.chargeAh - _d.dischargeAh) - _socAnchorAh;
_socCoulomb = socV + (netAh / BATT_CAPACITY_AH) * 100.0f;
if (m == MODE_STANDBY) {
_socCoulomb = socV;
_socAnchorAh = _d.chargeAh - _d.dischargeAh;
}
}
_socCoulomb = constrain(_socCoulomb, 0.0f, 100.0f);
float wV = (m == MODE_STANDBY) ? 1.0f : 0.3f;
float wC = (m == MODE_STANDBY) ? 0.0f : 0.7f;
float fused = wV*socV + wC*_socCoulomb;
return (uint8_t)constrain(fused+0.5f, 0.0f, 100.0f);
}
融合算法核心逻辑
OCV电压法,将补偿后的OCV电压通过10段分段线性表映射为SOC百分比;库仑计数法,从初始SOC基准点加减变化量得到当前SOC
3.3 内嵌HTML网页与CSV导出
WebHandler.cpp
通过
/data接口每1秒轮询JSON 获取全部监测数据,Canvas 实时绘制60点三通道波形。风扇控制通过/cmd接口实现开关/循环模式/PWM 占空比远程调节,/csv接口支持历史数据一键下载
cpp
void WebHandlerBattery::_onData() {
BatteryData d = Battery.getData();
String j = "{";
j += "\"voltage\":" + String(d.voltage, 3);
j += ",\"ocv\":" + String(d.ocv, 3);
j += ",\"current\":" + String(d.current, 3);
j += ",\"emaCurrent\":" + String(d.emaCurrent, 3);
j += ",\"power\":" + String(d.power, 2);
j += ",\"soc\":" + String((int)d.soc);
j += ",\"chargeAh\":" + String(d.chargeAh, 4);
j += ",\"dischargeAh\":" + String(d.dischargeAh,4);
j += ",\"mode\":" + String((int)d.mode);
j += ",\"timeEst\":" + String(d.timeEstMin, 1);
j += ",\"fan\":" + String(Fan.isRunning()?"true":"false");
j += ",\"pwm\":" + String(Fan.getDutyPct());
j += ",\"cycle\":" + String(Fan.isCycleMode()?"true":"false");
j += ",\"uptime\":" + String((unsigned long)d.uptimeSec);
j += ",\"heap\":" + String(ESP.getFreeHeap()/1024);
j += "}";
_srv.sendHeader("Access-Control-Allow-Origin","*");
_srv.send(200,"application/json",j);
}
void WebHandlerBattery::_onCmd() {
if (_srv.hasArg("fan")) {
String v = _srv.arg("fan");
if (v=="toggle") Fan.toggle();
else if (v=="on") Fan.setOn(true);
else if (v=="off") Fan.setOn(false);
DEBUG_PRINTF("[Web] 风扇: %s → %s\n", v.c_str(), Fan.isRunning()?"ON":"OFF");
}
if (_srv.hasArg("duty")) {
int pct = _srv.arg("duty").toInt();
Fan.setDuty((uint8_t)(constrain(pct,0,100)*255/100));
DEBUG_PRINTF("[Web] PWM: %d%%\n", pct);
}
if (_srv.hasArg("cycle")) {
bool en = (_srv.arg("cycle")=="on");
Fan.enableCycleMode(en);
DEBUG_PRINTF("[Web] 循环模式: %s\n", en?"ON":"OFF");
}
_srv.send(200,"text/plain","OK");
}
void WebHandlerBattery::_onCsv() {
String csv = "Timestamp_s,Voltage_V,Current_A,Power_W,SOC_pct\n";
for (int i=0; i<CSV_SIZE; i++) {
int idx=(_csvIdx+i)%CSV_SIZE;
if (_csv[idx].ts==0) continue;
csv += String(_csv[idx].ts)+"," +String(_csv[idx].v,3)+","
+String(_csv[idx].i,4)+","+String(_csv[idx].p,2)+","
+String((int)_csv[idx].soc)+"\n";
}
_srv.sendHeader("Content-Disposition","attachment; filename=batt.csv");
_srv.send(200,"text/csv",csv);
}
void WebHandlerBattery::_pushCsv() {
BatteryData d=Battery.getData();
_csv[_csvIdx]={millis()/1000UL, d.voltage, d.current, d.power, d.soc};
_csvIdx=(_csvIdx+1)%CSV_SIZE;
}
**JSON接口设计:**覆盖电压、电流、功率、OCV、EMA平滑值、SOC、充放电容量、时间估算、模式、风扇状态、PWM百分比、循环模式标志、运行时长和剩余堆内存
3.4 LVGL监视器界面
cpp
void DisplayHandler::_buildChart() {
lv_obj_t* card = makeCard(_scr, 0, 28, 202, 130, COL_CARD, COL_BORDER);
_chart = lv_chart_create(card);
lv_obj_set_size(_chart, 186, 116);
lv_obj_align(_chart, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_bg_color(_chart, lv_color_make(8,13,24), 0);
lv_obj_set_style_bg_opa(_chart, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(_chart, COL_BORDER, 0);
lv_obj_set_style_border_width(_chart, 1, 0);
lv_obj_set_style_radius(_chart, 3, 0);
lv_obj_set_style_pad_all(_chart, 2, 0);
lv_chart_set_type(_chart, LV_CHART_TYPE_LINE);
lv_chart_set_point_count(_chart, CHART_POINTS);
lv_chart_set_update_mode(_chart, LV_CHART_UPDATE_MODE_SHIFT);
// Y轴范围:-60~500(单位 0.01,覆盖 -0.6A~5.0V/A/W)
lv_chart_set_range(_chart, LV_CHART_AXIS_PRIMARY_Y, -60, 500);
// 分割线(网格)
lv_chart_set_div_line_count(_chart, 4, 8);
lv_obj_set_style_line_color(_chart, COL_BORDER, LV_PART_MAIN);
// 三路数据系列
_serV = lv_chart_add_series(_chart, COL_VOLT, LV_CHART_AXIS_PRIMARY_Y);
_serI = lv_chart_add_series(_chart, COL_CURR, LV_CHART_AXIS_PRIMARY_Y);
_serP = lv_chart_add_series(_chart, COL_POW, LV_CHART_AXIS_PRIMARY_Y);
// 用初始值填充,避免显示乱线
for (int i = 0; i < CHART_POINTS; i++) {
lv_chart_set_next_value(_chart, _serV, 0);
lv_chart_set_next_value(_chart, _serI, 0);
lv_chart_set_next_value(_chart, _serP, 0);
}
// 线宽
lv_obj_set_style_line_width(_chart, 2, LV_PART_ITEMS);
lv_obj_set_style_size(_chart, 0, 0, LV_PART_INDICATOR); // 隐藏点
// Y 轴刻度标签(放在 card 外侧,用 label 模拟)
_lblYMax = makeLabel(card, "5.0", COL_DIM, 0, 2, &lv_font_montserrat_10);
_lblYZero = makeLabel(card, "0", COL_DIM, 0, 56, &lv_font_montserrat_10);
_lblYMin = makeLabel(card, "-0.6", COL_DIM, 0, 110,&lv_font_montserrat_10);
}
LVGL内存控制策略
Draw buffer采用 SCREEN_W × 10 行 单缓冲、lv_chart固定60点三路数据系列、label使用LVGL内置Montserrat小字体、LV_MEM_SIZE 配置为32KB
系统流程图

3.5 INA238 库函数
getCurrent() 如何获得真实电流?
INA238库内部调用了 _readRegister(INA238_CURRENT, 2) 获得16位原始值 value,然后乘以 _current_LSB 得到实际电流
cpp
float INA238::getCurrent()
{
// remove reserved bits.
int16_t value = _readRegister(INA238_CURRENT, 2);
// int16_t handles negative values (16 bit)
float current = value * _current_LSB; // 8.1.2 formula (3)
return current;
}
int INA238::setMaxCurrentShunt(float maxCurrent, float shunt)
{
// Shunt can be really small
if (shunt < 0.0001) return -2;
if (maxCurrent < 0.0) return -3;
_maxCurrent = maxCurrent;
_shunt = shunt;
_current_LSB = _maxCurrent / (float)(1UL << 15); // pow(2, -15);
// _current_LSB = _maxCurrent * 3.0517578125e-5; // pow(2, -15);
// PAGE 28-29 (8.1.2)
float shunt_cal = 819.2e6 * _current_LSB * _shunt; // 8.1.2 formula (1,2)
// depends on ADCRANGE in INA238_CONFIG register.
if (_ADCRange == true)
{
shunt_cal *= 4;
}
// shunt_cal must be written to its REGISTER.
_writeRegister(INA238_SHUNT_CAL, shunt_cal);
return 0;
}
_current_LSB公式计算

该项目中:Max_Current = 5A,Rshunt = 0.015Ω,则 CURRENT_LSB ≈ 152.6 µA,SHUNT_CAL ≈ 1874。该值被写入芯片的 SHUNT_CAL 寄存器(0x02),芯片即按此比例输出电流值
INA238寄存器地址表
| 地址 | 寄存器名 | 位宽 | 本项目使用情况 |
|---|---|---|---|
| 0x00 | CONFIG | 16-bit | 芯片复位、ADC量程选择(ADCRANGE位) |
| 0x01 | ADC_CONFIG | 16-bit | 采样均值数、转换时间配置 |
| 0x02 | SHUNT_CAL | 16-bit | 关键:校准寄存器,决定电流精度 |
四、项目演示
4.1 操作流程
①硬件上电初始化

接通零知派ESP32的5V供电(USB或外部电源),系统自动完成INA238初始化、TFT屏幕初始化和WiFi热点启动。屏幕顶部标题栏显示"BATT MONITOR"即为初始化成功
②连接WiFi查看Web页面

手机或电脑搜索WiFi热点INA238_Monitor,密码12345678。浏览器访问http://192.168.4.1进入仪表盘页面,页面以1Hz频率自动刷新全部数据
③充放电演示
断开充电器并接入负载(如风扇),电流跃至负半轴、模式变为"DISCHG"

接入CSM4056充电模块对18650电池充电,观察屏幕波形区电流跃升至正半轴、模式变为"CHARGING"、SOC百分比缓慢上升
④数据导出

在Web仪表盘点击" CSV"按钮,浏览器自动下载历史数据CSV文件,可在Excel中直接打开进行趋势分析
4.2 视频演示
ESP32驱动INA238的18650锂电池充电监测系统
基于零知派ESP32和TI INA238高精度功率监测芯片,完整演示锂电池充放电实时监测系统的搭建与使用。涵盖硬件接线、TFT显示屏波形刷新、Web网页远程监控仪表盘、CSV数据一键导出整个操作流程。充放电状态自动识别,SOC融合算法精准跟踪,OCV内阻补偿消除充电虚高
五、INA238技术原理讲解
核心架构
INA238是一款超精密数字功率监测器,内置16位delta-sigma(Δ-Σ)ADC,专为电流检测应用设计。可测量±163.84 mV或±40.96 mV的全量程差分输入,共模电压支持范围从-0.3 V到+85 V
Δ-Σ ADC的核心原理是过采样 + 噪声整形:

**工作原理:**分流电阻Rshunt串入被测回路,电流I流过时产生压降Vshunt = I × Rshunt;INA238的内部Δ-Σ ADC对Vshunt进行16位高精度差分采样;同时VBUS引脚独立采样总线电压Vbus;内置乘法器计算功率P = Vbus × I
5.1 寄存器操作
①VSHUNT寄存器
INA238支持两种分流电压测量量程:±163.84 mV 或 ±40.96 mV,由CONFIG寄存器中的ADCRANGE位控制
本项目使用默认量程±163.84 mV(ADCRANGE=0),对应分辨率:

分流电压可以是正值或负值,因为系统中的电流是双向的,VSHUNT寄存器中的数据可以为正也可以为负

②POWER寄存器
VBUS寄存器的转换系数为3.125 mV/LSB,总线电压始终为正值,采用16位有符号但数值始终为正的存储格式
功率计算在芯片内部硬件完成

5.2 I2C通信协议
INA238使用标准I²C接口(最高1MHz),7位器件地址默认0x40(A0=GND, A1=GND时可配置为0x41~0x4F)
**I2C地址配置:**INA238的I2C地址由A0、A1两个引脚的接法决定:

I2C读取时序:主设备先发送从设备地址+寄存器指针字节,再发送或读取对应数据字节
写字节时序

从设备地址字节的值由A0和A1引脚的设置决定
读字节时序

读取数据来自最后一个寄存器指针位置;若需使用新寄存器,必须更新寄存器指针;主设备也可发送ACK确认信号
六、常见问题解答(FAQ)
Q1:波形图刷新太慢,卡顿明显?
*A:
SAMPLE_INTERVAL_MS设置过小(默认200ms)时,TFT全屏刷新和WiFi处理会竞争CPU。*建议:① 采样间隔不要小于200ms;② WebServer.handleClient()在loop里每帧都要调用,不能少;③ TFT_eSPI的SPI频率设置不要超过屏幕规格
Q2:网页数据不更新,一直显示0?
A:① 检查手机是否连接到了
INA238_Monitor热点; ② 浏览器输入http://192.168.4.1(注意是http不是https);③ 如果用了Safari或Chrome有时会缓存,尝试强制刷新;④ ESP32堆内存不足时服务器可能挂掉,串口查看HeapFree数值,建议保持>50KB
项目资源整合
INA238数据手册: INA238 datasheet
INA238库文件: RobTillaart/INA238
TFT_eSPI: Bodmer/TFT_eSPI