RT-Thread Version
master at 997de9433af42aacb1988489ccb08b6688f440cc.
The affected code is still present in current master:
Relevant configuration:
RT_TICK_PER_SECOND=1000
RT_USING_EVENT=y
single-core Cortex-M
hard thread timers (RT_USING_TIMER_SOFT is disabled)
Hardware Type/Architectures
GD32F303ZKT6, Cortex-M4 / ARMv7E-M, single core.
The same race should apply to other single-core targets where an interrupt that calls rt_event_send() can preempt the system tick handler.
Interrupt priorities in the failing system:
SysTick/PendSV effective priority: 0xF0 (lowest)
USART IRQ priority: 0x00
SCB->SHPR3 at failure: 0xf0f00000
Develop Toolchain
GCC: arm-none-eabi-gcc 14.2.1 20241119
Describe the bug
A thread waits on an event with a finite timeout. When its thread timer expires, _timer_check() removes the timer from the timer list and clears RT_TIMER_FLAG_ACTIVATED, then temporarily restores interrupts before calling _thread_timeout().
A higher-priority ISR can enter this window and call rt_event_send() more than once. The first wakeup sees sched_flag_ttmr_set == 1, but rt_timer_stop() fails because the timer has already been removed/deactivated by _timer_check(). rt_sched_thread_timer_stop() nevertheless clears sched_flag_ttmr_set.
A second event send can then see sched_flag_ttmr_set == 0 and successfully make the same thread ready/running. When execution returns to SysTick, _thread_timeout() is still called for the old timer and fails this assertion:
RT_ASSERT(rt_sched_thread_is_suspended(thread));
Observed backtrace
#0 rt_assert_handler
#1 _thread_timeout src/thread.c
#2 _timer_check src/timer.c
#3 rt_timer_check src/timer.c
#4 rt_tick_increase src/clock.c
#5 SysTick_Handler
Thread/timer state captured with GDB
thread.name = "run_mtr"
thread.stat = 3 (RT_THREAD_RUNNING)
sched_flag_ttmr_set = 0
thread_timer.parent.flag = 0x08 (THREAD_TIMER set, ACTIVATED cleared)
thread_timer.timeout_tick = 735028
current_tick = 735028
thread.event_set = 0x02
thread.event_info = 0x06 (OR | CLEAR)
A context switch from idle to the event waiter had been requested but PendSV had not executed yet:
rt_interrupt_from_thread = &idle_thread.sp
rt_interrupt_to_thread = &event_waiter.sp
rt_thread_switch_interrupt_flag = 1
This rules out a stack overflow or a corrupted thread object: the event path had already marked the waiter running while the timeout callback was still in flight.
Race sequence
SysTick: _timer_check()
remove thread timer from timer list
clear RT_TIMER_FLAG_ACTIVATED
restore interrupts
|
+-- higher-priority ISR
| rt_event_send(event)
| rt_timer_stop() -> -RT_ERROR
| sched_flag_ttmr_set is cleared anyway
| rt_sched_thread_ready() failure is ignored
|
| rt_event_send(event) again
| sched_flag_ttmr_set == 0
| thread is moved to READY/RUNNING
|
SysTick resumes
_thread_timeout(thread)
assert(thread is suspended) fails
Hardware stress reproducer
A minimal application can use one event waiter and a hardware timer IRQ whose priority is higher than SysTick. Use a hardware timer period close to, but not exactly equal to, one OS tick so its phase sweeps through the timeout window.
static struct rt_event race_event;
static void race_waiter(void *parameter)
{
rt_uint32_t received;
RT_UNUSED(parameter);
while (1)
{
received = 0;
(void)rt_event_recv(&race_event,
0x01,
RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR,
1,
&received);
}
}
void HIGH_PRIORITY_TIMER_IRQHandler(void)
{
rt_interrupt_enter();
clear_hardware_timer_irq();
/* Two producers or two notifications are required to expose the
incorrect clearing of sched_flag_ttmr_set. */
(void)rt_event_send(&race_event, 0x01);
(void)rt_event_send(&race_event, 0x01);
rt_interrupt_leave();
}
Suggested setup:
OS tick: 1000 us
hardware timer: approximately 1001 us
hardware timer IRQ: higher priority than SysTick
wait timeout: 1 tick
Deterministic regression test
For an upstream kernel regression test, timing dependence can be removed with a test-only hook immediately after _timer_check() restores interrupts and immediately before t->timeout_func() is called:
rt_spin_unlock_irqrestore(lock, level);
/* Test-only fault-injection hook. */
thread_timer_expiry_window_hook(t);
t->timeout_func(t->parameter);
The hook sends the waited event twice. Before the fix, this deterministically makes the second send resume the thread and _thread_timeout() asserts. After the fix, timeout remains the single owner, the callback completes, and the pending event is consumed by the next rt_event_recv().
Expected behavior
Only one path should win:
- If event wakeup stops the timer successfully, the event path resumes the thread.
- If
_timer_check() has already taken the timer, all competing wakeups must leave the thread suspended until _thread_timeout() completes.
- An event must only be cleared and reported as delivered when
rt_sched_thread_ready() succeeds.
Proposed fix
I tested the following three coordinated changes locally:
- In
rt_sched_thread_timer_stop(), clear sched_flag_ttmr_set only when rt_timer_stop() succeeds. A failed stop keeps later wakeups from bypassing the in-flight timeout callback.
- In
_thread_timeout(), clear sched_flag_ttmr_set after acquiring the scheduler lock and confirming the thread is suspended. The timeout callback now owns completion of the timer.
- In
rt_event_send(), only clear the event, set thread->error = RT_EOK, and request scheduling when rt_sched_thread_ready() returns RT_EOK.
Core logic of the patch:
--- a/src/scheduler_comm.c
+++ b/src/scheduler_comm.c
@@
error = rt_timer_stop(&thread->thread_timer);
- RT_SCHED_CTX(thread).sched_flag_ttmr_set = 0;
+ if (error == RT_EOK)
+ RT_SCHED_CTX(thread).sched_flag_ttmr_set = 0;
--- a/src/thread.c
+++ b/src/thread.c
@@
RT_ASSERT(rt_sched_thread_is_suspended(thread));
+ RT_SCHED_CTX(thread).sched_flag_ttmr_set = 0;
--- a/src/ipc.c
+++ b/src/ipc.c
@@
- rt_sched_thread_ready(thread);
- thread->error = RT_EOK;
- need_schedule = RT_TRUE;
+ status = rt_sched_thread_ready(thread);
+ if (status == RT_EOK)
+ {
+ /* Consume/clear the event here, after successful resume. */
+ thread->error = RT_EOK;
+ need_schedule = RT_TRUE;
+ }
The complete local firmware builds successfully with this change. Simply removing the assertion or lowering the producer IRQ priority hides the failure but does not fix event consumption or the duplicate-resume race.
Other additional context
The original failure occurred naturally with two independent high-priority UART RX sources that both notify the same event waiter. It occurred about 735 seconds after boot.
No application memory corruption was observed:
- The thread object type and object-list links were valid.
- The defunct list was empty.
- The 1 KiB waiter stack had approximately 668 bytes free.
- The captured timer and pending PendSV state consistently identify the event/timeout race described above.
RT-Thread Version
masterat997de9433af42aacb1988489ccb08b6688f440cc.The affected code is still present in current
master:src/timer.c:_timer_check()removes/deactivates a timer and unlocks before calling its callbacksrc/scheduler_comm.c:rt_sched_thread_timer_stop()clearssched_flag_ttmr_seteven whenrt_timer_stop()failssrc/ipc.c:rt_event_send()ignores the return value ofrt_sched_thread_ready()src/thread.c:_thread_timeout()asserts that the thread must still be suspendedRelevant configuration:
Hardware Type/Architectures
GD32F303ZKT6, Cortex-M4 / ARMv7E-M, single core.
The same race should apply to other single-core targets where an interrupt that calls
rt_event_send()can preempt the system tick handler.Interrupt priorities in the failing system:
Develop Toolchain
GCC:
arm-none-eabi-gcc 14.2.1 20241119Describe the bug
A thread waits on an event with a finite timeout. When its thread timer expires,
_timer_check()removes the timer from the timer list and clearsRT_TIMER_FLAG_ACTIVATED, then temporarily restores interrupts before calling_thread_timeout().A higher-priority ISR can enter this window and call
rt_event_send()more than once. The first wakeup seessched_flag_ttmr_set == 1, butrt_timer_stop()fails because the timer has already been removed/deactivated by_timer_check().rt_sched_thread_timer_stop()nevertheless clearssched_flag_ttmr_set.A second event send can then see
sched_flag_ttmr_set == 0and successfully make the same thread ready/running. When execution returns to SysTick,_thread_timeout()is still called for the old timer and fails this assertion:Observed backtrace
Thread/timer state captured with GDB
A context switch from idle to the event waiter had been requested but PendSV had not executed yet:
This rules out a stack overflow or a corrupted thread object: the event path had already marked the waiter running while the timeout callback was still in flight.
Race sequence
Hardware stress reproducer
A minimal application can use one event waiter and a hardware timer IRQ whose priority is higher than SysTick. Use a hardware timer period close to, but not exactly equal to, one OS tick so its phase sweeps through the timeout window.
Suggested setup:
Deterministic regression test
For an upstream kernel regression test, timing dependence can be removed with a test-only hook immediately after
_timer_check()restores interrupts and immediately beforet->timeout_func()is called:The hook sends the waited event twice. Before the fix, this deterministically makes the second send resume the thread and
_thread_timeout()asserts. After the fix, timeout remains the single owner, the callback completes, and the pending event is consumed by the nextrt_event_recv().Expected behavior
Only one path should win:
_timer_check()has already taken the timer, all competing wakeups must leave the thread suspended until_thread_timeout()completes.rt_sched_thread_ready()succeeds.Proposed fix
I tested the following three coordinated changes locally:
rt_sched_thread_timer_stop(), clearsched_flag_ttmr_setonly whenrt_timer_stop()succeeds. A failed stop keeps later wakeups from bypassing the in-flight timeout callback._thread_timeout(), clearsched_flag_ttmr_setafter acquiring the scheduler lock and confirming the thread is suspended. The timeout callback now owns completion of the timer.rt_event_send(), only clear the event, setthread->error = RT_EOK, and request scheduling whenrt_sched_thread_ready()returnsRT_EOK.Core logic of the patch:
The complete local firmware builds successfully with this change. Simply removing the assertion or lowering the producer IRQ priority hides the failure but does not fix event consumption or the duplicate-resume race.
Other additional context
The original failure occurred naturally with two independent high-priority UART RX sources that both notify the same event waiter. It occurred about 735 seconds after boot.
No application memory corruption was observed: