CODESYS supports object-oriented features, but predictable PLC reliability comes from bounding execution and memory behavior rather than banning encapsulation. A test that converted the current RTC value to an IEC string with a time zone inside a regular scan exceeded scan time; the code compiled without warnings or errors. Treat that as a runtime-budget problem to measure and prevent, not as proof that every object-oriented construct is unsafe.
Choose an object-oriented subset with explicit limits
Object-oriented programming (OOP) groups data and behavior behind defined interfaces. That organization is separate from runtime mechanisms such as dynamic allocation and pointer access. Encapsulation can improve maintainability without allocating memory dynamically; conversely, code can use pointers without gaining useful encapsulation. Set policy feature by feature instead of treating OOP as one indivisible choice.
A practical CODESYS policy can retain function blocks, encapsulation, and interfaces while restricting dynamic memory, pointer use, and inheritance depth. One team may instead prohibit OOP features it cannot review consistently. Those are project policy choices, not guarantees of reliability: the implementation still has to meet execution-time and memory constraints.
| Feature | Reliability question | Practical boundary |
|---|---|---|
| Encapsulation | Can callers affect behavior only through the documented contract? | Keep implementation state local; expose required commands and status through a public interface. |
| Dynamic allocation | Can allocation, lifetime, or memory consumption vary at runtime? | Prefer fixed, statically declared storage for cyclic control; allow exceptions only with a documented bound and lifecycle. |
| Pointers | Can the address become invalid or refer to the wrong data? | Use only for a necessary case, validate before access, and refresh after changes that can move memory. |
| Inheritance and polymorphism | Can reviewers trace the complete behavior and execution path? | Prefer composition and interfaces; keep any base class simple and hierarchy shallow. |
Do not set a blanket rule such as “OOP is unreliable” or “interfaces make code safe.” Neither says whether a scan has a bounded workload or whether a reference is valid. Make reviewable limits part of the coding standard: which features are permitted, what exception evidence is required, and who verifies the runtime cost.
- Check the project rule set. Expected: each feature has a stated rule and review condition; no feature is implicitly approved merely because the compiler accepts it.
Separate successful compilation from scan-time proof
A compiler checks language rules, types, and other compile-time conditions. It does not prove that a function's worst-case execution time fits the task's configured cycle budget. String conversion and formatting are especially worth measuring when input length, formatting work, or call frequency can vary. The reported RTC-to-IEC-string conversion ran in the regular scan and exceeded scan time despite compiling cleanly.
A task overrun and a controller crash are not interchangeable diagnoses. An overrun means the task did not complete within its configured timing constraint; the runtime's response depends on the controller and configuration. A stopped task, watchdog response, or controller fault must be identified from the runtime diagnostics rather than inferred from the word “crash.” The test case provides a concrete failure mechanism—too much work in a scan—but does not supply an error code or controller-specific response.
- Read the task configuration and record its cycle interval and watchdog or execution limit.
- Monitor task execution time and the controller's task/runtime diagnostics while running the suspected conversion at its actual call frequency and with representative inputs.
- Repeat with the heaviest valid inputs and other work that runs in the same task; compare measured execution against the configured limit.
Use the runtime's task monitor or diagnostic buffer supported by the target. Do not infer a safe margin from an average scan; compare the worst measured execution under relevant operating conditions with the configured budget. If the platform's diagnostic distinguishes a missed deadline from a stopped task or controller fault, retain that distinction in the test record.
- Check the timing diagnosis. Expected: the monitor shows the conversion's measured cost and whether the task stayed within its configured limit; any runtime fault is identified by its actual diagnostic, not guessed from compilation results.
Keep cyclic storage fixed and bounded
Static declaration makes the storage requirement visible before runtime: array dimensions and function-block instances have defined sizes in the program. Dynamic allocation adds runtime decisions about whether storage can be obtained and how long it remains owned. Allocation leaks consume memory over time; fragmented or exhausted memory can make behavior depend on prior execution history. These failure modes make dynamic storage difficult to bound in a control task.
The conservative default is to declare arrays and instances at fixed sizes and reuse them. If a feature needs a variable number of records, use a fixed-capacity representation with an explicit count and reject or fault requests beyond that capacity according to the machine's defined behavior. This keeps the maximum storage requirement visible, but it does not automatically bound the time needed to search, copy, or process all records: bound those loops as well.
Where CODESYS dynamic allocation is available, treat it as an exception rather than an incidental convenience. Before accepting it, document the maximum allocation, allocation and release points, ownership, behavior when the requested memory is unavailable, and how repeated operation proves that memory does not grow without bound. If those conditions cannot be reviewed and tested, use fixed storage instead.
- List the arrays, instances, and buffers used by the cyclic path, including their declared capacities.
- For any dynamic allocation, trace every creation and release path and define the result when capacity is unavailable.
- Exercise maximum-capacity and repeated-use cases while observing the target's memory diagnostics.
- Check storage limits. Expected: fixed structures show their declared maximum, processing loops respect that maximum, and repeated tests do not show unexplained memory growth.
Constrain pointer and reference lifetimes
A pointer stores an address, so correctness depends on that address still identifying the intended object when it is dereferenced. CODESYS pointers are true pointers; they are not merely symbolic aliases. An online change can shift memory, so a pointer saved in a declaration or retained across such a change can become stale. A successful build does not establish that a runtime pointer remains valid.
Prefer REFERENCE or VAR_IN_OUT where the interface can express the required data flow without manually managing an address. Reserve POINTER, ADR, and dereference syntax such as ^ for cases that need address-level access, such as traversing bytes. Consider whether a UNION provides the required representation access without pointer arithmetic. Where pointer use remains necessary, define what object it may address, when it is refreshed, and how validity and bounds are checked before every use.
- Search the code for pointer declarations, address creation, and dereferences; document the owning object and the valid lifetime for each.
- Update the pointer immediately before use if the target can move, rather than relying on an address initialized once in a declaration.
- Test valid, null or otherwise invalid, and boundary cases using checks available in the target environment; repeat the checks after an online change that can alter memory layout.
Do not copy a pointer-handling pattern without knowing the target language's validity mechanisms and data layout. If qualifiers such as CONST or VOLATILE are used, verify their supported semantics in the project toolchain and apply them to the actual access requirement; they do not replace lifetime or bounds checks.
- Check each dereference path. Expected: every access has a current, valid target and a verified range; tests after online change do not use a pre-change address.
Prefer composition and interfaces over deep inheritance
Inheritance couples a derived behavior to assumptions in its base, and deeper hierarchies make the executed behavior harder to trace during review. Composition keeps collaborating function blocks as explicit components. Interfaces define the operations and status that callers may use without exposing implementation details. In CODESYS, a restrained pattern is to extend only a simple abstract base where it removes genuine duplication, while generally composing components and interacting through interfaces.
Polymorphic organization does not itself establish a timing bound. For every interface call in a cyclic task, reviewers still need to identify the possible implementation and account for the work each path performs. Keep a predictable call graph: avoid a hierarchy in which a reviewer must search many layers to understand how a command reaches an output or how a fault reaches its caller. Use inheritance only where the shared contract and behavior remain clear.
- For each base type, state the behavior shared by every derived function block and keep the base narrow.
- For each interface call on a cyclic path, enumerate the implementations that can be selected and review their timing and data requirements.
- Use composition when a component relationship can express the design without a deeper inheritance chain.
Interfaces are useful boundaries, not a substitute for inspection. A public call can still invoke expensive string handling, an unbounded loop, or an invalid pointer operation. Review the implementation behind the contract whenever the called behavior changes.
- Check the call graph. Expected: each cyclic interface call resolves to a known implementation set, and each path has bounded work that fits the measured task budget.
Expose a stable public contract and protect local state
Encapsulation reduces accidental dependencies when callers use commands and status rather than reaching into another function block's implementation variables. In the CODESYS practice described here, local variables are not accessed directly from outside the function block, even though the environment permits that access. This is a useful team rule: a change to private implementation state should not silently break unrelated logic or HMI references.
Define public inputs, outputs, and status according to the interactions the caller needs. A team using a physical-model organization can pair a function block with a public data structure and a separate instance data block. The names PublicData and PrivateData are conventions in that approach, not language-enforced privacy: another block or an HMI may technically read the instance data. Enforce the boundary through code review and expose any cross-module value through the public structure instead of adding direct references to local state.
Keep ownership clear. A module should own its internal state and report meaningful state or fault information through the contract. A caller should issue the documented request and consume the documented feedback, not reproduce internal sequencing by inspecting hidden flags. This keeps code portable because the caller depends on behavior rather than storage layout.
- Identify every value read or written outside its owning function block.
- Move required cross-boundary data into the public contract and remove direct dependencies on local implementation variables.
- Review HMI and supervisory references as callers too; external visibility does not make a value part of the supported interface.
- Check module boundaries. Expected: cross-module and HMI access uses documented public fields or interfaces; no external code relies on local state or a private storage layout.
Map equipment behavior to bounded module layers
A physical-model organization can deliver modularity and reuse without relying on dynamic objects. The structure described here uses a control module for an individual device, an equipment module for related devices and equipment behavior, a unit module for equipment modules, and a process cell for units. Use these as organizational layers where they fit the plant; they do not require a project to force every application into the same hierarchy.
Put device-specific behavior in the control module: for example, a motor, valve, or fan function block can own its local mode, interlock response, fault, reset, and output behavior. An equipment module composes the relevant device modules and any associated measurement scaling or calibration. A unit-level sequence can coordinate equipment modules; references to physical or network I/O belong at a deliberately chosen boundary rather than being scattered through every layer. In a design with multiple units in one PLC, the process-cell layer can coordinate those units. Keep the I/O boundary explicit and consistent.
Separate equipment behavior from recipe or supervisory sequencing by asking whether an action is intrinsic to the equipment or commanded by the broader process. For vessel purging, the equipment module can own the valve sequence and a parameterized purge operation; a phase or higher-level sequence can request the operation and supply purge quantity, time, or flow when the recipe determines those values. If the operation is a self-contained equipment function independent of the wider sequence, keep its state machine with that equipment. If it coordinates several equipment modules, place the orchestration at their common parent rather than duplicating low-level control.
For batch logic, a task may manage start, stop, hold, and stage progression; recipe stages select unit processes such as adding an ingredient, mixing, or heating; those processes command equipment control functions. Keep this division as simple as the process allows. Excessive splitting increases the number of handoffs and makes fault tracing harder, while an overly broad module obscures which layer owns a sequence.
- Draw the actual asset and control relationships from device to equipment and unit; add a process-cell layer only when it coordinates multiple units.
- For each operation, decide whether it is equipment-internal or recipe/process-driven; identify the module that owns the sequence and the parameters passed across its interface.
- Trace physical I/O references and verify they occur only at the chosen module boundary.
- Check one representative sequence end to end. Expected: each command has one owning layer, equipment interlocks and faults remain with the equipment behavior, and the higher-level sequence receives defined status without direct access to local variables.
Commission the complete workload on the target
Reliability is a property of the deployed implementation under its actual workload, not a label attached to its programming style. A useful acceptance test combines structural review with runtime measurement. It exercises the expensive conversion that exposed the problem, the maximum declared data cases, all implementations reachable through cyclic interfaces, and pointer paths that can be affected by online changes.
Measure on the target controller and in the task configuration used by the application. Include other work that executes in the same task; timing one function in isolation can understate the cost of the combined scan. Record the configured cycle and watchdog values from the project rather than substituting a generic timing target. If the maximum tested execution approaches or exceeds the limit, reduce work in the cyclic path, bound its inputs and loops, or move noncritical computation to a deliberately scheduled task only after verifying the data exchange and timing requirements.
For the RTC formatting case, test the real conversion with representative and maximum-format values at the intended call rate. Consider whether formatting needs to occur every scan; if not, invoke it only when the displayed value changes at the application's required update rate. That change must preserve the required time-zone and display behavior, and its actual task cost still needs measurement. Never treat a slower call rate as proof of safety without testing concurrent task work and the target's diagnostics.
- Load the reviewed application and run the actual cyclic task with the maximum relevant input sizes, repeatable RTC conversion, and the complete set of concurrent logic.
- Record worst observed task execution and compare it to the configured timing limit; inspect task and runtime diagnostics for overruns or faults.
- Exercise each pointer-dependent path before and after any online change included in the commissioning procedure, and verify the public status and equipment response at the module boundary.
- Repeat the run after the final code change; preserve the measured result, configured task limit, inputs, and diagnostic outcome in the commissioning record.
- Final end-to-end check. Expected: the task completes within its configured limit under the tested maximum workload, runtime diagnostics show no related overrun or fault, pointer checks pass after applicable changes, and the intended equipment behavior and public status agree.
Frequently asked questions
Why can CODESYS compile a string function that causes a task overrun?
Compilation checks the program's language and type rules, not whether its runtime work fits the cyclic task budget. Measure the function in the task monitor with representative and maximum inputs, then compare its execution with the configured limit.
Why avoid dynamic memory in a PLC task?
Allocation and object lifetime add runtime memory behavior that is harder to bound than fixed declarations; leaks can consume memory over repeated operation. Prefer fixed-capacity arrays and instances unless the allocation limit, ownership, failure behavior, and release paths are explicitly controlled and tested.
How do I verify a CODESYS pointer after an online change?
Refresh the address before use, validate that it targets the expected object and range, and test the dereference path after the online change. Do not reuse a pointer captured before a change that can shift memory; the final check is a passing runtime test of the affected path with no invalid access or unexpected status.