CH32V203 のシリアル USB 化

[戻る]

はじめに

相変わらず, PC に繋げる必要のある電子機器は, 秋月のモジュールなどの専門 USB シリアル通信モジュールを使うことが多いです.

問題が分離できてデバッグが楽だとか拡張しやすいとか, ベンダー ID, プロダクト ID 等の USB に纏わるわずらわしさから解放できるからです.

とは言え, 通信モジュールはそれなりにコストと基板面積を食います. 配布する等, 少し安上がりにしたいのならば USB 機能付きのマイコンを使いたくなります. マイコンプログラムで USB シリアルに化けさせておけば, PC 側からは同じに見えるので, PC 側プログラムは互換性を保ったままです.

USB 機能付きマイコンはいろいろあります. 有名所は PIC18F14K50で しょうか。 ネットを彷徨えばいろいろ事例があります (このページにもここにあります).

先日また USB 機能付きマイコン使って安くてコンパクトな基板を作りたくなったのですが, 今更 18F14K50, もしくはPIC16F1455 を使うのも何です. 一方で最近 VH32V003というマイコンにハマってます. その上位機種にCH32V203というのがあり, それには USB 機能がついてます. それを使ってみたとき分かったことを備忘録として書いておきます. なお本記事は CH32V203K8T6 という 32 ピン版に特化してます. その他のパッケージを扱う際は適当に読み替えてください.

開発環境の構築とライタとの接続

基本的にch32v003 と同じです. 違うのは二点.

ここに注意すると以下のような接続になるでしょう.

サンプルプログラムの入手と書き込み

今回やりたいことは, PC から見てシリアルポートと見えることです. 1 から構築するのは大変なので, サンプルプログラムを入手してそれを改造することにします. ここに一式あるので ダウンロードします. シリアルポートのサンプルは EVT/EXAM/USB/USBD/SimulateCDC にあります. SimulateCDC.wvproj をダブルクリックすると MounRiver Studio が起動します (当然インストールされているという前提).

サンプルプログラムを動かすにあたって上のように配線すればいいでしょう. 注意点は以下の通りです.

サンプルプログラムですが, そのままだと上手く動きません. この回路には水晶を繋げてませんが, 「ある」という前提のコードだからです. system_ch32v20x.c の中に

#define SYSCLK_FREQ_96MHz_HSE 96000000

と書かれている部分があるので, これをコメントアウトして代わりに

//#define SYSCLK_FREQ_96MHz_HSI 96000000

という行のコメントアウトを外し有効にします. これによりチップ内部の発信器を使うようになります.

コンパイルしてプログラムを焼くと, PC からは WCH-LinkE と USB の 二つの COM ポートが見えるはずです. 前者は 115200bps でターミナルを開くといろいろデバッグ情報が流れるはずです. 後者は 8 番ピン 9 番ピンをループバックしているので, ターミナルプログラムで文字を打つと打った文字が表示されるはずです.

改造方針

サンプルプログラムの Main.c を覗いてみると, メインループは次のようになっています.

while(1)
{
  UART2_DataRx_Deal();
  UART2_DataTx_Deal();
}

要は UART2_DataRx_Deal() で USB から貰った情報を UAET の Tx に流すことと UART2_DataTx_Deal() で逆に UART の Rx で受けた信号を USB に流すことを 交互に繰り返しているようです.

と言うことで, ここから「USB 読取」と「USB 書込」を抜き出して, プログラムから読み書きできるようにすればいいのですが, UART2_DataRx_Deal(), UART2_DataTx_Deal() の方を覗くと 二つの機能が入り乱れてちょっと一筋縄ではいきませんでした.

この後, 分かったことを書いて行くのですが, その前に USB 機器ですからベンダ ID とプロダクト ID が気になります. それらは User/USBLIB/CONFIG/usb_desc.c に USBD_DeviceDescriptor[] としてベンダ ID 6790, プロダクト ID 65036 として 書かれてました. USBORG の管理表によるとベンダ ID 6790 は Nanjing Qinherg Electronics Co., Ltd. となっており CH32V203 のメーカと一致します. と言うわけで, 商売に使わず, かつ USB シリアルとして見せる限りは 多分この番号を使って構わないのではないかと考えます (当方が思うだけで「そうだ」と保証していません).

サンプルプログラムのダビング

SimulateCDC プロジェクトをいきなり改造するのも何なので, 以下の手順で新しいプロジェクトとしてダビングします.

  1. MounRiver Studio を起動し CH32V203 [RISC-V], CH32V203K8T6 という新しいプロジェクトを作る

    この辺は MounRiver Studio の使い方なので説明は割愛します

  2. SimulateCDC の User にある, 以下のディレクトリとファイルを新しいプロジェクトに上書きする

    先のテストで system_ch32v203x.c は SYSCLK_FREQ_96MHz_HSI が定義されているはずです.

  3. Project Explorer より新しく作ったプロジェクトの User 部分で右クリック [Add]-[Existing Directory] を使って, UART ディレクトリを追加する.

    2 でコピーしたディレクトリを間違えず指してください. また USBLIB は UART ディレクトリを追加したら自動で入るようです.

  4. Project Explorer の該当プロジェクトを右クリックして [Properties]して, [C/C++ Build]-[Settings]-[GNU RISC-V Cross C Compiler]-[Includes] を開き, Includes paths の最後に以下の三つを追加.

これでコンパイル・書き込みができるはずです. これを元に RecvUSB(), SendUSB() 関数が動作するプロジェクトに改造していきます.

プロジェクトの改造

実際の改造手順を伝える前に幾つか確認しておくことがあります. まず当方が改造したサンプルプログラムですが, 一式を 2025/1/20 にダウンロードし, SimulateCDC のタイムスタンプは 2024/11/10 22:39 でした. 新しいバージョンが出た場合, 当記事で書かれた内容がそのまま反映されないかもしれません. また改造した結果なのですが, 正直 UART 関係なくなったのに関数名や変数名の中に 'UART' という文字が付いているなど気に入りません. しかしその辺も改造してしまうと本家がバージョンアップした時に キャッチアップしできなくなる可能性があります. そこでその辺の不満は目を瞑ることにしました. と言う訳で手順です.

  1. User/USBLIB/CONFIG/hw_config.c

    Enter_LowPowerMode(), Leave_LowPowerMode() 関数内にある printf() を削除 (これがあると, Main.c で USART_Printf_Init() の呼び出しを削除すると急に動かなくなる).

  2. User/USBLIB/CONFIG/usb_prop.h

    以下の文言を追加

    int USBD_isSendBlocked(void);
  3. User/USBLIB/CONFIG/usb_endp.c

    以下の関数を追加

    int USBD_isSendBlocked(void){
      return USBD_Endp3_Busy;
    }
  4. UART/UART.h

    以下の文言をインクルードガード内に追加

    void SendUSB(unsigned char *, int);
    void SendUSB_Char(unsigned char);
    int RecvUSB(unsigned char *, int);
    int RecvUSB_Char(void);

    オプティマイズが効いて無駄かもしれないけど, 精神衛生上に以下の定義を消しておく.


  5. UART/UART.c

    冒頭 (UART2_Tx_Buf[] 定義の後あたり) に以下の文言を加えるとともに

    __attribute__ ((aligned(4))) uint8_t Tx_Buf[DEF_USB_FS_PACK_LEN];

    最後の方に以下の関数を追加

    int RecvUSB(unsigned char *p, int len){ // based on UART2_Datatx_Deal()
      int ret=0;
      uint16_t count;

      if (Uart.Tx_Flag) {
        Uart.Tx_Flag = 0x00;

        NVIC_DisableIRQ (USB_LP_CAN1_RX0_IRQn);
        NVIC_DisableIRQ (USB_HP_CAN1_TX_IRQn);

        count = Uart.Tx_CurPackLen;
        Uart.Tx_CurPackLen -= count;
        Uart.Tx_CurPackPtr += count;
        if( Uart.Tx_CurPackLen == 0x00 ) {
          Uart.Tx_PackLen[Uart.Tx_DealNum] = 0x0000;
          Uart.Tx_DealNum++;
          if(Uart.Tx_DealNum >= DEF_UARTx_TX_BUF_NUM_MAX) {
            Uart.Tx_DealNum = 0x00;
          }
          Uart.Tx_RemainNum--;
        }

        NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
        NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn);
      } else {
        if (Uart.Tx_RemainNum) {
          if (Uart.Tx_CurPackLen == 0x00) {
            Uart.Tx_CurPackLen = Uart.Tx_PackLen[Uart.Tx_DealNum];
            Uart.Tx_CurPackPtr = (Uart.Tx_DealNum * DEF_USB_FS_PACK_LEN);
          }
          ret = Uart.Tx_CurPackLen;
          if (ret > len)
            ret = len;

          memcpy((void *)p, (void *)(UART2_Tx_Buf+Uart.Tx_CurPackPtr), ret);

          Uart.Tx_Flag = 0x01;
        }
      }
      return ret;
    }

    void SendUSB(unsigned char *p, int len){ // based on UART2_DataRx_Deal()
      if (len > DEF_USBD_MAX_PACK_SIZE)
        len = DEF_USBD_MAX_PACK_SIZE;

      while (USBD_isSendBlocked());

      NVIC_DisableIRQ(USB_LP_CAN1_RX0_IRQn);
      NVIC_DisableIRQ(USB_HP_CAN1_TX_IRQn);
      Uart.USB_Up_IngFlag = 0x01;
      Uart.USB_Up_TimeOut = 0x00;

      USBD_ENDPx_DataUp(ENDP3, p, len);

      Uart.USB_Up_IngFlag = 0x00;
      Uart.USB_Up_Pack0_Flag = 0x00;
      NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
      NVIC_EnableIRQ(USB_HP_CAN1_TX_IRQn);
    }

    int RecvUSB_Char(void){
      static int last=0;
      static int now=0;

      if (now==last){
        now = 0;
        last = RecvUSB(Tx_Buf,DEF_USB_FS_PACK_LEN);
      }

      return last ? Tx_Buf[now++] : -1;
    }

    void SendUSB_Char(unsigned char c){
      SendUSB(&c, 1);
    }

    なおこの RecvUSB() は UART2_Datatx_Deal() から, SendUSB() は UART2_DataRx_Deal() からコードを拝借しています (黄色部分が加筆修正した所). さらに, 精神衛生より次の部分を消す.

関数

SendUSB() は第一パラメータのバイト列を第二パラメータのサイズだけ送る関数です. RecvUSB() は受け取ったデータを第一パラメータに入れるもので, 第二引数が第一引数のサイズ, 戻り値が受け取ったデータのサイズという関数です.

この SendUSB() ですが, 前回送ろうとしたデータが送り切れてない時に呼び出されると, それが送り終わるまで待つ仕様にしています (直接 while(USBD_Endp3_Busy); と書いてもダメだったので関数にしています). そのため, あまり頻繁に呼び出すとプログラムのパフォーマンスが落ちます. バッファに溜めるとかやり様はあるのですが, 面倒なので保留です. 一方 RecvUSB() は, もしこの関数を呼び出された時に USB からデータが流れてきてたら それをバッファに溜め, 次に呼び出された時に「データ来てるよん」と返します. つまりこの関数は, ポーリングで頻繁に呼び出されることを前提にした仕様です. これもソースを読みこんでもうちょっといろいろやればできそうですが, 面倒なので保留です.

SendUSB_Char() と RecvUSB_Char() は 1 byte のデータを送ったり読み取ったりするものです. 当方 PIC マイコンでよく

void SendUART(unsigned char c){
  while(TRMT==0);
  TXREG = c;
}

int RecvUART(){
  if (RCIF==0)
    return -1;
  return (int)RCREG;
}

というフレーズを使っていたので, それと互換性を持たせたものです.

サンプルプログラム

以下サンプルプログラムですが, 簡単なのがいいと思いますので, 次のように 6 番ピン (PA0) と 7 番ピン (PA1) に LED が二個繋がった回路で動かす前提で話を進めます. この図では左から WCH-LinkE にも繋げてますが, プログラムをきちんと焼いた後, 電源を適切に供給すれば要りません.

サンプルプログラムは, PC からは USB シリアルポートとして見え, データを受けると, そのデータの下位 2bit を 二つの LED に出力します. さらに受け取ったデータはループバックさせています. 単に線を繋いだだけでない, プログラムで何か処理できることを示すため, 受信データが英小文字なら二文字ずらしています.

#include "UART.h"
#include "usb_lib.h"

void Port_Init(void) {
  GPIO_InitTypeDef GPIO_InitStructure = {0};

  RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
  GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;

  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
  GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
  GPIO_Init(GPIOA, &GPIO_InitStructure);
}

int main(void){
  NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1);
  SystemCoreClockUpdate();

  RCC_Configuration( );
  TIM2_Init();

  Set_USBConfig();
  USB_Init();
  USB_Interrupts_Config();

  Port_Init();

  GPIO_WriteBit(GPIOA, GPIO_Pin_0, 1);
  GPIO_WriteBit(GPIOA, GPIO_Pin_1, 1);

  for(;;) {
    int n=RecvUSB_Char();
    if (n>=0){
      GPIO_WriteBit(GPIOA, GPIO_Pin_0, n & 1);
      GPIO_WriteBit(GPIOA, GPIO_Pin_1, n & 2);
      if (n >='a' && n <='z'){
        n+=2;
        if (n >'z')
          n -= 'z'-'a' + 1;
      }
      SendUSB_Char(n);
    }
  }
}

なんやかんや書いてますが, シリアル通信に関する所は黄色い部分のみです. ポートの出力に関しては CH32V003 と同じです.


2025.1