Giao thức truyền thông trên STM32 (Phần 9): USB CDC – tạo cổng COM ảo và các lỗi kinh điển
USB CDC trên STM32, từ endpoint, tốc độ Full-speed, cấu hình CubeIDE và clock 48 MHz đến mã nhận gửi dùng ring buffer, kèm bảng thông lượng tính toán và các lỗi hay gặp. Phần 9 của series giao thức truyền thông trên STM32.

Ghi chú: sơ đồ trong bài do Capitlab vẽ để minh hoạ. Số liệu trong các bảng là giá trị tính theo công thức, cột "Đo thực tế" sẽ được cập nhật khi đo trên phần cứng. Mã nguồn soạn theo tài liệu HAL và datasheet, chưa chạy thử trên phần cứng, nên hãy kiểm tra lại trước khi dùng cho sản phẩm.
USB CDC (Communications Device Class) cho STM32 hiện lên máy tính như một cổng COM ảo, không cần chip chuyển USB-UART. Phía máy tính mở cổng bằng PuTTY, MobaXterm hay pyserial như mọi cổng nối tiếp khác. Dùng để in log, gỡ lỗi, hay làm cổng cấu hình cho sản phẩm đều tiện.
Người mới làm USB CDC thường mất thời gian ở ba chỗ: clock USB không đúng 48 MHz, hàm nhận chạy quá lâu trong ngắt, và CDC_Transmit_FS trả về USBD_BUSY mà không ai kiểm tra. Bài này đi qua cả ba.
1. Nguyên lý
USB có một host (máy tính) và các device (ở đây là STM32). Host luôn chủ động: nó hỏi, device trả lời. Dữ liệu đi qua các endpoint.
Hình 1. Một thiết bị CDC ACM dùng bốn endpoint: EP0 điều khiển, cặp bulk IN và OUT cho dữ liệu, và một interrupt IN cho thông báo.
Endpoint 0 dùng để điều khiển: host liệt kê thiết bị và gửi các lệnh của class, chẳng hạn đặt tốc độ baud, qua endpoint này. Dữ liệu thật đi qua cặp endpoint bulk IN và OUT. Thiết bị CDC còn có một endpoint interrupt IN để báo trạng thái, thực tế hầu như không dùng.
Khi cắm vào, host đọc các descriptor (VID/PID, class, endpoint) rồi chọn driver. Windows 10 trở lên có sẵn driver CDC, bản cũ hơn cần file driver. Thiết bị hiện ra là một cổng COM trên Windows, hoặc /dev/ttyACMx trên Linux.
1.1. Tốc độ
STM32 thường chạy Full-speed 12 Mbit/s qua chân USB có sẵn trên chip. Gói bulk ở Full-speed tối đa 64 byte, và theo đặc tả một khung 1 ms chứa được khoảng 19 giao dịch bulk 64 byte. Trần lý thuyết vì vậy khoảng 19 × 64 = 1216 byte/ms, tức chừng 1,2 MB/s. Thực tế thấp hơn, tuỳ host, hệ điều hành và cách firmware xử lý. High-speed 480 Mbit/s cần PHY ngoài (ULPI) trên nhiều dòng như F4, F7, H7, dù một số dòng H7 có PHY High-speed tích hợp.
| Chế độ | Tốc độ bit | Gói bulk tối đa | Thông lượng lý thuyết |
|---|---|---|---|
| Full-speed | 12 Mbit/s | 64 byte | khoảng 1,2 MB/s (19 gói mỗi khung 1 ms) |
| High-speed | 480 Mbit/s | 512 byte | khoảng 53 MB/s trên lý thuyết |
Dữ liệu đi qua USB chứ không qua dây UART, nên tốc độ baud chọn trên máy tính không ảnh hưởng tới tốc độ thật. Host chỉ gửi lệnh SET_LINE_CODING xuống. Firmware có thể bỏ qua lệnh này, hoặc dùng nó để cấu hình một UART thật khi làm cầu nối USB-UART (mục 4). Mở cổng ở 9600 hay 115200 thì tốc độ vẫn như nhau.
2. Cấu hình trong STM32CubeIDE
| Bước | Thiết lập | Ghi chú |
|---|---|---|
| 1 | Bật USB_OTG_FS (hoặc USB tùy dòng), chế độ Device_Only | |
| 2 | Middleware: bật USB_DEVICE, Class For FS IP chọn Communication Device Class (Virtual Port Com) | |
| 3 | Clock USB đúng 48 MHz | Dùng HSE + PLL, hoặc HSI48 có CRS (các dòng có) |
| 4 | Điền VID/PID và chuỗi mô tả | VID/PID mặc định của ST chỉ để thử nghiệm, sản phẩm thương mại cần VID/PID hợp lệ |
| 5 | Kích thước bộ đệm nhận và gửi | Mặc định 2048 byte, chỉnh theo nhu cầu |
| 6 | Generate Code | Cube sinh usbd_cdc_if.c chứa các hàm nhận gửi |
Máy tính báo "Unknown USB Device" thì việc đầu tiên là xem lại clock. Trong tab Clock Configuration, ô "48MHz Clocks" phải đúng 48 MHz. Với thạch anh 8 MHz, PLL phải được chỉnh sao cho cả SYSCLK mong muốn lẫn nhánh 48 MHz đều ra số tròn.
3. Mã nguồn
3.1. Gửi dữ liệu lên máy tính
#include "usbd_cdc_if.h"
#include <string.h>
// Trả USBD_BUSY nếu lần gửi trước chưa xong
uint8_t usb_print(const char *s)
{
return CDC_Transmit_FS((uint8_t *)s, strlen(s));
}
// Gửi có thử lại, tránh mất dữ liệu khi bận
int usb_send_retry(const uint8_t *p, uint16_t n, uint32_t timeout_ms)
{
uint32_t t0 = HAL_GetTick();
while (CDC_Transmit_FS((uint8_t *)p, n) == USBD_BUSY) {
if (HAL_GetTick() - t0 > timeout_ms) return -1;
}
return 0;
}
CDC_Transmit_FS chỉ nhận bộ đệm mới khi gói trước đã gửi xong, nếu không nó trả USBD_BUSY. Khi host chưa mở cổng hoặc không đọc, usb_send_retry sẽ hết thời gian chờ, vì vậy luôn đặt timeout hữu hạn.
3.2. Nhận dữ liệu bằng ring buffer
Hình 2. Ngắt USB chỉ chép dữ liệu vào ring buffer, vòng lặp chính xử lý và gửi phản hồi.
Sửa hàm Cube sinh ra trong usbd_cdc_if.c:
#define RB_SIZE 1024 // lũy thừa của 2
static uint8_t rb[RB_SIZE];
static volatile uint16_t rb_head, rb_tail;
static void ring_write(const uint8_t *p, uint32_t n)
{
while (n--) {
uint16_t next = (rb_head + 1) & (RB_SIZE - 1);
if (next == rb_tail) break; // đầy: bỏ phần còn lại
rb[rb_head] = *p++;
rb_head = next;
}
}
int ring_read(uint8_t *out)
{
if (rb_tail == rb_head) return 0;
*out = rb[rb_tail];
rb_tail = (rb_tail + 1) & (RB_SIZE - 1);
return 1;
}
static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len)
{
// Giữ hàm này NGẮN (chạy trong ngữ cảnh ngắt USB): chỉ chép vào ring buffer
ring_write(Buf, *Len);
USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
USBD_CDC_ReceivePacket(&hUsbDeviceFS); // bắt buộc để nhận tiếp
return USBD_OK;
}
Quên gọi USBD_CDC_ReceivePacket ở cuối hàm thì thiết bị chỉ nhận đúng một gói rồi thôi. Còn nếu xử lý nặng ngay trong hàm này, như in log, gửi khối lớn hay chờ đợi, ngắt USB sẽ bị trễ và host có thể báo lỗi.
3.3. Ví dụ: echo trong vòng lặp chính
while (1) {
uint8_t c;
while (ring_read(&c)) {
// xử lý lệnh, ở đây chỉ echo lại
usb_send_retry(&c, 1, 10);
}
}
Echo từng byte dễ viết nhưng kém hiệu quả, vì mỗi byte là một giao dịch USB. Với dữ liệu lớn, gom thành khối rồi gửi một lần, và kiểm tra giá trị trả về.
3.4. Biết khi nào máy tính mở cổng
Muốn chỉ gửi log khi máy tính đã mở cổng (tín hiệu DTR bật), xử lý lệnh CDC_SET_CONTROL_LINE_STATE trong CDC_Control_FS:
static volatile uint8_t host_open;
// trong CDC_Control_FS(...):
case CDC_SET_CONTROL_LINE_STATE: {
USBD_SetupReqTypedef *req = (USBD_SetupReqTypedef *)pbuf;
host_open = (req->wValue & 0x0001) ? 1 : 0; // bit 0: DTR
break;
}
Sau đó chỉ gọi CDC_Transmit_FS khi host_open bằng 1.
4. Cầu nối USB-UART
Một ứng dụng hay gặp: máy tính gửi lệnh qua cổng COM ảo, STM32 chuyển tiếp ra một UART thật tới thiết bị bên ngoài, và chiều ngược lại cũng vậy.
Chiều từ máy tính, dữ liệu vào CDC_Receive_FS, nằm trong ring buffer, rồi vòng lặp chính gửi sang UART bằng DMA. Chiều về, UART nhận bằng DMA cộng IDLE như ở phần 1, rồi gọi CDC_Transmit_FS. Muốn tốc độ UART thật khớp với tốc độ máy tính chọn, xử lý CDC_SET_LINE_CODING để cấu hình lại UART.
5. Lỗi thường gặp
| Triệu chứng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Máy tính không nhận ("Unknown USB Device") | Clock USB không phải 48 MHz, D+/D− nối sai, thiếu điện trở kéo lên D+ ở board tự thiết kế, cáp chỉ có dây sạc | Kiểm tra Clock Configuration, đổi cáp có dây dữ liệu, kiểm tra điện trở 1,5 kΩ kéo D+ |
| Gửi mất dữ liệu | CDC_Transmit_FS trả USBD_BUSY do lần gửi trước chưa xong, không kiểm tra giá trị trả về |
Kiểm tra trả về và thử lại, hoặc dùng hàng đợi gửi |
| Chỉ nhận được một gói rồi thôi | Quên USBD_CDC_ReceivePacket trong CDC_Receive_FS |
Thêm lại hai dòng cuối hàm nhận |
| Treo khi gửi lúc chưa kết nối host | Chờ vô hạn khi host chưa mở cổng | Đặt timeout, kiểm tra host_open |
| Mất kết nối khi vào ngủ hoặc reset | Thiết bị USB biến mất rồi liệt kê lại | Xử lý mở lại cổng ở phía máy tính |
| Số COM thay đổi giữa các máy | Hệ điều hành gán số cổng | Dùng VID/PID và chuỗi serial để nhận diện thiết bị thay vì số COM |
| Thông lượng thấp hơn mong đợi | Gửi từng byte, bộ đệm nhỏ, vòng lặp chính chậm | Gom khối lớn, dùng bộ đệm gửi lớn, xử lý trong vòng lặp nhanh |
| Hỏng khi chạy cùng ngắt khác | Ngắt khác có ưu tiên cao chặn ngắt USB quá lâu | Đặt ưu tiên ngắt hợp lý |
6. Khi đưa vào sản phẩm
Clock USB trong sản phẩm nên lấy từ HSE qua PLL. Trên các dòng có HSI48 với CRS (tự đồng bộ theo xung SOF của USB), có thể bỏ thạch anh.
Đường D+ và D− cần bảo vệ: diode TVS và điện trở nối tiếp nhỏ.
Sản phẩm bán ra phải có VID/PID hợp lệ, đăng ký với USB-IF hoặc xin PID từ nhà cung cấp, không dùng VID của ST.
Nếu cần thêm chức năng, có thể ghép CDC với class khác như HID hay MSC trong một thiết bị composite.
7. Hỏi đáp
Mở cổng ở tốc độ baud khác nhau thì có sao không? Không sao, tốc độ truyền thật không đổi. Đó chỉ là tham số máy tính gửi xuống, firmware muốn dùng thì dùng.
USB có cần thạch anh ngoài không? Full-speed cần clock 48 MHz với sai số rất nhỏ, khoảng ±0,25%. Dòng nào có HSI48 kèm CRS thì không cần thạch anh, các dòng khác cần HSE.
MCU ngủ thì USB CDC còn chạy không? Thiết bị phải chạy liên tục để đáp ứng host. Ngủ sâu sẽ làm mất kết nối, chỉ các chế độ suspend theo đặc tả USB là dùng được.
Chọn CDC, HID hay MSC? CDC cho luồng dữ liệu nối tiếp tuỳ ý. HID cho thiết bị kiểu bàn phím, chuột, tay cầm, không cần driver. MSC cho thiết bị lưu trữ.
8. Kế hoạch đo thực tế
Cột "Đo thực tế" để trống cho đến khi chạy trên phần cứng.
| Thử nghiệm | Cách đo | Giá trị tính toán | Đo thực tế |
|---|---|---|---|
| Thời gian từ cắm cáp đến khi cổng COM xuất hiện | Đo trên máy tính | Không có số tính | (điền sau) |
| Thông lượng gửi từ STM32 lên máy tính | Gửi liên tục 10 MB, đo thời gian bằng script Python | Trần lý thuyết khoảng 1,2 MB/s | (điền sau) |
| Thông lượng gửi từ máy tính xuống STM32 | Như trên, chiều ngược lại | Trần lý thuyết khoảng 1,2 MB/s | (điền sau) |
| Độ trễ vòng echo (round-trip) | Script Python gửi 1 byte, đo thời gian nhận lại | Phụ thuộc host, thường vài ms | (điền sau) |
| Mất dữ liệu khi tải lớn | Gửi dữ liệu có số thứ tự, kiểm tra | Không mất nếu xử lý đúng BUSY | (điền sau) |
| Ảnh hưởng của gửi từng byte so với khối | So thông lượng với gói 1, 16, 64, 512 byte | Khối lớn nhanh hơn rõ rệt | (điền sau) |
| Bắt gói USB | Wireshark với USBPcap, xem các gói bulk | Gói 64 byte ở Full-speed | (điền sau) |
Kết luận
Thiết bị không lên thì kiểm tra clock 48 MHz trước. Giữ hàm nhận thật ngắn, đẩy dữ liệu vào ring buffer. Khi gửi, luôn kiểm tra USBD_BUSY.
Phần 10, cũng là phần cuối, chuyển sang Ethernet và TCP/IP: đưa STM32 lên mạng với LwIP, DHCP và một TCP echo server.
Tài liệu tham khảo
- Tài liệu ST về USB Device library (UM1734 cho STM32Cube USB device library)
- USB Device Class Definition for Communications Devices (usb.org)