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

73. Measuring time and performance

"Faster" and "slower" without a measurement are an opinion. On a microcontroller time is not an idle question: an interrupt handler must finish before the next interrupt, a control loop must fit into its period, a protocol response must arrive before the other side decides the link is lost.

This section covers what to measure time with on the chip and on a computer, how to convert cycles into time — and above all, how not to fool yourself with a measurement. One of the examples is a "speed-up" that in fact turned out to be a broken wait loop.

Terms used in the documentation

Term Meaning
execution time, latency how long code runs, the delay before a response
cycle count, CPI the number of cycles, cycles per instruction
cycle counter (DWT CYCCNT) the core register that counts cycles
timestamp a time mark for an event
resolution, overhead the smallest step, the cost of the measurement itself
wall-clock time, CPU time real elapsed time, time the processor spent
monotonic clock a clock that never jumps backwards
benchmark a reference measurement
warm-up runs made before measuring
logic analyzer an instrument that records digital lines
profiling finding where the time goes

73.1 What to measure with on the chip

Tool Resolution Where available When to use
a pin and an oscilloscope or logic analyzer nanoseconds everywhere time "from outside", without affecting the code
cycle counter DWT->CYCCNT one cycle Cortex-M3, M4, M7, M33 precise measurement of a code section; on some M7 chips access to the block is first unlocked by writing 0xC5ACCE55 to DWT->LAR
SysTick millisecond any Cortex-M long intervals, timeouts
hardware timer timer tick everywhere intervals between events, capture
marks in the debug channel cycle M3 and up an event log with time

A pin and an oscilloscope is the most honest method. Before a code section you raise a pin, after it you lower it, and the instrument shows the pulse width. Two writes to BSRR cost almost nothing, and the instrument does not depend on what happens in the program. A logic analyzer also shows the interface lines alongside — you can see how much time passes between bytes.

The cycle counter is a 32-bit counter built into the core that increments on every cycle. How to enable and use it is covered in section 35.3. It is important to know that Cortex-M0 and M0+ do not have it at all: their core headers have neither a DWT block nor CYCCNT, and code that reads DWT->CYCCNT simply does not build with the F072 header. On F0 you measure time with a timer or a pin.

SysTick gives milliseconds (section 7.4), a hardware timer gives everything in between, and capture mode measures intervals between external events without the processor (section 13.6). Marks in the debug channel are a log with time that barely slows the program down (section 21).


73.2 Cycles and time

The conversion is simple: time is cycles divided by frequency.

Core frequency One cycle The cycle counter overflows after
72 MHz (F103) 13.9 ns 59.7 s
168 MHz (F407) 5.95 ns 25.6 s

Overflow does not get in the way of measuring short sections: the difference end − start in unsigned arithmetic is correct across zero as well (section 7.4). It only gets in the way of measuring longer than these seconds.

The SysTick counter is 24 bits wide: at the full frequency with no prescaler it goes through all 2²⁴ values in 233 ms at 72 MHz and in 99.9 ms at 168 MHz. That is why it is used not as a stopwatch but as the source of an interrupt once a millisecond — and the milliseconds are counted in software.

The same number of cycles is not the same time, and vice versa. At a high frequency Flash cannot keep up with the core and inserts wait states (section 6.4): the same code at 72 MHz takes more cycles than at 24. So you record a measurement result together with the frequency and the Flash settings.


73.3 The cost of a call: HAL versus the register

A popular claim: "a library call is slow, write to the registers". Let us check it on the pin write function from the F4 library — HAL_GPIO_WritePin, built from its source with optimization:

HAL_GPIO_WritePin:
    cmp     r2, #0
    it      eq
    lsleq   r1, r1, #16        ; сброс — сдвинуть маску в старшую половину
    str     r1, [r0, #24]      ; запись в BSRR
    bx      lr

Five instructions. The call HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET) adds five more: four put the three arguments into registers (the port address takes two) and one branches to the function. The direct write GPIOA->BSRR = GPIO_PIN_5 is four instructions plus a return.

HAL_GPIO_WritePin versus a write to BSRR: armclang -O2 listings and instruction counts

In total that is ten instructions against five — twice as many by instruction count. But an instruction is not a cycle: a branch and a return on Cortex-M3 and M4 cost from two to four cycles (the pipeline refills), and after a branch Flash may add wait states (73.2). In time the ratio comes out at about two, but only the cycle counter on the board gives the exact number (73.1). Not five times and not ten times — that is clear even without it. The difference shows in a loop that toggles the pin millions of times, and is invisible wherever something else happens between the writes.

The picture changes if you build without optimization or enable the library's parameter checking (USE_FULL_ASSERT): then every call grows checks and overhead. The conclusion is simple: a "library versus registers" comparison depends on the library, the optimization level and the build settings — you measure it, you do not take it on faith.


73.4 A "speed-up" that turned out to be a bug

Sometimes code rewritten "directly with registers" starts running noticeably faster. Before you celebrate, check that it works correctly. Here is a typical way to write a wait for a flag:

while (!SPI1->SR & SPI_SR_TXE) { }     /* ждём, пока освободится передатчик */

The negation ! binds tighter than the bitwise & (section 65.3), so the condition reads as (!SPI1->SR) & SPI_SR_TXE. !SR is 0 or 1, and the flag mask is not the least significant bit, so the result is always zero. The compiler does exactly that: after reading the register, it returns the constant 0 — the wait loop never waits. The correct form is while (!(SPI1->SR & SPI_SR_TXE)).

The code became "faster" because it stopped waiting. Bytes are now written to the transmitter without waiting for it to become free, and some of them are lost or corrupted — but on an oscilloscope this is not obvious at once.

The good news: the compiler warns about such code without any flags — "logical not is only applied to the left hand side of this bitwise operator". The rule from this example is broader: a suspiciously good measurement result is a reason to check correctness first.

while (!SR & TXE) never waits: "!" binds tighter than "&", and the condition is always 0


73.5 Measuring on a computer

Function What it measures
clock() the program's CPU time; CLOCKS_PER_SEC = 1000000
clock_gettime(CLOCK_MONOTONIC, …) wall-clock time, resolution 1 ns
time() seconds — not suitable for measurements

These are the Linux values. On Windows with the Microsoft library clock() counts elapsed time rather than CPU time, CLOCKS_PER_SEC equals 1000, and there is no clock_gettime — QueryPerformanceCounter is used instead.

struct timespec a, b;
clock_gettime(CLOCK_MONOTONIC, &a);
work();
clock_gettime(CLOCK_MONOTONIC, &b);
double ms = (b.tv_sec - a.tv_sec) * 1e3 + (b.tv_nsec - a.tv_nsec) * 1e-6;

A monotonic clock does not jump when the system time is changed — that is why it is the one used for measurements, not the real-time clock.

CPU time from clock() does not grow while the program waits for input or sleeps — this is useful when you need to separate computation from waiting.


73.6 Method: how not to fool yourself

The optimizer throws away work whose result is not needed. A loop of a hundred million steps that sums numbers into a variable nobody reads afterwards "ran" in almost zero milliseconds on a computer: the compiler simply removed it (section 63.5). The same loop writing to a volatile took noticeable time. The measured work is either used — the result is printed — or stored in a volatile variable.

Measurements do not repeat. Five identical measurements in a row on a computer give five different numbers: other programs, caches and the processor frequency interfere. Repeat the measurement many times and take the smallest value — it is closest to the code's own cost. On the chip the spread is smaller, but interrupts introduce it: a short section is measured either with interrupts disabled or many times.

To check deadlines on the chip you need the opposite — the largest value: a handler and a control loop must fit even in the worst case, while the minimum hides rare long runs — an interrupt in the middle of the section, a Flash accelerator miss, a long branch. The maximum is accumulated right in the program, if (dt > dt_max) dt_max = dt;, and looked at after a long run (section 76.9).

The measurement itself costs time. Reading the counter takes a few cycles; for very short sections you subtract the cost of an "empty" measurement.

Printing inside the measured section ruins everything. One character at 115200 baud takes 86.8 µs, a 20-character line takes 1.74 ms, that is 125000 cycles at 72 MHz. That is longer than almost any section you would want to measure. Store the timestamp in a variable and print afterwards.

Compare under the same conditions: the same frequency, the same optimization level, the same data. A measurement without these three labels is useless.


73.7 Measurement as an argument

A measurement is good when it answers a specific question. An example from section 66.8: a recursive fib(30) and an iterative one. The difference is visible even without a clock (2692537 calls against 30 steps), but a measurement confirms it quantitatively — the recursive version is thousands of times slower. The exact number varies from run to run, but the order of magnitude stays.

Such a measurement almost always shows that what matters is the algorithm, not "library versus registers": choosing a different way to compute gives thousands of times, rewriting one function gives percents. How to evaluate algorithms without measuring is covered in section 76, how to speed up code in section 75.


73.8 Pitfalls

After a rewrite the code "got five times faster". Check that it works: for example, the wait loop !SR & FLAG never waits.

The measurement shows zero. The optimizer threw away work whose result is not used.

The measurement results jump around. Interrupts, other programs and caches interfere; repeat and take the smallest, and for deadline checks the largest.

A measurement with printing shows milliseconds. The time went into printing, not into the code.

DWT->CYCCNT does not compile on F0. Cortex-M0 has no cycle counter; measure with a timer or a pin.

The cycle counter "jumped" over zero. For short measurements this is harmless — the subtraction is unsigned; for long ones — more than a minute at 72 MHz — you need another tool.

SysTick as a stopwatch does not reach a second. 24 bits at full frequency are less than a quarter of a second; count milliseconds with an interrupt.

The same code at a different frequency took a different number of cycles. Flash wait states.

A library function is "many times slower". Check the optimization level and USE_FULL_ASSERT.


73.9 Summary

Next: section 74 "C libraries".

Open in the interactive handbook