ESP32 Emulation from Scratch using QEMU

Table of Contents

We are Setting up a ESP32 Emulation in QEMU(QuickEMUlator), to write and compile the program we need ESP-IDF, which is an SDK provided by Espressif for ESP32 chipsets, so first we write and compile the programs in ESP-IDF then we use QEMU to emulate it, the end goal of this is to integrate this with Yaksh:Python based evaluation platform by FOSSEE so that we can test if the program is correct or not via the platform.
Make sure you have pretty good internet connection as we are gonna download multiple repositories

OS Config (my pc)

  • OS : Ubuntu 24.04.3 LTS (6.14.0-34-generic)
  • CPU : 12th Gen Intel(R) Core(TM) i7-12650H (x86_64)
  • Mem : 31.0 GiB
  • GPU : NVIDIA GeForce RTX 3050 Mobile

Software Config (my pc)

We will be installing these in next steps

  • Python 3.12.3
  • git version 2.43.0
  • pip 24.0
  • cmake version 3.28.3
  • GNU Make 4.3
  • gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
  • g++ (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
  • QEMU emulator version 8.2.2
  • ESP-IDF v5.5.1

Step 1: Setting up the system

have a linux or a WSL Environment, here i am using Ubuntu, if you have a GNU/linux(preferably Ubuntu/Debian) system skip the WSL step, continue to step 2, else if your using windows follow the below steps

Setting up Ubuntu/Debian in WSL (for Windows users)

Setting up and using Windows Subsystem for Linux, you can find the official documentation from windows here or you can follow the steps below to install Ubuntu in WSL, or you can choose your own linux flavour
Install WSL command
You can now install everything you need to run WSL with a single command. Open PowerShell in administrator mode by right-clicking and selecting “Run as administrator”, enter the wsl –install command, then restart your machine:

wsl --install

This command will enable the features necessary to run WSL and install the Ubuntu distribution of Linux. (This default distribution can be changed).
Change the default Linux distribution installed(if you know whay you are doing)
To see a list of available Linux distributions available for download through the online store, enter:

wsl.exe --list --online

To change the distribution installed, enter:

wsl.exe --install [Distro] # Example: wsl.exe --install ubuntu  

Now once all the installation is done restart the PC and to enable WSL, in terminal enter:

wsl # to activate WSL

then enter

cd # to redirect to home(~/) directory

which make sure that we are using the linux file system and not the /mnt/ from windows

Step 2: Prerequisites

now lets update the system repositories to latest version run

sudo apt update && sudo apt upgrade -y

once it is done lets download the basic packages, in terminal enter:

sudo apt install -y git python3 python3-pip cmake make gcc g++

now to ensure if you have installed all the packages run:

git --version
python3 --version
pip3 --version
cmake --version
make --version
gcc --version
g++ --version

If each command prints a version (e.g., git version 2.34.1), it means it’s installed and working fine, else install that specific package again

Step 3: Installing Packages

ESP-IDF

The ESP IDF come in many versions, like latest and stable, i have gone with stable and will be using the stable version for the entire process, documentation is pretty easy to follow and install and you can find it at ESP-IDF Installation

for Ubuntu/Debian users, i have copied an Excerpt for installing ESP-IDF in Linux systems below or continue from here

ESP-IDF installation for Ubuntu/Debian users

To compile using ESP-IDF, you need to get the following packages. The command to run depends on which distribution of Linux you are using:
Prerequisites
basically downloading and installing the prerequisites:

sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0 

Installing Python
now checking python 3

python3 --version

if this shows an output like

Python 3.12.3

you are good to go ! or else download python, now lets get started with the ESP-IDF installation
Getting ESP-IDF
To build applications for the ESP32, you need the software libraries provided by Espressif in ESP-IDF repository.
To get ESP-IDF, navigate to your installation directory and clone the repository with git clone, following instructions below specific to your operating system(here we are going with Ubuntu / Debian).

mkdir -p ~/esp
cd ~/esp
git clone -b v5.5.1 --recursive https://github.com/espressif/esp-idf.git

Setting up ESP-IDF
Aside from the ESP-IDF, you also need to install the tools used by ESP-IDF, such as the compiler, debugger, Python packages, etc, for projects supporting ESP32.

cd ~/esp/esp-idf
./install.sh esp32

Set up the Environment Variables In the terminal where you are going to use ESP-IDF, run:

. $HOME/esp/esp-idf/export.sh

Note the space between the leading dot and the path!

Extras(Really useful)
now we need to add this idf.py to the PATH so that we can access it from anywhere
Copy and paste the following command to your shell’s profile (.profile, .bashrc, .zprofile, etc.), usually its .bashrc

alias get_idf='. $HOME/esp/esp-idf/export.sh'

then do

source .bashrc

Yay we downloaded and installed ESP-IDF, now if we enter

get_idf

in terminal it activates ESP-IDF if that directory just running esp idf

now to get the ESP-IDF version, we need to enter this commands into terminal:

idf.py --version #this should be done after entering get_idf

After selecting esp idf version which gives the output

ESP-IDF v5.5.1

QEMU

  • Now moving on to QEMU, we need to download and install QEMU from the espressif’s Repositories, as they have support for CPU, memory, and several peripherals of ESP32, we are going to follow the QEMU Emulator Documentation from espressif

  • also if you need build for a specific target with specific features check this out

  • first lets install the prerequisites
    To use QEMU with idf.py, you first need to install the above-mentioned fork of QEMU. ESP-IDF provides pre-built binaries for x86_64 and arm64 Linux and macOS, as well as x86_64 Windows. Before you use the pre-built binaries on Linux and macOS platforms please install system dependencies:

    sudo apt-get install -y libgcrypt20 libglib2.0-0 libpixman-1-0 libsdl2-2.0-0 libslirp0   
    
  • Then install the pre-built binaries with the following command:

    get_idf # to activate ESP-IDF environmenet
    python $IDF_PATH/tools/idf_tools.py install qemu-xtensa qemu-riscv32 # Installing binaries
    

    After installing QEMU, make sure it is added to PATH by running:

    cd ~/esp/esp-idf
    . ./export.sh
    

    For those who have already installed QEMU via apt repositories, this does not cause a conflict, you can continue with this installation…

    to check if the installation is successful, enter:

    python $IDF_PATH/tools/idf_tools.py list | grep qemu # use the terminal with get_idf
    

    if the output is similar to the one given below, you have successfully installed QEMU for ESP-IDF

    * qemu-xtensa: QEMU for Xtensa (optional)
    * qemu-riscv32: QEMU for RISC-V (optional)
    

now we move on to creating project with ESP-IDF and emulating it with QEMU for ESP 32
We have Successfully Installed QEMU for ESP32 and ESP-IDF in our system

Note: QEMU for ESP32 emulates CPU and core peripherals, but not physical GPIO behavior.

Demo 1: Hello World

Now since all requirements are met, the next topic will guide you on how to start your first project, here also im using the official Espressif’s documentation, you can find it here

This guide helps you on the first steps using ESP-IDF. Follow this guide to start a new project on the ESP32 and build, flash, and monitor the device output.

Now you are ready to prepare your application for ESP32. You can start with get-started/hello_world project from examples directory in ESP-IDF.

So basically esp-idf comes with some basic programs.. we are going to run one of them called hello world
lets create a working directory via:

mkdir ~/esp-hello-world
cd ~/esp-hello-world
get_idf

now lets copy the hello world example from the ~/esp/esp-idf/examples/get-started/hello_world to the current working directory

cp -r $IDF_PATH/examples/get-started/hello_world . # This should be done inside esp-hello-world folder
cd hello_world

Now lets build the project, for that we need to set the target device, in our case, its esp32 and build it

idf.py set-target esp32
idf.py build

Now here is the difference between running on a physical esp32 and in QEMU, if we was using a physical devboard we would have used command idf.py -p PORT flash but in this case we launch it via QEMU:

idf.py qemu monitor #Starts QEMU and attaches the ESP-IDF monitor to it

NOTE: To exit the emulation use CTRL + ]

now this will run the program in QEMU successfully, it looks like Hello World simulation

Demo 2: Blinking LED

Now that everything is working fine lets start a new project from scratch, to blink an LED, I am believing that you have a bit of background in ESP32 and how its GPIO’s work, if not here is a link to that.
So we are going to attach an LED on GPIO 2 and Turn it ON wait for 1000ms(1 second) then turn it OFF then wait for another 1000ms(1 second) until i end the program, or like a loop.
first lets get started with initializing a simple project, i am using the same workspace directory as before(from Step:4):

cd ~/esp-hello-world
get_idf
idf.py create-project led_blink

now move into main directory inside led_blink, enter:

cd led_blink/main
ls

It shows these files as output LED blink basic

now We need to edit the led_blink.c file, it is the root file or like the main file of the project.
open this in any of your liked text editors like nvim, vs-code, nano etc… we are using nano text editor as it comes inbuilt with Ubuntu/Debian systems and is easy to use.
Copy the below code snippet into the file led_blink.c, you can do this by:

nano led_blink.c # in the led_blink/main directory

NOTE: Remove any lines of code currently in the file led_blink.c

Then copy the Code snippet below and paste it into the nano window via Ctrl+Shift+v Key combination

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define BLINK_GPIO 10   //Using GPIO 10

void app_main(void)
{
    gpio_reset_pin(BLINK_GPIO);
    gpio_set_direction(BLINK_GPIO, GPIO_MODE_OUTPUT);

    while (1) {
        gpio_set_level(BLINK_GPIO, 1);  // LED ON
        vTaskDelay(1000 / portTICK_PERIOD_MS);
        gpio_set_level(BLINK_GPIO, 0);  // LED OFF
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

after copying we need to save the file and exit from nano hence:

Important:
To save the file in nano enter: Ctrl+o
After that to exit nano: Ctrl+x

now make sure you are in the project directory, not in the main directory

cd ..

Now we need to do the steps we have previously done, build and run in QEMU,

idf.py build
idf.py qemu monitor

After running this you might end up with someting like Blink No work So i can’t see an LED lightup, but its actually happening, as we are not connected to the hardware we cannot see physical GPIO’s to see the ON/OFF commands we need to add Logging into the program…or we could do it in a more simpler way via printf which will be demonstrated in the next prorgam so once again go to /main/led_blink.c and edit the file via nano, if you want you can remove the previous code snipped and add this or add the newly added lines

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define BLINK_GPIO 10
static const char *TAG = "blink";

void app_main(void)
{
    gpio_reset_pin(BLINK_GPIO);
    gpio_set_direction(BLINK_GPIO, GPIO_MODE_OUTPUT);

    while (1) {
        gpio_set_level(BLINK_GPIO, 1);
        ESP_LOGI(TAG, "LED ON");
        vTaskDelay(1000 / portTICK_PERIOD_MS);

        gpio_set_level(BLINK_GPIO, 0);
        ESP_LOGI(TAG, "LED OFF");
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

Important: Use this code snippet or else you might not get the correct output

Now you will be seeing Blinking LED working YAY ! We successfully done blinking LED in QEMU via ESP-IDF

Demo 3: Simulating Temperature Sensor

Here eveything is similar to what we have done before, but rather than using Logging, we are going to use a simple printf, but it also have its disadvantages, like timestamps wont be there, also if we want to do advanced log filtering it cant be done, but if we just want simple output without all the advanced things we can use printf.
now coming back to the program, first lets create a new program in the workspace

cd ~/esp-hello-world
get_idf # ensuring that ESP-IDF is running in the current directory
idf.py create-project temp_simulate
cd temp_simulate

Now that we are in the project directory, add the code snippet below into temp_simulate/main/temp_simulate.c,

nano main/temp_simulate.c

Here we are using an actual sensor, we should be changing the read_temperature_sensor to read vales from the actual sensor and rest of the code remains the same….

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define TEMP_SENSOR_GPIO 4  // pretend sensor pin (e.g. GPIO4)

// Simulated function to "read" from GPIO
float read_temperature_sensor(void) {
    // In a real sensor: read analog/digital data here
    // For simulation, generate random readings
    int raw_value = rand() % 1024;  // simulate ADC value (0–1023)
    float voltage = (raw_value / 1023.0) * 3.3;  // 0–3.3V
    float temperature = (voltage / 3.3) * 100.0; // scale to 0–100°C (example)
    return temperature;
}

void app_main(void)
{
    srand(time(NULL));

    // Configure the GPIO pin (input mode)
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << TEMP_SENSOR_GPIO),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE
    };
    gpio_config(&io_conf);

    printf("Temperature sensor initialized on GPIO %d\n", TEMP_SENSOR_GPIO);

    while (1) {
        float temp = read_temperature_sensor();
        printf("Temperature from sensor (GPIO %d): %.2f°C\n", TEMP_SENSOR_GPIO, temp);
        vTaskDelay(pdMS_TO_TICKS(2000)); // read every 2s
    }
}

now lets build and run this in QEMU, its same as we have done previously

idf.py set-target esp32
idf.py build
idf.py qemu monitor

Which gives the output: Temperature sensor output

Now if we want to make the program simpler, because we are only simulating the values you can copy the code below to make the simulation simpler by just creating a random number between 20 and 35, also congratulations on making it till here.

NOTE: to delete the entire program without deleting the file you can use the command echo "" > main/temp_simulate.c

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void)
{
    srand(time(NULL));

    while (1) {
        float temp = 20.0 + (rand() % 1500) / 100.0; // 20.00°C to 35.00°C
        printf("Simulated Temperature: %.2f°C\n", temp);
        vTaskDelay(pdMS_TO_TICKS(2000)); // delay 2 seconds
    }
}

now lets build and run this in QEMU, its same as we have done previously

idf.py set-target esp32
idf.py build
idf.py qemu monitor

We get similar output Get the new temperature data

YAY !!! We have successfully completed Setting up ESP-IDF and integrating QEMU with it and ran multiple programs with it

Running QEMU In Headless-mode: for Yaksh (Most Important Section)

I am using this Reference.
Till now we have been running idf.py qemu for emulation, but our end goal is to run this on Yaksh, hence we need to run QEMU in headless mode to have more control over what is happening and make it easy to run in Yaksh’s backend
for that first let go the temp_simulate project inside the ~/esp-hello-world workspace :

cd ~/esp-hello-world/temp_simulate
get_idf
idf.py build

now we can check the build directory by:

ls build

Yaksh Build process Here we can see the bootloader, partition_table directories and temp_simulate.bin we need to combine these three to create a proper SPI flash image (with bootloader + partitions + app), we can do this via:

esptool.py --chip esp32 merge_bin -o build/flash_image.bin \
  0x1000 build/bootloader/bootloader.bin \
  0x8000 build/partition_table/partition-table.bin \
  0x10000 build/temp_simulate.bin

WARNING !: Make sure you are in the project directory when running this, in this case its ~/esp-hello-world/temp_simulate
NOTE: This process is same for all programs, ony thing you have to change is the build/{program_name} in line 4

This command creates build/flash_image.bin — a valid 4 MB flash image (bootloader + partition table + firmware).
Just to be on the safer side we are going to pad this image flash_image.bin into 4MB by:

truncate -s 4M build/flash_image.bin

Now we can successfully run this in QEMU by:

qemu-system-xtensa -nographic -machine esp32 \
  -drive file=build/flash_image.bin,if=mtd,format=raw

which gives the output: QEMU in headless mode

TO exit from this session you have to use Ctrl+a then click x

Reflection: Integrating with Yaksh

From the previous step we have more control over running in QEMU hence we could store its output in a text file and analyze it in the Yaksh backend with simple bash commands like:

qemu-system-xtensa -nographic -machine esp32 \
  -drive file=build/flash_image.bin,if=mtd,format=raw > output.txt

Here i saved the program into an output.txt file, i could grep into the output file to check if the desired output is present or not… Now the workflow would be:

  1. A student submits ESP-IDF source code on Yaksh.
  2. A Python based grader script automatically builds the code using idf.py build, generating an ELF binary and corresponding flash image.
  3. The flash image is executed inside QEMU in headless mode the above commands
  4. The console output (produced by printf() or ESP_LOGI()) is captured by Yaksh.
  5. The grader compares this output against expected results to determine correctness.

This approach enables fully automated and hardware-independent testing of embedded programs. It ensures fairness, reproducibility, and scalability

Conclusion

Through this guide, we successfully set up a complete ESP32 emulation environment using QEMU and ESP-IDF, enabling us to build, compile, and run embedded applications entirely in software. By walking through progressively complex demos ranging from Hello World to LED Blinking and a Simulated Temperature Sensor, we demonstrated how real ESP32 programs can execute seamlessly on a virtual device. Finally, by running QEMU in headless mode, we established a foundation for integrating with Yaksh, allowing automated evaluation of embedded code submissions.