简介:这份资源是面向工业自动化与物联网方向开发者的C#版BACnet通信源码包,包含两个可运行程序及配套BACnet模拟器,分别演示模拟量(如温度)的读取与开关量(BO点)的读写控制,适合需要快速上手楼宇自控协议对接、验证设备通信逻辑的中级C#开发者。压缩包共186个文件,约4.41MB,以93个cs源码文件为核心,辅以dll、exe可执行程序、pdb调试符号、resx与resources资源文件及csproj、sln工程配置,另含pdf参考资料,工程结构完整可直接编译调试。已有301人学习下载。读者可从中获得两套可复用的BACnet客户端实现范例、模拟器点位配置方法以及同网段跨机测试的排错思路,便于对照源码理解读写流程并移植到实际项目中。
1. 从一台“哑巴”楼控设备说起:这套 C# BACnet 源码能干什么
去年帮一个做楼宇自控的团队排查现场问题,最头疼的不是控制器本身,而是手头没有趁手的调试工具。现场一台空调机组挂在 BACnet 总线上,上位机读不到点位,厂商的调试软件又要授权、又要装驱动,折腾半天连设备对象列表都刷不出来。后来对方丢过来一份 C# 写的 BACnet 源码,附带一个模拟器,编译完直接跑,设备对象、属性读写、服务请求全都能在本地复现,那一刻才意识到:有一套能自己改、能脱离现场硬件做联调的源码,比什么商业工具都实在。
这份资源就是一套用 C# 实现的 BACnet 协议栈源码,配套一个 BACnet 模拟器,作者标注测试通过。它解决的核心问题是:让做楼控、暖通、能源管理的开发者,能在没有真实 BACnet 设备的情况下,把协议交互、对象模型、服务调用这条链路完整跑起来,并且源码开放,可以按自己的点位表、对象类型去改。适合两类人:一类是刚接触 BACnet、想搞懂协议到底怎么收发报文的开发者;另一类是手里有项目、需要一个可裁剪协议栈做二次集成的熟手。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆。
2. 技术栈与目录结构:这套 C# BACnet 栈是怎么搭起来的
2.1 技术选型:为什么是 C# 而不是 C
BACnet 协议栈在工业现场常见的是 C 或 C++ 实现,追求的是嵌入式和跨平台。这套源码选 C#,出发点很实际:楼控项目的上位机、调试工具、数据采集服务大多跑在 Windows 上,C# 的开发效率、调试体验、与数据库和界面的集成成本都更低。协议栈本身是纯托管代码,不依赖第三方协议库,网络层基于 UDP,走的是 BACnet/IP 的常规路子——默认端口 47808,报文用 BVLL 封装后再套 BVLC 头。
从工程角度看,这种选型带来的好处是:断点能直接打在报文解析函数里,一个byte[]进来,逐字段看过去,比在 C 里加打印再重编译快得多。代价是如果目标设备是资源受限的嵌入式控制器,这套代码不能直接搬过去,得做裁剪或移植。所以定位要清楚:它是上位机侧、工具侧、服务侧的协议实现,不是给单片机用的。
2.2 目录结构:按协议分层组织
源码的目录组织基本遵循 BACnet 的分层模型,常见做法是分成网络层、应用层、对象模型、服务处理和模拟器几块。下面是一个典型的目录骨架,具体文件名可能略有差异,但分层逻辑是一致的:
BacnetStack/ ├── Bacnet/ │ ├── Network/ # 网络层:BVLL、BVLC、UDP 收发 │ │ ├── BacnetIpLayer.cs │ │ └── BvlcCodec.cs │ ├── Application/ # 应用层:APDU 编解码、服务分发 │ │ ├── ApduCodec.cs │ │ └── ServiceDispatcher.cs │ ├── Objects/ # 对象模型:AnalogInput、BinaryOutput 等 │ │ ├── BacnetObject.cs │ │ └── AnalogInputObject.cs │ ├── Services/ # 服务实现:ReadProperty、WriteProperty 等 │ │ ├── ReadPropertyService.cs │ │ └── WritePropertyService.cs │ └── Types/ # 基础类型:ObjectIdentifier、PropertyValue ├── BacnetSimulator/ # 模拟器:虚拟设备 + 点位表 │ ├── SimulatedDevice.cs │ └── PointTable.cs └── BacnetDemo/ # 控制台/界面示例:发起请求、打印结果这个结构的关键在于职责边界清晰:Network只管字节进出,Application只管 APDU 的组装和拆解,Objects维护对象和属性的当前值,Services把“读某个对象的某个属性”翻译成对对象模型的访问。模拟器单独一个工程,不污染协议栈本身,这一点对二次开发很重要——你可以只引用协议栈,把模拟器换成自己的设备接入层。
2.3 核心抽象:对象标识与属性访问
BACnet 的一切交互都围绕“对象标识 + 属性”展开。对象标识是一个 32 位值,高 10 位是对象类型,低 22 位是实例号。源码里通常会封装成一个结构体或类,避免到处传裸整数。属性访问则统一走一个基类方法,子类按需覆写。下面这段是对象基类的典型写法:
// BacnetObject.cs public abstract class BacnetObject { public ObjectIdentifier Id { get; protected set; } public string Name { get; set; } // 属性读取的统一入口,子类按属性类型覆写 public abstract PropertyValue GetProperty(PropertyIdentifier prop); // 属性写入,只读对象直接抛异常 public virtual void SetProperty(PropertyIdentifier prop, PropertyValue value) { throw new NotSupportedException($"对象 {Id} 不支持写入属性 {prop}"); } }ObjectIdentifier负责类型和实例号的打包与解包,PropertyValue是一个带类型标记的容器,能装实数、枚举、字符串、数组等。这样设计的好处是服务层不需要知道具体对象类型,拿到BacnetObject引用就能调GetProperty,新增对象类型时只加子类,不动服务代码。参数上要注意:PropertyIdentifier是枚举,值必须和 BACnet 标准里的属性编号对齐,写错一个数字,对面设备就会返回错误码,这种问题在现场排查时非常隐蔽。
3. 跑起来:从编译到模拟器联调的完整步骤
3.1 环境准备与编译
这套源码是纯 C# 工程,没有额外的原生依赖,所以环境要求不高。常见做法是用 Visual Studio 打开解决方案,或者用 .NET SDK 命令行编译。如果工程是较新的 SDK 风格,直接dotnet build即可;如果是旧式 csproj,用 MSBuild 或 VS 打开更稳。
# 进入解决方案目录后编译 dotnet build BacnetStack.sln -c Release # 如果只想跑模拟器工程 dotnet run --project BacnetSimulator/BacnetSimulator.csproj编译时容易翻车的地方是目标框架版本。如果源码写的时候用的是某个较老的 .NET Framework,而你本机只装了新版 SDK,可能报找不到目标框架。解决办法是装对应的开发者包,或者把 csproj 里的TargetFramework改成你本机有的版本,再处理少量 API 差异。这一步不要跳过,先让编译通过,再谈联调。
3.2 启动模拟器:虚拟设备与点位表
模拟器的作用是在本机起一个“假设备”,监听 UDP 47808,响应读属性、写属性等请求。启动后它会加载一份点位表,把若干模拟对象注册进去,比如一个模拟输入对象、一个模拟输出对象、一个二进制输入对象。点位表通常是一个配置结构,代码里长这样:
// PointTable.cs public static class PointTable { public static List<BacnetObject> Build() { return new List<BacnetObject> { // 实例号 1 的模拟输入,当前值 23.5 new AnalogInputObject(new ObjectIdentifier(ObjectType.AnalogInput, 1)) { Name = "AI-1 送风温度", PresentValue = 23.5f }, // 实例号 1 的模拟输出,可写 new AnalogOutputObject(new ObjectIdentifier(ObjectType.AnalogOutput, 1)) { Name = "AO-1 阀门开度", PresentValue = 0f } }; } }ObjectType和实例号共同决定设备对外暴露的对象列表,PresentValue是当前值。模拟器启动后,这些对象就能被外部客户端读到。参数上要注意实例号不要重复,同一类型下实例号唯一,否则对象字典会覆盖,读出来的值对不上。点位表改成从 JSON 或数据库加载也很容易,把Build()里的硬编码换成反序列化即可,这是二次开发第一个会动的地方。
3.3 发起一次读属性请求
模拟器跑起来后,用 Demo 工程或自己写一个客户端去读。核心是构造一个 ReadProperty 请求,指定目标设备地址、对象标识和属性。下面是一段典型的调用代码:
// BacnetDemo/Program.cs var client = new BacnetClient("127.0.0.1", 47808); // 读 AI-1 的 PresentValue var request = new ReadPropertyRequest { ObjectId = new ObjectIdentifier(ObjectType.AnalogInput, 1), Property = PropertyIdentifier.PresentValue }; var response = client.Send(request, timeoutMs: 3000); if (response.IsError) { Console.WriteLine($"错误码: {response.ErrorCode}"); } else { Console.WriteLine($"读回值: {response.Value}"); }Send内部做的是:把请求编码成 APDU,套上 BVLL 头,通过 UDP 发出去,然后等回包、解码、匹配 invoke ID。timeoutMs是等待超时,现场网络差的时候可以适当放大。如果返回错误码,先看对象标识和属性编号对不对,再看模拟器日志里有没有收到请求——收不到是网络层问题,收到了但报错是应用层问题,这个二分法能省很多时间。
3.4 写属性与值变更验证
读通之后,写属性是第二个要验证的动作。写请求除了对象和属性,还要带优先级和值。BACnet 的写入有优先级数组的概念,模拟输出这类对象通常支持 16 级优先级,低优先级的值会被高优先级覆盖。代码上:
var write = new WritePropertyRequest { ObjectId = new ObjectIdentifier(ObjectType.AnalogOutput, 1), Property = PropertyIdentifier.PresentValue, Value = new RealValue(65.0f), Priority = 8 }; var result = client.Send(write, timeoutMs: 3000);写完后再读一次,确认值变成 65。如果读回来还是旧值,检查模拟器里该对象是否允许写、优先级是否被更高优先级占住。这套“写后读”的验证习惯,在对接真实设备时同样适用,因为有些设备写入成功但值要过一轮扫描才刷新,光看返回码会误判。
4. 核心模块拆解:报文编解码与服务分发链路
4.1 网络层:BVLL 封装与 UDP 收发
BACnet/IP 的报文最外层是 BVLC 头,里面再包 BVLL,然后是 NPDU,最后是 APDU。网络层要做的就是加头、去头、收发。下面是一个简化的发送封装:
// BacnetIpLayer.cs public void Send(byte[] apdu, IPEndPoint target) { // BVLC 头:类型 0x81,功能 0x0A(Original-Unicast) var bvlc = new byte[] { 0x81, 0x0A }; var length = (ushort)(4 + apdu.Length); var buffer = new byte[length]; buffer[0] = bvlc[0]; buffer[1] = bvlc[1]; // 长度字段大端 buffer[2] = (byte)(length >> 8); buffer[3] = (byte)(length & 0xFF); Array.Copy(apdu, 0, buffer, 4, apdu.Length); _udp.Send(buffer, buffer.Length, target); }0x81是 BACnet/IP 的固定标识,0x0A表示单播。长度字段是大端,写反了对面直接丢弃,而且不会有任何提示,这是网络层最典型的坑。接收侧反过来解析:先读前两位判断是不是 BACnet/IP,再读长度,再按长度截取 APDU。UDP 是无连接的,所以接收循环要一直挂着,收到就分发,不要指望“发一次收一次”的同步模型,现场广播包很多,容易串。
4.2 应用层:APDU 编解码与 invoke ID 匹配
APDU 里最关键的是服务选择码和 invoke ID。请求方生成一个自增的 invoke ID,响应方原样带回,客户端靠它把响应和请求对上。编解码代码通常是一个静态类,按服务类型分支:
// ApduCodec.cs public static byte[] EncodeReadProperty(ReadPropertyRequest req, byte invokeId) { var ms = new MemoryStream(); ms.WriteByte(0x00); // 确认请求 PDU 类型 ms.WriteByte(0x05); // 最大段数/最大 APDU,按需 ms.WriteByte(invokeId); // invoke ID ms.WriteByte(0x0C); // 服务选择:ReadProperty // 对象标识:4 字节 var oid = req.ObjectId.ToUInt32(); ms.WriteByte((byte)(oid >> 24)); ms.WriteByte((byte)(oid >> 16)); ms.WriteByte((byte)(oid >> 8)); ms.WriteByte((byte)oid); // 属性标识:按属性编号编码,长度可变 WriteContextTag(ms, 0, req.Property); return ms.ToArray(); }invokeId是匹配的关键,客户端维护一个待响应字典,key 是 invoke ID,value 是等待中的请求。收到响应先解出 invoke ID,再查字典,查不到就丢弃。参数上要注意属性标识的编码方式:BACnet 的属性编号是可变长整数,小于 256 用一个字节,大于等于 256 要两个字节,编码函数写错会导致属性读错位,读出来的值完全对不上,这种 bug 靠日志很难看出来,只能对着标准逐字节比对。
4.3 服务分发:从服务码到具体处理函数
服务分发是一个典型的 switch 或字典映射。收到 APDU 后,先判断 PDU 类型(确认请求、非确认请求、简单确认等),再取服务选择码,路由到对应处理器。下面是一个分发骨架:
// ServiceDispatcher.cs public Apdu Handle(Apdu request, BacnetDevice device) { switch (request.ServiceChoice) { case ServiceChoice.ReadProperty: return ReadPropertyService.Execute(request, device); case ServiceChoice.WriteProperty: return WritePropertyService.Execute(request, device); case ServiceChoice.ReadPropertyMultiple: return ReadPropertyMultipleService.Execute(request, device); default: // 不支持的服务返回错误 PDU return Apdu.Error(request.InvokeId, ErrorCode.ServiceNotSupported); } }device是本地设备对象,持有对象字典。ReadPropertyService.Execute做的事是:从请求里解出对象标识和属性,去对象字典里找对象,调GetProperty,把结果编码成响应 APDU。这里要注意错误处理:对象不存在返回ObjectNotFound,属性不支持返回PropertyNotSupported,不要一律返回通用错误,否则对接方没法定位问题。服务分发层是二次开发加新服务的地方,比如加一个ReadPropertyMultiple的批量读,就在这里挂一个 case。
5. 避坑与排查:联调时最容易翻车的几个点
5.1 端口被占用或防火墙拦截
现象是客户端发请求一直超时,模拟器日志里没有任何收到请求的记录。原因通常是 47808 端口被别的进程占了,或者本机防火墙拦了 UDP 入站。解决方法是先用netstat -ano | findstr 47808看端口占用,换端口时客户端和服务端要同步改;防火墙则给对应进程放行 UDP。这个坑在第一次跑的时候几乎必踩,因为 UDP 没有连接状态,超时和“对端没开”表现一模一样。
5.2 字节序写反导致报文被丢弃
现象是抓包能看到报文发出去了,但对面不响应,或者自己解析响应时字段全乱。原因是 BACnet 的长度、对象标识等多字节字段是大端,而 C# 的BitConverter默认跟机器字节序走,小端机器上直接BitConverter.GetBytes就反了。解决办法是统一手写大端读写,或者用BinaryPrimitives.WriteUInt16BigEndian这类显式 API,不要图省事用默认转换。
5.3 对象实例号重复导致读值错乱
现象是读某个点位,返回的值和预期对不上,或者时对时错。原因是点位表里同一对象类型下实例号重复,后注册的覆盖了先注册的,对象字典里只剩一个。解决办法是启动时对点位表做一次唯一性校验,发现重复直接抛异常,别让它带病运行。这个校验代码很短,但能省掉大量现场排查时间。
5.4 invoke ID 不匹配导致响应被丢弃
现象是请求发出去了,对面也回了,但客户端一直报超时。原因是响应里的 invoke ID 和请求对不上,或者客户端待响应字典的清理逻辑有并发问题。解决办法是给每个请求分配唯一 ID,收到响应先校验再移除,超时后也要清理字典,避免 ID 泄漏。多线程发请求时尤其要注意加锁,字典不是线程安全的。
5.5 属性编码长度错误导致解析错位
现象是读某些属性正常,读另一些属性返回错误或值明显不对。原因是属性标识是可变长编码,小于 256 和大于等于 256 的编码长度不同,编码函数没处理边界。解决办法是对照标准把编码函数写全,并针对 255、256、257 这几个边界值写单元测试。这种问题靠肉眼读代码很难发现,测试是唯一的后悔药。
6. 二次开发:把模拟器换成真实设备接入层
这套源码真正有价值的地方,不是它能跑一个 Demo,而是它的分层让你能把模拟器整块换掉。我的做法是保留Bacnet协议栈工程不动,新建一个接入层工程,引用协议栈,把PointTable.Build()换成从配置文件或数据库加载点位,把SimulatedDevice换成真实设备的会话管理。具体来说,分三步走。
第一步,抽象设备接口。定义一个IBacnetDevice,暴露对象字典查询和属性读写,模拟器和真实设备各实现一份。这样服务层代码一行不用改,只换实现。第二步,把点位配置外置。用 JSON 描述对象类型、实例号、名称、初始值,启动时反序列化后注册进对象字典,改点位不用重编译。第三步,加一层采集调度。真实项目里读点位不是一次性的,要按周期轮询,把读请求排队、限流、失败重试,这部分放在接入层,不污染协议栈。
验证方法很直接:先用模拟器跑通全链路,记录每个请求的报文十六进制;再接真实设备,抓包对比报文结构,差异只应该出现在地址和实例号上。如果结构都对不上,说明编码层有问题,回到第 4 章逐字段核对。下面是一个配置外置后的加载片段:
// 从 JSON 加载点位,替代硬编码 var json = File.ReadAllText("points.json"); var points = JsonSerializer.Deserialize<List<PointConfig>>(json); foreach (var p in points) { var obj = ObjectFactory.Create(p.ObjectType, p.InstanceNumber); obj.Name = p.Name; device.AddObject(obj); // 注册进对象字典 }ObjectFactory按类型创建对应子类,AddObject做实例号唯一性校验。这样一套点位表可以同时喂给模拟器和真实设备接入层,联调时切换配置就行。
血泪经验是:不要一上来就接真实设备调协议,先用模拟器把读、写、批量读、错误码这几条路径全部走一遍,把报文抓下来存成基线。后面接真机出问题,拿基线一对比,是编码问题还是设备兼容问题,一目了然。从那以后我每次碰协议类项目,都强制先跑模拟器基线再上现场,这个习惯帮我省掉了太多“到底是代码错还是设备怪”的扯皮。希望帮到你。
本文还有配套的精品资源,点击获取