Embedded HandbookПриложения
Все разделы › V. Отладка и качество
STM32F103, STM32F407 · уровень средний · 60 мин чтения
Открыть в интерактивном справочнике

35г. Журнал на приборе: уровни, кольцевой буфер и двоичные записи вместо printf

Текстовый вывод в справочнике уже разобран: printf в порт (16.6), ITM и SWO (раздел 21), RTT (21а), цена printf в байтах (74.4). Раздел 35в сохранил слепок отказа, раздел 112 принял журнал на сервере. Не хватает середины — самого журнала на приборе. Такого, чтобы писать в него можно было из прерывания, чтобы строки не занимали флеш-память и чтобы выключенные сообщения не стоили ничего.

Здесь — такой журнал целиком: 776 байт кода на Cortex‑M3. Уровней четыре; лишние отсекаются при сборке. Запись — это слова в кольцевом буфере в ОЗУ: номер сообщения, время и сырые аргументы; на неё уходит от 60 до 93 команд. Строки формата в прошивку не попадают. Они остаются на компьютере, в словаре, который строит скрипт на Python. Фоновый цикл выдаёт записи в линию кадрами COBS с CRC‑16, декодер на компьютере превращает их обратно в строки. Всё показано прогоном: 5000 случайных сообщений сверены с настоящим printf, поток портили и рвали, кольцо переполняли, прошивку гоняли в QEMU и сравнили с snprintf по размеру, байтам в линии и числу команд.

Идея известна по открытому проекту Trice (github.com/rokath/trice). Его исходники не использовались: весь код раздела свой.

Опоры: раздел 194 — кадр COBS и CRC‑16/XMODEM, здесь тот же алгоритм; раздел 127 — макросы ##, __LINE__ и _Static_assert; раздел 21а — кольцо и счёт команд в QEMU. Опыты на компьютере — образ stm-sys:1 (gcc 12.2.0, Python 3.11.2): тот же файл журнала собран для ПК и прогнан против декодера. Опыты для Cortex‑M — образ stm-arm:1: arm-none-eabi-gcc 12.2.1 с ключом -Os, newlib-nano, QEMU 7.2.22 (stm32vldiscovery для F1 и netduinoplus2 для F4), gdb 13.1. Тот же код собран armclang 6.24.

Как это называется в документации

Оригинал Перевод
log level уровень журнала
compile-time filtering отсечение при сборке
ring buffer кольцевой буфер
deferred logging отложенная выдача: записать сейчас, вывести потом
binary (dictionary-based) logging двоичный журнал со словарём
format string строка формата
message ID номер сообщения
drop newest / drop oldest терять новые / терять старые
frame delimiter разделитель кадров
sequence counter счётчик кадров

35г.1 Что уходит в прибор, а что остаётся на компьютере

В программе вызов журнала выглядит как printf с уровнем:

    case 1:
        DLOG_D("adc ch %u raw %u", (unsigned)a & 15u, (unsigned)b & 4095u);
        break;
    case 2:
        DLOG_W("temp %d.%u C over limit %d", a / 10, (unsigned)(a < 0 ? -a : a) % 10u, b);
        break;
    case 3:
        DLOG_E("i2c nack addr 0x%02x reg 0x%02x try %u", (unsigned)a & 0x7Fu, (unsigned)b & 0xFFu, (unsigned)c & 7u);
        break;

printf сделал бы три вещи на приборе: разобрал строку формата, перевёл числа в текст и вывел символы. Всё это — работа, которую можно отложить и отдать компьютеру. Строка формата известна уже при сборке. Значит, в прибор достаточно передать её номер и аргументы как есть, по четыре байта.

Путь одного сообщения двоичного журнала: при сборке скрипт dlog_gen.py выдаёт строке формата номер и пишет его в dlog_ids.h для компилятора и в словарь dlog_dict.json для компьютера; в приборе dlog_put кладёт в кольцо в ОЗУ заголовок, время и аргументы, dlog_drain в фоновом цикле собирает кадр с CRC-16 и COBS и отдаёт в UART; на компьютере dlog_decode.py по словарю восстанавливает строку

Запись в кольце — слова по 32 бита: заголовок, время и аргументы. Заголовок устроен так:

/* Заголовок записи в кольце — одно слово. Младшие 16 бит уходят в линию как есть. */
#define DLOG_ID(h)    ((h) & 0x3FFFu)            /* биты 13:0  — номер сообщения */
#define DLOG_LVL(h)   (((h) >> 14) & 3u)         /* биты 15:14 — уровень */
#define DLOG_NARG(h)  (((h) >> 16) & 7u)         /* биты 18:16 — число аргументов */
#define DLOG_LOST(h)  ((h) >> 19)                /* биты 31:19 — сколько записей потеряно перед этой */
#define DLOG_LOST_MAX 0x1FFFu                    /* 8191 значит «не меньше 8191» */
#define DLOG_ID_LOST  0u                         /* служебные номера: метка потери … */
#define DLOG_ID_START 1u                         /* … и метка запуска с отпечатком словаря */

Номеров — 14 бит, то есть 16 384; два из них служебные. Уровень едет в линию вместе с номером, число аргументов и счётчик потерь нужны только самому прибору. В линию запись уходит кадром: номер с уровнем (2 байта), счётчик кадров (1), время (4), аргументы (по 4) и CRC‑16 (2). Это 9 + 4·n байт; COBS добавляет один байт, разделитель — ещё один. Итого 11 + 4·n байт: 11 без аргументов, 19 с двумя, 27 с четырьмя.

Вот сообщение adc ch 5 raw 1234 из прогона в QEMU — как прошивка собрала его и как выдала в UART:

frame decoded: 03 c0 02 fa 00 00 00 05 00 00 00 d2 04 00 00 f7 64 (17 bytes)
frame on line: 05 03 c0 02 fa 01 01 02 05 01 01 03 d2 04 01 03 f7 64 00 (19 bytes)

03 c0 — номер 3 и уровень 3 (D) в старших битах, 02 — третий кадр с запуска, fa 00 00 00 — время 250, дальше 5 и 1234, в конце CRC. Строки adc ch %u raw %u в кадре нет, и в образе прошивки её нет тоже. Декодер со словарём выдал начало журнала так:

0 E start, dictionary 2c9c6546 (same)
250 I state idle -> run
250 D adc ch 5 raw 1234
250 D pid e -12 i 3400 d -7 out 3388
1316 D pid e -192621 i 39532774 d 6009 out 39340153

Первое число — время, буква — уровень. Первая строка — служебная запись о запуске; о ней в 35г.4.

У такого журнала три ограничения, и все они сознательные. Аргументы — только целые до 32 бит: %d, %i, %u, %x, %X, %c с флагами и шириной. Строк %s нет: строку пришлось бы копировать в кольцо целиком. Вещественных нет: их печатают целыми с масштабом (74.4). Аргументов — не больше семи.


35г.2 Уровни: порог при сборке и порог на ходу

Уровней четыре: ошибки E, предупреждения W, сведения I, отладка D. Порогов два. Первый — при сборке: макрос DLOG_BUILD говорит, сколько уровней вообще попадает в образ. Второй — на ходу: dlog_set_level() отсекает уже собранные сообщения, не трогая прошивку.

/* Только объявление: компилятор сверяет формат с аргументами, кода и строки в образе нет. */
int dlog_fmt_check(const char *fmt, ...) __attribute__((format(printf, 1, 2)));

#define DLOG_CAT_(a, b, c) a##b##_##c
#define DLOG_CAT(a, b, c) DLOG_CAT_(a, b, c)
#define DLOG_HERE(prefix) DLOG_CAT(prefix, DLOG_FILE, __LINE__)   /* DLOG_H_app35g_27 */

#ifndef DLOG_SINK                        /* стенд подменяет приёмник, чтобы сравнить с printf */
#define DLOG_SINK(lvl, fmt, ...) dlog_put(DLOG_HERE(DLOG_H_) | ((lvl) << 14), ##__VA_ARGS__)
#endif

#define DLOG_OFF(fmt, ...) ((void)sizeof(dlog_fmt_check(fmt, ##__VA_ARGS__)))
#define DLOG_ON(lvl, fmt, ...)                                                                  \
    do {                                                                                        \
        _Static_assert(sizeof(fmt) - 1 == DLOG_HERE(DLOG_L_), "dlog_ids.h is stale: run dlog_gen.py"); \
        DLOG_OFF(fmt, ##__VA_ARGS__);                                                           \
        DLOG_SINK(lvl, fmt, ##__VA_ARGS__);                                                     \
    } while (0)

#if DLOG_BUILD > 0
#define DLOG_E(fmt, ...) DLOG_ON(DLOG_ERR, fmt, ##__VA_ARGS__)
#else
#define DLOG_E(fmt, ...) DLOG_OFF(fmt, ##__VA_ARGS__)
#endif

Макросы DLOG_W, DLOG_I, DLOG_D устроены так же, с порогами 1, 2 и 3. Вся хитрость — в DLOG_OFF. Выключенный вызов превращается в sizeof от вызова функции dlog_fmt_check. Оператор sizeof свой аргумент не вычисляет, поэтому в образ не попадает ничего: ни кода, ни строки, ни самой функции — она только объявлена. Но объявлена с атрибутом format(printf, …), и компилятор всё равно сверяет формат с аргументами. В опыте вызов DLOG_D("x %d y %d", a) дал ошибку -Werror=format и при DLOG_BUILD=4, и при DLOG_BUILD=0: выключенное сообщение не сгниёт незаметно.

Что остаётся в образе, показывает размер. Одна и та же прошивка-стенд — шестнадцать сообщений, из них двенадцать в app35g.c, — собрана для Cortex‑M3 с -Os при пяти значениях DLOG_BUILD. Рядом — она же в текстовом варианте, где каждое сообщение уходит в snprintf (35г.6):

DLOG_BUILD Уровни в образе Двоичный журнал: весь код из него app35g.o Текстовый: весь код из него app35g.o
0 ни одного 1324 2 928 2
1 E 1380 60 4436 132
2 E, W 1444 116 4588 248
3 E, W, I 1556 184 4884 418
4 все 1616 244 5020 551

При нуле от двенадцати вызовов в app35g.o остаются 2 байта: вся функция — одна команда возврата bx lr. Каждый включённый уровень добавляет только свои вызовы. У двоичного журнала при нуле остаётся его ядро: стенд по-прежнему зовёт dlog_init и dlog_drain.

Вместе с кодом исчезают и аргументы. Это удобно — и это ловушка:

static unsigned calls;

static unsigned next_sample(void)
{
    return ++calls;
}

void dlog_put(uint32_t hdr, ...)
{
    (void)hdr;
}

int main(void)
{
    for (int i = 0; i < 10; i++)
        DLOG_D("sample %u", next_sample());
    printf("DLOG_BUILD=%d next_sample called %u times\n", DLOG_BUILD, calls);
    return 0;
}
DLOG_BUILD=4 next_sample called 10 times
DLOG_BUILD=3 next_sample called 0 times

При DLOG_BUILD=3 уровень D выключен, и next_sample() не вызвана ни разу. В аргументы журнала нельзя класть то, без чего программа не работает.

Порог на ходу — первая проверка в dlog_put (35г.3): уровень из заголовка сравнивается с байтом L.level. В опыте на компьютере из 5000 событий при пороге DLOG_WARN в линию ушли 1716 — только E и W, и все они совпали с эталоном. Порог на ходу удобно менять командой с компьютера: прибор в поле молчит, пока его не попросят рассказать подробнее.


35г.3 Кольцо записей: запись из прерывания, переполнение, отложенная выдача

Журнал делится на две половины. Быстрая, dlog_put, только кладёт слова в кольцо; её зовут из задач и из прерываний. Медленная, dlog_drain, кодирует и выводит; её зовёт фоновый цикл, когда есть время.

static struct {
    uint32_t ring[DLOG_WORDS];
    uint32_t head;                       /* слов записано за всё время; двигает только dlog_put */
    uint32_t tail;                       /* слов прочитано или выброшено */
    uint32_t pending;                    /* записей отброшено после последней принятой */
    uint8_t level;                       /* порог на ходу */
    uint8_t drop_old;                    /* 0 — терять новые, 1 — терять старые */
    uint8_t seq;                         /* счётчик кадров в линии */
} L;

Кольцо — массив слов, а не байтов: запись всегда кратна четырём байтам и аргументы копируются без упаковки. head и tail — не индексы, а счётчики слов за всё время; индекс получается маской. Поэтому размер кольца обязан быть степенью двойки — это закреплено _Static_assert. Занято слов — просто head - tail, и переход счётчиков через 2³² ничего не ломает. Это проверено: стенд со счётчиками, стартующими с 0xFFFFFF00, выдал поток, побайтно равный обычному.

static uint32_t add_lost(uint32_t hdr, uint32_t n)
{
    uint32_t v = DLOG_LOST(hdr) + n;
    if (v > DLOG_LOST_MAX)
        v = DLOG_LOST_MAX;
    return (hdr & 0x7FFFFu) | (v << 19);
}

void dlog_put(uint32_t hdr, ...)
{
    uint32_t n = DLOG_NARG(hdr), lost = 0;
    va_list ap;
    if (DLOG_LVL(hdr) > L.level)
        return;                          /* уровень выключен на ходу */
    va_start(ap, hdr);
    uint32_t key = dlog_lock();
    while (DLOG_WORDS - (L.head - L.tail) < n + 2u) {
        if (!L.drop_old) {               /* терять новые: запись не берём, только считаем */
            L.pending++;
            goto out;
        }
        uint32_t old = L.ring[L.tail & MASK];    /* терять старые: выбросить самую старую */
        L.tail += DLOG_NARG(old) + 2u;
        lost += DLOG_LOST(old) + 1u;
    }
    if (lost)                            /* новая самая старая несёт счёт: «передо мной потеряно» */
        L.ring[L.tail & MASK] = add_lost(L.ring[L.tail & MASK], lost);
    uint32_t w = L.head;
    L.ring[w++ & MASK] = add_lost(hdr, L.pending);
    L.pending = 0;
    L.ring[w++ & MASK] = dlog_now();
    while (n--)
        L.ring[w++ & MASK] = va_arg(ap, uint32_t);
    L.head = w;                          /* запись видна читателю только целиком */
out:
    dlog_unlock(key);
    va_end(ap);
}

Писателей может быть несколько: задача и прерывание, которое её перебило. Поэтому резерв места и запись слов идут при закрытых прерываниях:

static inline uint32_t dlog_lock(void)
{
    uint32_t key;
    __asm volatile("mrs %0, primask\n"
                   "cpsid i" : "=r"(key) :: "memory");
    return key;
}

static inline void dlog_unlock(uint32_t key)
{
    __asm volatile("msr primask, %0" :: "r"(key) : "memory");
}

Это критическая секция из 124.4: запомнить PRIMASK, закрыть, в конце вернуть как было — так функцию можно звать и там, где прерывания уже закрыты. Вопрос — надолго ли. gdb посчитал команды пошагово в QEMU:

Запись Команд в dlog_put из них при закрытых прерываниях
без аргументов 60 43
1 аргумент (запись о запуске) 69 52
2 аргумента 77 60
4 аргумента 93 76

Числа одинаковы для Cortex‑M3 и Cortex‑M4. Время берётся внутри закрытого участка: иначе запись с более поздним временем могла бы лечь в кольцо раньше. Отсюда требование к dlog_now() — она должна быть короткой и не ждать ничего. head сдвигается последним действием: читатель видит запись только целиком.

Переполнение. Если фоновый цикл не успевает, кольцо заполнится. Выбора два, и он задаётся в dlog_init. Терять новые: запись не берётся, растёт счётчик pending; следующая принятая запись унесёт его в старших битах своего заголовка. Терять старые: самые старые записи выбрасываются, пока не хватит места, а их число записывается в заголовок той, что стала самой старой. В обоих случаях в линию перед записью уходит служебное сообщение lost N — потеря видна в самой ленте, на своём месте.

Опыт на компьютере: кольцо в 256 слов, 5000 событий пачками по 100 без выдачи между ними, после пачки — выдача до дна.

Терять новые Терять старые
записей всего (запуск и 5000 событий) 5001 5001
дошло до декодера 3127 3137
меток lost 74 50
сумма по меткам 1874 1864
дошло + сумма меток 5001 5001
метка равна разрыву в номерах событий 74 из 74 50 из 50
что уцелело из первой пачки события 1…59 события 42…100

Счёт сходится до единицы: ни одна запись не пропала молча. Меток при потере новых больше, чем пачек: в заполненное кольцо длинная запись уже не лезет, а короткая ещё может проскочить, и серия потерь рвётся на части. Что выбирать — зависит от вопроса. Начало пачки объясняет, с чего всё началось; конец — что было перед самым отказом, и тогда журнал хорошо дополняет слепок из 35в.

Задача и прерывание вместе. Третий шаг прошивки-стенда в QEMU: задача пишет 300 сообщений и после каждого выдаёт одну запись, а SysTick тем временем пишет из прерывания.

void SysTick_Handler(void)
{
    now_ms++;
    ticks++;
    DLOG_W("tick %u from interrupt", (unsigned)ticks);
}
    DLOG_I("task and interrupt write together");
    SYST_RVR = TICK_RELOAD - 1u;
    SYST_CVR = 0;
    SYST_CSR = 7u;                               /* такт ядра, прерывание, включён */
    for (unsigned i = 0; i < TASK_STEPS; i++) {
        DLOG_I("task step %u", i);
        dlog_drain();                            /* фоновый цикл: по одной записи за проход */
    }
    SYST_CSR = 0;
    DLOG_I("done: task %u, interrupt %u",
           TASK_STEPS, (unsigned)ticks);
    drain_all();

Декодер получил все 300 шагов задачи по порядку и все записи прерывания — в прогоне их 41 — тоже по порядку, без потерь и без единого испорченного кадра. Время в ленте нигде не идёт назад. Для сравнения тот же стенд собран в текстовом варианте, где прерывание печатает строку сразу. Там 24 строки из 471 оказались разорваны: прерывание вклинилось в строку задачи.

214585 I 214586 W tick 10 from interrupt

У двоичного журнала такого быть не может: в линию пишет только фоновый цикл.


35г.4 Словарь: номера выдаёт скрипт

Остался вопрос, откуда берётся номер. Писать номера руками нельзя: их перепутают в первый же день. Макрос собирает имя из названия файла и номера строки: в файле с #define DLOG_FILE app35g вызов в строке 15 разворачивается в DLOG_H_app35g_15. А значения этих имён пишет скрипт dlog_gen.py: он обходит исходники, находит вызовы и создаёт заголовок dlog_ids.h. Начало заголовка в прогоне:

/* Создан dlog_gen.py — не править. H: номер | число аргументов << 16; L: длина формата. */
#define DLOG_DICT_CRC 0x2c9c6546u
#define DLOG_H_app35g_12 0x00002u
#define DLOG_L_app35g_12 17
#define DLOG_H_app35g_15 0x20003u
#define DLOG_L_app35g_15 16

DLOG_H — готовая половина заголовка записи: номер 3 и два аргумента. DLOG_L — длина строки формата; её сверяет _Static_assert в макросе DLOG_ON. Вторым файлом скрипт пишет словарь для компьютера:

{
 "format": 1,
 "crc": "0x2c9c6546",
 "history": [],
 "messages": [
  {"id": 0, "fmt": "lost %u", "args": 1, "used": true},
  {"id": 1, "fmt": "start, dictionary %08x", "args": 1, "used": true},
  {"id": 2, "fmt": "state idle -> run", "args": 0, "used": true},
  {"id": 3, "fmt": "adc ch %u raw %u", "args": 2, "used": true},
  {"id": 4, "fmt": "temp %d.%u C over limit %d", "args": 3, "used": true},
  {"id": 5, "fmt": "i2c nack addr 0x%02x reg 0x%02x try %u", "args": 3, "used": true},
  {"id": 6, "fmt": "motor %c speed %6d rpm", "args": 2, "used": true},
  {"id": 7, "fmt": "crc %08x len %u", "args": 2, "used": true},
  {"id": 8, "fmt": "load 100%% for %u ms", "args": 1, "used": true},
  {"id": 9, "fmt": "assert line %d: %d != %d", "args": 3, "used": true},
  {"id": 10, "fmt": "queue %-5u of %5u, delta %+d", "args": 3, "used": true},
  {"id": 11, "fmt": "pid e %d i %d d %d out %d", "args": 4, "used": true},
  {"id": 12, "fmt": "mask %#x flags %04X", "args": 2, "used": true},
  {"id": 13, "fmt": "heartbeat", "args": 0, "used": true},
  {"id": 14, "fmt": "tick %u from interrupt", "args": 1, "used": true},
  {"id": 15, "fmt": "task and interrupt write together", "args": 0, "used": true},
  {"id": 16, "fmt": "task step %u", "args": 1, "used": true},
  {"id": 17, "fmt": "done: task %u, interrupt %u", "args": 2, "used": true}
 ]
}

Сам скрипт:

import json, os, re, sys, zlib

SERVICE = ["lost %u", "start, dictionary %08x"]          # номера 0 и 1 — служебные
ID_MAX, ARGS_MAX = 0x3FFF, 7
SKIP = r'//[^\n]*|/\*.*?\*/'                             # комментарии
LIT = r'"(?:\\.|[^"\\\n])*"'                             # строка в кавычках
TOKEN = re.compile(SKIP + '|' + LIT + r"|'(?:\\.|[^'\\\n])*'|\bDLOG_[EWID]\s*\(|[()]", re.S)
FIRST = re.compile(r'(?:\s|' + SKIP + r')*(' + LIT + ')', re.S)
CONV = re.compile(r'%(?:%|[-+ #0]*[0-9]*([diuxXc]))')    # что умеет декодер
ESC = {'n': '\n', 't': '\t', '\\': '\\', '"': '"', "'": "'"}


def count_args(fmt):
    """Сколько аргументов берёт формат; неподдержанное преобразование — ошибка."""
    n = i = 0
    while (i := fmt.find('%', i)) >= 0:
        m = CONV.match(fmt, i)
        if not m:
            raise ValueError("unsupported conversion in \"%s\"" % fmt)
        n += m.group(1) is not None
        i = m.end()
    if n > ARGS_MAX:
        raise ValueError("more than %d arguments in \"%s\"" % (ARGS_MAX, fmt))
    return n


def unquote(lit):
    """Литерал C → текст: тот, что увидит sizeof."""
    def one(m):
        if m.group(1) not in ESC:
            raise ValueError("unsupported escape \\%s" % m.group(1))
        return ESC[m.group(1)]
    return re.sub(r'\\(.)', one, lit[1:-1])


def scan(path):
    """Вызовы в одном файле: [(первая строка, последняя строка, формат)] и имя DLOG_FILE."""
    text = open(path, encoding="utf-8").read()
    name = re.search(r'^[ \t]*#[ \t]*define[ \t]+DLOG_FILE[ \t]+(\w+)', text, re.M)
    line = lambda pos: text.count("\n", 0, pos) + 1
    calls, it = [], TOKEN.finditer(text)
    for m in it:
        if not m.group().startswith("DLOG_"):
            continue
        where = "%s:%d" % (os.path.basename(path), line(m.start()))
        first = FIRST.match(text, m.end())
        if not first:
            raise ValueError("%s: format must be a string literal" % where)
        depth, end = 1, None
        for t in it:                                     # до парной закрывающей скобки
            depth += {"(": 1, ")": -1}.get(t.group(), 0)
            if depth == 0:
                end = t.start()
                break
        if end is None or not name:
            raise ValueError("%s: %s" % (where, "no DLOG_FILE in this file" if end else "unbalanced brackets"))
        calls.append((line(m.start()), line(end), unquote(first.group(1))))
    return name.group(1) if name else None, calls


def fingerprint(msgs):
    """Отпечаток словаря: CRC-32 по всем парам «номер, формат»."""
    return zlib.crc32("".join("%d\t%s\n" % (m["id"], m["fmt"]) for m in msgs).encode("utf-8"))


def dump(d):
    rows = ",\n".join("  " + json.dumps(m, ensure_ascii=False) for m in d["messages"])
    return '{\n "format": 1,\n "crc": "%s",\n "history": %s,\n "messages": [\n%s\n ]\n}\n' % (
        d["crc"], json.dumps(d["history"]), rows)


def main(dict_path, header_path, sources):
    if os.path.exists(dict_path):
        d = json.load(open(dict_path, encoding="utf-8"))
    else:
        d = {"crc": None, "history": [], "messages": [
            {"id": i, "fmt": f, "args": count_args(f), "used": True} for i, f in enumerate(SERVICE)]}
    msgs = d["messages"]
    by_fmt = {m["fmt"]: m for m in msgs}
    for m in msgs[len(SERVICE):]:
        m["used"] = False                                # отметим заново те, что встретятся
    lines, taken, new, ncalls = [], {}, 0, 0
    for path in sources:
        name, calls = scan(path)
        for first, last, fmt in calls:
            ncalls += 1
            if fmt not in by_fmt:                        # новый формат — следующий свободный номер
                by_fmt[fmt] = {"id": msgs[-1]["id"] + 1, "fmt": fmt, "args": count_args(fmt), "used": True}
                msgs.append(by_fmt[fmt])
                new += 1
            m = by_fmt[fmt]
            m["used"] = True
            if m["id"] > ID_MAX:
                raise ValueError("more than %d messages" % ID_MAX)
            for ln in sorted({first, last}):             # gcc берёт __LINE__ первой строки вызова, clang — последней
                if taken.setdefault((name, ln), fmt) != fmt:
                    raise ValueError("%s:%d: two DLOG calls on one line" % (os.path.basename(path), ln))
                lines.append("#define DLOG_H_%s_%d 0x%05Xu\n#define DLOG_L_%s_%d %d\n" % (
                    name, ln, m["id"] | m["args"] << 16, name, ln, len(fmt.encode("utf-8"))))
    crc = "0x%08x" % fingerprint(msgs)
    if d["crc"] not in (None, crc):
        d["history"].append(d["crc"])                    # прошивки со старым отпечатком словарь знает
    d["crc"] = crc
    head = "/* Создан dlog_gen.py — не править. H: номер | число аргументов << 16; L: длина формата. */\n"
    for path, body in ((dict_path, dump(d)), (header_path, head + "#define DLOG_DICT_CRC %su\n" % crc + "".join(lines))):
        if not os.path.exists(path) or open(path, encoding="utf-8").read() != body:
            open(path, "w", encoding="utf-8", newline="\n").write(body)
    used = sum(m["used"] for m in msgs) - len(SERVICE)
    print("dlog_gen: %d calls in %d files; messages: %d used, %d retired, %d new; next id %d; crc %s" % (
        ncalls, len(sources), used, len(msgs) - len(SERVICE) - used, new, msgs[-1]["id"] + 1, crc))

Четыре решения в нём стоит назвать.

Номер привязан к тексту, а не к месту. Строки файла можно двигать, вызов можно перенести в другой файл — номер останется. Новый текст получает следующий свободный номер. Исчезнувший текст остаётся в словаре с пометкой "used": false, и его номер больше никому не достаётся. Словарь только растёт — поэтому его хранят вместе с исходниками, а не создают заново.

Отпечаток словаря. crc — CRC‑32 по всем парам «номер, текст». Он же попадает в прошивку константой DLOG_DICT_CRC, и прибор при запуске сообщает его первой записью. Декодер сравнивает: словарь тот же, словарь новее прошивки (старый отпечаток лежит в history) или прошивка словарю незнакома.

Что умеет декодер, то и разрешено. Формат с %s, %f или %lld скрипт не пропустит: unsupported conversion in "name %s", код выхода 1.

Две строки на вызов. Если вызов занимает несколько строк, gcc подставляет в __LINE__ первую, а armclang — последнюю: вызов в строках 3–5 дал 3 у gcc и 5 у armclang. Скрипт пишет имена для обеих строк; в прогоне это вызов в строках 79–80 файла main35g.c.

Если словарь отстал от исходников. Скрипт запускают перед компилятором на каждой сборке. Если это забыть, сборку остановит сам компилятор. В опыте в начало app35g.c добавили одну строку, не обновив заголовок: 12 ошибок вида «DLOG_L_app35g_13 undeclared». Добавили три — вызовы попали на строки соседей, и 11 раз сработал _Static_assert с текстом dlog_ids.h is stale: run dlog_gen.py. Одну дыру эта защита оставляет — о ней в «Граблях».

Следующая версия прошивки в опыте: одно сообщение удалено, у другого изменён текст, одно добавлено, и все строки файла сдвинуты. Скрипт ответил:

dlog_gen: 16 calls in 2 files; messages: 16 used, 2 retired, 2 new; next id 20; crc 0x1e493416

Все 18 прежних номеров остались на местах, два текста помечены неиспользуемыми, новые получили номера 18 и 19, старый отпечаток 0x2c9c6546 ушёл в history.


35г.5 Кадр в линии: COBS, CRC-16 и восстановление после сбоя

Выдача — вторая половина журнала. Она забирает из кольца одну запись, собирает кадр и отдаёт его функции dlog_out, которую пишет прибор.

/* CRC-16/XMODEM: полином 0x1021, начальное 0 — как в 194.4. Здесь по полбайта, с таблицей на 16 слов;
 * с ключом -DDLOG_CRC_BITWISE — побитово, как в 194.4: результат тот же, выдача кадра вдвое дольше. */
static uint16_t crc16(const uint8_t *p, uint32_t n)
{
    uint16_t c = 0;
#ifndef DLOG_CRC_BITWISE
    static const uint16_t t[16] = { 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
                                    0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF };
    while (n--) {
        c = (uint16_t)((c << 4) ^ t[(c >> 12) ^ (*p >> 4)]);
        c = (uint16_t)((c << 4) ^ t[(c >> 12) ^ (*p++ & 15u)]);
    }
#else
    while (n--) {
        c ^= (uint16_t)(*p++ << 8);
        for (int i = 0; i < 8; i++)
            c = (c & 0x8000u) ? (uint16_t)((c << 1) ^ 0x1021u) : (uint16_t)(c << 1);
    }
#endif
    return c;
}

/* COBS: убирает нули из данных, чтобы ноль означал только «конец кадра» */
static uint32_t cobs_encode(const uint8_t *in, uint32_t n, uint8_t *out)
{
    uint32_t w = 1, code_at = 0;
    uint8_t code = 1;
    for (uint32_t i = 0; i < n; i++) {
        if (in[i] == 0) {
            out[code_at] = code;
            code_at = w++;
            code = 1;
        } else {
            out[w++] = in[i];
            if (++code == 0xFF) {
                out[code_at] = code;
                code_at = w++;
                code = 1;
            }
        }
    }
    out[code_at] = code;
    return w;
}

/* Кадр: номер и уровень (2 байта), счётчик кадров, слова w[0..nw-1] младшим байтом вперёд,
 * CRC-16 старшим байтом вперёд; всё это через COBS и ноль в конце. */
static void frame_out(uint32_t idlvl, const uint32_t *w, uint32_t nw)
{
    uint8_t raw[FRAME_MAX], enc[FRAME_MAX + 2];
    uint32_t k = 0;
    raw[k++] = (uint8_t)idlvl;
    raw[k++] = (uint8_t)(idlvl >> 8);
    raw[k++] = L.seq++;
    for (uint32_t i = 0; i < nw; i++)
        for (uint32_t sh = 0; sh < 32; sh += 8)
            raw[k++] = (uint8_t)(w[i] >> sh);
    uint16_t c = crc16(raw, k);
    raw[k++] = (uint8_t)(c >> 8);
    raw[k++] = (uint8_t)c;
    k = cobs_encode(raw, k, enc);
    enc[k++] = 0;                        /* конец кадра */
    dlog_out(enc, k);
}

int dlog_drain(void)
{
    uint32_t rec[2 + DLOG_MAXARGS], n = 0, lost;
    uint32_t key = dlog_lock();
    if (L.tail != L.head) {
        n = DLOG_NARG(L.ring[L.tail & MASK]) + 2u;
        for (uint32_t i = 0; i < n; i++)
            rec[i] = L.ring[(L.tail + i) & MASK];
        L.tail += n;
        lost = DLOG_LOST(rec[0]);
    } else {                             /* пусто; отброшенные после последней записи — сообщить сейчас */
        lost = L.pending;
        L.pending = 0;
    }
    dlog_unlock(key);                    /* дальше — без запрета прерываний */
    if (lost) {                          /* метка потери: время записи, один аргумент — счёт */
        uint32_t m[2] = { n ? rec[1] : dlog_now(), lost };
        frame_out(DLOG_ID_LOST | (DLOG_WARN << 14), m, 2);
    }
    if (n)
        frame_out(rec[0] & 0xFFFFu, rec + 1, n - 1u);
    return n || lost;                    /* 0 — выдавать было нечего */
}

Кадр — тот же, что в 194.4: данные, CRC‑16/XMODEM старшим байтом вперёд, COBS, ноль. COBS убирает нули из данных, и ноль в линии означает только одно: конец кадра. cobs_encode — та же, что в 194, отличается только тип счётчиков. Прерывания закрыты только на время копирования записи из кольца — от 30 до 58 команд; CRC, COBS и вывод идут уже с открытыми.

CRC сначала была побитовой, как в 194.4. Счёт команд показал цену: с ней выдача почти вдвое длиннее. Её заменила таблица на 16 слов — два шага на байт вместо восьми. Результат тот же: поток из 5000 сообщений с обеими версиями совпал побайтно.

Запись dlog_drain, CRC по таблице dlog_drain, CRC побитово
без аргументов, кадр 11 байт 442 819
1 аргумент, 15 байт 620 1213
2 аргумента, 19 байт 802 1611
4 аргумента, 27 байт 1186 2427
кольцо пусто 17 17

Таблица стоила 56 байт кода: 1616 против 1560. В счёт входит и вывод байтов в регистр UART по опросу; в QEMU передатчик всегда свободен, ожидание линии сюда не попало.

При запуске прибор первым делом шлёт одиночный ноль и запись о запуске:

void dlog_init(uint32_t level, uint32_t drop_old)
{
    static const uint8_t zero = 0;
    L.head = L.tail = DLOG_T0;
    L.pending = 0;
    L.seq = 0;
    L.level = (uint8_t)level;
    L.drop_old = (uint8_t)drop_old;
    dlog_out(&zero, 1);                  /* закрыть то, что осталось в линии до запуска */
    dlog_put(DLOG_ID_START | (1u << 16), DLOG_DICT_CRC);
}

Ноль закрывает мусор, который мог остаться в линии от прошлой жизни; пустой кадр декодер пропускает. Запись о запуске несёт отпечаток словаря.

Что будет, если линия портит байты. Поток из 5001 кадра (96 796 байт) испортили шестью способами и отдали декодеру:

Что сделали с потоком Принято кадров Потеряно Отброшено декодером Разрывов счётчика, кадров в них Неверных строк
удалили 50 байт по одному 4949 52 50 48, 52 0
перевернули по одному биту в 50 байтах 4947 54 51 50, 54 0
вставили 50 случайных байт 4951 50 50 50, 50 0
удалили 20 кусков по 30 байт 4948 53 20 20, 53 0
подключились посреди кадра (нет первых 7 байт) 5000 1 1 0, 0 0
питание сняли посреди кадра (нет последних 6 байт) 5000 1 0 0, 0 0

Главный столбец — последний: ни одна испорченная запись не превратилась в правдоподобную строку. Кадр либо принят и верен, либо отброшен — по невозможному коду COBS, по длине, не равной 9 + 4·n, или по CRC. Со следующего нуля приём снова в порядке.

Сколько стоит одно повреждение, показали 1200 отдельных проб на потоке из 301 кадра. Вставленный байт всегда стоил одного кадра — 400 из 400. Удалённый байт — одного кадра в 382 пробах и двух в 18: если пропал сам разделитель, два кадра слипаются и не проходят оба. Перевёрнутый бит — одного в 380 пробах и двух в 20, по той же причине.

Счётчик кадров — третий байт — нужен, чтобы компьютер знал о потере, а не догадывался. Декодер сравнивает его с ожидаемым и пишет в ленту, сколько кадров не дошло: во всех четырёх опытах с порчей сумма по этим пометкам равна числу потерянных кадров. Потерю самого первого кадра после подключения и самого последнего счётчик не покажет — не с чем сравнить; это видно в двух нижних строках таблицы.


35г.6 Декодер, старые прошивки и сравнение с printf

Декодер делает обратный путь: режет поток по нулям, снимает COBS, сверяет длину и CRC, по номеру находит формат и печатает.

import json, re, struct, sys

LEVELS = "EWID"
ID_LOST, ID_START = 0, 1
CONV = re.compile(r'%(?:%|([-+ #0]*)([0-9]*)([diuxXc]))')


def crc16(data):
    """CRC-16/XMODEM: полином 0x1021, начальное 0 — как в прошивке."""
    c = 0
    for b in data:
        c ^= b << 8
        for _ in range(8):
            c = ((c << 1) ^ 0x1021 if c & 0x8000 else c << 1) & 0xFFFF
    return c


def cobs_decode(enc):
    """Обратное COBS; None — если код блока выводит за конец кадра."""
    out, r = bytearray(), 0
    while r < len(enc):
        code = enc[r]
        if r + code > len(enc):
            return None
        out += enc[r + 1:r + code]
        r += code
        if code != 0xFF and r < len(enc):
            out.append(0)
    return bytes(out)


def c_format(fmt, args):
    """printf для слов по 32 бита: d i u x X c, флаги - + пробел # 0, ширина."""
    it = iter(args)

    def one(m):
        if m.group(3) is None:
            return "%"
        flags, conv, v = m.group(1), m.group(3), next(it)
        if conv in "di":
            v -= (v & 0x80000000) << 1                   # слово как число со знаком
        if conv == "c":
            return ("%" + flags.replace("0", "") + m.group(2) + "c") % chr(v & 0xFF)
        if conv in "xX" and v == 0:
            flags = flags.replace("#", "")               # в C «%#x» от нуля — просто 0
        return ("%" + flags + m.group(2) + conv.replace("u", "d")) % v
    return CONV.sub(one, fmt)


class Decoder:
    def __init__(self, dictionary):
        self.msgs = {m["id"]: m for m in dictionary["messages"]}
        self.known = {dictionary["crc"]: "same"}
        self.known.update((c, "older firmware") for c in dictionary["history"])
        self.buf = bytearray()
        self.seq = None                                  # счётчик следующего кадра; None — ещё не знаем
        self.st = dict(frames=0, empty=0, bad_cobs=0, bad_len=0, bad_crc=0, gaps=0, missed=0,
                       unknown=0, mismatch=0, device_lost=0, starts=0)

    def feed(self, data):
        """Принять кусок потока; вернуть готовые строки."""
        lines = []
        for b in data:
            if b:
                self.buf.append(b)
                continue
            frame, self.buf = bytes(self.buf), bytearray()       # ноль — конец кадра
            if not frame:
                self.st["empty"] += 1                    # два нуля подряд: пустой кадр, не ошибка
                continue
            line = self.frame(frame, lines)
            if line:
                lines.append(line)
        return lines

    def frame(self, enc, lines):
        st = self.st
        raw = cobs_decode(enc)
        if raw is None:
            st["bad_cobs"] += 1
        elif len(raw) < 9 or len(raw) > 37 or (len(raw) - 9) % 4:
            st["bad_len"] += 1
        elif crc16(raw[:-2]) != raw[-2] << 8 | raw[-1]:
            st["bad_crc"] += 1
        else:
            st["frames"] += 1
            idlvl, seq = struct.unpack_from("<HB", raw)
            t, *args = struct.unpack_from("<%dI" % ((len(raw) - 5) // 4), raw, 3)
            mid, lvl = idlvl & 0x3FFF, LEVELS[idlvl >> 14]
            if mid == ID_START and len(args) == 1:       # прибор запустился: счётчик кадров с нуля
                st["starts"] += 1
                self.seq = 0
            if self.seq is not None and seq != self.seq:
                n = (seq - self.seq) & 0xFF              # сколько кадров не дошло (по модулю 256)
                st["gaps"] += 1
                st["missed"] += n
                lines.append("-- line: %d frame(s) missing" % n)
            self.seq = (seq + 1) & 0xFF
            m = self.msgs.get(mid)
            if m is None:                                # формата нет в словаре: аргументы — как есть
                st["unknown"] += 1
                return "%u %s ?id=%u" % (t, lvl, mid) + "".join(" %08x" % a for a in args)
            if m["args"] != len(args):                   # словарь не от этой прошивки
                st["mismatch"] += 1
                return "%u %s ?id=%u wants %u args:" % (t, lvl, mid, m["args"]) + "".join(" %08x" % a for a in args)
            text = c_format(m["fmt"], args)
            if mid == ID_LOST:
                st["device_lost"] += args[0]
            if mid == ID_START:
                text += " (%s)" % self.known.get("0x%08x" % args[0], "unknown to this dictionary")
            return "%u %s %s" % (t, lvl, text)
        return None

Форматирование — своё, без printf языка C под рукой, поэтому главный опыт раздела — сверка. Стенд на компьютере каждое событие пишет дважды: в двоичный журнал и обычным printf в файл. Событий 5000, двенадцать видов сообщений, аргументы — случайные числа со знаком разрядностью от 1 до 30 бит. Декодер выдал 5000 строк, и все 5000 совпали с printf дословно — с шириной поля, нулями слева, знаком +, выравниванием влево и %#x. На последнем декодер поначалу и споткнулся бы: Python печатает %#x от нуля как 0x0, а C — как 0; в потоке таких случаев 14.

Совместимость со старой прошивкой. Приборы в поле живут дольше, чем версия прошивки. Проверены все сочетания двух версий из 35г.4:

Прошивка и словарь Строк совпало с printf Номер неизвестен Запись о запуске
старая и старый 5000 0 same
старая и новый 5000 0 older firmware
новая и новый 5000 0 same
новая и старый 4162 838 unknown to this dictionary

Новый словарь читает старый журнал без единой ошибки: удалённые тексты из него никуда не делись. Старый словарь с новой прошивкой разбирает всё, что знает, а незнакомое показывает честно — номер и сырые аргументы:

10929 I ?id=18 00000044 ff55fe85 ffffb303

Разбор при этом не ломается: число аргументов декодер знает из длины кадра, а не из словаря.

Сравнение с printf. Для сравнения тот же стенд собран второй раз: ключ -include tlog35g.h подменяет приёмник, и те же вызовы DLOG_x уходят в функцию, которая собирает строку через vsnprintf из newlib-nano и сразу выводит её.

static int head(char *p, unsigned room, const char *fmt, ...)    /* начало строки — тем же vsnprintf */
{
    va_list ap;
    va_start(ap, fmt);
    int n = vsnprintf(p, room, fmt, ap);
    va_end(ap);
    return n;
}

void tlog(unsigned lvl, const char *fmt, ...)
{
    char line[96];
    va_list ap;
    if (lvl > level)
        return;
    int n = head(line, sizeof(line) - 1u, "%u %c ", (unsigned)dlog_now(), "EWID"[lvl]);
    va_start(ap, fmt);
    n += vsnprintf(line + n, sizeof(line) - 1u - (unsigned)n, fmt, ap);
    va_end(ap);
    if (n > (int)sizeof(line) - 2)
        n = (int)sizeof(line) - 2;               /* строка не влезла — обрезана */
    line[n++] = '\n';
    dlog_out((const uint8_t *)line, (uint32_t)n);
}

Обе прошивки в QEMU выдали одни и те же 203 сообщения первых двух шагов: строки декодера совпали со строками текстовой прошивки все до одной.

Размер (Cortex‑M3, -Os, все уровни включены):

Двоичный журнал Текст через vsnprintf
образ во флеш-памяти, байт 1620 5124
код и константы (text) 1616 5020
ОЗУ: data + bss 4 + 1048 104 + 24
app35g.o: двенадцать вызовов 244 551
строки формата в образе нет есть

Образ меньше в 3,2 раза. Ядро журнала — 776 байт: dlog_put 272, frame_out вместе с CRC и COBS 260, dlog_drain 148, dlog_init 64, таблица CRC 32. В текстовом варианте одни только _printf_i (680 байт), _svfprintf_r (556) и _printf_common (294) занимают больше. Двенадцать строк формата — это 271 байт с завершающими нулями; у двоичного журнала их место занимают двенадцать номеров. Для Cortex‑M4 картина та же: 1604 против 5116 байт кода. Платит двоичный журнал оперативной памятью: кольцо в 256 слов — это 1024 байта, вся структура — 1040. У текстового варианта data и bss вместе — 128 байт, и ещё буфер строки в 96 байт на стеке; стек самого vsnprintf здесь не измерялся.

Байты в линии (5000 сообщений на компьютере):

Всего байт На сообщение
двоичные кадры 96 796 19,4
текст: время, уровень, сообщение 179 922 36,0
текст без времени и уровня 131 048 26,2

Выигрыш — в 1,86 раза, и он зависит от сообщения. heartbeat — 11 байт вместо 19,8 в среднем, pid e %d i %d d %d out %d — 27 вместо 49,9. А вот adc ch %u raw %u с маленькими числами — 19 байт против 27,6: аргумент в четыре байта ничего не экономит на числе из одной цифры. На скорости 115 200 бод при 10 битах на байт средний кадр занимает линию 1,7 мс, средняя строка — 3,1 мс.

Команды (gdb, пошагово; у текстового варианта — от входа в tlog до возврата, вместе с выводом):

Сообщение dlog_put dlog_put + dlog_drain tlog, Cortex‑M3 tlog, Cortex‑M4
без аргументов 60 502 1450 1406
2 аргумента 77 879 2249 2161
4 аргумента 93 1279 3322 3190

Всего двоичный журнал тратит в 2,6–2,9 раза меньше команд. Но важнее первый столбец. Там, где сообщение возникло — в прерывании, в регуляторе, — программа платит 60–93 команды вместо 1450–3322, в 24–36 раз меньше. Остальное доделывает фоновый цикл, когда ему удобно.

Это счёт команд, а не время: QEMU такты не считает, и на плате журнал не запускался. Ожидание передатчика UART в счёт не вошло ни у одного варианта.


35г.7 Грабли

Журнал расшифрован, а текст не тот. Словарь создали заново из новых исходников вместо того, чтобы продолжить старый. Номера раздались по порядку, и два из них достались другим текстам. Старый журнал с таким словарём: 4162 строки верны, 405 декодер отметил сам — словарь ждёт другое число аргументов, — а 433 вышли правдоподобными и неверными:

16811 I fan pwm 46339%   <- printf: 16811 I load 100% for 46339 ms

Словарь — такой же исходник, как код: он лежит в хранилище версий, и dlog_gen.py его только дополняет. О подмене предупреждает первая строка журнала: unknown to this dictionary.

Текст поправили, длина та же, скрипт не запустили. raw заменили на val в adc ch %u raw %u — компилятор промолчал: имя по номеру строки на месте, длина совпала. Декодер покажет старый текст. _Static_assert ловит сдвиг строк, но не такую правку. Поэтому dlog_gen.py — шаг сборки перед компилятором, а не команда, о которой помнят.

Два вызова в одной строке. Имя собирается из номера строки, и двум вызовам досталось бы одно. Скрипт останавливается: two DLOG calls on one line.

Вместе с уровнем исчезло действие. Аргументы выключенного уровня не вычисляются (35г.2): next_sample() в опыте вызвана 0 раз вместо 10. Счётчики, чтение регистров со сбросом флага, i++ в аргументы журнала не кладут.

Текстовое сообщение из прерывания рвёт строку задачи. В текстовом варианте стенда так испорчены 24 строки из 471. Двоичный журнал выводит только из фонового цикла, а в прерывании лишь кладёт слова в кольцо.

Потери не видны. Журнал без счётчика потерь врёт молча: по ленте кажется, что между двумя записями ничего не было. Здесь счёт идёт на двух уровнях. Потерянное в приборе показывает метка lost N, потерянное в линии — счётчик кадров.

Счётчик кадров — один байт. Пропуск считается по модулю 256: если подряд пропало ровно 256 кадров, декодер этого не заметит. И потерю последнего кадра перед тишиной он не видит — пока не придёт следующий.

Фоновый цикл стоит, пока идёт кадр. dlog_out в стенде ждёт передатчик по опросу. Кадр в 19 байт на 115 200 бод — это 1,65 мс. Если цикл так ждать не может, dlog_out должна только класть байты в очередь передатчика, а отправлять их — прерывание или DMA (раздел 16).

Аргумент шире 32 бит. В кольцо ложатся слова по 32 бита. long long и double туда не попадут: скрипт не примет %lld и %f, а компилятор не примет такой аргумент под %d — -Werror=format включён не зря.


35г.8 Итоги

Полные исходники

dlog35g.h — интерфейс журнала: уровни, макросы DLOG_E/W/I/D, запись в кольцо.

/* Раздел 35г: журнал на приборе. В кольцо в ОЗУ ложатся номер сообщения, метка времени
 * и сырые аргументы; строки формата остаются на компьютере, в словаре dlog_dict.json.
 * Номера выдаёт dlog_gen.py: он обходит исходники и пишет dlog_ids.h. */
#ifndef DLOG35G_H
#define DLOG35G_H
#include <stdint.h>
#include "dlog_ids.h"

#define DLOG_ERR   0u                    /* уровни: чем больше число, тем подробнее */
#define DLOG_WARN  1u
#define DLOG_INFO  2u
#define DLOG_DBG   3u

#ifndef DLOG_BUILD
#define DLOG_BUILD 4                     /* сколько уровней попадает в образ: 0 — ни одного, 4 — все */
#endif
#ifndef DLOG_WORDS
#define DLOG_WORDS 256u                  /* кольцо, в словах по 4 байта; степень двойки */
#endif
#define DLOG_MAXARGS 7u

/* Заголовок записи в кольце — одно слово. Младшие 16 бит уходят в линию как есть. */
#define DLOG_ID(h)    ((h) & 0x3FFFu)            /* биты 13:0  — номер сообщения */
#define DLOG_LVL(h)   (((h) >> 14) & 3u)         /* биты 15:14 — уровень */
#define DLOG_NARG(h)  (((h) >> 16) & 7u)         /* биты 18:16 — число аргументов */
#define DLOG_LOST(h)  ((h) >> 19)                /* биты 31:19 — сколько записей потеряно перед этой */
#define DLOG_LOST_MAX 0x1FFFu                    /* 8191 значит «не меньше 8191» */
#define DLOG_ID_LOST  0u                         /* служебные номера: метка потери … */
#define DLOG_ID_START 1u                         /* … и метка запуска с отпечатком словаря */

void dlog_init(uint32_t level, uint32_t drop_old);   /* drop_old: 0 — терять новые, 1 — старые */
void dlog_set_level(uint32_t level);                 /* порог на ходу: DLOG_ERR … DLOG_DBG */
void dlog_put(uint32_t hdr, ...);                    /* из задачи и из прерывания */
int dlog_drain(void);                                /* из фонового цикла: одна запись → один кадр */

uint32_t dlog_now(void);                             /* даёт прибор: метка времени */
void dlog_out(const uint8_t *p, uint32_t n);         /* даёт прибор: байты в линию */

/* Только объявление: компилятор сверяет формат с аргументами, кода и строки в образе нет. */
int dlog_fmt_check(const char *fmt, ...) __attribute__((format(printf, 1, 2)));

#define DLOG_CAT_(a, b, c) a##b##_##c
#define DLOG_CAT(a, b, c) DLOG_CAT_(a, b, c)
#define DLOG_HERE(prefix) DLOG_CAT(prefix, DLOG_FILE, __LINE__)   /* DLOG_H_app35g_27 */

#ifndef DLOG_SINK                        /* стенд подменяет приёмник, чтобы сравнить с printf */
#define DLOG_SINK(lvl, fmt, ...) dlog_put(DLOG_HERE(DLOG_H_) | ((lvl) << 14), ##__VA_ARGS__)
#endif

#define DLOG_OFF(fmt, ...) ((void)sizeof(dlog_fmt_check(fmt, ##__VA_ARGS__)))
#define DLOG_ON(lvl, fmt, ...)                                                                  \
    do {                                                                                        \
        _Static_assert(sizeof(fmt) - 1 == DLOG_HERE(DLOG_L_), "dlog_ids.h is stale: run dlog_gen.py"); \
        DLOG_OFF(fmt, ##__VA_ARGS__);                                                           \
        DLOG_SINK(lvl, fmt, ##__VA_ARGS__);                                                     \
    } while (0)

#if DLOG_BUILD > 0
#define DLOG_E(fmt, ...) DLOG_ON(DLOG_ERR, fmt, ##__VA_ARGS__)
#else
#define DLOG_E(fmt, ...) DLOG_OFF(fmt, ##__VA_ARGS__)
#endif
#if DLOG_BUILD > 1
#define DLOG_W(fmt, ...) DLOG_ON(DLOG_WARN, fmt, ##__VA_ARGS__)
#else
#define DLOG_W(fmt, ...) DLOG_OFF(fmt, ##__VA_ARGS__)
#endif
#if DLOG_BUILD > 2
#define DLOG_I(fmt, ...) DLOG_ON(DLOG_INFO, fmt, ##__VA_ARGS__)
#else
#define DLOG_I(fmt, ...) DLOG_OFF(fmt, ##__VA_ARGS__)
#endif
#if DLOG_BUILD > 3
#define DLOG_D(fmt, ...) DLOG_ON(DLOG_DBG, fmt, ##__VA_ARGS__)
#else
#define DLOG_D(fmt, ...) DLOG_OFF(fmt, ##__VA_ARGS__)
#endif

#endif

dlog35g.c — кольцо записей в ОЗУ и выдача кадрами COBS + CRC-16.

/* Раздел 35г: журнал на приборе — кольцо записей в ОЗУ и выдача кадрами COBS + CRC-16.
 * Запись (dlog_put) коротка и годится для прерывания; кодирование и линия — в dlog_drain,
 * которую зовёт фоновый цикл. Кадр тот же, что в разделе 194: данные + CRC → COBS → 0x00. */
#include <stdarg.h>
#include "dlog35g.h"

#define MASK (DLOG_WORDS - 1u)
#define FRAME_MAX (9u + 4u * DLOG_MAXARGS)       /* номер 2, счётчик 1, время 4, аргументы, CRC 2 */
#ifndef DLOG_T0
#define DLOG_T0 0u                               /* откуда стартуют счётчики слов; иное — только для опыта */
#endif

_Static_assert((DLOG_WORDS & MASK) == 0 && DLOG_WORDS >= 32, "DLOG_WORDS: power of two, at least 32");

static struct {
    uint32_t ring[DLOG_WORDS];
    uint32_t head;                       /* слов записано за всё время; двигает только dlog_put */
    uint32_t tail;                       /* слов прочитано или выброшено */
    uint32_t pending;                    /* записей отброшено после последней принятой */
    uint8_t level;                       /* порог на ходу */
    uint8_t drop_old;                    /* 0 — терять новые, 1 — терять старые */
    uint8_t seq;                         /* счётчик кадров в линии */
} L;

#if defined(__arm__)
static inline uint32_t dlog_lock(void)
{
    uint32_t key;
    __asm volatile("mrs %0, primask\n"
                   "cpsid i" : "=r"(key) :: "memory");
    return key;
}

static inline void dlog_unlock(uint32_t key)
{
    __asm volatile("msr primask, %0" :: "r"(key) : "memory");
}
#else                                    /* на компьютере писатель один */
static inline uint32_t dlog_lock(void)
{
    return 0;
}

static inline void dlog_unlock(uint32_t key)
{
    (void)key;
}
#endif

static uint32_t add_lost(uint32_t hdr, uint32_t n)
{
    uint32_t v = DLOG_LOST(hdr) + n;
    if (v > DLOG_LOST_MAX)
        v = DLOG_LOST_MAX;
    return (hdr & 0x7FFFFu) | (v << 19);
}

void dlog_put(uint32_t hdr, ...)
{
    uint32_t n = DLOG_NARG(hdr), lost = 0;
    va_list ap;
    if (DLOG_LVL(hdr) > L.level)
        return;                          /* уровень выключен на ходу */
    va_start(ap, hdr);
    uint32_t key = dlog_lock();
    while (DLOG_WORDS - (L.head - L.tail) < n + 2u) {
        if (!L.drop_old) {               /* терять новые: запись не берём, только считаем */
            L.pending++;
            goto out;
        }
        uint32_t old = L.ring[L.tail & MASK];    /* терять старые: выбросить самую старую */
        L.tail += DLOG_NARG(old) + 2u;
        lost += DLOG_LOST(old) + 1u;
    }
    if (lost)                            /* новая самая старая несёт счёт: «передо мной потеряно» */
        L.ring[L.tail & MASK] = add_lost(L.ring[L.tail & MASK], lost);
    uint32_t w = L.head;
    L.ring[w++ & MASK] = add_lost(hdr, L.pending);
    L.pending = 0;
    L.ring[w++ & MASK] = dlog_now();
    while (n--)
        L.ring[w++ & MASK] = va_arg(ap, uint32_t);
    L.head = w;                          /* запись видна читателю только целиком */
out:
    dlog_unlock(key);
    va_end(ap);
}

/* CRC-16/XMODEM: полином 0x1021, начальное 0 — как в 194.4. Здесь по полбайта, с таблицей на 16 слов;
 * с ключом -DDLOG_CRC_BITWISE — побитово, как в 194.4: результат тот же, выдача кадра вдвое дольше. */
static uint16_t crc16(const uint8_t *p, uint32_t n)
{
    uint16_t c = 0;
#ifndef DLOG_CRC_BITWISE
    static const uint16_t t[16] = { 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
                                    0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF };
    while (n--) {
        c = (uint16_t)((c << 4) ^ t[(c >> 12) ^ (*p >> 4)]);
        c = (uint16_t)((c << 4) ^ t[(c >> 12) ^ (*p++ & 15u)]);
    }
#else
    while (n--) {
        c ^= (uint16_t)(*p++ << 8);
        for (int i = 0; i < 8; i++)
            c = (c & 0x8000u) ? (uint16_t)((c << 1) ^ 0x1021u) : (uint16_t)(c << 1);
    }
#endif
    return c;
}

/* COBS: убирает нули из данных, чтобы ноль означал только «конец кадра» */
static uint32_t cobs_encode(const uint8_t *in, uint32_t n, uint8_t *out)
{
    uint32_t w = 1, code_at = 0;
    uint8_t code = 1;
    for (uint32_t i = 0; i < n; i++) {
        if (in[i] == 0) {
            out[code_at] = code;
            code_at = w++;
            code = 1;
        } else {
            out[w++] = in[i];
            if (++code == 0xFF) {
                out[code_at] = code;
                code_at = w++;
                code = 1;
            }
        }
    }
    out[code_at] = code;
    return w;
}

/* Кадр: номер и уровень (2 байта), счётчик кадров, слова w[0..nw-1] младшим байтом вперёд,
 * CRC-16 старшим байтом вперёд; всё это через COBS и ноль в конце. */
static void frame_out(uint32_t idlvl, const uint32_t *w, uint32_t nw)
{
    uint8_t raw[FRAME_MAX], enc[FRAME_MAX + 2];
    uint32_t k = 0;
    raw[k++] = (uint8_t)idlvl;
    raw[k++] = (uint8_t)(idlvl >> 8);
    raw[k++] = L.seq++;
    for (uint32_t i = 0; i < nw; i++)
        for (uint32_t sh = 0; sh < 32; sh += 8)
            raw[k++] = (uint8_t)(w[i] >> sh);
    uint16_t c = crc16(raw, k);
    raw[k++] = (uint8_t)(c >> 8);
    raw[k++] = (uint8_t)c;
    k = cobs_encode(raw, k, enc);
    enc[k++] = 0;                        /* конец кадра */
    dlog_out(enc, k);
}

int dlog_drain(void)
{
    uint32_t rec[2 + DLOG_MAXARGS], n = 0, lost;
    uint32_t key = dlog_lock();
    if (L.tail != L.head) {
        n = DLOG_NARG(L.ring[L.tail & MASK]) + 2u;
        for (uint32_t i = 0; i < n; i++)
            rec[i] = L.ring[(L.tail + i) & MASK];
        L.tail += n;
        lost = DLOG_LOST(rec[0]);
    } else {                             /* пусто; отброшенные после последней записи — сообщить сейчас */
        lost = L.pending;
        L.pending = 0;
    }
    dlog_unlock(key);                    /* дальше — без запрета прерываний */
    if (lost) {                          /* метка потери: время записи, один аргумент — счёт */
        uint32_t m[2] = { n ? rec[1] : dlog_now(), lost };
        frame_out(DLOG_ID_LOST | (DLOG_WARN << 14), m, 2);
    }
    if (n)
        frame_out(rec[0] & 0xFFFFu, rec + 1, n - 1u);
    return n || lost;                    /* 0 — выдавать было нечего */
}

void dlog_set_level(uint32_t level)
{
    L.level = (uint8_t)level;
}

void dlog_init(uint32_t level, uint32_t drop_old)
{
    static const uint8_t zero = 0;
    L.head = L.tail = DLOG_T0;
    L.pending = 0;
    L.seq = 0;
    L.level = (uint8_t)level;
    L.drop_old = (uint8_t)drop_old;
    dlog_out(&zero, 1);                  /* закрыть то, что осталось в линии до запуска */
    dlog_put(DLOG_ID_START | (1u << 16), DLOG_DICT_CRC);
}

main35g.c — прошивка-стенд для QEMU в двух вариантах: двоичный и текстовый журнал.

/* Раздел 35г: прошивка-стенд для QEMU. Один и тот же файл собирается в двух вариантах:
 * с dlog35g.c — двоичный журнал, с tlog35g.c и ключом -include tlog35g.h — текстовый.
 * Шаг 1: три сообщения для счёта команд. Шаг 2: 200 случайных событий, время — счётчик.
 * Шаг 3: задача и прерывание SysTick пишут в журнал вперемешку. */
#define DLOG_FILE main35g
#include "dlog35g.h"
#include "app35g.h"
#include "regs35g.h"
#include "rnd35g.h"

#define TASK_STEPS 300u

static volatile uint32_t now_ms, ticks;

uint32_t dlog_now(void)
{
    return now_ms;
}

void dlog_out(const uint8_t *p, uint32_t n)      /* байты в USART1, по опросу */
{
    while (n--) {
        while (!(USART1_SR & USART_SR_TXE)) {
        }
        USART1_DR = *p++;
    }
}

void SysTick_Handler(void)
{
    now_ms++;
    ticks++;
    DLOG_W("tick %u from interrupt", (unsigned)ticks);
}

static void drain_all(void)
{
    while (dlog_drain()) {
    }
}

static void qemu_exit(void)                      /* semihosting SYS_EXIT: закрыть QEMU (раздел 35а) */
{
    register uint32_t r0 __asm("r0") = 0x18u;
    register uint32_t r1 __asm("r1") = 0x20026u;
    __asm volatile("bkpt 0xAB" : : "r"(r0), "r"(r1) : "memory");
}

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

    now_ms = 250u;
    app_event(0, 0, 0, 0);                       /* ЗАМЕР: без аргументов */
    app_event(1, 5, 1234, 0);                    /* ЗАМЕР: два аргумента */
    app_event(9, -12, 3400, -7);                 /* ЗАМЕР: четыре аргумента */
    drain_all();

    for (unsigned i = 0; i < 200u; i++) {
        now_ms += 1u + rnd() % 2000u;
        unsigned kind = rnd() % APP_KINDS;
        int a = rnd_val(), b = rnd_val(), c = rnd_val();
        app_event(kind, a, b, c);
        drain_all();
    }

    DLOG_I("task and interrupt write together");
    SYST_RVR = TICK_RELOAD - 1u;
    SYST_CVR = 0;
    SYST_CSR = 7u;                               /* такт ядра, прерывание, включён */
    for (unsigned i = 0; i < TASK_STEPS; i++) {
        DLOG_I("task step %u", i);
        dlog_drain();                            /* фоновый цикл: по одной записи за проход */
    }
    SYST_CSR = 0;
    DLOG_I("done: task %u, interrupt %u",
           TASK_STEPS, (unsigned)ticks);
    drain_all();
    qemu_exit();
    for (;;) {
    }
}

tlog35g.c — вариант для сравнения: тот же прибор пишет журнал текстом через snprintf.

/* Раздел 35г: вариант для сравнения — тот же прибор пишет журнал текстом. Строка собирается
 * snprintf из newlib-nano и сразу уходит в линию; кольца и отложенной выдачи нет. */
#include <stdarg.h>
#include <stdio.h>
#include "dlog35g.h"
#include "tlog35g.h"

static uint8_t level;

static int head(char *p, unsigned room, const char *fmt, ...)    /* начало строки — тем же vsnprintf */
{
    va_list ap;
    va_start(ap, fmt);
    int n = vsnprintf(p, room, fmt, ap);
    va_end(ap);
    return n;
}

void tlog(unsigned lvl, const char *fmt, ...)
{
    char line[96];
    va_list ap;
    if (lvl > level)
        return;
    int n = head(line, sizeof(line) - 1u, "%u %c ", (unsigned)dlog_now(), "EWID"[lvl]);
    va_start(ap, fmt);
    n += vsnprintf(line + n, sizeof(line) - 1u - (unsigned)n, fmt, ap);
    va_end(ap);
    if (n > (int)sizeof(line) - 2)
        n = (int)sizeof(line) - 2;               /* строка не влезла — обрезана */
    line[n++] = '\n';
    dlog_out((const uint8_t *)line, (uint32_t)n);
}

void dlog_init(uint32_t lvl, uint32_t drop_old)
{
    (void)drop_old;
    level = (uint8_t)lvl;
}

int dlog_drain(void)
{
    return 0;                                    /* выдавать нечего: всё уже ушло */
}

void *_sbrk(int incr)                            /* куча не нужна: snprintf пишет в свой буфер */
{
    (void)incr;
    return (void *)-1;
}

app35g.c — «прибор» для опытов: двенадцать сообщений разных уровней и форматов.

/* Раздел 35г: «прибор» для опытов. Двенадцать сообщений: четыре уровня, от нуля до четырёх
 * аргументов, знаковые и беззнаковые, ширина, нули слева, символ и знак процента.
 * Один и тот же файл собирается для компьютера и для Cortex-M. */
#define DLOG_FILE app35g
#include "dlog35g.h"
#include "app35g.h"

void app_event(unsigned kind, int a, int b, int c)
{
    switch (kind % APP_KINDS) {
    case 0:
        DLOG_I("state idle -> run");
        break;
    case 1:
        DLOG_D("adc ch %u raw %u", (unsigned)a & 15u, (unsigned)b & 4095u);
        break;
    case 2:
        DLOG_W("temp %d.%u C over limit %d", a / 10, (unsigned)(a < 0 ? -a : a) % 10u, b);
        break;
    case 3:
        DLOG_E("i2c nack addr 0x%02x reg 0x%02x try %u", (unsigned)a & 0x7Fu, (unsigned)b & 0xFFu, (unsigned)c & 7u);
        break;
    case 4:
        DLOG_I("motor %c speed %6d rpm", 'A' + (a & 3), b);
        break;
    case 5:
        DLOG_D("crc %08x len %u", (unsigned)a, (unsigned)b);
        break;
    case 6:
        DLOG_I("load 100%% for %u ms", (unsigned)c % 60000u);
        break;
    case 7:
        DLOG_E("assert line %d: %d != %d", a & 0xFFF, b, c);
        break;
    case 8:
        DLOG_W("queue %-5u of %5u, delta %+d", (unsigned)a & 1023u, (unsigned)b & 1023u, c);
        break;
    case 9:
        DLOG_D("pid e %d i %d d %d out %d", a, b, c, a + b);
        break;
    case 10:
        DLOG_I("mask %#x flags %04X", (unsigned)a & 0xFFu, (unsigned)b & 0xFFFFu);
        break;
    default:
        DLOG_D("heartbeat");
        break;
    }
}

app35g_v2.c — следующая версия того же «прибора» — для опыта со словарём.

/* Раздел 35г: тот же «прибор», следующая версия прошивки — для опыта со словарём.
 * Изменено: сообщение «load 100%…» удалено, у «motor…» другой текст и третий аргумент,
 * добавлено «fan pwm…»; из-за этого комментария все строки ещё и сдвинулись.
 * Номера остальных сообщений должны остаться прежними. */
#define DLOG_FILE app35g
#include "dlog35g.h"
#include "app35g.h"

void app_event(unsigned kind, int a, int b, int c)
{
    switch (kind % APP_KINDS) {
    case 0:
        DLOG_I("state idle -> run");
        break;
    case 1:
        DLOG_D("adc ch %u raw %u", (unsigned)a & 15u, (unsigned)b & 4095u);
        break;
    case 2:
        DLOG_W("temp %d.%u C over limit %d", a / 10, (unsigned)(a < 0 ? -a : a) % 10u, b);
        break;
    case 3:
        DLOG_E("i2c nack addr 0x%02x reg 0x%02x try %u", (unsigned)a & 0x7Fu, (unsigned)b & 0xFFu, (unsigned)c & 7u);
        break;
    case 4:
        DLOG_I("motor %c speed %d rpm, torque %d", 'A' + (a & 3), b, c);
        break;
    case 5:
        DLOG_D("crc %08x len %u", (unsigned)a, (unsigned)b);
        break;
    case 6:
        DLOG_I("fan pwm %u%%", (unsigned)c % 101u);
        break;
    case 7:
        DLOG_E("assert line %d: %d != %d", a & 0xFFF, b, c);
        break;
    case 8:
        DLOG_W("queue %-5u of %5u, delta %+d", (unsigned)a & 1023u, (unsigned)b & 1023u, c);
        break;
    case 9:
        DLOG_D("pid e %d i %d d %d out %d", a, b, c, a + b);
        break;
    case 10:
        DLOG_I("mask %#x flags %04X", (unsigned)a & 0xFFu, (unsigned)b & 0xFFFFu);
        break;
    default:
        DLOG_D("heartbeat");
        break;
    }
}

cut35g.c — выключенный уровень не оставляет в программе ничего, даже вычисления аргументов.

/* Раздел 35г: выключенный уровень не оставляет в программе ничего — даже вычисления аргументов.
 * Собирается дважды: с -DDLOG_BUILD=4 и с -DDLOG_BUILD=3. */
#include <stdio.h>
#define DLOG_FILE cut35g
#include "dlog35g.h"

static unsigned calls;

static unsigned next_sample(void)
{
    return ++calls;
}

void dlog_put(uint32_t hdr, ...)
{
    (void)hdr;
}

int main(void)
{
    for (int i = 0; i < 10; i++)
        DLOG_D("sample %u", next_sample());
    printf("DLOG_BUILD=%d next_sample called %u times\n", DLOG_BUILD, calls);
    return 0;
}

dlog_gen.py — словарь журнала: находит вызовы DLOG_* в исходниках и выдаёт номера сообщений.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Раздел 35г: словарь журнала. Обходит исходники, находит вызовы DLOG_E/W/I/D("формат", ...),
выдаёт каждой строке формата постоянный номер и пишет два файла: словарь для компьютера (JSON)
и dlog_ids.h для компилятора.

    python3 dlog_gen.py dlog_dict.json dlog_ids.h app35g.c main35g.c

Номер привязан к тексту формата, а не к месту в файле: строки можно двигать. Исчезнувший формат
остаётся в словаре с пометкой "used": false, его номер заново не выдаётся."""
import json, os, re, sys, zlib

SERVICE = ["lost %u", "start, dictionary %08x"]          # номера 0 и 1 — служебные
ID_MAX, ARGS_MAX = 0x3FFF, 7
SKIP = r'//[^\n]*|/\*.*?\*/'                             # комментарии
LIT = r'"(?:\\.|[^"\\\n])*"'                             # строка в кавычках
TOKEN = re.compile(SKIP + '|' + LIT + r"|'(?:\\.|[^'\\\n])*'|\bDLOG_[EWID]\s*\(|[()]", re.S)
FIRST = re.compile(r'(?:\s|' + SKIP + r')*(' + LIT + ')', re.S)
CONV = re.compile(r'%(?:%|[-+ #0]*[0-9]*([diuxXc]))')    # что умеет декодер
ESC = {'n': '\n', 't': '\t', '\\': '\\', '"': '"', "'": "'"}


def count_args(fmt):
    """Сколько аргументов берёт формат; неподдержанное преобразование — ошибка."""
    n = i = 0
    while (i := fmt.find('%', i)) >= 0:
        m = CONV.match(fmt, i)
        if not m:
            raise ValueError("unsupported conversion in \"%s\"" % fmt)
        n += m.group(1) is not None
        i = m.end()
    if n > ARGS_MAX:
        raise ValueError("more than %d arguments in \"%s\"" % (ARGS_MAX, fmt))
    return n


def unquote(lit):
    """Литерал C → текст: тот, что увидит sizeof."""
    def one(m):
        if m.group(1) not in ESC:
            raise ValueError("unsupported escape \\%s" % m.group(1))
        return ESC[m.group(1)]
    return re.sub(r'\\(.)', one, lit[1:-1])


def scan(path):
    """Вызовы в одном файле: [(первая строка, последняя строка, формат)] и имя DLOG_FILE."""
    text = open(path, encoding="utf-8").read()
    name = re.search(r'^[ \t]*#[ \t]*define[ \t]+DLOG_FILE[ \t]+(\w+)', text, re.M)
    line = lambda pos: text.count("\n", 0, pos) + 1
    calls, it = [], TOKEN.finditer(text)
    for m in it:
        if not m.group().startswith("DLOG_"):
            continue
        where = "%s:%d" % (os.path.basename(path), line(m.start()))
        first = FIRST.match(text, m.end())
        if not first:
            raise ValueError("%s: format must be a string literal" % where)
        depth, end = 1, None
        for t in it:                                     # до парной закрывающей скобки
            depth += {"(": 1, ")": -1}.get(t.group(), 0)
            if depth == 0:
                end = t.start()
                break
        if end is None or not name:
            raise ValueError("%s: %s" % (where, "no DLOG_FILE in this file" if end else "unbalanced brackets"))
        calls.append((line(m.start()), line(end), unquote(first.group(1))))
    return name.group(1) if name else None, calls


def fingerprint(msgs):
    """Отпечаток словаря: CRC-32 по всем парам «номер, формат»."""
    return zlib.crc32("".join("%d\t%s\n" % (m["id"], m["fmt"]) for m in msgs).encode("utf-8"))


def dump(d):
    rows = ",\n".join("  " + json.dumps(m, ensure_ascii=False) for m in d["messages"])
    return '{\n "format": 1,\n "crc": "%s",\n "history": %s,\n "messages": [\n%s\n ]\n}\n' % (
        d["crc"], json.dumps(d["history"]), rows)


def main(dict_path, header_path, sources):
    if os.path.exists(dict_path):
        d = json.load(open(dict_path, encoding="utf-8"))
    else:
        d = {"crc": None, "history": [], "messages": [
            {"id": i, "fmt": f, "args": count_args(f), "used": True} for i, f in enumerate(SERVICE)]}
    msgs = d["messages"]
    by_fmt = {m["fmt"]: m for m in msgs}
    for m in msgs[len(SERVICE):]:
        m["used"] = False                                # отметим заново те, что встретятся
    lines, taken, new, ncalls = [], {}, 0, 0
    for path in sources:
        name, calls = scan(path)
        for first, last, fmt in calls:
            ncalls += 1
            if fmt not in by_fmt:                        # новый формат — следующий свободный номер
                by_fmt[fmt] = {"id": msgs[-1]["id"] + 1, "fmt": fmt, "args": count_args(fmt), "used": True}
                msgs.append(by_fmt[fmt])
                new += 1
            m = by_fmt[fmt]
            m["used"] = True
            if m["id"] > ID_MAX:
                raise ValueError("more than %d messages" % ID_MAX)
            for ln in sorted({first, last}):             # gcc берёт __LINE__ первой строки вызова, clang — последней
                if taken.setdefault((name, ln), fmt) != fmt:
                    raise ValueError("%s:%d: two DLOG calls on one line" % (os.path.basename(path), ln))
                lines.append("#define DLOG_H_%s_%d 0x%05Xu\n#define DLOG_L_%s_%d %d\n" % (
                    name, ln, m["id"] | m["args"] << 16, name, ln, len(fmt.encode("utf-8"))))
    crc = "0x%08x" % fingerprint(msgs)
    if d["crc"] not in (None, crc):
        d["history"].append(d["crc"])                    # прошивки со старым отпечатком словарь знает
    d["crc"] = crc
    head = "/* Создан dlog_gen.py — не править. H: номер | число аргументов << 16; L: длина формата. */\n"
    for path, body in ((dict_path, dump(d)), (header_path, head + "#define DLOG_DICT_CRC %su\n" % crc + "".join(lines))):
        if not os.path.exists(path) or open(path, encoding="utf-8").read() != body:
            open(path, "w", encoding="utf-8", newline="\n").write(body)
    used = sum(m["used"] for m in msgs) - len(SERVICE)
    print("dlog_gen: %d calls in %d files; messages: %d used, %d retired, %d new; next id %d; crc %s" % (
        ncalls, len(sources), used, len(msgs) - len(SERVICE) - used, new, msgs[-1]["id"] + 1, crc))


if __name__ == "__main__":
    if len(sys.argv) < 4:
        sys.exit(__doc__)
    try:
        main(sys.argv[1], sys.argv[2], sys.argv[3:])
    except ValueError as e:
        sys.exit("dlog_gen: error: %s" % e)

dlog_decode.py — декодер: поток байтов из линии → строки текста по словарю.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Раздел 35г: декодер журнала. Поток байтов из линии → строки текста по словарю dlog_gen.py.

    python3 dlog_decode.py dlog_dict.json uart.bin

Кадр: COBS(номер и уровень 2 байта, счётчик кадров 1, время 4, аргументы по 4, CRC-16 2) + 0x00."""
import json, re, struct, sys

LEVELS = "EWID"
ID_LOST, ID_START = 0, 1
CONV = re.compile(r'%(?:%|([-+ #0]*)([0-9]*)([diuxXc]))')


def crc16(data):
    """CRC-16/XMODEM: полином 0x1021, начальное 0 — как в прошивке."""
    c = 0
    for b in data:
        c ^= b << 8
        for _ in range(8):
            c = ((c << 1) ^ 0x1021 if c & 0x8000 else c << 1) & 0xFFFF
    return c


def cobs_decode(enc):
    """Обратное COBS; None — если код блока выводит за конец кадра."""
    out, r = bytearray(), 0
    while r < len(enc):
        code = enc[r]
        if r + code > len(enc):
            return None
        out += enc[r + 1:r + code]
        r += code
        if code != 0xFF and r < len(enc):
            out.append(0)
    return bytes(out)


def c_format(fmt, args):
    """printf для слов по 32 бита: d i u x X c, флаги - + пробел # 0, ширина."""
    it = iter(args)

    def one(m):
        if m.group(3) is None:
            return "%"
        flags, conv, v = m.group(1), m.group(3), next(it)
        if conv in "di":
            v -= (v & 0x80000000) << 1                   # слово как число со знаком
        if conv == "c":
            return ("%" + flags.replace("0", "") + m.group(2) + "c") % chr(v & 0xFF)
        if conv in "xX" and v == 0:
            flags = flags.replace("#", "")               # в C «%#x» от нуля — просто 0
        return ("%" + flags + m.group(2) + conv.replace("u", "d")) % v
    return CONV.sub(one, fmt)


class Decoder:
    def __init__(self, dictionary):
        self.msgs = {m["id"]: m for m in dictionary["messages"]}
        self.known = {dictionary["crc"]: "same"}
        self.known.update((c, "older firmware") for c in dictionary["history"])
        self.buf = bytearray()
        self.seq = None                                  # счётчик следующего кадра; None — ещё не знаем
        self.st = dict(frames=0, empty=0, bad_cobs=0, bad_len=0, bad_crc=0, gaps=0, missed=0,
                       unknown=0, mismatch=0, device_lost=0, starts=0)

    def feed(self, data):
        """Принять кусок потока; вернуть готовые строки."""
        lines = []
        for b in data:
            if b:
                self.buf.append(b)
                continue
            frame, self.buf = bytes(self.buf), bytearray()       # ноль — конец кадра
            if not frame:
                self.st["empty"] += 1                    # два нуля подряд: пустой кадр, не ошибка
                continue
            line = self.frame(frame, lines)
            if line:
                lines.append(line)
        return lines

    def frame(self, enc, lines):
        st = self.st
        raw = cobs_decode(enc)
        if raw is None:
            st["bad_cobs"] += 1
        elif len(raw) < 9 or len(raw) > 37 or (len(raw) - 9) % 4:
            st["bad_len"] += 1
        elif crc16(raw[:-2]) != raw[-2] << 8 | raw[-1]:
            st["bad_crc"] += 1
        else:
            st["frames"] += 1
            idlvl, seq = struct.unpack_from("<HB", raw)
            t, *args = struct.unpack_from("<%dI" % ((len(raw) - 5) // 4), raw, 3)
            mid, lvl = idlvl & 0x3FFF, LEVELS[idlvl >> 14]
            if mid == ID_START and len(args) == 1:       # прибор запустился: счётчик кадров с нуля
                st["starts"] += 1
                self.seq = 0
            if self.seq is not None and seq != self.seq:
                n = (seq - self.seq) & 0xFF              # сколько кадров не дошло (по модулю 256)
                st["gaps"] += 1
                st["missed"] += n
                lines.append("-- line: %d frame(s) missing" % n)
            self.seq = (seq + 1) & 0xFF
            m = self.msgs.get(mid)
            if m is None:                                # формата нет в словаре: аргументы — как есть
                st["unknown"] += 1
                return "%u %s ?id=%u" % (t, lvl, mid) + "".join(" %08x" % a for a in args)
            if m["args"] != len(args):                   # словарь не от этой прошивки
                st["mismatch"] += 1
                return "%u %s ?id=%u wants %u args:" % (t, lvl, mid, m["args"]) + "".join(" %08x" % a for a in args)
            text = c_format(m["fmt"], args)
            if mid == ID_LOST:
                st["device_lost"] += args[0]
            if mid == ID_START:
                text += " (%s)" % self.known.get("0x%08x" % args[0], "unknown to this dictionary")
            return "%u %s %s" % (t, lvl, text)
        return None


def decode(data, dictionary):
    d = Decoder(dictionary)
    return d.feed(data), d.st


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit(__doc__)
    out, st = decode(open(sys.argv[2], "rb").read(), json.load(open(sys.argv[1], encoding="utf-8")))
    sys.stdout.write("".join(s + "\n" for s in out))
    sys.stderr.write("frames %(frames)d, bad crc %(bad_crc)d, bad cobs %(bad_cobs)d, bad length %(bad_len)d, "
                     "missing %(missed)d, lost on device %(device_lost)d, unknown id %(unknown)d\n" % st)

Дальше: раздел 35д «Как отладчик управляет ядром: SWD, порты DP и AP, остановка ядра и точки останова FPB» — что идёт по двум проводам SWD, когда отладчик читает память, останавливает ядро и ставит точку останова.

Открыть в интерактивном справочнике