Priority Grouping NVIC su STM32: Perché la prelazione degli interrupt non funziona come pensi

2026-05-30 · Davide Carrese
STM32 · NVIC · Firmware Architecture
Commenti

La priorità degli interrupt sul NVIC ARM Cortex-M sembra semplice finché un interrupt USART con un valore di priorità numericamente più basso non preempta un task a priorità più alta e rompe il tuo sistema real-time. La causa non è quasi mai un numero sbagliato nel campo priorità. È una misunderstanding di come il campo PRIGROUP in SCB->AIRCR divide il registro di priorità in preempt priority e subpriority, combinato col fatto che solo 4 bit sono implementati nella maggior parte degli STM32. Questo articolo spiega la codifica e ti dà la sequenza esatta per configurarla correttamente.

Il registro di priorità: solo 4 bit, non 8

Ogni interrupt ARM Cortex-M—da SysTick all'IRQ periferico col numero più alto—ha un registro di priorità a 8 bit associato nel NVIC. L'architettura Cortex-M3/M4/M7 supporta 8 bit di priorità, ma le implementazioni siliconiche sono libere di usarne meno. Su STM32F0, F1, F3, F4, G0, G4, L0, L4 e la maggior parte delle famiglie mainstream, solo i 4 bit superiori di ogni registro di priorità sono implementati. I 4 bit inferiori si leggono come zero e le scritture vengono ignorate. Su STM32F7, H7 e U5 vale lo stesso salvo diversa indicazione nel manuale di riferimento.

Questo significa che su un tipico STM32F401 o STM32F411, una priorità interrupt di 0 è la più alta, 15 è il valore più basso utilizzabile, e qualsiasi valore sopra 15 si allinea al valore equivalente a 4 bit. CubeMX e l'HAL non nascondono questo: HAL_NVIC_SetPriority(IRQn, 0, 0) produce registro di priorità 0x00, mentre HAL_NVIC_SetPriority(IRQn, 5, 0) produce 0x50 se la preempt priority è posizionata nel nibble alto. Quel posizionamento è controllato da PRIGROUP.

PRIGROUP: cosa fa realmente il registro

Il campo PRIGROUP vive nei bit [10:8] dell'Application Interrupt and Reset Control Register (SCB->AIRCR), all'indirizzo 0xE000ED0C. Scriverci richiede la chiave VECTKEY (0x05FA nei 16 bit superiori), altrimenti la scrittura viene ignorata. Questa è la ragione singola più comune per cui una modifica a PRIGROUP sembra non avere effetto nella vista del debugger: mancava VECTKEY.

La funzione CMSIS __NVIC_SetPriorityGrouping() gestisce correttamente la sequenza di sblocco. Il parametro è un valore da 0 a 7, ma la mappatura alla suddivisione effettiva preempt/subpriority non è intuitiva ed è dove la maggior parte degli ingegneri si perde. Ecco la tabella completa per un STM32F4 dove __NVIC_PRIO_BITS = 4:

PRIGROUP (param)  | Preempt bits  | Subpriority bits  | Livelli preempt disponibili
        0          |      0        |        4          |     1 (tutti uguali)
        1          |      1        |        3          |     2
        2          |      2        |        2          |     4
        3          |      3        |        1          |     8
        4          |      4        |        0          |    16
        5          |      4        |        0          |    16  (come 4)
        6          |      4        |        0          |    16  (come 4)
        7          |      4        |        0          |    16  (come 4)

La formula è: preempt_bits = min(PRIO_BITS, 7 - PRIGROUP). Per PRIGROUP 4 e superiori su una parte a 4 bit, tutti i 4 bit sono preempt e non c'è subpriority. Per PRIGROUP 3, ottieni 3 bit preempt e 1 bit subpriority.

Qui è dove l'HAL aggiunge il suo strato di confusione. La macro HAL NVIC_PriorityGroup_4 non significa PRIGROUP = 4. Significa 4 bit di preempt priority, che si mappa a PRIGROUP = 3 (per parti a 4 bit) o PRIGROUP = 4 (per parti a 8 bit). L'enumerazione HAL è:

NVIC_PriorityGroup_0 -> PRIGROUP 7  (0 bit preempt, 4 bit subpriority)
NVIC_PriorityGroup_1 -> PRIGROUP 6  (1 bit preempt,  3 bit subpriority)
NVIC_PriorityGroup_2 -> PRIGROUP 5  (2 bit preempt, 2 bit subpriority)
NVIC_PriorityGroup_3 -> PRIGROUP 4  (3 bit preempt, 1 bit subpriority)
NVIC_PriorityGroup_4 -> PRIGROUP 3  (4 bit preempt, 0 bit subpriority)

Se chiami HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4) e poi leggi SCB->AIRCR, vedrai PRIGROUP = 3, non 4. È corretto. L'HAL astrae il valore hardware di PRIGROUP in modo che l'argomento significhi sempre "numero di bit di preempt priority."

Come NVIC_SetPriority interpreta il valore di priorità

Quando chiami HAL_NVIC_SetPriority(IRQn, PreemptPriority, SubPriority), l'HAL combina i due valori in un singolo campo a 8 bit usando il priority group correntemente configurato. La codifica funziona così: la preempt priority viene shiftata a sinistra del numero di bit di subpriority, poi la subpriority viene ORata nei bit inferiori. Il risultato viene memorizzato nel registro di priorità NVIC.

Per PRIGROUP 3 (NVIC_PriorityGroup_4 su una parte a 4 bit), ci sono 4 bit preempt e 0 bit subpriority, quindi il valore è semplicemente la preempt priority shiftata nel nibble alto. HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0) scrive 0x20 nel registro di priorità, il che significa che il valore di priorità dell'interrupt è 2 nella logica di confronto NVIC.

Per PRIGROUP 4 (NVIC_PriorityGroup_3), ci sono 3 bit preempt e 1 bit subpriority. HAL_NVIC_SetPriority(TIM2_IRQn, 2, 1) codifica come: preempt=2 (binario 010), subpriority=1 (binario 1). Il preempt va nei 3 bit superiori del nibble implementato, la subpriority va nel bit 0. Risultato: 0b0101 = 0x50 nel registro. Il NVIC confronta solo la porzione preempt per le decisioni di prelazione.

La regola di prelazione che gli ingegneri dimenticano

Ecco la regola specifica che causa la maggior parte dei bug sul campo: il NVIC non preempta in base al valore numerico grezzo del registro di priorità. Confronta solo la porzione di preempt priority. Se due interrupt hanno la stessa preempt priority, un interrupt pendente con subpriority più alta non preempta l'handler in esecuzione. Il secondo interrupt deve aspettare che il primo handler finisca, poi il NVIC fa tail-chain.

Questo significa che se configuri PRIGROUP = 3 (NVIC_PriorityGroup_4) e imposti TIM2_IRQn a preempt 2 e USART1_IRQn a preempt 5, USART1 non preempterà mai TIM2. Questo è il comportamento atteso. Ma ecco lo scenario insidioso: se configuri PRIGROUP = 4 (NVIC_PriorityGroup_3) e imposti TIM2_IRQn a preempt 2, subpriority 0 e USART1_IRQn a preempt 2, subpriority 1, USART1 non preempterà TIM2 anche se la sua subpriority è più alta. I due interrupt sono allo stesso livello preempt; si serializzano. Se hai effettivamente bisogno che USART1 interrompa TIM2, devono avere valori di preempt priority diversi.

/* SBAGLIATO: entrambi a preempt 2, USART1 NON preempterà TIM2 */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_3);
HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0);
HAL_NVIC_SetPriority(USART1_IRQn, 2, 1);

/* CORRETTO: TIM2 a preempt 2, USART1 a preempt 1 (urgenza maggiore) */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4);
HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0);
HAL_NVIC_SetPriority(USART1_IRQn, 1, 0);

SysTick e PendSV: la trappola di FreeRTOS

Il porting FreeRTOS per Cortex-M assegna la priorità più bassa possibile a PendSV e SysTick, tipicamente priorità 15 su una parte a 4 bit. Questo garantisce che nessun interrupt periferico possa essere bloccato dal tick RTOS o dal context switch. Il port si aspetta che SysTick e PendSV condividano la stessa preempt priority e che nessuna distinzione di subpriority possa ritardare PendSV.

Se configuri PRIGROUP a qualcosa di diverso dal preempt completo a 4 bit (NVIC_PriorityGroup_4), SysTick e PendSV avranno bit di subpriority che frammentano il confronto. Se PendSV è a preempt 15, subpriority 0 e SysTick è a preempt 15, subpriority 1, PendSV funziona ancora perché la porzione preempt è la stessa—ma stai sprecando un livello di subpriority senza beneficio. Peggio, se assegni accidentalmente un interrupt periferico allo stesso livello preempt di PendSV ma con una subpriority più alta, la periferica può ritardare il context switch anche se non dovrebbe.

La soluzione è semplice: usa sempre NVIC_PriorityGroup_4 quando esegui FreeRTOS su una parte STM32 a 4 bit. Questo dà 16 livelli preempt distinti e zero bit di subpriority, esattamente ciò che il port FreeRTOS si aspetta.

/* Chiamato una volta all'avvio, prima di qualsiasi HAL_NVIC_SetPriority */
HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4);

/* La configurazione FreeRTOS per Cortex-M si aspetta questa config */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY    5
/* Gli interrupt con priorità >= 5 sono mascherati dentro le sezioni critiche.
   Gli interrupt con priorità 0-4 possono preemptare le sezioni critiche. */

Esempio pratico: un sistema sensoriale a criticità mista

Considera un prodotto con STM32F401 che legge un giroscopio SPI ad alta velocità a 10 kHz, comunica con un sensore via I2C a 100 kHz, pubblica risultati via USB CDC a 1 MHz e gestisce un interrupt da pulsante per uno spegnimento di sicurezza. Le priorità devono essere:

void SystemClock_Config(void); /* configurazione RCC */
void MX_GPIO_Init(void);

void InterruptPriorities_Init(void)
{
    /* 4 bit preempt, 0 subpriority -- port FreeRTOS pulito */
    HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4);

    /* SPI1: DMA giroscopio -- urgenza massima */
    HAL_NVIC_SetPriority(SPI1_IRQn, 0, 0);
    HAL_NVIC_SetPriority(DMA2_Stream0_IRQn, 0, 0);

    /* EXTI0: pulsante sicurezza */
    HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);

    /* USB CDC */
    HAL_NVIC_SetPriority(OTG_FS_IRQn, 2, 0);

    /* I2C1: polling sensore */
    HAL_NVIC_SetPriority(I2C1_EV_IRQn, 3, 0);
    HAL_NVIC_SetPriority(I2C1_ER_IRQn, 3, 0);
}

Nota che gli stream SPI e DMA TX condividono lo stesso livello preempt. Se il DMA TC SPI e l'interrupt di completamento trasferimento SPI scattano entrambi, si serializzano correttamente perché sono allo stesso livello preempt—non c'è una tempesta di interrupt SPI annidati.

Verificare PRIGROUP a runtime

Il singolo passo di debug più utile quando il comportamento degli interrupt sembra sbagliato è leggere il valore effettivo di PRIGROUP e il registro di priorità dei due IRQ che non si preemptano come previsto.

uint32_t aircr = SCB->AIRCR;
uint32_t prigroup = (aircr >> 8) & 0x07;
printf("PRIGROUP: %lu (0x%lx)\n", prigroup, aircr);

/* Legge la priorità di un interrupt NVIC specifico */
uint32_t prio_spi = NVIC_GetPriority(SPI1_IRQn);
uint32_t prio_usart = NVIC_GetPriority(USART1_IRQn);
printf("Registro priorità SPI1: 0x%02lx\n", prio_spi);
printf("Registro priorità USART1: 0x%02lx\n", prio_usart);

Se PRIGROUP è 0, allora tutti gli interrupt condividono lo stesso livello preempt indipendentemente da ciò che hai scritto nei loro registri di priorità. Non avviene nessuna prelazione. Il sistema serializza tutti gli handler degli interrupt. Questo è il valore di reset predefinito su alcune parti STM32 ed è il bug di produzione più comune che vedo in codebase che saltano completamente la configurazione del priority group.

Porting tra famiglie STM32: cambiamenti di PRIO_BITS

Quando sposti firmware da un STM32F4 (4 bit) a un STM32H730 (4 bit sul NVIC, ma con bit di priorità aggiuntivi configurabili attraverso il System Control Block), la codifica PRIGROUP si allarga. Su un STM32U5 o STM32H7, __NVIC_PRIO_BITS è ancora 4 per il NVIC standard a meno che la parte non sia configurata per priorità a 8 bit via registro SCB->CCR (l'H7 lo permette). Se passi a una parte a 8 bit, PRIGROUP 4 dà 4 bit preempt e 4 bit subpriority invece di 4 preempt e 0 subpriority, e la stessa macro NVIC_PriorityGroup_4 produce un comportamento degli interrupt completamente diverso.

L'HAL nasconde questo finché usi le macro, perché NVIC_PriorityGroup_4 significa "4 bit di preempt priority" indipendentemente dal PRIGROUP sottostante. Ma il conteggio della subpriority cambia silenziosamente. Se il tuo codebase ha sezioni platform-specific che scrivono PRIGROUP direttamente (SCB->AIRCR = 0x05FA0300), devi revisionarle quando porti tra famiglie perché PRIGROUP 3 su una parte a 4 bit non è uguale a PRIGROUP 3 su una parte a 8 bit.

Checklist pratica

Come lo affronterei su un progetto cliente

Il primo giorno di un nuovo progetto firmware, prima di scrivere qualsiasi codice di init delle periferiche, scrivo una singola funzione InterruptPriorities_Init() che chiama HAL_NVIC_SetPriorityGrouping(NVIC_PriorityGroup_4) e poi assegna priorità preempt a ogni interrupt che il sistema utilizzerà. Tengo questa funzione nel suo file sorgente (interrupts.c) con un commento tabellare che elenca ogni IRQ e il suo livello preempt, la motivazione e la priorità mascherata di FreeRTOS. Questo diventa il documento di riferimento per ogni sviluppatore sul progetto.

Durante il test di integrazione, strumento ogni ISR non banale con un toggle GPIO su un canale oscilloscopio, incluso un secondo toggle all'inizio e alla fine dell'handler interrupt per misurare la profondità reale di prelazione. Ho trovato configurazioni errate di PRIGROUP in questo modo più di una volta—l'oscilloscopio mostra l'interrupt A in esecuzione dentro l'interrupt B anche se A doveva essere a priorità più bassa, perché PRIGROUP era impostato a 0 e tutti gli interrupt erano allo stesso livello preempt.

In code review, la prima cosa che cerco in un progetto bare-metal non FreeRTOS è se il priority group è stato impostato. La seconda cosa è se ci sono scritture dirette a SCB->AIRCR che bypassano la sequenza VECTKEY. Questi due controlli individuano circa l'80% dei bug di priorità NVIC che vedo nel codice ereditato.

Fonti consultate

Commenti

Hai una storia vera di priorità NVIC in cui un valore PRIGROUP di default ha rovinato una prova sul campo? Scrivimi una nota breve via email.