The Measurement That Didn’t Fit
The spec said 18 microamps in deep sleep. The board drew 21. That’s a 17% overrun on a number that was supposed to be locked, and it was the number our customer had quoted to their customer, which meant it was now a Tuesday-morning problem with a Friday deadline.
Three microamps is not, on its own, a dramatic number. It’s two LEDs worth of leakage in a sock drawer. On a CR2032 running a humidity sensor every ninety seconds, though, it’s the difference between an 18-month field life and a 15-month one. And 15 months gets you into the returns window.
Nobody wanted to be the person who said ship it anyway. So we opened a logbook, labelled page one, and started.
The Measurement Setup
We use a Nordic Power Profiler Kit II1 for anything below 200 µA. The unit was set to source mode at 3.0 V — matching the battery under load — with an averaging window of 10 seconds and a sample rate of 100 kHz. We let the board cycle through ten full sleep/wake intervals and averaged across the last six, throwing out the first four to let any sensor warmup settle.
One thing worth saying plainly: the PPK2 will lie to you if your ground path is wrong. We’ve seen a 40 µA offset from a dodgy ground clip. So before any of this, we shorted the inputs and verified the floor at <200 nA. It was clean. The 21 µA was real.
Methodical Elimination
We did this the slow way. There’s a faster way involving intuition and a scope and three cups of coffee, and sometimes it works, and this time it didn’t — so we went to the checklist.
The approach is boring and effective: disable one subsystem at a time, remeasure, write it down. Sensors first, then radios, then GPIO groups, then finally the MCU’s own sleep configuration. The goal is not to find the bug. The goal is to isolate the band where the current is going, and then look harder inside that band.
| # | Change | Sleep (µA) | Δ |
|---|---|---|---|
| 00 | Baseline (firmware as shipped) | 21.2 | — |
| 01 | SHT40 removed from I²C bus | 21.1 | −0.1 |
| 02 | BLE radio explicitly disabled | 21.2 | 0.0 |
| 03 | RTC slow clock → 32 kHz XTAL only | 20.7 | −0.5 |
| 04 | All GPIOs → INPUT, no pull | 19.4 | −1.3 |
| 05 | GPIO 12 internal pull-up disabled | 17.9 | −1.5 |
| 06 | (control) re-enable pull-up on GPIO 12 | 21.1 | +3.2 |
Row 04 was where we thought we were done. A handful of tenths, shaved off various places, and we were under 20 µA. Close enough. We almost walked away.
Row 05 happened because the junior engineer on the bench — Priya — asked why GPIO 12 was still configured with a pull-up when it was listed as unused in the pin map. It was one of those questions where the room goes quiet for four seconds.
The Discovery
GPIO 12 on our module was routed to a test point for factory programming. In production that test point sits in air — nothing connected. The schematic had a 10 kΩ internal pull-up enabled “just in case,” a phrase that should be treated the way electrical engineers treat “load-bearing duct tape.”
Here is what was happening. The internal pull-up was pulling the pin high, to 3.0 V, through ~50 kΩ. The pin was floating — no external connection, no external pull, no load. In that configuration, a rail-to-pull-up pair should draw essentially nothing; a few nanoamps of leakage at most.
Except the test pad on the PCB was long. About 22 mm. It was also routed next to the 32 kHz crystal’s guard trace. With the pull-up active, the pin was acting as a tiny, unintentional antenna. It was picking up the crystal, oscillating a few millivolts above and below its nominal high state, and the input buffer — which on the ESP32 is always-on in deep sleep for wakeup logic — was toggling. Every toggle cost a few hundred nanoamps of switching current. At 32 kHz, that added up to 3.2 microamps.
We confirmed this with a scope probe on the pin: a clean 32 kHz sine riding on top of the DC level, 18 mV peak-to-peak. Removing the pull-up collapsed the oscillation and the current dropped to spec.
The Fix
Two lines of code. The entire investigation came down to two lines.
// BEFORE — "just in case" defaults. Cost: 3.2 µA.
static void configure_unused_gpio(void) {
gpio_config_t cfg = {
.pin_bit_mask = (1ULL << GPIO_NUM_12),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, // the ghost
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&cfg);
}
// AFTER — no internal pulls, explicit isolate in deep sleep.
static void configure_unused_gpio(void) {
gpio_config_t cfg = {
.pin_bit_mask = (1ULL << GPIO_NUM_12),
.mode = GPIO_MODE_DISABLE, // isolate input buffer
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&cfg);
gpio_sleep_sel_dis(GPIO_NUM_12); // don't track this pin in sleep
}The GPIO_MODE_DISABLE is the important one. It tri-states the pin and, critically, powers down the input buffer so the pin isn’t consuming any wakeup-detection current. The gpio_sleep_sel_dis call is belt-and-braces; the ESP32 lets you opt individual GPIOs out of the sleep subsystem entirely, and for pins you truly don’t need, you should.
After the fix: 17.9 µA. Six-hour soak test: 17.9 ± 0.2 µA. We shipped it2.
Lessons
Four takeaways we wrote down at the bottom of that logbook page, which we are now sharing here so we can stop repeating them to each other.
1. Unused does not mean safe.
An unused pin is a configured pin. Its default configuration is whatever the MCU’s reset state is — which, depending on the part, can be anything from high-Z to a 40 kΩ pull-up you didn’t ask for. If you don’t configure unused pins explicitly, you are shipping the SoC vendor’s defaults to your customer. That’s someone else’s engineering decision in your product.
2. Internal pull-ups are not free in deep sleep.
The datasheet will tell you the pull-up resistance (nominal 45 kΩ on this family). It usually won’t tell you that the input buffer stays powered when the pin is anything other than DISABLE. Multiply always-on input buffer × sixteen unused GPIOs and you have a battery budget problem you didn’t know you had.
3. Long test traces next to clocks are antennas.
Factory test points are a necessary evil. Keep them short, keep them away from the crystal, and — if you can — route them to a pad that can be fully isolated after programming. A test pad that is also a tiny antenna is worse than no test pad, because you don’t know it’s costing you until you measure.
4. Ask the junior engineer.
Priya hadn’t built up the same assumptions the senior team had. She looked at the pin map and saw a contradiction — “unused” and “pulled-up” in the same row — and asked. Everyone who had been on the project for more than a week had stopped seeing it. This is a recurring pattern on our bench. Fresh eyes catch the things the rest of us have compiled away.
Three microamps. Two lines of code. One good question. That’s the story.
- The Nordic PPK2 is the unit we reach for first on anything battery-operated. It’s not the most accurate device on the shelf (the Keysight N6705 upstairs is better), but it’s fast, it’s on the bench already, and its noise floor at room temperature is below 200 nA in source mode. For most low-power debugging, that’s enough.
- The customer’s customer did not ask. The customer’s customer never asks. But we would have known.