C++ runtime
Since the birth of the project in 2008, Genode has employed C++ exceptions for propagating error conditions, the rationale having been that this form of error handling would facilitate the use of the language as intended by the language designers.
However, in version 21.11, we started exploring the use of sum types as a potential alternative to exceptions. This form of error handling has become widely popularized by the Rust programming language with its Result<T,E> type, which fosters a programming style that emphasises the proper handling of error conditions over the happy path. Once we identified workable patterns for redesigning Genode's formerly exception-heavy APIs, we ultimately reached the conclusion to relieve Genode's base framework from the burden of the C++ exception mechanism in version 25.05.
What are these burdens specifically? They boil down to two problems that cannot be surmounted without language changes.
First, the C++ exception mechanism requires a dynamic memory allocator. This allocator needs to be flexible about allocation sizes because those sizes ultimately depend on the exception types. Even though the exception types used by Genode are known, the C++ runtime must also be compatible with 3rd-party code and the exception types used therein. The allocator must also be thread-safe. In other words, there is the unwritten assumption that the C++ runtime sits atop a C runtime providing malloc. Genode's C-less C++ runtime deviates from this beaten track. Even though we certainly made it work, it cannot be made perfect because it contradicts with Genode's rigid resource-partitioning regime.
Second, and more importantly, the compiler gives the developer no means to statically check that no error condition remains unconsidered. This property, however, is fundamentally needed to attain assurance. Our coping strategy combined disciplined annotations in the form of comments with extensive testing. But as with any measure that depends on discipline and testing, certainty remains unattainable.
Sum types overcome both of these two hard problems. As they can be expressed using C++ templates without any cleverness, there exists a natural migration path away from exceptions. In Genode, the basic building blocks for exception-less error handling are the Attempt<T,E> and Unique_attempt<T,E> utilities located at base/attempt.h.
Rationale behind using exceptions
Compared to return-based error handling as prominently used in C programs, the C++ exception mechanism is much more complex. In particular, it requires the use of a C++ runtime library that is called as a back-end by the exception handling code and generated by the compiler. This library contains the functionality needed to unwind the stack and a mechanism for obtaining runtime type information (RTTI). The C++ runtime libraries that come with common tool chains, in turn, rely on a C library for performing dynamic memory allocations, string operations, and I/O operations. Consequently, C++ programs that rely on exceptions and RTTI implicitly depend on a C library. For this reason, the use of those C++ features is universally disregarded for low-level operating-system code that usually does not run in an environment where a complete C library is available.
In principle, C++ can be used without exceptions and RTTI (by passing the arguments -fno-exceptions and -fno-rtti to GCC). However, without those features, parts of the C++ language become unavailable.
For example, when the operator new is used, it performs two steps: Allocating the memory needed to hold the to-be-created object and calling the constructor of the object with the return value of the allocation as this pointer. In the event that the memory allocation fails, the only way for the allocator to propagate the out-of-memory condition is throwing an exception. If such an exception is not thrown, the constructor would be called with a null as this pointer.
Another example is the handling of errors during the construction of an object. The object construction may consist of several consecutive steps such as the construction of base classes and aggregated objects. If one of those steps fails, the construction of the overall object remains incomplete. This condition must be propagated to the code that issued the object construction. There are two principle approaches:
-
The error condition can be kept as an attribute in the object. After constructing the object, the user of the object may detect the error condition by requesting the attribute value. However, this approach is plagued by the following problems.
First, the failure of one step may cause subsequent steps to fail as well. In the worst case, if the failed step initializes a pointer that is passed to subsequent steps, the subsequent steps may use an uninitialized pointer. Consequently, the error condition must eventually be propagated to subsequent steps, which, in turn, need to be implemented in a defensive way.
Second, if the construction failed, the object exists but it is inconsistent. In the worst case, if the user of the object misses to check for the successful construction, it will perform operations on an inconsistent object. But even in the good case, where the user detects the incomplete construction and decides to immediately destruct the object, the destruction is error prone. The already performed steps may have had side effects such as resource allocations. So it is important to revert all the successful steps by invoking their respective destructors. However, when destructing the object, the destructors of the incomplete steps are also called. Consequently, such destructors need to be implemented in a defensive manner to accommodate this situation.
Third, objects cannot have references that depend on potentially failing construction steps. In contrast to a pointer that may be marked as uninitialized by being a null pointer, a reference is, by definition, initialized once it exists. Consequently, the result of such a step can never be passed as reference to subsequent steps. Pointers must be used.
Fourth, the mere existence of incompletely constructed objects introduces many variants of possible failures that need to be considered in the code. There may be many different stages of incompleteness. Because of the third problem, every time a construction step takes the result of a previous step as an argument, it explicitly has to consider the error case. This, in turn, tremendously inflates the test space of the code.
Furthermore, there needs to be a convention of how the completion of an object is indicated. All programmers have to learn and follow the convention.
-
The error condition triggers an exception. Thereby, the object construction immediately stops at the erroneous step. Subsequent steps are not executed at all. Furthermore, while unwinding the stack, the exception mechanism reverts all already completed steps by calling their respective destructors. Consequently, the construction of an object can be considered as a transaction. If it succeeds, the object is known to be completely constructed. If it fails, the object immediately ceases to exist.
Thanks to the transactional semantics of the second variant, the state space for potential error conditions (and thereby the test space) remains small. Also, the second variant facilitates the use of references as class members, which can be safely passed as arguments to subsequent constructors. When receiving such a reference as argument (as opposed to a pointer), no validity checks are needed. Consequently, by using exceptions, the robustness of object-oriented code (i.e., code that relies on C++ constructors) can be greatly improved over code that avoids exceptions.
Hence, the case for or against the use of the C++ exception mechanism is not clear cut but should be made per component individually. For foundational components like Genode's core or resource multiplexers, the consistent use of sum-types-based error handling is preferable over exceptions. But for higher-level components, in particular those interfacing with 3rd-party C++ libraries, the use of exceptions is natural.
Bare-metal C++ runtime
For Genode, the complexity of the trusted computing base is a fundamental metric. The C++ exception mechanism with its dependency to the C library arguably adds significant complexity. The code complexity of a C library exceeds the complexity of the fundamental components (such as the kernel, core, and init) by an order of magnitude. Making the fundamental components depend on such a C library would jeopardize one of Genode's most valuable assets, which is its low complexity.
To enable the use of C++ exceptions and runtime type information but avoid the incorporation of an entire C library into the trusted computing base, Genode comes with a customized C++ runtime that does not depend on a C library. The C++ runtime libraries are provided by the tool chain, which interface with the symbols provided by Genode's C++ support code (repos/base/src/lib/cxx).
Unfortunately, the interface used by the C++ runtime does not reside in a specific namespace but it is rather a subset of the POSIX API. When linking a real C library to a Genode component, the symbols present in the C library would collide with the symbols present in Genode's C++ support code. For this reason, the C++ runtime (of the compiler) and Genode's C++ support code are wrapped in a single library (repos/base/lib/mk/cxx.mk) in a way that all POSIX functions remain hidden. All the references of the C++ runtime are resolved by the C++ support code, both wrapped in the cxx library. To the outside, the cxx library solely exports the CXA ABI as required by the compiler.