Adding a third interrupt source to this board is where I stopped trusting the priority numbers in our project. GPIO, ADC and FlexPWM all reach the CPU through the same NVIC, all get a 4-bit priority, and all look interchangeable in the Config Tool.

They are not. FreeRTOS draws a hard line through that priority range, and which side of it an ISR lands on decides whether it is allowed to call a FreeRTOS API at all. Get it wrong and nothing fails immediately — you get an assert under load, or worse, silent scheduler corruption months later.

So the useful exercise is not “how do I set up a GPIO interrupt”. It is “what is actually in my NVIC table, and is every entry on the right side of the line”.

In this article I show the configuration pattern the three sources share, the two places they genuinely differ, and how to audit a real NVIC table against the FreeRTOS barrier.

MCUXpresso Config Tools NVIC interrupt overview: GPIO1_INT3 priority 4, GPIO1_INT5 priority 5, ADC1 priority 3, LPSPI3 priority 5, GPIO10 priority 5, PWM2_3 priority 10, GPIO3 priority 4

A real NVIC table from this project. Three entries sit at priority 3 and 4 — above the FreeRTOS syscall barrier. That is the audit.

TL;DR

All three sources follow the same NVIC pattern: clock, peripheral config, clear the flag, enable the peripheral interrupt, set NVIC priority, enable the IRQ. They differ in where the trigger lives and how the flag clears — GPIO and FlexPWM need an explicit clear, the ADC clears on result read. The number that actually matters is priority: at or above configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY numerically, you may call *FromISR APIs. Below it, you may not — ever.

The barrier

The RT1062 routes all peripheral interrupts through the Cortex-M7 NVIC. Priority is a 4-bit field, 0 = highest, values 0–15. FreeRTOS reserves the top band for its own kernel tick and scheduler:

configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5

  priority 0-4   above the barrier  -> hard-RT only. Calling ANY FreeRTOS API
                                       from here is undefined behaviour.
  priority 5-15  below the barrier  -> xTaskNotifyFromISR(), xQueueSendFromISR()
                                       and friends are safe.

Note the inversion that trips people up: numerically lower means higher priority means less allowed. An ISR at priority 3 is more urgent and more restricted than one at 10.

Audit your own table before reading further

In the NVIC table above, ADC1_IRQn is at priority 3, and GPIO1_INT3_IRQn and GPIO3_Combined_16_31_IRQn are at 4. All three are above the barrier. Those handlers — AN_IN_INT_IrqHandler, SPE_INT_IrqHandler, DI_IN_INT_IrqHandlermust not call any FreeRTOS API, not xTaskNotifyFromISR, not xQueueSendFromISR, nothing. If they need to hand data to a task, it has to be a volatile variable plus a memory barrier, picked up by a lower-priority ISR or a polling task.

PWM2_3_IRQn at priority 10 is comfortably below the barrier and can post freely. That is the one this project’s context-switching walkthrough traces end to end.

Go read your own NVIC table before you add the next interrupt. The Config Tool will not warn you.

The pattern all three share

Every RT1062 peripheral pin goes through two layers before an interrupt can fire:

  1. IOMUXC — selects the alternate function and the electrical pad settings. The pad fields are worth understanding properly; I wrote them up in pin config: pull/keeper, drive strength and slew rate.
  2. Peripheral clock gate — must be enabled before touching any peripheral register.

The Config Tool generates both into pins.c and clock_config.c. By hand:

/* GPIO1_IO04 as a GPIO input with interrupt */
IOMUXC_SetPinMux(IOMUXC_GPIO_AD_B0_04_GPIO1_IO04, 0U);
IOMUXC_SetPinConfig(IOMUXC_GPIO_AD_B0_04_GPIO1_IO04,
    IOMUXC_SW_PAD_CTL_PAD_HYS(1)   |   /* hysteresis — mechanical input   */
    IOMUXC_SW_PAD_CTL_PAD_PUS(1)   |   /* 47k pull-up                     */
    IOMUXC_SW_PAD_CTL_PAD_PUE(1)   |
    IOMUXC_SW_PAD_CTL_PAD_PKE(1));

MCUXpresso Config Tools routing table showing GPIO3 inputs configured for falling/rising edge with hysteresis enabled and 100K pull-up

The same settings as generated output. Hysteresis is enabled on the digital inputs and disabled on the analogue ones — correct, and worth checking rather than assuming.

From there the sequence is identical for all three peripherals:

  1. Enable the peripheral clock.
  2. Configure the peripheral.
  3. Clear the pending flag — before enabling, or you take a spurious first interrupt.
  4. Enable the interrupt inside the peripheral.
  5. Set the NVIC priority — before EnableIRQ().
  6. EnableIRQ().

GPIO

Interrupt detection lives inside the GPIO block (GPIO1–GPIO9). Each pin can be configured for rising edge, falling edge, both edges, high level or low level.

CLOCK_EnableClock(kCLOCK_Gpio1);

gpio_pin_config_t cfg = { kGPIO_DigitalInput, 0, kGPIO_IntRisingEdge };
GPIO_PinInit(GPIO1, 4U, &cfg);

GPIO_ClearPinsInterruptFlags(GPIO1, 1U << 4U);   /* before enabling */
GPIO_EnableInterrupts(GPIO1, 1U << 4U);

NVIC_SetPriority(GPIO1_Combined_0_15_IRQn, 6U);  /* >= MAX_SYSCALL */
EnableIRQ(GPIO1_Combined_0_15_IRQn);
void GPIO1_Combined_0_15_IRQHandler(void)
{
    uint32_t flags = GPIO_GetPinsInterruptFlags(GPIO1);

    if (flags & (1U << 4U))
    {
        GPIO_ClearPinsInterruptFlags(GPIO1, 1U << 4U);

        BaseType_t xHigherPriorityTaskWoken = pdFALSE;
        xTaskNotifyFromISR(xTargetTask, 0, eNoAction, &xHigherPriorityTaskWoken);
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}

One vector, sixteen pins

GPIO1 pins 0–15 all share GPIO1_Combined_0_15_IRQn. The handler must read the flag register and work out which pin fired — and clear only that pin’s flag.

Separately: GPIO1 and GPIO6 reach the same physical pads. Standard GPIO (1–5) goes through the AHB bus; fast GPIO (6–9) maps to the TCM crossbar. If you need sub-microsecond response, use the fast alias.

ADC

The RT1062 has two 12-bit SAR ADCs. A conversion-complete interrupt fires when the result register is ready, and the flag clears when you read the result — no explicit clear.

CLOCK_EnableClock(kCLOCK_Adc1);

adc_config_t adcCfg;
ADC_GetDefaultConfig(&adcCfg);
adcCfg.resolution = kADC_Resolution12Bit;
ADC_Init(ADC1, &adcCfg);

ADC_DoAutoCalibration(ADC1);          /* mandatory after power-on reset */

adc_channel_config_t chCfg = {
    .channelNumber                        = 0U,   /* ADC1_IN0 */
    .enableInterruptOnConversionCompleted = true,
};
ADC_SetChannelConfig(ADC1, 0U, &chCfg);  /* also kicks off the first conversion */

NVIC_SetPriority(ADC1_IRQn, 6U);
EnableIRQ(ADC1_IRQn);
void ADC1_IRQHandler(void)
{
    /* Reading the result register clears the interrupt flag. */
    uint32_t result = ADC_GetChannelConversionValue(ADC1, 0U);

    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xQueueSendFromISR(xAdcQueue, &result, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

Two things to watch. ADC_DoAutoCalibration() is not optional — skip it and your readings carry an offset you will spend an afternoon chasing. And in continuous-conversion mode the ISR fires at the sample rate: a 16-cycle conversion on a 1 MHz ADC clock is roughly a 62 kHz interrupt rate, which will starve the scheduler. Above a few kHz, use DMA.

Note that the ISR above assumes ADC1_IRQn sits below the barrier. In the real table at the top of this article it is at priority 3, so that version of the handler would be wrong for this project.

FlexPWM

Four modules (PWM1–PWM4), each with four submodules (SM0–SM3). Each submodule has its own compare registers and can interrupt on compare match, reload, or a hardware fault input.

CLOCK_EnableClock(kCLOCK_Pwm1);

pwm_config_t pwmCfg;
PWM_GetDefaultConfig(&pwmCfg);
pwmCfg.reloadLogic = kPWM_ReloadPwmFullCycle;
pwmCfg.clockSource = kPWM_BusClock;
pwmCfg.prescale    = kPWM_Prescale_Divide_4;
PWM_Init(PWM1, kPWM_Module_0, &pwmCfg);

pwm_signal_param_t signal = {
    .pwmChannel       = kPWM_PwmA,
    .dutyCyclePercent = 50U,
    .level            = kPWM_HighTrue,
};
PWM_SetupPwm(PWM1, kPWM_Module_0, &signal, 1U, kPWM_EdgeAligned,
             20000U, CLOCK_GetFreq(kCLOCK_IpgClk));

PWM_EnableInterrupts(PWM1, kPWM_Module_0, kPWM_CompareVal0InterruptEnable);

NVIC_SetPriority(PWM1_0_IRQn, 6U);
EnableIRQ(PWM1_0_IRQn);

PWM_StartTimer(PWM1, kPWM_Control_Module_0);
void PWM1_0_IRQHandler(void)
{
    uint16_t flags = PWM_GetStatusFlags(PWM1, kPWM_Module_0);

    if (flags & kPWM_CompareVal0Flag)
    {
        PWM_ClearStatusFlags(PWM1, kPWM_Module_0, kPWM_CompareVal0Flag);

        BaseType_t xHigherPriorityTaskWoken = pdFALSE;
        xTaskNotifyFromISR(xPwmTask, PWM_COMPARE_EVENT, eSetBits, &xHigherPriorityTaskWoken);
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}

Fault inputs are level-sensitive by default

If a FlexPWM fault line stays asserted, the ISR re-fires immediately on exit — a tight loop that locks out everything below it. Qualify the fault condition, or use one-shot fault mode (kPWM_FaultInputFilter plus kPWM_Automatic).

Related trap: a FlexPWM submodule has one counter shared across its channels and its capture logic. Committing a channel to something like a backlight can block a later input-capture feature entirely — that is exactly what happened on the LCD backlight.

Where they actually differ

AspectGPIOADCFlexPWM
Trigger sourceExternal pin stateInternal conversion doneInternal counter / compare
Flag clearGPIO_ClearPinsInterruptFlags()Automatic on result readPWM_ClearStatusFlags()
Shared IRQ?Yes — GPIO1 pins 0–15 share one vectorNo — ADC1 has its ownNo — each submodule has its own
DMA alternativeFast GPIO can capture via DMAYes, and required above a few kHzYes, for waveform playback
Typical useButton, encoder, digital I/O edgeSensor samplingTiming events, fault detection

Assigning priorities

A workable default set, assuming MAX_SYSCALL = 5:

InterruptPriorityReason
GPIO, hard-RT (encoder counting)3No FreeRTOS call — volatile + barrier only
FlexPWM fault5Time-critical, minimal handler
Ethernet DMA5Network hard-RT
GPIO, user button6Calls xTaskNotifyFromISR
FlexPWM compare6Calls xTaskNotifyFromISR
ADC complete7Calls xQueueSendFromISR
SysTick / PendSV15FreeRTOS-managed, lowest by design

Compare that against the real table in the first figure and you can see the gap: this project runs ADC1 at 3 rather than 7. That is a legitimate choice if the handler is genuinely hard-RT and touches no FreeRTOS API. It is a latent bug if it is not. The table cannot tell you which — only reading the handler can.

The FreeRTOS rules that are not negotiable

  1. Always end a posting ISR with portYIELD_FROM_ISR(xHigherPriorityTaskWoken). Without it the woken task stays READY until the next tick — up to 1 ms of latency you did not budget for. The mechanics of why are in the context-switching walkthrough.
  2. Never call vTaskDelay() or any blocking API from an ISR. It asserts, or corrupts the scheduler.
  3. portEND_SWITCHING_ISR and portYIELD_FROM_ISR resolve to the same thing on the ARM_CM7 port. portYIELD_FROM_ISR is the current canonical form.
  4. The ISR stack is separate from task stacks. Cortex-M7 switches to MSP on exception entry. Size it for the worst nesting case — Ethernet and PWM firing together stacks deeper than you would guess.

Checklist

  • IOMUXC pin mux and pad settings configured before peripheral init
  • Peripheral clock enabled
  • Interrupt flag cleared before enabling the IRQ
  • NVIC priority set before EnableIRQ()
  • Priority ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY if the handler calls any FreeRTOS API
  • Every handler above the barrier audited for FreeRTOS calls
  • portYIELD_FROM_ISR() at the exit of every posting ISR
  • Handler name matches the vector table entry in startup_MIMXRT1062.S

Summary

The setup sequence is the boring part and it is the same for all three peripherals. The part worth your attention is the priority column, because it is the only field where a wrong value produces no error at configuration time and an intermittent failure under load.

What works now: the pattern above is what this project uses. What is still open on my side: auditing the three handlers that sit above the barrier — AN_IN_INT_IrqHandler, SPE_INT_IrqHandler and DI_IN_INT_IrqHandler — to confirm none of them reaches for a FreeRTOS API. Open your own NVIC table and do the same; it takes ten minutes.


💬 Questions, corrections, or your own war story with NVIC priorities? Leave a comment below — I read and answer all of them.