过程数据
过程数据就是过程映像里的一段字节,IO 控制器每周期自动收发它。把它映射成结构体后,读写成员就是直接读写过程映像本身 —— 0 拷贝:不用拷贝、不用刷新,读到的永远是当前周期,写下的下个周期发出。本 SDK 里写 Q(设备 → 控制器)、读 I(控制器 → 设备);I 区只读,控制器每周期整段覆盖输入映像,写它静默无效(InputsMapping<T>() 返回 const T&,编译期就禁写)。总线周期由服务侧完成,SDK 不要求你写周期循环。
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 copyable 且 standard 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 没有用户周期回调,调度(循环 / 定时器 / 事件)由你自己定。
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— 当前偏移那一个字节最低位的值(0→false,1→true)。
说明:
- 非规范字节
0x02与0x80都读false—— 不是「非 0 即真」。 - 本 SDK 只提供这一个整字节布尔口径。要按整字节 0 / 非 0 判真假,请读
Content()自行比较。 - 写方向把整个字节写成
0x01或0x00,不是改单个位。要按位读写请用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— 该位的值。
说明:
- 位索引取值
0–7,0是最低位(LSB)。越界抛PnSException("位索引必须在 0-7 之间")。 - 位索引只用元素偏移处的那一个字节,与元素宽度无关:在按
AsInt16/AsInt32读写的那个偏移上取0–7,仍然只访问起始字节,不会跨到后续字节。 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>()
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::NativeError,what()是"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::pair—first是 Q 侧净荷(与Outputs()同一块,长度 = 输出区长 − 3);second是 I 侧净荷(与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/Db或Unknown抛PnSException("区域非法")。- 读的失败形态:
"区域非法";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.0(M 区) | "不支持 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.0,q4=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— 该地址所在字节那一位的值(0→false,1→true)。
说明:
- 地址必须带位号(
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 已经 #include 了 pns_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()
打开并映射命名共享内存,并校验头的 Size 与 Version。
void map(); // 幂等: 已映射直接返回
void unmap() noexcept; // 幂等, 不抛
bool is_mapped() const;
返回值:
is_mapped()—bool:视图在 →true。
说明:
map()幂等:已映射就直接返回。失败如实抛PnSShmException,文案里带NTSTATUS;失败路径把已经开的句柄 / 视图收回去,不留半映射状态。- 映射的是头 + 两个方向的过程映像整段,契约总大小 3104 字节(头 224 + 2 × 1440)。实际视图比契约小也抛。
- 头校验不过(
Size或Version与契约值不符)同样抛,视图与句柄一并回收。 - 驱动没加载 / 非管理员 / 服务没启动,
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 屏障保证净荷先对驱动可见。 - 抛
PnSShmException:data是空指针但length > 0;offset越出净荷窗口;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= 掉线 / 安全态。周期循环里先判它,再决定用数据还是走安全态。它为0时read_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 的入口 |
|---|---|---|
| 用户区记录 | 0x0000–0x7FFF | GetRecords()(Diagnostics().Records() 同一数据) |
| I&M 设备标识 | 0xAFF0–0xAFF4 | GetIm() / ImData() |
GetRecords()
取用户区记录表。只读观测,本 SDK 没有写记录的入口。
PnSUserRecords GetRecords();
返回值:
PnSUserRecords— 记录表。没有记录就是空表,不是失败。
说明:
- 覆盖范围是用户区
0x0000–0x7FFF。这里面没有 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());
}
GetRecords() 读的是用户区(0x0000–0x7FFF);I&M 在另一套索引空间(0xAFF0–0xAFF4),走另一个端点。在记录表里翻 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(
0xAFF0–0xAFF4)的入口;记录表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 文案推断,两次相同文案也报得出来):失败抛 PnSException(code() = 本次失败码),且 started_ 保持 true(如实:未确认停用),再调一次会真正重试。析构路径禁用。 |
Close() | void Close() | 不抛。仍在跑则先 Stop();服务清全部 Q、I 保留;随后解除共享内存映射、释放实时门。之后所有映射引用失效。 |
| 析构 | ~PnSService() | 等价于 Close()。 |
- 映射须在
Start()成功之后取;Stop()只停刷新 —— 视图仍在,读到的是旧值;Close()之后失效。 IsRunning()看的是「Start 已成功且尚未 Stop」;主站是否真的在周期交换看Connected()(或静态ServiceConnected())—— 两者不是一回事。- 状态查询的失败口径不一样:
Status()/StatusEx()在服务不可达时如实抛PnSException;WaitReason()从不抛 —— 未挂接时走静态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 (不抛)
}