CH32VのオレオレArduino環境を作ろう その4 UART/USART

概要

前回は基本的なArduino Core APIの導入とGPIOまでできました。今回はSerialクラスを作っていきいたいと思います。

USARTとUARTとは?

Serialクラスはシリアル通信を行うクラスであり、ESP32だとUARTと呼ばれる機能です。CH32ではUSARTとUARTで機能が分かれています。USARTはクロック同期用の線があり、通信速度はクロック同期に応じて決定されます。UARTはあらかじめ通信速度を決めておき、一方的的に送信する通信方式です。

とはいえ、マイコンではあまりUSARTは利用されておらず、基本はUARTを使うことが多いように思います。ただし、CH32は名前が分かれているので、Arduinoクラスにするときにはかなり面倒なことになります。

シリーズSerial1Serial2Serial3Serial4Serial5Serial6Serial7Serial8
CH32V003USART1
CH32V006USART1USART2
CH32V103USART1USART2USART3
CH32V20xUSART1USART2USART3UART4
CH32V307USART1USART2USART3UART4UART5UART6UART7UART8
CH32X035USART1USART2USART3USART4
CH32L103USART1USART2USART3USART4

シリーズごとのUSARTとUARTの対応表は上記になります。Serial4はUSARTとUARTが混在しています。今後はどう拡張されるかわからないので、各SerialはUSARTとUARTの両対応にしたいと思います。

Serialクラスの構成

基本構造

class UartClass : public HardwareSerial{
}

Arduino Core APIのHardwareSerialをベースにUartClassを作りました。

#if defined(CH32_UART1_TX)
extern UartClass Serial1;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial2;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial3;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial4;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial5;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial6;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial7;
#endif
#if defined(CH32_UART1_TX)
extern UartClass Serial8;
#endif

#define Serial Serial1

実際のSerialクラスは上記のように定義しています。

#if defined(USART1) && defined(CH32_UART1_TX)
UartClass Serial1(USART1);
#elif defined(UART1) && defined(CH32_UART1_TX)
UartClass Serial1(UART1);
#endif

USARTとUARTで名称が分かれているためCH32だと上記のような宣言になります。そして気をつけないといけないのがUSART1などのdefineなのですがCH32内部で定義していますが、CH32V103だと利用できないUART4も定義されています。

#define USART1                                  ((USART_TypeDef *)USART1_BASE)
#define USART2                                  ((USART_TypeDef *)USART2_BASE)
#define USART3                                  ((USART_TypeDef *)USART3_BASE)
#define UART4                                   ((USART_TypeDef *)UART4_BASE)
#define UART5                                   ((USART_TypeDef *)UART5_BASE)

上記のような定義があり、使えないはずのUART4とUART5が定義されているので#ifdefで定義名だけで判断ができません。割り込みのUSART1_IRQnがあるのですがenum定義なので#ifdefには利用できないので、ボードごとに時前で定義した送信ピン定義を確認する必要がでていました。

この手のまちがって不要なものが定義されているのがCH32は結構あるので注意しましょう。。。

HardwareSerialクラス

class HardwareSerial : public Stream
{
  public:
    virtual void begin(unsigned long) = 0;
    virtual void begin(unsigned long baudrate, uint16_t config) = 0;
    virtual void end() = 0;
    virtual int available(void) = 0;
    virtual int peek(void) = 0;
    virtual int read(void) = 0;
    virtual void flush(void) = 0;
    virtual size_t write(uint8_t) = 0;
    using Print::write; // pull in write(str) and write(buf, size) from Print
    virtual operator bool() = 0;
};

上記の構造になっています。自作のUartClassでは上記の仮想関数を実装する必要があります。一番重要なのがwrite関数になります。1文字だけ出力する関数を定義すればprintlnなどから自動的に出力するような動きになっています。

Streamクラス

class Stream : public Print
{
  // 略
}

ファイルや入出力のときにはほぼStreamクラスを経由しているはずです。中間クラスなので今回は内部はあまり関係していません。

Printクラス

class Print
{
  private:
    int write_error;
    size_t printNumber(unsigned long, uint8_t);
    size_t printULLNumber(unsigned long long, uint8_t);
    size_t printFloat(double, int);
  protected:
    void setWriteError(int err = 1) { write_error = err; }
  public:
    Print() : write_error(0) {}
    int getWriteError() { return write_error; }
    void clearWriteError() { setWriteError(0); }
    virtual size_t write(uint8_t) = 0;
    size_t write(const char *str) {
      if (str == NULL) return 0;
      return write((const uint8_t *)str, strlen(str));
    }
    virtual size_t write(const uint8_t *buffer, size_t size);
    size_t write(const char *buffer, size_t size) {
      return write((const uint8_t *)buffer, size);
    }

    // default to zero, meaning "a single write may block"
    // should be overridden by subclasses with buffering
    virtual int availableForWrite() { return 0; }

    size_t print(const __FlashStringHelper *);
    size_t print(const String &);
    size_t print(const char[]);
    size_t print(char);
    size_t print(unsigned char, int = DEC);
    size_t print(int, int = DEC);
    size_t print(unsigned int, int = DEC);
    size_t print(long, int = DEC);
    size_t print(unsigned long, int = DEC);
    size_t print(long long, int = DEC);
    size_t print(unsigned long long, int = DEC);
    size_t print(double, int = 2);
    size_t print(const Printable&);

    size_t println(const __FlashStringHelper *);
    size_t println(const String &s);
    size_t println(const char[]);
    size_t println(char);
    size_t println(unsigned char, int = DEC);
    size_t println(int, int = DEC);
    size_t println(unsigned int, int = DEC);
    size_t println(long, int = DEC);
    size_t println(unsigned long, int = DEC);
    size_t println(long long, int = DEC);
    size_t println(unsigned long long, int = DEC);
    size_t println(double, int = 2);
    size_t println(const Printable&);
    size_t println(void);

    virtual void flush() { /* Empty implementation for backward compatibility */ }
};

このPrintクラスが一番ベースとなるクラスでSerialクラスではかなり重要です。println関数を呼び出したときにprint関数と改行コードを送信する関数に分離してくれて、print関数で数値だったら文字列に変換などを行い、文字列用のwrite関数を呼び出します。文字列用のwrite関数では文字単位のwrite関数を呼び出すみたいな処理を経て、自作したUartClassクラスのwrite関数が呼び出されます。

これによって似たいような処理を個別に実装する必要がなくなっています。出力だけであれば文字単位で出力をするwrite関数だけ独自実装すれば標準的なSerialクラスが作れるのでかなり楽ですね。

ただし、printfには対応していません。

独自に拡張しようと思ったのですが、Arduinoの中の人はprintfには対応しないと言っているので、標準的なArduino Core API設計に従いCH32でも対応しません。必要なときにはsnprintfを使うことにします。printfは内部的に作業用メモリを使ったりと、省メモリのマイコンとは相性が悪いんですよね。

文字フォーマットとかがArduino Core APIの設計思想からすると受け入れられない感じもしました。年中対応してくれと要望されているがリジェクトを繰り返しているので中の人も対応が雑な感じです。

実装してみたら動かない

さて、基本構造がわかったのでUartClassクラスを実装してみましたが動きません。。。どうやらPrintクラスから独自実装したwrite関数の呼び出しをするとハングアップしています。

arm-none-eabi-gccでの静的変数コンストラクタの問題について
STM32F3DiscoveryでC++のプログラムを書いてます。 GNUのarm-none-eabi-gccを使って、 bareCortexM を参考に自前で作ったプロジェクトで開発しています。 その中で、 std::listやstd::...

似たような事例が上記で見つかりました。どうやらベースにしているMounRiverの開発環境はC言語専用で、C++言語で利用する場合には__libc_init_arrayなどを呼び出す必要があるようです。

MounRiver Studio 将工程转换为 C++ 工程-CSDN博客
文章浏览阅读618次。MounRiver Studio 将工程转换为 C++ 工程_mounriver studio

上記にMounRiverでC++言語で開発する場合の手順が書いてありました。スタートアップのアセンブラで__libc_init_arrayを呼び出すコールを追加する必要があったようです。

あとは空_fini関数と_init関数を定義したところ動くようになりました。しかしながら可変長引数の関数を追加すると動かなくなったりと、もう少しC++で動かすためには必要な処理があるような気がしています。

UARTの送受信の同期・非同期ついて

実装をすすめる準備ができましたので、仕様を確定していきたいと思います。まず送信ですが送信バッファを用意して、キューに入れて送信するか、DMA転送などで非同期の送信が実現できます。

DMA転送はちょっとやり過ぎだと思うのと、今回シングルタスクで動くのでキューからのバックエンド送信ができないのでブロック型の同期送信を採用します。つまり低速で大量の送信をすると、送信完了まで結構な時間ブロックされます。RTOSを搭載してマルチタスク化する手もありますが、今回はシンプルにいきたいと思います。

受信ですが、特殊処理をしない場合には定期的に取得するポーリングになりますが、送信をしている間にはブロックされており、受信できないので取りこぼしが発生します。完全な半二重通信になってしまうので一般的には厳しいと思います。そこでDMAか割り込みを使って受信バッファに保存したいと思います。今回は受信割り込みがありましたので、受信割り込みが発生したら受信バッファに保存する方式にしたいと思います。

受信割り込みの実験

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

  RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
  GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
  GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
  GPIO_Init(GPIOA, &GPIO_InitStructure);
}

void setup() {
  NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1);
  SystemCoreClockUpdate();
  Delay_Init();
  USART_Printf_Init(115200);
  printf("SystemClk:%d\r\n", SystemCoreClock);
  printf("ChipID:%08x\r\n", DBGMCU_GetCHIPID());
  printf("GPIO Toggle TEST\r\n");
  GPIO_Toggle_INIT();

  GPIO_InitTypeDef GPIO_InitStructure = { 0 };
  USART_InitTypeDef USART_InitStructure = { 0 };
  NVIC_InitTypeDef NVIC_InitStructure = { 0 };

  GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
  GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
  GPIO_Init(GPIOA, &GPIO_InitStructure);

  USART_InitStructure.USART_BaudRate = 115200;
  USART_InitStructure.USART_WordLength = USART_WordLength_8b;
  USART_InitStructure.USART_StopBits = USART_StopBits_1;
  USART_InitStructure.USART_Parity = USART_Parity_No;
  USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
  USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
  USART_Init(USART1, &USART_InitStructure);

  USART1->STATR = 0x00C0;
  USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
  NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
  NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
  NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
  NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
  NVIC_Init(&NVIC_InitStructure);
  USART_Cmd(USART1, ENABLE);
}

int cnt = 0;

void loop() {
  static u8 i = 0;
  Delay_Ms(250);
  GPIO_WriteBit(GPIOA, GPIO_Pin_0, (i == 0) ? (i = Bit_SET) : (i = Bit_RESET));
  printf("GPIO_WriteBit %d\n", i);
  printf("cnt = %d\n", cnt);
}


#ifdef __cplusplus
extern "C" {
#endif

  void USART1_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
  void USART1_IRQHandler(void) {
    USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    cnt++;
  }

#ifdef __cplusplus
}
#endif

実験はnoneos版を利用して行います。EVTから適当なサンプルをベースにシンプル化して動作確認をします。ちなみにopenwch版は割り込み周りをSTM32向けのラッパーをしているので少し違う関数群なので注意する必要があります。

  USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;

注意点としてはEVTだとUSART1は送信のみで初期化しているので、受信も有効化する必要があります。とりあえず割り込みがかかってハングアップする状態まで手を入れてから、実際の割り込み関数であるUSART1_IRQHandlerの中身を調整するのがよさそうでした。

受信バッファについて

Arduino Coreだと受信バッファ用のリングバッファクラスが用意されています。

typedef RingBufferN<SERIAL_BUFFER_SIZE> RingBuffer;

本当はこのクラスを使いたかったのですが、テンプレートを使っています。つまり宣言時にバッファサイズを確定させる必要があります。個人的にはテンプレートを使うのは無しで、begin関数などを呼び出して実際に利用したところでバッファを確保してほしいです。CH32V307だと8個もSerialクラスがあるので、未使用の受信バッファがもったいないです。

あと受信バッファが必要な状況は実はあまりなく、送信のみを利用する要件のほうが多いかもしれません。今回はbegin関数で受信バッファを確保していますが、available関数かread関数などを呼び出した時に未初期化の場合には受信用の初期化をする方がメモリ効率は良さそうです。

実装

arduino_core_ch32_riscv_arduino/copy/libraries/ArduinoCoreAPI/src/Uart.cpp at main · ch32-riscv-ug/arduino_core_ch32_riscv_arduino
CH32 Risc-V for Arduino IDE. Contribute to ch32-riscv-ug/arduino_core_ch32_riscv_arduino development by creating an acco...

できあがったソースが上記になります。あまり細かくすると実装に時間がかかるので、細かいところの修正や省メモリ化は完成してからすすめたいと思います。

まとめ

最初のクラスなので非常に苦労しました。とくにC++環境が正しく動いていないのはわかったのですが、原因特定まではどこかでメモリ破壊をしていたのかと思っていました。

まだまだ実用的なところまでは程遠いので、どんどんArduino Core APIを実装していきたいと思います。

コメント