Many “hardware problems” on a microcontroller are actually C bugs that corrupt memory, break timing, or access registers incorrectly. The board resets, a motor twitches, UART output becomes garbage, or an ADC reading changes—and the hardware gets blamed because the symptom appears on a pin.
The dangerous part is that undefined or implementation-dependent C behavior can survive bench testing and fail only after optimization, a different compiler version, a larger input, or a longer run time.
1. Writing past the end of an array
uint8_t rx[16];
for (size_t i = 0; i <= 16; ++i) {
rx[i] = read_byte();
}
The valid indices are 0 through 15. Writing rx[16] modifies some other object in memory. On a microcontroller that “other object” may be a state flag, stack data, pointer, task control structure or peripheral configuration value.
The SEI CERT C standard explicitly warns against out-of-bounds pointer and array operations because C defines many of them as undefined behavior.
2. Buffer length and buffer pointer disagree
A pointer does not carry its own size. APIs should make capacity explicit:
bool read_packet(uint8_t *buffer, size_t capacity);
If the caller supplies a 32-byte buffer but the callee assumes 64 bytes, memory corruption follows even though both functions individually compile without warnings.
This is why pointer and array ownership rules matter in embedded APIs.
3. Signed integer overflow changes program behavior
Unsigned integer arithmetic wraps modulo 2N for its width. Signed overflow is different: in standard C it is undefined behavior.
int32_t position = INT32_MAX;
position += 1; /* signed overflow: undefined behavior */
An optimizer may assume signed overflow never happens and transform surrounding logic accordingly. A test that appears to rely on wraparound can therefore fail in an optimized build.
When wraparound is intentional, use an unsigned type and document the arithmetic. When it is not intentional, check ranges before the operation.
4. Signed/unsigned comparisons create impossible-looking branches
int count = -1;
size_t limit = 10;
if (count < limit) {
/* may not behave the way a beginner expects */
}
Usual arithmetic conversions can convert the negative signed value to an unsigned type before comparison. The result can make a negative number appear extremely large.
Compiler warnings such as -Wall -Wextra -Wconversion can expose many of these mistakes early. Do not silence them mechanically.
5. Shifting by an invalid amount
uint32_t mask = 1u << bit;
This is only valid when bit is in range for the promoted left operand. Shifting by the width of the type or more is undefined. Left shifting signed values can introduce additional problems.
This matters in register code because bit numbers often come from macros, protocol fields or configuration tables.
6. Using an uninitialized automatic variable
uint16_t timeout;
if (ready) {
timeout = 100;
}
start_timer(timeout);
If ready is false, timeout has an indeterminate value. The resulting delay or register write may vary between builds because stack contents are not guaranteed to start at zero.
Initialize variables deliberately and enable compiler diagnostics for maybe-uninitialized paths.
7. Returning a pointer to a local object
uint8_t *get_frame(void)
{
uint8_t frame[32];
return frame;
}
The local array stops existing when the function returns. The pointer becomes invalid. It may appear to work until another function reuses the same stack area.
Use caller-owned buffers, static storage when appropriate, or a clearly defined allocator/lifetime strategy.
8. Stack overflow becomes random peripheral behavior
Small microcontrollers often have limited RAM. Deep call chains, large local arrays, recursion, printf formatting and RTOS task stacks can consume more stack than expected.
Once the stack collides with other memory, symptoms are rarely polite. Return addresses, local variables, interrupt context and RTOS metadata can be corrupted.
Measure stack high-water marks where the RTOS supports them, inspect linker map files, and avoid large automatic buffers in deeply nested code. Our guide to writing Embedded C with lower RAM use covers this in more detail.
9. volatile is used as if it were a lock
volatile tells the compiler that accesses to an object are observable; it does not automatically make compound operations atomic and it is not a general synchronization primitive.
volatile uint32_t counter;
/* main */
counter++;
/* ISR */
counter++;
Each increment is a read-modify-write sequence. If execution contexts overlap, an update can be lost even though the variable is volatile.
Use the synchronization or atomic mechanism appropriate to the MCU and RTOS. See our detailed guide to volatile, const, static and extern in Embedded C.
10. Register access width is wrong
Peripheral registers have defined access widths and semantics. Treating a 32-bit register as an arbitrary byte array can create invalid accesses or unexpected side effects.
Use the MCU vendor’s CMSIS/device headers or official register definitions where possible. Respect read-only, write-only, write-one-to-clear and reserved-bit rules from the reference manual.
11. Read-modify-write breaks write-one-to-clear registers
Some status registers clear a bit when software writes a 1. Code like this can be wrong:
STATUS |= ERROR_BIT;
The compiler may read the entire register, OR the bit, then write every previously-set status bit back as 1—clearing more flags than intended.
When a register uses write-one-to-clear semantics, follow the vendor’s specified write pattern rather than treating it like ordinary RAM.
12. Bit fields are used as a portable register layout
C bit-field allocation order and several layout details are implementation-defined. A struct of bit fields may look like a convenient representation of hardware registers or wire protocols, but portability depends on compiler and ABI behavior.
Vendor-provided definitions can be valid for the supported toolchain. Hand-written “universal” register bit-field structs are risky.
13. Arbitrary pointer casts violate aliasing rules
Reinterpreting an object through an incompatible pointer type can invoke undefined behavior. GCC enables strict-aliasing optimizations at common optimization levels such as -O2, -O3 and -Os.
This is one reason firmware can work at -O0 and fail when optimized. Do not “fix” it by disabling optimization before understanding the invalid access.
14. ISR code calls functions that are not safe in interrupt context
An interrupt handler should not casually call blocking functions, take normal task mutexes, allocate memory, or perform slow formatted I/O. The exact restrictions depend on the MCU and RTOS.
Keep ISR work bounded and move longer processing to the main loop, deferred handler, queue or task using the platform’s documented ISR-safe APIs.
15. Timing depends on an empty software loop
for (volatile uint32_t i = 0; i < 10000; ++i) {
}
This is not a reliable time base. Clock frequency, compiler behavior, flash wait states and optimization can change execution time. Use a hardware timer, SysTick, RTOS delay or cycle counter when timing matters.
Why optimization exposes these bugs
The optimizer is allowed to assume your program follows the C language rules. If code contains undefined behavior, transformations that are valid for correct C can make the failure look completely unrelated to the source mistake.
Therefore “it only fails with optimization” is evidence to investigate undefined behavior, aliasing, races, timing assumptions and missing volatile/atomic semantics—not evidence that optimization itself is defective.
A practical defensive build configuration
During development, enable aggressive warnings and treat new warnings as defects to investigate. For GCC-family toolchains, a useful starting point is:
-Wall -Wextra -Wshadow -Wconversion -Wsign-conversion
Not every warning must be enabled in every legacy project, but suppressions should be narrow and justified.
Where the target and toolchain allow it, use host-based unit tests and sanitizers for portable logic. AddressSanitizer and UndefinedBehaviorSanitizer are usually not deployed on tiny production MCUs, but they can catch defects when the same modules are tested on a desktop build.
Debugging sequence when “hardware behaves randomly”
- Capture the reset cause and fault registers.
- Check compiler warnings.
- Inspect stack usage and linker map.
- Audit every buffer length and array index near the fault.
- Look for ISR/main shared variables.
- Check pointer lifetime and ownership.
- Verify register semantics against the MCU reference manual.
- Compare behavior at different optimization levels without treating
-O0as the fix. - Add assertions around impossible states and bounds.
- Reproduce with the smallest input that still fails.
Primary references
- ISO/IEC JTC1/SC22/WG14: C standards and public working drafts
- SEI CERT C: out-of-bounds pointers and array subscripts
- GCC optimization documentation, including strict aliasing
Bottom line
On a microcontroller, a C bug often becomes a physical symptom because software directly controls memory-mapped hardware, timing and real-world outputs. Treat bounds, integer rules, lifetime, stack use, register semantics and concurrency as hardware-reliability issues—not just style problems.