Wciąż pamiętam, jak pewnego wieczoru patrzyłem na diody LED TM4C123GH6PM, czekając na prosty, nigdy nie przychodzący „tłumik serca”. Myślałem, że zrobiłem wszystko poprawnie: zainstalowałem SysTick, zawiesiłem PendSV i zainicjowałem moje blokady sterowania wiązką (TCB). Ale zamiast stałego zawieszenia, diody LED skręciły raz, a następnie zamarzały – wyśmiewując mnie. Ten moment krystalizował to, co naprawdę oznacza budowanie małego RTOS: walka z dziwnymi problemami sprzętu, ściganie się z trudnymi błędami i układanie wystarczającej ilości kodu, aby wszystko działało płynnie.
Trochę backstory
Około roku temu moja klasa systemów osadzonych przydzieliła nam budowę prostego RTOS od podstaw na TM4C123GH6PM (ARM Cortex-M4). Używałem FreeRTOS wcześniej, ale nigdy w pełni nie zrozumiałem, co dzieje się za kulisami.
Why?
- Chciałem dokładnie zobaczyć, jak procesor przełącza się z jednego drutu na drugi.
- Potrzebowałem dowiedzieć się, dlaczego zadania o niskich priorytetach mogą przypadkowo wygłodzić zadania o wyższych priorytetach (hello, inwersja priorytetów).
- Pragnąłem prawdziwych historii debugujących - takich jak ten czas, w którym spędziłem pół dnia zastanawiając się, dlaczego PendSV nigdy nie działał (wydaje się, że dałem mu taki sam priorytet jak SysTick).
Spoiler: Mój RTOS nie był idealny, ale ścigając jego wady, dowiedziałem się więcej o ARM internals niż z jakiejkolwiek książki.
Nurkowanie w świecie Cortex-M4
Przed napisaniem pojedynczego wiersza kodu musiałem owinąć głowę wokół tego, jak Cortex-M4 radzi sobie z przerwami i przełączaniem kontekstu.
- 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.”
- 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.
- I aimed for a 1 ms tick. On a 16 MHz clock, that meant
- 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.
Tworzenie bloków kontroli prądu (TCB)
Każda strona w moim RTOS zawiera:
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;
W praktyce stwierdziłem:
#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;
A Stack-Overflow Horror Story zwiastun
Kiedy po raz pierwszy przydzielonoSTACK_WORDS = 128
Wszystko wydawało się być w porządku – dopóki mój „pracownik”, który wykonał kilka wbudowanych połączeń funkcyjnych, zaczął niszczyć pamięć.0xDEADBEEF
podczas uruchamiania i sprawdzania, jak daleko został napisany, odkryłem, że 128 słów nie wystarczyło pod zoptymalizowanymi flagami budowania.
Wyrzucanie nowej nici
Tworzenie przewodu oznaczało wyrzeźbienie jego stosu i symulowanie gromadzenia sprzętu, które dzieje się na wejściu wyjątku.
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;
}
Pielgrzymki do obserwacji
- Brak go oznacza, że procesor spróbuje interpretować kod jako instrukcje ARM — natychmiastowy błąd.
- Magic 0xFFFFFFFD. To mówi procesorowi: „Na zwrot z wyjątkiem, użyj PSP i przejdź do trybu Thread.” Przypominam, że patrzę na ARM ARM (Architecture Reference Manual) co najmniej trzy razy, aby to zrobić.
- Pushing R4–R11 ręcznie musi podążać za dokładnym porządkiem, którego oczekuje operator. prosty typ (np. naciskając R11 najpierw zamiast R4) wyrzuca całą ramę.
Pozbycie się czasu: SysTick Handler
Oto mój ostatni SysTick ISR, przycinany do zasadniczych:
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
}
Oto kilka notatek:
- W wczesnej wersji próbowałem zadzwonić do rtos_schedule() bezpośrednio wewnątrz SysTick. To doprowadziło do nieszczelnych przerw i zamieszania.
- Odkryłem, że jeśli SysTick_IRQn i PendSV_IRQn mają ten sam priorytet, PendSV czasami nigdy nie działa. Zawsze nadaj PendSV absolutnie najniższy priorytet liczbowy (tj. NVIC_SetPriority(PendSV_IRQn, 0xFF)), a następnie trzymaj SysTick nieco wyżej (np. 2 lub 3).
Wielki przełącznik PendSV Handler
Kiedy PendSV wreszcie wybuchnie, wykonuje rzeczywisty przełącznik kontekstu. Moja implementacja w montażu w linii GCC (syntax ARM) wygląda tak:
__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 śledziłem, że nieprzyjemny 32-byte offset
Początkowo mój kod "zapisz / przywróć" był wyłączony o 32 bajty. objaw? Przewody byłyby uruchamiane, a następnie w jakiś sposób wróciły do życia w niewłaściwym miejscu - zgarbione instrukcje, losowe skoki.STMDB
, aby dokładnie zmierzyć, ile bajtów zostało naciśniętych.W moim debuggerze porównałem numeryczną wartość PSP dotcbs[].stack_ptr
Na pewno, przypadkowo użyłemSTMDB R0!, {R4-R11, R12}
Zamiast{R4-R11}
— naciskając jeden dodatkowy rejestr. Usuwanie tego dodatkowego wciśniętego rejestru go naprawiło.
Picking the Next Thread: Logika harmonogramu
Skanuje wszystkie TCBs, aby znaleźć nić READY o najwyższym priorytecie, z małym okrągłym dostosowaniem robiny dla nić o równym priorytecie:
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:
- Jeśli dwa pasma mają priorytet 1, ale zawsze wybieram niższy identyfikator, drugi nigdy nie otrzyma czasu procesora.
- Przewód bezczynny (ID 0) jest specjalnym przypadkiem: zawsze GOTOWY, zawsze najniższy priorytet (priorytet = 255), więc jeśli nic innego nie jest uruchamialne, po prostu się obraca (lub wzywa __WFI() aby zaoszczędzić moc).
Kernel Primitives: wydajność, sen i semaphores
Po zagłębieniu się w podstawy, następnym krokiem było prawidłowe interakcje drutów.
zyski
void rtos_yield(void) {
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
Lubię sprinklertos_yield()
na końcu długich łańcuchów, aby inne pasma uzyskały uczciwy strzał.yield()
Oznaczało to, że niektóre zadania przechwyciły procesor pod pewnymi konfiguracjami priorytetowymi.
śpią
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 callsrtos_sleep(500)
Kiedy oglądam, jak miga co pół sekundy, wiem, że SysTick i PendSV wykonują swoją pracę poprawnie.
Semifinale
Początkowo próbowałem naiwnego podejścia:
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();
}
Pierwszeństwo Inversion Nightmare
Pewnego dnia miałem trzy wiersze:
- T0 (priorytet 2): utrzymuje światłowód.
- T1 (priorytet 1): czekanie na ten semafor.
- T2 (priorytet 3): gotowy do działania i wyższy niż T0 ale niższy niż T1.
Ponieważ T1 został zablokowany, T2 nadal działał – nigdy nie dając T0 szansy na uwolnienie semaforu dla T1. T1 głodował. Mój szybki hack polegał na tymczasowym zwiększeniu priorytetu T0, gdy T1 został zablokowany – jest to rudymentalne dziedziczenie priorytetu. Pełne rozwiązanie śledziłoby, który nić trzyma semafor i automatycznie podnosi jego priorytet.
Wyrzucanie wszystkiego na zewnątrz:Zacznij od Start()
Zacznij od Start()
wmain()
, po podstawowym init sprzętu (zegarki, GPIO dla diod LED, UART dla skorupy), zrobiłem:
// 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
}
Kilka ostatnich notatek:
- Idle Thread (ID 0): Po prostu przełącza diodę LED o niskim priorytecie.
- Priorytet przerwania UART: Przerwanie TX UART wymagało wyższego priorytetu niż PendSV; w przeciwnym razie długie połączenia printf byłyby przerwane w środkowej transmisji, osłabiając wyjście.
Tytuł oryginalny: Peeking Under the Hood
Zrobiłem małą powłokę, dzięki czemu mogłem wprowadzać polecenia w UART i sprawdzać stan przewodów:
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");
}
}
}
Ta powłoka była moją siatką bezpieczeństwa: jeśli diody LED zachowywały się dziwnie, wskoczyłem do mojego seryjnego terminala, napiszps
, i natychmiast zobaczyć, które paski były gotowe, zablokowane lub śpiące.
Lekcje wyciągnięte w drodze
- Przerwanie priorytetów to wszystko, co spędziłem całe popołudnie przekonując się, że mój kod PendSV jest błędny – aż zdałem sobie sprawę, że ustawiłem SysTick i PendSV na ten sam priorytet.
- Pomiar Twojego użycia stosu Przed wypełnieniem stosu każdego sznurka z znanym wzorem (0xDEADBEEF) w momencie uruchomienia. Po uruchomieniu, sprawdź pamięć, aby zobaczyć, jak głęboko wzorzec został nadpisany. Jeśli twój wskaźnik stosu kiedykolwiek wchodzi w ten wzorzec, wiesz, że potrzebujesz większego stosu.
- Użyj diod LED i GPIO do debugowania Jeśli nie masz fancy debugera, po prostu przełącz pin GPIO (przyłącz go do diody LED). Włożyłem jeden przełącznik LED na samym początku PendSV_Handler, a drugi na jego końcu.
- Keep It Simple at First Moja pierwsza wersja RTOS próbowała obsługiwać dynamiczne tworzenie zadań w czasie uruchamiania – ogromny błąd. skończyłem się fragmentacją pamięci i dziwnymi awariami.
- Napisałem krótką notatkę: „Wartość rejestru linku, która wskazuje na powrót do trybu prądu przy użyciu PSP”.
Dalej Następny wpis: Dokąd zmierzamy
Jeśli zdecydujesz się zbudować na tym fundamencie, oto kilka pomysłów:
- Pełny priorytet dziedziczenia dla Semaphores Zamiast mojego szybkiego "boost-and-forget" hack, wdrożenie odpowiedniego protokołu, w którym priorytet blokowanego drutu o najwyższym priorytecie jest dziedziczony, aż do uwolnienia semaphore.
- Wiązki wiadomości międzyprzewodowych Pozwól przewodom bezpiecznie wysyłać sobie nawzajem małe wiadomości lub wskazówki. Myśl o tym jak o małym skrzynce pocztowej do przesyłania danych między zadaniami.
- Dynamic Task Creation/Deletion Tack na małym menedżerze gromady lub puli pamięci, dzięki czemu możesz tworzyć i zabijać pasma na biegu – pamiętaj o dodatkowej złożoności!
- Profiling Hooks Rozszerz obsługę SysTick (lub użyj innego timeru), aby rejestrować liczbę kliknięć wykonywanych przez każdy pasek.
- Sprawdzanie bezpieczeństwa w czasie rzeczywistym Wprowadzanie ograniczeń użytkowania stosu i wykrywanie, gdy wskaźnik stosu drutu przechodzi do regionu 0xDEADBEEF – wyzwala bezpieczne wyłączenie lub resetowanie zamiast przypadkowych awarii.
Ostatnie myśli
Budowanie minimalnego RTOS jest kłopotliwe, często frustrujące i absolutnie oświecające. Od ścigania niewłaściwego priorytetu PendSV po walkę z nadpływami stackowymi, każdy błąd zmusił mnie do głębszego zrozumienia wnętrz Cortex-M4. Jeśli spróbujesz tego na własnym TM4C lub jakiejkolwiek innej części Cortex-M, spodziewaj się kilku nocy debugowania - ale także głębokiej satysfakcji, gdy wreszcie pojawi się ten pierwszy niezawodny błysk LED.
Jeśli dajesz to zdjęcie, daj mi znać, która część popchnęła cię do ściany.Chciałbym usłyszeć twoje własne historie "LED blinking" lub jakiekolwiek inne sztuczki, które odkryłeś podczas ścigania duchów w harmonogramie.