So far we have written programs that do one single thing. A smart watch has to draw the screen, read the accelerometer, keep the Bluetooth connection alive and count seconds - all at once, on a single processor, with 64 KB of memory and a battery that must last a week. In this lab we study how this is solved, on a real product whose source code is entirely public.
1Objectives of the lab
- Understanding the difference between a main loop and a preemptive scheduler
- Applying schedulability criteria: utilization, the Rate Monotonic bound, EDF
- Building a complete, real firmware from source code
- Creating your own FreeRTOS task and correctly choosing its priority and stack size
- Showing on the watch the data produced by your own task and checking it in the system task list
- Communication between tasks through message queues, instead of global variables
- Triggering and fixing priority inversion
- The link between scheduling and consumption: why a poorly written task drains the battery
2About the project
We take the InfiniTime firmware - the system that runs on the PineTime watch - build it from source, add our own task and a new watchface, then flash it onto the watch over Bluetooth. At the end we measure what our addition cost, in consumption.
3Materials needed
- 1 PineTime (the sealed variant is enough)
- 1 Computer with Linux or WSL2
- 1 Docker installed
- 1 Phone with Gadgetbridge (Android) or InfiniLink (iOS) - see section 12
- 1 Magnetic charging cable (included with the watch)
- 1 ESP32-C6 from the previous lab (for the FreeRTOS-without-a-watch part)
4Why a main loop is not enough
A microcontroller program almost always starts like this:
void loop() {
read_accelerometer(); // 2 ms
update_display(); // 30 ms <-- long
handle_bluetooth(); // 5 ms
count_seconds(); // 1 ms
}It works until the moment one of the tasks becomes slow. While update_display() is
drawing, the other three do not run. If a Bluetooth frame arrives during that interval, it is
lost. And if the screen occasionally needs 200 ms, the watch visibly falls behind.
You can patch this with state machines and millis() checks - exactly the technique
from the microcontroller lab. But by the fifth or sixth task, the code becomes impossible to
follow. The industrial solution is a scheduler that interrupts a task partway through and
gives the processor to another, more urgent one.
The vocabulary of scheduling
| Term | Notation | Meaning |
|---|---|---|
| Period | T | at what interval the task must be resumed |
| Execution time | C | how much processor time it needs, in the worst case |
| Deadline | D | by when it must finish; usually D = T |
| Utilization | U = Σ C/T | what fraction of the processor is demanded in total |
| Preemption | - | interrupting a task in progress in favor of a more urgent one |
The two classic policies
| Rate Monotonic (RM) | Earliest Deadline First (EDF) | |
|---|---|---|
| Priority | fixed: shorter period → higher priority | dynamic: the nearest deadline wins |
| Guarantee | U ≤ n·(21/n − 1) | U ≤ 1 |
| Bound for large n | ≈ 69% | 100% |
| Runtime cost | low - priorities are computed once | higher - deadlines are compared constantly |
| Behavior under overload | predictable: the slowest tasks give way first | unpredictable: everything can fail |
| Used by | FreeRTOS, Zephyr, most embedded systems | research systems, some real-time Linux kernels |
U is below the RM bound, the system is guaranteed schedulable. If it exceeds it,
that does not mean it will not work - it only means this test cannot decide, and the answer
must be found through simulation or response-time analysis. This is a distinction the simulator
below makes explicit.5Scheduling simulator
Three periodic tasks, one processor. Move the sliders for the execution time and watch the diagram: the upward arrows are the release times, the colored rectangles are the actual execution, and a red ✕ marks a missed deadline. Switch between the two policies and compare.
- Find the RM bound. Increase the duration of the "display" task step by step. At what utilization does the first missed deadline appear? Compare it with the theoretical bound of 78% for three tasks.
- The uncertain zone. Bring the utilization between the RM bound and 100%. The verdict becomes "the test cannot decide", yet the simulation shows zero missed deadlines. This is the practical proof that the test is sufficient, but not necessary.
- EDF's superiority. Find a configuration where RM misses a deadline, then switch to EDF without changing anything else. Same tasks, same processor, different result.
6FreeRTOS tasks in practice
The code below runs unchanged on the ESP32-C6 and, with minimal changes, on any FreeRTOS system - including PineTime.
// FreeRTOS priorities: a BIGGER number = a HIGHER priority.
// We choose them by the Rate Monotonic rule: shorter period, higher priority.
#define PRIO_SENSOR 3 // period 20 ms
#define PRIO_BLUETOOTH 2 // period 100 ms
#define PRIO_DISPLAY 1 // period 200 ms
void sensorTask(void* param) {
// vTaskDelayUntil keeps an EXACT period, no matter how long the work took.
TickType_t last = xTaskGetTickCount();
for (;;) {
read_accelerometer();
vTaskDelayUntil(&last, pdMS_TO_TICKS(20));
}
}
void bluetoothTask(void* param) {
TickType_t last = xTaskGetTickCount();
for (;;) {
handle_bluetooth();
vTaskDelayUntil(&last, pdMS_TO_TICKS(100));
}
}
void displayTask(void* param) {
TickType_t last = xTaskGetTickCount();
for (;;) {
draw_display(); // may take 30 ms - will be interrupted by the sensor
vTaskDelayUntil(&last, pdMS_TO_TICKS(200));
}
}
void setup() {
Serial.begin(115200);
// function name stack param priority handle
xTaskCreate(sensorTask, "sensor", 2048, NULL, PRIO_SENSOR, NULL);
xTaskCreate(bluetoothTask, "bluetooth", 4096, NULL, PRIO_BLUETOOTH, NULL);
xTaskCreate(displayTask, "display", 4096, NULL, PRIO_DISPLAY, NULL);
}
void loop() {
// The main loop is itself a FreeRTOS task, at priority 1.
// We let it sleep, so it does not steal processor time.
vTaskDelay(pdMS_TO_TICKS(1000));
}Three mistakes almost everyone makes
vTaskDelay instead of vTaskDelayUntil
vTaskDelay(20) means "wait 20 ms from now". If the work took 5 ms, the real
period becomes 25 ms, and the error accumulates. vTaskDelayUntil is measured from the
previous release time and keeps the period exact. For any periodic task, the second option is the
correct one.delay() inside a task
On the ESP32, delay() is redirected to vTaskDelay() and behaves nicely.
On other FreeRTOS systems it is an active-wait loop that blocks every lower-priority task
for its entire duration. Always use the FreeRTOS functions.void diagnosticTask(void* param) {
for (;;) {
// Returns the minimum number of words that have remained FREE since startup.
// If it approaches zero, the stack is undersized.
UBaseType_t free_words = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("stack remaining: %u words (%u bytes)\n",
free_words, free_words * sizeof(StackType_t));
vTaskDelay(pdMS_TO_TICKS(5000));
}
}7Communication between tasks
Two tasks that write to the same global variable produce data corruption that is hard to reproduce. FreeRTOS's solution is the message queue: a protected circular buffer, into which one task puts data and another takes it out, without the two knowing about each other.
typedef struct {
uint32_t timestamp_ms;
float x, y, z;
} Reading;
QueueHandle_t queue;
void sensorTask(void* param) {
TickType_t last = xTaskGetTickCount();
for (;;) {
Reading r = { millis(), readX(), readY(), readZ() };
// The third parameter is how long we wait if the queue is full.
// With 0 we do not wait at all: better to lose a reading than to
// delay a higher-priority task.
if (xQueueSend(queue, &r, 0) != pdTRUE) {
// A full queue means the reader is not keeping up - useful information.
Serial.println("reading lost - the consumer is too slow");
}
vTaskDelayUntil(&last, pdMS_TO_TICKS(20));
}
}
void displayTask(void* param) {
Reading r;
for (;;) {
// portMAX_DELAY: the task sleeps until something arrives. While asleep,
// it does NOT consume processor time - exactly what we need for the battery.
if (xQueueReceive(queue, &r, portMAX_DELAY) == pdTRUE) {
draw(r.x, r.y, r.z);
}
}
}
void setup() {
Serial.begin(115200);
queue = xQueueCreate(10, sizeof(Reading)); // 10 messages pending
if (queue == NULL) {
Serial.println("not enough memory for the queue");
return;
}
xTaskCreate(sensorTask, "sensor", 2048, NULL, 3, NULL);
xTaskCreate(displayTask, "display", 4096, NULL, 1, NULL);
}
void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }- It is atomic - you cannot read half of a half-written structure.
- It does not lose data - a global variable only keeps the last value; the queue keeps all ten.
- It blocks efficiently - the consuming task sleeps without consuming the processor, instead of polling in a loop.
- It tells you when the system cannot keep up - a full queue is a measurable symptom, not a silent failure.
8Priority inversion
This is the most famous trap in real-time systems. It endangered the Mars Pathfinder mission in 1997: the rover on Mars kept rebooting on its own, at irregular intervals, and the cause was exactly the mechanism described below.
How it happens
- A low-priority task takes a shared resource (for example the I²C bus).
- A high-priority task needs the same resource and blocks, waiting for it.
- A medium-priority task, which has nothing to do with the resource, becomes ready and preempts the low-priority one.
- Result: the medium-priority task indirectly delays the high-priority one. The priority hierarchy has been inverted.
Trigger it yourself
SemaphoreHandle_t resource;
void busy_wait(uint32_t ms) {
// ACTIVE wait loop: keeps the processor busy, like real processing.
uint32_t t0 = millis();
while (millis() - t0 < ms) { }
}
void lowTask(void* p) { // priority 1
for (;;) {
xSemaphoreTake(resource, portMAX_DELAY);
Serial.println(" [low] took the resource");
busy_wait(300); // works with the resource
Serial.println(" [low] releasing the resource");
xSemaphoreGive(resource);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void mediumTask(void* p) { // priority 2
vTaskDelay(pdMS_TO_TICKS(50)); // starts after the low one
for (;;) {
Serial.println(" [medium] occupying the processor for 800 ms");
busy_wait(800); // never touches the resource!
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void highTask(void* p) { // priority 3
vTaskDelay(pdMS_TO_TICKS(100)); // requests the resource after the low one
for (;;) {
uint32_t t0 = millis();
Serial.println("[HIGH] requesting the resource");
xSemaphoreTake(resource, portMAX_DELAY);
Serial.printf("[HIGH] waited %lu ms\n", millis() - t0);
xSemaphoreGive(resource);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
Serial.begin(115200);
delay(500);
// ---- CHANGE ONLY THIS LINE ----
resource = xSemaphoreCreateBinary(); // WITHOUT priority inheritance
xSemaphoreGive(resource); // binary semaphores start empty
// resource = xSemaphoreCreateMutex(); // WITH priority inheritance
// -------------------------------
xTaskCreate(lowTask, "low", 2048, NULL, 1, NULL);
xTaskCreate(mediumTask, "medium", 2048, NULL, 2, NULL);
xTaskCreate(highTask, "high", 2048, NULL, 3, NULL);
}
void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }xSemaphoreCreateBinary(), the high-priority task waits around 1000 ms: the
300 ms during which the resource is busy, plus the 800 ms during which the medium-priority task
preempts the holder. With xSemaphoreCreateMutex() the wait drops to roughly
300 ms - just how long the work with the resource actually takes.Practical rule: use a mutex for mutual exclusion (protecting a resource) and a semaphore for signaling (announcing an event). They are different things, even though the API looks similar.
9Building the InfiniTime firmware
We move from examples to a complete firmware. InfiniTime has over a hundred thousand lines of code and runs on FreeRTOS with the LVGL graphical interface.
doc/buildAndProgram.md file in the repository. The steps below reflect the
usual procedure, but the repository is always the source of truth.- Download the source codedownload
git clone https://github.com/InfiniTimeOrg/InfiniTime.git cd InfiniTime git submodule update --init --recursive
- Build using the official Docker image
This is the recommended path: it brings along the ARM compiler and the Nordic SDK, so you do not need to install anything else.
builddocker run --rm -it \ -v "$(pwd)":/sources \ -u $(id -u):$(id -g) \ infinitime/infinitime-build
The first run takes a while, because it downloads the image. The result appears in
build/output/- the file with a.zipextension and the-dfusuffix is the package that gets loaded over Bluetooth. - Explore the project structureorientation
ls src/systemtask/ # the central task, which coordinates the others ls src/displayapp/ # the display task and the apps ls src/components/ # the drivers: battery, screen, sensors, Bluetooth grep -rn "xTaskCreate" src/ # where the tasks are created grep -rn "configTOTAL_HEAP_SIZE" src/FreeRTOSConfig.h
- The watch-free alternative: the simulator
InfiniSim builds the same interface code for the computer and displays it in a window, with an emulated screen and touch. It is the fastest way to develop a watchface.
simulatorgit clone https://github.com/InfiniTimeOrg/InfiniSim.git cd InfiniSim && git submodule update --init --recursive # follow the instructions in the README for the SDL2 dependencies
Anatomy of the system
Once the code is built, look for the xTaskCreate calls. You will find a clear pattern:
| Task | Role | How it is sized |
|---|---|---|
SystemTask | coordinates everything: startup, sleep, events | high priority, generous stack |
DisplayApp | draws the screen and handles touches | the largest stack - LVGL needs memory |
| Bluetooth stack | maintains the radio connection | high priority, strict deadlines imposed by the protocol |
| idle task | runs when nothing else has to | this is where the low-power state is entered |
vTaskDelay, the idle
task never runs again, consumption rises tenfold and the battery lasts a day instead of a week. A
single misplaced while(1){} has this effect.10Adding your own task
We add to InfiniTime a task that counts the steps taken in every hour of the day, and we show the result on the watch, in the steps app. The example is small, but it sets three tasks in motion and uses both communication mechanisms from section 7:
- SystemTask reads the accelerometer and, whenever the step total changes, sends a report through a queue. The queue carries events: "something happened".
- statistics, our task, sleeps blocked on the queue. On each report it wakes up, works out how many steps were added, records them in the per-hour table, then goes back to sleep.
- DisplayApp reads the table when it draws the screen. The table is shared state, so reading and writing it are protected by a critical section.
#pragma once
#include <FreeRTOS.h>
#include <task.h>
#include <queue.h>
#include <array>
#include <cstdint>
#include "components/datetime/DateTimeController.h"
namespace Pinetime {
namespace Components {
class Statistics {
public:
struct Report {
uint8_t hour; // 0...23
uint32_t steps; // the day's total, as the sensor gives it
};
explicit Statistics(Controllers::DateTime& dateTime) : dateTime {dateTime} {}
void Start(); // creates the queue and the task
void ReportSteps(uint32_t steps); // called from the system task
uint32_t StepsInHour(uint8_t hour); // called from the display task
private:
static void Task(void* param); // the task function
void Loop();
Controllers::DateTime& dateTime; // where we read the current hour from
TaskHandle_t handle = nullptr;
QueueHandle_t queue = nullptr;
uint32_t lastSent = 0; // used ONLY by the system task
uint32_t lastReceived = 0; // used ONLY by the statistics task
bool haveBaseline = false; // used ONLY by the statistics task
std::array<uint32_t, 24> per_hour {}; // written by statistics, read by display
};
}
}#include "Statistics.h"
using namespace Pinetime::Components;
void Statistics::Start() {
// The queue is small: we only keep a few reports pending.
queue = xQueueCreate(4, sizeof(Report));
if (queue == nullptr) {
return; // no memory even for the queue
}
// A 512-word stack = 2 KB. The task does not use LVGL and has no
// large buffers, so this is enough for it. Check with
// uxTaskGetStackHighWaterMark before treating this as the final value.
if (xTaskCreate(Task, "statistics", 512, this,
tskIDLE_PRIORITY + 1, &handle) != pdPASS) {
handle = nullptr; // no memory for the stack
}
}
void Statistics::Task(void* param) {
static_cast<Statistics*>(param)->Loop();
}
void Statistics::Loop() {
Report r;
for (;;) {
// The task SLEEPS here until a report arrives. While waiting it costs
// nothing, and the idle task can stop the processor's clock.
if (xQueueReceive(queue, &r, portMAX_DELAY) != pdTRUE) {
continue;
}
// The first report after boot only gives the starting point: we do not
// know in which hour the steps taken before the reboot happened.
if (!haveBaseline) {
lastReceived = r.steps;
haveBaseline = true;
continue;
}
// The sensor gives the day's total; the difference from the previous
// total is the number of steps taken in between.
uint32_t added;
if (r.steps >= lastReceived) {
added = r.steps - lastReceived;
} else {
// The total went down: midnight has passed and the counter restarted
// from zero. We begin a new day of statistics.
added = r.steps;
taskENTER_CRITICAL();
per_hour.fill(0);
taskEXIT_CRITICAL();
}
lastReceived = r.steps;
if (r.hour < per_hour.size()) {
taskENTER_CRITICAL(); // the display may read the table at any time
per_hour[r.hour] += added;
taskEXIT_CRITICAL();
}
}
}
void Statistics::ReportSteps(uint32_t steps) {
// Only send when something changed - otherwise we fill the queue for nothing.
if (queue == nullptr || steps == lastSent) {
return;
}
Report r {dateTime.Hours(), steps};
// No waiting: if the queue is full, the report is lost. No steps are lost,
// though - the next report again carries the day's total.
if (xQueueSend(queue, &r, 0) == pdTRUE) {
lastSent = steps;
}
}
uint32_t Statistics::StepsInHour(uint8_t hour) {
if (hour >= per_hour.size()) {
return 0;
}
taskENTER_CRITICAL();
uint32_t value = per_hour[hour];
taskEXIT_CRITICAL();
return value;
}- Priority
tskIDLE_PRIORITY + 1- the lowest above the idle task. Statistics have no deadline; they must not be allowed to delay drawing the screen or the Bluetooth stack. - A 512-word stack - the starting value, which must be verified with
uxTaskGetStackHighWaterMark. On a system with 64 KB, every kilobyte allocated needlessly is taken from someone else. Section 11 shows you where to see it on the watch. - The task waits on the queue instead of waking up periodically -
xQueueReceivewithportMAX_DELAYtakes it out of scheduling entirely until a report arrives. While you stand still the task does not run at all; a loop withvTaskDelaywould wake up for nothing thousands of times a day. - Zero wait time when sending - the system task has a higher priority and must not be blocked by a full queue belonging to an unimportant one. If a report is lost, the steps are not: every report carries the day's total, so the next one recovers everything.
- Every variable has a single owner -
lastSentis used only by the system task,lastReceivedonly by the statistics task. The only thing used by two tasks is theper_hourtable, and only it is protected by the critical section.
configASSERT
is a macro that disappears entirely in the build that goes onto the watch. A line like
BaseType_t r = xTaskCreate(...); configASSERT(r == pdPASS); leaves behind a variable that is never
read, and the project is compiled with -Werror, which turns every warning into an error: the build
stops with unused variable. Worse, the check itself is gone too. That is why the result is tested
explicitly with if, and if something could not be created, the task simply stays inactive instead
of bringing the watch down.11Wiring the task in and rebuilding
The two files from the previous section do nothing yet. If you copy them into the project and
build, the build succeeds with no error, the watch boots, and the task does not exist. The reason: the
compiler does not know the new file has to be compiled, nobody creates the object or calls its
Start(), and nothing displays the result. Four steps follow, then a rebuild.
.cpp file that is not listed in
CMakeLists.txt produces no error at all: it is simply not compiled. If you changed the code and
nothing changes on the watch, check this step first.undefined reference errorThe message comes from the linker
(ld), not the compiler: every file compiled fine, but in the end something calls a function whose
code was not compiled into that executable. Look at which target fails - its name appears in the line
CMakeFiles/pinetime-recovery.dir/.... The dangerous relocation lines around it are
just consequences of the same missing function.- Register the new file with CMake
Open
src/CMakeLists.txtand add the path of the.cppfile, relative tosrc/, next to the other components. The.hfile does not go in the list. Careful: the line has to go into two lists, not one.Besides the normal firmware, InfiniTime also builds a recovery firmware - a minimal image the bootloader loads if the main application no longer starts. It contains
SystemTask.cpptoo, so it also callsStart()andReportSteps(). Its source list is calledRECOVERY_SOURCE_FILES. If you add the file toSOURCE_FILESonly, compilation succeeds but linking the recovery firmware stops withundefined reference to Pinetime::Components::Statistics::Start().src/CMakeLists.txt# 1. the normal firmware list(APPEND SOURCE_FILES ... components/datetime/DateTimeController.cpp components/statistics/Statistics.cpp # <-- the added line ... ) # 2. the recovery firmware - further down in the same file list(APPEND RECOVERY_SOURCE_FILES ... components/datetime/DateTimeController.cpp components/statistics/Statistics.cpp # <-- the same line, once more ... )
- Create the object in the system task
Our task needs the date-and-time controller to know the current hour (see the constructor in
Statistics.h).SystemTaskalready holds a reference to it, so the object is declared there. Order matters: class members are constructed in the order they are declared, sostatisticsmust be declared afterdateTimeController. TheGetStatistics()function gives the steps app access to the object, in step 4.Be careful where you put it: right below
enum class SystemTaskStatestarts the declaration of theSystemTask(...)constructor, which spans about 20 lines. If the function ends up inside its parameter list, the compiler reports hundreds of errors (expected ')' before '{' token, thenuninitialized reference memberfor every member), even though the mistake is a single line. Put it afterPushMessage, as below.src/systemtask/SystemTask.h#include "systemtask/Messages.h" #include "components/statistics/Statistics.h" // <-- added // ... in the public section, RIGHT AFTER the PushMessage declaration. // CAREFUL: not inside the parameter list of the SystemTask(...) constructor, // which spans about 20 lines just above and ends with ");" void Start(); void PushMessage(Messages msg); Pinetime::Components::Statistics& GetStatistics() { return statistics; } // ... in the private section, RIGHT AFTER the dateTimeController line: Pinetime::Controllers::DateTime& dateTimeController; Pinetime::Components::Statistics statistics {dateTimeController}; - Start the task and feed it data
Start()is called once, at boot, after the other initialisation inSystemTask::Work(). The steps come from the function that reads the accelerometer,UpdateMotion()- that is where we pass them on through the queue.src/systemtask/SystemTask.cppvoid SystemTask::Work() { // ... existing initialisation ... motionController.Init(motionSensor.DeviceType()); settingsController.Init(); statistics.Start(); // <-- starts our task // ... } void SystemTask::UpdateMotion() { // ... auto motionValues = motionSensor.Process(); motionController.Update(motionValues.x, motionValues.y, motionValues.z, motionValues.steps); statistics.ReportSteps(motionValues.steps); // <-- the day's total, into the queue // ... the rest of the function is unchanged ... } - Show the result in the steps app
The Steps app (the shoe icon) receives the object through
AppControllers, which already holds a pointer toSystemTask, and adds a new line at the top of the screen. TheRefresh()function runs in the display task ten times a second while the app is open - that is where the table is read, throughStepsInHour().There are four changes in
Steps.h, marked (a)–(d). The easiest one to forget is the first, the#includeat the top. Without it the compiler does not know whatStatisticsis and reports'Pinetime::Components::Statistics' has not been declared, theninvalid use of incomplete type 'class Pinetime::System::SystemTask'- that is, it knowsSystemTaskexists but has not yet seen its definition.src/displayapp/screens/Steps.h// (a) AT THE TOP, with the includes - without it, Steps.h does not know // what Statistics is, nor what SystemTask contains: #include "Symbols.h" #include "systemtask/SystemTask.h" // <-- added // (b) the constructor takes two more references: Steps(Controllers::MotionController& motionController, Controllers::Settings& settingsController, Components::Statistics& statistics, Controllers::DateTime& dateTime); // (c) in the private section, below settingsController: Controllers::Settings& settingsController; Components::Statistics& statistics; Controllers::DateTime& dateTime; lv_obj_t* lHour; // (d) and in AppTraits<Apps::Steps>, the Create function becomes: static Screens::Screen* Create(AppControllers& controllers) { return new Screens::Steps(controllers.motionController, controllers.settingsController, controllers.systemTask->GetStatistics(), controllers.dateTimeController); };src/displayapp/screens/Steps.cppSteps::Steps(Controllers::MotionController& motionController, Controllers::Settings& settingsController, Components::Statistics& statistics, Controllers::DateTime& dateTime) : motionController {motionController}, settingsController {settingsController}, statistics {statistics}, dateTime {dateTime} { // ... all the existing constructor code ... // the new label, at the top of the screen - right before lv_task_create: lHour = lv_label_create(lv_scr_act(), nullptr); lv_obj_set_style_local_text_color(lHour, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::orange); lv_label_set_text_static(lHour, ""); lv_obj_align(lHour, nullptr, LV_ALIGN_IN_TOP_MID, 0, 25); taskRefresh = lv_task_create(RefreshTaskCallback, 100, LV_TASK_PRIO_MID, this); } void Steps::Refresh() { // ... existing code ... lv_arc_set_value(stepsArc, int16_t(500 * stepsCount / settingsController.GetStepsGoal())); uint8_t hour = dateTime.Hours(); lv_label_set_text_fmt(lHour, "Hour %02d: %lu", int(hour), statistics.StepsInHour(hour)); lv_obj_align(lHour, nullptr, LV_ALIGN_IN_TOP_MID, 0, 25); } - Rebuild
Exactly the same command as in section 9. The build is incremental: only the changed files and whatever depends on them are recompiled, so it usually takes under a minute, not as long as the first time.
rebuild# from the root of the InfiniTime repository docker run --rm -it \ -v "$(pwd)":/sources \ -u $(id -u):$(id -g) \ infinitime/infinitime-build # where the package for the phone ended up: find build -name "*-dfu*.zip"
If you get an error, read the first error in the list - the others are usually consequences of it. If you changed
CMakeLists.txtand the build seems to ignore it, delete thebuild/directory and build again from scratch.
grep -l "Statistics" build/src/*.map. Both
pinetime-app and pinetime-recovery must show up. If nothing shows up, the file was not
compiled - go back to step 1.- In the Steps app an orange line appears at the top,
Hour 14: 0. The first report after boot only sets the starting point, so counting begins with the first steps taken after the reboot. Walk a few dozen steps and reopen the app: the value goes up. On the hour, the line moves to the new hour and starts from zero, and at midnight the whole table is cleared. - In Settings → About, on screen 4 (the list of FreeRTOS tasks) a new row appears,
sta. The name is cut to three characters because InfiniTime setsconfigMAX_TASK_NAME_LENto 4 -MAIanddisshow up the same way. Column S almost always shows B (Blocked): the task sleeps on the queue, exactly as intended. Column Free shows how many stack words were never used - use it to answer the question from section 10: what value would you choose instead of 512?
lab03-statistics-en.patch. It applies to
InfiniTime 1.16.0, from the root of the repository: first
git apply --check lab03-statistics-en.patch (checks without changing anything), then
git apply lab03-statistics-en.patch. Use it to compare, after you have tried on your own -
git diff shows you exactly where your code differs.SOURCE_FILES: screens are registered in src/displayapp/apps/CMakeLists.txt and enabled at
build time by adding -e ENABLE_USERAPPS="..." or -e ENABLE_WATCHFACES="..." to the Docker
command. Read doc/code/Apps.md in the repository for the exact steps of the version you build.12Flashing the watch
The watch is updated over Bluetooth, with no wires and no programmer. The bootloader keeps the previous version and automatically falls back to it if the new firmware does not confirm that it started correctly. The procedure differs slightly between Android and iOS - pick the column that applies to your phone.
- Fully charge the watch
An update interrupted by a drained battery is the most realistic way to brick a PineTime. The battery must be above 50%.
- Install the companion app that matches your phone
Android iOS (iPhone / iPad) App Gadgetbridge InfiniLink Source F-Droid (free, open source) App Store (free, open source) What else it can do notifications, weather, music control time/date, battery, heart rate, steps, Apple Music, HealthKit One name to avoid: nRF Connect for iOS It is the app that shows up most often in searches for flashing Nordic boards, but it no longer works for PineTime: versions after 4.24.3 broke compatibility, and the App Store always installs the latest version. There is no easy way to install the older version that used to work. Use InfiniLink. - Transfer the
.zippackage to your phoneThe file is in
build/output/and has the-dfusuffix. Do not unzip it - the archived format is what the bootloader expects. On an iPhone, the simplest way is to send it to the Files app (AirDrop from your computer, or a direct download in Safari) - InfiniLink can load it directly from there. - Start the update
Android (Gadgetbridge): connect the watch, then
Firmware/App installand choose the file.
iOS (InfiniLink): connect the watch from the main screen, then from the firmware menu choose "Update from file" and select the.ziparchive from Files. Alternatively, InfiniLink can check the InfiniTime releases page on GitHub itself and offer the latest version directly, without downloading anything manually.
The transfer takes a few minutes on any platform. Do not move the phone away from the watch during this time. - Confirm the new version
After rebooting, the watch asks for confirmation that the firmware works. If you do not confirm, the bootloader reverts to the previous version on the next reboot - an elegant safety net, worth studying as a mechanism in its own right.
13Assignments
- Using the simulator from section 5, determine the maximum utilization at which the set of three tasks remains schedulable under RM. Compare it with the theoretical bound and explain the difference.
- Find a configuration where RM misses deadlines and EDF does not. Copy both diagrams into your report and explain exactly what different decision EDF makes.
- Upload
basic_tasks.inoto the ESP32-C6. ModifydisplayTaskto take 250 ms (longer than its period) and describe what happens to the other tasks. - Add
diagnosticTaskand note the remaining stack for each task. Shrink the allocations down to the safe limit and calculate how much memory you recovered. - Run
inversion.inoin both variants. Note the high-priority task's wait time in each case and explain the difference through the priority-inheritance mechanism. - Build InfiniTime. Find every
xTaskCreatecall and produce a table of the system's tasks: name, priority, stack size and the role of each. - Look up the
configUSE_TICKLESS_IDLEoption inFreeRTOSConfig.hand explain, based on the code, what happens when every task is asleep. - Add the
statisticstask following sections 10 and 11, flash the firmware onto the watch and photograph the Steps app screen before and after a few dozen steps. - In Settings → About, screen 4, write down the value in the Free column for the
statask. Choose a stack size that keeps a 25 % margin over what was actually used, rebuild and check again. How many bytes of RAM did you recover compared with 512 words? - Explain why losing a report from the queue does not lose any steps. What would happen if
ReportSteps()sent only the difference from the previous report, instead of the day's total?
14Deeper-dive challenge
The interesting part is not the drawing, but the refresh budget. Every screen redraw wakes the processor and costs energy. Measure, with
uxTaskGetStackHighWaterMark and a
wakeup counter:
- How many times a minute does your watchface redraw?
- What is the minimum number of redraws needed to show the correct time?
- How much does the autonomy improve if you redraw only the digit that changed, instead of the whole screen?
StepsInHour(0) ... StepsInHour(23). Hints: an lv_chart object of type
LV_CHART_TYPE_COLUMN with 24 points; the screen is registered as a user app
(doc/code/Apps.md) and enabled through ENABLE_USERAPPS.
Two questions to answer in your report: Why is no new queue needed for this screen? And how often does the chart have to be refreshed, if the data changes at most every few seconds - are the 10 refreshes per second the Steps app does really necessary?