Skip to content
Salah Adawi Salah Adawi

Tingou Wu's Website

We believe that this document is primarily human-written, with some AI-generated content detected.

Hacker News Article AI Analysis

Content Label

Mixed

AI Generated

12%

Human

88%

Window 1 - 100% AI-Generated
Building a Hot-Swappable, Dual-Paradigm Environment on Espressif SiliconI'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.
Window 2 - Human
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.Background: Why Not Just Pin a FreeRTOS Task?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. // You could pin a Rust function to Core 1 this way — but FreeRTOS // would still manage the scheduler on that core. BaseType_t xTaskCreatePinnedToCore( TaskFunction_t pvTaskCode, // Function that implements the task const char * const pcName, // Human-readable name for debugging const uint32_t usStackDepth, // Stack size in words (not bytes) void * const pvParameters, // Arbitrary pointer passed to the task UBaseType_t uxPriority, // Priority (higher = more CPU time) TaskHandle_t * const pvCreatedTask, // Output: handle to the created task const BaseType_t xCoreID // 0 = PRO core, 1 = APP core ); 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.
Window 3 - Human
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.Part 0: Statically Linked Rust on a Bare CoreLet'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.Step 1: Reserve Memory for the Bare-Metal Core (C Side)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. // The two hex values define the start and end addresses of the reserved region. // 0x3FCE9710 - 0x3FCC9710 = 0x20000 = 131072 bytes = 128KB. // "rust_app" is just a label for debugging — it shows up in boot logs. SOC_RESERVE_MEMORY_REGION(0x3FCC9710, 0x3FCE9710, rust_app); Why 128KB? It's a reasonable default for an embedded stack plus some working memory. You can adjust this range depending on how much RAM your Rust code needs — just make sure the addresses fall within the ESP32-S3's internal SRAM region and don't overlap with anything ESP-IDF is using.Step 2: Wake Up Core 1 from the C SideThis is the main ESP-IDF application running on Core 0.
Window 4 - Human
Its job is to:Set up the system (Wi-Fi, peripherals, etc. — or in our test case, just boot).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.To verify that everything is working, Core 0 will read a shared counter variable (RUST_CORE1_COUNTER) that the Rust code on Core 1 increments in a loop.#include <stdio.h> #include <stdint.h> #include "esp_log.h" #include "esp_cpu.h" #include "heap_memory_layout.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "soc/system_reg.h" #include "soc/soc.h" static const char *TAG = "rust_app_core"; // Reserve memory so ESP-IDF's heap allocator doesn't use it. // (Same macro from Step 1 — it must appear in a compiled C file.) SOC_RESERVE_MEMORY_REGION(0x3FCC9710, 0x3FCE9710, rust_app); // ---- External symbols ---- // These are defined in other files and resolved at link time: // rust_app_core_entry — the Rust function (from our .a library) // app_core_trampoline — tiny assembly stub that sets the stack pointer // _rust_stack_top — address from our linker script (top of reserved 128KB) // ets_set_appcpu_boot_addr — ROM function that tells Core 1 where to start extern void rust_app_core_entry(void); extern void ets_set_appcpu_boot_addr(uint32_t); extern uint32_t _rust_stack_top; extern void app_core_trampoline(void); /* * Boot Core 1 by directly manipulating ESP32-S3 hardware registers. * This bypasses FreeRTOS entirely — Core 1 will run our code with * no scheduler, no interrupts (unless we set them up), and no OS. */
Window 5 - Human
static void start_rust_on_app_core(void) { ESP_LOGI(TAG, "Starting Rust on Core 1..."); ESP_LOGI(TAG, " Stack: 0x3FCC9710 - 0x3FCE9710 (128K)"); /* 1. Tell Core 1 where to begin executing after it resets. * This ROM function writes the address into a register that the * CPU reads on boot. We point it at our assembly trampoline. */ ets_set_appcpu_boot_addr((uint32_t)app_core_trampoline); /* 2. Hardware-level wake-up sequence for Core 1. * These register writes control the clock, stall, and reset * signals for the second CPU core. */ // Enable the clock gate — Core 1 can't run without a clock signal. SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_CLKGATE_EN); // Clear the RUNSTALL bit. While stalled, the core is frozen mid-instruction. CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RUNSTALL); // Pulse the reset line: assert it, then immediately de-assert. // This causes Core 1 to reboot and jump to the address we set above. 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); ESP_LOGI(TAG, "Core 1 released"); } // This counter lives in the Rust code. Because it's an AtomicU32 with // #[no_mangle], the C linker can find it by this exact name. extern volatile uint32_t RUST_CORE1_COUNTER; void app_main(void) { ESP_LOGI(TAG, "Core 0: Starting IDF app"); // Wake up Core 1 and start the Rust code start_rust_on_app_core(); // Core 0 continues running FreeRTOS as normal. // Here we just monitor the shared counter to prove both cores are alive.
Window 6 - Human
while (1) { ESP_LOGI(TAG, "Rust Core 1 counter: %lu", (unsigned long)RUST_CORE1_COUNTER); vTaskDelay(pdMS_TO_TICKS(1000)); // Print once per second } } Step 3: The Assembly TrampolineWhen a CPU core wakes up from reset, it doesn't have a stack yet. And without a stack, it can't call any C or Rust functions — function calls need somewhere to store return addresses and local variables.The ESP32-S3 uses the Xtensa instruction set architecture, where register a1 serves as the stack pointer. Our tiny assembly stub loads the address of our reserved memory into a1, then jumps into Rust. That's all it does — just two instructions.We place this code in the .iram1 section, which maps to Internal RAM. This is important because when a core first boots, it may not have flash caching set up yet. Code in IRAM is always accessible.app_core_trampoline.S/* * app_core_trampoline.S * * Minimal startup code for Core 1. Sets the stack pointer to our * reserved memory region, then jumps to the Rust entry point. * * Placed in IRAM (.iram1) so it's available immediately after core * reset, before flash cache is configured. */ .section .iram1, "ax" /* "ax" = allocatable + executable */ .global app_core_trampoline .type app_core_trampoline, @function .align 4 /* Xtensa requires 4-byte alignment */ app_core_trampoline: /* Load the top of our 128KB reserved stack into register a1. * Stacks grow downward on Xtensa, so "top" means the highest * address — the stack will grow toward lower addresses from here. */ movi a1, _rust_stack_top /* Jump to the Rust entry function. call0 is a "windowless" call * (no register window rotation), suitable for bare-metal startup. * This function never returns — it contains an infinite loop. */ call0 rust_app_core_entry .size app_core_trampoline, . - app_core_trampoline Step 4: Gluing It Together with CMake and a Linker ScriptESP-IDF uses CMake as its build system.