110а. Прошивка под присмотром конвейера: дельта размера на каждую правку и assert без строк в рабочей сборке
Конвейер из раздела 110 собирает образ, гоняет тесты и складывает
артефакты. Но один вопрос он оставляет без ответа: насколько эта правка
сделала прошивку больше. На F103 с 64 КБ флеш-памяти сборка обычно ломается
не от одной большой правки, а от десятка маленьких, каждая из которых
«всего на килобайт». Когда компоновщик наконец пишет region FLASH
overflowed, найти виноватого уже трудно. Лекарство — мерить размер на
каждом запросе слияния и писать разницу прямо в запрос.
Вторая половина раздела — про то, что часто и раздувает образ:
утверждения. Библиотечный assert тащит в прошивку имя файла, текст
условия и printf. Здесь он заменён макросом, который сохраняет два
адреса — где сработало и откуда вызвали, — переживает сброс и потом
превращается в файл и строку на компьютере.
Опоры: свой скрипт сравнения size_diff.py на Python (полностью ниже),
вывод arm-none-eabi-size, arm-none-eabi-nm и map-файл компоновщика
GNU ld; адреса DHCSR и AIRCR — заголовок core_cm3.h из CMSIS 5.9.0;
.noinit и SYSRESETREQ — как в разделе 35в, BKPT без отладчика — 35а.
Опыты — в образе stm-arm:1: arm-none-eabi-gcc 12.2.1, newlib-nano,
QEMU 7.2.22 (машины netduinoplus2 для F4 и stm32vldiscovery для F1),
addr2line 2.40. Идея обеих частей — заметки в блоге Interrupt
(interrupt.memfault.com) о дельте размера и об утверждениях; текст, код и
числа здесь свои.
Как это называется в документации
Оригинал Перевод code size delta дельта размера: на сколько байт выросла сборка text,data,bssкод и константы; начальные значения переменных; обнуляемые переменные merge request, pull request запрос слияния merge base общий предок ветки и основной ветки map file map-файл: где что лежит после компоновки size budget, threshold бюджет размера, порог assertion, assertутверждение: проверка предположения во время работы __FILE__,__FILE_NAME__путь к файлу, как его передали компилятору; только имя файла (gcc 12+) __builtin_return_address(0)адрес возврата из текущей функции, то есть LR на входе DHCSR,C_DEBUGENрегистр отладки ядра; бит «отладчик включил отладку» cross-jumping слияние одинаковых хвостов кода в одно место
110а.1 Что считать: text, data, bss и две памяти
arm-none-eabi-size без ключей печатает три числа. text — код и
константы, они лежат во флеш-памяти. data — переменные с начальным
значением: в ОЗУ они живут, но их начальные значения стартовый код копирует
из флеш-памяти, поэтому data считается дважды. bss — обнуляемые
переменные, только ОЗУ; сюда же скрипт компоновщика этого раздела кладёт
резерв стека и кучи. Отсюда две суммы, которые и надо сторожить:
- Flash = text + data;
- ОЗУ = data + bss.
Опытная прошивка — F103: USART1, АЦП, скользящее среднее и строка
температуры через printf("%ld") раз в секунду. Исходная сборка (-Os,
newlib-nano): text 7 224, data 100, bss 1 592 — Flash 7 324 байта,
ОЗУ 1 692. Из этих 1 592 байт bss 1 536 — резерв стека и кучи.
Одного итога мало. Чтобы понять, что выросло, нужны три среза:
секции (size -A), символы (nm --size-sort -S) и файлы — по map-файлу,
где у каждой входной секции указан объектник или член библиотеки. Срез по
файлам особенно полезен для библиотек: символов в них десятки, а файл
один — libc_nano.a(lib_a-dtoa.o).
110а.2 Скрипт сравнения двух сборок
Скрипт берёт два ELF (и, если есть, два map-файла), сам вызывает
arm-none-eabi-size и arm-none-eabi-nm, считает разницу и печатает
отчёт в Markdown. Код возврата — 0, если пороги соблюдены, 1 — если хоть
один превышен, 2 — если что-то не запустилось: конвейер отличит «сборка
слишком большая» от «скрипт сломался». Целиком:
#!/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())
Три места, где легко ошибиться.
- Секции с адресом 0 в выводе
size -A— отладочные данные и атрибуты. Во флеш-память они не попадают, и в сравнение их брать нельзя: иначе правка, добавившая только отладочную информацию, «выросла» бы, хотя образ байт в байт тот же. - Одна функция под двумя именами. В libgcc
__aeabi_daddи__adddf3— один и тот же код. Если считать имена отдельно, отчёт удвоит рост. Скрипт склеивает имена с общим адресом и размером в строку__adddf3 = __aeabi_dadd. - Длинное имя секции в map-файле занимает строку целиком, а адрес,
размер и файл переезжают на следующую. Разбор помнит, что ждёт
продолжения (
pend), и отличает выходную секцию (с нулевой колонки) от входной (с пробелом).
110а.3 Опыт: четыре правки на настоящей прошивке
От исходной сборки сделаны четыре правки — каждая как отдельный запрос
слияния. Первая — вывод вещественным 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)
Вторая меняет одну строку config.mk: -O2 вместо -Os. Третья заменяет
линейный датчик на терморезистор с таблицей из 33 точек — и забывает
const. Четвёртая — та же таблица с const. Пороги для всех одни:
Flash не больше чем на 1 024 байта и не больше чем на 2 %, ОЗУ не больше
чем на 64 байта, свободными должны остаться 2 048 байт Flash и 1 024 байта
ОЗУ (F103C8: 64 КБ и 20 КБ).
| Правка | text | data | Flash | ОЗУ | Сборка |
|---|---|---|---|---|---|
printf("%.1f") |
+14 308 | +364 | +14 672 (+200,33 %) | +364 | красная |
-O2 вместо -Os |
+40 | 0 | +40 (+0,55 %) | 0 | зелёная |
таблица без const |
+60 | +68 | +128 (+1,75 %) | +68 | красная |
таблица с const |
+124 | 0 | +124 (+1,69 %) | 0 | зелёная |
Отчёт для третьей правки — так, как он попадает в запрос:
### Размер прошивки: 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
Что показали срезы.
printfс плавающей точкой втрое увеличил образ: 7 324 → 21 996 байт. Главные вкладчики по символам —_dtoa_r(3 856 байт) и_printf_float(1 496), по файлам —lib_a-dtoa.o(4 281),lib_a-mprec.o(3 095),lib_a-nano-vfprintf_float.o(1 906) и программная арифметикаdoubleиз libgcc. Ценуprintfраздел 74.4 уже мерил; здесь важно другое — конвейер увидел её до слияния.-O2на этом коде обошёлся в 40 байт:uart_putcисчез (встроен в_write, который вырос с 24 до 44 байт),uart_putsвырос с 18 до 32. На больших прошивках разница другая — потому её и мерят, а не угадывают (уровни оптимизации — 75.3).- Таблица без
constлегла в.data: 66 байт в ОЗУ плюс столько же во флеш-памяти. По Flash правка проходит, порог ОЗУ её остановил. Сconstтаблица ушла в.rodata, ОЗУ не изменилось.
110а.4 В конвейере: общий предок, порог, отчёт
Сравнивать надо с общим предком ветки и основной ветки, а не с
вершиной основной ветки. Опыт: пока запрос с printf("%.1f") висел на
проверке, в основную ветку влили правку коллеги, который убрал printf
совсем (образ 1 184 байта, −6 140). Сравнение ветки с новой вершиной
показало рост +20 812 байт вместо +14 672 — правке приписана
чужая экономия. В обратном случае (в основной ветке что-то выросло) правка
выглядела бы меньше, чем есть.
Задание для запроса слияния (синтаксис GitLab CI; команды сборки и сравнения — те же, что в опыте):
# Раздел 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]
Правила, которые стоит перенести в любой сервис:
- образ сборки тот же, что у основной сборки (раздел 110.3), иначе разница размеров — это разница компиляторов;
- история целиком (
GIT_DEPTH: "0"): при неглубоком клонеgit merge-baseобщего предка не найдёт; - отчёт сохраняется и при красной сборке (
when: always) — именно тогда он и нужен; - два вида порогов: на рост (байты и проценты — ловят одну неудачную правку) и на остаток (2 КБ Flash, 1 КБ ОЗУ — ловят медленное наползание). У почти полной Flash правка на 100 байт проходит по росту, но останавливается по остатку;
- порог — это повод поговорить, а не запрет: правка с
printf("%.1f")может быть оправдана, но решение принимает человек, глядя на +14 672 байта, а не компоновщик через полгода.
Готовый инструмент для разбора двух ELF по секциям и символам — bloaty
(Apache-2.0, bloaty -d symbols new.elf -- old.elf); в разделе он не
запускался.
110а.5 Цена утверждения: строки, библиотека, место вызова
Тестовый файл — 100 функций, в каждой по утверждению с разными
условиями (p->len <= 1, p->type != 0x08 …). Собран для F103 с -Os
по абсолютному пути /builds/acme/sensor-node/firmware/src/drivers/sensors/checks.c
(62 знака) — так обычно передаёт пути CMake, и именно этот путь подставит
__FILE__. Шесть способов написать ASSERT плюс выключенный, как
с NDEBUG:
| Вариант | Объектник, байт | На одно утверждение | Образ, text | Прибавка к образу |
|---|---|---|---|---|
| выключено | 998 | — | 1 596 | — |
assert из newlib |
6 584 | 55,86 | 14 092 | +12 496 |
__FILE__ и строка |
3 291 | 22,93 | 3 908 | +2 312 |
то же с -fmacro-prefix-map |
3 257 | 22,59 | 3 876 | +2 280 |
__FILE_NAME__ и строка |
3 237 | 22,39 | 3 856 | +2 260 |
| PC и LR | 2 622 | 16,24 | 3 368 | +1 772 |
| только вызов | 2 222 | 12,24 | 2 840 | +1 244 |
Откуда берутся байты.
- newlib передаёт в
__assert_funcфайл, строку, имя функции и текст условия — у каждого утверждения свой..rodataвыросла с 408 до 2 788 байт. Сверху приезжаютfiprintf,_vfiprintf_r,abortи даже_malloc_r: 12,5 КБ на сотню проверок, пятая часть F103C8. - Файл и строка. Путь хранится один раз на файл: 63 байта с нулём
в конце у полного пути,
checks.cпосле__FILE_NAME__. Основная цена — не строка, а код в месте вызова: загрузить адрес строки (с литералом рядом с функцией), загрузить номер строки, вызвать. Поэтому полный путь, укороченный и одно имя различаются всего на 52 байта образа: строка на файл одна, сколько бы в нём ни было утверждений. - PC и LR: в месте вызова две пересылки и вызов, 8 байт команд:
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а.7: компилятор сливает такие вызовы.
110а.6 Свой assert: два адреса в .noinit, сброс, addr2line
Макрос кладёт в r0 текущий PC (команда mov r0, pc в Thumb читает
адрес самой команды плюс 4), в r1 — LR на входе в функцию, то есть
место, откуда её вызвали, и уходит в обработчик:
/* Раздел 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
Обработчик пишет 20 байт в .noinit — область в конце ОЗУ, которую
стартовый код не трогает (как устроить её в скрипте компоновщика — 35в.2).
Если подключён отладчик (бит C_DEBUGEN в DHCSR), ядро останавливается
на BKPT прямо на месте. Без отладчика BKPT превратился бы в HardFault
(35а), поэтому бит проверяется до команды, а дальше — программный
сброс:
/* Раздел 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 (;;) {
}
}
Опыт — кольцевой буфер на 8 байт. Первый запуск кладёт в него 12 байт, второй читает из пустого:
__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 */
}
После старта прошивка печатает запись, если она цела. В 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 в QEMU читается нулём — отладчика нет, путь со сбросом. На
компьютере адреса превращаются в места по тому же ELF. Из PC вычитается
4 (поправка mov rX, pc), из LR — 3: сбросить бит Thumb и попасть внутрь
команды вызова, а не на следующую за ней:
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
Первая строка каждой пары — где сработало (demo.c:39 и demo.c:46 —
строки с ASSERT), вторая — кто позвал (demo.c:56 и demo.c:62).
На F1 (stm32vldiscovery) адреса другие — pc=0x08000178,
lr=0x0800019d — а ответ addr2line тот же. ELF с отладочными данными
для этого обязан храниться рядом с выпущенным образом (110.9); проверить,
что запись подходит именно к этому ELF, помогает отпечаток сборки (35в).
110а.7 Грабли
Отчёт показывает чужие правки. Сравнение с вершиной основной
ветки: в опыте +20 812 байт вместо настоящих +14 672. Сравнивать с
git merge-base.
Таблица без const ест ОЗУ дважды. data лежит и во флеш-памяти,
и в ОЗУ: +68 байт ОЗУ на таблицу в 66 байт. Если же таблица static
и в неё никто не пишет, gcc сам уводит её в .rodata (в опыте символ
стал типа r) — и ошибка не видна, пока кто-нибудь не уберёт static.
printf("%.1f") в newlib-nano молча не печатает. Без
-u _printf_float функции вывода вещественных в образ не попадают
(_printf_float в таблице символов нет), хотя образ всё равно вырос до
9 304 байт text. Ключ включает печать — и ещё 12 КБ.
С -flto пропадает _write. Библиотеку компилятор при оптимизации
всей программы не видит и выбрасывает функцию, которую зовёт только она:
undefined reference to _write. Помогает __attribute__((used)).
Утверждения одной функции сливаются в одно. Три ASSERT подряд
в одной функции, вариант «только вызов»: и при -Os, и при -O2
остаётся одно место вызова, и любое из трёх утверждений покажет строку 6.
С mov r0, pc то же самое, пока вставки одинаковые: одно место, строка 8.
Номер строки в комментарии вставки (@ line 7) делает их разными — три
места, строки 6, 7 и 8. Цена — те же 8 байт команд, но gcc перестаёт
выносить ветку с утверждением в отдельную функцию: без номера строки
у всех 100 функций тестового файла появились .part.0, а в них
__builtin_return_address(0) указывает уже не на вызывающего, а обратно
в саму функцию. С номером строки — ни одной .part.0, и объектник вырос
с 2 232 до 2 622 байт.
BKPT без отладчика. Не остановка, а HardFault. Сначала проверить
C_DEBUGEN.
assert с побочным действием. ASSERT(init() == 0) при выключенных
утверждениях не вызовет init (74.9). Это правило не меняется от того,
что утверждения оставлены в рабочей сборке: их могут выключить завтра.
Утверждение там, где сброс опаснее ошибки. Обработчик перезапускает прибор. Если на выходах в этот момент привод или нагреватель, сначала безопасное состояние (129.7), а проверку входных данных извне — обычной обработкой ошибки, не утверждением (129.6).
110а.8 Итоги
- Мерить надо Flash = text + data и ОЗУ = data + bss;
dataсчитается в обеих памятях. - Скрипт на 178 строк сравнивает две сборки по итогам, секциям, символам и файлам из map-файла и возвращает 0, 1 или 2.
- Четыре правки к прошивке в 7 324 байта:
printf("%.1f")+14 672,-O2+40, таблица безconst+68 байт ОЗУ, сconst— 0. Две красные, две зелёные. - Сравнение с вершиной основной ветки вместо общего предка дало +20 812 вместо +14 672.
- 100 утверждений на F103 при
-Os: newlib +12 496 байт к образу,__FILE__+2 312,__FILE_NAME__+2 260, PC и LR +1 772. - Сработавшее утверждение пережило сброс в QEMU на F4 и F1, и
addr2lineназвал строку утверждения и строку вызова:demo.c:39иdemo.c:56.
Полные исходники
app.c — главный цикл прошивки, на которой меряются четыре правки.
/* Раздел 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 — пять способов написать ASSERT для замера цены.
/* Раздел 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 — прошивка для замера: сто проверок и обработчики вариантов.
/* Раздел 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 — опыт со своим assert: переполнение кольца, чтение из пустого, запись в .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 (;;) {
}
}
Дальше: раздел 110б «Плата на столе в конвейере: pytest, последовательный порт и свой исполнитель» — тот же конвейер запускает тесты на плате: фикстура pytest, порт с тайм-аутом и свой исполнитель с тегом bench.