Running Bare-Metal Rust Alongside ESP-IDF on the ESP32-S3's Second Core

Learn how to leverage the dual-core architecture of the ESP32-S3 to run bare-metal Rust code on one core while keeping ESP-IDF and FreeRTOS on the other for wireless connectivity.
Running Bare-Metal Rust Alongside ESP-IDF on the ESP32-S3's Second Core
I've been working with the RP2350 and no_std Rust for a while now, and I've really come to appreciate how Rust is designed – safe yet surprisingly straightforward. But my latest project needs Wi-Fi and BLE, and the RP2350 doesn't have wireless hardware built in. That meant switching to the ESP32-S3.
The ESP32-S3 is a great chip, but here's the catch: most Wi-Fi and Bluetooth functionality lives inside Espressif's ESP-IDF framework, which is a C-based SDK built on top of FreeRTOS. There are community Rust wrappers for parts of ESP-IDF, and Espressif themselves offer some Rust support, but both are a moving target – documentation is sparse compared to the mature C API, and there's always one or two critical features missing.
So I was stuck choosing between two imperfect options:
Go all-in on Rust. I'd get the language features and crates I love, but the no_std ecosystem on ESP32-S3 is still young. In a shipping product, I didn't want to risk hitting undefined behavior in an immature HAL at 2 AM.
Go all-in on ESP-IDF (C). I'd get battle-tested Wi-Fi and BLE stacks, but I'd be writing C for everything – including the business logic, audio processing, and data handling where Rust really shines.
Then I remembered something: the ESP32-S3 has two CPU cores.
There's an option buried in ESP-IDF's Kconfig called CONFIG_FREERTOS_UNICORE. When you enable it, FreeRTOS only runs on Core 0. Core 1 just... sits there, stalled, doing nothing. That got me thinking: what if I let ESP-IDF own Core 0 for all the Wi-Fi, BLE, and system tasks, and then wake up Core 1 to run my own bare-metal Rust code – completely outside the RTOS?
Both cores share the same memory space, so passing data between them should be straightforward (though it does require some unsafe Rust). And since Core 1 wouldn't be managed by FreeRTOS, there'd be no scheduler preempting my time-critical audio processing loop.
After convincing myself this wasn't completely insane, I got to work. Here's how it all fits together.
Before diving in, it's worth addressing the obvious question: ESP-IDF already provides xTaskCreatePinnedToCore, which can pin a task to a specific core:
// FreeRTOS provides this function to create a task on a specific core.
BaseType_t xTaskCreatePinnedToCore(
TaskFunction_t pvTaskCode,
const char * const pcName,
const uint32_t usStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
TaskHandle_t * const pvCreatedTask,
const BaseType_t xCoreID
);
You could absolutely compile your Rust code as a static library, export a pub extern "C" fn, and have FreeRTOS run it on Core 1 via this API. The ESP-IDF build system would statically link your Rust .a file into the firmware.
The problem is that FreeRTOS's scheduler is still running on Core 1. Your task can be preempted at any time by higher-priority tasks or system ticks. For a high-performance audio processing loop where every microsecond of jitter matters, that's a non-starter. I needed a guarantee that nothing would interrupt my code once it started running.
By disabling FreeRTOS on Core 1 entirely (via CONFIG_FREERTOS_UNICORE=y), we get an empty CPU that we can control directly at the hardware level – no scheduler, no context switching, no surprises.
Let's start with the simpler approach: building Rust as a static library, linking it into the ESP-IDF firmware at compile time, and manually booting Core 1 to run it. This is the foundation everything else builds on.
When Core 1 wakes up outside of FreeRTOS, it doesn't get a dynamically allocated stack from the OS – because there is no OS on that core. We need to manually set aside a chunk of RAM that ESP-IDF's heap allocator won't touch.
ESP-IDF provides the SOC_RESERVE_MEMORY_REGION macro for exactly this. It tells the bootloader and memory allocator to treat a specific address range as off-limits:
#include "heap_memory_layout.h"
// Reserve 128KB of internal SRAM for Core 1's stack and data.
SOC_RESERVE_MEMORY_REGION(0x3FCC9710, 0x3FCE9710, rust_app);
This is the main ESP-IDF application running on Core 0. Its job is to:
- Set up the system (Wi-Fi, peripherals, etc.).
- Wake up Core 1 and point it at our Rust code.
- Go about its normal FreeRTOS business.
Instead of using xTaskCreatePinnedToCore, we're talking directly to the ESP32-S3's hardware registers to boot Core 1. We set a boot address, enable the clock, release the stall, and pulse the reset line. Core 1 wakes up completely independent of FreeRTOS.
static void start_rust_on_app_core(void)
{
ESP_LOGI(TAG, "Starting Rust on Core 1...");
ets_set_appcpu_boot_addr((uint32_t)app_core_trampoline);
SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_CLKGATE_EN);
CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RUNSTALL);
SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RESETING);
CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RESETING);
}
Source: Hacker News














