硬件通信技术

盘点常用的硬件通信技术

硬件通信技术有

  1. 有线通信技术:包括以太网、USB、HDMI、VGA、串口等有线通信接口。
  2. 无线通信技术:包括Wi-Fi、蓝牙、Zigbee、LoRa等无线通信技术。
  3. 射频通信技术:包括RFID、NFC、GPS等射频通信技术。
  4. 光通信技术:包括光纤通信、激光通信等光通信技术。
  5. 传感器技术:包括温度传感器、湿度传感器、加速度传感器等传感器技术。

相关工具盘点

硬件通信全能工具

RWEverything是一款强大的硬件级调试工具,可以直接读取和写入硬件的I/O端口,内存地址,PCI配置空间,SMBus,SPD等

官网下载地址

p.s. 实测,在x64系统下,无法运行起来

盘点一些使用方式如下:

  • 查看I/O端口映射

    在顶部的菜单中,点击Access → I/O Space

    I/O Space窗口中,您可以输入设备的端口地址范围,例如0x300到0x31F(假设您的ISA设备地址在此范围内)。

    点击Read按钮,您会看到这些地址的值。

  • 查看物理内存映射

    在菜单中,点击Access → Memory

    Memory窗口中,输入起始物理地址读取的长度(以十六进制表示),例如0xA0000。

    点击Read,RWEverything会显示物理内存的内容。

    如果看到内存地址是0xFF或是全0数据,这可能表明这些内存未被映射,或者访问被系统保护。

  • 查看设备的ISA地址

    在Access → PCI中,您可以找到和ISA桥(ISA Bridge)相关的设备。

    选择ISA桥,并查看其BAR(Base Address Register),这可能会显示ISA设备的基地址。

USB通信技术

在Windows上,最早用VB语言开发,后来由于C++的发展,采用MFC开发,近几年,微软发布了基于.NET框架的面向对象语言C#,更加稳定安全,再配合微软强大的VS进行开发,效率奇高;

另外,如果想要在Linux上跨平台运行,可以选用Qt;如果想要更加丰富好看的数据显示界面,可以选用Labview开发;

在C#中,可以使用.NET Framework提供的SerialPort类来进行SerialPort类来进行串口通信,通过该类可以实现与USB串口设备的通信。另外,也可以使用第三方库(如HidLibrary、UsbLibrary等)来简化USB通信的操作。这些库提供了更高级别的接口和封装,使得USB通信更加方便和易用。

C++中,可以使用操作系统提供的USB库(如libusb)或者第三方库(如WinUSB、libusb-win32等)来进行USB通信。通过这些库,可以实现USB设备的枚举、数据传输和控制等操作。开发者需要编写相应的代码来配置USB设备、发送和接收数据等。

USB是一种通用的、高速的、双向的串行总线标准,用于连接计算机和外部设备。USB接口支持多种设备类型(如存储设备、打印机、键盘、鼠标等)和高速数据传输,具有热插拔、即插即用等特点。USB通信是基于主从设备的通信模式,通过USB协议栈进行通信。

传统的串口通信技术(如RS-232串口)是一种较老的串行通信接口标准,通常用于连接计算机和外部设备。RS-232串口通信速度较低,通常用于较简单的数据传输和控制应用。RS-232通信是基于点对点的通信模式,通过串口通信协议(如UART)进行通信。

通信流程如下:

  1. 打开USB设备
  2. 发送数据
  3. 接受数据
  4. 关闭USB设备

根据USB规范的规定,所有USB设别都有供应商ID(VID)和产品识别码(PID),主机通过不同的VID和PID来区别不同的设备

附一个使用C#获取USB设备信息的代码

如何查看设别的VID和PID

img

windows是通过设备接口类的GUID来区分设备是什么类型的

常用设备接口类GUID参考

比如

1
2
3
HID:         "{4D1E55B2-F16F-11CF-88CB-001111000030}";
USB_DEVICE: "{A5DCBF10-6530-11D2-901F-00C04FB951ED}";
COMPORT: "{86E0D1E0-8089-11D0-9CE4-08003E301F73}"

根据设备接口类的GUID=>获取对应的设备列表=>再通过VID和GUID去设备列表中匹配对应的设备=>获取设备的路径=>通过路径打开设备进行通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
using Microsoft.Win32.SafeHandles;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace TPCL.USB
{
class UsbDevice
{
#region MyRegion
private FileStream DeviceIo = null; //异步IO流
private bool is_open = false;
private IntPtr device = new IntPtr(-1);

private const int MAX_USB_DEVICES = 64;
private static IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
//常用设备接口类GUID
private const string HidGuid = "{4D1E55B2-F16F-11CF-88CB-001111000030}";
private const string UsbDevGuid = "{A5DCBF10-6530-11D2-901F-00C04FB951ED}";
private const string UsbComPort = "{86E0D1E0-8089-11D0-9CE4-08003E301F73}";

public static void GetAllUsbDevice(ref List<string> UsbDeviceList)
{
UsbDeviceList.Clear();
Guid guid = Guid.Parse(UsbDevGuid);
IntPtr deviceInfoSet = SetupDiGetClassDevs(ref guid, 0, IntPtr.Zero, DIGCF.DIGCF_PRESENT | DIGCF.DIGCF_DEVICEINTERFACE);
if (deviceInfoSet != IntPtr.Zero)
{
SP_DEVICE_INTERFACE_DATA interfaceInfo = new SP_DEVICE_INTERFACE_DATA();
interfaceInfo.cbSize = Marshal.SizeOf(interfaceInfo);
for (uint index = 0; index < 64; index++)
{
if (SetupDiEnumDeviceInterfaces(deviceInfoSet, IntPtr.Zero, ref guid, index, ref interfaceInfo))
{
// 取得接口详细信息:第一次读取错误,但可以取得信息缓冲区的大小
int buffsize = 0;
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceInfo, IntPtr.Zero, buffsize, ref buffsize, null);
//构建接收缓冲
IntPtr pDetail = Marshal.AllocHGlobal(buffsize);
SP_DEVICE_INTERFACE_DETAIL_DATA detail = new SP_DEVICE_INTERFACE_DETAIL_DATA();
//detail.cbSize = Marshal.SizeOf(typeof(SP_DEVICE_INTERFACE_DETAIL_DATA));
if (IntPtr.Size == 8)
detail.cbSize = 8; // for 64 bit operating systems
else
detail.cbSize = 4 + Marshal.SystemDefaultCharSize; // for 32 bit operating systems
Marshal.StructureToPtr(detail, pDetail, false);
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceInfo, pDetail, buffsize, ref buffsize, null))
{
UsbDeviceList.Add(Marshal.PtrToStringAuto((IntPtr)((int)pDetail + 4)));
}
//
Marshal.FreeHGlobal(pDetail);
}
}
}
SetupDiDestroyDeviceInfoList(deviceInfoSet);
}

public int OpenUsbDevice(UInt16 vID, UInt16 pID)
{
List<string> deviceList = new List<string>();
GetAllUsbDevice(ref deviceList);
if (deviceList.Count == 0)
return 0;

string VID = string.Format("{0:X4}", vID);
string PID = string.Format("{0:X4}", pID);

foreach (string item in deviceList)
{
if (item.ToLower().Contains(VID.ToLower()) && item.ToLower().Contains(PID.ToLower())) //指定设备
{
Debug.WriteLine(item);
if (is_open == false)
{
device = CreateFile(item, DESIREDACCESS.GENERIC_READ | DESIREDACCESS.GENERIC_WRITE,
0, 0, CREATIONDISPOSITION.OPEN_EXISTING, 0x40000000, 0);

if (device != INVALID_HANDLE_VALUE)
{
Debug.WriteLine("open");
DeviceIo = new FileStream(new SafeFileHandle(device, false), FileAccess.ReadWrite,40,true);
//DeviceIo = new FileStream(new SafeFileHandle(device, false), FileAccess.ReadWrite);
this.is_open = true;
return 1;
}
CloseHandle(device);
}
}
}
return 0;
}

public void CloseDevice()
{
if (is_open == true)
{
is_open = false;
DeviceIo.Close();
CloseHandle(device);
}
}

public void Send(string dataString)
{
if (DeviceIo == null)
{
Debug.WriteLine("USB Device not open");
return;
}
byte[] data = Encoding.GetEncoding("GBK").GetBytes(dataString); //打印机支持GBK中文
// byte[] data = System.Text.Encoding.ASCII.GetBytes(dataString);
DeviceIo.Write(data, 0, data.Length);
}

public void Read()
{
//DeviceIo.Read
}

public bool GetDeviceState()
{
return is_open;
}

#endregion

#region Win32_api
public enum DIGCF
{
DIGCF_DEFAULT = 0x00000001, //只返回与系统默认设备相关的设备。
DIGCF_PRESENT = 0x00000002, //只返回当前存在的设备。
DIGCF_ALLCLASSES = 0x00000004, //返回所有已安装的设备。如果这个标志设置了,ClassGuid参数将被忽略
DIGCF_PROFILE = 0x00000008, //只返回当前硬件配置文件中的设备。
DIGCF_DEVICEINTERFACE = 0x00000010 //返回所有支持的设备。
}

/// <summary>
/// 接口数据定义
/// </summary>
public struct SP_DEVICE_INTERFACE_DATA
{
public int cbSize;
public Guid interfaceClassGuid;
public int flags;
public int reserved;
}
/// <summary>
/// 定义设备实例,该实例是设备信息集的成员
/// </summary>
public class SP_DEVINFO_DATA
{
public int cbSize = Marshal.SizeOf(typeof(SP_DEVINFO_DATA));
public Guid classGuid = Guid.Empty; // temp
public int devInst = 0; // dumy
public int reserved = 0;
}

internal struct SP_DEVICE_INTERFACE_DETAIL_DATA
{
internal int cbSize;
internal short devicePath;
}

/// <summary>
/// 获取USB-HID设备的设备接口类GUID,即{4D1E55B2-F16F-11CF-88CB-001111000030}
/// </summary>
/// <param name="HidGuid"></param>
[DllImport("hid.dll")]
private static extern void HidD_GetHidGuid(ref Guid HidGuid);

/// <summary>
/// 获取对应GUID的设备信息集(句柄)
/// </summary>
/// <param name="ClassGuid">设备设置类或设备接口类的guid</param>
/// <param name="Enumerator">指向以空结尾的字符串的指针,该字符串提供PNP枚举器或PNP设备实例标识符的名称</param>
/// <param name="HwndParent">用于用户界面的顶级窗口的句柄</param>
/// <param name="Flags">一个变量,指定用于筛选添加到设备信息集中的设备信息元素的控制选项。</param>
/// <returns>设备信息集的句柄</returns>
[DllImport("setupapi.dll", SetLastError = true)]
private static extern IntPtr SetupDiGetClassDevs(ref Guid ClassGuid, uint Enumerator, IntPtr HwndParent, DIGCF Flags);

/// <summary>
/// 根据句柄,枚举设备信息集中包含的设备接口。
/// </summary>
/// <param name="deviceInfoSet"></param>
/// <param name="deviceInfoData"></param>
/// <param name="interfaceClassGuid"></param>
/// <param name="memberIndex"></param>
/// <param name="deviceInterfaceData"></param>
/// <returns></returns>
[DllImport("setupapi.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern Boolean SetupDiEnumDeviceInterfaces(IntPtr deviceInfoSet, IntPtr deviceInfoData, ref Guid interfaceClassGuid, UInt32 memberIndex, ref SP_DEVICE_INTERFACE_DATA deviceInterfaceData);

/// <summary>
/// 获取接口详细信息,在第一次主要是读取缓存信息,第二次获取详细信息(必须调用两次)
/// </summary>
/// <param name="deviceInfoSet">指向设备信息集的指针,它包含了所要接收信息的接口。该句柄通常由SetupDiGetClassDevs函数返回。</param>
/// <param name="deviceInterfaceData">返回数据</param>
/// <param name="deviceInterfaceDetailData"></param>
/// <param name="deviceInterfaceDetailDataSize"></param>
/// <param name="requiredSize"></param>
/// <param name="deviceInfoData"></param>
/// <returns></returns>
[DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern bool SetupDiGetDeviceInterfaceDetail(IntPtr deviceInfoSet, ref SP_DEVICE_INTERFACE_DATA deviceInterfaceData, IntPtr deviceInterfaceDetailData, int deviceInterfaceDetailDataSize, ref int requiredSize, SP_DEVINFO_DATA deviceInfoData);

/// <summary>
/// 删除设备信息并释放内存。
/// </summary>
/// <param name="HIDInfoSet"></param>
/// <returns></returns>
[DllImport("setupapi.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern Boolean SetupDiDestroyDeviceInfoList(IntPtr deviceInfoSet);
#endregion

#region Open_Device
/// <summary>
/// 访问权限
/// </summary>
static class DESIREDACCESS
{
public const uint GENERIC_READ = 0x80000000;
public const uint GENERIC_WRITE = 0x40000000;
public const uint GENERIC_EXECUTE = 0x20000000;
public const uint GENERIC_ALL = 0x10000000;
}
/// <summary>
/// 如何创建
/// </summary>
static class CREATIONDISPOSITION
{
public const uint CREATE_NEW = 1;
public const uint CREATE_ALWAYS = 2;
public const uint OPEN_EXISTING = 3;
public const uint OPEN_ALWAYS = 4;
public const uint TRUNCATE_EXISTING = 5;
}

/// <summary>
///
/// </summary>
/// <param name="lpFileName">普通文件名或设备文件名</param>
/// <param name="desiredAccess">访问模式(写/读) GENERIC_READ、GENERIC_WRITE </param>
/// <param name="shareMode">共享模式</param>
/// <param name="securityAttributes">指向安全属性的指针</param>
/// <param name="creationDisposition">如何创建</param>
/// <param name="flagsAndAttributes">文件属性</param>
/// <param name="templateFile">用于复制文件句柄</param>
/// <returns></returns>
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr CreateFile(string lpFileName, uint desiredAccess, uint shareMode, uint securityAttributes, uint creationDisposition, uint flagsAndAttributes, uint templateFile);

/// <summary>
/// 关闭
/// </summary>
/// <param name="hObject">Handle to an open object</param>
/// <returns></returns>
[DllImport("kernel32.dll")]
private static extern int CloseHandle(IntPtr hObject);

#endregion
}
}

串口通信

串口通信通过串行端口(Serial Port)进行数据传输的一种通信方式。串口通信技术通常用于连接计算机与外部设备(如传感器、打印机、嵌入式系统等)进行数据交换和控制。常见的串口通信标准包括RS-232、RS-485、UART等。串口通信技术在工业控制、嵌入式系统、通信设备等领域广泛应用。

  1. RS-232:RS-232是一种串行通信标准,定义了信号电平、数据格式、控制信号等规范。在编程接口上,RS-232通常需要使用特定的串行通信库或驱动程序来进行数据传输和控制。常见的RS-232编程接口包括使用C语言的串口编程库(如termios库)、Python的pySerial库等。
  2. RS-485:RS-485也是一种串行通信标准,与RS-232相比,RS-485支持多点通信和远距离传输。在编程接口上,RS-485通常需要使用特定的串口通信库或驱动程序来进行数据传输和控制。常见的RS-485编程接口包括使用Modbus协议库、专门的RS-485通信库等。
  3. UART:UART(Universal Asynchronous Receiver/Transmitter)是一种硬件接口,用于串行数据通信。在编程接口上,UART通常需要通过操作系统提供的串口通信接口或者相关的串口编程库来进行数据传输和控制。常见的UART编程接口包括Linux系统的串口编程接口、Windows系统的串口通信API等。

在实际应用中,RS-232、RS-485和UART这三种通信协议各有其适用的场景和优势,没有一种是绝对广泛应用的。但从整体来看,UART通信协议可能是最广泛应用的一种。因为UART是一种通用的串行通信协议,几乎所有的微控制器、传感器、通信模块等设备都支持UART通信。在嵌入式系统、传感器网络、物联网等领域,UART通信协议被广泛应用。 RS-232和RS-485通信协议在工业控制、通信设备、自动化系统等领域也有广泛的应用,特别是在需要远距离传输、抗干扰能力强的场景下,RS-485通常比RS-232更受青睐。

真正开发中似乎无需关心串行通信的标准是哪一种

  • C/C++可以直接使用系统API来操作串口通信(直接不需要关心具体的协议),或者可以使用第三方库如Boost.Asio、libserial等来简化串口通信的编程过程
  • C#中,可以使用.NET Framework提供的SerialPort类来实现串口通信,同样不关心具体的协议

理解原理视频参考|720x360

使用上述通信接口开发都需要设置如下项

windows查看串口情况命令 mode 只会显示空闲的串口,被打开的串口将不会显示

重要参数

  1. 串口名称(Port Name):指定要打开的串口设备的名称,如”COM1”、”COM2”等。
  2. 波特率(Baud Rate):指定串口通信的波特率,即数据传输速率,常见的波特率有9600、115200等。
  3. 数据位(Data Bits):指定每个数据字节的位数,通常为8位。
  4. 停止位(Stop Bits):指定每个数据帧的停止位数,通常为1位或2位。
  5. 校验位(Parity):指定用于检测数据传输中错误的校验位,常见的校验方式有无校验、奇校验、偶校验等。

除了以上基本参数外,还可以根据需要传入其他参数来配置串口通信,如流控制(Flow Control)、超时设置(Timeouts)、缓冲区大小等。

  1. 流控制(Flow Control):用于控制数据传输的速度,常见取值为硬件流控制(如RTS/CTS)或软件流控制(如XON/XOFF)
  2. 丢弃空字符(DiscardNull):指定是否丢弃接收到的空(null)字符。如果设置为true,接收到的空字符将被丢弃而不会传递给应用程序
  3. 启用DTR信号(DtrEnable):用于启用或禁用数据终端就绪(DTR)信号。DTR信号通常用于指示设备是否准备好进行通信
  4. 握手方式(Handshake):指定数据传输时使用的握手协议。常见的握手协议包括无握手、软件握手、硬件握手等,用于控制数据传输的流程
  5. 替换奇偶校验(ParityReplace):指定在接收到奇偶校验错误时应如何处理。通常可以设置为替换错误的奇偶校验位,以确保数据的正确性
  6. 超时设置(Timeouts):指定连接、读取、写入数据的超时时间。
  7. 缓冲区大小(Buffer Size):指定输入和输出缓冲区的大小,以影响串口通信的性能。

位时间计算

位时间(bit time)是指在串行通信中传输一个位所需的时间,它的具体值取决于通信的波特率(baud rate)

一个位时间的时间间隔可以用下面公式计算:
$$
\text{一个位时间的时间间隔(秒)}=\frac{1}{\text{波特率(波特)}}
$$
如9600波特率的24位时间为:
$$
\frac{24}{9600}\approx0.0025秒
$$

串口通信开发

以CSharp为例

在.NET Framework 2.0中提供了 SerialPort 类,该类主要实现串口数据通信等。

SerialPort类

常用属性

名称 说明
BaseStream 获取 SerialPort 对象的基础 Stream 对象
BaudRate 获取或设置串行波特率
BreakState 获取或设置中断信号状态
BytesToRead 获取接收缓冲区中数据的字节数
BytesToWrite 获取发送缓冲区中数据的字节数
CDHolding 获取端口的载波检测行的状态
CtsHolding 获取“可以发送”行的状态
DataBits 获取或设置每个字节的标准数据位长度
DiscardNull 获取或设置一个值,该值指示 Null 字节在端口和接收缓冲区之间传输时是否被忽略
DsrHolding 获取数据设置就绪 (DSR) 信号的状态
DtrEnable 获取或设置一个值,该值在串行通信过程中启用数据终端就绪 (DTR) 信号
Encoding 获取或设置传输前后文本转换的字节编码
Handshake 获取或设置串行端口数据传输的握手协议
IsOpen 获取一个值,该值指示 SerialPort 对象的打开或关闭状态
NewLine 获取或设置用于解释 ReadLine( )和 WriteLine( )方法调用结束的值
Parity 获取或设置奇偶校验检查协议
ParityReplace 获取或设置一个字节,该字节在发生奇偶校验错误时替换数据流中的无效字节
PortName 获取或设置通信端口,包括但不限于所有可用的 COM 端口
ReadBufferSize 获取或设置 SerialPort 输入缓冲区的大小
ReadTimeout 获取或设置读取操作未完成时发生超时之前的毫秒数
ReceivedBytesThreshold 获取或设置 DataReceived 事件发生前内部输入缓冲区中的字节数
RtsEnable 获取或设置一个值,该值指示在串行通信中是否启用请求发送 (RTS) 信号
StopBits 获取或设置每个字节的标准停止位数
WriteBufferSize 获取或设置串行端口输出缓冲区的大小
WriteTimeout 获取或设置写入操作未完成时发生超时之前的毫秒数

事件

注意:下面事件绑定的回调方法都是在非UI线程中执行的

名称 说明
DataReceived 当串口接收到数据时引发此事件。可以通过该事件处理程序读取接收到的数据。
PinChanged 当串口的控制信号引脚状态发生变化时引发此事件。可以用于监控 RTS、CTS 等引脚的状态。
ErrorReceived 当串口出现错误(例如,溢出错误)时引发此事件。可以在事件处理程序中处理错误信息。
Disposed SerialPort 对象被释放时引发此事件。可以用于清理资源。

常用方法

方法名称 说明
Close 关闭端口连接,将 IsOpen 属性设置为 False,并释放内部 Stream 对象
Open 打开一个新的串行端口连接
Read 从 SerialPort 输入缓冲区中读取
ReadByte 从 SerialPort 输入缓冲区中同步读取一个字节
ReadChar 从 SerialPort 输入缓冲区中同步读取一个字符
ReadLine 一直读取到输入缓冲区中的 NewLine 值
ReadTo 一直读取到输入缓冲区中指定 value 的字符串
ReadExisting 读到马上可用的字节
Write 已重载。将数据写入串行端口输出缓冲区
WriteLine 将指定的字符串和 NewLine 值写入输出缓冲区

进阶

SerialPort 类中的 BaseStream 属性表示底层的流对象,该流对象提供对串口端口的低级别访问。通过 BaseStream,可以使用更多的流方法,包括异步方法,如 ReadAsyncWriteAsync,这些方法不在 SerialPort 类本身中直接提供。

使用 BaseStream 进行异步操作的优点包括:

  1. 非阻塞操作:异步方法不会阻塞调用线程,可以提高应用程序的响应速度。
  2. 更细粒度的控制:可以利用 Stream 类提供的所有功能,包括异步读写操作。

SerialPort 类中使用 BaseStream 和异步方法来处理异步串口通信,是一种更现代和高效的编程方式。

核心代码(确保读取完全)

1
2
3
4
5
6
7
8
9
10
11
int bytesRead = 0; 
while (bytesRead < result.Length)
{
int read = await serialPort.BaseStream.ReadAsync(result, bytesRead, result.Length - bytesRead);
if (read == 0)
{
// No more data available
break;
}
bytesRead += read;
}

事件驱动读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
private void DataReceivedHandler(object sender, SerialDataReceivedEventArgs e)
{
serialport.DataReceived -= DataReceivedHandler; // 暂时取消事件处理程序,以防止重入
receiveActive = true; // 标记接收活动状态
SerialPort serialPort = (SerialPort)sender; // 获取触发事件的串口对象

if (bytesToRead == 0)
{
serialPort.DiscardInBuffer(); // 如果没有要读取的字节,丢弃输入缓冲区中的数据
receiveActive = false; // 标记接收活动结束
serialport.DataReceived += DataReceivedHandler; // 重新添加事件处理程序
return;
}

readBuffer = new byte[256]; // 初始化读取缓冲区
int num = 0; // 已读取的字节数
int num2 = 0; // 当前读取位置
DateTime now = DateTime.Now; // 当前时间

do
{
try
{
now = DateTime.Now; // 更新当前时间
while (serialPort.BytesToRead == 0)
{
Thread.Sleep(10); // 如果没有数据可读,等待10毫秒
if (DateTime.Now.Ticks - now.Ticks > 20000000) // 超时检查
{
break;
}
}

num = serialPort.BytesToRead; // 获取可读取的字节数
byte[] array = new byte[num]; // 创建临时缓冲区
serialPort.Read(array, 0, num); // 从串口读取数据到临时缓冲区
Array.Copy(array, 0, readBuffer, num2, (num2 + array.Length <= bytesToRead) ? array.Length : (bytesToRead - num2)); // 将数据复制到读取缓冲区
num2 += array.Length; // 更新已读取的字节数
}
catch (Exception)
{
// 处理读取过程中可能发生的异常
}
}
while (bytesToRead > num2 && !(DetectValidModbusFrame(readBuffer, (num2 < readBuffer.Length) ? num2 : readBuffer.Length) | (bytesToRead <= num2)) && DateTime.Now.Ticks - now.Ticks < 20000000); // 循环读取直到满足条件

receiveData = new byte[num2]; // 初始化接收数据缓冲区
Array.Copy(readBuffer, 0, receiveData, 0, (num2 < readBuffer.Length) ? num2 : readBuffer.Length); // 将读取的数据复制到接收数据缓冲区

if (debug)
{
StoreLogData.Instance.Store("Received Serial-Data: " + BitConverter.ToString(readBuffer), DateTime.Now); // 如果启用调试,记录接收到的数据
}

bytesToRead = 0; // 重置要读取的字节数
dataReceived = true; // 标记数据已接收
receiveActive = false; // 标记接收活动结束
serialport.DataReceived += DataReceivedHandler; // 重新添加事件处理程序

if (this.ReceiveDataChanged != null)
{
this.ReceiveDataChanged(this); // 触发接收数据更改事件
}
}

事件处理程序的移除和重新添加:

  • serialport.DataReceived -= DataReceivedHandler;:在处理数据接收时,暂时移除事件处理程序以防止重入。
  • serialport.DataReceived += DataReceivedHandler;:在处理完数据后,重新添加事件处理程序。

接收数据的初始化:

  • readBuffer = new byte[256];:初始化读取缓冲区。
  • int num = 0; 和 int num2 = 0;:初始化已读取的字节数和当前读取位置。

数据读取循环:

  • while (serialPort.BytesToRead == 0):如果没有数据可读,等待10毫秒。
  • num = serialPort.BytesToRead;:获取可读取的字节数。
  • serialPort.Read(array, 0, num);:从串口读取数据到临时缓冲区。
  • Array.Copy(array, 0, readBuffer, num2, (num2 + array.Length <= bytesToRead) ? array.Length : (bytesToRead - num2));:将数据复制到读取缓冲区。

数据接收完成后的处理:

  • receiveData = new byte[num2];:初始化接收数据缓冲区。
  • Array.Copy(readBuffer, 0, receiveData, 0, (num2 < readBuffer.Length) ? num2 : readBuffer.Length);:将读取的数据复制到接收数据缓冲区。
  • if (debug) { StoreLogData.Instance.Store("Received Serial-Data: " + BitConverter.ToString(readBuffer), DateTime.Now); }:如果启用调试,记录接收到的数据。
  • bytesToRead = 0;:重置要读取的字节数。
  • dataReceived = true;:标记数据已接收。
  • receiveActive = false;:标记接收活动结束。
  • if (this.ReceiveDataChanged != null) { this.ReceiveDataChanged(this); }:触发接收数据更改事件。

在串口通信中,即使在事件处理方法中,有时仍需要使用循环来确保数据的完整接收。这主要是因为串口通信的特点和数据到达的不确定性。

原因解释

  1. 数据到达的不确定性
    • 串口数据是以字节流的形式异步到达的,具体到达的时间和数量可能不确定。
    • 即使触发了数据接收事件,可能只接收到部分数据,需要循环等待和读取后续到达的数据。
  2. 处理连续数据帧
    • 有时需要从串口接收连续的数据帧,这意味着一次接收事件可能无法完全获取一个完整的数据帧。
    • 循环读取可以确保在处理事件时获取完整的数据帧。
  3. 缓冲区处理
    • 串口接收缓冲区可能在一次事件中未能完全处理,循环读取可以确保缓冲区中的所有数据都被读取和处理。

动机说明

  • 事件驱动 ==使得==> 没有数据传输的情况下不需要一直检测串口状态
  • 事件驱动中循环接受 ==使得==> 每次事件驱动触发后充分接受完全,至少接收完一个数据帧

下位机与上位机间串口通信

处理思想:

一般情况下,当下位机高速发送应答数据时,串口接收到的数据不会是一个完整应答数据,而是多个应答数据的混合集,因此当你以单一应答数据来解析收到的数据时往往会发现应答数据格式不正确,在界面上的表现就是“没有收到数据”。

另外把收到的原始字节数组解析为程序能读懂的数据也是一项费时费力的事情,因此会出现“高速收,低速埋”的矛盾。但是,如果只让串口执行“收”,而辅助线程执行“埋”,那么就有效的解决了这个矛盾,即使下位机发的速度再高,系统也能抗得住。

为了实现这个思想,可以有如下设计:

  1. 数据接收与处理分离
  • 串口接收线程:专责监听串口,并将接收到的数据累积在一个共享的缓冲区中。这个线程只负责数据的接收,不做任何处理,以保证数据能够尽快从串口被读取出来,避免丢失数据。
  • 数据处理线程:负责从共享缓冲区读取数据,并进行解析和处理。处理完的数据可以进一步用于更新UI、存储或进行其他操作。
  1. 线程安全的共享缓冲区

因为数据接收和处理是由不同的线程并发执行的,共享缓冲区必须是线程安全的。可以使用锁(如C#中的lock语句)来同步对缓冲区的访问,或者使用线程安全的集合(如ConcurrentQueue<T>)来自动管理同步。

  1. 数据的边界识别与完整性保证

由于接收到的数据可能是多个应答数据的混合集,数据处理线程需要能够正确识别每个独立应答数据的边界。这通常需要根据具体的应答数据格式来设计解析算法,例如,通过特定的起始字节、结束字节、长度字段或校验和来识别和验证数据的完整性。

开发步骤概述

  1. 设计共享缓冲区:选择或实现一个线程安全的数据结构来作为共享缓冲区,用于存储从串口接收到的原始数据。
  2. 实现串口接收线程
    • 连续监听串口。
    • 将接收到的数据追加到共享缓冲区。
    • 使用最小的处理,确保高效接收。
  3. 实现数据处理线程
    • 循环从共享缓冲区读取数据。
    • 识别和提取完整的应答数据。
    • 对每个完整应答数据进行解析和处理。
  4. 同步机制:确保对共享缓冲区的访问是线程安全的,可以通过锁或线程安全的集合来实现。
  5. 错误处理与异常管理:添加必要的错误处理和异常管理机制,以确保系统的鲁棒性。

采用这种多线程的设计方案,可以有效地解决接收和处理速度不匹配的问题,提高系统对高速串口数据流的处理能力。

统一数据读取:所有数据读取都通过事件驱动进行,然后将数据存储到一个线程安全的容器中。手动读取时从该容器中取数据。并且使用 ConcurrentQueue 确保多线程环境下的数据安全

ModBUS

​ Modbus协议是一种用于工业控制的网络通讯协议,可以片面的理解为,Modbus协议一种机器与机器之间进行数据、信息传递的一种格式规范。
​ Modbus协议还遵循主从协议,支持单主机,多从机,最多支持247个从机设备。并且,在同一个通信线路上只会有一个主机,所有的通讯过程全部由主机主动发起,从机接收到主机请求后,会对请求做出响应。从机不会主动进行数据的发送,从机之间也不会有通讯过程。

通俗一点来说
主机从机之间想要实现通讯,需要将主机与从机进行连接,然后再进行数据传输。而连接方式有上述4种方式,连接实现之后,主机与从机之间就可以进行数据传输了。而它们传输的数据内容,均按照Modbus协议规定的格式进行转换。这样,就保证了能够让同一个主机与不同功能、不同厂家的设备之间进行准确的通讯。

优势:

  • 标准、开放、免费
  • 支持多种电器接口,如串行接口RS-232、RS-485等,还可以在各种介质上传递,如:光纤、无线等
  • Modbus的帧格式简单、紧凑、通俗易懂。用户使用简单,厂商开发简单。

注意:单个 Modbus 数据包的最大长度为 256 字节。由于每个保持寄存器占用 2 字节,因此在读取保持寄存器时,最多可以请求 125 个寄存器(125 × 2 = 250 字节),加上请求的其他字节(如地址、功能码等),总长度不能超过 256 字节

格式分类

MODBUS通信协议的三种格式

  • MODBUS TCP/IP
  • Modbus UDP/IP
  • MODBUS RTU 工业领域中用得最多的
  • MODBUS ASCII

上述三种其实就是硬件接口以及传输数据方式的不同

注意

在Modbus协议中,从机(Slave)属于服务端(Server),而主机(Master)属于客户端(Client)。这是因为在Modbus通信中,主机负责发起请求并控制通信过程,而从机则负责响应请求并提供数据。主机通过发送请求帧给从机,从机接收并处理请求,然后返回响应帧给主机。

MODBUS TCP/IP

基于以太网的一种通讯方式,它将Modbus协议封装在TCP/IP协议栈中,通过以太网传输数据。具有高速、稳定的特点。

传输的是TCP码,使用的接口是以太网口

image-20240719162356207
  • ACK(acknowledgement 确认)
  • PSH(push传送)
  • FIN(finish结束)
  • RST(reset重置)
  • URG(urgent紧急)
  • SYN(synchronous建立联机)
  • Sequence Number(顺序号码)
  • Acknowledge Number(确认号码)

主要用于做网络通讯

Modbus UDP/IP

基于UDP/IP协议的一种通讯方式。与Modbus TCP/IP不同,Modbus UDP/IP采用无连接的通讯方式,不保证数据的可靠性和顺序。相比于Modbus TCP/IP,Modbus UDP/IP的通讯开销较小,可以减少网络负载。

MODBUS RTU

使用串口通讯协议,Modbus RTU使用二进制格式进行数据传输,通讯效率更高,可读性较差

直接传输的是二进制的数字

用的是RS232或者是RS485/422等接口,主要是做串口通信

image-20240719162444529

MODBUS ASCII

可读性好,但通讯效率更低

接口与MODBUS RTU一样,主要是做串口通信,只不过传递的是二进制的ASCII码

协议格式

通用modbus帧:

image-20240719163938621

无论哪一种Modbus协议版本的帧格式都是一样的

  • 地址域:主机要访问的从机的地址
  • 功能码:主机对从机实现的操作,功能码有很多,不同的功能码也对应操作不同类型的寄存器。比如:0x01读线圈、0x03读保持寄存器、0x06写单个寄存器、0x10写多个寄存器等。(更多功能码见下方Modbus功能码列表
  • 数据:根据功能的不同,以及传输的数据为请求数据还是响应数据的不同,会有不同的内容。(详见报文结构解析
  • 差错校验:为保障传输数据的准确性,modbus会进行差错校验,如Modbus CRC16校验等。详情请自行了解。

Modbus将采用大端字节序传输报文,比如一个16位数据0x55AA,先传输高字节0x55,再传输低字节0xAA。

Modbus将数据抽象成四张表

主表 对象类型 读写属性 描述 读功能码
离散输入寄存器(Discrete Input Registers) 单个位数据 只读 这些寄存器包含只读的布尔值,通常用于表示输入状态,如开关或传感器状态 0x02
线圈寄存器(Coil Registers) 单个位数据 可读可写 这些寄存器包含可读写的布尔值,通常用于控制输出设备,如开关或继电器 0x01
输入寄存器(Input Registers) 16位数据 只读 这些寄存器包含只读的16位数据,通常用于表示输入数据,如传感器数据 0x04
保持寄存器(Holding Registers) 16位数据 可读可写 这些寄存器包含可读写的16位数据,通常用于存储和控制数据,如设备配置或控制参数 0x03

左边第一列的名字不用关心,属于历史遗留问题,因为modbus协议原本是Modicon公司针对其PLC产品开发的协议,与其特殊的工业PLC控制编程有很大关系,对于使用modbus进行应用开发并不需要关心

这四个表本质上就是将应用数据规划为离散位开关量,以及寄存器变量,其中线圈与保持寄存器表为可读可写,其他两个表为只读。这个四个表中将应用数据都利用寄存器地址进行索引。地址范围为0x0000-0xFFFF。需要理解的是,这里的地址与芯片的地址空间完全是两个概念,把它简单理解成modbus可以索引0x0000-0xFFFF这么多个用户应用16位数据即可。其中有的可能是开关量,有的可能利用两个连续寄存器对应用户的浮点数,字符串等等,都有可能

一般的应用而言,单个位开关量通信效率不免低下,现在很多产品开发已很少使用。其实对于这样的离散量也完全可以直接放在输入寄存器表以及保持寄存器表中。modbus对于用户应用并没有严格的规定。用户可以自由进行寄存器地址(或叫索引) 映射

Modbus功能码列表

功能码 解释翻译 作用
0x01 Read Coils 读线圈状态 读取远程设备中1到2000个连续的线圈的状态
0x02 Read Discrete Inputs 读离散输入状态 读取远程设备中1到2000个连续的离散输入的状态
0x03 Read Holding Registers 读保持寄存器内容 读取远程设备中1到125个连续的保持寄存器的内容
0x04 Read Input Registers 读输入寄存器内容 读取远程设备中1到125个连续的输入寄存器的内容
0x05 Write Single Coil 写单个线圈 在远程设备中把单个线圈状态改变为打开或关闭的状态
0x06 Write Single Register 写单个保持寄存器 在远程设备中写入单个保持寄存器
0x07 Read Exception Status (Serial Line only) 读取异常状态(仅限串行线路) 读取远程设备中八个异常状态输出的内容
0x08 Diagnostics (Serial Line only) 通信系统诊断(仅限串行线路)
0x0B Get Comm Event Counter (Serial Line only) 获取通讯事件计数器(仅限串行线路) 从远程设备的通信事件计数器获取状态字和事件计数
0x0C Get Comm Event Log (Serial Line only) 获取通讯事件日志(仅限串行线路) 从远程设备获取状态字、事件计数、消息计数和事件字节字段
0x0F Write Multiple Coils 写多个线圈 强制远程设备中线圈序列中的每个线圈接通或断开
0x10 Write Multiple registers 写多个保持寄存器 在远程设备中写入连续寄存器块
0x11 Report Slave ID (Serial Line only) 报导从机信息(仅限串行线路) 读取远程设备特有的类型、当前状态和其他信息的说明。数据内容特定于每种类型的设备
0x14 Read File Record 读取文件记录
0x15 Write File Record 写文件记录
0x16 Mask Write Register 带屏蔽字写入寄存器
0x17 Read/Write Multiple registers 读、写多个寄存器 执行一次连续写和连续读,写入操作在读取之前执行
0x18 Read FIFO Queue 读取先进先出队列
0x2B Encapsulated Interface Transport 封装接口传输

报文结构解析(Modbus RTU版本)

CRC16在线校验工具

[[算法#CRC16|CRC16算法参考]]

下面使用最常用的Modbus RTU版本、使用Modbus CRC16校验的保持寄存器(Holding registers)做演示,解析其三个常用功能0x03读、0x06写单个、0x10写的报文结构

0x03请求应答方式

主机请求

示例:01 03 00 01 00 0A 94 0D

含义:从机设备地址(01)+功能码(03)+起始寄存器完整地址(00 01)+要读取的寄存器个数(00 0A)+CRC16校验码(94 0D)

解析:从地址为1的从机读取寄存器块内容,寄存器开始地址为1,连续读取10个寄存器,即读取地址为1到10的寄存器块。

从机应答

示例:01 06 27 11 00 01 12 BB

含义:从机设备地址(01)+功能码(06)+寄存器完整地址(27 11)+成功写入的数据(00 01)+CRC16校验码(12 BB)

解析:在地址为1的从机,地址为10001的寄存器中,成功写入数据1。如果06功能写入成功的话,请求码和响应码会是一样的。

0x10请求应答方式

示例:01 10 4E 21 00 03 06 00 01 00 11 00 08 BB 05

含义:从机设备地址(01)+功能码(10)+起始寄存器地址(4E 21)+写入的寄存器个数(00 03)+数据字节数(00 06)+数据内容(00 01、00 11、00 08)+CRC16校验码(BB 05)

解析:在地址为1的从机中,向起始地址为20001的连续3个寄存器,分别写入1、17、8,字节数6个。

示例:01 10 4E 21 00 03 C7 2A

含义:从机设备地址(01)+功能码(10)起始寄存器地址(4E 21)+写入的寄存器个数(00 03)+CRC16校验码(C7 2A)

解析:在地址为1的从机,起始地址为20001的连续3个寄存器中(20001、20002、20003),写入数值。

通信库盘点

尝试编写modbus RTU通信库

核心代码以一个回环验证来演示

  • 重试机制
  • 超时机制
  • 循环堵塞读取
  • 起始从机号验证
  • crc循环冗余验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
public bool ConnectCheck()
{
byte[] LoopTestCmd = new byte[] { 0x01, 0x08, 0x00, 0x00, 0x1f, 0x34, 0xe9, 0xec }; // 环路检查
const int maxRetries = 3;
int retryCount = 0;

serialport.ReadTimeout = connectTimeout;
serialport.WriteTimeout = connectTimeout;

while (retryCount < maxRetries)
{
try
{
App.DebugWriteLine($"开始{retryCount}次回环测试");
serialport.Write(LoopTestCmd, 0, LoopTestCmd.Length);

byte[] receivedBytes = new byte[LoopTestCmd.Length];
int bytesRead = 0;
DateTime timeoutTime = DateTime.Now.AddMilliseconds(connectTimeout);


bool firstLoop = true;
while (bytesRead < receivedBytes.Length && DateTime.Now < timeoutTime)
{
int read = serialport.Read(receivedBytes, bytesRead, receivedBytes.Length - bytesRead);

if (read > 0)
{
bytesRead += read;

if (firstLoop)
{
firstLoop = false;
// 确定第一个是从机号
int index = 0;
while (index < bytesRead && receivedBytes[index] != unitIdentifier)
{
index++;
}

if (index < bytesRead)
{
// 去除从机号之前的数
Array.Copy(receivedBytes, index, receivedBytes, 0, bytesRead - index);
bytesRead -= index;
}
else
{
// 如果没有找到从机号,重置bytesRead
bytesRead = 0;
}
}
}
}
App.DebugWriteLine($"{retryCount}次读到数据为:" + BitConverter.ToString(receivedBytes));

if (bytesRead == receivedBytes.Length)
{
byte[] receivedCrc = new byte[2];
Array.Copy(receivedBytes, receivedBytes.Length - 2, receivedCrc, 0, 2);
byte[] calculatedCrc = BitConverter.GetBytes(calculateCRC(receivedBytes, (ushort)(receivedBytes.Length - 2), 0));

if (receivedCrc.SequenceEqual(calculatedCrc) && receivedBytes.Take(LoopTestCmd.Length).SequenceEqual(LoopTestCmd))
{
return true; // 连接成功
}
}
}
catch (TimeoutException)
{
// 超时处理
}
catch (Exception ex)
{
Debug.WriteLine($"Error: {ex.Message}");
}

retryCount++;
}

if (serialport.IsOpen)
{
serialport.Close();
}
return false; // 连接失败
}

easyModbus

支持自动重连,异步读写等高级特性

easyModbus

使用案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Program
{
static void Main(string[] args)
{

// 创建 Modbus RTU 客户端实例
ModbusClient modbusClient = new ModbusClient("COM3");
modbusClient.Baudrate = 19200;
modbusClient.Parity = Parity.None;
modbusClient.StopBits = StopBits.One;
modbusClient.ConnectionTimeout = 500;
modbusClient.UnitIdentifier = 1; // 设置从机地址为1

try
{
// 打开串行端口
modbusClient.Connect();


for (int i = 0; i < 1000; i++)
{
// 读取从站的线圈状态
int[] request = new int[] { 0x0108, 0x0000, 0x1F34, 0xE9EC };


// 读取从站的响应
List<int> response = modbusClient.ReadHoldingRegisters(0, 4).ToList();

// 打印响应结果
Console.WriteLine("从站响应:");
foreach (int b in response)
{
byte highByte = (byte)(b >> 8); // 高字节
byte lowByte = (byte)(b & 0xFF); // 低字节

Console.Write(highByte.ToString("X2") + " " + lowByte.ToString("X2") + " ");
}
Console.WriteLine();
}



}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
finally
{
// 关闭串行端口
modbusClient.Disconnect();
}
}
}

SerialPortStream

串口通信库

SerialPortStream开源地址

其通过多种机制来确保读取到完整的数据,这些机制包括内部缓冲区管理、超时处理和设备错误检查

SerialPortStream 类是 RJCP.IO.Ports 命名空间下的一个 .NET 库,用于提供串行端口通信的功能。它对 Microsoft 的 SerialPort 类进行了重新实现,旨在解决一些在 .NET 4.0 及更早版本中存在的问题。这个类提供了一种流(Stream)的方式来处理串行数据的读写

关于如何确定读取操作完成,SerialPortStream 类使用了几种机制来确保数据可以被完整读取:

  1. 缓冲机制SerialPortStream 内部使用缓冲区(SerialBuffer)来存储从串行端口读取的数据。这意味着即使数据还没有完全从硬件接收完毕,它也会被暂存在缓冲区内。
  2. 超时设置:类中提供了 ReadTimeoutWriteTimeout 属性来设置读写操作的超时时间。如果操作在超时时间内没有完成,将会抛出异常或返回错误。
  3. 事件驱动SerialPortStream 使用事件(如 DataReceived)来通知应用程序有数据到达或发生错误。这允许应用程序在数据到达时立即响应,而不是等待特定数量的数据或超时。
  4. 读取方法SerialPortStream 提供了多种读取方法,例如 Read, ReadLine, ReadTo 等,这些方法可以读取字节、字符或直到特定字符串。特别是 ReadTo 方法,它能够读取直到输入缓冲区中出现特定的文本,这有助于读取基于特定分隔符的数据。
  5. 状态监控:通过监控串行端口的状态,SerialPortStream 可以在适当的时机进行读取操作。例如,它可以检查硬件缓冲区的状态,或者等待特定的硬件事件(如 CTS 信号)。
  6. 错误处理SerialPortStream 在读取过程中会检查设备错误,如果发现设备已断开或发生错误,将会抛出异常。

开源使用要求

对于RJCP SerialPortStream项目采用的微软公用许可证(MS-PL),根据该许可证的规定,您使用这个库时通常是需要在您的项目中声明许可证信息的。

根据MS-PL许可证的要求,您需要在派生作品(包括商业软件)的源代码中保留原始许可证文本,并提供对应的版权声明、免责声明等信息。具体的声明内容和位置可以参考许可证文档中的要求。

因此,为了遵守MS-PL许可证的要求和最佳实践,建议您在使用RJCP SerialPortStream时,在您的项目文档、Readme文件或其他相关介绍中提供适当的许可证声明和鸣谢。这样可以向其他人清楚地传达您使用了RJCP SerialPortStream库,并表达对原作者的感谢与尊重。

NModbus

主要是支持跨平台和crc校验

MIT开源

开源地址

WINIO库

WinIO是一个专为Windows设计的低级I/O接口库,它允许程序员直接对硬件进行端口级访问,实现高速数据传输和控制。该库通常用于系统开发和驱动编写,尽管它能提供更高的效率,但也带来了复杂性和潜在风险。WinIO的功能包括端口I/O操作、内存映射I/O、中断处理、系统调用拦截等,并强调使用时的安全性和兼容性。它主要服务于需要底层硬件交互的应用程序开发,如系统工具、调试器和驱动程序等

开源地址

也可以在此处下载

正常的Windows访问硬件的方式: 应用程序 -> Windows API -> 设备驱动 -> 硬件

WinIO库的访问方式: 应用程序 -> WinIO -> 硬件

使用该库的时候需要确保winio.sys被加载进系统,他在64位系统上需要签名才可以成功加载,可以使用命令sc query winiodriverquery /v | findstr winio 查看是否加载

这种直接访问的场景通常用在:

  • 工控卡:比如采集卡在特定物理地址映射了其寄存器
  • 工业设备:设备可能在固定的I/O端口提供数据
  • 特殊硬件:需要直接读写物理内存或I/O端口的设备

优点:响应速度快,没有系统调用开销可以实现一些操作系统不支持的特殊功能

缺点:不安全,可能会破坏系统需要管理员权限,兼容性差

WinIO提供了两种方式访问硬件:

  • 直接访问物理内存

    1
    2
    3
    4
    5
    6
    // 将物理地址0xd8000映射到程序可以访问的内存空间
    pbLinAddr = MapPhysToLin((PBYTE)0xd8000, 65536, &hPhysicalMemory);

    // 之后可以直接读写这段内存
    pbLinAddr[0] = 1; // 写入数据
    value = pbLinAddr[0]; // 读取数据
  • 直接访问I/O端口

    1
    2
    3
    4
    5
    6
    DWORD value;
    // 直接读取端口0x378的值
    GetPortVal(0x378, &value, 1);

    // 直接向端口0x378写入数据
    SetPortVal(0x378, 0x55, 1);

此库使用需要加载驱动程序,因此需要管理员权限以及可能需要驱动签名(win32位不需要)

获取管理员权限

WinIo 需要管理权限才能正常运行。这可以通过以下方式实现:

使用服务加载驱动

从作为 LocalSystem 运行的服务中使用 WinIo(必须显式启用 SE_LOAD_DRIVER_NAME 权限)

大概步骤

  1. 编写一个以 LocalSystem 帐户运行的服务(通过调用 Windows API(如 AdjustTokenPrivileges)来修改服务进程的权限),并在服务启动时启用 SE_LOAD_DRIVER_NAME 权限。
  2. 在服务中加载 WinIo 驱动,处理所需的硬件交互。
  3. 在服务的关闭过程中,卸载驱动并释放相关资源。

使用清单文件

在请求权限提升的应用程序中嵌入清单文件

应用程序可以包含一个清单文件(Manifest)来声明所需的权限级别

自动生成如下

  1. 右键点击项目,选择“属性”。
  2. 在左侧导航栏中,展开“配置属性” -> “链接器” -> “清单文件”。
  3. 将“生成清单”设置为 Yes(生成清单)。
  4. 将“UAC 执行级别”设置为 requireAdministrator,这样程序将要求以管理员身份运行。
  5. 应用更改并重新编译项目。

手动创建如下

  1. 创建一个 .manifest 文件,例如 MyApp.manifest,文件内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
    <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
    <security>
    <requestedPrivileges>
    <requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
    </requestedPrivileges>
    </security>
    </trustInfo>
    </assembly>
  2. 将 Manifest 文件添加到项目中。

  3. 在项目属性中,展开“配置属性” -> “链接器” -> “清单文件”。

  4. 将“清单输入”设置为“嵌入”,并指定你创建的 .manifest 文件路径。

  5. 重新编译项目,Manifest 文件将被嵌入应用程序。

用户同意后,应用程序将以管理员权限运行,可以加载 WinIo 驱动。

提醒用户管理员启动

要求用户在启动应用程序时选择“以管理员身份运行”选项

在 Windows 中可以通过检查当前用户是否属于管理员组来确定应用程序是否以管理员权限运行。以下是 C++ 中的代码示例:

示例代码:检测管理员权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <windows.h>
#include <shellapi.h>
#include <iostream>

bool IsRunningAsAdministrator() {
BOOL isAdmin = FALSE;
PSID adminGroup = NULL;

// Create a SID for the Administrators group.
SID_IDENTIFIER_AUTHORITY ntAuthority = SECURITY_NT_AUTHORITY;
if (AllocateAndInitializeSid(
&ntAuthority,
2,
SECURITY_BUILTIN_DOMAIN_RID,
DOMAIN_ALIAS_RID_ADMINS,
0, 0, 0, 0, 0, 0,
&adminGroup)) {

// Check if the current token is part of the Administrators group.
CheckTokenMembership(NULL, adminGroup, &isAdmin);
FreeSid(adminGroup);
}
return isAdmin;
}

int main() {
if (IsRunningAsAdministrator()) {
std::cout << "Program is running with administrator privileges.\n";
} else {
std::cout << "Program is not running with administrator privileges.\n";
}
return 0;
}

相关函数盘点

c++函数类型定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#ifndef WINIO_H
#define WINIO_H

#include <windows.h>

#ifdef __cplusplus
extern "C"
{
#endif

typedef struct tagPhysStruct
{
DWORD dwPhysMemSizeInBytes;
DWORD dwPhysAddress;
PBYTE pvPhysMemLin;
HANDLE PhysicalMemoryHandle;
} PHYSSTRUCT, *PPHYSSTRUCT;

// 定义函数指针类型
typedef BOOL (*InitializeWinIoType)();
typedef void (*ShutdownWinIoType)();
typedef PBYTE (*MapPhysToLinType)(PBYTE pbPhysAddr, DWORD dwPhysSize, HANDLE *pPhysicalMemoryHandle) ;
typedef BOOL (*UnmapPhysicalMemoryType)(PBYTE pbPhysAddr, DWORD dwPhysSize, HANDLE *pPhysicalMemoryHandle) ;
typedef BOOL (*GetPhysLongType)(PBYTE pbPhysAddr, PDWORD pdwPhysVal);
typedef BOOL (*SetPhysLongType)(PBYTE pbPhysAddr, DWORD dwPhysVal);
typedef BOOL (*GetPortValType)(WORD wPortAddr, PDWORD pdwPortVal, BYTE bSize);
typedef BOOL (*SetPortValType)(WORD wPortAddr, DWORD dwPortVal, BYTE bSize);

#ifdef __cplusplus
}
#endif

#endif // WINIO_H

以下是 WinIO API 提供的每个函数的详细介绍:


InitializeWinIo()

功能:初始化 WinIO 库。

说明:加载并初始化 WinIO 驱动程序,使其可以被应用程序调用。此函数是 WinIO 操作的起始点,必须在调用其他 WinIO 函数之前调用。

返回值:true 表示初始化成功,false 表示失败(可能是由于驱动未正确加载或权限不足)。


ShutdownWinIo()

功能:关闭 WinIO 库并卸载驱动。

说明:停止并卸载 WinIO 驱动程序,释放资源。所有使用 WinIO 进行的内存映射或端口操作应在调用此函数之前完成,以确保资源正确释放。

返回值:无。


*MapPhysToLin(PBYTE pbPhysAddr, DWORD dwPhysSize, HANDLE pPhysicalMemoryHandle)

功能:将物理内存地址映射到应用程序的虚拟地址空间。

说明:此函数接受一个 tagPhysStruct 结构,其中包含物理内存地址和所需的映射大小。函数会将指定的物理地址空间映射为进程可用的线性地址(虚拟地址),便于用户态程序对物理内存直接操作。

参数

  • PhysStruct:一个结构体,包含要映射的物理地址和映射区域大小。

返回值:返回映射后的虚拟地址(PBYTE),如果失败则返回 nullptr。

使用案例:

1
2
3
PBYTE pbLinAddr;   
HANDLE PhysicalMemoryHandle;   
pbLinAddr = MapPhysToLin(0xA0000, 65536, &PhysicalMemoryHandle);   

*UnmapPhysicalMemory(PBYTE pbPhysAddr, DWORD dwPhysSize, HANDLE pPhysicalMemoryHandle)

功能:取消先前的物理内存到虚拟地址的映射。

说明:用于解除物理内存映射关系,释放虚拟内存地址。此函数在不再需要访问物理内存时调用,以清理资源。

参数

  • PhysStruct:包含要解除映射的物理地址信息,与 MapPhysToLin 中使用的结构一致。

返回值:true 表示成功,false 表示失败。


GetPhysLong(PBYTE pbPhysAddr, PDWORD pdwPhysVal)

功能:读取指定物理地址的 32 位数据。

说明:该函数用于从指定的物理内存地址读取一个 32 位的值。适用于需要从特定硬件地址获取状态或配置信息的情况。

参数

  • pbPhysAddr:指向要读取数据的物理地址。
  • pdwPhysVal:输出参数,用于存储读取到的值。

返回值:true 表示读取成功,false 表示失败。


SetPhysLong(PBYTE pbPhysAddr, DWORD dwPhysVal)

功能:向指定物理地址写入 32 位数据。

说明:该函数用于将 32 位数据写入指定的物理内存地址,可用于配置或控制硬件设备。

参数

  • pbPhysAddr:指向要写入数据的物理地址。
  • dwPhysVal:要写入的 32 位数据。

返回值:true 表示写入成功,false 表示失败。


GetPortVal(WORD wPortAddr, PDWORD pdwPortVal, BYTE bSize)

功能:读取指定 I/O 端口的值。

说明:从指定 I/O 端口地址读取数据,用于控制与设备的直接通信。bSize 指定要读取的字节数,可以是 1、2 或 4,分别对应 8 位、16 位和 32 位数据。

参数

  • wPortAddr:I/O 端口地址。
  • pdwPortVal:输出参数,用于存储读取到的端口值。
  • bSize:要读取的字节数。

返回值:true 表示读取成功,false 表示失败。


SetPortVal(WORD wPortAddr, DWORD dwPortVal, BYTE bSize)

功能:向指定 I/O 端口写入值。

说明:将数据写入指定的 I/O 端口地址,用于向硬件设备发送命令或数据。bSize 指定要写入的字节数(1、2 或 4 字节)。

参数

  • wPortAddr:I/O 端口地址。
  • dwPortVal:要写入的值。
  • bSize:要写入的字节数。

返回值:true 表示写入成功,false 表示失败。


InstallWinIoDriver(PWSTR pszWinIoDriverPath, bool IsDemandLoaded = false)

功能:安装 WinIO 驱动程序。

说明:加载 WinIO 的内核驱动程序。可以指定驱动程序文件路径,是否按需加载驱动(IsDemandLoaded 为 true 时,驱动按需加载)。

参数

  • pszWinIoDriverPath:WinIO 驱动程序文件路径。
  • IsDemandLoaded:是否按需加载驱动,默认为 false(立即加载)。

返回值:true 表示安装成功,false 表示失败。


RemoveWinIoDriver()

功能:卸载 WinIO 驱动程序。

说明:卸载并删除 WinIO 的驱动程序,通常在不再需要硬件操作时调用。

返回值:true 表示卸载成功,false 表示失败。


I/O端口地址获取

在 PC 机上,标准的串口端口地址一般是:

COM1: 0x3F8

COM2: 0x2F8

COM3: 0x3E8

COM4: 0x2E8

并口通常是 0x378 或 0x278。

通过设备管理器获取

  1. 打开“设备管理器”(右键“此电脑” -> “属性” -> “设备管理器”)。
  2. 找到目标设备,右键点击并选择“属性”。
  3. 进入“资源”选项卡,查看 I/O 端口地址和中断请求号(IRQ)等资源分配信息。

注意,设备管理器主要适用于标准设备;某些设备可能不会在资源中显示 I/O 端口信息。

image-20241029134955695

使用系统命令

命令提示符中输入: msinfo32 进入系统信息窗口

在“硬件资源” -> “I/O”中可以找到设备的 I/O 地址映射。

I/O端口与内存映射I/O

I/O端口和内存映射I/O是两种不同的硬件访问机制

这两个是分开的地址空间,使用不同的CPU指令访问。这种设计是历史原因造成的,现代的ARM等架构一般只使用内存映射I/O

在x86架构中

内存地址空间

物理内存使用4GB地址空间(0x00000000-0xFFFFFFFF)

1
2
3
4
5
6
7
8
9
10
物理内存地址空间
+------------------+ 0xFFFFFFFF
| |
| 物理内存 | <- RAM、ROM等
| |
+------------------+
| |
| 内存映射I/O | <- 一些设备的寄存器映射到这里
| | 比如显存、网卡缓存等
+------------------+ 0x00000000

I/O端口空间

I/O端口使用独立的64KB地址空间(0x0000-0xFFFF)

1
2
3
4
5
6
I/O端口地址空间
+------------------+ 0xFFFF
| |
| I/O端口 | <- 独立的I/O地址空间
| | 用于直接与设备通信
+------------------+ 0x0000

I/O端口使用专门的cpu指令:

1
2
IN AL, DX   ; 从I/O端口读取数据
OUT DX, AL ; 向I/O端口写入数据

内存地址cpu指令:

1
2
MOV AL, [地址]  ; 从内存读取
MOV [地址], AL ; 写入内存

IRQ

IRQ(中断请求,Interrupt Request)是一种硬件信号,用于通知 CPU 需要处理某个事件。它的主要目的是协调硬件设备和 CPU 的交互,使设备可以及时通知 CPU,触发相应的处理流程。IRQ 在硬件和操作系统之间充当了“信使”的角色,确保关键事件不会被忽视。

IRQ 的主要作用

  1. 事件通知:当外部设备(如键盘、鼠标、硬盘等)需要 CPU 的关注时,会向 CPU 发送一个 IRQ 信号。这个信号会让 CPU 暂停当前任务,转而处理该事件。
  2. 资源共享:通过中断机制,CPU 可以高效地在多个任务之间切换,而无需一直等待设备完成操作。例如,打印机完成打印任务时会发出中断请求,而 CPU 无需一直等待,可以处理其他任务。
  3. 系统响应性:IRQ 提升了系统的响应性,确保设备请求能够被及时响应。例如,当网络接口收到数据包时,IRQ 会通知 CPU 进行处理。

工作流程

  1. 发送中断:设备向 CPU 发送中断信号。每个设备都有自己的 IRQ 通道,用于标识设备的中断请求。
  2. 中断处理:CPU 停止当前任务并跳转到对应的中断处理程序,这个程序由操作系统分配给每种中断类型。
  3. 恢复工作:CPU 完成中断处理后,返回到之前被中断的任务。

常见的 IRQ 应用场景

  • 键盘:按键时触发 IRQ,CPU 获取按键数据。
  • 硬盘:数据读写完成时触发 IRQ,CPU 继续处理数据。
  • 网络设备:收到数据包时触发 IRQ,CPU 处理网络数据。

IRQ 是多任务系统中实现高效设备管理和事件响应的关键机制。

计算机总线接口

计算机总线是连接计算机各个组件的通道,它们允许CPU、内存、外设等之间进行数据传输。在计算机发展过程中,出现了多种类型的总线,每种总线都有其独特的设计和应用场景

有ISA总线、AT总线、PCI总线和PCIE总线等等

ISA总线设备(老式设备)

ISA(Industry Standard Architecture)总线最早由IBM在1981年为其个人计算机(PC)推出。ISA总线是最早期的计算机总线之一,在20世纪80年代和90年代初广泛使用。

ISA卡是长条形的

有金手指接口

可能有地址选择跳线

img

物理地址是固定的,通过跳线或开关设置

例如经典的显存地址: 0xA0000

ISA设备通常使用以下固定地址范围:

  • 0xA0000-0xBFFFF: 显存区域
  • 0xC0000-0xDFFFF: ROM和设备内存映射区域

ISA总线在早期的PC中广泛使用,主要用于连接键盘、鼠标、打印机、调制解调器等外设。由于其速度和带宽限制,ISA总线在性能要求较高的应用中逐渐被淘汰

在设备管理器中查看ISA设备

在设备管理器窗口中,展开系统设备,查找ISA桥(通常带有ISA BridgeLPC Controller字样)

与PCI设备不同,ISA设备通常没有标准化的资源管理机制,所以Windows的Device Manager不一定会显示ISA设备的资源。

ISA设备的I/O端口和内存地址是固定的物理地址,不像PCI设备那样可动态分配

PCI/PCIE设备(现代设备)

物理地址是动态分配的

需要通过PCI配置空间读取设备分配到的基地址

1
2
3
// 正确的做法应该是:
DWORD baseAddr = GetPCIDeviceBaseAddress(); // 从PCI配置空间获取
pbLinAddr = MapPhysToLin((PBYTE)baseAddr, 65536, &hPhysicalMemory);

PCI总线

PCI总线在20世纪90年代和2000年代初广泛应用于PC、服务器和嵌入式系统中。它支持多种外设,如显卡、声卡、网卡、硬盘控制器等,显著提升了系统的性能和扩展性。

img

PCIE总线

PCIE(PCI Express)总线是PCI总线的继承者,由PCI-SIG(PCI Special Interest Group)在2003年推出。PCIE总线设计旨在满足高速数据传输需求,广泛应用于现代计算机系统中。

PCIE总线是现代计算机系统中最常用的扩展总线,广泛应用于PC、服务器、工作站和嵌入式系统。它支持高速显卡、固态硬盘(SSD)、网卡、存储控制器等设备,满足了高速数据传输和高性能计算的需求。

img

SECS/GEM通信协议

由于当前还没用到,暂时略

其通过一组标准化的 SECS/GEM 消息实现。这些消息定义了如何上传、下载、删除和验证配方(Recipe),从而实现对配方的集中管理和控制

SECS/GEM (SEMI Equipment Communications Standard/Generic Equipment Model) 是一种用于半导体制造设备和工厂自动化系统之间的通信协议标准,由SEMI (Semiconductor Equipment and Materials International) 定义。该协议主要应用于半导体生产过程,允许设备与工厂系统进行数据交换、控制和监控,帮助实现生产的自动化和智能化管理。

**SECS (SEMI Equipment Communications Standard)**:定义了设备和主控系统之间的数据传输协议。SECS 有两个版本:

  • SECS-I (SEMI E4):使用串行通信方式(RS-232)进行设备数据的传输,适用于数据量较小、实时性要求不高的环境。
  • HSMS (High-Speed SECS Message Services) (SEMI E37):采用基于 TCP/IP 的网络通信方式,适用于数据传输速度要求较高的场合。

**GEM (Generic Equipment Model)**:建立在 SECS 标准之上,定义了通用的设备模型,包括设备状态、报警管理、数据收集、事件报告、远程控制等功能。GEM 允许主控系统通过一组通用指令来控制设备的操作和数据采集,统一了不同设备的控制方式。

image-20241030103307442

SECS/GEM 协议的核心功能包括:

  • 状态管理:设备状态(例如运行、暂停、报警等)监控与报告。
  • 数据收集:按需或定时收集设备参数和状态数据,便于生产分析。
  • 事件报告:设备发生特定事件时,自动向主控系统报告。
  • 报警管理:设备报警时通知主控系统,帮助及时排查设备故障。
  • 远程控制:主控系统可向设备发送命令,实现设备的远程控制与管理。

SECS的标准是有问必有答:主动发送和被动响应

协议格式

参考文档

参考文档2

视频参考

HSMS 协议

HSMS 全称为 High Speed SECS Message Service,是一种高速的 SECS 消息传输协议,而自身只负责连接的控制,用于现代半导体设备高效的信息传输,承载整个通讯连接的底层基础消息,也有人称之为 SECS II,是 SECS I(E4)的替代品。

HSMS 协议拥有 4个状态,分别为

  • Not Connected
  • Connected
  • Not Selected
  • Selected。

之所以出现这四种状态,是因为 HSMS 是一种基于 TCP/IP 协议上的高层协议,当套字节(Raw Socket)建立连接后,还需要保证两端能互相确认对方能支持 HSMS 协议,故会有 Select 状态,只有 Selected 的 HSMS 连接才被视为有效可用的连接

对于 TCP Stream 而言,发送和接收的均为二进制数据包,它的整体长度大小随着消息长度大小而灵活决定的,但是 HSMS 的握手过程中,没有交换数据包需求,它为固定的 14 Bytes 消息。

img

硬件测量的校准算法

零点漂移

零点漂移指的是设备在没有输入信号时输出不为零的现象。这种漂移会影响测量的准确性,尤其在低信号测量中更为明显。可以通过以下方式进行补偿:

  • 读取零点反馈值:使用零点反馈数据(如 ui1st_2uA_Zero、ui2nd_2mA_Zero 等),来识别零点偏移。
  • 计算偏移量:将零点反馈值作为零点偏移量。例如,如果设备在没有输入时反馈为 5(理想应为 0),则漂移量为 +5。
  • 补偿当前测量数据:在每次测量时,减去这个偏移量,使得测量值回归到真实的零点基准。例如,实际测量值 M 经过零点补偿后的结果为 M_corrected = M - offset

这种零点补偿应在每次测量开始前进行,确保每次测量值都基于当前的零点状态。

增益校正

增益校正是一种基于多点校准反馈的校准算法,旨在消除设备的增益误差,以确保输出值与理想响应线性相关。这个算法的核心是计算设备的实际增益与理想增益之间的比率(增益误差因子),然后对测量值进行调整,使其更加准确。

增益校正算法的原理

增益误差发生在设备的输出与输入信号之间的放大比率不符合设计的理想比率时。假设理想情况下,输入信号和输出信号之间的关系为:

$$
y = G_{\text{ideal}} \times x
$$

其中:

  • y 是测量输出值。
  • $ G_{\text{ideal}} $ 是理想的增益(预期放大倍数)。
  • x 是输入信号(比如电流、电压等)。

然而,由于硬件误差等原因,实际测量响应会偏离理想值。为了校正这一偏差,我们引入了增益误差因子 K ,用公式表示为:
$$
K=\frac{G_{\mathrm{ideal}}}{G_{\mathrm{actual}}}
$$

获取校准反馈数据:通过多点校准测量不同输入点(例如 2uA、20uA、2mA 等)的实际输出,计算各点的增益。

应用增益校正因子 K :用每个输入点的校正因子来调整测量值 M ,使校正后的输出尽可能接近理想值:
$$
M_{\text{corrected}} = M \times K
$$
多点插值或拟合:为了在整个测量范围内进行校准,可以对校正因子进行插值或拟合,确保增益校正平滑地覆盖整个测量范围,而不仅限于已校准的点上。

接口和信号类型

  • DI(Digital Input) - 数字输入:用于接收开关量信号,比如按钮、限位开关等,输入信号是数字化的(通常是0或1)。

    在控制系统中,DI信号通常用于监测设备的状态,如设备是否运行、开关是否接通等。

  • DO(Digital Output) - 数字输出:用于输出开关量信号,控制设备如继电器、指示灯等,其输出信号为数字化的(0或1)。

    DO信号一般用于控制外部设备,系统通过改变DO信号状态来发出控制指令。

  • DA(Digital to Analog) - 数字-模拟转换:用于将数字信号转换为模拟信号。例如,控制器输出一个数字信号,通过DA模块转换成电压或电流信号去控制执行机构。

    通过AD转换,控制系统可以从外部获取精确的模拟信息,从而进行复杂的监测

  • AD(Analog to Digital) - 模拟-数字转换:用于将模拟信号转换为数字信号。例如,将传感器的电压或电流信号通过AD模块转换为控制器可以识别的数字信号。

    DA转换器将系统的计算结果或设定值转化为外部设备能够理解的模拟信号,实现对外部设备的精细控制

实际编程中,信号的处理方式总结如下:

  • DI:读取布尔值,表示开关状态。
  • DO:写入布尔值,控制设备开关。
  • AD:读取数值,表示传感器测量值。
  • DA:写入数值,调节设备参数(如电压、电流)。

设备的物理接口

端口编号

端口编号代表硬件接口的标识符,用来指示系统去访问或控制具体的物理引脚或接口。可以理解为是程序与硬件设备沟通的“地址”,让程序知道该去控制哪个具体的连接点

端口编号通常是设备或接口的逻辑编号,它帮助操作系统或开发者区分不同的接口或设备实例。端口编号是一个设备标识符,而不是一个物理地址。

在Windows中,串口可以被标识为COM1、COM2等,这些逻辑名称被称为“端口编号”。

端口编号用于标识设备的物理接口,每个接口通过唯一的编号或名称在编程中加以区分。控制系统中的不同硬件接口(DI、DO、AD、DA等)都有对应的端口编号,程序通过这些编号访问特定的物理端口。例如:

  • DI/DO端口编号:用于标识数字输入或输出端口。假设有一块控制板,编号DI_PIN_1可能代表一个按钮的输入端口,编号DO_PIN_2可能代表一个LED灯的输出端口。
  • AD/DA端口编号:用于标识模拟输入或输出端口。例如AD_PIN_1可能连接到温度传感器,DA_PIN_1可能用于输出一个电压控制信号。

在编程中,端口编号的作用是告诉程序具体访问或控制哪个物理接口。例如,假设编号1的端口连接到温度传感器,而编号2的端口连接到湿度传感器,程序会读取特定端口编号来获取各自的数据。

概念 作用和用途 示例 与端口编号的关系
I/O端口地址 独立的地址空间,通过I/O指令访问硬件寄存器 0x3F8(COM1端口地址) 操作系统将端口编号映射到对应I/O端口地址
映射内存地址 内存地址空间,通过内存指令访问硬件寄存器(如PCI设备) 0xFFFC0000 不直接与端口编号相关,直接映射内存地址
端口编号 设备标识符,用于逻辑上区分不同设备 COM1、COM2 通过驱动和系统资源管理器映射到I/O或内存地址

端口编号确定端口地址

  • 操作系统驱动程序:在Windows或Linux等操作系统中,驱动程序会管理设备的物理端口地址。操作系统提供了对端口的逻辑编号,例如串口的COM1、COM2,或者在Linux下的/dev/ttyS0、/dev/ttyS1等。当程序访问这些逻辑编号时,操作系统会自动查找到实际的硬件端口地址。
  • 硬件手册和设备资源表:在嵌入式或自定义硬件系统中,端口地址与端口编号的对应关系通常在硬件手册中详细列出。例如,串口设备COM1的I/O端口地址是0x3F8,而COM2的I/O端口地址是0x2F8。
  • BIOS/固件配置:在一些嵌入式系统中,I/O端口地址可能由BIOS或固件配置,并存储在硬件的资源表中。启动时,操作系统读取这些信息,将逻辑编号与实际I/O端口地址或内存地址对应起来。

GPIO(通用输入/输出端口)

GPIO串口是两种完全不同类型的接口,分别用于不同的场景和控制方式

  • GPIO(通用输入/输出端口):这是一个灵活的接口,可以用作输入或输出。GPIO通常用于简单的信号控制,比如检测按钮、控制LED灯、驱动继电器等。它们可以以高低电平(0和1)的形式接收和发出简单的开关信号。
  • 串口(Serial Port):串口是一种用于数据通信的专用端口,通常用于点对点的串行通信,如UART(通用异步收发器)、RS232等协议。串口用于在两个设备之间传输数据,以字节流的形式进行双向通信。串口端口编号通常标为COM1、COM2等(在Windows系统中),而在Linux系统中,可能标为/dev/ttyS1等。

GPIO(General-Purpose Input/Output)是一类可以由用户或程序控制的输入/输出端口,用于处理简单的开关信号,常见于微控制器(如Arduino、树莓派)和单片机中。GPIO可以设定为输入或输出模式:

  • 输入模式:GPIO端口用于读取外部设备的状态,比如检测按钮是否按下。
  • 输出模式:GPIO端口用于控制外部设备,比如点亮LED灯、启动继电器。

GPIO端口的优点是灵活,可以自由配置为输入或输出,并且适用于多种外设。每个GPIO端口有唯一的编号来标识硬件引脚,程序可以通过编号控制特定端口的状态。

假设有一个GPIO端口PIN_5,可以用一下方式设定模式和操作:

1
2
3
4
5
6
7
8
// 将GPIO PIN_5设置为输入模式,用于读取按钮状态
SetGPIOMode(PIN_5, InputMode);
bool buttonPressed = ReadGPIO(PIN_5);

// 将GPIO PIN_6设置为输出模式,用于控制LED
SetGPIOMode(PIN_6, OutputMode);
SetGPIO(PIN_6, true); // 点亮LED
SetGPIO(PIN_6, false); // 关闭LED

数字输入(DI):GPIO端口作为数字输入,用于读取开关量信号。

数字输出(DO):GPIO端口作为数字输出,用于控制LED、蜂鸣器、继电器等。

模拟输入(通过ADC):在某些硬件上,GPIO可以连接到AD模块,实现模拟信号的采集。

模拟输出(通过DAC或PWM):GPIO也可通过数字-模拟转换器或脉宽调制(PWM)输出模拟信号,用于控制电机转速、亮度调节等。

端口通信

对于直接访问硬件I/O端口,Windows系统出于安全和稳定性的考虑,限制了用户程序对I/O端口的直接访问,尤其是在Windows NT及其后续系统中

对于低层I/O端口访问,通常需要使用驱动程序开发工具(如Windows Driver Kit, WDK),这涉及对硬件端口的读写操作(如inb、outb指令)。在用户态下无法直接使用这些指令访问I/O端口。

如果你需要直接操作I/O端口(如工业控制的PLC或I/O卡),则通常需要使用厂商提供的驱动库,或者编写一个内核模式驱动。

通常在内核模式下进行。Windows驱动程序开发中使用Windows Driver Kit (WDK) 提供的函数或者使用硬件访问指令来操作I/O端口。

  • I/O端口(Port I/O)

    I/O端口是通过特殊的指令和独立的地址空间与设备通信的一种方式。它具有独立于主内存的I/O地址空间。在x86架构中,CPU通过专门的INOUT指令与I/O端口通信。

  • 内存映射寄存器(Memory-Mapped I/O, MMIO)

    内存映射寄存器是通过内存地址与硬件设备通信的机制。即设备的寄存器被“映射”到CPU的主内存地址空间中,系统将设备的寄存器地址视为内存中的地址。

MMIO访问的速度往往更快,尤其在现代计算机体系中,与CPU内存访问相似的操作减少了I/O指令切换的开销。同时,MMIO允许映射更多的地址空间。

特性 I/O端口操作函数 内存映射寄存器操作函数
地址空间 专用I/O地址空间 主内存地址空间
地址含义 I/O端口地址(非内存地址) 实际内存地址
访问指令 使用in、out等I/O指令访问 使用标准内存指令访问
适用设备 简单的I/O设备(串口、并口等) 复杂设备(显卡、网卡、PCI设备等)
访问速度 相对较慢 相对较快
映射 不需要,直接使用I/O端口地址 需要,通过内存映射(如MmMapIoSpace)
  1. IO地址空间:在许多嵌入式系统中,外设和IO设备被映射到特定的地址空间。这些地址通常在硬件设计阶段固定,处理器通过访问这些地址来与硬件设备进行通讯。
  2. 中断处理:许多嵌入式系统使用中断来实现对DI事件的响应。当一个DI发生变化时,可以触发一个中断,处理器会暂停当前执行的任务,转而去执行与该中断相关的ISR(中断服务程序)。
  3. 直接内存访问(DMA):一些嵌入式系统可以使用DMA来高效处理大量数据的传输,而无需占用CPU的时间。

I/O端口操作函数

Windows内核提供了专门的I/O端口访问函数,用于在不同数据位宽(8位、16位、32位)读取端口数据。

函数 描述
READ_PORT_UCHAR 从端口读取8位(1字节)的数据,返回UCHAR类型。
READ_PORT_USHORT 从端口读取16位(2字节)的数据,返回USHORT类型。
READ_PORT_ULONG 从端口读取32位(4字节)的数据,返回ULONG类型。
1
2
UCHAR data = READ_PORT_UCHAR((PUCHAR)0x3F8);  // 读取I/O地址0x3F8的数据
WRITE_PORT_UCHAR((PUCHAR)0x3F8, data); // 写入数据到I/O地址0x3F8

内存映射寄存器操作函数

READ_REGISTER_*系列函数用于读取内存映射寄存器,通常用于PCI、PCIe等设备。

与I/O端口不同,内存映射寄存器通过直接映射到内存的地址访问,因此使用这类函数更适合访问内存映射I/O端口

函数 描述
READ_REGISTER_UCHAR 从寄存器读取8位(1字节)的数据,返回UCHAR类型。
READ_REGISTER_USHORT 从寄存器读取16位(2字节)的数据,返回USHORT类型。
READ_REGISTER_ULONG 从寄存器读取32位(4字节)的数据,返回ULONG类型。
1
2
3
// 假设寄存器地址0xFFFC0000已映射到RegisterAddress
UCHAR data = READ_REGISTER_UCHAR((PUCHAR)0xFFFC0000); // 从映射内存地址读取数据
WRITE_REGISTER_UCHAR((PUCHAR)0xFFFC0000, data); // 写入数据到映射内存地址

使用指令(在特定硬件或CPU架构下)

在驱动开发中,in和out汇编指令用于直接读取或写入IO端口适合在嵌入式环境或裸机开发下工作。在Windows驱动中,不建议直接使用汇编,而是应使用上面的内核函数。

注意事项

  • 地址空间:在进行I/O端口访问前,必须确保端口地址已经分配并映射到当前驱动的地址空间。
  • 权限:必须运行在内核模式下,并且确保操作系统为该驱动授予访问硬件的权限。
  • 同步:某些端口访问可能需要同步,以确保数据的完整性和一致性。

PCIE通信原理

PCI发展史

最早期的电脑,CPU连接声卡,有声卡的接口;连接硬盘有硬盘的接口;连接网卡有网卡的接口,而且不同主板的接口还不一样

为了解决这个问题,为了统一硬件规格和标准,IBM公司联合intel公司,给它的PC和外围设备制定了一个标准的接口,即ISA总线

ISA总线是一种16位并行总线,带宽最大8MB/s,ISA总线有的缺点如下:

  • 无法即插即用:如插上一个ISA总线的声卡,需要手动配置一些软件参数,才可以使用
  • 最多支持6个外围设备

后面又慢慢发展到32位的EISA总线,VESA总线,MCA总线,不过都是昙花一现,知道PCI总线横空出世,并得到了主流厂商的认可,并迅速统一了各类总线,PCI总线直接一统天下

PCI的第一代的带宽就已经达到了133-266MB/s

Bus Type Clock Frequency Peak Bandwidth (32 - bit - 64 - bit bus) Number of Card Slots per Bus
PCI 33 MHz 133 - 266 MB/s 4 - 5
PCI 66 MHz 266 - 533 MB/s 1 - 2
PCI - X 1.0 66 MHz 266 - 533 MB/s 4
PCI - X 1.0 133 MHz 533 - 1066 MB/s 1 - 2
PCI - X 2.0 (DDR) 133 MHz 1066 - 2132 MB/s 1 (point - to - point bus)

由于显卡的需求要求的速度越来越快,PCIE诞生

PCI与PCIE根本上的差别如下:

  • PCI总线使用并行总线结构,在同一条总线上所有外部设备共享总线带宽,会出现不同设备抢占总线的情况,所有带宽还是有限
  • PCIE:点对点的传输,每一个外部设备独自拥有一条总线

image-20241128162415296image-20241128162611168

还有一个很大的差别在于:PCIE具有向后兼容性,即新的协议会兼容旧的协议

PCIe 是向后兼容的,这意味着高版本的 PCIe 设备可以在低版本的 PCIe 插槽上工作,但会以低版本的速度运行。例如,PCIe 3.0 的设备可以在 PCIe 2.0 的插槽上工作,但数据传输速率会降低到 PCIe 2.0 的水平。

如: PCIE可以兼容PCI,PCIE3.0可以兼容PCIE2.0,PCIEx8可以兼容PCIEx2等等

实际的数据传输速率会受到设备和插槽中较低版本的限制

桥Bridge: 方便CPU和外设进行通信

image-20241128163318990

  • 北桥芯片: 负责速率比较快的外设,比如支持PCI的设备
  • 南桥芯片: 负责速率比较慢的外设,比如麦克风,键盘等等

PCIE

PCIE饱和式学习的视频参考

PCI-Express(peripheral component interconnect express),简称PCIE,是一种高速串行计算机扩展总线标准,主要用于扩充计算机系统总线数据吞吐量以及提高设备通信速度。

特点:

  • 点对点,全双工通信
  • 路由方式简单
  • 基于数据包协议传输

PCIE本质上是一种全双工的的连接总线,传输数据量的大小由通道数lane决定的。一般,1个连接通道lane称为X1,每个通道lane由两对数据线组成,一对发送,一对接收,每对数据线包含两根差分线。即X1只有1个lane,4根数据线,每个时钟每个方向1bit数据传输。依次类推,X2就有2个lane,由8根数据线组成,每个时钟传输2bit。类似的还有X12、X16、X32。

image-20241128154205858

各版本速度

版本 编码方案 传输速率 X1 吞吐量 X4 吞吐量 X8 吞吐量 X16 吞吐量
1.0 8b/10b 2.5GT/s 250MB/s 1GB/s 2GB/s 4GB/s
2.0 8b/10b 5GT/s 500MB/s 2GB/s 4GB/s 8GB/s
3.0 128b/130b 8GT/s 984.6MB/s 3.938GB/s 7.877GB/s 15.754GB/s
4.0 128b/130b 16GT/s 1.969GB/s 7.877GB/s 15.754GB/s 31.508GB/s
5.0 128b/130b 32 或 25GT/s 3.9 或 3.08GB/s 15.8 或 12.3GB/s 31.5 或 24.6GB/s 63.0 或 49.2GB/s

发展历史:

image-20241128164432529

PCIE的配置空间

PCIE有三个相互独立的物理地址空间:设备存储器地址空间、I/O地址空间和配置空间。配置空间是PCIE所特有的一个物理空间。由于PCIE支持设备即插即用,所以PCIE设备不占用固定的内存地址空间或I/O地址空间,而是通过配置空间来实现地址映射的

系统加电时,BIOS检测PCIE总线,确定所有连接在PCIE总线上的设备以及它们的配置要求,并进行系统配置。所以,所有的PCIE设备必须实现配置空间,从而能够实现参数的自动配置,实现真正的即插即用。

边沿检测器

用于过滤干扰信号

重点理解: 这样就可以在上升沿或下降沿的时候做某种处理,而且还能去干扰,等于在无序中得到了某种执行的时间点

状态变化检测:

  • 通过比较前后两次状态来判断变化方向
  • 上升沿: false -> true
  • 下降沿: true -> false
  • 在指定的去抖时间内忽略状态变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class EdgeDetector
{
private bool? _lastState; // 上一次的状态
private bool? _currentState; // 当前状态
private readonly TimeSpan _debounceTime; // 去抖时间
private DateTime _lastDebounceTime; // 上次去抖的时间

public EEdgeType EdgeType { get; set; } // 边缘类型

public void UpdateState(bool? newState)
{
_lastState = _currentState; // 保存上一次状态
_currentState = newState; // 更新当前状态

// 如果状态无效则返回None
if (!_currentState.HasValue || !_lastState.HasValue)
{
EdgeType = EEdgeType.None;
return;
}

//去抖动
DateTime currentTime = DateTime.Now;
if ((currentTime - _lastDebounceTime) < _debounceTime)
return;

_lastDebounceTime = currentTime;

// 检测边缘类型
if(_currentState.Value && !_lastState.Value) // 从false变为true
{
EdgeType = EEdgeType.RisingEdge; // 上升沿
}
else if(!_currentState.Value && _lastState.Value) // 从true变为false
{
EdgeType = EEdgeType.FallingEdge; // 下降沿
}
else
{
EdgeType = EEdgeType.None; // 无变化
}
}
}

public enum EEdgeType
{
None,//无状态变化
RisingEdge,//上升沿
FallingEdge,//下降沿
}

使用案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;

class Program
{
static void Main(string[] args)
{
// 创建 EdgeDetector 实例,设置去抖时间为 50 毫秒
var edgeDetector = new EdgeDetector(TimeSpan.FromMilliseconds(50));

// 模拟状态变化
bool?[] states = { false, true, true, false, false, true };

foreach (var state in states)
{
// 更新状态
edgeDetector.UpdateState(state);

// 打印当前的边缘类型
Console.WriteLine($"Current State: {state}, Edge Type: {edgeDetector.EdgeType}");

// 模拟状态变化的延迟,例如 20 毫秒
Thread.Sleep(20);
}
}
}

行业相关通信

  • OPC UA 工业通用协议
  • SECS/GEM 半导体行业通信标准
  • MQTT 发布订阅者轻量级通信库
  • EtherCAT 倍福的工业以太网协议

hslCommunication,通用plc测试软件

基于时间帧监控整体变化并去抖动算法

实现核心原理在于

只有当新状态在 _debounceTime 内保持稳定时,才更新状态并触发回调

  • 每次状态变化时,将新状态缓存为 _pendingState,并记录开始时间。
  • 只有当新状态持续超过 _debounceTime 且未发生变化时,才更新 _currentState 并触发回调。
  • 如果在 _debounceTime 内状态再次变化,则重置待处理状态和计时。

具体实现:

  • 状态稳定检查:每次收到新状态时,如果与 _currentState 不同,缓存为 _pendingState,并记录开始时间 _pendingStateTime。只有当 _pendingState 持续超过 _debounceTime 且未变化时,才更新 _currentState 并触发回调。
  • 状态回退处理:如果新状态与 _currentState 相同,但与 _pendingState 不同,清除 _pendingState,以处理状态回退的情况(如短暂毛刺后回到原状态)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/// <summary>
/// 去抖动的字符串改变监视器
/// </summary>
public class StringChangeDetector
{
private string _lastState = null; // 上一次的状态
private string _currentState = null; // 当前状态
private string _pendingState = null; // 待处理的状态(用于去抖动)
private readonly TimeSpan _debounceTime; // 去抖时间,默认100毫秒
private DateTime _lastDebounceTime; // 上次去抖的时间
private DateTime _pendingStateTime; // 待处理状态的开始时间

public event Action<string, string> OnStateChanged = null; // 状态变化时的回调函数

public StringChangeDetector(Action<string, string> onStateChanged, TimeSpan? debounceTime = null)
{
_debounceTime = debounceTime ?? TimeSpan.FromMilliseconds(100); // 默认去抖动时间为100毫秒
OnStateChanged = onStateChanged;
}

public void UpdateState(string? newState)
{
DateTime currentTime = DateTime.Now;

// 如果状态无效则返回
if (newState == null)
{
Console.WriteLine("新状态为空直接返回");
return;
}

// 如果当前状态为空(初次设置)
if (_currentState == null)
{
_currentState = newState;
_lastDebounceTime = currentTime;
Console.WriteLine("初次设置状态");
return;
}

// 如果新状态与当前状态相同
if (newState.Equals(_currentState))
{
// 如果有待处理状态,检查是否需要清除
if (_pendingState != null && !_pendingState.Equals(newState))
{
Console.WriteLine($"状态回退到当前状态 {_currentState},清除待处理状态");
_pendingState = null;
}
Console.WriteLine("无变化");
return;
}

// 如果去抖动时间为0,直接更新状态并触发回调
if (_debounceTime == TimeSpan.Zero)
{
_lastState = _currentState;
_currentState = newState;
_lastDebounceTime = currentTime;
Console.WriteLine($"去抖动时间为0,直接触发变化函数");
OnStateChanged?.Invoke(_lastState, _currentState);
return;
}

// 如果新状态与待处理状态相同,检查是否稳定
if (_pendingState != null && _pendingState.Equals(newState))
{
if ((currentTime - _pendingStateTime) >= _debounceTime)
{
// 状态稳定,更新当前状态并触发回调
_lastState = _currentState;
_currentState = _pendingState;
_lastDebounceTime = currentTime;
_pendingState = null;

Console.WriteLine($"触发变化函数");
OnStateChanged?.Invoke(_lastState, _currentState);
}
else
{
Console.WriteLine($"待处理状态 {newState} 未稳定,等待中...");
}
return;
}

// 新状态与当前状态不同,且与待处理状态也不同,更新待处理状态
_pendingState = newState;
_pendingStateTime = currentTime;
Console.WriteLine($"状态变化,缓存待处理状态为 {newState}");
}
}

上面是绝对变化的字符串监视

如果需要字符串表示数值,需要有容差数值部分,只有变化的时候才触发修改,也可以相应的修改代码以实现

信号分析理论知识

信号的分析分为:

  • 时域分析

    时域分析是直接在时间维度上观察信号变化的方法,这是最直观的信号观察方式,横轴是时间,纵轴是信号幅度。时域分析关注信号的波形、周期、上升/下降时间等特性

  • 频域分析

    频域分析是将信号从时间域转换到频率域进行分析的方法,横轴是频率,纵轴是幅度或功率。它通过傅里叶变换等工具将信号分解为不同频率的正弦波分量,可以揭示信号的频率组成

  • 幅值域分析

    幅值域分析关注的是信号幅值的统计分布特性,不涉及时间或频率信息。它分析信号幅度取值的分布情况,使用概率密度函数、直方图等工具

核心视角 横轴 (X轴) 纵轴 (Y轴) 关键工具/图 擅长领域 主要缺点
📈 时域分析 信号随时间的变化 时间幅度 示波器波形图、相关分析 直观观察波形、捕捉瞬时事件、测周期/幅度/上升时间 难以看清内部频率组成
📊 频域分析 信号的频率组成 频率幅度/功率/相位 FFT、频谱图、功率谱图、伯德图 揭示频率结构、分离信号与噪声、分析系统频率响应 不够直观、丢失时间信息 (传统方法)
📉 幅值域分析 信号幅值的统计分布 幅值 直方图、概率密度函数图、累积分布图 分析强度分布、极值概率、随机性、信号量化/压缩基础 完全丢失时间和频率信息