Vendor Request và Bulk IN trên STM32G0
EP0 vendor command và Bulk IN RAM dump trên STM32G0: pending log flag, tại sao không dùng ZLP, Zadig cho Windows, và output thật từ vendor_test.py.
Bài 7/8 trong series USB Device trên STM32.
Trước khi đọc bài này
Bài 2 đã giới thiệu 4 kiểu truyền dữ liệu của USB. Đến bài 6, thiết bị mới thật sự dùng Interrupt (HID) và Bulk (CDC log) - còn Control vẫn chỉ dừng ở mức phục vụ enumeration. Bài này đưa Control vào dùng thật, đồng thời mở thêm một đường Bulk thứ hai với mục đích khác hẳn CDC log.
Điểm khác biệt lớn nhất so với các bài trước: từ bài 4 đến bài 6, host chỉ quan sát được firmware qua CDC log. Bài này cho host ra lệnh được cho firmware - đổi đèn, bật tắt tính năng, và kéo cả một vùng SRAM ra ngoài để kiểm tra. Tôi cũng viết một công cụ test bằng Python đơn giản để minh hoạ điều đó ngay trong bài.
Bài này được xây dựng trực tiếp trên kết quả của bài 6, nên nếu bạn chưa đọc xong bài trước thì nên quay lại trước khi tiếp tục.
Ý chính bài này
- Control transfer giờ mới thật sự phục vụ ứng dụng, không chỉ enumeration.
- Gửi lệnh (Control) và truyền khối lượng lớn (Bulk) là hai việc khác nhau, cần tách hẳn hai module không phụ thuộc nhau.
- Xử lý lệnh từ host vẫn nằm trong USB stack context, nên vẫn phải log qua pending flag đúng như bài 6.
- Không cần gói rỗng báo kết thúc, vì host đã biết trước số byte cần đọc.
- Windows cần cài driver riêng cho interface này trước khi công cụ test truy cập được.
Thiết kế tổng quan
Hai đường giao tiếp phục vụ hai mục đích khác nhau, nên có hình dạng hoàn toàn khác nhau. Đường lệnh là một vòng hỏi-đáp ngắn, còn đường dump là một luồng dữ liệu chảy một chiều sau khi đã được cho phép.
Gửi lệnh
GET_FIRMWARE_INFO, SET_LED_MODE... qua Control.
Xử lý ngay
VendorCmd_HandleSetup() đọc lệnh, chuẩn bị phản hồi.
Nhận phản hồi
Cùng một transaction, không cần chờ vòng sau.
Xin phép dump
START_RAM_DUMP qua Control.
Chấp nhận
Trả về acceptedLength, chưa gửi dữ liệu.
Tự động bơm
Chunk 64 byte nối tiếp nhau qua Bulk, không cần host hỏi lại.
Nhận đủ, dừng
Đọc đúng acceptedLength byte rồi tự kết thúc.
Ba trong bốn lệnh (thông tin firmware, bật/tắt repeat, đổi đèn) dừng lại ở đúng vòng hỏi-đáp đầu tiên. Chỉ riêng lệnh dump mới mở tiếp đường Bulk phía sau.
Source map
Cấu trúc thư mục dưới đây là toàn bộ project STM32CubeIDE thật đi kèm trong series. Thư mục Usb/ sẽ chứa nội dung chính của bài này.
stm32g0-usb-device-lab/
├─ Core/
├─ Drivers/
├─ App/
├─ Hardware/
├─ Keyboard/
├─ Usb/
│ ├─ usb_vendor_cmd.c / .h // bảng lệnh EP0, pending log flag
│ ├─ usb_vendor_bulk.c / .h // trạng thái dump, chia chunk qua EP4
│ └─ usbd_composite.c // Interface 3, EP4, điều phối lệnh vendor
├─ USB_Device/
└─ docs/
└─ tools/ usb_vendor_cmd.c không đụng vào usb_vendor_bulk.c, ngoài đúng một lời gọi hàm bắt
đầu dump - không biết địa chỉ SRAM hay cách chia chunk. Ngược lại usb_vendor_bulk.c
không biết bảng lệnh hay cách EP0 đọc request. Ranh giới này giữ hai module độc lập,
đổi một bên không cần hiểu bên kia.
Các hàm chính trong bài
Trước khi đi vào chi tiết, đây là danh sách các hàm sẽ xuất hiện xuyên suốt bài, để tiện tra cứu lại khi cần:
| Hàm | File | Vai trò |
|---|---|---|
VendorCmd_HandleSetup() | usb_vendor_cmd.c | Xử lý control request, set pending log flag, trả phản hồi ngay trong lúc host vẫn chờ |
VendorCmd_FlushPendingLog() | usb_vendor_cmd.c | Gọi mỗi vòng main loop, log thật dựa trên pending flag đã set |
VendorCmd_UpdateLed() | usb_vendor_cmd.c | Gọi mỗi vòng main loop, điều khiển đèn theo mode đã set qua HAL_GetTick() |
VendorDump_Start() | usb_vendor_bulk.c | Đánh dấu bắt đầu dump, chưa gửi chunk nào ngay lúc này |
VendorDump_Run() | usb_vendor_bulk.c | Gọi mỗi vòng main loop, gửi chunk đầu tiên và log khi dump hoàn tất |
VendorDump_OnTxCplt() | usb_vendor_bulk.c | Callback ngắt khi 1 chunk gửi xong, tự gửi tiếp chunk kế tiếp |
VendorDump_SendNextChunk() | usb_vendor_bulk.c | Dùng chung cho cả Run() và OnTxCplt(), tính và gửi đúng 1 chunk |
VendorDump_Run() và VendorDump_OnTxCplt() không viết trùng logic tính chunk - cả
hai đều gọi vào VendorDump_SendNextChunk(), chỉ khác nơi gọi (main loop và ngắt).
Descriptor cho EP0 Vendor Command
Tất cả vendor command dùng chung Control IN, theo đúng bộ ba đã trình bày ở bài 3:
bmRequestType = 0xC0 (Device→Host | Vendor | Device)
1. Interface Vendor Specific
Khai báo class theo đúng bộ ba, không kèm theo descriptor mô tả thêm nào như CDC ở bài 6 - Vendor Specific không có class chuẩn nào quy định sẵn cấu trúc riêng:
bInterfaceClass = 0xFF // Vendor SpecificbInterfaceSubClass = 0x00bInterfaceProtocol = 0x002. Endpoint Bulk IN
bmAttributes: 0x02 // BulkwMaxPacketSize: 0x0040 (64 byte)bInterval: 0x00bInterval: 0x00 đúng theo quy tắc đã nói ở
bài 2: field này vô nghĩa với Bulk
endpoint, phải để 0.
Thêm interface và endpoint này làm bNumInterfaces tăng từ 3 lên 4, wTotalLength
tăng theo đúng kích thước hai descriptor mới - cùng nguyên tắc tính wTotalLength đã
nói ở bài 3 và áp dụng lại ở bài 6 cho CDC.
USBView xác nhận sau khi thêm: bNumInterfaces = 0x04, interface mới hiện đúng dòng
“Interface Class Unknown to USBView” - đúng, không phải lỗi, vì vendor-specific không
có class driver chuẩn nào mà USBView biết trước để đối chiếu.
3. Bốn lệnh vendor
VENDOR_REQ_GET_FIRMWARE_INFO (0x01) → FirmwareInfo_t, 17 byte
VENDOR_REQ_SET_REPEAT_ENABLE (0x02) → VendorResponse_t
VENDOR_REQ_SET_LED_MODE (0x03) → VendorResponse_t
VENDOR_REQ_START_RAM_DUMP (0x04) → VendorDumpResponse_t
FirmwareInfo_t có magic "SG0L" ở 4 byte đầu - công cụ test kiểm tra magic trước
khi tin các field còn lại, tránh đọc nhầm nếu vô tình nối sai thiết bị.
Vùng nhớ chứa phản hồi (sFwInfo, sVendorRsp, sDumpRsp) khai static, không phải
biến cục bộ trên stack - vì phải còn sống đến khi USBD_CtlSendData hoàn tất truyền,
có thể lâu hơn thời gian hàm xử lý request chạy xong và return.
SET_LED_MODE nhận 0-3 (tắt/bật/nháy chậm 500ms/nháy nhanh 125ms), điều khiển từ
VendorCmd_UpdateLed() gọi mỗi vòng main loop dựa trên HAL_GetTick() - không dùng
ngắt timer riêng, vẫn đủ chính xác cho mắt người nhìn thấy nháy đèn.
Hai bước triển khai riêng biệt
Bước đầu chỉ thêm khung xử lý lệnh EP0 với 4 lệnh. START_RAM_DUMP ở bước này chỉ là
một hàm giả lập luôn trả lỗi, vì interface Vendor và endpoint Bulk chưa tồn tại. Build
và test độc lập được mà không cần chờ bước sau.
Bước sau mới thêm interface và endpoint Bulk vào descriptor, viết cơ chế dump thật,
rồi nối START_RAM_DUMP vào cơ chế đó thay vì trả lỗi cứng như trước.
Tách như vậy giúp kiểm chứng từng phần độc lập trên git - quay lại đúng commit của bước đầu và build vẫn chạy được, không cần bất kỳ phần nào của bước sau.
Log lệnh từ host mà không rời main loop
VendorCmd_HandleSetup() chạy trong lúc USB đang xử lý request - gọi từ hàm điều phối
Setup của composite class ngay khi host gửi vendor command. Bài 6 đã xác lập ràng
buộc: hàm log dùng vsnprintf không được gọi lúc đang xử lý request như vậy.
Giải pháp giữ nguyên: handler chỉ set cờ, VendorCmd_FlushPendingLog() ở main loop
đọc và log thật - đúng nguyên tắc pending flag đã dùng cho việc phát hiện kết nối CDC
ở bài 6:
/* Trong VendorCmd_HandleSetup() - lúc USB đang xử lý request */sPendingLogVal = (uint32_t)sRepeatEnable; /* mang theo tham số cần log */sPendingLog = sRepeatEnable ? VLOG_SET_REPEAT_ON : VLOG_SET_REPEAT_OFF;/* Trong VendorCmd_FlushPendingLog() - main loop, gọi từ HID_Keyboard_App */VendorLogEvent_t evt = sPendingLog;if (evt == VLOG_NONE) return;sPendingLog = VLOG_NONE; /* clear trước khi log - log có thể mất thời gian */Có 2 biến, không phải 1: sPendingLog mang loại sự kiện, sPendingLogVal mang theo
giá trị cần in ra (mode đèn, trạng thái repeat, số byte đã chấp nhận dump) - tách riêng
vì bản thân loại sự kiện không đủ chỗ chứa một số nguyên tuỳ ý.
Cài driver cho Windows
Interface Vendor Specific không có driver có sẵn trên Windows. Trước khi công cụ
Python (vendor_test.py) gửi được lệnh hay đọc dữ liệu dump, phải cài libusbK qua
Zadig - chọn đúng interface Vendor, không phải Interface 0 (HID) hay Interface 1/2
(CDC), vì cài nhầm driver cho HID sẽ làm bàn phím ngừng hoạt động ngay lập tức.
Linux và macOS: thư viện USB phía Python truy cập trực tiếp, không cần cài thêm gì.
Vì sao dump đi qua Bulk thay vì trả luôn trong Control
Sao không trả nguyên khối RAM dump ngay trong phản hồi của Control transfer, thay vì
mở thêm một đường Bulk riêng? Control transfer dùng chung EP0 với toàn bộ enumeration
và cả 3 lệnh còn lại, không có vùng đệm dự phòng. Đẩy 144 KB qua đó sẽ vừa chậm vừa
tranh giành trực tiếp với mọi request khác đi qua cùng EP0. Endpoint Bulk riêng được
host cấp băng thông độc lập, không đụng đến EP0 - đó là lý do START_RAM_DUMP qua
Control chỉ trả về một phản hồi nhỏ báo số byte sẽ nhận được, còn dữ liệu thật đi qua
Bulk sau đó.
VendorDump_Start() chỉ đánh dấu trạng thái, chưa gửi chunk nào ngay:
bool VendorDump_Start(uint32_t *acceptedLength){ if (sDumpActive) return false; /* đang bận */ sDumpAddr = RAM_DUMP_BASE; /* 0x20000000, SRAM1 */ sDumpTotal = RAM_DUMP_MAX_SIZE; /* 144 KB */ sDumpOffset = 0U; sDumpActive = true; *acceptedLength = RAM_DUMP_MAX_SIZE; return true;}Chunk đầu tiên gửi từ VendorDump_Run() (main loop), các chunk tiếp theo gửi từ
VendorDump_OnTxCplt() (callback khi một lần truyền hoàn tất, chạy trong ngắt) ngay
khi chunk trước xong - giữ luồng gửi liên tục, không phải chờ main loop quay lại mới
gửi tiếp. Cả hai nơi gọi chung VendorDump_SendNextChunk() để tính chunk kế tiếp,
không viết trùng logic ở hai nơi:
/* Dùng chung cho cả callback ngắt lẫn main loop */static void VendorDump_SendNextChunk(USBD_HandleTypeDef *pdev){ uint32_t remaining = sDumpTotal - sDumpOffset; uint16_t chunkLen = (remaining > 64U) ? 64U : (uint16_t)remaining; /* ... gửi chunk, cập nhật sDumpOffset, set sDumpDone nếu đã xong ... */}sDumpDone khai volatile vì phía ngắt set, main loop đọc ở lần gọi kế tiếp để log
lúc hoàn tất - đây là điểm giao tiếp duy nhất giữa hai phía cho cơ chế này.
sDumpActive/sDumpOffset không cần khai volatile: main loop chỉ đọc chúng để quyết
định có gửi chunk đầu tiên hay không (sDumpOffset == 0), còn việc tiến từng bước qua
các chunk tiếp theo nằm gọn trong ngắt, không có vòng lặp main loop nào đứng chờ hai
giá trị đó thay đổi.
Vì sao không cần gói rỗng báo kết thúc
147456 byte chia hết cho 64 (bằng đúng 2304 lần) - đúng bội số của kích thước gói lớn
nhất mỗi lần truyền. Theo lý thuyết, trường hợp này cần thêm một gói rỗng để host biết
việc truyền đã kết thúc. Nhưng công cụ test đã biết chính xác số byte sẽ nhận
(147456) ngay từ phản hồi của START_RAM_DUMP, nên chỉ cần đọc đúng số byte đó rồi tự
dừng lại, không cần dựa vào gói rỗng - tránh phụ thuộc vào cách mỗi hệ điều hành xử lý
gói rỗng khác nhau.
Kết quả thật từ công cụ test Python
Tôi viết vendor_test.py, một script Python ngắn dùng thư viện pyusb, để gửi lần
lượt cả 4 lệnh và xác nhận firmware phản hồi đúng. Đây là output thật từ một lần chạy
(lab-13):
Found: 'STM32 USB HID 4x4 Macro Keypad' (0x0483:0x572b)
[1] GET_FIRMWARE_INFO
[OK] firmware v0.1
features : HID | CDC_LOG | VENDOR_BULK | REPEAT_CONTROL
[5] START_RAM_DUMP -> ram_dump.bin
147456/147456 bytes (100%)
0.34 s -> 418.7 KB/s
418.7 KB/s trên USB Full Speed (lý thuyết tối đa khoảng 1.2 MB/s). Điểm nghẽn nằm ở chi phí xử lý phía thư viện Python trên máy tính, không phải ở firmware. Toàn bộ 5 lệnh test và output đầy đủ nằm ở trang project Host Debug Tool.
Tóm tắt
Đường giao tiếp cuối cùng của composite device hoàn thiện khi ba điều được xử lý đúng:
- Đường lệnh và đường dump không phải cùng một luồng kéo dài - một cái là vòng hỏi-đáp ngắn kết thúc ngay, cái kia là dữ liệu tự chảy sau khi đã xin phép.
- Log lúc đang xử lý request từ host vẫn theo đúng cách làm đã dùng ở bài 6, không phải cơ chế riêng cho vendor command.
- Bulk dump không cần gói rỗng vì thiết kế đã cho host biết trước kích thước, và không viết trùng logic tính chunk giữa ngắt và main loop.
Bài tiếp theo, cũng là bài cuối, sẽ ghép mọi đường giao tiếp đã xây dựng lại thành một công cụ có giao diện: xem log CDC, gửi lệnh vendor, và tải RAM dump, tất cả trong một cửa sổ duy nhất.
Tài liệu tham khảo
- pyusb documentation - thư viện Python truy cập USB
- Zadig - công cụ cài driver WinUSB/libusbK cho Windows
- USB 2.0 Specification - Section 5.8: Bulk Transfers, hành vi gói rỗng
Bài viết này hữu ích với bạn?
Chia sẻ, góp ý, hoặc ủng hộ nếu bạn thấy nội dung này có giá trị.