
1. 项目缘起与核心思路拆解1.1 为什么会有“串口转USB键盘”这个需求做过嵌入式的人大概率都遇到过这种场景手头有一台设备主控跑的是CH32X035这类RISC-V内核的MCU它本身带USB外设但你要让它模拟成一个标准USB键盘把另一路串口收到的数据“翻译”成按键动作发出去。听起来像是绕了个弯但实际需求非常真实。举几个我亲身碰到的例子。工厂里有一台老式测试治具上位机只能通过串口往外吐测试结果操作员需要手动把结果敲进MES系统一天几百次累且容易错。如果能让串口数据自动变成键盘输入光标停在输入框里数据自己“打”进去效率直接翻倍。再比如一些仪器仪表只有RS232或TTL串口输出但配套软件只认键盘输入中间就需要一个协议转换器。还有做自动化演示的想让MCU根据传感器数据模拟按键来触发PC上的快捷操作串口转USB键盘就是最省事的路径。这个项目的本质是MCU同时扮演两个角色——对下是UART从机接收串口数据对上是USB HID设备向主机发送键盘报告。CH32X035这颗芯片恰好把这两件事都包了它内置USB 2.0全速控制器和多个USART不需要外挂CH340之类的USB转串口芯片一颗芯片就能完成“串口进、USB键盘出”的完整链路。1.2 方案选型为什么是CH32X035而不是别的市面上能做USB HID的MCU很多STM32F103、AT32、GD32都能干为什么偏偏选CH32X035我当初选型时列了几个硬指标原生USB外设必须内置USB控制器不能靠软件模拟否则时序和稳定性都成问题。RISC-V内核想尝鲜也想验证国产RISC-V在USB场景下的成熟度。封装小、外围少目标产品是个拇指大小的转换头PCB空间紧张。成本敏感批量做的话BOM每多一毛钱都要算。开发工具链友好要有现成的USB HID例程不能从零撸USB协议栈。CH32X035在这几点上表现很均衡。它基于青稞V4C内核主频48MHz内置USB 2.0全速设备控制器支持HID、CDC等常用类。官方提供了USB HID的例程报告描述符、端点配置都有现成模板改起来快。封装有TSSOP20、QFN20等小体积选项外围只需要晶振也可用内部时钟、几个去耦电容和USB匹配电阻BOM极简。对比下来STM32F103价格偏高且供货波动大GD32的USB例程相对分散CH32X035在“够用、便宜、好买、例程全”这四个维度上最平衡。当然它也有短板后面会讲到比如USB差分信号引脚的布局要求比较严格这是硬件设计时最容易翻车的地方。1.3 整体数据流与角色分工先把数据流理清楚后面写代码才不会乱。整个系统的数据通路是这样的PC/上位机 --(串口TX)-- CH32X035 UART_RX --(解析)-- USB HID报告 --(USB)-- PC/主机注意这里有个容易绕晕的点串口和USB可能接的是同一台电脑也可能是两台不同设备。典型场景是一台设备通过串口发数据给CH32X035CH32X035再通过USB把数据以键盘形式发给另一台电脑。如果串口和USB接同一台电脑那电脑上会同时出现一个串口设备和一个键盘设备串口调试助手发数据键盘就“打字”调试起来非常直观。CH32X035内部的分工是UART外设负责接收串口字节流主循环或中断里做协议解析把解析结果映射成USB HID键盘报告再通过USB端点发送出去。USB部分用中断传输键盘报告固定8字节包含修饰键、保留字节和最多6个按键码。整个过程不需要操作系统裸机跑就行。提示如果你的串口数据是ASCII文本映射逻辑很简单一个字符对应一个按键码。如果是二进制协议就需要先定义一套“字节到按键”的映射表这部分后面会详细讲。2. 核心细节解析与实操要点2.1 USB HID键盘报告描述符到底长什么样很多人卡在USB HID这一步不是代码写不出来而是报告描述符看不懂。报告描述符是USB设备告诉主机“我是什么设备、我发什么数据”的说明书主机靠它来识别键盘。CH32X035的例程里已经有一份标准的键盘报告描述符但如果你想改按键数量、加多媒体键就得自己动它。标准键盘报告是8字节结构如下字节偏移含义说明0修饰键Ctrl/Shift/Alt/GUI的组合位1保留固定为02按键码1第一个按下的键3按键码2第二个按下的键4按键码3第三个按下的键5按键码4第四个按下的键6按键码5第五个按下的键7按键码6第六个按下的键修饰键字节的位定义是bit0左Ctrlbit1左Shiftbit2左Altbit3左GUIbit4右Ctrlbit5右Shiftbit6右Altbit7右GUI。按键码用的是USB HID Usage ID比如字母a是0x04b是0x05数字1是0x1E回车是0x28空格是0x2C。报告描述符里最关键的是Usage Page和Usage。键盘的Usage Page是0x01Generic DesktopUsage是0x06Keyboard。输入报告用Input项声明告诉主机这是8字节的输入数据。输出报告一般用不到但有些键盘带LED指示灯就需要Output项来控制Num Lock、Caps Lock灯。我建议第一次做的时候直接用官方例程的描述符跑通之后再改。改的时候用“HID报告描述符分析工具”这类小工具验证一下能省很多调试时间。网上有在线的描述符解析器把十六进制粘进去就能看到结构树非常直观。2.2 串口数据到按键码的映射策略这是整个项目里最“业务相关”的部分没有标准答案完全取决于你的串口数据格式。我把它分成三种典型情况第一种纯ASCII文本。串口发来的是“Hello World”这样的字符串每个字符直接映射成对应的按键码。字母、数字、常用符号都有固定的Usage ID查表即可。大写字母需要同时置Shift位比如‘A’是Shift0x04。这种最简单一个switch或查表函数就搞定。第二种带控制字符的文本。比如串口发来“\r\n”表示回车换行映射成回车键0x28。发来“\t”映射成Tab0x2B。发来“\b”映射成退格0x2A。这种在第一种基础上加几个特殊字符处理就行。第三种二进制协议。串口发来的是0x01、0x02这样的字节每个字节代表一个预定义的按键或组合键。比如0x01代表CtrlC0x02代表CtrlV。这种需要你自己定义一张映射表把字节值映射到修饰键按键码的组合。映射表可以放在Flash里用const数组存不占RAM。我实际做的时候用的是第三种因为上位机发的是二进制指令效率高且不易出错。映射表大概长这样typedef struct { uint8_t modifier; uint8_t keycode; } KeyMap_t; const KeyMap_t keyMap[] { {0x00, 0x04}, // 0x00 - a {0x02, 0x04}, // 0x01 - Shifta A {0x01, 0x06}, // 0x02 - Ctrlc {0x01, 0x19}, // 0x03 - Ctrlv {0x00, 0x28}, // 0x04 - Enter // ... 更多映射 };发送按键时先把报告缓冲区清零填入modifier和keycode调用USB发送函数延时几十毫秒再发一个全零报告表示“松开”。这个“按下-延时-松开”的节奏很关键后面会细说。2.3 硬件设计上最容易踩的坑CH32X035的USB差分信号引脚是固定的通常是PA11D-和PA12D具体看封装手册。这两个引脚在PCB布局时有几个硬性要求不注意的话USB枚举都过不了。第一差分走线要等长且尽量短。USB全速虽然只有12Mbps但差分信号对阻抗和长度匹配还是有要求的。走线长度差控制在5mil以内总长度越短越好最好不超过5厘米。我第一版板子把USB座放在板边MCU放中间走线拉了8厘米结果枚举时好时坏后来缩短到3厘米就稳了。第二串联匹配电阻不能省。D和D-上各串一个22欧姆左右的电阻靠近MCU放置。有些MCU内部有匹配电阻但CH32X035建议外部加实测加了之后信号质量明显改善。第三D上拉电阻的处理。USB全速设备需要在D上接1.5k上拉电阻告诉主机“我是全速设备”。CH32X035内部可以配置这个上拉不需要外部电阻但要在代码里使能。如果外部加了上拉代码里就要关掉内部的否则冲突。第四晶振和USB时钟。CH32X035的USB模块需要48MHz时钟可以从内部PLL倍频得到也可以用外部晶振。用内部时钟省事但精度稍差用外部晶振稳定但多两个元件。我建议批量产品用外部晶振调试阶段可以用内部时钟先跑通。注意如果你用的是TSSOP20封装PA11和PA12可能和别的功能复用配置时要先把这两个引脚设成USB功能别被默认的GPIO状态干扰了。3. 实操过程与核心环节实现3.1 开发环境搭建与工程配置我用的是MounRiver Studio这是CH32系列官方推荐的IDE基于Eclipse集成了RISC-V工具链和烧录工具。下载安装后新建工程时选CH32X035对应的型号官方会带一份USB HID的例程模板直接拿来改最省事。如果你习惯用Keil也可以但需要装CH32的器件支持包和RISC-V编译插件配置起来稍麻烦。我两种都试过MounRiver在USB例程的完整性上更好开箱即用。工程建好后先确认几个关键配置系统时钟配置成48MHzUSB模块需要这个时钟。USB外设使能USB设备控制器选择HID类。UART外设使能USART1或你用的那个配置波特率、数据位、停止位。波特率要和上位机一致常用115200。中断USB中断和UART接收中断都要开优先级USB稍高。官方例程里USB HID的初始化代码已经写好了包括端点配置、描述符数组、中断处理函数。你要改的主要是两处一是报告描述符如果需要改按键定义二是主循环里的数据处理逻辑。3.2 串口接收与数据解析代码实现串口接收我用的是中断环形缓冲区的方式。中断里把收到的字节塞进缓冲区主循环里从缓冲区取数据解析。这样不会因为解析耗时导致丢字节。环形缓冲区的实现很经典两个指针一个头一个尾头进尾出。大小根据你的数据量定我用了256字节够用了。中断服务函数里判断接收标志读数据寄存器存入缓冲区更新头指针。#define UART_BUF_SIZE 256 volatile uint8_t uartBuf[UART_BUF_SIZE]; volatile uint16_t uartHead 0; volatile uint16_t uartTail 0; void USART1_IRQHandler(void) __attribute__((interrupt)); void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); uint16_t next (uartHead 1) % UART_BUF_SIZE; if(next ! uartTail) // 缓冲区未满 { uartBuf[uartHead] data; uartHead next; } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }主循环里判断缓冲区非空取出一个字节查映射表得到modifier和keycode然后调用USB发送函数。发送函数我封装了一下void sendKey(uint8_t modifier, uint8_t keycode) { uint8_t report[8] {0}; report[0] modifier; report[2] keycode; USB_SendReport(report); // 发送按下报告 delay_ms(20); // 保持按下状态20ms memset(report, 0, 8); USB_SendReport(report); // 发送松开报告 delay_ms(10); // 松开后短暂延时 }这里的延时很关键。太快了主机可能来不及识别太慢了输入速度上不去。我实测20ms按下10ms松开主机识别很稳连续输入也不会丢键。如果你要输入长字符串每个字符之间至少留10ms间隔否则主机的键盘缓冲区可能溢出。3.3 USB HID发送函数的实现细节USB发送的核心是把8字节报告通过端点1中断输入端点发出去。CH32X035的USB库提供了底层函数但要注意几个细节。首先发送前要判断端点是否空闲。如果上一次传输还没完成直接写数据会覆盖导致报告错乱。库函数一般有状态查询接口或者用标志位自己管理。uint8_t usbBusy 0; void USB_SendReport(uint8_t *report) { while(usbBusy); // 等待上一次发送完成 usbBusy 1; memcpy(EP1_Buf, report, 8); USB_SetEP1TxReady(); // 触发发送 } // 在USB中断里发送完成回调中清除usbBusy void USB_IRQHandler(void) { if(USB_GetEP1TxComplete()) { usbBusy 0; USB_ClearEP1TxComplete(); } }其次报告描述符里的端点大小要和你发送的字节数一致。键盘报告是8字节端点最大包大小就设成8。如果设成64主机可能会等更多数据导致报告延迟。第三USB枚举过程中不要发数据。等主机完成配置、SET_CONFIGURATION之后再开始发报告。官方例程里有个设备状态变量判断状态为已配置时才发。3.4 完整代码结构与关键片段整个工程的代码结构大概是main.c主循环串口数据取出、映射、发送。usb_hid.cUSB初始化、描述符、发送函数。uart.c串口初始化、中断处理、环形缓冲区。keymap.c字节到按键的映射表。主循环的逻辑很简洁int main(void) { SystemInit(); UART_Init(115200); USB_HID_Init(); while(1) { if(uartTail ! uartHead) { uint8_t data uartBuf[uartTail]; uartTail (uartTail 1) % UART_BUF_SIZE; if(data KEYMAP_SIZE) { sendKey(keyMap[data].modifier, keyMap[data].keycode); } } } }映射表我放在keymap.c里用const修饰编译后放在Flash的只读段不占RAM。表的大小根据你的协议定我用了64个条目覆盖了常用按键和组合键。烧录的时候用MounRiver自带的烧录工具选WCH-Link调试器连接SWD接口PA13/PA14。第一次烧录前确认芯片供电正常3.3V电流不超过100mA。如果烧录失败先检查SWD线有没有接反再检查芯片有没有进入低功耗模式导致调试口关闭。4. 常见问题与排查技巧实录4.1 USB枚举失败怎么办这是最常见的问题表现是插上电脑后设备管理器里出现“未知USB设备”或者干脆没反应。排查顺序如下现象可能原因排查方法完全无反应供电不足或D上拉未使能测3.3V电压检查代码里USB上拉配置未知设备描述符错误或端点配置不对用USB抓包工具看枚举过程枚举成功但无键盘报告描述符不是键盘类检查Usage Page和Usage时好时坏差分走线太长或匹配电阻缺失缩短走线加22欧姆电阻我遇到过一次枚举失败查了半天发现是D上拉电阻的问题。CH32X035内部有上拉但需要在USB初始化代码里调用使能函数我漏掉了主机一直认为没有设备插入。加上那行代码就正常了。还有一次是描述符里的VID和PID用了默认值和电脑上某个已安装的驱动冲突导致枚举后驱动加载失败。改成自定义的VID/PID就好了。VID不能随便用但调试阶段可以用0x8888之类的不冲突值。4.2 按键发送了但主机没反应USB枚举成功了键盘设备也认出来了但发报告主机不响应。这种情况一般是报告格式不对或者发送时机不对。先确认报告描述符里的Input项是不是8字节。如果描述符里写的是6字节你发8字节主机就解析不了。再确认发送的报告里按键码是不是有效的Usage ID。比如你想发‘a’用了ASCII码0x61但USB HID里‘a’是0x04主机当然不认。还有一个隐蔽的问题修饰键和按键码的顺序。有些教程里把修饰键放在报告的第0字节按键码从第2字节开始这是对的。但如果你把按键码放在第0字节修饰键放第1字节主机解析出来的就是完全不同的键。发送时机上必须在主机完成SET_CONFIGURATION之后再发。如果设备还没配置好就发报告主机会丢弃。可以在代码里加个标志USB配置完成后置位主循环里判断这个标志再发。4.3 串口数据丢字节或乱码串口接收丢数据八成是中断处理太慢或者缓冲区溢出。先确认波特率是否匹配115200下每个字节间隔约87微秒中断处理必须在87微秒内完成。如果中断里做了太多事情比如直接查表发送USB报告就可能来不及。我的做法是中断里只做一件事把数据塞进缓冲区。解析和发送都在主循环里做。这样中断执行时间极短不会丢字节。如果还是丢检查环形缓冲区大小。数据量大且主循环处理慢的时候256字节可能不够加到512或1024。另外注意缓冲区满的判断逻辑头指针追上尾指针时不能再写否则会覆盖未处理的数据。乱码一般是波特率不匹配或者时钟配置错误。CH32X035的UART时钟来源于系统时钟如果系统时钟不是预期的48MHz波特率就会偏。用示波器测一下TX引脚的实际波特率和理论值对比偏差超过3%就可能乱码。4.4 连续输入时按键丢失或重复连续发多个按键时偶尔会丢一个或者重复一个。这通常是USB发送的节奏问题。主机处理键盘报告需要时间如果两个报告间隔太短主机可能来不及处理。我实测下来按下20ms、松开10ms、字符间隔10ms这个节奏在Windows和Linux下都很稳。如果还丢把按下时间加到30ms。重复的问题一般是松开报告没发出去或者发送函数里usbBusy标志没清导致下一次发送被跳过。检查USB中断里的清除逻辑。还有一个可能是主机的键盘重复率设置。如果主机开启了“长按重复”你按住一个键超过500ms主机会自动重复。所以发送报告时按下时间不要超过500ms否则主机会以为你在长按。4.5 独家避坑技巧汇总做了几版之后我总结了几个文档里不会写的经验先跑通官方例程再改。官方例程是验证过的最小系统先确认它能枚举、能发按键再往上加串口功能。这样出问题时能快速定位是USB的问题还是串口的问题。用USB抓包工具看实际数据。软件层面的调试信息有限抓包能看到主机和设备之间的完整通信枚举失败、报告格式错误一目了然。映射表用const放Flash。不要用全局变量数组RAM宝贵映射表这种只读数据放Flash最合适。串口和USB共地。如果串口设备是外部设备一定要和CH32X035共地否则串口通信不稳定。加一个LED指示灯。USB枚举成功亮一个灯收到串口数据闪一下调试时不用接串口助手就能判断设备状态。烧录失败先查供电。CH32X035烧录时电流会增大如果供电不足会失败。用万用表测烧录瞬间的电压低于3.0V就要换电源。这个方案后续还可以扩展比如加一个拨码开关切换“文本模式”和“二进制模式”或者加个EEPROM存映射表通过串口命令在线修改映射关系。我目前做的是固定映射够用但如果你要做通用产品可配置性会是个加分项。