How I am handling errors in a freestanding environment
Helio Nunes Santos
Posted on May 5, 2022
Introduction
Hi guys!
It has been a good while since I have written anything (well, I actually have written an article just once), but as I have gotten inspiration from a myriad of things recently, I have decided to start writing more, to showcase what I am working on and receive feedback on my code.
I will tell you guys a small story and then we can proceed to the meat. If you want, you can skip the next part.
The story behind it
I have recently discovered the RISC-V ISA, which is an open instruction set architecture. With the RISC-V ISA if you want to develop a new CPU that supports the architecture. you don't have to pay royalties to anyone and, for me at least, it sounds amazing! It can also open doors to endless possibilities if the architecture succeeds and is widely adopted and produced, given that not paying royalties could be another factor in decreasing the cost of CPUs, which in turn can encourage more innovation (not that we are not facing any currently).
Given my interest in the architecture and its proposed benefits, I have decided to take my time to develop a small kernel for it. I am not being the first one to do so, but I have decided to do this as a challenge to myself, given that developing a kernel is not an easy task and it is going to make me get a good grasp of a lot of Computer Science concepts. As the architecture doesn't have to be compatible with anything developed more than 40 years ago (cough cough x86), it is easier to have it as a target for a brand new kernel.
Right at the beginning of developing the kernel, I had to figure out a way of dealing with errors. Given that my kernel gets the address of the Device Tree (a structure used to describe the system's physical hardware) from the arguments that are passed to its start function, I have to read the arguments and parse the value which contains the address in hexadecimal for the Flattened Device Tree.
Given that I am in a freestanding environment, I have to implement from scratch almost everything, which includes a routine to parse a string that contains (or not) a value in hexadecimal.
Now, how can I handle errors? An example would be when the user supplies garbage instead of an hexadecimal value.
In a freestanding environment, there are no exceptions, so using them is ruled out.
One idea would be to determine certain values as errors, which is how a lot of softwares developed in C do. But I would have to say that it is not the ideal solution, given that, for example, if I get 0 as the value to indicate an error, how can I be sure that the user really didn't give 0 as an argument? Surely, for the address of the Device Tree, 0 wouldn't be a valid value, but I will be reusing the routine to parse hexadecimals in other cases as well, and 0 might be a valid value. Of course, when applicable, I could simply check if it is a value that represent an error, but this would demand developing a different logic for errors each time, which can be tiresome and bug prone.
I could also use retrieve errors from global variables, but I am planning to support multi-threading in the future, so this option would add unnecessary complexity to my code.
As this small case has given me food for thought, I remembered of a talk I have seen a while ago and decided to go with the idea presented. We are using a multi-paradigm programming language, so why not take advantage of this?
We can simply have a safe union that indicates whether we have a value or an error and contains either of both. Doing like this, we don't have to reserve a value to indicate an error, and it helps us to handle the error logic separately, given that we have a whole range of values to use as error values!
How I am doing it
So, for handling errors, I am using a small class that I called ValueOrError. It has a simple interface which allows the user to know if what it holds is either a value or an error value and get what it holds accordingly.
To make it more expressive and easier for the user, I had to define a class called GenericWrapper, which is a wrapper over a value.
Here is the preamble of the header file which contains the class ValueOrError. This preamble contains a few declarations (that will be explained later) and the definition of the class GenericWrapper.
#include <utility>
#include <type_traits>
namespace hls {
template<typename, typename>
class ValueOrError;
template<typename T, int x>
class GenericWrapper;
template<typename T>
using ErrorType = GenericWrapper<T, 0>;
template<typename T>
using ValueType = GenericWrapper<T, 1>;
template<typename T, int x>
class GenericWrapper {
T v;
GenericWrapper(T&& a) : v(std::forward<T>(a)) {};
GenericWrapper(const T& a) : v(a) {};
GenericWrapper(const GenericWrapper&) = default;
GenericWrapper(GenericWrapper&&) = default;
public:
~GenericWrapper() = default;
template<typename, typename>
friend class ValueOrError;
template<typename U>
friend auto error(U&& error) -> ErrorType<std::remove_cvref_t<U>>;
template<typename U>
friend auto value(U&& value) -> GenericWrapper<std::remove_cvref_t<U>, 1>;
};
// Remaining of the file...
}
Have you seen the declaration of those two friend functions at the bottom? These two are the ones that will help giving more expressiveness to the code! Have you also seen the using declarations for ErrorType and ValueType? I will be taking advantage of the C++ type system to conform with DRY (Don't repeat yourself).
Whenever a user wants to return a value or an error, they can simply do:
#define ERROR_FOR_REASON_X 10
ValueOrError<int, int> my_function(int input_value) {
// Do stuff based on input_value
// Nice, we didn't get an error
if(all_good)
return hls::value(10);
// Oh no, we got an error
else
return hls::error(ERROR_FOR_REASON_X);
}
Have you seen that we used the same value for the return value and for the reason of the error? That is where our class ValueOrError comes. Here is the remaining of the file:
template<typename T>
auto error(T&& error) -> ErrorType<std::remove_cvref_t<T>> {
return {std::forward<T>(error)};
}
template<typename T>
auto value(T&& value) -> ValueType<std::remove_cvref_t<T>> {
return {std::forward<T>(value)};
}
template<typename T, typename ErrorT = int>
class ValueOrError {
using Type = std::remove_cvref_t<T>;
using Error = std::remove_cvref_t<ErrorT>;
union {
Type value;
Error error;
} m_stored;
bool m_is_error = false;
public:
template<typename U>
requires (std::is_same_v<std::remove_cvref_t<std::remove_pointer_t<U>>, std::remove_cvref_t<std::remove_pointer_t<T>>>)
ValueOrError(ValueType<U>&& value) {
new(&m_stored.value)T(std::move(value.v));
}
template<typename U>
requires (std::is_same_v<std::remove_cvref_t<std::remove_pointer_t<U>>, std::remove_cvref_t<std::remove_pointer_t<ErrorT>>>)
ValueOrError(ErrorType<U>&& error) {
m_is_error = true;
new(&m_stored.error)ErrorT(std::move(error.v));
}
~ValueOrError() {
if(is_error())
m_stored.error.~ErrorT();
else
m_stored.value.~T();
}
ValueOrError(const ValueOrError& other) {
if(other.is_error()) {
m_is_error = true;
m_stored.error = other.get_error();
}
else {
m_stored.value = other.get_value();
}
}
ValueOrError(ValueOrError&& other) {
if(other.is_error()) {
m_is_error = true;
m_stored.error = std::move(other.get_error());
}
else {
m_stored.value = std::move(other.get_value());
}
}
bool is_error() const {
return m_is_error;
}
bool is_value() const {
return !is_error();
}
T& get_value() {
const auto& as_const = *this;
return const_cast<T&>(as_const.get_value());
}
const T& get_value() const {
return m_stored.value;
}
ErrorT& get_error() {
const auto& as_const = *this;
return const_cast<ErrorT&>(as_const.get_error());
}
const ErrorT& get_error() const {
return m_stored.error;
}
};
}
As you can see, the class ValueOrError takes advantage of the fact that using a different value for the template argument creates a different type, which causes the compiler to call the proper constructor. Note also that we have the requires clause as to disallow unintentional type conversion. Without it, you could say hls::value(10.0f)
instead of hls::value(10)
and it would work. If this is desired, a cast then should be used.
Note that the classes take advantage of move semantics, so the compiler should be able to optimize all of it.
Last but not least, I will be producing a small example with the usage of it:
#include <iostream>
#include <iomanip>
#include "valerror.hpp"
// When not specified, a enum defaults to an int
enum ERROR_CODE {
INVALID_STRING,
NOT_AN_HEX_VALUE,
VALUE_TOO_LARGE
};
hls::ValueOrError<int, ERROR_CODE> hex_to_int(const char* hex_string) {
if(hex_string == nullptr)
return hls::error(INVALID_STRING);
bool is_hex_value;
// Code that checks if is an hex value
// ...
if(!is_hex_value)
return hls::error(NOT_AN_HEX_VALUE);
int required_bit_size;
// Code that checks if the hex value fits in an int
// ...
if(required_bit_size > 32)
return hls::error(VALUE_TOO_LARGE);
int result;
// Code that converts it to an int
return hls::value(result);
};
int main() {
while(true) {
std::cout << "Please, provide an hex value that fits in a 32 bits integer: \n";
std::string user_input;
std::cin >> user_input;
auto result = hex_to_int(user_input.c_str());
if(result.is_error()) {
switch(result.get_error()) {
case INVALID_STRING:
std::cout << "The pointer to the string was nullptr\n";
break;
case NOT_AN_HEX_VALUE:
std::cout << "The user input was not an hex value\n";
break;
case VALUE_TOO_LARGE:
std::cout << "The value the user has given doesn't fit in a 32 bit integer.\n";
break;
}
}
else {
std::cout << "The user input was: " << std::setbase(16) << result.get_value() << "\n";
break;
}
}
}
And that is it guys. I hope everything was clear. I would also like to have your feedback on improvement and suggestions, be it on my code or my writing. Thank you and see you soon!
If you want to get in touch with me, add me on LinkedIn:
https://www.linkedin.com/in/heliobatimarqui/
Posted on May 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.