C# SerialPortの使い方完全ガイド|接続・送受信・DataReceived・文字化け対策まで解説

はじめに

C#で計測器、バーコードリーダー、PLC、マイコン、Arduino、電子天秤、医療機器、産業機器などと通信する場面では、SerialPortクラスを使ったシリアル通信がよく利用されます。

シリアル通信は古くから使われている通信方式ですが、現在でもUSBシリアル変換アダプタや仮想COMポートを経由して、多くの現場で使われています。C#ではSystem.IO.Ports.SerialPortを使うことで、COMポートのオープン、データ送信、データ受信、イベントによる非同期受信、文字コード設定、タイムアウト処理などを比較的簡単に実装できます。

一方で、C# SerialPortにはつまずきやすいポイントもあります。たとえば、ポートが開けない、DataReceivedが思ったタイミングで発生しない、ReadLineで止まる、受信データが途中で切れる、文字化けする、アプリ終了時にポートが解放されない、といったトラブルです。

この記事では、C# SerialPortの基本から、接続、切断、送信、受信、DataReceivedイベント、文字化け対策、例外処理、実用サンプル、よくあるトラブルの解決策まで、実装で必要になる内容を順番に解説します。

1. C# SerialPortとは?できることと利用シーン

1-1. SerialPortクラスで実現できるシリアル通信の概要

C#のSerialPortクラスは、シリアルポートを通じて外部機器とデータを送受信するためのクラスです。名前空間はSystem.IO.Portsで、COMポートを指定して通信条件を設定し、Openメソッドで接続してからデータを送受信します。

代表的には、次のような処理を実装できます。

C#
using System;
using System.IO.Ports;

class Program
{
static void Main()
{
using var port = new SerialPort("COM3", 9600);

port.Open();
port.WriteLine("HELLO");

string response = port.ReadLine();
Console.WriteLine(response);
}
}

この例では、COM3を9600bpsで開き、文字列を送信し、相手側から返ってきた1行分のデータを受信しています。

SerialPortで扱う通信は、基本的には「バイト列の送受信」です。文字列を送受信する場合も、内部的には指定されたエンコーディングに従って文字列とバイト列が変換されます。そのため、文字コードや改行コードの設定が合っていないと、文字化けや受信待ちの停止が発生します。

1-2. COMポート・RS-232C・USBシリアル変換・Arduinoとの関係

シリアル通信では、Windows上でCOM1COM2COM3のようなCOMポート名を使って通信先を指定します。

かつてはPCにRS-232Cポートが搭載されていることが多く、物理的なシリアルポートとして利用されていました。現在のPCではRS-232C端子がないことも多いため、USBシリアル変換アダプタを使ってUSB接続をCOMポートとして認識させるケースが一般的です。

Arduinoや各種マイコンボードも、PCから見ると仮想COMポートとして認識されることがあります。たとえばArduinoをUSB接続すると、WindowsのデバイスマネージャーにCOM3COM5のようなポートが表示され、C# SerialPortからそのCOMポートに接続できます。

ただし、COMポートは同時に複数のアプリから開けないのが基本です。Arduino IDEのシリアルモニタ、Tera Term、別のC#アプリなどがすでに同じCOMポートを開いている場合、C#側でUnauthorizedAccessExceptionが発生することがあります。

1-3. C#でSerialPortを使うメリットと注意点

C#でSerialPortを使うメリットは、標準的なAPIでシリアル通信を扱えることです。Windowsアプリ、コンソールアプリ、WinForms、WPFなどから利用しやすく、計測器やマイコンとの通信処理を業務アプリに組み込みやすい点が大きな利点です。

主なメリットは次のとおりです。

C#
// COMポート一覧を取得できる
string[] ports = SerialPort.GetPortNames();

foreach (var name in ports)
{
Console.WriteLine(name);
}

SerialPort.GetPortNames()を使えば、PCで認識されているCOMポート一覧を取得できます。WinFormsやWPFでは、この一覧をコンボボックスに表示してユーザーに選択させる実装がよく使われます。

一方で、SerialPortには注意点もあります。

DataReceivedイベントはUIスレッドとは別のスレッドで実行されます。そのため、WinFormsやWPFの画面部品をイベント内から直接更新すると例外や不安定な動作の原因になります。また、シリアル通信ではデータが必ず1回でまとまって届くとは限らず、1つの電文が複数回に分かれて受信されることもあります。

つまり、C# SerialPortを安定して使うには、単にReadLineReadExistingを呼び出すだけでなく、受信バッファ、改行コード、エンコーディング、タイムアウト、例外処理、UIスレッド制御まで考慮する必要があります。

1-4. この記事で解説する接続・送信・受信・DataReceived・文字化け対策の全体像

この記事では、C# SerialPortの使い方を次の流れで解説します。

まず、System.IO.Portsの参照方法、COMポート確認、通信確認ツールの使い方など、実装前の準備を説明します。次に、PortNameBaudRateDataBitsParityStopBitsHandshakeEncodingNewLineなどの基本設定を整理します。

その後、OpenCloseDisposeを使った接続・切断、WriteWriteLineを使った送信、ReadLineReadExistingReadを使った受信を解説します。

さらに、実務でよく使われるDataReceivedイベントの使い方、UI更新時のInvokeDispatcher、文字化け対策、CR/LFの違い、タイムアウトや例外処理、Arduinoとの通信サンプル、ログ保存、バイナリプロトコルの扱いまで紹介します。

2. C# SerialPortを使う前の準備

2-1. System.IO.Portsの参照方法

C#でSerialPortを使うには、System.IO.Ports名前空間を利用します。

C#
using System.IO.Ports;

.NET FrameworkのWindowsアプリでは、標準で利用できることが多いです。一方、.NET 6、.NET 7、.NET 8などの新しい.NETプロジェクトでは、プロジェクトの種類によってSystem.IO.Portsパッケージの追加が必要になる場合があります。

NuGetで追加する場合は、Visual Studioの「NuGetパッケージの管理」からSystem.IO.Portsを検索してインストールします。コマンドで追加する場合は、次のようにします。

Bash
dotnet add package System.IO.Ports

追加後、C#コードで次のようにSerialPortを使えます。

C#
using System;
using System.IO.Ports;

class Program
{
static void Main()
{
var port = new SerialPort();
Console.WriteLine("SerialPortを使用できます。");
}
}

2-2. .NET Framework / .NET 6以降での違い

.NET Frameworkでは、WindowsデスクトップアプリでSerialPortを使うケースが多く、WinFormsやWPFとの組み合わせが一般的でした。Visual Studioで.NET Frameworkプロジェクトを作成した場合、System.IO.Portsをそのまま参照できることがあります。

一方、.NET 6以降では、プロジェクトテンプレートやターゲット環境によってNuGetパッケージの追加が必要になる場合があります。特に、コンソールアプリやクロスプラットフォームを意識したプロジェクトでは、パッケージ参照の有無を確認してください。

プロジェクトファイルにパッケージ参照を追加すると、次のような形になります。

XML
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="*" />
</ItemGroup>

実務では、バージョン番号を*のままにせず、プロジェクトで検証済みのバージョンを固定することをおすすめします。

また、SerialPortはWindowsのCOMポートで使われることが多いですが、.NET自体は複数のOSで動作します。Linuxでは/dev/ttyUSB0/dev/ttyACM0のようなデバイス名を使うケースがあります。ただし、権限設定やデバイス名の扱いがWindowsとは異なるため、対象OSに合わせて確認が必要です。

2-3. 使用できるCOMポートを確認する方法

C#で使用可能なCOMポート一覧を取得するには、SerialPort.GetPortNames()を使います。

C#
using System;
using System.IO.Ports;

class Program
{
static void Main()
{
string[] ports = SerialPort.GetPortNames();

if (ports.Length == 0)
{
Console.WriteLine("使用可能なCOMポートが見つかりません。");
return;
}

foreach (string port in ports)
{
Console.WriteLine(port);
}
}
}

このコードを実行すると、たとえば次のように表示されます。

COM3
COM5
COM7

WinFormsであれば、取得したCOMポート一覧をコンボボックスに設定できます。

C#
comboBoxPorts.Items.Clear();
comboBoxPorts.Items.AddRange(SerialPort.GetPortNames());

ただし、GetPortNames()で表示されるのはWindowsが認識しているポート名です。実際に通信できるかどうかは、相手機器が正しく接続され、通信条件が合っている必要があります。

2-4. デバイスマネージャーでポート番号を確認する手順

WindowsでCOMポート番号を確認するには、デバイスマネージャーを使います。

手順は次のとおりです。

  1. Windowsのスタートボタンを右クリックする

  2. 「デバイス マネージャー」を開く

  3. 「ポート(COMとLPT)」を展開する

  4. 対象機器の名前とCOM番号を確認する

たとえば、USBシリアル変換アダプタを接続している場合は、次のような表示になることがあります。

USB-SERIAL CH340 (COM3)
USB Serial Port (COM5)
Arduino Uno (COM7)

この場合、C#のPortNameには"COM3""COM7"を指定します。

C#
var port = new SerialPort();
port.PortName = "COM3";

USBを抜き差しするとCOM番号が変わることがあります。アプリ側で固定のCOM番号を前提にしていると接続できなくなるため、ユーザーがCOMポートを選択できるようにする設計が安全です。

2-5. Tera Termなどで事前に通信確認しておく理由

C# SerialPortで実装する前に、Tera Term、PuTTY、RealTerm、Arduino IDEのシリアルモニタなどで通信確認をしておくと、トラブルの切り分けがしやすくなります。

C#コードで通信できない場合、原因は大きく分けて次のどれかです。

・C#コードの問題
・COMポート番号の間違い
・ボーレートなど通信条件の不一致
・相手機器側の設定ミス
・ケーブルや変換アダプタの問題
・改行コードや文字コードの不一致

事前にTera Termなどで通信できることを確認しておけば、少なくとも機器、ケーブル、COMポート、基本的な通信条件が正しい可能性が高くなります。その状態でC#アプリを実装すれば、問題が発生したときにコード側の原因を調査しやすくなります。

特に、送信コマンドの末尾にCR、LF、CRLFのどれが必要かは、通信確認ツールで先に調べておくと便利です。

3. SerialPortの基本設定項目

3-1. PortName:接続先COMポートの指定

PortNameは、接続先のシリアルポート名を指定するプロパティです。WindowsではCOM1COM3COM10のような名前を指定します。

C#
var port = new SerialPort();
port.PortName = "COM3";

使用可能なCOMポートを取得して、存在するか確認してから開くこともできます。

C#
string targetPort = "COM3";
string[] ports = SerialPort.GetPortNames();

if (!ports.Contains(targetPort))
{
Console.WriteLine($"{targetPort} が見つかりません。");
return;
}

using var serialPort = new SerialPort(targetPort, 9600);
serialPort.Open();

COMポート番号は環境によって異なるため、実務アプリでは固定値にせず、設定ファイルや画面選択で指定できるようにするのがおすすめです。

3-2. BaudRate:ボーレートの設定

BaudRateは通信速度を指定するプロパティです。単位はbpsで、1秒あたりの信号変化数を表します。よく使われる値には、9600192003840057600115200などがあります。

C#
var port = new SerialPort();
port.PortName = "COM3";
port.BaudRate = 9600;

重要なのは、C#側と相手機器側のボーレートを一致させることです。Arduino側で次のように設定している場合、

C++
Serial.begin(9600);

C#側も次のように設定します。

C#
serialPort.BaudRate = 9600;

ボーレートが一致していないと、データを受信できなかったり、文字化けしたりします。

3-3. DataBits・Parity・StopBitsの設定

シリアル通信では、ボーレート以外にも、データビット、パリティ、ストップビットを相手機器と合わせる必要があります。

一般的によく使われる設定は、次の組み合わせです。

9600bps / 8bit / パリティなし / ストップビット1

C#では次のように設定します。

C#
var port = new SerialPort();

port.PortName = "COM3";
port.BaudRate = 9600;
port.DataBits = 8;
port.Parity = Parity.None;
port.StopBits = StopBits.One;

コンストラクタでまとめて指定することもできます。

C#
using var port = new SerialPort(
portName: "COM3",
baudRate: 9600,
parity: Parity.None,
dataBits: 8,
stopBits: StopBits.One
);

相手機器の仕様書に「9600, 8, N, 1」と書かれている場合、これは一般的に次の意味です。

9600:ボーレート
8:データビット
N:パリティなし
1:ストップビット1

3-4. Handshakeによるフロー制御

Handshakeは、送受信のフロー制御を指定するプロパティです。フロー制御とは、送信側と受信側の処理速度の違いによってデータがあふれないように制御する仕組みです。

C#では次のような値を指定できます。

C#
serialPort.Handshake = Handshake.None;

代表的な設定は次のとおりです。

Handshake.None:フロー制御なし
Handshake.XOnXOff:ソフトウェアフロー制御
Handshake.RequestToSend:RTS/CTSによるハードウェアフロー制御
Handshake.RequestToSendXOnXOff:RTS/CTSとXON/XOFFの併用

多くのArduino通信や簡単な計測器通信ではHandshake.Noneが使われます。ただし、産業機器や古いRS-232C機器ではRTS/CTSなどのハードウェアフロー制御が必要な場合があります。

相手機器の仕様書にフロー制御の指定がある場合は、それに合わせて設定してください。

3-5. Encodingによる文字コード設定

Encodingは、文字列を送受信するときの文字コードを指定するプロパティです。

C#
using System.Text;

serialPort.Encoding = Encoding.UTF8;

英数字だけを扱う場合はASCIIで問題ないケースが多いですが、日本語を扱う場合はShift_JISやUTF-8など、相手機器と同じ文字コードに合わせる必要があります。

C#
using System.Text;

// UTF-8
serialPort.Encoding = Encoding.UTF8;

// ASCII
serialPort.Encoding = Encoding.ASCII;

// Shift_JIS
serialPort.Encoding = Encoding.GetEncoding("shift_jis");

.NET Coreや.NET 5以降でShift_JISを使う場合、環境によってはエンコーディングプロバイダーの登録が必要です。

C#
using System.Text;

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
serialPort.Encoding = Encoding.GetEncoding("shift_jis");

文字化けが起きる場合、ボーレートの不一致だけでなく、Encodingの不一致も疑ってください。

3-6. NewLine・ReadTimeout・WriteTimeoutの設定

NewLineは、ReadLineWriteLineで使用される改行文字列を指定します。既定では環境に依存する改行が使われることがありますが、シリアル通信では相手機器の仕様に合わせて明示的に設定するのが安全です。

C#
serialPort.NewLine = "\r\n"; // CRLF

代表的な改行コードは次のとおりです。

"\r"   :CR
"\n" :LF
"\r\n" :CRLF

ReadTimeoutは読み取り時のタイムアウト、WriteTimeoutは書き込み時のタイムアウトを指定します。単位はミリ秒です。

C#
serialPort.ReadTimeout = 3000;
serialPort.WriteTimeout = 3000;

タイムアウトを設定しておくと、相手機器から応答がない場合に処理が永久に止まることを防げます。ReadLineを使う場合は、指定したNewLineが届かないと待ち続けるため、ReadTimeoutを設定しておくことが重要です。

3-7. よく使うSerialPort初期化コード例

実務でよく使うSerialPortの初期化例は次のとおりです。

C#
using System;
using System.IO.Ports;
using System.Text;

public static SerialPort CreateSerialPort(string portName)
{
var port = new SerialPort
{
PortName = portName,
BaudRate = 9600,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Handshake = Handshake.None,
Encoding = Encoding.UTF8,
NewLine = "\r\n",
ReadTimeout = 3000,
WriteTimeout = 3000
};

return port;
}

使用例は次のようになります。

C#
using var port = CreateSerialPort("COM3");

try
{
port.Open();
port.WriteLine("STATUS");

string response = port.ReadLine();
Console.WriteLine(response);
}
catch (TimeoutException)
{
Console.WriteLine("応答がタイムアウトしました。");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}

このように初期化処理をメソッド化しておくと、複数の画面や処理で同じ通信設定を使い回しやすくなります。

4. C# SerialPortで接続・切断する方法

4-1. Openメソッドでシリアルポートを開く

SerialPortで通信を開始するには、Openメソッドを呼び出します。

C#
using System;
using System.IO.Ports;

class Program
{
static void Main()
{
using var port = new SerialPort("COM3", 9600);

port.Open();

Console.WriteLine("ポートを開きました。");
}
}

Openを呼び出す前に、PortNameBaudRateなどの通信条件を設定しておきます。

C#
var port = new SerialPort();

port.PortName = "COM3";
port.BaudRate = 9600;
port.DataBits = 8;
port.Parity = Parity.None;
port.StopBits = StopBits.One;

port.Open();

ポートを開いた後に通信条件を変更すると、意図しない動作になる場合があります。基本的には、設定を完了してからOpenする流れにしてください。

4-2. IsOpenで接続状態を確認する

IsOpenプロパティを使うと、SerialPortが開いているか確認できます。

C#
if (!serialPort.IsOpen)
{
serialPort.Open();
}

送信前に接続状態を確認する例です。

C#
if (serialPort != null && serialPort.IsOpen)
{
serialPort.WriteLine("HELLO");
}
else
{
Console.WriteLine("ポートが開いていません。");
}

ただし、IsOpentrueでも、USBケーブルが抜かれた直後などには実際の通信が失敗する場合があります。そのため、送受信処理では例外処理も必ず組み合わせてください。

4-3. CloseとDisposeで安全に切断する

通信が終わったら、CloseまたはDisposeでポートを解放します。

C#
serialPort.Close();

Disposeを呼び出すと、内部リソースも解放されます。

C#
serialPort.Dispose();

実務では、アプリ終了時や画面を閉じるタイミングで確実にポートを閉じることが重要です。ポートを解放しないままアプリが残っていると、次回起動時に「Access to the port is denied」のようなエラーになることがあります。

安全に切断する例です。

C#
private void ClosePort()
{
if (serialPort == null)
{
return;
}

try
{
if (serialPort.IsOpen)
{
serialPort.Close();
}
}
finally
{
serialPort.Dispose();
serialPort = null;
}
}

4-4. usingを使ったSerialPortのリソース管理

コンソールアプリや短時間の通信では、usingを使うとリソース管理が簡単になります。

C#
using var port = new SerialPort("COM3", 9600);

port.Open();
port.WriteLine("HELLO");
string response = port.ReadLine();

Console.WriteLine(response);

usingを使うと、スコープを抜けるときに自動的にDisposeが呼ばれます。

従来の書き方では、次のように記述できます。

C#
using (var port = new SerialPort("COM3", 9600))
{
port.Open();
port.WriteLine("HELLO");

string response = port.ReadLine();
Console.WriteLine(response);
}

ただし、WinFormsやWPFのように画面表示中ずっとポートを開いておくアプリでは、usingで短時間に閉じるのではなく、フォームやViewModelのライフサイクルに合わせてOpenCloseDisposeを管理します。

4-5. ポートが開けない場合の原因と対処法

Openでポートが開けない場合、よくある原因は次のとおりです。

・COMポート名が間違っている
・別のアプリが同じCOMポートを使用している
・USBシリアル変換アダプタが認識されていない
・ドライバがインストールされていない
・アクセス権限の問題がある
・前回のアプリ終了時にポートが解放されていない

例外処理を入れると、原因をログに残しやすくなります。

C#
try
{
serialPort.Open();
}
catch (UnauthorizedAccessException ex)
{
Console.WriteLine("ポートにアクセスできません。別のアプリが使用中の可能性があります。");
Console.WriteLine(ex.Message);
}
catch (ArgumentException ex)
{
Console.WriteLine("ポート名が不正です。");
Console.WriteLine(ex.Message);
}
catch (IOException ex)
{
Console.WriteLine("入出力エラーが発生しました。デバイス接続を確認してください。");
Console.WriteLine(ex.Message);
}
catch (Exception ex)
{
Console.WriteLine("ポートを開けませんでした。");
Console.WriteLine(ex.Message);
}

特にTera TermやArduino IDEのシリアルモニタを開いたままにしていると、C#アプリから同じCOMポートを開けません。通信確認後は、確認ツールを閉じてからC#アプリを起動してください。

5. C# SerialPortでデータを送信する方法

5-1. Writeで文字列やバイト列を送信する

Writeメソッドを使うと、文字列やバイト配列を送信できます。

文字列を送信する例です。

C#
serialPort.Write("HELLO");

文字列を送信する場合、SerialPort.Encodingに従って文字列がバイト列に変換されます。

C#
serialPort.Encoding = Encoding.UTF8;
serialPort.Write("こんにちは");

バイト配列を送信する場合は、次のようにします。

C#
byte[] data = { 0x01, 0x02, 0x03, 0x04 };
serialPort.Write(data, 0, data.Length);

コマンド制御機器では、文字列ではなくバイナリ形式のコマンドを送信するケースもあります。その場合は、byte[]で扱うのが安全です。

5-2. WriteLineで改行付きデータを送信する

WriteLineは、文字列の末尾にNewLineで指定された改行コードを付けて送信します。

C#
serialPort.NewLine = "\r\n";
serialPort.WriteLine("STATUS");

この場合、実際には次のようなデータが送信されます。

STATUS\r\n

相手機器が「コマンドの最後にCRLFが必要」という仕様であれば、WriteLineを使うと便利です。

一方で、相手機器が改行なしの固定長コマンドを要求している場合、WriteLineを使うと余計な改行コードが付いてしまいます。その場合はWriteを使います。

C#
serialPort.Write("STATUS");

WriteWriteLineの違いを理解して、相手機器の仕様に合わせて使い分けることが重要です。

5-3. バイナリデータをbyte配列で送信する

バイナリプロトコルでは、STX、ETX、チェックサム、固定長データなどを含む電文を送信することがあります。その場合、文字列ではなくbyte[]で送信します。

C#
byte[] command =
{
0x02, // STX
0x30, 0x31, // データ
0x03 // ETX
};

serialPort.Write(command, 0, command.Length);

チェックサムを付与する例です。

C#
byte[] body = { 0x30, 0x31, 0x32 };
byte checksum = 0;

foreach (byte b in body)
{
checksum ^= b;
}

byte[] packet = new byte[body.Length + 3];
packet[0] = 0x02; // STX
Array.Copy(body, 0, packet, 1, body.Length);
packet[packet.Length - 2] = 0x03; // ETX
packet[packet.Length - 1] = checksum;

serialPort.Write(packet, 0, packet.Length);

バイナリデータを文字列に変換して送信すると、エンコーディング変換によって値が変わる可能性があります。制御コードやチェックサムを含む通信では、必ずバイト配列で扱いましょう。

5-4. 送信時の改行コードCR/LFの扱い

シリアル通信でよくあるトラブルのひとつが、改行コードの不一致です。

相手機器によって、コマンドの終端に必要な改行コードが異なります。

CR   :\r
LF :\n
CRLF :\r\n

C#で明示的に送信する場合は、次のようにします。

C#
serialPort.Write("STATUS\r");
serialPort.Write("STATUS\n");
serialPort.Write("STATUS\r\n");

WriteLineを使う場合は、NewLineを設定します。

C#
serialPort.NewLine = "\r";
serialPort.WriteLine("STATUS");

この場合、STATUS\rが送信されます。

相手機器がCRだけを期待しているのにC#側がCRLFを送ると、余計なLFが次のコマンドとして扱われることがあります。逆に、相手機器がCRLFを期待しているのにCRだけを送ると、コマンドが確定せず応答が返ってこないことがあります。

5-5. 送信できない・相手側に届かない場合のチェックポイント

C# SerialPortで送信できない、または相手側に届かない場合は、次の点を確認します。

・serialPort.Open()が成功しているか
・serialPort.IsOpenがtrueか
・COMポート番号が正しいか
・ボーレートなど通信条件が一致しているか
・相手機器が受信可能な状態か
・改行コードが仕様に合っているか
・WriteTimeoutが短すぎないか
・Handshake設定が正しいか
・TX/RX配線が正しいか
・USBシリアル変換アダプタのドライバが正常か

送信処理には例外処理を入れておくと原因を把握しやすくなります。

C#
try
{
if (!serialPort.IsOpen)
{
serialPort.Open();
}

serialPort.WriteLine("STATUS");
}
catch (TimeoutException)
{
Console.WriteLine("送信がタイムアウトしました。");
}
catch (InvalidOperationException)
{
Console.WriteLine("ポートが開いていません。");
}
catch (Exception ex)
{
Console.WriteLine($"送信エラー: {ex.Message}");
}

送信できているか確認するには、相手機器の代わりにTera Termや別PCを接続して、C#アプリから送った文字列が見えるか確認すると切り分けしやすくなります。

6. C# SerialPortでデータを受信する方法

6-1. ReadLineで1行ずつ受信する

ReadLineは、NewLineで指定された改行コードまでの文字列を読み取るメソッドです。

C#
serialPort.NewLine = "\r\n";
string line = serialPort.ReadLine();
Console.WriteLine(line);

相手機器が次のようなデータを送信する場合、

OK\r\n

ReadLineOKを返します。末尾の改行コードは通常、返される文字列には含まれません。

ReadLineを使う場合は、必ず相手機器の送信する改行コードとNewLineを合わせてください。

C#
serialPort.NewLine = "\n";

相手機器がLFで終端するなら"\n"、CRで終端するなら"\r"、CRLFで終端するなら"\r\n"を設定します。

ReadLineは指定された改行コードが届くまで待ちます。そのため、改行コードが届かない通信では処理が止まったように見えることがあります。実務ではReadTimeoutを設定しておくのが安全です。

C#
serialPort.ReadTimeout = 3000;

try
{
string line = serialPort.ReadLine();
Console.WriteLine(line);
}
catch (TimeoutException)
{
Console.WriteLine("受信がタイムアウトしました。");
}

6-2. ReadExistingで受信バッファの文字列を取得する

ReadExistingは、受信バッファに存在するデータを文字列として取得します。

C#
string text = serialPort.ReadExisting();
Console.WriteLine(text);

ReadExistingは、現在受信済みのデータをすぐに取り出す用途に向いています。DataReceivedイベント内でよく使われます。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();
Console.WriteLine(data);
}

ただし、ReadExistingで取得できるのは「その時点でバッファにある分だけ」です。1つの電文が必ず一度で取得できるとは限りません。

たとえば、相手機器がABCDEF\r\nを送信しても、C#側では次のように分割されて受信する可能性があります。

1回目:ABC
2回目:DEF\r\n

そのため、ReadExistingを使う場合は、受信した文字列を一時バッファに追加し、改行コードや終端文字を検出して電文単位に組み立てる設計が必要です。

6-3. Readでバイト単位のデータを受信する

Readは、バイト配列または文字配列にデータを読み込むメソッドです。バイナリ通信では、Readを使ってbyte[]として受信するのが基本です。

C#
byte[] buffer = new byte[1024];

int readBytes = serialPort.Read(buffer, 0, buffer.Length);

for (int i = 0; i < readBytes; i++)
{
Console.WriteLine(buffer[i].ToString("X2"));
}

Readは、読み取ったバイト数を返します。バッファサイズと実際に読み取ったバイト数は一致するとは限らないため、必ず戻り値を使って処理します。

C#
int length = serialPort.Read(buffer, 0, buffer.Length);

byte[] received = new byte[length];
Array.Copy(buffer, received, length);

バイナリプロトコルでは、STX、ETX、データ長、チェックサムなどをもとに電文を組み立てます。文字列として扱うとデータが壊れる可能性があるため、Readでバイト単位に処理しましょう。

6-4. BytesToReadで受信済みデータ量を確認する

BytesToReadは、受信バッファにあるバイト数を取得するプロパティです。

C#
int count = serialPort.BytesToRead;
Console.WriteLine($"受信済みバイト数: {count}");

DataReceivedイベント内で、現在受信済みのバイト数だけ読み取る例です。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
int size = serialPort.BytesToRead;
byte[] buffer = new byte[size];

int read = serialPort.Read(buffer, 0, size);

Console.WriteLine(BitConverter.ToString(buffer, 0, read));
}

BytesToReadを使うと、受信済みの分だけ配列を確保して読み取れるため便利です。ただし、BytesToReadで取得した直後にさらにデータが届くこともあるため、「その瞬間の受信済みサイズ」として扱う必要があります。

6-5. 文字列受信とバイナリ受信の使い分け

C# SerialPortでは、文字列受信とバイナリ受信を明確に使い分けることが重要です。

相手機器がテキスト形式でデータを送る場合は、ReadLineReadExistingを使いやすいです。

TEMP=25.4\r\n
STATUS=OK\r\n

このようなデータなら、文字列として処理できます。

C#
string line = serialPort.ReadLine();

if (line.StartsWith("TEMP="))
{
string value = line.Substring("TEMP=".Length);
Console.WriteLine(value);
}

一方、次のようなデータはバイナリとして扱うべきです。

02 10 01 00 3A 03 2B

この場合は、byte[]で受信します。

C#
int size = serialPort.BytesToRead;
byte[] buffer = new byte[size];
serialPort.Read(buffer, 0, size);

制御コード、チェックサム、任意のバイト値を含む通信では、文字列変換を避けるのが原則です。

6-6. 受信データが分割される理由と対策

シリアル通信では、送信側が1回で送ったデータが、受信側でも1回でまとまって届くとは限りません。これは非常に重要です。

たとえば、相手機器が次の1行を送信したとします。

SENSOR,25.4,60.2\r\n

C#側のDataReceivedでは、次のように分かれて届くことがあります。

1回目:SENSOR,
2回目:25.4,
3回目:60.2\r\n

そのため、DataReceivedが発生するたびに「1回のイベント = 1つの電文」と考えるのは危険です。

文字列通信では、受信バッファに追加して、改行コードが見つかった時点で1行として処理します。

C#
private readonly StringBuilder receiveBuffer = new StringBuilder();

private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

lock (receiveBuffer)
{
receiveBuffer.Append(data);

string text = receiveBuffer.ToString();
int index;

while ((index = text.IndexOf("\r\n")) >= 0)
{
string line = text.Substring(0, index);
Console.WriteLine($"受信行: {line}");

text = text.Substring(index + 2);
}

receiveBuffer.Clear();
receiveBuffer.Append(text);
}
}

バイナリ通信では、データ長、終端バイト、チェックサムなどを使って電文を組み立てます。受信データが欠ける、途中で切れるというトラブルの多くは、この「分割受信」を考慮していないことが原因です。

7. DataReceivedイベントの使い方

7-1. DataReceivedイベントとは

DataReceivedは、SerialPortがデータを受信したときに発生するイベントです。受信を待ち続けるループを書かなくても、データ到着時に処理を実行できます。

C#
serialPort.DataReceived += SerialPort_DataReceived;

イベントハンドラは次のように書きます。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();
Console.WriteLine(data);
}

DataReceivedを使うと、WinFormsやWPFのアプリで、画面を操作しながらバックグラウンドで受信処理を行うような実装ができます。

ただし、DataReceivedには重要な注意点があります。イベントはUIスレッドとは別のスレッドで実行されます。また、イベントが発生した時点で1電文すべてが届いているとは限りません。そのため、受信バッファを使って電文単位に組み立てる設計が必要です。

7-2. DataReceivedイベントを登録する方法

DataReceivedイベントは、ポートを開く前または開いた後に登録できます。一般的には、初期化時に登録してからOpenします。

C#
serialPort = new SerialPort("COM3", 9600);
serialPort.NewLine = "\r\n";
serialPort.DataReceived += SerialPort_DataReceived;

serialPort.Open();

イベント解除も忘れずに行うと安全です。

C#
if (serialPort != null)
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
serialPort = null;
}

イベント解除を忘れると、フォームを閉じた後にイベントが発生して例外になることがあります。特にWinFormsやWPFでは、画面の破棄時にイベント解除、ポートクローズ、Disposeをまとめて行うようにしましょう。

7-3. DataReceived内でReadExisting・ReadLine・Readを使う例

DataReceived内で文字列を受信する場合、簡単なのはReadExistingです。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();
Console.WriteLine($"受信: {data}");
}

1行単位で受信したい場合は、ReadLineを使うこともできます。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
try
{
string line = serialPort.ReadLine();
Console.WriteLine($"受信行: {line}");
}
catch (TimeoutException)
{
Console.WriteLine("ReadLineがタイムアウトしました。");
}
}

ただし、ReadLineは改行コードが届くまで待つため、DataReceived内で長時間ブロックする可能性があります。相手機器の改行コードが確実に分かっている場合に使いましょう。

バイナリデータを受信する場合は、Readを使います。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
int size = serialPort.BytesToRead;
byte[] buffer = new byte[size];

int read = serialPort.Read(buffer, 0, size);

Console.WriteLine(BitConverter.ToString(buffer, 0, read));
}

実務では、ここで読み取ったデータをさらに受信バッファに追加し、電文単位で解析します。

7-4. DataReceivedは別スレッドで実行される点に注意

DataReceivedイベントは、UIスレッドとは別のスレッドで実行されます。そのため、WinFormsのTextBoxやWPFのTextBlockなどを直接更新してはいけません。

悪い例です。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

// UIスレッドではないため危険
textBoxLog.AppendText(data);
}

このようなコードは、実行時にクロススレッド操作の例外が発生することがあります。

正しくは、WinFormsではInvokeBeginInvoke、WPFではDispatcherを使ってUIスレッドに処理を渡します。

通信処理と画面更新処理を分離することが、C# SerialPortを安定して使うための重要なポイントです。

7-5. WinForms・WPFでUIを更新する場合のInvoke / Dispatcher対応

WinFormsでDataReceivedからUIを更新する例です。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

BeginInvoke(new Action(() =>
{
textBoxLog.AppendText(data);
}));
}

InvokeRequiredを使う書き方もあります。

C#
private void AppendLog(string text)
{
if (InvokeRequired)
{
BeginInvoke(new Action<string>(AppendLog), text);
return;
}

textBoxLog.AppendText(text);
}

DataReceived内では次のように呼び出します。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();
AppendLog(data);
}

WPFではDispatcherを使います。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

Dispatcher.Invoke(() =>
{
textBoxLog.AppendText(data);
});
}

より軽くしたい場合は、Dispatcher.BeginInvokeを使って非同期にUI更新を依頼します。

C#
Dispatcher.BeginInvoke(new Action(() =>
{
textBoxLog.AppendText(data);
}));

受信頻度が高い場合、毎回UI更新すると画面が重くなることがあります。その場合は、一定時間ごとにまとめて表示する、ログファイルに出力する、キューに入れて別処理で表示するなどの工夫が必要です。

7-6. DataReceivedが発生しない・複数回発生する原因

DataReceivedが発生しない場合、次の原因が考えられます。

・イベント登録を忘れている
・ポートをOpenしていない
・COMポート番号が間違っている
・相手機器がデータを送っていない
・ボーレートやパリティなどの設定が合っていない
・配線が間違っている
・Handshake設定が合っていない
・受信しようとしているポートとは別のポートに接続している

確認用の最小コードで試すと切り分けしやすくなります。

C#
serialPort = new SerialPort("COM3", 9600);
serialPort.DataReceived += (s, e) =>
{
Console.WriteLine("DataReceived発生");
Console.WriteLine(serialPort.ReadExisting());
};
serialPort.Open();

逆に、DataReceivedが複数回発生することは正常です。シリアル通信ではデータが分割されて届くことがあるため、1つのメッセージに対して複数回イベントが発生する場合があります。

したがって、DataReceivedの発生回数に依存した処理ではなく、受信バッファに追加して、終端文字やデータ長で電文を判断する設計にしましょう。

7-7. ReceivedBytesThresholdの使い方と注意点

ReceivedBytesThresholdは、受信バッファに何バイト以上たまったらDataReceivedイベントを発生させるかを指定するプロパティです。

C#
serialPort.ReceivedBytesThreshold = 10;

この例では、受信バッファに10バイト以上たまったときにDataReceivedが発生しやすくなります。

ただし、ReceivedBytesThresholdを設定したからといって、必ず指定バイト数ちょうどでイベントが発生するとは考えない方が安全です。イベント発生のタイミングはOSやドライバ、受信状況の影響を受けます。

固定長10バイトの電文だからといって、次のように単純に処理するのは危険です。

C#
serialPort.ReceivedBytesThreshold = 10;
C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
byte[] buffer = new byte[10];
serialPort.Read(buffer, 0, 10);
}

実際には10バイト以上届いていることもあれば、別のタイミングでイベントが発生することもあります。ReceivedBytesThresholdはイベント発生タイミングの目安として使い、最終的な電文解析は受信バッファ側で行いましょう。

8. 文字化け・改行コード・エンコード対策

8-1. C# SerialPortで文字化けが起きる主な原因

C# SerialPortで文字化けが起きる主な原因は、次のとおりです。

・C#側と相手機器側の文字コードが違う
・ボーレートが一致していない
・DataBits、Parity、StopBitsが一致していない
・バイナリデータを文字列として読み取っている
・相手機器が制御コードを含むデータを送っている
・改行コードや終端文字を誤って処理している

英数字だけの場合は問題が見えにくいですが、日本語や記号を扱うと、文字コードの違いによる文字化けが発生しやすくなります。

たとえば、相手機器がShift_JISで送信しているのに、C#側がUTF-8として読み取ると文字化けします。

C#
serialPort.Encoding = Encoding.UTF8;

この設定が相手機器と合っていない場合、正しく表示されません。

文字化けが発生したときは、まず通信条件と文字コードを確認してください。特に日本語を扱う機器では、Shift_JISが使われていることも多いです。

8-2. EncodingをShift_JIS・UTF-8・ASCIIに設定する方法

C# SerialPortで文字コードを指定するには、Encodingプロパティを設定します。

UTF-8の例です。

C#
using System.Text;

serialPort.Encoding = Encoding.UTF8;

ASCIIの例です。

C#
serialPort.Encoding = Encoding.ASCII;

Shift_JISの例です。

C#
using System.Text;

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
serialPort.Encoding = Encoding.GetEncoding("shift_jis");

.NET FrameworkではShift_JISをそのまま使えるケースが多いですが、.NET 6以降ではSystem.Text.Encoding.CodePagesパッケージとEncoding.RegisterProviderが必要になる場合があります。

NuGetで追加する場合は次のようにします。

Bash
dotnet add package System.Text.Encoding.CodePages

そのうえで、アプリ起動時に登録します。

C#
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

文字列送信と受信の両方で同じEncodingが使われます。

C#
serialPort.Encoding = Encoding.GetEncoding("shift_jis");

serialPort.WriteLine("開始");
string response = serialPort.ReadLine();

相手機器の仕様に合わせて、UTF-8、Shift_JIS、ASCIIを使い分けてください。

8-3. 相手機器の文字コードを確認する方法

文字化けを解決するには、相手機器がどの文字コードでデータを送受信しているか確認する必要があります。

確認方法としては、次のようなものがあります。

・機器の通信仕様書を確認する
・メーカーのマニュアルを確認する
・Tera Termなどの表示設定を変えて確認する
・受信データをバイト列でログ出力して確認する
・相手機器のファームウェア設定を確認する

C#側で受信した生データを16進数で表示すると、文字コードの判断に役立ちます。

C#
int size = serialPort.BytesToRead;
byte[] buffer = new byte[size];
serialPort.Read(buffer, 0, size);

Console.WriteLine(BitConverter.ToString(buffer));

たとえば、日本語の「あ」をShift_JISで表すとバイト列は82-A0になります。一方、UTF-8ではE3-81-82です。このように、バイト列を見ることで文字コードの推測ができます。

文字化けしている状態で文字列だけを見ても原因が分かりにくいため、まずはバイト列で確認するのが有効です。

8-4. ReadLineで止まる原因になるNewLine設定

ReadLineで処理が止まる原因として多いのが、NewLine設定の不一致です。

ReadLineは、NewLineで指定された文字列を受信するまで待ちます。たとえば、C#側が次の設定になっているとします。

C#
serialPort.NewLine = "\r\n";

この状態で相手機器がLFだけを送信している場合、

OK\n

C#側は"\r\n"が届くのを待ち続けるため、ReadLineが戻らないことがあります。

対策は、相手機器の改行コードに合わせることです。

C#
// LFの場合
serialPort.NewLine = "\n";

// CRの場合
serialPort.NewLine = "\r";

// CRLFの場合
serialPort.NewLine = "\r\n";

さらに、ReadTimeoutを設定して、永久待ちを防ぎます。

C#
serialPort.ReadTimeout = 3000;

try
{
string line = serialPort.ReadLine();
}
catch (TimeoutException)
{
Console.WriteLine("改行コードが届かずタイムアウトしました。");
}

ReadLineで止まる場合は、「相手がデータを送っていない」のではなく、「終端と判断できる改行コードが届いていない」可能性もあります。

8-5. CR・LF・CRLFの違いと設定例

CR、LF、CRLFは、改行や行末を表す制御文字です。

CR   :Carriage Return、\r、0x0D
LF :Line Feed、\n、0x0A
CRLF :\r\n、0x0D 0x0A

C#で送信する例です。

C#
serialPort.Write("COMMAND\r");   // CR
serialPort.Write("COMMAND\n"); // LF
serialPort.Write("COMMAND\r\n"); // CRLF

WriteLineで送る場合は、NewLineを設定します。

C#
serialPort.NewLine = "\r\n";
serialPort.WriteLine("COMMAND");

受信でReadLineを使う場合も、同じくNewLineが重要です。

C#
serialPort.NewLine = "\n";
string line = serialPort.ReadLine();

通信仕様書に次のように書かれていることがあります。

コマンド終端:CR
応答終端:CRLF

このような場合、送信と受信で終端が異なる可能性があります。WriteLineだけに頼るのではなく、必要に応じてWrite("COMMAND\r")のように明示的に送信する方が分かりやすい場合もあります。

8-6. バイナリデータを文字列として扱ってはいけないケース

バイナリデータをReadExistingReadLineで文字列として扱うと、データが壊れる可能性があります。

たとえば、次のような電文を扱う場合です。

02 01 FF 00 10 03 A5

この中には、文字として表示できない制御コードや、文字コードとして解釈できない値が含まれることがあります。これを文字列として読み取ると、エンコーディング変換によって別の値になったり、欠落したりする可能性があります。

バイナリ通信では、必ずReadbyte[]として扱います。

C#
int size = serialPort.BytesToRead;
byte[] buffer = new byte[size];

int read = serialPort.Read(buffer, 0, size);

for (int i = 0; i < read; i++)
{
Console.Write($"{buffer[i]:X2} ");
}

送信も同じです。

C#
byte[] command = { 0x02, 0x01, 0x03 };
serialPort.Write(command, 0, command.Length);

「人間が読める文字列の通信」なのか、「制御コードを含むバイナリ通信」なのかを最初に判断することが、C# SerialPort実装では非常に重要です。

9. タイムアウト・例外・エラー処理

9-1. ReadTimeoutとWriteTimeoutの設定方法

ReadTimeoutは読み取り処理のタイムアウト時間、WriteTimeoutは書き込み処理のタイムアウト時間を指定します。単位はミリ秒です。

C#
serialPort.ReadTimeout = 3000;
serialPort.WriteTimeout = 3000;

この例では、読み取りや書き込みが3秒以内に完了しない場合にタイムアウトします。

ReadLineで応答を待つ処理では、ReadTimeoutを設定しておくと安全です。

C#
try
{
string response = serialPort.ReadLine();
Console.WriteLine(response);
}
catch (TimeoutException)
{
Console.WriteLine("受信がタイムアウトしました。");
}

送信時も、相手機器やドライバの状態によっては書き込みが完了しないことがあります。

C#
try
{
serialPort.WriteLine("STATUS");
}
catch (TimeoutException)
{
Console.WriteLine("送信がタイムアウトしました。");
}

タイムアウト時間は、相手機器の応答速度に合わせて設定します。計測器によっては応答に数秒かかるものもあるため、短すぎる値にすると正常な通信でもタイムアウトになります。

9-2. TimeoutExceptionが発生する原因

TimeoutExceptionは、読み取りまたは書き込みが指定時間内に完了しなかった場合に発生します。

受信時の主な原因は次のとおりです。

・相手機器が応答していない
・COMポート番号が間違っている
・ボーレートなど通信条件が一致していない
・ReadLineで期待する改行コードが届いていない
・コマンド送信後の待ち時間が不足している
・相手機器側で処理中のため応答が遅い

特にReadLineでのタイムアウトは、NewLine不一致が原因になりやすいです。

C#
serialPort.NewLine = "\r\n";
serialPort.ReadTimeout = 3000;

try
{
string line = serialPort.ReadLine();
}
catch (TimeoutException)
{
Console.WriteLine("改行コードが一致していない可能性があります。");
}

送信時のタイムアウトでは、フロー制御や相手機器の受信状態、ケーブル接続を確認してください。

タイムアウトは必ずしも異常とは限りません。応答がないことを仕様として扱う通信もあります。その場合は、タイムアウトを正常な分岐として設計することもあります。

9-3. UnauthorizedAccessExceptionの原因と対処法

UnauthorizedAccessExceptionは、指定したCOMポートにアクセスできない場合によく発生します。

主な原因は次のとおりです。

・別のアプリが同じCOMポートを開いている
・同じアプリ内で二重にOpenしている
・前回のアプリ終了時にポートが解放されていない
・アクセス権限に問題がある

典型的なエラーメッセージは次のようなものです。

Access to the port 'COM3' is denied.

対処法として、まずTera Term、Arduino IDE、別のC#アプリなど、同じCOMポートを使っているアプリを閉じます。

C#側では、二重オープンを防ぐためにIsOpenを確認します。

C#
if (!serialPort.IsOpen)
{
serialPort.Open();
}

切断時には確実にCloseDisposeを呼び出します。

C#
if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();

アプリが異常終了した場合、プロセスが残ってポートをつかんだままになることがあります。その場合は、タスクマネージャーで該当プロセスを終了するか、USB機器を抜き差しして復旧を試します。

9-4. IOExceptionが発生するケース

IOExceptionは、入出力処理中に問題が発生した場合に発生します。SerialPortでは、次のようなケースで発生することがあります。

・USBシリアル変換アダプタが抜かれた
・デバイスが切断された
・ドライバがエラー状態になった
・通信中にポートが無効になった
・ハードウェア側で異常が発生した

たとえば、通信中にUSBケーブルを抜くと、次回の読み書きやClose時に例外が発生することがあります。

C#
try
{
serialPort.WriteLine("STATUS");
}
catch (IOException ex)
{
Console.WriteLine("入出力エラーが発生しました。デバイスが切断された可能性があります。");
Console.WriteLine(ex.Message);
}

IOExceptionが発生した場合は、ポートを閉じて再接続できる状態に戻す処理が必要です。

C#
private void ResetPort()
{
try
{
if (serialPort != null)
{
if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
serialPort = null;
}
}
catch
{
serialPort = null;
}
}

USB抜き差しが発生する現場では、再接続処理を設計に含めておくことが重要です。

9-5. ポート切断・USB抜き差し時の例外処理

USBシリアル変換アダプタやArduinoを使う場合、通信中にUSBが抜かれることがあります。このとき、IsOpenがすぐにfalseになるとは限りません。IsOpentrueでも、実際の読み書きで例外が発生することがあります。

そのため、送受信処理では毎回例外処理を入れます。

C#
try
{
serialPort.WriteLine("PING");
}
catch (InvalidOperationException)
{
Console.WriteLine("ポートが開いていません。");
}
catch (IOException)
{
Console.WriteLine("デバイスが切断された可能性があります。");
ResetPort();
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("ポートにアクセスできません。");
ResetPort();
}

受信イベント内でも同様です。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
try
{
string data = serialPort.ReadExisting();
Console.WriteLine(data);
}
catch (IOException)
{
Console.WriteLine("受信中にデバイスが切断されました。");
}
catch (InvalidOperationException)
{
Console.WriteLine("ポートが閉じられています。");
}
}

切断後に自動再接続する場合は、一定間隔でSerialPort.GetPortNames()を確認し、対象ポートが復帰したら再度Openするようにします。ただし、再接続ループが短すぎるとCPU負荷やログ出力が増えるため、1秒から数秒程度の間隔を設けるのが一般的です。

9-6. ErrorReceivedイベントの使い方

ErrorReceivedイベントは、シリアルポートで通信エラーが発生したときに通知されるイベントです。

C#
serialPort.ErrorReceived += SerialPort_ErrorReceived;

イベントハンドラの例です。

C#
private void SerialPort_ErrorReceived(object sender, SerialErrorReceivedEventArgs e)
{
Console.WriteLine($"シリアル通信エラー: {e.EventType}");
}

SerialErrorには、フレーミングエラー、オーバーラン、受信バッファオーバーフロー、パリティエラーなどがあります。

C#
private void SerialPort_ErrorReceived(object sender, SerialErrorReceivedEventArgs e)
{
switch (e.EventType)
{
case SerialError.Frame:
Console.WriteLine("フレーミングエラー");
break;

case SerialError.Overrun:
Console.WriteLine("オーバーランエラー");
break;

case SerialError.RXOver:
Console.WriteLine("受信バッファオーバーフロー");
break;

case SerialError.RXParity:
Console.WriteLine("パリティエラー");
break;

case SerialError.TXFull:
Console.WriteLine("送信バッファがいっぱいです");
break;
}
}

ErrorReceivedだけで全ての異常を検出できるわけではありません。実務では、DataReceived、送受信時の例外処理、ログ出力、再接続処理と組み合わせて使います。

10. 実用的なC# SerialPortサンプルコード

10-1. コンソールアプリで送受信するサンプル

まずは、コンソールアプリでC# SerialPortの基本的な送受信を行うサンプルです。

C#
using System;
using System.IO.Ports;
using System.Text;

class Program
{
static void Main()
{
using var port = new SerialPort
{
PortName = "COM3",
BaudRate = 9600,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Handshake = Handshake.None,
Encoding = Encoding.UTF8,
NewLine = "\r\n",
ReadTimeout = 3000,
WriteTimeout = 3000
};

try
{
port.Open();
Console.WriteLine("接続しました。");

port.WriteLine("STATUS");
Console.WriteLine("送信: STATUS");

string response = port.ReadLine();
Console.WriteLine($"受信: {response}");
}
catch (TimeoutException)
{
Console.WriteLine("タイムアウトしました。");
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}
}
}

このサンプルは、STATUSというコマンドを送信し、相手機器から1行の応答を受け取る想定です。相手機器の改行コードがLFだけの場合は、NewLine = "\n"に変更してください。

10-2. WinFormsでCOMポート選択・接続・送受信するサンプル

WinFormsでは、COMポート一覧をコンボボックスに表示し、接続ボタン、送信ボタン、ログ表示欄を用意する構成がよく使われます。

C#
using System;
using System.IO.Ports;
using System.Text;
using System.Windows.Forms;

public partial class Form1 : Form
{
private SerialPort serialPort;

public Form1()
{
InitializeComponent();
}

private void Form1_Load(object sender, EventArgs e)
{
comboBoxPorts.Items.Clear();
comboBoxPorts.Items.AddRange(SerialPort.GetPortNames());

if (comboBoxPorts.Items.Count > 0)
{
comboBoxPorts.SelectedIndex = 0;
}
}

private void buttonConnect_Click(object sender, EventArgs e)
{
try
{
string portName = comboBoxPorts.Text;

serialPort = new SerialPort
{
PortName = portName,
BaudRate = 9600,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Encoding = Encoding.UTF8,
NewLine = "\r\n",
ReadTimeout = 3000,
WriteTimeout = 3000
};

serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();

AppendLog("接続しました。" + Environment.NewLine);
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "接続エラー");
}
}

private void buttonSend_Click(object sender, EventArgs e)
{
try
{
if (serialPort == null || !serialPort.IsOpen)
{
AppendLog("ポートが開いていません。" + Environment.NewLine);
return;
}

string text = textBoxSend.Text;
serialPort.WriteLine(text);

AppendLog("送信: " + text + Environment.NewLine);
}
catch (Exception ex)
{
AppendLog("送信エラー: " + ex.Message + Environment.NewLine);
}
}

private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
try
{
string data = serialPort.ReadExisting();

BeginInvoke(new Action(() =>
{
AppendLog("受信: " + data + Environment.NewLine);
}));
}
catch (Exception ex)
{
BeginInvoke(new Action(() =>
{
AppendLog("受信エラー: " + ex.Message + Environment.NewLine);
}));
}
}

private void AppendLog(string text)
{
textBoxLog.AppendText(text);
}

private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (serialPort != null)
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
serialPort = null;
}
}
}

このサンプルでは、DataReceived内から直接UIを更新せず、BeginInvokeでUIスレッドに処理を渡しています。

10-3. WPFで受信データを画面表示するサンプル

WPFでは、Dispatcherを使ってUIスレッドに処理を渡します。

C#
using System;
using System.IO.Ports;
using System.Text;
using System.Windows;

public partial class MainWindow : Window
{
private SerialPort serialPort;

public MainWindow()
{
InitializeComponent();
comboBoxPorts.ItemsSource = SerialPort.GetPortNames();
}

private void ButtonConnect_Click(object sender, RoutedEventArgs e)
{
try
{
serialPort = new SerialPort
{
PortName = comboBoxPorts.Text,
BaudRate = 9600,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Encoding = Encoding.UTF8,
NewLine = "\r\n"
};

serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();

textBoxLog.AppendText("接続しました。\r\n");
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}

private void ButtonSend_Click(object sender, RoutedEventArgs e)
{
try
{
if (serialPort == null || !serialPort.IsOpen)
{
textBoxLog.AppendText("ポートが開いていません。\r\n");
return;
}

serialPort.WriteLine(textBoxSend.Text);
textBoxLog.AppendText("送信: " + textBoxSend.Text + "\r\n");
}
catch (Exception ex)
{
textBoxLog.AppendText("送信エラー: " + ex.Message + "\r\n");
}
}

private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

Dispatcher.BeginInvoke(new Action(() =>
{
textBoxLog.AppendText("受信: " + data + "\r\n");
}));
}

protected override void OnClosed(EventArgs e)
{
if (serialPort != null)
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
}

base.OnClosed(e);
}
}

WPFでも、DataReceivedイベントから直接UI部品を操作しないことが重要です。受信頻度が高い場合は、ログ表示を間引く、キューにためて一定間隔で表示するなどの対策を行います。

10-4. ArduinoとC# SerialPortで通信するサンプル

ArduinoとC# SerialPortで通信する例を紹介します。

Arduino側のコードです。

C++
void setup()
{
Serial.begin(9600);
}

void loop()
{
if (Serial.available() > 0)
{
String command = Serial.readStringUntil('\n');
command.trim();

if (command == "LED_ON")
{
Serial.println("OK:LED_ON");
}
else if (command == "LED_OFF")
{
Serial.println("OK:LED_OFF");
}
else
{
Serial.println("NG:UNKNOWN_COMMAND");
}
}
}

C#側のコードです。

C#
using System;
using System.IO.Ports;
using System.Text;

class Program
{
static void Main()
{
using var port = new SerialPort
{
PortName = "COM3",
BaudRate = 9600,
Encoding = Encoding.ASCII,
NewLine = "\n",
ReadTimeout = 3000,
WriteTimeout = 3000
};

try
{
port.Open();

port.WriteLine("LED_ON");

string response = port.ReadLine();
Console.WriteLine(response);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}

ArduinoのSerial.printlnは末尾に改行を付けて送信します。C#側ではNewLine = "\n"または環境に合わせた改行設定にします。

Arduinoはシリアルポートを開いたタイミングでリセットされることがあります。その場合、C#側でOpen直後に少し待ってから送信すると安定することがあります。

C#
port.Open();
Thread.Sleep(2000);
port.WriteLine("LED_ON");

10-5. 受信データをログファイルに保存するサンプル

通信内容をログファイルに保存しておくと、トラブル調査がしやすくなります。

C#
using System;
using System.IO;
using System.IO.Ports;
using System.Text;

class Program
{
private static SerialPort serialPort;
private static readonly object lockObj = new object();
private static readonly string logPath = "serial_log.txt";

static void Main()
{
serialPort = new SerialPort
{
PortName = "COM3",
BaudRate = 9600,
Encoding = Encoding.UTF8,
NewLine = "\r\n"
};

serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();

Console.WriteLine("受信待機中です。Enterで終了します。");
Console.ReadLine();

serialPort.Close();
serialPort.Dispose();
}

private static void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

string log = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {data}";

lock (lockObj)
{
File.AppendAllText(logPath, log, Encoding.UTF8);
}

Console.WriteLine(log);
}
}

受信データをそのままログに出すだけでなく、送信データもログに残すと、どのコマンドに対してどの応答が返ったのか追跡できます。

C#
private static void SendCommand(string command)
{
serialPort.WriteLine(command);

string log = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} SEND: {command}\r\n";
File.AppendAllText(logPath, log, Encoding.UTF8);
}

バイナリ通信の場合は、16進数文字列としてログに残すと解析しやすくなります。

C#
string hex = BitConverter.ToString(buffer, 0, read);
File.AppendAllText(logPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} RECV: {hex}\r\n");

10-6. バイナリプロトコルを送受信するサンプル

バイナリプロトコルでは、受信したバイト列をバッファにため、STXやETXなどの終端で電文を切り出します。

ここでは、0x02をSTX、0x03をETXとして扱う簡単な例を示します。

C#
using System;
using System.Collections.Generic;
using System.IO.Ports;

class BinarySerialSample
{
private SerialPort serialPort;
private readonly List<byte> receiveBuffer = new List<byte>();

public void Start()
{
serialPort = new SerialPort("COM3", 9600);
serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();
}

public void SendPacket(byte[] payload)
{
List<byte> packet = new List<byte>();

packet.Add(0x02); // STX
packet.AddRange(payload);
packet.Add(0x03); // ETX

serialPort.Write(packet.ToArray(), 0, packet.Count);
}

private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
int size = serialPort.BytesToRead;
byte[] data = new byte[size];

int read = serialPort.Read(data, 0, size);

lock (receiveBuffer)
{
for (int i = 0; i < read; i++)
{
receiveBuffer.Add(data[i]);
}

ParsePackets();
}
}

private void ParsePackets()
{
while (true)
{
int stx = receiveBuffer.IndexOf(0x02);
int etx = receiveBuffer.IndexOf(0x03);

if (stx < 0)
{
receiveBuffer.Clear();
return;
}

if (etx < 0 || etx <= stx)
{
return;
}

int length = etx - stx - 1;
byte[] payload = receiveBuffer.GetRange(stx + 1, length).ToArray();

Console.WriteLine("受信ペイロード: " + BitConverter.ToString(payload));

receiveBuffer.RemoveRange(0, etx + 1);
}
}
}

このサンプルではシンプルなSTX/ETX形式を扱っています。実際の機器通信では、データ長、コマンド種別、チェックサム、CRCなどを含むことが多いため、仕様書に従って解析処理を実装してください。

11. C# SerialPortでよくあるトラブルと解決策

11-1. Access to the port is deniedと表示される

Access to the port is deniedは、指定したCOMポートにアクセスできない場合に表示される代表的なエラーです。

主な原因は、別のアプリが同じCOMポートを使用していることです。Tera Term、Arduino IDEのシリアルモニタ、別のC#アプリ、常駐アプリなどがポートを開いていないか確認してください。

また、同じアプリ内で二重にOpenしている場合もあります。

C#
if (!serialPort.IsOpen)
{
serialPort.Open();
}

アプリ終了時にポートを解放していない場合、次回起動時にアクセスできないことがあります。フォーム終了時やアプリ終了時に必ずCloseDisposeを行います。

C#
if (serialPort != null)
{
if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
serialPort = null;
}

それでも解決しない場合は、該当プロセスが残っていないかタスクマネージャーで確認し、必要に応じてUSB機器の抜き差しやPC再起動を試します。

11-2. COMポートが見つからない

COMポートが見つからない場合は、まずSerialPort.GetPortNames()で一覧を確認します。

C#
string[] ports = SerialPort.GetPortNames();

foreach (string port in ports)
{
Console.WriteLine(port);
}

一覧に表示されない場合は、Windowsのデバイスマネージャーで確認します。USBシリアル変換アダプタやArduinoが認識されていない場合、ドライバが必要なことがあります。

確認ポイントは次のとおりです。

・USBケーブルが正しく接続されているか
・充電専用ケーブルではなく通信対応ケーブルか
・デバイスマネージャーに表示されているか
・ドライバが正しくインストールされているか
・COM番号が変更されていないか
・別のUSBポートに接続して変化があるか

COMポート番号は環境によって変わるため、アプリでは固定値にせず、一覧から選択できるようにするのがおすすめです。

11-3. Openは成功するがデータを受信できない

Openは成功するのにデータを受信できない場合、C#側はポートを開けていますが、通信条件や相手機器の送信状態に問題がある可能性があります。

確認ポイントは次のとおりです。

・相手機器が本当にデータを送信しているか
・ボーレートが一致しているか
・DataBits、Parity、StopBitsが一致しているか
・TX/RXの配線が正しいか
・GNDが接続されているか
・Handshake設定が合っているか
・受信イベントを登録しているか
・受信処理で例外が出ていないか

まずTera Termなどで同じCOMポートを開き、相手機器からデータが見えるか確認してください。Tera Termでも受信できない場合、C#コードではなく機器、配線、設定の問題である可能性が高いです。

C#側では、受信イベントが登録されているか確認します。

C#
serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();

また、ReadLineで待っている場合は、改行コードが届いていない可能性があります。まずはReadExistingで確認すると切り分けしやすくなります。

C#
string data = serialPort.ReadExisting();

11-4. ReadLineで処理が止まる

ReadLineで処理が止まる原因は、多くの場合、指定された改行コードが届いていないことです。

たとえば、C#側がCRLFを待っている場合、

C#
serialPort.NewLine = "\r\n";
string line = serialPort.ReadLine();

相手機器がLFだけを送っていると、ReadLineは戻らないことがあります。

対策は、相手機器の改行コードに合わせることです。

C#
serialPort.NewLine = "\n";

また、タイムアウトを設定しておきます。

C#
serialPort.ReadTimeout = 3000;
C#
try
{
string line = serialPort.ReadLine();
}
catch (TimeoutException)
{
Console.WriteLine("ReadLineがタイムアウトしました。");
}

固定長データやバイナリデータのように、改行コードが存在しない通信ではReadLineを使うべきではありません。その場合は、ReadBytesToReadを使ってバイト単位で受信します。

11-5. DataReceivedで受信データが欠ける・途中で切れる

DataReceivedで受信データが欠ける、途中で切れるように見える場合、実際にはデータが分割されて届いているだけのことがあります。

悪い例です。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

// ここで1つの完全な電文だと思い込むのは危険
ProcessMessage(data);
}

シリアル通信では、1回のDataReceivedで1電文が届く保証はありません。受信バッファを用意し、終端文字や改行コードで電文単位に切り出します。

C#
private readonly StringBuilder buffer = new StringBuilder();

private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();

lock (buffer)
{
buffer.Append(data);

string text = buffer.ToString();
int index;

while ((index = text.IndexOf("\n")) >= 0)
{
string line = text.Substring(0, index).TrimEnd('\r');
ProcessMessage(line);

text = text.Substring(index + 1);
}

buffer.Clear();
buffer.Append(text);
}
}

データが欠けると感じた場合は、まず受信した生データをログに残し、イベント単位ではなくバイト単位で確認してください。

11-6. 送受信はできるが文字化けする

送受信自体はできているのに文字化けする場合、通信条件または文字コードが合っていない可能性があります。

まず、ボーレート、データビット、パリティ、ストップビットを確認します。

C#
serialPort.BaudRate = 9600;
serialPort.DataBits = 8;
serialPort.Parity = Parity.None;
serialPort.StopBits = StopBits.One;

次に、文字コードを確認します。

C#
serialPort.Encoding = Encoding.UTF8;

相手機器がShift_JISの場合は、次のようにします。

C#
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
serialPort.Encoding = Encoding.GetEncoding("shift_jis");

英数字も含めて全体的に意味不明な文字になる場合は、ボーレートなど通信条件の不一致が疑われます。日本語だけが文字化けする場合は、文字コードの不一致が疑われます。

バイナリデータを文字列として表示している場合も文字化けのように見えます。その場合は、16進数で表示してください。

C#
Console.WriteLine(BitConverter.ToString(buffer));

11-7. アプリ終了時にポートが解放されない

アプリ終了時にポートが解放されないと、次回起動時に同じCOMポートを開けないことがあります。

WinFormsでは、FormClosingで切断処理を行います。

C#
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
CloseSerialPort();
}

private void CloseSerialPort()
{
if (serialPort == null)
{
return;
}

try
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}
}
finally
{
serialPort.Dispose();
serialPort = null;
}
}

WPFでは、OnClosedClosingイベントで同様に処理します。

C#
protected override void OnClosed(EventArgs e)
{
if (serialPort != null)
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
serialPort = null;
}

base.OnClosed(e);
}

DataReceivedイベントが発生中にCloseすると例外になるケースもあるため、アプリの設計によっては受信停止フラグを用意し、イベント処理と終了処理が競合しないようにします。

12. C# SerialPortを安全に使う設計のポイント

12-1. UI処理と通信処理を分離する

C# SerialPortを安定して使うには、UI処理と通信処理を分離することが重要です。

WinFormsやWPFのフォーム内にすべての通信処理を書くと、画面更新、受信解析、例外処理、再接続処理が混在して保守しにくくなります。可能であれば、SerialPortを扱う専用クラスを作成し、画面側はイベントやコールバックで結果を受け取る設計にします。

C#
public class SerialService : IDisposable
{
private SerialPort serialPort;

public event Action<string> MessageReceived;

public void Open(string portName)
{
serialPort = new SerialPort(portName, 9600)
{
NewLine = "\r\n"
};

serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();
}

public void Send(string message)
{
serialPort.WriteLine(message);
}

private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();
MessageReceived?.Invoke(data);
}

public void Dispose()
{
if (serialPort != null)
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
}
}
}

画面側では、受信イベントを受けてUIスレッドに渡します。

C#
serialService.MessageReceived += message =>
{
BeginInvoke(new Action(() =>
{
textBoxLog.AppendText(message);
}));
};

このように分離すると、通信処理のテストや再利用がしやすくなります。

12-2. 受信バッファを使って電文単位で処理する

SerialPortの受信では、受信バッファを使って電文単位で処理する設計が重要です。

1回のDataReceivedで1つの完全な電文が届くとは限りません。そのため、イベント内で読み取ったデータを一時バッファに追加し、改行コード、終端文字、データ長などをもとに電文を切り出します。

文字列の行単位プロトコルであれば、次のような設計になります。

C#
private readonly StringBuilder receiveBuffer = new StringBuilder();

private void AddReceivedText(string data)
{
receiveBuffer.Append(data);

string text = receiveBuffer.ToString();
int index;

while ((index = text.IndexOf("\r\n")) >= 0)
{
string line = text.Substring(0, index);
HandleLine(line);

text = text.Substring(index + 2);
}

receiveBuffer.Clear();
receiveBuffer.Append(text);
}

固定長バイナリプロトコルであれば、必要なバイト数がたまった時点で処理します。

C#
if (buffer.Count >= packetLength)
{
byte[] packet = buffer.Take(packetLength).ToArray();
buffer.RemoveRange(0, packetLength);

HandlePacket(packet);
}

受信データが途中で切れる問題は、受信バッファ設計で解決できることが多いです。

12-3. CancellationTokenやTaskで非同期処理する考え方

SerialPortにはDataReceivedイベントを使う方法のほかに、Taskで読み取りループを作る考え方もあります。長時間動作するアプリでは、CancellationTokenを使って安全に停止できるようにすると管理しやすくなります。

例として、ReadLineをバックグラウンドで繰り返す処理です。

C#
private CancellationTokenSource cts;

private void StartReading()
{
cts = new CancellationTokenSource();

Task.Run(() => ReadLoop(cts.Token));
}

private void ReadLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
string line = serialPort.ReadLine();
Console.WriteLine(line);
}
catch (TimeoutException)
{
// タイムアウトは継続
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
break;
}
}
}

private void StopReading()
{
cts?.Cancel();
}

ReadLineを使う場合は、ReadTimeoutを設定しておくと、キャンセル確認の機会を作れます。

C#
serialPort.ReadTimeout = 500;

非同期処理では、ポートのクローズと読み取りループが競合しないように注意が必要です。停止フラグ、例外処理、Disposeの順序を整理して実装しましょう。

12-4. ログ出力で通信内容を追跡できるようにする

シリアル通信のトラブルは、現象だけでは原因が分かりにくいことがあります。そのため、送信データ、受信データ、時刻、例外内容をログに残す設計が重要です。

文字列通信のログ例です。

C#
private void LogSend(string text)
{
File.AppendAllText("serial.log",
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} SEND {text}\r\n");
}

private void LogReceive(string text)
{
File.AppendAllText("serial.log",
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} RECV {text}\r\n");
}

バイナリ通信では、16進数でログ出力します。

C#
private void LogReceiveBytes(byte[] data, int length)
{
string hex = BitConverter.ToString(data, 0, length);

File.AppendAllText("serial.log",
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} RECV {hex}\r\n");
}

ログがあると、次のようなことを確認できます。

・送信コマンドが正しいか
・改行コードが付いているか
・相手機器から応答が返っているか
・応答が分割されていないか
・文字化け前のバイト列がどうなっているか
・例外がいつ発生したか

本番環境ではログファイルが肥大化しないように、日付ごとに分ける、一定サイズでローテーションするなどの対応も検討してください。

12-5. 再接続処理を実装する

USBシリアル変換やArduinoとの通信では、ケーブル抜き差し、機器再起動、スリープ復帰などで通信が切れることがあります。安定したアプリにするには、再接続処理を用意しておくと安心です。

基本的な考え方は次のとおりです。

1. 通信エラーを検出する
2. 既存のSerialPortをClose/Disposeする
3. 一定時間待つ
4. COMポート一覧を再取得する
5. 対象ポートが見つかれば再度Openする

簡単な再接続例です。

C#
private bool TryReconnect(string portName)
{
try
{
ClosePort();

serialPort = new SerialPort(portName, 9600)
{
NewLine = "\r\n",
ReadTimeout = 3000,
WriteTimeout = 3000
};

serialPort.DataReceived += SerialPort_DataReceived;
serialPort.Open();

return true;
}
catch
{
return false;
}
}

再接続を無限に高速で繰り返すと負荷が高くなるため、一定間隔を空けます。

C#
await Task.Delay(3000);

また、USB抜き差し後にCOM番号が変わることもあるため、必要に応じてユーザーに再選択させる設計にします。

12-6. Dispose漏れを防ぐ

SerialPortはOSのポートリソースを扱うため、Dispose漏れを防ぐことが重要です。

短時間で使う場合はusingを使います。

C#
using var port = new SerialPort("COM3", 9600);
port.Open();
port.WriteLine("HELLO");

画面アプリでは、終了時に明示的に破棄します。

C#
public void Dispose()
{
if (serialPort != null)
{
serialPort.DataReceived -= SerialPort_DataReceived;

if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.Dispose();
serialPort = null;
}
}

Dispose漏れを防ぐためには、SerialPortを直接あちこちで生成せず、通信管理クラスに閉じ込める設計が有効です。生成、接続、送信、受信、切断、破棄の責任を1つのクラスにまとめることで、リソース管理のミスを減らせます。

13. C# SerialPortに関するよくある質問

13-1. SerialPortは.NET 6や.NET 8でも使える?

C#のSerialPortは、.NET 6や.NET 8でも利用できます。ただし、プロジェクトの種類によってはSystem.IO.Portsパッケージの追加が必要です。

Bash
dotnet add package System.IO.Ports

コードでは次の名前空間を使用します。

C#
using System.IO.Ports;

WindowsのCOMポートを扱う場合は、従来の.NET Frameworkと同じようにCOM3などを指定します。

C#
using var port = new SerialPort("COM3", 9600);
port.Open();

.NET 6以降でShift_JISなどのコードページを使う場合は、System.Text.Encoding.CodePagesが必要になることがあります。

Bash
dotnet add package System.Text.Encoding.CodePages
C#
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

13-2. C#でCOMポート一覧を取得するには?

C#でCOMポート一覧を取得するには、SerialPort.GetPortNames()を使います。

C#
string[] ports = SerialPort.GetPortNames();

foreach (string port in ports)
{
Console.WriteLine(port);
}

WinFormsのコンボボックスに表示する例です。

C#
comboBoxPorts.Items.Clear();
comboBoxPorts.Items.AddRange(SerialPort.GetPortNames());

WPFでは、ItemsSourceに設定できます。

C#
comboBoxPorts.ItemsSource = SerialPort.GetPortNames();

COMポートが表示されない場合は、デバイスマネージャーで機器が認識されているか、ドライバが正しくインストールされているかを確認してください。

13-3. ReadLineとReadExistingはどちらを使うべき?

改行コードで終端されるテキスト通信なら、ReadLineが便利です。

C#
serialPort.NewLine = "\r\n";
string line = serialPort.ReadLine();

ただし、ReadLineは指定した改行コードが届くまで待つため、NewLineが合っていないと処理が止まります。必ずReadTimeoutも設定しましょう。

C#
serialPort.ReadTimeout = 3000;

一方、ReadExistingは、その時点で受信バッファにある文字列を取得します。

C#
string data = serialPort.ReadExisting();

DataReceivedイベント内で使いやすいですが、1回で完全な電文が取れる保証はありません。受信バッファに追加して、改行コードなどで電文を切り出す設計が必要です。

固定長やバイナリ通信では、ReadLineReadExistingではなく、Readbyte[]として扱うのがおすすめです。

13-4. DataReceived内でUIを直接更新できないのはなぜ?

DataReceivedイベントは、WinFormsやWPFのUIスレッドとは別のスレッドで実行されるためです。

WinFormsのTextBoxを直接更新する次のようなコードは危険です。

C#
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
string data = serialPort.ReadExisting();
textBoxLog.AppendText(data);
}

WinFormsではInvokeまたはBeginInvokeを使います。

C#
BeginInvoke(new Action(() =>
{
textBoxLog.AppendText(data);
}));

WPFではDispatcherを使います。

C#
Dispatcher.BeginInvoke(new Action(() =>
{
textBoxLog.AppendText(data);
}));

UI更新と通信処理を分離することで、クロススレッド例外や画面フリーズを防ぎやすくなります。

13-5. Arduinoとの通信で文字化けする原因は?

Arduinoとの通信で文字化けする場合、まずボーレートを確認します。Arduino側が次の設定なら、

C++
Serial.begin(9600);

C#側も同じボーレートにします。

C#
serialPort.BaudRate = 9600;

次に、文字コードを確認します。Arduinoとの簡単な英数字通信では、ASCIIまたはUTF-8で問題ないケースが多いです。

C#
serialPort.Encoding = Encoding.ASCII;

また、改行コードも重要です。Arduino側でSerial.printlnを使うと改行付きで送信されます。C#側でReadLineを使う場合は、NewLineを合わせます。

C#
serialPort.NewLine = "\n";

ArduinoはC#側でポートを開いたタイミングでリセットされることがあります。Open直後にすぐ送信して応答がない場合は、少し待ってから送信します。

C#
serialPort.Open();
Thread.Sleep(2000);
serialPort.WriteLine("HELLO");

13-6. シリアル通信の動作確認に必要なツールは?

C# SerialPortの実装前後には、シリアル通信確認ツールを使うと便利です。

代表的なツールには、Tera Term、PuTTY、RealTerm、Arduino IDEのシリアルモニタなどがあります。

これらのツールを使うと、次の確認ができます。

・COMポートが正しいか
・ボーレートが正しいか
・相手機器からデータが送られているか
・送信コマンドに応答があるか
・必要な改行コードがCR、LF、CRLFのどれか
・文字化けが発生するか

C#アプリでうまく通信できない場合でも、Tera Termで通信できるなら、機器やケーブルではなくC#側の実装に原因がある可能性が高くなります。

逆に、Tera Termでも通信できない場合は、COMポート番号、通信条件、配線、ドライバ、相手機器の設定を確認する必要があります。

まとめ

C# SerialPortを使うと、COMポートを通じて外部機器とシリアル通信を行えます。計測器、Arduino、PLC、バーコードリーダー、USBシリアル変換アダプタなど、さまざまな機器との接続に利用できます。

基本的な流れは、SerialPortを作成し、PortNameBaudRateDataBitsParityStopBitsHandshakeEncodingNewLineなどを設定し、Openで接続してからWriteWriteLineReadLineReadExistingReadで送受信します。

C# SerialPortで特に重要なのは、相手機器と通信条件を合わせることです。COMポート番号、ボーレート、データビット、パリティ、ストップビット、フロー制御、文字コード、改行コードが一致していないと、受信できない、文字化けする、ReadLineで止まるといったトラブルが発生します。

また、DataReceivedイベントを使う場合は、別スレッドで実行される点に注意が必要です。WinFormsやWPFでUIを更新する場合は、InvokeDispatcherを使ってUIスレッドに処理を渡します。さらに、1回のDataReceivedで1電文が届くとは限らないため、受信バッファを使って電文単位に組み立てる設計が重要です。

実務で安定したC# SerialPortアプリを作るには、次のポイントを意識してください。

・通信前にTera Termなどで動作確認する
・COMポート一覧を取得して選択できるようにする
・通信条件を相手機器の仕様に合わせる
・ReadTimeoutとWriteTimeoutを設定する
・DataReceivedでは受信バッファを使う
・UI更新はInvokeやDispatcherで行う
・文字コードと改行コードを明示的に設定する
・バイナリ通信ではbyte配列で扱う
・例外処理とログ出力を実装する
・CloseとDisposeで確実にポートを解放する

C# SerialPortはシンプルに見えますが、通信条件、受信タイミング、文字コード、例外処理を正しく設計することで、現場でも安定して使えるシリアル通信アプリを実装できます。