315 letture
315 letture

Come iniziare con RTOS: una guida pratica per i principianti che usano Cortex-M4

di Manoj Gowda15m2025/06/04
Read on Terminal Reader

Troppo lungo; Leggere

Costruire un RTOS minimo è confuso, spesso frustrante e assolutamente illuminante.
featured image - Come iniziare con RTOS: una guida pratica per i principianti che usano Cortex-M4
Manoj Gowda HackerNoon profile picture

Mi ricordo ancora di guardare i LED di TM4C123GH6PM una sera, in attesa di un semplice flash “cardio” che non è mai arrivato. Pensavo di aver fatto tutto bene: configurare SysTick, pendere PendSV, e inizializzare i miei Blocchi di controllo del filo (TCBs). Ma invece di un flash costante, i LED hanno twitched una volta, poi congelato-mocking me. Quel momento ha cristallizzato ciò che costruire un piccolo RTOS significa veramente: lottare con i trucchi hardware, inseguire bug elusivi, e patch insieme abbastanza codice per rendere tutto funzionare senza problemi. In questo articolo, ti camminerò attraverso il mio viaggio di costruire un programmatore minimo, basato su priorità su un ARM Cortex-M4 - insetti,


Un po’ di backstory

Circa un anno fa, la mia classe di sistemi incorporati ci ha assegnato di costruire un RTOS semplice da zero su un TM4C123GH6PM (ARM Cortex-M4). avevo usato FreeRTOS prima, ma non ho mai capito appieno cosa stava accadendo dietro le quinte.

Why?

  • Volevo vedere esattamente come la CPU passa da un filo all'altro.
  • Ho bisogno di imparare perché le attività a bassa priorità possono inavvertitamente affamare quelle a priorità più elevate (hello, inversione di priorità).
  • Ho desiderato storie di debug reale - come quel tempo che ho trascorso mezza giornata a chiedersi perché PendSV non ha mai funzionato (si scopre che gli avevo dato la stessa priorità di SysTick).

Spoiler: Il mio RTOS non era perfetto, ma nel perseguire i suoi difetti, ho imparato più su ARM internals che da qualsiasi libro di testo.


Immergersi nel mondo Cortex-M4

Prima di scrivere una singola riga di codice, ho dovuto avvolgere la testa intorno a come Cortex-M4 gestisce le interruzioni e lo scambio di contesto.

  1. PendSV’s “Gentle” Role
    • PendSV is designed to be the lowest-priority exception—meaning it only runs after all other interrupts finish. My first mistake was setting PendSV’s priority to 0 (highest). Of course, it never ran because every other interrupt always preempted it. Once I moved it to 0xFF, scheduling finally kicked in.
    • Note to self (and you): write down your NVIC priorities on a Post-it. It’s easy to confuse “0 is highest” with “0 is lowest.”
  2. SysTick as the Heartbeat
    • I aimed for a 1 ms tick. On a 16 MHz clock, that meant LOAD = 16 000 – 1.
    • I initially tried to do all scheduling decisions inside the SysTick ISR, but that got messy. Instead, I now just decrement sleep counters there and set the PendSV pending bit. Let the “real” context switch happen in PendSV.
  3. Exception Stack Frame
    • When any exception fires, hardware auto-pushes R0–R3, R12, LR, PC, and xPSR. That means my “fake” initial stack for each thread must match this exact layout—else, on the very first run, the CPU will attempt to pop garbage and crash.
    • I once forgot to set the Thumb bit (0x01000000) in xPSR. The result was an immediate hard fault. Lesson: that Thumb flag is non-negotiable.

Il blocco di controllo dei fili (TCB)

Ogni thread nel mio RTOS contiene:

typedef enum { READY, RUNNING, BLOCKED, SLEEPING } state_t;

typedef struct {
    uint32_t *stack_ptr;   // Saved PSP for context switches
    uint8_t   priority;    // 0 = highest, larger = lower priority
    state_t   state;       // READY, RUNNING, BLOCKED, or SLEEPING
    uint32_t  sleep_ticks; // How many SysTick ticks remain, if sleeping
} tcb_t;

In pratica ho dichiarato:

#define MAX_THREADS 9
#define STACK_WORDS 256

uint32_t    thread_stacks[MAX_THREADS][STACK_WORDS];
tcb_t       tcbs[MAX_THREADS];
uint8_t     thread_count = 0;
int         current_thread = -1;

Una storia di orrore stack-overflow

Quando per la prima volta assegnatoSTACK_WORDS = 128, tutto sembrava bene - fino a quando il mio filo "lavoratore", che ha fatto alcune chiamate di funzione incastonate, ha iniziato a corrompere la memoria. Il LED lampeggiava due volte, poi scompare.0xDEADBEEFQuando ho iniziato e controllato fino a che punto è stato sovrascritto, ho scoperto che 128 parole non erano sufficienti sotto le bandiere di costruzione ottimizzate.


Raccogliere un nuovo filo

Creare un filo significava scolpire la sua pila e simulare l'accumulo hardware che accade all'entrata di eccezione. Ecco la routine, con commenti sulle mie prime trappole:

void rtos_create_thread(void (*fn)(void), uint8_t prio) {
    int id = thread_count++;
    tcb_t *t = &tcbs[id];
    uint32_t *stk = &thread_stacks[id][STACK_WORDS - 1];

    // Simulate hardware stacking (xPSR, PC, LR, R12, R3, R2, R1, R0)
    *(--stk) = 0x01000000;            // xPSR: Thumb bit set
    *(--stk) = (uint32_t)fn;          // PC → thread entry point
    *(--stk) = 0xFFFFFFFD;            // LR → return with PSP in Thread mode
    *(--stk) = 0x12121212;            // R12 (just a marker)
    *(--stk) = 0x03030303;            // R3
    *(--stk) = 0x02020202;            // R2
    *(--stk) = 0x01010101;            // R1
    *(--stk) = 0x00000000;            // R0

    // Save space for R4–R11 (popped by the context switch)
    for (int r = 4; r <= 11; r++) {
        *(--stk) = 0x0; // or use a pattern if you want to measure usage
    }

    t->stack_ptr   = stk;
    t->priority    = prio;
    t->state       = READY;
    t->sleep_ticks = 0;
}

Pitfalls da guardare

  • Mancando significa che la CPU cercherà di interpretare il codice come istruzioni ARM – errore immediato.
  • Il Magic 0xFFFFFFFD. Questo dice alla CPU, "In eccezione ritorna, usa PSP e vai in modalità Thread." ricordo di cercare l'ARM ARM (Architecture Reference Manual) almeno tre volte per ottenere questo giusto.
  • Pushing R4–R11 manualmente deve seguire l'ordine esatto che il gestore si aspetta. Un semplice typpo (ad esempio, premendo R11 prima invece di R4) getta via l'intero telaio.

Permettere il passaggio del tempo: SysTick Handler

Ecco il mio ultimo SysTick ISR, tagliato all'essenziale:

void SysTick_Handler(void) {
    for (int i = 0; i < thread_count; i++) {
        if (tcbs[i].state == SLEEPING) {
            if (--tcbs[i].sleep_ticks == 0) {
                tcbs[i].state = READY;
            }
        }
    }
    SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // Pend PendSV for scheduling
}

Un paio di note:

  • In una versione iniziale, ho provato a chiamare rtos_schedule() proprio all'interno di SysTick. Ciò ha portato a interruzioni nested e confusione stack.
  • Ho scoperto che se SysTick_IRQn e PendSV_IRQn condividono la stessa priorità, PendSV a volte non viene eseguito. date sempre a PendSV la priorità numerica assoluta più bassa (ad esempio, NVIC_SetPriority(PendSV_IRQn, 0xFF)), e mantenete SysTick leggermente più alto (ad esempio, 2 o 3).

Il Big Switch: PendSV Handler

Quando PendSV infine si spegne, fa lo scambio di contesto effettivo. La mia implementazione in assemblaggio in linea GCC (sintax ARM) sembra questo:

__attribute__((naked)) void PendSV_Handler(void) {
    __asm volatile(
        "MRS   R0, PSP                \n" // Get current PSP
        "STMDB R0!, {R4-R11}          \n" // Push R4–R11 onto stack
        "LDR   R1, =current_thread    \n"
        "LDR   R2, [R1]               \n"
        "STR   R0, [R2, #0]           \n" // Save updated PSP into TCB

        "BL    rtos_schedule          \n" // Decide next thread

        "LDR   R1, =current_thread    \n"
        "LDR   R2, [R1]               \n"
        "LDR   R0, [R2, #0]           \n" // Load next thread’s PSP
        "LDMIA R0!, {R4-R11}          \n" // Pop R4–R11 from its stack
        "MSR   PSP, R0                \n" // Update PSP to new thread
        "BX    LR                     \n" // Exit exception, restore R0–R3, R12, LR, PC, xPSR
    );
}

Come ho tracciato quel pesante 32-byte offset

All'inizio, il mio codice "salva / ripristina" era spento per 32 byte. Il sintomo? i fili funzionerebbero, poi in qualche modo tornerebbero in vita nel posto sbagliato - istruzioni sfocate, salti casuali.STMDB) per misurare esattamente quanti byte sono stati spinti. Nel mio debugger, ho poi confrontato il valore PSP numerico con iltcbs[].stack_ptrMi aspettavo. Sicuro abbastanza, avrei usato per casoSTMDB R0!, {R4-R11, R12}Invece di{R4-R11}— spingendo un registro aggiuntivo. Rimuovere quel registro spinto aggiuntivo lo ha risolto.


Scegliere il prossimo thread: Scheduler Logic

Il mio programmatore è intenzionalmente semplice. Esegue la scansione di tutti i TCB per trovare il thread READY con la massima priorità, con un piccolo aggiustamento rotondo per i thread con uguale priorità:

void rtos_schedule(void) {
    int next = -1;
    uint8_t best_prio = 0xFF;

    for (int i = 0; i < thread_count; i++) {
        if (tcbs[i].state == READY) {
            if (tcbs[i].priority < best_prio) {
                best_prio = tcbs[i].priority;
                next = i;
            } else if (tcbs[i].priority == best_prio) {
                // Simple round-robin: if i is after current, pick it
                if (i > current_thread) {
                    next = i;
                    break;
                }
            }
        }
    }
    if (next < 0) {
        // No READY threads—fall back to idle thread (ID 0)
        next = 0;
    }
    current_thread = next;
}

What I Learned Here:

  • Se due fili condividono la priorità 1, ma scelgo sempre l'ID più basso, l'altro non otterrà mai il tempo della CPU.
  • Il filo idle (ID 0) è un caso speciale: sempre PREPARATO, sempre la priorità più bassa (priorità = 255), in modo che se non c'è altro da eseguire, si ruota semplicemente (o chiama __WFI() per risparmiare energia).

Primitivi del nucleo: rendimento, sonno e semaphore

Dopo aver tratto le basi, il passo successivo era quello di far interagire correttamente i fili.

Il reddito

void rtos_yield(void) {
    SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}

Amo lo sprinklingrtos_yield()alla fine di lunghi circuiti in modo che gli altri fili ottengano un colpo equo.yield()Ciò significa che alcuni compiti hanno messo la CPU sotto determinate configurazioni prioritarie.

Dormire

void rtos_sleep(uint32_t ticks) {
    tcb_t *self = &tcbs[current_thread];
    self->sleep_ticks = ticks;
    self->state       = SLEEPING;
    rtos_yield();
}

Il mio “LED blinking” thread callsrtos_sleep(500)Quando guardo il flash ogni mezzo secondo, so che SysTick e PendSV fanno il loro lavoro correttamente.

Semifinale

Inizialmente ho provato un approccio ingenuo:

typedef struct {
    volatile int count;
    int waiting_queue[MAX_THREADS];
    int head, tail;
} semaphore_t;

void rtos_sem_wait(semaphore_t *sem) {
    __disable_irq();
    sem->count--;
    if (sem->count < 0) {
        sem->waiting_queue[sem->tail++] = current_thread;
        tcbs[current_thread].state = BLOCKED;
        __enable_irq();
        rtos_yield();
    } else {
        __enable_irq();
    }
}

void rtos_sem_post(semaphore_t *sem) {
    __disable_irq();
    sem->count++;
    if (sem->count <= 0) {
        int tid = sem->waiting_queue[sem->head++];
        tcbs[tid].state = READY;
    }
    __enable_irq();
}

Inversione di priorità Nightmare

Un giorno, ho avuto tre thread:

  • T0 (priorità 2): detiene il semaforo.
  • T1 (Priorità 1): aspettando quel semaforo.
  • T2 (priorità 3): pronto a correre e superiore a T0 ma inferiore a T1.

Poiché T1 è stato bloccato, T2 ha continuato a correre - mai dando a T0 la possibilità di rilasciare il semaphore per T1. T1 ha affamato. Il mio rapido hack era quello di aumentare temporaneamente la priorità di T0 quando T1 è stato bloccato - è un'eredità di priorità rudimentale. Una soluzione completa traccerebbe quale filo detiene il semaphore e solleva automaticamente la sua priorità.


Spegniamo tutto:rtos_start()

L’avvio di un progetto (

inmain(), dopo init hardware di base (orologi, GPIO per LED, UART per la scatola), ho fatto:

// 1. Initialize SysTick and PendSV priorities
systick_init(16000);        // 1 ms tick on a 16 MHz system
NVIC_SetPriority(PendSV_IRQn, 0xFF);

// 2. Create threads (ID 0 = idle thread)
rtos_create_thread(idle_thread, 255);
rtos_create_thread(shell_thread, 3);
rtos_create_thread(worker1, 1);
rtos_create_thread(worker2, 2);

// 3. Switch to PSP and unprivileged Thread mode
current_thread = 0;
__set_PSP((uint32_t)tcbs[0].stack_ptr);
__set_CONTROL(0x02); // Use PSP, unprivileged
__ISB();

// 4. Pend PendSV to start first context switch
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;

// 5. Idle loop
while (1) {
    __WFI();  // Save power until next interrupt
}

Un paio di note finali:

  • Idle Thread (ID 0): Sposta semplicemente un LED a bassa priorità. Se qualcosa va storto, so che almeno sono arrivato al filo inutile.
  • Priorità di interruzione UART: l'interruzione TX di UART necessitava di una priorità più alta rispetto a PendSV; altrimenti, le chiamate printf lunghe sarebbero interrotte nel mezzo della trasmissione, limitando la produzione.

Titolo originale: The UART Shell: Peeking Under the Hood

Ho costruito una piccola shell in modo da poter immettere comandi su UART e ispezionare gli stati di thread:

void shell_thread(void) {
    char buf[64];
    while (1) {
        uart_print("> ");
        uart_read_line(buf, sizeof(buf));

        if (strncmp(buf, "ps", 2) == 0) {
            for (int i = 0; i < thread_count; i++) {
                uart_printf("TID %d: state=%d prio=%d\n",
                    i, tcbs[i].state, tcbs[i].priority);
            }
        } else if (strncmp(buf, "sleep ", 6) == 0) {
            uint32_t msec = atoi(&buf[6]);
            rtos_sleep(msec);
        } else if (strncmp(buf, "kill ", 5) == 0) {
            int tid = atoi(&buf[5]);
            if (tid >= 1 && tid < thread_count) { // don’t kill idle
                tcbs[tid].state = BLOCKED; // crude kill
                uart_printf("Killed thread %d\n", tid);
            }
        } else {
            uart_print("Unknown command\n");
        }
    }
}

Questo guscio era la mia rete di sicurezza: se i LED si comportassero stranamente, sarei saltato nel mio terminale di serie,ps, e vedere immediatamente quali fili erano PREPARATI, BLOCKATI o SLEEPING. mi ha risparmiato ore di indovinello.


Le lezioni imparate lungo il percorso

  1. Le priorità di interruzione sono tutto ciò che ho trascorso un'intera pomeriggio convinto che il mio codice PendSV era sbagliato - fino a quando mi sono reso conto che avevo impostato SysTick e PendSV alla stessa priorità. Una volta che ho dato a SysTick una priorità più alta, PendSV ha iniziato a sparare in modo affidabile. lezione: controllare le chiamate NVIC_SetPriority() in doppio e triplo.
  2. Misurare l'uso del tuo stack Pre-compilare lo stack di ogni thread con un modello conosciuto (0xDEADBEEF) all'avvio. Dopo aver eseguito, ispezionare la memoria per vedere quanto profondo il modello è stato sovrascritto. Se il tuo puntatore stack entra mai in quel modello, sai che hai bisogno di uno stack più grande. Ho imparato questo in modo difficile quando una catena di chiamata più profonda nel mio thread lavoratore ha causato un sovrascritto silenzioso.
  3. Uso di LED e GPIO per debug Se non hai un debugger di fantasia, basta passare un pin GPIO (catturalo a un LED). Ho posizionato un switch LED all'inizio di PendSV_Handler e un altro alla sua fine.
  4. La mia prima versione di RTOS ha cercato di supportare la creazione dinamica di attività in tempo di esecuzione – un errore enorme. ho finito con la frammentazione della memoria e strani crash. congelando il numero di fili alla startup (solo nove slot nel mio caso), ho evitato un mondo di dolore.
  5. Documento Ogni Numero Magico Che 0xFFFFFFFD valore nel frame stack “falso”? ho scritto una breve nota: “Link registrazione valore che indica il ritorno alla modalità Thread utilizzando PSP.”

Successivo Articolo successivo: Dove andare da qui

Se decidi di costruire su questa fondazione, ecco alcune idee:

  • Piuttosto che il mio rapido "boost-and-forget" hack, implementare un protocollo appropriato in cui la priorità del filo bloccato di alta priorità viene ereditata fino a quando il semaphore non viene rilasciato.
  • Inter-Thread Message Queues Lascia che i thread inviino piccoli messaggi o puntatori l'uno all'altro in modo sicuro. Pensa a questo come a una piccola cassetta postale per passare i dati tra attività.
  • Dynamic Task Creation/Deletion Tack su un piccolo gestore di pile o un pool di memoria in modo da poter creare e uccidere i thread sul volo – tenete a mente la complessità aggiuntiva!
  • Profiling Hooks Espandere il gestore SysTick (o utilizzare un altro timer) per registrare quanti tick ogni filo esegue.
  • Controlli di sicurezza in tempo reale Introduzione dei limiti di utilizzo della pila e rilevamento quando il puntatore della pila di un filo attraversa la regione 0xDEADBEEF, innescando una chiusura o una reimpostazione sicura invece di incidenti casuali.

I pensieri finali

Costruire un RTOS minimo è confuso, spesso frustrante e assolutamente illuminante. Dal perseguire una priorità pendSV sbagliata a lottare con gli overflows di pile, ogni bug mi ha costretto a capire le interni Cortex-M4 più profondamente. Se provi questo sul tuo TM4C o su qualsiasi altra parte Cortex-M, aspettati alcune notti di debug – ma anche la profonda soddisfazione quando arriva finalmente quel primo lampo LED affidabile.

Se dai questo un colpo, ti prego di farmi sapere quale parte ti ha spinto verso il muro. mi piacerebbe sentire le tue storie "LED flashing" o qualsiasi altro trucco che hai scoperto durante la caccia ai fantasmi nel tuo programmatore.

Trending Topics

blockchaincryptocurrencyhackernoon-top-storyprogrammingsoftware-developmenttechnologystartuphackernoon-booksBitcoinbooks