Skip to content

soc_esp32::appcpu

Starting the second core.

The ESP32 has two Xtensa LX6 cores. Only PRO (core 0) runs at reset; APP (core 1) sits held in reset, clock-gated, and stalled by the RTC — three separate mechanisms, all of which must be released, in order.

It starts a core. It does not make the kernel SMP-safe, and the two are very different jobs. See [start] for exactly what the started core may and may not touch.

Missing any one of them gives a core that never runs, with nothing reporting a problem:

Hold Register Released by
RTC stall RTC_CNTL_OPTIONS0 bits [1:0], RTC_CNTL_SW_CPU_STALL bits [25:20] clearing both fields
Clock gate DPORT_APPCPU_CTRL_B bit 0 setting it
Reset DPORT_APPCPU_CTRL_A bit 0 pulsing it

The stall is the one that catches people: it is in the RTC domain, not DPORT, and it survives a DPORT reset. A core released from reset and clock but left stalled fetches nothing.

Register facts from soc/dport_reg.h and soc/rtc_cntl_reg.h.

unsafe fn is_running() -> bool

Whether the APP CPU is actually executing instructions.

Not the complement of [is_stalled]. A core that has never been started is held in reset with its clock gated, and the stall bits are clear — so !is_stalled() calls it running and every caller that acts on that pays for stalling a core that is not there.

That cost was real and large. esp32-flash’s cache-off window used !is_stalled(), so on any single-core application every flash read stalled and released a core at reset, and stall/unstall each wait 32768 cycles for the RTC’s slow-clock domain to catch up. 819 µs of settling per read, on a read that takes microseconds. It showed up as kvstore taking 1.36 s to load 1904 bytes.

Reads DPORT and RTC. No side effects.

unsafe fn is_stalled() -> bool

Whether the APP CPU is currently held stalled by the RTC.

Reads RTC registers. No side effects.

unsafe fn stall()

Hold the APP CPU stalled.

The inverse of unstall, and the pair encodes a magic value rather than a flag: 0x21 in the C1 field and 0x2 in C0 is what the hardware reads as stalled. Writing anything else leaves the core running.

esp-idf deliberately does not stall the other core for flash operations while its scheduler is running; it uses a task handshake instead, because stalling a core that holds a spinlock deadlocks whoever wants that lock next.

FlintOS can stall, but the reason is narrower than it first looks, and the obvious justification is wrong: the APP CPU does run kernel tasks and does take the scheduler spinlock — apps/tests/smp calls join_scheduler on it. [start]’s doc comment still says otherwise and predates that.

What makes it safe is the caller, not the core: the flash driver takes no lock between stalling and releasing. Core 1 may be stalled holding the scheduler lock, and nothing on core 0 asks for it during that window.

That is a property of the flash path, and a fragile one. Add a lock acquisition anywhere inside a flash operation and this deadlocks — core 0 waiting for a lock held by a core that cannot run. If that ever becomes awkward to guarantee, the answer is NuttX’s shape in esp32_spiflash_opstart: signal the other core, wait for it to park itself, and never stall hardware that might be holding something.

Writes RTC registers. The caller must unstall: a core left stalled fetches nothing and looks exactly like a core that crashed.

unsafe fn start(entry: fn(..))

Start the APP CPU at entry.

Historically not the kernel, and this paragraph is now out of date: kernel::boot::join_scheduler brings the APP CPU into the scheduler, and apps/tests/smp does exactly that. What follows described the state before that existed and is kept because the constraints it lists still apply to a core started without joining.

Not the kernel. FlintOS’s scheduler is single-core: scheduler::global() hands out &mut to a static, and a critical section masks interrupts on the calling core only. Two cores in that code is a data race in the language’s own terms, not merely a logical one.

So entry may use its own stack and memory it owns, and may read or write AtomicU32s shared with the PRO CPU. It must not call into kernel, take a FlintOS mutex, send on a FlintOS queue, or spawn a task. Making any of that safe is the rest of #19, and it is a much larger job than this function.

entry must never return — there is no context to return to. It must set its own stack pointer before touching memory: the core arrives with a1 undefined, and the trampoline in arch-xtensa is what handles that.

Starting an already-running core resets it mid-instruction.

unsafe fn stop()

Put the APP CPU back in reset.

Whatever it was executing stops mid-instruction. Anything it was holding — a lock, a half-written buffer — stays in that state.

unsafe fn unstall_now()

Release the stall. Public counterpart to the private helper used by [start], for callers that stalled with [stall].

Writes RTC registers.