Skip to content

IEC 61131-3 ST — 30-minute journey

Use the built-in ST runtime when you want PLC-style sequencing on top of the same motion kernel. The host still owns memory, binding, and scan scheduling; the VM only executes the program.

In this journey you will compile one ST program, bind an AXIS_REF, drive MC_Power and MC_MoveAbsolute, then observe completion through a host-side symbol read. The exact host below is compiled and run in CI.

Requirements

  • C++17 compiler (GCC 9+, Clang 10+, MSVC 2022+)
  • CMake 3.21+
  • An installed plcopen package or a local build prefix

0–10 minutes: understand the ownership model

The ST runtime does not hide host responsibilities:

  • compile() is load-domain work and may allocate
  • scan() is cycle-domain work and follows the RT rules
  • the caller owns the aligned byte buffer passed to Instance::load()
  • AXIS_REF / GROUP_REF objects stay in the host and are bound explicitly

That separation is deliberate. It keeps RT semantics obvious and testable.

10–20 minutes: build the canonical ST consumer

Create a directory containing this CMakeLists.txt:

cmake_minimum_required(VERSION 3.21)
project(plcopen_st_journey LANGUAGES CXX)

find_package(plcopen CONFIG REQUIRED)
add_executable(st_motion st_motion.cpp)
target_compile_features(st_motion PRIVATE cxx_std_17)
target_link_libraries(st_motion PRIVATE plcopen::plcopen)

Copy this host program to st_motion.cpp:

#include "axis/state.h"
#include "rt/error_text.h"
#include "st/st.h"

#include <cmath>
#include <cstdint>
#include <iostream>
#include <vector>

namespace
{

using namespace plcopen::core;

constexpr const char *kSource = R"(PROGRAM motion_demo
VAR
  AxisX : AXIS_REF;
  Power : MC_Power;
  Move : MC_MoveAbsolute;
  Powered : BOOL;
  Busy : BOOL;
  Done : BOOL;
  Error : BOOL;
  ErrorID : DINT;
END_VAR
Power(Axis := AxisX, Enable := TRUE);
Move(
  Axis := AxisX,
  Execute := Power.Status,
  ContinuousUpdate := FALSE,
  Position := 1.25,
  Velocity := 1.0,
  Acceleration := 2.0,
  Deceleration := 2.0,
  Jerk := 10.0,
  Direction := MC_DIRECTION#current,
  BufferMode := MC_BUFFER_MODE#aborting);
Powered := Power.Status;
Busy := Move.Busy;
Done := Move.Done;
Error := Move.Error;
ErrorID := Move.ErrorID;
END_PROGRAM
)";

std::vector<std::uint64_t> make_storage(const st::Program &program)
{
    return std::vector<std::uint64_t>((program.required_bytes() + 7U) / 8U + 1U);
}

int fail_compile(const st::CompileResult &compiled)
{
    std::cerr << "st motion demo: compile failed\n";
    for (const st::Diagnostic &diagnostic : compiled.diagnostics)
    {
        std::cerr << "  line " << diagnostic.line << ", column " << diagnostic.column << ": "
                  << st::to_string(diagnostic.code);
        if (!diagnostic.message.empty())
        {
            std::cerr << " - " << diagnostic.message;
        }
        std::cerr << '\n';
    }
    return 1;
}

int fail_runtime(const char *stage, rt::ErrorCode error)
{
    std::cerr << "st motion demo: " << stage << " failed: " << rt::to_string(error) << '\n';
    return 1;
}

int fail_scan(st::ScanError error, int cycle)
{
    std::cerr << "st motion demo: scan failed at cycle " << cycle << ": " << st::to_string(error)
              << '\n';
    return 1;
}

} // namespace

int main()
{
    const st::CompileResult compiled = st::compile(kSource);
    if (!compiled.ok)
    {
        return fail_compile(compiled);
    }

    std::vector<std::uint64_t> storage = make_storage(compiled.program);
    st::Instance instance;
    axis::AxisModel axis;

    const rt::ErrorCode loaded =
        instance.load(compiled.program, reinterpret_cast<unsigned char *>(storage.data()),
                      storage.size() * sizeof(storage[0]), 1000000);
    if (loaded != rt::ErrorCode::ok)
    {
        return fail_runtime("load", loaded);
    }

    if (instance.bind_axis("AxisX", &axis) != st::BindingError::ok)
    {
        std::cerr << "st motion demo: bind_axis failed\n";
        return 1;
    }

    const int powered_index = instance.find("Powered");
    const int busy_index = instance.find("Busy");
    const int done_index = instance.find("Done");
    const int error_index = instance.find("Error");
    const int error_id_index = instance.find("ErrorID");
    if (powered_index < 0 || busy_index < 0 || done_index < 0 || error_index < 0 ||
        error_id_index < 0)
    {
        std::cerr << "st motion demo: demo symbols not found\n";
        return 1;
    }

    bool done = false;
    bool saw_powered = false;
    int cycle = 0;
    for (; cycle < 4000; ++cycle)
    {
        const st::ScanError scan = instance.scan(512);
        if (scan != st::ScanError::ok)
        {
            return fail_scan(scan, cycle);
        }

        saw_powered = saw_powered || instance.value_bool(static_cast<std::size_t>(powered_index));
        if (instance.value_bool(static_cast<std::size_t>(error_index)))
        {
            const auto code = static_cast<rt::ErrorCode>(
                instance.value_i64(static_cast<std::size_t>(error_id_index)));
            std::cerr << "st motion demo: motion FB error: " << rt::to_string(code) << '\n';
            return 1;
        }
        done = instance.value_bool(static_cast<std::size_t>(done_index));
        if (done)
        {
            break;
        }
        axis.cycle();
    }

    const double final_position = axis.snapshot().command_position;
    std::cout << "st motion demo: powered=" << saw_powered << ", done=" << done
              << ", busy=" << instance.value_bool(static_cast<std::size_t>(busy_index))
              << ", cycles=" << cycle << ", position=" << final_position << '\n';

    return saw_powered && done && std::fabs(final_position - 1.25) < 1e-8 ? 0 : 1;
}

Configure, build, and run it. Replace /opt/plcopen with your install prefix:

cmake -S . -B build -DCMAKE_PREFIX_PATH=/opt/plcopen
cmake --build build --config Release
./build/st_motion

Exit code 0 means:

  • the ST source compiled
  • the program loaded into a caller-owned buffer
  • bind_axis("AxisX", &axis) succeeded
  • repeated scan() + axis.cycle() drove the axis to position 1.25

20–25 minutes: read the key boundary

The ST source embedded in the host uses per-cycle motion values:

  • Velocity := 1.0
  • Acceleration := 2.0
  • Deceleration := 2.0
  • Jerk := 10.0

Those values are interpreted as per-cycle quantities in the 1 kHz task passed to Instance::load(). If your human-facing config is in SI units, convert once in the host with rt::CycleConfig, then generate or inject the ST inputs accordingly.

25–30 minutes: decide when ST is the right surface

Choose ST when:

  • the machine logic should stay PLC-shaped
  • sequencing belongs with IEC 61131-3 function blocks
  • the hardware bridge still lives in a host application

Choose direct C++ when you want every motion call site under native code control. You can also mix both: ST for sequencing, C++ for adapters.

More detail