Moving from Arduino to ESP32: Pin Mapping, 3.3V Pitfalls, WiFi and Code Porting

Outgrown the Arduino Uno? This migration guide ports you to the ESP32 systematically: the 3.3V rule that protects your board, pin quirks (strapping pins, input-only GPIOs, ADC2 vs WiFi), ADC attenuation, LEDC PWM, Preferences instead of EEPROM, library compatibility, WiFi provisioning done properly, and a checklist plus diagnosis table for the failures every migrant hits.

Written by Projectech15 min readPublished
For B.E./B.Tech Electronics, E&TC and IoT final-year students porting Arduino Uno projects to the ESP32 Topics: Arduino Uno, ESP32, GPIO, ADC, WiFi, Level Shifting
Illustration of an Arduino board connected by circuit traces to an ESP32 board with a WiFi symbol, showing migration.
Illustration generated for this guide.
In this guide

The Arduino Uno is where most students learn embedded programming — and the ESP32 is where most of them end up when the project needs WiFi, more speed, or more memory. The migration looks easy ("it's all Arduino code, right?") until the first surprises hit: a sensor wired to 5 V that the ESP32's 3.3 V pins cannot tolerate, an analog reading that behaves nothing like the Uno's, a pin that refuses to work as an output, or a sketch that compiles and then boot-loops with a brownout message. This guide walks the migration systematically: pin mapping, the 3.3 V logic pitfalls, WiFi setup, ADC/PWM/timer differences, library compatibility, and a porting checklist that gets old code running on the new chip.

If you are still deciding whether to migrate at all, ESP32 vs Arduino vs Raspberry Pi compares the platforms head-on, and the longer the detailed comparison for final-year IoT projects version goes deeper on the decision. A typical migration target looks like a smart irrigation valve controller on ESP32 — WiFi, sensors, and actuation in one build.

What actually changes (and what doesn't)

The good news first: digitalWrite, digitalRead, analogRead, Serial, millis(), delay(), and the whole Arduino library ecosystem style carry over — the ESP32 Arduino core is a genuine Arduino implementation. Your program structure survives. What changes is the hardware underneath it:

Arduino Uno (ATmega328P) ESP32
Logic voltage 5 V 3.3 V (pins are NOT 5 V tolerant)
CPU 8-bit, 16 MHz, single core 32-bit dual-core, 160–240 MHz
RAM 2 KB ~520 KB
Flash 32 KB 4 MB+ (typical module)
WiFi / Bluetooth No (needs shields) Built in
ADC 10-bit, 0–5 V, 6 channels 12-bit, 0–3.3 V (with attenuation settings), ~18 channels
PWM analogWrite(), 8-bit, fixed pins LEDC peripheral, up to 16-bit, any GPIO
EEPROM 1 KB real EEPROM Emulated in flash via Preferences/NVS
Operating voltage range 7–12 V barrel jack 5 V USB or 3.3 V regulated (onboard regulator)
Deep sleep ~µA only with hardware mods ~10 µA in software

The migration is 20% code changes and 80% electrical awareness. Most migration failures are hardware, not software.

The 3.3 V rule: the pitfall that kills boards

This is the single most important section. The ESP32's GPIO pins are not 5 V tolerant — applying 5 V to any GPIO can permanently damage the pin or the chip. On the Uno you could be casual about 5 V everywhere; on the ESP32 every signal must be checked.

What this means in practice:

  • Sensors: a sensor breakout powered at 5 V whose output swings 0–5 V (many ultrasonic modules' ECHO pin, some 5 V logic sensors) must not drive an ESP32 input directly. Fix with a voltage divider (two resistors — e.g. 1 kΩ/2 kΩ gives 3.3 V from 5 V) for slow signals, or a bidirectional level shifter for buses (I2C/SPI). Many modern sensor breakouts have a 3.3 V regulator and level shifting on board — powering the breakout at 3.3 V instead of 5 V often solves it at the source.
  • 5 V sensor, 3.3 V ESP32, analog output: a sensor outputting 0–5 V analog into the ESP32's 0–3.3 V ADC both clips the reading (anything above 3.3 V reads as max) and stresses the pin. Divide the signal down first, then scale in software.
  • Outputs are safe downward: the ESP32's 3.3 V HIGH usually drives 5 V logic inputs fine (3.3 V exceeds most 5 V CMOS/TTL input-high thresholds), so ESP32 → 5 V relay module control pins generally work. The danger direction is 5 V → ESP32.
  • Power the ESP32 correctly: feed 5 V into the VIN/USB pin (the onboard regulator makes 3.3 V), or feed regulated 3.3 V into the 3V3 pin. Never feed 5 V into the 3V3 pin.

Rule of thumb: before connecting any wire to an ESP32 GPIO, ask "what is the highest voltage this wire will ever carry?" If the answer is above 3.6 V, divide or shift it first. Make this a habit and you will never kill a board.

The power and regulators guide covers the supply side of this in depth.

Pin mapping: the Uno layout does not exist here

The Uno's pins are fixed-function and numbered 0–13 plus A0–A5. The ESP32's ~34 GPIOs are a flexible pool with quirks — some pins are input-only, some are strapping pins that affect boot, and some are connected to the onboard flash. The common 30-pin dev board exposes most of them, but "GPIO number" and "board label" sometimes differ (boards often label the physical pin D-something — always work from GPIO numbers in code).

Pins to treat with respect:

Pin(s) Quirk
GPIO34–39 Input only — no digitalWrite, no pull-up/down, no PWM output. Fine for buttons and analog sensors.
GPIO0 Boot-mode select (strapping). Pulled low at reset → bootloader mode. Do not hold it low with your circuit; a button to GND is fine (the board has its own pull-up), but a sensor pulling it low at boot bricks your boot.
GPIO2 Strapping pin (must be floating or low at boot on some modules). The onboard LED is often here — convenient, but do not attach boot-critical hardware.
GPIO12 Strapping pin (bootloader flash voltage select). Must be low/floating at boot — do not connect anything that drives it high during reset.
GPIO15 Strapping pin on some modules. Same caution as GPIO12.
GPIO6–11 Connected to the SPI flash chip. Do not use these — your program lives on that flash.
GPIO1 (TX0), GPIO3 (RX0) USB-serial. Usable as GPIO only if you do not need serial debugging (you will).
ADC2 pins (GPIO0, 2, 4, 12–15, 25–27) ADC2 cannot be used while WiFi is active — the WiFi driver claims it. Use ADC1 pins (GPIO32–39) for analog sensors in any WiFi project. This is the "analogRead returns garbage when WiFi connects" bug, and it bites everyone once.

Safe default pins for a new ESP32 project: GPIO13, 14, 16, 17, 18, 19, 21, 22, 23, 25, 26, 27, 32, 33 for general I/O; ADC1 (GPIO32–39) for analog; I2C on 21/22; SPI on 18/19/23. Start here and you avoid every quirk in the table.

ADC: 12-bit, but not like the Uno's

The Uno's ADC is simple: 10-bit, 0–5 V, analogRead() returns 0–1023, done. The ESP32's ADC is 12-bit (0–4095) with programmable attenuation that sets the input range — and non-linearity at the extremes that surprises Uno migrants:

  • Default attenuation (0 dB): measures ~0–1.1 V. A 3.3 V sensor output reads as maxed-out 4095 — not broken, just the wrong range.
  • 11 dB attenuation: measures ~0–3.3 V (actually ~0–3.9 V full scale, with the top end compressed). This is what you want for 3.3 V sensors: analogSetAttenuation(ADC_11db) or per-pin analogSetPinAttenuation(pin, ADC_11db).
  • Non-linearity: the ESP32 ADC is noticeably non-linear near 0 V and near full scale. For a threshold ("is soil wet?") this does not matter. For a measurement you will quote in a report (a calibrated pH reading), characterise your chip against known voltages and apply a correction curve — or use an external ADC (ADS1115, 16-bit, I2C) for the measurement channel. The external-ADC-via-I2C pattern is standard practice, not overkill.
  • WiFi vs ADC2 (from the pin table): in any WiFi project, analog sensors go on ADC1 pins only.

Porting rule: every analogRead() call gets reviewed — pin moved to ADC1, attenuation set for the sensor's voltage range, and the 0–1023 → 0–4095 scaling updated (value * 3.3 / 4095.0 instead of value * 5.0 / 1023.0).

PWM: analogWrite() is gone (sort of)

The Uno's analogWrite(pin, 0-255) does 8-bit PWM on fixed pins. The ESP32 Arduino core does provide analogWrite() as a compatibility shim in recent versions — but the native mechanism is the LEDC peripheral: up to 16 PWM channels, configurable resolution (1–16 bit), assignable to almost any GPIO:

// Native LEDC: 5 kHz PWM, 8-bit resolution, on GPIO16
ledcSetup(0, 5000, 8);       // channel 0, 5 kHz, 8-bit
ledcAttachPin(16, 0);        // attach GPIO16 to channel 0
ledcWrite(0, 128);           // 50% duty

Why bother with LEDC instead of the shim? Frequency control (motor drivers and LED dimming want specific frequencies — the Uno's fixed ~490 Hz is often wrong), higher resolution for smooth dimming, and any-pin assignment. For servo control, the ESP32Servo library wraps this properly — do not bit-bang servo pulses with delayMicroseconds() on a dual-core chip running WiFi; the jitter will be visible.

EEPROM → Preferences: storage done right

The Uno's EEPROM.read/write maps to 1 KB of real EEPROM. The ESP32 has no EEPROM hardware — the Arduino core emulates it in flash, but the modern answer is the Preferences library (NVS under the hood): named key-value storage with wear levelling, surviving reboots and OTA updates:

#include <Preferences.h>
Preferences prefs;

void setup() {
  prefs.begin("irrigation", false);       // namespace, read-write
  int threshold = prefs.getInt("moist_th", 40);  // key, default
  // ... use threshold ...
  prefs.putInt("moist_th", 45);           // persists across reboots
}

Port every EEPROM address to a named key — the code becomes self-documenting, and you stop doing address arithmetic. Note the namespace is limited to 15 characters and keys to 15 characters.

WiFi setup: the new superpower, done properly

The reason most students migrate. Minimum viable WiFi:

#include <WiFi.h>

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin("YourSSID", "YourPassword");
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nConnected: " + WiFi.localIP().toString());
}

Production-grade additions (do these before the project ships, not after):

  • Captive-portal provisioning (WiFiManager library): hardcoding your home SSID/password means the device fails at the demo venue. WiFiManager starts an access point ("ESP32-Setup") when it cannot connect; you join it on a phone, pick the venue's network, and the credentials persist in NVS. This is the difference between a lab demo and a deployable device.
  • Reconnection handling: WiFi drops. The WiFiEvent system or a periodic WiFi.status() check with WiFi.reconnect() keeps the device alive through router reboots. A device that needs a power cycle after every router hiccup is not finished.
  • Static IP / fast connect: storing the AP's BSSID and channel and using a static IP cuts reconnection from seconds to under a second — the low-power guide quantifies why this matters for battery devices.
  • NTP time: configTime() gives you real timestamps for logging — the Uno never had a clock; the ESP32 can have one in three lines.

From here, the cloud-connection guide takes the device from "on WiFi" to "on the internet."

Library compatibility: what ports cleanly and what doesn't

  • Pure-Arduino libraries (DHT, OneWire, LiquidCrystal, most sensor drivers): usually compile unchanged. Install the same library in the ESP32 board environment and try it — the hit rate is high.
  • Libraries with AVR assembly or direct register access (some NeoPixel/WS2812 drivers, software serial implementations, timer hacks): these break. Look for ESP32-specific forks — NeoPixelBus or the ESP32 RMT-based WS2812 drivers replace Adafruit_NeoPixel's AVR timing tricks.
  • Timing-critical bit-banging: the ESP32 is fast but its WiFi interrupts introduce jitter. Protocols needing microsecond-precise timing (DHT11/DHT22, WS2812) work via the RMT peripheral or well-maintained ESP32 libraries — do not hand-roll them with delayMicroseconds().
  • SD library: works, but use the ESP32's SD_MMC (1-bit/4-bit SDIO) or SPI mode deliberately; the default CS pin differs from the Uno.
  • Check the library's examples for ESP32 variants. Many maintained libraries ship examples_esp32 or #ifdef ESP32 branches — use them instead of fighting the AVR defaults.

Porting procedure per library: compile the library's own example for ESP32 first, on its own. If the example works, your ported code will work. If the example fails, fix or replace the library before touching your application code.

Timers and interrupts: similar, but check the details

  • attachInterrupt(pin, isr, mode) works the same — but ESP32 ISRs should carry IRAM_ATTR (so they run from RAM, not flash, which may be busy): void IRAM_ATTR myISR() {...}. Without it, interrupts during flash operations crash.
  • Keep ISRs minimal: set a flag or signal from the ISR, and do the slow work in loop() or a task — the standard ISR-to-task pattern.
  • Hardware timers differ: the ESP32 has four 64-bit timers via hw_timer_t — more capable than the Uno's 8/16-bit timers, different API. Most student needs (periodic sensor reads) are better served by millis() checks, vTaskDelay, or FreeRTOS software timers than raw hardware timers.

The porting checklist: Uno sketch → ESP32 firmware

  • Voltage audit: every input signal verified ≤ 3.3 V; dividers/shifters added where needed; sensor breakouts powered at 3.3 V where possible.
  • Pin reassignment: no GPIO6–11, no strapping pins driven at boot, analog sensors on ADC1, input-only pins (34–39) never used as outputs.
  • ADC review: attenuation set per sensor range; 0–1023 scaling replaced with 0–4095; non-linearity characterised if quoting measurements.
  • PWM ported: analogWrite replaced with LEDC (or the core's shim verified); frequency chosen for the load; servos via ESP32Servo.
  • EEPROM → Preferences: every stored value moved to named keys; defaults defined.
  • Libraries verified: each library's ESP32 example compiled and run standalone before integration.
  • Serial ports: debug stays on USB-serial (UART0); peripherals moved to UART1/UART2 on chosen pins.
  • WiFi added properly: WiFiManager provisioning (no hardcoded credentials), reconnection handling, NTP for timestamps.
  • Power supply: 5 V to VIN/USB or regulated 3.3 V to 3V3 — never 5 V to the 3V3 pin; current budget checked (WiFi bursts hit 160–260 mA).
  • Brownout tested: device runs cleanly at the low end of its supply range under WiFi transmit load.

Diagnosis table: the migration failures everyone hits

Symptom Likely cause Check
Brownout detector was triggered reset loop Supply sags under WiFi TX burst (160–260 mA); weak USB port, thin cable, or nearly-empty battery Short thick USB cable or powered hub; bench supply at 5 V; measure voltage at VIN during transmit
A fatal error occurred: Failed to connect to ESP32 on upload Wrong port, GPIO0 strapping, or the board needs the BOOT button held Correct port selected; hold BOOT, press EN, release BOOT; try a different USB cable (many are power-only)
analogRead returns 4095 constantly Default 0 dB attenuation with a 3.3 V-range sensor, or sensor powered at 5 V into the ADC Set ADC_11db attenuation; verify sensor output range with a multimeter
Analog readings go wild when WiFi connects Sensor on an ADC2 pin — WiFi claims ADC2 Move the sensor to an ADC1 pin (GPIO32–39)
Device won't boot with the sensor connected Sensor drives a strapping pin (GPIO0/2/12/15) at reset Move the sensor off strapping pins; check what the pin is driven to during EN reset
GPIO34 won't drive the relay GPIO34–39 are input-only Move the output to a normal GPIO (e.g. GPIO26)
I2C sensor worked on Uno, silent on ESP32 5 V pull-ups on the breakout feeding 5 V into ESP32 pins, or wrong SDA/SCL pins Level-shift or 3.3 V-power the breakout; confirm Wire.begin(21, 22) pins
WiFi connects at home, not at the venue Hardcoded credentials Add WiFiManager captive-portal provisioning
DHT/NeoPixel reads garbage Bit-banged timing vs WiFi interrupts Use the ESP32-native library (RMT-based); never hand-roll microsecond timing
Old code compiles but behaves oddly int is 32-bit on ESP32 vs 16-bit on Uno — overflows and bit math change Audit int assumptions; use explicit int16_t/uint32_t where width matters

That last one deserves emphasis: on the Uno, int is 16 bits (max 32,767); on the ESP32 it is 32 bits. Code that relied on 16-bit overflow wrapping, or that stored millis() in an int, behaves differently. It compiles cleanly and fails mysteriously — grep for int in timing and protocol code during every port.

Putting it together

Migrate in this order: voltage audit first (protect the hardware), pins second (respect the strapping and ADC2 rules), peripherals third (ADC attenuation, LEDC, UART remap), libraries fourth (examples first, then integration), WiFi last (provisioning, reconnection, NTP). Done in that sequence, each step is verifiable before the next begins — and the device that emerges is not just "the Uno project with WiFi bolted on" but a proper ESP32 build, like the smart irrigation valve controller it is modelled on.

Useful companions for the journey: the ESP32 vs Arduino vs Raspberry Pi comparison for the platform decision, the low-power battery design guide when the migrated device needs to run on a battery, and bluetooth/WiFi app control when the migrated project needs a phone interface.

Sourcing and cost: what the migration actually costs

A common worry is that moving to the ESP32 is expensive. In practice the ESP32 dev board costs roughly the same as an Arduino Uno clone in the Indian market (both in the few-hundred-rupees range from electronics suppliers) — the migration cost is not the board, it is the accessories:

  • Level shifters: a 4-channel bidirectional shifter board is tens of rupees. Buy two; you will use them.
  • Voltage dividers: two resistors per 5 V signal — effectively free from any resistor kit.
  • Better USB cable: the number-one brownout fix. A short, thick data cable (not the freebie charging cable) is the cheapest reliability upgrade in this entire guide.
  • External ADC (optional): an ADS1115 breakout for measurement-grade analog — a few hundred rupees, only if your report quotes calibrated figures.

What you stop buying: WiFi shields/modules (the ESP32 has WiFi), and in many cases the separate USB-serial adapter. Net cost of migration for a typical project: roughly zero, sometimes negative.

Keep the Uno: when not to migrate

Migration is not mandatory. Keep the Arduino Uno (or Nano) when the project is genuinely simple and has no connectivity need: a line-follower, a basic weather display, a timer-based appliance controller. The Uno's 5 V tolerance makes it forgiving for beginners' wiring mistakes, its behaviour is deterministic (no RTOS, no WiFi interrupts), and its power draw is easier to reason about. The right question is never "which board is better" — it is "which board's strengths match this project's needs." Migrate for connectivity, memory, speed, or cost-at-scale; stay for simplicity, 5 V robustness, and deterministic timing.

More project guides

More in IoT & Embedded