Clean Code for Embedded Systems and C
Urs Fässler
In embedded systems, the quality of code is crucial for long term reliability and efficiency. Clean Code principles, widely used in general software development, can significantly enhance embedded C development. This article explores how these principles can be applied to embedded C, drawing on extensive experience to improve code readability, reduce technical debt, and lower costs over the system’s lifetime. Whether you’re a developer or a manager, this guide offers practical insights into integrating Clean Code into your embedded systems projects.
To understand how Clean Code can be effectively implemented, let’s delve into the key principles and their application in embedded C development.
Clean Code
This introductory chapter emphasizes the universal need for clean code, regardless of the technology or system. It highlights the business case for maintaining clean code and the responsibility developers have for their work’s quality. The "Boy Scout Rule" encourages leaving code cleaner than you found it, fostering a culture of continuous improvement.
Meaningful Names
The guidance on naming conventions presented in this chapter is universally applicable, aiming to enhance code readability and facilitate better communication among developers.
In C, the absence of namespaces or packages can make naming more challenging, often necessitating the use of prefixes or suffixes to avoid conflicts.
Clear and descriptive naming is essential, both for immediate tasks such as code reviews and for long-term maintenance, such as when revisiting code to adjust behavior a decade later.
Functions
The principles discussed in this chapter are broadly applicable across various programming languages and systems.
One aspect I would like to emphasize is "Structured Programming."
In practice, code readability can often be enhanced by using more than one return
statement within a function.
The chapter’s advice on switch statements is less applicable to C, as most C code does not utilize polymorphism. To maintain clean code when using switch statements, consider the following guidelines:
-
Ensure the switch statement is the only top-level statement in the function.
-
Avoid including other statements within a case block; instead, use a single function call.
-
Use an enum as the control expression.
-
Make sure the compiler issues an error if the enum is extended, as discussed in the related blog post.
Since C does not support exceptions, the error-handling strategies proposed in the book cannot be directly applied. However, we can improve upon the traditional error codes. This will be discussed further in the chapter on Error Handling.
Comments
I particularly appreciate this chapter, as I have never been fond of writing comments. The insights provided are equally applicable to C and embedded systems as they are to other programming languages and environments.
A comment is a failure to express yourself in code. If you fail, then write a comment; but try not to fail.
Formatting
The principles outlined in this chapter are largely applicable to all programming languages and systems.
One notable exception for C and C++ is "The Newspaper Metaphor." This metaphor suggests that, as we read a file from top to bottom, the most high-level and important function should be placed at the top. However, this approach can be problematic because high-level functions often require numerous private helper functions. To adhere to this metaphor, developers would need to add prototype declarations, leading to cumbersome code duplication.
In practice, I have grown accustomed to reading C files from bottom to top, automatically navigating to the end of the file upon opening it. This approach is feasible because functions are typically short and source files are small.
I would like to emphasize the importance of "Team Rules". The first challenge here is that I often see embedded developers working individually within companies, rather than as part of a team. The second challenge is the need for a Continuous Integration (CI) server and the active practice of CI. Once a company and its developers commit to this path, many of these practices naturally fall into place. Defining formatting rules then becomes a straightforward task.
Objects and Data Structures
Although some object-oriented principles do not directly apply to C, the underlying concepts remain relevant and valuable.
To maintain clean code, avoid "train wrecks" by minimizing the use of nested structs or functions. Additionally, hide implementation details within source files to promote encapsulation.
Data Transfer Objects, or structs, can significantly enhance the maintainability of C code by providing clear and organized data structures.
Error Handling
Much of the chapter focuses on exceptions, which are not applicable to C code.
However, the guidance on using NULL
and other error-handling strategies offers valuable insights for C programming.
In the absence of exceptions, how do we handle errors in C?
In practice, I have found that detailed error information is often unnecessary.
Instead, each function at every level should strive to complete its task to the best of its ability.
For example, a high-level function sending bytes over I2C might retry several times before returning an error.
In such cases, we only need to know whether the operation succeeded or failed, which can be indicated by returning an ERROR/SUCCESS
enum or a boolean value.
The appropriate response to an error depends on the specific code and requirements. Options include attempting to resolve the issue at a higher level (e.g., reinitializing I2C) or propagating the failure further up. Logging errors is best done within the function where the error occurs, as this is where the most contextual information is available.
In embedded systems, robust error handling is crucial due to the potential costs and risks associated with failures. These errors are often part of the system’s business logic and represent expected behaviors that must be managed, for example, using an alarm management system within the software.
Boundaries
While this chapter is fully applicable to C and embedded systems, it may require some additional explanation.
First, you need a unit test framework. Choose one that suits your needs; I recommend using a C++ framework even when working with C for production, as they are generally more user-friendly.
Besides third-party code, embedded systems often interact with external devices via physical interfaces. Treat these devices similarly to third-party code, as described in the book. Write API tests to understand how to control the device. Although these tests may not run on the CI system, they are invaluable for verifying functionality after a firmware update of the external device.
When implementing "Using Code That Does Not Yet Exist," mocking is essential. While mocking in C can be cumbersome, it is achievable. For a detailed guide on implementing TDD and mocking in embedded C, I recommend the book "Test-Driven Development for Embedded C" by James W. Grenning. This resource provides practical insights and examples tailored to C and embedded systems development.
Unit Tests
Unit testing in C and embedded systems follows the same principles as in other programming environments. The hardware being controlled is irrelevant, as it is merely an external dependency that must be abstracted away. The same architectural patterns and principles apply to hardware as they do to other external dependencies, such as databases or email notification services in cloud applications. This requires abstraction and mocking, as described in the chapter Boundaries.
It is crucial to emphasize that tests are not optional for clean code; they are fundamental. As discussed in the chapter Emergence, good software evolves over time. This evolution requires refactoring, which in turn necessitates automated tests as a safety net.
Personally, I am primarily interested in the code itself, and I would not mandate test-driven development (TDD) for everyone. However, writing tests after production code can be tedious and uninspiring, making it difficult to understand why one would choose this approach. TDD is not merely about creating tests—that is a byproduct. Instead, TDD focuses on designing modular, testable code.
Classes
At first glance, this chapter might seem irrelevant to C programming, as C does not support classes. However, the underlying concepts are indeed applicable to individual C implementation files.
Encapsulation, size, the Single Responsibility Principle, cohesion, and organization for change are all crucial considerations when designing and refactoring C files. By adhering to these principles, you can create modular, maintainable, and robust code, even in the absence of object-oriented features.
Systems
While this chapter is broadly applicable to various programming languages and systems, it includes some Java-specific content. However, the core ideas and concepts can be effectively transferred to C and embedded systems.
The challenge lies in shifting the approach to building embedded software. Although C is not inherently designed for creating modular systems and abstracting details, it is both possible and necessary to do so. This shift is essential for developing clean, maintainable software that can endure over time.
For many embedded software developers, the concept of developing software incrementally and iteratively, splitting code into small files and components, or using nested directories for source files may seem unfamiliar. These practices might initially appear out of touch with traditional embedded development methodologies. However, it is important to recognize that software development is a distinct discipline from hardware and electronics design, particularly within hardware-focused companies. Embracing these modern software development practices can lead to more robust and adaptable embedded systems.
Emergence
Emergence is a core concept and one of the most important ideas in software development, including embedded systems. Many other principles are necessary to create emergent designs, and this concept is equally applicable to embedded software.
In embedded systems, software is often viewed as an unavoidable burden, an additional task that must be completed. The focus is typically on the device being developed, with product development, manufacturing, mechanical engineering, and electrical engineering being the traditional strengths of the company. It can be challenging to appreciate the significance of software development in a company culture centered around physical products.
However, times have changed. Software is now integral to most electrical devices and is no longer a one-time task during device development. Devices are increasingly connected, making security updates essential—whether driven by customer care or regulatory requirements, such as the Cyber Resilience Act. Additionally, the growing complexity of software necessitates the use of modular components across different products. Libraries for data aggregation, configuration handling, and other functionalities do not need to be reinvented for each product. As products and their software evolve over years, the ability to adjust and improve them is crucial.
Concurrency
Concurrency is a critical topic in embedded systems, as these systems interact with the real world where events occur in parallel.
However, many embedded systems, particularly those focused on control, spend most of their time idle, making them I/O-bound rather than CPU-bound. Therefore, the emphasis on multithreading, as discussed in the book, may not be as relevant. Multithreading is complex and often unnecessary for embedded systems.
A well-designed embedded system is event-driven. Every input to the system, such as a button press, a change in temperature, or a completed I2C data transfer, is treated as an event. Depending on the system’s state, these events trigger specific actions, such as sending data over I2C, turning on an LED, or activating a motor. Parallelism is managed manually, often through state machines, as part of the system’s business logic designed to handle real-world concurrency.
Does this mean multithreading is useless? Not entirely. Modern embedded systems often have multiple cores, and for performance-critical applications, multithreading can be beneficial. By adhering to a modular design (facilitated by practices like TDD) and other Clean Code principles, software can be structured to run in a multithreaded environment. However, this should be considered an optimization of the implementation, undertaken only when supported by concrete performance data, and kept separate from core functionality.
Conclusion
The journey towards adopting Clean Code principles in embedded C development is not just about writing better code; it’s about cultivating a mindset of excellence and craftsmanship. As we’ve explored, the principles of Clean Code are universally applicable, offering tangible benefits such as improved maintainability, reduced bugs, and faster development cycles. For embedded developers, this means creating more reliable and efficient systems. For managers, it means fostering a culture of continuous improvement and innovation. The key to success lies in embracing these principles holistically, from individual coding practices to organizational strategies. By committing to Clean Code, embedded systems teams can navigate the complexities of modern development with confidence, delivering high-quality software that stands the test of time.

I am passionate about helping adopt Clean Code, offering expert guidance for this transformative journey. Together, we can elevate embedded software development.
Contact me at urs.fassler@iqilio.ch to learn more about how I can support your team in adopting Clean Code practices.