Filesystem Inspired Abstraction in Embedded C++
I recently rewrote a family of C++ libraries that I had been using for 10 years. The main themes of the rewrite include:
- Thread Local Error Contexts
- Method Chaining
- Strong Arguments
- RAII Everywhere
- Filesystems Inspired Abstraction
Why Filesystems?
I became quite familiar with the power of filesystems when writing Stratify OS. Stratify OS is a POSIX based operating system for microcontrollers. POSIX was born out of a need to standardize early Unix-like operating systems. One of the core features of UNIX is treating many different constructs as files. In Unix, files, network sockets, and devices can all be read/written using the same API. As I was rethinking my C++ libraries, the idea of creating even more constructs like fails made complete sense. In the API framework, I extended the idea of file-like interaction to heap memory, a variable on the stack, or even callbacks. Doing this creates a universal API for data management regardless of the medium.
A Buffered Approach
The API framework is primarily designed to run on memory-constrained systems (but can be used on anything POSIX compatible, including Windows). On memory-constrained systems, there is a constant need to do operations with limited buffers. For example, if I want to write a large file to a socket, I can’t just load the whole file in RAM then send it. I need to load manageable chunks into RAM and send those chunks before reading more. The code looks something like this (just for illustration, not-compiled):
int write_file_to_socket(int file_fd, int socket_fd, int file_size){
//reasonable buffer
char buffer[512];
int bytes_transferred = 0;
int page_size;
do {
page_size = (file_size - bytes_transferred > 512) ? 512 : file_size - bytes_transferred;
int result = read(file_fd, buffer, page_size);
if( result > 0 ){
write(socket_fd, buffer, result);
bytes_transferred += result;
} else {
return bytes_transferred;
}
} while( bytes_transferred < file_size);
return bytes_transferred;
}
The above code (or variations of it) seemed to appear a lot in my previous C++ libraries. To avoid rewriting this algorithm, I created an abstract FileObject
that could be written to by reading another FileObject
.
class Write {
//including things like buffer size, max size, file offset
//to give flexibility in doing the transfer
};
FileObject& write(const FileObject & source, const Write& options = Write()){
//basically the above but with some flexible options for page_size
//and buffer_size
return *this;
}
Ultimately, this meant I could write a file to a socket like this:
Socket socket; //inherits FileObject
File file; //inherits FileObject
socket.write(file, File::Write().set_page_size(256));
Using RAII to open/close files, you can copy a file from one location to another like this:
File(File::IsOverwrite::yes, "dest.txt").write(File("source.txt"));
All Kinds of Files
By adding a DataFile
class that uses the heap to create a file in memory, you can read a socket:
Socket socket;
DataFile incoming; //inherits FileObject
incoming.write(socket);
A ViewFile
turns any variable into a file:
typedef struct {
int version;
int size;
} header_t;
header_t header;
//read the file header into header
ViewFile(View(header)).write(File("file_with_header.dat");
A LambaFile
allows you to provide read/write callbacks. This is handy if the data read from a file requires complex manipulation rather than a simple transfer to another device.
Devices are also Files
Of course, devices are implemented as files as well.
//create a uart log file -- blocks until 2048 bytes have been written
File(File::IsOverwrite::yes, "uart_log.txt").write(Uart("/dev/uart0"), File::Write().set_size(2048));
What Now?
If you write C/C++ code on constrained devices, you are familiar with the buffered data transfer example above. Inheritance in C++ is a powerful tool. With the right approach, you can re-use the same code in many, many different situations. Feel-free to use file-like abstractions or create your own to maximize code re-use.