倍福 PLC:C# 用 ADS 读写变量和结构体
2022 年 8 月 29 日 · 3 分钟读完
项目里要和倍福 PLC 交互,用的是 ADS 通讯,研究了一下。
ADS 是什么
ADS(Automation Device Specification)是倍福 TwinCAT 的通讯协议。和西门子按「DB 号 + 字节偏移」读写不同,ADS 可以直接按变量名访问,比如 MAIN.nCounter、GVL.stData,不用关心变量在内存里的地址。
准备
- 上位机电脑上要有 ADS 路由服务:装了 TwinCAT(XAE 或 XAR)就有;只做上位机的话,装 TwinCAT ADS(或 TwinCAT 3 的 ADS 组件)就够了。原文附的安装包:TwinCAT System。
- 添加路由:上位机和 PLC 不在同一台电脑时,要在两边互相加路由(TwinCAT 托盘图标 → Router → Edit Routes → Add),填对方的 IP 和 AMS Net ID。
- C# 引用 NuGet 包
Beckhoff.TwinCAT.Ads。
端口
| 运行时 | 端口 |
|---|---|
| TwinCAT 2 的第 1 个 PLC 运行时 | 801 |
| TwinCAT 3 的第 1 个 PLC 运行时 | 851 |
两边的数据定义
PLC 端的变量:

结构体定义:

注意结构体顶部的 {attribute 'pack_mode' := '1'},它表示结构体成员按 1 字节对齐,紧密排列,中间没有填充字节。C# 端的结构体必须用同样的排列方式,否则读出来的字段会错位。
两边类型对照:

C# 端的结构体
[StructLayout(LayoutKind.Sequential, Pack = 1)] // 对应 pack_mode := '1'
public struct MyStruct
{
[MarshalAs(UnmanagedType.I1)] public bool b; // BOOL 在 TwinCAT 里占 1 字节
[MarshalAs(UnmanagedType.U1)] public byte bt; // BYTE
[MarshalAs(UnmanagedType.U2)] public ushort word; // WORD
[MarshalAs(UnmanagedType.U4)] public uint dword; // DWORD
[MarshalAs(UnmanagedType.I1)] public sbyte sint; // SINT
[MarshalAs(UnmanagedType.I2)] public short m_int; // INT
[MarshalAs(UnmanagedType.I4)] public int dint; // DINT
[MarshalAs(UnmanagedType.I8)] public long lint; // LINT
[MarshalAs(UnmanagedType.R4)] public float real; // REAL
[MarshalAs(UnmanagedType.R8)] public double lreal;// LREAL
// PLC 端是 STRING(10):10 个字符 + 1 个结束符 0,共 11 字节
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 11)]
public string string_en;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
public int[] intArr; // ARRAY[0..9] OF DINT
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 9)]
public int[] intArr2; // ARRAY[0..2, 0..2] OF DINT,二维数组按行展开成 9 个
}
// 结构体数组:外面再包一层
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct MyStructArr
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 9)]
public MyStruct[] arr;
}
几个要点:
- 用
struct,不要用class。单个结构体用 class 也能读,但结构体数组里的元素是 class 时,ByValArray没法按值内联展开,会报错。 - 字符串长度要 +1:TwinCAT 的
STRING(n)以 0 结尾,占 n + 1 字节。 - 二维数组:C# 的
ByValArray只支持一维,PLC 的ARRAY[0..2, 0..2]在内存里按行连续存放,C# 端用 9 个元素的一维数组接,[i, j]对应下标i * 3 + j。 - 中文用
WSTRING,PLC 端是 UTF-16 小端(和 Windows 一样),C# 端用ByValTStr加CharSet = CharSet.Unicode,长度同样要 +1 个字符。原文试过把 STRING 当 UTF-8 转中文,坑很多,不推荐。
读写
下面用的是 TcAdsClient(TwinCAT.Ads.dll,TwinCAT 3 的老接口)。新版 NuGet 包里的类叫 AdsClient,方法名基本相同。
1. 连接,按变量名拿句柄
ADS 读写前要先用变量的完整名字(程序名.变量名)换一个句柄,之后用句柄读写。我把每个变量的类型和句柄存在字典里:
public enum PLCType
{
BOOL, BYTE, WORD, DWORD, SINT, INT, DINT, LINT,
USINT, UINT, UDINT, ULINT, REAL, LREAL, STRING, WSTRING, STRUCT
}
public struct PLCPointInfo
{
public PLCType plcType;
public int Handle;
public int byteCount; // 字符串的长度
}
TcAdsClient ads;
readonly Dictionary<string, PLCPointInfo> dic = new Dictionary<string, PLCPointInfo>();
private void Form1_Load(object sender, EventArgs e)
{
ads = new TcAdsClient();
ads.Connect("169.254.109.125.1.1", 851); // PLC 的 AMS Net ID 和端口
}
private void btnHandle_Click(object sender, EventArgs e)
{
dic.Clear();
// PLC 里的变量名是 MAIN.bool1、MAIN.byte1……这里按枚举名批量拿句柄
foreach (PLCType t in Enum.GetValues(typeof(PLCType)))
{
string name = t.ToString().ToLower();
dic[name] = new PLCPointInfo
{
plcType = t,
Handle = ads.CreateVariableHandle("MAIN." + name + "1"),
byteCount = t == PLCType.STRING || t == PLCType.WSTRING ? 10 : 0,
};
}
}
2. 读
ReadAny 按传入的 C# 类型把读到的字节转换好,所以关键就是 PLC 类型和 C# 类型的对应:
public object GetPLCValue(PLCPointInfo info, Type structType = null)
{
int h = info.Handle;
switch (info.plcType)
{
case PLCType.BOOL: return ads.ReadAny(h, typeof(bool));
case PLCType.BYTE:
case PLCType.USINT: return ads.ReadAny(h, typeof(byte));
case PLCType.WORD:
case PLCType.UINT: return ads.ReadAny(h, typeof(ushort));
case PLCType.DWORD:
case PLCType.UDINT: return ads.ReadAny(h, typeof(uint));
case PLCType.SINT: return ads.ReadAny(h, typeof(sbyte));
case PLCType.INT: return ads.ReadAny(h, typeof(short));
case PLCType.DINT: return ads.ReadAny(h, typeof(int));
case PLCType.LINT: return ads.ReadAny(h, typeof(long));
case PLCType.ULINT: return ads.ReadAny(h, typeof(ulong));
case PLCType.REAL: return ads.ReadAny(h, typeof(float));
case PLCType.LREAL: return ads.ReadAny(h, typeof(double));
case PLCType.STRING:
// 字符串要告诉它长度
return ads.ReadAny(h, typeof(string), new[] { info.byteCount == 0 ? 255 : info.byteCount });
case PLCType.WSTRING:
// WSTRING 是 UTF-16。ReadAny 按单字节字符串读,再按 Unicode 重新解码
object raw = ads.ReadAny(h, typeof(string), new[] { info.byteCount == 0 ? 255 : info.byteCount });
return Encoding.Unicode.GetString(Encoding.Default.GetBytes(raw.ToString()));
case PLCType.STRUCT:
if (structType == null) throw new ArgumentNullException(nameof(structType));
return ads.ReadAny(h, structType); // 例如 typeof(MyStructArr)
default:
return null;
}
}
读取结果:

3. 写
数值类型先转换成对应的 C# 类型再 WriteAny;字符串用 WriteAnyString,并指定编码:
public void SetPLCValue(PLCPointInfo info, object value)
{
int h = info.Handle;
switch (info.plcType)
{
case PLCType.BOOL: ads.WriteAny(h, Convert.ToBoolean(value)); break;
case PLCType.BYTE:
case PLCType.USINT: ads.WriteAny(h, Convert.ToByte(value)); break;
case PLCType.WORD:
case PLCType.UINT: ads.WriteAny(h, Convert.ToUInt16(value)); break;
case PLCType.DWORD:
case PLCType.UDINT: ads.WriteAny(h, Convert.ToUInt32(value)); break;
case PLCType.SINT: ads.WriteAny(h, Convert.ToSByte(value)); break;
case PLCType.INT: ads.WriteAny(h, Convert.ToInt16(value)); break;
case PLCType.DINT: ads.WriteAny(h, Convert.ToInt32(value)); break;
case PLCType.LINT: ads.WriteAny(h, Convert.ToInt64(value)); break;
case PLCType.ULINT: ads.WriteAny(h, Convert.ToUInt64(value)); break;
case PLCType.REAL: ads.WriteAny(h, Convert.ToSingle(value)); break;
case PLCType.LREAL: ads.WriteAny(h, Convert.ToDouble(value)); break;
case PLCType.STRING:
ads.WriteAnyString(h, value.ToString(), info.byteCount, Encoding.Default);
break;
case PLCType.WSTRING:
ads.WriteAnyString(h, value.ToString(), info.byteCount, Encoding.Unicode);
break;
case PLCType.STRUCT:
ads.WriteAny(h, value); // 结构体或结构体数组,整个写入
break;
}
}
Convert.ToXxx 不能省:WriteAny 按传入对象的实际类型决定写几个字节。直接传一个 int 字面量 100 去写 BYTE,会写入 4 个字节,把后面的变量也覆盖了。
写结构体数组:
var data = new MyStructArr { arr = new MyStruct[9] };
for (int i = 0; i < data.arr.Length; i++)
{
data.arr[i].intArr = new int[10]; // ByValArray 字段必须先分配好长度
data.arr[i].intArr2 = new int[9];
data.arr[i].dint = i + 1;
data.arr[i].intArr[0] = i + 1;
data.arr[i].string_en = (i + 1).ToString();
}
SetPLCValue(dic["struct"], data);
结构体里的数组字段如果是 null 或长度不对,编组时会报错,所以原文给结构体写了一个 StructIni() 方法统一初始化。
写入结果:

结构体数组:

STRING 存中文的坑
我试过把中文按 UTF-8 编码塞进 STRING,问题很多,最后放弃了:
- 先写「嗡嗡嗡」再写「哇哇」,读出来是「哇哇嗡」。短字符串只覆盖了前面的字节,后面的旧字节还在。
- 最后一个字符经常乱码,读回来的最后一个字节变成了 63。
63 是 ? 的编码。原因在于这条路上编码转了好几次:UTF-8 字节被当成 Encoding.Default(GBK)的字符串传来传去,有的字节组合在 GBK 里不是合法字符,转换时就被替换成了 ?,原来的字节就丢了。同理,写入时结尾的 \0 结束符一旦没写进去,读的时候就会把旧字节也读出来。
结论:中文用 WSTRING,C# 端用 Encoding.Unicode,不要在 STRING 里存 UTF-8。
延伸
- 变量通知代替轮询:
ads.AddDeviceNotificationEx("MAIN.nCount", AdsTransMode.OnChange, 100, 0, null, typeof(int)),值变化时触发AdsNotificationEx事件,不用开线程循环读。注意事件在 ADS 的线程里触发,更新界面要Invoke。 - 批量读:变量很多时,用 Sum Command(
SumSymbolRead)一次请求读多个变量,比逐个ReadAny快得多。 - 句柄会失效:PLC 重新下载程序后句柄可能失效,读写报
DeviceSymbolVersionInvalid时,重新CreateVariableHandle。 - 和西门子对比:倍福是小端(和 PC 一样),按变量名访问,结构体整体读写很方便;西门子 S7 是大端、按地址访问,见 用 S7.Net 读写 1500 和 数据在 DB 块里怎么存。
- 结构体内存布局:
StructLayout和对齐的原理,见 C# 结构体和类的区别。
原文首发于 blog.csdn.net