LABORATORUL 03

Sisteme de operare în timp real: firmware pe un ceas open source

Durată: 4 ore Suport: Capitolele 7 și 8 Platformă: PineTime + InfiniTime (FreeRTOS) Carte capitolul de referință EN English version

Până acum am scris programe care fac un singur lucru. Un ceas inteligent trebuie să deseneze ecranul, să citească accelerometrul, să întrețină conexiunea Bluetooth și să numere secundele - simultan, pe un singur procesor, cu 64 KB de memorie și o baterie care trebuie să țină o săptămână. În această lucrare studiem cum se rezolvă asta, pe un produs real al cărui cod sursă este public integral.

1Obiectivele lucrării

  • Înțelegerea diferenței dintre o buclă principală și un planificator preemptiv
  • Aplicarea criteriilor de planificabilitate: utilizare, limita Rate Monotonic, EDF
  • Compilarea unui firmware complet, real, din codul sursă
  • Crearea unui proces FreeRTOS propriu și alegerea corectă a priorității și a stivei
  • Afișarea pe ceas a datelor produse de un proces propriu și verificarea lui în lista de procese a sistemului
  • Comunicarea între procese prin cozi de mesaje, în locul variabilelor globale
  • Provocarea și rezolvarea inversiunii de prioritate
  • Legătura dintre planificare și consum: de ce un proces prost scris golește bateria

2Obiectul lucrării

Luăm firmware-ul InfiniTime - sistemul care rulează pe ceasul PineTime - îl compilăm din sursă, îi adăugăm un proces propriu și un cadran nou, apoi îl încărcăm pe ceas prin Bluetooth. La final măsurăm ce a costat, în consum, ceea ce am adăugat.

De ce tocmai acest ceas PineTime este singurul ceas inteligent la care aveți acces la absolut tot: schema electrică, cablajul, codul bootloaderului și codul firmware-ului. Nu este un exercițiu didactic - este un produs care se vinde, cu constrângeri reale de memorie și de baterie. Puteți citi de ce a fost luată fiecare decizie de proiectare, apoi o puteți schimba și vedea ce se întâmplă. Acest lucru nu este posibil pe niciun ceas comercial.
procesor
nRF52832
ARM Cortex-M4F, 64 MHz
memorie
64 KB RAM · 512 KB flash
plus 4 MB flash extern pentru resurse
ecran
ST7789, 240×240
IPS color, cu panou tactil capacitiv
senzori
accelerometru · puls
BMA421 și HRS3300
radio
Bluetooth Low Energy
și pentru actualizarea firmware-ului
baterie
180 mAh
autonomie de aproximativ o săptămână
Constrângerea care schimbă totul 64 KB de memorie înseamnă că un singur proces cu stiva prost dimensionată poate doborî întreg sistemul. Pe un calculator obișnuit ați aloca liniștit un tampon de 100 KB; aici nu aveți atâta memorie în total. Fiecare proces din InfiniTime are stiva măsurată la octet - și veți vedea cum.

3Materiale necesare

  • 1 PineTime (varianta sigilată e suficientă)
  • 1 Calculator cu Linux sau WSL2
  • 1 Docker instalat
  • 1 Telefon cu Gadgetbridge (Android) sau InfiniLink (iOS) - vezi secțiunea 12
  • 1 Cablu de încărcare magnetic (inclus cu ceasul)
  • 1 ESP32-C6 din lucrarea precedentă (pentru partea de FreeRTOS fără ceas)
Lucrarea funcționează și fără ceas fizic Secțiunile 5, 6 și 9 (planificare, procese, inversiune de prioritate) se pot parcurge integral pe ESP32-C6, care rulează tot FreeRTOS. Secțiunea 7 include și InfiniSim, un simulator care rulează firmware-ul complet pe calculator, cu ecran și atingere emulate. Ceasul fizic este necesar doar pentru secțiunile 10 și 11.

4De ce nu ajunge o buclă principală

Un program de microcontroler începe aproape întotdeauna așa:

bucla clasică - și problema ei
void loop() {
  citeste_accelerometru();     // 2 ms
  actualizeaza_ecranul();      // 30 ms  <-- lung
  trateaza_bluetooth();        //  5 ms
  numara_secundele();          //  1 ms
}

Funcționează până în clipa în care una dintre sarcini devine lentă. Cât timp actualizeaza_ecranul() desenează, celelalte trei nu se execută. Dacă un cadru Bluetooth sosește în acest interval, se pierde. Iar dacă ecranul are nevoie ocazional de 200 ms, ceasul rămâne în urmă vizibil.

Se poate cârpi cu mașini de stare și verificări de millis() - exact tehnica de la laboratorul de microcontrolere. Dar la a cincea sau a șasea sarcină, codul devine imposibil de urmărit. Soluția industrială este un planificator care întrerupe o sarcină la mijloc și dă procesorul alteia, mai urgente.

Vocabularul planificării

TermenNotațieÎnțeles
PerioadăTla ce interval trebuie reluată sarcina
Durată de execuțieCcât timp de procesor îi trebuie, în cazul cel mai defavorabil
Termen limităDpână când trebuie terminată; de obicei D = T
UtilizareU = Σ C/Tce fracțiune din procesor este cerută în total
Preempțiune-întreruperea unei sarcini în curs, în favoarea uneia mai urgente

Cele două politici clasice

Rate Monotonic (RM)Earliest Deadline First (EDF)
Prioritateafixă: perioadă mai mică → prioritate mai maredinamică: termenul cel mai apropiat câștigă
GaranțieU ≤ n·(21/n − 1)U ≤ 1
Limita pentru n mare≈ 69 %100 %
Cost la execuțiemic - prioritățile se calculează o datămai mare - termenele se recompară permanent
Comportare la supraîncărcareprevizibilă: procesele lente cedează primeleimprevizibilă: poate rata totul
Folosit deFreeRTOS, Zephyr, majoritatea sistemelor încorporatesisteme de cercetare, unele nuclee Linux în timp real
Testul de utilizare este suficient, nu necesar Dacă U este sub limita RM, sistemul este garantat planificabil. Dacă o depășește, nu înseamnă că nu funcționează - înseamnă doar că acest test nu poate decide, iar răspunsul trebuie căutat prin simulare sau prin analiza timpului de răspuns. Este o distincție pe care simulatorul de mai jos o face explicită.

5Simulator de planificare

Trei procese periodice, un procesor. Mișcați cursoarele pentru durata de execuție și urmăriți diagrama: săgețile în sus sunt momentele de lansare, dreptunghiurile colorate sunt execuția efectivă, iar un ✕ roșu marchează un termen ratat. Comutați între cele două politici și comparați.

Rate Monotonic și EDF, cu diagramă de execuție
Trei experimente de făcut, în ordine
  1. Găsiți limita RM. Creșteți durata procesului „ecran” treaptă cu treaptă. La ce utilizare apare primul termen ratat? Comparați cu limita teoretică de 78 % pentru trei procese.
  2. Zona incertă. Aduceți utilizarea între limita RM și 100 %. Verdictul devine „testul nu poate decide”, iar simularea arată totuși zero ratări. Aceasta este dovada practică a faptului că testul este suficient, dar nu necesar.
  3. Superioritatea EDF. Găsiți o configurație la care RM ratează, apoi comutați pe EDF fără să schimbați nimic altceva. Aceleași procese, același procesor, alt rezultat.
De ce industria alege totuși RM EDF folosește procesorul până la ultimul procent, dar are două neajunsuri grave. Costă mai mult la execuție, pentru că termenele trebuie recomparate la fiecare comutare. Și, mai important, la supraîncărcare se prăbușește haotic: nu se poate prezice ce proces va rata. Cu RM, la supraîncărcare cedează întotdeauna procesele cu perioada cea mai mare - deci proiectantul poate hotărî dinainte ce anume are voie să sufere.

6Procese FreeRTOS în practică

Codul de mai jos rulează neschimbat pe ESP32-C6 și, cu modificări minime, pe orice sistem FreeRTOS - inclusiv pe PineTime.

procese_baza.ino - trei procese periodice
// Prioritatile FreeRTOS: numar mai MARE = prioritate mai MARE.
// Le alegem dupa regula Rate Monotonic: perioada mai mica, prioritate mai mare.
#define PRIO_SENZOR     3        // perioada  20 ms
#define PRIO_BLUETOOTH  2        // perioada 100 ms
#define PRIO_ECRAN      1        // perioada 200 ms

void procesSenzor(void* param) {
  // vTaskDelayUntil pastreaza o perioada EXACTA, indiferent cat a durat lucrul.
  TickType_t ultima = xTaskGetTickCount();
  for (;;) {
    citeste_accelerometru();
    vTaskDelayUntil(&ultima, pdMS_TO_TICKS(20));
  }
}

void procesBluetooth(void* param) {
  TickType_t ultima = xTaskGetTickCount();
  for (;;) {
    trateaza_bluetooth();
    vTaskDelayUntil(&ultima, pdMS_TO_TICKS(100));
  }
}

void procesEcran(void* param) {
  TickType_t ultima = xTaskGetTickCount();
  for (;;) {
    deseneaza_ecranul();         // poate dura 30 ms - va fi intrerupt de senzor
    vTaskDelayUntil(&ultima, pdMS_TO_TICKS(200));
  }
}

void setup() {
  Serial.begin(115200);

  //            functia          nume         stiva  param  prioritate  descriptor
  xTaskCreate(procesSenzor,    "senzor",      2048,  NULL,  PRIO_SENZOR,    NULL);
  xTaskCreate(procesBluetooth, "bluetooth",   4096,  NULL,  PRIO_BLUETOOTH, NULL);
  xTaskCreate(procesEcran,     "ecran",       4096,  NULL,  PRIO_ECRAN,     NULL);
}

void loop() {
  // Bucla principala este ea insasi un proces FreeRTOS, cu prioritate 1.
  // O lasam sa doarma, ca sa nu fure timp de procesor.
  vTaskDelay(pdMS_TO_TICKS(1000));
}

Trei greșeli care se fac aproape întotdeauna

1. vTaskDelay în loc de vTaskDelayUntil vTaskDelay(20) înseamnă „așteaptă 20 ms de acum”. Dacă lucrul a durat 5 ms, perioada reală devine 25 ms, iar eroarea se acumulează. vTaskDelayUntil se raportează la momentul precedent de lansare și menține perioada exactă. Pentru orice sarcină periodică, a doua variantă este cea corectă.
2. delay() într-un proces Pe ESP32, delay() este redirectat către vTaskDelay() și se comportă cuminte. Pe alte sisteme FreeRTOS este o buclă de așteptare activă care blochează toate procesele de prioritate mai mică pe toată durata ei. Folosiți întotdeauna funcțiile FreeRTOS.
3. Stiva aleasă la întâmplare Fiecare proces primește propria stivă, iar depășirea ei nu dă o eroare clară: sistemul se blochează sau repornește aparent aleatoriu, adesea abia după câteva ore. Măsurați, nu ghiciți:
verificarea stivei
void procesDiagnostic(void* param) {
  for (;;) {
    // Intoarce numarul minim de cuvinte ramase LIBERE de la pornire.
    // Daca se apropie de zero, stiva este subdimensionata.
    UBaseType_t liber = uxTaskGetStackHighWaterMark(NULL);
    Serial.printf("stiva ramasa: %u cuvinte (%u octeti)\n",
                  liber, liber * sizeof(StackType_t));
    vTaskDelay(pdMS_TO_TICKS(5000));
  }
}
Regula practică Rulați aplicația cu toate ramurile de cod solicitate (inclusiv cazurile de eroare), citiți valoarea minimă rămasă, apoi păstrați o rezervă de aproximativ 30 %. Pe PineTime, unde memoria totală este de 64 KB, această măsurătoare nu este o rafinare - este singurul mod de a face aplicația să încapă.

7Comunicarea între procese

Două procese care scriu în aceeași variabilă globală produc coruperi de date greu de reprodus. Soluția FreeRTOS este coada de mesaje: un tampon circular protejat, în care un proces depune și altul extrage, fără ca ele să se cunoască.

coada.ino - de la senzor la ecran
typedef struct {
  uint32_t moment_ms;
  float    x, y, z;
} Masuratoare;

QueueHandle_t coada;

void procesSenzor(void* param) {
  TickType_t ultima = xTaskGetTickCount();
  for (;;) {
    Masuratoare m = { millis(), citesteX(), citesteY(), citesteZ() };

    // Al treilea parametru este cat asteptam daca coada e plina.
    // Cu 0 nu asteptam deloc: mai bine pierdem o masuratoare decat sa
    // intarziem un proces de prioritate mare.
    if (xQueueSend(coada, &m, 0) != pdTRUE) {
      // Coada plina inseamna ca cititorul nu tine pasul - o informatie utila.
      Serial.println("masuratoare pierduta - consumatorul e prea lent");
    }
    vTaskDelayUntil(&ultima, pdMS_TO_TICKS(20));
  }
}

void procesEcran(void* param) {
  Masuratoare m;
  for (;;) {
    // portMAX_DELAY: procesul doarme pana soseste ceva. Cat doarme,
    // NU consuma timp de procesor - exact ce ne trebuie pentru baterie.
    if (xQueueReceive(coada, &m, portMAX_DELAY) == pdTRUE) {
      deseneaza(m.x, m.y, m.z);
    }
  }
}

void setup() {
  Serial.begin(115200);
  coada = xQueueCreate(10, sizeof(Masuratoare));   // 10 mesaje in asteptare
  if (coada == NULL) {
    Serial.println("memorie insuficienta pentru coada");
    return;
  }
  xTaskCreate(procesSenzor, "senzor", 2048, NULL, 3, NULL);
  xTaskCreate(procesEcran,  "ecran",  4096, NULL, 1, NULL);
}

void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }
De ce coada este mai bună decât o variabilă globală
  • Este atomică - nu puteți citi jumătate dintr-o structură scrisă pe jumătate.
  • Nu pierde date - o variabilă globală păstrează doar ultima valoare; coada le păstrează pe toate zece.
  • Blochează eficient - procesul consumator doarme fără să consume procesor, în loc să verifice în buclă.
  • Vă spune când sistemul nu face față - coada plină este un simptom măsurabil, nu o defecțiune tăcută.

8Inversiunea de prioritate

Aceasta este cea mai renumită capcană a sistemelor în timp real. A pus în pericol misiunea Mars Pathfinder în 1997: roverul de pe Marte repornea singur, la intervale neregulate, iar cauza a fost exact mecanismul descris mai jos.

Cum apare

  1. Un proces de prioritate mică ia o resursă comună (de exemplu magistrala I²C).
  2. Un proces de prioritate mare are nevoie de aceeași resursă și se blochează, așteptând-o.
  3. Un proces de prioritate medie, care nu are nicio legătură cu resursa, devine gata de execuție și îl întrerupe pe cel de prioritate mică.
  4. Rezultat: procesul de prioritate medie îl întârzie, indirect, pe cel de prioritate mare. Ierarhia priorităților s-a inversat.
mare medie mică ia I²C cere BLOCAT - așteaptă resursa rulează liniștit, deși e mai puțin importantă eliberează în sfârșit rulează întârziere provocată de un proces de prioritate MAI MICĂ
Fig. 1 - Inversiunea de prioritate. Procesul de prioritate medie nu atinge niciodată resursa disputată, dar întârzie totuși procesul cel mai important, prin simplul fapt că îl întrerupe pe deținătorul ei.

Provocați-o singuri

inversiune.ino - o singură linie face diferența
SemaphoreHandle_t resursa;

void asteapta_activ(uint32_t ms) {
  // Bucla de asteptare ACTIVA: tine procesorul ocupat, ca o prelucrare reala.
  uint32_t t0 = millis();
  while (millis() - t0 < ms) { }
}

void procesMic(void* p) {                       // prioritate 1
  for (;;) {
    xSemaphoreTake(resursa, portMAX_DELAY);
    Serial.println("  [mic] am luat resursa");
    asteapta_activ(300);                        // lucreaza cu resursa
    Serial.println("  [mic] eliberez resursa");
    xSemaphoreGive(resursa);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void procesMediu(void* p) {                     // prioritate 2
  vTaskDelay(pdMS_TO_TICKS(50));                // porneste dupa cel mic
  for (;;) {
    Serial.println(" [mediu] ocup procesorul 800 ms");
    asteapta_activ(800);                        // nu atinge resursa deloc!
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void procesMare(void* p) {                      // prioritate 3
  vTaskDelay(pdMS_TO_TICKS(100));               // cere resursa dupa cel mic
  for (;;) {
    uint32_t t0 = millis();
    Serial.println("[MARE] cer resursa");
    xSemaphoreTake(resursa, portMAX_DELAY);
    Serial.printf("[MARE] am asteptat %lu ms\n", millis() - t0);
    xSemaphoreGive(resursa);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void setup() {
  Serial.begin(115200);
  delay(500);

  // ---- SCHIMBATI DOAR ACEASTA LINIE ----
  resursa = xSemaphoreCreateBinary();           // FARA mostenire de prioritate
  xSemaphoreGive(resursa);                      // semafoarele binare pornesc goale
  // resursa = xSemaphoreCreateMutex();         // CU mostenire de prioritate
  // --------------------------------------

  xTaskCreate(procesMic,    "mic",    2048, NULL, 1, NULL);
  xTaskCreate(procesMediu,  "mediu",  2048, NULL, 2, NULL);
  xTaskCreate(procesMare,   "mare",   2048, NULL, 3, NULL);
}

void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }
Ce veți măsura Cu xSemaphoreCreateBinary(), procesul de prioritate mare așteaptă în jur de 1000 ms: cei 300 ms în care resursa e ocupată, plus cei 800 ms în care procesul de prioritate medie îl întrerupe pe deținător. Cu xSemaphoreCreateMutex() așteptarea scade la aproximativ 300 ms - doar cât durează efectiv lucrul cu resursa.
Moștenirea de prioritate Un mutex FreeRTOS nu este un semafor binar cu alt nume. Când un proces de prioritate mare se blochează pe un mutex, sistemul ridică temporar prioritatea deținătorului la nivelul celui blocat. Deținătorul nu mai poate fi întrerupt de procese intermediare, termină repede și eliberează resursa, iar prioritatea îi revine la valoarea inițială. Aceasta este soluția care a fost încărcată prin radio pe Mars Pathfinder.

Regula practică: folosiți mutex pentru excludere mutuală (protejarea unei resurse) și semafor pentru semnalizare (anunțarea unui eveniment). Sunt lucruri diferite, deși API-ul seamănă.

9Compilarea firmware-ului InfiniTime

Trecem de la exemple la un firmware complet. InfiniTime are peste o sută de mii de linii de cod și rulează pe FreeRTOS cu interfața grafică LVGL.

Verificați întâi documentația proiectului Comenzile de compilare ale proiectelor open source se schimbă de la o versiune la alta. Înainte de a începe, citiți fișierul doc/buildAndProgram.md din depozit. Pașii de mai jos reflectă procedura obișnuită, dar depozitul este întotdeauna sursa de adevăr.
  1. Descărcați codul sursă
    descărcare
    git clone https://github.com/InfiniTimeOrg/InfiniTime.git
    cd InfiniTime
    git submodule update --init --recursive
  2. Compilați folosind imaginea Docker oficială

    Este calea recomandată: aduce cu ea compilatorul ARM și pachetul de dezvoltare Nordic, deci nu trebuie să instalați nimic altceva.

    compilare
    docker run --rm -it \
      -v "$(pwd)":/sources \
      -u $(id -u):$(id -g) \
      infinitime/infinitime-build

    Prima rulare durează mult, pentru că descarcă imaginea. Rezultatul apare în build/output/ - fișierul cu extensia .zip și sufixul -dfu este pachetul care se încarcă prin Bluetooth.

  3. Explorați structura proiectului
    orientare
    ls src/systemtask/          # procesul central, care le coordoneaza pe celelalte
    ls src/displayapp/          # procesul de afisare si aplicatiile
    ls src/components/          # driverele: baterie, ecran, senzori, Bluetooth
    grep -rn "xTaskCreate" src/ # unde se creeaza procesele
    grep -rn "configTOTAL_HEAP_SIZE" src/FreeRTOSConfig.h
  4. Alternativa fără ceas: simulatorul

    InfiniSim compilează același cod al interfeței pentru calculator și îl afișează într-o fereastră, cu ecran și atingere emulate. Este calea cea mai rapidă pentru a dezvolta un cadran.

    simulator
    git clone https://github.com/InfiniTimeOrg/InfiniSim.git
    cd InfiniSim && git submodule update --init --recursive
    # urmati instructiunile din README pentru dependentele SDL2

Anatomia sistemului

Odată ce codul e compilat, căutați apelurile xTaskCreate. Veți găsi un tipar clar:

ProcesRolCum este dimensionat
SystemTaskcoordonează totul: pornire, somn, evenimenteprioritate mare, stivă generoasă
DisplayAppdesenează ecranul și tratează atingerilecea mai mare stivă - LVGL cere memorie
stiva Bluetoothîntreține conexiunea radioprioritate mare, termene stricte impuse de protocol
proces inactivrulează când nu are cine altcinevaaici se intră în starea de consum redus
Procesul inactiv este cel mai important pentru baterie Când toate procesele dorm, planificatorul îi dă controlul procesului inactiv, care oprește ceasul procesorului până la următoarea întrerupere. Acest mecanism se numește tickless idle. Dacă un singur proces din sistem așteaptă activ în buclă în loc să folosească vTaskDelay, procesul inactiv nu se mai execută niciodată, consumul crește de zece ori și bateria ține o zi în loc de o săptămână. Un singur while(1){} plasat greșit are acest efect.

10Adăugarea unui proces propriu

Adăugăm în InfiniTime un proces care numără pașii făcuți în fiecare oră a zilei, iar rezultatul îl afișăm pe ceas, în aplicația de pași. Exemplul e mic, dar pune în mișcare trei procese și ambele mecanisme de comunicare de la secțiunea 7:

  1. SystemTask citește accelerometrul și, când totalul pașilor se schimbă, trimite un raport printr-o coadă. Coada transportă evenimente: „s-a întâmplat ceva”.
  2. statistici, procesul nostru, doarme blocat pe coadă. La fiecare raport se trezește, calculează câți pași s-au adăugat și îi trece în tabelul pe ore, apoi adoarme la loc.
  3. DisplayApp citește tabelul când desenează ecranul. Tabelul este stare partajată, deci citirea și scrierea lui sunt protejate de o secțiune critică.
src/components/statistici/Statistici.h
#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 Statistici {
    public:
      struct Raport {
        uint8_t  ora;      // 0...23
        uint32_t pasi;     // totalul zilei, asa cum il da senzorul
      };

      explicit Statistici(Controllers::DateTime& dateTime) : dateTime {dateTime} {}

      void Start();                          // creeaza coada si procesul
      void RaporteazaPasi(uint32_t pasi);    // apelat din procesul de sistem
      uint32_t PasiInOra(uint8_t ora);       // apelat din procesul de afisare

    private:
      static void Proces(void* param);       // functia procesului
      void Bucla();

      Controllers::DateTime& dateTime;       // de aici luam ora curenta
      TaskHandle_t  descriptor = nullptr;
      QueueHandle_t coada = nullptr;

      uint32_t ultimulTrimis = 0;            // folosit DOAR de procesul de sistem
      uint32_t ultimulPrimit = 0;            // folosit DOAR de procesul statistici
      bool     amReferinta = false;          // folosit DOAR de procesul statistici
      std::array<uint32_t, 24> pe_ora {};    // scris de statistici, citit de afisare
    };
  }
}
src/components/statistici/Statistici.cpp
#include "Statistici.h"

using namespace Pinetime::Components;

void Statistici::Start() {
  // Coada este mica: pastram doar cateva rapoarte in asteptare.
  coada = xQueueCreate(4, sizeof(Raport));
  if (coada == nullptr) {
    return;                  // nu a fost memorie nici pentru coada
  }

  // Stiva de 512 de cuvinte = 2 KB. Procesul nu foloseste LVGL si nu
  // are tampoane mari, deci atat ii ajunge. Verificati cu
  // uxTaskGetStackHighWaterMark inainte de a considera valoarea finala.
  if (xTaskCreate(Proces, "statistici", 512, this,
                  tskIDLE_PRIORITY + 1, &descriptor) != pdPASS) {
    descriptor = nullptr;    // nu a fost memorie pentru stiva
  }
}

void Statistici::Proces(void* param) {
  static_cast<Statistici*>(param)->Bucla();
}

void Statistici::Bucla() {
  Raport r;
  for (;;) {
    // Procesul DOARME aici pana soseste un raport. Cat asteapta nu consuma
    // nimic, iar procesul inactiv poate opri ceasul procesorului.
    if (xQueueReceive(coada, &r, portMAX_DELAY) != pdTRUE) {
      continue;
    }

    // Primul raport dupa pornire da doar punctul de plecare: pasii facuti
    // inainte de repornirea ceasului nu stim in ce ora au fost facuti.
    if (!amReferinta) {
      ultimulPrimit = r.pasi;
      amReferinta = true;
      continue;
    }

    // Senzorul da totalul zilei; diferenta fata de totalul precedent
    // este numarul de pasi facuti intre timp.
    uint32_t noi;
    if (r.pasi >= ultimulPrimit) {
      noi = r.pasi - ultimulPrimit;
    } else {
      // Totalul a scazut: a trecut miezul noptii si numaratorul a luat-o
      // de la zero. Incepem o zi noua de statistici.
      noi = r.pasi;
      taskENTER_CRITICAL();
      pe_ora.fill(0);
      taskEXIT_CRITICAL();
    }
    ultimulPrimit = r.pasi;

    if (r.ora < pe_ora.size()) {
      taskENTER_CRITICAL();              // afisarea poate citi tabelul oricand
      pe_ora[r.ora] += noi;
      taskEXIT_CRITICAL();
    }
  }
}

void Statistici::RaporteazaPasi(uint32_t pasi) {
  // Trimitem doar cand s-a schimbat ceva - altfel am umple coada degeaba.
  if (coada == nullptr || pasi == ultimulTrimis) {
    return;
  }
  Raport r {dateTime.Hours(), pasi};
  // Fara asteptare: daca coada e plina, raportul se pierde. Nu pierdem insa
  // pasi - urmatorul raport contine din nou totalul zilei.
  if (xQueueSend(coada, &r, 0) == pdTRUE) {
    ultimulTrimis = pasi;
  }
}

uint32_t Statistici::PasiInOra(uint8_t ora) {
  if (ora >= pe_ora.size()) {
    return 0;
  }
  taskENTER_CRITICAL();
  uint32_t valoare = pe_ora[ora];
  taskEXIT_CRITICAL();
  return valoare;
}
Deciziile de proiectare, explicit
  1. Prioritatea tskIDLE_PRIORITY + 1 - cea mai mică peste procesul inactiv. Statisticile nu au termen limită; nu au voie să întârzie desenarea ecranului sau stiva Bluetooth.
  2. Stiva de 512 de cuvinte - valoarea de pornire, care trebuie verificată cu uxTaskGetStackHighWaterMark. Pe un sistem cu 64 KB, fiecare kilooctet alocat degeaba este luat de la altcineva. Secțiunea 11 vă arată unde se vede pe ceas.
  3. Procesul așteaptă pe coadă, nu se trezește periodic - xQueueReceive cu portMAX_DELAY îl scoate complet din planificare până sosește un raport. Cât stați pe loc, procesul nu rulează deloc; o buclă cu vTaskDelay s-ar trezi degeaba de mii de ori pe zi.
  4. Timp de așteptare zero la trimitere - procesul de sistem are prioritate mai mare și nu are voie să fie blocat de o coadă plină aparținând unuia neimportant. Dacă un raport se pierde, nu se pierd și pașii: fiecare raport conține totalul zilei, deci următorul recuperează tot.
  5. Fiecare variabilă are un singur proprietar - ultimulTrimis e folosit doar de procesul de sistem, ultimulPrimit doar de procesul statistici. Singurul lucru folosit de două procese este tabelul pe_ora, și numai el este protejat de secțiunea critică.
De ce nu folosim configASSERT pentru verificăriÎn InfiniTime, configASSERT este un macro care, în versiunea compilată pentru ceas, dispare cu totul. O linie ca BaseType_t r = xTaskCreate(...); configASSERT(r == pdPASS); lasă în urmă o variabilă care nu mai este citită nicăieri, iar proiectul compilează cu opțiunea -Werror, care transformă orice avertisment în eroare: compilarea se oprește cu unused variable. Mai grav, verificarea dispare și ea. De aceea rezultatul se testează explicit, cu if, iar dacă ceva nu s-a putut crea, procesul rămâne pur și simplu inactiv în loc să blocheze ceasul.

11Conectarea procesului și recompilarea

Cele două fișiere din secțiunea anterioară nu fac încă nimic. Dacă le copiați în proiect și compilați, compilarea reușește fără nicio eroare, ceasul pornește, iar procesul nu există. Motivul: compilatorul nu știe că fișierul nou trebuie compilat, nimeni nu creează obiectul și nu îi apelează Start(), iar nimic nu afișează rezultatul. Urmează patru pași, apoi recompilarea.

Cel mai derutant mod de a greșiUn fișier .cpp care nu este trecut în CMakeLists.txt nu produce nicio eroare: pur și simplu nu este compilat. Dacă ați modificat codul și pe ceas nu se schimbă nimic, verificați întâi acest pas.
Cum citiți eroarea undefined referenceMesajul vine de la editorul de legături (ld), nu de la compilator: fiecare fișier s-a compilat corect, dar la final cineva apelează o funcție al cărei cod nu a fost compilat în acel executabil. Priviți ce țintă eșuează - numele apare în linia CMakeFiles/pinetime-recovery.dir/.... Cele câteva rânduri dangerous relocation care o însoțesc sunt doar consecințe ale aceleiași funcții lipsă.
  1. Înregistrați fișierul nou în CMake

    Deschideți src/CMakeLists.txt și adăugați calea fișierului .cpp, relativă la src/, lângă celelalte componente. Fișierul .h nu se trece în listă. Atenție: linia trebuie pusă în două liste, nu într-una.

    Pe lângă firmware-ul obișnuit, InfiniTime compilează și un firmware de recuperare - o variantă minimă, încărcată de bootloader dacă aplicația principală nu mai pornește. Și el conține SystemTask.cpp, deci și el apelează Start() și RaporteazaPasi(). Lista lui de surse se numește RECOVERY_SOURCE_FILES. Dacă adăugați fișierul doar în SOURCE_FILES, compilarea trece, dar legarea firmware-ului de recuperare se oprește cu undefined reference to Pinetime::Components::Statistici::Start().

    src/CMakeLists.txt
    # 1. firmware-ul obisnuit
    list(APPEND SOURCE_FILES
      ...
      components/datetime/DateTimeController.cpp
      components/statistici/Statistici.cpp      # <-- linia adaugata
      ...
    )
    
    # 2. firmware-ul de recuperare - mai jos in acelasi fisier
    list(APPEND RECOVERY_SOURCE_FILES
      ...
      components/datetime/DateTimeController.cpp
      components/statistici/Statistici.cpp      # <-- aceeasi linie, inca o data
      ...
    )
  2. Creați obiectul în procesul de sistem

    Procesul nostru are nevoie de controllerul de dată și oră ca să știe ora curentă (vedeți constructorul din Statistici.h). SystemTask are deja o referință la el, deci obiectul se declară acolo. Ordinea contează: membrii unei clase se construiesc în ordinea în care sunt declarați, deci statistici trebuie declarat după dateTimeController. Funcția GetStatistici() îi dă aplicației de pași acces la obiect, la pasul 4.

    Atenție unde o puneți: imediat sub enum class SystemTaskState începe declarația constructorului SystemTask(...), care se întinde pe vreo 20 de rânduri. Dacă funcția ajunge înăuntrul listei lui de parametri, compilatorul raportează sute de erori (expected ')' before '{' token, apoi uninitialized reference member pentru fiecare membru), deși greșeala este o singură linie. Puneți-o după PushMessage, ca mai jos.

    src/systemtask/SystemTask.h
    #include "systemtask/Messages.h"
    #include "components/statistici/Statistici.h"     // <-- adaugat
    
    // ... in sectiunea public, IMEDIAT DUPA declaratia lui PushMessage.
    //     ATENTIE: nu in lista de parametri a constructorului SystemTask(...),
    //     care ocupa vreo 20 de randuri chiar deasupra si se termina cu ");"
          void Start();
          void PushMessage(Messages msg);
          Pinetime::Components::Statistici& GetStatistici() { return statistici; }
    
    // ... in sectiunea private, IMEDIAT DUPA linia cu dateTimeController:
          Pinetime::Controllers::DateTime& dateTimeController;
          Pinetime::Components::Statistici statistici {dateTimeController};
  3. Porniți procesul și trimiteți-i datele

    Start() se apelează o singură dată, la pornire, după celelalte inițializări din SystemTask::Work(). Pașii vin din funcția care citește accelerometrul, UpdateMotion() - acolo îi trimitem mai departe prin coadă.

    src/systemtask/SystemTask.cpp
    void SystemTask::Work() {
      // ... initializarile existente ...
      motionController.Init(motionSensor.DeviceType());
      settingsController.Init();
      statistici.Start();                              // <-- porneste procesul nostru
      // ...
    }
    
    void SystemTask::UpdateMotion() {
      // ...
      auto motionValues = motionSensor.Process();
    
      motionController.Update(motionValues.x, motionValues.y, motionValues.z, motionValues.steps);
      statistici.RaporteazaPasi(motionValues.steps);   // <-- totalul zilei, catre coada
      // ... restul functiei ramane neschimbat ...
    }
  4. Afișați rezultatul în aplicația de pași

    Aplicația Steps (pictograma cu pantof) primește obiectul prin AppControllers, care are deja un pointer spre SystemTask, și adaugă un rând nou în partea de sus a ecranului. Funcția Refresh() rulează în procesul de afișare de zece ori pe secundă cât timp aplicația este deschisă - de aici se citește tabelul prin PasiInOra().

    În Steps.h sunt patru modificări, marcate (a)–(d). Cea mai ușor de uitat este prima, #include-ul de sus. Fără ea compilatorul nu știe ce este Statistici și raportează 'Pinetime::Components::Statistici' has not been declared, apoi invalid use of incomplete type 'class Pinetime::System::SystemTask' - adică știe că SystemTask există, dar nu i-a văzut încă definiția.

    src/displayapp/screens/Steps.h
    // (a) SUS, la includeri - fara ea, Steps.h nu stie ce e Statistici
    //     si nici ce contine SystemTask:
    #include "Symbols.h"
    #include "systemtask/SystemTask.h"                 // <-- adaugat
    
    // (b) constructorul primeste doua referinte in plus:
            Steps(Controllers::MotionController& motionController,
                  Controllers::Settings& settingsController,
                  Components::Statistici& statistici,
                  Controllers::DateTime& dateTime);
    
    // (c) in sectiunea private, sub settingsController:
            Controllers::Settings& settingsController;
            Components::Statistici& statistici;
            Controllers::DateTime& dateTime;
            lv_obj_t* lOra;
    
    // (d) iar in AppTraits<Apps::Steps>, functia Create devine:
          static Screens::Screen* Create(AppControllers& controllers) {
            return new Screens::Steps(controllers.motionController,
                                      controllers.settingsController,
                                      controllers.systemTask->GetStatistici(),
                                      controllers.dateTimeController);
          };
    src/displayapp/screens/Steps.cpp
    Steps::Steps(Controllers::MotionController& motionController,
                 Controllers::Settings& settingsController,
                 Components::Statistici& statistici,
                 Controllers::DateTime& dateTime)
      : motionController {motionController}, settingsController {settingsController}, statistici {statistici}, dateTime {dateTime} {
    
      // ... tot codul existent al constructorului ...
    
      // eticheta noua, in partea de sus a ecranului - chiar inainte de lv_task_create:
      lOra = lv_label_create(lv_scr_act(), nullptr);
      lv_obj_set_style_local_text_color(lOra, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::orange);
      lv_label_set_text_static(lOra, "");
      lv_obj_align(lOra, nullptr, LV_ALIGN_IN_TOP_MID, 0, 25);
    
      taskRefresh = lv_task_create(RefreshTaskCallback, 100, LV_TASK_PRIO_MID, this);
    }
    
    void Steps::Refresh() {
      // ... codul existent ...
      lv_arc_set_value(stepsArc, int16_t(500 * stepsCount / settingsController.GetStepsGoal()));
    
      uint8_t ora = dateTime.Hours();
      lv_label_set_text_fmt(lOra, "Ora %02d: %lu", int(ora), statistici.PasiInOra(ora));
      lv_obj_align(lOra, nullptr, LV_ALIGN_IN_TOP_MID, 0, 25);
    }
  5. Recompilați

    Exact aceeași comandă ca la secțiunea 9. Compilarea este incrementală: se recompilează doar fișierele modificate și cele care depind de ele, deci durează de obicei sub un minut, nu cât prima dată.

    recompilare
    # din radacina depozitului InfiniTime
    docker run --rm -it \
      -v "$(pwd)":/sources \
      -u $(id -u):$(id -g) \
      infinitime/infinitime-build
    
    # unde a aparut pachetul pentru telefon:
    find build -name "*-dfu*.zip"

    Dacă apare o eroare, citiți prima eroare din listă - celelalte sunt de obicei consecințe ale ei. Dacă ați modificat CMakeLists.txt și compilarea pare să ignore schimbarea, ștergeți directorul build/ și compilați din nou de la zero.

Verificați că fișierul chiar a fost compilatÎnainte de a încărca pe ceas, căutați numele clasei în harta de memorie generată la compilare: grep -l "Statistici" build/src/*.map. Trebuie să apară atât pinetime-app, cât și pinetime-recovery. Dacă nu apare nimic, fișierul nu a fost compilat - reveniți la pasul 1.
Ce vedeți pe ceas după încărcare
  • În aplicația Steps apare sus un rând portocaliu, Ora 14: 0. Primul raport după pornire stabilește doar punctul de plecare, deci numărătoarea începe cu primii pași făcuți după repornire. Mergeți câteva zeci de pași și redeschideți aplicația: valoarea crește. La ora fixă rândul trece la ora nouă și pornește de la zero, iar la miezul nopții tot tabelul se golește.
  • În Settings → About, pe ecranul 4 (lista proceselor FreeRTOS) apare un rând nou, sta. Numele e tăiat la trei caractere pentru că InfiniTime setează configMAX_TASK_NAME_LEN la 4 - la fel apar MAI și dis. Coloana S arată aproape mereu B (Blocked): procesul doarme pe coadă, exact cum am vrut. Coloana Free arată câte cuvinte din stivă nu au fost folosite niciodată - cu ea răspundeți la întrebarea de la secțiunea 10: ce valoare ați alege în locul lui 512?
Soluția de referințăToate modificările din secțiunile 10 și 11, strânse într-un singur fișier: lab03-statistici-ro.patch. Se aplică pe InfiniTime 1.16.0, din rădăcina depozitului: întâi git apply --check lab03-statistici-ro.patch (verifică fără să modifice nimic), apoi git apply lab03-statistici-ro.patch. Folosiți-o ca să comparați, după ce ați încercat singuri - git diff vă arată exact unde diferă codul vostru.
Un cadran sau o aplicație nouă se adaugă altfelPentru un ecran complet nou nu folosiți SOURCE_FILES: ecranele se înregistrează în src/displayapp/apps/CMakeLists.txt și se activează la compilare, adăugând comenzii Docker -e ENABLE_USERAPPS="..." sau -e ENABLE_WATCHFACES="...". Citiți doc/code/Apps.md din depozit pentru pașii exacți ai versiunii pe care o compilați.

12Încărcarea pe ceas

Ceasul se actualizează prin Bluetooth, fără fire și fără programator. Bootloaderul păstrează versiunea anterioară și revine la ea automat dacă noul firmware nu confirmă că a pornit corect. Procedura diferă puțin între Android și iOS - alegeți coloana care se aplică telefonului vostru.

  1. Încărcați ceasul complet

    O actualizare întreruptă de o baterie descărcată este singurul mod realist de a strica un PineTime. Bateria trebuie să fie peste 50 %.

  2. Instalați aplicația companion potrivită telefonului
    AndroidiOS (iPhone / iPad)
    AplicațieGadgetbridgeInfiniLink
    SursăF-Droid (gratuită, open source)App Store (gratuită, open source)
    Ce mai poate facenotificări, vreme, control muzicăceas/dată, baterie, puls, pași, muzică Apple, HealthKit
    Un singur nume de evitat: nRF Connect for iOS Este aplicația care apare cel mai des în căutări pentru actualizat plăci Nordic, dar pentru PineTime nu mai funcționează: versiunile ulterioare 4.24.3 au rupt compatibilitatea, iar App Store instalează mereu ultima versiune. Nu există cale simplă de a instala varianta veche care mai mergea. Folosiți InfiniLink.
  3. Transferați pachetul .zip pe telefon

    Fișierul se află în build/output/ și are sufixul -dfu. Nu îl dezarhivați - formatul arhivat este cel pe care îl așteaptă bootloaderul. Pe iPhone, cel mai simplu e să-l trimiteți spre aplicația Fișiere (AirDrop de pe calculator, sau descărcare directă în Safari) - InfiniLink îl poate încărca direct din acolo.

  4. Porniți actualizarea

    Android (Gadgetbridge): conectați ceasul, apoi Firmware/App install și alegeți fișierul.
    iOS (InfiniLink): conectați ceasul din ecranul principal, apoi din meniul de firmware alegeți „Update from file” și selectați arhiva .zip din Fișiere. Alternativ, InfiniLink poate verifica singur pagina de versiuni InfiniTime de pe GitHub și propune direct ultima variantă, fără să mai descărcați nimic manual.
    Transferul durează câteva minute pe orice platformă. Nu îndepărtați telefonul de ceas în acest timp.

  5. Confirmați noua versiune

    După repornire, ceasul cere confirmarea că firmware-ul funcționează. Dacă nu confirmați, bootloaderul revine la versiunea anterioară la următoarea repornire - o plasă de siguranță elegantă, care merită studiată ca mecanism în sine.

Actualizarea în siguranță, ca principiu Mecanismul de aici - două zone de firmware, confirmare explicită după pornire, revenire automată la eșec - este exact ce folosesc automobilele, routerele și sateliții. Un dispozitiv la care nu ai acces fizic nu are voie să poată fi transformat într-o cărămidă printr-o actualizare greșită. Citiți codul bootloaderului: este scurt și instructiv.
De ce a fost nevoie de o aplicație separată pentru iOS Gadgetbridge nu are și nu va avea o variantă de iOS: politica Apple pentru accesul la Bluetooth în fundal și la notificările altor aplicații este mult mai restrictivă decât pe Android, iar comunitatea InfiniTime a preferat să scrie de la zero o aplicație nativă în Swift - InfiniLink - în loc să porteze codul Gadgetbridge. Rezultatul: cele două aplicații nu sunt interschimbabile, dar amândouă vorbesc același protocol de actualizare (DFU-ul Nordic), deci ceasul primește exact același fișier, indiferent pe ce telefon îl încărcați.
Dacă ceva merge prost Ceasul sigilat nu se poate reprograma prin fire fără a-l deschide. Bootloaderul este însă proiectat exact pentru această situație și, în practică, revenirea la versiunea anterioară funcționează. Nu întrerupeți niciodată un transfer în curs.

13Sarcini de lucru

  1. Folosind simulatorul de la secțiunea 5, determinați utilizarea maximă la care setul de trei procese rămâne planificabil sub RM. Comparați cu limita teoretică și explicați diferența.
  2. Găsiți o configurație la care RM ratează termene iar EDF nu. Copiați ambele diagrame în referat și explicați exact ce decizie diferită ia EDF.
  3. Încărcați procese_baza.ino pe ESP32-C6. Modificați procesEcran să dureze 250 ms (mai mult decât perioada lui) și descrieți ce se întâmplă cu celelalte procese.
  4. Adăugați procesDiagnostic și notați stiva rămasă pentru fiecare proces. Reduceți alocările până la limita sigură și calculați câtă memorie ați recuperat.
  5. Rulați inversiune.ino în ambele variante. Notați timpul de așteptare al procesului de prioritate mare în fiecare caz și explicați diferența prin mecanismul de moștenire a priorității.
  6. Compilați InfiniTime. Găsiți toate apelurile xTaskCreate și realizați un tabel cu procesele sistemului: nume, prioritate, dimensiunea stivei și rolul fiecăruia.
  7. Căutați în FreeRTOSConfig.h opțiunea configUSE_TICKLESS_IDLE și explicați, pe baza codului, ce se întâmplă atunci când toate procesele dorm.
  8. Adăugați procesul statistici urmând secțiunile 10 și 11, încărcați firmware-ul pe ceas și fotografiați ecranul aplicației Steps înainte și după câteva zeci de pași.
  9. În Settings → About, ecranul 4, notați valoarea din coloana Free pentru procesul sta. Alegeți o dimensiune a stivei care păstrează o rezervă de 25 % față de cât s-a folosit efectiv, recompilați și verificați din nou. Câți octeți de RAM ați recuperat față de 512 cuvinte?
  10. Explicați de ce pierderea unui raport din coadă nu duce la pași pierduți. Ce s-ar întâmpla dacă RaporteazaPasi() ar trimite doar diferența față de raportul precedent, în loc de totalul zilei?

14Aplicație de aprofundare

Cadranul propriu Creați un cadran nou pentru InfiniTime, care afișează ora, nivelul bateriei și numărul de pași. Dezvoltați-l în simulator, apoi încărcați-l pe ceas.

Partea interesantă nu este desenul, ci bugetul de reîmprospătare. Fiecare redesenare a ecranului trezește procesorul și costă energie. Măsurați, cu uxTaskGetStackHighWaterMark și cu numărătorul de treziri:
  • De câte ori pe minut se redesenează cadranul dumneavoastră?
  • Care este numărul minim de redesenări necesar ca să afișeze ora corectă?
  • Cu cât se prelungește autonomia dacă redesenați doar cifra care s-a schimbat, în loc de tot ecranul?
Un cadran care redesenează tot ecranul de zece ori pe secundă arată identic cu unul care o face o dată pe minut - dar consumă de sute de ori mai mult. Aceasta este, în esență, întreaga artă a programării pentru dispozitive purtabile.
Graficul zilei Construiți un ecran nou care desenează 24 de bare, câte una pentru fiecare oră, cu valorile din PasiInOra(0) ... PasiInOra(23). Indicii: un obiect lv_chart de tip LV_CHART_TYPE_COLUMN cu 24 de puncte; ecranul se înregistrează ca aplicație de utilizator (doc/code/Apps.md) și se activează prin ENABLE_USERAPPS.

Două întrebări la care trebuie să răspundeți în referat: De ce nu este nevoie de o coadă nouă pentru acest ecran? Și cât de des trebuie reîmprospătat graficul, dacă datele se schimbă cel mult la câteva secunde - sunt necesare cele 10 reîmprospătări pe secundă pe care le face aplicația Steps?

15Întrebări de verificare

16Resurse