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

189а. Обновление прошивки из мобильного приложения: от кнопки в Flutter до загрузчика STM32

В разделе 48 загрузчик принимал «поток байт» от кого-то снаружи, в 189 приложение научилось говорить с прибором по BLE и кабелю. Здесь эти половины сходятся: кнопка «Обновить» на телефоне, файл прошивки, кадры через радио, загрузчик, который пишет Flash, и новая прошивка, которая должна сама подтвердить, что работает. Если не подтвердит — прибор возвращается на прежнюю.

Весь путь собран своим кодом с обеих сторон: клиент на Dart, экран на Flutter, загрузчик на C. Проверен он на компьютере: клиент говорит с моделью прибора, где работает тот же C-код загрузчика, а Flash и канал BLE заменены моделями с честно названными параметрами. Модель роняет связь посреди передачи, теряет посылки и выключает питание прибора на каждой из 2069 операций Flash по очереди.

Опоры: раздел 48 (раскладка, заголовок образа, 48.6 — как приложение просит перепрошивку, 48.8 — подтверждение), 48а (подпись), 195 (A/B, подтверждение и откат), 194 (кадры COBS и CRC-16), 185–189 (Dart, асинхронность, экран, транспорт).

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

Оригинал Перевод
device firmware update (DFU) обновление прошивки прибора
image, slot образ, область под образ
chunk, block, offset кусок, блок, смещение
acknowledge (ACK) подтверждение приёма
stop-and-wait «послал — жди ответа»: один блок в пути
resume продолжение с места обрыва
trial boot, confirm, rollback пробный запуск, подтверждение, откат
write with response запись с подтверждением
connection interval интервал связи BLE
app lifecycle (paused, resumed) жизненный цикл приложения: ушло в фон, вернулось

189а.1 Весь путь одной картинкой

Последовательность обновления: приложение на телефоне проверяет файл, спрашивает прибор INFO, просит приложение прибора уйти в загрузчик, загрузчик стирает свободную область, принимает блоки с подтверждением, проверяет CRC и пишет метку последней, перезапускается в новую прошивку на пробу; она подтверждает себя, иначе сторож и откат на прежнюю версию

Четыре участника и одно правило на каждого:

Кто Что делает Чего не делает никогда
приложение на телефоне проверяет файл до отправки, ведёт передачу, показывает состояние не шлёт образ, который прибору не подходит
транспорт (раздел 189) режет кадры по MTU, держит одну запись за раз не знает, что везёт прошивку
приложение прибора отвечает «кто я», по просьбе уходит в загрузчик, подтверждает себя не пишет чужую область Flash
загрузчик стирает свободную область, принимает, проверяет, выбирает, что запускать не стирает рабочую область и себя

Почему принимает загрузчик, а не приложение. Вариантов два: приложение прибора пишет вторую область само, на ходу (так в разделе 195 делает обновитель Linux), или приложение только ставит признак 48.6 и перезапускается в загрузчик, а принимает он. Здесь выбран второй. Приём один на все версии приложения и работает даже тогда, когда годного приложения нет вовсе: прибор, потерявший оба образа, остаётся в загрузчике и ждёт файл. Приложение от этого проще — ему не нужен ни драйвер Flash, ни разбор блоков. Цена — прибор на время обновления не делает свою работу, а загрузчик вырастает на приём и разбор кадров (размеры — 189а.6). Если остановка недопустима, берут первый путь, и приём из этого раздела переезжает в приложение.

Две области и два образа на выпуск. Образ собран под свой адрес и из чужой области не запустится (48.2). Здесь выбран путь «два образа на выпуск»: сборка даёт файл под область A и файл под B, прибор говорит, какая свободна, телефон шлёт подходящий. Прежняя прошивка остаётся на месте до самого подтверждения новой — откатываться есть куда, и ничего не копируется.

Готовые решения, если свой протокол не нужен: у MCUboot есть протокол управления SMP (MCUmgr), у Nordic — свой DFU для их радиокристаллов, у ST — заводской загрузчик в системной памяти (48.11). Большой образ по медленному каналу можно передавать разностью — раздел 48в. Целиком свой загрузчик серийного прибора на F405 собран в мини-проекте 198щ.


189а.2 Протокол поверх кадров 194

Кадр — из раздела 194: сообщение, к нему CRC-16/XMODEM старшим байтом вперёд, всё в COBS, в конце ноль. Кадр раздела 185 сюда не годится: в нём не больше 32 байт и CRC-8 — для команд хватает, для прошивки мало. Одно добавление: ведущий ноль перед каждым кадром с обеих сторон (189а.6 — зачем).

Числа в сообщениях — младшим байтом вперёд. Ответ — та же команда с поднятым старшим битом и байт состояния.

Команда Код Поля запроса Ответ Кто отвечает
INFO 0x01 — режим, плата, версия, состояние, свободная область, её размер, наибольший блок приложение и загрузчик
ENTER 0x02 — состояние; затем признак .noinit и сброс приложение
BEGIN 0x10 область, размер тела, CRC-32 тела, версия состояние, с какого смещения слать загрузчик
BLOCK 0x11 смещение, данные (кратно 4, до 240 байт) состояние, следующее ожидаемое смещение загрузчик
END 0x12 — состояние: CRC сошлась, метка записана загрузчик
REBOOT 0x13 — состояние; затем сброс загрузчик

Состояния: OK, BAD_CMD, BAD_STATE (не тот режим — сеанса приёма нет), BAD_SLOT, BAD_SIZE, OLD_VERSION, BAD_OFFSET, BAD_CRC, FLASH, BAD_HEADER. На каждое у телефона своя фраза для экрана.

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

Размер блока. Кадр блока длиннее данных ровно на 10 байт: ведущий ноль, команда, четыре байта смещения, два байта CRC, байт COBS и завершающий ноль. Наибольший блок, который входит в одну посылку, — полезная часть посылки минус 10, округлённая вниз до кратного четырём: 8 байт при MTU 23, 172 при MTU 185, 232 при MTU 247. Странице Flash блок кратным быть не обязан: стирание делает BEGIN заранее, а запись идёт словами. Обязательна только кратность четырём — у F407 запись словом, у F103 слово пишется двумя полусловами.

Сроки ответа. Блок — 1 с, INFO и прочие короткие — 0,5 с, END — 2 с, а BEGIN — 8 с: он стирает область. У F103 это 33 страницы по 20…40 мс (DocID13587, табл. 28), у F407 сектор 128 КБ стирается 1 с типично и до 2 с (DS8626, табл. 40, запись словом), а область из трёх секторов — до 6 с. Срок ожидания BEGIN, взятый как у блока, означает, что телефон сдастся посреди стирания и начнёт всё сначала.


189а.3 Телефон: файл, проверки до отправки, передача

Образ — тот же, что в 48.3: заголовок 512 байт и тело. Два слова reserved из 48.3 стали ячейками «запускался на пробу» и «подтверждён» (48.8), в первом слове дополнения лежит код платы. В файле со сборки все три ячейки стёрты — 0xFFFFFFFF.

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

/// Годится ли файл этому прибору. null — годится, иначе причина для экрана.
String? checkImage(Uint8List f, DeviceInfo dev) {
  if (f.length < imgHdrSize + 8) return 'файл короче заголовка образа';
  final ImageHeader h = ImageHeader(f);
  if (h.magic != imgMagic) return 'это не образ прошивки: нет метки STM3';
  if (h.size != f.length - imgHdrSize) return 'длина в заголовке ${h.size}, а в файле ${f.length - imgHdrSize}';
  if (crc32(f, imgHdrSize) != h.crc) return 'файл повреждён: CRC не сходится';
  if (h.tried != cellErased || h.ok != cellErased) return 'в файле стоят отметки запуска: это снимок с прибора, а не сборка';
  if (h.board != dev.board) return 'образ для платы ${hex(h.board)}, а прибор — ${hex(dev.board)}';
  if (h.size > dev.slotSize - imgHdrSize) return 'образ ${h.size} байт не помещается в область';
  if (h.entry != h.pc || (h.pc & 1) == 0 || h.pc < dev.freeSlot + imgHdrSize || h.pc >= dev.freeSlot + dev.slotSize) {
    return 'образ собран под другой адрес: свободна область ${hex(dev.freeSlot)}';
  }
  if (h.version <= dev.version) return 'версия ${h.version} не новее версии ${dev.version} на приборе';
  return null;
}

Выпуск — два файла; телефон берёт тот, чья точка входа попадает в свободную область, о которой сказал прибор:

/// Выпуск — два образа, под область A и под B (48.2); берём тот, что под свободную.
Uint8List? pickImage(List<Uint8List> release, DeviceInfo dev) {
  for (final Uint8List f in release) {
    if (f.length < imgHdrSize + 8) continue;
    final int pc = get32(f, imgHdrSize + 4);
    if (pc >= dev.freeSlot + imgHdrSize && pc < dev.freeSlot + dev.slotSize) return f;
  }
  return null;
}

Кадр на отправку и приёмник ответов — повторение 194 на Dart, с ведущим нулём:

/// Кадр на отправку. Ведущий ноль закрывает обрывок прошлого кадра,
/// если его посылка потерялась: иначе прибор склеит обрывок с этим кадром.
Uint8List encodeMessage(List<int> msg, {bool leadingZero = true}) {
  final int c = crc16(msg);
  final List<int> body = cobsEncode(<int>[...msg, c >> 8, c & 0xFF]);
  return Uint8List.fromList(<int>[if (leadingZero) 0, ...body, 0]);
}

Запрос с повтором: после четырёх таймаутов подряд — переподключение и снова; три круга без ответа — ошибка «прибор перестал отвечать».

/// Запрос с повторами по таймауту; после [tries] неудач — переподключение и снова.
Future<Uint8List> _ask(List<int> msg, Duration timeout, {int payload = 0}) async {
  for (int round = 0; round < 3; round++) {
    for (int n = 0; n < tries; n++) {
      stats.requests++;
      stats.payload += payload;
      try {
        final Uint8List? r = await port.request(msg, timeout);
        if (r != null) return r;
        stats.timeouts++;
      } on LinkLost {
        break;                                  // писать некуда — сразу переподключаться
      }
    }
    await _reconnect();
  }
  throw const _Fatal('прибор перестал отвечать');
}

Сама процедура. Если прибор ответил BAD_STATE, значит, он перезапускался и сеанс пропал — всё начинается заново, не больше трёх раз:

Future<void> _attempt() async {
  _set(const UpdateStatus(Phase.connecting));
  DeviceInfo info = await _info();
  _set(const UpdateStatus(Phase.checking));
  Uint8List f = _choose(info);                  // до перезапуска: не тот файл — прибор не трогаем
  if (info.mode == DeviceInfo.app) {
    _set(const UpdateStatus(Phase.entering));
    await _ask(<int>[Cmd.enter], timeouts.short);
    info = await _info();
    if (info.mode != DeviceInfo.loader) throw const _Fatal('прибор не перешёл в загрузчик');
    f = _choose(info);
  }
  final ImageHeader h = ImageHeader(f);

  final Ack? begin = parseAck(await _ask(msgBegin(info.freeSlot, h.size, h.crc, h.version), timeouts.begin), Cmd.begin);
  if (begin == null) throw const _Fatal('прибор ответил не по протоколу');
  if (begin.status == Status.badState) throw const _Restart();
  if (begin.status != Status.ok) throw _Fatal(statusText[begin.status]!);

  int next = begin.next;                        // не ноль — прибор помнит, докуда дошли
  final int from = next;
  final Duration t0 = port.now;
  _set(UpdateStatus(Phase.sending, sent: next, total: f.length));
  while (next < f.length) {
    final int end = next + block < f.length ? next + block : f.length;
    final List<int> data = f.sublist(next, end);
    while (data.length % 4 != 0) {
      data.add(0xFF);                           // хвост до слова: стёртое прибор не пишет
    }
    final Ack? ack = parseAck(await _ask(msgBlock(next, data), timeouts.block, payload: data.length), Cmd.block);
    if (ack == null) throw const _Fatal('прибор ответил не по протоколу');
    if (ack.status == Status.badState) throw const _Restart();   // прибор перезапускался: сеанса нет
    if (ack.status != Status.ok) throw _Fatal(statusText[ack.status]!);
    next = ack.next;
    final double s = (port.now - t0).inMicroseconds / 1e6;
    _set(UpdateStatus(Phase.sending, sent: next, total: f.length, bytesPerSecond: s > 0 ? (next - from) / s : 0));
  }

  _set(UpdateStatus(Phase.verifying, sent: f.length, total: f.length));
  final Status? st = parseStatus(await _ask(<int>[Cmd.end], timeouts.end), Cmd.end);
  if (st == Status.badState) throw const _Restart();
  if (st != Status.ok) throw _Fatal(st == null ? 'прибор ответил не по протоколу' : statusText[st]!);

  _set(const UpdateStatus(Phase.restarting));
  try {
    await port.request(<int>[Cmd.reboot], timeouts.short);   // ответ может и не прийти: прибор уже в сбросе
  } on LinkLost {
    // переподключится _ask ниже
  }

  _set(const UpdateStatus(Phase.confirming));
  for (int n = 0; n < 5; n++) {
    info = await _info();
    if (info.mode == DeviceInfo.app && info.version == h.version && info.state == DeviceInfo.confirmed) {
      _set(UpdateStatus(Phase.done, sent: f.length, total: f.length));
      return;
    }
    if (info.mode == DeviceInfo.app && info.version != h.version) {
      throw _Fatal('прибор вернулся на версию ${info.version}: новая не подтвердила себя или пропадало питание');
    }
    await Future<void>.delayed(timeouts.poll);
  }
  throw const _Fatal('прибор не подтвердил новую прошивку');
}

Под этим кодом — FwPort с двумя методами: «запрос — ответ за срок» и «переподключиться». Настоящая реализация стоит на интерфейсе Transport из 189: кадр режет по MTU BleTransport, и очередь записей та же. В проверке этот путь пройден целиком: BleTransport с MTU 20 и настоящими таймаутами поверх модели прибора довёл обновление до конца, самая длинная запись — 20 байт.


189а.4 Прибор: приём, метка последней, выбор и откат

Загрузчик — fwup.c. Начало сеанса: проверить, что область та, что свободна, размер помещается, версия новее подтверждённой, — и стереть.

static uint8_t on_begin(const uint8_t *p, uint32_t n)
{
    if (n != 16u)
        return ST_BAD_CMD;
    uint32_t origin = rd32(p), size = rd32(p + 4), crc = rd32(p + 8), ver = rd32(p + 12);
    if ((s.active || s.done) && origin == s.origin && size == s.size && crc == s.crc && ver == s.version)
        return ST_OK;                          /* тот же образ после обрыва: продолжаем с s.next */
    uint32_t best = best_confirmed();
    if (origin != free_slot())
        return ST_BAD_SLOT;
    if (size == 0u || size > SLOT_SIZE - IMG_HDR_SIZE)
        return ST_BAD_SIZE;
    if (best != 0u && ver <= HDR(best)->version)
        return ST_OLD_VERSION;
    s.active = 0;
    s.done = 0;
    if (flash_erase_range(origin, IMG_HDR_SIZE + size) != 0)
        return ST_FLASH;
    s.origin = origin; s.size = size; s.crc = crc; s.version = ver;
    s.next = 0;
    s.active = 1;
    return ST_OK;
}

Блок. Метка magic и стёртые слова не пишутся: стёртое и так стёрто, а метка появится последней, когда всё сошлось.

static uint8_t on_block(const uint8_t *p, uint32_t n)
{
    if (!s.active)
        return ST_BAD_STATE;
    if (n < 8u || ((n - 4u) & 3u) != 0u || n - 4u > FW_MAX_BLOCK)
        return ST_BAD_CMD;
    uint32_t off = rd32(p), len = n - 4u;
    if (off != s.next)
        return off < s.next ? ST_OK : ST_BAD_OFFSET;   /* повтор уже записанного — просто ответить */
    if (off + len > ((IMG_HDR_SIZE + s.size + 3u) & ~3u))
        return ST_BAD_SIZE;
    for (uint32_t i = 0; i < len; i += 4u) {
        uint32_t w = rd32(p + 4 + i);
        if (off + i == 0u || w == CELL_ERASED)
            continue;                          /* магия — в самом конце; стёртое не пишем */
        if (flash_prog_word(s.origin + off + i, w) != 0) {
            s.active = 0;
            return ST_FLASH;
        }
    }
    s.next = off + len;
    return ST_OK;
}

Конец: заголовок сверяется с BEGIN, CRC тела считается по тому, что реально записано во Flash, и только потом пишется метка. До этой записи загрузчик область не видит вовсе — пропало питание на любом байте, и image_valid её отвергнет на первой же проверке.

static uint8_t on_end(void)
{
    const img_hdr_t *h = HDR(s.origin);
    if (s.done)
        return ST_OK;                          /* ответ на первый END потерялся */
    if (!s.active || s.next < IMG_HDR_SIZE + s.size)
        return ST_BAD_STATE;
    if (h->size != s.size || h->crc != s.crc || h->version != s.version || h->board != BOARD_ID ||
        h->entry < s.origin + IMG_HDR_SIZE || h->entry >= s.origin + SLOT_SIZE)
        return ST_BAD_HEADER;
    if (crc32((const void *)(uintptr_t)(s.origin + IMG_HDR_SIZE), s.size) != s.crc)
        return ST_BAD_CRC;
    s.active = 0;
    if (flash_prog_word(s.origin, IMG_MAGIC) != 0)     /* последним: теперь образ виден */
        return ST_FLASH;
    if (!image_valid(s.origin))
        return ST_BAD_HEADER;
    s.done = 1;
    return ST_OK;
}

Выбор, что запускать, — при каждом старте. Свежий образ (не запускался) новее подтверждённого — сначала отметка «на пробу», потом запуск. Образ с отметкой, но без подтверждения, — проба не удалась: загрузчик пишет нули в его метку и запускает подтверждённый.

/* ── Что запускать: подтверждённый новейший или свежий на пробу ─────── */
static uint32_t best_confirmed(void)
{
    uint32_t best = 0;
    for (uint32_t s = SLOT_A; s <= SLOT_B; s += SLOT_SIZE)
        if (image_valid(s) && HDR(s)->ok != CELL_ERASED &&
            (best == 0u || HDR(s)->version > HDR(best)->version))
            best = s;
    return best;
}

uint32_t fw_select(void)
{
    uint32_t fresh = 0;
    for (uint32_t s = SLOT_A; s <= SLOT_B; s += SLOT_SIZE) {
        if (!image_valid(s) || HDR(s)->ok != CELL_ERASED)
            continue;
        if (HDR(s)->tried != CELL_ERASED)
            flash_prog_word(s, 0u);            /* проба была и не подтвердилась: образ списан */
        else
            fresh = s;
    }
    uint32_t best = best_confirmed();
    if (fresh != 0u && (best == 0u || HDR(fresh)->version > HDR(best)->version)) {
        flash_prog_word(fresh + offsetof(img_hdr_t, tried), 0u);   /* сначала отметка, потом запуск */
        return fresh;
    }
    return best;
}

Все ячейки только дописываются: из стёртого состояния в записанное, а «списать» образ — записать нули в метку: ноль можно записать и поверх записанного — у F103 это единственное исключение из запрета писать в нестёртое, у F407 запись и так даёт побитовое «И» (раздел 4). Ни одна отметка не требует стирания — стирание страницы с заголовком стёрло бы и начало образа (48.8).

Сторона приложения прибора — короткая: ответить на INFO, по ENTER поставить признак 48.6 и сброситься, подтвердить себя.

void fw_confirm(uint32_t slot)
{
    if (HDR(slot)->ok == CELL_ERASED)
        flash_prog_word(slot + offsetof(img_hdr_t, ok), 0u);
}

void app_handle(const uint8_t *msg, uint32_t n, uint32_t my_slot)
{
    uint8_t out[24], frame[32];
    uint32_t len = 2;
    if (n == 0u)
        return;
    fw_confirm(my_slot);                 /* телефон до нас достучался — живы */
    out[0] = REPLY(msg[0]);
    switch (msg[0]) {
    case CMD_INFO:  len = fw_info(MODE_APP, my_slot, out); break;
    case CMD_ENTER: out[1] = ST_OK; break;
    default:        out[1] = ST_BAD_STATE; break;   /* приём образа — только в загрузчике */
    }
    frame[0] = 0;                        /* ведущий ноль: закрыть обрывок чужого кадра */
    fw_tx(frame, 1u + frame_encode(out, len, frame + 1));
    if (msg[0] == CMD_ENTER) {
        reboot_flag = REBOOT_MAGIC;      /* 48.6: признак в .noinit и сброс */
        fw_reset();
    }
}

Здесь приложение подтверждает себя на первом целом кадре от телефона — это и есть проверка «связь работает». В настоящем приборе к ней добавляют свои: датчики отвечают, нагрузка управляется (195.4). Подтверждение раньше проверки здоровья делает откат бесполезным.

Драйвер Flash для F103 — регистры из RM0008, имена — из CMSIS:

static int prog_half(uint32_t addr, uint16_t v)
{
    FLASH->CR |= FLASH_CR_PG;
    *(volatile uint16_t *)addr = v;
    int rc = wait_ready();
    FLASH->CR &= ~FLASH_CR_PG;
    return (rc != 0 || *(volatile uint16_t *)addr != v) ? -1 : 0;
}

int flash_prog_word(uint32_t addr, uint32_t value)
{
    int rc;
    if (addr < SLOT_A || (addr & 3u))
        return -1;
    unlock();
    rc = prog_half(addr, (uint16_t)value);
    if (rc == 0)
        rc = prog_half(addr + 2u, (uint16_t)(value >> 16));
    FLASH->CR |= FLASH_CR_LOCK;
    return rc;
}

У F407 то же самое словом при PSIZE = x32, а стирание — по номеру сектора (FLASH_CR_SNB), который считается по карте 16/64/128 КБ из 48.2.

Подпись. CRC защищает от порчи, не от подмены. Проверка подписи из 48а ложится в on_end перед записью метки: образ без верной подписи метку не получит и запущен не будет. Здесь её нет, чтобы размеры и числа остались про сам протокол.


189а.5 Экран обновления во Flutter

Экран подписан на поток состояний Updater и показывает одну фразу на каждое — без технических слов:

const Map<Phase, String> phaseText = <Phase, String>{
  Phase.connecting: 'Подключение к прибору',
  Phase.checking: 'Проверка файла прошивки',
  Phase.entering: 'Прибор переходит в загрузчик',
  Phase.sending: 'Передача прошивки',
  Phase.verifying: 'Прибор проверяет прошивку',
  Phase.restarting: 'Перезапуск прибора',
  Phase.confirming: 'Ждём подтверждения новой прошивки',
  Phase.done: 'Готово: новая прошивка работает',
  Phase.failed: 'Обновление не удалось',
};

Уход приложения в фон экран узнаёт от WidgetsBindingObserver:

void didChangeAppLifecycleState(AppLifecycleState state) {
  setState(() => background = state != AppLifecycleState.resumed);
}

Построение: процент и скорость при передаче, причина при ошибке. Кнопка заблокирована, пока идёт обновление, «Назад» не уводит с экрана, а уход в фон даёт предупреждение:

Widget build(BuildContext context) {
  final UpdateStatus? v = s;
  return PopScope(
    canPop: !running,                          // «Назад» посреди передачи не уводит с экрана
    child: Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Text(v == null ? 'Прибор подключён' : phaseText[v.phase]!, key: const Key('phase')),
            if (v != null && v.phase == Phase.sending) ...<Widget>[
              LinearProgressIndicator(value: v.percent / 100),
              Text('${v.percent.round()} %  ·  ${ru1(v.bytesPerSecond / 1024)} КБ/с', key: const Key('progress')),
            ],
            if (v != null && v.phase == Phase.failed)
              Text(v.reason, key: const Key('reason'), style: const TextStyle(color: Colors.red)),
            if (running && background)
              const Text('Не сворачивайте приложение, пока идёт обновление', key: Key('background')),
            ElevatedButton(
              key: const Key('start'),
              onPressed: running ? null : widget.onStart,
              child: const Text('Обновить'),
            ),
          ],
        ),
      ),
    ),
  );
}

Четыре виджет-теста без телефона: «38 % · 3,9 КБ/с» при передаче и запрет повторного нажатия, причина ошибки на экране и кнопка снова доступна, PopScope не отпускает посреди проверки на приборе, предупреждение при уходе в фон и его исчезновение при возврате. Не давать экрану гаснуть во время передачи — задача платформенного плагина; в проверку это не входит. Разрешения BLE — 189.5.


189а.6 Опыт на компьютере: время, потери, обрывы, откат

Клиент на Dart говорит с моделью прибора через dart:ffi: в библиотеке, собранной gcc, работает тот же fwup.c и тот же fw_app.c, а Flash модели лежит по настоящему адресу 0x08000000 — загрузчик читает образ обычными указателями. Правила записи у модели — как у камня: у F103 полуслово пишется только в стёртое, кроме нуля; у F407 бит можно только сбросить. Времена — из документации:

F103 (DocID13587) F407 (DS8626, x32)
единица стирания страница 1 КБ, 40 мс (наибольшее из 20…40) сектор 128 КБ, 1 с (типично)
запись полуслово 52,5 мкс слово 16 мкс

Модель канала — не замер радио, а договорённость, и её параметры названы: каждая посылка (запись с подтверждением или уведомление) занимает один интервал связи 15 мс; модуль BLE отдаёт байты микроконтроллеру по UART на 115 200 бод; переподключение — 2 с. Абсолютные секунды зависят от этих чисел; соотношения между строками — от протокола.

Время обновления. Тело образа 32 КБ, файл 33 280 байт, F103, без потерь. Время — от первого INFO до «Готово», включая стирание 1,32 с; скорость — на передаче блоков.

MTU Блок, байт Посылок на блок Время, с Скорость, КБ/с
23 8 — в одну посылку 1 137,73 0,24
23 16 2 102,74 0,32
23 64 4 45,30 0,74
23 128 7 37,03 0,92
23 232 13 35,75 0,95
185 16 1 71,48 0,46
185 64 1 21,84 1,60
185 128 1 13,57 2,70
185 172 — в одну посылку 1 11,47 3,28
185 232 2 12,02 3,10
247 16 1 71,48 0,46
247 64 1 21,84 1,60
247 128 1 13,57 2,70
247 232 — в одну посылку 1 9,88 3,90

Из таблицы три вывода:

Тот же образ на F407: 8,83 с — одно стирание сектора за 1 с вместо 33 страниц по 40 мс, а четыре байта пишутся одним словом за 16 мкс вместо двух полуслов по 52,5 мкс.

Потери. Модель теряет посылку целиком с заданной вероятностью, в обе стороны. Потеря стоит таймаута — секунды на блок.

MTU, блок Потери Ведущий ноль Время, с Потеряно посылок Таймаутов Переподключений
247, 232 1 % да 12,91 3 3 0
247, 232 5 % да 26,06 16 16 0
23, 64 5 % да 204,86 155 150 0
23, 64 5 % нет 249,82 164 189 2

При MTU 247 кадр блока — одна посылка, и потерянная посылка уносит ровно один кадр. При MTU 23 кадр из четырёх посылок теряет середину, и прибор получает обрывок без завершающего нуля. Без ведущего нуля обрывок склеивается со следующим кадром — с повтором того же блока — и CRC губит и его: таймаутов 189 вместо 150, два переподключения, на 44,96 с дольше. Ведущий ноль закрывает обрывок до начала нового кадра — одна потеря стоит одного кадра.

Обрыв связи посреди передачи. На пятой секунде связь рвётся. Телефон переподключается (2 с модели), повторяет тот же блок, прибор отвечает «жду N» — и передача идёт дальше. Итог: 11,89 с вместо 9,88, одно переподключение, повторно ушло 232 байта — один блок.

Проверки до отправки. Прибор с версией 2; во всех четырёх случаях телефон отказал после единственного запроса INFO, прибор не перезапускался:

Что в файле Что на экране
код платы 0x104, прибор — 0x103 «образ для платы 0x00000104, а прибор — 0x00000103»
в выпуске только образ под область A «в выпуске нет образа под свободную область 0x08012000»
версия 2 — та же, что на приборе «версия 2 не новее версии 2 на приборе»
испорчен один байт тела «файл повреждён: CRC не сходится»

Если телефон проверку пропустит, прибор отвергнет сам: BEGIN с версией 1 поверх подтверждённой 2 получил ответ OLD_VERSION.

Обрыв питания. Сценарий: в области A — подтверждённая версия 2, в B — версия 1 от прошлого обновления, телефон ставит версию 3 в B. Чистый прогон с образом 4 КБ делает у F103 2069 операций Flash: стирание пяти страниц, запись тела и заголовка полусловами, метку, отметку «на пробу» и подтверждение. Опыт повторяет обновление 2069 раз и в каждом выключает питание на очередной операции — посреди неё: у стирания успевает половина страницы, у записи — младший байт. Через 3 с питание возвращается, телефон переподключается, а если сеанс пропал — пользователь жмёт «Обновить» ещё раз.

F103 F407
точек обрыва 2069 1033
после включения запустился целый образ (CRC сошлась) 2069 1033
первым запустилась прежняя версия 2 2067 1032
первым запустилась новая версия 3 2 1
обновление в итоге закончено версией 3 2069 1033
понадобилось второе нажатие «Обновить» 2 1

Ни в одной точке прибор не остался без загружаемой прошивки и ни разу не запустил половину образа. Новая версия запускалась сразу после обрыва только тогда, когда питание пропало на записи подтверждения: частично записанная ячейка уже не стёрта и считается записанной — приложение к этому моменту живо. Второе нажатие нужно, когда питание пропало на записи отметки «на пробу»: отметка недописана, загрузчик считает пробу неудачной, списывает образ и запускает версию 2. Это безопасно, но телефон видит возврат на прежнюю версию — поэтому фраза на экране честно называет обе причины: «прибор вернулся на версию 2: новая не подтвердила себя или пропадало питание».

Откат. Версия 3 зависает до подтверждения, сторож перезапускает прибор (в модели — через 1 с). Итог: три перезапуска — в загрузчик, в версию 3 на пробу, по сторожу; загрузчик списал версию 3 (её область больше не проходит проверку) и запустил версию 2; телефон показал «Обновление не удалось» и причину.

Сборка для камня. arm-none-eabi-gcc -Os -Wall -Wextra -Werror, загрузчик целиком — fwup.c, драйвер Flash, старт и приём по USART1:

F103, Cortex-M3 F407, Cortex-M4
Flash, байт 2532 2628
ОЗУ, байт 552 552
признак reboot_flag 0x20004FF0 0x2001FFF0

У F103 загрузчик занимает меньше шестой части отведённых ему 16 КБ. Подпись так просто не войдёт: в сборке 48а под Cortex-M4 проверка подписи добавила 17 248 байт — больше всех 16 КБ, отведённых загрузчику F103 в 48.2. Признак лежит по адресу общей области из 48.6, одинаковому для загрузчика и приложения.


189а.7 Грабли

Обновление «зависает» на первом же шаге у F407. Срок ответа BEGIN взят как у блока. Стирание трёх секторов длится до 6 с — срок на BEGIN считают от размера области.

При малом MTU потери обходятся вдвое дороже, чем должны. Обрывок потерянного кадра склеивается со следующим, и CRC губит оба. Ведущий ноль перед каждым кадром — с обеих сторон.

После переподключения передача начинается с нуля. Блоки пронумерованы, а прибор не говорит, что ждёт. Смещение в запросе и «жду N» в ответе, BEGIN узнаёт тот же образ.

Повтор блока портит Flash. Прибор пишет блок, ответ на который потерялся, второй раз; у F103 это PGERR, а у F407 — тихое «И» старого с новым (раздел 4). Блок раньше ожидаемого — только ответить.

Пропало питание — прибор не загружается. Метку записали первой, вместе с заголовком. Метка — последней, после проверки CRC по записанному.

Новая прошивка зависла — и так навсегда. Нет отметки «на пробу» перед запуском или нет сторожа: зависший образ не перезапускается, попытка не засчитывается (195.4).

Откат не срабатывает. Приложение подтверждает себя первой строкой main. Сначала проверка здоровья, потом подтверждение.

Телефон шлёт файл, а прибор отвечает BAD_SLOT. Файл собран под область, где лежит работающая прошивка. Выпуск — два образа, телефон берёт тот, что под свободную область.

Пользователь свернул приложение посреди передачи. Что станет со связью в фоне, решает платформа. Экран предупреждает, а после обрыва телефон переподключается и продолжает с того смещения, которое назовёт прибор.


189а.8 Итоги

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

fwup.c — загрузчик: приём образа по кадрам, проверка, подтверждение и откат.

/* Загрузчик с приёмом образа по кадрам: выбор области, приём, проверка,
   подтверждение и откат. Раздел 189а; раскладка и заголовок — раздел 48,
   кадры COBS + CRC-16 — раздел 194, порядок подтверждения — раздел 195. */
#include <stddef.h>
#include <string.h>
#include "fwup.h"

#define HDR(slot)  ((const img_hdr_t *)(uintptr_t)(slot))

static uint32_t rd32(const uint8_t *p)
{
    return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}

static void wr32(uint8_t *p, uint32_t v)
{
    p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24);
}

/* ── Контрольные суммы ───────────────────────────────────────────────── */
uint32_t crc32(const void *data, uint32_t len)          /* как zlib: 0xEDB88320 */
{
    const uint8_t *p = data;
    uint32_t c = 0xFFFFFFFFu;
    while (len--) {
        c ^= *p++;
        for (int i = 0; i < 8; i++)
            c = (c & 1u) ? (c >> 1) ^ 0xEDB88320u : c >> 1;
    }
    return ~c;
}

uint16_t crc16(const uint8_t *p, uint32_t n)            /* CRC-16/XMODEM, как в 194 */
{
    uint16_t c = 0;
    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);
    }
    return c;
}

/* ── Кадр 194: COBS(сообщение + CRC-16 старшим байтом вперёд) + 0x00 ── */
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;
}

static int32_t cobs_decode(const uint8_t *in, uint32_t n, uint8_t *out)
{
    uint32_t r = 0, w = 0;
    while (r < n) {
        uint8_t code = in[r++];
        if (code == 0)
            return -1;
        for (uint8_t i = 1; i < code; i++) {
            if (r >= n)
                return -1;
            out[w++] = in[r++];
        }
        if (code != 0xFF && r < n)
            out[w++] = 0;
    }
    return (int32_t)w;
}

uint32_t frame_encode(const uint8_t *msg, uint32_t n, uint8_t *out)
{
    uint8_t buf[FW_RX_MAX];
    if (n > sizeof buf - 2u)
        return 0;
    memcpy(buf, msg, n);
    uint16_t c = crc16(msg, n);
    buf[n] = (uint8_t)(c >> 8);
    buf[n + 1u] = (uint8_t)c;
    uint32_t m = cobs_encode(buf, n + 2u, out);
    out[m++] = 0;
    return m;
}

/* ── Проверка образа: раздел 48.4, плюс плата ────────────────────────── */
int image_valid(uint32_t slot)
{
    const img_hdr_t *h = HDR(slot);
    const uint32_t *vec = (const uint32_t *)(uintptr_t)(slot + IMG_HDR_SIZE);

    if (h->magic != IMG_MAGIC || h->board != BOARD_ID)
        return 0;
    if (h->size == 0u || h->size > SLOT_SIZE - IMG_HDR_SIZE)
        return 0;
    if (vec[0] < RAM_ORIGIN || vec[0] > RAM_ORIGIN + RAM_BYTES)
        return 0;
    if ((vec[1] & 1u) == 0u || vec[1] < slot + IMG_HDR_SIZE || vec[1] >= slot + SLOT_SIZE)
        return 0;
    return crc32((const void *)(uintptr_t)(slot + IMG_HDR_SIZE), h->size) == h->crc;
}

/* ── Что запускать: подтверждённый новейший или свежий на пробу ─────── */
static uint32_t best_confirmed(void)
{
    uint32_t best = 0;
    for (uint32_t s = SLOT_A; s <= SLOT_B; s += SLOT_SIZE)
        if (image_valid(s) && HDR(s)->ok != CELL_ERASED &&
            (best == 0u || HDR(s)->version > HDR(best)->version))
            best = s;
    return best;
}

uint32_t fw_select(void)
{
    uint32_t fresh = 0;
    for (uint32_t s = SLOT_A; s <= SLOT_B; s += SLOT_SIZE) {
        if (!image_valid(s) || HDR(s)->ok != CELL_ERASED)
            continue;
        if (HDR(s)->tried != CELL_ERASED)
            flash_prog_word(s, 0u);            /* проба была и не подтвердилась: образ списан */
        else
            fresh = s;
    }
    uint32_t best = best_confirmed();
    if (fresh != 0u && (best == 0u || HDR(fresh)->version > HDR(best)->version)) {
        flash_prog_word(fresh + offsetof(img_hdr_t, tried), 0u);   /* сначала отметка, потом запуск */
        return fresh;
    }
    return best;
}

/* Свободная область — та, где не лежит подтверждённый образ. */
static uint32_t free_slot(void)
{
    return best_confirmed() == SLOT_A ? SLOT_B : SLOT_A;
}

uint32_t fw_info(uint8_t mode, uint32_t run_slot, uint8_t *out)
{
    const img_hdr_t *h = HDR(run_slot);
    int have = run_slot != 0u && image_valid(run_slot);
    out[0] = REPLY(CMD_INFO);
    out[1] = ST_OK;
    out[2] = mode;
    wr32(out + 3, BOARD_ID);
    wr32(out + 7, have ? h->version : 0u);
    out[11] = !have ? RUN_NONE : h->ok != CELL_ERASED ? RUN_CONFIRMED : RUN_TRIAL;
    wr32(out + 12, free_slot());
    wr32(out + 16, SLOT_SIZE);
    out[20] = (uint8_t)FW_MAX_BLOCK;
    out[21] = (uint8_t)(FW_MAX_BLOCK >> 8);
    return 22;
}

/* ── Сеанс приёма ────────────────────────────────────────────────────── */
static struct {
    uint32_t origin, size, crc, version;
    uint32_t next;          /* следующее ожидаемое смещение в файле */
    uint8_t  active;        /* идёт приём */
    uint8_t  done;          /* образ принят и проверен: повтор END и BEGIN — не ошибка */
} s;

static struct {
    uint8_t  acc[FW_RX_MAX + 2u], dec[FW_RX_MAX + 2u];
    uint32_t al;
    uint8_t  overflow;
} rx;

void fw_init(void)                              /* после сброса: ОЗУ чистое */
{
    memset(&s, 0, sizeof s);
    memset(&rx, 0, sizeof rx);
}

static uint8_t on_begin(const uint8_t *p, uint32_t n)
{
    if (n != 16u)
        return ST_BAD_CMD;
    uint32_t origin = rd32(p), size = rd32(p + 4), crc = rd32(p + 8), ver = rd32(p + 12);
    if ((s.active || s.done) && origin == s.origin && size == s.size && crc == s.crc && ver == s.version)
        return ST_OK;                          /* тот же образ после обрыва: продолжаем с s.next */
    uint32_t best = best_confirmed();
    if (origin != free_slot())
        return ST_BAD_SLOT;
    if (size == 0u || size > SLOT_SIZE - IMG_HDR_SIZE)
        return ST_BAD_SIZE;
    if (best != 0u && ver <= HDR(best)->version)
        return ST_OLD_VERSION;
    s.active = 0;
    s.done = 0;
    if (flash_erase_range(origin, IMG_HDR_SIZE + size) != 0)
        return ST_FLASH;
    s.origin = origin; s.size = size; s.crc = crc; s.version = ver;
    s.next = 0;
    s.active = 1;
    return ST_OK;
}

static uint8_t on_block(const uint8_t *p, uint32_t n)
{
    if (!s.active)
        return ST_BAD_STATE;
    if (n < 8u || ((n - 4u) & 3u) != 0u || n - 4u > FW_MAX_BLOCK)
        return ST_BAD_CMD;
    uint32_t off = rd32(p), len = n - 4u;
    if (off != s.next)
        return off < s.next ? ST_OK : ST_BAD_OFFSET;   /* повтор уже записанного — просто ответить */
    if (off + len > ((IMG_HDR_SIZE + s.size + 3u) & ~3u))
        return ST_BAD_SIZE;
    for (uint32_t i = 0; i < len; i += 4u) {
        uint32_t w = rd32(p + 4 + i);
        if (off + i == 0u || w == CELL_ERASED)
            continue;                          /* магия — в самом конце; стёртое не пишем */
        if (flash_prog_word(s.origin + off + i, w) != 0) {
            s.active = 0;
            return ST_FLASH;
        }
    }
    s.next = off + len;
    return ST_OK;
}

static uint8_t on_end(void)
{
    const img_hdr_t *h = HDR(s.origin);
    if (s.done)
        return ST_OK;                          /* ответ на первый END потерялся */
    if (!s.active || s.next < IMG_HDR_SIZE + s.size)
        return ST_BAD_STATE;
    if (h->size != s.size || h->crc != s.crc || h->version != s.version || h->board != BOARD_ID ||
        h->entry < s.origin + IMG_HDR_SIZE || h->entry >= s.origin + SLOT_SIZE)
        return ST_BAD_HEADER;
    if (crc32((const void *)(uintptr_t)(s.origin + IMG_HDR_SIZE), s.size) != s.crc)
        return ST_BAD_CRC;
    s.active = 0;
    if (flash_prog_word(s.origin, IMG_MAGIC) != 0)     /* последним: теперь образ виден */
        return ST_FLASH;
    if (!image_valid(s.origin))
        return ST_BAD_HEADER;
    s.done = 1;
    return ST_OK;
}

void fw_handle(const uint8_t *msg, uint32_t n)
{
    uint8_t out[24], frame[32];
    uint32_t len = 2;
    if (n == 0u)
        return;
    out[0] = REPLY(msg[0]);
    switch (msg[0]) {
    case CMD_INFO:   len = fw_info(MODE_LOADER, best_confirmed(), out); break;
    case CMD_BEGIN:  out[1] = on_begin(msg + 1, n - 1u); wr32(out + 2, s.next); len = 6; break;
    case CMD_BLOCK:  out[1] = on_block(msg + 1, n - 1u); wr32(out + 2, s.next); len = 6; break;
    case CMD_END:    out[1] = on_end(); break;
    case CMD_REBOOT: out[1] = ST_OK; break;
    default:         out[1] = ST_BAD_CMD; break;
    }
    frame[0] = 0;                        /* ведущий ноль: закрыть обрывок чужого кадра */
    fw_tx(frame, 1u + frame_encode(out, len, frame + 1));
    if (msg[0] == CMD_REBOOT)
        fw_reset();
}

/* ── Приём байтов: до нуля копим, потом COBS, CRC и разбор ───────────── */
void fw_rx_byte(uint8_t b)
{
    if (b != 0u) {
        if (rx.al < sizeof rx.acc) rx.acc[rx.al++] = b;
        else rx.overflow = 1;                  /* длиннее допустимого — кадр испорчен */
        return;
    }
    int32_t dl = (rx.al == 0u || rx.overflow) ? -1 : cobs_decode(rx.acc, rx.al, rx.dec);
    rx.al = 0;
    rx.overflow = 0;
    if (dl >= 3 && crc16(rx.dec, (uint32_t)dl - 2u) == (uint16_t)(rx.dec[dl - 2] << 8 | rx.dec[dl - 1]))
        fw_dispatch(rx.dec, (uint32_t)dl - 2u);   /* целый кадр; пустой и битый — молча мимо */
}

flash_f1.c — драйвер Flash STM32F103 для загрузчика.

/* Драйвер Flash STM32F103 для загрузчика: страница 1 КБ, запись полусловом (RM0008). Раздел 189а. */
#include "stm32f10x.h"
#include "fwup.h"

static int wait_ready(void)
{
    while (FLASH->SR & FLASH_SR_BSY) {
    }
    uint32_t sr = FLASH->SR;
    FLASH->SR = FLASH_SR_EOP | FLASH_SR_PGERR | FLASH_SR_WRPRTERR;   /* сбрасываются записью единицы */
    return (sr & (FLASH_SR_PGERR | FLASH_SR_WRPRTERR)) ? -1 : 0;
}

static void unlock(void)
{
    if (FLASH->CR & FLASH_CR_LOCK) {
        FLASH->KEYR = FLASH_KEY1;
        FLASH->KEYR = FLASH_KEY2;
    }
}

int flash_erase_range(uint32_t addr, uint32_t len)
{
    int rc = 0;
    if (addr < SLOT_A)
        return -1;                               /* себя не стираем никогда */
    unlock();
    for (uint32_t a = addr & ~1023u; a < addr + len && rc == 0; a += 1024u) {
        FLASH->CR |= FLASH_CR_PER;
        FLASH->AR = a;
        FLASH->CR |= FLASH_CR_STRT;
        rc = wait_ready();
        FLASH->CR &= ~FLASH_CR_PER;
    }
    FLASH->CR |= FLASH_CR_LOCK;
    return rc;
}

static int prog_half(uint32_t addr, uint16_t v)
{
    FLASH->CR |= FLASH_CR_PG;
    *(volatile uint16_t *)addr = v;
    int rc = wait_ready();
    FLASH->CR &= ~FLASH_CR_PG;
    return (rc != 0 || *(volatile uint16_t *)addr != v) ? -1 : 0;
}

int flash_prog_word(uint32_t addr, uint32_t value)
{
    int rc;
    if (addr < SLOT_A || (addr & 3u))
        return -1;
    unlock();
    rc = prog_half(addr, (uint16_t)value);
    if (rc == 0)
        rc = prog_half(addr + 2u, (uint16_t)(value >> 16));
    FLASH->CR |= FLASH_CR_LOCK;
    return rc;
}

fw_app.c — сторона приложения прибора: «кто я», уход в загрузчик и подтверждение себя.

/* Сторона приложения прибора: ответить «кто я», уйти в загрузчик по просьбе
   телефона (48.6) и подтвердить себя (48.8, 195). Приложение собирается
   с тем же fwup.c: из него нужны кадры, fw_info и проверка образа. */
#include <stddef.h>
#include "fwup.h"

#define HDR(slot)  ((const img_hdr_t *)(uintptr_t)(slot))

void fw_confirm(uint32_t slot)
{
    if (HDR(slot)->ok == CELL_ERASED)
        flash_prog_word(slot + offsetof(img_hdr_t, ok), 0u);
}

void app_handle(const uint8_t *msg, uint32_t n, uint32_t my_slot)
{
    uint8_t out[24], frame[32];
    uint32_t len = 2;
    if (n == 0u)
        return;
    fw_confirm(my_slot);                 /* телефон до нас достучался — живы */
    out[0] = REPLY(msg[0]);
    switch (msg[0]) {
    case CMD_INFO:  len = fw_info(MODE_APP, my_slot, out); break;
    case CMD_ENTER: out[1] = ST_OK; break;
    default:        out[1] = ST_BAD_STATE; break;   /* приём образа — только в загрузчике */
    }
    frame[0] = 0;                        /* ведущий ноль: закрыть обрывок чужого кадра */
    fw_tx(frame, 1u + frame_encode(out, len, frame + 1));
    if (msg[0] == CMD_ENTER) {
        reboot_flag = REBOOT_MAGIC;      /* 48.6: признак в .noinit и сброс */
        fw_reset();
    }
}

Дальше: раздел 190 «Телеметрия и выпуск: кольцевой буфер, прореживание, хранение, сборка».

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