The CHERIoT platform was designed to support a (safe) shared heap. This means that embedded systems that contain multiple mutually distrusting components do not need to pre-reserve memory and so the total SRAM requirement is the peak of all concurrently executing components, not the peak of all components. This can be a significant cost reduction for systems that have relatively high memory requirements for different phases of computation, for example doing a post-quantum key exchange at boot followed by running a memory-intensive loop after initialisation.
This document describes the memory model.
Allocating memory requires a capability that authorises memory allocation. These are created by the DECLARE_AND_DEFINE_DEFINE_ALLOCATOR_CAPABILITY
macro, which takes two arguments. The first is the name of the capability, the second is the amount of memory that this capability authorises the holder to allocate. This capability may then be accessed with the STATIC_SEALED_VALUE
macro, which takes the name as the argument. If you wish to refer to the same capability from multiple C compilation units, you can use the separate DECLARE_
and DEFINE_
versions of this combined macro. See the documentation on software-defined capabilities for more information.
Compartments may hold more than one allocation capability. The design embodies the principle of intentionality: you must explicitly specify the quota against which an allocation counts when performing that allocation. The standard C/C++ interfaces do not respect this principle and so are implemented as wrappers that use a default allocator capability (see below).
When inspecting the linker audit report for a firmware image, you will see an entry like this for each allocator capability:
{ "contents": "00001000 00000000 00000000 00000000 00000000 00000000", "kind": "SealedObject", "sealing_type": { "compartment": "alloc", "key": "MallocKey", "provided_by": "build/cheriot/cheriot/release/cherimcu.allocator.compartment", "symbol": "__export.sealing_type.alloc.MallocKey" } },
The contents
is a hex encoding of the contents of the allocator capability. The first word is the size, so 0x00001000 here indicates that this capability authorises 4096 bytes of allocation. The remaining space is reserved for use by the allocator (the object must be 6 words long). The sealing type describes the kind of sealed capability that this is, in particular it is a type exposed by the alloc
compartment as MallocKey
.
The allocator APIs all begin heap_
. The heap_allocate
and heap_allocate_array
functions allocate memory (the latter is safe in the presence of arithmetic overflow). All memory allocated by these functions is guaranteed to be zeroed. These functions will fail if the allocator capability does not have sufficient remaining quota to handle the allocation (or if the allocator itself is out of memory). All allocations have an eight-byte header and this counts towards the quota, so the total quota required is the sum of the size of all objects plus eight times the number of live objects.
The amount of quota remaining in an allocator capability can be queried with heap_quota_remaining
.
The heap_free
function deallocates memory. This must be called with the same allocator capability that allocated the memory (you may not free memory unless authorised to do so). This function is also used to remove claims (see below).
In some situations it is important for a compartment to be able to guarantee that another compartment cannot deallocate memory that was delegated to it. This is done with the heap_claim
function, which adds a claim on the memory. This prevents the object from being deallocated until the claim is dropped. This requires an allocator capability because it can prevent an object from being deallocated and so can increase peak memory consumption in a system. This function returns the size of object that has been claimed (or zero on failure) because the object can be larger than the bounds of the capability but there is no way to claim part of an object and allow the remainder to be freed.
Claims are dropped with heap_free
, which allows cleanup code to relinquish ownership without knowing whether an object was allocated locally or claimed. In particular, it is safe to claim an object that you originally allocated, as long as you free it the correct number of times.
The C malloc
and free
and C++ new
and delete
functions are implemented as inline wrappers around the core functions. These use the default malloc capability for the compartment, with a quota defined by the MALLOC_QUOTA
macro. This currently defaults to 4096 bytes.
These APIs are provided for compatibility. They are not ideal in embedded systems or with mutual distrust because they do not take explicit allocator capabilities and because they do not provide timeouts (and so can block indefinitely).
We do not provide an implementation of realloc
because it is dangerous in a single-provenance pointer model. Realloc may not do in-place size reduction usefully because there may be dangling capabilities that have wider bounds. Doing length extension in place would cause problems with existing pointers being able to access only a subset of the object. The only safe way of implementing realloc is as an allocate, copy, deallocate sequence and users are free to provide their own implementation that does this.
Compartments can opt out of C malloc by defining CHERIOT_NO_AMBIENT_MALLOC
. This will prevent the malloc
family of functions being exposed in the compartment. It will also prevent the compartment from having a default allocator capability. This makes it easy to audit the property that some compartments should not allocate memory: their linker audit report will not contain a capability of the form described above.
The C++ new
/ delete
functions wrap malloc
and friends. These can be hidden by defining the CHERIOT_NO_NEW_DELETE
macro.
All heap_
allocator APIs can fail with -ENOTENOUGHSTACK
, which indicates that the stack space available was insufficient for the allocator to safely execute. This is because executing the allocator on insufficient stack space may allow attackers to trigger arbitrary failures in the allocator through carefully setting the size of the stack.
This implies an important difference of semantics between heap_allocate
and the C stdlib malloc
: upon failure heap_allocate
may return a (void*) -ENOTENOUGHSTACK
pointer as well as a NULL
pointer. Success of heap_allocate
must thus be checked by querying the tag bit of the returned capability, instead of comparing with a NULL
pointer.
A correct usage of heap_allocate
with the C++ API looks like the following:
Timeout t{1}; Capability ptr{heap_allocate(&t, MALLOC_CAPABILITY, sizeof(int))}; // Correct check if (!ptr.is_valid()) { // ... }
Using the C-level API, a correct check is:
Timeout t = {0, UnlimitedTimeout}; void *ptr = heap_allocate(&t, MALLOC_CAPABILITY, sizeof(int)); // Correct check if (!__builtin_cheri_tag_get(ptr)) { // ... }
As opposed to an incorrect NULL
check, which would fail if the return value is -ENOTENOUGHSTACK
:
Timeout t{1}; void *ptr = heap_allocate(&t, MALLOC_CAPABILITY, sizeof(int)); // Incorrect, this will go wrong if we get (void*) ENOTENOUGHSTACK if (ptr == nullptr) { // ... }
Similarly, the semantics of heap_free
differ from those of the C stdlib free
. Whereas the stdlib free
does not report failure (e.g., if passed pointer is not a heap-allocated pointer, or if the function runs out of stack), heap_free
returns an errno value.
heap_free
may fail if the stack is insufficient, if the heap capability cannot be used to free the capability, or if the pointer has already been freed, among others. Still - provided a valid heap-allocated pointer, heap_free
should only fail if the stack space is insufficient. heap_can_free
can be used to check if a call to heap_free
would succeed without actually performing the free. This is useful to ensure that a failure to free will not happen at a point of no return (e.g., when resources have already been partially freed).