Ch20.2: fast_io in a Freestanding Environment
1. The OS-Dependent Pieces Are Type Parameters
fast_io is an I/O library that does not assume an operating system underneath it. The operating-system-dependent pieces are not compiled into the library and then switched off by a flag: they are type parameters or symbols the target supplies. That is the difference between "the formatting code is header-only" and "the library has no hidden heap, no hidden console, and no hidden error runtime".
The split is visible in the umbrella headers:
-
<fast_io_freestanding.h>— the core, formatting, scanning, buffers, serialisation and the buffer devices. No files, consoles or processes. -
<fast_io_hosted.h>— adds the native devices, files, consoles, processes, threads and the filesystem, and only when the platform is hosted. -
<fast_io.h>— includes the hosted layer plus the legacy<iostream>bridge.
A freestanding program includes fast_io_freestanding.h (or the smaller
fast_io_core.h plus the units it actually uses). It never includes
fast_io.h, because that asks for devices that do not exist on the target.
2. Containers Carry Their Allocator
::fast_io::containers::vector<T, Alloc> is always available. The
convenience alias ::fast_io::vector<T>, which picks the platform's
default heap, exists only when there is a hosted platform. The condition is in the header,
not in the documentation:
#if ((__STDC_HOSTED__ == 1 && (!defined(_GLIBCXX_HOSTED) || _GLIBCXX_HOSTED == 1) && \
!defined(_LIBCPP_FREESTANDING)) || \
defined(FAST_IO_ENABLE_HOSTED_FEATURES))
namespace fast_io
{
template <typename T, typename Alloc = ::fast_io::native_global_allocator>
using vector = ::fast_io::containers::vector<T, Alloc>;
On a freestanding target, ::fast_io::vector does not exist. There is no silent
fallback to a hidden heap, because there is no name to fall back to. You write
::fast_io::containers::vector<int, my_allocator>, and the allocator is
part of the container's type.
That is the concrete difference from the standard library.
::fast_io::containers::vector<T, Alloc> is freestanding;
::std::vector<T> is not. The data structure is the same kind of thing; the
interface is not. fast_io puts the allocator in the type, so the container cannot reach a
heap the caller did not provide. std::vector puts the allocator in a global and
reports failure by throwing, which is why no amount of standard wording can make it
freestanding.
The same applies to views: the freestanding std::string_view deletes its
throwing methods, while fast_io provides fast_io::string_view and
fast_io::cstring_view in its own container layer.
The default allocator is selected by the same boundary. When the platform is hosted it is
c_malloc_allocator (or the NT heap, or mimalloc); when it is freestanding it is
freestanding_global_allocator. That class is only declared in
fast_io. Its three functions have no definition anywhere in the library:
class freestanding_global_allocator
{
public:
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_die(
::std::size_t alignment, ::std::size_t n, bool zero) noexcept;
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_try(
::std::size_t alignment, ::std::size_t n, bool zero)
FAST_IO_HERBCEPTIONS_THROWS;
static void deallocate_aligned_n(
void* p, ::std::size_t alignment, ::std::size_t n) noexcept;
};
That is the whole freestanding allocator contract. You implement it for your target — a bump arena, a slab, a per-CPU pool — and every fast_io container and every formatting routine that needs scratch memory uses exactly that, and nothing else. A library that cannot link without you providing a heap is a library that cannot allocate behind your back.
The smallest useful arena looks like this. It is deliberately single-threaded: on a board you know which core is running, and a lock is another runtime dependency.
#include <fast_io_core.h>
#include <fast_io_dsal/vector.h>
template <::std::size_t extent>
class arena
{
public:
alignas(::std::max_align_t) static inline ::std::byte storage[extent]{};
static inline ::std::size_t used{};
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_die(
::std::size_t alignment, ::std::size_t n, bool) noexcept
{
::std::size_t const start{(used + alignment - 1u) / alignment * alignment};
if (start + n > extent) { ::fast_io::fast_terminate(); }
used = start + n;
return {storage + start, n};
}
static void deallocate_aligned_n(
void*, ::std::size_t, ::std::size_t) noexcept {}
};
using heap_a = ::fast_io::generic_allocator_adapter<arena<4096>>;
int main()
{
::fast_io::containers::vector<int, heap_a> v;
v.push_back(42);
return v[0];
}
The failure policy is in the name of the function you implement. The
_die flavour calls fast_terminate() when the arena is exhausted;
the _try flavour reports ::std::errc::not_enough_memory on the
throws channel. Choosing one is a compile-time decision per container type,
not a global switch.
3. Error Handling: Herbceptions
A herbception is an error value on the return path: a domain pointer and a code, plus a
failure bit in the calling convention. Nothing is unwound, so nothing needs a stack walker,
frame tables or a personality routine. A legacy throw needs all of them, and
the comparison is not close:
| Legacy exceptions | Herbceptions | |
|---|---|---|
| Linker symbols | Around ten runtime entries | One data symbol for the error domain |
| Heap at failure time | Yes, for the exception object | No |
| Stack walk | Yes | No |
| Frame tables, personality routine | Yes | No |
| RTTI | Yes, for handler matching | No |
| Works on a board, in a kernel, on a GPU | No | Yes |
The first point is not speed: the legacy mechanism is a separate runtime — unwinder, exception-table parser, personality routine, RTTI, allocator — and that runtime is exactly what a kernel cannot provide (20.1). Herbceptions are the solution because they remove the runtime, not because they optimize it.
The speed follows from that. On the error path, a herbception is orders of magnitude cheaper
than a legacy throw: no heap allocation, no stack walk, no frame-table search.
19.8 measured 934,540 ps per
legacy throw against 885 ps per herbception, about a thousand times. On the happy path, a
herbception adds a small amount of work to the return path — carrying and testing the
failure bit — which is in the same range as returning an error code, and usually less
than the error-code plumbing that has to carry a separate status and output value through
every layer.
The build flag from 19.7 does
not break them. -fno-exceptions removes the legacy mechanism, and the
herbception mechanism keeps working because it never used it. A freestanding build can and
should have -fno-exceptions -fno-rtti in it while using throws
everywhere.
The remaining cost is the message text. A freestanding runtime can be built with
LIBHERBCEPTIONS_FREESTANDING, which defaults
__HERBCEPTIONS_NO_MESSAGES on; every domain then reports only
[domain](code) instead of allocating and copying a string. The table is never
touched, which is where the flash savings come from. A kernel that wants text for the twenty
codes it actually reports can override the domain with its own implementation
(19.1).
4. I/O Without a Console
The device is a type too. ::fast_io::basic_obuffer_view<char> writes
into a caller-supplied buffer, and print_freestanding writes into any output
stream that satisfies the interface. The formatting engine does not know or care whether
the destination is a UART, a framebuffer, a ring buffer or a file.
The same design applies to I/O, and it is the other half of the library. fast_io is not a
container library with an I/O add-on: the formatting and scanning layer is constrained by
concepts, so it works with any type that satisfies the output-stream interface. You define
the device once — the write operation your UART, framebuffer or ring buffer needs
— and print / print_freestanding knows how to format every
supported argument into it. The library does not need a console, a file descriptor or a
locale to know how to print.
int render(int value) throws
{
char buffer[64];
::fast_io::containers::vector<int, heap_a> v;
v.push_back(value);
::fast_io::basic_obuffer_view<char> out{buffer, buffer + sizeof(buffer)};
::fast_io::operations::print_freestanding<false>(out, "v[0] = ", v[0], "\n");
return static_cast<int>(out.size());
}
int main()
{
try { return render(12345); }
catch throws (::std::error) { return -1; }
}
This translation unit includes only fast_io_core.h and the vector. It never
touches a file, a console or a locale. For a real board, you write a device type with the
write operation your UART or framebuffer needs and pass it to the same
print_freestanding; none of the formatting code changes.
That is the point: the operation is generic over the device, not tied to a fixed
std::ostream. The compiler checks the device against the concept; if it
satisfies the interface, the same formatting code instantiates for it. There is no runtime
dispatch and no hidden console.
5. The Checklist
| Problem on a bare target | What fast_io gives you |
|---|---|
| One hidden global heap | The allocator is a type parameter, and fast_io::vector does not exist when hosted |
| Error handling needs an OS | Herbceptions on the return path, with the failure policy chosen by the allocator |
| Message tables cost flash | LIBHERBCEPTIONS_FREESTANDING reports [domain](code) |
| The library assumes a console | The device is a type: a buffer view, a UART, a framebuffer, or your own stream |
The practical rules, in order:
- Include
fast_io_freestanding.h, orfast_io_core.hplus the units you use. Do not includefast_io.hon a bare target. - Never write
new. Usecontainers::vector<T, Alloc>with an allocator you supplied. - Provide
freestanding_global_allocator, or pass a specific allocator to every container. - Use
throwsfor recoverable errors andfast_terminate()for broken invariants. - Supply the C library pieces the target does not have (
memcpy,memset,memmove) and the compiler runtime (libgccorcompiler-rt). - Write start-up code that zeroes
.bss, copies.data, and walks.init_array, or have no dynamic initialisers at all. - Verify with
nm -u. A freestanding program should have no unexpected undefined symbols.