What a Microcontroller Actually Is
Pins, flash, RAM, and why this chip has no operating system to catch your mistakes.
By the end of this session you will be able to:
- Say, for any variable or constant in your sketch, whether it lives in flash or in RAM, and whether it survives a reset
- Read the two memory lines the compiler prints after every build and explain what each number does and does not promise
- Describe what actually happens when firmware hits a bad pointer, and why the board goes quiet instead of showing you an error
A microcontroller is a computer with the parts you rely on missing
Your laptop is a CPU on one chip, RAM on other chips, storage on another, and an operating system in between you and all of it. An ESP32-S3 is one piece of silicon: two Xtensa LX7 cores at up to 240 MHz, 512 KB of SRAM, and a pile of peripherals, all on the same die. An ESP32-C3 is the same idea with one RISC-V core at 160 MHz and 400 KB of SRAM. The Arduino UNO R4 WiFi runs a Renesas RA4M1, an Arm Cortex-M4 at 48 MHz with 256 KB of flash and 32 KB of SRAM, and it carries a whole second chip, an ESP32-S3, purely to do the radio.
Hold those numbers next to your laptop. 32 KB of RAM is smaller than this page in your browser. That is not a limitation you work around later, it is the shape of everything you write from here on.
The peripherals are the interesting part. A GPIO pin is not a device you open. It is a bit inside a hardware register at a fixed memory address. When you write digitalWrite(5, HIGH), the chip is not asking anyone for permission: it stores a value to an address, and a physical piece of copper changes voltage a few nanoseconds later. Same for the timers, the ADC, the I2C controller, the Wi-Fi MAC. They are all just addresses. This is why a wild pointer here is worse than a wild pointer on a PC: writing to the wrong address does not only corrupt data, it can reconfigure hardware.
Flash and RAM, and the two numbers you get after every build
There are two memories and they behave nothing alike.
Flash holds your compiled program and anything marked constant. It survives power loss, it is slow to write, and it wears out after tens of thousands of erase cycles per sector, so it is not a scratchpad. On the RA4M1 the 256 KB of flash is on the chip itself. On an ESP32 it is a separate SPI flash part beside the chip, usually 4 MB or 8 MB on a devkit, mapped into the address space so code executes straight out of it.
SRAM holds your global variables, your stack, and your heap. It is fast, it is tiny, and at every reset it is gone. Not stale, not corrupted: gone. Anything you want to remember across a power cycle has to be deliberately written to flash.
Every build prints the split. Compile any sketch and look at the last two lines:
Sketch uses 62684 bytes (23%) of program storage space. Maximum is 262144 bytes.
Global variables use 6572 bytes (20%) of dynamic memory, leaving 26196 bytes for local variables. Maximum is 32768 bytes.The first line is flash. The second is SRAM. Read that second line carefully, because it is the most misread output in embedded work. "Leaving 26196 bytes for local variables" is arithmetic, not a measurement: the compiler counted your globals and subtracted. It has no idea how deep your call stack gets at runtime, how large a buffer some library allocates when a packet arrives, or what your heap looks like after an hour of String concatenation. There is no guard page. When the stack grows down into the heap growing up, nothing traps. Variables quietly start holding the wrong values.
Nothing is underneath you
On Linux, a bad pointer hits a page your process does not own, the MMU faults, the kernel kills the process, and the rest of the machine carries on. You get a message. Something survived to tell you.
These chips have no MMU and one flat address space. Your code, your data, and the hardware registers all share it, and your code runs at full privilege over all of it. There is no process to kill, and no main() to return from either: your program is a setup() that runs once and a loop() that must never end, because ending is not a thing the chip knows how to do.
void setup() {
Serial.begin(115200);
Serial.println("boot");
}
void loop() {
// runs forever, and there is nothing to return to
}So what does a crash look like? It depends entirely on the chip, and both outcomes surprise people. On an ESP32 you get a panic handler that dumps registers over serial and reboots:
Guru Meditation Error: Core 1 panic'ed (LoadProhibited). Exception was unhandled.
Core 1 register dump:
PC : 0x400d1a3f PS : 0x00060730 A0 : 0x800d2b1cThe wording differs between the Xtensa parts and the RISC-V C3, but you get something. On a plain Cortex-M board the default HardFault_Handler is an infinite loop, so you get nothing at all: the board stops, LEDs frozen mid-state, serial silent. Beginners conclude it is dead and order another one.
One honest complication. On the arduino-esp32 3.x core there is a scheduler under your sketch: FreeRTOS. Your loop() runs inside a task, created in the core's main.cpp:
xTaskCreateUniversal(loopTask, "loopTask", ARDUINO_LOOP_STACK_SIZE, NULL, 1, &loopTaskHandle, ARDUINO_RUNNING_CORE);ARDUINO_LOOP_STACK_SIZE defaults to 8192 bytes and the priority is 1. So your entire sketch has an 8 KB stack, and FreeRTOS is not an operating system in the sense that protects you. It is a scheduler linked into your binary, running at your privilege level, sharing your address space. It will happily schedule code that is already corrupting it.
Try it
Open Wokwi, start a new ESP32 project, and build the default sketch. Write down both numbers from the build output.
Now add this as a global, above setup(), and build again:
char lookup[8000];Program storage barely moves. Dynamic memory jumps by about 8000 bytes. Now change one word:
const char lookup[8000] = {0};Build a third time.
Success condition: on the third build the dynamic memory figure has dropped back by roughly 8000 bytes and the program storage figure has risen by roughly 8000. You added one keyword and moved eight kilobytes from RAM into flash. Be ready to say why: a constant array can never change, so it never needs copying into RAM at boot, it can be read in place from flash.
Common mistakes
- Treating "leaving N bytes for local variables" as free RAM. It is a subtraction, not a guarantee. Your stack, your heap and any library's internal buffers all come out of that number at runtime, and nothing warns you when they meet. Get suspicious when a sketch works until you add one more feature.
- Expecting a variable to survive a reset. SRAM comes up as garbage or zeroes every boot. A counter or a calibration value that has to persist goes into flash on purpose. There is no ambient "the system saved it for me".
- Reaching for
Stringand dynamic allocation out of habit. With kilobytes and no memory manager watching over you, repeated allocation and release fragments the heap until an allocation that used to work fails. Fixed-size buffers are how you get a device that runs for a month. - Reading a frozen board as a broken board. Silence almost always means a fault handler spinning or a watchdog reboot loop, not dead silicon. Open the serial output before concluding anything about hardware.
- Assuming the number in your sketch is the pin on the board. GPIO numbers, the silkscreen label, and the physical package pin are three different numbering schemes, and on ESP32 devkits they disagree constantly. Check your specific board's pinout.
Where this goes next
Next session, Toolchain and First Upload, you install Arduino IDE 2.3, add the arduino-esp32 board core, and get a blink running on real hardware or in Wokwi. The memory numbers you just learned to read will be printed at you every single time you press upload.