Chapter 76: Battery fuel gauge + charger¶
What: the three pieces of a battery-powered embedded product: a fuel gauge that tracks state-of-charge (Maxim MAX17048, I²C, “ModelGauge” algorithm), a charger that manages the CC/CV cycle (TI TP4056, analog, simple. Or TI BQ24074, I²C-configurable, path-managed), and the
power_supply_classframework that ties them into Linux. For each: physics, protocol, mainline driver, plus a from-scratch MAX17048 driver implementing thepower_supplyprovider model.Why: any battery-powered product needs to report “percent full” to the user accurately. The naive approach, voltage divider into ADC, lookup table, is wrong: Li-ion voltage doesn’t track SoC linearly, and load voltage drops badly bias the reading. A fuel gauge chip does the right thing: integrates current (coulomb counting) or models the cell (impedance tracking) to get sub-2 % SoC accuracy. Plus a charger that knows when to terminate.
Focus: The kernel exposes battery state to user-space through
power_supply_class, the same framework used by laptops, phones, and embedded boards. Drivers register aspower_supplyproviders. User-space (UPower, systemd-battery-monitor, your custom app) reads from/sys/class/power_supply/. Same shape for laptop batteries, e-bike packs, phones, embedded devices.
76.1 Chip comparison¶
Maxim MAX17048 |
TI TP4056 |
TI BQ24074 |
|
|---|---|---|---|
Function |
1-cell fuel gauge |
1-cell Li-ion charger |
1-cell path-managed charger |
Interface |
I²C (0x36) |
none (analog control) |
I²C (0x6B) |
Algorithm |
ModelGauge (impedance) |
linear CC/CV |
linear CC/CV, dynamic-power-path mgmt |
Charge current setting |
n/a |
resistor (≤ 1 A) |
resistor (≤ 1.5 A) |
Charge termination |
n/a |
C/10 |
C/10 + safety timer |
Power-path |
n/a |
no, battery powers load while charging |
yes, input powers load directly, battery only when input absent |
Idle current |
23 µA |
none |
100 µA |
Volume price |
$2.50–4.00 |
$0.30–0.80 |
$2–4 |
Mainline driver |
|
none (no I²C; status detected via GPIO) |
|
Pick guide:
MAX17048: lowest-cost real fuel gauge. Use unless you need coulomb-counting precision.
TP4056: cheap charger. Fine for “low-power device that mostly runs from battery.” No power-path = device loses power when battery dies, even with USB plugged.
BQ24074: production-quality charger with power-path. Device stays on with input even when battery is removed. Charger negotiates input current. The standard choice for production-grade hardware.
A complete battery system typically combines a charger + a gauge: TP4056 + MAX17048 is the cheap stack. BQ24074 + MAX17048 is the production stack.
76.2 The physics, why a fuel gauge is needed¶
A naïve “voltage → %” approach fails for two reasons:
Voltage isn’t linear with SoC. A Li-ion cell goes 4.2 V (100 %) → 3.7 V (50 %) → 3.4 V (10 %) → 3.0 V (cutoff). The middle plateau is flat, a small voltage range covers most of the capacity.
Voltage drops under load. The cell’s internal resistance (50–500 mΩ depending on age and chemistry) causes voltage to drop by
I × R_internal. A 500 mA load on a 100 mΩ cell drops the measured voltage by 50 mV, equivalent to ~5 % SoC on the plateau. If you read while a high-current load is active, you may tell the user 60 % when the resting SoC is actually 65 %.
Two real-world approaches:
Coulomb counting (TI BQ27xxx, MAX17042). Measure current with a shunt. Integrate over time. Subtract from a known-full capacity. Pros: accurate. Cons: needs full charge cycle to calibrate. Drift over time as full capacity changes with age.
Impedance tracking / ModelGauge (MAX17048). Build an internal model of the cell’s V/I/T/SoC relationship. Use measured V and I (in MAX17048’s case, just V, it estimates I from V swings) to look up SoC. Pros: no shunt needed. No full-charge required to calibrate. Cons: needs a per-chemistry pre-loaded model.
MAX17048’s main selling point: 23 µA standby current, no shunt resistor required, a factory-loaded “typical Li-ion” model, and SoC accurate enough for most consumer products.
76.3 Protocol, MAX17048¶
Register map (every register is 16 bits, big-endian):
Reg |
Name |
Purpose |
|---|---|---|
0x02 |
VCELL |
Cell voltage, 78.125 µV/LSB |
0x04 |
SOC |
State of charge, bits 15:8 = %, bits 7:0 = fractional 256ths |
0x06 |
MODE |
Quick-start trigger |
0x08 |
VERSION |
IC version (0x0011 for MAX17048) |
0x0A |
HIBRT |
Hibernate threshold |
0x0C |
CONFIG |
Rcomp + alert threshold |
0x14 |
VALRT |
Voltage alert min/max |
0x16 |
CRATE |
C-rate estimate (current as fraction of capacity) |
0x18 |
VRESET / ID |
Soft-reset trigger; chip ID |
0x1A |
STATUS |
Alert flags |
A reading¶
Host: START | 0x6C | 0x02 | START | 0x6D | (2 bytes MSB,LSB) | STOP
(0x36 << 1 = 0x6C for write; ... | 1 = 0x6D for read)
raw = (buf[0] << 8) | buf[1]; vcell_uV = raw × 78.125;, voltage in microvolts. Divide by 1000 for mV.
For SoC: soc_pct = buf[0]; soc_frac = buf[1]; so a reading of 0x52 0xC0 is 82 + 192/256 = 82.75 %.
Bring-up¶
Practically no init needed for default operation:
Read VERSION (0x08). Verify chip is alive.
(Optional) write MODE = 0x4000 to issue “quick-start”, forces a fresh SoC estimate, useful after a brand-new pack is connected.
Start reading VCELL and SOC.
The “ModelGauge” runs continuously inside the chip on its own 30-second cycle. You just read the output.
76.4 power_supply_class, how Linux models batteries¶
power_supply_class (in drivers/power/supply/power_supply_core.c) is the framework that ties together battery / charger / AC-adapter into one consistent user-space view.
A driver registers as one or more power_supply objects, each with a type (BATTERY, MAINS, USB, etc.) and a set of properties it can report:
enum power_supply_property {
POWER_SUPPLY_PROP_STATUS, /* CHARGING / DISCHARGING / FULL / NOT_CHARGING */
POWER_SUPPLY_PROP_HEALTH, /* GOOD / OVERHEAT / DEAD / OVERVOLTAGE / etc. */
POWER_SUPPLY_PROP_PRESENT, /* battery present (0/1) */
POWER_SUPPLY_PROP_VOLTAGE_NOW, /* µV */
POWER_SUPPLY_PROP_CURRENT_NOW, /* µA */
POWER_SUPPLY_PROP_CHARGE_NOW, /* µAh — coulomb counters */
POWER_SUPPLY_PROP_CHARGE_FULL, /* µAh capacity */
POWER_SUPPLY_PROP_CAPACITY, /* % (0..100) */
POWER_SUPPLY_PROP_CAPACITY_LEVEL, /* CRITICAL/LOW/NORMAL/HIGH/FULL */
POWER_SUPPLY_PROP_TEMP, /* deci-degrees C */
POWER_SUPPLY_PROP_TIME_TO_EMPTY_NOW, /* seconds */
POWER_SUPPLY_PROP_TIME_TO_FULL_NOW,
/* ... 40+ more */
};
The driver provides a single get_property callback that the framework calls with each property the user-space requests. User-space sees /sys/class/power_supply/<name>/:
[root@pa-mini:~]# ls /sys/class/power_supply/
battery ac
[root@pa-mini:~]# cat /sys/class/power_supply/battery/capacity
82
[root@pa-mini:~]# cat /sys/class/power_supply/battery/voltage_now
3852000
[root@pa-mini:~]# cat /sys/class/power_supply/battery/status
Discharging
The naming is standardised across phones, laptops, and embedded boards.
76.5 Writing a MAX17048 power_supply driver from scratch¶
Goal: register the chip as a POWER_SUPPLY_BATTERY and report at least capacity + voltage + status. ~200 lines.
mymax17048.c:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/power_supply.h>
#define REG_VCELL 0x02
#define REG_SOC 0x04
#define REG_VERSION 0x08
struct mymax {
struct i2c_client *client;
struct power_supply *psy;
struct mutex lock;
};
/* === Low-level I²C: 16-bit big-endian register reads === */
static int mm_read_reg(struct mymax *m, u8 reg, u16 *val)
{
int err = i2c_smbus_read_word_swapped(m->client, reg);
if (err < 0) return err;
*val = err;
return 0;
}
static int mm_read_voltage_uV(struct mymax *m, int *uV)
{
u16 raw;
int err = mm_read_reg(m, REG_VCELL, &raw);
if (err) return err;
/* MAX17048: 78.125 µV/LSB.
* voltage_uV = raw × 78.125 = (raw × 78125) / 1000 */
*uV = ((u32)raw * 78125) / 1000;
return 0;
}
static int mm_read_soc_permille(struct mymax *m, int *permille)
{
u16 raw;
int err = mm_read_reg(m, REG_SOC, &raw);
if (err) return err;
/* High byte = percent; low byte = fractional 256ths.
* Return as permille (parts per thousand) — power_supply_class uses integer % usually,
* but we'll return tenths and let CAPACITY round. */
int pct = (raw >> 8) & 0xFF;
int frac256 = raw & 0xFF;
/* permille = pct × 10 + frac256 × 10 / 256 */
*permille = pct * 10 + frac256 * 10 / 256;
return 0;
}
/* === power_supply_class callback === */
static enum power_supply_property mm_props[] = {
POWER_SUPPLY_PROP_PRESENT,
POWER_SUPPLY_PROP_VOLTAGE_NOW,
POWER_SUPPLY_PROP_CAPACITY,
POWER_SUPPLY_PROP_STATUS,
};
static int mm_get_property(struct power_supply *psy,
enum power_supply_property prop,
union power_supply_propval *val)
{
struct mymax *m = power_supply_get_drvdata(psy);
int err = 0;
int v;
mutex_lock(&m->lock);
switch (prop) {
case POWER_SUPPLY_PROP_PRESENT:
val->intval = 1; /* if we're talking to it, it's present */
break;
case POWER_SUPPLY_PROP_VOLTAGE_NOW:
err = mm_read_voltage_uV(m, &v);
if (!err) val->intval = v;
break;
case POWER_SUPPLY_PROP_CAPACITY:
err = mm_read_soc_permille(m, &v);
if (!err) val->intval = v / 10; /* round to % */
if (val->intval > 100) val->intval = 100;
break;
case POWER_SUPPLY_PROP_STATUS:
/* MAX17048 doesn't know charge direction directly — would need to
* monitor SoC trend or read CRATE. Stub: report UNKNOWN. */
val->intval = POWER_SUPPLY_STATUS_UNKNOWN;
break;
default:
err = -EINVAL;
}
mutex_unlock(&m->lock);
return err;
}
static const struct power_supply_desc mm_desc = {
.name = "battery",
.type = POWER_SUPPLY_TYPE_BATTERY,
.properties = mm_props,
.num_properties = ARRAY_SIZE(mm_props),
.get_property = mm_get_property,
};
/* === Probe / Remove === */
static int mm_probe(struct i2c_client *client)
{
struct mymax *m;
struct power_supply_config psy_cfg = {};
u16 version;
int err;
m = devm_kzalloc(&client->dev, sizeof(*m), GFP_KERNEL);
if (!m) return -ENOMEM;
m->client = client;
mutex_init(&m->lock);
err = mm_read_reg(m, REG_VERSION, &version);
if (err) return dev_err_probe(&client->dev, err, "version read failed\n");
if ((version & 0xFFF0) != 0x0010)
return dev_err_probe(&client->dev, -ENODEV,
"unexpected version 0x%04x\n", version);
dev_info(&client->dev, "MAX17048 ready, version 0x%04x\n", version);
psy_cfg.drv_data = m;
psy_cfg.of_node = client->dev.of_node;
m->psy = devm_power_supply_register(&client->dev, &mm_desc, &psy_cfg);
if (IS_ERR(m->psy))
return dev_err_probe(&client->dev, PTR_ERR(m->psy),
"power_supply register failed\n");
i2c_set_clientdata(client, m);
return 0;
}
static const struct of_device_id mm_of_match[] = {
{ .compatible = "linuxlearn,mymax17048" },
{ }
};
MODULE_DEVICE_TABLE(of, mm_of_match);
static const struct i2c_device_id mm_id[] = { { "mymax17048", 0 }, { } };
MODULE_DEVICE_TABLE(i2c, mm_id);
static struct i2c_driver mm_driver = {
.driver = {
.name = "mymax17048",
.of_match_table = mm_of_match,
},
.probe = mm_probe,
.id_table = mm_id,
};
module_i2c_driver(mm_driver);
MODULE_LICENSE("GPL");
DT:
&i2c1 {
fuelgauge@36 {
compatible = "linuxlearn,mymax17048";
reg = <0x36>;
};
};
Test:
[root@pa-mini:~]# insmod mymax17048.ko
[root@pa-mini:~]# ls /sys/class/power_supply/
battery
[root@pa-mini:~]# cat /sys/class/power_supply/battery/capacity
82
[root@pa-mini:~]# cat /sys/class/power_supply/battery/voltage_now
3852023
[root@pa-mini:~]# cat /sys/class/power_supply/battery/present
1
[root@pa-mini:~]# upower -i /org/freedesktop/UPower/devices/battery_battery
native-path: battery
power supply: yes
state: unknown
energy-percentage: 82%
voltage: 3.85 V
upower (the desktop daemon) auto-discovers the battery and presents it as a standard battery device. Same applies to systemd’s logind, fwupd, and many other userspace consumers.
To improve: implement STATUS by reading CRATE (register 0x16), a positive C-rate means charging, negative means discharging. Add it as a third property type the driver supports.
76.6 TP4056, the analog charger¶
TP4056 is a simple linear Li-ion charger:
USB 5V ──► VIN ┌─────────┐ BAT ──► Li-ion cell
│ │
│ TP4056 │ CHRG (active-low LED indicator)
│ │ STDBY (active-low LED indicator)
│ │ PROG (resistor to GND sets charge current)
└─────────┘
Charge current is set by R_PROG:
I_charge = 1200 / R_prog amps
R = 1.2 kΩ → 1 A. R = 2.4 kΩ → 500 mA, etc.
The chip does:
Trickle charge (8 % of programmed current) until cell > 2.9 V.
Constant current at programmed current up to 4.2 V.
Constant voltage at 4.2 V. Current tapers.
Termination when current drops to 10 % of programmed.
No I²C, no control. CHRG and STDBY pins drive LEDs (or GPIOs into the SoC for status detection).
To integrate with Linux: wire CHRG to a GPIO. In DT, declare a power_supply of type AC with this GPIO. The mainline framework gpio-charger.c does this generic pattern:
MCU bridge: Think of Linux GPIO like the same pin set/reset block you used on STM32, but accessed through a kernel subsystem that owns numbering, direction, interrupts, and user-space exposure. GPIO: General-Purpose Input/Output, a pin controlled as a digital input, output, or interrupt source.
charger {
compatible = "gpio-charger";
charger-type = "mains";
gpios = <&gpio4 5 GPIO_ACTIVE_LOW>; /* CHRG LED line */
charge-status-gpios = <&gpio4 6 GPIO_ACTIVE_LOW>;
};
/sys/class/power_supply/main_charger/online reads 1 when charging.
TP4056 limitations: No power-path. When battery is empty and USB is plugged in, the chip charges the battery but does not separately power the load. Voltage on BAT depends on cell state. For a Linux SoC needing 4.2 V min at 500+ mA, this can cause boot loops on a deeply-discharged battery.
76.7 BQ24074, production-quality¶
BQ24074 adds:
Power-path: input feeds VOUT directly when present. Battery feeds VOUT only when input absent. Device stays on regardless of battery state, as long as input is present.
Dynamic Power-Path Management (DPPM): monitors input voltage. If input collapses below the threshold (overloaded USB port), the chip reduces charge current to keep the load alive.
Configurable via I²C: programmed charge current, termination voltage, safety timer.
Pick BQ24074 for any product that must come up the moment a USB cable is plugged in, regardless of whether the battery is alive.
Mainline driver: drivers/power/supply/bq24190_charger.c covers the BQ241xx family. DT:
&i2c1 {
charger@6b {
compatible = "ti,bq24074";
reg = <0x6b>;
ti,charge-current = <500000>; /* 500 mA */
ti,input-current-limit = <1000000>; /* 1 A USB-3 capable port */
interrupt-parent = <&gpio4>;
interrupts = <12 IRQ_TYPE_EDGE_FALLING>;
};
};
/sys/class/power_supply/bq24074-charger/:
input_current_limit
charge_current_limit
status ← Charging / Not Charging / Full
online ← input present
voltage_now
A user-space battery daemon (UPower, your own) combines this with the MAX17048’s capacity to give a complete view.
76.8 Integrating: charger + gauge → battery status¶
To synthesise a full picture, your application reads:
charger_online = read("/sys/class/power_supply/charger/online")
charger_status = read("/sys/class/power_supply/charger/status")
battery_capacity = read("/sys/class/power_supply/battery/capacity")
battery_voltage = read("/sys/class/power_supply/battery/voltage_now")
if charger_online:
if battery_capacity == 100:
print("Plugged in, fully charged")
else:
print(f"Charging: {battery_capacity}%")
else:
if battery_capacity < 10:
print(f"Low battery: {battery_capacity}%, shut down soon")
else:
print(f"On battery: {battery_capacity}%")
In product UI, do time-averaged SoC display, instantaneous readings vary by ±1 % when the load is unsteady. EMA with τ = 30 s smooths the indicator.
76.9 Lab¶
Wire a MAX17048 to your i.MX6ULL I²C bus, with a Li-ion cell on its VCELL input.
i2cdetect. Verify 0x36 appears.
Build and load
mymax17048.ko. Read capacity and voltage. Disconnect / reconnect the cell. Capacity should jump after a 30-second model-recompute period.Cell discharge test. Connect a load (resistor). Log capacity + voltage every 10 s for an hour. Plot. You should see voltage drop and capacity decline together, but not in a strictly linear relationship.
upower integration. Install UPower. Verify
upower -i /org/freedesktop/UPower/devices/battery_batteryreports your readings.TP4056 charging. Wire a TP4056 to charge the cell from USB. Wire CHRG to a GPIO. Use
gpio-chargerin DT. Verifyonlinereports correctly.Switch to mainline MAX17048 driver.
compatible = "maxim,max17048";. Verify same data. Gain access to extra properties (alerts, capacity-level).Capacity-level logic. Write a user-space daemon that reads capacity every 30 s. Logs to syslog when crossing thresholds (low 20 %, critical 5 %). Triggers
poweroffat critical-3 %.
76.10 Pitfalls¶
Quick-start without justification. Writing 0x4000 to MODE clears the chip’s internal model and forces a fresh estimate. Useful on fresh-pack-insertion. Harmful if done routinely (degrades SoC accuracy).
Reading SoC immediately after power-on. Chip needs ~250 ms to initialise. The first read returns the cached value from last shutdown.
Trusting MAX17048 SoC on a cell-chemistry mismatch. Factory-loaded model is “typical Li-ion 3.7 V nominal.” LiFePO₄ (3.2 V nominal) reads wildly wrong. Use BQ27xxx with the right chemistry profile for non-standard cells.
TP4056 without thermal management. At 1 A charge, the chip dissipates ~1 W. Without thermal vias and a ground plane, it overheats and reduces charge current, slower charging.
TP4056 in deep-discharge cells. Cells below 2.5 V might fail TP4056’s pre-charge. Use a chip with adjustable pre-charge current.
BQ24074 without I²C in the boot path. If the chip needs config at boot to set higher input current, U-Boot must initialize it, otherwise it defaults to 100 mA input, which can’t power the SoC.
MCU bridge: Think of U-Boot like a much larger boot stub plus debug monitor: it initializes hardware, loads the next image, and gives you commands before Linux starts. U-Boot: the bootloader that initializes enough hardware to load and start the Linux kernel.
Power-path absent on production-bound product. User unplugs charger. Device dies. They expected the battery to take over. Verify your topology before laying out.
Forgetting
presentproperty. Some user-space code refuses to talk to a battery whosepresentis 0. Always report 1 if the chip is responding.Capacity hysteresis. Naïve daemons toggle “5% low warning” at 4.9 % then back at 5.0 %. Add hysteresis (warn at 5 %, clear at 8 %).
76.11 Going deeper¶
drivers/power/supply/max17040_battery.c: production MAX17048/40/44 driver.drivers/power/supply/bq27xxx_battery.c: coulomb-counting gauges from TI.drivers/power/supply/gpio-charger.c: generic “charging status from GPIO” driver.drivers/power/supply/bq2415x_charger.c+bq24190_charger.c, TI charger family.Documentation/power/power_supply_class.rst: framework documentation.Documentation/ABI/testing/sysfs-class-power: full sysfs attribute list.
ABI: Application Binary Interface: the calling convention, register use, binary format, and library contract that let separately built code run together. sysfs: a kernel-generated filesystem under /sys that exposes devices, drivers, and attributes.
MAX17048 datasheet (Maxim): register map. Quickstart guide.
TP4056 datasheet (NanJing Top Power): application circuit.
BQ24074 datasheet (TI SLUS818): power-path explained.
UPower source at https://gitlab.freedesktop.org/upower/upower, how user-space consumes power_supply_class.
End of Group E, Power & current (Ch 75–76). Together with Ch 51B (power management) you now have all three pieces of low-power product engineering: PM saves power, INA reports it, fuel gauge predicts remaining time.
Next chapter: Chapter 77: 1-Wire sensors (DS18B20 / DHT22). Maxim’s odd “one wire + ground” protocol. Slow but charming. The kernel’s w1 subsystem. Why DHT22 is a worse fit for Linux than for an MCU.