In Episode 8 we implemented TIM2 interrupts and learned the 3 Principles of ISR Design:

  1. Keep it short — finish before the next interrupt fires
  2. Minimize side effects — stick to flag notification
  3. Protect shared variablesvolatile and critical sections

Now let’s see what actually happens when you break these rules.

“NG” in theory doesn’t always sink in. But watching something break live — you never forget it.

Today we deliberately break things to earn a real understanding of interrupt design.

📌 Every anti-pattern here applies to ALL microcontrollers

Code examples use STM32, but every failure mode in this article is hardware-independent. Arduino (AVR), ESP32, Renesas, PIC — on any MCU, blocking in an ISR hangs the system, missing volatile causes optimization bugs, and forgotten flag clears cause infinite loops.

Read this not as “STM32 stuff” but as “iron rules for any MCU with interrupts.”


📖 Previous Episode

Episode 8: Understanding Interrupts — Vector Table, NVIC, Context Saving, and TIM2 Implementation

📍 Series Home

[Full 13-Episode Series] Beyond Pointers — The Embedded World


✅ What you'll be able to do after this episode

  • Explain why printf (UART) inside an ISR causes deadlock
  • Reproduce how missing volatile crashes an -O2 optimized build
  • Measure and observe timing breakdown from heavy ISR processing
  • Reproduce shared variable corruption from non-atomic multi-variable updates
  • Explain the correct design pattern for each anti-pattern

Table of Contents

  1. Anti-Pattern 1: printf (UART) inside an ISR
  2. Anti-Pattern 2: Forgetting volatile
  3. Anti-Pattern 3: Heavy processing inside an ISR
  4. Anti-Pattern 4: Non-atomic multi-variable updates
  5. Anti-Pattern 5: Forgetting to clear the interrupt flag
  6. Anti-Pattern 6: Holding __disable_irq() too long
  7. Correct Design Pattern Summary
  8. Summary

💣 Anti-Pattern 1: printf (UART) inside an ISR

Symptom: main() stops responding, or behavior becomes unpredictable

One debug line, added in good faith, destroys the entire system.

/* ❌ The most common fatal mistake */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;

    printf("tick = %lu\r\n", g_tim2_tick);   /* ← this is the landmine */
}

Why it breaks — deadlock is the core issue

printfHAL_UART_Transmit() → busy-polls the UART transmit-complete flag (TXE), waiting until done. That waiting is the root cause.

TIM2 ISR running
  ↓ printf → HAL_UART_Transmit() called
  ↓ Busy-polling for TXE flag (blocking)
  ↓ SysTick fires during this time
  ↓ Interrupts at same priority or lower are held pending
    ※ Only higher-priority interrupts can preempt
  ↓ HAL_GetTick() counter stops updating
  ↓ If main() is inside HAL_Delay()
  ↓ Counter never increments → waits forever → complete freeze

The real danger is not “taking too long” — it’s that SysTick is stalled, so HAL_Delay() never returns.

Strictly speaking, whether this causes a full freeze depends on the interrupt priority configuration and HAL implementation. However, any design that calls blocking I/O from inside an ISR is fundamentally broken — treat it as an absolute “never do this.”

The transmission time itself is also a problem. For "tick = 1000\r\n" (14 characters):

T_{\text{total}} = 14 \times \frac{10 \text{ bits}}{115{,}200 \text{ bps}} \approx 1{,}215 \, \mu\text{s} = 1.2 \text{ ms}

The ISR occupies the CPU for 1.2ms against a 1ms period, so main() is nearly starved. Deadlock + CPU lockout — a double blow.

⚠️ SysTick gets dragged in — never call HAL_Delay() from an ISR

If an ISR runs continuously for more than 1ms, SysTick (the HAL millisecond counter) can no longer operate correctly. HAL_Delay() and HAL_GetTick() become unreliable, breaking all time-dependent code.

Even more severe: if you call HAL_Delay() directly from inside an ISR, it waits for SysTick to increment the counter. If TIM2 ISR has higher priority than SysTick, SysTick cannot fire while TIM2 ISR is running, so the counter never updates. HAL_Delay() waits forever — complete system freeze.

/* ❌ HAL_Delay() inside ISR → absolute NG */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    HAL_Delay(10);   /* ← SysTick can't fire → infinite wait */
}

The only HAL functions safe to call from an ISR are simple register-write wrappers like HAL_GPIO_WritePin().

What actually breaks

/* Broken code: printf inside ISR */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
    printf("tick=%lu\r\n", g_tim2_tick);
}

/* Check tick count after 1 second in main */
HAL_Delay(1000);
/* ← HAL_Delay doesn't work correctly — may never reach here */
printf("1sec tick = %lu\r\n", g_tim2_tick);
/* Should be 1000, but will be much lower */

Measuring with DWT CYCCNT confirms ISR execution time far exceeds 1ms = 84,000 cycles.

The correct approach

/* ✅ ISR only sets a flag; output happens in main */
volatile uint32_t g_tim2_tick = 0;
volatile uint8_t  g_debug_flag = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
    g_debug_flag = 1;   /* notify main only */
}

/* main loop */
while (1)
{
    if (g_debug_flag)
    {
        g_debug_flag = 0;
        printf("tick=%lu\r\n", g_tim2_tick);   /* ← output in main */
    }
}
✅ Alternatives to printf for ISR debugging
Method Characteristics
LED / GPIO toggle Oscilloscope view, zero overhead
DWT CYCCNT Cycle-accurate ISR timing measurement
SWO (ITM) Serial Wire Output, async, near-zero CPU load
Global variable Store value in ISR, printf from main

💣 Anti-Pattern 2: Forgetting volatile

Symptom: Works with -O0, hangs the instant you switch to -O2

This is the classic “optimization bug.” Works perfectly in debug builds, breaks completely in release builds. (Compiler optimization details are covered in Episode 6.)

/* ❌ No volatile */
uint32_t g_tim2_tick = 0;   /* missing volatile! */

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
}

int main(void)
{
    /* Intended to wait for 1000 counts */
    while (g_tim2_tick < 1000)
    {
        /* nothing */
    }
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);   /* ← never reached */
}

Why it breaks

Here is the pseudo-assembly the compiler generates at each optimization level:

/* -O0 (no optimization) */
.loop:
    LDR  R0, [g_tim2_tick]   /* read from RAM every iteration */
    CMP  R0, #1000
    BLT  .loop               /* loop if < 1000 */

/* -O2 (optimized, no volatile) */
    LDR  R0, [g_tim2_tick]   /* read from RAM only once */
.loop:
    CMP  R0, #1000           /* compare against cached register value */
    BLT  .loop               /* R0 never changes → infinite loop */

The compiler sees no code modifying g_tim2_tick inside the loop and concludes: “this value can’t change — cache it in a register.” No matter how many times the ISR writes to RAM, main only sees the value cached in the register before the loop started.

Observe the failure yourself

/* Experiment: compare behavior at -O0 vs -O2 */

/* ① Build with -O0 → works correctly (LED lights up) */
/* ② Build with -O2 → stuck in while(), LED never lights */

Seeing this difference firsthand makes volatile click permanently.

The correct approach

/* ✅ Always add volatile to variables shared with ISRs */
volatile uint32_t g_tim2_tick = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_tim2_tick++;
}

int main(void)
{
    while (g_tim2_tick < 1000)   /* volatile forces RAM read every iteration */
    {
        /* nothing */
    }
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);   /* reached correctly */
}
💡 How to identify variables that need volatile

Any variable that could be written by an ISR, DMA, hardware register, or another thread needs volatile.

  • ✅ Flags and counters written by ISRs
  • ✅ Buffers written by DMA
  • ✅ Peripheral registers (TIM2->SR, etc.) — already declared __IO (= volatile) in the HAL headers
  • ❌ Local variables or pure computation results that ISR/DMA/hardware never touches → no volatile needed

Overusing volatile (applying it when unnecessary) is also a problem — it disables optimizations and reduces performance.


💣 Anti-Pattern 3: Heavy processing inside an ISR

Symptom: Interrupt period violated, data only updates sporadically

Unlike Anti-Pattern 1 (blocking I/O), no deadlock occurs here. But if execution time exceeds the period, the result is the same.

/* ❌ I2C read and computation inside ISR (breaks even without printf) */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;

    /* Read sensor via I2C (hundreds of µs to several ms) */
    int16_t raw = read_sensor_i2c();

    /* Moving average filter (array ops, µs to tens of µs) */
    float filtered = moving_average(raw, filter_buf, FILTER_SIZE);

    g_sensor_val = (uint32_t)filtered;
}

Why it breaks — timing collapses

Execution may stay within the period under normal conditions, but I2C is subject to bus contention, clock stretching, and sensor response delays that can spike the time dramatically.

[Normal]
read_sensor_i2c()  ≈ 400 µs
moving_average()   ≈   5 µs
ISR total          ≈ 405 µs  ← fits in 1ms period (looks fine)

[With bus delay]
read_sensor_i2c()  ≈ 2,000 µs (clock stretch event)
moving_average()   ≈     5 µs
ISR total          ≈ 2,005 µs ≫ 1,000 µs (1ms period)

The moment execution time exceeds the period, the next interrupt fires immediately as the ISR returns. The CPU is nearly always inside the ISR, and main() barely runs.

Unlike AP1, HAL_Delay() doesn’t deadlock here — but the temporal reliability of the interrupt system completely collapses, which is equally damaging.

Correct approach: flag notification pattern

/* ✅ ISR only signals what happened; main does the work */
volatile uint8_t g_sensor_read_request = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_sensor_read_request = 1;   /* set flag only */
}

int main(void)
{
    while (1)
    {
        if (g_sensor_read_request)
        {
            g_sensor_read_request = 0;

            /* Heavy work all happens in main */
            int16_t raw = read_sensor_i2c();
            float filtered = moving_average(raw, filter_buf, FILTER_SIZE);
            printf("sensor=%.2f\r\n", filtered);
        }
    }
}
📌 The Golden Rule of ISR Design

The ISR’s only job is to record what happened. What to do about it is main’s decision.

Specifically:

  • ✅ Increment a flag or counter
  • ✅ Push one item into a ring buffer
  • ✅ Toggle a GPIO pin once
  • ❌ I2C / SPI communication
  • ❌ Large floating-point calculations
  • ❌ Dynamic memory allocation (malloc)
  • ❌ printf / UART transmission

Make ISR timing measurement a habit

Apply DWT CYCCNT (introduced in Episodes 7 and 8) to your ISRs as well.

volatile uint32_t g_isr_max_cycles = 0;

void TIM2_IRQHandler(void)
{
    uint32_t t0 = DWT->CYCCNT;
    TIM2->SR &= ~TIM_SR_UIF;

    /* ... ISR work ... */

    uint32_t cycles = DWT->CYCCNT - t0;
    if (cycles > g_isr_max_cycles)
        g_isr_max_cycles = cycles;   /* track worst case */
}

Check g_isr_max_cycles in the debugger. The rule of thumb: keep ISR execution below 10% of the interrupt period. For a 1ms period (84,000 cycles), target under 8,400 cycles.


💣 Anti-Pattern 4: Non-atomic multi-variable updates

Symptom: Reading partially-updated, “corrupted” state

In Episode 8 we learned that “an aligned uint32_t read/write is atomic.” But when you treat multiple variables as a single consistent unit, that guarantee no longer applies.

💡 What does 'aligned uint32_t read/write is atomic' mean?

Aligned means the variable’s address is a multiple of its size. For uint32_t (4 bytes), the address must be a multiple of 4 (e.g., 0x20000000, 0x20000004 …). Normal global variables are automatically aligned by the compiler.

Atomic means the operation cannot be interrupted mid-way. An aligned uint32_t assignment compiles to a single STR instruction (one bus cycle), so even if an interrupt fires during that instruction, there is no half-written value in memory.

By contrast, uint64_t (8 bytes) requires two STR instructions. If an interrupt fires between them, the upper 32 bits are new while the lower 32 bits are old — a “half-corrupted” value. That’s what non-atomic means.

/* ❌ Non-atomic update of timestamp + sensor value pair */
volatile uint32_t g_timestamp = 0;
volatile uint32_t g_sensor_val = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    g_timestamp  = HAL_GetTick();
    g_sensor_val = read_sensor();
}

/* main side: trying to read both as a pair */
uint32_t ts  = g_timestamp;
uint32_t val = g_sensor_val;
/* ts and val may be from different ISR invocations */

Why it breaks

The race occurs when the ISR fires between main’s two reads.

main running:
  ts = g_timestamp;        ← reads old value (from ISR invocation N)
  [TIM2 interrupt fires]
    g_timestamp  = new value (invocation N+1)
    g_sensor_val = new sensor reading (invocation N+1)
  [ISR returns → back to main]
  val = g_sensor_val;      ← reads new value (invocation N+1)

Result:
  ts  = timestamp from invocation N    ← old
  val = sensor value from invocation N+1  ← new
  → a mismatched pair is used as if it were consistent!

When treating multiple variables as a unit, if an ISR fires between the two reads, you read a partially-updated state. This type of bug has low reproducibility (it depends on exact interrupt timing) and is extremely difficult to debug.

Correct approach: critical section

/* ✅ Protect the multi-variable update with a critical section */
volatile uint32_t g_timestamp = 0;
volatile uint32_t g_sensor_val = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;

    uint32_t ts  = HAL_GetTick();
    uint32_t val = read_sensor();

    /* Temporarily disable interrupts to write atomically */
    __disable_irq();
    g_timestamp  = ts;
    g_sensor_val = val;
    __enable_irq();
}

/* main side: also protect the reads */
uint32_t ts, val;
__disable_irq();
ts  = g_timestamp;
val = g_sensor_val;
__enable_irq();

Double-buffer pattern (advanced)

When you want to avoid critical sections entirely, a double buffer works well:

/* ✅ Lock-free double-buffer read/write */
typedef struct {
    uint32_t timestamp;
    uint32_t sensor_val;
} SensorData;

volatile SensorData g_buf[2];
volatile uint8_t    g_write_idx = 0;   /* index ISR writes to */

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;

    uint8_t w = g_write_idx;
    g_buf[w].timestamp  = HAL_GetTick();
    g_buf[w].sensor_val = read_sensor();
    g_write_idx ^= 1;   /* swap write buffer */
}

/* main side: read from the buffer ISR is NOT currently writing */
uint8_t r = g_write_idx ^ 1;
uint32_t ts  = g_buf[r].timestamp;
uint32_t val = g_buf[r].sensor_val;
⚠️ Critical section caveats

While __disable_irq() is active, SysTick is also paused:

  • ❌ Don’t call HAL_Delay() inside a critical section
  • ❌ Don’t put multi-millisecond processing inside a critical section
  • ✅ Only copy a few variables (a few µs at most)
  • ✅ Always pair __disable_irq() with __enable_irq()

💣 Anti-Pattern 5: Forgetting to clear the interrupt flag

Symptom: main() never runs (interrupt won’t stop)

As touched on in Episode 8, this is the most common root cause of “why isn’t it working.”

/* ❌ No flag clear */
void TIM2_IRQHandler(void)
{
    /* TIM2->SR &= ~TIM_SR_UIF;  ← forgot this! */
    g_tim2_tick++;
}

Why it breaks

TIM2 UIF flag set
  ↓
NVIC calls TIM2_IRQHandler
  ↓
ISR runs (flag still set)
  ↓
ISR returns
  ↓
NVIC sees flag still set
  ↓
Immediately calls TIM2_IRQHandler again
  ↓
(infinite loop → main() never gets control)

Correct approach

/* ✅ Always clear the flag at the top of the ISR */
void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;   /* ← clear first (see Episode 8 for why) */
    g_tim2_tick++;
}
💡 Why clearing at the top is safer than at the bottom

Even after you write the clear instruction, STM32 hardware takes a few cycles to propagate it through the bus to the peripheral. If you clear at the end of the ISR, the NVIC may re-detect the flag immediately after return. At 84MHz this window is real. Clear at the top of the ISR — it’s the rule.


💣 Anti-Pattern 6: Holding __disable_irq() too long

Symptom: Interrupt jitter increases, SysTick-based operations degrade

You know you need a critical section — but “let me hold it a bit longer to be safe” is a dangerous instinct.

/* ❌ Unnecessarily long critical section */
__disable_irq();

/* Sensor read (hundreds of µs) */
int16_t raw = read_sensor_i2c();

/* Filter processing (µs to tens of µs) */
float filtered = moving_average(raw, filter_buf, FILTER_SIZE);

/* Variable update */
g_sensor_val = (uint32_t)filtered;

__enable_irq();

Why it breaks

While __disable_irq() is active, all interrupts are held pending.

__disable_irq() starts
  TIM2 fires → recorded as Pending (not executed)
  SysTick also paused → HAL_GetTick() stops updating
  I2C transfer ≈ 400µs (no interrupts during this time)
__enable_irq() ends
  → all pending interrupts fire at once (burst)
  → TIM2's 1ms period is disrupted
  → HAL_Delay() accuracy degrades

Correct approach: minimum critical section

/* ✅ Only protect the variable-copy moment */
int16_t raw = read_sensor_i2c();      /* outside critical section */
float filtered = moving_average(raw, filter_buf, FILTER_SIZE);  /* outside */

/* Protect only the write moment (a few cycles) */
__disable_irq();
g_sensor_val = (uint32_t)filtered;
__enable_irq();
Operation Inside critical section?
I2C / SPI communication ❌ Do it outside
Floating-point computation ❌ Do it outside
printf / UART transmission ❌ Do it outside
Atomic copy of multiple variables ✅ Inside (a few µs max)
Single uint32_t read/write ✅/❌ Usually not needed (atomic)

✅ Correct Design Pattern Summary

Putting the anti-patterns together reveals the full picture of correct ISR design.

Flag notification pattern (the most fundamental)

/* The canonical ISR / main role separation */

/* Shared variables (all volatile) */
volatile uint8_t  g_flag_1ms  = 0;
volatile uint8_t  g_flag_10ms = 0;
volatile uint32_t g_tick      = 0;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;   /* clear flag (first!) */
    g_tick++;

    /* Set period flags */
    g_flag_1ms = 1;
    if (g_tick % 10 == 0) g_flag_10ms = 1;
}

int main(void)
{
    /* ... initialization ... */

    while (1)
    {
        /* 1ms task */
        if (g_flag_1ms)
        {
            g_flag_1ms = 0;
            task_1ms();   /* lightweight work */
        }

        /* 10ms task */
        if (g_flag_10ms)
        {
            g_flag_10ms = 0;
            task_10ms();  /* heavier work (e.g., I2C) */
        }

        /* Background task */
        task_background();   /* low-priority work */
    }
}

Ring buffer pattern (for streaming data)

For continuously incoming data (UART receive, ADC sampling, etc.):

/* Ring buffer */
#define RING_BUF_SIZE 64

typedef struct {
    uint8_t  buf[RING_BUF_SIZE];
    uint16_t head;   /* written by ISR */
    uint16_t tail;   /* read by main */
} RingBuf;

volatile RingBuf g_uart_rx;

/* UART receive ISR */
void USART2_IRQHandler(void)
{
    if (USART2->SR & USART_SR_RXNE)
    {
        uint8_t data = USART2->DR;
        uint16_t next = (g_uart_rx.head + 1) % RING_BUF_SIZE;
        if (next != g_uart_rx.tail)   /* not full */
        {
            g_uart_rx.buf[g_uart_rx.head] = data;
            g_uart_rx.head = next;
        }
    }
}

/* main side: pop one byte */
uint8_t ring_read(RingBuf *rb, uint8_t *out)
{
    if (rb->head == rb->tail) return 0;   /* empty */
    *out = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 1;
}
💡 Why head/tail are atomic — no critical section needed

In this ring buffer, head is written only by the ISR and tail is written only by main. Since each has a single writer, no critical section is needed. This is the Single Producer Single Consumer (SPSC) pattern.

Why is a uint16_t write safe?

On Cortex-M4, a single assignment instruction (STRB / STRH / STR) to an aligned 8-bit, 16-bit, or 32-bit variable completes atomically at the hardware level. An interrupt cannot fire mid-instruction, so the reader can never observe a half-written value.

Normal global variables and struct members are automatically aligned by the compiler, so this guarantee holds. The exception is __attribute__((packed)), which may break alignment.


Summary

The 6 anti-patterns we deliberately broke, and how to fix them:

# Anti-Pattern Symptom Fix
1 printf in ISR Deadlock + CPU lockout Output in main; ISR only sets flag
2 Missing volatile Hangs at -O2 (optimization bug) Always add volatile to ISR-shared variables
3 Heavy ISR processing Interrupt delay / main starvation Keep ISR light; move heavy work to main
4 Non-atomic update Data inconsistency (hard to reproduce) Protect with critical section
5 Forgotten flag clear ISR infinite loop, main never runs Always clear at top of ISR
6 Long disable_irq Interrupt jitter Protect only the variable-copy moment
📌 The essence of interrupt design

What all 6 anti-patterns have in common: treating the ISR like a normal function. An ISR is a special execution context that intrudes asynchronously — you cannot design it with the same intuition as ordinary code.

Interrupts feel “scary” because you never know exactly when they’ll arrive. But follow the rules and you can control them.

  • ISR = notifier, main = executor — keep the roles strict
  • volatile guards single shared variables; critical sections guard multi-variable groups
  • Measure — check ISR execution time with DWT and keep it under 10% of the period

An interrupt is not “a convenient function call” — it is “an asynchronous intruder from a parallel world.” Designing with that intuition is the dividing line between embedded engineers.

Next up: “The DMA Idea — Making the CPU Idle.” A concept above interrupts — the technique of delegating work the CPU doesn’t need to do to hardware.


What’s Next

🚀 Episode 10: The DMA Idea — Making the CPU Idle

Transform UART transmission from "CPU sends one byte at a time" to "DMA handles it all." Build intuition for throughput, latency, and bus contention — understand embedded "true parallelism."


FAQ

Q. Can I use malloc (dynamic memory allocation) inside an ISR?

No. malloc acquires an internal heap lock. If main is also calling malloc, both will contend for the same lock, causing a deadlock. Execution time is also non-deterministic, making timing guarantees impossible. Allocate all ISR buffers statically.

Q. Can I use HAL functions inside an ISR?

Avoid them in general. The key criterion: does the function internally do any waiting (polling or timeout)? Functions with waiting are blocking and cannot be used inside an ISR.

The deeper reason is that most HAL functions are not reentrant. Functions called from an ISR must be safe to call from anywhere, at any time — a reentrant implementation. HAL provides no such guarantee, so avoid it inside ISRs as a rule.

The exceptions are simple register-write functions like HAL_GPIO_WritePin() and HAL_GPIO_TogglePin().

Q. What happens if I configure priorities wrong?

The classic accident is setting an ISR to the same priority as SysTick (priority 0). Without preemption between them, both compete unpredictably. Another common problem: accidentally setting an ISR to very high priority so it constantly preempts lower-priority ISRs, which then never get to run — known as priority inversion.

Q. What happens if I nest __disable_irq() calls?

Using __disable_irq() / __enable_irq() directly breaks when the function is called from code that already has interrupts disabled.

/* ❌ Nesting-unsafe critical section */
void some_function(void)
{
    __disable_irq();   /* may already be disabled */
    /* ... */
    __enable_irq();    /* unconditionally re-enables — dangerous! */
}

If the caller already ran __disable_irq(), this function’s __enable_irq() re-enables interrupts at the wrong moment.

Robust approach: save and restore with __get_PRIMASK()

/* ✅ Save and restore the interrupt state */
void some_function(void)
{
    uint32_t primask = __get_PRIMASK();   /* save current PRIMASK */
    __disable_irq();

    /* ... critical section ... */

    __set_PRIMASK(primask);   /* restore original state */
}

This series uses __disable_irq() / __enable_irq() for simplicity, but for library code or reusable functions, __get_PRIMASK() is the professional approach. FreeRTOS’s taskENTER_CRITICAL() is based on exactly this pattern.

Q. Do these rules change when using FreeRTOS?

The fundamentals are the same, but in FreeRTOS you use taskENTER_CRITICAL() / taskEXIT_CRITICAL() instead of direct __disable_irq(). When posting to queues or semaphores from an ISR, you must use the FromISR-suffixed APIs (e.g., xQueueSendFromISR()).