← 全部文章 工业通讯

倍福 PLC:C# 用 ADS 读写变量和结构体

2022 年 8 月 29 日 · 3 分钟读完

目录
  1. ADS 是什么
  2. 准备
  3. 端口
  4. 两边的数据定义
  5. C# 端的结构体
  6. 读写
  7. 1. 连接,按变量名拿句柄
  8. 2. 读
  9. 3. 写
  10. STRING 存中文的坑
  11. 延伸

项目里要和倍福 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# 结构体和类的区别。

# 倍福 # PLC # ADS # C#

原文首发于 blog.csdn.net