C++实现USB数据监控:从协议解析到HID键盘捕获实战

发布时间:2026/9/16 7:32:04

C++实现USB数据监控:从协议解析到HID键盘捕获实战 1. 项目概述从“数据线”到“数据流”的洞察USB接口这个我们每天插拔无数次的小小矩形口早已成为数字世界与现实世界交互的物理基石。从传输一份文档到连接一个键盘它承载着海量、实时的数据流。然而对于开发者、安全研究员乃至硬件爱好者而言这些在数据线上“无声流淌”的字节往往是一个黑盒。我们只知道数据“进去了”或“出来了”却对其中具体的内容、时序、协议细节一无所知。这正是“USB监控技术”试图解决的问题——它就像给USB数据流安装了一个高清摄像头和录音笔让我们能够捕获、解析并理解每一个数据包。这个项目的核心就是利用C构建一套底层的USB监控与分析工具。为什么是C因为USB通信发生在操作系统内核与硬件驱动层面对性能、实时性和直接硬件访问能力要求极高。C凭借其零成本抽象、直接内存操作能力以及与C语言API的无缝兼容性成为深入操作系统底层、与USB驱动栈“对话”的不二之选。它允许我们编写既高效又灵活的程序直接与libusb、WindowsWinUSBAPI或内核驱动交互实现原始数据的抓取。而源代码分析则是将捕获到的二进制数据流按照USB协议规范如设备描述符、配置描述符、各种请求进行解码和可视化将晦涩的十六进制码转化为人类可读的“会话记录”。无论你是想逆向工程一个不公开协议的USB设备调试自己开发的USB固件分析系统的USB设备枚举行为还是进行安全审计检查是否有异常设备插入或恶意数据注入掌握这套技术都至关重要。它让你从被动的USB使用者转变为主动的通信观察者和分析者。2. 核心原理与监控方案选型要实现USB监控首先得理解数据在哪一层被拦截和解读。不同的监控层次决定了你能看到的信息粒度、开发复杂度以及对系统的影响。2.1 USB通信协议栈与监控切入点一个USB数据包从应用层到物理层的旅程大致经过以下层次应用层/用户态你的应用程序调用像freadwrite这样的标准I/O函数。操作系统内核驱动栈包括功能驱动如usbstor.sys用于U盘、USB总线驱动usbhub.sys、主机控制器驱动如usbxhci.sysfor USB 3.0。主机控制器硬件负责将数据包转换成物理信号在总线上传输。监控的切入点主要有三个用户态API Hook拦截应用程序对libusb、WinUSB等用户态库的调用。这种方法相对简单安全但只能看到经过这些库封装后的数据会丢失底层总线时序、ACK/NAK握手包等细节且无法监控内核驱动直接处理的设备如HID键盘、大容量存储设备。内核态驱动过滤在Windows下编写一个WDM或WDF过滤驱动程序附加到目标USB设备栈或整个USB总线驱动上。这是功能最强大、信息最全的方式可以捕获所有IRPI/O请求包包括控制传输、批量传输、中断传输和等时传输的每一个细节。但开发难度大需要驱动签名一个错误的驱动可能导致系统蓝屏BSOD。专用硬件嗅探器如Beagle USB协议分析仪。它物理上串联在USB主机和设备之间非侵入式地捕获所有电气信号并将其解码为协议数据。这是最权威、最不影响系统的方式但成本高昂。对于大多数软件开发者而言在用户态基于现有库进行监控是性价比最高的起点。而C项目通常选择libusb跨平台或Windows的SetupAPI、WinUSB组合来实现。2.2 基于libusb的跨平台监控框架解析libusb是一个开源的用户态库它提供了统一的API让开发者可以绕过操作系统的标准驱动直接与USB设备进行通信。我们的监控工具可以利用它来“劫持”设备。核心思路是枚举与定位遍历系统所有USB设备通过供应商IDVID、产品IDPID或设备描述符找到目标设备。设备劫持在监控工具中使用libusb_open_device_with_vid_pid打开目标设备。一旦成功系统原有的驱动如键盘驱动将失去对该设备的控制权。数据拦截我们成为设备的新“主人”可以主动发起控制传输libusb_control_transfer来获取描述符也可以为中断或批量传输端点libusb_alloc_transferlibusb_submit_transfer提交异步请求。所有设备发送的数据都会通过我们注册的回调函数送达。数据转发与记录在回调函数中我们将收到的数据包解析、打印或保存到日志文件。同时为了不影响设备原有功能例如监控键盘时不能导致键盘失灵我们可能需要将数据包“原样转发”给一个虚拟的或原有的设备接口这需要更复杂的设置如创建虚拟设备驱动。注意直接使用libusb接管像键盘、鼠标这样的HID设备在Windows上可能会遇到权限问题需要以管理员身份运行程序或者为设备安装特定的libusb驱动通过Zadig工具。在Linux上通常需要将用户加入plugdev组或配置udev规则。2.3 Windows平台特有方案SetupDI与WinUSB在纯Windows环境下我们可以利用其原生API构建更底层的监控工具无需额外安装libusb驱动。设备发现SetupAPI使用SetupDiGetClassDevs函数获取所有USB设备的信息集然后遍历SetupDiEnumDeviceInterfaces枚举设备接口通过SetupDiGetDeviceInterfaceDetail获取设备的详细路径如\\?\usb#vid_046dpid_c332#...。设备通信WinUSB使用WinUsb_Initialize函数传入上述设备路径句柄初始化WinUSB会话。之后便可以使用WinUsb_ReadPipe、WinUsb_WritePipe、WinUsb_ControlTransfer等函数与设备的各个端点进行通信。监控实现监控的核心在于“读取”设备主动发送的数据如键盘按键。这通常通过异步I/O或重叠I/OOverlapped I/O来完成。我们为需要监控的输入端点通常是INTERRUPT IN类型发起一个异步读请求。当设备有数据到来时该请求完成我们便在回调函数中处理数据包然后立即发起下一个异步读请求形成一个持续的监控循环。这种方法更贴近Windows系统底层性能较好但代码复杂度高于libusb。3. C源代码实战构建一个简易HID键盘监控器让我们以一个具体的例子来贯穿上述理论用C和libusb编写一个监控USB HID键盘输入的工具。这个工具将捕获按键的按下和释放事件并打印出对应的键值。3.1 环境准备与项目配置首先你需要一个支持C17及以上标准的开发环境如Visual Studio 2022 GCC 10并安装libusb库。Windows (MSVC)从libusb官网下载预编译的二进制包例如libusb-1.x.x.7z。解压后将include/libusb-1.0目录添加到项目的“附加包含目录”。将MS64\dll\libusb-1.0.lib用于静态链接或MS64\dll\libusb-1.0.dll.lib用于动态链接添加到“附加依赖项”。将libusb-1.0.dll复制到你的可执行文件输出目录。Linux (GCC)通常通过包管理器安装如sudo apt install libusb-1.0-0-dev。编译时添加链接参数-lusb-1.0即可。创建一个新的C控制台项目确保包含libusb.h头文件。3.2 核心数据结构与设备枚举HID键盘的通信基于报告描述符Report Descriptor和报告Report。一个简单的按键报告可能只有几个字节。我们需要定义对应的数据结构。#include iostream #include vector #include thread #include atomic #include libusb-1.0/libusb.h // 一个简单的HID键盘输入报告结构示例实际结构需根据报告描述符解析 // 通常第一个字节是Modifier KeysCtrl, Shift, Alt, GUI后面是6个普通按键码 struct HidKeyboardReport { uint8_t modifier; uint8_t reserved; uint8_t keycode[6]; }; // 全局变量用于控制监控循环 std::atomicbool g_monitoring{true}; // 查找并返回指定VID/PID的USB设备句柄 libusb_device_handle* open_keyboard_device(uint16_t vid, uint16_t pid) { libusb_device_handle* dev_handle nullptr; // 尝试直接通过VID/PID打开设备 dev_handle libusb_open_device_with_vid_pid(nullptr, vid, pid); if (dev_handle nullptr) { std::cerr 无法找到或打开设备 VID: std::hex vid PID: pid std::dec std::endl; // 可以在这里实现更复杂的枚举逻辑列出所有设备供用户选择 libusb_device** devs; ssize_t cnt libusb_get_device_list(nullptr, devs); if (cnt 0) return nullptr; for (ssize_t i 0; i cnt; i) { libusb_device_descriptor desc; if (libusb_get_device_descriptor(devs[i], desc) 0) { std::cout 发现设备: Bus libusb_get_bus_number(devs[i]) Device libusb_get_device_address(devs[i]) VID: std::hex desc.idVendor PID: desc.idProduct std::dec std::endl; } } libusb_free_device_list(devs, 1); } return dev_handle; }3.3 数据捕获与解析循环打开设备后我们需要找到正确的输入端点INTERRUPT IN并启动异步传输。// 监控键盘输入的主函数 void monitor_keyboard(libusb_device_handle* dev_handle) { if (dev_handle nullptr) return; // 声明接口Claim Interface从内核驱动手中接管设备 int interface_num 0; // HID键盘通常使用接口0 int r libusb_claim_interface(dev_handle, interface_num); if (r 0) { std::cerr 声明接口失败: libusb_error_name(r) std::endl; libusb_close(dev_handle); return; } // 在实际项目中这里应该解析配置描述符和端点描述符 // 动态查找类型为LIBUSB_TRANSFER_TYPE_INTERRUPT方向为LIBUSB_ENDPOINT_IN的端点。 // 这里我们假设HID键盘的输入端点地址是0x81这是一个常见值。 unsigned char endpoint_in 0x81; // INTERRUPT IN endpoint std::cout 开始监控键盘输入... 按CtrlC退出。 std::endl; HidKeyboardReport report; int actual_length 0; while (g_monitoring) { // 发起同步中断传输。异步传输更高效但代码更复杂。 r libusb_interrupt_transfer(dev_handle, endpoint_in, reinterpret_castunsigned char*(report), sizeof(report), actual_length, 1000); // 超时时间1000ms if (r 0 actual_length 0) { // 成功接收到数据 parse_and_print_report(report, actual_length); } else if (r LIBUSB_ERROR_TIMEOUT) { // 超时是正常的表示这段时间没有按键事件 continue; } else { // 其他错误 std::cerr 传输错误: libusb_error_name(r) std::endl; if (r LIBUSB_ERROR_NO_DEVICE) { std::cerr 设备已断开。 std::endl; break; } } } // 清理工作 libusb_release_interface(dev_handle, interface_num); libusb_close(dev_handle); } // 解析并打印报告 void parse_and_print_report(const HidKeyboardReport report, int length) { std::cout 收到报告长度: length 字节 std::endl; std::cout Modifier: 0x std::hex static_castint(report.modifier) std::dec (; // 解析修饰键 if (report.modifier 0x01) std::cout LeftCtrl ; if (report.modifier 0x02) std::cout LeftShift ; if (report.modifier 0x04) std::cout LeftAlt ; if (report.modifier 0x08) std::cout LeftGUI ; if (report.modifier 0x10) std::cout RightCtrl ; if (report.modifier 0x20) std::cout RightShift ; if (report.modifier 0x40) std::cout RightAlt ; if (report.modifier 0x80) std::cout RightGUI ; std::cout ) std::endl; // 解析普通按键 std::cout 按键码: ; for (int i 0; i 6; i) { if (report.keycode[i] ! 0) { std::cout 0x std::hex static_castint(report.keycode[i]) std::dec ; // 这里可以添加一个将HID Usage ID映射为字符如‘A’ ‘Enter’的函数 // print_key_char(report.keycode[i]); } } std::cout std::endl --- std::endl; }3.4 主函数与信号处理最后编写主函数来初始化libusb库打开设备并处理退出信号。// 信号处理函数用于优雅退出 #ifdef _WIN32 #include windows.h BOOL WINAPI console_handler(DWORD signal) { if (signal CTRL_C_EVENT) { g_monitoring false; return TRUE; } return FALSE; } #else #include csignal void signal_handler(int signal) { if (signal SIGINT) { g_monitoring false; } } #endif int main(int argc, char* argv[]) { // 默认的键盘VID/PID以罗技为例。你需要替换成你目标键盘的VID/PID。 uint16_t target_vid 0x046d; // Logitech uint16_t target_pid 0xc332; // 示例PID请根据实际情况修改 // 初始化libusb int r libusb_init(nullptr); if (r 0) { std::cerr 初始化libusb失败 std::endl; return 1; } // 设置控制台信号处理以便用CtrlC退出 #ifdef _WIN32 SetConsoleCtrlHandler(console_handler, TRUE); #else std::signal(SIGINT, signal_handler); #endif // 打开设备 libusb_device_handle* keyboard_handle open_keyboard_device(target_vid, target_pid); if (keyboard_handle) { monitor_keyboard(keyboard_handle); } else { std::cerr 未能打开目标键盘设备。请检查VID/PID或使用管理员权限运行。 std::endl; // 提示用户如何使用Zadig工具安装libusb驱动Windows std::cout \n在Windows上你可能需要使用Zadig工具为你的键盘安装WinUSB或libusb驱动才能从用户态接管它。 std::endl; std::cout 注意这会使系统自带的键盘驱动失效监控结束后需在设备管理器中卸载设备并重新扫描或使用Zadig恢复原驱动。 std::endl; } // 清理libusb libusb_exit(nullptr); return 0; }实操心得获取正确的VID/PID和端点地址是关键。你可以先运行枚举代码查看插入键盘后打印出的设备列表。端点地址如0x81需要查阅设备的USB描述符。一个更健壮的方法是在open_keyboard_device函数中打开设备后使用libusb_get_config_descriptor等函数遍历配置、接口和端点描述符自动寻找符合INTERRUPT IN类型的端点。4. 高级话题协议解析、性能优化与安全考量一个基础的监控器已经成型但要将其打造成一个实用的分析工具还需要考虑更多。4.1 动态解析USB描述符硬编码端点地址和报告结构是不通用的。专业的监控工具应该能动态解析USB描述符。获取设备描述符libusb_get_device_descriptor。获取配置描述符libusb_get_config_descriptor。一个设备可以有多个配置但一次只能激活一个。遍历接口和端点在配置描述符中包含多个接口interface每个接口下有多个端点描述符endpoint_descriptor。我们需要找到bInterfaceClass为0x03HID类的接口并在其下寻找bEndpointAddress最高位为1表示IN方向且bmAttributes的低两位为0x03表示中断传输的端点。解析HID报告描述符对于HID设备还需要获取并解析报告描述符通过Get_Descriptor请求类型为HID。这需要使用HID报告描述符解析库如hidrdd过程非常复杂它定义了报告的长度、每个字段的用途和逻辑值范围。4.2 异步传输与性能优化同步传输libusb_interrupt_transfer在超时时间内会阻塞线程。对于需要高实时性或同时监控多个端点的场景应使用异步传输libusb_transfer。struct TransferContext { libusb_transfer* transfer; unsigned char buffer[64]; // 根据端点大小调整 // ... 其他上下文信息 }; void async_callback(struct libusb_transfer* transfer) { TransferContext* ctx reinterpret_castTransferContext*(transfer-user_data); if (transfer-status LIBUSB_TRANSFER_COMPLETED) { // 处理数据 ctx-buffer parse_and_print_report(*(reinterpret_castHidKeyboardReport*(ctx-buffer)), transfer-actual_length); // 重新提交传输形成循环 libusb_submit_transfer(transfer); } else { // 处理错误 std::cerr 异步传输错误: transfer-status std::endl; g_monitoring false; } } // 初始化并提交异步传输 void setup_async_transfer(libusb_device_handle* dev_handle, unsigned char endpoint) { TransferContext* ctx new TransferContext(); ctx-transfer libusb_alloc_transfer(0); libusb_fill_interrupt_transfer(ctx-transfer, dev_handle, endpoint, ctx-buffer, sizeof(ctx-buffer), async_callback, ctx, // user_data 0); // timeout int r libusb_submit_transfer(ctx-transfer); if (r 0) { // 错误处理 delete ctx; } } // 在主循环中需要调用 libusb_handle_events(nullptr) 或 libusb_handle_events_completed 来处理事件。使用异步传输后主线程可以解放出来处理UI或其他逻辑性能更好。4.3 安全、伦理与法律边界USB监控是一把双刃剑。隐私侵犯在未经他人明确同意的情况下监控其键盘输入记录密码、聊天内容是严重的违法行为。系统稳定性编写质量低劣的USB驱动或监控程序可能导致系统不稳定、设备失灵甚至数据损坏。恶意软件载体此类技术常被用于制作键盘记录器等恶意软件。开发和使用原则仅用于合法目的设备调试、安全研究在授权范围内、协议学习、个人设备自动化。明确告知与授权在任何涉及他人设备或数据的测试中必须获得明确授权。隔离测试环境尽可能在虚拟机、备用机器或与生产环境隔离的测试设备上进行。代码审慎确保代码有良好的错误处理避免资源泄漏如忘记释放libusb_transfer或关闭设备句柄。5. 常见问题排查与调试技巧在实际开发中你会遇到各种各样的问题。下面是一个快速排查指南。问题现象可能原因排查步骤与解决方案libusb_open_device_with_vid_pid返回NULL1. VID/PID错误。2. 设备已被系统驱动占用。3. 权限不足Linux。4. 设备未连接或已损坏。1. 运行枚举代码确认VID/PID。2. (Windows) 使用Zadig为设备安装libusb或WinUSB驱动。3. (Linux) 使用sudo运行或配置udev规则。4. 检查设备连接。libusb_claim_interface失败1. 接口已被其他程序或驱动占用。2. 接口号错误。1. 关闭可能占用该设备的其他软件如键盘宏软件。2. 遍历设备的接口描述符确认正确的接口号。传输函数返回LIBUSB_ERROR_TIMEOUT1. 端点地址错误。2. 设备没有数据发送对于IN传输。3. 传输类型或方向错误。1. 动态解析端点描述符确认IN端点地址。2. 检查设备是否正常工作如按键。3. 确认使用的是libusb_interrupt_transfer且方向正确。接收到的数据全是0或乱码1. 报告数据结构定义错误。2. 端点实际数据长度与预期不符。3. 需要先发送特定请求如SET_REPORT设备才开始发送数据。1. 根据HID报告描述符正确定义结构体。2. 打印actual_length调整缓冲区大小。3. 查阅设备协议可能需要先发送一个控制传输来初始化设备状态。程序崩溃或内存泄漏1. 未检查指针是否为NULL。2. 异步传输上下文未正确管理生命周期。3. 未在循环中调用libusb_handle_events。1. 对所有libusb_开头的函数返回值进行错误检查。2. 确保在传输完成或程序退出时使用libusb_free_transfer释放资源。3. 在异步模式下确保有线程持续处理事件。监控时原设备功能失效成功“劫持”了设备但未将数据转发给系统。这是预期行为。若需“嗅探”而非“劫持”需更复杂方案1. (Windows) 编写内核过滤驱动。2. 使用硬件协议分析仪。3. 创建虚拟设备并双向转发数据高级。调试技巧启用libusb调试日志在调用libusb_init之前设置环境变量LIBUSB_DEBUG3Linux/macOS或在代码中调用libusb_set_debug(nullptr, 3)。这会打印出详细的库内部操作信息。使用Wireshark进行USB抓包Wireshark配合USBPcap插件 on Windows可以捕获系统级的USB流量是验证你监控程序结果是否正确、学习USB协议交互的终极利器。你可以对比Wireshark抓到的包和你程序解析出的数据。分步验证先写一个简单的程序只实现设备枚举和打开。成功后再添加声明接口的代码接着再添加数据传输代码。每一步都验证成功后再继续。从理解USB协议栈开始到选择libusb或原生API作为武器再到用C一步步构建出能从数据流中解读出按键事件的监控器这个过程本身就是对系统底层通信一次深刻的剖析。记住能力越大责任越大这项技术请务必用在正途。当你看到自己编写的程序第一次正确打印出你按下的字母时那种穿透抽象层、直接触摸到数据实体的成就感正是系统编程的魅力所在。
延伸阅读

更多相关文章

2026/9/8 16:09:22

C语言实现五子棋AI:从数据结构到Alpha-Beta剪枝算法详解

1. 项目概述:从棋盘到大脑的C语言之旅五子棋,一个规则简单到三岁小孩都能理解的游戏,却蕴含着足以让计算机科学家着迷的复杂性。当我们在棋盘上落下一枚棋子时,大脑在瞬间完成了对局势的评估、对对手意图的揣测以及对未来几步的推…

2026/9/16 4:01:57

Android 12后台限制与WorkManager加急作业实践

1. Android 12后台限制与WorkManager的变革Android 12带来的最显著变化之一就是针对后台服务的严格限制。从实际开发经验来看,这种限制直接影响了我们处理后台任务的方式。在Android 12之前,开发者可以相对自由地使用前台服务执行重要任务,但…

2026/9/15 16:44:41

Python使用pikepdf提取PDF隐藏文本的完整方案

1. 问题背景:为什么PDF文本无法直接复制?最近处理一份PDF文档时遇到了一个棘手问题:当我尝试复制其中的文字内容时,系统却把整段文字当作图片复制了。这种情况在扫描版PDF、某些加密文档或经过特殊处理的文件中尤为常见。作为经常…

2026/9/16 7:29:29

LabVIEW在高铁应答器出厂测试中的工业级应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/16 7:29:29

警惕AI服务虚假消息:如何识别ChatGPT订阅类谣言

我无法基于该标题生成符合要求的博文内容。原因如下:该标题“OpenAI宣布:ChatGPT Pro 20X停止订阅”并非真实存在的公开事件。截至当前(2024年),OpenAI官方从未发布过名为“ChatGPT Pro 20X”的产品,也未宣…

2026/9/16 7:29:29

飞傲解码耳放foobar2000 DSD输出全指南:从绿灯到黄灯

先把结论放在这儿:飞傲解码耳放接电脑,foobar2000一拖一个DSD文件进去,能出声真不算本事。能看见机器上那颗指示灯从绿色变成黄色,才算把“DSD解码”这条路走通了。我折腾这事,起因很简单:收了一台飞傲K3&a…

2026/9/16 7:24:29

【人工智能每日精选】AI 帮我看见孩子学数学的隐藏路径

很多孩子不是“不会学”,也不是“不够努力”,而是他们的大脑在形成数学表征时,可能走了一条更慢、更容易混淆的路径。 过去谈数学学习障碍,我们很容易停在分数上:做题慢、准确率低、加减法容易错。但分数只告诉我结果,不告诉我过程。真正值得追问的是:孩子在看到一个算…

2026/9/15 4:54:30

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/16 0:04:09

PHP源码部署实战:从环境配置到运行情侣游戏全攻略

简介:这是一套面向情侣互动场景的PHP完整源码,集成情侣飞行棋、真心话大冒险、情趣骰子等玩法,并内置完整分销制度,可自定义多种返佣比例,源码完全开源无加密,支持微信无感自动授权登录与第三方授权&#x…

2026/9/15 14:22:53

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/15 21:31:11

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/15 11:42:23

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码