實戰(zhàn):從枚舉到穩(wěn)定收發(fā))
簡介這份資源面向嵌入式開發(fā)初學者與需要快速驗證USB通信的工程師提供基于GD32芯片的USB虛擬串口收發(fā)程序幫助理解CDC-ACM設備類在ARM Cortex-M平臺上的實現(xiàn)方式。壓縮包共271個文件約5.96MB以c與h源碼為主配合o、d、crf等編譯中間文件以及hex、bin固件、sct分散加載腳本和uvprojx、eww等工程文件覆蓋Keil與IAR兩套開發(fā)環(huán)境便于直接編譯下載。內容圍繞USB協(xié)議、CDC類、GD32內置USB OTG控制器、中斷服務程序與數(shù)據(jù)緩沖區(qū)管理展開官網(wǎng)例程給出配置USB控制器、注冊中斷、建立通信管道的完整細節(jié)可對照參考手冊調整收發(fā)邏輯與錯誤處理。目前已有5297人學習下載適合作為USB虛擬串口調試與固件移植的實踐起點。1. GD32 芯片 USB 虛擬串口收發(fā)程序從零跑通 CDC 回環(huán)的實戰(zhàn)拆解手頭有一塊 GD32 開發(fā)板USB 口插上電腦卻只認成未知設備串口助手翻遍端口列表也找不到那個 COM 號——這是很多嵌入式同行第一次碰 USB 虛擬串口CDC時的經(jīng)典翻車現(xiàn)場。這份 GD32 芯片 USB 虛擬串口收發(fā)程序資源解決的正是這件事它把 GD32 系列 MCU 的 USB 外設配置成 CDC 類設備讓 PC 端無需額外驅動就能識別出一個標準串口實現(xiàn)雙向收發(fā)。適合正在做數(shù)據(jù)采集、調試通道、設備升級通道的嵌入式開發(fā)者尤其是從 STM32 轉 GD32 平臺、發(fā)現(xiàn) USB 庫調用習慣不太一樣的那批人。下面按「資源是什么、怎么落地、坑在哪」的順序拆開講。2. USB CDC 協(xié)議棧與 GD32 外設選型為什么不是隨便配個 GPIO 就行2.1 CDC 類設備的枚舉流程與端點分配USB 虛擬串口本質上是 USB CDC-ACMCommunication Device Class - Abstract Control Model類設備。主機上電后先做設備枚舉主機發(fā) GET_DESCRIPTOR 拿設備描述符再拿配置描述符集合里面包含接口描述符、端點描述符和 CDC 特有的功能描述符如 Call Management、Abstract Control Management。GD32 的 USB 外設支持全速12 Mbps設備模式枚舉階段需要固件正確響應標準請求否則主機不會加載 CDC 驅動自然也不會出現(xiàn) COM 端口。端點分配是第一個容易搞混的點。CDC-ACM 通常需要三個端點一個控制端點 EP0雙向枚舉和類請求走這里一個批量輸入端點設備到主機比如 EP1_IN一個批量輸出端點主機到設備比如 EP2_OUT。有些實現(xiàn)還會加一個中斷輸入端點用于串口狀態(tài)通知但最小回環(huán)只需要前三個。GD32 的 USBFS 外設端點 FIFO 是共享的分配時要算好每個端點占多少深度不然枚舉能過但一發(fā)數(shù)據(jù)就丟包。常見做法是直接復用官方 USB 庫里的 CDC 例程框架把端點號、FIFO 深度、緩沖區(qū)大小按自己的芯片型號改。GD32F103、GD32F303、GD32E230 這幾個系列的 USB 寄存器布局有差異不能拿一份代碼無腦通刷。2.2 時鐘樹與 USB 48 MHz 時鐘源配置USB 全速設備要求 48 MHz 時鐘精度不夠就會枚舉失敗或者頻繁掉線。GD32 的 USB 時鐘來源一般有兩個內部 IRC48M 或者外部晶振經(jīng) PLL 倍頻。用內部 IRC48M 省事但溫漂和精度在批量產(chǎn)品里是隱患用外部 8 MHz 晶振經(jīng) PLL 到 48 MHz 更穩(wěn)但時鐘樹配置錯一步就出不來。以 GD32F103 為例常見配置是外部 8 MHz 晶振PLL 倍頻到 72 MHz 作為系統(tǒng)時鐘USB 預分頻器設置為 1.5 分頻得到 48 MHz。這里有個血淚經(jīng)驗USB 預分頻寄存器的值不是隨便填的必須保證輸出嚴格 48 MHz差一點枚舉就時好時壞。配置完可以用 MCO 引腳輸出 USB 時鐘拿示波器量一下比盲猜靠譜。/* 時鐘配置片段外部8MHz晶振 - PLL 72MHz - USB 48MHz */ void system_clock_config(void) { /* 使能外部高速晶振 */ rcu_osci_on(RCU_HXTAL); while(rcu_osci_stab_wait(RCU_HXTAL) ! SUCCESS); /* 配置PLLHXTAL 8MHz預分頻1倍頻9 - 72MHz */ rcu_pll_config(RCU_PLLSRC_HXTAL, RCU_PLL_MUL9); /* 配置USB預分頻72MHz / 1.5 48MHz */ rcu_usb_clock_config(RCU_CKUSB_CKPLL_DIV1_5); /* 使能PLL并切換系統(tǒng)時鐘 */ rcu_osci_on(RCU_PLL_CK); while(rcu_osci_stab_wait(RCU_PLL_CK) ! SUCCESS); rcu_system_clock_source_config(RCU_CKSYSSRC_PLL); while(rcu_system_clock_source_get() ! RCU_SCSS_PLL); }這段代碼的關鍵參數(shù)是RCU_PLL_MUL9和RCU_CKUSB_CKPLL_DIV1_5。前者決定系統(tǒng)主頻后者決定 USB 時鐘。如果你的板子用的是 12 MHz 晶振倍頻系數(shù)和預分頻都要重算不能照抄。配置完建議先點個燈確認系統(tǒng)時鐘跑起來了再往下調 USB。2.3 描述符定制VID/PID 與字符串描述符枚舉能不能過描述符占一半責任。設備描述符里的 VID廠商 ID和 PID產(chǎn)品 ID如果和主機上已有驅動沖突或者用了未授權的值Windows 可能直接拒絕加載。常見做法是開發(fā)階段先用一個不沖突的自定義 VID/PID量產(chǎn)前再換成正式申請的值。字符串描述符支持多語言索引 0 是語言 ID 列表后面才是廠商字符串、產(chǎn)品字符串、序列號字符串。序列號建議每臺設備唯一方便主機區(qū)分多個同型號設備。如果序列號全一樣插兩個上去主機可能只認一個。/* 設備描述符關鍵字段 */ const usb_desc_dev cdc_dev_desc { .header { .bLength USB_DEV_DESC_LEN, .bDescriptorType USB_DESCTYPE_DEV, }, .bcdUSB 0x0200, /* USB 2.0 */ .bDeviceClass USB_CLASS_CDC, /* CDC類 */ .bDeviceSubClass 0x00, .bDeviceProtocol 0x00, .bMaxPacketSize0 64, /* EP0最大包64字節(jié) */ .idVendor 0x1234, /* 自定義VID量產(chǎn)需替換 */ .idProduct 0x5678, /* 自定義PID */ .bcdDevice 0x0100, .iManufacturer 0x01, .iProduct 0x02, .iSerialNumber 0x03, .bNumberConfigurations 0x01, };bMaxPacketSize0對全速設備固定是 64低速才是 8填錯枚舉直接掛。bDeviceClass填 CDC 類有些實現(xiàn)填 0 讓接口描述符去聲明類兩種都能過但填 0 時接口描述符必須寫清楚不然主機不知道加載什么驅動。3. 收發(fā)程序落地從枚舉成功到雙向回環(huán)的完整步驟3.1 工程搭建與 USB 庫文件裁剪拿到這份資源后第一步不是急著編譯而是把工程結構理清楚。典型目錄里會有 USB 庫文件usbd_core.c、usbd_std.c 等、CDC 類實現(xiàn)文件usbd_cdc_core.c、硬件驅動gd32f10x_usb.c 之類和應用層main.c、usb_cdc_app.c。不同 GD32 系列的庫文件名可能帶不同前綴比如 GD32F30x 和 GD32E23x 的庫不通用。裁剪原則是只保留用到的類。如果只做 CDCHID、MSC 那些文件全刪掉減少編譯時間和 Flash 占用。中斷向量表里 USB 中斷的入口要對應上GD32 的 USBFS 中斷處理函數(shù)名一般是USBFS_IRQHandler在啟動文件里確認沒被別的外設占用。# 典型工程目錄結構以GD32F103為例 project/ ├── firmware/ │ ├── gd32f10x_usb.c # USB外設底層驅動 │ └── gd32f10x_usb.h ├── usbd/ │ ├── usbd_core.c # USB設備核心狀態(tài)機 │ ├── usbd_std.c # 標準請求處理 │ └── cdc/ │ ├── usbd_cdc_core.c # CDC類實現(xiàn) │ └── usbd_cdc_core.h ├── app/ │ ├── main.c # 主循環(huán) │ └── usb_cdc_app.c # 收發(fā)應用邏輯 └── startup/ └── startup_gd32f10x.s # 啟動文件確認USB中斷入口編譯前先確認啟動文件里的堆棧大小夠用USB 枚舉階段會用到一定??臻g棧太小會進 HardFault。我一般把棧調到 0x400 以上保險。3.2 端點初始化與數(shù)據(jù)收發(fā)函數(shù)USB 初始化分兩步先初始化 USB 外設和端點再等主機枚舉完成。枚舉完成的標志是usbd_cdc_core里的配置狀態(tài)變成已配置此時才能開始批量端點收發(fā)。發(fā)送函數(shù)的核心是把數(shù)據(jù)拷進端點 FIFO然后置發(fā)送允許位。接收則是等端點收到數(shù)據(jù)后從 FIFO 讀出來。GD32 的 USB FIFO 訪問有 32 位對齊要求按字節(jié)讀會出問題必須用 32 位讀再拆字節(jié)。/* CDC數(shù)據(jù)發(fā)送把buf里的len字節(jié)通過EP1_IN發(fā)出 */ uint8_t cdc_send_data(uint8_t *buf, uint16_t len) { uint16_t sent 0; /* 等待上一次發(fā)送完成 */ while(usb_tx_flag 0); usb_tx_flag 0; /* 寫入端點FIFO注意32位對齊 */ while(sent len) { uint32_t word 0; for(int i 0; i 4 (sent i) len; i) { word | (uint32_t)buf[sent i] (8 * i); } usb_ep_write_fifo(EP1_IN, word); sent 4; } /* 設置傳輸長度并允許發(fā)送 */ usb_ep_set_tx_len(EP1_IN, len); usb_ep_tx_enable(EP1_IN); return 0; } /* CDC數(shù)據(jù)接收從EP2_OUT讀數(shù)據(jù)到buf */ uint16_t cdc_recv_data(uint8_t *buf, uint16_t max_len) { uint16_t recv_len usb_ep_get_rx_len(EP2_OUT); if(recv_len 0 || recv_len max_len) return 0; for(uint16_t i 0; i recv_len; i 4) { uint32_t word usb_ep_read_fifo(EP2_OUT); for(int j 0; j 4 (i j) recv_len; j) { buf[i j] (word (8 * j)) 0xFF; } } usb_ep_rx_enable(EP2_OUT); /* 重新允許接收 */ return recv_len; }usb_tx_flag是個軟件標志在發(fā)送完成中斷里置位主循環(huán)靠它判斷能不能發(fā)下一包。沒有這個標志直接連續(xù)發(fā)第二包會覆蓋第一包現(xiàn)象就是發(fā)出去的數(shù)據(jù)缺斤少兩。接收端每次讀完必須重新使能端點否則只能收到第一包這是新手最容易踩的坑之一。3.3 主循環(huán)回環(huán)測試與串口助手驗證應用層最簡單的驗證就是回環(huán)收到什么原樣發(fā)回去。主循環(huán)里輪詢接收標志有數(shù)據(jù)就調發(fā)送函數(shù)。PC 端打開串口助手波特率隨便填CDC 虛擬串口波特率是形式上的實際速率由 USB 決定發(fā)一串字符能原樣收到就說明收發(fā)通了。/* 主循環(huán)回環(huán)測試 */ int main(void) { uint8_t rx_buf[64]; uint16_t rx_len; system_clock_config(); usb_gpio_config(); usbd_init(); /* USB設備初始化 */ usbd_connect(); /* 連接主機觸發(fā)枚舉 */ while(1) { if(usb_rx_flag) { usb_rx_flag 0; rx_len cdc_recv_data(rx_buf, sizeof(rx_buf)); if(rx_len 0) { cdc_send_data(rx_buf, rx_len); /* 原樣回發(fā) */ } } } }驗證時注意串口助手要選對 COM 號設備管理器里能看到「USB 串行設備」才算枚舉成功。如果發(fā)數(shù)據(jù)沒反應先看設備管理器有沒有黃色感嘆號再看發(fā)送標志是不是卡住了?;丨h(huán)通了之后再把回環(huán)邏輯換成自己的業(yè)務邏輯比如把收到的數(shù)據(jù)解析后控制外設或者把傳感器數(shù)據(jù)打包上傳。4. 避坑與排查CDC 虛擬串口最常見的五類翻車4.1 枚舉失敗設備管理器顯示未知設備現(xiàn)象插上 USB 后電腦提示「無法識別的 USB 設備」設備管理器里帶黃色感嘆號串口列表里沒有新端口。原因描述符錯誤、時鐘不對、端點 FIFO 配置沖突或者 USB 中斷沒使能。最常見的是描述符長度字段填錯主機解析到一半發(fā)現(xiàn)長度對不上就放棄枚舉。解決先用 USB 分析儀或者軟件抓包看枚舉卡在哪一步。沒有分析儀就逐項檢查設備描述符bLength是不是 18配置描述符集合總長度對不對bMaxPacketSize0是不是 64。時鐘用 MCO 輸出量一下確認 48 MHz。中斷向量表里 USB 中斷入口確認沒被覆蓋。4.2 枚舉成功但發(fā)數(shù)據(jù)丟包現(xiàn)象COM 口出來了發(fā)少量數(shù)據(jù)正常一發(fā)長數(shù)據(jù)就丟或者發(fā)幾包之后卡死。原因發(fā)送標志沒等就連續(xù)寫 FIFO或者端點 FIFO 深度不夠長包被截斷。也有可能是主循環(huán)里收發(fā)邏輯阻塞太久USB 中斷響應不及時。解決發(fā)送前必須等usb_tx_flag這個標志在傳輸完成中斷里置位。FIFO 深度按最大包長配全速批量端點最大 64 字節(jié)配 64 或 128 都行別配小了。主循環(huán)里別放延時函數(shù)USB 中斷優(yōu)先級要高于其他非關鍵中斷。4.3 接收只能收到第一包現(xiàn)象第一包數(shù)據(jù)能收到之后再也不進接收中斷或者讀出來全是 0。原因接收端點讀完沒有重新使能。GD32 的 USB 端點收到數(shù)據(jù)后會自動 NA K必須軟件重新置接收允許位否則主機發(fā)不進來。解決在接收處理函數(shù)末尾加usb_ep_rx_enable(EP2_OUT)確保每次讀完都重新打開。這個操作在中斷里做還是主循環(huán)里做都行但必須做。4.4 換一塊同型號板子就不行現(xiàn)象在自己板子上跑得好好的換一塊同型號板子有的能枚舉有的不能。原因晶振精度不一致或者 USB 差分線走線質量差。USB 全速對差分信號質量有要求D 和 D- 走線不等長、沒做阻抗匹配就會出現(xiàn)枚舉時好時壞。解決優(yōu)先用外部晶振而不是內部 IRC48M。差分線走線盡量等長串 22 歐姆電阻做匹配。如果板子已經(jīng)做好了改不了可以在軟件里加枚舉重試機制枚舉失敗后延時重新連接。4.5 主機休眠后無法喚醒現(xiàn)象電腦休眠再喚醒COM 口還在但發(fā)數(shù)據(jù)沒反應拔插一次才恢復。原因USB 掛起和恢復處理沒做完整。主機休眠時 USB 總線進入掛起狀態(tài)設備固件如果沒正確處理恢復中斷就會卡在掛起狀態(tài)出不來。解決在 USB 中斷里處理掛起和恢復事件恢復時重新初始化端點。如果不需要低功耗可以在描述符里把遠程喚醒關掉減少主機掛起的概率。但根本辦法還是把掛起恢復流程寫對。5. 進階技巧用環(huán)形緩沖把收發(fā)解耦順帶驗證穩(wěn)定性回環(huán)測試跑通只是起點真正用到項目里收發(fā)速率一上來主循環(huán)輪詢那套就頂不住了。我一般會加一層環(huán)形緩沖把 USB 中斷和業(yè)務處理解耦中斷里只負責把數(shù)據(jù)塞進環(huán)形緩沖或者從緩沖取數(shù)據(jù)發(fā)走主循環(huán)慢慢消費。這樣即使業(yè)務處理偶爾卡一下USB 也不會丟包。環(huán)形緩沖的實現(xiàn)要點是讀寫指針用volatile修飾防止編譯器優(yōu)化出玄學問題。緩沖區(qū)大小取 2 的冪用位與代替取模速度快且不會算錯。下面是一個最小實現(xiàn)/* 環(huán)形緩沖大小256字節(jié)2的冪方便位與 */ #define RING_BUF_SIZE 256 #define RING_BUF_MASK (RING_BUF_SIZE - 1) typedef struct { volatile uint16_t head; /* 寫指針 */ volatile uint16_t tail; /* 讀指針 */ uint8_t buf[RING_BUF_SIZE]; } ring_buf_t; /* 寫入數(shù)據(jù)返回實際寫入字節(jié)數(shù) */ uint16_t ring_write(ring_buf_t *rb, uint8_t *data, uint16_t len) { uint16_t i; for(i 0; i len; i) { uint16_t next (rb-head 1) RING_BUF_MASK; if(next rb-tail) break; /* 緩沖滿 */ rb-buf[rb-head] data[i]; rb-head next; } return i; } /* 讀出數(shù)據(jù)返回實際讀出字節(jié)數(shù) */ uint16_t ring_read(ring_buf_t *rb, uint8_t *data, uint16_t max_len) { uint16_t i; for(i 0; i max_len; i) { if(rb-tail rb-head) break; /* 緩沖空 */ data[i] rb-buf[rb-tail]; rb-tail (rb-tail 1) RING_BUF_MASK; } return i; }head和tail用volatile是必須的因為中斷和主循環(huán)都會改它們不加編譯器可能把判斷優(yōu)化掉現(xiàn)象就是緩沖明明有數(shù)據(jù)但讀不出來。RING_BUF_MASK用位與代替%運算在 Cortex-M 上快很多而且不會因為負數(shù)取模出問題。驗證穩(wěn)定性有個土辦法但很有效PC 端用腳本持續(xù)發(fā)隨機長度數(shù)據(jù)設備端回環(huán)跑一晚上第二天看有沒有丟包或者死機。我一般會寫個簡單的 Python 腳本用 pyserial 每隔幾毫秒發(fā)一包包長在 1 到 64 之間隨機同時統(tǒng)計發(fā)送和接收的字節(jié)數(shù)對不上就說明中間丟了。# 穩(wěn)定性測試腳本持續(xù)發(fā)隨機長度數(shù)據(jù)并校驗回環(huán) import serial import random import time ser serial.Serial(COM3, 115200, timeout1) total_sent 0 total_recv 0 try: while True: length random.randint(1, 64) data bytes([random.randint(0, 255) for _ in range(length)]) ser.write(data) total_sent length echo ser.read(length) total_recv len(echo) if echo ! data: print(f數(shù)據(jù)不匹配: 發(fā)送{data.hex()}, 收到{echo.hex()}) time.sleep(0.005) except KeyboardInterrupt: print(f發(fā)送{total_sent}字節(jié), 接收{total_recv}字節(jié)) ser.close()這個腳本跑起來后如果total_sent和total_recv對不上或者頻繁打印數(shù)據(jù)不匹配就說明固件端還有丟包。常見原因是環(huán)形緩沖太小高速率下溢出把RING_BUF_SIZE加到 512 或 1024 再試。另一個原因是主循環(huán)消費太慢可以在主循環(huán)里一次多讀幾個字節(jié)減少循環(huán)次數(shù)。從那以后我每次調 USB 收發(fā)都強制先跑一遍這個隨機長度回環(huán)測試確認不丟包了再往上疊業(yè)務邏輯。很多看起來是業(yè)務層的問題根子其實在 USB 收發(fā)這一層沒做穩(wěn)。希望幫到你。本文還有配套的精品資源點擊獲取