C#とC++/MFCの別プロセス間で双方向通信する方法:WM_COPYDATA+RegisterWindowMessageでイベント駆動IPC

C# アプリと C++/MFC アプリを別プロセスで動かし、「どちらからでも任意のタイミングで文字列を送りたい」「受信側はイベントのように即処理したい」という双方向(フルデュプレックス)の要件は、実は“つなぎ方”を決めるだけで難易度が大きく変わります。ここでは同一PC・同一ログオンセッション内を前提に、WM_COPYDATA と登録済みメッセージによるハンドシェイクで、起動順不同でも双方向通信できる実装例と設計の勘所をまとめます。

目次

この問題の本質は「C++/CLI」ではなく「IPC(プロセス間通信)」

まず押さえたいのは、C# と C++ をつなぐ手段には大きく2系統あるという点です。

やりたいこと典型的な手段向いているケース今回の要件との相性
同一プロセス内で C# ↔ C++ を呼び合うP/Invoke / C++/CLI / C++ DLLC# からネイティブDLLを呼ぶ、同一EXE内で混在など別プロセス前提なので不一致
別プロセス(EXE同士)でデータをやり取りする名前付きパイプ / ソケット / WM_COPYDATA / 共有メモリ等アプリ同士、サービスとアプリ等まさにこれ(IPCが必要)

C++/CLI は「同一プロセス内の相互呼び出し」に強い一方、EXE 同士で双方向に送受信するなら、基本は IPC を選びます。今回の条件が「同一PC・同一ログオンセッション内」「双方がウィンドウ(UI/隠しどちらでも)を持てる」なら、最短距離は WM_COPYDATA です。

なぜ WM_COPYDATA が手軽なのか

WM_COPYDATA は Windows のウィンドウメッセージの一種で、送信側が相手の HWND(ウィンドウハンドル)を持っていれば、文字列などのデータを相手プロセスへ渡せます。

  • 追加のサーバ実装がいらない(パイプの接続待ち、ポート管理などが不要)
  • 送受信が対称(C#→C++ も C++→C# も同じやり方)
  • イベント駆動にしやすい(受信側は WndProc / OnCopyData で受けてイベント発火)

ただし万能ではなく、WM_COPYDATA には前提と癖があります。

注意点何が起きるか対策の考え方
相手 HWND が必要起動順不同だと「相手が見つからない」問題が出る登録済みメッセージで“ハンドシェイク”して互いのHWNDを確定する
SendMessage は同期受信側が重い処理をすると送信側が待たされる受信側は取り込みだけして、処理はキュー/別タスクへ
同一セッション前提になりがちサービスや別ユーザー/別セッションには届かない・届きにくい要件が広いなら名前付きパイプ/ソケットへ
文字コード/バイト数の整合が必須文字化け、末尾欠け、アクセス違反UTF-16LE(Unicode)で統一、cbData の計算を両側で合わせる

設計の肝は「相手 HWND をどう見つけるか」

WM_COPYDATA 自体は「HWND を知っている相手へ送る」仕組みです。つまり、双方向で使うには最初に互いの HWND を知る必要があります。

ここで役立つのが RegisterWindowMessage(登録済みウィンドウメッセージ)です。両者が同じ文字列で登録すると、同じメッセージID(UINT)が返り、そのIDを使って「起動したよ」「私のHWNDはこれ」という合図を送り合えます。

ハンドシェイクの流れ(起動順不同に強い)

フェーズ送信先送る内容受信側の動き
起動通知HWND_BROADCAST登録済みメッセージ(wParam=自分のHWND、lParam=自分のPIDなど)自分宛てでなければ無視、相手候補なら返信して確定
返信相手のHWND同じ登録済みメッセージ(自分のHWND/PID)互いに相手HWNDを保存、以後はそのHWNDにWM_COPYDATAを送る
運用相手のHWNDWM_COPYDATA(文字列)受信イベント発火、処理へ

ポイントは次の通りです。

  • 登録済みメッセージの文字列はユニークに:衝突を避けるため、会社名/製品名/用途/GUID 風の文字列を混ぜるのが現実的です。
  • PID は OS が自動付与:アプリが任意に決めるものではなく、識別用の補助情報として使います。
  • 「誰とペアになるか」をルール化:サンプルは 1対1 を前提にし、既に接続済みなら後から来た通知は無視、などで 3つ目以降の誤接続を防げます。

実装例(C# WinForms):イベント駆動で受信し、いつでも送れる

ここでは WinForms のフォーム(表示しても隠してもOK)を通信エンドポイントにし、WM_COPYDATA と登録済みメッセージを処理します。受信はイベントとして外に公開し、アプリ側は「イベントを購読して処理する」形にします。

P/Invoke 定義と構造体

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows.Forms;

public partial class MainForm : Form
{
private const int WM_COPYDATA = 0x004A;
private static readonly int WM_READY = RegisterWindowMessage("com.example.ipc.ReadyForCopyData.{C1B7A5B5-4A0E-4F89-9D7F-3B6C2F6E5D11}");


private IntPtr _peerHwnd = IntPtr.Zero;
private int _peerPid = 0;

public event EventHandler<string> CopyDataReceived;

[StructLayout(LayoutKind.Sequential)]
private struct COPYDATASTRUCT
{
    public IntPtr dwData;   // アプリ側のメッセージ種別など
    public int cbData;      // lpData のバイト数
    public IntPtr lpData;   // データ本体へのポインタ
}

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterWindowMessage(string lpString);

[DllImport("user32.dll", SetLastError = true)]
private static extern bool PostMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);

[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, ref COPYDATASTRUCT lParam);

[DllImport("user32.dll")]
private static extern bool IsWindow(IntPtr hWnd);

public MainForm()
{
    InitializeComponent();
}

protected override void OnShown(EventArgs e)
{
    base.OnShown(e);
    BroadcastReady();
}

private void BroadcastReady()
{
    // 起動通知:wParam=自分のHWND、lParam=自分のPID
    var pid = Process.GetCurrentProcess().Id;
    PostMessage(new IntPtr(0xFFFF), WM_READY, this.Handle, new IntPtr(pid)); // HWND_BROADCAST = 0xFFFF
}

protected override void WndProc(ref Message m)
{
    if (m.Msg == WM_READY)
    {
        HandleReadyMessage(m.WParam, m.LParam);
        return;
    }

    if (m.Msg == WM_COPYDATA)
    {
        HandleCopyData(m.WParam, m.LParam);
        m.Result = new IntPtr(1);
        return;
    }

    base.WndProc(ref m);
}

private void HandleReadyMessage(IntPtr senderHwnd, IntPtr senderPidPtr)
{
    int senderPid = senderPidPtr.ToInt32();
    int myPid = Process.GetCurrentProcess().Id;

    // 自分自身のブロードキャストも届くため除外
    if (senderPid == myPid) return;

    // すでに接続済みで相手が生きているなら、追加の相手は受け付けない(1対1前提)
    if (_peerHwnd != IntPtr.Zero && IsWindow(_peerHwnd))
    {
        return;
    }

    // 相手を確定
    _peerHwnd = senderHwnd;
    _peerPid = senderPid;

    // 返信して相手にもこちらのHWNDを知らせる
    PostMessage(_peerHwnd, WM_READY, this.Handle, new IntPtr(myPid));
}

private void HandleCopyData(IntPtr senderHwnd, IntPtr lParam)
{
    var cds = Marshal.PtrToStructure<COPYDATASTRUCT>(lParam);

    // Unicode(UTF-16LE) の文字列を受け取る想定
    int charCount = Math.Max(0, (cds.cbData / 2) - 1); // 終端NULLを除外
    string text = Marshal.PtrToStringUni(cds.lpData, charCount) ?? string.Empty;

    // 重い処理はここで直接やらない(送信側が待つ)。イベントで渡して後続で処理。
    CopyDataReceived?.Invoke(this, text);
}

// 送信(C# → C++ を想定した名前に)
public bool SendDatatoCPP(string text, uint kind = 1)
{
    if (_peerHwnd == IntPtr.Zero || !IsWindow(_peerHwnd)) return false;

    // null終端を付けてUTF-16LEで送る
    byte[] bytes = Encoding.Unicode.GetBytes(text + "\0");
    IntPtr pData = Marshal.AllocHGlobal(bytes.Length);
    try
    {
        Marshal.Copy(bytes, 0, pData, bytes.Length);

        var cds = new COPYDATASTRUCT
        {
            dwData = new IntPtr(unchecked((int)kind)),
            cbData = bytes.Length,
            lpData = pData
        };

        // wParam は「送信元HWND」として渡すのが慣例(受信側で参照可能)
        SendMessage(_peerHwnd, WM_COPYDATA, this.Handle, ref cds);
        return true;
    }
    finally
    {
        Marshal.FreeHGlobal(pData);
    }
}


}

C# 側で「イベントとして受け取る」例

フォーム側が受信イベントを公開しているので、アプリのロジックは次のように“イベント購読”で書けます。

public MainForm()
{
    InitializeComponent();

    this.CopyDataReceived += (s, text) =>
    {
        // 例:UIに表示(必要ならBeginInvokeで負荷分散)
        this.textBoxLog.AppendText($"[RX] {text}{Environment.NewLine}");

        // 例:受信に応じて返信(フルデュプレックス)
        if (text.StartsWith("PING"))
        {
            SendDatatoCPP("PONG");
        }
    };
}

これで C# 側は「いつ送るか決まっていない」状況でも、受信はイベントで発火し、送信は任意タイミングで呼べます。

実装例(C++/MFC):ON_WM_COPYDATA と登録済みメッセージで双方向に

MFC 側は、ダイアログベースでもフレームベースでも考え方は同じです。ここでは説明が短く済む ダイアログベース(CDialogEx) の例を示します。

ヘッダ(メンバと宣言)

#pragma once
#include <string>
#include <Windows.h>

static UINT WM_READY = ::RegisterWindowMessage(L"com.example.ipc.ReadyForCopyData.{C1B7A5B5-4A0E-4F89-9D7F-3B6C2F6E5D11}");

class CMyMfcDlg : public CDialogEx
{
public:
CMyMfcDlg(CWnd* pParent = nullptr);

protected:
HWND  m_peerHwnd = nullptr;
DWORD m_peerPid  = 0;


void BroadcastReady();
void RaiseReceived(const std::wstring& text); // 受信イベント相当(必要ならコールバックにしてもOK)

bool SendDatatoCSharp(const std::wstring& text, ULONG_PTR kind = 1);

afx_msg BOOL OnCopyData(CWnd* pWnd, COPYDATASTRUCT* pCopyDataStruct);
afx_msg LRESULT OnReadyMsg(WPARAM wParam, LPARAM lParam);

virtual BOOL OnInitDialog();

DECLARE_MESSAGE_MAP()


};

実装(ハンドシェイク+受信+送信)

#include "pch.h"
#include "MyMfcDlg.h"
#include <Psapi.h>

BEGIN_MESSAGE_MAP(CMyMfcDlg, CDialogEx)
ON_WM_COPYDATA()
ON_REGISTERED_MESSAGE(WM_READY, &CMyMfcDlg::OnReadyMsg)
END_MESSAGE_MAP()

BOOL CMyMfcDlg::OnInitDialog()
{
CDialogEx::OnInitDialog();
BroadcastReady();
return TRUE;
}

void CMyMfcDlg::BroadcastReady()
{
DWORD myPid = ::GetCurrentProcessId();
// HWND_BROADCAST = 0xFFFF
::PostMessage(HWND_BROADCAST, WM_READY, (WPARAM)this->GetSafeHwnd(), (LPARAM)myPid);
}

LRESULT CMyMfcDlg::OnReadyMsg(WPARAM wParam, LPARAM lParam)
{
HWND senderHwnd = (HWND)wParam;
DWORD senderPid = (DWORD)lParam;
DWORD myPid = ::GetCurrentProcessId();


if (senderPid == myPid) return 0; // 自分自身は無視

// 1対1前提:既に接続済みで相手が生きているなら無視
if (m_peerHwnd && ::IsWindow(m_peerHwnd))
{
    return 0;
}

m_peerHwnd = senderHwnd;
m_peerPid = senderPid;

// 返信(相手にこちらのHWNDを知らせる)
::PostMessage(m_peerHwnd, WM_READY, (WPARAM)this->GetSafeHwnd(), (LPARAM)myPid);
return 0;


}

BOOL CMyMfcDlg::OnCopyData(CWnd* pWnd, COPYDATASTRUCT* pCopyDataStruct)
{
if (!pCopyDataStruct || !pCopyDataStruct->lpData || pCopyDataStruct->cbData <= 0)
return FALSE;


// UTF-16LE(wchar_t)で受け取る想定
size_t wcharCount = (size_t)pCopyDataStruct->cbData / sizeof(wchar_t);
if (wcharCount == 0) return FALSE;

// 末尾NULLを前提にするなら -1、ない可能性もあるなら安全側に調整
size_t actualCount = wcharCount;
const wchar_t* p = (const wchar_t*)pCopyDataStruct->lpData;
if (p[wcharCount - 1] == L'\0' && wcharCount > 0) actualCount = wcharCount - 1;

std::wstring text(p, p + actualCount);

// ここで重い処理をしない(送信側が待つ)。必要ならPostMessageで自前キューへ。
RaiseReceived(text);

return TRUE;


}

void CMyMfcDlg::RaiseReceived(const std::wstring& text)
{
// 例:ログ表示や処理
// SetDlgItemText(IDC_EDIT_LOG, text.c_str()); など
// 受信内容に応じて返信も可能(フルデュプレックス)
if (text.rfind(L"PING", 0) == 0)
{
SendDatatoCSharp(L"PONG");
}
}

bool CMyMfcDlg::SendDatatoCSharp(const std::wstring& text, ULONG_PTR kind)
{
if (!m_peerHwnd || !::IsWindow(m_peerHwnd)) return false;


// null終端も含めて送る
std::wstring payload = text;
payload.push_back(L'\0');

COPYDATASTRUCT cds{};
cds.dwData = kind;
cds.cbData = (DWORD)(payload.size() * sizeof(wchar_t));
cds.lpData = (PVOID)payload.c_str();

// WM_COPYDATA は SendMessage(同期)で送る
::SendMessage(m_peerHwnd, WM_COPYDATA, (WPARAM)this->GetSafeHwnd(), (LPARAM)&cds);
return true;


}

MFC 側も C# 側と同様に、「受信したらイベント(相当)で処理」「必要ならその場で返信」ができるため、どちらが先に送るか決まっていない要件に合います。

「双方向・イベント駆動」を崩さないための運用ルール

WM_COPYDATA で動くサンプルは簡単に書けますが、運用でつまずきやすいのは次の3点です。

受信ハンドラで重い処理をしない

WM_COPYDATA は送信側が SendMessage で送ります。つまり受信側が戻るまで送信側が止まります。ログ書き込み、DBアクセス、ネットワーク呼び出しのような重い処理を WndProc / OnCopyData 内で直接やると、体感で「送信が詰まる」「アプリが固まる」原因になります。

  • 受信側は「文字列をコピーしてキューへ積む」だけにする
  • 処理は UI スレッドならタイマー/BeginInvoke、非UIならワーカースレッドへ
  • 応答が必要な場合でも「最小限のACKだけ返し、重い処理は後で」

プロトコル(文字列のルール)を先に決める

「とりあえず文字列を投げる」は最初は速いのですが、あとで拡張すると破綻しがちです。最低限、次のどれかを決めておくと運用が楽になります。

決める項目おすすめ理由
文字コードUTF-16LE(Windows Unicode)C# の Unicode と MFC の wchar_t が自然に合う
メッセージ種別COPYDATASTRUCT.dwData を用途別に使う受信側で分岐が簡単(文字列をパースする前に判定できる)
本文の形式JSON / key=value / CSV などを統一将来フィールドが増えても壊れにくい
バージョン本文に version を入れる古い相手と接続したときの事故を減らせる

例えば dwData を次のように予約しておくと、後から「通知」「コマンド」「ログ」「状態同期」などを足しやすくなります。

dwData意味本文例
1テキスト通知Hello
2コマンド{“cmd”:”Start”,”param”:”A”}
3状態同期{“state”:”Idle”,”ts”:1700000000}
4ACK/応答{“ok”:true,”reqId”:”…”}

「誤送信しない」ために、最初の握手で相手を絞る

HWND_BROADCAST は全ウィンドウに届きます。登録済みメッセージを使えば「その合図を知っている相手だけが参加できる」仕組みになりますが、より安全にするなら握手を次のように拡張できます。

  • 登録文字列を長くユニークに(例:逆ドメイン + 機能名 + GUID)
  • 返信時にアプリ固有トークンを追加(例:WM_COPYDATA で “HELLO|token=…” を送る)
  • 相手のプロセス名を確認(PID から OpenProcess → GetModuleBaseName など。運用権限に注意)
  • 1対1運用なら「先着1つだけ採用」(サンプルのように2つ目以降は無視)

複数インスタンス対応が必要なら、「接続先名(チャンネル名)」を握手に含め、同じチャンネル名の相手だけとペアになる設計が堅実です。

よくある質問に先回りで答える

「ID は誰が決める?」

プロセスID(PID)は OS がプロセス生成時に自動で割り当てます。アプリが任意に決めるものではありません。今回の設計では、PID は「自分のブロードキャストを除外する」「ログやデバッグで相手を識別する」補助情報として使います。

一方、「この通信の相手は誰か」をアプリ側で明確にしたいなら、PID ではなく、アプリ固有の GUID や接続名(チャンネル名)を握手に含めて判定するのが一般的です。

「起動順が固定できないけど大丈夫?」

登録済みメッセージのブロードキャスト + 返信のハンドシェイクにしておけば、C# が先でも MFC が先でも、後から起動した側が相手の通知を受けて接続できます。片方が落ちて再起動した場合も、再度ブロードキャストすれば再接続できます。

「管理者実行と通常実行が混ざると?」

UAC の影響でメッセージが届きにくいケースがあります。確実性が必要で、権限レベルが混在する可能性があるなら、WM_COPYDATA より 名前付きパイプ のような IPC の方が安定しやすいです。運用で「両方同じ権限で起動」を徹底できるなら、WM_COPYDATA のままでも問題が出ないことは多いです。

WM_COPYDATA が向かない要件と、代替案の選び方

「C# + MFC の2プロセスで手軽に文字列」なら WM_COPYDATA が現実的ですが、要件が少し広がると別手段の方が良いこともあります。

要件おすすめ IPC理由
UI を持たない(サービス/コンソール)名前付きパイプウィンドウ不要、バックグラウンドで安定
複数クライアント/複数インスタンス名前付きパイプ / TCP接続管理がしやすい
ネットワーク越しTCP ソケット / gRPC同一PC前提を外せる
大きいデータ(画像・大量ログ)共有メモリ + イベント / パイプWM_COPYDATA は大きなペイロードに不向き
高い堅牢性(再接続、切断検知、再送)名前付きパイプ通信路としての機能が揃っている

とはいえ、最初の一歩として「まずは WM_COPYDATA で動かし、要件が膨らんだらパイプへ移行」という進め方は、現場での成功率が高いです。プロトコル(dwData と本文形式)を最初から整理しておくと、移行時もコードが崩れません。

トラブルシューティング:動かないときのチェックリスト

症状原因の候補確認・対処
そもそも相手が見つからないハンドシェイクが届いていない / WM_READY の文字列が不一致RegisterWindowMessage の文字列を両アプリで完全一致させる(大文字小文字・GUID含め)
受信はするが文字化けするUTF-16/UTF-8 の混在、cbData 計算ミス両側で UTF-16LE に統一し、cbData を「バイト数」で合わせる。終端NULLの有無も統一
送信側が固まる受信側が WndProc/OnCopyData で重い処理受信側はコピーしてキューへ。処理は別スレッド/後段へ移す
たまに届かない/落ちる相手HWNDが無効になった(再起動・クラッシュ)送信前に IsWindow を確認、失敗時は再度 BroadcastReady して再接続
複数起動で別の相手とつながる1対1のルールが曖昧「先着1つだけ」「チャンネル名一致」など接続ポリシーを明文化し、握手で判定する
管理者実行だと片方向だけ動かない権限レベル差による影響両方同権限で起動する運用にするか、名前付きパイプへ切り替える

まとめ:最短で「双方向・イベント駆動」を作るなら WM_COPYDATA + ハンドシェイク

C# と C++/MFC の別プロセス間で、どちらが先に送るか決まっていないフルデュプレックス通信を実現するなら、同一PC・同一セッションという前提で WM_COPYDATA は非常に実装効率が高い選択です。肝は「相手 HWND をどう確定するか」なので、登録済みメッセージでのハンドシェイクを入れて起動順不同に耐えられる形にすると、実運用でも安定します。

そのうえで、将来の拡張(複数クライアント、UIなし、ネットワーク越し、大容量データ)を少しでも見込むなら、本文のプロトコル設計(dwData と文字列フォーマット)だけは最初に整えておくと、後戻りのコストが大きく下がります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次