ESP32 Bench Reference

Five board models on three silicon families — ESP32, ESP32-C3, ESP32-S3. The toolchain, Wi-Fi, OTA, NVS, filesystem, sleep and FreeRTOS layers are identical on all of them and are written here once. Everything that is not portable lives in one matrix in §02, so a sketch can be checked against it before it is moved between boards.

Boards
12 units · 5 models
Silicon
ESP32 · C3 · S3
Core
arduino-esp32 3.x
IDF
v5.1+ (core 3.x)
Radio
2.4 GHz only
01

The boards

Twelve units, five models, but only three distinct silicon families — and the family, not the board name, decides what a sketch can do. Two of the five models are the same chip as a third, which is why the shared core below is worth writing once.

Inventory 12 boards · 5 models · 3 families

BoardQtyModuleFamilyCoreMHzSRAMFlashUSBRadios
ESP32 DEVKIT V1 Type-C3ESP32-WROOM-32ESP322× Xtensa LX6240520 KB4 MBbridge chipWi-Fi 4 · BT Classic · BLE 4.2
MIN132 V1.0.0 WiFi3ESP-WROOM-32ESP322× Xtensa LX6240520 KB4 MBbridge chipWi-Fi 4 · BT Classic · BLE 4.2
Freenove ESP32 WROOM2ESP32-WROOM-32EESP322× Xtensa LX6240520 KB4 MBbridge chipWi-Fi 4 · BT Classic · BLE 4.2
ESP32-C3 Super Mini2ESP32-C3C31× RISC-V RV32IMC160400 KB4 MBnativeWi-Fi 4 · BLE 5.0
Heltec HTIT-WB32LAF2ESP32-S3 + SX1262S32× Xtensa LX7240512 KB8 MBnativeWi-Fi 4 · BLE 5.0 · LoRa
Quantities and model names from the 2026-08-10 parts-bin inventory. Clock, SRAM, flash and radio figures are the published specifications of the named modules. The Heltec row is the V3 generation reading — see §13 for what still needs confirming off the silkscreen.

Eight of twelve are one chip

The DevKit V1, the MIN132 and the Freenove boards all carry the same ESP32-WROOM-32 silicon. The -32E on the Freenove is a later module revision — smaller package options, updated eFuse defaults — not a different part from the programmer's side.

Practical effect: one binary, one board target, one pin map covers 8 of the 12 boards. The remaining four are the two C3 Super Minis and the two Heltecs, and those are where the divergences in §02 apply.

They are not interchangeable in a carrier. Same silicon, different board outline — the 30-pin carriers take the DevKit and Freenove; the MIN132 may be a 38-pin board. Count the pins before seating one.

Reading the family off a board

Can reads WROOM-32classic ESP32 — dual Xtensa, BT Classic, DACs, touch
Thumb-sized, no bridge ICC3 — one RISC-V core, BLE-only, native USB
OLED + u.FL socketS3 Heltec — the LoRa pair
Chip beside the USB jacka CP2102 or CH340 bridge ⇒ classic ESP32. C3/S3 have nothing there

Or ask the chip: esptool.py --port <port> chip_id prints the exact variant and revision without flashing anything.

What every one of them shares

These hold across all 12 boards and are the reason a single sheet works:

2.4 GHz Wi-Fi onlyno 5 GHz part in the family — the AP must offer a 2.4 GHz SSID
3.3 V logicGPIO are not 5 V tolerant on any of them
GPIO matrixUART/I²C/SPI/PWM route to almost any pin — pin choice is software, not silicon
Same four toolchainsarduino-esp32, PlatformIO, ESP-IDF, MicroPython
FreeRTOS underneathloop() is a task; the scheduler is always running
NVS · LittleFS · OTAidentical APIs and partition mechanics
esptool protocolone flashing tool, one boot-mode dance
02

Portability matrix

Everything below is a compile-time or runtime difference, not a pin difference. A sketch that uses anything in the first table will not survive a move between families. Check here first; the rest of this sheet is common ground.

Not portable the whole list

Feature / APIESP32
WROOM-32
C3
Super Mini
S3
Heltec
What breaks, and the way round it
Bluetooth Classic
BluetoothSerial.h
yesnonoC3 and S3 are BLE-only radios. #include <BluetoothSerial.h> fails to compile with Bluetooth is not enabled. Port to BLE (NimBLE UART service) or keep that job on a WROOM board.
dacWrite()
true analog out
GPIO25/26nonenoneThe 8-bit DACs exist only on the original ESP32. Neither C3 nor S3 has one. Substitute LEDC PWM through an RC low-pass, or an external I²C DAC (MCP4725).
touchRead()
capacitive touch
10 chnone14 chC3 has no touch peripheral at all — the function does not exist. Touch pin numbering also differs between ESP32 (T0–T9) and S3 (T1–T14), so even ESP32↔S3 needs the pin re-picked.
esp_sleep_enable_
ext0_wakeup()
yesnonoext0 is an original-ESP32 peripheral. On C3/S3 use esp_deep_sleep_enable_gpio_wakeup(), or ext1 where available. Silent behaviour change if you only test on one family.
ADC2 while Wi-Fi is upblockedavoidavoidOn classic ESP32 the Wi-Fi driver owns ADC2, so analogRead() on GPIO0/2/4/12–15/25–27 returns garbage or -1 with the radio on. Keep every analog input on ADC1.
Core pinning
xTaskCreatePinnedToCore
2 cores1 core2 coresC3 is single-core. The call still compiles, but core 1 does not exist and the task lands on core 0 — any design that assumes true parallelism serialises.
Serial over USBbridge ICnative CDCnative CDCDifferent device node, different reset behaviour, and on C3/S3 the Arduino option USB CDC On Boot must be Enabled or Serial prints to a UART you are not watching. See §05.
LEDC PWM channels1668A design driving 8 independent PWM outputs works on ESP32 and S3 and runs out of channels on C3. Channels also pair off onto shared timers, so distinct frequencies cost more than distinct duties.
Hardware UARTs323One is spoken for by the console. Serial2 does not exist on C3.
Flash-reserved GPIO6–1111–1726–32Entirely different holes in the pin space. A pin number hard-coded for one family may be wired to the SPI flash on another — that is a board that stops booting, not a pin that misreads.
PSRAMnonenonenone*None of these twelve has PSRAM. ps_malloc() falls back to internal RAM. *Other S3 modules do have it; the Heltec V3 does not.
USB HID / MSCnolimitedyesClassic ESP32 has no USB peripheral, so it cannot be a keyboard or a mass-storage device. S3 has full USB-OTG; C3's is serial/JTAG-focused.
yes available · no absent, code will not work · amber present but with a condition attached.

The five-line portability guard

Rather than remembering the table, let the preprocessor refuse to build the wrong thing:

// arduino-esp32 defines exactly one of these
#if defined(CONFIG_IDF_TARGET_ESP32)
  #include <BluetoothSerial.h>   // classic BT: this family only
  #define HAS_DAC   1
#elif defined(CONFIG_IDF_TARGET_ESP32C3)
  #define HAS_DAC   0
  #define SINGLE_CORE 1
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
  #define HAS_DAC   0
#else
  #error "Unhandled ESP32 target"
#endif

The #error is the useful half — it turns "wrong board selected" from a runtime mystery into a build failure on the line that says so.

Portable by construction

Habits that keep a sketch family-agnostic without any #ifdef:

Name every pinone constexpr int PIN_x block at the top; porting becomes editing five lines
Pass pins explicitlyWire.begin(SDA_PIN, SCL_PIN) — defaults differ per family and are actively hostile on the C3
ADC1 onlysidesteps the Wi-Fi conflict everywhere at once
PWM, not DACLEDC exists on all three; dacWrite exists on one
BLE, not BT Classicthe only Bluetooth all twelve boards can do
xTaskCreatelets the scheduler place it; pin only when you have measured a reason to
Timer wakeesp_sleep_enable_timer_wakeup() is identical on all three; GPIO wake is not
03

Pin maps

Because of the GPIO matrix, these tables are about which pins exist and which are spoken for — not about fixed peripheral assignments. The defaults shown are what the Arduino core picks if you do not say otherwise.

ESP32 DevKit V1 · 30-pin also Freenove · MIN132 (verify pin count)

ENreset, active low
GPIO36VP · ADC1_0 · input only
GPIO39VN · ADC1_3 · input only
GPIO34ADC1_6 · input only
GPIO35ADC1_7 · input only
GPIO32ADC1_4 · T9
GPIO33ADC1_5 · T8
GPIO25DAC1 · ADC2_8
GPIO26DAC2 · ADC2_9
GPIO27ADC2_7 · T7
GPIO14ADC2_6 · T6 · HSPI SCK
GPIO12strap MTDI — must be low at boot
GND
GPIO13ADC2_4 · T4 · HSPI MOSI
GND
ESP32-WROOM-32
VIN / 5VUSB rail, 5 V in
GND
GPIO23VSPI MOSI
GPIO22I²C SCL default
GPIO1U0 TXD — console
GPIO3U0 RXD — console
GPIO21I²C SDA default
GND
GPIO19VSPI MISO
GPIO18VSPI SCK
GPIO5strap · VSPI SS · boot-log pin
GPIO17U2 TXD
GPIO16U2 RXD
GPIO4ADC2_0 · T0
GPIO2strap · onboard LED · T2
GPIO15strap · T3 · HSPI SS
GPIO0strap · BOOT button · T1
3V3regulator out, ~600 mA

Gold = has a condition attached (strapping, flash, or ADC2). Input only means no output driver and no internal pull-up or pull-down — GPIO34–39 need an external resistor for a button. Clone silkscreens reorder the power pins; the set of pins is what matters here, not the row order.

GPIO6–11 are the SPI flash. The 30-pin board protects you by not bringing them out at all. The 38-pin variant exposes them as SD0–SD3 / CMD / CLK — and driving one stops the board booting. If a MIN132 turns out to be 38-pin, that is the difference that matters, not the extra width.

ESP32-C3 Super Mini

5VUSB rail
GND
3V3regulator out
GPIO4ADC1_4 · SPI
GPIO3ADC1_3
GPIO2ADC1_2 · strap
GPIO1ADC1_1
GPIO0ADC1_0
ESP32-C3
GPIO5ADC2_0 — avoid with Wi-Fi
GPIO6general
GPIO7general
GPIO8onboard LED, active LOW · strap
GPIO9BOOT button · strap
GPIO10general
GPIO20U0 RXD
GPIO21U0 TXD
The C3's default I²C pins are its two worst pins. The core defaults Wire to SDA=8, SCL=9 — which on the Super Mini are the LED and the BOOT button. Always call Wire.begin(6, 7) or similar with pins you chose.

GPIO11–17 are the SPI flash and are not brought out. GPIO18/19 are the native USB D−/D+ and are also not on the header.

Heltec WiFi LoRa 32 V3 fixed on-board nets

Most of this board's pins are already wired to something. These are the assignments you need before any free GPIO matters:

NetGPIONote
SX1262 NSS8LoRa chip select
SX1262 SCK9dedicated SPI bus
SX1262 MOSI10
SX1262 MISO11
SX1262 RST12
SX1262 BUSY13poll before every command
SX1262 DIO114TX/RX done interrupt
OLED SDA17SSD1306 128×64, its own I²C bus
OLED SCL18
OLED RST21pulse low then high at start-up
Vext control36active LOW — powers the OLED rail
User LED35white
PRG button0also the boot strap
VBAT sense1gated by ADC_Ctrl on GPIO37
Heltec V3 assignments. Confirm against the silkscreen before wiring — see §13.
A blank OLED is usually Vext. The display rail is switched by GPIO36 and it is active low: pinMode(36,OUTPUT); digitalWrite(36,LOW); then wait ~50 ms before touching the bus.

Strapping pins — read at reset

These are sampled in the microseconds after reset to decide boot mode. A circuit that holds one at the wrong level makes a board that will not start — and the symptom is silence, not an error.

ESP32 GPIO0low at reset ⇒ download mode. This is the BOOT button. Never load it with anything that pulls low.
ESP32 GPIO2must be floating or low at reset — and it is the onboard LED, so an LED to 3V3 breaks booting
ESP32 GPIO12high at reset selects a 1.8 V flash rail and the board dies. The worst of the five.
ESP32 GPIO15low at reset silences the ROM boot log — handy, and confusing if unintended
ESP32 GPIO5strap; also emits a PWM burst during boot, which twitches a servo
C3 GPIO2 / 8 / 9GPIO9 low ⇒ download mode (the BOOT button); GPIO8 must not be held low at reset
S3 GPIO0 / 3 / 45 / 46GPIO0 is boot; GPIO45 sets the flash rail voltage; GPIO46 is input-only and strapped
Rule that covers all of it: anything on a strapping pin must be an output the ESP32 drives, not an input something else drives. If you need a button or a sensor, move it.
04

Toolchain

All four toolchains support all three families. The only thing that changes between boards is the target identifier — one string in one place.

Board targets the one line that differs per board

BoardArduino FQBNPlatformIO boardESP-IDF target
DevKit V1 (30-pin)esp32:esp32:esp32doit-devkit-v1esp32doit-devkit-v1esp32
MIN132 / generic WROOMesp32:esp32:esp32esp32devesp32
Freenove ESP32 WROOMesp32:esp32:esp32esp32devesp32
ESP32-C3 Super Miniesp32:esp32:esp32c3esp32-c3-devkitm-1esp32c3
Heltec WiFi LoRa 32 V3esp32:esp32:heltec_wifi_lora_32_V3heltec_wifi_lora_32_V3esp32s3
The generic esp32dev target works for any WROOM-32 board — it simply makes no assumptions about LED pins or flash size beyond 4 MB.

Arduino CLI

# once
arduino-cli config init
arduino-cli config add board_manager.additional_urls \
  https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli core update-index
arduino-cli core install esp32:esp32

# per project
arduino-cli board list                    # find the port
arduino-cli compile -b esp32:esp32:esp32doit-devkit-v1 .
arduino-cli upload  -b esp32:esp32:esp32doit-devkit-v1 -p /dev/cu.usbserial-0001 .
arduino-cli monitor -p /dev/cu.usbserial-0001 -c baudrate=115200

Build options that are menu items in the IDE become --build-property or a :-suffixed FQBN, e.g. esp32:esp32:esp32c3:CDCOnBoot=cdc.

PlatformIO platformio.ini

[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
build_flags = -DCORE_DEBUG_LEVEL=3

[env:devkit]
board = esp32doit-devkit-v1

[env:c3]
board = esp32-c3-devkitm-1
build_flags = ${env.build_flags}
  -DARDUINO_USB_MODE=1
  -DARDUINO_USB_CDC_ON_BOOT=1

[env:heltec]
board = heltec_wifi_lora_32_V3
lib_deps = jgromes/RadioLib

One [env] per board, one shared src/. pio run -e c3 -t upload builds and flashes just that target — the cleanest way to keep a sketch honest across all three families.

ESP-IDF

. $HOME/esp/esp-idf/export.sh
idf.py set-target esp32c3       # esp32 | esp32c3 | esp32s3
idf.py menuconfig
idf.py build
idf.py -p /dev/cu.usbmodem101 flash monitor
set-targetdiscards sdkconfig and rebuilds it — save any hand edits in sdkconfig.defaults
fullcleanthe answer to most inexplicable build failures
Exit monitorCtrl-]
size-componentswhere the flash and RAM actually went

The Arduino core is a component on top of IDF — anything in this sheet's IDF column is callable from an Arduino sketch by including the right header.

MicroPython

# flash the interpreter
esptool.py --chip esp32 -p PORT erase_flash
esptool.py --chip esp32 -p PORT -b 460800 \
  write_flash 0x1000 ESP32_GENERIC-20250415.bin

# C3 and S3 load at 0x0, not 0x1000
esptool.py --chip esp32c3 -p PORT write_flash 0x0 ESP32_GENERIC_C3-*.bin
mpremotempremote connect PORT repl · mpremote fs cp main.py :
boot.py → main.pyrun in that order at reset
machinePin, ADC, PWM, I2C, SPI, deepsleep
networkWLAN(network.STA_IF) — same radio, different words

Different firmware binary per family, same language. Handy on the C3, where the native USB means the REPL appears the instant the board enumerates.

Core 2.x vs 3.x

arduino-esp32 3.x moved to ESP-IDF v5.x and changed APIs that older tutorials still use. If sample code does not compile, this is usually why:

Core 2.xCore 3.x
ledcSetup(ch,f,res);
ledcAttachPin(pin,ch);
ledcWrite(ch,duty);
ledcAttach(pin,f,res);
ledcWrite(pin,duty);
ledcDetachPin(pin)ledcDetach(pin)
adcAttachPin(pin)removed — not needed
analogSetClockDiv()removed
SPIFFS.hLittleFS.h preferred

Check with ESP_ARDUINO_VERSION_MAJOR. The LEDC change is the one that bites: ledcWrite still exists in 3.x but its first argument means something different, so old code compiles cleanly and drives the wrong pin.

05

Build & flash

The flashing protocol is identical across the three families. What differs is how the board is put into download mode and what the serial port is called — and those two are the source of most upload failures.

Serial port & reset behaviour the first real divergence

FamilyUSB pathmacOS deviceEnters download modePort after reset
ESP32CP2102 or CH340 bridge/dev/cu.usbserial-*
/dev/cu.wchusbserial-*
/dev/cu.SLAB_USBtoUART
auto, via DTR/RTS transistorsstays put
C3native USB Serial/JTAG/dev/cu.usbmodem*auto, or hold BOOT + tap RSTre-enumerates
S3native USB Serial/JTAG/dev/cu.usbmodem*auto, or hold BOOT + tap RSTre-enumerates
The native-USB gotcha. On C3 and S3 the USB device is the chip, so a reset drops the port and a new one appears. A serial monitor holding the old handle sees nothing, and the first second of boot output is gone before any monitor can attach. Reconnect after reset, or wire up a real UART for boot logs.
The bridge-chip gotcha. Clone ESP32 boards vary in how well the auto-reset circuit works. If upload stops at Connecting........_____, do it by hand: hold BOOT, tap EN/RST, release BOOT, then start the upload. A USB cable that is charge-only produces exactly the same symptom, as does a hub that cannot supply the radio's current peaks.

Arduino IDE settings that matter

USB CDC On BootC3S3 Enable it. Left disabled, Serial goes to the hardware UART pins and the USB console stays silent — the single most common "my board is dead" on these two
Flash Size4 MB on the WROOM boards and the C3, 8 MB on the Heltec. Over-declaring gives a board that flashes and then boot-loops
Partition Schemesee the storage section — Huge APP gives 3 MB of program space but gives up OTA
Core Debug Levelraises ESP_LOGx verbosity; Info is a good default, Verbose floods
Upload Speed921600 usually works; drop to 115200 on a long or cheap cable
Erase All Flashclears NVS too — turn it on once when stale saved settings are suspected, then off

esptool

# identify before you write
esptool.py --port PORT chip_id
esptool.py --port PORT flash_id     # size + manufacturer

# wipe (also clears NVS and any filesystem)
esptool.py --chip esp32 --port PORT erase_flash

# bootloader offset differs by family
#   esp32    -> 0x1000
#   esp32c3  -> 0x0
#   esp32s3  -> 0x0
esptool.py --chip esp32 --port PORT -b 460800 write_flash \
  0x1000 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin

# pull an image off a board before overwriting it
esptool.py --port PORT read_flash 0 0x400000 backup.bin
0x1000 vs 0x0 is the flash offset that catches everyone moving between an ESP32 and a C3/S3. Wrong offset flashes without complaint and the board never boots.

macOS drivers

CP2102 / CP2104macOS has shipped a built-in driver for years — appears as cu.usbserial-* with no install
CH340 / CH9102macOS 13+ has a built-in driver; on older versions install WCH's. Shows as cu.wchusbserial*
Native USB (C3/S3)no driver, ever — it is a standard CDC device
Use cu.*, not tty.*tty.* blocks waiting for carrier detect and will simply hang
ls /dev/cu.* # before and after plugging in

Boot messages, decoded

rst:0x1 POWERONnormal cold boot
rst:0x3 SW_RESETesp_restart() or the reset button
rst:0x5 DEEPSLEEPwoke from deep sleep — expected, not a fault
rst:0x8 TG1WDTwatchdog fired — a task blocked too long
rst:0xc SW_CPU_RESETthe panic handler restarted it — scroll up for the backtrace
boot:0x13SPI_FAST_FLASH_BOOT — the normal path
waiting for downloadGPIO0 was low at reset — a stuck BOOT button, or something on the pin
Brownout detectorthe 3.3 V rail sagged on a radio transmit peak. Cable, hub, or regulator — not code
invalid header: 0xffffffffnothing at the app offset — empty flash, or written at the wrong address
flash read err, 1000usually a Flash Size setting larger than the chip
06

GPIO & analog

Digital I/O is identical everywhere. Analog is where the three families quietly diverge — and where the ESP32's reputation for "inaccurate" readings comes from.

Digital portable across all 12

pinMode(p, mode)INPUT, OUTPUT, INPUT_PULLUP, INPUT_PULLDOWN, OUTPUT_OPEN_DRAIN
digitalWrite / digitalReadas Arduino, ~40 ns per call through the HAL
attachInterrupt(p, fn, mode)RISING FALLING CHANGE ONLOW ONHIGHevery GPIO can interrupt
IRAM_ATTRput ISRs in RAM: void IRAM_ATTR isr(){} — mandatory if flash may be busy
gpio_set_drive_capability()5 / 10 / 20 / 40 mA per pin; 40 mA is the absolute maximum, 20 mA the sane one
Input-only pinsESP32 GPIO34–39: no output driver, no internal pull resistors
Total current matters more than per-pin. The package limit is roughly 1200 mA in and out combined; the practical limit is the onboard regulator, which on most DevKits is a 600 mA part already feeding a radio that peaks near 500 mA.

ADC — the honest version

analogRead(pin)12-bit, 0–4095 by default
analogReadResolution(b)9–12 bits
analogSetAttenuation()ADC_0db ~0–0.95 V · ADC_2_5db ~0–1.32 V · ADC_6db ~0–1.75 V · ADC_11db ~0–3.1 V default
analogReadMilliVolts(p)applies the factory eFuse calibration — use this, not raw counts
The ESP32 ADC is not linear and does not reach the rail. At the default 11 dB attenuation it saturates around 3.1 V, and the bottom ~100 mV reads as zero. analogReadMilliVolts() corrects most of it; for real accuracy use an external ADC (ADS1115) or a ratiometric measurement.
ADC2 is unusable with Wi-Fi on ESP32. That rules out GPIO0, 2, 4, 12–15, 25–27 for analog in any connected sketch. ADC1 — GPIO32–39 — has no such restriction.

PWM (LEDC)

The ESP32 has no analogWrite hardware; the LED Control peripheral fills in, and it is better — arbitrary frequency and up to 20-bit resolution.

// core 3.x
ledcAttach(pin, 5000, 12);   // 5 kHz, 12-bit
ledcWrite(pin, 2048);        // 50 % duty

// servo: 50 Hz, 16-bit -> 1-2 ms is 3277-6554
ledcAttach(servoPin, 50, 16);
ledcWrite(servoPin, 4915);     // centre

ledcWriteTone(pin, 440);      // square wave, A4
Frequency and resolution trade against each other — they share an 80 MHz clock, so freq × 2^bits ≤ 80 MHz. Ask for 20-bit at 5 kHz and the call fails silently, leaving a dead output. Channel budget: 16 6 8.

DAC and touch family-restricted

dacWrite(25|26, 0-255)ESP32 only true 8-bit analog out, 0–3.3 V
touchRead(T0…T9)ESP32lower value means touched, opposite of intuition
touchRead(T1…T14)S3 — different numbering and different polarity from the ESP32
touchAttachInterrupt()threshold interrupt; can wake from deep sleep ESP32
On the C3neither exists — the functions are not declared, so it is a compile error, not a silent failure

Replacing a DAC portably: LEDC at a few hundred kHz into a 1 kΩ / 1 µF RC gives a clean slow analog voltage. For audio, an external I²S DAC is the right answer on every family including the ESP32.

07

Buses

I²C, SPI, UART and I²S behave the same on all three families. Only the default pin choices differ — so pass pins explicitly and the code stops caring which board it is on.

I²C

#include <Wire.h>
Wire.begin(SDA_PIN, SCL_PIN);      // always name them
Wire.setClock(400000);           // 100k std, 400k fast

Wire.beginTransmission(0x27);
Wire.write(0x00);
uint8_t err = Wire.endTransmission();  // 0 = ACKed
FamilyDefault SDADefault SCLBuses
ESP3221222
C3891
S3892
Never take the C3 default. GPIO8 is the Super Mini's LED and GPIO9 is its BOOT strap — using them as I²C gives a bus that half-works and a board that sometimes will not start.

Pull-ups: 4.7 kΩ to 3.3 V. Most breakout modules fit their own; three modules on one bus means three sets of pull-ups in parallel, which is often too strong.

// scanner — the first thing to run on any new bus
for (uint8_t a = 1; a < 127; a++) {
  Wire.beginTransmission(a);
  if (Wire.endTransmission() == 0) Serial.printf("0x%02X\n", a);
}

SPI

#include <SPI.h>
SPI.begin(SCK, MISO, MOSI, SS);
SPI.beginTransaction(SPISettings(10000000, MSBFIRST, SPI_MODE0));
digitalWrite(SS, LOW);
SPI.transfer(0x9F);
digitalWrite(SS, HIGH);
SPI.endTransaction();
ESP32 defaultsVSPI: SCK 18, MISO 19, MOSI 23, SS 5 · HSPI: SCK 14, MISO 12, MOSI 13, SS 15
Second busSPIClass hspi(HSPI); hspi.begin(14,12,13,15);
Speed80 MHz on the dedicated IOMUX pins; routing through the GPIO matrix caps it nearer 40 MHz
Heltecthe SX1262 already owns GPIO8–11 as its own SPI bus — do not share it

UART

Serial.begin(115200);              // console
Serial1.begin(9600, SERIAL_8N1, RX, TX);

// any pins, thanks to the GPIO matrix
Serial2.begin(115200, SERIAL_8N1, 16, 17);
SerialUART0 — the console. On classic ESP32 it is GPIO1/3 and the bridge chip; on C3/S3 it is USB CDC
Serial1free on every family
Serial2ESP32S3 only — the C3 has no third UART
setRxBufferSize()call before begin(); the 256-byte default drops bytes at high rates
setDebugOutput(true)routes the IDF log stream to that port

I²S and the other peripherals worth knowing

I²Sdigital audio in and out — the right way to do sound on any of these, DAC or not
RMTthe precise-timing engine. Drives WS2812 LEDs and IR codecs without blocking the CPU
PCNThardware pulse counter — rotary encoders and flow meters with no interrupts
MCPWMESP32S3 motor PWM with dead-time insertion
TWAIthe CAN 2.0 controller — needs an external transceiver
ULPESP32S3 a tiny coprocessor that runs while the main cores sleep

RMT is worth singling out: addressable LED strips driven from digitalWrite loops are the classic reason a sketch flickers when Wi-Fi connects. Use a library that sits on RMT (FastLED, Adafruit_NeoPixel on ESP32) and the problem disappears.

08

Wi-Fi

Identical on all twelve boards — same library, same calls, same behaviour. All of them are 2.4 GHz only, which is the one hardware fact that ever matters here.

Station mode

#include <WiFi.h>

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);        // latency over battery
  WiFi.begin(ssid, pass);

  while (WiFi.status() != WL_CONNECTED) {
    delay(250); Serial.print('.');
  }
  Serial.println(WiFi.localIP());
}
WiFi.status()WL_CONNECTED · WL_NO_SSID_AVAIL · WL_CONNECT_FAILED · WL_DISCONNECTED
WiFi.RSSI()dBm; better than −67 is comfortable, worse than −80 is trouble
WiFi.macAddress()the board's own MAC — a decent unique device id
setAutoReconnect(true)plus WiFi.persistent(false) to stop rewriting NVS on every connect
setTxPower()lower it (WIFI_POWER_11dBm) if a weak supply browns out on transmit
WiFi.onEvent()event callbacks — far better than polling status() in loop()

Access point & provisioning

WiFi.mode(WIFI_AP);
WiFi.softAP("esp32-setup", "12345678");
Serial.println(WiFi.softAPIP());   // 192.168.4.1

// both at once, for a config portal
WiFi.mode(WIFI_AP_STA);
Password ≥ 8 charsshorter and softAP() quietly creates an open network
WiFiManagerthe standard library for this: AP portal, credentials to NVS, no hard-coded SSID
WIFI_AP_STAone radio — the AP is forced onto the station's channel
softAPConfig()set a different AP subnet
Keep credentials out of the sketch. NVS via Preferences survives reflashing (unless "Erase All Flash" is on) and keeps secrets out of anything you might share.

HTTP, mDNS, time

#include <HTTPClient.h>
HTTPClient http;
http.begin("http://example.com/api");
int code = http.GET();
if (code == 200) Serial.println(http.getString());
http.end();

#include <ESPmDNS.h>
MDNS.begin("sensor1");        // -> sensor1.local
MDNS.addService("http", "tcp", 80);

configTime(0, 0, "pool.ntp.org");
setenv("TZ", "EST5EDT,M3.2.0,M11.1.0", 1); tzset();
WebServerserver.on("/", handler) — synchronous, called from loop()
ESPAsyncWebServernon-blocking, handles concurrent clients; the right choice for anything real
WiFiClientSecureHTTPS. setInsecure() skips validation; setCACert() does it properly
PubSubClientMQTT — keep the payload under the 256-byte default or raise MQTT_MAX_PACKET_SIZE

OTA updates

#include <ArduinoOTA.h>
ArduinoOTA.setHostname("sensor1");
ArduinoOTA.setPassword("…");
ArduinoOTA.begin();
// then in loop():
ArduinoOTA.handle();
Needs two app slotsthe partition scheme must be an OTA one — Huge APP has only one and OTA silently cannot work
Half the flashon a 4 MB board that is ~1.2 MB of program space, not 3 MB
HTTPUpdatepull a .bin from a URL instead of pushing over the network
Rollbackenable it and call esp_ota_mark_app_valid_cancel_rollback() once the new image proves itself
OTA is what makes the carrier-mounted boards practical. Once a board is screwed into an enclosure behind a terminal block, a working OTA path is the difference between an update and a disassembly.
09

Bluetooth

The sharpest divide on this sheet. Only the eight WROOM-32 boards can do Bluetooth Classic. All twelve can do BLE — so BLE is the portable choice.

Classic ESP32 family only

#include <BluetoothSerial.h>   // compile error on C3/S3
BluetoothSerial SerialBT;

void setup() { SerialBT.begin("ESP32-bench"); }
void loop()  { if (SerialBT.available()) Serial.write(SerialBT.read()); }
Failing to compile is the good outcome. On a C3 or S3 you get Bluetooth is not enabled! Please run `make menuconfig`… — a clear message pointing at a hardware fact that cannot be configured away.
SPPthe serial profile — pairs with macOS, Linux and Android as a virtual COM port
Not on iOSiOS does not expose SPP to apps — BLE is the only route to an iPhone from any of these boards
A2DPaudio sink or source, via ESP32-A2DP. Large — expect to need the Huge APP partition
CoexistenceWi-Fi and BT share one antenna; running both halves the throughput of each

BLE all twelve boards

#include <NimBLEDevice.h>

NimBLEDevice::init("esp32-node");
NimBLEServer *s   = NimBLEDevice::createServer();
NimBLEService *sv = s->createService("180F");   // battery
NimBLECharacteristic *c = sv->createCharacteristic(
    "2A19", NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
sv->start();
s->getAdvertising()->start();
c->setValue(pct); c->notify();
Use NimBLEthe Bluedroid stack costs ~1 MB of flash; NimBLE does the same job in roughly a third of it
4.2 vs 5.0ESP32 is BLE 4.2; C3S3 are BLE 5.0 with 2 Mbit PHY and coded long-range
Beacon modeadvertise-only, no connection — the lowest-power useful thing these boards do
MTUdefaults to 23 bytes; negotiate up before sending anything substantial
The C3 Super Mini's best job. Single-core, small, BLE 5.0, native USB — a BLE sensor or beacon plays to every one of its strengths and needs none of what it lacks.

ESP-NOW the portable third option

Espressif's own connectionless protocol: board-to-board over the Wi-Fi radio, no access point involved, and it works on all three families identically.

#include <esp_now.h>
esp_now_init();
esp_now_peer_info_t p = {};
memcpy(p.peer_addr, mac, 6);
esp_now_add_peer(&p);
esp_now_send(mac, (uint8_t*)&payload, sizeof(payload));
250-byte payloadper message — it is for telemetry, not files
~1–3 ms latencyno association handshake, so a sleeping node can wake, send and sleep in a few ms
Rangecomparable to Wi-Fi, better in practice because there is no AP in the middle
Same channelpeers must agree; mixing ESP-NOW with a Wi-Fi connection pins you to the AP's channel

For a sensor at the end of the garden reporting to a node indoors, ESP-NOW between two DevKits is simpler than either MQTT or LoRa — and unlike LoRa it needs no extra hardware.

10

Storage & partitions

Flash is carved up by a partition table chosen at build time. Getting this wrong is the cause of both "my settings vanished" and "OTA does nothing".

Preferences (NVS) the right place for settings

#include <Preferences.h>
Preferences prefs;

prefs.begin("net", false);          // namespace, read-write
prefs.putString("ssid", ssid);
prefs.putUInt("boots", prefs.getUInt("boots", 0) + 1);
String s = prefs.getString("ssid", "");   // default if unset
prefs.end();
15-char keyslonger names are truncated, and two keys that differ after char 15 collide
Wear levellingbuilt in — but do not write in loop(); write on change
Survives reflashingunless Erase All Flash is on, which is why stale settings outlive a "clean" upload
clear()wipes one namespace; nvs_flash_erase() wipes the lot

LittleFS

#include <LittleFS.h>
LittleFS.begin(true);              // true = format if unmountable

File f = LittleFS.open("/log.txt", FILE_APPEND);
f.printf("%lu,%.2f\n", millis(), temp);
f.close();

File d = LittleFS.open("/");
while (File e = d.openNextFile()) Serial.println(e.name());
Prefer it to SPIFFSpower-loss resilient, real directories, faster. SPIFFS is deprecated
Leading /required on every path
SpaceLittleFS.totalBytes() / usedBytes()
Upload a data dirpio run -t uploadfs, or the IDE's filesystem upload plugin

Partition schemes on 4 MB

SchemeAppOTAFSUse when
Default 4 MB1.2 MB ×2yes1.5 MBthe sane default
Minimal SPIFFS1.9 MB ×2yes190 KBbig sketch, still want OTA
Huge APP3 MB ×1no1 MBBLE + Wi-Fi + audio, no OTA
No OTA (2 MB APP)2 MB ×1no2 MBdata logger
The Heltec's 8 MB doubles all of these. A custom partitions.csv is the answer whenever none of the presets fits.
"Sketch too big" has two fixes and only one is right. Switching to Huge APP silently gives up OTA. Trimming the build — NimBLE instead of Bluedroid, dropping unused libraries — usually recovers more space than the partition change does.

RTC memory & other stores

RTC_DATA_ATTR8 KB that survives deep sleep but not a power cycle — the natural home for a sleep-wake counter
RTC_NOINIT_ATTRalso survives a software reset — useful for crash breadcrumbs
EEPROM.hemulated over NVS; it works, but Preferences is the honest API
SD / SD_MMCa microSD module over SPI works on all three families — none of these boards has a slot
Custom partitionsesp_partition_find() for a raw data area outside any filesystem
11

Power & sleep

The sleep modes are the same across all three families; the wake sources are not. And on a dev board, the board usually costs more current than the chip saves.

Sleep modes

ModeChip drawRAMResumes
Active, radio on80–260 mA
Modem sleep~20 mAkeptinstant
Light sleep~0.8 mAkeptnext line of code
Deep sleep~10 µAlostsetup(), from the top
Hibernation~5 µAlostsetup()
Chip-level figures. What a board actually draws is another matter — see the card to the right.
#include <esp_sleep.h>
RTC_DATA_ATTR int wakeCount = 0;

esp_sleep_enable_timer_wakeup(60ULL * 1000000);  // 60 s
esp_deep_sleep_start();               // never returns

Wake sources — not portable

SourceESP32C3S3
Timeryesyesyes
ext0 (one pin)yesnono
ext1 (pin mask)yesvariantyes
GPIO wakelight onlyyesyes
Touchyesnoyes
ULPyesnoyes
// ESP32 only
esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1);

// C3 / S3
esp_deep_sleep_enable_gpio_wakeup(BIT(4),
    ESP_GPIO_WAKEUP_GPIO_HIGH);

Only RTC-capable pins can wake the chip. On classic ESP32 that is GPIO0, 2, 4, 12–15, 25–27, 32–39 — never GPIO16–23. On the C3 it is GPIO0–5 only.

Check why you woke: esp_sleep_get_wakeup_cause() returns TIMER, EXT0, GPIO, UNDEFINED (a genuine cold boot) — the cleanest way to branch between first-run setup and a routine wake.

The dev-board tax

10 µA is the chip, not the board. A DevKit V1 in deep sleep typically draws 5–20 mA — the linear regulator's quiescent current, the USB-serial bridge, and the power LED, all of which stay powered whatever the ESP32 does.
The power LEDoften 2–5 mA on its own. Lifting its resistor is the single biggest win
AMS1117 regulator~5 mA quiescent. A board built for battery use would not have one
USB bridgedrawing current even with no host attached
Feed 3V3 directlybypasses the regulator — but then nothing may be on VIN/5V, or current back-feeds through it

For a genuinely long-lived battery node the answer is a bare module on a board of your own, or the Heltec — which at least has a proper JST battery connector and charge circuit.

Powering the board

USB / VIN5 V into the onboard regulator. VIN tolerates roughly 5–12 V, but the regulator turns the excess into heat
3V3 pina direct feed to the module — clean, but unprotected and unregulated by the board
Budget 500 mAWi-Fi transmit peaks are brief and large; a 470 µF bulk capacitor across 3V3 fixes most brownouts
3.3 V rail limitsmost DevKit regulators are 600 mA parts and share the rail with the radio
5 V peripheralsthe I²C LCD backpacks want 5 V power but tolerate 3.3 V signalling — power from VIN, signal from the ESP32
Brownout is a power fault that looks like a software bug. Random reboots when Wi-Fi connects, or on a servo's first move, are almost never the sketch.
12

FreeRTOS

Always present, on every board. loop() is a task like any other — knowing that turns a class of mysterious reboots into an obvious one.

Tasks

void sensorTask(void *arg) {
  for (;;) {
    readSensor();
    vTaskDelay(pdMS_TO_TICKS(1000));   // yields
  }
}

// portable: let the scheduler choose
xTaskCreate(sensorTask, "sensor", 4096, nullptr, 1, nullptr);

// dual-core only (ESP32, S3) — not the C3
xTaskCreatePinnedToCore(sensorTask, "sensor", 4096,
                        nullptr, 1, nullptr, 0);
Stack in byteson ESP-IDF the size is bytes, unlike vanilla FreeRTOS where it is words. 4096 is a reasonable floor; anything using String or TLS wants 8192
Priority0 is idle, 24 the top. Arduino's loop() runs at 1
Core 0 vs 1ESP32S3 core 0 runs the Wi-Fi/BT stack; loop() runs on core 1
High-water markuxTaskGetStackHighWaterMark(nullptr) — the bytes of stack never used. Size from measurement

The watchdog

The classic reboot loop. A tight while in a task with no vTaskDelay() starves the idle task on that core. The Task Watchdog fires, and the board resets with Task watchdog got triggered — a message that names the task but not the line.
vTaskDelay(), not delay()inside a task. (Arduino's delay() does yield on ESP32, but the intent is clearer.)
taskYIELD()for a loop that must not sleep but must let others run
esp_task_wdt_reset()feed it explicitly during a genuinely long operation
Interrupt WDTa different fault: an ISR ran too long, or called something in flash while flash was busy

Between tasks

QueuesxQueueCreate(len, size)xQueueSend / xQueueReceive. The default way to move data
MutexxSemaphoreCreateMutex() around anything shared — an I²C bus touched from two tasks will corrupt
From an ISRthe …FromISR variants only, and keep the ISR to a flag or a queue send
Critical sectionportENTER_CRITICAL(&mux) for a few instructions — never around anything that blocks
volatileon every variable shared with an ISR, and it is still not atomic for anything wider than a word

On the single-core C3 many races simply do not happen — which makes it a poor place to test concurrency you intend to run on a DevKit.

13

LoRa — Heltec only

The only section here that applies to two boards rather than twelve. Owning exactly two Heltecs is what makes them useful: a matched pair is a point-to-point link with nothing in between.

Confirm this before wiring the inventory records less than the board does

Two things are unverified. The parts-bin entry reads only "ESP32 + SX1262" and the part number HTIT-WB32LAF. Everything in this section assumes the WiFi LoRa 32 V3 generation, which is ESP32-S3. Read the silkscreen and settle:
  • Generation — a V3 board says so, and is S3. A V2 is a classic ESP32 with an SX1276, and then the ESP32 column of §02 applies instead, along with a completely different pin map and library.
  • Frequency bandUS 902–928 MHz or EU 863–870 MHz. The wrong band is not a configuration error; it is transmitting outside your licence-exempt allocation, and the matched antenna is cut for one of them.

Both are printed on the board or its shield. Until then, treat the pin table in §03 and the code below as V3 assumptions rather than facts.

RadioLib SX1262 on the V3

#include <RadioLib.h>
// NSS, DIO1, RST, BUSY
SX1262 radio = new Module(8, 14, 12, 13);

SPI.begin(9, 11, 10);      // SCK, MISO, MOSI
int st = radio.begin(
    905.2,   // MHz — must match your band
    125.0,   // bandwidth kHz
    9,       // spreading factor 5..12
    7,       // coding rate 4/7
    0x34,    // sync word
    14);     // TX power dBm
if (st != RADIOLIB_ERR_NONE) Serial.println(st);

radio.transmit("hello");
String in; radio.receive(in);
Never transmit without an antenna. An unterminated PA reflects its own power back and can destroy the SX1262. Fit the u.FL antenna before the board is ever powered with TX code on it.

Choosing the settings

Spreading factorSF7 is fastest and shortest; SF12 is slowest and furthest. Each step up roughly doubles airtime
Bandwidth125 kHz is the usual compromise; narrower reaches further and needs better crystals
Both ends must agreefrequency, bandwidth, SF, coding rate and sync word — a mismatch is silence, never an error
Duty cycleEU 868 is capped at 1 % airtime by regulation; US 915 uses frequency hopping and dwell-time limits instead
Payload255 bytes maximum, and at SF12 that is several seconds on air
LoRa ≠ LoRaWANraw point-to-point needs no gateway and no network server. LoRaWAN needs both

For a sensor at the edge of the property reporting to the house, raw LoRa between the two Heltecs at SF9 is the right starting point — kilometres of range, no infrastructure, and a few hundred milliseconds of airtime per reading.

The onboard OLED

// 1. power the Vext rail (ACTIVE LOW)
pinMode(36, OUTPUT); digitalWrite(36, LOW);
delay(50);

// 2. reset the panel
pinMode(21, OUTPUT);
digitalWrite(21, LOW);  delay(20);
digitalWrite(21, HIGH);

// 3. its own I2C bus, separate from anything you add
Wire1.begin(17, 18);

SSD1306, 128×64, address 0x3C. Skipping step 1 gives a display that never responds and an I²C scan that finds nothing — indistinguishable from a dead panel.

14

Debug

The ESP32 crashes usefully — a panic prints a backtrace of return addresses that decodes back to file and line. Learning to read one is the highest-value ten minutes on this sheet.

Reading a panic

Guru Meditation Error: Core 1 panic'ed (LoadProhibited).
Exception was unhandled.
Core 1 register dump:
PC : 0x400d1234  PS : 0x00060730  A0 : 0x800d0f1c
...
Backtrace: 0x400d1234:0x3ffb1f10 0x400d15a8:0x3ffb1f30
LoadProhibited / StoreProhibiteda null or wild pointer. Reading 0x00000000 is the usual cause
IntegerDivideByZeroexactly what it says
InstrFetchProhibitedjumped somewhere impossible — a corrupt function pointer, or stack overrun
Stack canary watchpointthat task's stack overflowed. Raise the stack size in xTaskCreate
Cache disabled but…an ISR touched flash while flash was busy. The missing IRAM_ATTR
assert failed / heap corruptiona buffer overrun somewhere earlier — the crash site is rarely the bug site

Decode it: the ESP Exception Decoder plugin in the Arduino IDE, or pio device monitor -f esp32_exception_decoder, which decodes the backtrace live. By hand: xtensa-esp32-elf-addr2line -pfiaC -e firmware.elf 0x400d1234.

Logging

#include <esp_log.h>
static const char* TAG = "sensor";

ESP_LOGE(TAG, "error %d", err);   // error
ESP_LOGW(TAG, "warn");            // warning
ESP_LOGI(TAG, "temp=%.2f", t);    // info
ESP_LOGD(TAG, "debug");           // debug

esp_log_level_set("wifi", ESP_LOG_WARN);  // quieten one tag
CORE_DEBUG_LEVELset at build time; calls above the level are compiled out, costing nothing
Serial.printf()full printf including %f — unlike the AVR cores
ESP.getFreeHeap()print it periodically; a steady decline is a leak, and String is usually the culprit
getMinFreeHeap()the low-water mark — catches a spike you never saw
ESP.getChipModel()prints ESP32, ESP32-C3, ESP32-S3 — a one-line sanity check that you flashed what you think
esp_reset_reason()panic, watchdog, brownout, deep sleep or power-on — log it in setup()

Serial monitors & JTAG

screenscreen /dev/cu.usbserial-0001 115200 — quit with Ctrl-A then K
tiotio /dev/cu.usbmodem101 — reconnects by itself when the port disappears, which is exactly what a C3 needs
pio device monitorwith -f esp32_exception_decoder, the most useful of the three
Built-in JTAGC3S3 real single-step debugging over the same USB cable, no probe. The classic ESP32 needs an external JTAG adapter
Core dumpsenable the coredump partition in IDF and the panic is saved to flash for later reading
The C3 and S3 are better debug targets than the DevKits. Native USB gives you a JTAG debugger for free on a board that costs a few pounds — a genuine advantage over the eight WROOM boards, which need a separate adapter to do the same thing.
15

API index

The Arduino-layer calls worth having to hand, with the family restriction where one applies. Use the filter box in the header — it searches this whole sheet and lands here.

System

ESP.restart()reboot now
ESP.getFreeHeap()free heap, bytes
ESP.getMinFreeHeap()lowest heap since boot
ESP.getHeapSize()total heap
ESP.getChipModel()the family, as a string
ESP.getChipRevision()silicon revision
ESP.getChipCores()1 on the C3, 2 elsewhere
ESP.getCpuFreqMHz()current clock
setCpuFrequencyMhz()240/160/80/40 — a cheap power saving with the radio idle
ESP.getFlashChipSize()flash size, bytes
ESP.getSketchSize()and getFreeSketchSpace()
ESP.getEfuseMac()factory MAC as a uint64_t — a durable device id
esp_reset_reason()panic / WDT / brownout / deep sleep / power-on
esp_random()hardware RNG — properly random once the radio is on
temperatureRead()die temperature. Measures the die, not the room

Timing

millis()ms since boot; rolls over at ~49 days
micros()µs; rolls over at ~71 minutes
delay(ms)yields to the scheduler on ESP32 — not a busy wait
delayMicroseconds(us)busy-waits; keep it short
esp_timer_get_time()µs as int64_t — no practical rollover
timerBegin() / timerAttachInterrupt()hardware timer interrupts, µs resolution
esp_timer_create()software timers with callbacks — usually the better choice
Rollover-safe testif (now - last >= interval) — never compare absolute values with >

GPIO & analog

pinMode(p, m)INPUT OUTPUT INPUT_PULLUP INPUT_PULLDOWN
digitalWrite(p, v)HIGH / LOW
digitalRead(p)
analogRead(p)0–4095 by default
analogReadMilliVolts(p)calibrated — prefer this
analogReadResolution(b)9–12
analogSetAttenuation(a)input range; ADC_11db for 0–3.1 V
analogSetPinAttenuation(p, a)per-pin version
ledcAttach(p, f, res)core 3.x set up PWM on a pin
ledcWrite(p, duty)core 3.x — takes a pin, not a channel
ledcWriteTone(p, hz)square wave at a frequency
ledcRead(p)current duty
dacWrite(p, v)ESP32 only GPIO25/26, 0–255
touchRead(p)ESP32S3 — value drops when touched
touchAttachInterrupt()ESP32 threshold callback
attachInterrupt(p, fn, m)any GPIO
detachInterrupt(p)
digitalPinToInterrupt(p)identity on ESP32 — kept for source compatibility

Wi-Fi & network

WiFi.mode(m)WIFI_STA WIFI_AP WIFI_AP_STA WIFI_OFF
WiFi.begin(ssid, pw)
WiFi.status()WL_CONNECTED etc.
WiFi.localIP()
WiFi.RSSI()dBm
WiFi.macAddress()
WiFi.disconnect(true)true also erases the stored credentials
WiFi.setSleep(false)lower latency, higher current
WiFi.setTxPower(p)reduce to survive a weak supply
WiFi.scanNetworks()then SSID(i), RSSI(i), channel(i)
WiFi.softAP(ssid, pw)password ≥ 8 chars or the network is open
WiFi.onEvent(cb)event-driven, better than polling
WiFi.config(ip, gw, mask, dns)static addressing — call before begin()
MDNS.begin(name)name.local
configTime()NTP; then getLocalTime(&tm)
esp_now_send()peer-to-peer, no AP, 250-byte payload

Storage

prefs.begin(ns, ro)namespace; false = read-write
prefs.put*()String Int UInt Bool Float Bytes
prefs.get*(key, dflt)the default is returned when the key is absent
prefs.remove / clear / end
LittleFS.begin(true)true = format if it will not mount
LittleFS.open(path, mode)FILE_READ FILE_WRITE FILE_APPEND
exists / remove / rename / mkdir
totalBytes / usedBytes
RTC_DATA_ATTR8 KB across deep sleep

Sleep & FreeRTOS

esp_sleep_enable_timer_wakeup(µs)all families
esp_deep_sleep_start()never returns
esp_light_sleep_start()returns on the next line, RAM intact
…enable_ext0_wakeup()ESP32 only
…enable_ext1_wakeup(mask, mode)pin bitmask
esp_deep_sleep_enable_gpio_wakeup()C3S3
esp_sleep_get_wakeup_cause()UNDEFINED means a genuine cold boot
xTaskCreate()stack in bytes; portable across all three
xTaskCreatePinnedToCore()ESP32S3 — the C3 has no core 1
vTaskDelay(pdMS_TO_TICKS(n))the yielding delay
xQueueCreate / Send / Receive
xSemaphoreCreateMutex()then Take / Give
uxTaskGetStackHighWaterMark()unused stack — size from this, not a guess
esp_task_wdt_reset()feed the watchdog
16

Traps

Failures that look like something other than what they are. Roughly in order of how much time they cost.

Silent and expensive

ADC2 reads garbage with Wi-Fi onESP32 no error, no warning — just wrong numbers, or -1. Keep analog inputs on ADC1 (GPIO32–39)
GPIO12 high at reset kills the boardit selects a 1.8 V flash rail. A pull-up or an LED to 3V3 on GPIO12 is a board that never boots again until the pin is freed
USB CDC On Boot left disabledC3S3 Serial goes to UART pins you are not watching. The board is running perfectly and looks dead
Wrong flash offset0x1000 on ESP32, 0x0 on C3/S3. Flashing succeeds; the board never boots
ledcWrite compiles under both corescore 2.x takes a channel, core 3.x takes a pin. Old code builds cleanly and drives the wrong output
Huge APP silently disables OTAone app partition means no slot to update into. ArduinoOTA.begin() does not complain
LEDC frequency × resolution overflowfreq × 2^bits > 80 MHz fails without a message and leaves a dead output
C3 default I²C pinsSDA=8 is the LED, SCL=9 is the BOOT strap. Always pass pins to Wire.begin()
Short softAP passwordunder 8 characters and the AP comes up open, with no warning

Looks like a code bug, is not

Random reboots when Wi-Fi connectsbrownout. The cable, the hub, or the regulator — add 470 µF across 3V3 before touching the sketch
Upload stalls at Connecting…hold BOOT, tap EN, release BOOT. Or the cable is charge-only
Nothing on the serial monitoron a C3/S3 the port re-enumerates after reset and the monitor is holding a dead handle. Use tio, which reconnects
Reboot loop with no panictask watchdog — a loop somewhere without a vTaskDelay()
Crash only after hoursheap fragmentation from String. Watch ESP.getMinFreeHeap(); switch to fixed buffers
Backtrace points at the wrong placeheap or stack corruption. The crash site is downstream of the bug — check stack high-water marks first
Blank OLED on the Heltecthe Vext rail (GPIO36, active low) was never switched on
Servo twitches at power-upESP32 GPIO5 emits a burst during boot. Move the servo to another pin
Settings survive a "clean" reflashNVS is not erased by a normal upload. esptool.py erase_flash if you mean it
Buttons on GPIO34–39 never read lowinput-only pins have no internal pull resistors. Fit an external one
Addressable LEDs flicker on connectbit-banged timing loses to the Wi-Fi stack. Use an RMT-based library

Bench-specific

MIN132 in a 30-pin carriercount the pins first — a 38-pin board does not seat, and its extra pins are the SPI flash
Three LCD backpacks at 0x27one I²C bus holds one device per address. Re-address two of them at the solder jumpers before running more than one
2004A LCD wants 5 Vpower it from VIN and signal it from the ESP32's 3.3 V I²C. At 3.3 V power it is dim or blank, which reads as dead
HT16K33 display is the 3.3 V-friendly one2.4–5.5 V and address-selectable 0x70–0x77 — the better choice for a multi-display bus
Heltec band unverifiedUS 915 or EU 868 changes both the code and the antenna. Read the silkscreen before transmitting
No ESP32-CAM on the benchnone of these twelve has a camera interface — camera work needs different hardware