Over the last few years, I’ve spent a great deal of time working on embedded applications. During this time, I’ve learned a lot about the intricacies and nuances of C. At first glance, C seems like a pretty simple language — not a lot of data types, fairly straightforward syntax — and if you limit your experience to entry-level textbook examples, you might maintain that point of view.
The challenge with C (and most other languages) is not understanding the basic constructs. It’s learning how to use the language correctly — and responsibly.
Bending the Rules Until They’re Brittle
As you gain experience and get involved in more complex applications, it is easy to catch yourself implementing tricky code, or as my co-worker calls it, a bit of jiggery-pokery. Tricky code can be a lot of things but, in general, I would define it as: A creative solution to a programming challenge that pushes the limit of (and often exceeds) what would generally be considered an acceptable use of the language.
The biggest problem with tricky code is that it can confuse and mislead other developers. Remember, there may come a day when you’ve moved on and someone else has to maintain your code. Tricky code is almost always brittle and creates great potential for bugs. Avoid it whenever possible.
On the other hand, because C lacks a lot of the handy features of higher-level programming languages, we are sometimes forced to implement creative solutions that have an inherent brittleness. So, what can be done about this? Commonly, responsible developers will put comments in their code to warn others of what is happening and explain the reason for it. That is a great idea, but I would suggest taking it one step further — use unit tests!
Unit Tests as Early Warning Systems
Unit tests are commonly used to test that a particular function does what is expected. Because of this, many people mistakenly correlate code coverage to quality testing. They think that if every path is covered by a unit test, then the testing is complete. Try to stay away from that line of thinking. When writing unit tests, try to think about how the function fits into the bigger picture of what is going on in the application. In what ways could someone break your code by making a minor change? How can you test for that? In many cases this involves testing your types, not your functions.
Let me give a couple of examples. A few weeks ago, I saw a structure similar to the one below. The application assumed the cmd_data
union was the last field in the structure. If another field was added after cmd_data
, problems would have occurred.
struct cmd_t {
uint8_t length;
uint32_t crc;
union {
struct CMD_DATA_A cmd_a;
struct CMD_DATA_B cmd_b;
} cmd_data;
} CMD_T;
We started by adding a comment to the structure definition that said, “Do not add any fields below cmd_data…” but that still seemed a little risky. Then we thought of a way we could protect against this with a unit test. We added a test that created an instance of this structure and then did some pointer math to verify that the cmd_data
union was in fact the last member in the structure. If anyone added a member to the struct, the test would fail and they would not be able to ignore/overlook it.
In another case, I saw a structure being used to contain values that were to be stored in non-volatile memory. The NVM size was quite limited, and if too many members were added to the structure, the data would not fit. A simple unit test to check the size of the struct and compare it to the total size of the NVM would have been great in that case.
C compilers for microcontrollers very greatly. Differences in machine word sizes can cause structures to be packed differently. Forcing a particular packing or alignment can cause major problems with code portability. Unit tests can be used to verify that a particular structure is packed as you would expect or that fields of the structure are aligned as you expect. Be very careful when doing this if you are not running your tests on a simulator. If you are compiling and running your tests on a different machine then your target your tests might not behave the same way as your release code.
In general, try to think outside the box when you are writing unit tests. Don’t just focus on code coverage. Code coverage is important, but 100% coverage doesn’t mean fully covered. Get creative, and think about protecting your design from others (or yourself) sometime in the future. Any time you find yourself adding a comment in your code that warns others against changing something, try to find a way to add a unit test for it as well.
Can you show us the tests?