Skip to main content

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]) {}