转:使用P/Invoke来开发用于与串行设备通讯的.NET基类(翻译)
首先应该介绍一个.NET下与串行设备通讯的基类,这个就是微软著名的:NetSerialComm.exe,介绍的文章大家请访问:http://msdn.microsoft.com/msdnmag/issues/02/10/NETSerialComm/default.aspx查看,相信很多人都看过了,但是这个控件相应的资料却很少,不过这个文章本身已经说得很清楚了,不过英文有些难看,终于在网上找到一篇翻译的文章,是DOC格式的,先转贴上来,相信大家会有一定的了解,过几天我会把自己的一些体会发上来,大家再斧正吧。闲话少说,下面是译文:
使用P/Invoke来开发用于与串行设备通讯的.NET基类
翻译:Tavor
这篇文章可以帮助你熟悉于用C#开发与RS232的通讯。
难易程度 1、2、3
本文相关代码下载:NetSerialComm.exe (89KB)
导引:在.NET环境下编写与RS252串口通信的应用程序的唯一方法,就是引用过时了的并且有点限制的MSComm ActiveX控件。这篇文章介绍了用C#安全代码编写一个多线程的,且时尚的与RS232通讯的基础类库。这个类库使用平台调用服务(即Platform Invocation Services)来与Win32 API直接交互。程序员可以通过继承在任何.NET语言下使用这个类库;这个文章还探讨了一些用C#和Visual Basic .NET写的示例程序。
微软.NET框架类库(FCL)提供了相当全面广泛的功能来替代在Win32@API编程下原有的功能,特别是C#与Visual Basic@.NET语言的可互访性。尽管如此,RS232串口通讯是.NET框架类库是明显未被涉及的方面之一。从而很正常的,很多人就把这些接口当成了遗弃物。目前,你还是通过软件层与串行调制解调器进行通讯,比如TAPI与PPP。其它从前使用这些接口的设备现在正在向USB接口移植。不过,一些专业的RS232设备的驱动程序仍然有通讯的需要,比如GPS接收器,barcode and swipe card readers,可编程控制器和一些可预见的程序员将来继续使用的设备。(关于RS232接口的规格信息,可以参看"Hardware Specs".)
平台调用服务 (P/Invoke)是能够使用托管的CLR代码调用非托管DLLs的.NET技术,包括那些实现Win32 API的DLLs。在这篇文章里,我将用C#把与RS232通信的API封装到CLR托管的代码中去。生成的基类库将使用.NET语言开发特定设备的驱动变得相对容易。完整的代码和示例可以从这篇文章顶部的链接中下载到。
设计原理
在把Win32串口通讯功能封装到托管类的时候,这里至少有四种实现方式让你选择:
1.使用P/Invoke把API函数、常数、结构作为静态成员封装到托管类中。虽然我在里面使用了这种方法,但没有这个类暴露给程序员。
2.写一个流的处理角色。这是.NET框架对文件、控制台、网络通讯的一般地、可扩充的提取。咋一看,这个很有吸引力,但近距离审视的时候,这个更适用于传统的调制解调器,而不适合于现在基于命令响应语法的设备。
3.做一个直接替换MSComm OLE Control Extension(OCX)控件的替代品。换句话说,新建一个封装了API文件处理并提供许多基本的方法和事件(比如,Open,Close,Read,Write等等)。你可以在应用程序类里初始化这个类库里的一个对象来达到重用的目的――那就是说,通过COM-style的集合。
4.写一个应用程序需要继承的基类。这是一个充分体现.NET优点――运行时对不同语言继承的无关性――的面向对象的方法。这些基础的方法被继承进应用程序对象中,虚方法将被使用,而不是使用事件。这个应用程序对象将巧妙地提供一个适用于真实RS232设备公共接口(比如,一个GPS接收器驱动可能拥有一些关于经度和纬度的公共属性)。
图1 继承层次
第一个库类,CommBase,对数据格式化、更容易开启与关闭通讯接口、发送与接收字节数据、输入与输出交互的控制等都不提供任何实现。
使用基类
两个应用程序的例子,BaseTerm和LineTerm,可以下载的到。他们可以用于与任何串口设备进行一般用途的交流,包括调制解调器。我先从一个用户的观点来简单的看一下BaseTerm,然后再更细致地分析一个LineTerm的源代码。
图2 BaseTerm
BaseTerm(参看图2)是一个完全基于Windows@Form的应用程序,它继承自CommBase并提供一个基于字节的可调节终端。点击Settings按纽可以打开了一个对话框来对通讯设置的全部参数进行设置(参看图3)。这个窗口上菜单可以帮助用户以结构化的XML文件来保存或加载这些设置参数,也保存大量用于普通流控制模式的设置。提示解释了各个设置项的用法。一旦保存为XML,当你再次启动这个程序的时候,你可以在命令行格式下设定这个文件。一旦连上线,打出的字符就可以立即被传送到远端设备中支。键盘上的按键发送合适的ASCII字节,如果你想发送键盘上没有的编码,你可以使用“escape facility”.
图3 Comm 设置
可以通过输入<符号来开启“escape”.然后,输入任一个ASCII控制码名字或一个位于0或255之间的十进制数。可通过输入一个>符号来结束这个“escape”,它可以把一个合适的ASCII码立即发送过去。当需要传输<符号时可以通过把它输入两次的方式进行。你可以在设置对话框中标记为”Xon Code”的下拉框中查看所有有效的ASCII控制符的名称,站点http://www.asciitable.com/还提供其它有用的信息。在终端窗口上大的文本框上将显示所有的ASCII形式或16进制形式(没进一步分析)的字节。
Imports JH.CommBase
Public Class LineTerm
Inherits CommLine
Public Sub SendCommand(ByVal s As String)
Send(s)
End Sub Protected Overrides Sub OnTxDone()
Console.WriteLine("TRANSMISSION COMPLETE")
Prompt()
End Sub Module Module1 Sub Main() Dim t As New LineTerm() Dim c As String Console.WriteLine("Press ENTER to open com port") Console.ReadLine() |
图四LineTerm示例代码
图4向我们展示了这个例子的完整源代码。在这第一行,我引入了库的命名空间。然后我建立了一个新的类,LineTerm,它继承自CommLine。它提供了打开和关闭这两个公共方法(实际上是继承自CommBase类),还有保护型方法发送,且我把它做成公共的当作为SendCommand的时候。在我的新类里,我重载了基类中大量的虚方法。在打开窗口配置通讯端口时调用了CommSettings方法;它会返回一个已经初始化了的CommBaseSettings对象。
这里我实际上使用了CommLineSettings,因为它继承自CommBaseSettings.在这个方法的最后两行,首先我传递了一个继承自CommLine的对象给Setup方法,并把它返回给CommBase类。所有的设置项都是公共的成员,可以直接被设定,但这里也有一个辅助方法,SetStandard,它可以把CommBase类自动配置成最常用的配置。你也许需要编辑这个方法及终端线、过滤成员的参数来适合你的可用于测试的设备。
应用程序的主方法只是简单的创建了一个我的类的实例,并调用了Open方法,并提供了一个可用于发送字符串和显示所收到的字符串的命令行界面。共有两个方法来完成这个,阻塞(blocking)和非阻塞(non-blocking)。使用SendCommand来启动非阻塞通讯。这个方法立刻返回,不久发送结束,重载的OnTxDone方法将报告结果。稍后,当远端设备完成了一个响应信号的输入,重载的OnExLine方法会在控制台上显示出结果。此时,主进程等待用户输入,但也可能它正在进行其它的工作。如果你注释掉SendCommand并用TransactCommand来替代,将会同样地使用阻塞式通信。此时,主进程会一直处于阻塞模式,直到出现有效地回应。你可以静静地等着从OnTxDone方法返回的结果信息,但代替从OnRxLine方法返回的收到的消息,你将看到从TransactCommand方法返回的回应的信息。
图5 GPS的流控制
在一个真正的应用程序中,比如GPS接收器的驱动程序,你不可能让它像我在示例中所仅实现的Send和Transact公共方法一样。相反,你需要提供那些能够表现这个设备功能的所有公有方法和属性(比如,速度和强度属性,或者比如PositionChanged的事件)。这些方法必须集合必要的命令。使用Transact方法,并把回应释放转化出返回值。图5就是介绍用于这类设备的流控制的。
发送
在串口通信中,在大多数情况下,发送信息比接收信息容易多了。对于接收信息,你也许正对远端设备胡思乱想,然而对于传输,你仍然可以控制时间。尽管如此,一般的位于2到20000波特的传输速率与计算机以千兆赫的速率相比,你可能不想待在一边等待传输的完成。Win32 API把串口通信看成对文件操作的一个特例,并使用了并称作与I/O交迭的技术来提供非阻塞式操作。
CommBase类提供了Open的公共方法,它使用了Win32API的CeeatFile方法来打开了一个串口,并把操作系统处理的结果作为一个私有成员变量存储起来:
hPort = Win32Com.CreateFile(cs.port, |
第一个参数是string类型的端口名,常常是COM1;或者是COM2;但在这里,你可以使用任何名字,所以我使用了名字而不是一个数字。我还没有方法来决定一串有效的端口名,所以选择一个可以让调用者尝试可以打开任一端口,并接受可以失败的事实。当端口存在但正被另一个应用程序使用时也可能失败。我使用FILE_FLAG_OVERLAPPED来描述发生这个文件句柄上的所有操作为非阻塞的,而其它的参数对于串口通信来说只是个样子而已。
Win32Com是一个封闭了API函数、结构、常数的容器型的辅助类,我将通过P/Invoke调用它。CreatFile在C#中像如下进行声明:
[DllImport("kernel32.dll", SetLastError=true)] |
各种常数也在这里被定义,比如:
internal const UInt32 FILE_FLAG_OVERLAPPED = 0x40000000; |
因为现在几乎没有工具支持P/Invoke,我不得不自己手动来定义这些。关键的资源包括Win32文档和用C++语言的提供的头文件。Visual Studio .NET中出色的文件搜索引擎对于在头文件中搜索定义是非常有用的。(我仅用这些作文档用而已,你在编译库文件的时候是不需要这些的。)关于interop marshaling的充分讨论,关于把托管数据类型翻译为非托管的API所使用的C语言定义的部分,不在本文讨论的范围之内。然而,你可以从Open方法中的另一段代码片段来理解这其中到底发生了什么:
wo.Offset = 0; wo.OffsetHigh = 0; if (checkSends) wo.hEvent = writeEvent.Handle; else wo.hEvent = IntPtr.Zero; ptrUWO = Marshal.AllocHGlobal(Marshal.SizeOf(wo)); Marshal.StructureToPtr(wo, ptrUWO, true); |
这里,wo是Win32Com.OVERLAPPED类型的局部变量,ptrUWO是一个IntPtr类型的私有类变量。Marshal是System.Runtime.InteropServices提供的用于对interop marshaler进行存取的全局对象。在这个代码里,当调用外部函数时,我手动处理那些marshaler平常自动处理的事务。首先,分配一块大小合适的非托管内存,继而把托管结构的内容拷贝到其中,根据需要重新对内存进行分配。在这个功能调用之后,marshaler将使用Marshal.PtrToStructure来显示拷贝的内容,然后Marshal.FreeHGlobal将释放内存。因为API使用OVERLAPPED结构的特殊方式,我手动来完成这个操作。我将在WriteFile方法中对它设值,但在这个调用返回时,操作系统将继续使用它。
不久,我将调用GetOverlappedResult方法,再次描述同样的的结构。如果我这些留给自动配制,这任务在两次调用间的非托管内存将被再次分配。如果这样的话,unmarshaling就不再是必须的了,因为这些域就再也不需要被存取了。尽管如此,当端口关闭时这些内存必须被释放掉:
if (ptrUWO != IntPtr.Zero) Marshal.FreeHGlobal(ptrUWO); |
用这些替换的基础代码,实际上发送一组字节就是非常直接的了:
if (!Win32Com.WriteFile(hPort, tosend, (uint)writeCount, out sent, ptrUWO)) if (Marshal.GetLastWin32Error != Win32Com.ERROR_IO_PENDING) ThrowException("Unexpected failure"); |
参数tosend是字节数据的指针;writeCount是字节数据的长度;参数sent将返回实际发送的字节的数量;参数ptrUWO是先前创建的非托管版的OVERLAPPED指针。正常情况下,这个方法将返回false,错误代码将是ERROR_IO_PENDING.这是一个表明操作因为排队而不能立即被执行的虚假错误。其它的错误码表明相应操作是不能进行排队队列。由于串口硬件的缓存功能及发送短的字符串,这个操作也许可以立刻被完成,这样的话这个方法将会返回true.
在发送新数据之前,对先前Send方法的结果将会进行出错条件及超时值进行检查。(奇怪地是,API把挂起的操作作为错误来处理,但是超时也许是非常正常的――侦测的唯一方式发送少量的几个字节而不是排队。消除这种异常是写这个封装库的一个乐趣!)尽管我允许多个发送的挂起,其中每一个都拥有自己的OVERLAPPED结构,它将会增加大量的复杂性。代替这个的是,我已经阻塞了并发的Send直到先前的一个完成。如果阻塞是一个问题,可以通过设置checkAllSends成员为false来关闭这种功能,在这种情况下,OVERLAPPED结构可以被重用,且不能保证所有错误和超时可以被捕捉到。
接收
也许你已经猜到,接收数据只是简单的调用了ReadFile这个API方法。就和先前所提到的,难点不是在接收上,而是在何时接收。为了避免应用程序员不断地检查数据,一些回调形式的设置就是必须的了。工作线程调用的虚方法可以实现这种功能。CommBase在接收到每一个字节的时候就调用一个虚方法。这个方法是重载自CommLine类的用来缓冲字节的,并且当线路终端连接器接收到时调用另一个虚方法。
rxThread = new Thread(new ThreadStart(this.ReceiveThread)); |
在私有方法ReceiveThread中启动一个新线程来运行这段代码。所需的最后一行代码令人我很是吃惊;我假使这个新的拥有更高优先级的将取代Start命令里中的原线程。某些情况下却不是,这就引起了麻烦,因为当它第一次需要被调用的时候,工作线程并不常常是准备好了的。第二次试验的时候,我使用了Sleep(0),因为在文档中是建议这种取得运行权时不要浪费任何时间(几乎是一毫秒的优势缘故),但实际上这根本不起任何作用。
ReceiveThread是一段死循环代码,仅有一种情况可以打破这个死循环。我使用下面这行代码在关闭端口时终止线程:
rxThread.Abort; |
在这个线程里,它抛出了一个ThreadAboutException异常,通过用于清理的catch子句捕捉并结束它。Finally子句也会被使用,但这样的话,也就没有什么分别了,因为只有通过一个异常才能使其退出。
2
3 byte[] buf = new Byte[1];
4 uint gotbytes;
5
6 AutoResetEvent sg = new AutoResetEvent(false);
7 Win32Com.OVERLAPPED ov = new Win32Com.OVERLAPPED();
8 IntPtr unmanagedOv = Marshal.AllocHGlobal(Marshal.SizeOf(ov));
9 ov.Offset = 0;
10 ov.OffsetHigh = 0;
11 ov.hEvent = sg.Handle;
12 Marshal.StructureToPtr(ov, unmanagedOv, true);
13
14 uint eventMask = 0;
15 IntPtr uMask = Marshal.AllocHGlobal(Marshal.SizeOf(eventMask));
16
17
18
19 try
20 {
21 while(true)
22 {
23 if (!Win32Com.SetCommMask(hPort, Win32Com.EV_RXCHAR))
24 {
25 throw new CommPortException("IO Error [001]");
26 }
27
28 Marshal.WriteInt32(uMask, 0);
29 if (!Win32Com.WaitCommEvent(hPort, uMask, unmanagedOv))
30 {
31 if (Marshal.GetLastWin32Error() == Win32Com.ERROR_IO_PENDING)
32 {
33 sg.WaitOne();
34 }
35 else
36 {
37 throw new CommPortException("IO Error [002]");
38 }
39 }
40 eventMask = (uint)Marshal.ReadInt32(uMask);
41 if ((eventMask & Win32Com.EV_RXCHAR) != 0)
42 {
43 do
44 {
45 gotbytes = 0;
46 if (!Win32Com.ReadFile(hPort, buf, 1, out gotbytes, unmanagedOv))
47 {
48 if (Marshal.GetLastWin32Error() == Win32Com.ERROR_IO_PENDING)
49 {
50 Win32Com.CancelIo(hPort);
51 gotbytes = 0;
52 }
53 else
54 {
55 throw new CommPortException("IO Error [004]");
56
57 }
58 }
59 if (gotbytes == 1) OnRxChar(buf[0]);
60 }
61 while (gotbytes > 0);
62 }
63 }
64 }
65
66 catch (Exception e)
67 {
68 if (uMask != IntPtr.Zero)
69 Marshal.FreeHGlobal(uMask);
70
71 if (unmanagedOv != IntPtr.Zero)
72 Marshal.FreeHGlobal(unmanagedOv);
73
74 if (!(e is ThreadAbortException))
75 {
76 rxException = e;
77 OnRxException(e);
78 }
79 }
80}
81
图6接收线程的简单版
图6是一个简化版的ReceiveThread。SetCommMask表明当一个新字节到达的时候我希望被通知到。WaitCommEvent可能返回true,在这种情况下已经有一个或更多的字节处于队列中。如果返回附带错误码的ERROR_IO_PENDING,我会挂起这个线程直到有一个字节到达。被传递给WaitCommEvent的OVERLAPPED结构包含一个到AutoResetEvent的句柄,它被当作有字节到达的信号。当我执行AutoResetEvent的WaitOne方法时,执行被挂起直到这个事件被触发。
不管WaitCommEvent立即返回true还是信号不久完成,eventMask变量有一位用于标识SetCommMask正常被引发所需的条件(在实际代码中,我也描述了一些其它的家务管理的条件)。
注意我同样为eventMask使用了手动排列技巧就像先前在OVERLAPPED中描述的一样。我猜测这也许是没必要的,也许自动排列也可以,但在文档中没有精确的描述,因为这样做更安全一些总比遗憾要好的多。用托管变量替换无序的指针作为引用参数好像有用,但那可能只是因为内存没有被重用而已。因为依赖于时间,不只是一个字符要排队,因此每次使用ReadFile来排出一个字节,重复使用,并每次使用一个字符来调用虚方法OnRxChar。当我接收到ERROR_IO_PENDING错误编码时,我就调用CancelIo方法,从而避免等在这里;我想在WaitCommEvent循环等待。
在使用工作线程,错误处理和异常需要好好地处理。任何发生在ReceiveThread里的未处理异常,及任何调用它的虚方法,以及由以上调用的方法或引发的事件将会级联下去并由catch子句捕获处理。如果产生的异常不是ThreadAbortException异常,那么它就存储在CommBase类作为一个私有成员,并且这个线程将被中止。下次在主线程里程序代码将调用一个方法然后再引发一个异常,端口就会关闭。这充分利用了内置异常结构的优点,当引发了一个一般性“接收线程错误”异常时,它里面就包含了存储在里面的原线程。ThrowException是继承类里的提供的一个辅助方法;它通过它所调用的线程来调节它的行为。
配置和其它细节
我从CommBaseSettings辅助类对象读取所有配置。Open方法通过调用虚方法CommSettings来获得这个对象,并把所有的置拷贝到API结构中去。CommBaseSettings类还提供了用于保存和覆盖配置到XML配置文件的方法,及大量地应用这些一般性配置。我利用窗口上的智能帮助提示为配置提供了帮助文档。因为继承类提供了自己的继承自CommBaseSettings类配置类,这种配置提供了一种可扩展性基础配置结构。通过这种方式我继承了CommLineSettings类,为CommLine类提供了额外的配置。
共有三个API方法用于配置通讯协议:SetupComm,SetCommState及SetCommTimeouts三个方法。SetupComm方法需要接收缓冲的大小及传输队列。正常情况下你可以把这些设置为0或根据操作系统决定,但对一些文件传输和简单应用程序,可调节的所需的大小是值得的。系统不一定能满足这种需要;在Windowns XP里,这就好像动态传输队列,并仅接收队列的长度是必须的。SetCommState方法在一个称为设备控制块(DCB)的结构里提供了波特传输率、字格式及握手设置等配置信息。
SetCommTimeouts在COMMTIMEOUTS结构里提供了三个接收和两个传输超时值。接收超时值对我所选择的设计没有用处,因为单个字符是异步处理的。如果接收超时时间是必须的,那它必须在一个高的水平上实现(比如,CommLine为它的传输方法提供了一个超时时间)。传输超时时间很有用,特别对于多字节传输。Send方法里的字节的数量由sendTimeoutMultiplier方法啬,然后sendTimeoutConstant被附加到这并提供以微秒为单位的总时间。
一旦端口被打开并被寝化里,Open方法调用了一个AfterOpen虚方法,它将被重写来检查到远程设置的连接状态且尽可能地配置它。如果这个返回false,端口将再次被关闭且Open方法自己将返回false.如果需要的话,还有一个BeforeClose方法来关闭远程设备。
CommB ase还提供了两个重载版本的Send方法,一个提供了一个字节数组为参数另一个提供了另一个单独的字节作为参数。CommLine提供了第三个版本的Send方法,用字符串作为参数。在进行合适的数据转化后,所有这些最终使用字节数组版本的方法。还提供了一个以单个字节为参数的SendImmediate方法。它将在传输队列里将比其它字节前面传输这个字节,并且对实现自定义流控制模式是非常有用的。它还提供了一些用于传输请求、数据终端准备输出插脚及把TX输出到暂停条件。输入插脚-Clear-to-Send(CTS),Data Set Ready(DSR),Received Line Signal Detector(RLSD),以及Ring Detect-可以使用GetModemStatus来直接读,当任意输入或输出插脚改变状态时,虚方法OnStatusChange将被调用。
GetQueuesStatus方法将返回一个QueueStatus对象,并给出传输的大小、内容、接收队列以及如果必要的话,流控制条件现在是块传输。
结论
我使用Platform Invocation Services来填补了FCL功能上的一个空白。但这表明确是一个不平凡但非常可行的锻炼。所存在的绝对多数困难在于还没有用于P/Invoke的完全的工具及文档支持。
最后,我做一下总结。作为这个项目的一部分,并在我考虑ManualResetEvent和AutoResetEvent框架类已经封装了所有我需要的功能之前,我写了和测试了所有的对于Win32 Waitable Events API的完全封装。记住:当时候,你仅需要把你所有的时间都花在写全新的类而不是凑合使用已经存在的你所需的东西。在从新改造之前先检查一下你的硬盘。基于这个原理,我希望这些基类能帮助其它的程序员把RS232设备通讯带入.NET世界。
相关文章请见:
House of COM: ating Native Code to the .NET CLR
Serial Communications Overview
RS232 standard
背景知识请见:
NET and COM: The Complete Interoperability Guide by Adam Nathan (Sams Publishing, 2002)
John Hind是一位位于英国伦敦的自由作者和顾问。他专注于微控制应用程序和控制解决方案。可以通过John.Hind@zen.co.uk和他联系。