Shared Memory

Binder transactions copy their payload through the kernel, and a single transaction is capped at the binder buffer size (1 MiB per process by default). For frames, audio chunks, or any buffer you would rather not copy per call, Android services share memory instead: the buffer is an anonymous shared region, only its file descriptor crosses the binder, and both processes map the same pages.

rsbinder's shared_memory module provides that pattern on Linux, Android and macOS, wire-compatible with Android's own types:

rsbinderAndroid equivalenton the wire
SharedMemoryandroid.os.SharedMemory, NDK ASharedMemoryone ParcelFileDescriptor
MemoryHeapBase / BnMemoryHeap / BpMemoryHeaplibbinder IMemoryHeap (android.utils.IMemoryHeap)handwritten AOSP transaction
MemoryBase / BnMemory / BpMemorylibbinder IMemory (android.utils.IMemory)handwritten AOSP transaction
MemoryDealer / Allocationlibbinder MemoryDealerIMemory windows on one heap

The full example lives in example-hello/src/bin/shm_service.rs and shm_client.rs; this page walks through it.

Which transports can carry a region?

A shared region is a file descriptor, so it travels wherever an fd can:

  • Kernel binder (Linux with binderfs, Android) — always.
  • Unix-socket RPC — when both sides opt into fd passing (FileDescriptorTransportMode::Unix, see below).
  • TCP / vsock / TLS RPC — never through the entry API, which refuses the Unix fd mode on these transports; writing a region into such a parcel fails with StatusCode::FdsNotAllowed, exactly like a plain ParcelFileDescriptor.

0. One large value: Parcel::write_blob

Before reaching for a region of your own, note that a byte payload has a ready-made form. Parcel::write_blob carries it inline when it is at most 16 KB and through a shared region when it is larger, tagging the wire so the reader knows which it got. The bytes are Java's Parcel.writeBlob: an int32 length, then what C++ Parcel::writeBlob writes. A C++ peer reads the length itself and passes it to Parcel::readBlob(len, …), as the JNI layer under Java does:

#![allow(unused)]
fn main() {
parcel.write_blob(&payload, false)?;      // false: the reader may not write it

let blob = parcel.read_blob()?;
let bytes = blob.to_vec()?;               // whichever form it took
}

Two properties make it the right default for "a big lump of bytes":

  • The transport decides, not your code. Where fds cannot travel — vsock, TLS, or a data-only parcel — the payload simply goes inline, as AOSP does when mAllowFds is false. There is nothing to configure and no error to handle. Parcel::allow_fds() answers the same question if you want to know in advance.
  • An immutable blob is sealed (F_SEAL_FUTURE_WRITE), so the reader maps a snapshot it cannot modify. Pass true to leave the reader's copy writable (BLOB_ASHMEM_MUTABLE). The writer keeps no handle on the region, so nothing the reader writes comes back; for a buffer both sides share, use a region of your own (below).

Reach for the sections below when you need the region itself: a window into a larger heap, a lifetime longer than one call, or many buffers out of one allocation.

1. One region: SharedMemory + ParcelFileDescriptor

This is the simplest and the most portable form — it is what Android apps do with SharedMemory. In AIDL the region is just a ParcelFileDescriptor:

package shm;

interface IShm {
    ParcelFileDescriptor getRegion();
    void regionWritten(int offset, int len);   // "data is ready" signal
    IBinder nextFrame(int seq);                 // see section 2
}

Service side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::SharedMemory;

struct ShmService {
    region: SharedMemory,
    // …
}

impl ShmService {
    fn new() -> rsbinder::Result<Self> {
        let region = SharedMemory::create(64 * 1024)?;   // page-rounded
        region.write_at(0, b"hello from shm_service")?;  // through our own mapping
        Ok(Self { region })
    }
}

impl IShm for ShmService {
    fn getRegion(&self) -> rsbinder::BinderResult<ParcelFileDescriptor> {
        // dup the fd for the parcel; the service keeps its mapping
        Ok(self.region.to_parcel_fd()?)
    }

    fn regionWritten(&self, offset: i32, len: i32) -> rsbinder::BinderResult<()> {
        let mut buf = vec![0u8; len as usize];
        self.region.read_at(offset as usize, &mut buf)?;   // the client's bytes
        Ok(())
    }
    // …
}
}

Client side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::SharedMemory;

let shm: Strong<dyn IShm> = client.get::<dyn IShm>(SERVICE_NAME)?;

let pfd = shm.getRegion()?;
let region = SharedMemory::from_fd(pfd.into())?;   // size recovered from the fd

let mut greeting = [0u8; 22];
region.read_at(0, &mut greeting)?;                  // same physical pages

region.write_at(4096, b"hello back")?;              // visible to the service at once
shm.regionWritten(4096, 10)?;                       // tell it to look
}

SharedMemory::from_fd accepts anything Android would send here: a memfd, a macOS POSIX shm object, or a legacy /dev/ashmem fd (its size is read with ASHMEM_GET_SIZE). A region that was sealed read-only by the owner maps read-only automatically (is_read_only()), and write_at then returns PermissionDenied.

Synchronisation is yours

The library does not serialise access to the pages: a region is, by definition, writable by the other process at any time. Use an ordinary binder call as the hand-off signal (regionWritten above), the way AOSP services do, and prefer the copying accessors read_at / write_at over holding a &[u8] into the region.

2. Many buffers: MemoryDealer + IMemory

Allocating a region per frame means an fd and an mmap per frame on both sides. AOSP's MemoryDealer avoids that: one heap is created and mapped once, and each frame is an (offset, size) window of it — an android.utils.IMemory binder whose reply names the shared heap.

Service side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::{Allocation, MemoryDealer};

struct ShmService {
    dealer: Arc<MemoryDealer>,
    frames: Mutex<VecDeque<Allocation>>,   // bounded; dropping returns the block
}

fn nextFrame(&self, seq: i32) -> rsbinder::BinderResult<SIBinder> {
    let frame = self.dealer.allocate_page_aligned(4096)?;   // NoMemory when full
    frame.write_at(0, format!("frame #{seq}").as_bytes())?;
    let binder = frame.export();                             // the IMemory binder
    let mut frames = self.frames.lock().unwrap();
    frames.push_back(frame);
    if frames.len() > 4 {
        frames.pop_front();   // Allocation::drop → block back to the dealer
    }
    Ok(binder)
}
}

The allocator is AOSP's SimpleBestFitAllocator: 32-byte granules, best-fit, neighbours coalesce on free, and allocate_page_aligned for buffers that must start on a page. Peers that still hold an exported binder keep reading the window after the Allocation is dropped; only the dealer's bookkeeping changes — so keep a frame alive until the consumer is done with it.

Client side

#![allow(unused)]
fn main() {
use rsbinder::shared_memory::{BpMemory, HeapCache};

let cache = HeapCache::new();        // one per session
for seq in 0..6 {
    let binder = shm.nextFrame(seq)?;
    let frame = BpMemory::new_with_cache(binder, cache.clone());
    let heap = frame.resolve()?;     // GET_MEMORY; HEAP_ID + mmap only the first time
    let mut buf = [0u8; 40];
    frame.read_at(0, &mut buf)?;     // offset is relative to the window
    assert_eq!(cache.len(), 1);      // one mapped heap behind every frame
}
}

Without the cache each BpMemory maps the heap on its own (still correct, just more VMAs); with it, frames from the same dealer resolve to bare offsets.

Because the transactions are the handwritten AOSP layout, the same binders work against a C++ interface_cast<IMemory> client or a C++ MemoryHeapBase server — that interop is part of rsbinder's test gate (example-hello/cpp/run_imemory_interop.sh).

Running the example

Kernel binder (Linux with rsb_hub running, or an Android device):

cargo run -p example-hello --bin shm_service kernel &
cargo run -p example-hello --bin shm_client kernel

Unix-socket RPC (works on macOS too; fd passing needs the rpc feature):

cargo run -p example-hello --features rpc --bin shm_service rpc &
cargo run -p example-hello --features rpc --bin shm_client rpc

Over RPC both ends must opt into fd passing before connecting. Through the entry API it is an option on the connect; on an android-14+ session you built yourself it is RpcClientConfig::fd_mode on the client and RpcServer::set_supported_fd_modes on the server, since the connection header fixes the mode. Only an r34 session negotiates it afterwards, with RpcSession::negotiate_fd_transport:

#![allow(unused)]
fn main() {
// service
rsbinder::serve(&uri)?
    .with(|o| o.fd_modes = Some(vec![FileDescriptorTransportMode::Unix]))
    .add(SERVICE_NAME, BnShm::new_binder(ShmService::new()?))?
    .run()?;

// client
let client = rsbinder::Client::open_with(&uri, |o, _endpoint| {
    o.fd_mode = Some(FileDescriptorTransportMode::Unix);
})?;
}

Both options are rejected on an endpoint that cannot carry fds, so ask endpoint.supports_fd_passing() first when the URI is not fixed (as shm_client.rs does). Without the opt-in, getRegion() fails with FdsNotAllowed.

Expected client output:

shm_client: region 65536 bytes, read-only=false, says "hello from shm_service"
shm_client: frame #0 @ offset 0 (window 0+4096 of a 1048576 byte heap, 1 mapped heap(s))
shm_client: frame #1 @ offset 4096 …       (16384 on a 16 KiB-page macOS)
…
shm_client: frame #5 @ offset 0 …          ← a recycled block

What the kernel enforces

propertyLinux / Android (memfd)macOS (POSIX shm)
size cannot change after creationF_SEAL_SHRINK | F_SEAL_GROWinherent: one ftruncate only
read-only for peers (FLAG_READ_ONLY, seal_read_only)F_SEAL_FUTURE_WRITEthe owner exports an O_RDONLY fd
receiver can verifyF_GET_SEALSfcntl(F_GETFL)

MemoryHeapBase::seals() reports these as the same SEAL_* bits on every platform, and MappedHeap::from_fd_strict rejects a sender whose claims the kernel does not back. One macOS difference to know: sealing read-only after a writable fd was already handed out does not revoke that fd — create the region with FLAG_READ_ONLY when peers must never write.