ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于C++的USB通信上位机程序开发实战:从libusb到协议解析

基于C++的USB通信上位机程序开发实战:从libusb到协议解析 简介面向需要掌握USB设备通信开发的C程序员这份上位机源码围绕设备枚举、驱动初始化、数据收发、错误处理与界面交互展开覆盖HID/CDC等设备类以及批量、中断、异步传输机制适合结合实例理解WinUSB/WDM调用和USB协议细节。压缩包共118个文件约48.83MB以cpp/h源码和Visual Studio工程文件为主另含exe、obj、lib、pdb等构建产物及rc/res界面资源整体结构接近完整项目便于直接编译追踪。目前已有2702人学习。通过阅读这份工程可以对照学习USB设备枚举、读写状态机、超时与CRC错误处理以及界面与通信线程的协作设计是一份可运行、可改写的C USB通信参考项目。1. 基于C的USB通信上位机程序到底在解决什么问题做仪器、工控、机器人的开发时下位机是一块带USB接口的板子比如STM32、Arduino或者厂商的采集模块PC端要实时读它的数据、发控制指令。这类基于C的USB通信上位机程序就是承担这个角色的桌面程序它不负责业务展示核心是把USB链路管理好把协议帧解析好把数据用可读的方式交给界面或逻辑层。适合谁经常要在Windows上做调试工具、量产治具或产品上位机的C工程师。它解决的是“怎么把USB设备当成一个可靠的消息源”的问题而不是单纯地调printf打日志。很多人最后调试调得想砸电脑问题往往不在下位机而在上位机把USB当成了串口来写。2. USB通信基础与C方案选型不是所有设备都走同一个API2.1 端点、管道与四种传输类型上位机眼里USB是什么做上位机最怕一上来就翻API结果不知道底层为什么这样设计。USB协议里设备不是一块“内存”而是一组端点的集合。每个端点有方向、编号、传输类型和最大包长主机和端点之间的数据通路称为管道。拿ST的USB设备库来说代码里配置的EP1 IN、EP2 OUT在你上位机里就是两个对应的地址。端点地址的bit7表示方向0是OUT1是IN低4位是端点号。很多第一次写的人把0x01和0x81写反设备没有任何输入日志只有一连串超时——这是USB通信上位机最常见的第一课。四种传输类型里控制传输走端点0专门用来枚举、配置设备和发厂商命令批量传输适合数据量大、允许延迟的传输比如U盘中断传输是主机定时轮询适合鼠标、键盘和遥控指令同步传输带宽固定但几乎不重传适合摄像头。对自定义USB通信上位机控制批量就覆盖了绝大多数场景。如果只是传指令中断也行但要注意端点最大包长通常只有64字节一包塞不下完整业务帧还得自己做分包。这里有个容易混淆的点像usb转串口模块里面其实是CDC类设备主机虚拟出一个COM口但物理链路仍然是USB。上位机用串口API读写时驱动层自动做了USB传输你不需要碰端点。可一旦遇到“想自己定义命令但又要和串口传输并存”的情况直接走控制传输往往更干净。C里同时操作多个句柄比操作一个COM口加多条私有命令要可控。2.2 C上位机方案选型HID、CDC、WinUSB/libusb三选一选型不是越底层越好而是看下位机能给你什么接口。常见的三种落地路径我用下面这张表来对比方案驱动要求端点类型适合场景上位机改动成本虚拟串口(CDC ACM)Windows自带批量/中断表现为COM口仪器、开发板、调试最低代码像写串口HIDWindows自带中断传输64字节/包键鼠、简单双向控制低设备需实现HID报表WinUSB/libusb需安装WinUSB或LibusbK驱动控制/批量/中断全支持自定义高速数据采集、厂商私有命令中高但最灵活如果你只是临时做一个调试工具下位机固件默认CDC直接枚举成COM口那用C写串口读写就够了。很多标称“usb转串口”的模块内部其实是USB CDC设备或FT231X这类UART桥上位机只要打开COM口即可。但当你需要持续传几十KB/s的采集数据或者要发厂商私有命令时CDC串口在某些驱动下会有缓冲延迟HID的64字节包又太小这时候最省心的做法是直接走libusb。libusb不是驱动而是一个用户态库封装了Linux、macOS、Windows上的USB访问接口。在Windows上它要求设备挂接WinUSB或LibusbK驱动在Linux上通常直接用内核的usbfs。C调用libusb最直接而且资料多。我不建议从零用Windows的WinUSB API裸写除非你只针对一款设备且不想引第三方库。libusb的跨平台特性意味着你上午在Linux Ubuntu上跑通下午搬到Windows主机上只需要把驱动装上、把链接库换一下代码几乎不用改。提到上位机开发还有一个常见误区以为“USB通信上位机”必须用MFC或者Qt这种界面框架。实际上界面框架只负责把数据显示出来通信层完全可以是一个独立的C库。我一般会把libusb封装在UsbLink类里对外只暴露init() / open() / sendFrame() / recvFrame() / close()界面层只管调用。等到后面你要把同一个通信模块嵌入到Qt、Win32或者控制台自测程序里才知道这么拆有多舒服。另外强调一下为什么不用Windows原生API硬写WinUSB API确实功能完整但它的异步模型、设备通知、管道事件都是Windows特有的代码写多了基本绑死在Windows上。如果你的产品日后要支持Linux工控机、Mac调试libusb是唯一不用重写通信层的方案。当然如果你的设备已经是一个USB转串口设备那就老老实实用串口API别给自己找事——这不算技术退步而是正确选型。3. 用libusb在Windows和Linux上跑通最小上位机枚举、打开、控制传输3.1 初始化、枚举与打开设备三步走先写一个不依赖界面框架的最小程序目标是把设备枚举出来并打开。引入libusb头文件后初始化上下文然后拿设备列表逐个看VID/PID。VID/PID是USB设备的标识在设备描述符里下位机固件里一般也会写死。下面这个示例可以在Windows和Linux上编译运行#include libusb-1.0/libusb.h #include iostream #include cstdint int main() { libusb_context* ctx nullptr; int r libusb_init(ctx); if (r 0) { std::cerr libusb_init failed: r \n; return r; } libusb_device** devs nullptr; ssize_t cnt libusb_get_device_list(ctx, devs); if (cnt 0) { std::cerr get_device_list failed\n; libusb_exit(ctx); return cnt; } const uint16_t target_vid 0x1234; const uint16_t target_pid 0x5678; libusb_device_handle* handle nullptr; for (ssize_t i 0; i cnt; i) { libusb_device_descriptor desc; int ret libusb_get_device_descriptor(devs[i], desc); if (ret ! LIBUSB_SUCCESS) continue; std::cout std::hex desc.idVendor : desc.idProduct \n; if (desc.idVendor target_vid desc.idProduct target_pid) { ret libusb_open(devs[i], handle); if (ret LIBUSB_SUCCESS) break; } } libusb_free_device_list(devs, 1); if (handle ! nullptr) { std::cout opened device\n; libusb_close(handle); } else { std::cerr device not found or open failed\n; } libusb_exit(ctx); return 0; }libusb_init之后会得到一个上下文后面所有API都需要它。libusb_get_device_list返回系统中所有USB设备返回值是设备数量若为负则说明底层接口不允许访问。libusb_get_device_descriptor从设备读取基本描述符包括VID/PID、设备类型等。libusb_open成功则返回设备句柄失败多半是驱动或权限问题后面避坑章节会细说。libusb_free_device_list的第二个参数传1表示同时释放设备引用防止句柄泄漏。在Windows上如果设备没有安装WinUSB或LibusbK驱动libusb_open大概率返回LIBUSB_ERROR_NOT_FOUND或者LIBUSB_ERROR_ACCESS。我一般的做法是先用Zadig把设备驱动替换成WinUSB再回到这个程序里测试。这个操作不影响下位机固件只影响Windows识别设备的方式。3.2 控制传输与批量读写把第一包数据发出去枚举成功只是拿到了句柄真正通信前还要做两件事设置配置、声明接口。很多设备只有一种配置配置号为1接口0对应固件里的一组端点。如果libusb_claim_interface返回LIBUSB_ERROR_BUSY说明驱动或另一个程序已经占用了接口需要先释放。下面这段代码演示厂商自定义控制传输以及最常用的批量读写// 打开设备后 r libusb_claim_interface(handle, 0); if (r ! LIBUSB_SUCCESS) { std::cerr claim_interface failed: libusb_error_name(r) \n; libusb_close(handle); libusb_exit(ctx); return r; } uint8_t buf[64] {0}; int transferred 0; // 厂商自定义控制读读取固件版本或状态 r libusb_control_transfer( handle, LIBUSB_ENDPOINT_IN | LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE, 0x01, // bRequest自定义命令号 0x0000, // wValue 0x0000, // wIndex buf, sizeof(buf), 1000 // 超时1秒 ); if (r 64) { std::cout firmware info: reinterpret_castchar*(buf) \n; } else if (r LIBUSB_ERROR_TIMEOUT) { std::cerr control timeout\n; } // 批量写把一帧数据发给 EP1 OUT uint8_t out_buf[16] {0xAA, 0x55, 0x01, 0x00}; r libusb_bulk_transfer(handle, 0x01, out_buf, sizeof(out_buf), transferred, 1000); if (r LIBUSB_SUCCESS) { std::cout sent transferred bytes\n; } else { std::cerr bulk out failed: libusb_error_name(r) \n; } // 批量读从 EP2 IN 读最多4096字节 uint8_t in_buf[4096]; r libusb_bulk_transfer(handle, 0x82, in_buf, sizeof(in_buf), transferred, 1000); if (r LIBUSB_SUCCESS transferred 0) { // 处理 in_buf[0] ~ in_buf[transferred-1] }控制传输这里的bmRequestType用三个宏拼出来LIBUSB_ENDPOINT_IN表示方向是设备到主机LIBUSB_REQUEST_TYPE_VENDOR表示厂商自定义请求LIBUSB_RECIPIENT_DEVICE表示请求发往设备本身。后面的bRequest、wValue、wIndex都是厂商自定义的只要下位机和上位机约定一致即可。超时参数单位是毫秒如果你设成0表示无限等待这在UI线程里等于自杀后面会说线程模型。批量传输的端点地址要写对方向OUT端点地址是0x01IN端点地址是0x82。比较隐蔽的是transferred返回实际传输的字节数它可能比len小。比如你传了4096的缓冲区但设备只回了64字节transferred就是64r仍然是LIBUSB_SUCCESS。所以读数据时一定看transferred而不是依赖len。3.3 参数与线程模型别把USB操作放在UI线程很多人的上位机一开始很简单点一个按钮同步调一次libusb_control_transfer等结果返回再更新界面。这在极短命令上没问题但一旦进入连续读取或高速采集同步调用会卡死界面。USB通信应该独立成工作线程界面只通过队列接收结果。我惯用的结构是这样UsbLink里维护一个std::atomicbool running_和一个收包线程。线程里循环执行libusb_bulk_transfer读端点遇到LIBUSB_ERROR_NO_DEVICE就退出遇到超时就继续下一次循环不把错误无限抛。收来的数据放进std::mutex保护的std::queue界面线程定时取。线程模型里最容易被忽略的是libusb_handle_events。如果你用异步libusb_submit_transfer你必须有一个线程定期调用libusb_handle_events否则回调永远不会被触发。如果只用同步API则不需要手动处理事件但热插拔事件就收不到。多数自定义USB上位机用同步API加独立线程已经够了只有需要极高吞吐时才上异步。再给两个参数建议超时设1000毫秒比较合理太短容易误判设备没响应太长会让拔线后的退出变得迟钝批量读缓冲区最好用端点最大包长的整数倍比如64、512、4096。如果缓冲区比一包数据小libusb会截断数据这不是丢包而是你给容器开小了。4. 通信帧结构设计命令、应答、CRC校验与环形缓冲解析4.1 帧格式定义先约定好别让上下位机各自发挥USB传输是流式字节流除非你的协议固定成“每次bulk transfer恰好一帧”否则接收端必然面临粘包和半包。所以上位机程序里最重要的不是USB API而是通信帧结构。我的习惯是先用一张表写死字段顺序和字节序发给下位机确认后再写代码。字段长度(字节)说明同步头2固定0xAA 0x55帧长2从cmd到crc结束的长度小端命令字cmd10x01读版本0x02写参数等序号seq10~255循环用于辨认重传数据区payloadN业务数据CRC162覆盖cmd、seq、payload之所以帧长不包含同步头和长度本身是为了简化计算接收端读到长度后就知道接下来还要等多少字节。序号字段特别重要特别是批量传输偶尔会因超时重传如果没有序号你分不清刚收到的是新应答还是上一次的迟到应答。CRC16我用Modbus多项式因为下位机几乎都支持查表法算起来快。这里有个血泪经验不要直接用C结构体#pragma pack(1)定义帧然后memcpy到buffer里。结构体虽然可以内存对齐但两端编译器版本、大小端、字段类型一旦有差异协议就翻车。用字节流方式手动填充反而更简单、更可控。C里做一个字节流容器优先用std::vectoruint8_t不要用char*加裸数组后面扩容和切片都省心。4.2 CRC16计算与命令封装把结构体塞进字节流帧封装的核心是把业务参数填进字节流并计算CRC。下面是CRC16计算函数适合放在一个公共工具文件里#include cstdint #include vector #include cstring uint16_t crc16_modbus(const uint8_t* data, size_t len) { uint16_t crc 0xFFFF; for (size_t i 0; i len; i) { crc ^ data[i]; for (int bit 0; bit 8; bit) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }生成一帧命令的时候我一般封装一个build_frame函数参数为命令、序号、payload指针和长度std::vectoruint8_t build_frame(uint8_t cmd, uint8_t seq, const uint8_t* payload, size_t len) { std::vectoruint8_t frame; frame.reserve(4 len 4); // 同步头 frame.push_back(0xAA); frame.push_back(0x55); // 帧长cmd seq payload crc共 len 4 uint16_t frame_len static_castuint16_t(len 4); frame.push_back(static_castuint8_t(frame_len 0xFF)); frame.push_back(static_castuint8_t(frame_len 8)); // 命令与序号 frame.push_back(cmd); frame.push_back(seq); // 数据区 if (len 0 payload ! nullptr) { frame.insert(frame.end(), payload, payload len); } // CRC覆盖cmd、seq、payload也就是从第4字节开始 uint16_t crc crc16_modbus(frame.data() 4, frame.size() - 4); frame.push_back(static_castuint8_t(crc 0xFF)); frame.push_back(static_castuint8_t(crc 8)); return frame; }注意frame_len从cmd开始算所以它等于payload长度 4。CRC计算范围是cmd、seq、payload不包含同步头和长度字段。这样下位机收到后先验证同步头再读帧长然后等齐整个帧后算CRC流程很规整。调这个函数时payload可以传空指针代表一个无数据命令帧长度就是4。4.3 接收端解析状态机把粘包和半包问题一次解决接收端从USB批量传输里拿到的是一段连续字节流可能是半帧、多帧、甚至一帧加上下一帧的四分之一。要稳定解析不能用“等收到某个固定长度再处理”的方式而应该用状态机按字节扫描并且每处理完一帧就从缓冲区头部裁剪掉。下面是一个精简但完整的解析类class FrameParser { public: void push(const uint8_t* data, size_t len) { buffer_.insert(buffer_.end(), data, data len); try_parse(); } private: std::vectoruint8_t buffer_; void try_parse() { while (buffer_.size() 4) { // 检查同步头 if (buffer_[0] ! 0xAA || buffer_[1] ! 0x55) { buffer_.erase(buffer_.begin()); continue; } // 读取帧长 uint16_t frame_len buffer_[2] | (buffer_[3] 8); if (frame_len 4) { // 长度不可能小于4说明帧头损坏 buffer_.erase(buffer_.begin(), buffer_.begin() 2); continue; } // 如果缓冲区还没收齐一帧继续等数据 if (buffer_.size() 4 frame_len) return; // 校验CRC范围从cmd到payload共frame_len - 2字节 uint16_t crc_calc crc16_modbus(buffer_.data() 4, frame_len - 2); uint16_t crc_recv buffer_[4 frame_len - 2] | (buffer_[4 frame_len - 1] 8); if (crc_calc ! crc_recv) { // 同步头正确但CRC错误丢弃最前面的1字节重新找帧头 buffer_.erase(buffer_.begin()); continue; } // 解析一帧成功 uint8_t cmd buffer_[4]; uint8_t seq buffer_[5]; const uint8_t* payload buffer_.data() 6; size_t payload_len frame_len - 4; // 处理业务帧例如回调到上层协议处理器 printf(recv cmd0x%02X seq%u payload_len%zu\n, cmd, seq, payload_len); // 从缓冲区中移除这一整帧 buffer_.erase(buffer_.begin(), buffer_.begin() 4 frame_len); } } };这里用std::vectoruint8_t做字节缓冲push每收到一段数据就插入末尾。try_parse用循环处理因为一次可能积累多个帧。如果同步头不对就删除一个字节继续找如果长度字段小于4说明帧头错乱删除两个字节重新同步。帧未完整时直接return等待下一次push。CRC比中错误时只丢一个字节而不是整帧因为出错的可能是同步头后面的数据也有可能下一个字节就是真正的帧头。这个“丢一个字节重新找”的技巧能最大程度避免因单帧错误导致整条流被清空。这里的坑是解析器只处理完整帧收到半包时不能报错。很多新手把“超时”当成“协议错误”一超时就清空缓冲区结果设备回传稍慢一点就把上一帧的有效数据丢了。正确做法是保留缓冲区超时只影响本次USB传输不影响已经收到的字节流。5. USB上位机常见毛病排查驱动、超时、热插拔与抓包定位5.1 设备打开失败未知USB设备或libusb_open返回Access现象程序枚举时能看到VID/PID但libusb_open返回错误Windows设备管理器里显示“未知USB设备(设备描述符请求失败)”或者设备带一个黄色感叹号。Linux下可能直接返回LIBUSB_ERROR_ACCESS或LIBUSB_ERROR_NOT_FOUND。原因最常见是驱动不对。设备默认被Windows识别为某个类驱动libusb无权访问或者下位机固件枚举时USB配置描述符有问题导致主机没拿到有效描述符。再有就是供电不足USB线缆太差。还有一种“玄学”现象把设备插在前面板USB口能识别插后面板不行多半是延长线压降太大。解决Windows用Zadig把设备驱动替换为WinUSB或LibusbK再重新枚举。如果设备描述符请求失败先换一条粗短的USB线或者给下位机独立供电。Linux下写一条udev规则允许当前用户访问该VID/PID的设备例如给设备设置MODE0666否则非root用户打不开。插拔后如果驱动依然不对去设备管理器里卸载设备并勾选删除驱动程序再重新插拔避免旧的错误驱动缓存。5.2 数据传着传着就不动了超时、缓冲区和端点方向现象上位机刚启动时收发正常运行几分钟后批量读返回超时有时候还能连续恢复几帧然后再次卡住。控制台日志里全是timeout但下位机明明还在发数据。原因十有八九不是链路断了而是缓冲区容量或端点方向配置错了。批量读给的是4096字节缓冲但下位机一包只发64字节且发送间隔不稳定如果代码把transferred忽略默认认为每次拿到一帧就会把上一帧残留数据当作新帧头。另一种情况是本次传输超时后上位机清了缓冲区但下位机那边已经积压了若干包下一次读到的全是你清空前的旧数据协议分析器看到的就是“乱帧”。解决USB传输层和协议层分开。传输层只负责把数据读出来放进FrameParser不关心业务第几包协议层只从FrameParser取完整帧。超时后不要清空协议解析缓冲因为缓冲里可能还有半包甚至完整包。如果设备的批量数据是连续的读线程应该用“读完立刻继续读”的循环不要每读一包就sleep等界面。还可以用usb抓包工具看URB层是否有多次NAK判断是不是下位机端点没准备好。5.3 帧错位和大量CRC错误协议没做重同步或ZLP没有处理现象上位机收到的帧数量对但内容全是CRC错或者前几帧正常后边全部错位。对比下位机固件发送的原始数据长度和内容完全对不上。原因典型的下位机发送长度和上位机接收长度不匹配。比如下位机用bulk_transfer发送100字节批量端点最大包长64USB控制器会把100字节拆成6436这是正常的。但如果上位机缓冲区只给了32字节libusb一次只能拿到第一块32字节剩下64字节滞留在驱动层下一轮读到的就是上一轮残余数据帧就被拆碎了。还有一种隐藏问题数据总长度恰好是64的整数倍时端点需要发一个零长度包ZLP表示传输结束很多下位机库不自动处理上位机就会一直等后续数据直到超时。解决把上位机读缓冲区设为端点最大包长的整数倍至少不小于下位机单次最大发送长度。事务型协议建议一包一帧每帧长度固定且不超过设备某个安全阈值避免跨USB传输拼接。对于必须使用长帧的情况在协议里用帧头重同步且CRC错误时按前面讲的“丢一字节”策略来找下一个同步头。下位机方面让固件工程师确认整倍长度时是否发送ZLP尤其是在使用USB库生成CDC或自定义批量端点时。5.4 热插拔崩溃句柄悬空和事件循环没退干净现象上位机正常运行用户拔掉USB线程序马上崩溃或者下一次发送时卡死有的程序拔线后功能正常但再插回去就再也打不开设备了。这种问题在量产现场特别常见操作工拔设备从来不管软件状态。原因收包线程还在阻塞的libusb_bulk_transfer里此刻设备断开了库返回LIBUSB_ERROR_NO_DEVICE但代码拿到错误后仍然继续使用已经失效的handle。如果另一个线程同时执行libusb_close两个线程竞争同一份设备上下文轻则崩溃重则整个进程挂住。还有的代码在句柄关闭后没有清理libusb初始化的上下文下一次重连时重新libusb_init旧上下文事件线程还活着就冲突了。解决USB设备句柄的生命周期必须单一。我一般在UsbLink里约定只有收包线程使用handle做传输主线程只在连接成功或收到退出命令时才关闭设备。收包线程检测到NO_DEVICE后设置running_false并退出然后由主线程统一libusb_close。同时注册热插拔回调拔线时立刻通知界面层清理状态而不是等下一次发送才发现设备没了。如果你不想用回调至少要在每次发送前检查libusb_get_device_list里目标设备还在不在虽然多一次枚举开销但比悬空指针稳妥得多。6. 最后一步用USB抓包验证上位机而不是靠日志猜以前我调一个时好时坏的采集设备上位机日志里偶尔出现CRC错误我一直以为是下位机算法问题改了好几版协议都没用。后来用Wireshark加USBPcap做了usb抓包才发现下位机每次枚举到的端点地址和我代码里写的不一样固件里EP IN配置成了0x84我上位机却一直用0x82读数据。这不叫丢包纯粹是端点地址写错了但如果没有抓包光看日志根本定位不了。从那之后我所有USB上位机调试都会先做一次抓包作为基线。抓包只需要在Windows上装USBPcap然后Wireshark里选对应的USB总线开始捕获。上位机发一帧命令抓包里会看到URB请求和响应能精确到发送了哪些字节、收到哪些字节、超时前有没有重传。常见的USBPcap会同时抓到系统枚举过程字段很多先过滤usb.idVendor 0x1234把无关设备滤掉。然后看URB_BULK和URB_CONTROL对照帧结构就能定位是下位机没回、上位机发错还是驱动缓冲出错。稳定性验证方面我会用一个自动化压测循环连续发10000帧每帧带递增值的seq上位机统计应答数、CRC错误数、超时数和延迟分布。下面这个思路可以直接抄uint32_t ok 0, timeout 0, crc_err 0; for (uint32_t i 0; i 10000; i) { uint8_t seq static_castuint8_t(i 0xFF); auto frame build_frame(CMD_READ, seq, nullptr, 0); if (!link.send(frame)) { timeout; continue; } if (auto resp link.recvFrame(1000); resp) { if (resp-seq ! seq) timeout; else ok; } else { crc_err; } } double success_rate 100.0 * ok / 10000; std::cout success_rate success_rate %\n;压测结果如果成功率低于99%不要急着改协议先抓包看是哪个环节丢的。延迟异常时我在关键函数前后用std::chrono::steady_clock打时间戳把USB传输耗时和协议解析耗时分开统计避免把解析慢误判成USB慢。如果你用的是同步API每次传输超时设1000毫秒那么一次超时压测就可能拖后很久所以压测前先把超时参数调小到200毫秒识别真正的死链路然后再用正常超时跑功能测试。最后说一个我自己的习惯每次改完上位机通信代码我会先在控制台做一个不带界面的自测程序用同一个UsbLink类把打开、枚举、发送、解析、重连全部跑一遍通过后再接界面。界面出问题时我会先在自测程序里复现而不是打开一个复杂的GUI到处找按钮。这个习惯帮我避开了很多“在界面层找问题其实通信层早就翻了车”的弯路也希望帮到你。本文还有配套的精品资源点击获取
返回列表