GPIO, ADC and FlexPWM interrupts all reach the Cortex-M7 through the same NVIC, all get a priority from 0 to 15, and all look interchangeable in the config tool.

With FreeRTOS they are not. FreeRTOS draws a hard line through that priority range, and the side an interrupt lands on decides whether its handler may call a FreeRTOS API at all. Get it wrong and nothing fails at start-up. You get an assert under load, or silent scheduler corruption months later.

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

In this article I show the setup pattern the three sources share, the two places where they really differ, and how to audit an NVIC table against the FreeRTOS barrier.

The barrier

The mental model: a lower number means more urgent, and more urgent means less allowed.

The i.MX RT1062 uses 4 priority bits, so every interrupt has a priority from 0 (most urgent) to 15 (least urgent). FreeRTOS splits that range at configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY in FreeRTOSConfig.h:

  • Numerically below it (more urgent): the interrupt is never masked by the kernel, so its handler must not call any FreeRTOS API, not even the ...FromISR ones.
  • At or above it (less urgent): the handler may call xTaskNotifyFromISR(), xQueueSendFromISR() and the other ...FromISR functions.

The kernel’s own interrupts, SysTick and PendSV, run at the lowest priority.

The 16 priority levels and the FreeRTOS barrier. The barrier value is an example; yours is in FreeRTOSConfig.h.

Priority 0 is the default

Every NVIC priority resets to 0, the most urgent level, which is above the barrier. An interrupt you enable without calling NVIC_SetPriority() first is therefore not allowed to call FreeRTOS, even if its handler does. With configASSERT() defined, FreeRTOS catches this at run time; without it, it is silent.

A handler above the barrier can still hand data to a task: write a volatile variable, add a memory barrier, and let a task or a lower-priority interrupt pick it up.

The pattern all three share

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

  1. IOMUXC selects the pin function and its electrical pad settings (pull, drive strength, slew rate, hysteresis).
  2. The peripheral clock gate must be on before you touch any peripheral register.

The config tool generates both into pins.c and clock_config.c. By hand, for a GPIO input with a pull-up and hysteresis:

c
/* 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 for a slow or noisy edge */
    IOMUXC_SW_PAD_CTL_PAD_PUS(1)   |   /* 47k pull-up                         */
    IOMUXC_SW_PAD_CTL_PAD_PUE(1)   |
    IOMUXC_SW_PAD_CTL_PAD_PKE(1));

From there the sequence is the same for all three peripherals:

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

GPIO

Edge and level detection live inside the GPIO block. Each pin can trigger on a rising edge, a falling edge, both edges, a high level or a low level.

c
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);  /* at or above the barrier */
EnableIRQ(GPIO1_Combined_0_15_IRQn);
c
void GPIO1_Combined_0_15_IRQHandler(void)
{
    uint32_t flags = GPIO_GetPinsInterruptFlags(GPIO1);

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

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

One vector, many pins

Most GPIO interrupts arrive on a combined vector shared by 16 pins, such as GPIO1_Combined_0_15_IRQn. The handler must read the flag register, work out which pin fired, and clear only that pin’s flag. Clearing all of them loses another pin’s edge.

ADC

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

c
CLOCK_EnableClock(kCLOCK_Adc1);

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

ADC_DoAutoCalibration(ADC1);          /* calibrate after reset */

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

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

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

Two things to watch. Run the calibration after reset; without it the readings carry an offset. And in continuous-conversion mode the interrupt fires once per sample, so at high sample rates it takes over the CPU. Use DMA there instead.

FlexPWM

The RT1062 has four FlexPWM modules, each with four submodules. Each submodule has its own counter and compare registers, and can interrupt on a compare match or a reload. Fault inputs have a vector of their own.

c
CLOCK_EnableClock(kCLOCK_Pwm1);

pwm_config_t pwmCfg;
PWM_GetDefaultConfig(&pwmCfg);
pwmCfg.reloadLogic = kPWM_ReloadPwmFullCycle;
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);
c
void PWM1_0_IRQHandler(void)
{
    uint32_t flags = PWM_GetStatusFlags(PWM1, kPWM_Module_0);

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

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

A fault that is still there fires again

If the cause of a fault interrupt is still present when the handler exits, the interrupt can fire again straight away, and a high-priority loop like that locks out everything below it. In the handler, put the outputs into a safe state, clear the flag, and re-arm only once the fault input is inactive again.

One more FlexPWM trap: the channels of a submodule share one counter. Committing one channel to a fixed PWM frequency fixes it for the others too, including the submodule’s input-capture logic. Plan the channel allocation before the board layout.

Where they really differ

Same setup, two real differences: where the trigger lives and how the flag clears.

Anatomy of a handler that posts to a task

The mental model: four steps, in this order, and each one has a failure mode.

A posting interrupt handler, step by step, with the failure each step prevents.

The rules that are not negotiable:

  1. End every posting handler with portYIELD_FROM_ISR(woken). Without it, the task you just woke stays ready but does not run until the next tick or the next context switch: latency you did not plan for.
  2. Never call a blocking API from an ISR, such as vTaskDelay() or a queue send with a timeout. Only the ...FromISR functions belong there.
  3. Interrupts have their own stack. On a Cortex-M, exceptions run on the main stack (MSP), not on the task’s stack. Size it for the worst case of nested interrupts.

How to audit your NVIC table

Open the interrupt overview in your config tool, or list every NVIC_SetPriority() call, and check each enabled interrupt:

  • Its priority is set explicitly. None is left at the reset default of 0.
  • If its handler calls any FreeRTOS API, its priority is numerically at or above configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY.
  • Every handler above the barrier has been read, line by line, for FreeRTOS calls, including calls hidden inside helper functions and drivers.
  • The flag is cleared before the interrupt is enabled, and inside the handler.
  • The handler name matches the vector table entry in the startup file. A misspelt handler silently falls back to the default handler.

The table alone cannot tell you whether an interrupt above the barrier is correct. That depends on what its handler calls, so only reading the handler can.

Summary

  1. The setup is the same for all three sources: clock, configure, clear the flag, enable, set the priority, then EnableIRQ().
  2. The differences are where the trigger lives and how the flag clears: GPIO and FlexPWM need an explicit clear, the ADC clears when you read the result.
  3. The priority is the field that matters: it is the only one where a wrong value gives no error at configuration time and an intermittent failure under load. Audit it.

Sources

  • NXP, i.MX RT1060 Processor Reference Manual: GPIO, ADC, FlexPWM and IOMUXC chapters.
  • Arm, Cortex-M7 Devices Generic User Guide: NVIC priorities and exception entry.
  • FreeRTOS documentation: “Running the RTOS on an ARM Cortex-M core” (interrupt priorities and configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY).
  • NXP MCUXpresso SDK driver documentation for fsl_gpio, fsl_adc and fsl_pwm.

💬 Questions, corrections, or your own war story? Leave a comment below.