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 allocatescan()is cycle-domain work and follows the RT rules- the caller owns the aligned byte buffer passed to
Instance::load() AXIS_REF/GROUP_REFobjects 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 position1.25
20–25 minutes: read the key boundary
The ST source embedded in the host uses per-cycle motion values:
Velocity := 1.0Acceleration := 2.0Deceleration := 2.0Jerk := 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.