post-cover

2025-07-03 技術ブログ

Raspberry Pi Pico で作る RS-232 ⇔ USB HDMI 切替システム

GitHub ソース全文・KiCad 配線図はこちら

https://github.com/isec-corp/raspberry-pi-pico-hdmi-switcher

1. 背景とゴール

顧客から「映像伝送装置に搭載されたシリアルポートを活用し、受信側から送信側へコマンドを送り、HDMI のチャンネルを切り替えられないか」という相談を受けたのが今回の開発のきっかけでした。

そこで当社では、HDMI 切替器を送信機・受信機双方に設置し、受信側からのシリアル信号によって送信側の入力チャンネルを切り替える構成を考案しました。

この仕組みを実現するために、Raspberry Pi Pico を用いた以下のユニットを開発しました。

  • 送受信機
    • 送信機能 GP0〜GP3 に接続した物理ボタンを押すと、それぞれ対応する番号(1〜4)を RS-232(GP4, GP5)経由で送信します。
    • 受信機能 RS-232(GP4, GP5)経由で信号を受信すると、GP16, GP17 に接続した USB DIP 経由で「EZS OUT1 VS IN %d」(%d は 1〜4)というコマンドを送信します。

ハードウェアは、Raspberry Pi Pico と MAX3232を組み合わせることで、RS-232 レベルのシリアル信号と Pico の GPIO 間の電圧変換を実現しました。

ソフトウェアは、 デュアルコア構成、PIO-USB、Arduino 互換 C++ を用いて開発しました。当初は MicroPython で実装を試みましたが、USB デバイス機能を PIO で実装するには C++が必要だったため、最終的に C++ベースの構成を採用しました。


2. ハードウェア構成

送受信ユニットは以下の構成で設計しています。

  • メインボード

    • Raspberry Pi Pico
  • 操作部

    • タクトスイッチ ×4(GP0〜GP3 に接続)
  • レベル変換

    • MAX3232(Pico の 3.3V ロジックと RS-232 信号の電圧レベル変換用)
    • MAX3232 はこちらのモジュールを使用しました。Amazon の商品レビューにもある通り、RS-232 用のチャネルが 2 系統ありますが、未使用のチャネルの端子は 47k〜100kΩ 程度の抵抗で GND に接続する必要があります。これを行わないと発熱によりモジュールが故障する恐れがあります。
    • 今回は Ch1 を使用しないため Ch1-IN と GND を 51k オームの抵抗で接続しました。 max3232-resist
  • USB 出力

    • USB-A ソケット(GP16, GP17 に接続)
    • USB データライン(D+ / D-)と GP16, GP17 の間には各 22Ω の直列抵抗を挿入し、インピーダンス整合とノイズ低減を図っています。

この構成により、物理ボタン操作から RS-232 送信、USB コマンド出力までをコンパクトな回路で一貫して実現できます。

ブレッドボートで試作品を作り、KiCad で回路設計を行い、基板を作り、JLCPCBで基板を発注しました。

Fritzingで作成した配線図

KiCadの回路図

kiCadの3D基板イメージ


3. ソフトウェア全体像

このシステムのソフトウェアは、 Arduino C++フレームワーク をベースに開発しました。最大のポイントは、Raspberry Pi Pico の デュアルコアPIO (Programmable I/O) をフル活用している点です。

3.1. デュアルコアによる役割分担

Pico に搭載されている 2 つの CPU コア(Core0, Core1)に、それぞれ異なる役割を持たせて処理を分担させることで、応答性と安定性を両立させています。

  • Core0 (メインコア)

    • 物理ボタン(4 つ)の入力監視とチャタリング除去
    • RS-232(Serial2)経由でのデータ送受信
    • 基板上の LED 制御
    • システム全体の安定性を保つためのウォッチドッグタイマーの管理
  • Core1 (USB 専用コア)

    • PIO-USBライブラリを利用したUSB ホスト機能の実現
    • HDMI 切替器へのコマンド送信タスク

USB ホスト機能は継続的な処理が求められるため、専用のコアに担当させることで、Core0 はボタン入力やシリアル通信といったユーザーインターフェース処理に集中できます。これにより、どちらの処理も遅延なく実行されるようになります。

3.2. 処理フロー

ソフトウェアの処理は、「送信機能」「受信機能」 の 2 つに大別されます。

  1. 送信機能(ボタン操作 → RS-232 出力)

    1. Core0 が物理ボタンの押下を検出します。
    2. millis()関数を用いたノンブロッキング方式でチャタリング(短時間の ON/OFF の繰り返し)を防止します。
    3. 押されたボタンに対応する番号(1〜4)を、RS-232(Serial2)経由で送信します。
  2. 受信機能(RS-232 入力 → USB コマンド出力)

    1. Core0 が RS-232(Serial2)からデータを受信します。
    2. 受信データが '1'〜'4' のいずれかであることを確認し、snprintf関数を使って "EZS OUT1 VS IN %d" という書式のコマンド文字列を生成します。
    3. 生成したコマンドは一旦「送信待ちバッファ」に格納されます。
    4. Core1 で動作する USB ホスト機能が、HDMI 切替器との接続を確認し、送信可能な状態になったタイミングで、バッファからコマンドを読み出して送信します。

また、システムがフリーズしていないかを監視するウォッチドッグタイマーを導入しました。プログラムの各所でタイマーをリセットし、万が一リセットが指定時間内に行われなかった場合は、Pico が自動的に再起動して復旧を試みます。


4. コード解説(抜粋)

フルソースは記事末尾の GitHub リポジトリを参照してください。主要部だけコメント付きで追います。

セットアップ処理 (setup, setup1)

Pico の起動時に一度だけ実行される初期化処理です。setup()は Core0、setup1()は Core1 でそれぞれ実行され、役割分担の基盤をここで作ります。

// [Core1] USBホスト用セットアップ
void setup1() {
  Serial.begin(115200); // Core1のデバッグ用シリアル出力
  Serial.println("Core1: USB Host Setup");

  // PIO を使って USB ホスト動作させる設定
  pio_usb_configuration_t pio_cfg = PIO_USB_DEFAULT_CONFIG;
  pio_cfg.pin_dp = HOST_PIN_DP; // D+ピンをGP16に指定
  USBHost.configure_pio_usb(1, &pio_cfg);

  // USBホスト機能を開始
  USBHost.begin(1);
}

// [Core0] メインのセットアップ処理
void setup() {
  // LED、スイッチ、RS-232(Serial2)のピン設定
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(SWITCH_PIN_0, INPUT_PULLUP);
  // ... (他のスイッチピンも同様に設定)
  Serial2.setTX(SERIAL2_TX_PIN);
  Serial2.setRX(SERIAL2_RX_PIN);
  Serial2.begin(9600);

  // Core1のセットアップ関数はArduinoフレームワークが自動で呼び出してくれる

  // ウォッチドッグを有効化(3秒でタイムアウト)
  watchdog_enable(WDT_TIMEOUT_MS, 1);
  watchdog_update(); // タイマーを初期化
}

setup()では、Core0 が担当するボタンや RS-232 ポートの初期設定を行います。setup1()では、USB ホスト機能の心臓部であるPIO-USBライブラリの設定を行い、Core1 を USB 専用タスクに割り当てています。また、watchdog_enable()でシステムの安定稼働を支える監視タイマーを起動させています。

メインループ (loop)

setup()完了後、Core0 で繰り返し実行されるメインループです。ここでは、各機能を呼び出し、処理が滞らないようにノンブロッキングで実装されています。

// [Core0] メインループ
void loop() {
  // Core1で実行されているUSBホストのタスク処理を呼び出す
  // ※実際には`USBHost.task()`はloop1()で実行するのが一般的
  loop1();
  watchdog_update(); // WDTリセット

  // ① ボタン押下をチェックし、Serial2 に数字を送信
  checkButtons();
  watchdog_update(); // WDTリセット

  // ② Serial2 からの受信処理
  if (Serial2.available() > 0) {
    // ... (受信データを1バイトずつ読み込み、コマンドを生成する処理)
    // ...
  }
  watchdog_update(); // WDTリセット

  // ③ USBホストへコマンドを転送
  if (pendingFlag) {
    // ... (送信待ちコマンドがあればUSB経由で送信する処理)
    // ...
  }
  watchdog_update(); // WDTリセット

  // ④ LED の消灯判定
  checkLedOff();
  watchdog_update(); // WDTリセット
}

loop()関数の中では、delay()のようなプログラム全体を停止させる関数は一切使われていません。代わりに、各処理を細かく分割し、それぞれの関数(checkButtons(), checkLedOff()など)を順番に呼び出しています。処理の合間ごとにwatchdog_update()を挟むことで、いずれかの処理で万が一無限ループに陥っても、システムが自動で復旧できるようになっています。

ボタン入力処理とチャタリング対策 (checkButtons)

millis()を使い、時間経過を基準にすることで、他の処理をブロックせずにボタンの状態を監視します。

// ボタン検知+デバウンス処理 (millis() ベース)
void checkButtons() {
  for (int i = 0; i < 4; i++) {
    uint8_t pin = SWITCH_PIN_0 + i;
    bool currentState = (digitalRead(pin) == LOW); // 押されているか
    unsigned long now = millis();

    // 状態が「離す→押す」に変わり、かつ前回の押下から一定時間経っていたら
    if (currentState == LOW && lastButtonState[i] == HIGH && (now - lastPressTime[i] >= DEBOUNCE_DELAY)) {
      sendToSerial2(i + 1);    // データをRS-232へ送信
      lastPressTime[i] = now;  // 今回の押下時刻を記録
    }
    lastButtonState[i] = currentState; // 今回の状態を保存
  }
}

この関数は、単にボタンが押された(LOWになった)ことだけを検知するのではありません。

  1. 前回は押されていなかった (lastButtonState[i] == HIGH)
  2. 今回は押されている (currentState == LOW)
  3. 前回の有効な押下から一定時間 (DEBOUNCE_DELAY) が経過している

という 3 つの条件をすべて満たしたときに初めて「有効な押下」と判断します。これにより、物理的なスイッチ接点の微細な振動によって発生するチャタリングをソフトウェア的に確実に取り除いています。

RS-232 受信と USB コマンド送信 (loop内)

RS-232 から受信したデータをもとに USB コマンドを生成し、送信する一連の処理です。

// ===== ② Serial2 からの受信を1バイトずつチェック =====
if (Serial2.available() > 0) {
  char received = Serial2.read();
  if (received == '\n') { // 改行コードを受け取ったら1行の終わり
    recvBuffer[recvLen] = '\0'; // 文字列として終端
    if (recvLen == 1 && recvBuffer[0] >= '1' && recvBuffer[0] <= '4') {
      int number = recvBuffer[0] - '0';

      // 送信コマンドを作成し、送信待ちバッファへ格納
      snprintf(pendingCmd, MAX_CMD_LEN, "EZS OUT1 VS IN %d", number);
      pendingFlag = true; // 「送信待ち」フラグを立てる
      blinkLed();
    }
    recvLen = 0; // 受信バッファをクリア
  }
  else {
    // ... (改行が来るまで文字をバッファに貯める) ...
  }
}

// ===== ③ SerialHost へ転送可能なら送信 =====
if (pendingFlag) {
  size_t cmdLen = strlen(pendingCmd) + 2;
  // USBデバイスが接続され、送信バッファに十分な空きがあるか?
  if (SerialHost && SerialHost.connected() && SerialHost.availableForWrite() >= cmdLen) {
    SerialHost.println(pendingCmd); // コマンド送信!
    pendingFlag = false; // 送信待ちフラグを下ろす
  }
}

この部分では、受信処理と送信処理が分離されています。

  • 受信: まず RS-232 からのデータをrecvBufferに貯めます。1 行(改行まで)受信し終えたら、内容を解釈して送信用のコマンド文字列pendingCmdを作成し、pendingFlagtrueにします。
  • 送信: メインループは常にpendingFlagを監視しています。フラグがtrueで、かつ USB ホスト(SerialHost)の送信準備が整っている場合のみ、実際にコマンドを送信します。

このように処理を分離することで、USB 側の準備ができていない場合でも受信処理は滞りなく進み、データを取りこぼすリスクを低減しています。

5. まとめ

今回は、顧客の具体的な要望をきっかけに、Raspberry Pi Pico を使った RS-232 ⇔ USB HDMI 切替システムを開発しました。物理ボタンやシリアル信号をトリガーに、USB 経由で HDMI 切替器を制御するという当初のゴールを、コンパクトな基板で実現することができました。

開発の鍵となったのは、やはり Raspberry Pi Pico のポテンシャルの高さです。

  • デュアルコアによる処理の分担: USB ホスト機能という重い処理を片方のコアに任せることで、もう片方のコアはボタン入力やシリアル通信といった応答性が求められる処理に専念でき、システム全体の安定性を高められました。
  • PIO を活用した USB ホスト機能: PIO-USB ライブラリのおかげで、本来 USB ホスト機能を持たない Pico で、柔軟に USB デバイスを制御するという道が拓けました。
  • C++/Arduino フレームワーク: millis() を活用したノンブロッキング処理や、ウォッチドッグタイマーによる堅牢性の確保など、組み込み開発で求められる細やかな実装を、使い慣れた環境で行うことができました。

今回製作したシステムは、特定の HDMI 切替器を制御するものですが、少しの改造で様々なシリアル機器と USB 機器を連携させるブリッジとして応用が可能です。Pico の強力な機能を活用すれば、アイデア次第で多様な制御システムを低コストで構築できるでしょう。

この記事が、Raspberry Pi Pico を使った組み込み開発や、異なるインターフェース間の連携に挑戦する方々の参考になれば幸いです。最後までお読みいただき、ありがとうございました。

GitHub ソース全文・KiCad 配線図はこちら

https://github.com/isec-corp/raspberry-pi-pico-hdmi-switcher