Silent Bugs in C – Vol. 1: The Therac-25 Case and Hardware Demonstrations

How invisible failures in C led to real accidents — practical simulations on an embedded Cortex-R5F system (TI AM243x)

🚀 Reading Tip:
For the best use of this article, it is recommended that the reader have basic familiarity with the following topics:
  • C Language: Integer types, variable scope, and runtime behavior of arithmetic operations.
  • Concurrency in C: Difference between sequential and concurrent execution, and initial notions of shared data access.
  • Real-Time Systems: Basic notions of RTOS, preemptive tasks, and scheduling.

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

Therac-25 operating interface displayed on a VT100 terminal
Operating interface of the Therac-25 system displayed on a DEC VT100 terminal. Source: Wikipedia, article Therac-25. Available at: https://en.wikipedia.org/wiki/Therac-25 (CC BY-SA license).

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.

Expected output (with synchronization)
      [TASK A] Should have used a semaphore
      [TASK B] Need to implement a critical section
          
Output with race condition (without synchronization)
      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 < 100 becomes 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_log are 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

Didactic example of internal and external fragmentation in a heap
Debug terminal output demonstrating the constant overflow and race condition problems.

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_state became 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_Get and SystemState_Add), avoiding direct writes to the global variable.
  • The increment operation now includes explicit bounds checking, applying saturation at UINT8_MAX to 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

Didactic example of internal and external fragmentation in a heap
Code with protections against overflow and race conditions.

Going deeper into the study

Although the use of a mutex and encapsulation already eliminates the race conditions and silent overflow demonstrated in this experiment, professional embedded systems often adopt even more robust architectures for state management.

In larger-scale projects, it is common to completely avoid shared access to global variables. Instead, the system state is maintained by a dedicated task, and the other tasks interact with it through explicit communication mechanisms, such as queues, messages, or events.

This approach reduces coupling between tasks, simplifies concurrency analysis, and makes the system's behavior more predictable, facilitating verification, validation, and certification activities in critical systems.

For readers who wish to deepen the theoretical and practical foundation of these techniques, a technical booklist has been prepared with fundamental references on:

  • Concurrency models and inter-task communication
  • Deterministic architectures in real-time systems
  • Design best practices for embedded and safety-critical software
→ See recommended reading for further technical information.

Code Availability

The source code for the experiment is available on GitHub:

References

  1. Leveson, N. G., Turner, C. S. An Investigation of the Therac-25 Accidents. IEEE Computer, vol. 26, no. 7, 1993.
  2. Bryant, R. E., O’Hallaron, D. R. Computer Systems: A Programmer’s Perspective. Pearson, 3rd ed.
  3. Buttazzo, G. Hard Real-Time Computing Systems: Predictable Scheduling Algorithms and Applications. Springer, 3rd ed.
  4. ARM Ltd. Procedure Call Standard for the ARM Architecture (AAPCS).
  5. Wilson, P. R., Johnstone, M. S., Neely, M., Boles, D. Dynamic Storage Allocation: A Survey and Critical Review. University of Texas at Austin.
  6. FreeRTOS. Memory Management. Available at: freertos.org. Accessed on 23/12/2025.
  7. Rierson, L. Developing Safety-Critical Software: A Practical Guide for Aviation Software and DO-178C Compliance. 4th ed., CRC Press, 2024.
Enjoyed the experiments?

Comments and discussion