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.
What this does not do
Section titled “What this does not do”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.
The three holds
Section titled “The three holds”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.
Functions
Section titled “Functions”is_running
Section titled “is_running”unsafe fn is_running() -> boolWhether 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.
Safety
Section titled “Safety”Reads DPORT and RTC. No side effects.
is_stalled
Section titled “is_stalled”unsafe fn is_stalled() -> boolWhether the APP CPU is currently held stalled by the RTC.
Safety
Section titled “Safety”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.
When this is the right instrument
Section titled “When this is the right instrument”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.
Safety
Section titled “Safety”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.
What the started core may touch
Section titled “What the started core may touch”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.
Safety
Section titled “Safety”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.
Safety
Section titled “Safety”Whatever it was executing stops mid-instruction. Anything it was holding — a lock, a half-written buffer — stays in that state.
unstall_now
Section titled “unstall_now”unsafe fn unstall_now()Release the stall. Public counterpart to the private helper used by
[start], for callers that stalled with [stall].
Safety
Section titled “Safety”Writes RTC registers.