The core value proposition of software is flexibility - but in embedded systems, we often lose that advantage. Codebases become tightly coupled to a specific hardware revision, making even small platform changes expensive and risky. As hardware complexity grows linearly, software complexity grows exponentially. Costs rise. Schedules slip. Eventually organizations become resistant to improving their own products because the software architecture can’t absorb change.
This risk is avoidable. By intentionally separating the high-level business rules from low-level hardware details, we regain the flexibility software is supposed to provide. One of the most effective techniques for achieving this separation is dependency inversion. In short, lower-level components implement an interface defined at a higher level. Control still flows from high to low abstraction layers, but the dependencies flow upward. High-level code is unaware of how the interface is concretely implemented. In an embedded context, this paradigm allows the software architecture to adapt quickly and cheaply to new hardware iterations without rewriting core logic.
Languages with full runtime polymorphism natively support this paradigm. They can define abstract base classes from which concrete implementations are derived, and the components depending on these interfaces can depend only on the base class type. C does not have such an elegant built-in solution for inverting dependencies. There is no virtual function table or class hierarchy. But a vtable is just a struct of function pointers - something C expresses naturally. So in C, we provide our own layer of indirection using structs of function pointers.
In this post, we explore dependency inversion in C through a concrete, practical example: designing a flexible logging interface.
A Simple Logger
Suppose we’re developing a system in need of a logging facility. The logger needs two APIs:
- An initialization function to set up any resources required by the logger.
- A log function that accepts a message to log.
A naïve implementation might couple the logger directly to a specific output, like stdout. But this has a serious drawback: it becomes difficult to change how a component logs. What if we wanted a component to sometimes log to stdout, and sometimes log to a file? What if we are working on an embedded platform and need to emit log messages via UART or SPI, or some other serial interface? The solution is dependency inversion.
Let’s dig into our logger example. The tightly-coupled implementation might look like this, where the the component depends directly or transitively on a specific logger implementation:
Dependency inversion frees components from needing to know the details of how logging is implemented. It also leads to sparser build dependency graphs - components that depend only on the interface don’t need to be recompiled when implementations change. The resulting architecture looks as follows:
It’s worth noting that it is not only acceptable but expected that main depends on low-level details. Ideally, main - or whatever the entry point for your application is - should be the centralized location where all concrete implementations are defined and injected into the more abstract components.
Defining the Logger Interface
So, let’s define an abstract interface - implemented via function pointers and let the high-level component code depend only on that. A real implementation would probably be a variadic function, but we’ll just log a string for simplicity.
File: core/components/logger/include/logger_interface.h
#pragma once
#include <stddef.h>
typedef struct logger_interface
{
void (*init)(void);
void (*log)(const char* logger_name, const char *msg);
const char* name;
} logger_interface_t;Implementing Two Concrete Loggers
Now, we create two low-level implementations of the high-level logger interface. Notice how they depend on the logger interface. Components which do logging do not directly utilize either of these concrete implementations.
stdout Logger
File: plugins/stdout_logger/include/stdout_logger.h
#pragma once
#include <stddef.h>
#include "logger_interface.h"
logger_interface_t mk_stdout_logger(const char* name);plugins/stdout_logger/src/stdout_logger.c#include <stdio.h>
#include <string.h>
#include "stdout_logger.h"
static void stdout_logger_init()
{
}
static void stdout_logger_log(const char* logger_name, const char *msg)
{
printf("[%s] %s\n", logger_name, msg);
}
logger_interface_t mk_stdout_logger(const char* name)
{
logger_interface_t logger;
logger.name = name;
logger.init = stdout_logger_init;
logger.log = stdout_logger_log;
return logger;
}File Logger
File: plugins/file_logger/include/file_logger.h
#pragma once
#include <stdio.h>
#include <stddef.h>
#include "logger_interface.h"
logger_interface_t mk_file_logger(const char* name);plugins/file_logger/src/file_logger.c#include <stdio.h>
#include <string.h>
#include "file_logger.h"
static char* logname = "log.txt";
static void file_logger_init()
{
FILE* f = fopen(logname, "w");
if (f != NULL) {
fclose(f);
}
}
static void file_logger_log(const char* logger_name, const char *msg)
{
FILE* f = fopen(logname, "a");
if (f != NULL) {
fprintf(f, "[%s] %s\n", logger_name, msg);
fflush(f);
fclose(f);
}
}
logger_interface_t mk_file_logger(const char* name)
{
logger_interface_t logger;
logger.name = name;
logger.init = file_logger_init;
logger.log = file_logger_log;
return logger;
}Using the Logger in a High-Level Component
Let’s create a component that uses a logger. We’ll make a generic worker that does some task - in our case, logs a message. This worker depends only on the logging interface and not a particular logger.
File: core/components/worker/include/worker.h
#pragma once
#include "logger_interface.h"
typedef struct
{
const logger_interface_t *logger;
} worker_t;
void worker_init(worker_t *w,
const logger_interface_t *logger);
void worker_do_work(worker_t *w);File: core/components/worker/src/worker.c
#include <stdio.h>
#include "worker.h"
void worker_init(worker_t *w,
const logger_interface_t *logger)
{
w->logger = logger;
w->logger->init();
}
void worker_do_work(worker_t *w)
{
w->logger->log(w->logger->name, "Worker did some work");
}Wiring Together into an Executable
It’s now trivial to create a main which instantiates a few different workers, and flexibly select which logger to use. We’ll create three loggers: two that utilize the stdout implementation and one that logs to a file. Each logger instance maintains its own module name buffer, allowing multiple workers to share the same logger implementation while retaining independent module names.
File: app/main.c
#include <stdio.h>
#include "worker.h"
#include "stdout_logger.h"
#include "file_logger.h"
int main(void)
{
logger_interface_t stdout1 = mk_stdout_logger("stdout1");
logger_interface_t stdout2 = mk_stdout_logger("stdout2");
logger_interface_t file1 = mk_file_logger("file1");
worker_t w1, w2, w3;
worker_init(&w1, &stdout1);
worker_init(&w2, &stdout2);
worker_init(&w3, &file1);
worker_do_work(&w1);
worker_do_work(&w2);
worker_do_work(&w3);
return 0;
}If we compile this all together and run, we should see the two stdout loggers emit their messages on the console, and the file logger’s output in log.txt. The makefile in the source code for this example defines the required target.
make > /dev/null
./demo
[stdout1] Worker did some work
[stdout2] Worker did some work
cat log.txt
[file1] Worker did some work
Verifying Dependency Inversion
To confirm the components implementing business logic - like worker - have no dependency on any concrete implementation, let’s investigate the worker object file. If we run make worker.o to create the object file for our worker component, we can then use nm to prove there are no undefined symbols. This demonstrates the dependency inversion worked: the worker component has zero dependency on any concrete logger implementation.
make worker.o
nm worker.o
0000000000000030 T _worker_do_work
0000000000000000 T _worker_init
000000000000006c s l_.str
0000000000000000 t ltmp0
000000000000006c s ltmp1
0000000000000088 s ltmp2
The Cost of Indirection
While this abstraction is very low cost, it is not zero cost. We need to dereference our function pointer, which incurs a small runtime cost each time we log. For almost all applications, this is completely negligible.
Other Options
What are the other options for tackling this sort of problem?
- Define entirely separate worker components for each logger type: worker-file, worker-stdout, etc
- Conditionally compile our worker or logger libraries with different implementations depending on the desired configuration.
The first bullet is hitting the problem with a hammer - introduce tons of duplication and maintain tightly coupled interfaces. This makes the project much more expensive to maintain and less scalable. The second option is even worse - we’ve made it impossible to instantiate multiple types of loggers in a single translation unit. It’s my opinion that conditional compilation is nearly always a code smell and should be avoided at all costs - a topic for another time. In short, both alternatives reduce flexibility and maintainability compared to dependency inversion.
Conclusion
We’ve now walked through implementing dependency inversion in C - a language without native support for dynamic polymorphism. Using function-pointer interfaces allows you to decouple high-level policy from hardware-specific implementations with minimal overhead. This pattern produces more testable, more portable, and more maintainable embedded systems.
The full source code for this example is available on GitHub. If you’d like to discuss this pattern or how it might apply to your system, feel free to reach out: sam@volatileint.dev
If you found this article valuable, consider subscribing to the newsletter to hear about new posts!