← All Posts

IEC 61131-3 Structured Text: A Practical Guide for Modern PLC Programming

Ladder logic grew out of relay wiring diagrams in the late 1960s and maps relay circuits to a screen. For simple start/stop logic, it's fine. For anything with math, loops, state machines, or data processing, it can become hard to follow, even for the person who wrote it.

Structured Text (ST) is the IEC 61131-3 language that looks and works like a modern programming language. If you've written Python, JavaScript, or C, ST will feel familiar. If you're new to PLC programming but comfortable with code, start with ST, not ladder logic. This guide covers everything you need to write your first ST program, with real examples you can adapt to CODESYS, TwinCAT, OpenPLC, or any other IEC 61131-3 environment.

What Is IEC 61131-3 Structured Text?

IEC 61131-3 is the international standard for PLC programming languages. It defines five languages. Structured Text is the high-level, text-based one. Think of it as Pascal or a simplified C designed specifically for industrial control:

  • Text-based: you type code, not draw rungs. Version control works. Copy-paste works. Search-and-replace works.
  • Block structured: code is organized into PROGRAMs, FUNCTION_BLOCKs, and FUNCTIONs. Each is a self-contained unit with local variables and interfaces.
  • Cyclic execution: unlike Python scripts that run top-to-bottom once, ST programs run in a continuous scan loop: read inputs → execute logic → write outputs → repeat. This is fundamental to how PLCs work.
  • Strongly typed: variables have explicit types (BOOL, INT, REAL, etc.). Type mismatches are caught at compile time, not when the motor spins the wrong direction.

The other IEC 61131-3 languages (Ladder Diagram (LD), Function Block Diagram (FBD), Sequential Function Chart (SFC), Instruction List (IL)) each have their place. But ST is the one that scales to complex programs and the one that software-native engineers reach for first.

ST Syntax in 10 Minutes

Variables and Types

Every variable must be declared before use. Declarations go in a VAR...END_VAR block at the top of a PROGRAM or FUNCTION_BLOCK:

PROGRAM MotorControl
VAR
    start_button : BOOL;          (* true/false *)
    stop_button  : BOOL;
    motor_run    : BOOL;
    speed_sp     : REAL := 0.0;   (* floating point, initialized *)
    counter      : INT := 0;      (* signed 16-bit integer *)
    temperature  : REAL;
    alarm_active : BOOL := FALSE;
END_VAR

Common data types:

  • BOOL: true/false
  • INT, DINT, SINT: signed integers (16-bit, 32-bit, 8-bit)
  • UINT, UDINT, USINT, ULINT: unsigned integers; BYTE, WORD, DWORD: bit strings
  • REAL, LREAL: floating point (32-bit, 64-bit)
  • TIME: time duration (e.g., T#500ms, T#2s, T#1m30s)
  • STRING: text strings
  • ARRAY [0..9] OF INT: fixed-size arrays

Conditional Logic: IF / ELSIF / ELSE

IF temperature > 80.0 THEN
    cooling_fan := TRUE;
    alarm_active := TRUE;
ELSIF temperature > 60.0 THEN
    cooling_fan := TRUE;
    alarm_active := FALSE;
ELSE
    cooling_fan := FALSE;
    alarm_active := FALSE;
END_IF;

Nested IFs work as you'd expect. Parentheses around conditions are optional; ST uses AND, OR, NOT, and XOR instead of &&, ||, !. Comparison uses = and <>, while assignment uses :=. Writing = where you meant := is one of the commonest beginner mistakes, and every statement ends with a semicolon, including END_IF;.

Multi-Way Branch: CASE

CASE pump_state OF
    0:  (* idle *)
        pump_run := FALSE;
        valve_open := FALSE;
    1:  (* starting *)
        valve_open := TRUE;
    2:  (* running *)
        pump_run := TRUE;
    3:  (* stopping *)
        pump_run := FALSE;
ELSE
    pump_state := 0;  (* fallback *)
END_CASE;

Loops: FOR, WHILE, REPEAT

(* FOR loop: known iteration count *)
FOR i := 0 TO 9 DO
    readings[i] := 0;
END_FOR;

(* WHILE loop: condition at start *)
WHILE count < max_count DO
    count := count + 1;
END_WHILE;

(* REPEAT loop: condition at end, always runs at least once *)
REPEAT
    result := result * 2;
    steps := steps - 1;
UNTIL steps = 0
END_REPEAT;
Watch out: Avoid infinite loops in ST. Remember your program runs in a scan cycle. A WHILE loop that doesn't terminate blocks the scan, and the PLC's watchdog timer will fault. Keep loops bounded.

Timers: The Heartbeat of PLC Programs

Timers are the single most-used function blocks in PLC programming. Every motor start has a delay. Every alarm has a debounce. Every sequence has a timeout. IEC 61131-3 defines three standard timer types:

TON: Timer On-Delay

Turns output ON after a delay when input goes TRUE. The most common timer, used for startup delays, sequential operations, and debouncing.

(* Declared in VAR: my_timer : TON; *)
(* Turn on an output 2 seconds after the input goes true *)
my_timer(IN := start_signal, PT := T#2s);
IF my_timer.Q THEN
    output := TRUE;
END_IF;

If start_signal goes FALSE before 2 seconds, the timer resets. ET (elapsed time) shows current progress. Q is TRUE when elapsed time ≥ preset time.

TOF: Timer Off-Delay

Turns output OFF after a delay when input goes FALSE. Used for cooling fans that run after the motor stops, or lights that stay on after the door closes.

(* Keep fan running 30 seconds after motor stops *)
my_tof(IN := motor_running, PT := T#30s);
fan := my_tof.Q;

TP: Timer Pulse

Generates a pulse of fixed duration when triggered. Used for one-shot operations: open a valve for exactly 5 seconds, regardless of how long the trigger signal stays high.

(* Open valve for exactly 5 seconds on trigger *)
my_tp(IN := trigger, PT := T#5s);
valve_open := my_tp.Q;

Counters: CTU, CTD, CTUD

Counters track events: parts produced, cycles completed, errors detected. IEC 61131-3 defines three types:

(* Declared in VAR: part_counter : CTU; *)
(* Count up: CV increments on each rising edge of CU *)
part_counter(CU := sensor_on, RESET := FALSE, PV := 1000);
IF part_counter.Q THEN
    batch_full := TRUE;  (* Q is TRUE when CV >= PV *)
END_IF;

(* CTD: count down from preset *)
(* CTUD: combined up/down counter *)

Edge Detection: R_TRIG and F_TRIG

PLCs scan continuously. Without edge detection, a button press that's TRUE for 10 scans would trigger your logic 10 times. Edge detectors fire only on the transition:

(* Declared in VAR: button_edge : R_TRIG; stop_edge : F_TRIG; *)
(* R_TRIG: detect rising edge (FALSE to TRUE) *)
button_edge(CLK := start_button);
IF button_edge.Q THEN
    cycle_count := cycle_count + 1;  (* increments exactly once per press *)
END_IF;

(* F_TRIG: detect falling edge (TRUE to FALSE) *)
stop_edge(CLK := stop_button);
IF stop_edge.Q THEN
    motor_run := FALSE;
END_IF;

Real-World Example: Motor Start/Stop with Overload Protection

Here's a complete motor control program in Structured Text. It handles start/stop buttons with a seal-in, a motor contactor output, a thermal overload input with a latched trip, a restart lockout, and a trip counter. Stop and overload take priority over start. The stop button and the overload contact are both wired normally closed, so a broken wire stops the motor.

PROGRAM MotorStarter
VAR
    (* I/O *)
    start_btn    : BOOL;   (* normally-open pushbutton: TRUE while pressed *)
    stop_btn     : BOOL;   (* normally-closed pushbutton: FALSE while pressed *)
    overload_ok  : BOOL;   (* NC thermal overload contact: FALSE when tripped *)
    fault_reset  : BOOL;   (* normally-open reset pushbutton *)
    contactor    : BOOL;   (* motor contactor output *)

    (* Internal *)
    motor_run    : BOOL;
    tripped      : BOOL;
    start_edge   : R_TRIG;
    trip_edge    : F_TRIG;
    cooldown     : TOF;
    trip_count   : DINT := 0;
END_VAR

(* Edge detection: one pulse per press, one pulse per trip *)
start_edge(CLK := start_btn);
trip_edge(CLK := overload_ok);

IF trip_edge.Q THEN
    trip_count := trip_count + 1;
END_IF;

(* Latch the trip; clear it only on reset with the overload healthy *)
tripped := (tripped OR NOT overload_ok) AND NOT (fault_reset AND overload_ok);

(* Restart lockout: cooldown.Q stays TRUE for 5 s after the motor stops *)
cooldown(IN := motor_run, PT := T#5s);

(* Seal-in: start latches the motor on; stop or trip drop it out *)
motor_run := ((start_edge.Q AND NOT cooldown.Q) OR motor_run)
             AND stop_btn AND NOT tripped;

(* Output *)
contactor := motor_run;
END_PROGRAM

Each scan the program reads the inputs, runs the logic once, and updates the outputs. The edge detectors make the trip counter increase once per trip and the start button act once per press, not once per scan. F_TRIG and R_TRIG start with no pending edge, so downloading the program while the overload is healthy does not count a false trip. Note that the cooldown restarts every time the motor stops, including after a trip.

ST vs Ladder Logic: When to Use Each

This is the wrong question. The right question is: what's the best language for this specific task? Modern PLCs support multiple languages in the same project. Use ST for the complex logic, ladder for the simple I/O mapping, and SFC for the sequence orchestration.

But if you're starting fresh and you have any programming experience: start with ST. It's more expressive, more maintainable, and version-control friendly. You can always add ladder diagrams later for the specific rungs where they add clarity.

Practicing Without PLC Hardware

You do not need a physical PLC to learn ST. The CODESYS Development System is a free download for Windows and has an Online > Simulation command that runs your application inside the IDE. OpenPLC Editor runs on Windows, macOS and Linux and includes a built-in simulator with a live debugger. IronPLC runs ST from VS Code or in a browser playground. Write the examples above, step through the scan, and watch the variables change before you touch a controller.

When you move to real devices, many controllers can expose variables over Modbus TCP, though it depends on the model and has to be configured. That makes it easy to check that your program writes the registers you expect. See the ladder logic post for how SCADA reads that data, and the TwinCAT alternatives and PLC programming on a Mac posts for tool options.

Check What Your PLC Exposes

Once your ST program is running on a controller, Modbus Poll reads and writes its Modbus registers from your Mac so you can confirm the program does what you wrote. Voltrus SCADA then turns those tags into dashboards and alarms.

Explore Modbus Poll

Further Reading