verylowfreqのアイコン画像
verylowfreq 2021年08月15日作成 (2021年12月15日更新)
製作品 製作品 Lチカ Lチカ 閲覧数 5613
verylowfreq 2021年08月15日作成 (2021年12月15日更新) 製作品 製作品 Lチカ Lチカ 閲覧数 5613

16連装「Lチカ」USBデバイスの製作(AVR ATtiny44AでUSB HIDデバイスを作る)

16連装「Lチカ」USBデバイスの製作(AVR ATtiny44AでUSB HIDデバイスを作る)

回路外観:
回路外観

Lチカの動作風景:

概要

V-USB(ソフトウェア実装のUSBスタック)をATtiny44A向けにビルドし、Windowsパソコンからデータの送信、AVRからのデータの受信を実装しました。USB HIDデバイスとして振舞うため、追加のドライバのインストールは不要で、CやPythonなどから制御できます。

おまけで、16個のLEDをLチカします。これはただ単に見た目重視です。上記のV-USBデバイスの応用用途に困って、とりあえず作った感じです。

構成

AVRとUSB

コアはAVR ATtiny44A-PUです。この選定に大した理由はなく、秋月電子で安い(110円)、DIP 14ピンの見た目が良い、というだけです。一般論としては、プログラムFlashROMが4KBはある(tiny2313は2KB)、ADCがある(tiny2313は無し)、空きピンが7あるので入出力に少し余裕がある(tiny85だと3しか残らない)、という優位性はあります。UARTないけど。
Arduino LeonardやPro microでおなじみのATmega32U4のようにUSB通信機能は内蔵しないので、V-USBを使います。V-USBはUSB 1.1 Low Speed (最大1.5Mbps)をソフトウェア実装したものです。

V-USB - objective development : https://www.obdev.at/products/vusb/index.html
obdev/v-usb on Github: https://github.com/obdev/v-usb/

V-USB使用にあたっての今回における制約は以下の通りです。

  • プログラムFlashROMは4KBしかない。 ← 頑張って納める。
  • ピン配置はデフォルトからずらす必要がある。 ← 使用する割り込みを変える。
    • デフォルトでINT0割り込み(PORTB)を使うが、PORTBにD+/D-の両方を割り当てる余地がないので、そのまま使うのは厳しい。
  • tiny85と違って、内蔵RC発振器で駆動できそうにない。 ← 水晶発振子を使う(2ピン消費する)。

USBデバイスと自由なデータ転送をしたい場合、ドライバのインストールなどを回避しないならば(特にWindows)、libusb(など)向けにデバイスを組むことが考えられます。
しかし今回は、ドライバに関する追加作業を避けるために、HIDデバイスとして実装することにしました。USBのHIDは、Human Interface Deviceとは名乗るものの、データのやり取りが制限付きで可能なので、対人インターフェースデバイスに限らず、少量データのやり取りが必要なケースで利用できます。

※MicrosoftのWinUSB仕様に対応させれば、Windowsでは設定不要でWinUSBデバイスとして自由にデータ通信できるらしいのですが、対応作業がわりと大変そうなので見送り。特定のディスクリプタをいくつか用意してやらないといけないので、ロジックを組む必要があるのと、プログラムFlashROM容量も心配。

16連装LED

ATtiny44Aは14ピンしかなく、V-USB実装時点で空いているピンは7ピンのみです。そのため、ロジックICを用いて、16個の出力を確保しました。

ラッチ付きシフトレジスタ(シリアルパラレル変換)である74HC595を2つ利用しますが、ラッチ動作は不要なので、参考資料を基に、マイコンからは2ピンで制御します。
※ラッチなしシフトレジスタには74HC164がありますが、単価が74HC595に比べて高いので見送り。

参考資料 「たった1ピンでHC595を使ってみる」 - K-ichi's memo : http://k-ichi.blogspot.com/2019/07/hc595.html

ラッチ動作してないのでLEDの表示更新中にチラつきます。結果的にはピン数も余ったし、Lチカに全力を注ぐなら、ラッチも制御したほうが良いです。

回路

回路図

部品表

品名 数量 参考単価 メモ
AVR ATtiny44A 1 110
水晶発振子 16MHz 1
コンデンサ 22pF 2 水晶発振子
コンデンサ 0.1uF 1 おまじない
ツェナーダイオード 3.6V 2 USB D+/D-の電圧制限
USBケーブル 1 適当に切断して配線を取り出す
抵抗 1.5K 1 USB D-のプルアップ
抵抗 75R 2 USB D+/D-
抵抗 10K 6
74HC595 2 35 シフトレジスタ ラッチあり
LED 16
抵抗 1K 16 LEDの電流制限
タクトスイッチ 2
ブレッドボード そこそこ広め
配線材 たくさん

精査してないけど、ブレッドボードと配線材を除いてざっくり500円くらいでしょうか。

実装の様子

ブレッドボードで組むとUSB接続がわりと不安定になりがちな気がしますが、ユニバーサル基板に起こすほどのものではないので。

回路外観 注釈付き

AVRファームウェア概要

V-USBを使用するファームウェアの構成

(※USBの規格はホストを中心に記述されている(らしい)ので、GETやSETやWRITEやREADはホストの視点からの記述になります。たとえばV-USBでの'read'は、ホストがデバイスからデータを読み込むことなので、デバイスから見たら送信処理です。)

ファームウェアの組み方としては、まず初期化し、メインループ内でusbPool()を高頻度に(50ms以下の間隔で)呼び出します。

ホストからなんらかの要求が来たときは、V-USB内で処理できないリクエスト(アプリケーション依存なリクエスト)についてはusbFunctionSetup()で処理します。
usbconfig.hで設定した場合は、さらにusbFunctionRead()、usbFunctionWrite()が呼ばれ、そこでデータの受信や送信を行います。
ただしインタラプト転送(定期的にホストへ送信)の場合は、メインループ内でusbSetInterrupt()により、データをセットします。

V-USBの設定は、usbdrv/usbconifg-prototype.husbconfig.hとしてコピーし、これを編集します。

使用するピンと割り込みの変更

usbconfig.hを編集します。

デフォルトのピン割り当てを変更します。
PA0をUSB D-に、PA3をUSB D+につなぎます。

/* ---- Hardware Config ---- */

#define USB_CFG_IOPORTNAME A
#define USB_CFG_DMINUS_BIT 0
#define USB_CFG_DPLUS_BIT 3

ピン割り当てを変更したので、使用する割り込みの種類も変更します(デフォルトはINT0)。USB D+はPA3につながっているので、PA3とピンを共有しているPCINT3を使うようにします。

/* ---- Optional MCU Description ---- */

#define USB_INTR_CFG PCMSK0 
#define USB_INTR_CFG_SET (1 << PCINT3) 
#define USB_INTR_ENABLE GIMSK
#define USB_INTR_ENABLE_BIT PCIE0 
#define USB_INTR_PENDING GIFR 
#define USB_INTR_PENDING_BIT PCIF0 
#define USB_INTR_VECTOR PCINT0_vect

HIDディスクリプタの組み立て

USB HIDデバイスでは、やり取りするデータの内容の宣言にHIDディスクリプタを用います。標準的なキーボードやマウスだけでなく、用途を規定しない任意のデータを流すようにも宣言できます。

データのやり取りに注目すると、FEATUREとして宣言するものと、INPUT/OUTPUTとして宣言するものがあります(両方を宣言することもできる)。今回の製作の範囲では、ホスト側でのデータの送受信関数や呼び出し方が変わるくらいです。データ量はほとんどないので、とりあえず送受信とも8バイトにしておきました。

FEATUREとして宣言する場合(V-USBサンプルのhid-dataそのまま):

PROGMEM const char usbHidReportDescriptor[USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH] = { /* USB report descriptor */
  0x06, 0x00, 0xff, // USAGE_PAGE (Generic Desktop)
  0x09, 0x01, // USAGE (Vendor Usage 1)
  0xa1, 0x01, // COLLECTION (Application)
  0x15, 0x00, //   LOGICAL_MINIMUM (0)
  0x26, 0xff, 0x00, //   LOGICAL_MAXIMUM (255)
  0x75, 0x08, //   REPORT_SIZE (8)
  0x95, 0x08, //   REPORT_COUNT (8)
  0x09, 0x00, //   USAGE (Undefined)
  0xb2, 0x02, 0x01, //   FEATURE (Data,Var,Abs,Buf)
  0xc0 // END_COLLECTION
};

INPUT, OUTPUTとして宣言する場合:

PROGMEM const char usbHidReportDescriptor[USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH] = { /* USB report descriptor */
  0x06, 0x00, 0xff, // USAGE_PAGE (Generic Desktop)
  0x09, 0x01, // USAGE (Vendor Usage 1)
  0xa1, 0x01, // COLLECTION (Application)
  0x15, 0x00, //   LOGICAL_MINIMUM (0)
  0x26, 0xff, 0x00, //   LOGICAL_MAXIMUM (255)
  0x75, 0x08, //   REPORT_SIZE (8)

  0x09, 0x00, //   USAGE (Undefined)
  0x95, 0x08, //   REPORT_COUNT (8)
  0x81, 0x02, //   INPUT (Data,Var,Abs)

  0x09, 0x00, //   USAGE (Undefined)
  0x95, 0x08, //   REPORT_COUNT (8)
  0x91, 0x02, //   OUTPUT (Data,Var,Abs)

  0xc0 // END_COLLECTION
};

データの送受信(FEATUREの場合)

FEATUREで宣言した場合、ホストからのデータの送信も、ホストからのデータ要求への返答も、コントロール転送で行なわれます。V-USBレベルでは、usbFunctionSetup()にまず入り、その後、usbFunctionRead()とusbFunctionWrite()で処理します。

usbconfig.hの変更点:

#define USB_CFG_IMPLEMENT_FN_WRITE 1
#define USB_CFG_IMPLEMENT_FN_READ 1

ホストからのデータを受け取るusbFunctionRead()関数:
led16_push_word()は、16ビットの値を受け取って、LEDへ反映する関数。

uchar usbFunctionWrite(uchar *data, uchar len)
{
    uint16_t ledpattern = data[0] << 8 | data[1];
    led16_push_word(ledpattern);

    return 1;
}

データをホストへ送るusbFunctionRead()関数:

uchar usbFunctionRead(uchar *data, uchar len)
{
    data[0] = digitalRead(PIN_BUTTON_1) == LOW ? 1 : 0;
    data[1] = digitalRead(PIN_BUTTON_2) == LOW ? 1 : 0;
    for (int i = 2; i < 8; ++i) {
        data[i] = 0x00;
    }

    return len;
}

データの送受信(INPUT, OUTPUTの場)

INPUT、OUTPUTで宣言し、かつusbconfig.h内で#define USB_CFG_HAVE_INTRIN_ENDPOINT 1とした場合、ホストからのデータ送信はコントロール転送、ホストからのデータ要求への返答はインタラプト転送で行なわれます。インタラプト転送は、ホスト側が高頻度で(デバイスが要求した頻度で)デバイスへデータを問い合わせる通信方式です。データは8バイトまで送れます。最小間隔は10msです。
データの受信はFEATUREのときと同じくusbFunctionWrite()で処理します。データ送信は、メインループ内でusbSetInterrupt()で最新のデータを設定します。

※このHIDディスクリプタであっても、USB HIDの規格上、デバイスからのデータ送出がインタラプト転送でなければならないということはないようなのですが、後述のホスト側のhidapiライブラリとの組み合わせでは、この構成で通信するしかないようです。

usbFunctionWrite()はFEATUREと同じのため省略。

usbconfig.hの変更点:

#define USB_CFG_HAVE_INTRIN_ENDPOINT    0
#define USB_CFG_IMPLEMENT_FN_WRITE 1
#define USB_CFG_IMPLEMENT_FN_READ 1

インタラプト転送のデータをセットする:

// ...

uint8_t reportBuffer[8];

int main(void) {
	// ...

    // main event loop
    while (true) {
        wdt_reset();
        usbPoll();

        if (usbInterruptIsReady()) {
            memset((void*)reportBuffer, 0x00, 8);
            reportBuffer[0] = digitalRead(PIN_BUTTON_1) == LOW ? 1 : 0;
            reportBuffer[1] = digitalRead(PIN_BUTTON_2) == LOW ? 1 : 0;
            usbSetInterrupt((void *)&reportBuffer, sizeof(reportBuffer));
        }
    }
	
	return 0;
}

(不採用)内蔵RC発振で駆動する

水晶発振子ではなく、内蔵RC発振で動作させることもできる設計になっています。ただし8MHz動作では処理が追い付かないので、 USBのリセット時の信号を利用して内蔵RC発振の周波数を保証範囲外まで上げるような調整を自動で行う関数(OSCCALレジスタの適切な値を探る関数)が用意されています。これをリセット時に呼び出すようにします。詳細な実装はV-USBサンプルを参照してください。

今回の構成で手元で試した限りでは、通信は成立するものの、データ送信時にホストのOSレベルでは失敗のステータスになることがたまにあり(ただしデータは送信できている)、若干の挙動の怪しさがありました。通信の成否がわかりづらいので、今回は水晶発振子を利用します。

Digispark(ATtiny85搭載)など、市販ボードで内蔵RC発振を利用しているものもあるため、チップの組み合わせや、適切に実装した場合は、きちんと実用になるようです。

16個のLEDを制御する

前述のように、16個のLEDを2個の74HC595で制御します。回路図の通り、ラッチ制御をしていないので、データ線とクロック線の2本をAVRと接続します。

74HC595は、クロックの立ち上がりでデータピンのデータを採取するソースクロックピンと、同じく立ち上がりでラッチするラッチクロックピンの2本があります。2本に全く同じクロックを供給すると、ひとつ前のクロックでのデータがラッチされてしまい、一般的に期待される動作にならない(らしい)です。
前掲の参考資料によれば、この2本を大きめの抵抗で結線しIC由来の寄生容量を利用して、ラッチクロックピンへ少し遅れたクロックを供給します。

(再掲)参考資料 「たった1ピンでHC595を使ってみる」 - K-ichi's memo : http://k-ichi.blogspot.com/2019/07/hc595.html

なぜこれでうまくいくのかきちんと理解していないのですが、手元の回路は10usのウェイトで動いたので、そのようにしています。ブレッドボードだから、なのかもしれませんが。
(追記:参考資料を読み直したら、そもそも20nsくらいおけば十分そうなので、今回の16MHz動作ではウェイト要らないですね。)

LEDの配置は、左端がMSBに対応しており、左から右へシフトしていきます。よって、データの送る順序としてはLSBファーストです。

※pinMode(), digitalWrite()は、自作のArduino互換関数です。PIN_LED_SERはデータ線、PIN_LED_SCKはクロック線です。

void led16_init(void) {
    pinMode(PIN_LED_SER, OUTPUT);
    pinMode(PIN_LED_SCK, OUTPUT);
    digitalWrite(PIN_LED_SER, LOW);
    digitalWrite(PIN_LED_SCK, LOW);
}

void led16_push_bit(bool bit) {
    digitalWrite(PIN_LED_SER, bit ? HIGH : LOW);
    digitalWrite(PIN_LED_SCK, HIGH);
    delayMicroseconds(10);
    digitalWrite(PIN_LED_SCK, LOW);
}

void led16_push_word(uint16_t w) {
    for (int i = 0; i < 16; ++i) {
        bool bit = (w & (1 << i)) != 0;
        led16_push_bit(bit);
    }
}

(準備中)全体のソースコード

ソースコードは整理中です。上記の変更点をV-USBサンプルのhid-dataに適用すれば、だいたい再現できます。

VIDとPIDについて

正しい情報はusbdrv/USB-IDs-for-free.txtusbdrv/USB-ID-FAQ.txtを参照のこと。

自分だけが使う場合は、デフォルトのままでも動くことには動きます(異なるドライバを要求する複数のV-USBデバイスを接続していなければ)。

異なるドライバを必要とするV-USBデバイスを作っている場合、あるいは第三者へ渡す場合は、ドキュメントの通りに設定するべきです。ドライバの適用はVID, PIDにより決定されるため、ドライバごとにVID,PIDの組み合わせを変える必要があり、複数のVID,PIDが準備されています。

今回の場合、マウスでもキーボードでもないHIDデバイスなので、VID: 0x16C0, PID: 0x05DF を利用します。
usbconfig.hにて、

#define USB_CFG_VENDOR_ID    0xc0, 0x16 
#define USB_CFG_DEVICE_ID    0xdf, 0x05

また、USB_CFG_VENDOR_NAMEUSB_CFG_DEVICE_NAMEも適切に設定します。

Windows側のアプリケーションの概要

とりあえずPythonで記述しました。pipでhidapiを導入します。

FEATUREの場合

HIDディスクリプタでFEATUREとして定義した場合、hidapiのset_feature_report()でデータを送信し、get_feature_report()でデータを受信要求します。

以下のコードでは判定していませんが、VIDとPIDに共用のものを利用した場合、VIDとPIDの組み合わせではデバイスの種類を特定できないため、製造者やプロダクトやシリアルナンバーの文字列情報を利用してさらに絞り込む必要があります。(usbdrv/USB-IDs-for-free.txtに記載あり。)

get_feature_report()を連続して呼び出す場合は、適度にウェイト(50msとか)を挟まないとエラーになるようです。(そもそも高頻度に呼び出す設計ではないといえばそうなんですけど。)

# USB HID "FEATURE"

import hid

h = hid.device()
h.open(0x16C0, 0x05DF)

# TODO: Look up: manufacturer, product, and/or serial number string.

# Send (SET_REPORT on device)
# (ReportID) + (Data body) = 9 bytes
wbuf = [0] + [0xF7, 0x31] + [0] * 6
wlen = h.send_feature_report(wbuf)
if wlen != len(wbuf):
	print("set_feature_report():", wlen")

# Read (GET_REPORT on device)
# Should be returned 9 bytes (same as above)
try:
	# args: (ReportID), (bytes to read)
	rbuf = h.get_feature_report(0, 9)
	print(rbuf)
except OSError as excep:
	print(excep)
	throw

INPUT, OUTPUTの場合

HIDディスクリプタでINPUT, OUTPUTとして定義した場合、hidapiのwrite()とread()を使用します。write()はFEATUREの場合と同じく、SET_REPORTとして受け取りますが、read()はインタラプト転送のことです(hidapiライブラリの仕様)。そのため、V-USB側ではusbconfig.hでインタラプト転送を有効にし、メインループ内でusbSetInterrupt()でデータを設定する必要があります。
Pythonコードとしては、hidapiの呼び出す関数が違うくらいでほぼ同じです。
read()は呼び出しまくってもエラーを吐かないようなので、むやみに呼び出しても安心です。もちろんインタラプト転送の実際の頻度や、デバイス側で値を更新しなければ、データ的な意味はないんですけど。

# USB HID "INPUT" and "OUTPUT"

import hid

h = hid.device()
h.open(0x16C0, 0x05DF)

# TODO: Look up: manufacturer, product, and/or serial number string.

# Send (SET_REPORT on device)
# (ReportID) + (Data body) = 9 bytes
wbuf = [0] + [0xF7, 0x31] + [0] * 6
wlen = h.write(wbuf)
if wlen != len(wbuf):
	print("set_feature_report():", wlen")

# Read (INTERRUPT_IN on device)
# Should be returned 9 bytes (same as above)
try:
	# bytes to read: 9 bytes (ReportID + Data body)
	rbuf = h.read(9)
	print(rbuf)
except OSError as excep:
	print(excep)
	throw

Lチカプログラム(ホスト側)

以上の例を踏まえ、以下のコードを書きました。
デバイス側のHIDディスクリプタはFEATUREで定義しています。ボタンは2つ実装し、返答8バイトの先頭バイトと2番目のバイトがそれぞれボタンの押下状態です(1で押下中)。LEDの点灯パターンは、送信したデータ(ReportIDより後ろ)の先頭バイトと2番目のバイトを ((1st byte) << 8) | (2nd byte) として16ビットに並べ、LEDには左からMSB -> LSBで並べます。(シフトレジスタへの送信としてはLSBファースト。)

import time
import hid

def main():
    h = hid.device()
    h.open(0x16C0, 0x05DF)

    print("Single point moves left to right.")
    p = 0x0001
    for i in range(0, 16):
        v = p << i
        buf = [0] + [(v >> 8) & 0xFF] + [v & 0xFF] + [0] * 6
        h.send_feature_report(buf)
        time.sleep(0.05)
    
    buf = [0] * 9
    h.send_feature_report(buf)

    print("All LEDs blinks")
    for i in range(0, 4):
        buf = [0] + [0xFF] * 2 + [0] * 6
        h.send_feature_report(buf)
        time.sleep(0.25)
        buf = [0] + [0x00] * 2 + [0] * 6
        h.send_feature_report(buf)
        time.sleep(0.25)

    buf = [0] * 9
    h.send_feature_report(buf)

    print("Button control")
    while True:
        btn = h.get_feature_report(0, 9)
        if type(btn) == list:
            if btn[1] != 0:
                print("Left to right")
                p = 0x0003
                for i in range(0, 16):
                    v = p << i
                    buf = [0] + [(v >> 8) & 0xFF] + [v & 0xFF] + [0] * 6
                    h.send_feature_report(buf)
                    time.sleep(0.05)
            elif btn[2] != 0:
                print("Right to left")
                p = 0xE000
                for i in range(0, 16):
                    v = p >> i
                    buf = [0] + [v >> 8] + [v & 0xFF] + [0] * 6
                    h.send_feature_report(buf)
                    time.sleep(0.05)
        
        buf = [0] * 9
        h.send_feature_report(buf)

        # interval for get_feature_report()
        time.sleep(0.05)

if __name__ == '__main__':
	main()

このプログラムの動作風景は冒頭の動画の通りなのですが、結局はLチカなので、見栄えするかというとあんまりっていう感じですね。製作者本人の盛り上がりとは関係なく、見た目じゃUSBかどうかなんてわからないし、これくらいならAVR単体で実現可能だし。

その他

  • WinUSB対応はいずれやりたい。
    • USBaspで対応させているレポジトリがあるので、参考になるはず。 https://github.com/dioannidis/usbasp/
    • この場合のVID, PIDはどうすれば良いだろうか。
  • USBをきちんと理解したい
    • いちおう動いてるけどさっぱりわからん。
  • 秋月電子でATtiny44Aは110円だが、ATtiny85は140円(表面実装品は120円)、ATmega8は180円なので、ATtiny44Aがフィットする範囲はわりと狭そう。ATtiny85は内蔵RC発振器で駆動できるので、Vcc, GND, USB D+, USB D-, RESETを配線しても3ピンは残るし、FlashROM 8KBなので容量は心配ない。(ちなみにATtiny84は230円なので、あんまり考慮してない。)
  • ATmega32U4やUSB内蔵Arm搭載ボードの高騰、欠品に負けない。
2
verylowfreqのアイコン画像
"verylowfreq" あるいは 「三峰スズ」(VTuber 2023年2月より) です。趣味で電子工作や3Dプリンターを楽しんでいます!
ログインしてコメントを投稿する