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:

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 exceptionsHerbceptions
Linker symbolsAround ten runtime entriesOne data symbol for the error domain
Heap at failure timeYes, for the exception objectNo
Stack walkYesNo
Frame tables, personality routineYesNo
RTTIYes, for handler matchingNo
Works on a board, in a kernel, on a GPUNoYes

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 targetWhat 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:

  1. Include fast_io_freestanding.h, or fast_io_core.h plus the units you use. Do not include fast_io.h on a bare target.
  2. Never write new. Use containers::vector<T, Alloc> with an allocator you supplied.
  3. Provide freestanding_global_allocator, or pass a specific allocator to every container.
  4. Use throws for recoverable errors and fast_terminate() for broken invariants.
  5. Supply the C library pieces the target does not have (memcpy, memset, memmove) and the compiler runtime (libgcc or compiler-rt).
  6. Write start-up code that zeroes .bss, copies .data, and walks .init_array, or have no dynamic initialisers at all.
  7. Verify with nm -u. A freestanding program should have no unexpected undefined symbols.