Estaba mirando un proyecto de TIA Portal que había crecido… otra vez.
Más FBs, más flags, más lógica duplicada. Nada estaba “mal” — la máquina funcionaba — pero cada vez era más difícil razonar sobre el sistema.
En un momento dado me pillé pensando:
"Ya estoy diseñando objetos. Solo que no lo estoy admitiendo."
Esta entrada es un ejemplo práctico y honesto de cómo aplicar diseño orientado a objetos en Siemens TIA Portal usando construcciones nativas de PLC — y después mostrar el mismo diseño implementado en C# con POO real.
0) El repo demo detrás de este post
Si quieres ver el “sistema real” (no solo fragmentos), este artículo está basado en una demo pequeña con doble implementación en mi GitHub: CylDemo_DualRepo. Contiene la misma arquitectura expresada dos veces:
- Siemens TIA Portal (SCL): construcciones nativas (FB + DB de instancia + UDTs), sin “herencia falsa”.
- C#: el equivalente con constructos OOP reales (interfaces/clases), para comparar las ideas 1:1.
Leer el post con el repo abierto en otra pestaña es la forma más rápida de “atar cabos”.
1) Dos capturas que explican la arquitectura
A veces la arquitectura se entiende mejor mirando el árbol del proyecto que leyendo párrafos. Estas dos imágenes son el “mapa” de todo lo que viene:
1) Primero: qué NO es POO en PLCs
En lenguajes de alto nivel, “POO” suele implicar: asignación dinámica, herencia profunda, despacho virtual, excepciones y un runtime que oculta detalles.
En un PLC, cada capa de abstracción se paga con tiempo de ciclo, memoria y, a veces, con menor facilidad de diagnóstico. El PLC debe seguir siendo predecible: mismo ciclo, mismo orden de ejecución, y propiedad clara de los datos.
Por eso el enfoque aquí es pragmático: tomamos lo que sí aporta en automatización—encapsulamiento, interfaces claras y composición—y evitamos lo que choca con el modelo de ejecución cíclica.
- Sin “magia” de runtime: sigues ejecutando determinísticamente en OB1.
- Sin ciclo de vida implícito: una instancia de FB es memoria fija (DB de instancia).
- Comportamiento explícito: siempre puedes señalar dónde se decide algo y qué datos se escriben.
POO no va de palabras clave. En TIA Portal no tenemos herencia de clases, métodos virtuales ni despacho dinámico al estilo C#. Intentar fingir esas features suele añadir complejidad sin mejorar la claridad.
Así que la pregunta correcta no es “¿TIA soporta POO?”. La pregunta correcta es:
¿Puedo aplicar principios de diseño POO usando construcciones nativas de PLC?
La respuesta es sí — si nos centramos en interfaces, encapsulación y composición.
2) La idea núcleo: misma interfaz, distinto comportamiento
Esta es la versión “amigable para PLC” del polimorfismo: no necesitas vtables para aprovecharlo. Basta con un contrato externo coherente y libertad para implementarlo de formas distintas.
En TIA, el “polimorfismo” se consigue llamando a instancias distintas de FB detrás de la misma frontera. Un cableado típico en OB1 se ve así:
// OB1 (o un bloque de programa de equipo)
#Cmd.Enable := #HMI_Enable;
#Cmd.Extend := #HMI_Extend;
#Cmd.Retract := #HMI_Retract;
// El resto del programa solo ve la interfaz compartida:
#Cylinder(Cmd := #Cmd, Status => #St);
La clave es que #Cylinder expone el mismo Cmd/Status independientemente de si por dentro usa CylA, CylB o una variante futura.
En la práctica, esto permite que tu HMI, tu secuenciación (SFC/lógica por pasos) o tu FB “supervisor” manden sobre “un cilindro” sin importar si es neumático, eléctrico, simulado o incluso un gemelo digital.
En el repositorio de demo, ambos cilindros se controlan con los mismos comandos (extender/retraer) y exponen los mismos estados (extendido/retraído/ocupado/fallo). La diferencia está dentro.
Elegí un sistema deliberadamente simple: dos cilindros.
- CylA: neumático — comportamiento discreto, depende de un permisivo (
PressureOk). - CylB: eléctrico — modela posición (0..100) y se mueve scan a scan.
Desde fuera, quiero tratarlos igual. Eso implica un contrato común:
- una interfaz de comandos
- una interfaz de estado
3) En PLC, las interfaces viven en datos (UDTs), no en comportamiento
En TIA Portal no declaramos interfaces como en C#, pero podemos obtener gran parte del beneficio definiendo un contrato de datos que toda implementación respete.
Regla práctica: mantén los UDTs de interfaz pequeños, estables y “aburridos”. Deben describir lo que necesita el exterior, no lo que el interior decide almacenar.
- UDT de comandos: peticiones por flanco, modos, consignas.
- UDT de estado: feedback estable, banderas de busy, diagnósticos.
- Versionado: mejor añadir campos al final y con valores por defecto seguros.
Con una interfaz en UDT, puedes mapearla a HMI, registrarla, exponerla por OPC UA o reutilizarla en varias máquinas sin destapar el estado interno.
En C#, una interfaz es un elemento del lenguaje. En PLC, el equivalente práctico es un UDT usado como contrato de datos.
3.1 Interfaz de comandos (TIA Portal — SCL)
TYPE UDT_IActCmd :
STRUCT
Enable : BOOL;
Extend : BOOL;
Retract : BOOL;
END_STRUCT
END_TYPE
3.2 Interfaz de estado (TIA Portal — SCL)
TYPE UDT_IActStatus :
STRUCT
Extended : BOOL;
Retracted : BOOL;
END_STRUCT
END_TYPE
Sin lógica. Sin estado privado. Solo contrato. Esto es la versión PLC de:
public interface IActCmd
{
bool Enable { get; }
bool Extend { get; }
bool Retract { get; }
}
public interface IActStatus
{
bool Extended { get; }
bool Retracted { get; }
}
4) Los Function Blocks como “clases” (estructura class-like)
Piensa en un FB como la plantilla y en su DB de instancia como la memoria del objeto. Esto te da:
- Encapsulamiento: las estáticas quedan dentro del FB (si no las “filtras” hacia fuera).
- Estado por instancia: cada DB guarda sus temporizadores, enclavamientos y variables internas.
- Inicialización tipo constructor: un bloque de
firstScanoInitfija valores una sola vez.
Un patrón útil es: entradas/salidas externas en UDTs de interfaz, y lo interno en un UDT de implementación o en estáticas. Por fuera, superficie limpia; por dentro, libertad para evolucionar.
Cada cilindro se implementa como un Function Block:
FB_CylA(neumático)FB_CylB(eléctrico)
Lo importante no es la keyword FB, sino que cada FB:
- posee su estado interno
- decide su comportamiento
- publica el estado a través de la interfaz
Para hacerlo explícito (y muy didáctico), estructuré cada FB como una clase: constructor + métodos, usando REGION y un flag firstScan.
4.1 CylA — implementación neumática (SCL)
REGION Constructor
IF #firstScan THEN
#isExtended := FALSE;
#isRetracted := TRUE;
#firstScan := FALSE;
END_IF;
END_REGION
REGION ExecuteCommands
#valveExtend := FALSE;
#valveRetract := FALSE;
IF #Cmd.Enable AND #Inp.PressureOk THEN
IF #Cmd.Extend THEN
#valveExtend := TRUE;
ELSIF #Cmd.Retract THEN
#valveRetract := TRUE;
END_IF;
END_IF;
END_REGION
REGION UpdateInternalState
IF #valveExtend THEN
#isExtended := TRUE;
#isRetracted := FALSE;
ELSIF #valveRetract THEN
#isExtended := FALSE;
#isRetracted := TRUE;
END_IF;
END_REGION
REGION PublishStatus
#Status.Extended := #isExtended;
#Status.Retracted := #isRetracted;
END_REGION
La misma idea en C# (POO real): campos privados + Update() + propiedades públicas.
public sealed class CylA : IActStatus
{
private bool _isExtended;
private bool _isRetracted = true;
public void Update(IActCmd cmd, bool pressureOk)
{
if (!cmd.Enable || !pressureOk) return;
if (cmd.Extend) { _isExtended = true; _isRetracted = false; }
else if (cmd.Retract) { _isExtended = false; _isRetracted = true; }
}
public bool Extended => _isExtended;
public bool Retracted => _isRetracted;
}
5) Implementación distinta, misma interfaz (CylB)
CylB es deliberadamente “distinto por dentro” manteniendo el mismo uso por fuera. Internamente modela posición y movimiento scan a scan, pero expone estado binario:
REGION ExecuteCommands
IF #Cmd.Enable THEN
IF #Cmd.Extend THEN
#target := 100;
ELSIF #Cmd.Retract THEN
#target := 0;
END_IF;
END_IF;
END_REGION
REGION UpdateMotion
IF NOT #Cmd.Enable THEN
#target := #position; // parar al deshabilitar
END_IF;
IF #position < #target THEN
#position := #position + #step;
IF #position > #target THEN #position := #target; END_IF;
ELSIF #position > #target THEN
#position := #position - #step;
IF #position < #target THEN #position := #target; END_IF;
END_IF;
END_REGION
REGION PublishStatus
#Status.Extended := (#position = 100);
#Status.Retracted := (#position = 0);
END_REGION
Y el equivalente en C#:
public sealed class CylB : IActStatus
{
private int _position = 0;
private int _target = 0;
private int _step = 1;
public void Update(IActCmd cmd, int speedPerScan)
{
_step = FC_ClampInt.ClampInt(speedPerScan, 1, 100);
if (cmd.Enable)
{
if (cmd.Extend) _target = 100;
else if (cmd.Retract) _target = 0;
}
else
{
_target = _position; // parar al deshabilitar
}
if (_position < _target) _position = Math.Min(_position + _step, _target);
else if (_position > _target) _position = Math.Max(_position - _step, _target);
}
public bool Extended => _position == 100;
public bool Retracted => _position == 0;
}
6) El contenedor: composición, no herencia (FB_Cylinder)
En vez de heredar de una clase base, componemos el cilindro con bloques más pequeños y enfocados:
- Una capa “API” que expone la interfaz común (comando + estado)
- Una capa de implementación que mueve válvulas o actualiza posición
- Ayudantes opcionales (interlocks, antirrebote, timeouts, gestión de fallos)
En la práctica se parece a un patrón Strategy: el FB contenedor decide qué implementación ejecutar según configuración (tipo, hardware disponible, modo simulación).
La ventaja: puedes añadir un tercer tipo (por ejemplo servo por PROFINET) sin reescribir la lógica de secuencias que usa la interfaz.
FB_Cylinder no es una clase base. Es un orquestador que posee dos objetos y los ejecuta en cada scan (composición por multi-instancia).
#CylA(Cmd := #CmdCylA, Inp := #CylA_Params, Status => #CylA_Status);
#CylB(Cmd := #CmdCylB, Cfg := #CylB_Params, Status => #CylB_Status);
Si necesitas seleccionar una implementación en tiempo de ejecución (por ejemplo, según configuración), deja la decisión fuera de los bloques concretos. El contenedor decide y delega en cada ciclo:
// Dentro de FB_Cylinder (boceto)
IF #Cfg.UseAnalog THEN
#CylB(Cmd := #Cmd, Cfg := #CfgB, Status => #Status);
ELSE
#CylA(Cmd := #Cmd, Inp := #CfgA, Status => #Status);
END_IF;
Así el resto del programa no cambia: desde fuera sigues hablando con FB_Cylinder, no con CylA/CylB.
En C# esto es un objeto contenedor normal:
public sealed class FB_Cylinder
{
private readonly CylA _cylA = new();
private readonly CylB _cylB = new();
public void Update(/* inputs */)
{
// build commands
// execute both objects
}
}
7) Las FC como “métodos estáticos” (comportamiento compartido)
Las FC son perfectas para comportamiento que sea:
- Sin estado: la salida depende solo de las entradas
- Reutilizable: usado por varios FBs
- Fácil de probar: determinista y sin efectos laterales
Ejemplos: utilidades de flancos, saturaciones, evaluación estándar de timeouts y pequeñas políticas (“¿es seguro moverse?”).
Si una FC empieza a “pedir memoria”, es señal de que debe convertirse en FB para que el estado sea explícito y por instancia.
Las reglas reutilizables viven en FCs sin estado. Ejemplo: clamp de configuración.
FUNCTION FC_ClampInt : INT
VAR_INPUT
Value : INT;
Min : INT;
Max : INT;
END_VAR
VAR_TEMP v : INT;
v := Value;
IF v < Min THEN v := Min; END_IF;
IF v > Max THEN v := Max; END_IF;
FC_ClampInt := v;
public static class FC_ClampInt
{
public static int ClampInt(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
}
8) Sobre la herencia (la versión honesta)
La herencia clásica no es la herramienta principal en PLC. Incluso en proyectos con “POO real”, la herencia profunda suele empeorar la legibilidad y el test.
Alternativas “friendly” para PLC:
- Composición: construir comportamiento complejo con FBs pequeños (multiinstancia).
- Protocolos con UDTs: contratos estables entre capas.
- Máquinas de estados: modos explícitos con transiciones claras (idle, moviendo, fallo).
- Plantillas por copia: duplicar un FB y mantener la interfaz idéntica cuando conviene (duplicación controlada > herencia enredada).
No hay herencia en la implementación PLC. TIA Portal no ofrece herencia entre FBs como C#.
Pero el diseño sí se puede entender como herencia conceptual:
- misma interfaz
- mismo uso desde fuera
- implementaciones internas distintas
Herencia en diseño, no en sintaxis PLC.
9) Cierre
Si te quedas con una sola idea: diseña alrededor de interfaces y propiedad de datos. Si cada dispositivo “posee” su estado y solo expone lo necesario, el proyecto escala mejor—al añadir dispositivos, variantes o simulación.
La demo mantiene un mapeo mental 1:1 con C#: FBs ≈ clases, UDTs ≈ interfaces, FCs ≈ helpers estáticos. Es un recurso didáctico, pero el objetivo real es práctico: software de automatización legible que sobreviva a la siguiente puesta en marcha.
Siguientes pasos que puedes probar:
- Añadir una implementación de Simulación que ignore hardware y actualice el estado desde un modelo.
- Introducir una estructura estándar de fallos (código, id de texto, timestamp) en el estado.
- Crear un FB “supervisor” que secuencie equipos solo mediante sus UDTs de comando/estado.
Los PLCs no impiden el buen diseño. Simplemente te obligan a ser explícito.
Si diseñas con:
- interfaces de datos (UDTs)
- comportamiento encapsulado (FBs)
- composición (multi-instancias)
- reglas compartidas sin estado (FCs)
…obtienes una estructura limpia, escalable, y que mapea de forma natural a la POO real en lenguajes de alto nivel.