PDO 输入输出
通过 slave.PDO 访问过程映像。
PDO 就是过程映像里的一段数据,内核每周期自动收发。映射成结构体后,读写成员就是直接读写过程映像本身 —— 0 拷贝:不用拷贝、不用刷新,读到的永远是当前周期,写下的下个周期发出。
邮箱通信(SDO/CoE/FoE 等)自动附带在 PDO 周期中,不占用额外帧。邮箱响应延迟约 2 个 PDO 周期。
- 必须
[StructLayout(LayoutKind.Sequential, Pack = 1)],字段顺序与 PDO 映射逐字节一致;漏写Pack = 1会带对齐填充、字段全部错位(能编译,静默读到脏数据)。 - 不得大于对应方向的字节数(
slave.InputsByteCount/slave.OutputsByteCount)。InputsMapping<T>()/OutputsMapping<T>()超尺寸抛InvalidOperationException。- Slice 变体按偏移越界抛
ArgumentOutOfRangeException。 - 带
onChanged的重载映射时不校验尺寸,不符要到后续读取时才暴露。 - 小于合法,按偏移覆盖。
- 只能放定长值类型:含引用字段(
string/ 数组 / 委托)抛InvalidOperationException(带onChanged的重载不做这项校验),字符串用整帧Inputs解。 bool字段不是 bit0(0 拷贝ref映射路):结构体字段由 C# 自己解释,走语言原生"非 0 即真"。字节0x02/0x80这种非规范值,ref映射里读true,元素视图AsBool读false。要 bit0 就用slave.PDO.In[i].AsBool, 或者按位GetBit/SetBit。例外:ValueSnapshot快照路会把bool字节按 bit0 归一后再解释,与AsBool同口径。- 映射一次长期有效;
Close/ 换代后旧ref作废,重进 OP 时重取。- 换代没有通知,旧
ref看不出自己已失效。取ref时把slave.ProcessImageGeneration一并存下,用之前比对:值变了就是换过代,旧ref必须丢弃、重新映射。 - 该值单调递增(初值 1,永不返回 0),是纯托管计数器 —— 读取零锁、零分配,任意线程可用;
Close与重新应用 DC / ENI 配置时 +1。协议栈在 PreOp→SafeOp 内部重算 FMMU 时不递增它,所以重进 OP 一律主动重取。
- 换代没有通知,旧
使用案例
日常使用
① 映射到结构体(0 拷贝,持续用):进 OP 后映射一次,拿到钉在过程映像上的 ref,之后当变量读写。
using System.Runtime.InteropServices;
using DarraEtherCAT_Master;
[StructLayout(LayoutKind.Sequential, Pack = 1)] // Pack = 1 必须;字段顺序与 PDO 逐字节一致
public struct InPdo { public ushort StatusWord; public int ActualPosition; }
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct OutPdo { public ushort ControlWord; public int TargetPosition; }
using var master = new DarraEtherCAT()
.SetENI(@"C:\EtherCAT\MyProject.deni") // DENI 一行加载
.Build()
.Master;
master.SetState(EcState.OP, out _); // 进 OP
var slave = master.Slaves[0];
ref readonly InPdo input = ref slave.PDO.InputsMapping<InPdo>(); // 映射一次(进 OP 后)
ref OutPdo output = ref slave.PDO.OutputsMapping<OutPdo>();
output.ControlWord = 0x000F; // 写:下个周期发出
output.TargetPosition = 10000;
Console.WriteLine($"status=0x{input.StatusWord:X4} pos={input.ActualPosition}"); // 读:当前周期
② 单次读取(一次性读):不要结构体,只取一份当前值。
// 单点:按字节偏移取元素视图(索引越界抛 IndexOutOfRangeException)
int pos = slave.PDO.In[2].AsInt32; // 偏移 2 起 4 字节
ushort sw = slave.PDO.In[0].AsUInt16; // 偏移 0 起 2 字节(StatusWord)
// 整段:一份完整输入帧快照,同一拍一致,拷出来不会跟着总线变
byte[] frame = slave.PDO.Inputs;
string name = System.Text.Encoding.ASCII.GetString(frame, 6, 16).TrimEnd('\0'); // 字符串字段
Console.WriteLine($"status=0x{frame[0] | (frame[1] << 8):X4} name={name}");
周期变化
映射取一次就够,同一份映射放进你自己的周期循环(或循环里的调度点)直接用:
using System.Threading;
while (running)
{
output.ControlWord = (input.StatusWord & 0x0004) != 0 ? (ushort)0x000F : (ushort)0x0006;
output.TargetPosition = input.ActualPosition;
Thread.Sleep(1); // 你自己的节奏,例如 1 ms
}
内核每周期搬运过程数据:循环里读到的永远是当前周期、写下的下个周期发出,不需要任何额外调用,也不要每拍重新映射(Close
/ 换代后旧 ref 才作废,重进 OP 时重取一次即可)。
结构体映射(零拷贝)
InputsByteCount / OutputsByteCount
当前从站输入 / 输出过程映像的字节数,用来在映射前核对结构体大小。
public int InputsByteCount { get; }
public int OutputsByteCount { get; }
返回值:
int— 输入 / 输出过程映像字节数;未进 OP 或未映射时为 0
说明: 这两个属性声明在 Slave 上(slave.InputsByteCount),不在 slave.PDO 上。数值有约 50 ms
节流,状态切换后短期内可能仍是上一拍的缓存值。
示例:
if (ValueCodec.StructOverlaySize<InPdo>() > slave.InputsByteCount)
throw new InvalidOperationException("结构体大于输入 PDO");
InputsMapping<T>()
把输入过程映像(TxPDO)映射成钉在内核 IOmap 上的只读结构体引用,映射一次后读字段即读当前周期值(0 拷贝,不是快照)。
public ref readonly T InputsMapping<T>() where T : struct
返回值:
ref readonly T— 钉在过程映像上的只读引用,任意线程可读
说明:
- 进 OP 后调用。
- I 区由主站每周期覆盖,写它静默无效;要写过程数据用
OutputsMapping<T>()。 - 过程映像不可用(未进 OP)抛
InvalidOperationException。 Slave已释放(Close()后)抛ObjectDisposedException。- 结构体大于输入 PDO 抛
InvalidOperationException。
示例:
ref readonly InPdo input = ref slave.PDO.InputsMapping<InPdo>();
ushort status = input.StatusWord; // 当前周期值
InputsMapping<T>(onChanged)
映射成结构体实例并挂数据变化回调。只想拿 ref 时不带 onChanged 即可,无需额外绑定 API。
public Slave.InputPdoInstance<T> InputsMapping<T>(Action<Slave.PdoStructChangedEventArgs<T>> onChanged) where T : struct
public Slave.InputPdoInstance<T> InputsMapping<T>(Action onChanged) where T : struct
返回值:
InputPdoInstance<T>—.Value是钉在过程映像上的ref readonly T(0 拷贝);.ValueSnapshot是当前值的一份托管快照(不会自己变)
回调参数:
public sealed class PdoStructChangedEventArgs<T> : EventArgs where T : struct
{
public ushort SlaveIndex { get; } // 从站索引
public bool IsInput { get; } // 是否为输入 PDO
public T Previous { get; } // 变化前的值
public T Current { get; } // 变化后的值
public DateTime Timestamp { get; } // 时间戳
}
说明: 内部按约 1 ms 后台轮询比较完整帧,不在 PDO 周期内触发;仅结构体值变化时才调用回调。
示例:
var input = slave.PDO.InputsMapping<InPdo>(e =>
Console.WriteLine($"位置变化: {e.Previous.ActualPosition} -> {e.Current.ActualPosition}"));
int pos = input.Value.ActualPosition; // 0 拷贝,当前周期
InPdo snap = input.ValueSnapshot; // 托管快照,不会跟着总线变
InputsSliceMapping<T>(int offset)
从输入过程映像的指定字节偏移处映射只读结构体引用,供 MDP 等按模块切段的设备使用。
public ref readonly T InputsSliceMapping<T>(int offset) where T : struct
参数:
offset(int) — 相对本从站输入过程映像起始的字节偏移
返回值:
ref readonly T— 钉在该偏移处的只读引用(0 拷贝)
说明: offset 为负、或 offset + ValueCodec.StructOverlaySize<T>() 越界抛 ArgumentOutOfRangeException(尺寸按托管布局计算,含 bool/char 的结构体不要用 Marshal.SizeOf 代替)。
相关结构:
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential, Pack = 1)] // Pack = 1 必须;漏写会有对齐填充、字段错位
public struct Module1In // 模块 1:占输入映像偏移 0-7(共 8 字节)
{
public ushort StatusWord; // 偏移 0
public ushort ErrorCode; // 偏移 2
public int Value; // 偏移 4
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct Module2In // 模块 2:输入映像从偏移 8 起
{
public ushort StatusWord; // 偏移 8
public ushort ErrorCode; // 偏移 10
public int Value; // 偏移 12
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct Module2Out // 模块 2:输出映像从偏移 8 起
{
public ushort ControlWord; // 偏移 8
public int Setpoint; // 偏移 10
}
每段结构体的字节宽度必须与对应模块的 PDO 长度一致;这里模块 1 占 8 字节,模块 2 才从偏移 8 开始。
示例:
// MDP 设备:模块 1 从偏移 0 开始,模块 2 从偏移 8 开始
ref readonly Module1In mod1 = ref slave.PDO.InputsSliceMapping<Module1In>(0);
ref readonly Module2In mod2 = ref slave.PDO.InputsSliceMapping<Module2In>(8);
InputsSliceMapping<T>(int offset, onChanged)
从输入过程映像的指定偏移处映射结构体实例并挂数据变化回调。
public Slave.InputPdoInstance<T> InputsSliceMapping<T>(int offset, Action<Slave.PdoStructChangedEventArgs<T>> onChanged) where T : struct
public Slave.InputPdoInstance<T> InputsSliceMapping<T>(int offset, Action onChanged) where T : struct
参数:
offset(int) — 相对本从站输入过程映像起始的字节偏移onChanged— 数据变化回调,形参同上
示例:
var mod2 = slave.PDO.InputsSliceMapping<Module2In>(8, e =>
Console.WriteLine($"模块 2 数据变化: {e.Previous} -> {e.Current}"));
ref readonly Module2In m2 = ref mod2.Value; // 0 拷贝
OutputsMapping<T>()
把输出过程映像(RxPDO)映射成结构体引用,改字段即写总线。
public ref T OutputsMapping<T>() where T : struct
返回值:
ref T— 可写引用,钉在过程映像上(0 拷贝);改字段后下个周期发往从站
说明: 结构体大于输出 PDO 抛 InvalidOperationException;过程映像不可用或 Slave 已释放同 InputsMapping<T>()。
示例:
ref OutPdo output = ref slave.PDO.OutputsMapping<OutPdo>();
output.ControlWord = 0x000F; // 下个周期发出
OutputsSliceMapping<T>(int offset)
从输出过程映像的指定字节偏移处映射可写结构体引用。
public ref T OutputsSliceMapping<T>(int offset) where T : struct
参数:
offset(int) — 相对本从站输出过程映像起始的字节偏移
示例:
ref Module2Out mod2 = ref slave.PDO.OutputsSliceMapping<Module2Out>(8);
mod2.Setpoint = 1500;
直接访问(不映射也能用)
下面这组调用与结构体映射彼此独立:不映射可以直接用,映射了也常并用(结构体放不下的字段用它解)。
In[i] / Out[i]
public PDOArrayInstance In { get; } // TxPDO(从站→主站):只读
public PDOArrayInstance Out { get; } // RxPDO(主站→从站):可读可写
按字节偏移索引过程数据元素:In[i] 读输入,Out[i] 读写输出,i 相对本方向过程映像起点。
返回值:
PDOArrayInstance— 按字节索引的过程映像视图;Length是本方向过程映像字节数,未进 OP / 未映射时为 0(此时任何索引都抛IndexOutOfRangeException)
说明:
In是 TxPDO,主站每周期覆盖,只读:对In赋值或写它的元素成员一律抛InvalidOperationException。Out是 RxPDO,每次成员赋值走一次版本化事务(Copy → patch → commit,冲突有界重试),成功的写下个周期发出。- 提交失败抛
InvalidOperationException。 - 元素按偏移缓存复用,读取每次取一份当前帧快照后按偏移解析 —— 与
slave.PDO.Inputs同一份快照语义,不是InputsMapping<T>()的 0 拷贝钉页引用。 - 索引越界抛
IndexOutOfRangeException。 - 选错字节 / 选错类型的越界分两道守卫,异常类型不同:
- 第一道按元素视图的边界(创建时的
Length,即带约 50 ms 节流的方向字节数)判:偏移加宽度超出抛ArgumentOutOfRangeException—— 与索引越界刻意分开,便于区分选错字节与选错类型。 - 第二道按本次实际拿到的帧长判(读取取到的快照 / 写事务租到的帧):与元素边界不一致抛
ArgumentException。缓存过期、或过程映像换代缩小后才可能命中。
- 第一道按元素视图的边界(创建时的
GetBit/SetBit的bitIndex不在 0-7、AsValue/WriteValue的类型不受支持时抛ArgumentOutOfRangeException。SetBit先判方向再验索引:对In调用(无论索引是否合法)抛的是InvalidOperationException;只有Out方向才可能因bitIndex越界抛ArgumentOutOfRangeException。Slave已释放抛ObjectDisposedException。
相关结构:
public sealed class PDODataItem // slave.PDO.In[i] / Out[i] 返回的元素视图
{
public byte Content { get; set; } // 原始字节(无符号 8 位)
public sbyte AsInt8 { get; set; } // 有符号 8 位
public byte AsUInt8 { get; set; } // 与 Content 同义同字节
public short AsInt16 { get; set; } // 以下多字节均为小端(EtherCAT 线序)
public ushort AsUInt16 { get; set; }
public int AsInt32 { get; set; }
public uint AsUInt32 { get; set; }
public long AsInt64 { get; set; }
public ulong AsUInt64 { get; set; }
public float AsFloat { get; set; }
public double AsDouble { get; set; }
public bool AsBool { get; set; } // 读 bit0 语义;写整字节 1 / 0(不是改单个位)
public bool GetBit(int bitIndex); // bitIndex 0-7,0 = LSB
public void SetBit(int bitIndex, bool value); // 仅 Out;同字节其余位不动
public object AsValue(EcDataType type); // 按映射类型读 boxed 值
public void WriteValue(EcDataType type, object value); // 按映射类型写
public BaseData AsBaseData(EcDataType type); // 位串 / 宽整数 / 变长类型的出口
public bool WriteFromBaseData(BaseData data); // 整段写回;失败返回 false
public static implicit operator byte(PDODataItem item); // byte x = slave.PDO.In[0]; ≡ .Content
}
AsBool 读只认最低位:字节 0x02 与 0x80 都读 false。要按整字节 0 / 非 0 判真假,读 Content 自行比较。
反过来说,0 拷贝 ref 映射里的 bool 字段是非 0 即真(见上面「结构体契约」)。同一个非规范字节,
ref 映射字段和 AsBool 读出来不一样;ValueSnapshot 快照已按 bit0 归一,两者一致。
GetBit(i) / SetBit(i, value) 是同一个字节的按位入口:i 取 0-7,0 = 最低位;GetBit 读该位(In / Out
都可读),SetBit 只改该位、同字节其余位不动。
slave.PDO.Out[i].AsBool = value 把该字节整体写成 1 或 0,不是只改 bit0 ——
slave.PDO.Out[0].AsBool = true 会把同一字节的 bit1–7 一起清掉。
同一字节里既有布尔位又有其它标志位时,用 SetBit(i, value):它只覆盖目标位,其余位保持不变。
AsValue / WriteValue 只支持与 CLR
定宽类型一一对应的数据类型:Boolean、Integer8/16/32/64、Unsigned8/16/32/64、Real32/Real64。
位串、24/40/48/56 位整数、字符串、Domain 一律抛 ArgumentOutOfRangeException;要按字节进出宽类型和位串,用
AsBaseData()(见下)。宽类型经 AsBaseData() 之后也能取值:Content 按映射类型解码(24 位整数回 int /
uint,40/48/56 位回 long / ulong),不必自己拼字节。
示例:
short sw = slave.PDO.In[0].AsInt16; // 读:当前周期
slave.PDO.Out[2].AsInt16 = 123; // 写:版本化局部提交,下个周期发出
slave.PDO.Out[0].SetBit(0, true); // 只置 bit0,同字节其余位不动
bool flag = slave.PDO.In[3].AsBool; // bit0 语义
for (int i = 0; i < slave.PDO.Out.Length; i++)
slave.PDO.Out[i].SetBit(0, false); // 遍历整个输出映像
byte same = slave.PDO.In[0]; // 隐式转换:等同 .Content
TryWriteOutputBatch(patches)
public bool TryWriteOutputBatch(IReadOnlyList<PdoOutputPatch> patches)
把多个输出字节段放进一次版本化事务提交:一次成功只产生一次 native commit。多个字段要同时更新时用它,别逐个
Out[i].X = ...(那是每字段一次事务)。
参数:
patches(IReadOnlyList<PdoOutputPatch>) — 要提交的字节段列表,偏移相对输出过程映像起点
返回值:
bool— 是否提交成功;false表示 3 轮内、5 ms 预算内没抢到(冲突 / 忙),这次调用不写入
相关结构:
public readonly struct PdoOutputPatch
{
public PdoOutputPatch(int offset, ReadOnlyMemory<byte> data); // offset 为负抛 ArgumentOutOfRangeException
public int Offset { get; } // 相对本从站输出过程映像起点的字节偏移
public ReadOnlyMemory<byte> Data { get; } // 该段要写入的字节
}
说明:
- 提交前每段都拷成自有副本,重试期间不受调用方后续改动影响。
patches为null抛ArgumentNullException;某段Data为空、或偏移 / 长度越界抛ArgumentException。- 空列表返回
true。 - 同一方法在
slave上也有一份(slave.TryWriteOutputBatch),语义相同。
示例:
var patches = new[]
{
new PdoOutputPatch(0, new byte[] { 0x0F, 0x00 }), // ControlWord
new PdoOutputPatch(2, BitConverter.GetBytes(10000)), // TargetPosition
};
bool ok = slave.PDO.TryWriteOutputBatch(patches); // 一次事务,一次 native commit
AsBaseData() / WriteFromBaseData()
public BaseData AsBaseData(EcDataType type);
public bool WriteFromBaseData(BaseData data);
宽类型出口:AsValue / WriteValue 只覆盖定宽标量,位串、变长字段用这一对进出。
参数:
type(EcDataType) — 该位置的映射类型。字节宽度取自 native 数据类型表:Integer40/Unsigned40= 5 字节,Integer48/Unsigned48/TimeOfDay/TimeDifference= 6 字节,Integer56/Unsigned56= 7 字节,其余定宽类型按位宽向上取整;字符串与Domain等变长类型、以及未登记的类型按 1 字节处理data(BaseData) — 要写回的整段原始字节
返回值:
BaseData— 按type宽度从当前位置拷出的原始字节包装;GetRawData()取这一段字节,WriteFromBaseData原样写回bool— 是否写成功;失败返回false(不抛),In方向固定返回false
说明:
AsBaseData在偏移 / 宽度越界时抛ArgumentOutOfRangeException。- 两道越界检查之间过程映像可能换代、窗口缩小;后一道按实际帧长校验的守卫抛的是
ArgumentException(不是ArgumentOutOfRangeException)。 type的宽度非法(超出协议上限)抛ArgumentException;帧里切下来的字节数与BaseData对该类型的宽度不一致时同样抛ArgumentException—— 宽度对不上不静默降级。- 快照不可用(未进 OP)抛
InvalidOperationException。 WriteFromBaseData走输出 PDO 的版本化提交,下个周期发出;写入失败以false返回,不抛异常。- 40/48/56 位整数没有 CLR 对标量,装箱类型统一取更宽的一族:
Integer40/Integer48/Integer56读写long,Unsigned40/Unsigned48/Unsigned56读写ulong(40 位已放不进int/uint,与Integer64/Unsigned64同族,也与 Rust 侧I64/U64同一规则)。 BaseData.Content对 40/48/56 位整数给出真实值(不再抛NotSupportedException):AsBaseData(EcDataType.Integer48).Content直接返回解码后的long,GetRawData()仍给 6 字节原始内容(40/56 位为 5 / 7 字节)。读出来的值可以直接写回同类型。
示例:
using DarraEtherCAT_Master.Utils;
BaseData wide = slave.PDO.In[0].AsBaseData(EcDataType.Integer48); // 按 native 表宽度取 6 字节
byte[] raw = wide.GetRawData(); // 原生 6 字节内容
bool ok = slave.PDO.Out[0].WriteFromBaseData(wide); // 失败返回 false(不抛)
Inputs / Outputs
按字节访问过程映像整帧,用于结构体放不下的字段(字符串、变长数据)。
public byte[] Inputs { get; } // 输入整帧:每次访问返回调用方独占的快照
public byte[] Outputs { get; set; } // 输出整帧:get 返回快照;set 提交完整帧
返回值:
byte[]— 快照,不是 0 拷贝:同一拍一致,拷出来不跟着总线变
说明:
- 这两个属性声明在
slave.PDO上(slave.PDO.Inputs),不在slave上。 - 每次访问都新分配一个
byte[];高频循环里改用slave.CopyInputPdo(span, out copied, out required)/slave.CopyOutputPdo(...)拷进自备缓冲(无托管分配,返回PdoIoStatus:只有Success算成功,BufferTooSmall会带出所需帧长)。 - 整帧提交用
slave.WriteOutputPdo(span, out _, out _)。 Outputs的 setter 长度必须与OutputsByteCount匹配,否则抛InvalidOperationException。- 两个 getter 在
Slave已释放(Close()后)时抛ObjectDisposedException。 - 拿不到完整帧时抛
InvalidOperationException(不返回空数组)。
示例:
byte[] frame = slave.PDO.Inputs;
string name = System.Text.Encoding.ASCII.GetString(frame, 6, 16).TrimEnd('\0');
PdoConsistentReader
public sealed class PdoConsistentReader
{
public PdoConsistentReader(ushort masterIndex); // 本从站所属主站号
public ulong CycleCount { get; } // 已完成的周期序号(内核每周期 +1)
public T Read<T>(Func<ulong, T> reader, int maxRetries = 8);
public T ReadInput<T>(Slave slave, int offset, int maxRetries = 8) where T : struct;
public int ReadInt32(Slave slave, int offset, int maxRetries = 8);
public short ReadInt16(Slave slave, int offset, int maxRetries = 8);
public byte ReadByte(Slave slave, int offset, int maxRetries = 8);
}
在一个逻辑帧里读多个字段时用它:读前读后各取一次周期序号,两次相同才算这一拍读完;跨了周期就整体重试。只读一个字段用
In[i] 就够。
说明:
- 一致性靠周期序号包夹检测,读的是托管快照;不钉页、不返回原生指针。
- 与每次取
slave.PDO.Inputs相比,不产生整帧数组分配。 - 传进来的从站不属于构造时绑定的那个主站抛
ArgumentException(slave为null抛ArgumentNullException)。 - 重试
maxRetries次(默认 8)仍拿不到一致快照抛InvalidOperationException:缩短读的 lambda,或减少一次读的字段数。 - 带
IntPtr的旧重载已废弃,调用即抛NotSupportedException—— 原生 PDO 指针不能跨代保持有效。
示例:
var reader = new PdoConsistentReader(masterIndex: 1); // 本从站所属主站号
InPdo frame = reader.Read(_ => new InPdo
{
StatusWord = slave.PDO.In[0].AsUInt16,
ActualPosition = slave.PDO.In[2].AsInt32,
}); // 两个字段要么同属一拍,要么整体重试