Effortlessly Master the ESP32 Bootloader | From Power-On to App Execution


The ESP32 Bootloader is the first piece of code that runs when the chip powers up. It is responsible for initializing the hardware, verifying the firmware, and deciding how to load the application.

This article will guide you through what happens from the moment the ESP32 is powered on. We’ll explore the role of the bootloader, and walk through a hands-on project to write your own ESP32 bootloader. You’ll gain insight into the full flow from ROM → Bootloader → App.

Whether you’re developing OTA functionality, a boot menu, or want to dive into more advanced features, this tutorial is your first step toward mastering the ESP32 startup mechanism. In just a few minutes, you’ll move beyond simply usingthe ESP32 bootloader — to understanding and controlling it.

ESP32 Bootloader

What Is the ESP32 Bootloader?

The bootloader is a low-level program that runs at the startup of an embedded system. Its key responsibilities include:

  • Initializing the CPU, memory, clocks, and other hardware
  • Loading the application (App) from flash
  • Verifying firmware integrity (optional)
  • Supporting OTA (Over-The-Air updates)

On the ESP32, the bootloader is typically stored at Flash address 0x1000 and is directly executed by the on-chip ROM code.

ESP32 Bootloader Flow Diagram

    A[Power On / Reset] --> B[Boot ROM (Factory Burned)]
    B --> C[Bootloader (Customizable)]
    C --> D[Load Application]
    D --> E[Run app_main()]
  • Boot ROM is factory-programmed into the chip and is responsible for loading the bootloader.
  • Bootloader is the customizable stage where you can implement your own logic.
  • The final step jumps to app_main() in your application code.

Development Environment

Before starting your programming, make sure to complete the following preparations:

Project Structure

esp32_boot_demo/
├── CMakeLists.txt
├── main
│   ├── CMakeLists.txt
│   └── main.c                 // User application
├── bootloader_components
│   └── main         
│       ├── CMakeLists.txt   
│       └── bootloader_main.c  // Custom bootloader
└── README.md              

Code

bootloader/bootloader_main.c

#include <stdbool.h>
#include "sdkconfig.h"
#include "esp_log.h"
#include "bootloader_init.h"
#include "bootloader_utility.h"
#include "bootloader_common.h"

static const char* TAG = "boot";

static int select_partition_number(bootloader_state_t *bs);

/*
 * We arrive here after the ROM bootloader finished loading this second stage bootloader from flash.
 * The hardware is mostly uninitialized, flash cache is down and the app CPU is in reset.
 * We do have a stack, so we can do the initialization in C.
 */
void __attribute__((noreturn)) call_start_cpu0(void)
{
    // 1. Hardware initialization
    if (bootloader_init() != ESP_OK) {
        bootloader_reset();
    }

#ifdef CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP
    // If this boot is a wake up from the deep sleep then go to the short way,
    // try to load the application which worked before deep sleep.
    // It skips a lot of checks due to it was done before (while first boot).
    bootloader_utility_load_boot_image_from_deep_sleep();
    // If it is not successful try to load an application as usual.
#endif

    // 2. Select the number of boot partition
    bootloader_state_t bs = {0};
    int boot_index = select_partition_number(&bs);
    if (boot_index == INVALID_INDEX) {
        bootloader_reset();
    }

    // 2.1 Print a custom message!
    esp_rom_printf("[%s] %s\n", TAG, "Custom Bootloader Welcome Message");

    // 3. Load the app image for booting
    bootloader_utility_load_boot_image(&bs, boot_index);
}

// Select the number of boot partition
static int select_partition_number(bootloader_state_t *bs)
{
    // 1. Load partition table
    if (!bootloader_utility_load_partition_table(bs)) {
        ESP_LOGE(TAG, "load partition table error!");
        return INVALID_INDEX;
    }

    // 2. Select the number of boot partition
    return bootloader_utility_get_selected_boot_partition(bs);
}

// Return global reent struct if any newlib functions are linked to bootloader
struct _reent *__getreent(void)
{
    return _GLOBAL_REENT;
}

main/main.c

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

// Entry point of the main application.
// This is the function that runs after the bootloader hands over control.
void app_main() {
    // Print a message to indicate the app has started.
    printf("=== Hello from app_main ===\n");

    // Run an infinite loop with 1-second delay.
    // This is a common pattern in embedded systems to keep the task alive.
    while (1) {
        vTaskDelay(1000 / portTICK_PERIOD_MS); // Delay for 1000 ms (1 second)
    }
}

CMakeLists.txt (root)

# esp32_boot_demo/CMakeLists.txt

cmake_minimum_required(VERSION 3.5)

include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32_boot_demo)

main/CMakeLists.txt

idf_component_register(
    SRCS "main.c"
    INCLUDE_DIRS "."
)

bootloader/CMakeLists.txt

idf_component_register(SRCS "bootloader_main.c"
                    REQUIRES bootloader bootloader_support)

idf_build_get_property(target IDF_TARGET)
# Use the linker script files from the actual bootloader
set(scripts "${IDF_PATH}/components/bootloader/subproject/main/ld/${target}/bootloader.ld"
            "${IDF_PATH}/components/bootloader/subproject/main/ld/${target}/bootloader.rom.ld")

target_linker_script(${COMPONENT_LIB} INTERFACE "${scripts}")

Code Explanation

  • bootloader_init()
    Initializes fundamental hardware components.
  • bootloader_utility_load_boot_image_from_deep_sleep()
    Used for quick startup after deep sleep by skipping verification steps.
  • select_partition_number()
    Parses the partition table and selects the correct boot partition (e.g., factoryota_0).
  • bootloader_utility_load_boot_image()
    Loads the application image and jumps to its entry point (usually 0x10000).

Compile and Flash

After writing the code, you can use the ESP-IDF tools to build, flash, and monitor:

In the VS Code lower-left ESP-IDF toolbar:

  • Click Build project
  • Click Flash device
  • Click Monitor device

You should then see:

[boot] Custom Bootloader Welcome Message=== Hello from app_main ===

Conclusion

Through this hands-on example, we’ve walked through the complete ESP32 boot process — from power-up, to bootloader initialization, and finally transferring control to the application.

You now understand what the ESP32 bootloader does and how to implement your own startup logic. This skill is extremely useful for OTA updates, secure boot verification, or designing a multi-boot system.

Understanding the ESP32 bootloader is not just for advanced developers — it’s essential knowledge for every ESP32 developer looking to work at the system level.