1. Introduction
The C language is widely used in embedded and real-time systems due to its efficiency and closeness to hardware. This same characteristic, however, makes the language permissive: operations that are perfectly valid in C can introduce serious failures without any warning at compile time.
This series of articles will explore the impact of historical software problems that caused real accidents. For this first volume, one of the best-known examples of a failure that led to a terrible accident will be analyzed: the famous Therac-25 case, in which synchronization and state-control errors in software resulted in radiation overdoses and patient deaths.
In this article, these problems are revisited in a practical way, through the simulation of Therac-25-inspired bugs on a real embedded system running FreeRTOS, highlighting how silent errors arise and how they can be avoided.
Therac-25
The Therac-25 was a medical linear accelerator designed by Atomic Energy of Canada Limited (AECL) for cancer treatment. Between 1985 and 1987, the machine administered massive radiation overdoses to at least six patients, resulting in deaths and serious injuries. Investigations revealed that the accidents were not caused by a single isolated fault, but by a combination of programming errors (race conditions), serious systems-engineering failures, and an organizational culture of excessive confidence in software as the primary safety mechanism. Described below are two of the major design and implementation problems identified in the investigations.
Problem 1: Race Condition
- The software was preemptive multitasking, with several tasks accessing shared global variables.
- There were no adequate synchronization mechanisms (mutexes, semaphores, or atomic operations).
- Flags and control variables were read and written non-atomically, meaning these actions could be interrupted before completion.
- The operator frequently selected mode "X" (X-ray), noticed the mistake, and quickly switched to "E" (Electron).
The bug:
The software took approximately 8 seconds to configure the bending magnets responsible for energy selection.
If the operator corrected the mode and pressed Enter within that interval, a race condition occurred between the data-entry task and the hardware-configuration tasks. As a result, the system advanced
to the treatment phase before all internal variables had been consistently updated.
Result:
- The interface correctly indicated "Electron" mode, but internally the system configured the machine with the high-energy parameters typical of "X-ray" mode, without the target and scattering filter in position. This resulted in a highly concentrated electron beam of lethal intensity.
- ➡️ Classic silent bug: the system appeared to be in a safe state, while internally it operated under a physically invalid condition.
And what is a race condition?
A race condition occurs when multiple execution contexts access and modify a shared resource without proper synchronization, causing the final result to depend on the non-deterministic order of execution. It would be as if, in a soccer match, players from the same team fiercely competed for possession of the ball without playing as a team, with no synchronization whatsoever, and every time one of them was about to score, the other would not let that player finish and would take the ball away, causing the goal to never happen or to go wide of the target.
As a somewhat more technical example this time, in real-time systems, consider two tasks transmitting messages over the same UART interface. One task needs to send the string "Should have used a semaphore", while another needs to send "Need to implement a critical section". In a preemptive system, a ready, higher-priority task can interrupt the execution of the other at any moment.
If character transmission is not protected by synchronization mechanisms, such as semaphores or critical sections, the tasks can be interrupted before completing their respective writes. As a result, the bytes sent to the UART can interleave unpredictably, producing corrupted, meaningless output, such as "Should Need have to used implement a a semaphore critical section".
This behavior does not represent a compilation error or an isolated logic error, but rather a direct consequence of uncontrolled concurrency. The system starts exhibiting unplanned, non-deterministic behavior, characterizing a race condition.
[TASK A] Should have used a semaphore
[TASK B] Need to implement a critical section
Should Need have to used implement a a semaphore critical
section
Problem 2: Variable Overflow (Integer Overflow)
- The software used reduced-size integer variables to store counters, states, and accumulated values related to equipment operation.
- There was no explicit bounds validation before arithmetic operations, implicitly assuming that values would never exceed the capacity of the type.
- The code was written in C, a language that provides no automatic mechanism for detecting integer variable overflow.
The bug:
When an integer variable reaches the maximum value representable
by its type and undergoes an additional increment, a variable overflow occurs.
In unsigned integers, the value simply "wraps around" back to zero and keeps counting.
This behavior happens silently, with no compilation error,
no runtime exception, and no warning to the software whatsoever.
Result:
- In the Therac-25, variables related to operational state and treatment parameters could undergo this kind of overflow, allowing the system to advance to dangerous operation phases while appearing to be in a safe state.
- ➡️ Classic silent bug: the internal value of the variable appears valid, but represents a logically impossible state from the standpoint of the system design.
Fundamentals: types, memory, and architecture in C
Unlike high-level languages that completely abstract away memory, the C language requires the programmer to explicitly declare the type and size of the variables used by the software, in the case of static allocation. This declaration tells the compiler how much memory space must be reserved to store each piece of data, whether on the stack or in static memory such as .bss or .data.
Fundamental types such as char, int, float, and
double do not represent merely semantic categories, but
finite quantities of bits. These bits determine the range
of values that can be represented and, consequently, the arithmetic limits
of each variable.
It is important to note that the C language does not fix the absolute size
of several primitive types. The standard only defines minimum sizes and relationships
between them. For example, the type int must have at least 16 bits, but
its actual size depends on the processor architecture and the data model adopted
by the compiler.
On 32-bit systems, it is common for int to occupy 32 bits. On 64-bit
systems, the type int may legally have 64 bits according to the language
standard, although in practice it remains 32 bits on most platforms. On these systems,
types such as long and pointers frequently come to
occupy 64 bits.
Thus, the effective size of types in C does not depend only on the language, but also on the processor architecture and the ABI (Application Binary Interface), which defines the rules for binary representation and interoperability between compiled programs.
This characteristic, widely discussed in classic works such as :contentReference[oaicite:0]{index=0} and :contentReference[oaicite:1]{index=1}, is one of the reasons why C offers great control over hardware, but also demands strict discipline from the programmer.
And what is variable overflow?
Variable overflow occurs when an arithmetic operation produces a value that cannot be represented by the data type used to store it. Since each type has a fixed number of bits, its range of values is necessarily limited.
In the C language, this event is not handled automatically. The compiler does not insert runtime overflow checks, and the software continues its execution normally, even after the value exceeds the representable limits.
In the case of unsigned integers, when the maximum value is
exceeded, the result wraps back to the start of the range. For example, a
uint8_t has 8 bits and can represent values from 0 to 255; when
adding 1 to the value 255, the result becomes 0. From the compiler's and the language's point of view, this value remains
perfectly valid. However, from the system's logical
point of view, it can represent a completely impossible or dangerous state.
This is why this type of failure is classified as a
highly dangerous silent bug!.
In embedded, real-time, and especially critical systems, this behavior is even more dangerous. Internal states can be corrupted without any exception, interrupt, or error message, directly affecting safety checks, timing, physical limits, or control states. The critical-systems literature, including guidelines such as MISRA-C and recommendations discussed in works such as :contentReference[oaicite:2]{index=2}, emphasizes that the mere incorrect use of integer types can be enough to compromise the predictability and safety of a system.
Simplified example in C:
Silent integer variable overflow
#include <stdint.h>
int main(void)
{
uint8_t counter = 250;
counter += 10;
while (counter < 100)
{
// unexpected loop
}
return 0;
}
What the programmer expects:
The initial value of counter is 250. After incrementing by 10, the
expected value would be 260, making the condition of the while false
and the loop not execute.
What actually happens:
The counter variable is of type uint8_t, which can only
store values between 0 and 255. When attempting to store the value 260, an
overflow occurs and the resulting value becomes 4 (260 modulo 256).
- The code compiles normally.
- The program runs without crashing.
- No error is signaled by the system.
- The software's control logic is silently altered.
Result:
-
The condition
counter < 100becomes true, causing the system to enter an unexpected loop or follow an unforeseen logic path.
Practical test
The goal of this experiment is to demonstrate, in a practical and observable way on real hardware, the simultaneous occurrence of a race condition and a silent variable overflow in a preemptive multitasking system. The scenario was intentionally simplified to highlight how subtle bugs in C can produce logically impossible states, even though the program remains apparently functional.
Environment and tools used
- AM243x LaunchPad Development Kit (Texas Instruments) development board, based on an ARM Cortex-R5F core, representative of real-time embedded systems with determinism requirements.
- FreeRTOS, running in preemptive mode, enabling real concurrency among multiple tasks.
- Texas Instruments MCU+ SDK, using as a base the UART Echo with FreeRTOS example, employed only as minimal infrastructure for operating system initialization.
- Code Composer Studio (CCS) (version 20.3.0.14__1.9.0), used for compilation, firmware loading, and debugging via JTAG.
-
Instrumentation via the CIO (C I/O) mechanism of CCS, in which
functions such as
DebugP_logare redirected by the debugger over JTAG, allowing internal system behavior to be observed without using a physical UART and without directly interfering with task scheduling.
Central idea of the experiment
This experiment uses a global state variable, shared among multiple tasks in a preemptive multitasking system. This variable represents an internal state of the system and is manipulated concurrently by two independent tasks, without any protection mechanism, such as mutexes, critical sections, or atomic operations.
Each task updates the same global variable using different growth rates and distinct execution periods. One task increments the state slowly and incrementally, while the other applies larger and more frequent increments. This combination intentionally creates a classic race-condition scenario, in which the final value of the variable comes to depend on the non-deterministic order of task execution.
In addition, the state variable was purposely defined with a reduced-width
integer type (uint8_t), making the occurrence of
silent overflow unavoidable. The observed result is a state
that remains valid from the C language's point of view, but that takes on
logically impossible values from the system's perspective.
The goal of the experiment is not to trigger explicit failures or crashes, but to demonstrate how silent bugs can arise in seemingly functional systems when global variables are shared unsafely in preemptive environments.
Shared global variable without protection
volatile uint8_t system_state = 0;
The system_state variable represents the global state of the system.
The volatile qualifier tells the compiler that the variable's value can change at any moment, preventing it from optimizing access by keeping the value in a register. However, volatile does not guarantee atomicity, does not create memory barriers, and does not replace hardware synchronization mechanisms.
Task A — Incremental state update
void OperatorTask(void *pvParameters)
{
(void)pvParameters;
for (;;)
{
system_state++; /* Non-atomic write */
vTaskDelay(pdMS_TO_TICKS(200));
}
}
This task simulates a gradual interaction with the system, incrementing the
global state periodically. The system_state++ operation is not
atomic and involves multiple internal steps (read, compute, and write), making
it vulnerable to preemption by other tasks.
Task B — Concurrent and more aggressive update
void ControlTask(void *pvParameters)
{
(void)pvParameters;
for (;;)
{
system_state += 10; /* Concurrent write */
vTaskDelay(pdMS_TO_TICKS(150));
}
}
This task represents internal control logic of the system and modifies the same global variable, but with larger increments and higher frequency. Running in parallel with the operator task, it creates a real race condition, in which updates can be overwritten or lost.
Global state monitoring task
void LoggerTask(void *pvParameters)
{
(void)pvParameters;
for (;;)
{
DebugP_log(
"[tick=%lu] system_state=%u\r\n",
xTaskGetTickCount(),
system_state
);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
The monitoring task passively observes the value of the global state and logs its evolution over time via the debugging environment's C I/O (CIO) mechanism. This separation allows evidence of the problem to be collected without directly interfering with the behavior of the tasks that manipulate the state.
Task creation and preemption
void freertos_main(void *args)
{
(void)args;
xTaskCreate(OperatorTask, "Operator", 1024,
NULL, tskIDLE_PRIORITY + 2, NULL);
xTaskCreate(ControlTask, "Control", 1024,
NULL, tskIDLE_PRIORITY + 3, NULL);
xTaskCreate(LoggerTask, "Logger", 1024,
NULL, tskIDLE_PRIORITY + 1, NULL);
vTaskDelete(NULL);
}
The tasks are created with distinct priorities, ensuring preemption occurs. Since there is no form of synchronization whatsoever, the final value of the global variable comes to depend on the order in which the tasks execute, resulting in race conditions and silent overflows, directly observable in the system logs.
Test output
After some time running the code, the output observed in the debug terminal via CIO is recorded in the image below
The output shown clearly evidences the simultaneous occurrence of a
race condition and a silent variable overflow.
Initially, an apparently consistent growth in the value of
system_state is observed, resulting from the concurrent action
of the two tasks. However, upon reaching values close to the maximum limit
of the uint8_t type, abrupt transitions to low values occur,
such as the jump from 226 to 2 and from 250 to 36, characterizing
overflow due to modular arithmetic.
In this experiment, two distinct tasks write to the same global variable
system_state without any kind of protection. The first task
increments this variable slowly, adding 1 on each execution. The second
task also writes to the same state, but more aggressively, adding 10 each
time it runs. Since these tasks are scheduled preemptively by FreeRTOS,
there is no guarantee whether one will be interrupted in the middle
of its operation.
The expected behavior, in an ideal scenario, would be for the variable's
value to grow predictably, always reflecting the sum of these increments.
However, what is observed in the output is different. At several points,
one task begins updating the value of system_state, but is
interrupted before completing the operation. Meanwhile, the other task
uses this partial value as the basis for its own increment. When the
first task resumes execution, it writes a value calculated from a stale
state, overwriting the update made by the other task.
This interleaving of executions causes some increments to be lost and others to combine unexpectedly, producing values that follow no regular growth pattern. The result is non-deterministic behavior: the program keeps running, the value remains valid for the C language, but the internal state of the system comes to assume logically incoherent values. This is the classic effect of a race condition combined with a silent variable overflow.
How to Protect the System?
In well-designed systems, global states are not accessed directly by multiple tasks. Instead, access is mediated by a controlled interface, which centralizes consistency rules, synchronization, and bounds validation, eliminating race conditions and silent bugs.
What was added to the code and why
To fix the problems observed in the previous experiment, the code was modified with three clear goals: eliminate the race condition, prevent silent overflow, and make access to the state predictable and controlled.
-
The global state
system_statebecame private to the module (static), preventing direct access from other parts of the code. -
A mutex (
SemaphoreHandle_t) was introduced to ensure that only one task at a time can read or modify the state, eliminating the race condition. -
Access to the state was encapsulated in a safe API
(
SystemState_GetandSystemState_Add), avoiding direct writes to the global variable. -
The increment operation now includes explicit bounds
checking, applying saturation at
UINT8_MAXto prevent silent overflow.
With these changes, the system's behavior no longer depends on task scheduling and becomes deterministic, predictable, and safe, even in a preemptive environment.
Encapsulation and protection of the global state
static uint8_t system_state = 0;
static SemaphoreHandle_t state_mutex;
static void SystemState_Add(uint8_t value)
{
xSemaphoreTake(state_mutex, portMAX_DELAY);
if ((uint16_t)system_state + value > UINT8_MAX)
{
system_state = UINT8_MAX; /* saturation */
}
else
{
system_state += value;
}
xSemaphoreGive(state_mutex);
}
New output after modifications
Code Availability
The source code for the experiment is available on GitHub:
Comments and discussion