Todavía recuerdo mirar a mis LEDs de TM4C123GH6PM una noche, esperando un simple flash de “corazón” que nunca llegó. Pensé que había hecho todo bien: configurado SysTick, pendiente PendSV, y inicializado mis Blocks de control de hilo (TCBs). Pero en lugar de un flash constante, los LEDs se golpearon una vez, y luego se congelaron, burlándome. Ese momento cristalizó lo que realmente significa construir un pequeño RTOS: luchar con los trucos del hardware, perseguir errores evasivos y recortar el código suficiente para hacer que todo funcione sin problemas. En este artículo, te guiaré a través de mi viaje de diseñar un programador de prioridad mínima en un ARM Cortex-M4 —incluidos los errores, los hacks extraños y los momentos
Un poco de backstory
Hace aproximadamente un año, mi clase de sistemas incorporados nos asignó construir un RTOS simple desde cero en un TM4C123GH6PM (ARM Cortex-M4). Yo había estado usando FreeRTOS antes, pero nunca entendí completamente lo que estaba sucediendo detrás de las escenas.
Why?
- Quería ver exactamente cómo la CPU cambia de un hilo a otro.
- Necesitaba aprender por qué las tareas de baja prioridad pueden inadvertidamente matar a las de mayor prioridad (hijo, inversión de prioridad).
- Me apetecía contar historias reales de depuración, como el tiempo que pasé medio día preguntándome por qué PendSV nunca funcionó (resulta que le había dado la misma prioridad que a SysTick).
Spoiler: Mi RTOS no era perfecto, pero al perseguir sus defectos, aprendí más sobre los internos de ARM que de cualquier libro de texto.
Descubra el mundo del Cortex-M4
Antes de escribir una sola línea de código, tuve que envolver mi cabeza en torno a cómo Cortex-M4 maneja las interrupciones y el cambio de contexto.
- 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.
Instalación del bloque de control de hilos (TCB)
Cada hilo en mi 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;
En la práctica, he declarado:
#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 historia de horror de Stack-Overflow
Cuando primero asignamosSTACK_WORDS = 128
, todo parecía bien - hasta que mi hilo "trabajador", que hizo unas cuantas llamadas de función envueltas, comenzó a corromper la memoria.0xDEADBEEF
En el inicio y comprobando hasta dónde se sobreescribió, descubrí que 128 palabras no eran suficientes bajo las banderas de construcción optimizadas.
Creando un nuevo hilo
Crear un hilo significaba esculpir su pila y simular la acumulación de hardware que ocurre en la entrada de excepción.Aquí está la rutina, con comentarios sobre mis primeras trampa:
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 para vigilar
- Bit de pulgar en xPSR. Perderlo significa que su CPU intentará interpretar el código como instrucciones de ARM - error inmediato.
- El Magic 0xFFFFFFFD. Esto le dice a la CPU: "En la excepción de regreso, use PSP y vaya al modo de hilo." Recuerdo buscar el ARM ARM (Architecture Reference Manual) por lo menos tres veces para obtener esto.
- Pushing R4–R11 manualmente debe seguir el orden exacto que el manipulador espera. Un simple tipo (por ejemplo, presionar R11 primero en lugar de R4) arroja todo el marco.
Deja pasar el tiempo: SysTick Handler
Aquí está mi último SysTick ISR, recortado a lo esencial:
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 par de notas:
- En una versión temprana, intenté llamar rtos_schedule() justo dentro de SysTick. Esto llevó a interrupciones y confusión de pilas.
- Descubrí que si SysTick_IRQn y PendSV_IRQn comparten la misma prioridad, PendSV a veces nunca se ejecuta. Siempre dé a PendSV la prioridad numérica absoluta más baja (es decir, NVIC_SetPriority(PendSV_IRQn, 0xFF)), y mantenga SysTick ligeramente más alto (por ejemplo, 2 o 3).
El gran interruptor: PendSV Handler
Cuando PendSV finalmente se apaga, hace el cambio de contexto real. Mi implementación en la asamblea en línea de GCC (sintaxis ARM) se ve así:
__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
);
}
Cómo he rastreado esa compensación de 32 bytes pesada
Al principio, mi código de "salvar / restaurar" estaba desactivado por 32 bytes. ¿El síntoma? los hilos se ejecutaban, luego de alguna manera volvían a la vida en el lugar equivocado - instrucciones enrolladas, saltos aleatorios.STMDB
) para medir exactamente cuántos bytes fueron empujados. En mi debugger, luego comparé el valor numérico de PSP con eltcbs[].stack_ptr
Yo esperaba. bastante seguro, yo habría usado accidentalmenteSTMDB R0!, {R4-R11, R12}
En lugar de{R4-R11}
Eliminar ese registro extra empujado lo resolvió.
Seleccionar el siguiente hilo: Lógica del planificador
Mi planificador es intencionalmente simple. Escansa todos los TCBs para encontrar el hilo READY de mayor prioridad, con un pequeño ajuste de rotonda para los hilos de igual prioridad:
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:
- Si dos hilos comparten prioridad 1, pero siempre elijo el ID inferior, el otro nunca obtendría tiempo de CPU. Al comprobar i > current_thread, el "twin" finalmente obtiene su turno.
- El hilo vacío (ID 0) es un caso especial: siempre READY, siempre la prioridad más baja (prioridad = 255), de modo que si nada más es ejecutable, simplemente gira (o llama __WFI() para ahorrar energía).
Primitivos del núcleo: rendimiento, sueño y semáforos
Después de trazar los principios básicos, el siguiente paso era hacer que los hilos interactúen correctamente.
ganancias
void rtos_yield(void) {
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
Me gusta el sprinklerrtos_yield()
en el final de los largos circuitos para que otros hilos obtengan un tiro justo. en las primeras pruebas, omitiryield()
significaba que algunas tareas hacían caer la CPU bajo ciertas configuraciones de prioridad.
dormido
void rtos_sleep(uint32_t ticks) {
tcb_t *self = &tcbs[current_thread];
self->sleep_ticks = ticks;
self->state = SLEEPING;
rtos_yield();
}
Mi “LED Blinking” Thread Callsrtos_sleep(500)
Cuando veo que el relámpago se mueve cada medio segundo, sé que SysTick y PendSV están haciendo su trabajo correctamente.
Semáforos
Al principio, intenté un enfoque 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();
}
Inversión de prioridad Pesadilla
Un día tuve tres cabezas:
- T0 (Prioridad 2): mantiene el semáforo.
- T1 (prioridad 1): esperando ese semáforo.
- T2 (Prioridad 3): Listo para correr y superior a T0 pero inferior a T1.
Debido a que T1 estaba bloqueado, T2 continuó corriendo, nunca dándole a T0 una oportunidad de liberar el semáforo para T1. T1 se desmayó. Mi hack rápido era aumentar temporalmente la prioridad de T0 cuando T1 estaba bloqueado, es una herencia de prioridad rudimentaria. Una solución completa rastrearía qué hilo mantiene el semáforo y levantaría automáticamente su prioridad.
Quitarse todo:rtos_entrenamiento()
rtos_entrenamiento()
enmain()
, después de la init de hardware básica (relojes, GPIO para LEDs, UART para la cascada), hice:
// 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 par de notas finales:
- Idle Thread (ID 0): Simplemente cambia un LED a baja prioridad. Si algo va mal, sé que al menos he llegado al hilo idle.
- Prioridad de interrupción de UART: la interrupción TX de UART necesitaba una prioridad más alta que PendSV; de lo contrario, las llamadas de printf largas se interrumpirían en el medio de la transmisión, interrumpiendo la salida.
Título original: Peeking Under the Hood
Construí una pequeña carcasa para que pudiese escribir comandos sobre UART e inspeccionar los estados de hilo:
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");
}
}
}
Esta carcasa era mi red de seguridad: si los LEDs se comportaban extrañamente, saltaría a mi terminal en serie, escribiendops
, y inmediatamente ver cuáles eran los hilos PREPARADOS, BLOCADOS o DORMIDOS. me salvó horas de adivinación.
Lecciones aprendidas a lo largo del camino
- Las prioridades de interrupción son todo lo que pasé una tarde entera convencido de que mi código PendSV estaba equivocado – hasta que me di cuenta de que había fijado SysTick y PendSV en la misma prioridad. Una vez que di a SysTick una prioridad más alta, PendSV comenzó a disparar de manera confiable. lección: comprobe las llamadas de NVIC_SetPriority() de forma doble y triple.
- Medir su uso de la pila Pre-enchufar la pila de cada hilo con un patrón conocido (0xDEADBEEF) en el inicio. Después de ejecutar, inspeccione la memoria para ver cuán profundo se ha sobreescrito el patrón. Si su indicador de pila entra en ese patrón, usted sabe que necesita una pila más grande. Lo aprendí de la manera más difícil cuando una cadena de llamadas más profunda en mi hilo de trabajador causó una sobreescritura silenciosa.
- Utilice LEDs & GPIO para debugar Si no tiene un debugador fantástico, simplemente cambie un pin GPIO (conexiéndolo a un LED).Pusí un conector de LED al principio de PendSV_Handler y otro al final.
- Mi primera versión de RTOS trató de soportar la creación de tareas dinámicas en el tiempo de ejecución – un error masivo. Acabé con fragmentación de la memoria y extraños accidentes. Al congelar el número de hilos en la puesta en marcha (sólo nueve ranuras en mi caso), evité un mundo de dolor.
- ¿Documento Cada Número Mágico Que 0xFFFFFFFD valor en el marco de la pila “fake”? Yo escribí una nota corta: “El valor de registro de enlace que indica el retorno al modo de filamento usando PSP.” Sin ese comentario, yo habría Googled “ARM excepción de valor de retorno” cada vez que revisé el código.
Siguiente Entrada siguiente: A dónde ir de aquí
Si usted decide construir sobre esta fundación, aquí hay algunas ideas:
- En lugar de mi rápido "boost-and-forget" hack, implementa un protocolo adecuado donde la prioridad del hilo bloqueado de mayor prioridad se hereda hasta que se libere el semáforo.
- Las filas de mensajes inter-thread permiten que los filamentos envíen mensajes pequeños o señales unos a otros de forma segura.Piensa en ello como una pequeña caja de correo para pasar datos entre tareas.
- Dynamic Task Creation/Deletion Tack en un pequeño gestor de aglomerados o un pool de memoria para que puedas crear y matar los hilos en el vuelo, ¡ten en cuenta la complejidad adicional!
- Extender el manipulador de SysTick (o utilizar otro temporizador) para registrar cuántos pinchazos cada hilo está ejecutando.
- Verificaciones de seguridad en tiempo real Introduce límites de uso de la pila y detecta cuando el puntero de la pila de un hilo cruza la región 0xDEADBEEF, desencadenando un apagado o restablecimiento seguro en lugar de accidentes aleatorios.
Pensamientos finales
Construir un RTOS mínimo es confuso, a menudo frustrante y absolutamente iluminante. Desde perseguir una prioridad de PendSV equivocada hasta luchar con las sobrecargas de pilas, cada error me obligó a comprender los internos de Cortex-M4 más profundamente. Si intenta esto en su propio TM4C o en cualquier otra parte de Cortex-M, espera unas pocas noches de depuración, pero también la profunda satisfacción cuando finalmente aparece ese primer flash de LED confiable.
Si da esto un disparo, por favor, déjame saber qué parte te llevó a la pared. me encantaría escuchar tus propias historias de "LED flashing" o cualquier otro truco que descubraste mientras perseguías a los fantasmas en tu cronista.