m5stack_core/mem.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Global heap ownership + external PSRAM integration.
3//!
4//! The BSP owns the global heap so a binary never spells out
5//! `esp_alloc::heap_allocator!` or the per-board region sizes itself:
6//! [`init_heap`] declares the esp-alloc DRAM regions for a [`HeapProfile`] and,
7//! optionally, registers external PSRAM. esp-alloc's global heap holds at most
8//! three regions, so each profile registers the reclaimed-ROM region, the
9//! plain-DRAM region, and (when PSRAM is supplied) the external region — never a
10//! fourth (a 4th `add_region` panics silently). The sizes are the HIL-proven
11//! per-board values previously copied into every binary.
12//!
13//! ```ignore
14//! use m5stack_core::mem::{self, HeapProfile};
15//!
16//! // After board init, before the first allocation:
17//! mem::init_heap(HeapProfile::Default); // DRAM regions only — never touches PSRAM
18//! mem::init_heap(HeapProfile::Lvgl);
19//! ```
20//!
21//! `init_heap` never touches PSRAM — it owns only the DRAM region sizes for a
22//! [`HeapProfile`]. PSRAM is a separate, deliberate decision: see
23//! [`psram_map`] / [`psram_split`] below. The PSRAM-specific surface (those two,
24//! the checked [`psram_box`] / [`psram_vec`], [`PsramSafe`]) needs the `psram`
25//! feature; the heap regions and [`dma_buffer`] need only `heap`.
26//!
27//! Both boards carry SPI PSRAM (Fire27: ~4 MB, CoreS3: ~8 MB). `esp-alloc`
28//! exposes a single global heap that can be backed by several regions. Getting
29//! PSRAM into one of those regions is opt-in and explicit — there is no
30//! function that puts PSRAM behind the global allocator as a side effect of
31//! anything else, because once a region carries external capability, *every*
32//! plain `alloc::vec!` / `Box` / `String` in the whole crate graph — not just
33//! your own code — becomes eligible to silently spill into it once internal
34//! DRAM is exhausted (esp-alloc has no "external, but not for capability-less
35//! requests" region flag).
36//!
37//! 1. **[`psram_map`] — the default.** Maps PSRAM and hands back the whole
38//! region as a private slice. Nothing is registered with the global heap;
39//! nothing here is ever reachable by a plain allocation. Hand the slice to
40//! a foreign allocator (e.g. LVGL's TLSF) or use it directly.
41//! 2. **[`psram_split`] — deliberate global exposure.** Carves a private
42//! region off the base (as above) *and* registers the remainder with the
43//! global heap, for the checked [`psram_box`] / [`psram_vec`] helpers
44//! (which reject atomic-bearing types at compile time — see [`PsramSafe`]).
45//! Reach for this only when you've decided part (or with `reserve: 0`, all)
46//! of PSRAM should be globally exposed, and accept what that costs.
47//!
48//! ```ignore
49//! use m5stack_core::mem;
50//!
51//! // Never touches the global allocator:
52//! let psram = mem::psram_map(peripherals.PSRAM);
53//!
54//! // Deliberately expose part of PSRAM globally:
55//! let split = mem::psram_split(peripherals.PSRAM, 2 * 1024 * 1024)?;
56//! let mut big = mem::psram_vec::<u8>(512 * 1024); // in PSRAM, atomics rejected
57//! let scratch = mem::psram_box([0u32; 1024]); // in PSRAM
58//! let dma = mem::dma_buffer(4 * 1024); // in internal DRAM, DMA-safe
59//! ```
60//!
61//! The raw marker allocators ([`ExternalMemory`] / [`InternalMemory`]) are also
62//! re-exported as an escape hatch for `allocator_api2` containers, but they do
63//! **not** perform the atomic check — reach for them only when you know what
64//! you are placing in PSRAM.
65//!
66//! ## Enforced vs. documented caveats
67//!
68//! - **Atomics must not live in PSRAM.** *Enforced* on the checked path:
69//! [`psram_box`] / [`psram_vec`] bound `T: PsramSafe`, so anything holding an
70//! `Atomic*` (directly or transitively) fails to compile.
71//! - **DMA from PSRAM:** the original ESP32 (Fire27) cannot DMA out of PSRAM.
72//! *Guarded* by [`assert_dma_capable`] (a `debug_assert` on Fire27, a no-op on
73//! CoreS3, which can DMA from PSRAM); use [`dma_buffer`] to get an
74//! internal-DRAM buffer in the first place.
75//! - **opt-level > 0:** *Enforced* at build time — enabling the `psram` feature
76//! with `opt-level = 0` fails the build (see `build.rs`). PSRAM timing
77//! calibration is unreliable unoptimized.
78
79use allocator_api2::vec::Vec;
80pub use esp_alloc::{AnyMemory, ExternalMemory, InternalMemory};
81use esp_hal::peripherals::PSRAM;
82use esp_hal::ram;
83
84#[cfg(feature = "psram")]
85use allocator_api2::boxed::Box;
86#[cfg(feature = "psram")]
87use core::mem::MaybeUninit;
88
89/// Heap size profile — selects the HIL-proven per-board DRAM region sizes for a
90/// workload. The BSP owns the sizes so every binary gets the validated values;
91/// pass the matching profile to [`init_heap`].
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum HeapProfile {
94 /// Display / I2C / WiFi-STA workloads: reclaimed-ROM + plain DRAM (+ PSRAM).
95 Default,
96 /// LVGL UI: the reclaimed-ROM region only (LVGL object/style pool); no PSRAM.
97 Lvgl,
98 /// WiFi + BLE coexistence: more controller heap (+ PSRAM). Fire27 favours the
99 /// reclaimed region; CoreS3 favours plain DRAM.
100 Coex,
101}
102
103/// Register the global heap's DRAM regions for `profile`, using the
104/// HIL-proven per-board sizes. Call once, right after [`crate::board::init`] /
105/// `Board::split` and before any allocation.
106///
107/// This is the single place a binary sets up the DRAM heap — it never calls
108/// `esp_alloc::heap_allocator!` itself. This never touches PSRAM: see
109/// [`psram_map`] / [`psram_split`] for that, called separately (and
110/// optionally) afterward. esp-alloc's global heap holds at most three
111/// regions; each profile registers at most the reclaimed-ROM region and the
112/// plain-DRAM region, leaving room for [`psram_split`]'s external region — a
113/// 4th `add_region` panics silently.
114pub fn init_heap(profile: HeapProfile) {
115 match profile {
116 HeapProfile::Default => {
117 esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 50 * 1024);
118 esp_alloc::heap_allocator!(size: 64 * 1024);
119 }
120 HeapProfile::Lvgl => {
121 esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 50 * 1024);
122 }
123 HeapProfile::Coex => {
124 #[cfg(feature = "fire27")]
125 {
126 esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 96 * 1024);
127 esp_alloc::heap_allocator!(size: 24 * 1024);
128 }
129 #[cfg(feature = "cores3")]
130 {
131 esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 50 * 1024);
132 esp_alloc::heap_allocator!(size: 96 * 1024);
133 }
134 }
135 }
136}
137
138/// Map the board's external PSRAM and return the whole region as a private
139/// slice. Nothing is registered with the global heap — nothing returned here
140/// is ever reachable by a plain `alloc::vec!` / `Box` / `String`.
141///
142/// The default way to get at PSRAM. Hand the slice to a foreign allocator
143/// (e.g. LVGL's built-in TLSF via `lv_mem_add_pool`) or use it directly. For
144/// deliberate global exposure (so [`psram_box`] / [`psram_vec`] can reach
145/// part of PSRAM), use [`psram_split`] instead.
146///
147/// The size is auto-detected. Call once, after [`esp_hal::init`]. Calling
148/// this (or [`psram_split`]) more than once is unsound — the PSRAM controller
149/// must only be initialized a single time.
150#[cfg(feature = "psram")]
151pub fn psram_map(psram: PSRAM<'static>) -> &'static mut [MaybeUninit<u8>] {
152 // SAFETY: see `psram_split`, which this mirrors with `reserve == total`
153 // (the whole region private, nothing registered globally).
154 let psram = esp_hal::psram::Psram::new(psram, Default::default());
155 let (base, total) = psram.raw_parts();
156 info!("PSRAM mapped: {} KiB private", total / 1024);
157 unsafe { core::slice::from_raw_parts_mut(base as *mut MaybeUninit<u8>, total) }
158}
159
160/// A private PSRAM region carved off the global heap, plus the external bytes
161/// registered with the global heap. Returned by [`psram_split`].
162#[cfg(feature = "psram")]
163pub struct PsramSplit {
164 /// A private, exclusive, contiguous PSRAM region for a *foreign* allocator
165 /// (e.g. LVGL's built-in TLSF via `lv_mem_add_pool`). It is **not** part of
166 /// the global heap, so the global allocator never hands it to `Box` / `Vec`
167 /// / DMA. Its base is the PSRAM mapping base, so it is large-aligned (≥ any
168 /// reasonable `ALIGN_SIZE`) — the caller needs no alignment math and no
169 /// `unsafe`.
170 ///
171 /// `'static` is sound because esp-hal's `Psram` has no `Drop`: the mapping is
172 /// a hardware side effect recorded in esp-hal's range statics and is *not*
173 /// undone when the `Psram` value drops.
174 pub private: &'static mut [MaybeUninit<u8>],
175 /// External (PSRAM) heap free immediately after registering the remainder
176 /// with the global heap, in bytes. `0` when `reserve` was `None` (all
177 /// private, nothing registered).
178 pub global_free: usize,
179}
180
181// `private` is a `&'static mut` to uninit memory, so `PsramSplit` cannot derive
182// `Debug`; a manual impl prints the base/len/free a consumer wants when bringing
183// this up on a new board (`log::info!("{:?}", split)`).
184#[cfg(feature = "psram")]
185impl core::fmt::Debug for PsramSplit {
186 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
187 f.debug_struct("PsramSplit")
188 .field("private_base", &self.private.as_ptr())
189 .field("private_len", &self.private.len())
190 .field("global_free", &self.global_free)
191 .finish()
192 }
193}
194
195/// Why [`psram_split`] could not satisfy the request.
196#[cfg(feature = "psram")]
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum PsramSplitError {
199 /// PSRAM did not map — `esp_hal::psram::init_psram` failed, or the board has
200 /// none. Nothing was registered with the global heap.
201 NotMapped,
202 /// PSRAM mapped, but smaller than the requested `reserve`. `available` is the
203 /// total mapped size, so the caller can retry with a smaller pool without
204 /// re-querying — the `PSRAM` peripheral was consumed by value.
205 TooSmall { available: usize },
206}
207
208/// Map the board's external PSRAM, carve a **private** region off the base, and
209/// register the remainder with the global heap.
210///
211/// The deliberate-global-exposure counterpart of [`psram_map`] (which makes
212/// the whole region private and touches the global heap not at all). Reach for
213/// `psram_split` only once you've decided that some of PSRAM should be
214/// reachable by the checked [`psram_box`] / [`psram_vec`] helpers — that
215/// decision then applies to the *whole* crate graph's plain allocations, not
216/// just yours, once internal DRAM runs out (see the module docs).
217///
218/// - `reserve` carves that many bytes private from the base and registers the
219/// remainder globally. `reserve == 0` is the maximal-exposure case: an empty
220/// [`PsramSplit::private`] slice and *all* PSRAM registered globally. Sound (a
221/// zero-length slice grants access to nothing) but rarely what you want —
222/// prefer [`psram_map`] if you don't need any global exposure at all.
223/// - The private region is carved **from the base**, so [`PsramSplit::private`]
224/// starts at the (large-aligned) PSRAM mapping base — aligned for LVGL's TLSF
225/// with no math; esp-alloc aligns the remainder's base internally.
226/// - This primitive controls *placement* (the private/global split). The PSRAM
227/// **hardware** mapping uses the default [`esp_hal::psram`] config, which
228/// auto-detects size — the board-correct choice for both boards. A
229/// `psram_split_with(config)` variant is a non-breaking addition if a consumer
230/// ever needs a custom `PsramConfig` (a fixed `PsramSize`, say); no current
231/// one does.
232///
233/// Call once, after [`esp_hal::init`], **instead of** [`psram_map`]: taking
234/// `PSRAM<'static>` by value makes the once-only mapping a type-level
235/// guarantee, so the two cannot both run.
236///
237/// # Caveats handed back to the caller
238/// - **No atomics in the private region.** The checked [`psram_box`] /
239/// [`psram_vec`] cannot guard a foreign allocator, so keeping `Atomic*` out of
240/// whatever is placed here is the caller's responsibility (holds for LVGL while
241/// `LV_USE_OS` is `LV_OS_NONE`). See [`PsramSafe`].
242/// - **DMA.** The ESP32 (Fire27) cannot DMA to/from PSRAM at all; the ESP32-S3
243/// can but slowly. A foreign allocator must not place DMA'd buffers here. See
244/// [`assert_dma_capable`] / [`dma_buffer`].
245///
246/// # Errors
247/// [`PsramSplitError::NotMapped`] if PSRAM does not map; [`PsramSplitError::TooSmall`]
248/// if it maps smaller than `reserve`.
249#[cfg(feature = "psram")]
250pub fn psram_split(psram: PSRAM<'static>, reserve: usize) -> Result<PsramSplit, PsramSplitError> {
251 // `Psram` has no `Drop`: the mapping is recorded in esp-hal's range statics
252 // and survives the value dropping (see `PsramSplit::private`), so a local is
253 // fine — nothing unmaps at the end of this block.
254 let psram = esp_hal::psram::Psram::new(psram, Default::default());
255 let (base, total) = psram.raw_parts();
256
257 // `Psram::new` maps only if `init_psram` succeeded; on failure the range
258 // statics stay unset and `raw_parts` reports a zero-size region.
259 if total == 0 {
260 return Err(PsramSplitError::NotMapped);
261 }
262
263 if reserve > total {
264 return Err(PsramSplitError::TooSmall { available: total });
265 }
266
267 // Carve `[base, base + reserve)` private.
268 // SAFETY: `base` is the exclusively-owned, `'static` PSRAM mapping (once-only,
269 // guaranteed by consuming `PSRAM<'static>` by value). This sub-range is handed
270 // out privately and is never registered with the global heap, so no aliasing.
271 // `MaybeUninit<u8>` has align 1, and `reserve <= total <= isize::MAX`. When
272 // `reserve == 0` the slice is empty: its base pointer lies inside the region
273 // that *is* registered globally below, but a zero-length slice dereferences
274 // nothing, so it still aliases nothing.
275 let private =
276 unsafe { core::slice::from_raw_parts_mut(base as *mut MaybeUninit<u8>, reserve) };
277
278 // Register the remainder `[base + reserve, base + total)` with the global heap.
279 let global_free = if reserve < total {
280 // SAFETY: disjoint from `private`, `'static`, exclusively the heap's, and
281 // `total - reserve > 0` here (so esp-alloc's `size > 0` precondition holds).
282 unsafe {
283 esp_alloc::HEAP.add_region(esp_alloc::HeapRegion::new(
284 base.add(reserve),
285 total - reserve,
286 esp_alloc::MemoryCapability::External.into(),
287 ));
288 }
289 esp_alloc::HEAP.free_caps(esp_alloc::MemoryCapability::External.into())
290 } else {
291 0
292 };
293
294 info!(
295 "PSRAM split: {} KiB private, {} KiB external free (global)",
296 reserve / 1024,
297 global_free / 1024
298 );
299 Ok(PsramSplit { private, global_free })
300}
301
302/// Marker for types safe to store in PSRAM: nothing holding an *inline* atomic.
303///
304/// Atomic read-modify-write instructions misbehave against PSRAM-backed
305/// addresses on ESP32 / ESP32-S3, so the checked allocators [`psram_box`] /
306/// [`psram_vec`] only accept `T: PsramSafe`. Like `Send` / `Sync` this is an
307/// auto trait: a struct is `PsramSafe` iff every field is, so a type that
308/// embeds an `Atomic*` (directly or transitively — e.g. via `Arc`, many lock
309/// types) is rejected at compile time.
310///
311/// A *pointer or reference* to an atomic living elsewhere is fine — the atomic
312/// itself is not in PSRAM — so `&T`, `&mut T`, `*const T` and `*mut T` are
313/// always `PsramSafe`.
314///
315/// # Safety
316/// Only implement (or negative-impl) this to reflect the atomic-in-PSRAM
317/// hazard; the checked allocators rely on it to keep atomics out of PSRAM.
318#[cfg(feature = "psram")]
319pub unsafe auto trait PsramSafe {}
320
321#[cfg(feature = "psram")]
322mod psram_safe_impls {
323 use super::PsramSafe;
324 use core::sync::atomic::{
325 AtomicBool, AtomicI8, AtomicI16, AtomicI32, AtomicIsize, AtomicPtr, AtomicU8, AtomicU16,
326 AtomicU32, AtomicUsize,
327 };
328
329 impl !PsramSafe for AtomicBool {}
330 impl !PsramSafe for AtomicI8 {}
331 impl !PsramSafe for AtomicU8 {}
332 impl !PsramSafe for AtomicI16 {}
333 impl !PsramSafe for AtomicU16 {}
334 impl !PsramSafe for AtomicI32 {}
335 impl !PsramSafe for AtomicU32 {}
336 impl !PsramSafe for AtomicIsize {}
337 impl !PsramSafe for AtomicUsize {}
338 impl<T> !PsramSafe for AtomicPtr<T> {}
339
340 // A pointer/reference to an atomic is fine — the atomic lives elsewhere.
341 // (Mirrors `unsafe impl<T: ?Sized> Send for &T` in std.)
342 unsafe impl<T: ?Sized> PsramSafe for &T {}
343 unsafe impl<T: ?Sized> PsramSafe for &mut T {}
344 unsafe impl<T: ?Sized> PsramSafe for *const T {}
345 unsafe impl<T: ?Sized> PsramSafe for *mut T {}
346}
347
348/// Allocate `value` in external PSRAM. Atomic-bearing `T` is rejected at
349/// compile time via [`PsramSafe`].
350#[cfg(feature = "psram")]
351pub fn psram_box<T: PsramSafe>(value: T) -> Box<T, ExternalMemory> {
352 Box::new_in(value, ExternalMemory)
353}
354
355/// A `Vec<T>` with room for `capacity` elements reserved in external PSRAM.
356/// Atomic-bearing `T` is rejected at compile time via [`PsramSafe`].
357#[cfg(feature = "psram")]
358pub fn psram_vec<T: PsramSafe>(capacity: usize) -> Vec<T, ExternalMemory> {
359 Vec::with_capacity_in(capacity, ExternalMemory)
360}
361
362/// Free bytes in the global heap's **internal** DRAM regions (reclaimed-ROM +
363/// plain-DRAM), right now. The tight resource on both boards; use it to measure
364/// headroom (e.g. before/after moving a subsystem's heap to PSRAM).
365pub fn internal_free() -> usize {
366 esp_alloc::HEAP.free_caps(esp_alloc::MemoryCapability::Internal.into())
367}
368
369/// Free bytes in the global heap's **external** (PSRAM) region, right now — `0`
370/// unless [`psram_split`] registered a non-empty remainder globally (a private
371/// [`psram_map`] / [`psram_split`] region is *not* counted here).
372pub fn external_free() -> usize {
373 esp_alloc::HEAP.free_caps(esp_alloc::MemoryCapability::External.into())
374}
375
376/// A zeroed byte buffer in internal DRAM, suitable as a DMA buffer.
377///
378/// Convenience for the common "I need a DMA-capable scratch buffer" case so the
379/// allocator does not have to be spelled out. The result is DMA-reachable on
380/// both chips; pair it with [`assert_dma_capable`] if a buffer's origin is ever
381/// in doubt.
382pub fn dma_buffer(len: usize) -> Vec<u8, InternalMemory> {
383 let mut v = Vec::with_capacity_in(len, InternalMemory);
384 v.resize(len, 0);
385 v
386}
387
388/// Debug-assert that `buf` is DMA-reachable on this chip.
389///
390/// On the ESP32 (Fire27) the DMA engine cannot reach the PSRAM-mapped data
391/// window, so a PSRAM-backed buffer handed to SPI/I2S DMA silently corrupts.
392/// This catches that on first use under `debug_assertions`. It is a no-op
393/// (compiled away) on the ESP32-S3, which *can* DMA from PSRAM.
394#[cfg(feature = "fire27")]
395#[inline]
396pub fn assert_dma_capable(buf: &[u8]) {
397 // ESP32 external RAM (PSRAM) is cache-mapped into this data window; internal
398 // DRAM lives above it. Constant per the ESP32 TRM external-memory map.
399 const PSRAM_DATA_WINDOW: core::ops::Range<usize> = 0x3F80_0000..0x3FC0_0000;
400 let p = buf.as_ptr() as usize;
401 debug_assert!(
402 !PSRAM_DATA_WINDOW.contains(&p),
403 "DMA buffer at {p:#x} lives in PSRAM; the ESP32 cannot DMA to/from PSRAM \
404 — allocate it in InternalMemory (see mem::dma_buffer)"
405 );
406}
407
408/// No-op on every target except the ESP32 (Fire27); see the Fire27 variant.
409#[cfg(not(feature = "fire27"))]
410#[inline]
411pub fn assert_dma_capable(_buf: &[u8]) {}