Firmware Log với USB CDC Debug Channel
Triển khai CDC debug channel trên STM32G0: descriptor CDC, ring buffer, ISR-safe logging và tránh xung đột DTR khi host mở COM port.
Bài 6/8 trong series USB Device trên STM32.
Trước khi đọc bài này
Bài 5 đã hoàn thiện một thiết bị USB với HID keyboard hoạt động đầy đủ từ đầu đến cuối. Trong bài này, tôi sẽ bổ sung thêm một kênh CDC để xuất log, giúp quan sát firmware chạy trực tiếp qua terminal mà không cần dùng JTAG debugger.
Bài này được xây dựng trực tiếp trên kết quả của bài 5, 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
- Nếu thiếu IAD, Windows sẽ không tạo được cổng COM dù cả hai interface CDC vẫn enumerate đúng.
- CDC dùng chung ý tưởng với UART (hàm gửi, callback, buffer) nhưng truyền qua USB stack thay vì bộ ngoại vi UART vật lý.
- Hàm format và ghi log không an toàn trong ISR nên chỉ gọi được từ main loop.
- Windows báo “CDC đã kết nối” tới 2 lần với 2 ý nghĩa khác nhau và cách phân biệt chính xác.
Thiết kế tổng quan
Log firmware sinh ra ở bất kỳ đâu trong main loop, đi qua một ring buffer trung gian, rồi mới thật sự gửi lên host khi endpoint rảnh:
Sự kiện cần log
Key event, vendor request... gọi CdcLog_Printf() từ main loop.
Ring buffer 512 byte
Ghi từng byte, chỉ số quay vòng bằng mask.
CdcLog_Run()
Kiểm tra kết nối, endpoint idle, gửi tối đa 64 byte mỗi lần.
Bulk IN
Packet gửi lên host qua CDC Data interface.
Terminal
Tera Term hoặc terminal COM port bất kỳ hiển thị log.
Ring buffer giúp tách thời điểm log được tạo ra khỏi thời điểm log thật sự được gửi qua USB. Hai thời điểm này gần như không bao giờ trùng nhau.
Firmware có thể log liên tục ngay cả khi chưa kết nối với host. Còn USB chỉ được gửi khi đã kết nối với host thành công và endpont đang rảnh.
Chính vì vậy, ring buffer là một phần quan trọng để đảm bảo log không bị mất trong quá trình boot hoặc khi host chưa sẵn sàng.
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 App/ và Usb/ sẽ chứa nội dung chính của bài này. Thư mục Keyboard/ chỉ gọi hàm ghi log để kiểm tra kết quả.
stm32g0-usb-device-lab/
├─ Core/
├─ Drivers/
├─ App/
│ └─ app_main.c // Khởi tạo ring buffer, gửi log khi có data
├─ Hardware/
├─ Keyboard/
│ └─ hid_keyboard_convert.c // ghi log vào ring buffer khi có sự kiện phím
├─ Usb/
│ ├─ usb_cdc_log.c / .h // khai báo ring buffer và các hàm xử lý log liên quan
│ └─ usbd_composite.c // khai báo và xử lý CDC class ở tầng USB
├─ USB_Device/
└─ docs/
└─ tools/ Việc triển khai kênh log được thực hiện theo hai bước chính:
usbd_composite.c: khai báo descriptor và thêm các logic xử lý dành riêng cho CDC class.usb_cdc_log.c: tạo ring buffer trung gian và các hàm xử lý ghi log, gửi log từ ring buffer lên host.
Việc khởi tạo ban đầu cho ring buffer cũng như kiểm tra và gửi log lên host
được triển khai trong app_main.c.
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ò |
|---|---|---|
CdcLog_Init() | usb_cdc_log.c | Khởi tạo ring buffer, đưa sWriteIdx/sReadIdx về 0 |
CdcLog_Printf() | usb_cdc_log.c | Format chuỗi kiểu printf, gọi CdcLog_Write() để ghi vào ring buffer |
CdcLog_Write() | usb_cdc_log.c | Ghi thẳng một buffer + độ dài vào ring buffer, tự cắt bớt nếu không đủ chỗ trống |
CdcLog_Run() | usb_cdc_log.c | Gọi mỗi vòng main loop, pop tối đa 64 byte và gửi lên host khi đủ điều kiện |
CdcLog_UpdateConnection() | usb_cdc_log.c | Theo dõi thời điểm host thật sự kết nối, trì hoãn 100ms trước khi gửi greeting log |
Composite_Setup() | usbd_composite.c | Xử lý control request SET_LINE_CODING/GET_LINE_CODING |
Composite_DataOut() | usbd_composite.c | Callback khi endpoint OUT nhận xong 1 packet, mở lại endpoint cho packet tiếp theo |
CdcLog_Printf và CdcLog_Write là hai hàm khác nhau. Printf lo phần format chuỗi,
Write lo phần ghi vào ring buffer. Tách riêng để có thể gọi Write trực tiếp với dữ
liệu đã có sẵn (binary, không cần format) mà không cần phải dùng vsnprintf.
CDC class cần Descriptor gì?
Như đã trình bày ở bài 3, để host biết thiết bị USB có chức năng Virtual COM Port thì cần phải khai báo IAD để nhóm hai Interface chuyên dụng của CDC là Comm và Data lại với nhau.
Mục tiêu là thêm interface CDC vào composite device mà không phá vỡ HID keyboard đã có
nên chỉ cần thêm một IAD mới, một Comm interface và một Data interface vào descriptor
gốc nơi đã khai báo HID keyboard descriptor. Các khai báo này cần đúng bộ field riêng
của CDC. Một điều cần lưu ý là đây là phần khai báo tĩnh, khác với SET_LINE_CODING ở
phần sau vốn là runtime request.
1. IAD (CDC ACM)
Khai báo cho host biết Interface 1 và 2 tuy đứng tách rời trong descriptor nhưng thuộc về cùng một chức năng CDC, không phải hai interface độc lập:
0x08, /* bLength = 8 */0x0B, /* bDescriptorType = IAD */0x01, /* bFirstInterface = 1 (bắt đầu từ Interface 1) */0x02, /* bInterfaceCount = 2 (gồm Interface 1 và 2) */0x02, /* bFunctionClass = CDC */0x02, /* bFunctionSubClass = ACM */0x01, /* bFunctionProtocol = AT Commands */0x00, /* iFunction */Thiếu khối này, Interface 1 và 2 vẫn enumerate bình thường và vẫn đúng field riêng lẻ nhưng Windows sẽ thấy hai interface CDC rời rạc thay vì một chức năng COM port duy nhất. Điều này dẫn đến việc không cổng COM nào được tạo ra cả.
2. Interface 1 (CDC Communication)
Khai báo class theo đúng bộ ba đã trình bày ở bài 3:
bInterfaceClass = 0x02 // CDCbInterfaceSubClass = 0x02 // ACM (Abstract Control Model)bInterfaceProtocol = 0x01 // AT commandsNgay sau Interface Descriptor này là 4 Functional Descriptor, loại descriptor
đặc thù của CDC mà HID không có. Cả 4 đều dùng chung bDescriptorType = 0x24
(CS_INTERFACE), chỉ khác nhau ở bDescriptorSubtype:
/* Header Functional Descriptor - khai đây là CDC 1.10 */0x05, 0x24, 0x00, 0x10, 0x01,/* Call Management Functional Descriptor - CDC Data nào phục vụ Comm này */0x05, 0x24, 0x01, 0x00, COMP_IF_CDC_DATA,/* ACM Functional Descriptor - khai báo có hỗ trợ SET/GET_LINE_CODING */0x04, 0x24, 0x02, 0x02,/* Union Functional Descriptor - Comm nào điều khiển Data nào */0x05, 0x24, 0x06, COMP_IF_CDC_COMM, COMP_IF_CDC_DATA,Ghi chú thêm:
- Header chỉ nói “đây là CDC bản 1.10” (
bcdCDC = 0x0110), giốngbcdHIDở HID Descriptor, không mang thông tin chức năng. - Call Management khai báo
bDataInterfacetrỏ sang Interface 2 (CDC Data), nói cho host biết cặp Comm/Data nào đi cùng nhau.bmCapabilities = 0x00nghĩa là device không tự quản lý call (không phải modem quay số). - ACM là field quan trọng nhất để hiểu phần “Baudrate” ở dưới:
bmCapabilities = 0x02chính là lời khai báo tĩnh “tôi hỗ trợSET_LINE_CODING/GET_LINE_CODING/SET_CONTROL_LINE_STATE”. Runtime có xử lý các request đó hay không là chuyện khác, nhưng descriptor phải khai báo sẵn để host biết và gửi request. - Union khai báo rõ
bControlInterface(Comm) vàbSubordinateInterface0(Data), nó có cùng ý nghĩa với IAD nhưng ở nằm ở tầng khác: IAD nhóm interface để Windows chọn đúng driver tổng thể còn Union nói cho driver CDC ACM biết cụ thể interface nào điều khiển interface nào.
3. Interface 2 (CDC Data)
Đơn giản hơn nhiều, không có Functional Descriptor nào cả, chỉ thuần truyền dữ liệu:
bInterfaceClass = 0x0A // CDC DatabInterfaceSubClass = 0x00bInterfaceProtocol = 0x00Ba endpoint cho cặp interface này: EP2 IN Interrupt 8 byte (notification, khai sẵn theo chuẩn nhưng project này không dùng để gửi notification chủ động), EP3 OUT/IN Bulk 64 byte (dữ liệu log thật đi qua đây, cùng cỡ Bulk endpoint đã nói ở bài 3).
Tổng kích thước khối CDC vừa thêm: IAD (8) + Interface 1 Descriptor (9) +
4 Functional Descriptor (5+5+4+5=19) + EP2 Endpoint Descriptor (7) + Interface 2
Descriptor (9) + 2 Endpoint Descriptor của EP3 OUT/IN (7+7=14) = 66 byte. Đây
chính xác là con số cần cộng thêm vào wTotalLength của Configuration Descriptor
so với lúc chỉ có HID đơn - thiếu hoặc thừa dù chỉ 1 byte trong 66 byte này cũng
đủ làm host đọc lệch toàn bộ phần descriptor phía sau, đúng nguyên tắc đã nói ở
bài 3.
CDC giống và khác UART ở đâu?
Từ góc độ người dùng thì trải nghiệm của CDC giống ý hệt UART qua USB-UART converter: cắm cáp, mở Tera Term, chọn COM port, thấy log.
Từ góc độ firmware thì cũng tương tự: CDC cũng có hàm gửi, có callback khi host nhận, có buffer trung gian. Điểm khác biệt là CDC đi qua USB stack thay vì được truyền qua bộ ngoại vi UART vật lý. Vì vậy nên nó không có baud rate thật sự, không cần USB-UART converter riêng và không có mức điện áp CMOS/TTL.
Baudrate trong Tera Term
Đây là điểm dễ hiểu nhầm nhất. Tera Term vẫn bắt chọn baudrate, cài đặt
stop bit hay parity,.., như mở COM port thật. Và những thông tin này thật sự
được gửi xuống firmware qua class request SET_LINE_CODING.
Theo tài liệu CDC thì gói dữ liệu này có 7 byte, gồm 4 field như sau:
SET_LINE_CODING (host → device):
dwDTERate (4 byte) - baudrate, ví dụ 115200
bCharFormat (1 byte) - stop bits
bParityType (1 byte) - parity
bDataBits (1 byte) - data bits
Middleware CDC riêng của ST không được dùng trong composite class này. Toàn bộ
xử lý SET_LINE_CODING được thêm vào trong hàm Composite_Setup() của file
usbd_composite.c như sau:
case CDC_SET_LINE_CODING: /* Data đến qua EP0 DataOut, xử lý tiếp ở EP0_RxReady */ (void)USBD_CtlPrepareRx(pdev, sEp0Buf, CDC_LINE_CODING_SIZE); break;case CDC_GET_LINE_CODING: (void)USBD_CtlSendData(pdev, (uint8_t *)&hcomp->lineCoding, CDC_LINE_CODING_SIZE); break;Struct hcomp->lineCoding lưu 4 field trên. baudRate được lưu nhưng không dùng
cho bất kỳ việc gì khác. Dữ liệu giữa host và device vẫn luôn đi qua Bulk endpoint,
chứ không có khái niệm start bit/stop bit theo timing như UART thật.
Vì vậy nếu đổi Tera Term từ 115200 sang 9600 thì không có gì thay đổi. Tera Term
gửi SET_LINE_CODING với baudrate 9600 thì firmware vẫn nhận, lưu vào baudRate
rồi bỏ qua. Log vẫn hiện đúng với tốc độ y hệt vì tầng truyền thật sự (USB
Full-Speed 12 Mbps) không phụ thuộc gì vào con số baudrate này.
Thao tác sau khi nhận data từ host
Khác với UART tự động nhận liên tục, CDC cần firmware chủ động báo “sẵn sàng nhận packet tiếp theo” sau mỗi lần được gọi.
Mỗi khi endpoint OUT của CDC Data nhận xong một packet, Composite_DataOut() được
gọi đúng 1 lần. Nếu không chủ động mở lại endpoint ngay trong đó thì endpoint sẽ
kẹt ở trạng thái NAK (từ chối nhận) và Composite_DataOut() sẽ không bao giờ
được gọi lần thứ hai dù host có gửi tiếp bao nhiêu packet:
static uint8_t Composite_DataOut(USBD_HandleTypeDef *pdev, uint8_t epnum){ USBD_Composite_HandleTypeDef *hcomp = (USBD_Composite_HandleTypeDef *)pdev->pClassDataCmsit[pdev->classId]; if (hcomp == NULL) return (uint8_t)USBD_FAIL; if (epnum == COMP_CDC_DATA_OUT_EP_ADDR) { /* Xử lý dữ liệu vừa nhận trong hcomp->cdcRxBuf ở đây trước */ /* USBD_LL_PrepareReceive: hàm middleware thật của ST, mở lại endpoint OUT cho packet tiếp theo. */ (void)USBD_LL_PrepareReceive(pdev, COMP_CDC_DATA_OUT_EP_ADDR, hcomp->cdcRxBuf, COMP_CDC_DATA_EP_SIZE); } return (uint8_t)USBD_OK;}Đây là khác biệt lớn nhất so với UART. UART có thể nhận liên tục không cần can thiệp còn USB OUT endpoint mặc định chỉ nhận đúng 1 packet rồi tự đóng lại, chờ firmware xác nhận rồi mới mở tiếp.
Thiết kế 1: ring buffer trung gian
Kênh log sử dụng ring buffer thay vì gọi CDC transmit trực tiếp vì hai lý do chính:
CdcLog_Printfcó thể được gọi từ nhiều vị trí trong main loop, thậm chí được gọi liên tiếp trước khi lần truyền trước hoàn tất- Log phải tiếp tục hoạt động ngay cả khi host chưa kết nối để không bỏ sót bất kì log nào.
#define CDC_LOG_BUF_SIZE 512U /* bắt buộc là lũy thừa của 2 */static uint8_t sRingBuf[CDC_LOG_BUF_SIZE];static uint16_t sWriteIdx;static uint16_t sReadIdx;CdcLog_Init() đưa cả sWriteIdx và sReadIdx về 0 lúc khởi động, gọi đúng 1 lần
trong app_main.c trước khi có bất kỳ lời gọi CdcLog_Printf nào.
Chỉ số được quay vòng bằng phép AND với (CDC_LOG_BUF_SIZE - 1U) thay vì %.
Buffer luôn chừa đúng 1 slot trống (tức chỉ dùng tối đa CDC_LOG_BUF_SIZE - 1 byte)
để tránh phải thêm cờ phân biệt trạng thái đầy/rỗng khi hai chỉ số trùng nhau.
Nếu buffer đầy, byte mới sẽ bị drop (bỏ qua) trong im lặng để tránh block firmware.
Thiết kế 2: chỉ gọi từ main loop
vsnprintf sử dụng bộ nhớ stack và không có bất kỳ cơ chế bảo vệ tranh chấp nào.
Nếu gọi nó từ ISR là undefined behavior. Đây là lý do cốt lõi khiến CdcLog_Printf()
bị giới hạn chỉ được gọi từ main loop, không được gọi từ bất kỳ ISR hay USB callback
nào (Composite_Setup, Composite_DataIn, Composite_SOF).
Ràng buộc này kéo theo một điểm cần chú ý khi dùng vsnprintf: hàm này trả về
số ký tự lẽ ra sẽ được ghi nếu buffer đủ lớn chứ không phải số ký tự thật sự
nằm trong buffer khi chuỗi bị cắt bớt. Code thực tế trong CdcLog_Printf() phải
kẹp lại trước khi dùng:
outLen = (len < sizeof(scratch)) ? len : sizeof(scratch) - 1U;CdcLog_Write(scratch, outLen); /* code rút gọn, xem bản đầy đủ trong repo */CdcLog_Write() nhận cả chuỗi và độ dài trong một lần gọi (không phải ghi từng byte)
và tự xử lý phần vượt quá dung lượng còn lại của ring buffer bằng cách tự bỏ
phần dư đúng như thiết kế.
Hai hàm tạo log và gửi log đều được thực thi trong main loop nên không cần sử dụng
critical section để bảo vệ sWriteIdx/sReadIdx của ring buffer. Nếu hàm tạo log
được gọi từ ISR thì cần tạo ra critical section. Tuy nhiên cách này không được khuyến
khích bởi nó block MCU trong thời gian dài, thay vào đó có thể dùng
pending flag pattern. Cách này sẽ được triển khai trong bài 7 khi muốn ghi log
trong USB stack context.
Thiết kế 3: gửi khi rảnh và đã kết nối
CdcLog_Run() chạy mỗi vòng trong main loop và chỉ thực hiện truyền khi thỏa mãn
đủ cả ba điều kiện sau:
- Thiết bị đã kết nối hoàn tất với host
- Endpoint đang ở trạng thái IDLE
- Ring buffer có log đang được lưu trữ
CdcLog_Run() sẽ pop tối đa 64 byte log mỗi lần. Con số 64 byte này là max packet
size của Bulk endpoint ở Full-Speed theo tài liệu USB 2.0.
Giới hạn này cũng giúp mỗi lần gọi transmit tương ứng một packet USB duy nhất mà
không cần tự chia nhỏ thêm ở tầng ứng dụng. Nếu dữ liệu cần gửi đúng bằng bội
số của 64 byte thì host sẽ cần thêm một Zero-Length Packet để biết transfer
đã kết thúc.
Xung đột thời điểm lúc khởi động
DTR (Data Terminal Ready) và RTS (Request To Send) là hai tín hiệu kế thừa từ chuẩn RS-232
nguyên bản dùng để báo thiết bị đầu cuối đã sẵn sàng giao tiếp. CDC ACM chỉ mô phỏng lại hai
bit này ở tầng phần mềm thông qua request SET_CONTROL_LINE_STATE, hoàn toàn không có
chân điện áp vật lý tương ứng.
Trên Windows, request này xuất hiện ít nhất 2 lần với 2 ý nghĩa khác nhau:
Value = 0(DTR=0, RTS=0): driver hệ điều hành tự gắn vào CDC interface ngay sau enumerate. Đây không phải ứng dụng mở port thật.Value = 3(DTR=1, RTS=1): một ứng dụng (Tera Term, PuTTY…) thật sự mở COM port. Đây mới thật sự là thời điểm host thực sự kết nối cần theo dõi.
Vì vậy phải bắt đúng Value = 3 chứ không phải chỉ Value != 0.
Ngay cả khi bắt đúng thời điểm host thật sự vừa kết nối (Value = 3) thì vẫn chưa đủ.
DTR sẵn sàng ở tầng driver không đồng nghĩa ứng dụng đã kịp bắt đầu vòng lặp đọc dữ liệu.
Nếu gửi log ngay tại thời điểm đó, packet có thể đến trước khi terminal được đưa vào
trạng thái sẵn sàng nhận dẫn tới việc log có thể tạm thời bị bỏ qua.
Để giải quyết vấn đề trên thì cách đơn giản nhất là thêm 1 xử lý delay mỗi khi COM port được
kết nối. Và để không chặn xử lý main loop thì chỉ cần lưu lại thời điểm host bắt đầu kết nối
và chỉ bắt đầu gửi log sau khi đã trôi qua đủ thời gian delay. CdcLog_UpdateConnection()
được gọi mỗi vòng main loop từ CdcLog_Run(), xử lý đúng logic này:
/* Trong CdcLog_UpdateConnection(), gọi mỗi vòng từ CdcLog_Run() */if (hostConnected && !sWasHostConnected) { sConnectedAtMs = HAL_GetTick(); sGreetingPending = true;}if (sGreetingPending && (HAL_GetTick() - sConnectedAtMs) >= 100U) { CdcLog_Printf("[CDC] log channel connected\r\n"); sGreetingPending = false;}HAL_GetTick() trả về uint32_t và sẽ xảy ra tràn số và quay vòng về 0 (wrap) sau khoảng
49.7 ngày chạy liên tục. Dùng (now - then) >= delay thay vì now >= then + delay để
phép so sánh vẫn đúng ngay cả khi vừa xảy ra wrap nhờ tính chất modular arithmetic của
kiểu unsigned (phép trừ vẫn đúng ngay cả khi vừa wrap). Nếu viết then + delay thì
giá trị của phép cộng có thể tự wrap trước khi so sánh dẫn đến sai logic.
Sau khi thêm delay thì Tera Term hiện message đúng mỗi lần kết nối. Quá trình chẩn đoán bằng USBPcap kèm số liệu từng bước nằm ở mục “Race condition lúc boot” trong trang project Composite Device.
Tóm tắt
Kênh CDC log hoạt động ổn định khi ba yếu tố được xử lý đúng:
- Ring buffer tách thời điểm format và tạo log khỏi thời điểm USB gửi để tránh mất log khi host chưa sẵn sàng.
CdcLog_Printfchỉ chạy trong main loop để tránh lỗi từ vsnprintf trong ISR.- Thời điểm “kết nối” phải được xác định chính xác bằng cạnh lên DTR và thêm một khoảng trễ ngắn để tránh gửi log quá sớm.
Bài tiếp theo sẽ thêm EP0 vendor request để điều khiển firmware trực tiếp từ host mà không cần giao thức text qua CDC.
Tài liệu tham khảo
- USB CDC Class 1.2 Specification - SET_CONTROL_LINE_STATE, DTR/RTS
- USBPcap - capture USB traffic trên Windows, dùng cùng Wireshark
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ị.