Published on June 30, 2026

It Boots! #1 - (trying to run doom on esp)

So I am trying to run doom on an esp32.

Not because the world needs another doom port. I've been using these chips for a while and I have no fucking idea how they actually work. I hit upload in Arduino IDE or run idf.py flash, and the chip does something. But what happens in between. How does my code turn into something a piece of silicon actually understands.

I don't know. That bothers me.

(so no libraries, no idf.py build and pray, no arduino hiding the ugly parts. just me, the chip, and the datasheet)

So I write this:

int main() {
    while(1) {}
    return 0;
}

That's it. A program that does nothing, forever. But how does this turn into ones and zeros sitting on a flash chip that the esp32 can boot.

Turns out there's a chain of steps, and the rough version is: c code becomes an object file, the linker script decides where everything lives in memory, that produces an elf file, esptool strips the elf down into a bin file, the bin gets written to flash, and then the chip boots it. Here's each step, explained the way I had to explain it to myself.

the compiler doesn't know where anything goes

I'm using xtensa-esp32s3-elf-gcc. Same gcc, just built to target a different cpu. It takes my c code and produces an object file. An object file is machine code, but it's not placed anywhere yet. The instructions exist, but there's no memory address attached to any of them.

why I needed my own linker script

The linker is what takes object files and decides where in memory each piece actually goes. But it can only decide that if it knows what memory exists and what it's for. On a normal computer that's mostly hidden from you. On a chip like this, nothing is hidden, and there's no default to fall back on. I had to tell the linker myself.

So I opened the esp32-s3 technical reference manual and found where internal sram lives (0x40370000), where dram lives (0x3FC88000), where flash starts (0x42000000), where psram sits. Then wrote this:

ENTRY(call_start_cpu0);

MEMORY
{
  /* internal sram 512kb */
  iram_seg   (RX) : org = 0x40370000, len = 0x40000  /* 256 kb for code iram */
  dram_seg   (RW) : org = 0x3FC88000, len = 0x60000  /* 384 kb for data dram */

  /* 2. external 16mb flash */
  flash_code (RX) : org = 0x42000000, len = 0x1000000 /* 16 mb code */
  flash_data (R)  : org = 0x3C000000, len = 0x1000000 /* 16 mb read only data */
}

SECTIONS
{
  .iram.text : { *(.iram1) *(.text.fastcode) } > iram_seg
  .dram.data : { *(.data) *(.data.*) *(.bss) *(.bss.*) } > dram_seg
  .flash.text : { *(.text) *(.text.*) } > flash_code
  .flash.rodata : { *(.rodata) *(.rodata.*) } > flash_data
}

The RX and RW and R aren't decoration, they're permission flags. RX means the cpu can read from that region and execute code out of it, so that's where instructions go. RW means the cpu can read and write it but not execute it, so that's where variables live. R is read-only, for constants. Every SECTIONS line is just telling the linker "this category of thing goes in that category of memory."

It's working, okay-ish. Which means it works but I know something in there is still wrong and I'll find out later. That's basically the theme of this whole project.

what an elf file actually is

After linking, you get an elf file. Executable and Linkable Format. I spent a full day on youtube trying to get this straight:

https://youtu.be/AvEHUfIm4oQ https://youtu.be/csg7kQ4Sltg https://youtu.be/wxywrh1zbKM https://youtu.be/nC1U1LJQL8o

There are sections, segments, headers, program headers. A lot of names for what's really just a structured container. The practical part is readelf, which lets you look inside one. .text is your actual code. .data is variables that already have a value before the program starts. .bss is variables that don't, just reserved space. .rodata is constants and strings, read-only.

why the chip needs a different format than the elf

The chip doesn't understand elf. Elf carries a lot of information meant for tools on a computer, symbol tables, debug info, section headers describing sections. None of that is useful to a tiny rom bootloader that just wants to know where to put bytes in memory and where to start executing. So esptool.py has a command, elf2image, that strips all of that away and produces a much simpler binary image.

esptool.py --chip esp32s3 elf2image \
    --flash_mode dio \
    --flash_freq 80m \
    --flash_size 16MB \
    --output output.bin \
    output.elf

the magic byte

Then I check the first byte of that bin file:

MAGIC=$(xxd $BIN | head -1 | awk '{print $2}')
if [ "$MAGIC" != "e900" ]; then
    echo "warning: first byte is not 0xe9"
fi

The esp32's rom bootloader expects the very first byte of a valid image to be 0xe9. It's a sanity check. Before the bootloader trusts a chunk of flash enough to start executing it as code, it looks for that one byte. No 0xe9, no boot. (there's something satisfying about a literal magic number gatekeeping whether your chip runs or not)

writing it to flash, and why download mode exists

esptool.py --chip esp32s3 --port /dev/ttyUSB0 --baud 460800 write_flash 0x0 output.bin

Before this works, the chip has to be in download mode. Hold boot, tap reset, release boot. Normally on reset the chip tries to run whatever's already in flash. Download mode tells the rom to skip that and instead listen on uart for esptool to hand it new data. Once the write finishes, a normal reset boots into the code that's now sitting there.

The full chain, start to finish: c code, object file, linker script places everything, elf file, esptool strips it to a bin, magic byte gets checked, flash gets written, chip boots, hopefully doesn't crash.

My win condition for today was small. The chip doesn't crash or reboot-loop. If my do-nothing-forever program runs and the chip just sits there doing nothing, that means the linker script mapped memory correctly, the entry point is right, and the chip actually found and ran my code.

It worked. Okay-ish.

This was just getting the chip to boot my own code with nothing prebuilt underneath it. Next is blinking a pin, which means finding the gpio direction register and the set-high register in the datasheet and flipping bits directly.

(and eventually, doom. on a five dollar chip. still just an idea for now)

subscribe to my substack

Get monthly summaries, books read, essays, and link digests delivered to your inbox.

Get updates viasubstackorsubscribe to RSS