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,bsscode 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, asserta 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_DEBUGENthe 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
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:
- Flash = text + data;
- RAM = data + bss.
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.
- Sections with address 0 in the
size -Aoutput are debug data and attributes. They do not go into Flash, and you must not include them in the comparison: otherwise a change that added only debug information would have "grown", even though the image is the same byte for byte. - One function under two names. In libgcc,
__aeabi_daddand__adddf3are the same code. If you count the names separately, the report doubles the growth. The script merges names with the same address and size into one line__adddf3 = __aeabi_dadd. - A long section name in the map file takes up the whole line, and the address,
size and file move to the next one. The parser remembers that it is waiting for a
continuation (
pend) and tells an output section (starting at column zero) from an input section (starting with a space).
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.
- Floating-point
printftripled the image: 7324 → 21996 bytes. The main contributors by symbol are_dtoa_r(3856 bytes) and_printf_float(1496); by file,lib_a-dtoa.o(4281),lib_a-mprec.o(3095),lib_a-nano-vfprintf_float.o(1906) and the softwaredoublearithmetic from libgcc. Section 74.4 already measured the cost ofprintf; what matters here is something else — the pipeline saw it before the merge. -O2cost 40 bytes on this code:uart_putcdisappeared (inlined into_write, which grew from 24 to 44 bytes),uart_putsgrew from 18 to 32. On larger firmware the difference is different — that is why it is measured, not guessed (optimization levels — 75.3).- The table without
constwent into.data: 66 bytes of RAM plus the same in Flash. The change passes on Flash, but the RAM threshold stopped it. Withconstthe table moved to.rodata, and RAM did not change.
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:
- the build image is the same as for the main build (section 110.3), otherwise the size difference is a difference between compilers;
- the full history (
GIT_DEPTH: "0"): with a shallow clonegit merge-basewill not find the common ancestor; - the report is saved even when the build is red (
when: always) — that is exactly when you need it; - two kinds of threshold: on growth (bytes and percent — they catch a single bad change) and on what is left (2 KB of Flash, 1 KB of RAM — they catch slow creep). With Flash almost full, a 100-byte change passes the growth check but is stopped by the remaining-space check;
- a threshold is a reason to talk, not a ban: a change with
printf("%.1f")may be justified, but the decision is made by a person looking at +14672 bytes, not by the linker half a year later.
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.
- newlib passes the file, the line, the function name and
the text of the condition to
__assert_func— each assertion has its own..rodatagrew from 408 to 2788 bytes. On top of that comefiprintf,_vfiprintf_r,abortand even_malloc_r: 12.5 KB for a hundred checks, a fifth of an F103C8. - File and line. The path is stored once per file: 63 bytes with the terminating
zero for the full path,
checks.cafter__FILE_NAME__. The main cost is not the string but the code at the call site: load the string address (with a literal next to the function), load the line number, call. That is why the full path, the shortened one and the bare name differ by only 52 bytes of image: there is one string per file, however many assertions it contains. - PC and LR: at the call site, two moves and a call, 8 bytes of instructions:
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}
- Call only is cheaper still: the handler takes its own return address. But more on this approach in 110а.7: the compiler merges such calls.
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
- Measure Flash = text + data and RAM = data + bss;
datacounts in both memories. - A 178-line script compares two builds by totals, sections, symbols and files from the map file and returns 0, 1 or 2.
- Four changes to a 7324-byte firmware:
printf("%.1f")+14672,-O2+40, the table withoutconst+68 bytes of RAM, withconst— 0. Two red, two green. - Comparing against the tip of the main branch instead of the common ancestor gave +20812 instead of +14672.
- 100 assertions on the F103 with
-Os: newlib +12496 bytes to the image,__FILE__+2312,__FILE_NAME__+2260, PC and LR +1772. - An assertion that fired survived a reset in QEMU on F4 and F1, and
addr2linenamed the assertion line and the call line:demo.c:39anddemo.c:56.
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.