The assert
function as such is problematic, as you've probably noticed since you asked this question. No, using it is not good practice. Do not mix up "lots of error checking" with assert
.
Like many things in C, assert
originates from Unix and it kind of assumes that "notify the OS, then lay down and die" is acceptable program behavior. You'd just terminate the executable and the OS would display some error message, end of story. Not really feasible in an embedded system though, is it...
Furthermore, it has always been a pain to make assert
spit out meaningful information about the cause. And per definition, assert
is only present in the debug build of the project while NDEBUG
is defined. So it was never meant to be used for anything else but quick & dirty debugging in the development phase.
(In fact assert
is even problematic in its intended environment, an old problem which has not been addressed until every recently - advanced topic for those interested: N2829 Make assert() macro user friendly for C and C++. These changes are live in C23/C++23.)
The conclusion is that assert
should be avoided in all (embedded) systems.
So what to use instead?
First it is worth mentioning that the C11 version of C provided a different, much better debug tool namely _Static_assert
, which does compile-time checking instead of run-time checking. This is a very nice one as long as the error is something you can check for at compile-time. Array sizes, read-only data integrity, stuff like that. But it can't be used to check things like run-time values of variables. So it isn't really a replacement for assert
, but rather a complement.
(As from C23 we can use static_assert
, lower case. It's been made a proper keyword now.)
You also mention "we probably just want to reset". Indeed, this is the most sensible thing to do in case of critical errors in a standard MCU application. Whenever errors happen, we generally want to reset the MCU in case the problems are related to register setups, memory corruption or similar.
However, in safety-related and real-time applications, it might not be advisable to just reset either, unless a seamless reset can happen.
The best solution to all of these problems in my experience is to always implement a proper error handler. Use static_assert
for program integrity stuff at compile-time, but beyond that do not separate "debugging errors" from actual errors in the live release version. Similarly, errors that pop up as a result of defensive programming (ie "this should never happen but what if it does anyway") should also get passed on to this error handler.
A rugged, sensible design of a high-integrity, "bare metal" MCU application will therefore most often look like this:
void main (void)
{
/* various init code here */
for(;;)
{
kick_wdog();
result = state[current_state]();
current_state = error_handler(result, current_state);
}
}
kick_wdog
is ideally the only place in the program where you kick the watch dog. Sometimes "time window" solutions can be used here and one might clock the time it takes to execute a state etc, but this is the fundamental idea.
state
is a state machine, most often implemented as an array of function pointers allocated in flash. This can be as simple or as advanced as needed, but on the top level this should probably just be "system is starting up", "system is in a safe mode" and "system is fully operational".
High-integrity systems always implement a safe mode. If this involves some "limp home" code or just error logging/reporting followed by a hard reset depends on the application.
result
is either OK or an error code. Notably all errors that may occur in your application may "trinkle down" to this.
It can go like in this example: error happens in a SPI driver. SPI driver reports up to the SPI protocol module. The SPI protocol module may or may not report the error further up to the caller, depending on the error. Lets say that the caller was some LCD driver - which in turn might report an error upwards. At each layer of the program you have a decision whether the error is relevant enough to pass on or not, until they all eventually end up in the top level main().
error_handler
is the only place in the program where the decision what to do in case of an error occurs. It may do the error reporting/logging, it may decide to revert to a safe mode, it may decide to reboot the MCU etc etc. Similarly, this centralized location is the only place in the program where state changes may occur.
Having de-centralized error decision handling code all over the program is bad - it makes it hard to track down where the error came from. Similarly, having de-centralized state change decisions (aka "stateghetti programming") is also very hard to maintain or even get an overview of. Instead of making the decision, the various modules just report a result code. The error handler then makes the decision based on the result code and the current state.
The above also have various other benefits such as "no debug code". It has low "cyclomatic complexity" (execution paths) and it makes it easy to test code coverage (all code present in the program should get executed at some point). This in turn makes it easier to deal with safety standards, if applicable.
And yeah lot of the above are design patterns coming from my usual stomping grounds of safety-critical applications. But a whole lot of what safety-related programming actually entails is just: don't write bugs, don't write unmaintainable programs, don't write programs where the programmer themselves have no clue what's going on, etc. Essentially just "don't write bad programs" and general software quality concerns.