Designing an API that provides "views" to manipulate binary data

Brace yourself, this is kind of a long post.

I am working on a library for an industrial protocol called EtherCAT.

I am trying to reach MVP quickly and simply, and add frills later.

The library essentially is just for manipulating special bits that are sent over an
ethernet port from the “main device” (linux computer) to subdevices arranges in a loop
topology. The Maindevice sends frames, they travel through the subdevices, the subdevices
write to and read from the frame, and then the frame returns to the maindevice.

I think the minimum workflow for the user is the following:

  1. Write a “bus configuration” that describes the subdevices to be controlled
  2. Write a while loop that repeatedly sends / recvs frames with binary data. Read the returned frame, do some logic,
    send a modified frame, repeat.

Questions:

  1. The bus configuration should be easy to write, and not verbose. In the below pseudocode the user defines the input memory area of subdevices as a type (only existing at comptime). This has the advantage of very closely representing what is actually sent over the wire, in perhaps the simplest user-definable way possible.
    The library can handle the endianness changes for the user and all those schenanigains.
    Do you think this is a good idea? This perhaps uses the features of zig’s comptime in the best possibly way, but may have the disadvantage of the user not being able to “read” the assembled process image type themself to reason about the code.

Here is some pseudo code:

const Subdevice = struct {

    // this information serves only to identify the subdevice
    // in the network (provided by subdevice datasheet)
    vendor_id: u32,
    product_code: u32,
    revision_number: u32,

    /// a packed struct to represent data that
    /// is produced by the subdevice and not intended
    /// to be manipulated by the user (the subdevice writes this to the frames)
    ///
    /// defined in datasheet of subdevice
    inputs: ?type,

    /// a packed struct to represent data that
    /// is read from the frames by the subdevice, should be
    /// set by the user before sending each frame
    ///
    /// defined in datasheet of subdevice
    outputs: ?type,
};

// an example configuraion for a subdevice
const EK1100Configuration = Subdevice{
    .vendor_id = 0x2,
    .product_code = 0x44c2c52,
    .revision_number = 0x110000,

    // this subdevice has no inputs or outputs
    .inputs = null,
    .outputs = null,
};

// this is a module with 8 digital outputs, bit packed into a
// single byte
const EL2008Configuration = Subdevice{
    .vendor_id = 0x2,
    .product_code = 0xaa34da,
    .revision_number = 0x100000,

    .outputs = packed struct {
        fill_valve: bool, // when this is true the fill valve is open
        vent_valve: bool, // then this is true the vent valve is open
        not_used: u6,
    },
};

pub const BusConfiguration = struct {
    // the order of the subdevices in the ring is based on the order in
    // this array
    subdevices: []Subdevice,
    // bunch of other configuration parameters etc.
};

pub const MainDevice = struct {

    // each of the subdevices input and output data is 
    // mapped to a place in contiguous memory
    // the process image is the concatenation of all of this data
    // the type is perhaps created at comptime
    process_image: some_comtpime_type,
    bus_configuration: BusConfiguration,
};


pub fn main() !void {

    // user defines what they expect the bus to look like
    const my_bus_configuration: BusConfiguration = ...;

    // user makes a main device
    const my_main_device: MainDevice = ...;

    // maindevice contacts subdevices and 
    // verifies the bus contains only the subdevices
    // defined in the bus configuration
    try my_main_device.validateBusConfiguration(my_bus_configuration);

    // begin controlling the subdevices
    while (true) {
        
        // recv frames to get inputs of subdevices
        try main_device.recv_frames();

        // do the business logic
        // for example, open a fill valve when the pressure is too low
        if (main_device.subdevices.pressure_transducer_module.inputs.tank_pressure < 100) {
            main_device.subdevices.digital_output_module.outputs,fill_valve = true;
        } else {
            main_device.subdevices.digital_output_module.outputs.fill_valve = false;
        }

        // send frames to command subdevice outputs
        try main_device.send_frames();
        sleep_microseconds(1000);
    }

}

The process image could possibly be all the subdevice input/outputs concatenated into a single packed struct. However, this limits the maximum size of the process image to 65535 bits, which is not acceptable for large networks. I guess I could use one packed struct per subdevice, which would mean I would be doing 1 memcopy per subdevice, which I think is acceptable.

A network can contain up to 65535 subdevices

What I’m picking up from your write-up here is that your question is about fundamental library design and not specifically about your problem domain.

I think the first question I have is how hands-off do you want your library to be? To draw an analogy, if I was writing this to be ultimately consumed through python wrappers, I’d try to handle as much as I could and at some point say “if you want more, either write it yourself or modify the lower-level utility”.

One trick that is often deployed here is something like user provided callbacks (take usockets as an example). Basically, you can give the user multiple callback points that could be invoked at defined steps in the process so if the user wants to “peek” into things, it’s easy enough to do. The issue there, however, is that error handling becomes more difficult. With comptime, you can check if such a thing was provided without having to reroute through function pointers.

I’m just throwing that out as an idea because you can certainly create customization points for your users depending on your design strategy (and that’s only one option).

1 Like

This is a good design for a library - just let it make requests or interpret data received.

now stop and think - should be “sending” (by itself) included into the library?
you might be using linux raw sockets but others may use some other software mechanisms.

I was planning on providing an interface layer or “hardware abstraction layer” so users could provide bespoke network interfaces. While raw socket will be included in the MVP library, it may be extended to kernel drivers or embedded interfaces for the best possible real time performace when interacting with NIC’s.

Yeah I think a better rephrasing of my question is:

Its not immediately clear to me where I should draw the lines between comptime and runtime.
If I design my API to accept comptime types as configuration, I believe I will obtain the best performance (least memory usage and optimal codegen) with the least verbose configuration. But this means my API is less capable for users who would like to perform configuration at runtime (by perhaps parsing a file). I could perhaps just choose to do the comptime version first (because I suspect it may create an easier to use library) and work on the runtime version later.

And by “comptime configuration” I am talking about designing an API similar to

where the user primarily creates a type , and through the magic of comptime obtains a lot of behavior that would be more arduous to create without reflection.

But my understanding of the limitations of this strategy are that it limits the use u your library at runtime. For example, it would be difficult for me to define the behavior of my command line argument parser in a configuration file loaded at runtime (I know nobody would do this, just for example).

I think the comptime configuration route will likely provide the easiest user experience for zig and I think that is what I want to acheive first.

Cool - thank you for clarifying. I’m going to just throw my two cents on the table here (take this with a grain of salt, it’s just one opinion).

I always recommend starting with being concrete and then working your way up the chain of abstraction. Couple reasons for that…


First, you may be pleasantly surprised by the runtime performance of your program. In fact, you may come to find that after a lot of comptime work, you may not get the performance gains you’re imagining. To be clear, this depends on the circumstances. That said, building a concrete model will help you identify those circumstances and mark out candidates for comptime alternatives.

Second, I personally find it easier to go from a concrete example to something more abstract. The abstractions that actually make sense naturally present themselves as you’re working through an example as opposed to trying to picture them upfront.

Third, a hybrid approach is probably better considering the fact that you have multiple customization points. Some things may be best as a config file you read at runtime (or build time if you want to go down that road) while other things may be better encoded as types. I think that distinction will be more clear as you flesh out your idea with something more “feature complete”.

3 Likes

AF_ETHERCAT as a domain argument to socket() syscall?
Would be nice.

It is possible to send raw ethernet frames using:

socket(AF.PACKET, SOCK.RAW, IPPROTO.RAW)

For linux you need to be root or have the capability CAP_NET_RAW.

yep, and if a program is a service in terms of systemd this capability can be set via AmbientCapabilities if I am not mistaken.

Yes, that is correct.