news 2026/10/16 4:04:46

STM32F103 USB复合HID:单接口实现键盘鼠标模拟

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 USB复合HID:单接口实现键盘鼠标模拟

简介:面向嵌入式开发者,这份资源基于STM32CubeMX配置STM32F103C8T6,实现USB复合设备(HID键盘+鼠标)的完整工程。内容涵盖USB OTG FS参数配置、HID报告描述符编写、中断服务程序设计以及MDK中的固件生成,重点展示了同一设备上两个HID接口如何协同工作,适合需掌握STM32 USB复合设备开发的中级工程师参考。压缩包共626个文件,以C源代码(345个)、头文件(114个)、汇编文件(43个)为主,另含uvprojx/uvoptx/ioc等工程配置文件,整体大小仅7.58MB,便于直接下载和快速浏览。目前已有3414人学习,工程结构完整,包含HAL库驱动、USB协议栈中间层以及调试映射文件,可直接导入STM32CubeMX重新生成代码,也可对照Keil工程观察HID枚举、报告发送与中断处理的实现细节,对深入理解复合HID设备机制有实际帮助。同时,资源保留了完整的工程配置备份与调试输出,便于排查USB枚举失败、报告描述符错误等常见问题。

1. STM32CubeMX + STM32F103C8T6:一个 USB 接口里藏两套输入设备

“一头键盘、一只鼠标,共用一个 USB 接口,插上电脑不用装驱动就被识别成两套输入设备”——这句话听起来像给 PC 接了一个硬件转换器,但用 STM32F103C8T6 这颗带全速 USB Device 外设的芯片,配合 STM32CubeMX 生成的工程,几十行代码就能实现。实现方式是 USB 复合设备,但并不是在配置描述符里挂两个 interface,而是只保留一个 HID 接口、通过 Report ID 区分键盘和鼠标。适合谁读:想给嵌入式设备增加模拟键鼠输入能力的人,以及第一次调 USB HID 不知道怎么检查枚举问题的开发者。下面按「原理 → 配置 → 改代码 → 排坑 → 再扩展」一步步拆。

2. 复合 HID 原理:一个 interface 是怎么同时承担键盘和鼠标的

2.1 先分清「设备、接口、端点、报告」这四个层

USB 是主从结构,F103 在这里只能当从设备,主机是电脑。一次枚举过程里,主机向设备请求一系列描述符,设备按固定层级返回:设备描述符说明厂商 ID、产品 ID、设备类;配置描述符说明整个设备有几种供电模式、几个接口;接口描述符说明这个接口属于哪个类;端点描述符告知主机数据从哪个端点进、哪个端点出。HID 设备还有一个报告描述符,它不是枚举时直接返回,而是主机拿到 HID Descriptor 后,通过单独请求获取的,真正的「键盘长什么样、鼠标长什么样」都写在这个数组里。

普通键盘设备在配置描述符里只有一个 HID 接口、一个输入端点;普通鼠标也是同样结构。如果要把两个设备做在一个 USB 芯片里,最直接的想法是在配置描述符里放两个 interface,第一个接口用键盘类,第二个接口用鼠标类,再给每个接口各分配一个端点。这种方案叫「多接口复合设备」,Windows 上需要额外的 HID 复合设备描述符支持,实现起来要动的描述符多得多。

这个项目里做的「一个 interface 两个设备」,本质是把 HID 报告层面的多集合堆在一个接口里。主机只会看到一个 USB 设备、一个 HID 接口、一个中断输入端点,但报告描述符里声明了两套 Collection:一套给键盘,一套给鼠标。主机根据报告的第一字节(Report ID)决定把后续数据交给键盘解析器还是鼠标解析器。

2.2 单接口多 Report ID:复合设备里最省事的姿势

F103 的 USB Device 库(USBD)是以「类」为单位组织描述符的。CubeMX 生成 HID 类后,接口描述符、端点描述符、HID 描述符都写在 usbd_hid.c 里,默认只支持单个集合。要在配置描述符里加第二个接口,得重写一堆数组,还要处理端点分配和 Windows 多接口 HID 协议,对大多数项目来说是过度设计。

HID 标准提供了一条更轻的路线:同一个接口里,用 Report ID 区分数据属于哪套设备。上报字节的第一个字节固定填 ID:填 0x01 时主机按键盘解析,填 0x02 时主机按鼠标解析。这样配置描述符几乎不用动,只需要改报告描述符数组和上报时的数据结构。修改量集中在两个文件:usbd_hid.c 和 main.c,这正适合在 STM32CubeMX 生成工程后快速落地。

需要强调的是,不要把这里的「一个接口两个设备」理解成「一个物理设备同时是两个 USB 设备」。主机会为每个 Report ID 建立独立的 HID 输入路径,所以在系统里能看到「HID 键盘设备」和「符合 HID 标准的鼠标」两个输入设备节点,但它们的父设备是同一个 USB 设备,占用的配置描述符也只有一组接口。

2.3 F103 USB 外设的能力边界:只做 Device 不上 OTG

要认清 F103C8T6 片上 USB 的位置:它只有 USB 2.0 Full Speed Device 外设,没有 OTG 控制器,也不能做 Host。这意味着这个方案只能把 F103 接到 PC、工控机等作为外设,不能反过来读 U 盘、接入键鼠。如果项目里有「让单片机识别 USB 键盘」这种需求,F103 原生外设做不到,得换带 OTG 或 HOST 的芯片。

USB 全速速率是 12Mbps,一个 9 字节的键盘报告在总线上耗时几十微秒,就算每秒发 1000 次也只占很小带宽。对键鼠输入来说,通信不是瓶颈,瓶颈反而在应用层的轮询频率和上报策略上。比如键盘扫描要做消抖,鼠标位移要做增量累加,不能每次都直接把原始坐标发出去。

还有时钟约束:全速 USB 需要 48MHz 收发时钟,F103 没有独立的 USB PLL,必须让 PLLCLK 接近某个值后做 1.5 分频得到 48MHz。CubeMX 时钟树里如果 USB 时钟没到 48MHz,枚举经常失败。这个细节放到下一章工程配置里细说。

3. 工程配置落地:从 CubeMX 生成到能枚举成功

3.1 硬件连接与 48MHz 时钟:USB 枚举的隐形门槛

STM32F103C8T6 的 USB 是 PA11 + PA12 两根引脚,PA11 是 DM(负数据线),PA12 是 DP(正数据线)。硬件上 D+ 必须通过一个 1.5kΩ 电阻上拉到 3.3V,主机才能识别到全速设备插入。很多最小系统板和核心板已经把上拉电阻做上去了,但如果自己画板子,这个电阻漏掉会导致设备一直无法枚举。还有一种常见错误是 DM/DP 接反,现象同样是「未知设备」。

时钟配置里,我建议用外部 8MHz HSE 晶振。CubeMX 的 Clock Configuration 页面里,HSE 设为 Crystal/Ceramic Resonator,PLL 倍频到 72MHz,USB 时钟那栏必须显示 48MHz。F103 的 USB 分频逻辑是 PLLCLK 除以 1.5,72MHz / 1.5 = 48MHz,这是最稳定的组合。如果板子上没有外部晶振,用 HSI 直接走 PLL 到 48MHz 也能枚举,但 HSI 精度不如 HSE,调试时更容易出毛病。

注意:CubeMX 里看的是「USB Clock」那一格,不是 SYSCLK,也不是 APB1。只要那里不是 48MHz,后面枚举出问题先回来查时钟。

3.2 CubeMX 配置步骤:USB_DEVICE 选 HID

打开 STM32CubeMX,选择 STM32F103C8T6 型号,先配置 RCC:HSE 选 Crystal/Ceramic Resonator。然后在 Connectivity 侧栏里点 USB,把 Active 勾上,PA11/PA12 会自动变成 DM/DP 功能。接着到 Middleware 里的 USB_DEVICE,Class for FS IP 选择 HID,这就是整套工程的中间件来源。

USB_DEVICE 中间件下有若干参数,比如 Minimum Refresh Rate 等,保持默认即可。HID 类是 USB Device Library 自带的标准实现,不需要额外下载扩展包。CubeMX 会生成一套完整的 USB 协议栈代码,包括设备描述符 usbd_desc.c、类实现 usbd_hid.c、底层平台配置 usbd_conf.c。应用层要跑起来,只需要在 main.c 里初始化 USB 并在主循环里发报告。

生成工程前,建议 Project Manager 里选好 MDK-ARM 或 Makefile,外置 Flash 设置不需要动。生成后先编译一次,再打开 usbd_hid.c 改描述符。这样能先确认 CubeMX 环境没问题,避免一上来就报一堆编译错误时分不清是配置问题还是代码问题。

3.3 生成代码结构:改动集中在 usbd_hid.c

生成后工程的核心结构大致如下:

Core/ Inc/ Src/ main.c usbd_conf.c usbd_desc.c usb_device.c Drivers/ STM32F1xx_HAL_Driver/ Middlewares/ ST/STM32_USB_Device_Library/ Core/src/usbd_core.c Class/HID/src/usbd_hid.c

真正要改的文件很少。设备描述符 usbd_desc.c 里可以改 VID/PID,不改也能枚举;平台相关 usbd_conf.c 里默认走 CubeMX 生成的时钟和 GPIO 初始化;HID 类实现 usbd_hid.c 里有两个最关键的数组:HID_ReportDesc 是报告描述符,HID_MOUSE_ReportDesc 是库自带的单鼠标描述符。后者通常不用,前者必须替换成我们自己写的复合描述符。

如果想给设备换一个更好认的名字,USB String Descriptor 在 usbd_desc.c 的 USBD_StrDesc 数组里改,默认是 STM32 Composite Device 之类的文本。VID/PID 不建议乱填,容易被某些上层驱动拦截;调试阶段保持默认或改成自己申请的即可。

4. 核心代码拆解:报告描述符、数据结构和发送链路

4.1 复合 HID 报告描述符:Report ID 1 是键盘、2 是鼠标

打开 usbd_hid.c,找到 HID_ReportDesc,默认内容是一份标准鼠标描述符。整个方案的第一个关键动作,就是把它替换成下面这份复合描述符。代码里的注释写明了每一段的含义,方便对着改。

__ALIGN_BEGIN static uint8_t HID_ReportDesc[] __ALIGN_END = { /* ===== 键盘集合,Report ID = 1 ===== */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x06, /* Usage (Keyboard) */ 0xA1, 0x01, /* Collection (Application) */ 0x85, 0x01, /* Report ID = 1 */ /* 修饰键区:Ctrl、Shift、Alt、Win 等 8 个 bit */ 0x05, 0x07, /* Usage Page (Keyboard/Keypad) */ 0x19, 0xE0, /* Usage Minimum (Left Control) */ 0x29, 0xE7, /* Usage Maximum (Right GUI) */ 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, /* Report Size: 1 bit */ 0x95, 0x08, /* Report Count: 8 */ 0x81, 0x02, /* Input (Data, Variable, Absolute) */ /* 1 字节保留位,固定填 0 */ 0x95, 0x01, 0x75, 0x08, 0x81, 0x01, /* Input (Constant) */ /* 6 字节普通按键码 */ 0x95, 0x06, 0x75, 0x08, 0x19, 0x00, 0x29, 0x65, /* Usage Min 0, Max 101 */ 0x81, 0x00, /* Input (Data, Array) */ 0xC0, /* End Collection */ /* ===== 鼠标集合,Report ID = 2 ===== */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x02, /* Usage (Mouse) */ 0xA1, 0x01, /* Collection (Application) */ 0x85, 0x02, /* Report ID = 2 */ 0x09, 0x01, /* Usage (Pointer) */ 0xA1, 0x00, /* Collection (Physical) */ /* 鼠标按键:左键、右键、中键,3 个 bit */ 0x05, 0x09, /* Usage Page (Buttons) */ 0x19, 0x01, 0x29, 0x03, 0x15, 0x00, 0x25, 0x01, 0x95, 0x03, 0x75, 0x01, 0x81, 0x02, /* Input (Data, Variable, Absolute) */ /* 5 bit 填充,凑满 1 字节 */ 0x95, 0x01, 0x75, 0x05, 0x81, 0x01, /* Input (Constant) */ /* 相对位移 X / Y / Wheel */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x30, /* Usage (X) */ 0x09, 0x31, /* Usage (Y) */ 0x09, 0x38, /* Usage (Wheel) */ 0x15, 0x81, 0x25, 0x7F, /* Logical Minimum -127, Maximum 127 */ 0x75, 0x08, 0x95, 0x03, 0x81, 0x06, /* Input (Data, Variable, Relative) */ 0xC0, /* End Physical */ 0xC0 /* End Application */ };

这份描述符里,Report ID 本身不参与描述符里定义的 Input 位宽,但传输时作为报告的第一个字节存在。所以键盘完整报告长度是 1 字节 ID + 1 字节修饰键 + 1 字节保留 + 6 字节按键码 = 9;鼠标完整报告长度是 1 字节 ID + 1 字节按键状态 + 3 字节位移 = 5。后面结构体定义必须和这个长度严格对应。

4.2 键盘鼠标消息结构体:发送前先在内存里整包拼好

应用层不直接拼裸字节数组,用结构体更直观。注意 USB HID 是按字节流解析的,结构体一旦被编译器加上对齐填充,sizeof 就不等于报告长度,发送出去轻则干扰解析,重则导致主机拒绝设备。我在工程里用了按字节压缩的结构体,Keil 环境下用__PACKED,GCC 环境下用__attribute__((packed))。

typedef struct { uint8_t id; /* 固定填 0x01 */ uint8_t modifier; /* bit0-Ctrl bit1-Shift bit2-Alt bit3-Win */ uint8_t reserved; /* 固定填 0 */ uint8_t keycode[6]; /* 普通按键 HID Usage ID,最多 6 键同时 */ } __PACKED KB_Report_t; typedef struct { uint8_t id; /* 固定填 0x02 */ uint8_t buttons; /* bit0-左键 bit1-右键 bit2-中键 */ int8_t x; /* 相对位移,-127 ~ 127 */ int8_t y; /* 相对位移 */ int8_t wheel; /* 滚轮 */ } __PACKED MS_Report_t;

键盘的 modifier 和按键码是两个字段。按 A 键时,keycode[0] 要填 0x04,而不是 0x41(ASCII 的 'A')。HID 标准里每个物理键有独立 Usage ID,字母从 0x04 开始编号,数字行从 0x1E 开始,F1~F12 从 0x3A 开始。如果发送的按键码不对,会出现「按 A 出 B」这类问题,它不是设备问题,是码表用错。

4.3 上报调用链:应用层到 USB 中断端点的完整路径

F103 的 USB 端点发送由硬件完成,应用层只是把报告交给协议栈。CubeMX 生成的 HID 类对外暴露了 USBD_HID_SendReport,原型如下:

USBD_StatusTypeDef USBD_HID_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len);

内部逻辑很简单:如果 HID 类当前状态是 IDLE,就把状态改成 BUSY,然后调用 USBD_LL_Transmit,把 report 指针指向的数据写入端点寄存器;如果当前已经 BUSY,说明上一次报告还没发完,直接返回 USBD_BUSY。发送完成后 USB 中断会触发,协议栈再把状态改回 IDLE。

一次实际发送是这样组织的:

KB_Report_t kb; uint8_t usb_busy = 0; void Send_Keyboard(void) { if (usb_busy) { return; /* 上一次没发完,丢本次输入 */ } kb.id = 0x01; kb.modifier = 0x00; /* 不加 Ctrl/Shift */ kb.reserved = 0x00; kb.keycode[0] = 0x04; /* A 键 */ kb.keycode[1] = 0x00; if (USBD_HID_SendReport(&hUsbDeviceFS, (uint8_t *)&kb, sizeof(kb)) == USBD_OK) { usb_busy = 1; /* 进入发送忙状态 */ } }

这里usb_busy必须放在传输完成回调里清掉。CubeMX 生成的 usbd_hid.c 里有 USBD_HID_TxSent,它是每次端点传输完成后的回调,可以在里面做标志位清理。如果不维护 busy 标志,直接在主循环里高频调用 USBD_HID_SendReport,会出现「第二个包永远发不出去」的假死现象,原理就是上一个报告还在端点里,协议栈拒绝接收新报告。

另外,在 USB 还没被主机配置完成时调用发送,函数返回的不是 USBD_OK,最好在调用前判断hUsbDeviceFS.dev_state == USBD_STATE_CONFIGURED。这个字段在 USB 主机完成枚举后才会变成 CONFIGURED,插上电脑前发报告没有意义,反而可能让类状态错乱。

5. 避坑与排查:四个让复合 HID 翻车的点

5.1 现象 1:设备管理器一直识别成「未知设备」

插入电脑后,设备管理器出现带感叹号的 Unknown Device,或者反复提示「USB 设备已连接 / 已断开」。

原因是多方面的,最先是时钟。USB 全速要求 48MHz 收发时钟,如果步兵时钟树里的 USB Clock 没到 48MHz,主机根本解不出合法的枚举时序。其次是 D+ 上拉电阻缺失,主机会认为设备未插入。第三是 PA11/PA12 引脚接反,也会导致枚举失败。

解决顺序:先在 CubeMX 里确认 USB Clock 那一栏是 48MHz,再检查原理图 PA11 接 DM、PA12 接 DP、D+ 到 3.3V 间有 1.5kΩ 上拉。硬件没问题后,单独拉一个最小工程只发送固定报告验证枚举,不要在没枚举成功时就跑完整应用。

5.2 现象 2:键盘正常,鼠标指针纹丝不动

设备管理器里键盘节点正常,按键能出来,鼠标就是不动。

典型原因是发送结构体时id字段填错了。描述符里鼠标集合声明了 Report ID = 2,如果结构体id填了 1,主机会把鼠标数据按键盘解析,键盘解析器认为这是键盘报告,多字节数据落在按键码区域,鼠标路径拿不到数据。还有一种情况是描述符里鼠标集合的 Usage Page 写错,比如多写了一组 Generic Desktop 导致集合嵌套关系不对,Windows 解析器找不到 Pointer 集合。

解决:先用 USB 描述符查看工具读出设备的 report descriptor,确认 0x85 0x02 确实出现在鼠标集合开头;再在发送前打印结构体前 9 个字节,确认首字节是 0x02。

5.3 现象 3:连续快速发送几个报告后整体卡死

设备刚插上时能工作几秒,主循环里连续发几包,之后就完全不动,重新插拔又恢复。

原因基本都出在 busy 状态没有清。USBD_HID_SendReport 在上一包传输未完成时返回 USBD_BUSY,如果应用把返回值忽略,下一次直接修改共享的报告结构体并再次调用,协议栈内部的 HID 状态还是 BUSY,所有新发送请求被拒绝。更隐蔽的情况是主循环和 USB 中断同时访问同一个报告结构体,数据被改乱了。

解决:应用层一定要维护自己的 busy 标志,在 USBD_HID_TxSent 回调里清;如果是多线程任务,发送前加临界区或关中断保护结构体赋值。报告结构体的修改和发送必须在一个地方完成,不要一边轮询改数据一边在回调里发送。

5.4 现象 4:按键没反应,或者「按 A 出 B」

设备被识别,上报也不报错,但输入内容和预期对不上。

原因是 HID 键盘码不等于 ASCII。标准键盘 Usage ID 里,字母 A 是 0x04,B 是 0x05,数字 1 是 0x1E,回车是 0x28。如果直接把 ASCII 的 0x41 填入 keycode,主机收到后只会把它当成一个不存在或者超范围的键位,通常表现为没反应。另一个坑是修饰键和普通键混用,Ctrl 同时按下时要在 modifier 里置位,而不是再发一个键码零。

解决:建一个unsigned char ascii_to_hid[128]映射表,字符输入先查表再发送。调试时先在记事本里按一个键验证,再用设备管理器确认是键盘节点还是鼠标节点在响应。

6. 二次开发:从这套键盘鼠标扩展出更实用的复合设备

6.1 再塞一个媒体控制设备

既然一个接口里能放两套集合,自然能放第三套。给描述符追加一个 Consumer Usage Page 的集合,Report ID 设为 3,Usage Page 是 0x0C,Usage 是 0x01,然后声明 2 字节输入报告:第一字节 Report ID,第二字节媒体键码。发送时id=0x03,数据里填音量增大 0xE9 或播放/暂停 0xCD,Windows 直接把这份报告映射到系统媒体控制。这样同一根 USB 线连的是键盘、鼠标还有遥控器,对 HTPC 类项目很实用。

6.2 把鼠标改成绝对坐标

默认鼠标是相对位移,适合控制指针。如果做成触摸屏校准、绘图仪、自动化点位点击,需要绝对坐标模式。把描述符里 X 和 Y 的 Logical Minimum/Maximum 改成 0~32767,报告内容从相对位移换成绝对值,同时保留 Report ID=2。注意绝对坐标模式下,鼠标集合里不能再用 Relative 标志,要改成 Absolute,否则 Windows 还是按相对模式处理。

6.3 验证这套改动的惯用工具

我每次改完描述符不会急着插 USB 试手感。先在 CubeMX 里确认生成代码没被手动改动覆盖,再打开 usbd_hid.c 检查 HID_ReportDesc 的 Report ID 声明,接着用 USB 描述符查看工具读一次设备实际返回的报告描述符,确认和数组一字不差。最后插上设备,在记事本和画图板里分别测键盘和鼠标。从那以后我每次改描述符,都会强制走一遍「查看工具读描述符 → 检查 Report ID → 再上机测试」这个流程,直接让我少踩了很多「看起来像是硬件问题、实际只是描述符多了一个 0x00」的暗坑,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 4:04:39

C# Socket通信实战:TCP粘包、心跳与断线重连详解

1. 为什么我们还在聊SocketSocket这个词,干这行的人耳朵都听出茧子了。但有意思的是,我面过不少自称"熟悉网络编程"的候选人,一问到Socket底层怎么收发数据、粘包怎么处理、心跳为什么必须自己实现,能讲清楚的不超过三成…

作者头像 李华
网站建设 2026/10/16 4:03:42

线性回归实战:用Python预测疫情走势与避坑指南

做数据分析这几年,我被问得最多的一句话就是:能不能帮我预测一下明天的数据?换成疫情相关的场景,就变成“能不能预测一下某传染病的走势”。说实话,这种问题听起来很宏大,但落到技术层面,很多人…

作者头像 李华
网站建设 2026/10/16 4:03:21

VS2019内联汇编实战:从环境配置到性能优化与调试技巧

1. 为什么还要聊VS2019内联汇编1.1 一个被低估的调试利器你可能觉得内联汇编是上个时代的东西,现在写代码谁还碰这玩意儿。但我在实际项目里踩过几次坑之后发现,有些场景还真绕不开它。比如你在做一个性能极度敏感的算法模块,C编译器生成的汇…

作者头像 李华
网站建设 2026/10/16 4:02:59

SDC命令详解:使用set_load命令进行约束

相关阅读 SDC命令详解https://blog.csdn.net/weixin_45791458/category_12931432.html?spm1001.2014.3001.5482 目录 指定负载值 指定对象列表/集合 简单使用 指定最大、最小条件 对于端口 对于线网 指定设置端口的引脚、互连负载 指定减去引脚负载 移除负载值 Multicorner-M…

作者头像 李华
网站建设 2026/10/16 4:02:55

cert-manager 与 ACME DNS01 实现 Kubernetes 证书全自动管理

某天凌晨三点,手机告警群突然炸了——某个生产集群的Kubernetes Ingress证书过期,所有HTTPS访问全部报错,浏览器里那个刺眼的“不安全”提示持续了整整二十分钟。也是从那次之后,我彻底把 cert-manager 配合 ACME DNS01 挑战这套自…

作者头像 李华
网站建设 2026/10/16 4:02:50

MFC中CListCtrl数据导出Excel:二进制写入方案与避坑指南

简介:这份资源面向使用 MFC 进行桌面开发的程序员,聚焦于将 CListCtrl 控件中的列表数据导出到 Excel 表格这一常见需求。内容围绕 COM 自动化接口展开,涵盖 Excel 应用程序对象创建、工作簿与工作表操作、逐行逐列读取 ListCtrl 单元格数据并…

作者头像 李华