跳到主要内容

过程数据

过程数据就是过程映像里的一段字节,IO 控制器每周期自动收发它。把它映射成结构体后,读写成员就是直接读写过程映像本身 —— 0 拷贝:不用拷贝、不用刷新,读到的永远是当前周期,写下的下个周期发出。本 SDK 里写 Q(设备 → 控制器)、读 I(控制器 → 设备);I 区只读,控制器每周期整段覆盖输入映像,写它静默无效(InputsMapping<T>() 返回 const T&,编译期就禁写)。总线周期由服务侧完成,SDK 不要求你写周期循环。

和 GSDML 的 Input / Output 对上号

GSDML 里那 20 字节的 Input(设备 → 控制器)就是 SDK 的 Q;GSDML 里那 32 字节的 Output(控制器 → 设备)就是 SDK 的 I。

本产品是固定单模块:槽 1 / 子槽 1,样例组态 Q 20 字节、I 32 字节。长度由运行配置决定,不要写死 —— C++ 这一侧没有任何一个函数带槽位 / 子槽位参数

结构体契约(映射前必读)
  • 必须 #pragma pack(push, 1),映射前后 #pragma pack(pop)。映射的是线上字节流,结构体带对齐填充就会整体错位;漏写 pack 编译期就拦下(alignof(T) == 1 是硬要求)。
  • T 必须 trivially copyablestandard layout:不能含虚函数、引用成员或非平凡拷贝,否则 overlay 到过程映像必错。这条也是编译期 static_assert
  • 字段顺序与过程映像逐字节一致。多字节字段是 PROFINET 网络大端:把 int16_t / float 字段当宿主数值直接读会字节颠倒 —— 要宿主序数值请走元素口或地址化接口(它们做大端编解码),或按字节自己拼。
  • I / Q 分两套结构体,不要混在一个里。sizeof(T) 不得超过该方向的净荷,否则抛 PnSException;小于是合法的。
  • 映射只在 Start() 成功之后可用;Stop() 只停刷新,视图仍在(读到旧值);Close() / 析构解除映射后引用失效。
  • 映射是实时字节视图,不是同步原语(无原子、无可见性屏障)。等某个值到位这类条件轮询请改用快照(Read() / ReadStruct<T>())重读。

使用案例

日常使用

① 映射到结构体(0 拷贝,持续用):定义好布局,映射一次,拿到钉在过程映像上的引用,之后当变量读写。

#include "pns.hpp"
#include <iostream>
using namespace darra::pns;

#pragma pack(push, 1) // 必须: 过程映像是线上字节流, 不能有对齐填充
struct FromPlc { uint8_t Present; }; // I: 控制器 → 设备
struct ToPlc { uint8_t Ready; }; // Q: 设备 → 控制器 (I / Q 分两套结构体)
#pragma pack(pop)

PnSService device; // 构造不占实时门, 不连总线
if (!device.Start()) { // Start 才使能 IO; 失败返回 false, 不抛
std::cerr << device.LastServiceError() << "\n";
return 1;
}

const FromPlc& inp = device.ProcessData().InputsMapping<FromPlc>(); // 映射一次, 取引用
ToPlc& outp = device.ProcessData().OutputsMapping<ToPlc>();

outp.Ready = 1; // 写: 下个周期发出, 不需要任何提交调用
uint8_t present = inp.Present; // 读: 永远是当前周期值

② 单次读取(一次性取一个值 / 一段字节):不想为一次读去定义结构体,或者只要那几个字节。

std::vector<uint8_t> raw = device.ProcessData().Inputs();  // I 区整段: 一次读出, 同一拍自洽
int16_t recipe = device.ProcessData().In()[2].AsInt16(); // 按字节偏移类型化读一个值 (大端)
bool done = device.ReadBool("I0.0"); // 按地址读一个位

Inputs()device.Read() 取的是同一份 I 区快照;元素口 In()[i]拷贝切片(不是视图),后面「直接访问」一节会把这层差别讲透。字符串字段在过程映像里是定长字节数组,不是 std::string:要一份不会自己变的副本就一次读出,自己截到第一个 '\0'

#pragma pack(push, 1)
struct FromPlcText { char PartNo[16]; }; // 定长 + 尾部补 0
#pragma pack(pop)

FromPlcText t = device.ProcessData().Read<FromPlcText>(); // 快照读出 (转发 ReadStruct<T>())
std::string partNo(t.PartNo, strnlen(t.PartNo, sizeof t.PartNo));

③ 按地址读写(照着工程软件里的地址写):不想自己算字节偏移时用地址化接口,写法是「区域字母 + 字节偏移 + 位」。

device.WriteBool("Q0.0", true);              // 位: 只动那一个字节的 bit0, 同字节其余位不动
device.WriteInt16("QW2", 123); // 字: 2 字节, 大端
device.WriteInt32("QD4", 10000); // 双字: 4 字节, 大端

int16_t recipe = device.ReadInt16("IW2"); // I 侧同样按地址读
int32_t counter = device.ReadInt32("ID4");

周期变化

同一份映射放进你自己的周期任务直接用(映射在循环外取一次):

const FromPlc& inp = device.ProcessData().InputsMapping<FromPlc>();
ToPlc& outp = device.ProcessData().OutputsMapping<ToPlc>();

while (running) { // 你自己的周期任务, 节拍自定
outp.Ready = (uint8_t)(inp.Present + 1); // 读当前拍 → 算 → 写下拍发出
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

映射在循环外取一次,循环内不要每拍重新映射。SDK 没有用户周期回调,调度(循环 / 定时器 / 事件)由你自己定。

I 侧视图的起点是每次映射现算的,不是恒定地址

InputsMapping<T>() 的净荷起点由当次扫描结果决定:先剥 2 字节 IOPS 前缀,再看净荷开头连续有几个 0x80,最多再剥 4 个。所以前缀长度在 2–6 之间浮动,映射能用到的窗口比输入区长小 2–6 字节sizeof(T) 的比较用的就是这个窗口。

「映射放循环外取一次」仍是推荐用法 —— 少一次扫描。但要清楚它换来的是什么:这个过程不保证前缀长度不变,一旦漂移,字段就是静默读错位(不抛异常,尺寸门只拦「结构体比窗口大」,拦不住偏移错位)。稳妥的取法是 Start() 成功之后取一次、一直用到 Close()Close() / 析构之后必须重取。Q 侧不受这个扫描影响:它的净荷起点是固定的(输出区 + 3)。

结构体映射(零拷贝)

映射入口把 live 过程映像 overlay 成引用(不是快照):映射一次,后面一直读同一块内存。T 的要求见页首的结构体契约。

映射起点固定在本方向净荷的第 0 字节。要映射中段,在结构体开头放一段占位数组(uint8_t _skip[k];)顶开;不然就改用 ReadArea(area, offset, length) 按偏移自己取字节。

InputsMapping<T>()

把输入过程映像(控制器 → 设备)映射成只读结构体引用。映射一次,之后读字段就是读当前周期值。

template <typename T>
const T& InputsMapping();

返回值:

  • const T& — 钉在输入过程映像净荷上的只读引用。I 区只读,类型上直接禁写。

说明:

  • T 必须 #pragma pack(push, 1) 且 trivially copyable / standard layout —— 两条编译期 static_assert,报错文案会直接点出漏了哪一条。
  • 失败一律抛 PnSException(本页所有「抛 PnSException」若是单参构造,code() 都是 -1,只有写提交失败与服务端启停失败是带码抛):
    • Connect / 未 Start"会话未建立 (请先 Start 或 Connect)""未 Start, 无法读写 IO"
    • 共享内存没映射上:"过程数据输入映像不可用"
    • sizeof(T) == 0,或 sizeof(T) 超出本方向净荷窗口:"结构体大于输入过程映像"
  • 净荷起点是每次调用按当前输入区内容重算的(剥 2 字节 IOPS 前缀,再看净荷开头连续几个 0x80,最多再剥 4 个),所以可用窗口比输入区长小 2–6 字节。这个起点不是常量,别把它当固定地址。
  • 引用在共享内存映射的生命周期内有效。Stop() 只禁用设备,视图仍在,引用仍指同一页;Close() / 析构解除映射后失效。视图跨 Start() 重取一次最稳。
  • 这是实时字节视图,不是同步原语:没有原子、没有可见性屏障。要「等某个值到位」请用快照重读,别在这个引用上做条件轮询。
  • 读方向可并发;写方向(OutputsMapping)同一映像同一时刻只开一个写者,多线程写请自行串行化。

示例:

#pragma pack(push, 1)
struct FromPlc {
uint8_t Present; // 偏移 0
int16_t Recipe; // 偏移 1: 大端 —— 直接当宿主 short 读会字节颠倒
uint8_t Flag; // 偏移 3
};
#pragma pack(pop)

const FromPlc& inp = device.ProcessData().InputsMapping<FromPlc>();
uint8_t present = inp.Present; // 当前周期值
int16_t recipe = device.ProcessData().In()[1].AsInt16(); // 要宿主序数值走元素口

OutputsMapping<T>()

把输出过程映像(设备 → 控制器)映射成结构体引用,改字段即改过程映像。

template <typename T>
T& OutputsMapping();

返回值:

  • T& — 可写引用,钉在输出过程映像净荷上;改字段后下个周期发出。

说明:

  • 与 I 侧不对称是刻意的:本侧返回 T&(可写),I 侧 InputsMapping<T>() 返回 const T&(只读)。
  • T 的两条编译期 static_assert 与 I 侧完全相同。
  • 失败抛 PnSException:未挂接 / 未 Start 的两条文案同上;共享内存没映射上是 "过程数据输出映像不可用";尺寸超出是 "结构体大于输出过程映像"
  • 净荷起点是固定的(输出区 + 3),不做 I 侧那种前缀扫描 —— 不随数据内容漂移。
  • 改字段就是改共享区的 Q 过程映像,落点与 Write() / SetOutputs() 的组帧契约一致:不需要再调一次提交函数,下一周期自动发出。
  • 生命周期与线程安全同 InputsMapping<T>();写侧同一映像同时只允许一个写者。
  • 想在写之前核对尺寸:Outputs().size() 就是本方向的净荷字节数(sizeof(T) 不得超过它,小于是合法的)。

示例:

#pragma pack(push, 1)
struct ToPlc {
uint8_t Ready; // 偏移 0
uint8_t Busy; // 偏移 1
};
#pragma pack(pop)

ToPlc& outp = device.ProcessData().OutputsMapping<ToPlc>();
outp.Ready = 1; // 下个周期发出, 无需提交调用
outp.Busy = 0;

直接访问(不映射也能用)

按字节偏移读写单个元素。这一族是拷贝,不是视图In()[i] / Out()[i] 每次取值都重新取一份本方向区域快照,再从里面切出那一小段字节解释,写入同理 —— 它不是「下标定位拿到的活视图」,[i] 定的只是偏移,值仍然是那一瞬间的快照。真正的 0 拷贝只有整幅映像这一族:结构体映射(InputsMapping<T>() / OutputsMapping<T>()),以及它们底下那两个共享区原始字节口(见「共享区直连」)。

uint8_t b  = device.ProcessData().In()[0].Content();      // 读 I 偏移 0 那 1 字节
int16_t iw = device.ProcessData().In()[2].AsInt16(); // 读 I 偏移 2 起 2 字节 (大端)
float f = device.ProcessData().In()[8].AsFloat(); // 读 I 偏移 8 起 4 字节 (大端 IEEE754)
bool bit = device.ProcessData().In()[0].GetBit(0); // 读 I 偏移 0 那字节的 bit0

device.ProcessData().Out()[0].Content(0x11); // 写 Q 偏移 0 那 1 字节
device.ProcessData().Out()[2].AsInt16(123); // 写 Q 偏移 2 起 2 字节 (大端)
device.ProcessData().Out()[0].SetBit(0, true); // 写 Q 偏移 0 那字节的 bit0

In() / Out()

取本方向的元素数组。In() 对应 I(只读),Out() 对应 Q(可写)。

PnSProcessDataArray& In();
PnSProcessDataArray& Out();

返回值:

  • PnSProcessDataArray& — 元素数组的引用;本身的构造不查会话、不分配。

说明:

  • 两个都返回数组对象的引用,真正取字节发生在 operator[] 之后的取值 / 写值调用上。
  • In() 取到的元素会抛 PnSException("ProcessData.In 只读 I (控制器→设备); 写请用 ProcessData.Out")
  • 元素口没有「取长度」成员。本方向有多少字节要从快照口拿:device.ProcessData().Inputs().size() / Outputs().size()(I 侧实际可用的映射窗口比这个数再小 2–6 字节,见页首那条 caution)。
  • 别把 In() / Out() 的引用绑到一个临时门面对象上ProcessData()按值返回的,auto& in = device.ProcessData().In(); 里那个临时 PnSProcessData 在整条语句结束时就没了,in 随即悬空。要用引用就先把门面存下来:
PnSProcessData pd = device.ProcessData();   // 门面存下来, 活到你不用为止
auto& in = pd.In();
auto& out = pd.Out();

结构体映射不受这条影响 —— InputsMapping<T>() / OutputsMapping<T>() 返回的引用指向共享内存页,不是门面对象的成员。

相关结构:

class PnSProcessDataArray {                    // In() / Out() 返回的就是它
public:
PnSProcessDataArray(PnSService& svc, bool writable);

// 按字节偏移取元素 (偏移相对本方向过程映像起点, 不是元素序号)
PnSProcessDataItem operator[](int offset) const; // offset < 0 抛 PnSException
};

示例:

uint8_t lo = device.ProcessData().In()[0].Content();   // 直接串起来用, 不要先绑引用
device.ProcessData().Out()[0].Content(lo + 1); // 写回 Q, 当场提交

PnSProcessData pd = device.ProcessData(); // 要引用就先把门面存下来
auto& in = pd.In(); // I: 只读
auto& out = pd.Out(); // Q: 可写
out[1].Content(in[1].Content() + 1);

PnSProcessDataItem(元素)

单个过程数据元素。它是拷切片不是视图:内部只存「服务指针 + 字节偏移 + 方向」三样,没有任何指向过程映像的指针;每次取值都经一次区域快照,写入都经一次区域提交。

class PnSProcessDataItem {
public:
PnSProcessDataItem(PnSService& svc, size_t offset, bool writable);
// ... 成员见下
};

说明:

  • 每个成员都是从过程映像里当前偏移起、按该类型宽度读一段 / 写一段,不做任何隐式对齐处理。偏移是字节偏移,不强制 2 / 4 对齐。
  • 读方向:In() 出来的元素读 I;Out() 出来的元素读 Q 的影子(写后读一致)。
  • 写方向:只对 Out() 出来的元素成立,且每次写都是一次当场提交,正常返回 ⟺ 字节已落共享区。
  • 越界不做「提前拦截」:读/写的宽度一旦超出本方向区域,抛的是区域越界 PnSException("区域越界: 偏移 N + 长度 M 超出过程映像区 K 字节")
  • 一次取值 = 一份新的区域快照。所以同一个表达式里的两次取值可能落在两个不同的总线周期上,跨调用不保证同拍。

相关结构:

class PnSProcessDataItem {
public:
PnSProcessDataItem(PnSService& svc, size_t offset, bool writable); // 一般不用自己造

// 字节: 1 字节, 无符号, 原始字节
uint8_t Content() const; void Content(uint8_t value);

// 定宽标量: 取值用无参重载, 写值用同名单参重载; 多字节一律大端 (PROFINET 网络序)
int8_t AsInt8() const; // 1 字节 有符号 void AsInt8(int8_t value);
uint8_t AsUInt8() const; // 1 字节 无符号 void AsUInt8(uint8_t value); // 与 Content 同义同字节
int16_t AsInt16() const; // 2 字节 有符号 大端 void AsInt16(int16_t value);
uint16_t AsUInt16() const; // 2 字节 无符号 大端 void AsUInt16(uint16_t value);
int32_t AsInt32() const; // 4 字节 有符号 大端 void AsInt32(int32_t value);
uint32_t AsUInt32() const; // 4 字节 无符号 大端 void AsUInt32(uint32_t value);
int64_t AsInt64() const; // 8 字节 有符号 大端 void AsInt64(int64_t value);
uint64_t AsUInt64() const; // 8 字节 无符号 大端 void AsUInt64(uint64_t value);
float AsFloat() const; // 4 字节 IEEE754 大端 void AsFloat(float value);
double AsDouble() const; // 8 字节 IEEE754 大端 void AsDouble(double value);

// 布尔: 整字节, bit0 语义
bool AsBool() const; void AsBool(bool value); // 写: 整字节 0 / 1

// 位: 只碰当前偏移那一个字节, 索引 0-7, 0 = LSB
bool GetBit(int bitIndex) const;
void SetBit(int bitIndex, bool value); // 同字节其余位不动
};

示例:

// 读 (I 侧)
uint8_t b = device.ProcessData().In()[0].Content();
int16_t iw = device.ProcessData().In()[2].AsInt16();
uint32_t dw = device.ProcessData().In()[4].AsUInt32();
double f8 = device.ProcessData().In()[8].AsDouble();

// 写 (Q 侧)
device.ProcessData().Out()[0].Content(0x11);
device.ProcessData().Out()[2].AsInt16(-123);
device.ProcessData().Out()[4].AsUInt32(10000);

AsBool()

按整字节读写真假值,bit0 语义:只有最低位为 1 才是 true

bool AsBool() const;
void AsBool(bool value);

返回值:

  • bool — 当前偏移那一个字节最低位的值(0false1true)。

说明:

  • 非规范字节 0x020x80 都读 false —— 不是「非 0 即真」。
  • 本 SDK 只提供这一个整字节布尔口径。要按整字节 0 / 非 0 判真假,请读 Content() 自行比较。
  • 写方向把整个字节写成 0x010x00,不是改单个位。要按位读写请用 GetBit() / SetBit()
  • In() 侧元素:PnSException

示例:

bool flag = device.ProcessData().In()[3].AsBool();     // 只有最低位为 1 才是 true
uint8_t raw = device.ProcessData().In()[3].Content(); // 要按整字节 0 / 非 0 判真假就读原始字节
device.ProcessData().Out()[3].AsBool(true); // 写: 整个字节 = 0x01

GetBit() / SetBit()

读写元素所在的那一个字节里的一位。

bool GetBit(int bitIndex) const;
void SetBit(int bitIndex, bool value);

返回值:

  • bool — 该位的值。

说明:

  • 位索引取值 070 是最低位(LSB)。越界抛 PnSException("位索引必须在 0-7 之间")
  • 位索引只用元素偏移处的那一个字节,与元素宽度无关:在按 AsInt16 / AsInt32 读写的那个偏移上取 07,仍然只访问起始字节,不会跨到后续字节。
  • SetBit读改写:读出那一个字节、只翻目标位、整字节写回 —— 同字节其余位不动。
  • SetBit 只对 Out() 侧成立;对 In() 侧先抛只读 PnSException(判读只读在判位索引之前,所以 In()[0].SetBit(9, true) 报的是只读,不是位索引越界)。写入时同字节其余位不动。
  • 区域越界时抛的是区域越界文案(见 PnSProcessDataItem 的说明)。

示例:

bool b0 = device.ProcessData().In()[3].GetBit(0);    // 偏移 3 那字节的 bit0
device.ProcessData().Out()[3].SetBit(0, true); // 只置 bit0, 同字节其余位不动
device.ProcessData().Out()[3].SetBit(0, false); // 只清 bit0

同一拍与快照

读接口分三类,混用前必须知道差别:

读法性质「哪一刻的值」
InputsMapping<T>() / OutputsMapping<T>() 拿到的引用live 视图,直接指向过程映像没有「读一次」这个动作,访问成员拿到的就是那一刻的过程映像
元素口 In()[i] / Out()[i]快照:每次取值重新取一份本方向完整区域,再按偏移解析一次取值 = 一份新的快照
Read() / ReadArea() / ReadStruct<T>() / Inputs() / Outputs() / GetIo()快照一次调用 = 一份新的快照

所以同一个表达式里的两次取值可能落在两个不同的总线周期上

// 两次调用 = 两份独立快照, 不保证同拍
int32_t delta = device.ProcessData().In()[6].AsInt32()
- device.ProcessData().In()[2].AsInt32();

写方向同理:一次写 = 一次提交,不做攒批。Out()[0].Content(v)Out()[2].AsInt16(v) 是两个独立的提交时刻,各自当场把自己那一段落到共享区;提交失败时脏区保留(数据不丢),下一次写会把累积的脏区一起提交重试。

需要多个字段一致时,任选一条:

① 用映射引用,按值拷一份(最简单,也最省):

const FromPlc& live = device.ProcessData().InputsMapping<FromPlc>();
FromPlc snapshot = live; // 一次拷贝: 拷完就不再跟总线变
int16_t recipe = snapshot.Recipe; // 与 snapshot.Present 来自同一次拷贝

② 整段快照一次读够,再自己按偏移解析

std::vector<uint8_t> frame = device.Read();          // 一份完整 I 区快照, 内部自洽
if (frame.size() >= 6) {
uint8_t a = frame[0];
int32_t b = (int32_t)((frame[2] << 24) | (frame[3] << 16) | // 大端, 自己拼
(frame[4] << 8) | frame[5]);
}

③ 结构体快照ReadStruct<T>() 一次拷一个结构体,字段之间来自同一次拷贝。

FromPlc s = device.ProcessData().Read<FromPlc>();    // 等价 ReadStruct<FromPlc>()
0 拷贝的读路径只有这一族
  • InputsMapping<T>() —— 整幅输入映像 overlay 成 const T&
  • OutputsMapping<T>() —— 整幅输出映像 overlay 成 T&,改字段即改过程映像。

这两个出口底下就是共享区原始字节口 i_payload() / q_payload()(见「共享区直连」):同样是指向过程映像的地址,不是拷贝,只是不带类型。

元素口、Read() / ReadArea() / ReadStruct<T>() / GetIo() 全是拷贝,不是 0 拷贝。写路径除 OutputsMapping<T>() 外都是快照提交(Write() / SetOutputs() / WriteArea())。

整区入口(快照)

不想逐项处理时,直接整段读写。这些都是拷贝,不是视图 —— 要「源头变字段自己变」还得用结构体映射。

Read()

取一份 I 区(控制器 → 设备)完整快照。

std::vector<uint8_t> Read();

返回值:

  • std::vector<uint8_t> — I 区整段拷贝,长度 = 输入区长。一次调用返回的这份内部自洽(同一拍)。

说明:

  • 失败抛 PnSException:未挂接 / 未 Start 的两条文案见 InputsMapping<T>();共享内存没映射上是 "过程数据未就绪, 无法读写 IO"
  • 只走共享内存通道:每次调用取当前值,不按节拍缓存
  • 返回的长度是输入区长(IOPS 前缀位置被前置净荷取代、多出来的尾部补 0),不是净荷窗口 —— 见「字节数怎么拿」。
  • 跨多次调用不保证同一拍:要多个字段一致,一次读够再自己切。

示例:

std::vector<uint8_t> all = device.Read();                  // I 整段
if (all.size() >= 4) {
int16_t recipe = (int16_t)((all[2] << 8) | all[3]); // 自己按大端解
}

Write(data)

写 Q(设备 → 控制器),允许短写。

void Write(const std::vector<uint8_t>& toPlc);

返回值:

  • 无。正常返回 ⟺ 字节已落共享区(当场提交,不按节拍延迟)。

说明:

  • 从偏移 0 起覆盖前 data.size() 字节,不动后面剩下的字节。
  • 失败抛 PnSException
    • data 为空:"写数据不能为空"
    • 未挂接 / 未 Start"会话未建立 (请先 Start 或 Connect)" / "未 Start, 无法读写 IO"
    • 共享内存没映射上:"过程数据未就绪, 无法读写 IO"
    • data.size() 超出 Q 区长:"区域越界: 偏移 0 + 长度 M 超出过程映像区 K 字节"
    • 提交失败:code() == PnSErrorCode::NativeErrorwhat()"IO 提交失败: <真因>""过程数据未就绪, 无法提交 IO"脏区保留,下一次写会重试;不报假成功,也不丢数据。
  • Write()SetOutputs() 是同一路径的两种写法(后者在 ProcessData() 上)。
  • 不接异常也看得到失败:device.LastServiceError() / 静态 LastError() / LastErrorCode()

示例:

device.Write(std::vector<uint8_t>{0x01, 0x00});     // 从偏移 0 起 2 字节

ReadStruct<T>() / WriteStruct<T>(data)

结构体快照的进 / 出,不是映射:返回值不会随总线刷新自己变。

template <typename T>
T ReadStruct();

template <typename T>
void WriteStruct(const T& data);

返回值:

  • T — 按值返回的结构体拷贝。

说明:

  • T 必须 trivially copyable(编译期 static_assert)。不需要 pack(1):这里没有 overlay,是按数组拷贝再 memcpy
  • 拷贝的是线上字节原样,不做字节序转换:结构体里的 int16_t / float 字段仍然是大端字节序,要用它得自己转。
  • ReadStruct<T>() 失败:断言外,sizeof(T) 大于 I 区长抛 PnSException("结构体大小 N 超出 I 区 M 字节")(这条文案本身就是尺寸不匹配的定位信息)。
  • WriteStruct<T>(data) 失败:sizeof(T) == 0 会被当成空写,抛 "写数据不能为空";其余失败与 Write() 完全相同(短写、当场提交、提交失败带 NativeError 码并保留脏区)。
  • 要长期跟随总线刷新的活视图,请用 InputsMapping<T>() / OutputsMapping<T>()

示例:

FromPlcText t = device.ProcessData().Read<FromPlcText>();   // 等价 ReadStruct<FromPlcText>()
ToPlc out{}; out.Ready = 1;
device.ProcessData().Write(out); // 等价 WriteStruct(out)

GetIo()

一次取回两个方向的净荷拷贝,直读共享区(不经快照缓存)。

std::pair<std::vector<uint8_t>, std::vector<uint8_t>> GetIo();

返回值:

  • std::pairfirstQ 侧净荷(与 Outputs() 同一块,长度 = 输出区长 − 3);secondI 侧净荷(与 Inputs() 同一块,长度 = 输入区长)。都是拷贝

说明:

  • 失败抛 PnSException:未挂接 / 未 Start 的两条文案同上;共享内存没映射上是 "过程数据未就绪, 无法读写 IO"
  • ReadArea 不同,它绕开快照缓存直读共享区,所以也不参与「写后读一致」的影子语义 —— 拿到的就是共享区此刻的值。
  • first / second 的顺序容易记反:先 Q 后 I。记不住就直接用 Inputs() / Outputs(),语义更直白。
  • 两个长度用的不是同一种减法(Q 侧减掉了固定的 3 字节 IOPS 前缀,I 侧没有减),所以不要拿 first.size()second.size() 直接比大小

示例:

auto io = device.GetIo();
size_t qLen = io.first.size(); // Q: 设备 → 控制器
size_t iLen = io.second.size(); // I: 控制器 → 设备
if (!io.second.empty()) {
uint8_t firstByteOfI = io.second[0];
}

ReadArea() / WriteArea()

按区域 + 字节偏移整段读 / 部分覆盖写。

std::vector<uint8_t> ReadArea(PnSServiceArea area, size_t offset, size_t length);
std::vector<uint8_t> ReadArea(PnSServiceArea area);

void WriteArea(PnSServiceArea area, size_t offset, const std::vector<uint8_t>& data);
void WriteArea(PnSServiceArea area, const std::vector<uint8_t>& data);

返回值:

  • ReadArea(...)std::vector<uint8_t>:整区快照按 offset / length 截取的一段。
  • WriteArea(...) — 无。正常返回 ⟺ 整段字节已落共享区(当场提交)。

说明:

  • area 只认 PnSServiceArea::Input(I,只读)与 PnSServiceArea::Output(Q,只写)。传 Memory / DbUnknownPnSException("区域非法")
  • 读的失败形态:"区域非法"offset + length 超出本区域:"区域越界: 偏移 N + 长度 M 超出过程映像区 K 字节";未挂接 / 未 Start / 共享内存没映射上见上。
  • 写的失败形态更多,顺序是先查参数、后动数据:I 区抛 "I 区 (控制器 -> 设备) 只读, 不支持写";区域非法抛 "区域非法"data 为空抛 "写数据不能为空";越界抛区域越界文案;提交失败抛 PnSException(code = PnSErrorCode::NativeError),脏区保留待重试。
  • WriteArea(area, data)WriteArea(area, 0, data) 的简写,从偏移 0 起覆盖。
  • 一次调用 = 一次提交:可以调多次写不相邻的段,但那就是多次提交,不是一次批量。要一次提交多个不相邻改动,请自己在 data 里拼好连续段,或改用 OutputsMapping<T>() 直接改字段。
  • 读路径会把整区拷出来再切片 —— 只想拿几个字节时不必在意,区域本身只有几十字节。

示例:

std::vector<uint8_t> part = device.ReadArea(PnSServiceArea::Input, 4, 2);   // I 区偏移 4 起 2 字节
std::vector<uint8_t> allQ = device.ReadArea(PnSServiceArea::Output); // Q 整区

device.WriteArea(PnSServiceArea::Output, 4, std::vector<uint8_t>{0x01, 0x02}); // 覆盖 Q[4..5]

Inputs() / Outputs() / SetOutputs() / Read<T>() / Write<T>()

ProcessData() 上的便利转发,底层就是上面那套整区入口。

std::vector<uint8_t> Inputs();                                 // → Read()
std::vector<uint8_t> Outputs(); // → ReadArea(Output)
void SetOutputs(const std::vector<uint8_t>& data); // → Write(data)

template <typename T> T Read(); // → ReadStruct<T>()
template <typename T> void Write(const T& data); // → WriteStruct<T>()

返回值:

  • Inputs() / Outputs() — 整段拷贝。
  • Read<T>() — 结构体拷贝。
  • SetOutputs() / Write<T>() — 无。

说明:

  • 读与写不同名Outputs() Q 影子;写 Q 要用 SetOutputs(data)。旧的同名写重载 Outputs(data) 已删除,不留 deprecated 转发 —— 别照着老示例写。
  • Inputs() / Outputs() 的长度口径见「字节数怎么拿」:I 侧是输入区长,Q 侧已减去固定的 3 字节 IOPS 前缀。
  • 失败形态与它们转发到的那几个入口逐条相同,不另立一套。

示例:

std::vector<uint8_t> i = device.ProcessData().Inputs();
std::vector<uint8_t> q = device.ProcessData().Outputs();
device.ProcessData().SetOutputs(std::vector<uint8_t>{0x01, 0x00});

ToPlc out{}; out.Ready = 1;
device.ProcessData().Write(out); // 结构体写 Q 的快照口

按地址读写(区域 + 字节 + 位)

不想自己算偏移、想照着工程软件里的地址写时用这一组:区域字母 + 字节偏移 + 位。地址由 ParseAddress 解析,字 / 双字偏移即字节偏移,不强制 2 / 4 对齐

地址写法是什么
I0.0 / Q0.0位地址:第 0 字节的第 0 位(位号只能 0–7)
IB0 / QB0字节:1 字节
IW2 / QW2字:2 字节,大端
ID4 / QD4双字:4 字节,大端
I0 / Q4不带类型字符 = 字节区(长度 1)

不允许的:M 区 / DB 区(服务模式不支持,解析即抛)。

ParseAddress()

静态解析 HSL 式地址串。不要求已连接、已 Start —— 纯字符串处理,可以拿来在启动前校验用户输入的地址。

static PnSServiceAddress ParseAddress(const std::string& text);

返回值:

  • PnSServiceAddress — 解析结果:区域、字节偏移、位号、数据宽度。bit-1 表示非位寻址;slot / subslot 是接口里的固定值(本产品固定单模块),读 / 写路径都不会用到它们。

说明: 逐条列出全部失败形态(都是 PnSException,单参构造,code()-1):

输入抛出的文案
空串 / 全空白"地址不能为空"
DB1.DBD4、(大小写不限的 db 开头)"不支持 DB 地址: DB1.DBD4"
M0.0M 区)"不支持 M 地址: M0.0"
其它首字母,如 X0.0"不支持的地址区域: X0.0"
I0. / I.0 / Q1."地址格式非法 (位地址应为 I0.0 形式): I0."
位号不在 0–7,如 I0.8"位号必须在 0-7 之间: I0.8"
偏移或位号含非数字,如 Q-1 / I0.0.0"字节偏移非法: Q-1" / "位号非法: I0.0.0"
偏移或位号超过 0xFFFF"字节偏移超出范围: IW70000" / "位号超出范围: I0.65536"
只有区域和类型字符、没有数字,如 I / IW"地址缺少偏移量: IW"
  • 区域字母大小写不敏感i0.0 = I0.0q4 = Q4);类型字符 B / W / D 只认大写Iw2 会被当成「偏移里有非数字」而抛 "字节偏移非法: Iw2"
  • 字 / 双字偏移不做对齐检查:IW3 / ID2 都合法,偏移就是字节偏移。
  • 位地址的 length 固定按 1 字节记 —— 位操作必须整字节读写,才不会踩坏同字节的其它位。

示例:

PnSServiceAddress a = PnSService::ParseAddress("IW2");
// a.area == PnSServiceArea::Input, a.byte_offset == 2, a.bit == -1, a.length == 2

try {
PnSService::ParseAddress("M0.0"); // 抛 "不支持 M 地址: M0.0"
} catch (const PnSException& ex) {
std::cerr << ex.what() << "\n";
}

ReadBool() / WriteBool()

按位地址读写一个位。

bool ReadBool(const std::string& address);
void WriteBool(const std::string& address, bool value);

返回值:

  • bool — 该地址所在字节那一位的值(0false1true)。

说明:

  • 地址必须带位号I0.0 / Q3.7)。给 IB0 这类不带位号的地址抛 PnSException("位地址必须带位号: IB0")
  • 这里判真判的是那一位(byte & (1 << bit)) != 0),与元素口 AsBool() 的整字节 bit0 口径是两回事:前者测的是你点的位,后者测的永远是字节的 bit0。要按整字节 0 / 非 0 判真假,读 Content() 自行比较。
  • WriteBool读改写:读出整字节、只翻目标位、整字节写回 —— 同字节其它位不动。
  • WriteBool 写 I 区(I0.0)抛 PnSException("I 区 (控制器 -> 设备) 只读, 不支持写")
  • 越界抛 "地址越界: 偏移 N + 长度 M 超出过程映像区 K 字节";未挂接 / 未 Start / 共享内存没映射上的文案见 InputsMapping<T>();写提交失败抛 PnSException(code = PnSErrorCode::NativeError),脏区保留待重试。
  • 读方向可并发;写方向由你自己串行化。

示例:

bool done = device.ReadBool("I0.0");      // 读 I 第 0 字节的 bit0
device.WriteBool("Q0.0", true); // 只置 Q 第 0 字节的 bit0
device.WriteBool("Q0.1", false); // 只清 Q 第 0 字节的 bit1, bit0 不动

ReadInt16() / WriteInt16()

按字地址(2 字节)读写,大端编解码。

int16_t ReadInt16(const std::string& address);
void WriteInt16(const std::string& address, int16_t value);

返回值:

  • int16_t — 该地址起 2 字节按大端(PROFINET 网络序)解出的有符号值。

说明:

  • 地址必须是 2 字节宽且非位寻址:只认 IW2 / QW2 这类,也给不认 IW2.0。给 I0(宽度 1)、ID2(宽度 4)、I0.0(位寻址)都抛 PnSException("字地址必须为 2 字节且非位寻址: <地址>")
  • 不强制 2 字节对齐IW3 合法。
  • 字节序是大端:先发高位字节。WriteInt16("QW2", 123) 落成 00 7B
  • WriteInt16 写 I 区抛 "I 区 (控制器 -> 设备) 只读, 不支持写";越界 / 未挂接 / 提交失败见 ReadBool() 一节。
  • 要无符号 16 位或浮点,本组没有对应入口 —— 用元素口 In()[i].AsUInt16() / AsFloat(),或 ReadArea 取原始字节自己解。

示例:

int16_t recipe = device.ReadInt16("IW2");     // I 偏移 2 起 2 字节 (大端)
device.WriteInt16("QW2", 123); // Q 偏移 2 起 2 字节 (大端)
device.WriteInt16("QW3", -1); // 偏移 3 也合法: 不对齐不报错

ReadInt32() / WriteInt32()

按双字地址(4 字节)读写,大端编解码。

int32_t ReadInt32(const std::string& address);
void WriteInt32(const std::string& address, int32_t value);

返回值:

  • int32_t — 该地址起 4 字节按大端解出的有符号值。

说明:

  • 地址必须是 4 字节宽且非位寻址:给 ID4 / QD4 这类。给 IW2(宽度 2)、I0.0(位寻址)抛 PnSException("双字地址必须为 4 字节且非位寻址: <地址>")
  • 不强制 4 字节对齐ID2 合法。
  • 字节序大端:WriteInt32("QD4", 10000) 落成 00 00 27 10
  • 其余失败形态(I 区只读 / 越界 / 未 Start / 提交失败)与 ReadInt16() 完全相同。
  • 要无符号 32 位或浮点,请走元素口 In()[i].AsUInt32() / AsFloat() / AsDouble()

示例:

int32_t counter = device.ReadInt32("ID4");
device.WriteInt32("QD4", 10000);

共享区直连(同一份过程映像的另一条路)

过程数据只有共享内存这一条通道:读写的落点都是过程映像本身。PnSSharedImage 就是这条通道的原始句柄,类里没有会话参数 —— 上面那一整套入口(ProcessData() / Read() / 地址化读写)都要先建服务会话、Start() 成功之后才能用;这一族只认共享区本身,判据是 is_mapped()connected()

它也是 InputsMapping<T>() / OutputsMapping<T>()底层原语i_payload() / q_payload() 返回的就是共享内存页内地址,结构体映射是在它们之上做的 overlay。差别是这一族不认结构体、不带尺寸门,越界按净荷窗口自己算。

pns.hpp 已经 #includepns_shm.hpp;后者也能单独 include,与 pns.hpp 任意顺序同用,都在 darra::pns 命名空间。这一族的失败PnSShmException(不是上面那个 PnSException),两种异常的文案互不通用。

#include "pns.hpp"          // 已含 pns_shm.hpp; 也可单独 #include "pns_shm.hpp"
using namespace darra::pns;

PnSSharedImage shm;
shm.map(); // 打开 + 映射 + 校验头 (失败抛 PnSShmException)
shm.write_to_plc(0, {0x11, 0x22}); // 写 Q (设备 → 控制器)
std::vector<uint8_t> fromPlc = shm.read_from_plc(); // 读 I (控制器 → 设备, 已剥 IOPS 前缀)
bool run = shm.connected(); // 看门狗: 周期数据正常
// 析构自动 unmap

map() / unmap() / is_mapped()

打开并映射命名共享内存,并校验头的 SizeVersion

void map();                  // 幂等: 已映射直接返回
void unmap() noexcept; // 幂等, 不抛
bool is_mapped() const;

返回值:

  • is_mapped()bool:视图在 → true

说明:

  • map() 幂等:已映射就直接返回。失败如实抛 PnSShmException,文案里带 NTSTATUS;失败路径把已经开的句柄 / 视图收回去,不留半映射状态
  • 映射的是头 + 两个方向的过程映像整段,契约总大小 3104 字节(头 224 + 2 × 1440)。实际视图比契约小也抛。
  • 头校验不过(SizeVersion 与契约值不符)同样抛,视图与句柄一并回收。
  • 驱动没加载 / 非管理员 / 服务没启动,map() 的失败文案里就写着这三条线索。
  • 非 Windows 平台能编译,但 map()"PnSSharedImage 仅支持 Windows"
  • unmap() 解除映射并关闭 Section 句柄,幂等且不抛;析构等价于 unmap()解除映射之后 i_payload() / q_payload() 给出的地址立即失效。

示例:

PnSSharedImage shm;
shm.map(); // 失败已经抛了, 返回了就说明已映射
bool mapped = shm.is_mapped(); // true
shm.unmap(); // 幂等, 不抛, 再调一次也没事

read_from_plc() / read_to_plc_shadow()

std::vector<uint8_t> read_from_plc() const;
std::vector<uint8_t> read_to_plc_shadow() const;

读输入区(I,控制器 → 设备)与读输出区影子(Q,你最近写进去的那份)。两个都是拷贝

返回值:

  • std::vector<uint8_t> — 已剥线上控制字前缀的净荷。

说明:

  • read_from_plc() 返回长度恒 = 输入区长(剥前缀前的长度):净荷前置、尾部补 0。真零数据返回同长全 0,不假装没数据。
  • read_to_plc_shadow()write_to_plc() 对称:写什么读回什么。输出区还没就绪(输出区长 ≤ 3 字节)时返回空 vector
  • 本族的读是快照,可能与运行时并发更新撕裂;要同一拍自洽就一次读够。
  • PnSShmException:未映射;过程数据区窗口越界。

示例:

std::vector<uint8_t> i = shm.read_from_plc();
std::printf("I %zu 字节\n", i.size());

std::vector<uint8_t> shadow = shm.read_to_plc_shadow(); // 复核刚写进去的 Q

write_to_plc()

从净荷偏移 offset 起写 Q 区(设备 → 控制器)。允许短写。

void write_to_plc(uint32_t offset, const uint8_t* data, size_t length);
void write_to_plc(uint32_t offset, const std::vector<uint8_t>& data); // 便捷重载

返回值:

  • 无。

说明:

  • 落点 = 输出区起始 + 3 + offset;净荷窗口 = 输出区长 − 3(前 3 字节是 0 长子模块的 IOPS 状态字节,固定 3、不扫描)。
  • 允许短写:写不下时按可容纳长度截断,不报错;实际落盘多少用 read_to_plc_shadow() 复核。要「越界即报错」的口,用 WriteArea() / 元素口那一族。
  • length == 0 允许:不拷数据,只置提交标志。
  • 提交时序:先写净荷、再置 OutputDirty,中间有 release 屏障保证净荷先对驱动可见。
  • PnSShmExceptiondata 是空指针但 length > 0offset 越出净荷窗口;offset + length 一个字节都落不进去。

示例:

shm.write_to_plc(0, std::vector<uint8_t>{0x01, 0x00});   // Q 净荷偏移 0 起 2 字节

uint8_t q[2] = {0x02, 0x00};
shm.write_to_plc(0, q, sizeof(q)); // 指针重载, 同一条路

std::vector<uint8_t> back = shm.read_to_plc_shadow(); // 复核实际落盘

i_payload() / q_payload()

净荷的页内地址,0 拷贝金路径的底层原语。

volatile uint8_t* i_payload(size_t* n = nullptr) const;
volatile uint8_t* q_payload(size_t* n = nullptr) const;

返回值:

  • volatile uint8_t*共享内存页内地址(不是拷贝)。n 非空时写回净荷字节数。

说明:

  • 映射一次之后,总线刷新字段自己变 —— 这一族不产生任何拷贝。InputsMapping<T>() / OutputsMapping<T>() 就是在它上面做的 overlay。
  • i_payload() 剥 IOPS 前缀的算法与 read_from_plc() 完全相同:默认剥 2 字节,抽出窗口仍是 0x80… 时最多再剥 4 个。所以可用窗口比输入区长小 2–6 字节,且每次调用现算,不是恒定地址。
  • q_payload() 剥固定 3 字节前缀,落点与 write_to_plc() / 驱动组帧契约一致(输出区 + 3)。改字节即改过程映像。
  • 地址在映射视图的生命周期内有效unmap() / 析构之后失效。
  • 结构体按这个视图映射必须 pack(1)(净荷非自然对齐,有填充就整体错位),多字节一律网络大端 —— 与页首的结构体契约同一条。
  • PnSShmException:未映射;输入区 / 输出区窗口越界;输入区长度不足以剥出净荷("过程数据输入映像不可用");输出区长 ≤ 3 字节("过程数据输出映像不可用")。

示例:

size_t n = 0;
volatile uint8_t* qp = shm.q_payload(&n); // 页内地址, 不是拷贝; n = 输出区长 - 3
uint8_t* raw = const_cast<uint8_t*>(qp);
raw[0] = 0x01; // 改字节即改 Q 过程映像

size_t m = 0;
volatile uint8_t* ip = shm.i_payload(&m); // I 侧同一条路, m = 输入区长 - 前缀(2-6)

read_header() / connected()

Header read_header() const;
bool connected() const;
uint32_t driver_state() const;
uint64_t last_error() const;

返回值:

  • read_header()Header:共享内存头的一次性快照。
  • connected()bool:主站周期数据是否正常。
  • driver_state()uint32_t:驱动运行态原值。
  • last_error()uint64_t:驱动最近一条错误的原值。

说明:

  • connected() 读的是头里的看门狗字段,不假绿1 = 周期数据正常,0 = 掉线 / 安全态。周期循环里先判它,再决定用数据还是走安全态。它为 0read_from_plc() 仍会返回共享区里的字节 —— 那几个字节属于哪一拍,本头不作承诺。
  • driver_state() 是驱动 DARRT_PNS_STATE 的镜像原值:Stopped / Bound / Configured / Running / Safe / Faulted。
  • 头里的长度 / 偏移类字段读出后立即 clamp 到契约上限(长度 ≤ 1440 字节,偏移 ≤ 3104),防脏头外推出越界窗口。
  • 这几个都是快照,可能与驱动并发写撕裂。要判「手上这份是不是新的一拍」,看头里的 Heartbeat(驱动每次喂狗 +1)与 CpmCycleCounter(每 IOCR 的 CPM 提交计数)。
  • 未映射时统一抛 "过程数据未就绪 (请先 map())"

示例:

if (!shm.connected()) {
// 掉线 / 安全态: 拍间等待自备, 本头不碰调度
}
PnSSharedImage::Header h = shm.read_header();
std::printf("State=%u LastError=0x%llX 心跳=%llu\n",
h.State, (unsigned long long)h.LastError, (unsigned long long)h.Heartbeat);

记录索引空间(用户区 / I&M)

非周期(acyclic)记录也是两套索引空间、两套端点,别当成一张表去找。

索引空间范围本 SDK 的入口
用户区记录0x00000x7FFFGetRecords()Diagnostics().Records() 同一数据)
I&M 设备标识0xAFF00xAFF4GetIm() / ImData()

GetRecords()

取用户区记录表。只读观测,本 SDK 没有写记录的入口。

PnSUserRecords GetRecords();

返回值:

  • PnSUserRecords — 记录表。没有记录就是空表,不是失败

说明:

  • 覆盖范围是用户区 0x00000x7FFF。这里面没有 I&M 记录。
  • 字段缺失一律取默认值(0 / 空串),不虚构。count 是服务端计数,不受本地数组长度影响。
  • 失败抛 PnSException:未挂接抛 "会话未建立 (请先 Start 或 Connect)";服务端非 200 / 不可达抛 PnSException(单参构造,code() = -1)。不需要 Start() —— 构造并 Connect 之后就能读。
  • 每条记录带 slot / subslot / index / length / data_hex —— 对端信息在记录条目里,但调用侧不接受槽位参数(本产品固定单模块)。

相关结构:

struct PnSUserRecord {
uint16_t slot = 0; // 槽位
uint16_t subslot = 0; // 子槽位
uint16_t index = 0; // 记录 Index (用户区 0x0000-0x7FFF)
uint16_t length = 0; // 负载字节数
std::string data_hex; // 负载十六进制文本 (缺字段为空串)
};

struct PnSUserRecords {
std::vector<PnSUserRecord> records; // 记录数组 (空 = 没有记录, 不是失败)
int count = 0; // 服务端计数 (缺字段为 0)
};

示例:

PnSUserRecords recs = device.GetRecords();
for (const PnSUserRecord& r : recs.records) {
std::printf("slot %u/%u index 0x%04X len %u %s\n",
r.slot, r.subslot, r.index, r.length, r.data_hex.c_str());
}
这张表里没有 I&M

GetRecords() 读的是用户区0x00000x7FFF);I&M 在另一套索引空间(0xAFF00xAFF4),走另一个端点。在记录表里翻 I&M 的 index 是找不到的 —— I&M 请用下面的 GetIm()

GetIm() / ImData()

取设备标识(I&M)观测数据。两个名字是同一个实现。

PnSServiceImInfo GetIm();
PnSServiceImInfo ImData(); // 同 GetIm()

返回值:

  • PnSServiceImInfo — I&M0 生效值 + I&M1–I&M3 回读值 + 工程标识字段。

说明:

  • 这是本 SDK 里唯一服务 I&M(0xAFF00xAFF4)的入口;记录表 GetRecords() 到不了这个索引空间。
  • I&M1–I&M3 由原生栈启用并如实回读:服务端从站已启动时 im14_supported == true,字段是主站写过的值(主站没写过就是栈的出厂占位值);I&M4 未启用(GSDML 未声明),im4_signature_hex 为空。这两种情况的口径都如实写在 im_note 里。
  • order_id / serial_number / hardware_revision / software_revision 是原生栈固定默认值的镜像;project_* 是工程 XML 里的标识字段(尚未接入 I&M0)。这些口径同样如实写在 im_note 里,别当成实测值往上报。
  • 字段缺失 / 解析失败取默认值(空串 / 0),不报错。
  • 失败抛 PnSException:未挂接抛 "会话未建立 (请先 Start 或 Connect)";服务端非 200 / 不可达抛 PnSException(单参构造,code() = -1)。不需要 Start() —— 构造并 Connect 之后就能读。

相关结构:

struct PnSServiceImInfo {
uint16_t vendor_id = 0; // 厂商 ID (I&M0, 读自运行配置)
uint16_t device_id = 0; // 设备 ID (I&M0, 读自运行配置)
std::string order_id; // 订货号 (原生栈固定默认值镜像)
std::string serial_number; // 序列号 (原生栈固定默认值镜像)
std::string hardware_revision;
std::string software_revision;
std::string station_name; // 站点名 (DCP 发现用, 来自运行配置)
std::string product_name; // 产品名 (LLDP/SNMP 用, 来自运行配置)
std::string im_supported; // 启用的 I&M 记录集 (按原生栈掩码如实拼, 如 "I&M0,I&M1,I&M2,I&M3")
bool im14_supported = false; // I&M1-4 是否读回成功 (从站已启动 = true; 未启动读不到 = false, 字段为空串)
std::string im_note; // 口径说明 (如实标注数据源 / 哪些没启用 / 没接入)
std::string im1_tag_function; // I&M1 功能标签 (主站没写过 = 出厂占位值)
std::string im1_tag_location; // I&M1 安装位置 (主站没写过 = 出厂占位值)
std::string im2_date; // I&M2 日期 (主站没写过 = 出厂占位值)
std::string im3_descriptor; // I&M3 描述 (主站没写过 = 出厂占位值)
std::string im4_signature_hex; // I&M4 签名 (未启用, 为空)
std::string project_order_number; // 工程 XML <OrderNumber> (未接入 I&M0)
std::string project_hardware_release; // 工程 XML <HardwareRelease> (未接入 I&M0)
std::string project_software_release; // 工程 XML <SoftwareRelease> (未接入 I&M0)
};

示例:

PnSServiceImInfo im = device.GetIm();                 // 或 device.ImData()
std::printf("vendor=0x%04X device=0x%04X station=%s\n",
im.vendor_id, im.device_id, im.station_name.c_str());
if (!im.im14_supported)
std::printf("I&M1-4 不可读: %s\n", im.im_note.c_str());

字节数怎么拿

长度由运行配置决定,不要写死 20 / 32(改过组态就变)。C++ 这一侧没有单独的「取长度」函数,长度就是取回那份 bytes 的 .size()

想拿走哪
I 整段长度device.Read().size() / device.ProcessData().Inputs().size() / GetIo().second.size()
Q 整段长度device.ProcessData().Outputs().size() / ReadArea(PnSServiceArea::Output).size() / GetIo().first.size()
映射能用的窗口ProcessData() 这一侧没有直接入口:I 侧 = 输入区长 − 前缀(前缀 2–6 字节,每次映射现算),Q 侧 = 输出区长 − 3。共享区直连的 i_payload(&n) / q_payload(&n) 出参给的就是它(见「共享区直连」)。
两个方向的配置长度device.GetDiag()input_area_length / output_area_length

两个容易踩的点:

  • I 侧与 Q 侧的 .size() 用的不是同一种减法:I 侧返回的是输入区长(IOPS 前缀位置被前置净荷取代、尾部补 0),Q 侧返回的是输出区长减掉固定的 3 字节前缀。所以别拿这两个数直接比大小。
  • sizeof(T) 的上限不是 Read().size(),而是映射窗口(输入区长 − 2 到 6 字节)。写结构体时按小的那个卡:拿 Inputs().size() 当上限,可能在尺寸临界点上被 "结构体大于输入过程映像" 拒收。

生命周期与失效

映射和引用的有效窗口与设备生命周期绑在一起,失败形态按源码如实列出:

入口签名失败形态
Start()bool Start()不抛。失败返回 false,原因在 device.LastServiceError()(实例)与静态 LastError() / LastErrorCode()。已 Start 且未 Stop 时幂等返回 true。过程数据通道只在本机 host(空 / 127.0.0.1 / localhost / ::1)上建立;PnSService(host, port) 备用构造连别的主机时没有本机过程数据通道,会失败在 "过程数据未就绪, 无法启用 IO"
Stop()void Stop()不抛。best-effort:本次结果不外报,失败写 LastServiceError() / 静态 LastError()(+ LastErrorCode()),随后 started_ 照旧翻回 false。析构 / Close() / ScopedStart 析构走的都是它。
StopChecked()void StopChecked()会抛。与 Stop() 同一停用流程,但把本次调用自己的结果如实报出(不靠 sticky 文案推断,两次相同文案也报得出来):失败抛 PnSExceptioncode() = 本次失败码),且 started_ 保持 true(如实:未确认停用),再调一次会真正重试。析构路径禁用。
Close()void Close()不抛。仍在跑则先 Stop();服务清全部 Q、I 保留;随后解除共享内存映射、释放实时门。之后所有映射引用失效。
析构~PnSService()等价于 Close()
  • 映射须在 Start() 成功之后取;Stop() 只停刷新 —— 视图仍在,读到的是旧值;Close() 之后失效。
  • IsRunning() 看的是「Start 已成功且尚未 Stop」;主站是否真的在周期交换看 Connected()(或静态 ServiceConnected())—— 两者不是一回事。
  • 状态查询的失败口径不一样:Status() / StatusEx() 在服务不可达时如实抛 PnSExceptionWaitReason() 从不抛 —— 未挂接时走静态 ServiceWaitReason(),已挂接但请求失败时回落到上次错误文案;Connected() 未挂接时不抛(走静态 ServiceConnected()),已挂接但服务不可达时随 StatusEx() 抛。

完整示例

#include "pns.hpp"
#include "pns_sugar.hpp"

#include <cstdio>
#include <iostream>
#include <vector>

using namespace darra::pns;

#pragma pack(push, 1)
struct FromPlc { // I: 控制器 → 设备 (只读)
uint8_t Present; // 偏移 0
int16_t Recipe; // 偏移 1: 大端
};
struct ToPlc { // Q: 设备 → 控制器 (可写)
uint8_t Ready; // 偏移 0
uint8_t Busy; // 偏移 1
};
#pragma pack(pop)

int main() {
PnSService device; // 构造不占实时门
ScopedStart guard(device); // 进作用域 Start, 出作用域 Stop
if (!guard.ok()) { // 构造不抛: 起没起来看 ok()
std::cerr << device.LastServiceError() << "\n";
return 1;
}

// ① 0 拷贝映射: 一次映射, 之后读写成员就是读写过程映像
const FromPlc& inp = device.ProcessData().InputsMapping<FromPlc>();
ToPlc& outp = device.ProcessData().OutputsMapping<ToPlc>();

outp.Ready = 1; // 写: 下个周期发出

// ② 元素口 (拷贝切片, 一次性取值)
int16_t recipe = device.ProcessData().In()[1].AsInt16(); // 大端 → 宿主序
uint8_t present = inp.Present; // 0 拷贝: 当前周期值

// ③ 按地址读写 (区域 + 字节 + 位)
bool done = device.ReadBool("I0.0"); // 读 I 第 0 字节 bit0
device.WriteInt16("QW2", recipe); // 写 Q 偏移 2 起 2 字节

// 整段快照 (拷贝): 一次读够才保证同一拍
std::vector<uint8_t> allI = device.Read();
std::vector<uint8_t> allQ = device.ProcessData().Outputs(); // 读 Q 要用 Outputs()

std::printf("present=%u recipe=%d done=%d I=%zu Q=%zu\n",
(unsigned)present, (int)recipe, (int)done, allI.size(), allQ.size());

// 两个方向的运行配置长度 (不写死 20 / 32)
PnSServiceDiagSnapshot diag = device.GetDiag();
std::printf("配置长度 I=%u Q=%u\n",
diag.input_area_length, diag.output_area_length);

return 0; // guard 析构: Stop (不抛)
}