C#串口通信

📚 专题概述

本文档专注于深入学习串口通信技术,这是工业自动化领域最基础、最重要的通信方式之一。学完本专题,你将能够独立开发串口通信应用程序。

学习目标:

  • ✅ 理解串口通信的基本原理

  • ✅ 熟练使用 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 线程池实现的异步机制,底层执行流程是关键:

  1. 当你调用serialPort.Open()打开串口后,.NET 会从系统线程池分配一个「独立的后台检测线程」,专门负责监控串口硬件的接收缓冲区;
  2. 这个后台线程会持续检测缓冲区,当收到的字节数达到你设置的ReceivedBytesThreshold = 1阈值时,不会阻塞主线程 ,而是直接触发DataReceived事件;
  3. 事件触发后,.NET 又会从线程池分配另一个(或复用)空闲线程 ,执行你写的SerialPort_DataReceived事件处理方法,完成数据读取和解析;
  4. 整个 **"检测数据 - 触发事件 - 执行处理"的过程,都在非主线程 ** 中完成,与你的主线程(如 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);
    }
}

🎉 总结

串口通信是工业自动化的基础,掌握这项技能将为你后续学习运动控制卡、工业通信协议打下坚实基础。

关键要点:

  1. 参数配置必须一致(波特率、数据位、停止位、校验位)

  2. 使用 DataReceived 事件异步接收数据

  3. 注意跨线程访问 UI 的问题

  4. 使用十六进制格式显示数据更可靠

相关推荐
小小码农Come on2 小时前
QT内存管理
开发语言·qt
周杰伦fans2 小时前
CAD二次开发中的线程、异步操作与LockDocument
c#
Zach_yuan2 小时前
C++ Lambda 表达式从入门到进阶
开发语言·c++
weixin_445402302 小时前
模板元编程应用场景
开发语言·c++·算法
xyq20242 小时前
Julia 日期和时间处理指南
开发语言
s1hiyu2 小时前
嵌入式C++低功耗设计
开发语言·c++·算法
阿钱真强道2 小时前
11 JetLinks MQTT 直连设备功能调用完整流程与 Python 实现
服务器·开发语言·网络·python·物联网·网络协议
£漫步 云端彡2 小时前
Golang学习历程【第十二篇 错误处理(error)】
开发语言·学习·golang
Cinema KI3 小时前
C++11(中):可变参数模板将成为重中之重
开发语言·c++