ATmega328pだけでUSBデバイスをつくる

2022/08/28に公開

Arduino UNOはUSBでパソコンと接続できるし、Arduino UNOに載っているマイコンはATmega328pだが、ATmega328pだけではUSBでパソコンとつなぐようなデバイスは作れない。Arduino UNOにはUSBドライバのためのハードウェアが別途搭載されていて、そのおかげでArduino UNOでは、直接はUSBをしゃべれないATMega328pのようなマイコンにUSBで気軽にプログラムを転送したり、USBでパソコンを操作するデバイスを開発したりできるようになっている。

Arduino UNOがないとUSBデバイスのマイコンとして使うのが大変そうなATmega328pだけど、実は、その上のソフトウェアとして仮想的にUSBプロトコルを実装したV-USBというファームウェアがある。これを導入すれば、ハードウェアを設計することなく、ATmega328pだけで動くUSBデバイスが作れてしまう。

最低限必要な工作

ハードウェアの設計がいらないといっても、マイコンである以上、どうしても避けられない部分はある。USBの場合は、供給される電圧は5V前後なのに対してデバイスから送り出すデータのプラス側、つまりUSBのD+の電圧を3.3Vにしないといけないので、その調整が必要になる。ATmega328pは5Vでも3.3Vでも動作するけど、パソコン側のUSBホストはD+は3.3Vであることを期待するので、5Vで返すとまずUSBデバイスとして認識してもらえない。

電圧の調整方法として、V-USBのサイトには2通りのアプローチが紹介されている。1つは供給される5Vの電圧を3.3Vに下げて回路の動作電圧も3.3Vにする方法。もうひとつは、D+を3.3Vに下げる方法。後者のほうが簡単なので、後者でいいとおもう。

USBケーブルからのデータ線をマイコンのどのポートに接続すればいいかは、 usbdrv/usbconfig.h で設定する。D-をD4ポート、D+をD2ポートに接続する場合はこうなる(これがデフォルト)。

#define USB_CFG_IOPORTNAME      D
#define USB_CFG_DMINUS_BIT      4
#define USB_CFG_DPLUS_BIT       2

デバイスの書き方

ざっくりとしたノリは、次のような main 関数を書くこと。

int main() {
  usbInit();
  usbDeviceConnect();
  sei();
  
  while(1) {
    usbPoll();
    // ここに書いた処理が、USBデバイスによる割り込み処理になる
  }
  
  return 0;
}

usbInit は、usbconfig.hをみていろいろ設定を決めるのに使われる。usbDeviceConnectは、その設定に応じてホストとの接続を手当たりしだいに試して実行するためのマクロ。このあとで呼ばれている sei という関数により割り込み処理が始まる("start enable interrupt" の略?)ので、usbPoll という関数を定期的に呼び出しつつ、好きな処理を書けばいい(書かなくてもいい)。

ここに必ずしも処理を書かなくていいのは、ホストとやり取りするデータの構築や、そのデータを使ってポートをオンオフしたりする仕事は、usbFunctionSetup という関数を定義することで実現するから。

usbFunctionSetup 関数の定義例

というわけで、main はしばらく忘れて、 usbFunctionSetup 関数を定義していく。この関数は usbdrv/usbdrv.h では次のように規定されている。

USB_PUBLIC usbMsgLen_t usbFunctionSetup(uchar data[8]);

引数の data は、実はパソコンからUSBプロトコルの「SETUPトランザクション」としてやってくるデータを表していて、以下のように規定される usbRequest_t 型にキャストできるようになっている。

typedef struct usbRequest{
    uchar       bmRequestType;
    uchar       bRequest;
    usbWord_t   wValue;
    usbWord_t   wIndex;
    usbWord_t   wLength;
}usbRequest_t;

この定義だけ睨んでても各要素の意味がよくわからないんだけど、実はこれ、パソコン側のプログラムを書くときに使うことになるlibusbでデバイスにデータを送信するときに使う libusb_control_transfer という関数の引数と比べるといい感じに対応している。つまりV-USB側では、「データを libusb_control_transfer で送ったときに引数にしたやつと同じ名前の要素を usbRequest_t から取り出せばいい」という理解でよさそう。

int libusb_control_transfer (libusb_device_handle * dev_handle,
    uint8_t           bmRequestType,
    uint8_t           bRequest,
    uint16_t          wValue,
    uint16_t          wIndex,
    unsigned char *   data,
    uint16_t          wLength,
    unsigned int      timeout 
	) 	

したがって、たとえば「libusbで libusb_control_transfer の3つめの引数を使って送った値にもとづいてマイコンのポートB0をオンオフしたい!」なら、こんな感じに定義することになる。

USB_PUBLIC uchar usbFunctionSetup(uchar data[8]) {
  usbRequest_t *rq = (void*) data;
  if(rq->bRequest == 1) {
    PORTB = PORTB | (1 << PB0)
    return 0;
  } else {
    PORTB &= ~1;
    return 0;
  }
}

実際にマイコンのポートB0にLEDをつないで、冒頭で触れたUSBケーブルとの接続をやってあげれば、これだけで「libusb_control_transfer でLEDをオンにするUSBデバイス」が完成する。

コンパイル

コーディングに必要なものはV-USBのソースusbdrv ディレクトリにすべて揃っているので、そこにあるアセンブリとCのソースから usbdrv/usbdrv.ousbdrv/usbdrvasm.ousbdrv/oddebug.o がコンパイルされるようにして、それらが自分のgcc-avrでプログラムにリンクされるようにすればいい。

まず、include まわりを含めたコードの全体はこんな感じ。

#include <avr/io.h>
#include <avr/interrupt.h>
#include <avr/wdt.h>

#include "usbdrv/usbdrv.h"
#define F_CPU 16000000
#include <util/delay.h>

USB_PUBLIC uchar usbFunctionSetup(uchar data[8]) {
  usbRequest_t *rq = (void*) data;
  if(rq->bRequest == 1) {
    PORTB |= 1;
    // same as
    // PORTB = PORTB | 0b00000001
    // PORTB = PORTB | (1 << PB0)
    return 0;
  } else {
    PORTB &= ~1;
    return 0;
  }
}

int main() {
  DDRB = 1; // setup port B0 as output
  
  usbInit();
  usbDeviceConnect();
  
  sei();
  
  while(1) {
    usbPoll();
  }

  return 0;
}

上記では、前提として、 usbdrv ディレクトリをそっくりこのソースと同じディレクトリに置き、avr-gcc を実行するときにそのディレクトリを -I usbdrv で指定するものとする。ATmega328pであれば、こんな感じのMakefileでいいはず。

CC = avr-gcc
OBJCOPY = avr-objcopy

CFLAGS = -Wall -Os -I usbdrv -mmcu=atmega328p
OBJFLAGS = -j .text -j .data -O ihex

OBJECTS = usbdrv/usbdrv.o usbdrv/oddebug.o usbdrv/usbdrvasm.o main.o

all: main.hex 

%.hex: %.elf
	$(OBJCOPY) $(OBJFLAGS) $< $@

main.elf: $(OBJECTS)
	$(CC) $(CFLAGS) $(OBJECTS) -o $@

$(OBJECTS): usbdrv/usbconfig.h

%.o: %.c	
	$(CC) $(CFLAGS) -c $< -o $@

%.o: %.S
	$(CC) $(CFLAGS) -x assembler-with-cpp -c $< -o $@

libusbでクライアントを作る

libusbについては昔かいた記事のほうを参照。V-USBデバイスはデフォルトで名前が "Template" になるので、それを見つけたらled_controlという関数で libusb_control_transfer の3つめの引数をオンオフするだけ。

#include <stdio.h>
#include <string.h>
#include <libusb-1.0/libusb.h>
#include <unistd.h>

libusb_device **list;
libusb_device_handle *handle;
struct libusb_device_descriptor desc;
unsigned char buffer[512];
int cnt, i;

void led_control (int);

int main() {
  libusb_init(NULL);
  cnt = libusb_get_device_list(NULL, &list);
  
  for (i = 0; i < cnt; i++) {
    libusb_device *device = list[i];
    libusb_get_device_descriptor(device, &desc);
    if (libusb_open(device, &handle) == 0) {
      
      libusb_get_string_descriptor_ascii(
        handle, desc.iProduct, buffer, sizeof(buffer));
        
      if (strcmp("Template", (char const *)buffer) == 0) {
        led_control(1);
        sleep(2);
        led_control(0);
        break;
      }

      libusb_close(handle);
    }
  }

  libusb_free_device_list(list, 1);
  libusb_exit(NULL);
  return 0;
}

void led_control (int on_off) {
  libusb_control_transfer(handle,
    LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_IN,
    on_off, 0, 0, buffer, sizeof(buffer), 5000);
}

デバイスからホストに対するアクションを送るパターン

ここまでで紹介したのは、ホスト、つまりパソコンからデバイスの挙動をコントロールするタイプ。反対に、デバイスを操作してパソコンでなにかのアクションが発生するやつを作りたいこともある。基本的には同じ考え方でV-USB側とlibusb側をプログラムすればいいんだけど、この場合は、デバイスからホストに伝えたい情報をデバイス側のバッファにためて、その場所を usbMsgPtr という名前のポインタで指してあげると、その情報をホストで取り出して使えるようになっている。下記は、スイッチを押すとパソコンのコンソールに文字を表示するというプロジェクトの例。

Discussion