Embedded HandbookApps
All sections › IX. The C language: from basics to advanced
STM32F103, STM32F407, STM32G431 · level basic · 10 min read
Open in the interactive handbook

75. Code optimization

Optimization turns a working program into a better working program: faster, smaller or more economical. The key word is "working". Code that is not yet debugged is too early to optimize, and code that already meets its timing has no need for it.

On a microcontroller "better" more often means "smaller": Flash and RAM run out before time does. That is why this section starts with the compiler's optimization levels — and with the unexpected result that the "fast" level gives larger code than no optimization at all. Then comes what the compiler does by itself, how to help it, and why all of this pales next to the choice of algorithm.

Terms used in the documentation

Term Meaning
bottleneck, hot spot the part of the code where the time goes
profiler, flat profile a tool that measures where time goes; a per-function time table
optimization level how hard the compiler optimizes (-O0…-Oz)
loop unrolling repeating the loop body to do fewer iterations
strength reduction replacing an expensive operation with a cheap one
inlining putting a function's body in place of its call
aliasing, restrict two pointers to the same memory; a promise that this does not happen
lookup table a table of precomputed values
code size, footprint how much memory the code takes
premature optimization optimizing before you know it is needed

75.1 When to optimize

The order is always the same:

  1. The program works and is tested. Optimizing code that does not work means speeding up a bug.
  2. A measurement showed that it does not fit — in time, in Flash or in RAM (section 73).
  3. The bottleneck has been found. Almost always the time goes into a small part of the code, and guessing which part is useless — you measure it.
  4. Algorithm first, everything else after it (75.7).
  5. After every change, measure again and check correctness again.

If the code already keeps up, optimization is wasted time and more complicated code that someone will have to read later.


75.2 The profiler

A profiler answers the question of where the time goes. On a PC this is, for example, gprof: you build the program with the -pg option, run it, and gprof shows how much time each function got.

  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
100.00      0.02     0.02        1    20.00    20.00  hot
  0.00      0.02     0.00        1     0.00     0.00  cold

The function hot got all the time, cold got nothing. It is hot that is worth optimizing, however many lines cold has.

On a chip the role of the profiler is played by a cycle counter over code regions and the RTOS per-task load statistics (section 35.3). The approach is the same: first understand where the time goes, then change something.


75.3 Compiler optimization levels

One and the same file — summing an array, copying, CRC-8, bubble sort — built with armclang at six levels:

Level Code, bytes Instructions in the sum function
-O0 352 26
-O1 188 10
-O2 780 44
-O3 784 44
-Os 174 10
-Oz 122 8

Code size at optimization levels from -O0 to -Oz

The main finding: -O2 gave larger code than -O0, and 6.4 times larger than -Oz. It unrolls loops — instead of one addition per step it does four, to test the condition and branch less often. On a PC with a cache and gigabytes this pays off; on a chip with 64 KB of Flash it does not always.

What to choose:

Task Level
debugging -O0; GCC also has -Og — optimizations that do not get in the debugger's way. At -O1 some variables are already invisible (section 35)
firmware where size matters most -Os or -Oz
firmware where the speed of hot spots matters most -O2, after checking that it fits

And test at every level that will be used in production: a bug that appears only with optimization is almost always a missing volatile (section 3.4) or undefined behavior.


75.4 What the compiler does by itself

The compiler applies many "manual" optimization tricks without you:

Expression What it became on Cortex-M3
x * 8 a left shift by 3 — one instruction
x / 8, unsigned a right shift — one instruction
x % 8, unsigned a mask & 7 — one instruction
x / 8, signed three instructions
x % 8, signed four instructions
a / b, variables the sdiv instruction
the same on Cortex-M0 a call to the library __aeabi_idiv

Conclusions:

More from what has already appeared in this handbook: a zeroing loop becomes a call to the library memclr (section 68.2), tail recursion becomes a loop (section 66.9), a short static inline function becomes inline code (section 71.11).


75.5 How to help the compiler

restrict is a promise that the pointers do not point to the same place:

int f(int *p, int *q)                   { *p = 1; *q = 2; return *p; }
int g(int *restrict p, int *restrict q) { *p = 1; *q = 2; return *p; }

In the first function the compiler must re-read *p from memory: what if q points to the same place, and writing the two changed *p. In the second it immediately returns one — there is no read. For buffer-processing functions, where the destination and the source are known to be different, this is a noticeable saving. But the promise must be true: if the pointers do coincide after all, the result is unpredictable.

A type the size of a register. The advice "use the smallest type", correct for 8-bit microcontrollers, does not work on ARM. The registers are 32-bit, and incrementing a uint8_t is an addition plus an extra uxtb instruction to cut the result down to a byte; for a uint32_t it is a single addition. In a loop the compiler often hides this difference by keeping the value in 32 bits, but for local variables and counters the natural choice on Cortex-M is uint32_t or int. Small types are needed where there are many of them: in arrays and structures, where they save RAM.

static on file-level functions lets the compiler inline a function or drop it if it is not needed. const on tables moves them to Flash (section 68.3). volatile only where it is needed: every access to a volatile the compiler must perform literally.


75.6 inline and the linking trap

inline int foo(void) { return 2; }
int main(void) { return foo(); }

With optimization this program builds: the call is inlined. Without optimization the linker stops — L6218E: Undefined symbol foo. By the rules of C, the word inline without static gives only an inline definition and does not create an ordinary function body to call. When the compiler without optimization decides not to inline, there is nothing to call.

The right way is static inline: it builds at any level. This is exactly how short functions in headers are written (section 71.11).


75.7 The algorithm matters most

The task: in an array every number occurs twice, and only one occurs once. Find it.

Head-on — for each element walk the whole array and look for its pair:

for (int i = 0; i < n; i++) {
    int dup = 0;
    for (int j = 0; j < n; j++)
        if (i != j && a[i] == a[j]) dup = 1;
    if (!dup) same = a[i];
}

With exclusive OR — one line: pairs cancel each other (x ^ x = 0), and the unpaired one remains.

for (int i = 0; i < n; i++) same ^= a[i];

For an array of 2001 elements: 4004001 comparisons versus 2001 operations — 2001 times less work. A measurement confirmed it: the second variant is faster by three orders of magnitude — roughly by the same factor as the reduction in operations. No optimization level and no rewriting of the first variant in registers will give that.

The second example is strlen in the loop condition:

for (size_t i = 0; i < strlen(s); i++) ...    /* strlen на каждом шаге */

Every check of the condition walks the whole string, and a loop over a string of 20000 characters takes about four hundred million steps instead of twenty thousand. The measurement: hundreds of times slower. The length is computed once before the loop.

The familiar techniques come from the same idea: iteration instead of double recursion (section 66.8), a table instead of computation — on a chip a const table spends no RAM at all — and walking a two-dimensional array by rows (section 68.7). How to evaluate algorithms in advance, without measuring, is in section 76.

The algorithm matters more than optimization: 4004001 comparisons versus 2001 operations


75.8 Optimizing for memory

On a chip this is often more important than speed:


75.9 The cost of optimization

Optimized code is usually less clear. Therefore:

And finally: writing several statements on one line and removing spaces does not make a program faster. The compiler does not care.


75.10 Pitfalls

The firmware grew after turning on the "fast" optimization. -O2 unrolls loops; for size you need -Os or -Oz.

A function was sped up, but the program did not get faster. The function was not the bottleneck — profile first.

The code breaks only with optimization. A volatile is missing or there is undefined behavior.

x >> 3 instead of x / 8 "did not speed anything up". The compiler already makes a shift — for unsigned values.

Division in a loop on the F0 is slow. Cortex-M0 has no divide instruction.

Switched to uint8_t "for speed" and it got slower. On ARM small types need truncation to a byte.

With restrict the function returns garbage. The pointers did coincide after all: the promise was false.

Does not build without optimization: undefined symbol. inline without static.

A loop over a string is slow. strlen in the loop condition.

Optimized code was "fixed" back. There was no comment explaining why it is written that way.


75.11 Summary

Next: section 75а "A profiler on the chip itself: sampling the program counter by timer and through the DWT" — where the program spends its time, without markers in the code.

Open in the interactive handbook