C# 串口通信
大约 10 分钟约 3089 字
C# 串口通信
简介
串口通信(Serial Port Communication)是工业自动化中最基础的数据传输方式。C# 通过 System.IO.Ports.SerialPort 类提供串口操作支持,广泛用于与 PLC、传感器、Modbus 设备、条码扫描枪等硬件通信。
特点
基本用法
串口参数配置
/// <summary>
/// 串口基本操作
/// </summary>
public class SerialPortService : IDisposable
{
private SerialPort? _serialPort;
// 打开串口
public bool Open(string portName, int baudRate = 9600, Parity parity = Parity.None,
int dataBits = 8, StopBits stopBits = StopBits.One)
{
try
{
_serialPort = new SerialPort
{
PortName = portName, // COM1, COM2...
BaudRate = baudRate, // 波特率:9600, 19200, 115200
Parity = parity, // 校验位:None, Odd, Even
DataBits = dataBits, // 数据位:7, 8
StopBits = stopBits, // 停止位:One, Two
ReadTimeout = 1000, // 读超时
WriteTimeout = 1000, // 写超时
ReadBufferSize = 4096,
WriteBufferSize = 4096
};
_serialPort.DataReceived += OnDataReceived;
_serialPort.ErrorReceived += OnErrorReceived;
_serialPort.PinChanged += OnPinChanged;
_serialPort.Open();
return true;
}
catch (Exception ex)
{
Console.WriteLine($"串口打开失败:{ex.Message}");
return false;
}
}
// 关闭串口
public void Close()
{
if (_serialPort?.IsOpen == true)
{
_serialPort.Close();
}
}
// 发送数据
public void Send(byte[] data)
{
if (_serialPort?.IsOpen != true)
throw new InvalidOperationException("串口未打开");
_serialPort.Write(data, 0, data.Length);
}
// 发送字符串
public void Send(string text)
{
if (_serialPort?.IsOpen != true)
throw new InvalidOperationException("串口未打开");
_serialPort.WriteLine(text);
}
// 数据接收事件
public event Action<byte[]>? DataReceived;
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
if (_serialPort == null) return;
var bytesToRead = _serialPort.BytesToRead;
var buffer = new byte[bytesToRead];
_serialPort.Read(buffer, 0, bytesToRead);
DataReceived?.Invoke(buffer);
}
private void OnErrorReceived(object sender, SerialErrorReceivedEventArgs e)
{
Console.WriteLine($"串口错误:{e.EventType}");
}
private void OnPinChanged(object sender, SerialPinChangedEventArgs e)
{
Console.WriteLine($"串口引脚变化:{e.EventType}");
}
// 获取可用串口
public static string[] GetAvailablePorts()
{
return SerialPort.GetPortNames();
}
public void Dispose()
{
Close();
_serialPort?.Dispose();
}
}常用串口参数
| 参数 | 常见值 | 说明 |
|---|---|---|
| 波特率 | 9600, 19200, 115200 | 数据传输速率 |
| 数据位 | 7, 8 | 每帧数据位数 |
| 停止位 | 1, 2 | 帧结束标志 |
| 校验位 | None, Odd, Even | 错误检测 |
| 流控制 | None, RTS/CTS, XON/XOFF | 流量控制 |
协议解析
Modbus RTU 帧解析
/// <summary>
/// Modbus RTU 帧构建和解析
/// </summary>
public static class ModbusRtuFrame
{
// 构建 Modbus RTU 读保持寄存器帧
public static byte[] BuildReadHoldingRegisters(byte slaveId, ushort startAddress, ushort quantity)
{
var frame = new List<byte> { slaveId, 0x03 }; // 功能码 03
frame.AddRange(BitConverter.GetBytes(startAddress).Reverse()); // 起始地址(大端)
frame.AddRange(BitConverter.GetBytes(quantity).Reverse()); // 数量(大端)
// CRC16 校验
var crc = CalculateCRC16(frame.ToArray());
frame.AddRange(BitConverter.GetBytes(crc)); // CRC(小端)
return frame.ToArray();
}
// 解析 Modbus RTU 响应
public static ushort[] ParseReadResponse(byte[] response)
{
// 最小长度:从站ID(1) + 功能码(1) + 字节数(1) + CRC(2) = 5
if (response.Length < 5)
throw new ArgumentException("响应帧长度不足");
var slaveId = response[0];
var functionCode = response[1];
if (functionCode == 0x83) // 异常响应
{
var errorCode = response[2];
throw new InvalidOperationException($"Modbus异常:错误码 {errorCode:X2}");
}
var byteCount = response[2];
var registerValues = new ushort[byteCount / 2];
for (int i = 0; i < registerValues.Length; i++)
{
registerValues[i] = BitConverter.ToUInt16(response, 3 + i * 2);
// 大端转小端
registerValues[i] = (ushort)((registerValues[i] >> 8) | (registerValues[i] << 8));
}
return registerValues;
}
// CRC16 校验
public static ushort CalculateCRC16(byte[] data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (int i = 0; i < 8; i++)
{
if ((crc & 0x0001) != 0)
{
crc >>= 1;
crc ^= 0xA001;
}
else
{
crc >>= 1;
}
}
}
return crc;
}
}自定义协议解析
/// <summary>
/// 自定义串口协议解析器
/// 协议格式:STX(1) + LEN(1) + CMD(1) + DATA(N) + CRC(1) + ETX(1)
/// </summary>
public class ProtocolParser
{
private readonly List<byte> _buffer = new();
private const byte STX = 0x02; // 帧头
private const byte ETX = 0x03; // 帧尾
public event Action<byte, byte[]>? FrameReceived; // cmd, data
// 输入原始数据
public void Feed(byte[] rawData)
{
_buffer.AddRange(rawData);
Parse();
}
private void Parse()
{
while (_buffer.Count > 0)
{
// 查找帧头
if (_buffer[0] != STX)
{
_buffer.RemoveAt(0);
continue;
}
// 检查是否收齐一帧
if (_buffer.Count < 4) return; // 最短帧:STX + LEN + CMD + ETX
var length = _buffer[1];
var totalLength = 2 + length + 2; // STX + LEN + DATA + CRC + ETX
if (_buffer.Count < totalLength) return; // 数据未收齐
// 提取一帧
var frame = _buffer.Take(totalLength).ToArray();
_buffer.RemoveRange(0, totalLength);
// 验证帧尾
if (frame[^1] != ETX) continue;
// 验证 CRC
var crc = frame[totalLength - 2];
var calculatedCrc = CalculateChecksum(frame.Skip(2).Take(length).ToArray());
if (crc != calculatedCrc) continue;
// 解析命令和数据
var cmd = frame[2];
var data = frame.Skip(3).Take(length - 1).ToArray();
FrameReceived?.Invoke(cmd, data);
}
}
private static byte CalculateChecksum(byte[] data)
{
byte sum = 0;
foreach (var b in data) sum += b;
return (byte)(~sum + 1);
}
}WPF 集成
MVVM 串口管理
/// <summary>
/// 串口通信 ViewModel
/// </summary>
public class SerialPortViewModel : ObservableObject, IDisposable
{
private SerialPort? _serialPort;
private readonly ProtocolParser _parser = new();
private string[] _availablePorts = Array.Empty<string>();
public string[] AvailablePorts
{
get => _availablePorts;
set => SetProperty(ref _availablePorts, value);
}
private string _selectedPort = "COM1";
public string SelectedPort
{
get => _selectedPort;
set => SetProperty(ref _selectedPort, value);
}
private bool _isConnected;
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
private string _receivedData = "";
public string ReceivedData
{
get => _receivedData;
set => SetProperty(ref _receivedData, value);
}
public ICommand RefreshPortsCommand { get; }
public ICommand ConnectCommand { get; }
public ICommand DisconnectCommand { get; }
public ICommand SendCommand { get; }
public SerialPortViewModel()
{
RefreshPortsCommand = new RelayCommand(RefreshPorts);
ConnectCommand = new RelayCommand(Connect);
DisconnectCommand = new RelayCommand(Disconnect);
SendCommand = new RelayCommand<string>(Send);
_parser.FrameReceived += OnFrameReceived;
RefreshPorts();
}
private void RefreshPorts()
{
AvailablePorts = SerialPort.GetPortNames();
if (AvailablePorts.Length > 0 && !AvailablePorts.Contains(SelectedPort))
SelectedPort = AvailablePorts[0];
}
private void Connect()
{
try
{
_serialPort = new SerialPort(SelectedPort, 9600, Parity.None, 8, StopBits.One);
_serialPort.DataReceived += OnSerialDataReceived;
_serialPort.Open();
IsConnected = true;
}
catch (Exception ex)
{
MessageBox.Show($"连接失败:{ex.Message}");
}
}
private void Disconnect()
{
if (_serialPort?.IsOpen == true)
{
_serialPort.Close();
}
IsConnected = false;
}
private void OnSerialDataReceived(object sender, SerialDataReceivedEventArgs e)
{
var bytesToRead = _serialPort!.BytesToRead;
var data = new byte[bytesToRead];
_serialPort.Read(data, 0, bytesToRead);
// 送到协议解析器
_parser.Feed(data);
// 更新显示(UI 线程)
Application.Current.Dispatcher.BeginInvoke(() =>
{
ReceivedData += BitConverter.ToString(data) + " ";
});
}
private void OnFrameReceived(byte cmd, byte[] data)
{
Application.Current.Dispatcher.BeginInvoke(() =>
{
ReceivedData += $"\n[CMD:{cmd:X2}] {BitConverter.ToString(data)}";
});
}
private void Send(string? text)
{
if (_serialPort?.IsOpen != true || string.IsNullOrEmpty(text)) return;
var data = HexStringToBytes(text);
_serialPort.Write(data, 0, data.Length);
}
private static byte[] HexStringToBytes(string hex)
{
hex = hex.Replace(" ", "").Replace("-", "");
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return bytes;
}
public void Dispose()
{
Disconnect();
_serialPort?.Dispose();
}
}常见问题与解决
| 问题 | 原因 | 解决 |
|---|---|---|
| 串口被占用 | 其他程序已打开 | 检查占用进程 |
| 数据丢失 | 读取不及时 | 增大缓冲区,及时读取 |
| 乱码 | 波特率/编码不匹配 | 检查通信参数 |
| 帧不完整 | 接收回调时数据未到齐 | 使用缓冲区拼接 |
| 超时 | 设备未响应 | 设置合理超时,重试 |
优点
缺点
总结
串口通信是工控开发的基本功。掌握 SerialPort 配置、异步接收、协议解析,是上位机开发的基础。核心原则:收发分离、缓冲拼接、超时重试、CRC 校验。推荐封装成服务类,方便在 MVVM 中使用。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
- 设备接入类主题通常同时涉及协议、线程、实时刷新和异常恢复。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
- 明确采集周期、重连策略、数据缓存和状态同步方式。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
- 只验证通信成功,不验证断线、抖动和异常包。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
- 继续补齐设备模拟、离线回放、现场诊断和配置中心能力。
适用场景
- 当你准备把《C# 串口通信》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《C# 串口通信》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《C# 串口通信》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《C# 串口通信》最大的收益和代价分别是什么?
串口通信进阶
异步串口通信
/// <summary>
/// 异步串口通信服务 — 避免阻塞 UI 线程
/// </summary>
public class AsyncSerialPortService : IDisposable
{
private SerialPort? _serialPort;
private readonly CancellationTokenSource _cts = new();
private readonly ConcurrentQueue<byte[]> _sendQueue = new();
private readonly ProtocolParser _parser = new();
public event Action<byte, byte[]>? FrameReceived;
public event Action<string>? ErrorOccurred;
public event Action? ConnectionChanged;
public bool IsConnected => _serialPort?.IsOpen == true;
public async Task<bool> OpenAsync(string portName, int baudRate = 9600, CancellationToken ct = default)
{
try
{
_serialPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
{
ReadTimeout = 500,
WriteTimeout = 500,
ReadBufferSize = 8192,
WriteBufferSize = 4096
};
_serialPort.Open();
// 启动异步读取循环
_ = ReadLoopAsync(_cts.Token);
// 启动异步发送循环
_ = SendLoopAsync(_cts.Token);
ConnectionChanged?.Invoke();
return true;
}
catch (Exception ex)
{
ErrorOccurred?.Invoke($"串口打开失败: {ex.Message}");
return false;
}
}
private async Task ReadLoopAsync(CancellationToken ct)
{
var buffer = new byte[4096];
while (!ct.IsCancellationRequested && _serialPort?.IsOpen == true)
{
try
{
var bytesRead = await Task.Run(() =>
{
try { return _serialPort.Read(buffer, 0, buffer.Length); }
catch (TimeoutException) { return 0; }
}, ct);
if (bytesRead > 0)
{
var data = new byte[bytesRead];
Array.Copy(buffer, data, bytesRead);
_parser.Feed(data);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
ErrorOccurred?.Invoke($"读取异常: {ex.Message}");
await Task.Delay(100, ct);
}
}
}
private async Task SendLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _serialPort?.IsOpen == true)
{
while (_sendQueue.TryDequeue(out var data))
{
try
{
await Task.Run(() => _serialPort.Write(data, 0, data.Length), ct);
}
catch (Exception ex)
{
ErrorOccurred?.Invoke($"发送异常: {ex.Message}");
}
}
await Task.Delay(10, ct);
}
}
public void EnqueueSend(byte[] data) => _sendQueue.Enqueue(data);
public void Close()
{
_cts.Cancel();
_serialPort?.Close();
ConnectionChanged?.Invoke();
}
public void Dispose()
{
Close();
_cts.Dispose();
_serialPort?.Dispose();
}
}自动重连机制
/// <summary>
/// 串口自动重连服务 — 设备热插拔场景
/// </summary>
public class AutoReconnectSerialService : IDisposable
{
private SerialPort? _serialPort;
private readonly string _portName;
private readonly int _baudRate;
private readonly TimeSpan _reconnectInterval = TimeSpan.FromSeconds(3);
private CancellationTokenSource? _reconnectCts;
private bool _intentionalClose;
public bool IsConnected => _serialPort?.IsOpen == true;
public event Action<byte[]>? DataReceived;
public event Action<bool>? ConnectionStateChanged;
public AutoReconnectSerialService(string portName, int baudRate = 9600)
{
_portName = portName;
_baudRate = baudRate;
}
public async Task StartAsync(CancellationToken ct = default)
{
_intentionalClose = false;
_reconnectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
while (!_reconnectCts.Token.IsCancellationRequested)
{
if (TryConnect())
{
ConnectionStateChanged?.Invoke(true);
await WaitForDisconnectionAsync(_reconnectCts.Token);
ConnectionStateChanged?.Invoke(false);
}
if (_intentionalClose) break;
// 等待重连
try { await Task.Delay(_reconnectInterval, _reconnectCts.Token); }
catch (OperationCanceledException) { break; }
}
}
private bool TryConnect()
{
try
{
// 检查串口是否存在
if (!SerialPort.GetPortNames().Contains(_portName))
return false;
_serialPort = new SerialPort(_portName, _baudRate, Parity.None, 8, StopBits.One)
{
ReadTimeout = 1000,
WriteTimeout = 1000
};
_serialPort.DataReceived += OnDataReceived;
_serialPort.ErrorReceived += OnErrorReceived;
_serialPort.Open();
return true;
}
catch { return false; }
}
private async Task WaitForDisconnectionAsync(CancellationToken ct)
{
while (_serialPort?.IsOpen == true && !ct.IsCancellationRequested)
{
await Task.Delay(500, ct);
try { await Task.Run(() => _serialPort?.Read(new byte[1], 0, 0)); }
catch (InvalidOperationException) { break; }
catch (TimeoutException) { /* 正常超时 */ }
catch { break; }
}
}
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
if (_serialPort?.IsOpen != true) return;
var bytesToRead = _serialPort.BytesToRead;
var data = new byte[bytesToRead];
_serialPort.Read(data, 0, bytesToRead);
DataReceived?.Invoke(data);
}
private void OnErrorReceived(object sender, SerialErrorReceivedEventArgs e)
{
// 串口错误可能是断连的前兆
}
public void Stop()
{
_intentionalClose = true;
_reconnectCts?.Cancel();
_serialPort?.Close();
ConnectionStateChanged?.Invoke(false);
}
public void Dispose()
{
Stop();
_reconnectCts?.Dispose();
_serialPort?.Dispose();
}
}数据日志记录与回放
/// <summary>
/// 串口数据日志记录与回放 — 用于调试和仿真
/// </summary>
public class SerialDataLogger
{
private readonly string _logDirectory;
private StreamWriter? _writer;
public SerialDataLogger(string logDirectory = "SerialLogs")
{
_logDirectory = logDirectory;
Directory.CreateDirectory(_logDirectory);
}
// 记录数据
public void StartRecording(string sessionName)
{
var fileName = $"{sessionName}_{DateTime.Now:yyyyMMdd_HHmmss}.binlog";
var path = Path.Combine(_logDirectory, fileName);
_writer = new StreamWriter(path, false) { AutoFlush = true };
_writer.WriteLine($"# Serial Data Log - Started at {DateTime.UtcNow:O}");
_writer.WriteLine("# Format: Timestamp|Direction|HexData");
_writer.WriteLine("# Direction: TX (Sent) / RX (Received)");
}
public void LogSent(byte[] data) => Log("TX", data);
public void LogReceived(byte[] data) => Log("RX", data);
private void Log(string direction, byte[] data)
{
if (_writer == null) return;
var hex = BitConverter.ToString(data).Replace("-", " ");
_writer.WriteLine($"{DateTime.UtcNow:O}|{direction}|{hex}");
}
// 回放数据
public async IAsyncEnumerable<(string Direction, byte[] Data)> ReplayAsync(
string filePath,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var line in await File.ReadAllLinesAsync(filePath, ct))
{
if (line.StartsWith("#") || string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split('|');
if (parts.Length < 3) continue;
var timestamp = DateTime.Parse(parts[0]);
var direction = parts[1];
var hex = parts[2].Replace(" ", "");
var data = HexStringToBytes(hex);
// 按原始时间间隔回放
await Task.Delay(10, ct);
yield return (direction, data);
}
}
public void StopRecording()
{
_writer?.WriteLine($"# Log ended at {DateTime.UtcNow:O}");
_writer?.Dispose();
_writer = null;
}
private static byte[] HexStringToBytes(string hex)
{
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
return bytes;
}
}