297 čtení

Jak začít s RTOS: Praktický průvodce pro začátečníky s Cortex-M4

podle manojgowda...15m2025/06/04
Read on Terminal Reader

Příliš dlouho; Číst

Vytvoření minimálního RTOS je nepořádné, často frustrující a naprosto osvětlující.
featured image - Jak začít s RTOS: Praktický průvodce pro začátečníky s Cortex-M4
Manoj Gowda HackerNoon profile picture

Stále si pamatuji, že jsem se jednoho večera podíval na LED světla TM4C123GH6PM a čekal na jednoduchý „srdeční úder“, který nikdy nepřišel. Myslel jsem si, že jsem udělal všechno správně: nastavil jsem SysTick, zvedl PendSV a inicializoval své kontrolní bloky (TCB). Namísto neustálého blikání se LED jednou otáčely a pak mě zmrazily – vysmívaly. Ten okamžik krystalizoval to, co budování malého RTOS opravdu znamená: bojovat s hardwarovými quirky, pronásledovat nepřehlédnutelné chyby a shromažďovat jen dostatek kódu, aby vše fungovalo hladce.


Trochu backstory

Asi před rokem nám moje třída vestavěných systémů přidělila jednoduchý RTOS od začátku na TM4C123GH6PM (ARM Cortex-M4).Předtím jsem používala FreeRTOS, ale nikdy jsem plně nepochopila, co se děje za scénami.

Why?

  • Chtěl jsem zjistit, jak přesně se procesor přepíná z jednoho drátu na druhý.
  • Potřeboval jsem se dozvědět, proč úkoly s nízkou prioritou mohou neúmyslně vyhladovět úkoly s vyšší prioritou (dobrý den, inverze priority).
  • Toužil jsem po opravdových příbězích o odstraňování potíží - jako když jsem strávil půl dne přemýšlením, proč PendSV nikdy neproběhl (uvádí se, že jsem mu dal stejnou prioritu jako SysTick).

Spoiler: Můj RTOS nebyl dokonalý, ale při pronásledování jeho nedostatků jsem se dozvěděl více o ARM interále než z jakékoliv učebnice.


Procházení Cortex-M4 World

Před psaním jediného řádku kódu jsem musel zabalit hlavu kolem toho, jak Cortex-M4 zvládá přerušení a přepínání kontextu.

  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.

Tvorba bloků pro řízení vláken (TCB)

Každý thread v mém RTOS obsahuje:

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;

V praxi jsem prohlásil:

#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;

Stack-Overflow hororový příběh

Když jsem poprvé přidělilSTACK_WORDS = 128, vše se zdálo být v pořádku - dokud můj "pracovník" drát, který udělal několik propojených funkčních hovorů, začal kazit paměť. LED by blikal dvakrát, pak zmizel.0xDEADBEEFpři spuštění a zkontrolování, jak daleko se to dostalo nadpisem, zjistil jsem, že 128 slov nebylo dost pod optimalizovanými stavebními vlajkami.


Vytvořte nový thread

Vytvoření pramene znamenalo vyřezávání jeho hromady a simulování hromadění hardwaru, které se děje na výjimce.

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 na pozorování

  • Chybějící v xPSR znamená, že váš procesor se pokusí interpretovat kód jako pokyny ARM – okamžitá chyba.
  • Magic 0xFFFFFFFD. To říká CPU, „Na výjimku zpět, použijte PSP a přejděte do režimu Thread.“ Vzpomínám si, že hledám ARM ARM (Architecture Reference Manual) nejméně třikrát, aby se to dostalo správně.
  • Pushing R4–R11 manuálně musí následovat přesné pořadí, které manipulátor očekává. Jednoduchý typ (např. stisknutím R11 nejdříve místo R4) vyhodí celý rám.

Nastavení času: SysTick Handler

Zde je můj poslední SysTick ISR, oříznutý na podstatné:

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
}

A pár poznámek:

  • V rané verzi jsem se pokusil zavolat rtos_schedule() přímo uvnitř SysTick. To vedlo k nested interrupts a stack zmatek.
  • Zjistil jsem, že pokud SysTick_IRQn a PendSV_IRQn sdílejí stejnou prioritu, PendSV někdy nikdy nefunguje.Vždy dát PendSV absolutní nejnižší číselnou prioritu (tj. NVIC_SetPriority(PendSV_IRQn, 0xFF)), a udržet SysTick mírně vyšší (např. 2 nebo 3).

Velký přepínač: PendSV Handler

Když PendSV konečně vypne, dělá skutečný kontextový přepínač. Moje implementace v GCC inline assembly (ARM syntaxe) vypadá takto:

__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
    );
}

Jak jsem sledoval, že pesky 32-byte kompenzace

Zpočátku byl můj kód "Uložit / Obnovit" vypnutý o 32 bajtů. Symptom? Threads by běžel, pak se nějakým způsobem vrátil do života na nesprávném místě - škrábané pokyny, náhodné skoky.STMDB) přesně změřit, kolik bajtů bylo tlačeno. V mém debuggeru jsem pak porovnal číselnou hodnotu PSP s hodnotoutcbs[].stack_ptrJsem si jistá, že jsem náhodou použilaSTMDB R0!, {R4-R11, R12}Namísto toho{R4-R11}Odstranění tohoto extra tlačeného registru ho opravilo.


Následující Následující příspěvek: Scheduler Logic

Můj plánovač je záměrně jednoduchý. skenuje všechny TCB, aby našel nejvyšší prioritu READY, s malým krouživým přizpůsobením pro prameny stejné priority:

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:

  • Pokud dvě prameny sdílejí prioritu 1, ale vždy zvolím nižší ID, druhá nikdy nedostane čas CPU.
  • Idle thread (ID 0) je zvláštní případ: vždy připravený, vždy nejnižší priorita (priorita = 255), takže pokud nic jiného není spustitelné, prostě se točí (nebo volá __WFI() pro úsporu energie).

Kernel Primitives: Výnos, spánek a semáfory

Po vymezení základů bylo dalším krokem, aby prameny správně interagovaly.

Výnosy

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

Líbí se mi sprintovánírtos_yield()na konci dlouhých závitů, takže ostatní prameny dostanou spravedlivý výstřel.yield()Znamenalo to, že některé úkoly ovládaly procesor pod určitými prioritními konfiguracemi.

spánek

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

Moje „LED blinking“ thread volánírtos_sleep(500)Když se dívám, jak bliká každých půl sekundy, vím, že SysTick a PendSV dělají svou práci správně.

Semifinále

Zpočátku jsem se snažil naivní přístup:

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();
}

Inverzní noční můra

Jednoho dne jsem měl tři prameny:

  • T0 (Priorita 2): drží semafor.
  • T1 (priorita 1): čekání na tento semafor.
  • T2 (Priorita 3): připraven k běhu a vyšší než T0 ale nižší než T1.

Protože T1 byl zablokován, T2 pokračoval v běhu – nikdy nedával T0 šanci uvolnit semafor pro T1. T1 hladověl. Mým rychlým hackem bylo dočasně zvýšit prioritu T0, když byl T1 zablokován – je to rudimentární dědictví priority.


Kick off všeho:rtos_start()

Zpět na start()

vmain(), po základním hardwarovém init (hodinky, GPIO pro LED, UART pro shell), jsem udělal:

// 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
}

Pár závěrečných poznámek:

  • Idle Thread (ID 0): Jednoduše přepne LED s nízkou prioritou. Pokud se něco pokazí, vím, že jsem se alespoň dostal do idle drátu.
  • Priorita přerušení UART: přerušení TX UART potřebovalo vyšší prioritu než PendSV; jinak by dlouhé hovory printf byly přerušeny uprostřed přenosu, což by narušilo výstup.

Originální název: Peeking Under the Hood

Vytvořil jsem malou shell, abych mohl zadávat příkazy přes UART a kontrolovat stav vlákna:

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");
        }
    }
}

Tato skořápka byla moje bezpečnostní síť: kdyby se LED chovaly podivně, skočil bych do svého sériového terminálu,ps, a okamžitě vidět, které prameny byly připraveny, zablokovány nebo spící.


Lekce, které jsme se naučili na cestě

  1. Přerušené priority jsou vše, co jsem strávil celé odpoledne přesvědčením, že můj kód PendSV byl špatný – dokud jsem si neuvědomil, že jsem nastavil SysTick a PendSV na stejnou prioritu. Jakmile jsem dal SysTick vyšší prioritu, PendSV začal spolehlivě střílet. lekce: dvakrát a trojnásobně zkontrolujte vaše hovory NVIC_SetPriority() .
  2. Změřte používání zásobníku Předplňte zásobník každého vlákna známým vzorem (0xDEADBEEF) při spuštění. Po spuštění zkontrolujte paměť, abyste zjistili, jak hluboko byl vzor přepsán. Pokud váš ukazatel zásobníku někdy vstoupí do tohoto vzorce, víte, že potřebujete větší zásobník. Naučil jsem se to těžkým způsobem, když hlubší řetězec volání v mém pracovním vláknu způsobil tiché přepsání.
  3. Použijte LED a GPIO na debugování Pokud nemáte fantazie debugger, stačí přepnout GPIO pin (hook to na LED). Umístil jsem jednu LED přepínání na samém začátku PendSV_Handler a další na jeho konci. sledování těch blikání na logický analyzátor mi pomohl ověřit, že manipulátor běžel očekávaný početkrát - a přibližně ve správných intervalech.
  4. Moje první verze RTOS se pokoušela podporovat dynamické vytváření úloh při spuštění – obrovská chyba. skončil jsem s fragmentací paměti a podivnými haváriemi. Zmrazením počtu vláken při spuštění (v mém případě jen devět slotů) jsem se vyhnul světu bolesti.
  5. Dokument Každý magické číslo, že 0xFFFFFFFD hodnota v „falešné“ stack frame? napsal jsem krátkou poznámku: „Link registrační hodnota, která naznačuje návrat do režimu Drát pomocí PSP.“ Bez tohoto komentáře bych Googled „ARM výjimky vrátit hodnoty“ pokaždé, když jsem znovu kód.

Následující Článek Kde jít odtud

Pokud se rozhodnete stavět na tomto základu, zde je několik nápadů:

  • Plná priorita dědictví pro Semaphores Namísto mého rychlého "boost-and-forget" hack, implementovat správný protokol, kde je priorita nejvyšší priority zablokované vlákno je zděděna, dokud semaphore je propuštěn.
  • Inter-Thread Message Queues Umožněte, aby prameny bezpečně posílaly malé zprávy nebo ukazatele navzájem.Představte si to jako malou poštovní schránku pro předávání dat mezi úkoly.
  • Dynamic Task Creation/Deletion Tack na malém hromadném správci nebo paměťovém bazénu, abyste mohli vytvářet a zabíjet prameny - myslete na extra složitost!
  • Rozšiřte ovladač SysTick (nebo použijte jiný časovač), abyste zaznamenali, kolik ticků každý pramen běží.
  • Bezpečnostní kontroly v reálném čase Zadejte limity používání zásobníku a zjistěte, kdy ukazatel zásobníku pramene přechází do oblasti 0xDEADBEEF – spouští bezpečné vypnutí nebo reset místo náhodných havárií.

Konečné myšlenky

Vytvoření minimálního RTOS je nepořádek, často frustrující a naprosto osvětlující. Od pronásledování chybně umístěné priority PendSV až po boj s přetížením zásob, každá chyba mě donutila hlouběji porozumět interiérům Cortex-M4.Pokud to vyzkoušíte sami na TM4C nebo na jakékoli jiné části Cortex-M, očekávejte několik nocí debugingu – ale také hlubokou spokojenost, když se konečně objeví ten první spolehlivý LED blink.

Pokud to uděláte, dejte mi prosím vědět, která část vás tlačila na zeď. Rád bych slyšel vaše vlastní příběhy "LED blinking" nebo jakékoliv jiné triky, které jste objevili při honbě za duchy ve vašem plánovači.

L O A D I N G
. . . comments & more!

About Author

Manoj Gowda HackerNoon profile picture
Manoj Gowda@manojgowda
I'm Manoj Gowda—embedded software engineer by day, bug whisperer by night, making cars smarter one crash log at a time.

ZAVĚŠIT ZNAČKY

TENTO ČLÁNEK BYL PŘEDSTAVEN V...

Trending Topics

blockchaincryptocurrencyhackernoon-top-storyprogrammingsoftware-developmenttechnologystartuphackernoon-booksBitcoinbooks