From Part 3 on, this series gets into the actual implementation. This part covers how the C# side and the PLC side were built, and how you use them.

To keep the explanation grounded I put together a small example. Count products as they pass a sensor, and when the count hits a target, report it to a server. Counting is what ladder has always done. Reporting to a server is not something a PLC ever did on its own. You’d normally stand a PC next to it or bolt on a data collector and hand the job over. That’s the grunt work I complained about in Part 1. Here, the ladder calls a C# function and handles the report itself.

I’ll walk through how this example is set up and built, and along the way look at each piece of the program, how it’s used, and why it was made the way it was.

Project

Senbrix project page - memory area sizes, an IO-8 board in a slot, Modbus TCP slave settings

The project page is where you define what this PLC is. Memory area sizes, which boards go in which slots, and communication links all get set here.

Memory area sizes

The area size section sets how big each memory area is. Memory is split into five areas.

AreaUnitDefaultPurpose
Pbit32768Physical I/O. Real inputs and outputs like sensors and motors land here
Mbit32768Internal flags used by the program
Tword2048Storage for ladder timer instructions
Cword2048Storage for ladder counter instructions
Dword4096Data registers that hold numeric values

Timers can only live in T, and counters only in C.

Boards

The board section picks which boards this project uses. There are up to nine slots. Click a slot, choose a board, and it’s plugged in. A plugged-in board gets its inputs and outputs mapped into the P area and its AD/DA channels into the D area automatically. If you want something different, change it in the I/O mapping.

The list of boards you can pick from is not hard-coded. It’s built by scanning the DLLs in the Modules folder, which makes it easy to extend. You can write one of these DLLs yourself as long as it follows the interface contract.

Communication

The communication section defines the links this PLC will use to talk to outside equipment. Hit Add, pick a protocol, and fill in the connection settings. Four come built in: Modbus RTU and TCP, each as master and slave.

The protocol list works the same way as the board list. Scan the Modules folder for DLLs, and write your own against the interface if you need to.

Add a slave and the PLC memory gets exposed to the outside as a standard Modbus table. The D area is split at a boundary address (100 by default): everything below it is read-only, everything from it up is writable. This is where a supervisory system or an HMI plugs in.

The example

This example keeps the default area sizes as they are. For the board, I put an IO-8 in slot 1 to take the product sensor. Inputs land on P0 to P3, outputs on P4 to P7, and the sensor comes in on P0. For communication I added a Modbus TCP Slave on port 502. From outside you can read 생산수량 (production count, D0) and write a new 목표수량 (target count, D100). Putting the target symbol at D100 was deliberate. That’s the boundary.

Symbols

Symbol page - 제품감지 P0, 생산수량 D0, 목표수량 D100, 보고완료 M10, 목표도달 M11

A symbol is a name attached to a memory address. Write everything in raw addresses and you won’t be able to read it a month later. So instead of P0 you name it 제품감지 (product sensor), something that says what it is, and use that same name in both ladder and C#. The symbol page is where these get registered and managed as a table.

What a symbol is made of

ColumnWhat it holds
NameThe identifier used in code. Case-sensitive, and Korean is allowed
AddressThe memory address. The suffix decides the type
PathEquipment hierarchy. Groups signals into a tree by which device they belong to
AccessRead-only or read-write
Description, unitA note on what the signal means and its unit
RetainWhether the value survives a restart

Memory resets to 0 on restart. A symbol with Retain on keeps its value across a power cycle. Retain only applies to M, C and D. P is physical I/O, so it gets read fresh from the hardware after a restart and there’s nothing worth saving. At build time the addresses of all retained symbols are gathered into ranges, signed, and shipped to the runtime. The runtime saves only those ranges to a file and restores them on restart. It restores only if the project and the signature match.

Columns like address, path and description are not there just for humans. When AI writes, analyzes or diagnoses ladder and code, it works from this symbol information.

Address types

You may have noticed the table has no column for data type. The address decides the type. More precisely, the suffix hanging off the address decides it. Memory is a byte buffer in the end, and the D area views it in words. The suffix is how you read a given spot.

SuffixMeaningWidthC# typeLadder expressionC# expression
(none)Unsigned 16-bit, same as .W1 wordintD0D[0].W
.WUnsigned 16-bit1 wordintD0.WD[0].W
.ISigned 16-bit1 wordintD0.ID[0].I
.DWUnsigned 32-bit2 wordsuintD0.DWD[0].DW
.DISigned 32-bit2 wordsintD0.DID[0].DI
.Rfloat2 wordsfloatD0.RD[0].R
.SString (UTF-8, up to 256 bytes)variablestringD0.SD[0].S
.LLow byte1 byteintD0.LD[0].L
.HHigh byte1 byteintD0.HD[0].H
.0 to .15Individual bit within the word1 bitboolD0.3D[0].Bit[3]

The same D0 becomes an integer, a float or a string depending on the suffix. One location, viewed through several types at once. The catch is that .DW, .DI and .R take two words, so D0.R occupies both D0 and D1.

The ladder and C# expressions differ because of what C# syntax allows. D0.3 is not a valid expression in C#, so on that side the same spot is reached through an indexer and a property: D[0].Bit[3].

P and M are bits, so they take no suffix. If you want to see a bit area as words, use WP and WM. These are not separate memory. They’re the same buffer as P and M, viewed a word at a time. Write to WP0 and P0 through P15 change.

The example

This example has five symbols. 제품감지 (product sensor) on P0, 생산수량 (production count) on D0, 목표수량 (target count) on D100, 보고완료 (report done) on M10, and 목표도달 (target reached) on M11. Physical input goes in P, numbers in D, internal state in M. I never picked a type for any of them.

Ladder

Ladder page - six rungs: init, count, check, C# call, reset, clear

Ladder is the language that draws machine control out of contacts and coils. The ladder page is where you write the program in it. Below I go through how you write it, the functions, and how special relays are used, in that order.

Writing ladder

You write ladder by dropping elements into cells on a grid, one per cell. Pick them from the palette on the right or hit a shortcut key. These are the elements you can place.

ElementShortcutKindDescription
NONESpaceDeleteEmpties the cell. For removing what you placed
IN AF3Input, A contactNormally open. Passes when the value is on
IN BF4Input, B contactNormally closed. Inverts the value, so it passes when the value is off
NOTF9Input, invertInverts the result up to that point
R EdgeF11Input, edgeRising edge. Passes for one cycle only, the moment it goes from off to on
F EdgeF12Input, edgeFalling edge. Passes for one cycle only, the moment it goes from on to off
LINE HF5WireHorizontal wire. Carries left to right
LINE VF6WireVertical wire. Ties the rows above and below in parallel
OUT COILF7OutputWrites the rung result into a bit
OUT FUNCF8OutputCalls a function or a C# method when the rung passes

Each cell has a position, a kind, and a code (an address or an expression). Column 0 on the far left is the power rail. Chain contacts left to right and you get a series AND. Tie rows together with a vertical line and you get a parallel OR. If there’s a path from the rail on the left to the output on the right, the rung passes.

There are two kinds of output. A coil writes the result into a bit. A function runs code. Whatever you write in the function cell runs when the rung passes, whether it’s an assignment like 생산수량 = 생산수량 + 1 or a function call.

Ladder functions

Ladder functions are the built-in instructions you put in a function cell. These are the instructions any PLC ships with, timers and counters and the like. Right now there are sixteen of them in five groups.

GroupInstructionDescription
TimerTONON delay. Counts while the condition is on and turns the relay on when it reaches the preset. 10 ms units
TAONON delay. 100 ms units
TOFFOFF delay. Turns the relay off once the preset time has passed after the condition goes off. 10 ms units
TAOFFOFF delay. 100 ms units
TMONMonostable. Even a momentary condition turns the relay on for the preset time. 10 ms units
TAMONMonostable. 100 ms units
CounterCTUIncrements on each rising edge. Turns the relay on at the preset
CTDDecrements on each rising edge. Turns the relay on at zero
CTRResets the counter when the condition is on
OutputSETOUTSets the relay when the condition is on (latch)
RSTOUTResets the relay when the condition is on
Master controlMCSStart of a section. Numbered 0 to 15
MCSCLREnd of the section with the same number. Paired with MCS
DataWXCHGSwaps two word values
DISTSplits 16 bits into 4-bit nibbles and stores them
UNITGathers the low nibbles into one word

Take counting. You could write it as a plain assignment, 생산수량 = 생산수량 + 1. Or you give CTU a counter address and a preset, and it counts every time the input rises and turns the relay on when the preset is reached.

These instructions check their arguments. A timer relay has to be in the T area, a counter in C, a preset between 1 and 65535. Each slot takes only what it’s supposed to. Get it wrong and the build catches it.

These sixteen are the bare foundation of ladder. I didn’t add more, and the reason is C#. A typical PLC also ships arithmetic, comparison, conversion and communication as part of its instruction set. In Senbrix, C# takes that seat. Math, string parsing, date arithmetic: .NET already has all of it, and there’s no reason to rebuild it as ladder instructions. Ladder functions cover only the floor that machine control needs.

Special relays

In a contact cell you can also put a special relay, which starts with @, instead of an address. These are not memory addresses. The runtime updates them.

TokenMeaning
@ON / @OFFAlways ON / always OFF
@BEGINON only during the first cycle
@10R to @1000RPeriodic pulse. ON for one cycle every N ms (8 periods: 10, 20, 50, 100, 200, 250, 500, 1000 ms)
@F10R to @F1000RFlicker. Toggles ON and OFF every N ms (same 8 periods as above)

The example

  @BEGIN                                     목표수량 = 10
──┤ ├───────────────────────────────────────────[ ]

The first rung is initialization. On the @BEGIN contact, 목표수량 is set to 10. @BEGIN is on only during the first cycle, so the target becomes 10 exactly once, at startup.

  제품감지                            생산수량 = 생산수량 + 1
──┤ ├────┤↑├────────────────────────────────────[ ]

The second rung counts. A rising edge sits after the 제품감지 contact, and 생산수량 goes up by one. The edge holds last cycle’s state and compares it with this one, passing for a single cycle only at the moment it flips from off to on. Without it, the count would keep climbing every 10 ms for as long as the sensor is held.

  @ON                  목표도달 = 목표수량 > 0 && 생산수량 >= 목표수량
──┤ ├───────────────────────────────────────────[ ]

The third rung is the check. The always-on @ON contact assigns 목표도달. Every cycle it recomputes whether a target is set (> 0) and whether the count has reached it, and refreshes the 목표도달 bit.

  목표도달     보고완료                     ReportProduction()
──┤ ├────────┤/├────────────────────────────────[ ]

The fourth rung calls C#. The 목표도달 contact and the B contact of 보고완료 are wired in series. It passes only when the target is reached and nothing has been reported yet. When it passes, it calls ReportProduction().

  보고완료                                    생산수량 = 0
──┤ ├───────────────────────────────────────────[ ]

The fifth rung resets. It picks up the 보고완료 that C# wrote and puts 생산수량 back to 0.

  보고완료     목표도달                       보고완료 = false
──┤ ├────────┤/├────────────────────────────────[ ]

The sixth rung clears. After the count resets and 목표도달 drops, it drops 보고완료 and gets ready for the next round. Ladder evaluates rungs top to bottom, so this order is the order things happen.

Code

Code page - three files, App.cs, App.Ladder.cs and App.Symbols.cs, and the ReportProduction function

The code page is where you write your own C#. The editor only goes as far as syntax highlighting, so for any real coding session where you want autocomplete and the rest, ‘Open in IDE’ and working in an external IDE is a lot more comfortable. The explorer shows three files: App.cs, App.Ladder.cs, App.Symbols.cs. App.cs is the one I write by hand. App.Ladder.cs is generated from the ladder at build time, and App.Symbols.cs from the symbols. The ladder drawn earlier and the symbols registered earlier turn into C# here and join up. You can open and edit the two generated files, but they’re regenerated on every build, so there’s no point. The source of truth is the ladder and symbol pages.

Ladder + C# = App

App is a single class where ladder and C# merge, and it’s the actual PLC program. The three files on the code page are partials of App, the body of a program that inherits PlcApp. So when you build, the ladder logic, the symbol properties and my hand-written C# all become members of the same App, and the output is a single App.dll.

Stripped to the minimum, the PlcApp that App inherits looks like this.

public abstract class PlcApp : CsApp
{
    // PLC memory, shared by ladder and C#
    public IBitMemory P { get; }
    public IBitMemory M { get; }
    public IWordMemory T { get; }
    public IWordMemory C { get; }
    public IWordMemory D { get; }

    // Ladder entry point, called by the 10 ms cycle. Overridden in App.Ladder.cs
    public virtual void LadderLoop() { }

    // User C# entry points, run on a separate Task. Overridden in App.cs
    protected virtual void Setup() { }
    protected virtual void Loop() { }

    // Ladder function instructions
    protected void TON(int idx, int val, bool condition) { /* … */ }
    protected void CTU(int idx, int preset, bool condition) { /* … */ }
    // …

    // Boards and communication
    public List<IBoard> Boards { get; }
    public List<ICommProtocol> Communications { get; }
}

This PlcApp does not live in the project. It lives in the Senbrix.Controller core. The editor builds against this core and the runtime on the Raspberry Pi uses the same core, so what you write in the editor and what runs on the floor don’t drift apart.

The build itself is nothing special. Write the ladder and symbols to disk as .cs files and dotnet build the project. There’s no embedded compiler, just an ordinary C# build. Which means errors come out as ordinary C# compile errors. The one addition is that every line of generated ladder code carries its original ladder coordinates as a comment, so when the compiler points at some line of App.Ladder.cs, that maps back to a row and column in the ladder and is shown that way.

Let’s look at what goes into each of the three files.

User C# (App.cs)

App.cs is the one of the three that I write by hand. The C# in here lives two lives. One is functions the ladder calls. The other is Arduino-style Setup and Loop. These override the entry points from the sketch above. Setup runs once at start, Loop keeps going, and it runs on a separate Task so it doesn’t collide with the ladder cycle.

Symbol access (App.Symbols.cs)

App.Symbols.cs gets a property for every name registered on the symbol page.

public int 생산수량 { get => D[0].W; set => D[0].W = value; }

What follows the name is the address, verbatim. Ladder and code both go through this property, touching the same memory by the same name. This is why in C# you can read and write 생산수량 as if it were a plain variable.

Calling C# (App.Ladder.cs)

App.Ladder.cs is where the ladder lands as C# code. The fourth rung of the example comes out roughly like this.

_result_ = 목표도달 & !보고완료;
if (_result_)
{
    ReportProduction();
}

Contacts become a condition, and when it passes the function runs. The 목표도달 and 보고완료 in the condition are the symbol properties we just saw, and the ReportProduction() call is inside the same class, so it’s a plain method call. No reflection, no events.

Here’s the thing though. The output of that fourth rung is also a function cell. The same cell where the timers and counters from the ladder function set go. So why does ReportProduction() become a C# call rather than a ladder instruction? The ladder looks the function name up in the instruction set first. If it’s a name it knows, like TON or CTU, it plants that built-in instruction. If not, it drops the text in as a C# expression, as is. ReportProduction is not in the instruction set, so it goes in as a C# function call. Same cell: a known name becomes a ladder instruction, an unknown name becomes the C# I wrote.

Extensibility

Ladder normally lives inside the instructions the vendor gave you. Need something outside that list and you’re stuck. So you hand it off. Senbrix ladder, when it hits the wall, calls C#. And C# gets all of .NET. Call an API over HTTP, insert into a DB, read a file, run a heavy calculation. What ladder can do is no longer tied to the ladder instruction set. The ladder function set is the floor. C# opens the ceiling above it.

You don’t even have to go far. A library of control, signal and metering algorithms comes built in, so PID, filters, totalizers and the like are called, not written. If that’s not enough, add a NuGet package. One line in the build settings file and it survives the next build.

The roles stay separate. When to act, whether it’s safe, what state we’re in: ladder decides. What to compute and where to send it: C#. The function call is the bridge between the two.

An open ceiling does not mean anything goes. A function the ladder calls runs inside the ladder cycle. If it takes a long time, the cycle stalls right there. The 10 ms slips. So no await and no blocking. Even the send to the server is not fired directly; it goes into a queue and gets handled in the background. The function reads a value, sets a flag, and gets out. The ceiling is open, with one rule attached: don’t block the cycle.

The example

The user C# in this example is a single function, the ReportProduction() that the fourth rung calls.

public void ReportProduction()
{
    int qty = 생산수량;              // read 생산수량 (D0)
    // Server report goes here: HTTP POST or DB INSERT
    Console.WriteLine($"[생산보고] 생산수량={qty} 목표 도달");
    보고완료 = true;                 // tell the ladder the report is done
}

Read the count, send it to the server, mark it done. The server send is just a log line for now, but HTTP POST or DB INSERT, write it in C# and it works. The fifth rung of the ladder picks up the 보고완료 that C# wrote and resets, and that works because both sides touch the same memory. All the control flow is in the ladder, so Setup and Loop are left empty.

For a ladder engineer, server reporting and DB loading were always someone else’s territory. That’s why the PC got stood up next to the PLC. Here, the ladder counts the machine, and only when it needs to reach outside does it call this function. It never leaves ladder, and the job the programmer used to do happens inside the same program.

Wrapping up

The sensor on the floor comes in on P0 and the ladder counts. Hit the target and the ladder calls C#. C# reads the count and reports it, then writes 보고완료, and the ladder picks that up and resets. Meanwhile the supervisory system reads 생산수량 over Modbus and, when it needs to, writes a new 목표수량.

The job from Part 1, standing up a second PC next to the PLC to run a collection program, is handled here by a few rungs of ladder and one C# function. The person who wrote the ladder sends values to a server without ever leaving ladder.

Next up - Running on the Raspberry Pi

That’s the writing part. Actually running this on a Raspberry Pi is Part 4.

Live monitoring of a ladder program deployed to the runtime