การจำลองบอร์ด MCU เป็นอุปกรณ์ USB
- วิกินี้เป็นส่วนหนึ่งของรายวิชา 01204223
ที่ผ่านมานั้นเราใช้พอร์ท USB เป็นเพียงแหล่งจ่ายพลังงานและโปรแกรมแฟลชเท่านั้น วิกินี้อธิบายถึงขั้นตอนการทำให้ไมโครคอนโทรลเลอร์จำลองตัวเองเป็นอุปกรณ์ USB ความเร็วต่ำ เพื่อที่จะสามารถสื่อสารกับโปรแกรมที่ทำงานอยู่บนเครื่องคอมพิวเตอร์ได้
ในที่นี้เราจะใช้โอเพนซอร์สไลบรารีชื่อ V-USB (เดิมเรียกว่า AVR-USB) ที่พัฒนาโดยบริษัท Objective Development โดยทำให้บอร์ด MCU ของเราทำงานเป็นอุปกรณ์ที่อยู่ในกลุ่ม custom class device ซึ่งจัดเป็นอุปกรณ์ USB ที่ไม่สังกัดคลาสใด โดยซอฟต์แวร์ฝั่งคอมพิวเตอร์จะอยู่ภายใต้ความควบคุมของเราทั้งหมด
นอกเหนือจาก custom class device แล้ว ไลบรารี V-USB ยังรองรับการโปรแกรมเฟิร์มแวร์ให้สังกัดคลาสอื่นได้อีกหลายคลาส อาทิเช่น HID (Human Interface Device) ซึ่งอยู่ในกลุ่มเดียวกับอุปกรณ์จำพวกแป้นพิมพ์ เมาส์ จอยสติ๊ก และเกมแพด
เนื้อหา
ไลบรารีและเครื่องมือที่จำเป็น
ให้แน่ใจว่าได้ติดตั้งไลบรารีและเครื่องมือที่จำเป็นตามที่ได้อธิบายไว้ในวิกิด้านล่าง ก่อนเริ่มทำตามขั้นตอนในวิกินี้
ขั้นตอนการใช้งานไลบรารี V-USB
- ดาวน์โหลดซอร์สโค้ดจาก Objective Development
- แตกไฟล์ .tar.gz ที่ดาวน์โหลดมาโดยใช้คำสั่ง
tar zxf vusb-20121206.tar.gz
- คัดลอกไดเรคตอรี
usbdrv/
ที่อยู่ในไดเรคตอรีvusb-20121206/
ไปวางในไดเรคตอรีโปรเจ็คของตน - ภายในไดเรคตอรีโปรเจ็คของตนเอง ย้ายไฟล์
usbdrv/usbconfig-prototype.h
มาวางไว้ด้านนอก และเปลี่ยนชื่อให้เป็นusbconfig.h
mv usbdrv/usbconfig-prototype.h ./usbconfig.h
- ไฟล์นี้จะเก็บข้อมูลการตั้งค่าเกี่ยวกับอุปกรณ์ USB ที่จะให้ไมโครคอนโทรลเลอร์จำลองตัวเองขึ้นมา
- ในไฟล์หลักของโปรเจ็ค เรียกใช้คำสั่ง
#include
ต่อไปนี้ไว้ที่ตอนต้นของโปรแกรม
#include <avr/io.h> #include <avr/interrupt.h> /* for sei() */ #include <util/delay.h> /* for _delay_ms() */ #include <avr/pgmspace.h> /* required by usbdrv.h */ #include "usbdrv.h"
- ในส่วนของฟังก์ชัน
main()
ต้องมีโครงสร้างหลักดังนี้ (สามารถใส่โค้ดอื่นเพิ่มได้ตามที่ต้องการ)
int main() { usbInit(); usbDeviceDisconnect(); _delay_ms(300); /* fake USB disconnect for > 250 ms */ usbDeviceConnect(); sei(); /* enable interrupts */ while (1) /* main event loop */ { usbPoll(); /* คำสั่งนี้ต้องถูกเรียกอย่างน้อยที่สุดทุก ๆ 50ms */ } return 0; }
- นิยามฟังก์ชัน
usbFunctionSetup
เพื่อประมวลผลข้อมูลที่รับมาจากเครื่องคอมพิวเตอร์ผ่านทางพอร์ท USB
usbMsgLen_t usbFunctionSetup(uint8_t data[8]) { ; }
- ฟังก์ชันนี้จะถูกเรียกทำงานโดยอัตโนมัติจากฟังก์ชัน
usbPoll
เมื่อทางฝั่งคอมพิวเตอร์ส่งคำร้องขอผ่านมาทางพอร์ท USB หัวข้อ #ตัวอย่างโปรแกรม แสดงตัวอย่างการการเขียนฟังก์ชันนี้เอาไว้
การคอมไพล์และลิ้งค์โปรแกรมรวมกับ V-USB
สร้าง Makefile ต่อไปนี้เพื่อคอมไพล์โปรแกรมและดำเนินการลิ้งค์เข้ากับไลบรารี V-USB ซึ่งตัวอย่าง Makefile นี้สมมติว่าไฟล์หลักของโปรเจ็คคือ main.c
MCU=atmega168 F_CPU=16000000L TARGET=main.hex OBJS=usbdrv/usbdrv.o usbdrv/usbdrvasm.o CFLAGS=-Wall -Os -DF_CPU=$(F_CPU) -Iusbdrv -I. -mmcu=$(MCU) all: $(TARGET) flash: $(TARGET) avrdude -p $(MCU) -c usbasp -U flash:w:$(TARGET) %.hex: %.elf avr-objcopy -j .text -j .data -O ihex $< $@ %.elf: %.o $(OBJS) avr-gcc $(CFLAGS) -o $@ $? %.o: %.c avr-gcc -c $(CFLAGS) -o $@ $< %.o: %.S avr-gcc $(CFLAGS) -x assembler-with-cpp -c -o $@ $< clean: rm -f $(OBJS) rm -f $(TARGET) rm -f *~
หากดูในกฎที่ระบุกลไกการสร้างไฟล์ main.elf
ขึ้นมาจาก main.o
จะเห็นว่าบรรทัดที่มีการเรียกใช้ avr-gcc
ได้มีการนำเอา $(OBJS)
(ซึ่งได้แก่ไฟล์ usbdrv/usbdrv.o
และ usbdrv/usbdrvasm.o
) ลิ้งค์รวมเข้าไปด้วย ในที่นี้ตัวแปรพิเศษ $@
แทน target ซึ่งหมายถึง main.elf
ส่วนตัวแปรพิเศษ $?
แทนรายการของ dependency ทั้งหมด นั่นคือไฟล์ main.elf
นั้นถูกสร้างขึ้นโดยการที่ make เรียกคำสั่งด้านล่างนี้อัตโนมัติ
avr-gcc -Wall -Os -DF_CPU=16000000L -Iusbdrv -I. -mmcu=atmega168 -o main.elf main.o usbdrv/usbdrv.o usbdrv/usbdrvasm.o
โครงสร้างของคำร้องขอ USB ที่รับจากฝั่งคอมพิวเตอร์
ในสถาปัตยกรรม USB นั้นการสื่อสารจะถูกเริ่มจากการที่ฝั่งคอมพิวเตอร์ (ฝั่งโฮสท์) ส่งคำร้องขอ (USB Request) ไปยังฝั่งอุปกรณ์ USB เสมอไม่ว่าจะมีการอ่านข้อมูลจากอุปกรณ์ USB หรือเขียนข้อมูลไปยังอุปกรณ์ USB ก็ตาม ข้อมูลคำร้องขอมีโครงสร้างตามที่นิยามไว้ในไฟล์ usbdrv/usbdrv.h
ดังนี้
typedef struct usbRequest{ uchar bmRequestType; /* 1 ไบต์ */ uchar bRequest; /* 1 ไบต์ */ usbWord_t wValue; /* 2 ไบต์ */ usbWord_t wIndex; /* 2 ไบต์ */ usbWord_t wLength; /* 2 ไบต์ */ }usbRequest_t;
bmRequestType
ประกอบด้วยฟิลด์ย่อย 3 ฟิลด์ดังต่อไปนี้
- บิต 7 ทิศทางการส่งข้อมูล (Data Phase Transfer Direction)
- 0 = จากคอมพิวเตอร์ไปอุปกรณ์ USB (Host to Device)
- 1 = จากอุปกรณ์ USB มายังคอมพิวเตอร์ (Device to Host)
- บิต 6..5 ประเภทคำร้องขอ (Type)
- 0 = Standard
- 1 = Class
- 2 = Vendor
- ฟังก์ชัน
usbFunctionSetup
ที่เราต้องเขียนขึ้นนั้นจะถูกเรียกใช้เมื่อค่าในฟิลด์ Type นี้มีค่า 2 (Vendor) เท่านั้น
- บิต 4..0 ผู้รับ (Recipient)
- 0 = Device
- 1 = Interface
- 2 = Endpoint
- 3 = Other
bRequest
ระบุหมายเลขคำร้องขอ คำร้องขอตามมาตรฐานของ USB นั้นมีประเภทเป็น Standard ซึ่งจะถูกประมวลผลจากไลบรารี V-USB อัตโนมัติ เราจึงไม่ต้องสนใจในส่วนนี้ ส่วนที่เราต้องรับผิดชอบคือคำร้องขอแบบ Vendor ซึ่งต้องถูกออกแบบไว้ล่วงหน้าแล้วว่าอุปกรณ์ USB ของเราจะรองรับคำร้องขอหมายเลขอะไรบ้าง โดยในฟังก์ชันusbFunctionSetup
ของเราต้องประมวลผลคำร้องขอเหล่านี้ได้ถูกต้องwValue
และwIndex
ทั้งคู่เป็นฟิลด์ที่ไม่มีความหมายใดในกรณีที่คำร้องขอเป็นแบบ Vendor ดังนั้นเราจึงมีอิสระเต็มที่ในการใช้งานฟิลด์ทั้งคู่นี้เป็นตัวส่งรายละเอียดของคำร้องขอ ซึ่งส่งได้สูงสุด 4 ไบต์wLength
กำหนดขนาดของข้อมูลเพิ่มเติมที่จะส่งจากฝั่งโฮสท์หรือจากอุปกรณ์ USB หากไม่มีข้อมูลเพิ่มเติม ค่านี้จะถูกเซ็ตเป็นศูนย์
ดูรายละเอียดเพิ่มเติมได้จาก USB in a NutShell
ตัวอย่างโปรแกรม
- โหลดไฟล์ usb-example.zip ซึ่งเก็บไฟล์ทั้งหมดที่ใช้ในตัวอย่างนี้ (กรณีที่ต้องการใช้งานผ่าน Arduino IDE ให้ดาวน์โหลดไฟล์ usb-example-arduino.zip)
เพื่อให้เห็นภาพของการใช้งานไลบรารี V-USB มากขึ้น เราลองสร้างตัวอย่างเฟิร์มแวร์อย่างง่ายขึ้นมาพร้อมทั้งใช้ภาษาไพธอนทดลองสั่งงานจากฝั่งคอมพิวเตอร์ ในที่นี้เราจะให้ตัวเฟิร์มแวร์จำลองตัวเป็นอุปกรณ์ USB ที่รองรับการสั่งงานจากโฮสท์ 2 คำร้องขอ ดังนี้
- คำร้องขอ SET_LED สั่งให้ LED ดวงที่ระบุติดหรือดับ มีรายละเอียดของคำร้องขอดังนี้
- กำหนดให้หมายเลขคำร้องขอ (request number) คือ 0
- ส่งรายละเอียดมาให้ 2 ไบท์ ไบท์แรกระบุตำแหน่งของ LED บนบอร์ดพ่วง (0, 1 หรือ 2) ส่วนไบท์ที่สองระบุว่าจะให้ LED ดวงดังกล่าวติดหากมีค่า 0 หรือดับหากมีค่าอื่นที่ไม่ใช่ศูนย์ เนื่องจากข้อมูลมีขนาดเพียงสองไบท์ เราจะใส่ข้อมูลนี้ลงไปในฟิลด์
wValue
ที่ถูกส่งไปพร้อมกับคำร้องขอโดยตรง - แม้ไม่มีข้อมูลอื่นเพิ่มเติมส่งจากคอมพิวเตอร์ไปยังอุปกรณ์ USB แต่เราจะระบุทิศทางการไหลของข้อมูลไว้เป็น Host to Device
- คำร้องขอ GET_SWITCH สั่งให้บอร์ด MCU รายงานสถานะการกดปุ่มสวิตช์กลับมา มีรายละเอียดของคำร้องขอดังนี้
- กำหนดให้หมายเลขคำร้องขอ (request number) คือ 1
- ทิศทางการไหลของข้อมูลเป็น Device to Host
- รับข้อมูลกลับมา 1 ไบท์ บอกสถานะของสวิตช์ (0 คือไม่ถูกกด 1 คือถูกกด)
การตั้งค่าให้อุปกรณ์ USB
เปิดไฟล์ usbconfig.h
เพื่อปรับค่าให้สอดคล้องกับโปรเจ็ค
- VID/PID: อุปกรณ์ USB ทุกตัวจะต้องถูกกำหนดค่า Vendor ID (VID) และ Product ID (PID) ให้ ซึ่งแต่ละตัวเลขมีขนาด 16 บิต ในโปรเจ็คนี้ให้กำหนดค่า VID และ PID ให้เป็น 0x16c0 และ 0x05dc ตามลำดับ โดยดูให้แน่ใจว่าในไฟล์
usbconfig.h
มีบรรทัดเหล่านี้
#define USB_CFG_VENDOR_ID 0xc0, 0x16 /* VID = 0x16c0 */ : #define USB_CFG_DEVICE_ID 0xdc, 0x05 /* PID = 0x05dc */
- อ่านหลักเกณฑ์การตั้งค่า VID/PID เพิ่มเติมได้จากหัวข้อ #เกี่ยวกับหมายเลข VID/PID
- Vendor Name: กำหนดค่า USB Vendor Name ให้เป็น
cpe.ku.ac.th
โดยเปลี่ยนนิยามของมาโครUSB_CFG_VENDOR_NAME
และUSB_CFG_VENDOR_NAME_LEN
ดังนี้
#define USB_CFG_VENDOR_NAME 'c','p','e','.','k','u','.','a','c','.','t','h' #define USB_CFG_VENDOR_NAME_LEN 12
- Device Name: กำหนดค่า USB Device Name ให้เป็น ID <รหัสนิสิต> โดยเปลี่ยนนิยามของมาโคร
USB_CFG_DEVICE_NAME
และUSB_CFG_DEVICE_NAME_LEN
ตัวอย่างเช่นนิสิตที่มีรหัสประจำตัว 1234567890 ให้แก้ไขมาโครดังนี้
#define USB_CFG_DEVICE_NAME 'I','D',' ','1','2','3','4','5','6','7','8','9','0' #define USB_CFG_DEVICE_NAME_LEN 13
เฟิร์มแวร์ฝั่งอุปกรณ์
- เพื่อเป็นแนวปฏิบัติที่ดีในการเขียนโปรแกรม เราจะนิยามคำร้องขอเหล่านี้ไว้ที่ส่วนหัวของ
main.c
ดังนี้
#define RQ_SET_LED 0 #define RQ_SET_LED_VALUE 1 #define RQ_GET_SWITCH 2 #define RQ_GET_LIGHT 3
- ภายในฟังก์ชัน
usbFunctionSetup
สร้างโค้ดสำหรับประมวลผลคำร้องขอที่รับมาจากโฮสท์ดังนี้
usbMsgLen_t usbFunctionSetup(uint8_t data[8]) { usbRequest_t *rq = (usbRequest_t*)data; static uint8_t switch_state; /* เป็น static เพื่อค่าในตัวแปรคงอยู่แม้ฟังก์ชัน return ไปแล้ว */ /* ประมวลผลตามหมายเลขคำสั่งที่อยู่ใน bRequest */ if (rq->bRequest == RQ_SET_LED) { uint8_t led_val = rq->wValue.bytes[0]; uint8_t led_no = rq->wIndex.bytes[0]; if (led_val) PORTC |= (1<<led_no); else PORTC &= ~(1<<led_no); return 0; /* ไม่ส่งข้อมูลกลับ */ } else if (rq->bRequest == RQ_GET_SWITCH) { if ((PINC & (1<<PC3)) == 0) /* switch is pressed */ switch_state = 1; else switch_state = 0; /* ให้ usbMsgPtr ชี้ไปที่ตำแหน่งหน่วยความจำที่เก็บข้อมูล */ usbMsgPtr = (uchar*) &switch_state; /* ส่งข้อมูลกลับ 1 ไบท์ (เท่ากับขนาดของตัวแปร switch_state) */ return sizeof(switch_state); } return 0; /* ไม่รู้จักคำร้องขอ ไม่ส่งข้อมูลกลับ */ }
- คอมไพล์เฟิร์มแวร์และอัพโหลดเข้าแฟลชของไมโครคอนโทรลเลอร์ ซึ่งหากได้สร้าง
Makefile
ตามที่อธิบายไว้ข้างต้นไว้เรียบร้อยแล้ว ให้เสียบบอร์ดไมโครคอนโทรลเลอร์เข้ากับพอร์ท USB กดสวิตช์เพื่อเข้าสู่ Bootloader แล้วพิมพ์คำสั่ง
make flash
แอพลิเคชันฝั่งโฮสท์
ขณะที่เฟิร์มแวร์ทำงานอยู่นั้นเราจะมองไม่เห็นผลลัพธ์การทำงานใด ๆ เนื่องจากเฟิร์มแวร์ถูกเขียนไว้ให้ตอบสนองต่อการสั่งงานผ่านคอมพิวเตอร์เท่านั้น ภายในไฟล์ตัวอย่างมีไฟล์ชื่อ practicum.py ซึ่งเป็นโมดูลไพทอนที่เราจะนำมาใช้ติดต่อกับบอร์ดไมโครคอนโทรลเลอร์ผ่านภาษาไพทอน ทดลองเปิดไพทอนเชลล์แล้วโหลดโมดูลมาใช้งาน โดยฟังก์ชันหลักที่เรียกใช้จากโมดูลคือ find_mcu_boards() ซึ่งคืนค่าเป็นลิสต์ของอุปกรณ์ USB ทุกตัวที่มี VID/PID เป็น 16c0:05dc ที่ต่ออยู่กับคอมพิวเตอร์ ณ ขณะนั้น
$ python >>> from practicum import find_mcu_boards,McuBoard >>> devices = find_mcu_boards() >>> devices [<usb.Device object at 0xd91d70>]
จากนั้นสร้างอ็อบเจกต์ของคลาส McuBoard ขึ้นมาจากอุปกรณ์ตัวแรกในลิสต์
>>> mcu = McuBoard(devices[0]) >>> mcu.handle.getString(mcu.device.iManufacturer, 256) 'cpe.ku.ac.th' >>> mcu.handle.getString(mcu.device.iProduct, 256) 'ID 1234567890' <- ต้องขึ้นเป็นรหัสนิสิตของตน
ทดลองส่งคำร้องขอหมายเลข 0 (ควบคุมสถานะ LED) เพื่อให้ LED หมายเลข 2 บนบอร์ดพ่วงติดสว่าง ใช้เมท็อต usb_write
ในคลาส McuBoard
ดังนี้
>>> mcu.usb_write(0, index=2, value=1)
คำสั่งด้านล่างมีผลทำให้ LED หมายเลข 2 ดับ และ LED หมายเลข 1 ติดขึ้นมาแทน
>>> mcu.usb_write(0, index=2, value=0) >>> mcu.usb_write(0, index=1, value=1)
ทดลองอ่านสถานะของสวิตช์โดยส่งคำร้องหมายเลข 2 ไปยังบอร์ด MCU
>>> mcu.usb_read(2, length=1) array('B', [0])
ค่าที่เมทอด usb_read
คืนกลับมาจะเป็นทูเปิลที่มีสมาชิกหนึ่งตัว ตามที่ระบุในเฟิร์มแวร์
ทดลองกดสวิตช์บนบอร์ดพ่วงค้างไว้ แล้วส่งคำร้องไปยังบอร์ด MCU ใหม่ ผลลัพธ์ที่ได้ควรเป็นดังนี้
>>> mcu.usb_read(2, length=1) array('B', [1])
ใช้คำสั่ง help เพื่อดูรายละเอียดการใช้งานคลาส McuBoard
>>> help(McuBoard)
เกี่ยวกับหมายเลข VID/PID
ชุดตัวเลข VID/PID ที่กำหนดให้กับอุปกรณ์ USB ไม่ควรตั้งเอาเองตามใจชอบเนื่องจากระบบปฏิบัติการจะอาศัยตัวเลขคู่นี้ในการเลือกซอฟต์แวร์ไดรเวอร์ที่จะมาควบคุมอุปกรณ์ โดยทั่วไปการจะได้มาซึ่งเลข VID/PID เพื่อใช้กับอุปกรณ์ที่เราสร้างขึ้นจำเป็นต้องสมัครเป็นสมาชิกของ USB Implementers Forum (ค่าสมาชิกปีละ 4,000 เหรียญสหรัฐ) หรือซื้อตัวเลข VID มาจากผู้ที่เป็นสมาชิกอีกทีหนึ่ง
อย่างไรก็ตาม Object Development ผู้พัฒนาไลบรารี V-USB ได้เตรียมชุดตัวเลข VID/PID ไว้ให้เราใช้งานโดยไม่เสียค่าใช้จ่าย หมายเลข 16C0:xxxx ที่เราเลือกนำมาใช้งานก็ได้มาจากตัวเลขในชุดดังกล่าว รายละเอียดเพิ่มเติมเกี่ยวกับการกำหนดค่า VID และ PID ให้กับอุปกรณ์ USB รวมถึงหลักเกณฑ์การปฏิบัติในการผลิตอุปกรณ์ USB สู่สาธารณะ สามารถศึกษาเพิ่มเติมได้จากเนื้อหาในไฟล์ USB-ID-FAQ.txt และไฟล์ USB-IDs-for-free.txt ในไดเรคตอรี usbdrv ที่ได้จากการติดตั้งไลบรารี V-USB รวมถึงเอกสาร How to obtain an USB VID/PID for your project
ข้อมูลเพิ่มเติม
- V-USB Documentation Wiki
- USB in a NutShell
- usb4java - ไลบรารีภาษาจาวาสำหรับติดต่อกับอุปกรณ์ USB