Quote
"If you work with a great programmer, you will find he is very familiar with his tools, like a painter with his brushes." -- Bill Gates
1. Don't Treat the Compiler as Just a Tool
Programmer work in embedded development is tightly coupled with hardware. C is used to read and write low-level registers, access data, and control hardware. The compiler bridges C and hardware; some hardware-specific operations not covered by the C standard are provided by the compiler.
Assembly can directly read/write specific RAM addresses, place code at specific flash addresses, and control precise variable placement in RAM. After understanding the compiler in depth, many of these tasks can also be achieved in C.
The C standard contains numerous undefined behaviors that are resolved by the compiler implementation. Understanding how your compiler handles those cases is necessary.
Embedded compilers often include debug-oriented features and tools to analyze code performance and inspect peripheral components. Knowing these features improves debugging efficiency.
Stack layout, code optimization, and data type ranges are further reasons to study the compiler rather than treating it as a black box.
If you thought the compiler only needs to produce binaries, it is time to revise that view.
2. Do Not Rely on the Compiler for Semantic Checks
Compiler semantic checks are limited and can even hide errors. Modern compilers are large projects; to keep design manageable, most compilers perform relatively weak semantic checks. For execution speed, C provides little or no runtime checking: array bounds, pointer validity, and overflow are not checked. This can produce programs that compile but behave unexpectedly at runtime.
C is flexible. For example, for an array test[30], C permits expressions like test[-1] to access memory before the first element; it allows casting an integer to a function pointer and calling it, e.g. ((void(*)())0)();. C gives programmers freedom and places responsibility for safe use on them.
2.1 Apparent Freezes
These two examples are infinite loops. If similar code appears in a rarely used branch, it can cause seemingly unexplained freezes or reboots.
unsigned char i; // example 1
for(i=0; i<256; i++)
{
// other code
}
unsigned char i; // example 2
for(i=10; i>=0; i--)
{
// other code
}
Unsigned char has a range of 0..255, so i is always less than 256 (first loop never ends) and always greater than or equal to 0 (second loop never ends). Note that C allows an assignment like i = 256 even though 256 is outside the representable range for i. C provides latitude that can easily lead to programmer errors.
2.2 Subtle Logic Changes
An accidental semicolon after an if can completely change program logic. The compiler may not warn. For example:
if(a > b); // stray semicolon
a = b; // this assignment always executes
Compilers also ignore extra whitespace and newlines. Consider the following example:
The intent was to return when n < 3, but a missing semicolon after return causes the compiler to interpret it as returning the value of the expression logrec.data = x[0]. Since C permits an expression after return without a trailing semicolon in this malformed code, the assignment may not execute in the intended places, introducing subtle bugs.
2.3 Hard-to-Find Array Out-of-Bounds
Arrays are a common source of instability. Programmers often inadvertently write out-of-bounds accesses.
A colleague had code where, after running for some time, a digit on the LCD display would change unexpectedly. The bug was traced to this code:
int SensorData[30];
// other code
for(i=30; i>0; i--)
{
SensorData[i] = ...;
// other code
}
The array was declared with 30 elements, but the loop used SensorData[30], a non-existent element. C permits this and will write to whatever memory location corresponds to that index. In this case the overwritten location held the LCD display variable, causing the observed display corruption.
Some compilers may warn "assignment exceeds array bounds", but not all programmers heed warnings, and compilers cannot detect every out-of-bounds situation. For example, if a module A defines:
int SensorData[30];
and module B references it as:
extern int SensorData[];
the compiler cannot warn because the size is not known at the point of reference. When an array has external linkage, its size should be declared explicitly.
Another case the compiler cannot detect is when an array is passed as a function parameter. Inside the function the array decays to a pointer, so the compiler does not know the element count.
The statement that initializes SensorData[30] produces no compiler warning because the array name is treated as a pointer to the first element inside the function body.
Array and pointer semantics can be a source of confusion; note that an array name is treated as a pointer only in function parameter contexts. Otherwise, array names and pointers are distinct.
Array overruns can also occur during interrupt-driven reception of communication frames. A receive interrupt may copy incoming bytes into a buffer until a complete frame is detected. If noise corrupts frame length or interrupts are not handled robustly, the received data can exceed the buffer and overwrite adjacent variables. Because interrupts are asynchronous, compilers cannot detect these overruns at compile time.
A local buffer overrun may trigger an ARM architecture hardware exception.
For example, a colleague's device that receives wireless sensor data began to hang after a software update. Debugging showed the ARM7 processor entered a hardware exception; the exception handler contained a dead loop. The receiver hardware placed a full packet into its internal buffer and signaled the CPU via external interrupt to fetch the packet. The simplified interrupt handler looked like this:
__irq ExintHandler(void)
{
unsigned char DataBuf[50];
GetData(DataBuf); // fetch a frame from hardware buffer
// other code
}
Because multiple sensors might transmit nearly simultaneously and GetData lacked sufficient protection, DataBuf overflowed while receiving. DataBuf was on the stack along with the interrupted context and return address. The overflow corrupted the return address; when returning from the interrupt the PC might become invalid, causing a hardware exception and the apparent crash.
Corruption of local variables can cause unpredictable behavior or system failure.
3. Code That Appears Meaningful May Be Incorrect
The C standard explicitly marks certain behaviors as undefined; writing code that relies on undefined behavior means the result is decided by the compiler. The C standards committee leaves behaviors undefined for reasons such as:
- to simplify the standard and give implementations flexibility, for example avoiding runtime checks that are hard to diagnose;
- to allow compiler vendors to extend the language via undefined behavior.
Undefined behavior makes C efficient and flexible and eases compiler implementation, but it can hinder writing robust embedded code. Many constructs that appear meaningful in C are undefined and can hide bugs or complicate porting between compilers. Languages like Java avoid undefined behavior through runtime checks and safer semantics, at the cost of size and performance. As embedded developers, we should understand undefined behavior and use C's flexibility to write code that is both safe and efficient.
3.1 Common Undefined Behaviors
Using increment/decrement operators multiple times on the same variable within a single expression, or using them once while the variable appears multiple times, is undefined. For example:
r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
Different compilers may generate different assembly: some may evaluate i++ before multiplication and addition, others may perform arithmetic first and then the increments. Even a single i++ combined with multiple uses of i is undefined, e.g.:
a[i] = i++; /* undefined behavior */
Whether i++ is applied before or after the assignment is implementation-defined.
3.2 How to Avoid Undefined Behavior in C
Introducing undefined behavior creates latent risks. Eliminating all undefined behavior is difficult, but the likelihood can be reduced by:
- learning and understanding common undefined behaviors in C;
- using tooling such as static analyzers, compiler warnings, and runtime sanitizers;
- adopting and following coding standards that avoid risky constructs.