公開日:2019/04/06
title.png

5th Season


#5-1 高速ソフトウェアシリアル(前編)

 非同期シリアル(UART;Universal Asynchronous Receiver Transmitter)をソフトウェアで実現した、「ソフトウェアシリアル」なる、ライブラリがArduinoから提供されています(便宜上、標準ソフトウェアシリアルと呼ぶことにします)。ソフトウェアシリアルって、一体、どの程度の速度で通信できるものなのでしょうか?
 実際に自分でプログラムを組んでみて、どこがボトルネックになって速度を制限するのか、確かめてみます。

目標仕様

 「#4-10 ArduinoとESP32の非同期シリアル通信」では、ESP32とArduinoを使って、WiFi通信を実現しました。ESP32の115,200baudと通信するため、Arduino(ATmega)の(ハードウェア)シリアルユニットを使って接続したのですが、シリアルユニットの空きが無いUNOでは接続できないことになってしまいました。

※まあ、ESP32の通信速度を落とす設定をすれば良いことなのですが……(1^^;)、したくない理由については、「#4-10 ArduinoとESP32の非同期シリアル通信」をご覧ください。

 今回作成するソフトウェアシリアルは、UNOとESP32を115,200baudで、シリアル接続できることを目標とします。

基本的な考え方

 基本的には、一定時間毎に出力を変化させたり(送信)、端子状態を取り込んだり(受信)すれば終了ですが、この、「一定時間」をソフトウェアでマネージするのが結構大変です。そこで、目標仕様を思い出してください。「高速通信実現のため、通信に使う端子の固定、リソースの占有を是とする」、と言うことで、タイマを1本、「一定時間」を刻むために占有使用します。ターゲットはUNO(ATmega328)なので、使用するタイマは8ビットタイマ(TC2)とします。

ar51_timer.png

 上図の様に、カウンタを適切な速度でフリーランさせておいて、スタートビットの1→0立ち下がりタイミングで、カウンタをクリアします。予め、カウンタのカウント速度と、通信ビットレート(baud_rate)の関係から、送信であれば端子出力を変化させるべきタイミングに相当するカウンタ値(オレンジ○)と、受信であれば端子状態を取り込むべきタイミングに相当するカウンタ値(赤↑)を計算してテーブルに保持しておきます。

※副作用として、TC2を使用するPWM出力(analogWrite()のD3とD11)が同時使用不可となります。

 まず、通信速度(baud_rate)から、TC2のカウントクロック周波数を決定するプリスケーラ(prescl)を決定します。

  256/(fcpu/prescl)/11=1/baud_rateより、
  prescl=11/256×fcpu/baud_rate

※"11":10ビット(=1フレーム)に、1ビットマージンを持たせた数値です。フレームとは、非同期シリアル通信の基本となる転送単位で、スタートビット(1ビット、0)+データビット(8ビット)+ストップビット(1ビット、1)の計10ビットからなります。

 TC2のプリスケーラのバリエーションは、データシートによるとfcpu/1, /8, /32, /64, /128, /256, /1024ですので、上式で計算したprescl値を超えて最小のプリスケーラ値に切り上げます。

 続いて、1フレーム分のカウンタ値cnt_frame(カウント/フレーム)を求めます。

  cnt_frame=10[ビット/フレーム]/baud_rate×fcpu/prescl

 最後に、上の定数を使って、受信時の端子取り込みタイミングテーブルrx_cnt[10]、送信時の端子変化タイミングテーブルtx_cnt[10]を計算します。

  // rx sampling time in TC2 counter
  for (idx=0; idx<10; idx++)
    rx_cnt[idx]=(uint32_t (cnt_frame)*(idx*2 + 1) + 10)/20;
  // tx output time in TC2 counter
  for (idx=0; idx<10; idx++)
    tx_cnt[idx]=(uint32_t (cnt_frame)*(idx + 1) + 5)/10;


 この計算は、通信速度が決まるとドミノ式に決定できるので、begin()メソッドで通信速度が指定された時に一気に計算しておきます。

※TC2で一番遅いプリスケーラfcpu/1024を使うと、0〜255までのカウンタ値で処理可能な通信速度の下限は、baud_rate≧672baudとなります。300、600baudなどの低速通信には対応できないので注意。低速通信が必要なら、標準ソフトウェアシリアルが使用可能なので、問題なしと判断しました。

送信手順

 送信は処理をブロック、割り込みも禁止した状態で、タイマ(TC2)のカウンタを参照しながら、適切なタイミングで端子出力を変化させて、データを出力します。以下、write()メソッドの処理を追いながら解説します。

  size_t HsswSerial::write (
  uint8_t chr)
  {
    // send data
    // return: 1 (count of data sent) / 0 (not initialized)
    uint8_t idx;
    uint8_t pbd;
    uint8_t pbd_c;
    uint8_t pbd_s;
    uint16_t frm;

    // return if not initialized
    if (prescl==0)
      return (0);


 write()メソッド。prescl==0の場合は、begin()メソッドが正常終了しなかった場合(begin()メソッド不実行の場合を含む)なので、0を返して終了します。

    // check & polling CTS
    if (cts_enb)
    {
      do
      {
        pbd=(*Hssw_pinb);
        pbd=(pbd&Hssw_bit_CTS)? HIGH: LOW;
        if (cts_neg)
          pbd=(pbd==LOW)? HIGH: LOW;
      } while (pbd==LOW);
    }


 相手からのCTS信号入力をチェックして、CTSがネゲートされている間は、送信を保留します。ここ、タイムアウト処理とか全く入っていないので、オプションでCTSを有効にした時、CTSがずっとネゲートされていると、完全にハングしますので、使用時注意してください。
 なお、端子の取り込み関数digitalRead()は遅いので、直接I/Oレジスタをアクセスしています。

    // negate RTS
    Hssw_negate_rts ();


 RTSをネゲートして、相手に「送信しないで!」と伝えます。これから割り込みを禁止して処理(送信)するので、送信中にデータを受信しても、受け取ることができず、ロストしてしまうためです。RTS出力をオプションで有効にした場合に出力します。当然、相手側もフロウ制御対応機器です。

 標準ソフトウェアシリアルも同じ状態だと思われますが、ソフトウェアによる非同期シリアル通信では、現実的に半二重通信になってしまうのです。

※半二重通信:双方向に通信可能だが、ある瞬間の通信は、送信か受信のいずれか一方しか発生しない。送信と受信が同時に起こる通信は、全二重通信と言う。ATmegaのハードウェアシリアルは全二重。

    // preparation
    pbd_c=(*Hssw_portb);
    pbd_c &= (~Hssw_bit_TXD);
    pbd_s=pbd_c | Hssw_bit_TXD;
    frm=(0x01<<9) | (chr<<1) | 0x00;


 送信準備。最後のfrmがこれから送信しようとするフレーム(10ビット分)データです。LSBファーストで送信するので、最下位ビットの0がスタートビット、最上位の1がストップビットです。

    // interrupt disable
    noInterrupts ();


 いよいよ、送信を開始するので、ここから割り込みを禁止します。割り込みを禁止しないと、何より、自分自身の受信割り込みにディスターブされて、送信波形が崩れてしまうためです。

    // send frame (10 bits)
    (*Hssw_tcnt2)=0x00;
    for (idx=0; idx<10; idx++)
    {
      if (frm&0x01)
        (*Hssw_portb)=pbd_s;
      else
        (*Hssw_portb)=pbd_c;
      frm >>= 1;
      // TC2 counter polling
      while ((*Hssw_tcnt2)<tx_cnt[idx])
        ;
    }


 最初にTC2のカウンタをクリアして、forループの最後で、次の端子変化タイミングになるまで、カウンタを参照しながらポーリングしています。digitalWrite()関数は遅いので、ここも直接I/Oレジスタにアクセスしています。1フレーム(10ビット)分送信するまで繰り返します。

    // check PCIF0 flag
    if ((*Hssw_pcifr)&0x01)
    {
      // ERROR: receive (and lost) data during sending
      errcod=HsswErrRecv;
      (*Hssw_pcifr)=0x01;  // PCIF0 clear
    }


 送信が終了した時点で、念のため、PCIF0フラグ(RXD)を確認しておきます。もし、フラグが立っていると、送信中にデータを受信してしまったことを示しているので、受信データをロストしたことになります。errcodにエラーコードを書き込みますが、例外等は投げていないので、受信側アプリケーションでエラーチェックしない限り、無視してそのまま処理を続けます。
 この後PCINT0割り込みが発生しても、所詮、誤ったデータしか受信できないので、フラグをクリアしてPCINT0割り込みが発生しないようにしておきます。

    // interrupt enable
    interrupts ();


 ここから再び割り込みを許可します。よって、データを受信可能となります。

    // RTS control
    Hssw_auto_rts ();


 受信バッファの埋まり具合に応じて、RTSをアサートまたはネゲートします。RTSの制御方法については、「#4-10 ArduinoとESP32の非同期シリアル通信」で解説済みですので、ご参照ください。

 ご覧の通り、ずっと送信のための波形出力にかかりっきりなので、送信バッファは必要ありません。

受信手順

 受信は、RXDに割り当てた端子割り込みPCINT0が発生したところから始まります。PCINT0割り込みサービスルーチン(ISR)内で、そのままブロックして1フレーム分のデータを取り切ります。では、ISR内の処理も、順を追って見ていきましょう。

  ISR (PCINT0_vect)
  {
    // ISR on PCINT0 as RXD
    int idx;
    uint8_t pbd;
    uint16_t frm;
    uint8_t *rx_cnt_ptr;


 PCINT0のISR宣言。

    // TC2 counter set
    (*Hssw_tcnt2)=SerialHssw.rx_cnt0;


 TC2カウンタのリセット。ここの仕組みは少し後で詳しく説明します。

    // test start bit
    pbd=(*Hssw_pinb);
    if (pbd&Hssw_bit_RXD)
    {
      // ERROR: not a start bit, ignore
      SerialHssw.errcod=HsswErrRecv;
      return;
    }


 PCINT0割り込みは、「端子の変化」で発生するので、変化の内、0→1の変化は無視してreturn、1→0の変化をスタートビットとみなして受信開始します。
 シリアルの通信フレームは、ストップビット(=1)で終わり、そのまま1の状態を保持しています。つまり、正常に受信できている間は、0→1の変化で割り込みがかかることはありません。よって、エラーコード(HsswErrRecv)を設定しておきます。

    frm=0;
    for (idx=0, rx_cnt_ptr=SerialHssw.rx_cnt;
      idx<10; idx++, rx_cnt_ptr++)
    {
      // TC2 counter polling
      while ((*Hssw_tcnt2)<(*rx_cnt_ptr))
        ;
      // sampling
      frm >>= 1;
      pbd=(*Hssw_pinb);
      if (pbd&Hssw_bit_RXD)
        frm |= 0x200;


 forループ先頭のポーリングで、サンプリング時間になるまでTC2のカウンタ値を監視します。サンプリングしたビット値は、変数frmにLSBファーストで取り込んでいきます。この処理を1フレーム(10ビット)分繰り返します。

      // distribution
      if (idx==8)
      {
        SerialHssw.rx_buf[SerialHssw.rx_buf_wr]=(frm>>2)&0xff;
        SerialHssw.rx_buf_wr++;
        if (SerialHssw.rx_buf_wr>=Hssw_max_buf)
          SerialHssw.rx_buf_wr -= Hssw_max_buf;
        if (SerialHssw.rx_buf_wr==SerialHssw.rx_buf_rd)
        {
          // ERROR: rx buffer overflow
          SerialHssw.errcod=HsswErrOvfl;
        }
      }
    }


 8ビットのデータを受信したところで、データを受信バッファに記録します。もう少し後ろで、別途細かく説明しますが、最後のストップビットを受け取ってからISRを抜けるまでの時間を最短にしたいので、ループ内で上の処理を終わらせるよう、if (idx==8)の条件で実行しています。この位置なら、forループの先頭に戻って、時間待ちのポーリングが入るので、待ち時間を有効に活用でき、ISRから抜けるまでの時間を最小化することができます。

    // check frame format
    if (frm&0x01)
    {
      // ERROR: bad frame (start bit is not 0)
      SerialHssw.errcod=HsswErrFrame0;
    }
    if (!(frm&0x0200))
    {
      // ERROR: bad frame (stop bit is not 1)
      SerialHssw.errcod=HsswErrFrame1;
    }


 一応、スタートビットの0と、ストップビットの1を確認して、それ以外の値だった場合にはエラーコードを設定します。

    // clear PCIF0
    (*Hssw_pcifr)=0x01;  // PCIF0 clear
  }


 最後にPCIF0フラグをクリアして、ISRを抜けます。PCIF0フラグは、割り込み処理を開始したときにハードウェアで自動的にクリアされているのですが、その後も各ビットを受信したときに端子が変化しているので、再びフラグが立ってしまっているはずです。明示的にクリアが必要です。

ドロドロの微調整#1

 特筆すべきは、ストップビットのサンプリングタイミングについて、です。上で一度示した図ですが、遠くなったので、もう一度示します。

ar51_timer.png

 上図に示したように、ストップビットのサンプリングから、フレームが終了するまで、1/2ビット分の時間しか残っていません。115,200baudでは、わずか1÷115,200/2≒4.3μsです。
 青く「次」と記した部分には、最速の場合、次フレームのスタートビットが来てしまうことになります。ストップビットをサンプリングしたら、速やかにISRを抜けて、次の割り込みに備えないと、次のフレームを受信失敗してしまいかねないタイミングで、余裕がありません。

 そこで、最後のストップビットのみ、サンプリングタイミングを少し前に倒してあります(緑↑)。改めて、対策後のコードを示しておきます。以下コードのidx==9成立時の例外処理がそれです。

  // rx sampling time in TC2 counter
  for (idx=0; idx<10; idx++)
  {
    if (idx==9)
    {
      // early capture for stop bit
      rx_cnt[idx]=uint32_t (cnt_frame)*(idx*8 + 1)/80;
    }
    else
      rx_cnt[idx]=(uint32_t (cnt_frame)*(idx*2 + 1) + 10)/20;
  }


 このせいで、もしかしたら、「ストップビットが1ではない」ことを示す、フレームエラー(HsswErrFrame1)が発生するかもしれませんが、データは正しく受信できているはずなので、うまくエラー処理して使ってください。

※HsswSerialクラスでは、エラーが起きてもerrcodを設定するだけなので、明示的にエラーチェックしなければ、そのまま無視して処理は継続されます。

ドロドロの微調整#2

 そう考えると、更に怖いのが、1→0の端子変化から、ISR内最初の命令を実行するまでの遅延時間です。
 ISR内で使用するMCUレジスタは、全てスタックにPUSHしてから使わないと、割り込みから復帰したときに処理を継続できません。つまり、最初の命令にたどり着くまでに相当数のpush命令が実行されています。しかも、push命令は2サイクル命令ですので、1命令で2サイクル(@16MHz)を要します。

 端子変化から割り込みフラグが立つまでの時間や、MCUが割り込み例外を開始するまでの時間など、読み切れない部分もあるので、実測してみます。ISR最初の命令をインラインアセンブラで特定のポートを変化させるout命令にしておき、そのポートの変化によって、PCINT0割り込みが起きるようにジャンパ線で接続しました。

  ISR (PCINT0_vect)
  {
    __asm__ __volatile__ (
      "out %[portb],%[pbd]\n\t"::
      [pbd]"M"(Hssw_bit_TXD),
      [portb]"I"(Hssw_io_portb)
      );
    :
    : (受信処理のコードをそのまま残す)
    :
  }
  void loop (void)
  {
    __asm__ __volatile__ (
      "out %[portb],%[pbd]\n\t"::
      [pbd]"M"((~Hssw_bit_TXD)&0xff),
      [portb]"I"(Hssw_io_portb)
    );
    delay (1000);
  }


※すみません、実験したときのコードが残っていないので、上のコードは「イメージ」です。TXD(D9)出力端子をテストに使ったので、TXD(D9)→RXD(D8)をジャンパ線で接続しました。

 STM32ボードでLogiscopeを起動し、上の様なコードを実行したUNOの端子D9のパルス幅を観測したところ、2〜5[μs]の間でばらつきました。「ばらつき」は、単に、Logiscopeのサンプリング誤差と思われます。115,200baudでは約4.3μsで1/2ビットが過ぎてしまうので、その内の2〜5[μs]は無視できない時間です。

 そこで、ISR先頭でタイマのカウンタを0にリセットする代わりに、端子変化からISR先頭命令までの遅延時間の実測:2〜5[μs]→平均して3.5μs分のカウンタ値をセットして、調整することにしました。

  // PCINT0 to ISR 1st instruction delay
  const int intr_ovhd=int (16*3.5 + 0.5);
  rx_cnt0=(intr_ovhd + prescl/2)/prescl;


 HsswSerial.begin()メソッド内で計算しておいたrx_cnt0をISR内で参照して、TC2カウンタの初期値として設定します。

 さすがに、115,200baudを安定して受信するためには、この位のドロドロした微調整がものを言うはずです。
 1段遅くしただけ(57,600baud)で、上の微調整は無くても安定して受信できるようになるので、最後のひと踏ん張り、ってこと。


 これで完成?いや、実はここからもう一波乱あるのですが、それはまた次の機会に取っておきます。クラスライブラリの完成も、その時に。


btn_arduino.png

btn_prev.png btn_next.png

home