简介:围绕 BACnet 楼宇自动控制技术整理的 C# 实践资料,面向希望理解设备基本读写与属性值订阅机制的开发者、自动化专业学生及楼宇自控入门人员。内容以可运行的工程结构为主体,配合协议交互相关代码,帮助读者梳理对象属性访问、读写请求组织以及属性变化通知的订阅流程,并可在调试中加深对 BACnet 通信过程的理解。压缩包共 131 个文件,约 2.12MB,包含 8 个 cs 源码、1 个 sln 解决方案与 1 个 csproj 项目文件,另有 19 个 dll 依赖库、10 个 pdb 调试符号、32 个 xml 配置或说明文件以及 config、txt、png 等辅助资源,便于还原项目并对照排查问题。已有 311 人学习,适合作为课程设计、协议入门和二次开发前的参考样例,帮助读者从源码结构、依赖组织与运行配置三个层面建立对 BACnet 设备访问功能的整体认识。
1. 从一台读不出温度的新风机说起:这套 BACnet C# 示例能解决什么
某栋楼的十二层,新风机组在楼宇自控界面上显示送风温度 0℃,现场手操器读数却正常。排查到最后不是传感器坏,也不是网络断,而是上位机侧读错了对象实例号,把 Analog Input 的 instance 3 当成 instance 1 在轮询,读回来的自然是一堆无意义的值。这类问题在 BACnet 楼宇自动控制项目里太常见了,因为 BACnet 的寻址不是「点名」,而是一组 (对象类型, 实例号) 加属性 ID 的二元组。
这份资源就是围绕设备的基本读写和订阅属性值变化这两件事做的 C# 示例:设备发现用 Who-Is/I-Am,读属性用 ReadProperty 和 ReadPropertyMultiple,写属性用带优先级的 WriteProperty,属性变化用 SubscribeCOV 加 COVNotification 回调解包。整套东西不依赖任何厂商组态软件,C# 侧自己组 BVLC/NPDU/APDU 三层报文收发,正好对上 BACnet C# 这个关键词的检索诉求。适合做上位机、协议网关、边缘采集的 C# 工程师,尤其是第一次接 BACnet 项目、被对象模型和优先级数组绕晕的人。
2. 对象模型与报文封装:C# 侧必须先对齐的三层参数
动手之前得先把两件事钉死:一是怎么描述「我要读哪个东西」,二是这个描述怎么塞进 UDP 包里。前者是对象模型,后者是协议栈封装。很多 C# 工程师第一次接 BACnet 会本能地按 Modbus 的点表思维去写,一个字符串 tag 对应一个寄存器地址,结果到了 WriteProperty 那一步就卡住——因为 BACnet 压根没有 tag 字符串这个概念,所有寻址都是数字化的三元组。
2.1 Device / Object / Property 三层:BACnet 里没有「点名」这回事
BACnet 设备内部是一个对象集合,每个对象由对象类型(Object Type,10 位)和实例号(Instance Number,22 位)唯一确定,对象的每个数据项叫属性(Property),用属性 ID 寻址。设备自己也是一个对象,类型固定为 Device(8),实例号就是通常说的 device instance,全網唯一。所以一次读取的完整描述是:目标设备的 IP 和 device instance → 目标对象的 (type, instance) → 目标属性的 property id。
把常用枚举值记牢能省掉大量翻文档的时间,下面这两张表基本覆盖了楼控点位里 90% 的场景。
| 对象类型 | 枚举值 | 典型用途 |
|---|---|---|
| Analog Input | 0 | 温度、压力、流量等只读测量值 |
| Analog Output | 1 | 阀门开度、风机频率等可写输出 |
| Analog Value | 2 | 设定值、计算中间量 |
| Binary Input | 3 | 运行状态、报警干接点 |
| Binary Output | 4 | 启停命令 |
| Binary Value | 5 | 虚拟开关、使能位 |
| Device | 8 | 设备本身,实例号即 device instance |
| 属性 | 属性 ID | 说明 |
|---|---|---|
| Present_Value | 85 | 当前值,读写最频繁的一个 |
| Object_Identifier | 75 | 对象标识,4 字节 |
| Object_Name | 77 | 对象名,字符串 |
| Object_Type | 79 | 对象类型,枚举 |
| Status_Flags | 111 | 4 位 BitString,含故障/越限等标志 |
| Units | 117 | 工程单位枚举 |
| Priority_Array | 87 | 16 元素数组,写优先级的核心 |
| Relinquish_Default | 104 | 释放后的默认值 |
| COV_Increment | 22 | COV 通知的触发增量 |
| Out_Of_Service | 81 | 是否脱离现场值 |
提示:Status_Flags 是 BitString 而不是 Boolean,解包时按位取,四位分别对应 in-alarm、fault、overridden、out-of-service。用 Real 解析器去解它必然翻车。
2.2 BVLC + NPDU + APDU:一个 UDP 包里的三层封装
BACnet/IP 走 UDP,默认端口 47808(十六进制 0xBAC0,很多现场也叫 BAC0)。一个完整报文从外到内是三层:BVLC 头 4 字节负责链路层路由,NPDU 至少 2 字节负责网络层(版本号和控制字节),APDU 才是真正的服务请求。C# 里自己组包,只需要老老实实按顺序拼字节。
using System; using System.Buffers.Binary; public static class BacnetPacket { public const int Port = 47808; // BAC0 public const byte BvlcType = 0x81; // BACnet/IP 固定标识 public const byte BvlcUnicast = 0x0A; // Original-Unicast public const byte BvlcBroadcast = 0x0B;// Original-Broadcast // 在 NPDU 外面套一层 BVLC 头 public static byte[] WrapBvlc(byte function, byte[] npdu) { var packet = new byte[4 + npdu.Length]; packet[0] = BvlcType; packet[1] = function; // 长度字段是大端,包含 BVLC 头自身 BinaryPrimitives.WriteUInt16BigEndian(packet.AsSpan(2, 2), (ushort)packet.Length); Buffer.BlockCopy(npdu, 0, packet, 4, npdu.Length); return packet; } // 从收到的包里跳过 NPDU 头,定位 APDU 起始位置 public static int GetApduOffset(byte[] packet) { int i = 4; // 跳过 BVLC byte control = packet[i + 1]; // NPDU 控制字节 i += 2; // 版本 + 控制 if ((control & 0x20) != 0) // 带目的地址(全局广播常见) { i += 3; // DNET(2) + DLEN(1) i += packet[i - 1]; // DADDR,长度由 DLEN 决定 i += 1; // Hop Count } if ((control & 0x08) != 0) // 带源地址(跨网段路由时出现) { i += 3; // SNET(2) + SLEN(1) i += packet[i - 1]; // SADDR } return i; } }WrapBvlc里最容易写错的是长度字段:它是整个 BVLC 包的总长,包括头部的 4 个字节,而且是大端。写成 NPDU 长度会让对方直接丢包,现象是「包发出去了,一点回应都没有」,非常像网络不通,实际是格式错误。GetApduOffset解决的是另一个隐性坑:NPDU 头长度不是固定的,带目的地址或源地址时都要额外跳过字段,硬编码成偏移 2 只能在纯本地网段碰运气。
NPDU 的控制字节按位用:bit5(0x20)表示带目的地址,bit3(0x08)表示带源地址,bit2(0x04)表示期望回复。单播读写的 NPDU 一般就是01 04两个字节,广播发现则是01 20 FF FF 00 FF。
2.3 对象标识符与标签编码:4 字节里塞 10 位类型和 22 位实例
对象标识符是 BACnet 里最需要手工处理的数据类型,4 个字节里高 10 位是对象类型,低 22 位是实例号。写成 C# 就是一个移位加掩码,但掩码写错就会串到别的对象上去,比如把 Analog Input 的实例 0 和 Analog Output 的实例 1 混成同一个。
public enum BacnetObjectType : uint { AnalogInput = 0, AnalogOutput = 1, AnalogValue = 2, BinaryInput = 3, BinaryOutput = 4, BinaryValue = 5, Device = 8 } public static class ObjectId { // 10 位类型 + 22 位实例压缩进 4 字节 public static uint Pack(BacnetObjectType type, uint instance) => ((uint)type << 22) | (instance & 0x3FFFFF); public static (BacnetObjectType type, uint instance) Unpack(uint raw) => ((BacnetObjectType)(raw >> 22), raw & 0x3FFFFF); }标签(Tag)编码是第二个绕不开的点。BACnet 每个数据项前面有一个标签字节,高 4 位是标签号,低 4 位是长度;长度 0~4 直接写在低 4 位,5 表示后面跟 1 字节扩展长度,0xE 表示打开标签、0xF 表示关闭标签。上下文标签(context tag)用于服务参数,应用标签(application tag)用于真实数据:Real 是 4,Unsigned 是 2,Enumerated 是 9,CharacterString 是 7,BACnetObjectIdentifier 是 12。
所以读 Analog Input 0 的 Present_Value,APDU 是这么拼的:00(Confirmed-Request)+04(不分段、Max APDU 1024)+ invokeId +0C(服务号 ReadProperty)+04加 4 字节对象 ID(context tag 0,长度 4)+11加55(context tag 1,长度 1,属性号 85)。响应回来是 ComplexACK,属性值被 context tag 3 的打开/关闭标签3E/3F包着,Real 值前面是应用标签44。
// 从 ComplexACK 里抠出一个 Real 类型的属性值 public static bool TryExtractReal(byte[] apdu, out float value) { value = 0f; for (int i = 0; i + 5 < apdu.Length; i++) { // opening tag 3 后面紧跟 application tag 4 (Real) if (apdu[i] == 0x3E && apdu[i + 1] == 0x44) { value = BinaryPrimitives.ReadSingleBigEndian(apdu.AsSpan(i + 2, 4)); return true; } } return false; }TryExtractReal只对模拟量有效,Binary Input 的 Present_Value 是 Enumerated(应用标签 9),BitString 类属性更是要按位拆。工程上稳妥的做法是按属性 ID 建一张类型表,先查表再选解析器,别指望一个万能解析函数。
3. 设备发现与读属性:Who-Is 到 ReadPropertyMultiple 的落地链路
报文封装搞定之后,业务链路其实只有四步:发现设备拿 device instance、读对象列表拿点表、常规轮询读值、异常时读状态标志。这一章按这四步走,每一步都给可直接复制的组包代码。
3.1 Who-Is / I-Am:先把网段里的设备摸清楚
Who-Is 是全局广播的非确认服务,服务号 0x08,外面套的 PDU 类型是 Unconfirmed-Request(0x10)。整个包只有 8 个字节的 NPDU+APDU,外面再加 4 字节 BVLC。
// 构造全局广播 Who-Is public static byte[] BuildWhoIs() { var npdu = new byte[] { 0x01, // NPDU 版本号,恒为 1 0x20, // 控制字节:带目的地址 0xFF, 0xFF, // DNET = 0xFFFF,表示全局广播 0x00, // DLEN = 0,无目的 MAC 0xFF, // Hop Count,经过路由器递减 0x10, // Unconfirmed-Request PDU 0x08 // 服务号 Who-Is }; return BacnetPacket.WrapBvlc(BacnetPacket.BvlcBroadcast, npdu); } // 从 I-Am 回包里解出 device instance 和厂商号 public static bool TryParseIAm(byte[] packet, out uint deviceId, out int vendorId) { deviceId = 0; vendorId = -1; int i = BacnetPacket.GetApduOffset(packet); if (packet[i] != 0x10 || packet[i + 1] != 0x00) return false; // 必须是 I-Am i += 2; if (packet[i] != 0xC4) return false; // ObjectIdentifier,长度 4 deviceId = (uint)((packet[i + 1] << 24) | (packet[i + 2] << 16) | (packet[i + 3] << 8) | packet[i + 4]) & 0x3FFFFF; // 后面依次是 Max APDU、Segmentation、Vendor ID,Vendor ID 是最后一个应用标签 vendorId = packet[^1]; return true; }实操里要注意两点。第一,Who-Is 不能发到 255.255.255.255 之外的地址,多网卡机器必须绑定到指定网卡再发,否则走默认路由出去,回包回不来。第二,I-Am 可能来自路由器代答,包里带了 SNET/SADDR,GetApduOffset会自动跳过,这也是前面坚持写通用偏移函数的原因。
发现阶段建议设一个 3 到 5 秒的收集窗口,把收到的 (deviceId, IP, vendorId) 存成字典。同一台设备可能在多个网卡各回一次,按 deviceId 去重即可,别按 IP 去重。
3.2 ReadProperty:单点读的组包与 ComplexACK 解析
ReadProperty 服务号 0x0C,属于确认服务,必须带一个 invokeId,范围 0 到 255,响应会原样带回这个 ID。它的 APDU 结构前面已经拆过,这里补完整实现。
// 构造 ReadProperty 请求 public static byte[] BuildReadProperty(uint objectId, byte propertyId, byte invokeId) { var apdu = new byte[11]; apdu[0] = 0x00; // Confirmed-Request PDU apdu[1] = 0x04; // 不分段,Max APDU 1024 apdu[2] = invokeId; apdu[3] = 0x0C; // 服务号 ReadProperty apdu[4] = 0x04; // context tag 0,长度 4:对象标识符 BinaryPrimitives.WriteUInt32BigEndian(apdu.AsSpan(5, 4), objectId); apdu[9] = 0x11; // context tag 1,长度 1:属性 ID apdu[10] = propertyId; var npdu = new byte[] { 0x01, 0x04 }.Concat(apdu).ToArray(); // 0x04 表示期望回复 return BacnetPacket.WrapBvlc(BacnetPacket.BvlcUnicast, npdu); }响应分四种:SimpleACK(0x20 那一支的简化版,无数据)、ComplexACK(0x20,带属性值)、Error(0x50,带错误码)、Reject(0x60,参数格式不对)。解析时先看 APDU 首字节,只有 ComplexACK 才值得继续往下解。错误响应里有两个字段:Error Class 和 Error Code,都是 Enumerated,常见的0x19对应 unknown-property(属性不存在)、0x1F对应 unknown-object(对象不存在)、0x28对应 write-access-denied。读失败时把这两个码打日志,比抓包快得多。
注意:invokeId 在同一个 socket 上必须唯一。用 0 到 255 循环取也行,但要维护一张在途请求表,收到响应后清掉,否则超时重试时会撞号,把 A 请求的响应当成了 B 的。
3.3 ReadPropertyMultiple:批量读把轮询成本压下来
一栋中型楼控项目动辄几千个点,如果每个点一次 ReadProperty,UDP 包量和设备 CPU 都受不了。ReadPropertyMultiple 服务号 0x0E,一次可以携带多个对象的多个属性,是轮询场景的标准做法。
它的参数结构分三层,用打开/关闭标签嵌套:context tag 0 打开,里面放一组对象 ID;每个对象 ID 后面跟 context tag 1 打开,里面是属性 ID 列表。C# 侧可以用 List 逐段拼接,比预分配数组清晰得多。
// 批量读:一个对象读多个属性 public static byte[] BuildReadPropertyMultiple( uint objectId, byte[] propertyIds, byte invokeId) { var buf = new List<byte> { 0x00, 0x04, invokeId, 0x0E }; buf.Add(0x0E); // context tag 0 打开:对象列表 buf.Add(0x0C); // context tag 0,长度 4:对象标识符 var oid = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(oid, objectId); buf.AddRange(oid); buf.Add(0x1E); // context tag 1 打开:属性列表 foreach (var pid in propertyIds) { buf.Add(0x09); // context tag 0,长度 1:属性 ID buf.Add(pid); } buf.Add(0x1F); // context tag 1 关闭 buf.Add(0x0F); // context tag 0 关闭 var npdu = new byte[] { 0x01, 0x04 }.Concat(buf).ToArray(); return BacnetPacket.WrapBvlc(BacnetPacket.BvlcUnicast, npdu); }单包大小要盯着 MTU 走。一个属性 ID 占 2 字节,一个对象 ID 占 5 字节,再加上标签开销,一包塞 20 到 30 个属性比较安全。超过 1476 字节会触发分段,而很多现场控制器根本不支持分段(I-Am 里的 Segmentation Supported 字段是 3 就代表不支持),表现是请求发出去石沉大海。稳妥做法是每包控制在 480 字节以内,宁可多包也别触发分段。
读回来的 ComplexACK 里,每个属性值是「属性 ID + 值 + 可选的错误码」三段结构,属性级错误不会导致整包失败,所以要逐段解析,把失败的属性单独记下来重试,别因为一个点不存在就丢掉整包数据。
4. 写属性与 COV 订阅:优先级数组和通知回调怎么落地
读通了只能算看到,写下去才算控制,订阅上才知道现场变了。这两件事的坑比读多得多,尤其是优先级——BACnet 的写入不是覆盖,而是占据 16 个槽位中的一个。
4.1 16 级优先级数组:WriteProperty 的 priority 到底写几
BACnet 的可命令对象(Analog Output、Binary Output、Analog Value 等)内部维护一个 16 元素的 Priority_Array,索引 1 到 16,1 优先级最高。Present_Value 永远等于这 16 个槽位里优先级最高的那个非 NULL 值。所谓「释放」,就是往某个槽位写 NULL(BACnetNull),让它退出竞争。
实际组包时,优先级用 context tag 4,长度 1,紧跟属性值之后。
// 写一个 Real 值到指定优先级槽位 public static byte[] BuildWriteProperty( uint objectId, byte propertyId, float value, byte? priority, byte invokeId) { var buf = new List<byte> { 0x00, 0x04, invokeId, 0x0F }; // WriteProperty buf.Add(0x0C); // 对象标识符 var oid = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(oid, objectId); buf.AddRange(oid); buf.Add(0x19); // context tag 1,长度 1:属性 ID buf.Add(propertyId); buf.Add(0x3E); // context tag 3 打开:属性值 buf.Add(0x44); // application tag 4:Real var raw = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(raw, value); buf.AddRange(raw); buf.Add(0x3F); // 关闭属性值 if (priority.HasValue) // 可选:优先级槽位 { buf.Add(0x41); // context tag 4,长度 1 buf.Add( <p> <a href="https://download.csdn.net/download/weixin_40314351/89156501" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>