Embedded HandbookApps
All sections › XII. Networks and DevOps for the engineer
STM32F103, STM32F407 · level intermediate · 28 min read
Open in the interactive handbook

110а. Firmware under the pipeline's eye: a size delta on every change and assert without strings in the release build

The pipeline from section 110 builds the image, runs the tests and stores the artifacts. But it leaves one question unanswered: how much bigger did this change make the firmware? On an F103 with 64 KB of Flash, the build usually breaks not because of one big change but because of a dozen small ones, each of them "only a kilobyte". By the time the linker finally prints region FLASH overflowed, the culprit is hard to find. The cure is to measure the size on every merge request and write the difference right into the request.

The second half of the section is about something that often bloats the image: assertions. The library assert drags the file name, the text of the condition and printf into the firmware. Here it is replaced with a macro that saves two addresses — where it fired and where it was called from — survives a reset and later turns into a file and a line on the computer.

Sources: our own comparison script size_diff.py in Python (in full below), the output of arm-none-eabi-size, arm-none-eabi-nm and the map file of the GNU ld linker; the DHCSR and AIRCR addresses come from the core_cm3.h header of CMSIS 5.9.0; .noinit and SYSRESETREQ are as in section 35в, BKPT without a debugger is 35а. Experiments run in the stm-arm:1 image: arm-none-eabi-gcc 12.2.1, newlib-nano, QEMU 7.2.22 (the netduinoplus2 machine for F4 and stm32vldiscovery for F1), addr2line 2.40. The idea of both parts comes from posts on the Interrupt blog (interrupt.memfault.com) about size deltas and assertions; the text, code and numbers here are our own.

Terms used in the documentation

Term Meaning
code size delta how many bytes the build grew by
text, data, bss code and constants; initial values of variables; zero-initialized variables
merge request, pull request a request to merge a branch
merge base the common ancestor of the branch and the main branch
map file a file showing where everything ended up after linking
size budget, threshold the allowed size, the limit
assertion, assert a check of an assumption at run time
__FILE__, __FILE_NAME__ the file path as passed to the compiler; the file name only (gcc 12+)
__builtin_return_address(0) the return address of the current function, i.e. LR on entry
DHCSR, C_DEBUGEN the core debug register; the "debugger has enabled debug" bit
cross-jumping merging identical code tails into one place

110а.1 What to count: text, data, bss and two memories

Top: a merge request: the common ancestor and the branch are built, size_diff.py compares them, writes a report and an exit code, on the right the results of four changes; bottom: an assertion that fired: two addresses in .noinit, a reset, after start-up addr2line names rb_push demo.c:39 and receive_frame demo.c:56

arm-none-eabi-size without options prints three numbers. text is code and constants; they live in Flash. data is variables with an initial value: they live in RAM, but the startup code copies their initial values from Flash, so data counts twice. bss is zero-initialized variables, RAM only; the linker script of this section also places the stack and heap reserve here. Hence the two sums you need to watch:

The test firmware is for the F103: USART1, the ADC, a moving average and a temperature line via printf("%ld") once a second. The baseline build (-Os, newlib-nano): text 7224, data 100, bss 1592 — Flash 7324 bytes, RAM 1692. Of those 1592 bytes of bss, 1536 are the stack and heap reserve.

One total is not enough. To see what grew, you need three views: sections (size -A), symbols (nm --size-sort -S) and files — from the map file, where every input section names its object file or library member. The per-file view is especially useful for libraries: they have dozens of symbols but only one file — libc_nano.a(lib_a-dtoa.o).


110а.2 A script that compares two builds

The script takes two ELF files (and two map files, if present), calls arm-none-eabi-size and arm-none-eabi-nm itself, computes the difference and prints a report in Markdown. The exit code is 0 if the thresholds are met, 1 if at least one is exceeded, 2 if something failed to run: the pipeline can tell "the build is too big" from "the script is broken". In full:

#!/usr/bin/env python3
"""Раздел 110а: сравнение размеров двух сборок прошивки для запроса слияния.

size_diff.py BASE.elf NEW.elf [--map-base B.map --map-new N.map] [пороги] [--report out.md]

Разбирает вывод arm-none-eabi-size (-B и -A), arm-none-eabi-nm --size-sort -S
и map-файл компоновщика; печатает отчёт в Markdown; код возврата 0 — пороги
соблюдены, 1 — превышен хотя бы один, 2 — ошибка запуска."""
import argparse
import os
import re
import subprocess
import sys


def tool(prefix, name, *args):
    return subprocess.run([prefix + name, *args], capture_output=True, text=True, check=True).stdout


def parse_berkeley(text):
    """`size -B`: text data bss dec hex имя — берётся последняя строка."""
    f = text.strip().splitlines()[-1].split()
    return {"text": int(f[0]), "data": int(f[1]), "bss": int(f[2])}


def parse_sections(text):
    """`size -A`: «имя размер адрес»; адрес 0 — секция не грузится (отладка, атрибуты)."""
    sec = {}
    for ln in text.splitlines():
        m = re.match(r"^(\.\S+)\s+(\d+)\s+(\d+)\s*$", ln)
        if m and int(m.group(3)) != 0:
            sec[m.group(1)] = int(m.group(2))
    return sec


def parse_symbols(text):
    """`nm --size-sort -S`: «адрес размер тип имя». Имена с одним адресом и размером
    (__aeabi_dadd и __adddf3) — один кусок кода: «имя = имя». Одноимённые static — имя#2."""
    by_addr = {}
    for ln in text.splitlines():
        p = ln.split()
        if len(p) == 4:
            by_addr.setdefault((p[0], p[1]), []).append(p[3])
    sym = {}
    for (addr, size), names in sorted(by_addr.items()):
        name = " = ".join(sorted(names))
        key, i = name, 2
        while key in sym:
            key, i = "%s#%d" % (name, i), i + 1
        sym[key] = int(size, 16)
    return sym


def parse_map(text):
    """Map-файл GNU ld: байты каждого входного файла (объектника или члена архива)
    в загружаемых выходных секциях; секции с адресом 0 — отладочные, не считаются."""
    body = text.split("Linker script and memory map", 1)[-1]
    per_file, out_ok, pend = {}, False, None
    for ln in body.splitlines():
        m = re.match(r"^(\.\S+)(?:\s+0x([0-9a-f]+)\s+0x[0-9a-f]+)?", ln)
        if m:                                      # выходная секция с нулевой колонки
            pend = None
            if m.group(2) is None:
                pend = "out"                       # длинное имя: адрес на следующей строке
            else:
                out_ok = int(m.group(2), 16) != 0
            continue
        m = re.match(r"^ (\.\S+|COMMON)(?:\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+(\S.*))?$", ln)
        if m:                                      # входная секция
            pend = "in" if m.group(2) is None else None
            if m.group(2) is not None and out_ok:
                f = os.path.basename(m.group(4).strip())
                per_file[f] = per_file.get(f, 0) + int(m.group(3), 16)
            continue
        m = re.match(r"^\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)(?:\s+(\S.*))?$", ln)
        if m and pend == "out":
            out_ok = int(m.group(1), 16) != 0
        elif m and pend == "in" and m.group(3) and out_ok:
            f = os.path.basename(m.group(3).strip())
            per_file[f] = per_file.get(f, 0) + int(m.group(2), 16)
        pend = None
    return {k: v for k, v in per_file.items() if v}


def totals(b):
    """Flash = text + data (начальные значения .data лежат во флеш-памяти), ОЗУ = data + bss."""
    return dict(b, flash=b["text"] + b["data"], ram=b["data"] + b["bss"])


def changed(a, b):
    """Изменившиеся ключи: (имя, было, стало), по убыванию модуля разницы."""
    rows = [(k, a.get(k, 0), b.get(k, 0)) for k in set(a) | set(b) if a.get(k, 0) != b.get(k, 0)]
    return sorted(rows, key=lambda r: (-abs(r[2] - r[1]), r[0]))


def verdict(old, new, a):
    """Нарушенные пороги — список строк; пустой список означает зелёную сборку."""
    bad = []
    grow = new["flash"] - old["flash"]
    if a.max_grow_bytes is not None and grow > a.max_grow_bytes:
        bad.append("Flash выросла на %d байт, порог %d" % (grow, a.max_grow_bytes))
    if a.max_grow_pct is not None and grow * 100.0 > a.max_grow_pct * old["flash"]:
        bad.append("Flash выросла на %s %%, порог %s %%" % (pct(grow, old["flash"]), num(a.max_grow_pct)))
    ram_grow = new["ram"] - old["ram"]
    if a.max_ram_grow_bytes is not None and ram_grow > a.max_ram_grow_bytes:
        bad.append("ОЗУ выросло на %d байт, порог %d" % (ram_grow, a.max_ram_grow_bytes))
    if a.flash_size and a.flash_size - new["flash"] < a.min_free_flash:
        bad.append("свободной Flash %d байт, нужно не меньше %d" % (a.flash_size - new["flash"], a.min_free_flash))
    if a.ram_size and a.ram_size - new["ram"] < a.min_free_ram:
        bad.append("свободного ОЗУ %d байт, нужно не меньше %d" % (a.ram_size - new["ram"], a.min_free_ram))
    return bad


def num(x):
    return ("%g" % x).replace(".", ",")


def pct(d, base):
    return ("%+.2f" % (d * 100.0 / base)).replace(".", ",") if base else "—"


def report(name_old, name_new, old, new, sec, sym, files, bad, top):
    out = ["### Размер прошивки: %s → %s" % (name_old, name_new), "",
           "| Область | Было | Стало | Разница |", "|---|---:|---:|---:|"]
    for k, title in (("text", "text"), ("data", "data"), ("bss", "bss"),
                     ("flash", "**Flash** (text+data)"), ("ram", "**ОЗУ** (data+bss)")):
        d = new[k] - old[k]
        out.append("| %s | %d | %d | %+d (%s %%) |" % (title, old[k], new[k], d, pct(d, old[k])))
    for title, rows in (("Секции", sec), ("Символы", sym[:top]), ("Файлы (по map)", files[:top])):
        if rows:
            out += ["", "**%s:**" % title, "", "| Имя | Было | Стало | Разница |", "|---|---:|---:|---:|"]
            out += ["| `%s` | %d | %d | %+d |" % (n, a, b, b - a) for n, a, b in rows]
    out += ["", "**Итог:** " + ("порог превышен — " + "; ".join(bad) if bad else "в пределах порогов"), ""]
    return "\n".join(out)


def main():
    ap = argparse.ArgumentParser(description="дельта размера двух сборок")
    ap.add_argument("base")
    ap.add_argument("new")
    ap.add_argument("--map-base")
    ap.add_argument("--map-new")
    ap.add_argument("--prefix", default="arm-none-eabi-")
    ap.add_argument("--top", type=int, default=8)
    ap.add_argument("--max-grow-bytes", type=int)
    ap.add_argument("--max-grow-pct", type=float)
    ap.add_argument("--max-ram-grow-bytes", type=int)
    ap.add_argument("--flash-size", type=int, default=0)
    ap.add_argument("--ram-size", type=int, default=0)
    ap.add_argument("--min-free-flash", type=int, default=0)
    ap.add_argument("--min-free-ram", type=int, default=0)
    ap.add_argument("--report")
    a = ap.parse_args()
    try:
        old = totals(parse_berkeley(tool(a.prefix, "size", "-B", a.base)))
        new = totals(parse_berkeley(tool(a.prefix, "size", "-B", a.new)))
        sec = changed(parse_sections(tool(a.prefix, "size", "-A", a.base)),
                      parse_sections(tool(a.prefix, "size", "-A", a.new)))
        sym = changed(parse_symbols(tool(a.prefix, "nm", "--size-sort", "-S", a.base)),
                      parse_symbols(tool(a.prefix, "nm", "--size-sort", "-S", a.new)))
        files = []
        if a.map_base and a.map_new:
            with open(a.map_base, encoding="utf-8") as fb, open(a.map_new, encoding="utf-8") as fn:
                files = changed(parse_map(fb.read()), parse_map(fn.read()))
    except (OSError, subprocess.CalledProcessError, ValueError, IndexError) as e:
        print("size_diff: %s" % e, file=sys.stderr)
        return 2
    bad = verdict(old, new, a)
    text = report(a.base, a.new, old, new, sec, sym, files, bad, a.top)
    print(text)
    if a.report:
        with open(a.report, "w", encoding="utf-8") as f:
            f.write(text)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())

Three places where it is easy to go wrong.


110а.3 Experiment: four changes to real firmware

Four changes are made on top of the baseline build — each as a separate merge request. The first is floating-point output with printf:

@@ -1,3 +1,3 @@
 # Настройки сборки: правки из запросов слияния меняют именно их.
 OPT = -Os
-LDFLAGS =
+LDFLAGS = -u _printf_float
@@ -28,8 +28,7 @@

 static void log_temp(int32_t t_dc)
 {
-    int32_t a = t_dc < 0 ? -t_dc : t_dc;
-    printf("t=%s%ld.%ld\n", t_dc < 0 ? "-" : "", (long)(a / 10), (long)(a % 10));
+    printf("t=%.1f\n", t_dc / 10.0f);
 }

 int main(void)

The second changes one line in config.mk: -O2 instead of -Os. The third replaces the linear sensor with a thermistor with a 33-point table — and forgets const. The fourth is the same table with const. The thresholds are the same for all: Flash may grow by no more than 1024 bytes and no more than 2 %, RAM by no more than 64 bytes, and 2048 bytes of Flash and 1024 bytes of RAM must remain free (F103C8: 64 KB and 20 KB).

Change text data Flash RAM Build
printf("%.1f") +14308 +364 +14672 (+200.33 %) +364 red
-O2 instead of -Os +40 0 +40 (+0.55 %) 0 green
table without const +60 +68 +128 (+1.75 %) +68 red
table with const +124 0 +124 (+1.69 %) 0 green

The report for the third change, as it appears in the request:

### Размер прошивки: base/build/fw.elf → lut/build/fw.elf

| Область | Было | Стало | Разница |
|---|---:|---:|---:|
| text | 7224 | 7284 | +60 (+0,83 %) |
| data | 100 | 168 | +68 (+68,00 %) |
| bss | 1592 | 1592 | +0 (+0,00 %) |
| **Flash** (text+data) | 7324 | 7452 | +128 (+1,75 %) |
| **ОЗУ** (data+bss) | 1692 | 1760 | +68 (+4,02 %) |

**Секции:**

| Имя | Было | Стало | Разница |
|---|---:|---:|---:|
| `.data` | 100 | 168 | +68 |
| `.text` | 6796 | 6856 | +60 |

**Символы:**

| Имя | Было | Стало | Разница |
|---|---:|---:|---:|
| `ntc_code` | 0 | 66 | +66 |
| `raw_to_dc` | 20 | 76 | +56 |

**Файлы (по map):**

| Имя | Было | Стало | Разница |
|---|---:|---:|---:|
| `filter.o` | 62 | 184 | +122 |

**Итог:** порог превышен — ОЗУ выросло на 68 байт, порог 64

What the views showed.


110а.4 In the pipeline: common ancestor, threshold, report

Compare against the common ancestor of the branch and the main branch, not against the tip of the main branch. An experiment: while the request with printf("%.1f") was waiting for review, a colleague's change was merged into the main branch that removed printf entirely (image 1184 bytes, −6140). Comparing the branch with the new tip showed growth of +20812 bytes instead of +14672 — someone else's savings were credited to the change. In the opposite case (something grew in the main branch) the change would look smaller than it is.

The job for a merge request (GitLab CI syntax; the build and comparison commands are the same as in the experiment):

# Раздел 110а: задание «размер» для запроса слияния (синтаксис GitLab CI).
# Образ сборки — с arm-none-eabi-gcc, make, python3 и git, закреплённый по хэшу (раздел 110.3).
size-delta:
  stage: test
  image: registry.example.com/fw-build@sha256:…
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    GIT_DEPTH: "0"                       # нужна история: иначе общего предка не найти
  script:
    - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - BASE=$(git merge-base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" HEAD)
    - git worktree add /tmp/base "$BASE"
    - make -s -C /tmp/base
    - make -s
    - python3 tools/size_diff.py /tmp/base/build/fw.elf build/fw.elf
        --map-base /tmp/base/build/fw.map --map-new build/fw.map
        --max-grow-bytes 1024 --max-grow-pct 2 --max-ram-grow-bytes 64
        --flash-size 65536 --ram-size 20480 --min-free-flash 2048 --min-free-ram 1024
        --report size.md
  artifacts:
    when: always                         # отчёт нужен и тогда, когда задание упало
    paths: [size.md, build/fw.elf, build/fw.map]

Rules worth carrying over to any service:

A ready-made tool for breaking down two ELF files by sections and symbols is bloaty (Apache-2.0, bloaty -d symbols new.elf -- old.elf); it was not run in this section.


110а.5 The cost of an assertion: strings, library, call site

The test file has 100 functions, each with an assertion with a different condition (p->len <= 1, p->type != 0x08 …). It is built for the F103 with -Os from the absolute path /builds/acme/sensor-node/firmware/src/drivers/sensors/checks.c (62 characters) — this is how CMake usually passes paths, and this is exactly the path that __FILE__ will expand to. Six ways to write ASSERT, plus a disabled one, as with NDEBUG:

Variant Object file, bytes Per assertion Image, text Added to the image
disabled 998 — 1596 —
assert from newlib 6584 55.86 14092 +12496
__FILE__ and line 3291 22.93 3908 +2312
the same with -fmacro-prefix-map 3257 22.59 3876 +2280
__FILE_NAME__ and line 3237 22.39 3856 +2260
PC and LR 2622 16.24 3368 +1772
call only 2222 12.24 2840 +1244

Where the bytes come from.

00000000 <check_000>:
   0:   ldrh    r0, [r0, #0]
   2:   mov r1, lr
   4:   cmp r0, #1
   6:   push    {r3, lr}
   8:   bls.n   10 <check_000+0x10>
   a:   mov r0, pc
   c:   bl  0 <assert_failed>
  10:   pop {r3, pc}

110а.6 Your own assert: two addresses in .noinit, a reset, addr2line

The macro puts the current PC into r0 (in Thumb, mov r0, pc reads the address of the instruction itself plus 4), LR on entry to the function into r1 — that is, the place it was called from — and jumps to the handler:

/* Раздел 110а: утверждение, которое оставляет в прошивке два адреса вместо строк. */
#ifndef ASSERT_PC_H
#define ASSERT_PC_H
#include <stdint.h>

#define ASSERT_MAGIC 0x54525341u                 /* байты A S R T в памяти */

typedef struct {
    uint32_t magic;
    uint32_t count;                              /* сработавших подряд с последней очистки */
    uint32_t pc;                                 /* где сработало утверждение */
    uint32_t lr;                                 /* куда вернулась бы функция с ним */
    uint32_t check;                              /* ~(magic ^ count ^ pc ^ lr) */
} assert_rec_t;

extern assert_rec_t assert_rec;
int assert_rec_valid(void);
__attribute__((noreturn)) void assert_failed(uint32_t pc, uint32_t lr);

#define ASSERT_STR2(x) #x
#define ASSERT_STR(x) ASSERT_STR2(x)

/* Номер строки в комментарии делает вставку у каждого утверждения своей:
 * иначе компилятор сливает утверждения одной функции в одно место вызова. */
#define ASSERT(c)                                                        \
    do {                                                                 \
        if (!(c)) {                                                      \
            uint32_t pc_;                                                \
            __asm volatile("mov %0, pc @ line " ASSERT_STR(__LINE__)     \
                           : "=r"(pc_));                                 \
            assert_failed(pc_, (uint32_t)__builtin_return_address(0));   \
        }                                                                \
    } while (0)

#endif

The handler writes 20 bytes to .noinit — an area at the end of RAM that the startup code does not touch (how to set it up in the linker script — 35в.2). If a debugger is attached (the C_DEBUGEN bit in DHCSR), the core halts on BKPT right on the spot. Without a debugger BKPT would turn into a HardFault (35а), so the bit is checked before the instruction, and then comes a software reset:

/* Раздел 110а: обработчик утверждения — запись в .noinit, затем остановка или сброс. */
#include "assert_pc.h"
#include "regs.h"

assert_rec_t assert_rec __attribute__((section(".noinit")));

static uint32_t rec_check(void)
{
    return ~(assert_rec.magic ^ assert_rec.count ^ assert_rec.pc ^ assert_rec.lr);
}

int assert_rec_valid(void)
{
    return assert_rec.magic == ASSERT_MAGIC && assert_rec.check == rec_check();
}

__attribute__((noreturn, noinline, cold)) void assert_failed(uint32_t pc, uint32_t lr)
{
    __asm volatile("cpsid i" ::: "memory");      /* прерывания больше не мешают */
    uint32_t n = assert_rec_valid() ? assert_rec.count + 1u : 1u;
    assert_rec.magic = ASSERT_MAGIC;
    assert_rec.count = n;
    assert_rec.pc = pc;
    assert_rec.lr = lr;
    assert_rec.check = rec_check();              /* последним: оборванная запись не пройдёт */
    if (SCB_DHCSR & DHCSR_C_DEBUGEN)             /* отладчик подключён — остановиться тут */
        __asm volatile("bkpt #0");
    __asm volatile("dsb" ::: "memory");
    SCB_AIRCR = AIRCR_RESET;                     /* без отладчика — перезапуск, ОЗУ цело */
    __asm volatile("dsb" ::: "memory");
    for (;;) {
    }
}

The experiment is an 8-byte ring buffer. The first run puts 12 bytes into it, the second reads from an empty one:

__attribute__((noinline)) void rb_push(rb_t *b, uint8_t x)
{
    ASSERT(b->count < RB_SIZE);                              /* УТВ-1 */
    b->data[(b->head + b->count) % RB_SIZE] = x;
    b->count++;
}

__attribute__((noinline)) uint8_t rb_pop(rb_t *b)
{
    ASSERT(b->count > 0u);                                   /* УТВ-2 */
    uint8_t x = b->data[b->head];
    b->head = (uint8_t)((b->head + 1u) % RB_SIZE);
    b->count--;
    return x;
}

__attribute__((noinline)) static void receive_frame(unsigned n)
{
    for (unsigned i = 0; i < n; i++)
        rb_push(&rx, (uint8_t)i);                            /* ВЫЗОВ-1 */
}

__attribute__((noinline)) static void drain(unsigned n)
{
    for (unsigned i = 0; i < n; i++)
        sink += rb_pop(&rx);                                 /* ВЫЗОВ-2 */
}

After start-up the firmware prints the record if it is intact. In QEMU (F4, netduinoplus2):

boot
no assert record
dhcsr=0x00000000
boot
ASSERT #1 pc=0x08000174 lr=0x08000199
dhcsr=0x00000000
boot
ASSERT #2 pc=0x080001b2 lr=0x080001d3
dhcsr=0x00000000
halt

DHCSR reads as zero in QEMU — there is no debugger, so the reset path is taken. On the computer the addresses are turned into locations using the same ELF. 4 is subtracted from PC (the mov rX, pc correction), 3 from LR: to clear the Thumb bit and land inside the call instruction rather than on the one after it:

decode #1 pc 0x08000174 at 0x08000170 -> rb_push at demo.c:39 (discriminator 1)
decode #1 lr 0x08000199 call 0x08000196 -> receive_frame at demo.c:56
decode #2 pc 0x080001b2 at 0x080001ae -> rb_pop at demo.c:46 (discriminator 1)
decode #2 lr 0x080001d3 call 0x080001d0 -> drain at demo.c:62

The first line of each pair is where it fired (demo.c:39 and demo.c:46 are the lines with ASSERT), the second is who called (demo.c:56 and demo.c:62). On F1 (stm32vldiscovery) the addresses are different — pc=0x08000178, lr=0x0800019d — but the addr2line answer is the same. For this, the ELF with debug data must be kept next to the released image (110.9); a build fingerprint (35в) helps check that the record belongs to exactly this ELF.


110а.7 Pitfalls

The report shows other people's changes. Comparing against the tip of the main branch: in the experiment +20812 bytes instead of the real +14672. Compare against git merge-base.

A table without const eats RAM twice. data lives both in Flash and in RAM: +68 bytes of RAM for a 66-byte table. If the table is static and nobody writes to it, gcc moves it to .rodata on its own (in the experiment the symbol became type r) — and the mistake stays hidden until someone removes static.

printf("%.1f") in newlib-nano silently prints nothing. Without -u _printf_float the floating-point output functions do not get into the image (there is no _printf_float in the symbol table), even though the image still grew to 9304 bytes of text. The option enables printing — and costs another 12 KB.

With -flto, _write disappears. During whole-program optimization the compiler cannot see the library and throws away a function that only the library calls: undefined reference to _write. __attribute__((used)) helps.

Assertions in one function merge into one. Three ASSERTs in a row in one function, the "call only" variant: with both -Os and -O2 a single call site remains, and any of the three assertions will report line 6. With mov r0, pc it is the same as long as the inline asm blocks are identical: one site, line 8. A line number in the asm comment (@ line 7) makes them different — three sites, lines 6, 7 and 8. The cost is the same 8 bytes of instructions, but gcc stops moving the branch with the assertion into a separate function: without the line number all 100 functions of the test file got .part.0 copies, and in them __builtin_return_address(0) no longer points to the caller but back into the function itself. With the line number there is not a single .part.0, and the object file grew from 2232 to 2622 bytes.

BKPT without a debugger. Not a halt but a HardFault. Check C_DEBUGEN first.

assert with a side effect. ASSERT(init() == 0) will not call init when assertions are disabled (74.9). This rule does not change just because assertions are left in the release build: someone may disable them tomorrow.

An assertion where a reset is more dangerous than the error. The handler restarts the device. If an actuator or a heater is on the outputs at that moment, go to a safe state first (129.7), and check external input data with ordinary error handling, not with an assertion (129.6).


110а.8 Summary

Full source code

app.c — the main loop of the firmware on which the four changes are measured.

/* Раздел 110а: главный цикл — раз в 10 мс отсчёт АЦП, раз в секунду строка в UART. */
#include <stdio.h>

#include "filter.h"
#include "regs.h"
#include "uart.h"

static filt_t filt;

static void adc_init(void)
{
    RCC_APB2ENR |= APB2_ADC1EN;
    ADC1_CR2 = ADC_CR2_ADON;
    ADC1_CR2 |= ADC_CR2_CAL;
    while (ADC1_CR2 & ADC_CR2_CAL) {
    }
    ADC1_SQR3 = 0;                          /* канал 0 — PA0 */
    ADC1_CR2 |= ADC_CR2_SWSTART_SEL | ADC_CR2_EXTTRIG;
}

static uint16_t adc_read(void)
{
    ADC1_CR2 |= ADC_CR2_SWSTART;
    while (!(ADC1_SR & ADC_SR_EOC)) {
    }
    return (uint16_t)ADC1_DR;
}

static void log_temp(int32_t t_dc)
{
    int32_t a = t_dc < 0 ? -t_dc : t_dc;
    printf("t=%s%ld.%ld\n", t_dc < 0 ? "-" : "", (long)(a / 10), (long)(a % 10));
}

int main(void)
{
    uart_init();
    adc_init();
    uart_puts("boot\n");
    SYST_RVR = 8000000u / 100u - 1u;        /* 10 мс от HSI 8 МГц */
    SYST_CSR = 5u;                          /* ENABLE, CLKSOURCE = ядро */
    unsigned n = 0;
    for (;;) {
        while (!(SYST_CSR & SYST_CSR_COUNTFLAG)) {
        }
        filt_push(&filt, adc_read());
        if (++n == 100u) {
            n = 0;
            log_temp(raw_to_dc(filt_avg(&filt)));
        }
    }
}

assert_cfg.h — five ways to write ASSERT for measuring the cost.

/* Раздел 110а: пять способов написать ASSERT — для замера цены. VARIANT задаётся ключом -D. */
#ifndef ASSERT_CFG_H
#define ASSERT_CFG_H
#include <stdint.h>

typedef struct {
    uint16_t len;
    uint8_t type, flags;
    uint32_t id;
} pkt_t;

#if VARIANT == 0                                 /* выключено, как с NDEBUG */
#define ASSERT(c) ((void)0)
#elif VARIANT == 1                               /* assert из newlib */
#include <assert.h>
#define ASSERT(c) assert(c)
#elif VARIANT == 2 || VARIANT == 3               /* файл и строка */
__attribute__((noreturn)) void assert_file_line(const char *file, int line);
#if VARIANT == 2
#define ASSERT(c) do { if (!(c)) assert_file_line(__FILE__, __LINE__); } while (0)
#else
#define ASSERT(c) do { if (!(c)) assert_file_line(__FILE_NAME__, __LINE__); } while (0)
#endif
#elif VARIANT == 4                               /* PC и LR */
#include "assert_pc.h"
#elif VARIANT == 5                               /* только вызов: адрес берёт обработчик */
__attribute__((noreturn)) void assert_here(void);
#define ASSERT(c) do { if (!(c)) assert_here(); } while (0)
#endif

#endif

measure.c — the firmware for the measurement: a hundred checks and the handlers for the variants.

/* Раздел 110а: прошивка для замера — вызывает все 100 проверок; обработчики вариантов 2, 3, 5. */
#include "assert_cfg.h"
#include "regs.h"

extern int (*const checks[100])(const pkt_t *);
volatile int acc;
const pkt_t probe = {12, 3, 1, 7};

#if VARIANT == 2 || VARIANT == 3 || VARIANT == 5
static struct {
    uintptr_t where;
    int line;
} last __attribute__((section(".noinit")));

static __attribute__((noreturn)) void reset(void)
{
    __asm volatile("dsb" ::: "memory");
    SCB_AIRCR = AIRCR_RESET;
    for (;;) {
    }
}
#endif

#if VARIANT == 2 || VARIANT == 3
void assert_file_line(const char *file, int line)
{
    last.where = (uintptr_t)file;
    last.line = line;
    reset();
}
#elif VARIANT == 5
__attribute__((noinline)) void assert_here(void)
{
    last.where = (uintptr_t)__builtin_return_address(0);
    reset();
}
#endif

int main(void)
{
    for (unsigned i = 0; i < 100u; i++)
        acc += checks[i](&probe);
    for (;;) {
    }
}

demo.c — the experiment with our own assert: ring overflow, reading from an empty ring, a record in .noinit.

/* Раздел 110а: опыт. Запуск 1 — переполнение кольцевого буфера, запуск 2 — чтение
 * из пустого, запуск 3 — только печать записи. Между запусками — сброс из assert_failed. */
#include "assert_pc.h"
#include "regs.h"

#define RB_SIZE 8u

typedef struct {
    uint8_t data[RB_SIZE];
    uint8_t head, count;
} rb_t;

static rb_t rx;
volatile uint32_t sink;

static void putc1(char c)
{
    while (!(USART1_SR & (1u << 7))) {
    }
    USART1_DR = (uint32_t)c;
}

static void puts1(const char *s)
{
    while (*s)
        putc1(*s++);
}

static void puthex(uint32_t v)
{
    static const char hex[] = "0123456789abcdef";
    puts1("0x");
    for (int i = 28; i >= 0; i -= 4)
        putc1(hex[(v >> i) & 0xFu]);
}

__attribute__((noinline)) void rb_push(rb_t *b, uint8_t x)
{
    ASSERT(b->count < RB_SIZE);                              /* УТВ-1 */
    b->data[(b->head + b->count) % RB_SIZE] = x;
    b->count++;
}

__attribute__((noinline)) uint8_t rb_pop(rb_t *b)
{
    ASSERT(b->count > 0u);                                   /* УТВ-2 */
    uint8_t x = b->data[b->head];
    b->head = (uint8_t)((b->head + 1u) % RB_SIZE);
    b->count--;
    return x;
}

__attribute__((noinline)) static void receive_frame(unsigned n)
{
    for (unsigned i = 0; i < n; i++)
        rb_push(&rx, (uint8_t)i);                            /* ВЫЗОВ-1 */
}

__attribute__((noinline)) static void drain(unsigned n)
{
    for (unsigned i = 0; i < n; i++)
        sink += rb_pop(&rx);                                 /* ВЫЗОВ-2 */
}

int main(void)
{
    RCC_APB2ENR |= USART1_EN;
    USART1_BRR = BRR_115200;
    USART1_CR1 = (1u << 13) | (1u << 3);                     /* UE, TE */

    puts1("boot\n");
    uint32_t n = 0;
    if (assert_rec_valid()) {
        n = assert_rec.count;
        puts1("ASSERT #");
        putc1((char)('0' + n));
        puts1(" pc=");
        puthex(assert_rec.pc);
        puts1(" lr=");
        puthex(assert_rec.lr);
        puts1("\n");
    } else {
        puts1("no assert record\n");
    }
    puts1("dhcsr=");
    puthex(SCB_DHCSR);
    puts1("\n");

    if (n == 0u)
        receive_frame(12);                                   /* в буфер влезает 8 */
    if (n == 1u)
        drain(3);                                            /* буфер после сброса пуст */
    puts1("halt\n");
    for (;;) {
    }
}

Next: section 110б "A board on the desk in the pipeline: pytest, the serial port and your own runner" — the same pipeline runs tests on the board: a pytest fixture, a port with a timeout and your own runner with the bench tag.

Open in the interactive handbook