Memory map¶
The PineTime has three kinds of memory: 512 KB of internal flash, 4 MB of external SPI flash and 64 KB of RAM. This page shows how BattleTime divides each one. Each box is drawn in proportion to its size. The memory treemap shows all three at once, as one map you can zoom into, from each memory down to what fills its regions.
The flash layouts are set in code by the battletime-boot crate (boot/src/lib.rs), which the
bootloader and the firmware share, so that is the source of truth. bootloader.md
explains how the bootloader uses them. The RAM layout is a planned budget, and each figure gets
replaced by a measurement as its part is built.
To redraw the pictures after a change, edit the sizes in tools/draw_memory_maps.py and run:
python3 tools/draw_memory_maps.py
Internal flash (512 KB)¶
| Region | Start | Size | Holds |
|---|---|---|---|
| Bootloader | 0x00000 |
20 KB | The embassy-boot bootloader with its status screen and button menu, 18,576 bytes of it used. There is no SoftDevice and no MBR. |
| Slot table | 0x05000 |
8 KB | Two pages recording where the update and recovery slots sit in the external flash; see bootloader.md. |
| Boot state | 0x07000 |
4 KB | The bootloader's record of an update in progress, for swapping and rolling back. |
| Firmware | 0x08000 |
480 KB | The running firmware, to the end of flash: a 256-byte image header, then its code, linked to start at 0x08100, then the shared library. No app, face, icon or font is built into the code apart from the notifications and quick settings screens (about 2.2 KB, compiled from shell/); What fills the firmware shows what is. The code and the library share the region, so either can grow into the other's room. |
The shared library sits right after the code, on a word boundary, where the image header says. It
holds what apps share: images, the app host's text font (4,945 B, one font of lines and arcs that
every text size scales), Bosch's configuration file for each accelerometer (6,144 B each, which the
firmware uploads to the chip; the library with all of it is 43,753 B), components, common functions
and the preset themes. It arrives with each firmware
update and is never written on the watch. No apps are kept in internal flash; they live in /apps
on the external flash. See app-runtime.md.
What fills the firmware¶
Measured on the release builds of firmware/ by tools/measure_memory.py (see
Measuring again). Each symbol counts towards the crate that owns it; the
firmware is linked with LTO into a single object, so the crate comes from the symbol's name, and
the files the firmware embeds, such as the Bosch configuration, are found by their bytes. A few crates
off the drawing and interpreter paths, such as battletime-companion and battletime-packages, are built
for size (opt-level = "z") in firmware/Cargo.toml, while the rest stay at "s".
| Component | Full build | Recovery build |
|---|---|---|
| Firmware (start-up, shell, tasks) | 137.5 KB | 46.6 KB |
App host (with battletime-ui) |
93.1 KB | 0.0 KB |
| App runtime | 36.2 KB | 0.0 KB |
| Bluetooth (controller and TrouBLE) | 33.7 KB | 43.3 KB |
| core, alloc and compiler builtins | 27.8 KB | 15.4 KB |
| Storage (flash store) | 18.0 KB | 14.4 KB |
| Settings, companion and notifications (with serde) | 17.7 KB | 2.4 KB |
| BLEFS file service | 9.5 KB | 3.1 KB |
| Embassy, allocator and Cortex-M | 8.6 KB | 5.5 KB |
| Packages | 7.8 KB | 1.3 KB |
| Display, graphics and drivers | 5.4 KB | 3.2 KB |
| Updates (DFU) | 3.1 KB | 2.8 KB |
| Other constants and padding | 32.6 KB | 14.3 KB |
| Code and data | 431.2 KB | 152.4 KB |
| Shared library, after the code | 48.3 KB | none |
| Free of the 480 KB region, after its header | 0.3 KB | 327.3 KB |
The recovery build (--features recovery) leaves out the shell, so the app
host and the runtime go with it.
The heart rate and battery logs (battletime-slot-log, written by the battery task through the
storage task) added 1,528 B of code to the full build, measured with cargo size against master on
2026-10-14 (433,200 B to 434,728 B of text), and 136 B of static RAM, most of it the battery task's
future holding the storage request while it waits to send it. The step log takes its paths and its
forgetting of old days from the same crate.
Keeping those logs' days in blocks programmed a slot at a time (program_file in the flash store,
used by battletime-slot-log) added 1,432 B to the firmware image, measured against master on
2026-10-15 (488,009 B to 489,441 B with the library), and no static RAM. It cut the erases the
three logs cost from about 80 a day, when each slot rewrote its whole day as a record, to one for
each new block, about one every four days.
Running closed apps (firmware/src/closed_apps.rs and battletime_app_host::closed_apps) added
5,008 B of code to the full build, measured with cargo size against master on 2026-10-14
(405,496 B to 410,504 B of text), and 32 B of static RAM. A first version wrapped the shell's
system in a second System type, which built the host's handler path twice and cost 23.8 KB, so
the shell's own WatchSystem takes the closed app's effects through ClosedRun::hold_back.
On the heap the shell keeps about 20 B for each closed app with a time to come (its id, due and
record times and run size). A run holds the app's image, its state, interpreter and bindings and
its saved state, plus what drawing borrows, closed_run_bytes: about 11.5 KB for the alarm, whose
image is 3 KB. It happens beside the open app when the free heap has that plus 2 KB, and otherwise
after closing it. The host is loaded into its slot out of line (open_in_background_into), as
opening an app does: loading it by value put the load's frame under the first derives, and the
start-up search for closed apps then peaked at 8,784 B of stack, 1,440 B spare.
Measured in the emulator on 2026-10-14 with the typical install (the digital face, the launcher
and TYPICAL_APPS): open the timer from the launcher, start its 10 minutes, leave it, and let it
ring while closed. The stack pattern was painted before start, and restart's least free heap and
allocation failure count were read at the end:
| Timer rings over | Stack peak | Spare above the guard | Least free heap | Highest heap byte touched | Resets |
|---|---|---|---|---|---|
| The face, screen dark | 6,352 B | 3,872 B | 8,996 B | 22,900 B | none |
| The stopwatch (11,424 B in use), run beside it | 6,352 B | 3,872 B | 9,080 B | 22,644 B | none |
| Settings (19,200 B in use), closed first and opened again | 6,352 B | 3,872 B | 10,976 B | 20,660 B | none |
Each time the timer opened on its ringing screen and lit the screen, with no allocation refused. The same install without the timer started peaks at 5,984 B, as master does. With a 1 KB margin the run beside Settings went ahead, leaving 1,304 B free at its lowest and touching the heap to 30,812 B of 30,976 B, so the margin is 2 KB.
The watch face, the launcher and the apps live in /apps on the external flash, and the icons,
the app host's text font and the accelerometers' configuration files in the shared library after
the firmware's code; none is inside the code. See
app-runtime.md.
External SPI flash (4 MB)¶
The chip is an XT25F32B, on the same SPI bus as the screen. The slots are where DEFAULT_SLOTS
puts them; the slot table can move them anywhere below the file
system.
| Region | Start | Size | Holds |
|---|---|---|---|
| Update slot | 0x000000 |
484 KB | A new firmware received over Bluetooth, waiting for the bootloader to swap it in: the firmware's 480 KB plus one page. |
| Free | 0x079000 |
308 KB | Room the slot table can give to either slot. |
| Recovery slot | 0x0C6000 |
232 KB | A known-good firmware to fall back to, up to 0x100000. |
| File system | 0x100000 |
3 MB | The flash store: 2 MB of files, 256 KB of records and 768 KB of logs; see storage.md. |
Anything that changes often, such as settings, notifications and app state, is kept here, because the store spreads its writes across the chip. The internal flash changes only when the firmware, with the shared library inside it, is updated.
What the file system holds¶
The flash store works in 4 KB sectors, one erase sector each. A file larger than 1 KB takes whole sectors, the first beginning with a 224-byte header, and a file of 1 KB or less is a record in the records ring, so it costs no sector. There are no folders, so an app costs nothing beyond its image.
| Path | Holds |
|---|---|
/apps/<id>/app.ntapp |
The compiled app or face, as battletime-compiler writes it. |
/apps/<id>/state |
What the app saved, written by the shell; a record. |
/apps/<id>/disabled |
Present when the app is installed but hidden from the launcher. |
/settings/system |
The watch-wide settings, 41 bytes. |
/settings/theme |
The theme's colours, 16 bytes. |
/settings/launcher |
The launcher's style, view and groups with their apps, which the companion writes; a few hundred bytes, kept in the system heap while the watch runs. |
/resources/ |
For resource packages; empty for now. |
/day_blocks/steps/<n> |
8 days of steps in six-minute slots, a blank file of one sector programmed a slot at a time; 90 to 97 days are kept, 12 or 13 sectors, about 52 KB. |
/steps/trip |
The trip count, 4 bytes. |
/day_blocks/heart/<n> |
16 days of average heart rate in six-minute slots, a byte each, one sector; 90 to 105 days kept, 6 or 7 sectors. |
/day_blocks/battery/<n> |
16 days of battery charge at the end of each six-minute slot, with the top bit set while charging, one sector; 90 to 105 days kept, 6 or 7 sectors. |
/steps/<year>/<month>-<day>, /logs/heart/..., /logs/battery/... |
Days older firmware kept as records, up to 480 bytes each and a record key each; kept until they are 90 days old. |
A typical install is the digital face, the launcher and ten everyday apps (alarm, timer,
stopwatch, settings, weather, steps, heart, flashlight, music and calculator), which
tools/measure_memory.py installs into a store laid out as the watch's:
| What | Bytes | File sectors |
|---|---|---|
/settings/system and /settings/theme, kept as records |
117 | 0 |
| 12 app images, 739 B (flashlight) to 7,490 B (settings); those of 1 KB or less are records | 27,019 | 12 |
| In use | 12 of 512: 48 KB of 2 MB, and 968 B of records in the 256 KB records ring |
The rest, almost 2 MB of files and most of the records ring, is free for apps, saved state and resources.
RAM (64 KB, planned)¶
While the bootloader runs it uses all 64 KB from 0x20000000. Nothing is kept when it jumps to the
firmware, which then has the whole of RAM but its last 16 bytes, the alive marker
(FIRMWARE_ALIVE_MARKER) an emulator reads; the bootloader and the reloader keep their stacks
below it too, so the marker survives from one firmware start to the next. Its third word is the
bootloader's note to the firmware (BOOT_NOTE_ADDRESS), which says whether the recovery firmware
was just installed, and its fourth is the bootloader updater's (UPDATER_NOTE_ADDRESS), which says
the rollback after it was meant. The firmware measures 53.4 KB of static RAM:
30.25 KB of heap (10 KB for the system and the 20.25 KB UI heap), the flash store's tables (2.7 KB, the last file header read included), and
the Bluetooth host with its eight 128-byte packets, controller, the display's two 1,920 B strip buffers and task state.
TrouBLE's host keeps one connection, no L2CAP channels of its own (every service speaks over GATT), and a send
queue of four packets, set by TROUBLE_HOST_L2CAP_TX_QUEUE_SIZE in firmware/.cargo/config.toml; its receive queue
stays at eight, since a full one drops the phone's writes. The
Bluetooth task's state is 3.2 KB, TrouBLE's runner (1.6 KB) much of it; of a write it keeps only the 20 bytes an
update needs across its awaits, and a file service command goes to the storage task on the heap, so the request
channel's two places no longer hold a 128-byte packet each; replies to the companion
app and half-received lines wait on the heap, and the update state, status page and confirmation
are held once in a static. The GATT server (SERVER) is 656 B, mostly TrouBLE's CCCD table for
each client. Its attribute table, 61 attributes of 32 B, is a constant in flash (1,952 B), built
when the firmware is compiled by the patched macros in third_party/; only the 388 B of values
that are written or set at run time are in RAM, in one static byte array (__SERVER_STORED_VALUES).
The Heart Rate service, the last one added, took 1,076 B of flash in the full build (about 0.8 KB in
the recovery build, which serves it too), 128 B of it its four attributes
and most of the rest notifying it, and 48 B of static RAM: 28 B in the CCCD table, 4 B of value and the reading's signal.
The client for the phone's own Current Time and Alert Notification services
(firmware/src/phone_services.rs and services/phone_client) took 3,008 B of flash in the full
build, measured with cargo size against master fcdff6dc (433,212 B to 436,220 B of text), and
56 B of static RAM, most of it in the Bluetooth task's state; the recovery build leaves it out.
While a phone is connected it keeps 368 B on the heap, and its 268 B frame is out of line, so the
launcher's stack peak on blank flash stayed at about 6.0 KB with 2,964 B spare. TrouBLE's own
GattClient took 12.6 KB of flash for the same work.
Built at start-up, as upstream TrouBLE does, the table took 2,536 B with its values' stores
(2,771 B in all) and 4.4 KB of code to build it. That leaves 10.6 KB (10,828 B) for the
stack, of which the lowest bytes, up to 360 B, are the stack guard: 256 B from the first 256-byte
boundary above the statics, which the memory protection unit forbids (firmware/src/stack_guard.rs).
Measured in the emulator by painting the stack before start, with a typical install, the deepest
path peaked at 6,616 B with 8,688 B above the guard; with the GATT table in flash there are 10,480
B above it, which leaves that path about 3,860 B spare. On 2026-10-14 opening the launcher's first
app peaked at 6,008 B, 4,472 B spare, against 6,016 B and 2,416 B spare before the table moved; on blank flash it is about 5.1 KB. A
store call's state, up to 1.8 KB, is on the heap while it runs, both for the shell's calls and in
the storage task, so neither the stack nor the task's static space holds it. Opening an app: the app
host loads the image and runs its handlers, and the interpreter formats numbers with 64-bit
division, all under the shell's task. Opening the launcher, the face and the alarm's editor all
peak there. Opening Remote with nine buttons, whose handler reads /remote/buttons from the flash
store under the interpreter, peaks at 6,620 B, 2,324 B above the guard. That fits because the
text instructions that copy a text, closing the old app, and the shell loop's other branches run
out of line, so their locals are not in the frames that stay on the stack while a handler runs. Start-up reaches 3,816 B: the Bluetooth server is built straight into its static
(third_party/trouble-host-macros), where building it by value took a 6.9 KB frame, and its
attribute table is a constant in flash, and the radio
and Bluetooth tasks start out of line from main. Drawing holds no buffer on the stack: the
display's two strip buffers, 3,840 B together, are static, and drawing's own frames stay under the
interpreter's. Earlier measurements, of the watch's own /apps tree (16 apps and faces) with
apps and install commands sent over the emulator's Bluetooth bridge, put packages commands
below the start path of that time. Under a connection the Bluetooth task's own frame is 0.6 KB and serve_connection's
2.1 KB; packages commands run in the storage task, since the verifier inside one once overflowed
the stack under those frames, and the app host verifies an image and runs its first derives outside
the 1.7 KB frame that loads it.
The last 88 bytes of the firmware's static RAM, in the .uninit section just below the stack, are
the kept clock (firmware/src/clock.rs, 20 B), the crash record (firmware/src/restart.rs, laid
out by battletime-crash-record) and the 8-byte stack overflow count, kept as the count and its bits
flipped. Start-up does not zero .uninit, so a panic or HardFault written there survives the reset
that follows. The kept clock is five words, which battletime-current-time writes and checks: "TIME",
UTC in milliseconds as two halves, the time zone and a check over the four. The watchdog task
writes it twice a second and start-up takes it back, so after a soft or watchdog reset the clock
counts on from it, and RAM after a power cut fails the check. A restored clock counts as set, the
same as one a phone set: BattleTime has no separate "set by phone" state, and the step log and alarms
go by the clock whatever set it, so a reset no longer throws them back to the build date. A kept
time earlier than the build time is moved up to it. It all sits at about 0x2000D61C, wherever the
linker puts the end of the other statics, so each build has its own address and a record from
another build fails its checksum; the overflow count starts again from 0 the same way, and the
first start of a new firmware starts the clock from its build time until the phone sets it. The bootloader runs between the two
starts but keeps clear of it: its statics are 336 B at the bottom of RAM and its stack reaches about
4.7 KB below the alive marker in an ordinary start, measured in the emulator, against the record's
10.3 KB. A stack that grew into the record would only happen after the firmware's own stack had
already overflowed.
Advertising like InfiniTime, the gadgetbridge_name setting, Gadgetbridge's nav message, the
request lines and rounding fractions in companion lines took 2,816 B of flash (2,808 B of text and
8 B of data) and 48 B of static RAM in the full build, against cargo size of master 5eaf23f2, and
312 B in the recovery build. Advertising itself saved about 450 B, since the advertisement is a
constant rather than encoded at start; most of the cost is the nav reader and serde's
deserializers, which the larger line parser no longer inlines. A line with a fraction is copied
once on the heap while it is read.
The wake gestures (raise, lower and shake to wake, sleep mode and the face's double tap) took 3,808 B
of flash, 16 B of data and 312 B of static RAM, against cargo size of master 97551242: the wake
gesture detector, about 130 B, lives in the motion task's state and the double tap finder in the
shell's. With them, a typical install with the settings app opened from the launcher peaked at
6,132 B, 3,836 B spare. The companion's longest line went from 512 to 640 bytes for the longer
settings report, which is built on the shell's stack only while it is sent.
To measure it again after a change, build the firmware and run, inside nix develop:
(ulimit -v 8000000; python3 -I tools/measure_stack.py firmware/target/thumbv7em-none-eabihf/release/battletime-firmware 8 --launcher)
It fills the stack with a pattern in the PineTime emulator, starts the firmware without the reset
its first start would cause, swipes to the launcher, and prints the peak, the spare bytes and any
resets. Spare bytes are counted down to the top of the guard. Keep at least 2 KB spare. With
--flash and --library for an install, --open-first-app also taps the launcher's first row.
The UI heap gave up 768 B for the heart rate work, so the launcher keeps as much stack spare as
without it.
With apps installed and packages commands sent over Bluetooth, as the companion app sends them:
cargo run -p battletime-packages --example filesystem_use -- --no-theme --image flash.bin launcher=launcher.ntapp ...
python3 -I tools/measure_stack.py firmware/target/thumbv7em-none-eabihf/release/battletime-firmware 3 \
--flash flash.bin --library shared.ntlib --packages '{"c":"apps"}' '{"c":"install","id":"alarm"}'
--packages starts the emulator with its Bluetooth bridge and sends each line through
tools/packages_over_hci.py, which speaks HCI to the bridge directly, needing neither BlueZ nor
root. The bridge is pinetime-engine --bluetooth, whose controller sits on a pseudo-terminal. The
library must be built from the same commit as the firmware, or the firmware faults as it starts and
never advertises. The bridge's controller now and then holds a packet back until more HCI traffic arrives, so
the client nudges it with a harmless command and sends a line again when the watch could not read it.
Static RAM by owner, from tools/measure_memory.py. The firmware's own share is mostly Embassy's
task storage, which holds each task's state, and the Bluetooth host's packet pool:
| Owner | Full build | Recovery build |
|---|---|---|
| Heap (system 10 KB, UI 20.25 KB; the recovery build has no UI heap) | 30,976 B | 10,240 B |
| Firmware: task storage, the Bluetooth host's resources and packet pool | 21,139 B | 11,266 B |
| Bluetooth controller | 2,610 B | 2,610 B |
| Display, graphics and drivers | 372 B | 372 B |
App host (with battletime-ui) |
364 B | 0 B |
| Other | 1 B | 1 B |
Static RAM (.data and .bss) |
55,528 B | 24,520 B |
| Left for the stack | 9,992 B | 41,000 B |
The table below is the budget:
| Region | Budget | Holds | Status |
|---|---|---|---|
| UI heap | 20.25 KB | The heap the open app lives in. The battletime-ui crate keeps no screen buffer of its own. battletime-app-host keeps the open app's state, interpreter and bindings here (1,156 B plus 16 B per expression, the variables' bytes twice and 6 B per variable, at most about 6.6 KB), one 128 B node per widget of the open screen that is not inside a hidden one, each its own allocation so no large free block is needed, plus its text, and the open app's image read from /apps. The system's 10 KB shares the same heap. How much an app needs is worked out from its image, as below. The stopwatch demo's screen holds 1,016 B on the computer and peaks at 1,080 B while drawing. A TextFile holds 4 B for every eighth line of its file and the text of the lines near the view, under 2 KB in the small font, and borrows a 512 B buffer while it reads; a 2,000-line note takes about 1 KB of places. A BitmapFile holds its picture while its screen is open, at most 1,802 B for 120 by 120 pixels, and frees it before reading another when its number changes. |
Partly measured: node size with size_of for the watch, the heap peaks in the emulator, the demo screen with a counting allocator on the computer |
| Bluetooth | 12 KB | The controller and the TrouBLE host: connection state, packet buffers and the GATT tables. | Partly measured: the controller is 1.5 KB; TrouBLE's example with it is 12.0 KB in all, executor included |
| Apps | 6 KB | Memory for the app that is open beyond the UI heap, such as the parts of its file read from /apps. The runtime's working memory is sized to the app: 1,156 B for the interpreter, 16 B for each expression's bindings, and the variables' bytes for the state and again for the interpreter's scratch room, with 6 B for each variable's place. AppHost keeps all three on the UI heap. |
Partly measured |
| Stack | 8 KB | The main stack, for interrupts and code that is not in a task. | Planned |
| Tasks | 6 KB | Embassy's tasks. Each task's state is fixed in size when the firmware is built. | Planned |
| Reserve | 3 KB | Left free, so a budget that runs over does not crash the watch. | Planned |
| System state | 4 KB | Settings, notifications, alarms and the step count. The latest 5 notifications are kept on the heap, about 200 B each, beside about 200 B of the shell task's state. | Planned |
| Display buffer | 2 KB | The buffer battletime-display fills before each SPI transfer. |
Planned |
| Flash store | 2.4 KB | The store's tables, in a static rather than the heap: 1 KB of record keys, 512 B of files, the rings' sector order, the log batches and a 64 B map of the file sectors; an upload's state, about 200 B, is on the heap only while a file is written a part at a time. Its calls borrow up to about 1 KB of stack. | Measured with size_of for the watch |
The 64 KB is all the chip has, so a part that needs more must take it from another row of this table.
How much heap an app needs¶
battletime_runtime::memory_need::estimate_memory_need works out an app's need from its image alone,
so the package manager can check it before an app is installed or turned on, and the shell before
it opens one. The parts, in bytes:
| Part | Bytes | Where the number comes from |
|---|---|---|
| The image | its length | Read into the heap while the app is open |
| Working memory | 1,156 | The interpreter without its scratch room, with size_of for the watch |
| Sized working memory | sized_working_bytes |
16 per expression for its bindings, 6 per variable for its place, and the variables' bytes for the state and again (at most 1,024) for the interpreter's scratch room |
| Subscriptions | 40 each | What the host keeps for each |
| Saved state | saved_state_bytes |
The buffer the shell writes it from when the app closes, and reads it into when it opens |
| Passing use | 1,536 | Drawing, the image checks at open, and the allocator's gaps |
| Read ahead | 4,096 when the app calls prefetch_file |
The one file part it holds until a file_bytes takes it |
| Folder listing | 1,600 when the app calls open_folder |
The listed folder's path (64), 64 entries of 8 and 1,024 for their names, reserved when a folder is listed |
| Nodes | 132 per widget of the largest screen | A node and its place in the list; every widget counts as shown, a Repeat counts each row (8 when the count is worked out at run time, only those in view down a list of fixed-height rows), and no screen more than 128 |
| Text | each fixed text's length, 16 for text worked out at run time, 2,560 for a TextFile |
An app's need must fit APP_HEAP_BUDGET_BYTES, 27,648 B: the 31,744 B heap less 4 KB for the
system while an app is open. At rest the system holds the shell's index of app ids, about 12 B an
app; the rest of its share is for kept notifications (up to about 1 KB), a small file gathered while it uploads (up to
1 KB), and lines to and from the companion app.
Measured in the PineTime emulator on 2026-10-12 with tools/measure_heap.py, which installs the
digital face, the launcher and one app, opens each and reads the allocator's bytes in use with
its first screen open, against the estimate (which counts hidden widgets, so it is the larger):
cargo run -p battletime-packages --example filesystem_use -- --no-theme --image flash.bin \
digital=digital.ntapp launcher=launcher.ntapp settings=settings.ntapp
python3 -I tools/measure_heap.py firmware/target/thumbv7em-none-eabihf/release/battletime-firmware flash.bin shared.ntlib
| App | In use | Estimate | Highest heap byte touched |
|---|---|---|---|
digital face |
12,072 B | 12,700 B | 13,156 B |
launcher, one app listed |
7,980 B | 8,524 B | |
alarm |
12,344 B | 18,224 B | |
settings |
19,288 B | 19,628 B | 20,676 B |
weather |
10,752 B | 16,644 B | |
stopwatch |
12,016 B | 14,488 B |
Those were measured with littlefs, whose buffers took about 1 KB of the heap, and in use counts
them. Measured again with the flash store on 2026-10-13, with a typical install: the digital
face 11,864 B in use, the launcher 8,156 B and the alarm 12,136 B; with the alarm's editor open the highest byte touched
was 13,188 B of the 31,744 B heap. The shell keeps a full list of every installed app, turned
off or not, only while an app that reads it is open, such as the launcher or the Software app
(reads_app_list): 62 B an app measured with 38 installed, against the LISTED_APP_BYTES (48 B)
plus id and name such an app's need counts for each. It reads the list once the old app has
closed, and drops it when the next app does not read it, keeping only the face, the launcher, its
look and the listed apps' ids, which every app's need counts as indexed_app_bytes. With all 52
apps and faces installed and the analog face on, measured in the emulator on 2026-10-14, the face
holds 10,376 B, the launcher 14,780 B and the alarm 11,784 B, against 14,192 B, 14,304 B and
15,600 B when every app kept the full list. With every app in
the repository and the digital face installed, the face held 14,356 B and the launcher, listing
every app, 11,832 B; the harness works the launcher's need out at 17,169 B with all 55 apps and
faces on, so it fits with room to spare. A launcher's need is its own, plus the names and icons of
the apps it lists; each icon is read from its image on its own, so no whole image is counted.
The largest app in the repository, settings, needs 24.5 KB since it gained the face chooser and
the date and time pages, at 128 widgets, the most an app may have, so every app fits with room to
spare, and harness/tests/quality.rs fails any app or face whose
estimate is over the budget, or whose opening screen builds more nodes than it counts. Before the
saved-state buffer was sized for each app, closing any app borrowed 18,694 B for it, which took
the highest byte touched after leaving settings to 30,796 B.
The refusals were checked the same way, over the emulator's Bluetooth bridge, with every app, the
digital face and a 36,580 B test image uploaded to /apps/huge but not installed:
python3 -I tools/measure_stack.py <firmware> 5 --launcher \
--flash flash.bin --library shared.ntlib --packages '{"c":"install","id":"huge"}' \
'{"c":"enable","id":"launcher","on":false}' '{"c":"enable","id":"digital","on":false}' \
'{"c":"enable","id":"timer","on":false}' '{"c":"enable","id":"timer"}' '{"c":"apps"}'
huge was refused with "why":1,"need":36580, the launcher and the only face with "why":3 and
"why":4, the timer went off and on again with "need":16104, and the launcher still opened with
every app listed, the stack peaking at 7,848 B, 2,632 B spare.
Measuring again¶
The firmware, RAM and file-system tables above come from one script, which writes them into
this page between the <!-- measure_memory:... --> markers, and the same figures into the
memory treemap (docs/memory-treemap.html) as its MEASURED line. Run it
inside nix develop; --check only reports whether either page is out of date:
python3 -I tools/measure_memory.py
CI's Memory map job (.github/workflows/battletime.yml) runs it on every push and pull request and keeps
both measured pages as an artifact. On master, when the figures change, it commits both pages as
docs(memory-map): measure <commit>, so nobody needs to run it before landing; rebase over that
commit as over any other. The prose around the tables still needs updating by hand, and so does the
treemap's hand-written part in docs/memory-treemap.html: the flash layouts, the bootloader and
shared library sizes, the deepest stack path and the split of the firmware's static RAM.
It builds the full and the recovery firmware, sorts their symbols by crate, and runs the
filesystem_use example of battletime-packages. That example also writes the whole 4 MB external
flash with --image <file>, for the emulator to load with
externalFlashMemory LoadBinary @<file> 0:
cargo run -p battletime-packages --example filesystem_use -- --image external.bin digital=firmware/apps/face.ntapp launcher=firmware/apps/launcher.ntapp
Watchdog¶
The bootloader leaves the watchdog running when it starts the firmware, with a 5 second timeout.
It keeps counting while the chip sleeps and pauses while a debugger has it halted. The firmware
must take it over with exactly those settings, because embassy-nrf refuses different ones.
The firmware feeds it only while the button is up, so holding the button for about 5 seconds resets the watch into the bootloader, and holding it 5 seconds more installs the recovery firmware. If the firmware hangs, the watch resets, and an update that never confirms itself is rolled back. See bootloader.md for the details.