Skip to content

Remove Mapper system (Tag<M,T>, IPlcMapper, DintPlcMapper, TagReal etc). #406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
timyhac opened this issue Jul 17, 2024 · 19 comments
Open

Comments

@timyhac
Copy link
Collaborator

timyhac commented Jul 17, 2024

A few years after the release of the official libplctag.NET wrapper, a pattern has emerged that all library consumers face a key problem;

What is the binary format of a tag buffer (a byte array)? How does it encode the tag's value?

In this ticket I will be making the argument that the Mapper system (Tag<M,T>, IPlcMapper, etc) provided by libplctag.NET has magnified this problem by obscuring/hiding it from library users.
This is to the detriment of those users, reduces the ability of the libplctag community to assist those users, and increases the burden on library authors - and therefore should be removed.

Introduction

libplctag handles the details of communicating with PLCs over Modbus or Ethernet/IP Explicit Messaging, so you can easily access the value of PLC tags.

This statement is mostly true; while it is correct that you (most of the time) do not need to understand the protocol itself, you do need to understand the device's Modbus or Ethernet/IP API, as not all devices are the same.

This is similar to using a HTTP REST API - while you do not need to understand the details of TCP/IP or even HTTP, you definitely need to know what data to send and how to interpret the response.

For example; you need to know how to configure the EthernetIP or Modbus requests by using an appropriate tag "name", path/gateway, and other attributes, and how the device will structure its response (a byte array) to encodes tag values.
For basic cases such as Atomic tag types (e.g. DINT, REAL) - this is usually straightforward and you only need to use a single Data Accessor method to extract/write the value, furthermore these are usually common across devices and for tag names.
UDTs, STRINGs, BOOLs and arrays however, are non-trivial.

In general, you will need prior knowledge of how these tags are structured.
This knowledge needs to come from device manuals or from reverse engineering efforts.

The good: it handles a common case well!

In Tag<M,T>, the M is a PlcMapper which converts between a byte array (the tag buffer) and the T .NET type and makes it possible to implement the Decode/Encode logic for a data type once, and then re-use it for 1D, 2D and 3D arrays as well as the atomic type.
For this use-case it excels.
This system can even be used for UDTs with static sizes.
The value is strongly-typed, which holds significant value in the .NET ecosystem and particularly C#.

It is entirely possible that this serves the silent majority of users perfectly - they have no complaints, and we do not hear from them.
They have used the system as it was intended and moved on with their life.

It's utility stems from being able to instantiate a strongly-typed Tag, simply by providing a Mapper class type (and most of the time this is built-in).
And even this can be hidden because we also ship "simple" classes.

The complexity cliff

Tag<M,T> makes a (somewhat implicit) promise that you as a consumer do not need to understand how PLC data is encoded in the buffer, as the PlcMapper handles that for you.
However, over time it has become clear that there are additional capabilities that PLCs offer, and that library consumers need, that the Mapper system doesn't fulfil.

At the time of development, the Mapper system was consistent with the core libplctag API, and was universally applicable (at least within the confines of libplctag.NET developers' limited exposure to other PLC vendors).
The key insight was that single values (whether they be atomic or UDTs) and arrays of values can use the same decoding logic.
The logical conclusion is that much of the setup of tags can be contained within PlcMapper classes, and Tag<M,T> merely needs to expose the Read/Write commands and events.
This abstraction falls off a "cliff" as soon as you go outside the simplest of cases.
But the promise remains - the PlcMapper is meant to do it, and it does not - therefore it is a bug in the PlcMapper or Tag<M,T>.

Ultimately, using libplctag does not preclude you (the library consumer) from understanding the device's Ethernet/IP API.

Consumers will encounter this working

  • When working with some of the more complicated types (BOOL Arrays, Strings, UDTs)
  • Using some advanced CIP services such as Tag Listing.
  • Arrays of arrays (a.k.a. Jagged arrays)
  • Dynamic Tags
  • When using multiple make/models of device (Allen-Bradley encode some tags different to Omron)
  • When using different tag names (e.g. MyBoolArray and MyBoolArray[0] return different data on Omron).

If we want to continue to make the promise that consumers do not need to understand their device's Ethernet/IP API, the only path forward for this project is to wait for complaints to arrive, and incrementally add handlers for specific cases into the mappers.
The mappers would effectively become a documentation store for this knowledge.

Its another API to learn, support, test and maintain

The good news is that consumers are not locked into using the built-in mappers.
If they are able to understand how their devices behave, they can add custom mappers.

However, this is another API that consumers must learn.
The Mapper system uses some somewhat advanced C# language features which can be tricky for early-career developers to understand and implement.

If there are features that consumers want (such as value change detection), consumers could spend a considerable amount of time trying to determine how libplctag.NET supports it (which it may not), rather than just implementing it themself.

Furthermore:

  • libplctag wiki does not apply to most of it.
  • There are three wrapper classes plus the mapper - objects which must be garbage collected.
  • There are many cases where the returned PLC memory does not have a static size.
  • It is code that the libplctag community must document, support and maintain.

It would be simpler if that layer was removed, and consumers directly interfaced with Tag - it confronts them with the complexity of the underlying API and does not hide any of the "foot-guns" (which exist whether they can see them or not).

Many consumers still add their own wrappers

A study of applications or libraries that are consuming libplctag.NET shows that most will create yet another wrapper around Tag<M,T>.
This implies that it is not the optimal level of abstraction and doesn't add much value.

Some examples:

What's the alternative?

Use an abstract base class and handle the specific data types with derived classes.

This has several advantages over the Tag<M,T>+PlcMapper system:

  • Several use-cases would have been immediately enabled (e.g. hooks to add before ad after Initialize is called).
  • Consumers can add their own methods such as custom setters/getters that trigger events.

The issue with this is that there is not an obvious universal interface that would serve as the base class.
As an example, should the Tag value be exposed as a .NET type such as int or should it be object - or should it be exposed at all?
What if the type was an array, and instead of exposing the entire array, the library consumer would prefer to force their application code to go through a method with an indexer (so that it can raise events or run other logic).

Summary

While Tag<M,T> has utility for a common use-case, the API it exposes is not the lowest-common denominator.
Having it is a burden on library consumers and maintainers alike.

I recommend:

  • Removing ITag, Tag<M,T>, all Mappers and Simple Tag classes, and also not supplying an alternative like an abstract base type.
  • Instead, moving Tag<M,T> and a demonstration of an alternative like an abstract base mapper type to the Examples folder.
  • Move Tag Listing for Rockwell PLC to the Examples folder.
  • Creating a separate repository within the libplctag organization for known tag buffer encodings for combinations of PlcType, Tag Name, Protocol, etc
  • Clarify the promise that libplctag makes to customers - what should libplctag be capable of, and what is left up to the consumer to discover?

If this happens, the libplctag.NET would be closer to a "wrapper" in spirit, rather than what could be argued to be an opinionated framework for working with libplctag.

I would be happy to offer a separate Nuget package for the Mapper system, but not as an "official" package by the libplctag organization, instead released under a "timyhac" brand such as timyhac.libplctag.Mappers

I've created this as a Github issue to open it up for community feedback. I realize that there are many users of the Mapper system at this point and I want to take into account the impact on them.

@kyle-github
Copy link
Member

kyle-github commented Jul 17, 2024 via email

@trinnan42
Copy link

trinnan42 commented Jul 17, 2024

I have some concerns with this. (Note: Please correct me if I misunderstood anything in your explanation.)

UDTs

We're currently using the custom mappers to handle UDTs quite extensively.

How do you imagine this would impact how UDTs are handled?

Would something in this new version of libplctag.NET handle UDTs itself in a way that wouldn't necessitate the mappers? If not, we would need to use this potential timyhac.libplctag.Mappers package or maintain these classes ourselves? If i understand correctly Tag<M,T> and IPlcMapper, would move to be examples?

I'm concerned with needing more dependencies if the latter were the case. I would prefer that it remains part of the main library to needing another library to maintain.

Wrapper

Just to give a little more information regarding your mention of wrappers, we do have our own wrapper around libplctag.NET, but for the purpose of being able to support other PLC manufacturers and protocols like Siemens and OPC UA.

We have an IPlcComm interface and then an implementation for each PLC type, currently LibPlcTagComm and SiemensOpcUaComm.

We define our tags in an XML file with a generic name that we use throughout our business logic. This name is defined with associated in the XML file, for each PLC driver that we support, with information used to generate the tag with (address, data type, etc.). Then I do some reflection to generate a dynamic typed Tag<M,T> instance using the appropriate native data type and its corresponding PlcMapper. It may be a bit messy but it's working quite well for us. (Unfortunately I can't directly provide the code itself...)

Here again, we'd either need to rewrite all of this logic to use just what libplctag.NET provides or use this additional Mapper library and again, I'd prefer if this all just remained part of the library.

@timyhac
Copy link
Collaborator Author

timyhac commented Jul 18, 2024

Hi @trinnan42 - thanks for the feedback!

You are correct in saying you would need to find an alternate implementation for the mapper system - either one you maintain yourself or a separate package. The proposal can be seen in this branch.

As I see it, the value of Tag<M,T> and the Mapper system is:

  1. The tag value is exposed as a C# type rather than a collection of Data Accessors.
  2. The mapper logic is a store of knowledge for how the tag bytes are structured, and which Data Accessors to use, for various scenarios.

For point 1, it sounds like you're already doing some logic to convert from a typed value back into dynamic - so I don't think you're receiving much of that benefit.

For point 2, frankly I'm doing a terrible job at supporting scenarios other than the fairly specific Rockwell/Atomic tag scenario - and I don't really have a way to improve this other than wait for complaints to roll in.

Do you mind if I ask what value you get out of the Tag<M,T> class?

Thanks so much for the detailed response to the post!

@timyhac
Copy link
Collaborator Author

timyhac commented Jul 18, 2024

Any kind of structured data should not be the core's concern. Strings raise the difficulty even higher because they are inconsistent even across the same hardware. Strings should not be supported in the core or the core wrapper.

@kyle-github - yep 100%, and I think that the libplctag.NET wrapper using the libplctag name makes some implicit promise about what it does, which can be tricky to get to the bottom of when things go wrong.

@trinnan42
Copy link

@timyhac

Well, to try to explain with more detail what I'm doing without being able to share actual code:

  1. I have an Enum which defines the available Data Types.
  2. I have some Dictionary instances which map these data types to C# native data types and the Mappers.
  3. I create a dynamic tag variable by creating an instance of Tag<,> with the types from these dictionaries based on the Data Type Enum's value.
  4. I have Read<T> and Write<T> methods exposed to my code defined with my IPlcComm interface.
  5. The Read method instantiates this dynamic instance of Tag<M,T> and then I just directly return Tag<M,T>.Value for Read and set Tag<M,T>.Value for Write.

I guess I'm not exactly sure myself what value I'm getting out of Tag<M,T>. When I began using this a couple years ago. I based what I'm doing off of the examples provided which all seemed to use Tag<M,T>. It was my assumption that this was how the library was meant to be used. I never used or looked at the base C library, so I didn't know what it offered.

Since I'm not familiar with libplctag itself, I guess what I'm not sure about is how I would do things without Tag<M,T>. Would we use just the Tag class and then just use its Set[Type] and Get[Type] methods like in this example?

It seems I'd have to replace this code with something like a switch statement that just uses the base Tag.Set[Type] method based on the type I'm trying to read?

We're also doing a lot with arrays of tags and UDTs. That support is provided by the PlcMapper system, right? Otherwise we'd be looping through and reading the tag with these base Set and Get methods? I think the PlcMapper system is indeed preventing us from writing some of that code ourselves.

We're also using the code from the NotifyChanged example to keep things current. Is that something specific to Tag<M,T>?

I was somewhat watching the recent problems someone was having with Omron, is that part of what's prompting this? The differences between how the different PLC manufacturers handle the specifics and the mappers not handling things properly between this differences?

We do intend to support Omron as some of our customers do require their PLCs, though we haven't made any attempt to implement this so far. I was planning on attempting to use OPC UA (and that might be our path forward for all PLCs given the cybersecurity pushes for secure protocols that we're getting from some customers). It seems Rockwell is slow to adopt OPC UA and sounds like they want to charge extra for it, unlike Siemens who just include it in their CPUs.

@timyhac
Copy link
Collaborator Author

timyhac commented Jul 19, 2024

Thanks @trinnan42 - this is great info!

When I began using this a couple years ago. I based what I'm doing off of the examples provided which all seemed to use Tag<M,T>. It was my assumption that this was how the library was meant to be used.

100% correct.
Although Tag has always been a type available to you, Tag<M,T> was the one we used in all of the examples and I promoted.

We're also doing a lot with arrays of tags and UDTs.

100% correct and this is where the Mapper system shines. Yes you would need to find some other way to do this.

the PlcMapper system is indeed preventing us from writing some of that code ourselves.

100% correct.
There are some major limitations with the Mapper system:

  • It does not allow you configure string attributes. Makes working with STRING types that don't have correct defaults impossible. You must use Tag instead. Many issues have been raised against this.
  • It does not allow you to set up raw tags due to the need to Initialize the tag, then write to the buffer.
  • Things like NotifyChanged are not supported directly (and can't be, due to the need to choose an appropriate hashing algorithm which the library can't do).
  • AutoSyncWriteInterval doesn't work.

Solving most of these issues would have been simpler if we instead used inheritance. In retrospect this would have been a better choice.
Having said that, my opinion is that libplctag.NET shouldn't ship that alternative, because it is not obvious what methods/properties the base class should expose, and it is trivial to write your own (particularly if there is an example to copy).

I was somewhat watching the recent problems someone was having with Omron, is that part of what's prompting this?

It certainly is another example of the issue that library consumers face but we have had these problems for years.
For example, #212 has been open for almost 3 years.

I'm concerned with needing more dependencies if the latter were the case. I would prefer that it remains part of the main library to needing another library to maintain.

This might not be the point you're making but potentially there is a case for remaining; in that you could argue being "official" it is more likely to get exposure, trust and momentum around it, and therefore would be maintained at a higher level.

I create a dynamic tag variable by creating an instance of Tag<,> with the types from these dictionaries based on the Data Type Enum's value.
Would we use just the Tag class and then just use its Set[Type] and Get[Type] methods ...

You can absolutely sidestep the Mapper system (even as libplctag.NET currently stands today) and do something like this:

enum MyTagType { SINT, INT, DINT /* etc */ }

class MyTag
{
    private readonly Tag _tag;
    private readonly Func<dynamic> decoder;
    private readonly Action<dynamic> encoder;

    public MyTag(MyTagType tagType)
    {
        _tag = new Tag()
        {
            PlcType = PlcType.ControlLogix,
            Protocol = Protocol.ab_eip,
        };

        switch (tagType)
        {
            case MyTagType.SINT:
                _tag.ElementSize = 1;
                decoder = () => _tag.GetInt8(0);
                encoder = (value) => _tag.SetInt8(0, value);
                break;

            case MyTagType.INT:
                _tag.ElementSize = 2;
                decoder = () => _tag.GetInt16(0);
                encoder = (value) => _tag.SetInt16(0, value);
                break;

            case MyTagType.DINT:
                _tag.ElementSize = 4;
                decoder = () => _tag.GetInt32(0);
                encoder = (value) => _tag.SetInt32(0, value);
                break;
        }
    }

    public Task InitializeAsync() => _tag.InitializeAsync();
    public Task WriteAsync() => _tag.WriteAsync();
    public Task ReadAsync() => _tag.ReadAsync();

    public object Value { get => decoder(); set => encoder(value); }

    public string Gateway { get => _tag.Gateway; set => _tag.Gateway = value; }
    public string Path { get => _tag.Path; set => _tag.Path = value; }
    public string Name { get => _tag.Name; set => _tag.Name = value; }
}

Which would be used like this:

var myTag = new MyTag(MyTagType.DINT)
{
    Name = "PROGRAM:SomeProgram.SomeDINT",
    Gateway = "10.10.10.10",
    Path = "1,0",
};

await myTag.ReadAsync();
var originalValue = myTag.Value;
Console.WriteLine($"Original value: {originalValue}");

int newValue = 3737;
myTag.Value = newValue;
await myTag.WriteAsync();
Console.WriteLine($"New value: {newValue}");

@trinnan42
Copy link

@timyhac Your explanations are much appreciated! I've got a better understanding now. This certainly alleviates my concerns.

@MountainKing91
Copy link

As a recent user of libplctag.NET, I too have to quote this:

When I began using this a couple years ago. I based what I'm doing off of the examples provided which all seemed to use Tag<M,T>. It was my assumption that this was how the library was meant to be used.

Accordingly, I developed many custom mappers in order to work with all my custom datatypes and even for Omron system structure (_sAXIS_REF, etc.), Omron being the brand I use.

How I "share" tag values in my application is actually a mess, basically I register them in a list and access their values with a LINQ method finding them by name. I couldn't come up with a better idea at the time, and the I stayed with that.

Following this with interest.

@timyhac
Copy link
Collaborator Author

timyhac commented Jul 21, 2024

Thanks for your input @MountainKing91!

@MountainKing91
Copy link

A study of applications or libraries that are consuming libplctag.NET shows that most will create yet another wrapper around Tag<M,T>.

@timyhac do you do this only by github search or is there anything else? I.e. user sharing their application or something?

@timyhac
Copy link
Collaborator Author

timyhac commented Jul 23, 2024

@MountainKing91 - just github/google.

timyhac added a commit that referenced this issue Jul 26, 2024
Although issue #406 says to **remove** these classes, start the process by deprecating them instead.
@timyhac
Copy link
Collaborator Author

timyhac commented Jul 26, 2024

@trinnan42, @MountainKing91 and @MitchRazga thankyou so much for your engagement and input!

I believe that removing the Mapper system and associated classes is still the right call to benefit the libplctag community long term, but have decided to phase out the mapper system in two stages to ease the transition:

  1. Deprecate the mapper system, and rewrite examples and docs to use Tag instead of Tag<M,T>.
  2. Remove the mapper system from the nuget library.

As you suggested, much of the issue is that the examples and documentation promote the use of the mapper system - so stage 1 will mitigate that part of the problem - while allowing current users to gradually transition.

For stage 2, I have not yet put in place an alternative for current users to migrate to, but I am thinking it will be a separate package but a drop-in replacement - it would be a separate package, and would not be an "official" .NET wrapper.

@kyle-github
Copy link
Member

Let me know your thoughts on how to set up a separate "extras" package. I am really interested in doing that for the core DLL. I want to pull out the examples, tools and tests (which are horribly mixed up now) from the main library.

@timyhac
Copy link
Collaborator Author

timyhac commented Jul 27, 2024

Heya @kyle-github

The examples and tests would stay in the libplctag.NET github repository, but I was thinking that the classes shipped with the nuget package that I just deprecated would be moved to a separate repository and released as a separate nuget package from there. The purpose being to make it clear that it is not a "libplctag" initiative.

A separate but related thought was that there could be a repository which does attempt to document the behaviour of the various devices the libplctag community uses.
Its purpose would be to be a store of knowledge for the Modbus and Ethernet/IP services that those devices offer.
A non-goal would be to be comprehensive and to work out-of-the-box, but instead to document what the community has learnt so far, and to explicitly document where the knowledge gaps are.
This knowledge could be documented as executable programs - so that others could easily run the tests and confirm whether it is true on their device, or whether there is a gap in this knowledge that needs to be clarified.

@MountainKing91
Copy link

Hi guys, I'm looking at the examples, trying to move past using the mappers.

I see in the examples folder that there are now two classes BaseTag.cs and Definitions.cs. Is this intended to be an alternative to mappers or am I missing something?
I will probably have the chance to start a new project with Omron + Libplctag, and I would like to start "fresh" and not use mappers if possible, even if until now I heavily relied on them.

Ty!

@timyhac
Copy link
Collaborator Author

timyhac commented Nov 18, 2024

Yes exactly - in the top post I said a little bit about it under the "What's the alternative?" heading in the top post on this issue.

@iancolledge
Copy link

For anyone googling moving away from mappers... ExampleListPlc.cs in the .NET Core examples folder is what you need.
We have about 30 or so mappers, they worked really well, but I can understand why they were deprecated. We'll re-write them when we get the chance. We did do a proof of concept a while back using our own low level routines in C# operating on the raw buffer avoiding the call to the C library and it was twice as fast basically, so we'll probably resurrect that code as part of this.

@zmilliron
Copy link

Hi, new to this library and have been experimenting with it and saw that the intent was to deprecate the mapper system and that alternatives to it were being investigated it. I'm currently working with ControlLogix and probably the easiest alternative is to just use marshalling. If other platforms use fixed-size data types, then it's probably easily applicable to those as well.

I made some extension methods for the Tag class for illustration purposes that I'll share:

public static T GetValue<T>(this Tag tag) where T : struct
{
    byte[] buffer = tag.GetBuffer();
    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);

    try
    {
        //ToInt64 is used because it's assumed the code will compile and run on 64-bit platform.
        //If it's going to run on a 32-platform, ToInt32 should be used.
        T retVal = Marshal.PtrToStructure<T>(new IntPtr(handle.AddrOfPinnedObject().ToInt64()))!;

        return (retVal);
    }
    finally
    {
        handle.Free();
    }
}

The key to all of this is the Marshal.PtrToStructure method. This will work with all atomic tags and convert them to .NET primitive types. Also note that the method declaration limits T to structs so only .NET primitives and structs can be used. Reference types (ie. classes) will not work. Removing that restriction will throw an exception because Marshal.PtrToStructure does not accept reference types.
Sample usage:

Tag t = new Tag()
{
    Name = "SomeDintTag"
    //additional properties
};

t.Read();
int i = t.GetValue<int>();

Even though strings are technically primitives in .NET, PtrToStructure will fail because strings aren't fixed length so it doesn't know what to do with them. Additionally, strings in Logix are UDTs so they have to be handled specially anyways. Which brings us to...

Marshalling UDTs

You can marshal UDTs by declaring structures that implement the following rules:

  • Structs must be decorated with a StructLayoutAttrbute
  • LayoutKind must be set to Sequential
  • The Pack property must be set to 4 because Logix sends UDTs in blocks of 4 bytes and pads out any blocks that are short. Using Pack = 4 will make sure that padding is accounted for and each field is marshalled correctly.
  • Fields in the struct must appear in the same order as fields in the UDTs and must have same size data types.

Take for example the following UDT

SomeUDT
    Field1 INT
    Field2 DINT

There's 6 total bytes, but Logix will send 8. The first block will contain Field 1 in the first two bytes + two bytes of padding, and then Field 2 will be sent in the second block. The corresponding struct is:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct SomeUDT
{
    public short Field1;
    public int Field2;
}

No special accommodation needs to be made for padding, it's handled automatically.

Logix strings can be converted with the following struct:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct LogixString
{
    public int Length;
    
    [MarshalAs(UnmanagedType.ByValStr, SizeConst =82)]
    public string Data;
}

Note that in this case a .NET string type can be used because the property is decorated with the MarshalAs attribute that explicitly defines the size. This also means that custom string sizes can be used so long as the SizeConst property is set correctly. The usage here is the same as with atomic tags:

LogixString ls = tag.GetValue<LogixString>();

Then the Data field can be accessed to get the actual string value. Or to make it even simpler, an implicit operator overload can be added to the LogixString struct:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct LogixString
{
    public int Length;
    
    [MarshalAs(UnmanagedType.ByValStr, SizeConst =82)]
    public string Data;

    public static implicit operator string(LogixString s) => s.Data;
}

And then used as follows:

string s = tag.GetValue<LogixString>();

Complex UDTs can also be converted. Here's a (rather large) struct that's based off a real UDT from a real world PLC program currently in production. I've tested this live and all data fields are correctly converted, including empty array indices:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct DataRecordUDT
{
    public int Field1;
    public int Field2;
    public int Field3;
    public int Field4;
    public int Field5;
    public int Field6;
    public int Field7;
    public short Field8;
    public LogixString Field9;
    public short Field10;
    public short Field11;
    public float Field12;
    public float Field13;
    public float Field14;
    public short Field15;
    public float Field16;
    public float Field17;
    public float Field18;
    public float Field19;
    public short Field20;
    public float Field21;
    public float Field22;
    public float Field23;
    public float Field24;
    public float Field25;
    public float Field26;
    public float Field27;
    public int Field28;
    public int Field29;
    public int Field30;
    public int Field31;
    public int Field32;
    public int Field33;
    public int Field34;
    public int Field35;
    public int Field36;
    public int Field37;
    public int Field38;
    public int Field39;
    public int Field40;
    public int Field41;
    public int Field42;
    public int Field43;
    public int Field44;
    public int Field45;
    public int Field46;
    public int Field47;
    public int Field48;
    public int Field49;
    public int Field50;
    public int Field51;
    public int Field52;
    public int Field53;
    public int Field54;
    public int Field55;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public LogixString[] Array1;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array2;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public short[] Array3;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array4;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array5;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public short[] Array6;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public LogixString[] Array7;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public short[] Array8;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public short[] Array9;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array10;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array11;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array12;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public float[] Array13;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
    public short[] Array14;

    public float Field56;
    public int Field57;
}

Apologies for how huge that is, but I really wanted to drive home how well this method works. Nested UDTs, arrays, arrays of UDTs are all correctly converted. When handling arrays nested in a UDT, the MarshalAs attribute must be set with the ByValArray type and SizeConst set to the size of the array.

BOOLs in UDTs Ruin Everything

Boolean values in UDTs are quite special in how they're handled in Logix and so special consideration has to be made when creating a corresponding struct. Bools in Logix are stored as bits and when transmitted in a UDT they're packed into bytes. This makes it a bit cumbersome but not impossible to deal with. Consider the following UDT:

SomeOtherUDT
    Field1 BOOL
    Field2 BOOL
    Field3 BOOL
    Field4 DINT

This gets transmitted as 8 bytes total. The bools are packed into a single byte + 3 bytes of padding, then 4 bytes for the DINT. A corresponding struct would be:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct SomeOtherUDT
{
    public byte Bools;
    public int Field4
}

If all 3 bools are on, then the byte value will read as 7 (bit 0, 1, and 2). Different methods can be used to determine which bits are set, like using a BitArray or shifting:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct SomeOtherUDT
{
    public byte Bools;
    public int Field4

    public bool Field1
    {
        get
        {
            BitArray ba = new BitArray(new byte[] { Bools });
            return ba[0];
        }
    }
    
    public bool Field2
    {
        get { return (Bools & (1 << 1)) != 0; }
    }

    public bool Field3
    {
        get { return (Bools & (1 << 2)) != 0; }
    }
}

If boolean fields are interwoven between multiple fields in a UDT, then the corresponding struct will have to have a byte for each block of consecutive bools. A pain, but manageable. Here's a quick example of what I mean.

UDT:

SomeUDT
    Field1 BOOL
    Field2 BOOL
    Field3 DINT
    Field4 BOOL
    Field5 BOOL
    Field6 REAL
    Field7 BOOL
    Field8 DINT
    Field9 BOOL

Corresponding struct:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct SomeUDT
{
    public byte Bools1;
    public int Field3;
    public byte Bools2;
    public float Field6;
    public bool Field7;
    public int Field8
    public bool Field9;
}

It may seem confusing that Field7 and Field9 are typed as bool, but that's because there's only 1 boolean field in the UDT between the other data types, they're padded out to 4 bytes, and Windows bools are 4-bytes long so the mapping will work. The annoyances of handling booleans can be mitigated by careful design of UDTs. Putting all bools at the start or end of a UDT can arguably make it easier to reason about them when creating a struct.

Marshalling Arrays

All of the above works with single tag values, but until now I ignored array tags because those have to be handled differently. Arrays in .NET are reference types, so Marshal.PtrToStructure can't be used to directly convert arrays from Logix into .NET arrays. Instead, a little bit of special handling must be done so I made an extension method just for arrays:

public static T[] GetArray<T>(this Tag tag) where T : struct
{
    byte[] buffer = tag.GetBuffer();
    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);

    try
    {
        int elementSize = Marshal.SizeOf<T>();
        T[] destination = new T[buffer.Length / elementSize];

        //Note ToInt64 assuming a 64-bit platform
        IntPtr source = new IntPtr(handle.AddrOfPinnedObject().ToInt64());
        for (int i = 0; i < destination.Length; i++)
        {
            destination[i] = Marshal.PtrToStructure<T>(source);
            source = IntPtr.Add(source, elementSize);
        }

        return (destination);
    }
    finally
    {
        handle.Free();
    }
}

Basically, an array is declared and then each element is set one at a time by looping through blocks of bytes. This method will handle both atomic types and UDTs. Usage is as follows:

Tag tag1 = //initialization 
Tag tag2 = //initialization

tag1.Read();
tag2.Read();

float[] floatValues = tag1.GetArray<float>();

SomeUDT[] udtValues = tag2.GetArray<SomeUDT>();

Writing to Tags

Writing to a tag is essentially the same as above but in reverse, so I don't think I need to go into much detail. Instead, here are the corresponding extension methods I came up with:

public static void SetArray<T>(this Tag tag, T[] array) where T : struct
{
    ArgumentOutOfRangeException.ThrowIfZero(array.Length, nameof(array));

    byte[] buffer = new byte[Marshal.SizeOf<T>() * array.Length];
    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);

    try
    {
        //ToInt64 assuming 64-bit platform
        IntPtr bufferAddress = new IntPtr(handle.AddrOfPinnedObject().ToInt64());
        for (int i = 0; i < array.Length; i++)
        {
            Marshal.StructureToPtr(array[i], bufferAddress, false);
            bufferAddress = IntPtr.Add(bufferAddress, Marshal.SizeOf<T>());
        }

        tag.SetBuffer(buffer);
    }
    finally
    {
        handle.Free();
    }
}

public static void SetValue<T>(this Tag tag, T value) where T : struct
{
    byte[] buffer = new byte[Marshal.SizeOf<T>()];
    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
    try
    {
        //ToInt64 assuming 64-bit platform
        Marshal.StructureToPtr(value!, new IntPtr(handle.AddrOfPinnedObject().ToInt64()), false);
        tag.SetBuffer(buffer);
    }
    finally
    {
        handle.Free();
    }
}

I'll admit that I haven't been able to extensively test writing. I've only been able to access PLCs that are "live," so I don't have one to play with where I can write arbitrary values and see what happens. However, I've been able to test and see that the byte buffer array appears to be set correctly. Usage is to just call the Set* method with your data type and then tag.Write();

Existing code in the libplctag library allows reading and writing to offsets, and that should be something easily integrated into these extension methods. They ultimately map to addresses, so an offset parameter could be added to the address before marshalling.

Finally, I'll also point out I've done no performance benchmarking. I don't know how well this works with large volumes of data or a large number of read/writes. For my purposes it's performed as well as it's needed to.

Misc. Notes

  • This will NOT work with @tags since tag names are returned as variable-length strings instead of UDT-style fixed-length strings.
  • You can declare structs with 'LayoutKind.Explicit', but you then have to decorate every field with a FieldOffsetAttribute and you can't use the Pack property and instead have to meticulously calculate every field offset in the buffer. The tradeoff is more work for the flexibility to order the fields any way you want in your struct definition.

@timyhac
Copy link
Collaborator Author

timyhac commented May 8, 2025

Hi @zmilliron - this is a fantastic contribution, thanks so much for sharing!

If you're up for it, it would be great to add your marshalling concept as another alternative in the Examples.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants