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
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:
- a repeat of a block already written (the reply was lost) is not written a second time; the device simply replies "waiting for N";
- after reconnecting, the phone sends BEGIN with the same size, CRC and version — the device recognizes the session and replies where to continue from;
- the block size can change on the fly: after reconnecting, the MTU may be different, and a block number with a different block size would mean a different place.
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:
- with a large MTU, the best block is the one that exactly fits in one packet: 232 bytes at MTU 247 take 9.88 s, while a 16-byte block at the same MTU takes 71.48 s, more than seven times longer;
- at MTU 23 the "one packet" block (8 bytes) is the worst: 137.73 s. Every block costs a reply and an interval for it, and with a small MTU a long block made of many packets pays off — 35.75 s with 232 bytes;
- negotiating a large MTU matters more than anything else: a 232-byte block takes 9.88 s at MTU 247 and 35.75 s at MTU 23.
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
- The path: the phone checks the file → INFO → ENTER (48.6 flag) → BEGIN with an erase → blocks with an offset and a "waiting for N" reply → END: CRC over what was written and the magic last → REBOOT → trial run → confirmation by the application or rollback.
- The frame comes from 194 plus a leading zero; a block frame is 10 bytes longer than the data, a block in one packet is 232 bytes at MTU 247, 172 at MTU 185, 8 at MTU 23.
- Link model (15 ms per packet, UART 115200): a 33280-byte image on the F103 takes 9.88 s at MTU 247 and a 232 block, 35.75 s at MTU 23; on the F407 8.83 s.
- 5 % losses at MTU 247: 16 timeouts and 26.06 s; at MTU 23 without the leading zero, 189 timeouts instead of 150.
- A link drop at the fifth second: one reconnect, one block sent again — 232 bytes.
- The power was cut at every Flash operation: 2069 points on the F103 and 1033 on the F407 — the device always started a complete image, the update always ended with version 3, a second press was needed 2 and 1 times.
- A version that did not confirm itself rolls back by itself: three restarts, the image retired, the previous one runs.
- The bootloader with reception, checking and selection takes 2532 bytes of Flash on the F103 and 2628 on the F407.
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".