Coding Step 4 - Design
Articles in this series:
- Coding Step 0 - Development Environments
- Coding Step 1 - Hello World and Makefiles
- Coding Step 2 - Source Control
- Coding Step 3 - High-Level Requirements
- Coding Step 4 - Design
The last article in this series discussed how to write functional high-level requirements: specifications for what your software is supposed to do. Software design is the other side of the coin. It encompasses all of the non-functional aspects of your code: the how your code fulfills its functional requirements. This encompasses things like:
- How your variables and functions are named
- How functionality is broken down between different files and functions in your code
- Your coding style: where braces go, whether you use tabs or spaces for indentation and how many of each
- What algorithms you use to generate CRCs, hashes or sort data
These sorts of concerns don't contribute to the correctness of the software you write, but following good design practices can further other goals such as making your code easier to work with, more robust and more efficient among other things. The below list isn't nearly a complete list of the goals that good design can further, but it's a good starting point for issues that deserve consideration for developing embedded systems.
As you read these you'll notice that multiple goals are furthered by similar actions: i.e., making your code more readable is a great goal all on its own, but it also assists you in reusing your code. This isn't a coincidence. Following good design practices has strong networking effects; the more good practices you put into effect, the more design goals you'll fulfill more easily.
If you ever want anyone (including yourself) to look at your code you should care about readability. Easily readable code makes its purpose clear and aids in understanding of the function of the program as a whole. If you really wanted to, you could write perfectly valid C code that was completely unreadable. The International Obfuscated C Code Contest is a competition in which people do just that. Here's a winning entry from 2014. It's surprising how far you can push the bounds of obfuscation and still have a program that does something useful.
Not everyone who writes unreadable code is trying to win a competition though: some people are trying to do the right thing but don't have the right guidance. There are simple rules that can help you write more readable code:
- Limit each line to one statement - People expect one line to do one thing and it's best not to disappoint them.
- Consistency is key - Surprises and exceptions make anything more difficult to read. Keep the same brace style, naming and whitespace conventions throughout your code.
- Limit the length of each line - Shorter lines are easier to read and organizing code into narrow columns allow you to easily show two files on one widescreen monitor
- Practice simplicity - Shorter names are more readable than longer ones. Simpler functions (shorter, fewer variables used, limited number of decisions and function calls, etc.) are easier to understand.
- Avoid tricky constructs - There are plenty of features you can use in any language that are not widely utilized, such as the comma operator in C. Using these may be clever, but difficult to understand for those not familiar with them.
- Write informative comments - Comments should always tell the reader why something is happening rather than what is happening. Compare: "Configuring direction register with value 0xFE" vs. "Setting Port A Pin 0 to input, all others on Port A to output."
Companies and other organizations write comprehensive documents outlining guidance on these and other 'nuts and bolts' aspects of writing code. These documents are called style guides or coding standards among other names. There are also automated utilities such as indent that can reformat source files automatically to enforce rules for whitespace, brace locations, etc. As of yet I haven't seen a program that can automatically enforce naming conventions, so if anyone knows of such a utility I'm interested in hearing about it. In the future I may write an article on coding style alone and/or publish a coding style guide.
Developing embedded systems is significantly easier and quicker if you have working implementations of programs and algorithms available to you. The more code you have on-hand to work from, the quicker you can adapt it to a new application or processor and the longer you keep code around, the more bugs you'll discover and fix in it. Code design that contribute to reusability include:
- Splitting code into logical functional blocks that perform only one action - In C, the function is the smallest unit of code that can be reused. Software functions should perform a single function to ensure that they can be cleanly reused.
- De-coupling your code from the rest of your program - The more a single function depends on the software system that surrounds it, the more difficult it will be to reuse in a different program. De-coupling your code from the surrounding system consists of things like minimizing the use of variables from outside the scope of the function (ie, global or file-local variables) and minimizing the number of function calls it makes.
- De-coupling your code from hardware - This idea follows on from the previous one, but is so important to embedded developers that I wanted to separate it. Hardware is ever-changing in the embedded world and you can't reuse application code that refers directly to the underlying hardware on a different system. Restrict the code that interacts with hardware to short, simple functions that can be sequestered away from the rest of the code. Keep your naming conventions consistent for functions that access hardware so you can easily tell the difference.
- Readability - You'll never reuse code if you don't understand what it's for and how it's doing it. Making your functions readable (as discussed above) supports their reuse.
Code isn't correct unless it's been tested and shown to be correct, but not all code is straightforward to test. The design choices you make when writing code affect how easy it is to verify the operation of that code via test.
Rules for enhancing testability of a given piece of code are generally focused on reducing the complexity :
- Minimize the number of inputs to a function - Fewer inputs means less complexity and thus, easieri to test code.
- Minimize the number of outputs from a function - Multiple outputs is a sign that your function is doing too many unrelated things at once. You code will be simpler and easier to test if independent functionality is implemented in independent functions.
- Minimize the number of function calls - Function calls have to be verified and this usually means generating test stubs. This can be simple or difficult depending on the organization of your code, so reducing the number of functions called reduces the work you have to do in testing.
- Minimize the number and complexity of control structures (ie, if statements, for loops, etc.) - Less complex code requires less and more straightforward testing.
You'll notice that many of the same rules for maximizing reusabilty also enhance testability. This is because highly decoupled functions which perform only one action are inherently easier to test than the alternative. This is an example of the networking effects of good design.
You'll probably spend the majority of your time developing embedded systems doing debugging and integration. It's a rule that no matter how intelligently you write the code and how much you plan ahead, once you burn the software to your microcontroller, all bets are off. Debugging code on a real system is all about verifying your assumptions: Is the clock set correctly? What about the baud rate of my UART? Is my software even running? Did it burn my new code or an old backup file? Design for debugging is all about adding features that let you quickly verify these sorts of assumptions. This can include adding features such as:
- Interface stimulation - If you use a UART for example, you can transmit a known byte sequence at startup to verify that your baud rate is correct, you're attached to the right pins, etc. Some systems will implement special loopback modes where the transmit pin of the microcontroller can be attached to the receive pin to test the interface that way. Other interfaces can be verified similarly.
- Heartbeat LEDs - A heartbeat LED is a a blinking LED implemented entirely via polling within your main loop. Thus, if you see the LED blinking at the rate you set it to, you can be sure just by looking at your board that your software is running, your clock settings are probably correct, and nothing is blocking its execution significantly.
- Version information - I will usually maintain a one-byte version ID in my code and transmit it over serial at startup so I can be certain that the software was successfully programmed on my microcontroller. You never know when a build or program will silently fail and leave you with an old build on your microcontroller and thwart your debugging efforts.
- Signal pins - I'll often reserve several DIO pins to toggle on and of at various points during execution to signal timing or entry/exit from various routines or program state. You can see much more precise timing from DIO pins compared to serial messaging and it's much lower overhead as well. You get bonus points for bringing these pins out to test points on your board.
Performance and efficiency
Embedded systems are, as a rule, resource limited but the demands of business are rarely limited. Given the fixed costs of parts, the temptation is to force as much functionality into any given processor as it can handle. There's nothing wrong with this - in fact it can be an exciting challenge to seamlessly add yet another feature into an already busy embedded system. But I've had enough real-world experience to know that it's difficult to add features or fix bugs on a system that's severely resource-limited. Things just don't quite work right when you're continually brushing up against hard limits on timing or memory.
The key to enhancing performance and efficiency is choosing appropriate algorithms and data structures for the problem you are trying to solve. It's hard to pin down general rules that will help nearly anyone working with embedded systems, but some general rules are:
- Use the smallest variable type you can to store data - This goes especially for large arrays. You can save significant amounts of memory by choosing the right variable types.
- Choose data structures that align with the type of work to be performed - The key is to minimize the overhead (memory and processing) associated with working with your fundamental data unit. UART data, for example, is all bytes, so storing it in an array is preferable to storing each byte in a linked list and incurring the extra overhead associated with the list.
- Perform costly operations in bulk - Don't copy one byte at a time if you can just as easily copy 10 for the same amount of overhead.
- Use loop unrolling - When you don't have large amounts of data consider loop unrolling to reduce overhead
- Use ROM - If possible, store constant data in program memory to save RAM for data that needs to be accessed quickly or modified during operation
When I talk about robustness I'm referring to the ability of your program to handle invalid or unexpected inputs and program states. The key to ensuring robustness in a program is to document all of the assumptions you write your code against, verify them in your code where appropriate and have a strategy to handle a failure in assumptions. For example, one typical assumption that embedded developers will make is that data received over a UART is valid. This assumption can be verified by examining the state of the hardware error flags for the UART after reception: if they indicate no error, then use the data. Otherwise, ignore it or raise an error. Exercising this process repeatedly during development will make it easier to identify assumptions and familiarize yourself with strategies to handle invalid inputs and program states. Here are more general guidelines for enhancing robustness:
- Check returned status - Never assume that any given function call or operation executed correctly. Always monitor and act on the returned status. This such a fundamental lesson that I'll offer advice for non-embedded systems as well: if you have exceptions then catch and handle them. Always catch specific exceptions, not general ones. Whatever you do don't just wrap main() in a try/catch block and call it a day.
- Handle default cases - Always handle the default case in every switch statement and have a strategy for fixing the unexpected condition that caused the default case.
- Monitor data validity - Know the valid data ranges for all of your variables and have a strategy if they stray outside those ranges (ie, saturate to min/max, mark data as invalid and ignore, extrapolate, etc.)
- Never use any unbounded loops - always have a deterministic limit on loops to prevent them from becoming infinite in the case of invalid or corrupt inputs.
- Use a watchdog timer - A watchdog timer will reset your microcontroller after a certain amount of time if it isn't reset periodically by your main loop. In this way, an infinite loop will be broken by a processor reset and give you the chance to resume normal operation.
For the purposes of this article the goal of 'security' is to prevent access to unauthorized information and ensure that unauthorized users don't gain control of the system. Security for small embedded systems is a growing concern. Traditionally, this wasn't the case for the type of embedded systems that I work with - usually because there are few or no user inputs and no connectivity with outside systems (i.e., the internet). This is changing with the advent of the Internet of Things: even small, dumb microcontrollers will soon be connected directly to the internet and programmers such as myself will need to be aware of methods and strategies for securing the embedded systems we create. I'm certainly no expert on security but even I can identify some basic steps to improve security:
- Work to ensure the robustness of your program - many attacks take advantage of code which poorly handles invalid input data
- Static analysis - Use static analysis tools such as Splint or Valgrind to ensure you're being consistent and correct in your use of memory and pointers
- Memory Management Unit - If your processor offers it, use an MMU to prevent unauthorized access to sensitive memory
- Secure cryptography - If you're using cryptographic functions, do your research to ensure that the functions you pick are appropriately secure for your application
- Use 'safe' versions of functions - Most built-in functions which present security complications (strcpy for example) have more robust implementations which aid security by preventing buffer overflows among and other undesirable situations. strncpy vs. strcpy is one such example.
Ideally we'd be able to fulfill all of the above objectives completely at the same time with one design, but in the real world we need to set priorities and make compromises. The objectives that are important to me for the design of Embedded Hello World are (in order):
- Readability - My primary goal for this code is is for it to serve as an example for others to learn from, so readability should be the main concern.
- Reusability - Having a working program that transmits data over a serial port is a great starting point for virtually any embedded application, so I want to structure this code so it's easy to adapt and reuse.
- Debugging - Making embedded systems is difficult and frustrating, so if I can demonstrate basic debugging strategies to ease the development process it will very likely pay off multiple times over the lifetime of the code.
- Testability - Another goal I have for this series of articles is to advocate for software test as a fundamental aspect of coding, so designing for testability is a priority.
- Robustness - I always aim to write deterministic software that doesn't crash at the drop of a hat.
- Performance/Efficiency - This application doesn't handle large amounts of data or have strict timing requirements, so performance and efficiency isn't much of a concern.
- Security - This isn't really a concern in this application for the simple reason that there's no sensitive data and no outside interfaces to manipulate. Thus, security is the least concern.
Design of Embedded Hello World
Now that you have an overview of the benefits of good design and some rules of thumb for how it can be achieved I'll discuss the design I've chosen for Embedded Hello World and discuss the design goals that it supports.
I've chosen an ATMega328P as the target processor for this application. You may recognize it as the microcontroller at the heart of the Arduino Uno. The Arduino is a very popular development platform, so it's likely that anyone reading this article has access to one or can get access to one very quickly. Apart from the Arduino, the AVR line of microcontrollers (of which the ATMega328P is a part) are cheap, easy to use and have excellent free development tools. The processor itself has a wide variety of peripherals and good performance which makes it an excellent microcontroller for beginners. It's simple ot use an Arduino as the development platform or to quickly create a development platform on a breadboard.
Coding style is the main driver behind readability of code. There are many aspects to readability that I won't cover, but some good starting decisions :
- Indent/brace style - I prefer the K&R indent style.
- Code line length - Lines of code will be limited to 79 characters or less. The rationale for this is that old-style terminals display 80 characters per line, so this number comes in just below that. It also makes it easier for the code to be printed (if you hate trees).
- Naming conventions - This dictates how files, functions and variables are named. I prefer short, descriptive names using a camelCase naming convention.
I have my ATMega328P set up on a breadboard with jumper wires attaching all of the power and peripherals. Serial connectivity is supplied via a 5V FTDI USB/Serial Cable. This cable also supplies power to the processor as well. The UART will be configured at 38400 baud with an 8-N-1 frame configuration, full duplex (not that we're receiving anything - yet).
The microcontroller will be configured to have an 8MHz instruction clock. There's no real reason to choose an 8MHz clock over the default 1MHz other than that I like extra speed (performance). The flip-side of such a high clock speed is increased power consumption, but that isn't a concern in this application.
The microcontroller is programmed via an AVR Dragon In-System Programmer (ISP) because I have one lying around. The Dragon is supported by Atmel Studio (Atmel's official development environment) and AVRDude (an open-source AVR programming utility) You can use any ISP that supports the ATMega328P - Sparkfun has a nice one.
The design includes a watchdog timer with a timeout set to 15ms. This will ensure that the main loop is executed at least once every 15ms or the processor will reset itself. This ensures robustness in the software by allowing it to recover from a situation such as a hardware failure serial transmission or excessive blocking by a task within the software.
The system will have a heartbeat LED to assist with debugging. It will flash at 1Hz when the main loop is executing unhindered. The LED itself is supplied by a custom breakout board I created.
Software Executive - ehwMain.c
A software executive controls the execution of the software and coordinates all of its functions. The executive for this application will use a superloop architecture: essentially, the software will execute each task within an infinite while loop. The loop isn't strictly infinite: the design allows it to be broken if any error which prevents operation is detected. For the purposes of this application, a serial transmit error or failure to properly configure the hardware would cause such a break. When the loop is broken out of, a smaller infinite loop will flash the heartbeat LED at an accelerated rate to signify an error. This loop can only be broken by a hard reset to the processor.
ehwMain.c will contain two functions: init and main. The main function is the entry point for the program and will call init prior to entering the main loop. init will return a status signifying whether the hardware configuration was successful. This status is checked by the main loop. init will also transmit a one-byte version identifier that is specified at compile-time, and then transmit its inverse (bitwise NOT). This serves to aid in debugging by identifying the software running and acting as a simple self-test of the serial hardware.
The main loop will transmit the 'Hello World!' and then idle. It will use a simple state machine to keep track of whether 'Hello World!' has been transmitted. State machines are a useful and easily extendable design for embedded systems such as this. Outside of the state machine, the main loop will maintain the heartbeat LED by polling a timer, service the UART and maintain the watchdog timer to prevent it from resetting the microcontroller.
Although it doesn't save a significant amount of RAM, the design also includes storing the string "Hello World!" in program memory (flash) instead of RAM. It may not make the program significantly more efficient, but it will serve as practice for applying the rules of good design to a practical situation.
All hardware configuration data will be saved in a file called hwConfig.h This includes constants which configure hardware registers as well as documentation of the fuse bits on the ATMega328P (which configure clocks, brown out reset behavior, etc.) This file will also contain prototypes for functions which interface with hardware.
UART/Serial Functionality - hwUart.c
This file will contain all functionality related to configuring the UART as well as transmitting and receiving data. The main design decision with regards to the UART is the use of circular buffers for transmit and receive. There are several reasons for using a circular buffer for serial data transmission and reception:
- Circular buffers help to decouple the application from hardware by providing a software interface to transmit data rather than directly accessing the hardware registers in the application
- Buffering data for transmission allows you to prevent excessive blocking in the application and provides for more deterministic (and robust) behavior overall.
- Buffering data for reception allows you to create higher-level protocols (i.e., data packets rather than just serial streams) on top of the serial hardware layer. This furthers reusability and extensibility of the code.
- Circular buffers are great to write low-level requirements and unit-tests for (which will be the topic of a future article).
UART functionality is provided by the following functions:
- uint8_t hwUartInit(void) - Initializes the UART for the given baud rate, message format, etc. This function will take no parameters - instead it will populate the hardware registers which configure the UART with #define values specified in hwConfig.h. This design was chosen to minimize the number of parameters that would need to be passed to the function. The function returns an 8-bit unsigned value which contains the result of the configuration (i.e., a pass/fail status or an error code).
- uint8_t hwUartTx(uint8_t const * data, const uint8_t len) - This function queues the specified number of bytes (len) from the passed array (data) for transmission over the UART. It returns an 8-bit unsigned value signifying the result of the operation. Data is copied from data to a buffer for transmission.
- uint8_t hwUartRx(uint8_t * data, const uint8_t len) - This function returns the specified number of bytes (len) from the UART receive buffer into the passed array (data). It returns the result of the operation as an 8-bit unsigned value.
- uint8_t hwUartIdle(void) - This function handles maintenance of the UART hardware and is meant to be called in the main loop. This function will check for received data and place it in the UART receive buffer, transmit any data in the UART transmit buffer and monitor the hardware status for any errors. It will return an 8-bit unsigned value denoting the state of the UART (normal, faulted, etc.).
Discrete I/O - hwDio.c
This file contains routines which configure and control discrete I/O on the microcontroller. This functionality is used to implement the heartbeat LED. It contains one function:
- uint8_t dioInit(void) - Initializes the discrete I/O on the microcontroller by configuring the registers controlling the DIO with the values from hwConfig.h
- void hbToggle(void) - Toggles the state of the Heartbeat LED DIO pin. Implementing this function in hwDio.c decouples the ehwMain.c file from hardware.
Misc. Functionality - boolean.h
This file contains macros which ease working with bits and boolean values. It will be mainly used for This includes definitions for TRUE and FALSE as well as the following macros:
- TOGGLE(x,y) - Toggle bit y in variable/register x
- SET(x,y) - Set (to '1') bit y in variable/register x
- CLEAR(x,y) - Clear (to '0') bit y in variable/register x
- READ(x,y) - Return TRUE if bit y in variable/register x is '1', 0 otherwise
This may seem like a lot of text for what ought to be a simple embedded software program, but the reality is that this article barely scratches the surface of software design as a topic. Being aware of good design practices and implementing them can significantly improve the quality of your software and the ease of the development and integration process. Implementing good design practices is what separates exceptional programmers who can deliver significant value (and demand significant compensation) from 'okay' programmers who get the job done and nothing more. Applying these lessons can be difficult, but the payoff is well worth the effort.
To post reply to a comment, click on the 'reply' button attached to each comment. To post a new comment (not a reply to a comment) check out the 'Write a Comment' tab at the top of the comments.
Please login (on the right) if you already have an account on this platform.
Otherwise, please use this form to register (free) an join one of the largest online community for Electrical/Embedded/DSP/FPGA/ML engineers: