Serielle Schnittstelle mit VB .NET ansprechen

Veröffentlicht: 07. Mrz 2002 | Aktualisiert: 16. Jun 2004

Von Hannes Preishuber

Wer die serielle Schnittstelle unter .NET ansprechen will, kann nicht auf eine Klasse der BCL (Base Class Library) von .NET zurückgreifen: So eine Klasse fehlt. Aber es gibt andere Wege, um Scanner oder Modem in eine Applikation einzubinden. Einer führt über das Active-X-Control MSCOMM.OCX, hier soll aber der Weg direkt über das Windows-API gezeigt werden.

Auf dieser Seite

 Externe DLL in VB .NET importieren
 Per API einen COM- Port öffnen
 Schreiben und Lesen der RS232-Schnittstelle über VB .NET
 WinForms-Programm als User Interface

Warum es keine entsprechende Klasse in der BCL gibt, darüber kann nur spekuliert werden. Ein Grund könnte die erwünschte Plattformunabhängigkeit der BCL sein. Eine Klasse für die RS-232-Schnittstelle würde nur unter wenigen Systemen wie PC Sinn machen.

Eine Alternative muss also her. Natürlich ließe sich für obiges Problem die MSCOMM32.OCX aus VB 6 über einen Wrapper einbinden. Doch das bringt Probleme mit sich. Zum Einen sinkt durch das Umsetzen der Typen von managed zu unmanaged Code und umgekehrt die Geschwindigkeit (Type Casting). Zum Anderen gibt es Lizenzprobleme bei der Verwendung von ActiveX-Controls.

Es bleibt der Weg über das Win32-API. Die Funktionen für die Steuerung der Ports wie der seriellen Schnittstelle stecken im Kernel.

Um diese Funktionen unter Visual Basic .NET zu nutzen, müssen sie lediglich über einen Import dem Programm bekannt gemacht werden. Das geschieht über eine Attributdefinition mit dem Schlüsselwort DllImport. Der Wert des Attributs ist hier gleichzeitig der Parameter des Imports und somit der Name der DLL. Allerdings funktioniert das nur, wenn Namespace System.Runtime.Interopservices eingebunden ist.

Imports System.Runtime.Interopservices

Als nächstes müssen noch der Funktionsname und die Parameter angegeben werden, die mit denen der Funktion in der externen DLL exakt übereinstimmen müssen.

<DllImport("kernel32.dll")> Public Shared Sub Beep(ByVal toner As Int16, ByVal dauer As Int16)

Als VB Profi werden Sie solches vielleicht schon gemacht haben. Alternativ kann Declare aus VB 6 weiter verwendet werden.

Declare Sub Beep Lib "kernel32.dll" (ByVal tone As Integer, ByVal dauer As Integer)

Der Aufruf von Beep erfolgt dann wie bei einer normalen Funktion.

Beep(10000,500)

Nachdem der grundsätzliche Mechanismus klar ist, gilt es zu entscheiden, welche Funktionen benötigt werden. Eine Dokumentation der API findet sich im MSDN im Bereich Plattform SDK, Suche nach "Serial Communications in Win32".

Die wichtigen Funktionen lauten:

  • CreateFile - Öffnet einen seriellen Port und liefert einen Handle

  • SetupComm - Setzt die Parameter für die Konfiguration

  • WaitCommEvent - Hilft beim Implementieren einer Ereignisbehandlung

  • Writefile - Schreibt auf die Schnittstelle

  • ReadFile - Liest von der Schnittstelle

Die Parameter und Events stehen unter im MSDN oder im Web unter diesem Link:

https://msdn.microsoft.com/library/default.asp?url=/library/en-us/hardware/commun_2alh.asp

Externe DLL in VB .NET importieren

Daraus ergibt sich folgender Code um die nötigen Funktionen einzubinden.

<DllImport("kernel32.dll")> Private Shared Function _ 
  SetCommState(ByVal hCommDev As Int32, ByRef lpDCB As DCB) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  GetCommState(ByVal hCommDev As Int32, ByRef lpDCB As DCB) As Int32 
  End Function 
<DllImport("kernel32.dll", CharSet:=CharSet.Auto)> Private Shared Function _ 
  BuildCommDCB(<MarshalAs(UnmanagedType.LPStr)> ByVal lpDef As String, ByRef lpDCB As DCB) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  SetupComm(ByVal hFile As Int32, ByVal dwInQueue As Int32, ByVal dwOutQueue As Int32) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  SetCommTimeouts(ByVal hFile As Int32, ByRef lpCommTimeouts As COMMTIMEOUTS) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  GetCommTimeouts(ByVal hFile As Int32, ByRef lpCommTimeouts As COMMTIMEOUTS) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  ClearCommError(ByVal hFile As Int32, ByVal lpErrors As Int32, ByVal l As Int32) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  PurgeComm(ByVal hFile As Int32, ByVal dwFlags As Int32) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  EscapeCommFunction(ByVal hFile As Integer, ByVal ifunc As Long) As Boolean 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  WaitCommEvent(ByVal hFile As Integer, ByRef Mask As Integer, _ 
  ByRef lpOverlap As OVERLAPPED) As Int32 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  WriteFile(ByVal hFile As Integer, ByVal Buffer As Byte(), _ 
  ByVal nNumberOfBytesToWrite As Integer, ByRef lpNumberOfBytesWritten As Integer, _ 
  ByVal lpOverlapped As Integer) As Integer 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  ReadFile(ByVal hFile As Integer, ByVal Buffer As Byte(), _ 
  ByVal nNumberOfBytesToRead As Integer, ByRef lpNumberOfBytesRead As Integer, _ 
  ByVal lpOverlapped As Integer) As Integer 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  CreateFile(<MarshalAs(UnmanagedType.LPStr)> ByVal lpFileName As String, _ 
  ByVal dwDesiredAccess As Integer, ByVal dwShareMode As Integer, _ 
  ByVal lpSecurityAttributes As Integer, ByVal dwCreationDisposition As Integer, _ 
  ByVal dwFlagsAndAttributes As Integer, ByVal hTemplateFile As Integer) As Integer 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function _ 
  CloseHandle(ByVal hObject As Integer) As Integer 
  End Function 
<DllImport("kernel32.dll")> Private Shared Function FormatMessage(ByVal dwFlags As Integer, _ 
  ByVal lpSource As Integer, ByVal dwMessageId As Integer, _ 
  ByVal dwLanguageId As Integer, _ 
  <MarshalAs(UnmanagedType.LPStr)> ByVal lpBuffer As String, _ 
  ByVal nSize As Integer, ByVal Arguments As Integer) As Integer 
  End Function

 

Per API einen COM- Port öffnen

Nachdem wir bereits im ersten Teil die nötigen Funktionen der API für die RS232 eingebunden haben, geht es nun an die Umsetzung. Die Schwierigkeit steckt weniger in der Programmierung mit VB .NET sondern mehr im Wissen um die API-Funktionen.
Kleine Schritte führen zum Erfolg. Zunächst muss das Programm die Schnittstelle öffnen. Dafür implementieren wir eine Funktion Open. Dieser übergeben wir per Parameter alle nötigen Informationen.

  • Port: Schnittstelle als Ganzzahl

  • Geschwindigkeit: in Baud (z.B. 9600)

  • Datenbits: Anzahl der Datenbits (Typischerweise 8)

  • Parität: (Typischerweise N für none, hier als 0)

  • Stopbit: Anzahl der Stoppbits als Ganzzahl

  • Buffergröße: Größe des IO Buffers

Diese Vorgehensweise bringt einen Nachteil mit sich. Alle Parameter müssen korrekt gesetzt werden. Grund: Optionale Parameter sind unter VB .NET nicht mehr möglich. Um trotzdem nicht jeden Parameter angeben zu müssen, könnte die Funktion Open mehrfach überladen werden. Das bedeutet aber einen erheblichen Kodier-Aufwand. Alternativ könnte man in der Klasse Propertys verwenden. Dazu muss aber auch der Code zum Lesen und Setzen jeder Eigenschaft über SET- und GET-Methoden implementiert werden.

Public Sub Open(ByVal iPort As Int16, ByVal ioSpeed As Integer, _ 
ByVal ioData As Int16, ByVal ioParity As Int16, _ 
ByVal ioStop As Int16, ByVal comBufferSize As Integer) 
   Dim uDCB As DCB, iResult As Int32 
   Try 
      hCOMM = CreateFile("COM" & iPort.ToString, _ 
              &H80000000 Or &H40000000, 0, 0, _ 
             3, 0, 0) 
      If hCOMM <> -1 Then 
                    Dim sCOMSettings As String 
                    iResult = ClearCommError(hCOMM, ErrCode, 0&) 'Fehler löschen 
                    iResult = PurgeComm(hCOMM, PurgeBuffers.RXClear Or PurgeBuffers.TxClear)  
'Buffer löschen 
                    iResult = GetCommState(hCOMM, uDCB) 'Setting von COM`? 
                    ' Port Settings 
                    sCOMSettings = ioSpeed & "," & ioParity & "," & _ 
ioData.ToString & "," & CInt(ioStop).ToString 
                    iResult = BuildCommDCB(sCOMSettings, uDCB) 
                    iResult = SetCommState(hCOMM, uDCB) 
                    iResult = SetupComm(hCOMM, comBufferSize, comBufferSize) 'Buffer setzen 
                    comSetTimeout(100) 'Timeout Struktur sezen 
       Else 
                    ' Fehler werfen 
       End If 
       Catch Ex As Exception 
                 'allgemeiner Fehler 
       End Try 
End Sub 

Zunächst wird über CreateFile ein Handle auf die Schnittstelle erzeugt. Die Fehlerbehandlung ist an dieser Stelle nur Ansatzweise implementiert. Die Hexzahlen (&H80000000) geben den Schreib- und Lese-Modus an. Der fünfte Parameter bewirkt, dass ein offener Port nicht nochmals geöffnet wird. Wenn alles gut geht, enthält der Handle einen positiven Wert. Über den Handle hCOMM werden Buffer und Errors gelöscht. Jede Funktion liefert einen Rückgabewert, der im Fehlerfalle, wenn Catch zuschlägt, ausgegeben werden kann.

Erst nachdem der Port offen ist, kann das Programm ihn mit den Settings füttern. Dazu bildet es aus den Parametern einen String, den die API-Funktion BuildComDCB in eine DCB-Struktur.

Die DCB-Structur ist folgendermaßen definiert:

<StructLayout(LayoutKind.Sequential, Pack:=1)> Private Structure DCB 
            Private DCBlength As Int32 
            Private BaudRate As Int32 
            Private Bits1 As Int32 
            Private wReserved As Int16 
            Private XonLim As Int16 
            Private XoffLim As Int16 
            Private ByteSize As Byte 
            Private Parity As Byte 
            Private StopBits As Byte 
            Private XonChar As Byte 
            Private XoffChar As Byte 
            Private ErrorChar As Byte 
            Private EofChar As Byte 
            Private EvtChar As Byte 
            Private wReserved2 As Int16 
End Structure 

Mehr dazu findet sich im Platform-SDK unter https://msdn.microsoft.com/library/default.asp?url=/library/en-us/hardware/commun_965u.asp
Nicht benötigte Eigenschaften muss man in der Struktur nicht setzen. Wichtig ist nur, dass der Name der Struktur identisch ist.

Mit dieser Struktur initialisiert das Programm über SetCommstate die Schnittstelle. Anschließend setzt es noch Buffergröße und Timeout. comSetTimeout ist dabei keine API-Funktion. Vielmehr kapselt sie die API-Funktion SetCommTimeouts.

Private Sub comSetTimeout(ByVal comTimeout As Int16) 
            Dim uCtm As COMMTIMEOUTS 
            If hCOMM = -1 Then 
                Exit Sub 
            Else 
                With uCtm 
                    .ReadIntervalTimeout = 0 
                    .ReadTotalTimeoutMultiplier = 0 
                    .ReadTotalTimeoutConstant = comTimeout 
                    .WriteTotalTimeoutMultiplier = 10 
                    .WriteTotalTimeoutConstant = 100 
                End With 
                SetCommTimeouts(hCOMM, uCtm) 
            End If 
End Sub 

Der Grund für diesen Wrapper liegt in der recht umfangreichen Struktur COMMTIMEOUTS. Näheres dazu im Platform-SDK unter
https://msdn.microsoft.com/library/default.asp?url=/library/en-us/hardware/commun_9tgy.asp

<StructLayout(LayoutKind.Sequential, Pack:=1)> Private Structure COMMTIMEOUTS 
            Public ReadIntervalTimeout As Int32 
            Public ReadTotalTimeoutMultiplier As Int32 
            Public ReadTotalTimeoutConstant As Int32 
            Public WriteTotalTimeoutMultiplier As Int32 
            Public WriteTotalTimeoutConstant As Int32 
End Structure 

Für ein sauberes Arbeiten gehört natürlich auch noch das Schließen des Ports dazu. Hier kapselt die Funktion Close einfach die entsprechende API-Funktion.

Public Sub Close() 
      If hCOMM <> -1 Then 
         CloseHandle(hCOMM) 
         hCOMM = -1 
      End If 
End Sub 

So, Vorbereitung abgeschlossen, jetzt geht es in die Vollen.

 

Schreiben und Lesen der RS232-Schnittstelle über VB .NET

Ein COM-Port ohne Gerät daran macht wenig Spaß beim Testen. Also her mit dem 14,4er Modem. Ganz egal wie alt es ist, es reicht!

Nahezu jedes Modem versteht den Hayes-Befehlssatz. Die Befehle sind Zeichenketten, die mit "AT" eingeleitet werden.

  • ATZ - Reset

  • ATL3 - Lautsprecher ein und auf volle Pulle

  • ATX3 - ignoriere das Freizeichen und wähle drauf los

  • ATDT - Wähle eine Nummer z.B. ATDT123

  • ATH – auflegen

Das Wichtige bei der Kommunikation mit dem Modem ist, , dass das Modem einen Befehl erst bestätigen sollte – idealerweise mit "OK", bevor das Programm den nächsten Befehl schickt.
Zum Testen des Modems und der Befehle eignet sich am besten Hyperterminal.Im folgenden Bild sind die eingetippten Befehle klein geschrieben und die Antwort des Modems in Grossbuchstaben.

 

WinForms-Programm als User Interface

Eine WinForms-Anwendung dient als Oberfläche, um die Klasse für Testzwecke zu nutzen. Dabei sollte man das Öffnen der Schnittstelle und das Senden von Befehlen in eine extra Routine legen. Wenn auf das Öffnen unmittelbar ein Write folgt, hängt sich das Modem wahrscheinlich auf. Je nach Anwendungstyp wird man dafür einen Button für das Öffnen und einen für das Senden einbauen. Im Code wird dann die Klasse instanziert.

Dim myCom As New ppComm() 
myCom.Open(1, 9600, 0, 1, 1, 512)

Ein anschließender Read auf die Schnittstelle stellt sicher, dass diese geöffnet ist. Zu beachten ist, dass die Kommunikation mit dem Modem asynchron erfolgt. Das heißt, das Programm darf den nächsten Befehl erst senden, wenn es sich über den Status der Schnittstelle im Klaren ist. Das könnte ein erhaltenes "OK" sein oder aber auch der Ablauf einer Zeitspanne (time out).

Beim Lesen von der Schnittstelle ist die Situation nicht ganz einfach. Die serielle Schnittstelle stellt einen Eingangspuffer zur Verfügung der gelöscht wird sobald gelesen wird oder dieser überläuft. Das heißt, das Programm muss auf Draht sein, um nichts zu verpassen.
Ideal wäre dafür ein Callback, der schreit, wenn der Buffer voll ist. Beim Microsoft MSCOMM.OCX heißt dieser OnComm. Dazu aber später mehr.
Zunächst definieren wir in der COM-Klasse einen Container für die empfangenen Daten. Da diese durchaus binäre Daten wie 0 Bytes enthalten können, wählen wir als Datentyp Byte.

Private bytRecvBufARR As Byte()

Diese Variable wird in der Read-Funktion beschrieben. Read kapselt die API-Funktion ReadFile und liest und leert den Puffer. Die Anzahl der gelesenen Byte wird aus der Variable iReadChars geholt die per ByRef an die Funktion übergeben wird.

Public Function Read(ByVal ReadSize As Integer) As Integer 
   Dim iReadChars, iResult As Integer 
   If ReadSize = 0 Then ReadSize = 512 'wenn keine Bytes zu lesen Standard Size 
      If hCOMM = -1 Then 
         Throw New ApplicationException("Der Port ist nicht geöffnet") 
      Else 
        Try 'abholen 
          ReDim bytRecvBufARR(ReadSize - 1) 
          iResult = ReadFile(hCOMM, bytRecvBufARR, ReadSize, iReadChars, 0) 
          If iResult = 0 Then 'Lese Fehler 
          Else 
              Return (iReadChars)'Anzahl Zeichen gelesen 
          End If 
        Catch Ex As Exception 
                    'Execption 
        End Try 
      End If 
End Function

Analog der Read-Funktion soll es eine Write-Funktion geben, die überladen wwird, um sowohl Strings als auch Bytes schreiben zu können. In Fall eines Strings wandelt die Helper-Funktion ASCIIEncoding die Zeichenfolge in einen Bytestream um.Über die als ByRef übergebene Variable iBytesWritten erhält die Funktion die tatsächlich geschriebenen Zeichen.

Public Sub Write(ByVal Buffer As String)  
  Dim iBytesWritten, iResult As Integer 
  Dim ascBuffer As New System.Text.ASCIIEncoding() 
  Try 
    PurgeComm(hCOMM, PURGE_RXCLEAR Or PURGE_TXCLEAR) 'löschen der Puffer 
    Dim btStream() As BytStream = ascBuffer.GetBytes(Buffer) 
    iResult = WriteFile(hCOMM, BytStream, BytStream.Length, iBytesWritten, 0) 
    If iResult = 0 Then 
      Throw New ApplicationException("Schreib Fehler - Bytes geschrieben. " & _ 
          iBytesWritten.ToString & " .. " & Buffer.Length.ToString) 
    End If 
  Catch Ex As Exception 
      Throw 
  End Try 
End Sub

Der Übersichtlichkeit halber ersetzen Konstanten (PURGE_RXCLEAR) die Hex-Werte.

Private Const PURGE_RXABORT As Integer = &H2 
Private Const PURGE_RXCLEAR As Integer = &H8 
Private Const PURGE_TXABORT As Integer = &H1 
Private Const PURGE_TXCLEAR As Integer = &H4 
Private Const GENERIC_READ As Integer = &H80000000 
Private Const GENERIC_WRITE As Integer = &H40000000 
Private Const OPEN_EXISTING As Integer = 3 
Private Const INVALID_HANDLE_VALUE As Integer = -1 
Private Const IO_BUFFER_SIZE As Integer = 1024

Mit Hilfe der Konstanten wird z.B. der Code zum Öffnen der Schnittstelle lesbarer:

hCOMM= CreateFile("COM" & iPort.ToString, _ 
                         GENERIC_READ Or GENERIC_WRITE, 0, 0, _ 
                         OPEN_EXISTING, 0, 0)

Nun brauchen wir noch den Schnipsel-VB.NET-Code zum Testen der Klasse. Dazu sendet das Programm ein "AT" an das Modem und erhält im Erfolgsfall ein "OK" von der Schnittstelle zurück. Für die Ausgabe des Ergebnisses wurde ein Label auf der Form platziert. Wiederum muss das Programm aus einem Byte-Array ein String machen, um das Label damit zu füttern. Beim Senden des AT-Strings muss noch ein CHR(10) angehängt werden. Dies geschieht am besten über die VB Konstante vbCR. Auch wenn das für dieses Programmierbeispiel wenig Sinn macht: Die Konstante macht den Code portabel.Läuft das Programm auf einer anderen Plattform, ersetzt das Framework die Konstante mit dem richtigen Hex-Code.

Dim myCom As New ppComm() 
myCom.Open(1, 9600, 0, 1, 1, 512) 
myCom.Write("AT" & vbCr) 
myCom.Read(20)   '20 Bytes lesen 
Dim oEncoder As New System.Text.ASCIIEncoding() 
label1.text = oEncoder.GetString(myCom.bytRecvBufARR) 
myCom.Close()

Obiger Code funktioniert allerdings nur, wenn man im Debug-Modus relativ langsam durch die Zeilen steppt. Das Modem und die Schnittstelle verschlucken sich sonst. Wenn das passiert, kommt nur Bytesalat über die Schnittstelle. In diesem Fall das Modem aus- und wieder einschalten. Alternativ besteht auch die Möglichkeit über den Befehl Thread.Sleep ein bisschen Wartezeit einzubauen.

So und nun viel Spaß mit den Anregungen aus diesem Artikel. Über das VB-.NET-Forum auf www.devtrain.de können wir über die sicherlich noch interessanten weiteren Details diskutieren.