简介:这份资源由C#编写,包含两个可直接运行的BACnet客户端程序及配套模拟器,面向工业自动化、楼宇自控领域的开发者与学习者,用于解决BACnet协议读写调试缺乏现成示例的问题。第一个程序演示模拟量(如温度)的读取,第二个程序实现开关量(BO点)的读写,配合BACnet模拟器添加AO、BO点即可观察通信效果;需注意模拟器与客户端应部署在同一网段的不同电脑上,这是BACnet协议特性所致。压缩包共186个文件,以93个cs源码、11个dll、10个exe及若干csproj、sln工程文件为主,另有resx、settings等配置资源,整体约4.41MB,工程结构完整可直接编译。目前已有299人学习下载,适合希望快速上手BACnet通信、对照源码理解读写流程并自行搭建测试环境的开发者参考。
1. 从一台“假设备”说起:这套 C# BACnet 源码到底能干什么
如果你做过楼宇自控或者工业协议对接,大概率遇到过这种场面:现场设备还没到货,甲方催着要演示,你手里只有一份协议文档和一台开发机。这时候最想要的东西,就是一个能跑起来的模拟器,加上一套能直接读、能直接改的协议栈源码。这份 C# 编写的 BACnet 源码包,解决的正是这个场景——它包含完整的 BACnet 协议实现,外加一个 BACnet 模拟器,并且标注了测试通过。换句话说,你不需要真实控制器,就能在本地把设备发现、对象读写、服务交互这一整套流程跑通。
它适合两类人:一类是刚接触 BACnet、想找个能调试的代码底子把协议概念落地的开发者;另一类是要在 .NET 项目里集成 BACnet 通信、需要一份可二次开发的协议栈做参考的熟手。BACnet 本身是楼宇自动化里绕不开的协议,涉及对象模型、服务类型、网络层编解码,光看标准文档很容易卡在“知道概念但写不出报文”的阶段。这套源码的价值就在于把协议栈和模拟器放在一起,让你能一边看实现一边发请求验证。
2. 技术选型与整体架构:为什么是 C# 加模拟器这套组合
2.1 语言与运行时选择
这套源码用 C# 编写,意味着它天然跑在 .NET 运行时上。常见做法是面向 .NET Framework 或 .NET Core/.NET 5+ 两个方向,前者兼容老旧的工控上位机环境,后者方便跨平台部署到 Linux 网关设备。BACnet 的通信底层依赖 UDP,C# 的System.Net.Sockets.UdpClient和Socket类足够支撑,不需要引入额外的网络库。选择 C# 的另一个现实原因是:很多楼宇自控的上位机、组态软件、网关程序本身就是 .NET 技术栈,协议栈用同一种语言写,集成时不用跨语言封送,调试也统一在 Visual Studio 里完成。
2.2 分层结构
一个合格的 BACnet 协议栈不会把所有逻辑塞进一个类。这套源码按典型的分层思路组织,从上到下大致是:
- 应用层:对外暴露读写属性、设备发现、服务调用的 API,业务代码只跟这一层打交道。
- 服务层:实现 BACnet 定义的各种服务,比如 Who-Is、I-Am、ReadProperty、WriteProperty、ReadPropertyMultiple。
- 对象层:维护模拟设备里的对象集合,比如 Analog Input、Analog Output、Binary Input、Device 对象,每个对象带一组属性和当前值。
- 编解码层:负责 APDU、NPDU 的组装与解析,处理标签、类型、长度这些二进制细节。
- 传输层:基于 UDP 收发报文,处理广播、单播和端口绑定。
模拟器则是在对象层和服务层之上包了一层交互界面,让你能手动改对象值、观察请求响应。
2.3 模拟器在架构里的位置
模拟器不是独立于协议栈的另一个程序,它更像是协议栈的一个宿主。它启动后会创建一个虚拟设备,注册若干对象,然后监听 UDP 端口。当外部工具(比如 BACnet 扫描器或另一份客户端代码)发来 Who-Is 广播时,模拟器回 I-Am;发来 ReadProperty 时,它从对象层取属性值编码后返回。这样你既验证了协议栈的收发逻辑,又有了一个可控的对端设备。
提示:模拟器和协议栈共用同一套编解码与服务代码,改协议栈时模拟器行为会同步变化,这一点对调试很关键。
3. 目录结构与核心模块拆解:从报文进来到响应出去
3.1 典型目录布局
虽然不同作者的命名习惯有差异,但这类项目的目录通常长这样:
BacnetSolution/ ├── Bacnet.Core/ # 协议栈核心 │ ├── Encoding/ # APDU/NPDU 编解码 │ ├── Services/ # 各类服务实现 │ ├── Objects/ # 对象模型与属性 │ └── Transport/ # UDP 传输 ├── Bacnet.Simulator/ # 模拟器宿主 │ ├── Program.cs │ └── DeviceConfig.cs └── Bacnet.Tests/ # 测试用例Bacnet.Core是可以被其他项目引用的类库,Bacnet.Simulator是可执行入口。二次开发时,你通常只改 Core,模拟器基本不用动。
3.2 编解码模块
BACnet 报文里大量使用标签(Tag)来表示数据类型和长度,这是最容易写错的地方。下面是一段读取无符号整数标签的示意代码:
// 从字节流中解析一个 BACnet 应用层标签 public static uint ReadUnsignedTag(byte[] buffer, ref int offset) { byte tag = buffer[offset++]; // 高4位是标签编号,低4位是长度/类型信息 int tagNumber = tag >> 4; int length = tag & 0x07; // 低3位表示长度 bool isExtended = (tag & 0x08) != 0; // 第4位表示是否扩展长度 if (isExtended) { length = buffer[offset++]; // 扩展长度用后续字节表示 } uint value = 0; for (int i = 0; i < length; i++) { value = (value << 8) | buffer[offset++]; } return value; }这段代码的关键点在于标签的位布局:高 4 位是标签号,低 4 位里又拆出长度和扩展标志。参数buffer是收到的原始字节,offset用ref传递,保证解析完一个标签后位置能往后推。实际协议里标签号对应不同的数据类型,长度也可能超过 4 字节,所以真实实现里还要处理更多分支。写错一个位,报文就解析不出来,这是 BACnet 开发里最常见的翻车点。
3.3 对象模型
对象层用类来描述设备里的每个数据点。以模拟量输入为例:
public class AnalogInput : BacnetObject { public uint InstanceNumber { get; set; } public string ObjectName { get; set; } public float PresentValue { get; set; } public string Units { get; set; } public bool OutOfService { get; set; } // 根据属性ID返回对应的值,供 ReadProperty 服务调用 public override object GetProperty(uint propertyId) { switch (propertyId) { case 85: return PresentValue; // Present_Value case 77: return ObjectName; // Object_Name case 117: return Units; // Units default: return null; } } }GetProperty是对象和服务的接口。服务层收到 ReadProperty 请求后,根据对象类型和实例号找到对象,再调用这个方法取属性值。属性 ID 是 BACnet 标准里定义的数字,比如 85 代表 Present_Value。二次开发时新增对象类型,主要工作就是补这个映射。
3.4 服务处理流程
一个 ReadProperty 请求从进来到返回,链路大致是:传输层收到 UDP 包,编解码层解析出服务类型和参数,服务层分发到对应处理器,处理器查对象取属性,再编码成响应包发回去。下面是一个简化的分发逻辑:
// 根据服务类型分发处理 public byte[] HandleApdu(byte[] apdu, string remoteEndPoint) { byte serviceChoice = apdu[1]; // 简化示意,实际偏移要看报文结构 switch (serviceChoice) { case 12: // ReadProperty return HandleReadProperty(apdu, remoteEndPoint); case 15: // WriteProperty return HandleWriteProperty(apdu, remoteEndPoint); case 8: // Who-Is return HandleWhoIs(apdu, remoteEndPoint); default: return BuildErrorResponse(serviceChoice); } }serviceChoice是 BACnet 里区分服务类型的编号,ReadProperty 是 12,WriteProperty 是 15,Who-Is 是 8。分发器只做路由,具体解析和响应构造在各自的 Handler 里。这种写法让新增服务变得简单:加一个 case,写一个 Handler。
3.5 模拟器启动与设备注册
模拟器入口通常做三件事:创建虚拟设备、注册对象、启动监听。示意如下:
static void Main(string[] args) { var device = new VirtualDevice(instanceNumber: 1001, name: "SimDevice"); // 注册两个模拟量输入对象 device.AddObject(new AnalogInput { InstanceNumber = 1, PresentValue = 22.5f, Units = "degreesCelsius" }); device.AddObject(new AnalogInput { InstanceNumber = 2, PresentValue = 45.0f, Units = "percent" }); var server = new BacnetServer(device, port: 47808); server.Start(); // 绑定 UDP 端口并开始监听 Console.WriteLine("模拟器已启动,按任意键退出"); Console.ReadKey(); }端口 47808 是 BACnet 的默认端口,设备实例号 1001 是虚拟设备的编号。启动后,外部工具广播 Who-Is,模拟器就会回 I-Am,然后你可以对它做读写。改对象初始值、加对象,都在这一段完成。
4. 跑起来:编译、启动与联调步骤
4.1 环境准备
先确认开发机装了 .NET SDK 或对应版本的 Visual Studio。如果项目面向 .NET Framework,用 Visual Studio 打开解决方案直接编译;如果面向 .NET Core/.NET 5+,命令行dotnet build也能过。UDP 端口 47808 不能被其他 BACnet 程序占用,否则绑定会失败。
4.2 编译与启动模拟器
用命令行的话,进入解决方案目录:
dotnet restore dotnet build -c Release dotnet run --project Bacnet.Simulator如果用的是 Visual Studio,把Bacnet.Simulator设为启动项目,F5 运行。启动成功后控制台会打印监听端口和设备信息。此时模拟器已经在 47808 端口上等待请求。
4.3 用客户端验证
验证方式有两种。一种是用现成的 BACnet 扫描工具,把目标地址设为本机广播地址,发 Who-Is,看能否发现设备实例 1001。另一种是直接用源码里的客户端代码或测试用例,构造 ReadProperty 请求读 Analog Input 1 的 Present_Value,预期返回 22.5。如果读不到,先确认防火墙有没有拦 UDP,再确认模拟器绑定的网卡是不是广播能到达的那块。
4.4 常见联调参数
| 参数 | 含义 | 常见取值 |
|---|---|---|
| 端口 | BACnet UDP 端口 | 47808 |
| 设备实例号 | 虚拟设备编号 | 1001 |
| 对象实例号 | 数据点编号 | 1、2 |
| 属性 ID | 要读的属性 | 85(Present_Value) |
| 服务编号 | 请求类型 | 12(ReadProperty) |
改这些值时,模拟器侧和客户端侧要对应,否则会出现“设备发现了但读不到属性”的情况。
5. 避坑与排查:几个真实会卡住你的地方
5.1 广播收不到 I-Am
现象:客户端发 Who-Is,模拟器没反应。原因通常是模拟器绑定的是单播地址,或者广播地址没设对。解决:确认BacnetServer绑定的是IPAddress.Any或正确的广播地址,并且客户端和模拟器在同一网段。跨网段时广播到不了,需要走 BBMD 或单播 Who-Is。
5.2 属性读出来是空
现象:ReadProperty 返回错误或空值。原因多半是属性 ID 映射没写全,GetProperty的 switch 里没有对应 case,返回了 null。解决:对照 BACnet 标准属性表,把要用的属性 ID 补进映射,并确认返回类型和编码时一致。
5.3 报文解析错位
现象:收到响应但解析出来是乱码或异常。原因通常是标签长度位处理错了,尤其是扩展长度和上下文标签。解决:在编解码层加日志,把原始字节和解析出的标签号、长度打出来,逐字节比对。BACnet 的标签规则不复杂,但位运算写错很难肉眼发现。
5.4 端口被占用
现象:模拟器启动时报绑定失败。原因:47808 已被另一个 BACnet 程序或上次没退干净的进程占用。解决:换端口测试,或结束占用进程。注意客户端也要同步改端口。
5.5 写入不生效
现象:WriteProperty 返回成功,但再读还是旧值。原因:对象没有实现写属性逻辑,或者OutOfService为 true 时行为不符合预期。解决:在对象类里补SetProperty,并确认服务层调用的是写路径而不是只改了本地副本。
6. 二次开发:从改一个对象到加一种服务
拿到这套源码后,最实际的改造方向有三个:加对象类型、加服务、接真实设备。加对象类型最直接,照着AnalogInput复制一个类,实现GetProperty和SetProperty,然后在模拟器启动时注册进去。加服务稍微复杂一点,要在分发器里加 case,写 Handler,再补编解码。接真实设备则是把模拟器换成真实通信层,保留对象和服务,替换传输实现。
我一般会先写一个最小验证:改一个对象的初始值,启动模拟器,用客户端读出来,确认改动生效。这一步过了,再动服务层。下面是一个加自定义属性的例子:
// 在 AnalogInput 里增加一个自定义属性映射 public override object GetProperty(uint propertyId) { switch (propertyId) { case 85: return PresentValue; case 77: return ObjectName; case 117: return Units; case 103: return 1; // 假设 103 是 Reliability,返回固定值 default: return null; } }改完编译,重启模拟器,用 ReadProperty 读属性 103,能返回 1 就说明映射生效。验证服务时,构造一个 WriteProperty 请求写 Present_Value,再读回来确认值变了。如果写不进去,检查对象有没有实现写路径,以及服务层有没有把写请求路由过去。
还有一个容易忽略的点:模拟器里的对象集合是内存态,重启就恢复初始值。如果你要持久化,得自己在SetProperty里落盘,或者在启动时从配置文件加载。我习惯在改完协议栈后,先用模拟器跑一轮读写,再拿真实设备验证,避免把协议栈的 bug 带到现场。从那以后我每次改 BACnet 相关代码,都强制走一遍“模拟器读写 + 抓包比对”的流程,省得在现场对着设备猜。希望帮到你。
本文还有配套的精品资源,点击获取