☰
C#蓝牙串口通信实战:SimpleBTComms示例与避坑指南
2026/10/17 2:37:22 网站建设 项目流程

简介:SimpleBTComms 是一份面向 C# 初学者与物联网爱好者的蓝牙通信入门示例,核心演示如何让 C# 程序与通过蓝牙连接的 Arduino 主板进行数据交换,适合作为课程设计或 DIY 项目的参考起点。资源包共 12 个文件,以 6 个 cs 源码文件为主体,辅以 2 个 resx 资源文件、1 个 sln 解决方案、1 个 csproj 工程文件及 settings、md 说明文档,压缩包约 125KB,结构轻量便于快速导入 Visual Studio 阅读。内容围绕蓝牙设备发现与枚举、基于服务 UUID 的连接管理、NetworkStream 数据收发、异常捕获与断开释放等关键环节展开,并提及旧项目在 BLE 兼容性、跨平台支持与安全性上的局限。目前已有 409 人学习,读者可借此理解蓝牙通信的基本流程与排错思路,再按需更新代码以适配现代蓝牙标准。

1. 蓝牙串口通信在 C# 里到底能跑多稳

去年帮一个做工业数据采集的团队看代码,他们用 C# 写了个上位机,通过蓝牙串口跟一台手持采集器通信。现场调试时数据时断时续,换了三根 USB 蓝牙适配器都没解决,最后发现是串口读线程和 UI 线程抢资源。这个场景让我意识到,蓝牙通信在 C# 里看起来简单——无非是打开串口、读写字节——但真正落地到项目里,稳定性取决于对串口生命周期和线程模型的理解。SimpleBTComms 这个项目就是冲着这个问题来的:它用最朴素的 SerialPort 类封装了一套蓝牙 SPP(串口协议)通信示例,没有第三方库依赖,适合刚接触 C# 蓝牙通信的开发者拿来当起点,也适合需要快速验证硬件链路的场景。你拿到这份代码,能直接看到从设备发现、配对、打开虚拟串口到收发数据的完整链路,而不是只给一个孤零零的 API 调用。

2. 项目结构拆解:从设备枚举到数据回环的完整链路

2.1 目录组织与模块划分

SimpleBTComms 的代码量不大,但分层清晰。根目录下通常包含这几个部分:BluetoothManager.cs负责设备发现和配对逻辑,SerialPortManager.cs封装串口打开、关闭、读写,MainForm.cs是 WinForms 界面,Program.cs是入口。有些版本还会带一个Models文件夹放设备信息实体类。这种划分的好处是,蓝牙协议层和串口传输层解耦——你换一个蓝牙适配器,或者从 SPP 切到 BLE,只需要动BluetoothManager,串口读写逻辑不用重写。

我一般会建议把设备发现和串口通信分成两个独立的类,因为它们的生命周期不一样。设备发现是瞬时的,配对完成后就不需要了;串口连接是长连接,需要持续维护。SimpleBTComms 就是这么做的,BluetoothManager里的方法大多是静态工具方法,SerialPortManager则是一个可实例化的类,持有SerialPort对象。

2.2 设备发现与配对:调用 Windows 蓝牙 API

C# 本身没有内置的蓝牙设备发现 API,SimpleBTComms 用的是 P/Invoke 调用 Windows 的BluetoothAPIs.dll。核心代码大致长这样:

// 引入 Windows 蓝牙 API [DllImport("bthprops.cpl", SetLastError = true)] static extern IntPtr BluetoothFindFirstDevice(ref BLUETOOTH_DEVICE_SEARCH_PARAMS searchParams, ref BLUETOOTH_DEVICE_INFO deviceInfo); [DllImport("bthprops.cpl", SetLastError = true)] static extern bool BluetoothFindNextDevice(IntPtr hFind, ref BLUETOOTH_DEVICE_INFO deviceInfo); // 枚举已配对设备 public List<BluetoothDevice> GetPairedDevices() { var devices = new List<BluetoothDevice>(); var searchParams = new BLUETOOTH_DEVICE_SEARCH_PARAMS { dwSize = Marshal.SizeOf(typeof(BLUETOOTH_DEVICE_SEARCH_PARAMS)), fReturnAuthenticated = true, // 只返回已配对设备 fReturnRemembered = true, fReturnUnknown = false, fReturnConnected = false }; var deviceInfo = new BLUETOOTH_DEVICE_INFO { dwSize = Marshal.SizeOf(typeof(BLUETOOTH_DEVICE_INFO)) }; IntPtr hFind = BluetoothFindFirstDevice(ref searchParams, ref deviceInfo); if (hFind == IntPtr.Zero) return devices; do { devices.Add(new BluetoothDevice { Name = deviceInfo.szName, Address = deviceInfo.Address, Connected = deviceInfo.fConnected }); } while (BluetoothFindNextDevice(hFind, ref deviceInfo)); BluetoothFindDeviceClose(hFind); return devices; }

这段代码的关键参数是fReturnAuthenticated和fReturnRemembered。前者设为true表示只返回已经配对过的设备,后者设为true表示返回系统记住的设备。如果你要发现新设备,需要把fReturnUnknown设为true,但那样会枚举出大量无关设备,实际项目中我一般只枚举已配对设备,让用户先在系统设置里完成配对。

BLUETOOTH_DEVICE_INFO结构体里的Address是 64 位整数,对应蓝牙 MAC 地址。拿到地址后,下一步就是找到它对应的虚拟串口。

2.3 虚拟串口映射:从蓝牙地址到 COM 端口

Windows 为每个已配对的 SPP 设备创建一个虚拟串口,但系统 API 不直接告诉你哪个 COM 口对应哪个蓝牙地址。SimpleBTComms 的做法是遍历注册表或者用 WMI 查询。常见做法是查Win32_SerialPort类,通过PNPDeviceID匹配蓝牙地址:

using System.Management; public string GetComPortByBluetoothAddress(ulong address) { string addressHex = address.ToString("X12"); using (var searcher = new ManagementObjectSearcher( "SELECT * FROM Win32_SerialPort WHERE PNPDeviceID LIKE '%BTHENUM%'")) { foreach (ManagementObject obj in searcher.Get()) { string pnpId = obj["PNPDeviceID"]?.ToString() ?? ""; // PNPDeviceID 里包含蓝牙地址的十六进制片段 if (pnpId.Contains(addressHex.Substring(0, 8))) { return obj["DeviceID"]?.ToString(); // 返回 "COM3" 这样的字符串 } } } return null; }

这里有个坑:PNPDeviceID里的地址格式和BLUETOOTH_DEVICE_INFO.Address的字节序可能不一致,需要做大小端转换。我一般会先把地址转成十六进制字符串,然后取前 8 位做模糊匹配,因为不同厂商的适配器在 PNP ID 里的地址片段长度不一样。匹配到 COM 口之后,就可以交给SerialPortManager打开了。

2.4 串口打开与参数配置

打开串口本身很简单,但参数配置决定了通信能不能成功。SimpleBTComms 默认用 9600 波特率、8 数据位、1 停止位、无校验,这是大多数蓝牙 SPP 模块的出厂设置:

public bool Open(string portName, int baudRate = 9600) { try { _serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _serialPort.ReadTimeout = 500; // 读超时 500ms _serialPort.WriteTimeout = 500; // 写超时 500ms _serialPort.DataReceived += OnDataReceived; // 注册数据到达事件 _serialPort.Open(); return true; } catch (UnauthorizedAccessException) { // 串口被占用,通常是另一个程序已经打开了 throw new InvalidOperationException($"串口 {portName} 已被占用"); } catch (IOException ex) { // 设备未连接或驱动异常 throw new InvalidOperationException($"无法打开 {portName}: {ex.Message}"); } }

ReadTimeout和WriteTimeout必须设,否则在设备掉线时读写操作会永久阻塞。DataReceived事件是在后台线程触发的,不能在里面直接更新 UI 控件,需要用Invoke或BeginInvoke切回 UI 线程。这是 WinForms 串口编程最常见的翻车点,后面避坑章节会细说。

2.5 数据收发与缓冲区管理

数据接收用事件驱动,发送用同步写。SimpleBTComms 的接收逻辑大致如下:

private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int bytesToRead = _serialPort.BytesToRead; byte[] buffer = new byte[bytesToRead]; int read = _serialPort.Read(buffer, 0, bytesToRead); // 触发自定义事件,把数据抛给上层 DataReceived?.Invoke(this, new BluetoothDataEventArgs(buffer, read)); }

这里有个细节:BytesToRead返回的是当前缓冲区里的字节数,但蓝牙传输有延迟,可能一次只收到半个包。如果你的协议有固定包头包尾,需要在应用层做粘包处理。SimpleBTComms 作为示例项目没有实现完整的协议解析,但留了DataReceived事件让使用者自己扩展。我一般会在这里加一个环形缓冲区,按协议长度字段截取完整帧再往上抛。

发送端就是简单的_serialPort.Write(data, 0, data.Length),但要注意写入前检查IsOpen状态,否则会抛InvalidOperationException。

3. 跑起来:从零编译到双向通信的实操步骤

3.1 环境准备与项目编译

SimpleBTComms 基于 .NET Framework 4.5 或更高版本,用 Visual Studio 打开.sln文件即可。如果你只有 .NET Core 环境,需要把项目文件改成 SDK 风格,并添加System.IO.PortsNuGet 包。WinForms 部分在 .NET Core 3.0 之后才支持,所以建议直接用 .NET Framework 4.7.2 或 .NET 6 的 Windows Desktop 运行时。

编译前检查两点:一是项目引用里有没有System.Management,WMI 查询需要它;二是目标平台是 x86 还是 x64,P/Invoke 调用的bthprops.cpl在 64 位系统上要用 64 位编译,否则会报BadImageFormatException。我一般把目标平台设成 x64,因为现在大多数开发机都是 64 位。

3.2 蓝牙设备配对与串口确认

代码跑起来之前,先在 Windows 设置里完成蓝牙配对。配对成功后,在设备管理器的“端口”节点下应该能看到两个新的 COM 口,一个标着“传入”,一个标着“传出”。SimpleBTComms 用的是传出端口,因为它是主动连接方。

如果你不确定哪个 COM 口对应你的设备,可以用项目里的设备枚举功能先列出已配对设备,再通过 WMI 查询映射关系。有些蓝牙模块配对后会生成多个 COM 口,选错了就打不开。我一般会先在串口调试助手里试一下,能收到数据的就是对的。

3.3 运行界面与操作流程

SimpleBTComms 的主界面通常包含:设备列表下拉框、刷新按钮、连接按钮、发送文本框、接收显示区。操作流程是:点“刷新”枚举已配对设备,从下拉框选一个,点“连接”,程序自动找到对应 COM 口并打开。连接成功后,在发送框输入内容,点“发送”,接收区会显示设备返回的数据。

如果连接失败,先看异常信息。UnauthorizedAccessException说明串口被占用,检查是不是有其他程序开着;IOException说明设备没连上或者驱动有问题,重新配对试试。接收区没数据但串口已打开,检查波特率是否匹配,有些模块默认 115200,不是 9600。

3.4 用虚拟串口做无硬件测试

手头没有蓝牙模块怎么办?可以用虚拟串口软件创建一对互联的 COM 口,比如 COM10 和 COM11。SimpleBTComms 连 COM10,串口调试助手连 COM11,两边就能互相收发数据。这样可以在没有硬件的情况下验证代码逻辑,特别是粘包处理和超时重连。

虚拟串口测试时要注意,DataReceived事件的触发时机和真实蓝牙设备略有不同,虚拟串口几乎是即时的,真实蓝牙可能有几十毫秒延迟。所以虚拟串口跑通了不代表真机没问题,最终还是要上硬件验证。

4. 避坑与排查:蓝牙串口通信的五个血泪教训

4.1 串口被占用导致打开失败

现象:调用Open()时抛UnauthorizedAccessException,提示“拒绝访问”。

原因:Windows 的串口是独占资源,同一个 COM 口只能被一个进程打开。常见情况是上一次调试的程序没退干净,或者串口调试助手还开着。

解决:在打开前先尝试关闭已存在的SerialPort实例,或者用Handle检查端口占用。更稳妥的做法是在程序退出时确保SerialPort.Close()被调用,我一般会把它放在FormClosing事件里,并且加try-catch防止关闭时抛异常。

4.2 跨线程更新 UI 导致程序崩溃

现象:收到数据后更新文本框,程序抛出InvalidOperationException,提示“线程间操作无效”。

原因:DataReceived事件在后台线程触发,WinForms 控件只能在 UI 线程访问。

解决:用Invoke或BeginInvoke切回 UI 线程:

private void OnDataReceived(object sender, BluetoothDataEventArgs e) { if (txtReceive.InvokeRequired) { txtReceive.BeginInvoke(new Action(() => { txtReceive.AppendText(Encoding.UTF8.GetString(e.Data) + "\r\n"); })); } else { txtReceive.AppendText(Encoding.UTF8.GetString(e.Data) + "\r\n"); } }

BeginInvoke是异步的,不会阻塞后台线程;Invoke是同步的,会等 UI 线程处理完。高频数据场景下用BeginInvoke,避免后台线程被拖死。

4.3 蓝牙设备掉线后串口未释放

现象:设备走远后自动断开,程序再次连接时提示串口不存在或打开失败。

原因:蓝牙 SPP 设备掉线后,Windows 不会立即删除虚拟串口,但SerialPort对象可能已经处于异常状态。如果不手动关闭,下次打开会失败。

解决:监听SerialPort.ErrorReceived事件,检测到SerialError.Frame或SerialError.Overrun时主动关闭串口并触发重连逻辑。另外,在Open()之前先判断_serialPort != null && _serialPort.IsOpen,如果是就Close()再重新创建实例。

4.4 波特率不匹配导致乱码

现象:串口能打开,但收到的数据全是乱码。

原因:波特率、数据位、停止位、校验位中有一项不匹配。蓝牙 SPP 模块的默认参数因厂商而异,有的 9600,有的 115200。

解决:查模块手册确认默认参数,或者在代码里做成可配置的下拉框。如果不知道参数,可以逐个尝试常见组合:9600/8/N/1、115200/8/N/1、38400/8/N/1。我一般会在界面上放一个波特率选择框,方便现场调试。

4.5 大数据量下的缓冲区溢出

现象:连续发送大量数据时,接收端丢包或程序卡死。

原因:SerialPort的接收缓冲区默认是 4096 字节,如果发送速度超过处理速度,缓冲区会溢出。另外,在DataReceived事件里做耗时操作会阻塞后续事件。

解决:增大ReadBufferSize,比如设成 8192 或 16384。在DataReceived里只做数据读取,把解析和处理放到独立线程或线程池里。如果数据量特别大,考虑用BaseStream.BeginRead做异步读取,避免事件驱动模式的性能瓶颈。

5. 二次开发:从示例到生产级蓝牙通信模块

5.1 把同步串口改成异步模型

SimpleBTComms 用的是同步Read和事件驱动,在低频场景够用,但高频采集时容易丢包。改造方向是用SerialPort.BaseStream的异步方法:

private async Task ReadLoopAsync(CancellationToken token) { byte[] buffer = new byte[4096]; while (!token.IsCancellationRequested && _serialPort.IsOpen) { try { int read = await _serialPort.BaseStream.ReadAsync(buffer, 0, buffer.Length, token); if (read > 0) { var data = new byte[read]; Buffer.BlockCopy(buffer, 0, data, 0, read); DataReceived?.Invoke(this, new BluetoothDataEventArgs(data, read)); } } catch (OperationCanceledException) { break; } catch (IOException) { /* 设备掉线,触发重连 */ } } }

异步模型的好处是不依赖DataReceived事件,读取节奏完全由自己控制,配合CancellationToken可以优雅退出。改造时注意ReadAsync在串口关闭时会抛IOException,需要捕获并处理。

5.2 增加协议解析层

示例项目只负责收发原始字节,实际项目需要在上面加协议解析。我一般会定义一个IProtocolParser接口,把粘包处理、校验、序列化都放在实现类里:

public interface IProtocolParser { IEnumerable<byte[]> Parse(byte[] rawData); // 输入原始字节,输出完整帧 byte[] Build(byte[] payload); // 把负载打包成帧 }

然后在SerialPortManager里注入解析器,收到数据先喂给解析器,解析出完整帧再往上抛。这样换协议只需要换解析器实现,串口层不用动。

5.3 自动重连与心跳机制

生产环境里蓝牙掉线是常态,需要自动重连。我的做法是维护一个状态机:Disconnected -> Connecting -> Connected -> Disconnected。在Connected状态下启动一个心跳定时器,每隔几秒发一个心跳包,如果连续三次没收到回应就主动断开并触发重连。

重连时要注意退避策略,不要立即重试,否则会在设备不在范围内时疯狂占用 CPU。我一般用指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多等 30 秒。重连成功后重置退避计数。

5.4 日志与诊断

蓝牙通信出问题时,没有日志基本没法排查。我习惯在SerialPortManager里加一个ILogger接口,把打开、关闭、发送、接收、异常都记下来。日志里要包含时间戳、方向(TX/RX)、字节数和十六进制内容。这样现场出问题时,让用户把日志发回来,一眼就能看出是没发出去还是没收到。

如果项目允许,可以加一个“诊断模式”,在界面上实时显示串口状态、缓冲区字节数、最后一次收发时间。这些信息对定位问题非常有用。

5.5 一个具体的改造验证方法

改造完成后怎么验证?我的习惯是写一个回环测试:用虚拟串口把 COM10 和 COM11 连起来,程序连 COM10,测试脚本连 COM11。脚本每隔 100 毫秒发一包随机数据,程序收到后原样返回,脚本校验返回的数据是否和发送的一致。连续跑 10 万包,统计丢包率和延迟分布。

这个测试能暴露大部分缓冲区管理和线程同步问题。我一般会把丢包率控制在 0.01% 以下才算通过。从那以后我每次改串口代码,都强制走一遍这个回环测试,不管改动多小。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询