公開日:2018/12/06
title.png

3rd Season


#3-1 LCD1602に表示する

 今回は、LCD1602(16文字×2行表示器)を使って、文字列を表示してみたいと思います。

仕様の確認

 仕様から確認していきます。

ar31_lcdout.jpg

 Iduinoスタータキットに入っていたのは、上図の様なLCD表示モジュールです。バラ売りされている同様なモジュールの中には、I2Cインターフェイスのついたモジュールもありますが、こちらにはそのモジュールは付いていません。8ビットのデータバスと思われる信号名(D7〜D0)が端子に引き出されているので、バス接続なのでしょう。他に制御信号が何本か必要みたいなので、これまた激しくI/Oポートを消費しそうですね。
 モジュールから引き出された端子数は、全部で16本見えます。

 まずは、「LCD+1602」辺りを手掛かりにデータシートを探してみます。すると、それらしきデータシートが複数見つかりました。いくつかの会社から互換チップが販売されているようです。どのデータシートも、同じ様なことしか書いていないので、使う分には、どのデータシートでも一緒かな?
 細かく電気的特性を知る必要に迫られると、どの会社から出ている互換チップなのか?を、知る必要がありますが、どうやら、LCDのコントローラ兼ドライバチップは、LCDパネルの下に実装されてしまっているようで、外からはメーカや型番を確認できません。何とか、入手した情報の中で使うしかありませんね……。

 機能の実装には、当面問題ないので、いくつか見つかったデータシートの中から、一番わかりやすく書かれていたものを選んで、作業を進めます。

 念のため、バックライト(LED)の抵抗値だけは確認しておきます。「#2-9 抵抗値を測定する」で作った抵抗測定装置とプログラムを使います。基準抵抗R0=1kΩで良いでしょう。

  測定サンプル:
  1000[ohm], 0, 588, 1023, Rx=1354[ohm]
  1000[ohm], 0, 588, 1023, Rx=1357[ohm]
  1000[ohm], 0, 588, 1023, Rx=1357[ohm]
  1000[ohm], 0, 588, 1023, Rx=1355[ohm]
  1000[ohm], 0, 588, 1023, Rx=1356[ohm]

 意外と抵抗値高いですね。LEDのON抵抗(ON電圧Vf)+LCDモジュール内にも電流制限抵抗が実装されているのでしょう。明るさを確認しながら330Ωの抵抗を外付けに実装することにしました(直結でも良いレベルですけどね)。

※「抵抗値」と言っても、ダイオードはI-V特性が線形ではないので、純粋な抵抗ではないですね。VfとIfから決まる動作点に、原点から直線を引いた時の傾きが見えているだけです。「ON抵抗」を測定したいなら、実際にLEDに流れる電流値を測定する必要があります。

 なお、抵抗測定回路は、LEDを光らせる回路と同じ構成なので、抵抗を測定しようとすると、バックライトが光ります。期待通りの動作なので、びっくりしないでください。

LCD1602へアクセスする

 I/Oポートは貴重なので、まずは、4ビットバスモードを使って、アクセスしてみましょう。回路図は以下の様になります。

ar31_schem.png

 貴重だと言いながら、バックライト制御に1端子使っていますが……。プログラムから明るさの調整が不要なら、単に電流制限抵抗だけ通して、Vccに吊ってもOKです。
 8本出ているデータバスは、4ビットモードの場合は、上位側DB7〜DB4を使いますので、下位側は未接続にしておきます。

 各社データシートを要約すると、書き込みタイミングは以下図の通りです。

ar31_timwr.png

 Arduinoには、「外部バス」が無いので、いつものようにdigitalWrite()でI/Oポートを1ピン1ピン動かしていくしかありません。お陰で、上図のタイミング順さえ守っておけば、tsu1、thd1などのタイミング条件は満たせるはずなので、そのまんま、記述してみます。

  void lcd_write_nibl (
  uchar ars,
  uchar dat)
  {
    // write nibble dat to LCD
    // RS <= ars, DB7-4 <= dat7-4

    // RS, RW
    digitalWrite (pin_RS, (ars)? HIGH: LOW);
    digitalWrite (pin_RW, LOW);

    // tsu1=100ns(min)>1cyc
    // E
    digitalWrite (pin_E, HIGH);
    // DB
    digitalWrite (pin_DB7, (dat&0x80)? HIGH: LOW);
    digitalWrite (pin_DB6, (dat&0x40)? HIGH: LOW);
    digitalWrite (pin_DB5, (dat&0x20)? HIGH: LOW);
    digitalWrite (pin_DB4, (dat&0x10)? HIGH: LOW);
    // output DB
    pinMode (pin_DB7, OUTPUT);
    pinMode (pin_DB6, OUTPUT);
    pinMode (pin_DB5, OUTPUT);
    pinMode (pin_DB4, OUTPUT);
    // tsu2=60ns(min)>0cyc, tw=300ns(min)>4cyc
    // E
    digitalWrite (pin_E, LOW);
    // thd2=10ns(min)>0cyc, tc=500ns(min)>8cyc
    // input DB
    pinMode (pin_DB7, INPUT);
    pinMode (pin_DB6, INPUT);
    pinMode (pin_DB5, INPUT);
    pinMode (pin_DB4, INPUT);
  }


 コメントで、満たすべきタイミングスペック値などを記載していますが、「目安」だと思ってください。LCD制御チップによって、若干制約値にばらつきがありますので、どれか一つのデータシートだけを見て、ギリギリに攻めたタイミングでアクセスすると、別なチップだった場合にアクセスを失敗する可能性があります。

※前節で述べた通り、肝心な制御チップはLCDパネルの下に実装されていて、表からは確認できないので、どのデータシートに従うのが正しいのか?判断できません。

 一例にtc=500ns(min)というスペックがあるのですが、これはE端子の立ち上がりエッジ→次の立ち上がりエッジまでの最小サイクル数です。ATmega328 @16MHzは、1命令/1サイクルの実行で、1命令当たり1/16MHz=62.5nsの実行時間がかかります。よって、500ns=8サイクル(命令)分に相当します。
 上に示したC言語のプログラムで、いったい、何サイクルかかっているの?と、言うことですが、そもそも、digitalWrite()は、ライブラリに収録されている関数ですので、必ず、アセンブラのcall命令で呼び出し、ret命令で戻ってきているはずです。ATmegaのデータシートを見ると、call命令もret命令も4サイクルを要する命令なので、digitalWrite()をコール(実行)した時点で、4+4=8サイクルは確実にかかっていることになります。先ほどのtc=8サイクルは、軽く満たせるので、ほぼ、何も気にせずに済みます。

 上の関数lcd_write_nibl()は、最初に「4ビットバスモード」を設定する時にだけ使用する関数です。4ビットバスモードを設定した後は、8ビットデータの上位4ビット+下位4ビットを連続して書き込む関数lcd_write()を使う必要があります。完成版プログラム(test_lcd.ino)でご確認ください。

 同様に、読み込みタイミングに従って、読み込み関数も実装します。書き込みと同様、タイミング図に従って端子を動かすだけですので、ここでの説明は省略します。完成版プログラム(test_lcd.ino)でご覧ください。

 例によって、シリアルモニタから制御できるようにコマンドを作っておきます。

 プログラムをリンクしておきます。

  完成プログラム:test_lcd.ino

  実行サンプル:【 】内はシリアルモニタからの送信データ
  【who】
  OK: test_lcd ver.1.00

  【pwm,255】 …… バックライトが明るくなる
  OK: back light duty=255

  【init】 …… 最初に1回だけ実行が必要な初期設定
  OK: init lcd, 4 bit bus mode

  【demo】 …… デモを実行。LCDの表示に注目
  OK: demo finished. 3836[ms] (BF=1[ms])

  【prn,0,0123456789】 …… LCDの上の行に"0123456789"を表示
  OK: print: prn,0,0123456789

  【prn,1,abcdefghijk】 …… LCDの下の行に"abcdefghijk"を表示
  OK: print: prn,1,abcdefghijk

  【iwr,01】 …… 全画面消去のインストラクションを発行
  OK: inst.write=0x01

  【ird】 …… BFの読み出し
  OK: stat.read=0x00

 ひとまず、アクセスはできた模様。

LCD1602の初期化

 電源投入時、LCD1602は自動的に内部で初期化を実行するので、特に初期化する必要はありません。初期化が終了するまで、アクセスできないので、その時間だけ待ちましょう。困ったことに、これまたメーカによってばらつきがあります。初期化時間40〜100[ms]くらい??少し余裕を見てウェイトした方が良いでしょう。

 問題は、LCD1602の「初期設定」です。

 Arduinoが起動した時(setup()内)、LCDモジュールがどちらのバスモードで動いているのかわからないので、要は、初期設定できないのですよ、このモジュール。そこで、現在、LCDが4ビットバスモードでも、8ビットバスモードでも、正しく初期設定できるように、「初期手順(initial procedures)」が規定されているデータシートがいくつかありました。これまたメーカによって内容が微妙に違っていて、意味不明です。

 恐らく、以下の様な手順で良いはずだ、と理解しました。

 上の手順でようやく、自分が使用したいバスモードを確定できたので、表示設定など、その他の初期設定インストラクションを順次書き込むことができます。この先は、LCDモジュールが他のインストラクションを処理していないことを、BFフラグをリードして確かめてから、次のインストラクションを書き込む必要があります。
 上の手順をシリアルモニタからの【init】コマンドで実行する様になっています。

※筆者手元のLCDモジュールは、【init】コマンドを実行するまで、何やらLCDの表示がおかしいです。一部データシートには、「電源投入で初期化される」とあるのですが、筆者手元のLCDモジュールは、上で示した初期化手順を踏まないと、初期化されないのかも?どちらにせよ、4ビットバスモードで使用するには、上述初期化手順は必須なので、問題ないですけどね。

 いつも通り、楽勝ですね、Arduino恐るべし。でもちょっと納得いかないのが、実行スピードです。あんなチマチマと、1ピン1ピンdigitalWrite()で端子を動かさないと制御できないものでしょうか?本来は、バスアクセスなので、本当に、数100nsで1アクセスが終了するはずなのに、一体全体、どれだけ時間をかけてアクセスしていることでしょう。

アセンブラで直接制御する

 アセンブラを使って、解決してみます。インラインアセンブラは「#2-8 ロータリエンコーダを使う」で既に使いました。まず、「インラインアセンブラは使えます」の状態が前提条件です。

 さて、ATmega328のデータシートを参照します。すると、「#2-8 ロータリエンコーダを使う」でも触れた通り、ATmega328には8ビットづつ一塊になったポートと言う単位があって、個別に1ピン1ピンの制御ではなく、8ビット(8ピン)毎のアクセスが基本になっています。ポートには、PORT-B、C、Dがあって、PORT-DとBの一部がデジタル端子、PORT-Cの一部がアナログ端子に割り当てられています。
 デジタル端子のD9〜D13に割り当たっているのが、PORT-B1〜B5なのですが、ここ、1つのポート(PORT-B)に集中している上に、B1〜B5のように綺麗にビットが並んでいます。このポート(PORT-B1〜B5)をうまく、データバスDB7〜DB4に割り当てることで、1ピン1ピン個別に制御することなく、4端子を一度に設定することが可能になるのです。

 なお、各ポートには、出力レジスタPORTx、方向レジスタDDRx、入力レジスタPINxの3つがあります(PORT-Bの場合はx←B)。
 digitalWrite()の様に、データを出力したい場合は、DDRxの対応するビットに1を書き込んだ状態(pinMode(OUTPUT)に相当)で、PORTxの対応するビットに出力したい値0または1を書き込みます。
 digitalRead()の様に、データを入力したい場合は、DDRxの対応するビットに0を書き込んだ状態(pinMode(INPUT)に相当)で、PINxの対応するビットを読み取ります。

 C言語バージョンでは、書き込み関数をご覧に入れましたので、今度は、読み出し関数を例に、インラインアセンブラで記述してみましょう。
 読み出しタイミングを要約すると、以下の様になります。

ar31_timrd.png

 書き込みの時と異なり、データ出力遅延時間tdには注意が必要です。上図にある通り、Eの立ち上がりエッジからtd時間遅延して、ようやく有効(valid)なデータがデータバスDB0-7に出力されますので、tdが経過する前にデータを取り込んでも、正しいデータが読めません。これまた、チップによってtdの値にばらつきがあり、どの程度待てば良いのやら、不明です。見つけたデータシートの中で、一番長いチップがtd=100nsでしたので、これをターゲットにプログラムしてみます。

 ピンアサインですが、複数ピンを一斉に制御可能な、PORT-B1〜B5をデータバスDB7〜DB4に割り当てます。データバスは4ピンなので、1ピン余ります。データバスと連動して操作が必要なE端子を残りの1ピンに割り当てます。

ATmega328ポート番号 LCD端子名   UNOピン番号 Microピン番号
PORT-B5 DB7 D9 D9
PORT-B4 DB6 D10 D8
PORT-B3 DB5 D11 D14
PORT-B2 DB4 D12 D16
PORT-B1 E D13 D15

 個人的には、所有するUNOとMicro(Leonardo)の両方の機体で使用したいので、Arduinoのデジタルピン番号と、ATmegaのポート番号、LCDの接続先信号名の対応表になっています。PORT-B5〜B1をアサインした結果として、Arduinoのピン番号が決まる、と言う順番なので、Arduinoのピン番号だけを変更しても、正常に動作しません。そういった意味では、自由度が失われるわけですが、8ビットRISCチップの性能をギリギリまで活かして使いたいとなったら、止むを得ないことと承知しています。

 アセンブラを使った読み出し関数は以下の様になります。

  const uchar pin_e_h=0x02;  // PB1=HIGH
  const uchar pin_e_l=~pin_e_h; // PB1=LOW
  const uchar pin_d_s=0x3c;  // PB5-2 set mask
  const uchar pin_d_c=~pin_d_s; // PB5-2 clear mask
  const uchar reg_pinb=0x03;  // i/o address for PINB
  const uchar reg_portb=0x05;  // i/o address for PORTB

  uchar lcd_read (
  uchar ars)
  {
    // read dat from LCD by 4 bit bus mode
    // RS <= ars, DB7-0 => dat[7-0]
    uchar dat;

    // RS, RW
    digitalWrite (pin_RS, (ars)? HIGH: LOW);
    digitalWrite (pin_RW, HIGH);

    uchar datl;
    uchar pdat;

    // asm
    __asm__ __volatile__ (
      // tsu=100ns(min)>1cyc
      // E
      "in %[pdat],%[reg_portb]\n\t"
      "ori %[pdat],%[pin_e_h]\n\t"
      "out %[reg_portb],%[pdat]\n\t"
      // td=100ns(max)>1+1(sync)cyc
      "nop\n\t"
      "nop\n\t"
      "nop\n\t"
      // DB(H)
      "in %[dat],%[reg_pinb]\n\t"
      // tw=300ns(min)>4cyc
      // E
      "andi %[pdat],%[pin_e_l]\n\t"
      "out %[reg_portb],%[pdat]\n\t"
      // tc=500ns(min)>8cyc
      "nop\n\t"
      "nop\n\t"

      // E
      "ori %[pdat],%[pin_e_h]\n\t"
      "out %[reg_portb],%[pdat]\n\t"
      // td=100ns(max)>1+1(sync)cyc
      "nop\n\t"
      "nop\n\t"
      "nop\n\t"
      // DB(L)
      "in %[datl],%[reg_pinb]\n\t"
      // tw=300ns(min)>4cyc
      // E
      "andi %[pdat],%[pin_e_l]\n\t"
      "out %[reg_portb],%[pdat]\n\t"
      // thd=10ns(min)>0cyc, tdh=20ns(min)>0cyc, tc=500ns(min)>8cyc

      : [pdat]"=&r"(pdat),
        [dat]"=&r"(dat),
        [datl]"=&r"(datl)
      : [reg_pinb]"I"(reg_pinb),
        [reg_portb]"I"(reg_portb),
        [pin_e_h]"M"(pin_e_h),
        [pin_e_l]"M"(pin_e_l&0xff),
        [pin_d_s]"M"(pin_d_s),
        [pin_d_c]"M"(pin_d_c&0xff)
      );
    // read data
    dat=((dat&pin_d_s)<<2) | ((datl&pin_d_s)>>2);

    return (dat);
  }


 では、上で示した読み出し関数をステップを踏んで見ていきましょう。
 RS端子、RW端子は、アセンブラで制御するピンに含まれないので、普通にCのライブラリで制御します。

    // RS, RW
    digitalWrite (pin_RS, (ars)? HIGH: LOW);
    digitalWrite (pin_RW, HIGH);


 アセンブラ内で使用する一時変数を宣言しておきます。

    uchar datl;
    uchar pdat;


 E端子を立ち上げます。これがPORT-Bを操作する初めのステップになるので、PORT-B5〜B1以外の値を壊さないように、現在のPORTBレジスタ値を、Cの変数pdatに取り込んでからE端子だけをHIGHに立ち上げる処理をします。

      // tsu=100ns(min)>1cyc
      // E
      "in %[pdat],%[reg_portb]\n\t"
      "ori %[pdat],%[pin_e_h]\n\t"
      "out %[reg_portb],%[pdat]\n\t"


 ここの記法は、GCCのインラインアセンブラならでは、ですね。in命令は、I/Oレジスタから読んだ値をATmegaのMCUレジスタr0〜r31にしか読み込むことができないのですが、それを、Cで宣言したpdatと言う変数に読み込みなさい、という表現「in %[pdat],%[reg_portb]」となっています。これが文法上通ってしまう訳ですから。

 E端子が立ち上がってから、有効なデータが出力されるまでにtd=100nsの時間がかかります。さらに、ATmega328のデータシートによると、ポートの読み出しには、1サイクルの同期化処理が含まれていることがわかります。つまり、入力レジスタPINBを読み出すと、読み出した値は、1サイクル過去の値だということです。
 よって、E端子の立ち上がりから、td+1サイクル待ってからPINBを読み出す必要があり、そのための時間稼ぎが必要です。何もしないnop命令を必要数実行して時間稼ぎします。

      // td=100ns(max)>1+1(sync)cyc
      "nop\n\t"
      "nop\n\t"
      "nop\n\t"


 入力レジスタを読み出して、Cの変数datに記憶します。

      // DB(H)
      "in %[dat],%[reg_pinb]\n\t"


 E端子を立ち下げます。ここで、E端子のHIGHパルス幅規定tw=300ns(min)がありますが、既に十分満たしているので、問題ありません。

      // tw=300ns(min)>4cyc
      // E
      "andi %[pdat],%[pin_e_l]\n\t"
      "out %[reg_portb],%[pdat]\n\t"


 最後に、E端子のサイクルタイム規定tc=500nsがあります。一度、E端子を立ち上げたら、次にE端子を立ち上げるまでにtcの時間を空けなさい、という規定です。さっきE端子を立ち上げてからのサイクル数(≒命令数)を数えてみると、ちょっと、tcに足りないようです。そこで、nop命令で時間稼ぎします。

      // tc=500ns(min)>8cyc
      "nop\n\t"
      "nop\n\t"


 これで、最初の(上位)4ビットリードが終了しました。8ビットデータなので、もう一回同じことを繰り返して下位4ビットをリードしなければなりません。上位4ビットリードと同じなので、説明を省略します。

 アセンブラを抜けると、変数datに上位4ビットデータを含むPINBの読み出し値、変数datlに下位4ビットデータを含むPINBの読み出し値が格納されています。各4ビットデータを抜き出して、8ビットに再編したら終了です。この処理はC(GCC)に任せます。

      // read data
      dat=((dat&pin_d_s)<<2) | ((datl&pin_d_s)>>2);


 いくつかnop命令を挟み込んで、時間調整しましたが、遅い(鈍い)Cのライブラリを叩く場合と異なり、アセンブラで記述すると、数100ns単位の高速動作になってくるため、タイミング制約に気を付けないと、違反してしまう箇所が出てくるので要注意です。

 アセンブラは嫌いではないのですが、インラインアセンブラは使い難くて、あまり好きではないです。まず、ご覧の通り、記法(constraint)が複雑で難解。記法を間違えた時のエラーメッセージもわかり難くて、嵌まる。それに、インラインアセンブラで記述した命令は、処理の流れをアセンブラの文法を借りて記述したに過ぎず、GCCの都合(最適化)によって、命令が追加されたりします。

 実は、Arduino IDEの右上の方にある▼マークで「新規タブ」を選び、「新規ファイルの名前」に、拡張子.S(大文字)を指定すると、直接アセンブラのソースファイルを挿入することができます。さすがに、この中にはGCCの最適化は及ばないので、ハナからアセンブラで関数丸ごと記述したい場合には、そういう方法も一つの解法かと思います。

実力は?

 結果は「自明」なのですが、一応やっておきますか。プログラム先頭の#define USE_ASMを有効にしたアセンブラバージョンと、コメント(無効化)したC言語バージョンの速度の差を確認したいと思います。
 以下図が完成した回路写真です。

ar31_circt.jpg

 0xa1以降のコードには、カタカナフォントが入っていました。日本向けのコントローラチップみたいですね。しかし、残念なことに、Arduino IDEはUTF-8(unicode)ベースなので、この位置(シフトJIS 1バイトカタカナコード領域)のカタカナコードを使用できません(Cのプログラムで 'ア' って使ったらエラーになった(UTF-8だと3バイトコードだからね)……、超迷惑なunicode?)。

  完成プログラム:test_lcd.ino
    ※先ほどと同じソースです。先頭付近の#define USE_ASMの有効/無効で選択できます。

  実行サンプル:【 】内はシリアルモニタからの送信データ
  −−−−C言語バージョン(#define USE_ASMをコメント)
  【init】
  OK: init lcd, 4 bit bus mode

  【demo】
  OK: demo finished. 3836[ms] (BF=1[ms])

  −−−−アセンブラバージョン(#define USE_ASMを有効)
  【init】
  OK: init lcd, 4 bit bus mode

  【demo】
  OK: demo finished. 1088[ms] (BF=383[ms])

 実行結果を動画にして比較してみました。



 でしょうね(1^^;)。このために必死こいてアセンブラ使っているのですから。でも、数字で比べると、思った程速くないような……?そうか、BF(Busy Flasg)の待ち時間が全実行時間の35%を占めているから。この分はアセンブラにしても縮まらないので。……C言語バージョンのBF待ち時間1msと言うのも、極端ですね。ほとんど、BF=1(Busy)状態を観測できないくらい、アクセスが遅いみたいです(1^^;)っ。


 まだまだ、満足してません。使用端子数が多すぎて、実用上問題が大きすぎます。I2Cモジュールが接続されたバージョンなら、2端子(SCLとSDA)で接続できるので、ずいぶんと可能性が広がるのに……。もう少しこだわってみたいと思います。


btn_arduino.png

btn_prev.png btn_next.png

home