Embedded HandbookApps
All sections › XXIII. The device's companion app: Dart and Flutter
STM32F103, STM32F407 · level intermediate · 36 min read
Open in the interactive handbook

189а. Firmware update from a mobile app: from a button in Flutter to the STM32 bootloader

In section 48 the bootloader received a "byte stream" from someone outside; in 189 the app learned to talk to the device over BLE and a cable. Here the two halves meet: the "Update" button on the phone, the firmware file, frames over the radio, a bootloader that writes Flash, and new firmware that has to confirm by itself that it works. If it does not confirm, the device goes back to the previous one.

The whole path is built with our own code on both sides: a client in Dart, a screen in Flutter, a bootloader in C. It was tested on a computer: the client talks to a model of the device in which the same C code of the bootloader runs, while Flash and the BLE link are replaced by models with honestly named parameters. The model drops the link in the middle of a transfer, loses packets and cuts the device's power at each of the 2069 Flash operations in turn.

Foundations: section 48 (layout, image header, 48.6 — how the app asks for reflashing, 48.8 — confirmation), 48а (signature), 195 (A/B, confirmation and rollback), 194 (COBS frames and CRC-16), 185–189 (Dart, asynchrony, screen, transport).

Terms used in the documentation

Term Meaning
device firmware update (DFU) updating the firmware of a device
image, slot a firmware image, the Flash area set aside for an image
chunk, block, offset a piece, a block, a position in the file
acknowledge (ACK) confirmation that data was received
stop-and-wait "send, then wait for the reply": one block in flight
resume continuing from the point where the link broke
trial boot, confirm, rollback a test run, confirmation, return to the old version
write with response a write that the other side acknowledges
connection interval the BLE connection interval
app lifecycle (paused, resumed) the app lifecycle: went to the background, came back

189а.1 The whole path in one picture

Update sequence: the app on the phone checks the file, asks the device for INFO, asks the device application to jump to the bootloader, the bootloader erases the free slot, receives blocks with acknowledgement, checks the CRC and writes the magic last, restarts into the new firmware for a trial; the new firmware confirms itself, otherwise the watchdog and a rollback to the previous version

Four participants and one rule for each:

Who What it does What it never does
the app on the phone checks the file before sending, runs the transfer, shows the state does not send an image that does not fit the device
the transport (section 189) cuts frames by MTU, keeps one write at a time does not know it is carrying firmware
the device application answers "who am I", jumps to the bootloader on request, confirms itself does not write somebody else's Flash slot
the bootloader erases the free slot, receives, checks, chooses what to run does not erase the working slot or itself

Why the bootloader receives, not the application. There are two options: the device application writes the second slot itself, on the fly (this is how the Linux updater works in section 195), or the application only sets the 48.6 flag and restarts into the bootloader, and the bootloader receives. The second one is chosen here. Reception is the same for all versions of the application and works even when there is no usable application at all: a device that has lost both images stays in the bootloader and waits for a file. The application gets simpler: it needs neither a Flash driver nor block parsing. The price: during the update the device does not do its job, and the bootloader grows by the reception and frame parsing (sizes in 189а.6). If stopping is not acceptable, take the first path, and the reception from this section moves into the application.

Two slots and two images per release. An image is built for its own address and will not run from the other slot (48.2). The path chosen here is "two images per release": the build produces a file for slot A and a file for B, the device says which one is free, and the phone sends the matching one. The previous firmware stays in place until the new one is confirmed — there is something to roll back to, and nothing is copied.

Ready-made solutions, if you do not need your own protocol: MCUboot has the SMP management protocol (MCUmgr), Nordic has its own DFU for its radio chips, ST has the factory bootloader in system memory (48.11). A large image over a slow link can be sent as a difference — section 48в. A complete custom bootloader for a production device on the F405 is built in mini-project 198щ.


189а.2 A protocol on top of the 194 frames

The frame comes from section 194: a message, followed by CRC-16/XMODEM, most significant byte first, everything in COBS, a zero at the end. The frame from section 185 does not fit here: it holds at most 32 bytes and CRC-8 — enough for commands, too little for firmware. One addition: a leading zero before every frame, on both sides (189а.6 explains why).

Numbers in messages are least significant byte first. A reply is the same command with the most significant bit set, plus a status byte.

Command Code Request fields Reply Who replies
INFO 0x01 — mode, board, version, state, free slot, its size, largest block application and bootloader
ENTER 0x02 — status; then the .noinit flag and a reset application
BEGIN 0x10 slot, body size, body CRC-32, version status, offset to send from bootloader
BLOCK 0x11 offset, data (multiple of 4, up to 240 bytes) status, next expected offset bootloader
END 0x12 — status: CRC matched, magic written bootloader
REBOOT 0x13 — status; then a reset bootloader

Statuses: OK, BAD_CMD, BAD_STATE (wrong mode — there is no reception session), BAD_SLOT, BAD_SIZE, OLD_VERSION, BAD_OFFSET, BAD_CRC, FLASH, BAD_HEADER. The phone has its own on-screen phrase for each one.

An offset, not a block number. A block is addressed by its offset in the file, and in every reply the device says which offset it expects next. Three properties follow from this at once:

Block size. A block frame is exactly 10 bytes longer than its data: the leading zero, the command, four offset bytes, two CRC bytes, a COBS byte and the terminating zero. The largest block that fits in one packet is the packet's payload minus 10, rounded down to a multiple of four: 8 bytes at MTU 23, 172 at MTU 185, 232 at MTU 247. The block does not have to be a multiple of the Flash page: BEGIN does the erase in advance, and writing goes by words. Only a multiple of four is required — the F407 writes a word, the F103 writes a word as two half-words.

Reply timeouts. A block gets 1 s, INFO and the other short ones 0.5 s, END 2 s, and BEGIN 8 s: it erases the slot. On the F103 that is 33 pages of 20…40 ms each (DocID13587, table 28); on the F407 a 128 KB sector is erased in 1 s typical and up to 2 s (DS8626, table 40, word programming), and a slot of three sectors takes up to 6 s. A BEGIN timeout taken from the block one means the phone gives up in the middle of the erase and starts everything over.


189а.3 The phone: the file, checks before sending, the transfer

The image is the same as in 48.3: a 512-byte header and a body. Two reserved words from 48.3 became the "trial run started" and "confirmed" cells (48.8), and the first padding word holds the board code. In a file fresh from the build all three cells are erased — 0xFFFFFFFF.

The check before sending covers everything the phone can find out on its own. An unsuitable file must not even switch the device into the bootloader:

/// Годится ли файл этому прибору. 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 release is two files; the phone takes the one whose entry point falls into the free slot that the device reported:

/// Выпуск — два образа, под область 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;
}

The outgoing frame and the reply receiver repeat 194 in Dart, with a leading zero:

/// Кадр на отправку. Ведущий ноль закрывает обрывок прошлого кадра,
/// если его посылка потерялась: иначе прибор склеит обрывок с этим кадром.
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]);
}

A request with retries: after four timeouts in a row, reconnect and try again; three rounds without a reply mean the error "the device stopped responding".

/// Запрос с повторами по таймауту; после [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('прибор перестал отвечать');
}

The procedure itself. If the device replied BAD_STATE, it has restarted and the session is gone — everything starts over, no more than three times:

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('прибор не подтвердил новую прошивку');
}

Under this code there is FwPort with two methods: "request, reply within a deadline" and "reconnect". The real implementation sits on the Transport interface from 189: BleTransport cuts the frame by MTU, and the write queue is the same. In the test this path was run end to end: BleTransport with an MTU of 20 and real timeouts on top of the device model brought the update to completion; the longest write was 20 bytes.


189а.4 The device: reception, magic last, selection and rollback

The bootloader is fwup.c. Start of a session: check that the slot is the free one, the size fits and the version is newer than the confirmed one — and erase.

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;
}

A block. The magic field and erased words are not written: erased is erased already, and the magic appears last, once everything has matched.

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;
}

The end: the header is compared with BEGIN, the body CRC is computed over what was actually written to Flash, and only then is the magic written. Until that write the bootloader does not see the slot at all — if power is lost at any byte, image_valid rejects the slot on the very first check.

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;
}

The choice of what to run is made at every start. A fresh image (never run) newer than the confirmed one first gets the "trial" mark, then it is started. An image with the mark but without confirmation means the trial failed: the bootloader writes zeros into its magic and starts the confirmed one.

/* ── Что запускать: подтверждённый новейший или свежий на пробу ─────── */
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;
}

All cells are only ever written on top of the erased state: from erased to written, and to "retire" an image you write zeros into its magic: a zero can be written even over a written value — on the F103 this is the only exception to the ban on writing to non-erased memory, and on the F407 a write gives a bitwise AND anyway (section 4). No mark requires an erase — erasing the page with the header would also erase the start of the image (48.8).

The device application side is short: answer INFO, on ENTER set the 48.6 flag and reset, confirm itself.

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();
    }
}

Here the application confirms itself on the first complete frame from the phone — that is the "the link works" check. A real device adds its own checks: the sensors respond, the load can be controlled (195.4). Confirming before the health check makes the rollback useless.

The Flash driver for the F103 uses registers from RM0008, with names from 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;
}

On the F407 it is the same with a word write at PSIZE = x32, and erasing goes by sector number (FLASH_CR_SNB), computed from the 16/64/128 KB map in 48.2.

Signature. A CRC protects against corruption, not against substitution. The signature check from 48а goes into on_end before the magic is written: an image without a valid signature will not get the magic and will not be started. It is left out here so that the sizes and numbers stay about the protocol itself.


189а.5 The update screen in Flutter

The screen subscribes to the Updater state stream and shows one phrase for each state — with no technical words:

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: 'Обновление не удалось',
};

The screen learns that the app went to the background from WidgetsBindingObserver:

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

The build: percentage and speed during the transfer, the reason on an error. The button is disabled while the update is running, "Back" does not leave the screen, and going to the background shows a warning:

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('Обновить'),
            ),
          ],
        ),
      ),
    ),
  );
}

Four widget tests without a phone: "38 % · 3.9 KB/s" during the transfer and no second press of the button; the error reason on the screen and the button available again; PopScope does not let go in the middle of the on-device check; the warning when going to the background and its disappearance on return. Keeping the screen from turning off during the transfer is the job of a platform plugin; it is not part of the test. BLE permissions are in 189.5.


189а.6 An experiment on a computer: time, losses, link drops, rollback

The Dart client talks to the device model through dart:ffi: a library built with gcc runs the same fwup.c and the same fw_app.c, and the model's Flash sits at the real address 0x08000000 — the bootloader reads the image with ordinary pointers. The model follows the same write rules as the chip: on the F103 a half-word can be written only into erased memory, except for zero; on the F407 a bit can only be cleared. The timings come from the documentation:

F103 (DocID13587) F407 (DS8626, x32)
erase unit 1 KB page, 40 ms (the largest of 20…40) 128 KB sector, 1 s (typical)
write half-word 52.5 µs word 16 µs

The link model is not a radio measurement but an agreement, and its parameters are stated: each packet (a write with response or a notification) takes one connection interval of 15 ms; the BLE module passes the bytes to the microcontroller over UART at 115200 baud; a reconnect takes 2 s. The absolute seconds depend on these numbers; the ratios between the rows depend on the protocol.

Update time. Image body 32 KB, file 33280 bytes, F103, no losses. The time is from the first INFO to "Done", including the 1.32 s erase; the speed is for the block transfer.

MTU Block, bytes Packets per block Time, s Speed, KB/s
23 8 — fits in one packet 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 — fits in one packet 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 — fits in one packet 1 9.88 3.90

Three conclusions from the table:

The same image on the F407: 8.83 s — one sector erase in 1 s instead of 33 pages of 40 ms, and four bytes are written as one word in 16 µs instead of two half-words of 52.5 µs each.

Losses. The model loses a whole packet with a given probability, in both directions. A loss costs a timeout — a second per block.

MTU, block Losses Leading zero Time, s Packets lost Timeouts Reconnects
247, 232 1 % yes 12.91 3 3 0
247, 232 5 % yes 26.06 16 16 0
23, 64 5 % yes 204.86 155 150 0
23, 64 5 % no 249.82 164 189 2

At MTU 247 a block frame is one packet, and a lost packet takes exactly one frame with it. At MTU 23 a frame of four packets loses its middle, and the device receives a fragment without the terminating zero. Without the leading zero the fragment is glued to the next frame — the retry of the same block — and the CRC kills that one too: 189 timeouts instead of 150, two reconnects, 44.96 s longer. The leading zero closes the fragment before a new frame starts — one loss costs one frame.

Link drop in the middle of the transfer. At the fifth second the link breaks. The phone reconnects (2 s in the model), repeats the same block, the device replies "waiting for N" — and the transfer continues. Result: 11.89 s instead of 9.88, one reconnect, 232 bytes sent again — one block.

Checks before sending. A device with version 2; in all four cases the phone refused after a single INFO request, and the device did not restart:

What is in the file What is on the screen
board code 0x104, device 0x103 "image for board 0x00000104, but the device is 0x00000103"
the release has only the image for slot A "the release has no image for the free slot 0x08012000"
version 2 — the same as on the device "version 2 is not newer than version 2 on the device"
one byte of the body corrupted "file corrupted: CRC does not match"

If the phone skips the check, the device rejects the file itself: a BEGIN with version 1 on top of a confirmed 2 got the reply OLD_VERSION.

Power loss. The scenario: slot A holds the confirmed version 2, slot B holds version 1 from the previous update, and the phone installs version 3 into B. A clean run with a 4 KB image performs 2069 Flash operations on the F103: erasing five pages, writing the body and the header in half-words, the magic, the "trial" mark and the confirmation. The experiment repeats the update 2069 times and in each run cuts the power at the next operation — in the middle of it: an erase gets through half a page, a write gets through the low byte. After 3 s the power comes back, the phone reconnects, and if the session is gone, the user presses "Update" once more.

F103 F407
power-cut points 2069 1033
after power-up a complete image started (CRC matched) 2069 1033
the old version 2 started first 2067 1032
the new version 3 started first 2 1
the update finally ended with version 3 2069 1033
a second press of "Update" was needed 2 1

At no point was the device left without bootable firmware, and not once did it start half an image. The new version started right after the power cut only when the power was lost while the confirmation was being written: a partially written cell is no longer erased and counts as written — and by that moment the application is alive. A second press is needed when the power was lost while the "trial" mark was being written: the mark is incomplete, the bootloader treats the trial as failed, retires the image and starts version 2. This is safe, but the phone sees a return to the previous version — which is why the on-screen phrase honestly names both causes: "the device went back to version 2: the new one did not confirm itself or the power was lost".

Rollback. Version 3 hangs before confirming itself, and the watchdog restarts the device (in the model, after 1 s). Result: three restarts — into the bootloader, into version 3 for a trial, by the watchdog; the bootloader retired version 3 (its slot no longer passes the check) and started version 2; the phone showed "Update failed" and the reason.

Build for the chip. arm-none-eabi-gcc -Os -Wall -Wextra -Werror, the whole bootloader: fwup.c, the Flash driver, startup and reception over USART1:

F103, Cortex-M3 F407, Cortex-M4
Flash, bytes 2532 2628
RAM, bytes 552 552
reboot_flag flag 0x20004FF0 0x2001FFF0

On the F103 the bootloader takes less than a sixth of the 16 KB set aside for it. A signature will not fit that easily: in the 48а build for Cortex-M4 the signature check added 17248 bytes — more than the whole 16 KB set aside for the F103 bootloader in 48.2. The flag sits at the address of the shared area from 48.6, the same for the bootloader and the application.


189а.7 Pitfalls

On the F407 the update "hangs" at the very first step. The BEGIN reply timeout was taken from the block one. Erasing three sectors takes up to 6 s — compute the BEGIN timeout from the slot size.

With a small MTU, losses cost twice as much as they should. The fragment of a lost frame gets glued to the next one, and the CRC kills both. Put a leading zero before every frame, on both sides.

After a reconnect the transfer starts from zero. The blocks are numbered, and the device does not say what it expects. Use an offset in the request and "waiting for N" in the reply; BEGIN recognizes the same image.

A repeated block corrupts Flash. The device writes a block whose reply was lost a second time; on the F103 this is PGERR, and on the F407 a silent AND of the old with the new (section 4). A block earlier than expected — only reply.

Power was lost and the device does not boot. The magic was written first, together with the header. The magic goes last, after the CRC check over what was written.

The new firmware hung — and stays that way forever. There is no "trial" mark before the start or no watchdog: the hung image is not restarted, and the attempt is not counted (195.4).

Rollback does not work. The application confirms itself in the first line of main. Health check first, confirmation second.

The phone sends the file and the device replies BAD_SLOT. The file was built for the slot that holds the running firmware. A release is two images; the phone takes the one for the free slot.

The user minimized the app in the middle of the transfer. What happens to the link in the background is up to the platform. The screen warns, and after a link drop the phone reconnects and continues from the offset the device names.


189а.8 Summary

Full source code

fwup.c — the bootloader: frame-based image reception, checking, confirmation and rollback.

/* Загрузчик с приёмом образа по кадрам: выбор области, приём, проверка,
   подтверждение и откат. Раздел 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 — the STM32F103 Flash driver for the bootloader.

/* Драйвер 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 — the device application side: "who am I", the jump to the bootloader and self-confirmation.

/* Сторона приложения прибора: ответить «кто я», уйти в загрузчик по просьбе
   телефона (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();
    }
}

Next: section 190 "Telemetry and release: ring buffer, decimation, storage, build".

Open in the interactive handbook