📚 专题概述
本文档专注于深入学习串口通信技术,这是工业自动化领域最基础、最重要的通信方式之一。学完本专题,你将能够独立开发串口通信应用程序。
学习目标:
-
✅ 理解串口通信的基本原理
-
✅ 熟练使用 SerialPort 类
-
✅ 掌握串口参数配置
-
✅ 能够发送和接收数据
-
✅ 处理异步事件
-
✅ 开发完整的串口调试工具
🎯 第一部分:串口通信基础理论
1.1 什么是串口通信?
定义: 串口通信(Serial Communication)是一种按位(bit)顺序传输数据的通信方式。数据一个接一个地通过单根线缆传输。
与并口通信的区别:
| 特性 | 串口通信 | 并口通信 |
|---|---|---|
| 传输方式 | 按位顺序传输 | 同时传输多个位 |
| 线缆数量 | 少(2-3根) | 多(8根或更多) |
| 传输距离 | 远(可达1200米) | 短(通常几米) |
| 成本 | 低 | 高 |
| 速度 | 较慢 | 较快 |
| 抗干扰 | 强 | 弱 |
为什么串口在工业中如此流行?
成本低廉
传输距离远
抗干扰能力强
协议简单,易于实现
几乎所有工业设备都支持
1.2 串口的物理接口类型
1. RS-232(最常见)
标准电压:+12V ~ -12V
传输距离:最大 15 米
常见于:PC 串口、调试接口
特点:简单、成本最低
2. RS-485(工业常用)
差分信号传输
传输距离:最大 1200 米
支持多点通信(最多 128 个设备)
常见于:工业现场、PLC 网络
特点:抗干扰强、距离远、支持多设备
3. RS-422
类似 RS-485,但不支持多点通信
传输距离:最大 1200 米
全双工通信
4. TTL 电平(单片机常用)
电压:0V ~ 5V 或 0V ~ 3.3V
传输距离:很短(几厘米到几米)
常见于:Arduino、STM32 等单片机
注意: 需要转换芯片才能连接到电脑
💡 实用提示:
开发时常用 USB 转串口模块(CP2102、CH340 等)
价格便宜(10-30 元)
使用方便,即插即用
Windows 会自动识别为 COM 口
1.3 串口通信的核心参数
这些参数必须在通信双方(你的程序和设备)完全一致,否则通信失败!
1. 波特率(Baud Rate)
定义: 数据传输速度,单位是 bits per second (bps)
常用值:
9600(最常见,默认值)
19200
38400
57600
115200(高速通信常用)
💡 重要提示:
-
波特率是最常见的配置错误来源
-
如果通信失败,首先检查波特率是否一致
-
波特率越高,距离越短,抗干扰能力越弱
2. 数据位(Data Bits)
定义: 每个数据包包含的有效数据位数
可选值: 5、6、7、8
最常用: 8 位
说明:
7 位:早期 ASCII 通信(只传输 128 个字符)
8 位:现代通信(可以传输所有 ASCII 字符和扩展字符)
3. 停止位(Stop Bits)
定义: 数据包结束的标志位,用于同步
可选值:
1 位(最常用)
1.5 位(较少用)
2 位(较慢但更可靠)
说明:
停止位越长,通信越可靠,但速度越慢
大多数设备使用 1 位停止位
4. 校验位(Parity)
定义: 错误检测机制,用于检测传输过程中的数据错误
可选值:
None(无校验) - 最常用,传输效率最高
Odd(奇校验) - 数据位中 1 的个数为奇数
Even(偶校验) - 数据位中 1 的个数为偶数
Mark(标记校验) - 校验位始终为 1
Space(空格校验) - 校验位始终为 0
💡 实用建议:
-
现代设备大多使用无校验(None)
-
如果环境干扰严重,可以使用奇校验或偶校验
-
校验会增加额外的开销,降低传输效率
1.4 串口通信的握手协议
握手协议用于控制数据流,防止数据丢失。
1. 无握手(None)
最简单的模式
不进行流量控制
适合短距离、低速通信
最常用
2. 硬件握手(RTS/CTS)
使用硬件信号线控制数据流
RTS(Request to Send):请求发送
CTS(Clear to Send):允许发送
适合高速、大数据量通信
3. 软件握手(XON/XOFF)
使用特殊字符控制数据流
XON(0x11):继续发送
XOFF(0x13):暂停发送
会占用数据位,影响传输效率
💡 建议:
-
初学者使用无握手(None)
-
只有在数据丢失时才考虑使用握手
1.5 串口通信的数据格式
一个完整的串口数据帧:
[起始位] [数据位] [校验位] [停止位]
1位 5-8位 0或1位 1-2位
示例:8N1(8数据位,无校验,1停止位)
起始位 | D0 | D1 | D2 | D3 | D4 | D5 | D6 | D7 | 停止位
1 | 0 | 1 | 0 | 1 | 1 | 0 | 0 | 1 | 1
总位数 = 1(起始) + 8(数据) + 0(校验) + 1(停止) = 10 位
传输时间计算:
-
波特率 = 9600 bps
-
传输 1 字节需要 10 位 = 10/9600 ≈ 1.04 毫秒
🎯 第二部分:SerialPort 类详解
SerialPort 是 .NET Framework 和 .NET Core/.NET 中提供的一个类,用于简化串口通信编程。
📖 基本概念
SerialPort 类位于 System.IO.Ports 命名空间中,是 .NET 提供的用于串口通信的标准 API。
完整命名空间:
using System.IO.Ports;
🎯 它的作用
SerialPort 类封装了串口通信的底层操作,让开发者不需要直接操作 Windows API 或硬件驱动,就能轻松实现串口通信。
它能帮你做什么:
-
✅ 打开/关闭串口
-
✅ 配置串口参数(波特率、数据位等)
-
✅ 发送数据(字符串或字节数组)
-
✅ 接收数据(同步或异步)
-
✅ 监听数据到达事件
-
✅ 控制硬件信号(RTS、DTR)
💡 简单类比
想象你在用电话沟通:
|--------|---------------------------------------------|
| 真实电话 | SerialPort 类 |
| 拿起电话听筒 | serialPort.Open() |
| 放下电话 | serialPort.Close() |
| 对着话筒说话 | serialPort.Write() 或 serialPort.WriteLine() |
| 听对方说话 | serialPort.Read() 或 DataReceived 事件 |
| 确认对方号码 | serialPort.PortName = "COM1" |
| 调整音量 | serialPort.BaudRate = 9600 |
SerialPort 类就像是电话的遥控器,你不需要知道电话内部怎么工作,只需要按几个按钮就能打电话。
🌟 为什么使用 SerialPort 类?
不使用 SerialPort 类时:
-
需要调用 Windows API(CreateFile、ReadFile、WriteFile 等)
-
需要处理复杂的硬件驱动
-
需要手动管理内存和缓冲区
-
代码量大,容易出错
使用 SerialPort 类时:
-
代码简洁,只需几行就能实现通信
-
自动管理缓冲区
-
提供事件机制,方便异步处理
-
.NET 官方支持,稳定可靠
2.1 创建和配置 SerialPort
方法1:使用构造函数(推荐)
cs
using System.IO.Ports;
// 创建 SerialPort 对象并立即配置
SerialPort serialPort = new SerialPort
{
PortName = "COM1", // 串口号
BaudRate = 9600, // 波特率
DataBits = 8, // 数据位
Parity = Parity.None, // 校验位
StopBits = StopBits.One, // 停止位
Handshake = Handshake.None, // 握手协议
ReadTimeout = 1000, // 读取超时(毫秒)
WriteTimeout = 1000, // 写入超时(毫秒)
ReadBufferSize = 4096, // 接收缓冲区大小(字节)
WriteBufferSize = 2048 // 发送缓冲区大小(字节)
};
// 打开串口
try
{
serialPort.Open();
Console.WriteLine("串口打开成功");
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("串口被占用,请关闭其他程序");
}
catch (IOException ex)
{
Console.WriteLine($"串口不存在或配置错误: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"打开串口失败: {ex.Message}");
}
方法2:使用默认值,然后逐步设置
cs
SerialPort serialPort = new SerialPort();
// 逐步设置参数
serialPort.PortName = "COM1";
serialPort.BaudRate = 9600;
serialPort.DataBits = 8;
serialPort.Parity = Parity.None;
serialPort.StopBits = StopBits.One;
// 打开串口
serialPort.Open();
2.2 重要属性详解
| 属性 | 类型 | 说明 | 默认值 |
|---|---|---|---|
| PortName | string | 串口号,如 "COM1" | "COM1" |
| BaudRate | int | 波特率 | 9600 |
| DataBits | int | 数据位(5-8) | 8 |
| Parity | Parity | 校验位 | None |
| StopBits | StopBits | 停止位 | One |
| Handshake | Handshake | 握手协议 | None |
| ReadTimeout | int | 读取超时(毫秒) | -1(无限) |
| WriteTimeout | int | 写入超时(毫秒) | -1(无限) |
| ReadBufferSize | int | 接收缓冲区大小 | 4096 |
| WriteBufferSize | int | 发送缓冲区大小 | 2048 |
| ReceivedBytesThreshold | int | 触发 DataReceived 事件的字节数 | 1 |
| RtsEnable | bool | 启用 RTS 信号 | false |
| DtrEnable | bool | 启用 DTR 信号 | false |
| IsOpen | bool | 串口是否已打开(只读) | false |
| BytesToRead | int | 接收缓冲区中可读取的字节数(只读) | 0 |
| BytesToWrite | int | 发送缓冲区中待发送的字节数(只读) | 0 |
2.3 获取可用串口
cs
// 获取系统中所有可用的串口
string[] ports = SerialPort.GetPortNames();
if (ports.Length == 0)
{
Console.WriteLine("没有检测到可用的串口");
}
else
{
Console.WriteLine($"检测到 {ports.Length} 个可用串口:");
foreach (string port in ports)
{
Console.WriteLine($"- {port}");
}
}
不过win基本上是不会自带串口的,我们需要一个串口虚拟器来模拟串口进行实现
🎯 第三部分:发送数据
3.1 发送字符串
cs
// 方法1:发送字符串(自动添加换行符)
serialPort.WriteLine("Hello Device");
// 方法2:发送字符串(不自动添加换行符)
serialPort.Write("Hello Device");
💡 区别:
-
WriteLine()会自动添加\r\n(回车换行) -
Write()只发送指定的字符串
3.2 发送字节数组(最常用)
cs
// 发送十六进制数据
byte[] data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A };
serialPort.Write(data, 0, data.Length);
// 参数说明:
// data: 要发送的字节数组
// 0: 起始索引
// data.Length: 要发送的字节数
3.3 发送单个字节
cs
// 发送一个字节
serialPort.WriteByte(0x55);
3.4 实用:十六进制字符串转字节数组
工业设备通常使用十六进制指令,需要转换:
cs
// 将十六进制字符串转为字节数组
string hexString = "01 03 00 00 00 01 84 0A";
byte[] hexData = HexStringToByteArray(hexString);
// 发送
serialPort.Write(hexData, 0, hexData.Length);
// 辅助方法
private byte[] HexStringToByteArray(string hex)
{
// 移除所有空格
hex = hex.Replace(" ", "").Replace("-", "").Trim();
// 检查长度是否为偶数
if (hex.Length % 2 != 0)
{
throw new ArgumentException("十六进制字符串长度必须为偶数");
}
int numberChars = hex.Length;
byte[] bytes = new byte[numberChars / 2];
for (int i = 0; i < numberChars; i += 2)
{
string hexByte = hex.Substring(i, 2);
bytes[i / 2] = Convert.ToByte(hexByte, 16);
}
return bytes;
}
// 反向转换:字节数组转十六进制字符串
private string ByteArrayToHexString(byte[] bytes)
{
return BitConverter.ToString(bytes).Replace("-", " ");
}
3.5 发送数据的最佳实践
cs
// 1. 发送前检查串口是否打开
if (!serialPort.IsOpen)
{
Console.WriteLine("串口未打开,请先打开串口");
return;
}
// 2. 设置合理的超时时间
serialPort.WriteTimeout = 1000; // 1秒超时
// 3. 使用 try-catch 处理异常
try
{
byte[] data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
serialPort.Write(data, 0, data.Length);
Console.WriteLine($"已发送 {data.Length} 字节数据");
}
catch (TimeoutException)
{
Console.WriteLine("发送超时");
}
catch (InvalidOperationException)
{
Console.WriteLine("串口未打开");
}
catch (Exception ex)
{
Console.WriteLine($"发送失败: {ex.Message}");
}
// 4. 检查发送缓冲区
if (serialPort.BytesToWrite > 0)
{
Console.WriteLine($"发送缓冲区还有 {serialPort.BytesToWrite} 字节未发送");
}
🎯 第四部分:接收数据
4.1 同步读取(适合简单场景)
读取指定数量的字节
cs
// 读取 10 个字节
byte[] buffer = new byte[10];
int bytesRead = serialPort.Read(buffer, 0, buffer.Length);
Console.WriteLine($"实际读取了 {bytesRead} 个字节");
Console.WriteLine($"数据: {BitConverter.ToString(buffer)}");
读取一行(以换行符结束)
cs
// 读取一行(直到收到 \r\n)
string line = serialPort.ReadLine();
Console.WriteLine($"收到: {line}");
读取单个字节
cs
// 读取一个字节
int oneByte = serialPort.ReadByte();
Console.WriteLine($"收到字节: 0x{oneByte:X2}");
读取所有可用数据
cs
// 读取当前缓冲区中的所有数据
int bytesToRead = serialPort.BytesToRead;
byte[] buffer = new byte[bytesToRead];
int bytesRead = serialPort.Read(buffer, 0, bytesToRead);
Console.WriteLine($"收到 {bytesRead} 字节");
⚠️ 同步读取的缺点:
-
会阻塞当前线程
-
可能导致程序界面卡死
-
不适合持续接收数据
4.2 异步事件处理(推荐)
这是最常用的接收方式,适合持续接收数据。
cs
// 1. 设置接收阈值(收到多少字节触发事件)
serialPort.ReceivedBytesThreshold = 1; // 收到1个字节就触发
// 2. 订阅数据接收事件
serialPort.DataReceived += SerialPort_DataReceived;
// 3. 打开串口
serialPort.Open();
// 事件处理方法
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
try
{
// 读取当前可用的所有数据
int bytesToRead = sp.BytesToRead;
byte[] buffer = new byte[bytesToRead];
sp.Read(buffer, 0, bytesToRead);
// 显示接收到的数据
string hexString = BitConverter.ToString(buffer).Replace("-", " ");
Console.WriteLine($"收到 {bytesToRead} 字节: {hexString}");
// 如果需要更新 UI,使用 Invoke
// this.Invoke(new Action(() => {
// textBoxReceive.AppendText(hexString + Environment.NewLine);
// }));
}
catch (Exception ex)
{
Console.WriteLine($"接收数据出错: {ex.Message}");
}
}
这段代码的 "异步"并非指使用了async/await语法 ,也不是方法本身是异步方法,而是 SerialPort 的DataReceived事件采用了 **「事件驱动的异步编程模型(APM)」** ------数据接收的检测、事件触发,与主线程完全分离并并行执行,主线程不会被阻塞,这是串口通信中 "异步" 的核心定义,也是工业开发中推荐的异步方式。
简单来说:你的主线程只管打开串口、做自己的事(如 UI 交互、其他业务),不用一直盯着串口有没有数据;串口底层会在独立线程中检测数据,收到数据后自动 "通知" 并执行事件处理方法,这个 "通知 - 执行" 的过程与主线程并行,就是异步。
SerialPort 的DataReceived事件是.NET 基于Windows 串口驱动 和CLR 线程池实现的异步机制,底层执行流程是关键:
- 当你调用
serialPort.Open()打开串口后,.NET 会从系统线程池分配一个「独立的后台检测线程」,专门负责监控串口硬件的接收缓冲区; - 这个后台线程会持续检测缓冲区,当收到的字节数达到你设置的
ReceivedBytesThreshold = 1阈值时,不会阻塞主线程 ,而是直接触发DataReceived事件; - 事件触发后,.NET 又会从线程池分配另一个(或复用)空闲线程 ,执行你写的
SerialPort_DataReceived事件处理方法,完成数据读取和解析; - 整个 **"检测数据 - 触发事件 - 执行处理"的过程,都在非主线程 ** 中完成,与你的主线程(如 WinForm UI 线程、控制台主线程)完全并行,主线程的所有操作不会受到任何影响。
4.3 实用的数据接收模式
模式1:实时显示所有数据
cs
serialPort.DataReceived += (sender, e) =>
{
SerialPort sp = (SerialPort)sender;
int bytesToRead = sp.BytesToRead;
byte[] buffer = new byte[bytesToRead];
sp.Read(buffer, 0, bytesToRead);
// 实时显示
Console.WriteLine(BitConverter.ToString(buffer).Replace("-", " "));
};
模式2:按帧接收(固定长度)
cs
// 假设每帧数据固定为 8 字节
private const int FRAME_SIZE = 8;
private byte[] frameBuffer = new byte[FRAME_SIZE];
private int frameIndex = 0;
serialPort.DataReceived += (sender, e) =>
{
SerialPort sp = (SerialPort)sender;
while (sp.BytesToRead > 0)
{
// 读取一个字节
int b = sp.ReadByte();
// 存入缓冲区
frameBuffer[frameIndex] = (byte)b;
frameIndex++;
// 如果收到一帧完整的数据
if (frameIndex >= FRAME_SIZE)
{
// 处理这一帧数据
ProcessFrame(frameBuffer);
// 重置索引
frameIndex = 0;
}
}
};
private void ProcessFrame(byte[] frame)
{
Console.WriteLine($"收到完整帧: {BitConverter.ToString(frame)}");
// 处理帧数据...
}
模式3:按帧接收(带起始和结束符)
cs
// 假设帧格式:[起始符 0xAA] [数据] [结束符 0x55]
private const byte START_BYTE = 0xAA;
private const byte END_BYTE = 0x55;
private List<byte> frameBuffer = new List<byte>();
private bool inFrame = false;
serialPort.DataReceived += (sender, e) =>
{
SerialPort sp = (SerialPort)sender;
while (sp.BytesToRead > 0)
{
int b = sp.ReadByte();
if (!inFrame)
{
// 等待起始符
if (b == START_BYTE)
{
inFrame = true;
frameBuffer.Clear();
frameBuffer.Add((byte)b);
}
}
else
{
// 正在接收帧
frameBuffer.Add((byte)b);
// 检查是否收到结束符
if (b == END_BYTE)
{
// 处理这一帧
ProcessFrame(frameBuffer.ToArray());
// 重置状态
inFrame = false;
frameBuffer.Clear();
}
}
}
};
4.4 接收数据的注意事项
1. DataReceived 事件在后台线程运行
cs
// ❌ 错误:直接访问 UI 控件会抛出异常
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
byte[] buffer = new byte[sp.BytesToRead];
sp.Read(buffer, 0, buffer.Length);
// 这行代码会抛出异常!
textBoxReceive.Text = BitConverter.ToString(buffer);
}
// ✅ 正确:使用 Invoke 访问 UI
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
byte[] buffer = new byte[sp.BytesToRead];
sp.Read(buffer, 0, buffer.Length);
// 使用 Invoke 切换到 UI 线程
this.Invoke(new Action(() =>
{
textBoxReceive.AppendText(BitConverter.ToString(buffer) + Environment.NewLine);
}));
}
非 UI 线程(后台线程)直接操作 WinForm 的 UI 控件本身就是违规的,会触发跨线程异常,而Invoke的作用就是把 "更新 UI 的操作" 转交给唯一合法的 UI 主线程来执行,从根源上避免异常、保证操作安全。
2. DataReceived 事件可能不会触发
原因:
-
串口没有正确打开
-
ReceivedBytesThreshold 设置过高
-
数据量太少
解决:
cs
// 确保 ReceivedBytesThreshold 设置合理
serialPort.ReceivedBytesThreshold = 1; // 最小值
// 如果仍然不触发,可以手动轮询
private void TimerPolling_Tick(object sender, EventArgs e)
{
if (serialPort.BytesToRead > 0)
{
byte[] buffer = new byte[serialPort.BytesToRead];
serialPort.Read(buffer, 0, buffer.Length);
// 处理数据...
}
}
3. 事件处理方法不要执行耗时操作
cs
// ❌ 错误:在事件中执行耗时操作
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
byte[] buffer = new byte[sp.BytesToRead];
sp.Read(buffer, 0, buffer.Length);
// 这会阻塞接收线程!
Thread.Sleep(1000); // 不要这样做!
ProcessComplexData(buffer); // 不要执行耗时操作
}
// ✅ 正确:使用队列+后台线程处理
private ConcurrentQueue<byte[]> dataQueue = new ConcurrentQueue<byte[]>();
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort sp = (SerialPort)sender;
byte[] buffer = new byte[sp.BytesToRead];
sp.Read(buffer, 0, buffer.Length);
// 只是把数据放入队列,不处理
dataQueue.Enqueue(buffer);
}
// 后台线程处理队列
private void ProcessDataThread()
{
while (true)
{
if (dataQueue.TryDequeue(out byte[] data))
{
ProcessComplexData(data); // 在这里处理耗时操作
}
Thread.Sleep(10);
}
}
🎉 总结
串口通信是工业自动化的基础,掌握这项技能将为你后续学习运动控制卡、工业通信协议打下坚实基础。
关键要点:
-
参数配置必须一致(波特率、数据位、停止位、校验位)
-
使用 DataReceived 事件异步接收数据
-
注意跨线程访问 UI 的问题
-
使用十六进制格式显示数据更可靠