简介:一套基于C#编写的BacNet客户端程序源码,面向楼宇自动化(BAS)开发者、工控软件工程师及希望掌握BACnet协议栈的C#程序员。BacNet是楼宇自动化系统的开放通信标准,支持局域网、以太网、Wi-Fi等多种介质;源码覆盖设备发现、对象浏览、读写属性、事件订阅、APDU报文构建与解析、ASN.1编解码等核心环节,并包含多线程并发处理与错误重试机制,可帮助读者从应用层完整理解设备间如何通过广播报文发现设备、查询对象并交换数据。包内共577个文件,以211个C#源码文件为主,辅以resx/resources资源文件、ICO/PNG/BMP界面素材以及少量DLL、EXE、配置与工程文件,压缩包约10.54MB;工程目录按功能模块组织,从网络接口配置到设备发现逻辑、读写操作函数均有对应代码,便于查阅。目前已有141人学习,源码附有详细注释,适合结合楼宇自控项目实践,或作为基于C#进行BACnet二次开发的基础参考,对初学者与工程实践者均有帮助。
1. 为什么自己写BACnet客户端?C#源码不等于轮子复用
做楼宇集成项目时,最难受的不是没有协议文档,而是设备厂商的BACnet SDK把平台绑定死了。如果你的团队主要技术栈是C#,手头又有一份BACnet客户端程序C#源码,那段代码就是最接近“可以改、可以追踪、可以加注释”的交付物。说透一点:BACnet客户端解决的是从楼宇控制器里发现设备、读点位、下发控制、订阅报警这几类动作,带注释的源码能让你在两天内跑通第一版协议栈,而不是把工作时间耗在手工组装报文字节上。
适合谁看?正在做楼宇自控集成、物联网平台接入,或者单纯想把一份C#协议代码吃透的开发者。需要先把预期放平:有注释不等于能跑通,真正的价值在于你能顺着注释把状态机改清楚。源码里常常藏着一堆设备兼容性的临时判断,这些只有实际跑起来才会暴露。
2. 先读懂BACnet客户端骨架:协议分层与C#工程组织
2.1 最小报文链路:BACnet/IP 从 APDU 到 UDP 的封装过程
BACnet 看起来是“建筑自动化网络”协议,落到 IP 网上其实是一层层套出来的。客户端发一个读属性请求,最终到达 UDP 时,报文已经是 BVLL + NPDU + APDU 三层结构。很多源码把这几层分开封装,上层业务只跟 APDU 打交道。常见做法是:APDU 构造放在 Service 层,BVLL 由统一的WrapBvlc方法处理,UDP 只管收发。这样业务代码里看不到字节偏移,调试时又能在边界打日志。
public static class BacnetConstants { public const int DefaultPort = 47808; // 0xBAC0,BACnet/IP 标准端口 public const byte PduTypeConfirmedRequest = 0x00; // 需要确认的请求 public const byte PduTypeUnconfirmedRequest= 0x01; // 广播/组播类 public const byte PduTypeSimpleAck = 0x02; public const byte PduTypeComplexAck = 0x03; // 带数据返回 public const byte ServiceWhoIs = 0x08; // 设备发现 public const byte ServiceIAm = 0x00; // 设备应答 public const byte ServiceReadProperty = 0x0C; public const byte ServiceWriteProperty = 0x0F; }这些常量是协议标准里的“服务编号”。注释到位的话,源码里每个数字都会对应一行说明,比如0x0C就是 ReadProperty,0x0F就是 WriteProperty。看到常量表,基本就能把整个服务的调用链拉起来。
下面这段是 BACnet/IP 最外层的封装,作用是把 APDU 包成 BVLL 帧,这样接收方才知道自己收到的是 BACnet 数据:
private byte[] WrapBvlc(byte[] apdu) { var frame = new byte[apdu.Length + 4]; frame[0] = 0x81; // BVLC 类型,BACnet/IP frame[1] = 0x0B; // 功能:Bacnet Data frame[2] = (byte)((apdu.Length + 4) >> 8); // 帧长度高字节 frame[3] = (byte)(apdu.Length + 4); // 帧长度低字节 Buffer.BlockCopy(apdu, 0, frame, 4, apdu.Length); return frame; }多数设备只认这种 BVLL 格式。如果你的源码里没有这一步,直接用原始 APDU 发 UDP,设备会不回包,这是 BACnet/IP 客户端遇到的第一个常规坑。做传输适配时,我一般会把WrapBvlc和UnwrapBvlc放在同一个类里,发送和接收用的字节序规则一致,避免后来的人只看一层协议。
除了 BACnet/IP,还有 MS/TP 这类走串口的物理层,但 C# 客户端绝大多数走 UDP,不需要处理串口电平和令牌传递。若一定要接 MS/TP 网关,也通常在网关侧把协议转成 BACnet/IP,客户端这边不需要大改。
2.2 源码的工程结构:服务请求、消息编解码与设备会话放在哪
带注释的源码通常不会把所有逻辑塞进一个文件。比较合理的目录会分成Transport/、Bacnet/、Services/、Client.cs。其中Client.cs是门面类,负责把 UDP 收包、请求超时、事件回调串在一起。
public sealed class BacnetClient : IDisposable { private readonly UdpClient _udp; // 保存 invokeId 到请求上下文的映射,拿到响应后靠它还原回调 private readonly Dictionary<byte, PendingRequest> _pending = new(); // 设备实例号 -> 网络地址的缓存 private readonly Dictionary<uint, BacnetAddress> _deviceCache = new(); private byte _nextInvokeId; public event EventHandler<DeviceFoundEventArgs>? DeviceFound; public event EventHandler<ReadPropertyEventArgs>? PropertyRead; public void WhoIs(uint lowLimit = 0, uint highLimit = 0x3FFFFF) { // 构造 UnconfirmedRequest APDU 后广播 } public void ReadProperty(BacnetAddress device, uint objectType, uint instance, uint propertyId) { // 分配 invokeId,登记 PendingRequest,发送 ConfirmedRequest } private void OnDatagramReceived(byte[] bytes, IPEndPoint remote) { // 解 BVLL -> 判 APDU 类型 -> 分发给事件 } }理解这个类,就能理解整个客户端的生命周期。_pending是状态机核心:每次发送一个确认请求,分配一个invokeId,把这个请求的异步等待句柄放进字典;收到响应后根据invokeId取出上下文,再触发事件。注释里最值得看的就是这里——设备不回包时是重发还是放弃,超时时间多少,全由这个字典管理。
源码里还有一个容易被忽略的IAmHandler。当收到 I-Am 响应时,代码会把设备的deviceInstance和当前IPEndPoint存进_deviceCache。如果没有这一步,后续读属性每次都要先发 Who-Is 才能拿到地址,效率很低。
2.3 连接设备前必须确定的三个选型:传输方式、APDU超时与分段开关
这里不是拿到代码就能直接跑,必须先确认三个参数。
| 参数 | 初始建议值 | 说明 |
|---|---|---|
| 传输方式 | BACnet/IP | 厂房里走 UDP 47808;若走 MS/TP,需借助网关转换 |
| APDU 超时 | 3000ms | 从发出请求到收到响应的最大等待时间,太短会误判超时 |
| 最大 APDU 长度 | 1024 | 设备支持能力决定;超过以后要开分段接收 |
public struct BacnetClientOptions { public int ApduTimeoutMs; // 请求超时,通常 2000-5000 public int MaxApduLength; // 双方协商后的分片阈值 public bool SegmentationEnabled; // 是否允许接收分段复杂 ACK public int Retries; // 超时重试次数 }把MaxApduLength设为 1024 是兼容性较强的做法。老设备可能只支持 480 字节,新设备通常能支持 1476。读属性时如果需要拿一个很大的数组,比如时间表或趋势日志,响应可能超过单包容量。此时客户端必须支持把分段的 ComplexAck 重新拼起来,否则收包会一直停在“等下一段”的状态,最后超时。
3. 跑通最小可用的设备发现:Who-Is 广播与 I-Am 解析
3.1 用UdpClient发出Who-Is广播的最小命令
设备发现是 BACnet 客户端的第一个动作。几乎所有设备都支持 Who-Is / I-Am,而且这组服务不加密、不鉴权,非常适合用来验证网络通不通。最小实现只要三条信息:UnconfirmedRequest 类型、保留字节、服务号 Who-Is。
private void SendWhoIs(IPEndPoint target) { // UnconfirmedRequest:高四位 0x01,所以首字节写 0x10 byte[] apdu = { 0x10, 0x00, 0x08 }; byte[] frame = WrapBvlc(apdu); _udp.Send(frame, frame.Length, target); }广播地址一般直接用本网段的有限广播地址,比如255.255.255.255。很多源码会把目标地址做成可配置项,因为有些三层交换机隔离网段广播。代码里的地址参数只需要传 IP,端口固定用 47808。
WrapBvlc在这里把 3 字节的 APDU 扩成了 7 字节帧。为什么需要那 4 字节?这是 BVLL 的起始标识和长度,设备收到 UDP 包后先检查这 4 个字节,确认是 BACnet 数据再继续解析。很多带注释的源码会在SendWhoIs旁边画一个报文结构图,别跳过,那是排查广播不到的起点。
3.2 自己动手解析I-Am:设备实例号、地址与网络号
收到 I-Am 响应后,你要做两件事:确认会话地址,记录设备实例号。I-Am 的 APDU 结构是固定的,服务号为0x00,后面跟着设备对象标识符、最大 APDU 长度、分段支持能力和厂商 ID。
private void HandleIAm(byte[] apdu, IPEndPoint remote) { using var ms = new MemoryStream(apdu); using var br = new BinaryReader(ms); br.ReadByte(); // APDU 类型和标志 br.ReadByte(); // 保留字节 br.ReadByte(); // 服务号 0x00,I-Am // 设备对象标识符:应用标签 0xC4 + 4字节内容 byte tag = br.ReadByte(); if (tag != 0xC4) throw new InvalidDataException($"I-Am 对象标识符标签异常: 0x{tag:X2}"); uint raw = ReadUInt32BigEndian(br); uint instance = raw & 0x003FFFFF; // 低 22 位是实例号 uint objectType = raw >> 22; // 高 10 位是对象类型 ushort maxApdu = ReadUInt16BigEndian(br); // 设备支持的最大 APDU 长度 byte segmented = br.ReadByte(); // 是否支持分段接收 ushort vendorId = ReadUInt16BigEndian(br);// 厂商 ID // 记录到设备缓存 _deviceCache[instance] = new BacnetAddress(remote, maxApdu, segmented != 0); DeviceFound?.Invoke(this, new DeviceFoundEventArgs(instance, remote)); } private static uint ReadUInt32BigEndian(BinaryReader br) { var b = br.ReadBytes(4); return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; } private static ushort ReadUInt16BigEndian(BinaryReader br) { var b = br.ReadBytes(2); return (ushort)((b[0] << 8) | b[1]); }注释里最容易出错的就是对象标识符的标签值。标准应用标签 ObjectIdentifier 是0xC4,但少数厂商设备会返回无标签的裸 4 字节。源码里一般会有TryParseObjectIdentifier做两种分支判断,这也是注释应该重点写的地方。我建议你在自己的源码里保留这种宽容写法,否则对接第三方设备时很容易在 I-Am 解析处翻车。
拿到remote地址后,最好把端口也存下来。有的网关回包源端口不是 47808,后续读属性若还发 47808,会一直收不到响应。
3.3 把发现结果收敛成设备清单的两种做法
现场调试时,把设备清单直接打印到控制台只能算第一步。完整做法是内存字典 + 持久化文件并存。
private void OnDeviceFound(DeviceFoundEventArgs e) { if (_deviceCache.TryAdd(e.DeviceInstance, e.Address)) { Console.WriteLine($"发现设备 {e.DeviceInstance} 地址 {e.Address}"); AppendToCsv(e); } } private void AppendToCsv(DeviceFoundEventArgs e) { var line = $"{e.DeviceInstance},{e.Address.Ip}:{e.Address.Port}"; File.AppendAllText("devices.csv", line + Environment.NewLine); }维持一个 CSV 在排查时很有用。设备重启、网络交换机重启后,部分设备不会主动重发 I-Am,客户端需要再次广播 Who-Is。这时 CSV 里保留的旧信息能用来判断是设备掉线还是网络隔离。不要只依赖内存字典,进程一重启全部丢光,现场又得重新扫。
4. 把属性读写做到可复用:ReadProperty与WriteProperty的代码设计
4.1 读属性:从请求编码到响应解包的完整函数
读属性是整个客户端的核心高频操作。一个稳健的 ReadProperty 函数必须做到:能指定毫秒级超时、能重试、能区分设备错误与网络错误。建议把所有细节封装进一个异步方法,业务层只传设备地址、对象类型、实例号、属性号。
private byte[] BuildReadPropertyApdu( byte invokeId, uint objectType, uint instance, uint propertyId) { var bytes = new List<byte>(); // ConfirmedRequest 首字节:00 表示 PDU 类型,低四位 0000 表示不分段 bytes.Add(0x00); bytes.Add(invokeId); bytes.Add(BacnetConstants.ServiceReadProperty); // 对象标识符:应用标签 0xC4 + 4字节内容 bytes.Add(0xC4); WriteUInt32BigEndian(bytes, ((objectType & 0x3FF) << 22) | (instance & 0x3FFFFF)); // PropertyIdentifier 用上下文标签 3,这里简化处理 // 真实源码需要写 Tag 头;在封装好的 encoder 里这层不用业务关心 bytes.Add(0x03); bytes.Add((byte)propertyId); return bytes.ToArray(); }上面代码里注释提到的“简化处理”不是偷懒,而是提醒你:上下文标签的编码有 Opening/Closing 之分。如果你的源码只在 ReadProperty 里手写标签,换到 WriteProperty 时很容易漏掉 ClosingTag。我建议把这部分提取成BacnetEncoder,让业务代码只传语义参数。
响应解包通常放在收到 UDP 报文的地方。简单 ACK 只说明设备收到了,拿不到值;真正带数据的是 ComplexAck。解析时先看 PDU 类型,再定位服务号,最后读取属性值。
private void HandleReadResponse(byte[] apdu) { int offset = 0; byte header = apdu[offset++]; byte invokeId = apdu[offset++]; byte service = apdu[offset++]; if ((header >> 4) == BacnetConstants.PduTypeComplexAck && service == BacnetConstants.ServiceReadProperty) { // 读服务号后面的对象标识符、属性编号、值 // 根据第一个应用标签判断值类型,对齐到 C# 类型 PropertyRead?.Invoke(this, ParseReadResponse(apdu, offset)); } else if ((header >> 4) == BacnetConstants.PduTypeSimpleAck) { // 设备只是确认收到,没有值,可能是只写属性 } }这里有个容易混淆的地方:invokeId必须和请求时发的值一致,否则会错误触发另一个属性的回调。源码中_pending字典的 key 就是它。每次请求前递增_nextInvokeId,注意不要并发重复分配。
4.2 写属性:优先级阵列(Priority Array)不是摆设
写属性比读属性复杂,因为 BACnet 支持优先级阵列。模拟量输出对象有一个 16 级的优先阵列,高优先级命令能覆盖低优先级命令。在楼宇控制里,有时候你用软件写一个风机频率,发现设备不执行,原因就是有一个更高级别的现场面板把控制权拿走了。
public void WriteProperty(BacnetAddress device, uint objectType, uint instance, uint propertyId, object value, byte priority = 16) { // 编码 WriteProperty 请求时,优先级非常重要 // 不传 priority 则默认 16,表示最低优先级,避免覆盖现场手动控制 var apdu = BuildWritePropertyApdu( _nextInvokeId++, objectType, instance, propertyId, value, priority); var pending = new PendingRequest(invokeId, ApduTimeoutMs, Retries); _pending[pending.InvokeId] = pending; _udp.Send(WrapBvlc(apdu), device.IpEndPoint); }写布尔或枚举值时,类型映射最容易出错。设备返回错误Error Class: PROPERTY, Code: INVALID_DATA_TYPE,多半是你把null或错误类型写进去了。写PresentValue前,最好先读一次ObjectType和Units,再做类型映射。
优先级参数值得单独讲:16 是最低优先级,是软件客户端最常用的安全值。8 以上通常用于自动控制策略,7 以下留给手动控制或紧急停机。你要是用 1 去写一个普通温度设定点,会让所有比它低优先级的路都被挡住,现场维护会很难受。
4.3 对象遍历:拿到设备对象列表再逐个读属性
设备可能挂了几百个对象,不可能一个个猜。BACnet 设备通常提供ObjectList属性(属性号 0x2E)。先读它拿对象清单,再遍历读每个对象的ObjectName、PresentValue、StatusFlags。
private async Task EnumerateDeviceAsync(BacnetAddress device) { var objectList = await ReadPropertyAsync( device, 0, 0, 0x2E); // 设备对象的 ObjectList foreach (var objectId in objectList) { uint objType = objectId.Type; uint objInstance = objectId.Instance; var name = await ReadPropertyAsync( device, objType, objInstance, 0x1D); // ObjectName var value = await ReadPropertyAsync( device, objType, objInstance, 0x55); // PresentValue ExportPoint(device, objType, objInstance, name, value); } }顺序读虽然慢,但稳定。如果要快,可以用ReadPropertyMultiple批量读,前提是设备支持。第一次对接时我建议用顺序读,把错误定位在单条属性上;跑通后再说性能优化。
5. BACnet客户端避坑与排查:广播发现翻车、状态机超时和COV订阅失效
5.1 跨网段Who-Is找不到设备
现场现象是:电脑和设备接同一个交换机,Who-Is 能发现设备;但只要经过路由或 VLAN 隔离,设备全部消失。原因是 BACnet/IP 的发现基于 UDP 广播,路由器默认不转发广播包。
解决办法有两个。最省事的是在路由上配置 UDP 广播转发,把指定广播地址指到设备网段;更稳妥的是给客户端加一个“定向发现”入口,让你在界面上直接填设备 IP,发单播 Who-Is。很多源码里没有单播 Who-Is,需要自己补:目标地址从广播地址改为设备 IP,APDU 内容不变。这个改动很小,但现场救急时非常管用。
5.2 读响应总在最后一个字节超时:没开分段接收
故障现象是读小属性一切正常,读趋势日志或历史记录时,等待时间明显变长,最终超时。抓包能看到设备已经回了数据,但后面还跟着几个重传包。原因是响应长度超过了 MaxAPDU,设备按分段响应发送,客户端没有把分段重组逻辑接上。
解决分两步。第一,在缓存设备信息时记下 I-Am 里的分段支持标志;第二,在收到分段 ComplexAck 时,按SequenceNumber拼装,并在最后一个分段收到后触发完整回调。如果源码里已经有SegmentAck响应,说明预留了机制,你只需要确认MaxApduLength没有写死。这段注释通常写明“部分设备声称支持分段,实际实现有 bug”,所以建议在传输层加超时保护。
5.3 写控制值设备返回错误:优先级位和值类型没对上
现象是读属性正常,写操作返回错误类PROPERTY、错误码WRITE_ACCESS_DENIED或INVALID_DATA_TYPE。原因通常是两类:优先级被设成 0,或者值类型不匹配。priority = 0表示“放弃对该点的控制”,有些设备会直接拒绝。
解决时先固定用priority = 16试写一次,排除优先级阵列问题;再检查PresentValue的类型。设备对象类型决定编码方式:模拟量用 Real 或 Integer,二进制用枚举{0:Inactive, 1:Active},多态值用 BitString。不要用 C# 的bool直接写二进制对象,容易在枚举类型上翻车。
5.4 COV订阅后收不到推送:TTL过期与周期续订
订阅变更通知(SubscribeCOV)后,刚开始能收到推送,过一段时间就安静了。这不是设备死了,而是订阅时带了一个生命周期(TTL)值,到期后设备自动清理订阅。很多客户端把 TTL 设为 0,表示永久订阅,但设备重启后订阅依然会丢。
解决方式是把“订阅续订”做成一个后台任务,每隔一段时间重新订阅一次。建议周期取 TTL 的一半,比如 TTL 设 600 秒,就每 300 秒续订。把续订任务的日志打出来,能帮你判断是设备主动断开,还是网络丢包导致订阅消失。踩过这个坑的人都明白,COV 看起来省流量,但状态维护成本一点不比轮询低。
5.5 多设备并发读时响应串台:invokeId 分配冲突
项目跑到几十个设备并发采集时,会出现属性值跳到另一个设备的奇怪现象。原因是不同设备的invokeId在同一个_pending字典里发生了重叠,或回调里没有校验返回的invokeId与本机分配的对齐。解决很简单:为每个目标设备单独维护一套invokeId序列,或者在_pending的 key 里追加设备实例号。最保险的是 key 用(DeviceInstance, InvokeId)二元组,回调时先匹配设备地址,再匹配请求号。源码注释里如果只提了invokeId,你要留个心眼,这个坑大概率还在。
6. 把客户端源码改造成自己的调试工具:单测、模拟器和属性导出
6.1 用模拟设备回放报文验证解析器
没有真实设备时,不要拿真设备去试错。把抓包工具存下来的 BACnet 报文整理成一批测试用例,喂给解析函数。具体做法是把 APDU 的 Hex 字符串写进测试用例,断言解析出来的设备实例号、属性值与预期一致。这样你改一个字节序的 Bug,不用去现场也能立刻发现回归。
6.2 属性点表导出与点位模板生成
把EnumerateDeviceAsync的结果导出成 CSV 或 JSON,后续烧录到数据平台时可以直接转成点位表。我一般会保留两级结构:设备级DeviceInstance/Address,对象级ObjectType/ObjectName/PresentValue。带注释的源码里如果有ExportPoint钩子,优先复用。
6.3 我的一条经验:先写测试再改源码
我过去改 BACnet 客户端喜欢先改协议解析,再拿真机验证,结果每次都被设备兼容性坑到深夜。后来养成习惯:先把抓包样本做成单元测试,再用模拟器跑回归,最后才动真机。这条经验是用血泪换的——协议文档写得再好,也抵不过一台真实设备的不按常理出牌。
也希望这份 C# 客户端源码能成为你自己工具箱里顺手的那把改锥。希望帮到你。
本文还有配套的精品资源,点击获取