This is an emulator for the Waveshare ESP32-S3-Touch-AMOLED-1.75C, written from scratch in C, that runs the board's real firmware binary unchanged: the same image you flash to the device, booted from the chip's reset vector through the bootloader and into the app, with the picture it draws coming out on a canvas. I wrote it so I could run the clock and the other programs I made for the board without having the board in front of me.
The board is a small round AMOLED on an ESP32-S3, in an aluminium case. It's an interesting target because the thing you most want to see, the screen, isn't a simple framebuffer. The panel has its own memory, and the chip talks to it over QSPI a window at a time, so to get a picture out you have to follow the pixels all the way through the chip's DMA and the panel's command set. The full board is in the clock article; the parts that matter for the emulator are these:
| Part | Detail |
|---|---|
| Chip | ESP32-S3, dual-core Xtensa LX7, up to 240MHz |
| Memory | 8MB octal PSRAM, 32MB NOR flash, ~512KB internal SRAM |
| Display | 1.75 inch round AMOLED, 466x466, CO5300 driver over QSPI |
| Touch | CST9217 capacitive, over I2C |
| Power | AXP2101 power management, over I2C |
I built it with a lot of help from Claude, and I took two runs at it in parallel: one writing the whole thing from scratch, and one using the Espressif fork of QEMU. Both are worth it, and they end up in different places. The from-scratch one is below; the QEMU comparison is at the end.
The CPU
The ESP32-S3 has two Xtensa LX7 cores. The emulator is an interpreter: it reads the instruction at the program counter, does what it says, moves on. There's no recompilation, which keeps it simple and easy to reason about; it's slower than the real chip, but fast enough to boot and run.
The unusual thing about Xtensa for anyone coming from ARM or x86 is the register window. The code sees sixteen registers, a0 to a15, but the chip has sixty-four physical ones, and a call slides the window along by 4, 8 or 12 registers rather than pushing to a stack. a0 holds the return address and a1 is the stack pointer. When the window wraps all the way round, the oldest registers are spilled to the stack by an exception, so the interpreter has to implement the window exceptions as well as the instructions:
| Register | What it's for |
|---|---|
| a0–a15 | The sixteen general registers the current window sees (a0 return address, a1 stack pointer) |
| AR[0–63] | The sixty-four physical address registers the window slides over |
| PC | Program counter |
| PS | Processor state: interrupt level, window overflow enable, the call increment |
| WindowBase, WindowStart | Where the current window sits, and which windows hold live frames |
| SAR | Shift amount, for the shift and funnel-shift instructions |
| LBEG, LEND, LCOUNT | Zero-overhead loops: hardware jumps from LEND back to LBEG LCOUNT times |
| EPC1–7, EPS2–7, EXCSAVE1–7 | Saved PC, state and scratch per interrupt level, on an exception |
| EXCCAUSE, EXCVADDR | Why the last exception fired, and the address that caused it |
| INTENABLE, VECBASE | Which interrupts are enabled, and the base of the exception vectors |
Both cores run from the same memory and peripherals. The emulator steps core 0, then core 1, one instruction each, and the second core is only brought online when the first releases it, which is what the real startup does. The system timer that drives the FreeRTOS tick is advanced on each step and raises its interrupt through the interrupt matrix, so the scheduler runs and tasks switch exactly as on the chip.
Memory and boot
The backing memory is placed at the real ESP32-S3 addresses, so the unchanged image loads and runs where it expects to. Internal SRAM is one block seen through several windows: the same bytes appear on the data bus at 0x3FC88000 and on the instruction bus at 0x40378000, which the startup code relies on when it copies code in through one and runs it through the other. Flash and PSRAM aren't in the address space directly; they're mapped in 64KB pages by an MMU table the bootloader fills in, flash for instructions at 0x42000000 and for read-only data at 0x3C000000.
| Address | Region |
|---|---|
| 0x3C000000 | Flash, read-only data, mapped by the MMU |
| 0x3FC88000 | Internal SRAM (data bus) |
| 0x40000000 | Mask boot ROM code |
| 0x40378000 | Internal SRAM (instruction bus, the same bytes) |
| 0x42000000 | Flash, instructions, mapped by the MMU |
| 0x50000000 | RTC fast RAM |
| 0x600C5000 | The flash/PSRAM MMU page table |
| 0x60000000 | Peripheral registers (1MB window) |
Boot is faithful: the whole 32MB flash image is loaded, and the CPU starts at the ROM reset vector, 0x40000400, exactly where the chip starts on power-up. To make that work, the emulator loads the real ESP32-S3 mask ROM, so the chip's own first-stage loader runs, reads the second-stage bootloader from flash, which sets up the clocks and the MMU and loads the app's segments, and then jumps into the app. Nothing is shortcut: the boot log that comes out of the emulated UART is the board's own boot log, down to the SPI mode and the partition table.
The screen
On the device the program never writes pixels to an address. The panel is a CO5300 with its own frame memory, and the chip drives it over QSPI using the panel's command set. To draw a rectangle you send a command to set the column range, another to set the row range, then a write-memory command followed by the pixels, two bytes each, in RGB565. The ESP-IDF display driver does this through the SPI2 peripheral with DMA, so the actual bytes going to the panel are scattered through DMA descriptors in memory, not sitting in a register.
So the way pixels reach the screen on the device is: the program packs a rectangle of RGB565 into a buffer, hands it to the panel driver, the driver programs SPI2 and points its DMA channel at the buffer, and SPI2 clocks the command and then the pixels out over four data lines to the panel, which writes them into its memory inside the window that was set.
The emulator models exactly that path. When the firmware triggers an SPI2 transaction, the emulator walks the DMA channel's descriptor chain, gathers the bytes, and feeds them to a model of the CO5300. The command set is small: a four-byte header beginning with the QSPI opcode (0x02 for a command, 0x32 for pixels) carries the command byte; then the window commands set the rectangle, and the write-memory command is followed by the pixels, which are written into a 466x466 frame buffer. That frame buffer is the panel's memory, and drawing it out is the display:
void co5300_spi2_xfer(const uint8_t *data, uint32_t len)
{
// A four-byte transfer starting with a QSPI opcode is a command header.
if (len == 4 && (data[0] == 0x02 || data[0] == 0x32)) {
s_cmd = data[2]; // {opcode, 0x00, cmd, 0x00}
if (data[0] == 0x32 && s_cmd == 0x2C) begin_ramwr(); // pixels follow
return;
}
switch (s_cmd) {
case 0x2A: s_x0 = (data[0] << 8) | data[1]; // CASET: column window
s_x1 = (data[2] << 8) | data[3]; break;
case 0x2B: s_y0 = (data[0] << 8) | data[1]; // RASET: row window
s_y1 = (data[2] << 8) | data[3]; break;
case 0x2C: // RAMWR: RGB565 pixels
for (uint32_t i = 0; i + 1 < len; i += 2) {
s_fb[s_cur_y * CO5300_W + s_cur_x] = data[i] | (data[i + 1] << 8);
if (++s_cur_x > s_x1) { s_cur_x = s_x0; s_cur_y++; }
}
break;
}
}Pixels on this panel are RGB565 with the two bytes swapped, which is how the firmware packs them, so the model stores them as it receives them and swaps back only when it reads the frame memory out to the canvas. Because the data path is the real one, the dirty-square trick the clock uses falls out for free: the emulator sees exactly the small windows the clock sends, nothing more. Here's the clock running under it, booted from the real binary:
Storage
The 32MB flash holds everything: the bootloader, the partition table, the app, and the read-only data and media above it. Most of the time the firmware reads flash through the address space, because the MMU maps it into the instruction and data windows and the cache fetches from it. The emulator serves those reads straight from a 32MB buffer, translating the address through the same MMU table the bootloader programmed.
But flash is also a SPI chip with a command protocol, and the firmware talks to it directly when it needs to erase or program, or when it's identifying the chip at boot. That goes through a dedicated command engine in the memory controller: the firmware writes a command and address into registers, sets a trigger bit, and polls it until the hardware clears it. The emulator honours that contract, decoding the flash commands (read, write, erase, and the JEDEC id) and running them against the buffer. The id matters more than it sounds: the chip reports a capacity byte, and if the emulator claims 16MB while the image header says 32MB, the startup aborts before it ever reaches the app. The emulated chip reports 32MB, so the probe passes and the board boots.
The QEMU route
The other run used the Espressif fork of QEMU, which has a machine for the ESP32-S3. It's a different kind of effort: instead of writing the chip, you configure a machine that already exists and see how far the firmware gets. It gets a long way. With the octal PSRAM option turned on and a 32MB flash model, the same binary boots through the bootloader, the PSRAM is detected and speed-tested, and it reaches the app:
I (142) esp_psram: Found 8MB PSRAM device
I (156) app_init: Project name: wood_clock
I (170) main_task: Calling app_main()
I (179) co5300: version: 2.2.0
I (180) co5300_spi: LCD panel create success, version: 2.2.0And then it stops. That last line is the panel driver creating its object; the next thing it does is send the panel its first command, and the ESP32-S3 machine in QEMU has no model for the CO5300, or for the QSPI display path, or for the I2C touch and power chips. So the first panel transaction never completes and the clock loop is never reached. There's no picture.
That's the difference between the two, and it's the whole point of doing both. QEMU is excellent for what it models: it proved the binary was built correctly, flashable at the right offsets, and that the chip, the PSRAM and the partitioning were all right, with none of the work of writing a CPU. The from-scratch emulator is more work, but because it models the display path it can actually show the clock, which is the thing I wanted to see. For this board, if you want pixels, you have to write the panel.