基于QT5与WinPcap的仿Wireshark抓包器:架构、解析与避坑

发布时间:2026/10/10 4:31:33
基于QT5与WinPcap的仿Wireshark抓包器:架构、解析与避坑
简介这是一份仿WireShark的网络抓包程序完整源码基于QT5和WinPcap实现面向网络工程师、C开发者及网络安全学习者可用于捕获、过滤与分析网络数据包辅助网络故障排查、协议分析与应用调试。压缩包共392个文件包含C/C源文件.c/.cpp/.h、QT界面文件.ui/.qrc、工程配置.vcproj/.dsp/.sln/.pro、编译产物.o/.lib/.a以及HTML帮助文档、图片等整体约8.15MB目录结构按功能模块划分查找定位方便。目前已有197人浏览学习。程序界面与操作逻辑贴近WireShark覆盖网卡选择、抓包启动、数据包解析、列表展示与过滤的完整流程源码涉及QT5信号槽、多线程界面更新以及WinPcap核心API的调用还包含UDP抓包、测试发包等可执行示例便于对照学习。对于希望深入理解Windows网络抓包原理或基于此做二次开发的读者是一份具参考价值的实战资料。1. Sniffer值得复现的 QT5 WinPcap 仿 WireShark 抓包器调一个私有协议的时候我最烦的就是打开 WireShark 等它加载一堆用不上的解析器。真正需要的其实就三样一个能实时滚动的包列表、一个能看字段的协议树、一个能还原字节的十六进制面板。这个基于 QT5 和 WinPcap 实现的网络抓包程序 Sniffer就是把 WireShark 最常用的这套三栏界面搬到一个 C 工程里自己掌控每一列显示什么、过滤怎么写、解析做到哪一层。它是抓包器里最标准的骨架WinPcap 负责收包和 BPF 过滤QT5 负责界面和线程调度中间用信号把包数据从底层送到界面。上手之后你会发现真正卡人的不是解析以太网帧头而是抓包线程和界面线程的同步以及 WinPcap 在 Win10/11 上的驱动兼容。这份资源适合想快速搭出私有协议调试工具的人也适合把 Qt 多线程和 C 网络编程串起来练一遍的进阶者。2. 工程骨架抓包线程、队列与信号上抛怎么搭这个工程能跑起来取决于三件事WinPcap/Npcap 的驱动装好QT5 的 pro 文件里链接对了库抓包线程不碰界面。前两件事是环境问题第三件事才是代码层面的重点。工程里最常见的错误是把 pcap_loop 直接丢进主线程跑界面瞬间冻结然后回调函数里又在 new QTableWidgetItemWindows 直接给你闪退。所以骨架的第一原则是抓包在一个线程界面在另一个线程中间用 Qt 信号做交接。2.1 三个类就能跑起来MainWindow、CaptureThread 与 PacketMeta工程里核心类就三个。PacketMeta 是包数据的统一出口一个 struct 里放源/目的 MAC、IP、端口、协议名、时间戳和原始包数据CaptureThread 继承 QThread里面跑 pcap_next_ex 循环抓到包就 emit 一次信号MainWindow 负责三栏界面和菜单收到信号填充表格和十六进制视图。pro 文件是编译的第一个门槛我一般这样写QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TEMPLATE app TARGET Sniffer INCLUDEPATH C:/npcap-sdk/include LIBS -L C:/npcap-sdk/lib -lwpcap -lws2_32 SOURCES main.cpp \ mainwindow.cpp \ capturethread.cpp \ packetparser.cpp HEADERS packetmeta.h \ capturethread.h \ mainwindow.hINCLUDEPATH 指向 Npcap SDK 的 includeLIBS 里 -lwpcap 和 -lws2_32 缺一不可。缺了 wpcap 编译直接报 undefined referencews2_32 是 Windows Sockets 库WinPcap 依赖它做底层名字解析。如果你的机器装的是老版本 WinPcap库路径可能是 C:\Program Files\WinPcap\Lib但建议直接用 Npcap SDK 重编原因放到避坑章细说。2.2 抓包循环pcap_next_ex 轮询别在主线程跑 pcap_loop抓包线程的 run 函数是整个工程的心脏。核心循环长这样void CaptureThread::run() { pcap_pkthdr* header nullptr; const u_char* data nullptr; while (running_) { int ret pcap_next_ex(handle_, header, data); if (ret 1) { PacketMeta meta parse_packet(data, header-caplen); meta.timestamp header-ts; meta.rawData QByteArray( reinterpret_castconst char*(data), header-caplen); emit packetReady(meta); } else if (ret -1) { emit errorHappened(QString::fromLocal8Bit(pcap_geterr(handle_))); break; } // ret 0 表示读取超时没有新包继续下一轮 } }running_ 是 volatile bool 成员stop() 里先置 false再调用 pcap_breakloop(handle_) 打断阻塞读。这里我刻意用 pcap_next_ex 而不是 pcap_dispatchpcap_dispatch 是回调式的包会成批进入回调栈在 Qt 信号里把栈打得很深pcap_next_ex 一次只取一个包循环结构好控制退出也可以在循环里随时检查 running_。打开网卡时的参数同样关键char errbuf[PCAP_ERRBUF_SIZE] { 0 }; pcap_t* handle pcap_open_live( deviceName.toStdString().c_str(), 65535, // snaplen抓完整包不截断 1, // promisc混杂模式 50, // to_ms读取超时 50ms errbuf);snaplen 设 65535 是为了拿到完整的以太网帧不截断如果设 1514 或者更小包尾的数据会在解析时丢失。promisc 设 1 表示混杂模式但要注意在交换式网络里混杂模式只能保证本网卡收下经过它的所有帧交换机会把别的机器的流量直接送到目标口能不能看到别人的包取决于网络结构不是软件层面的玄学。to_ms 设 50 是兼顾界面响应和 CPU 占用的值。注意to_ms 不要设 0在部分驱动下会导致读取线程长时间阻塞pcap_breakloop 也救不回来界面会像死掉一样。2.3 信号上抛跨线程别直接操作界面数据从抓包线程到界面线程靠的是信号槽。跨线程信号默认走队列连接Qt 会把参数拷到 UI 线程的事件队列里包数据到达后MainWindow 的槽函数在 UI 线程执行天然避开了线程竞争。class CaptureThread : public QThread { Q_OBJECT public: explicit CaptureThread(pcap_t* handle, QObject* parent nullptr); void stop(); signals: void packetReady(PacketMeta meta); void errorHappened(QString message); protected: void run() override; };这里有一个隐藏坑如果 PacketMeta 里放了 QByteArray需要先 Q_DECLARE_METATYPE(PacketMeta) 注册元类型否则运行时报错 Cannot queue arguments of type PacketMeta。还有一种做法是维护一个 QMutex 保护的 QQueueUI 线程用 QTimer 定时取两种方案都能跑。我一般用信号代码干净些但 Q_DECLARE_METATYPE 这一步别漏。千万别在 run() 里直接调用 table_-insertRow()。QTableWidget 不是线程安全的跨线程操作轻则刷新撕裂重则闪退。把包数据塞进信号参数交出去是这套工程里最值得坚持的一条纪律。3. 协议解析从字节流到三栏联动视图的完整路径抓包器把包摆上界面之前先要把字节数组翻译成能看的字段。网络抓包最难的不是抓而是看懂。WireShark 的可视化之所以高效是因为它把每种协议的字段偏移提前算好鼠标点一下就能看到对应字节。Sniffer 也要按这条路走先解析再显示最后把表格、协议树、十六进制三块联动起来。3.1 从 14 字节以太网头开始字节序和偏移别搞反我先把解析器要用的字段偏移列成一张表写代码时照着这张表取数基本不会错字段偏移长度说明目的 MAC06接收方地址源 MAC66发送方地址EtherType1220x0800IPv40x0806ARPIPv4 版本IHL141高 4 位版本低 4 位头长单位 4 字节TTL221经过的路由器跳数协议号2316TCP17UDP源 IP264网络字节序目的 IP304网络字节序TCP 源端口342大端序TCP 目的端口362大端序TCP 序号384会话重组时会用到注意 TCP 不是直接跟在以太网头后面中间隔着 IPv4 头IP 头长度不固定所以要先读 IHL 算出偏移再读端口。x86 是小端机但网络字节序是大端直接 memcpy 成 quint16 再强转拿到的一定是反的这是我最早翻车的地方。PacketMeta parse_packet(const u_char* data, int len) { PacketMeta m {}; if (len 14) return m; // 以太网头目的 MAC、源 MAC、EtherType m.dstMac macToString(data); // 偏移 0 m.srcMac macToString(data 6); // 偏移 6 quint16 etherType (data[12] 8) | data[13]; int offset 14; if (etherType 0x0800 len offset 20) { m.netProto IPv4; m.srcIp ipv4ToString(data offset 12); m.dstIp ipv4ToString(data offset 16); int ihl (data[offset] 0x0F) * 4; // 头长字段单位是 4 字节 m.ttl data[offset 8]; quint8 proto data[offset 9]; offset ihl; if (proto 6 len offset 20) { // TCP m.transProto TCP; m.srcPort (data[offset] 8) | data[offset 1]; m.dstPort (data[offset 2] 8) | data[offset 3]; m.tcpSeq (data[offset 4] 24) | (data[offset 5] 16) | (data[offset 6] 8) | data[offset 7]; } else if (proto 17 len offset 8) { // UDP m.transProto UDP; m.srcPort (data[offset] 8) | data[offset 1]; m.dstPort (data[offset 2] 8) | data[offset 3]; } } else if (etherType 0x0806) { m.netProto ARP; } return m; }macToString 要特别注意用 QString(%1:%2...).arg(b, 2, 16, QLatin1Char(0)) 补零不然 0A 会被打成 A。每次取字段之前都先判断 len防止畸形包越界这是抓包器稳定性的底线。snaplen 被截断时长度判断会拦住残缺包避免解析出完全没有意义的端口号。3.2 IPv4 与 TCP/UDP 解析协议号、端口和 IHL 的三处细节协议号字段是单字节在 IPv4 头里偏移 96 是 TCP17 是 UDP别和 EtherType 混了。EtherType 0x0800 表示下一层是 IPv4而 IP 头里那个 6 才表示再下一层是 TCP两层判断各司其职。IHL 是 IP 头里最容易读错的字段。它占一个字节的低 4 位单位是 4 字节所以代码里要先 0x0F 再 * 4。标准 IPv4 头是 20 字节带选项时能到 60 字节。如果直接按 20 字节固定偏移去读 TCP 端口带选项的包全部错位这也是用 WireShark 对比时最容易发现的差异。DNS 解析总是抓包时最显眼的小包流量因为真正的大流量还没开始域名解析已经先忙活起来了。看到 udp 53 的包基本就可以判断是哪台主机在发起 DNS 查询这对应 WireShark 里习惯按域名观察流量的场景。BPF 过滤表达式只能按 IP 和端口匹配想在 Sniffer 里按域名过滤需要自己在解析层做 DNS 响应缓存这是显示过滤和抓包过滤的差别后面会细说。3.3 三栏联动表格存 meta协议树按需构建解析完成后的 PacketMeta 进入 UI 线程表格只负责把每层的摘要填到对应列void MainWindow::onPacketReady(PacketMeta meta) { metaList_.append(meta); int row metaList_.size() - 1; table_-insertRow(row); table_-setItem(row, 0, new QTableWidgetItem(QString::number(row 1))); table_-setItem(row, 1, new QTableWidgetItem(meta.timestampString())); table_-setItem(row, 2, new QTableWidgetItem( meta.srcIp : QString::number(meta.srcPort))); table_-setItem(row, 3, new QTableWidgetItem( meta.dstIp : QString::number(meta.dstPort))); table_-setItem(row, 4, new QTableWidgetItem(meta.transProto)); table_-setItem(row, 5, new QTableWidgetItem( QString::number(meta.rawData.size()))); }这里只存几十字节的 meta不存协议树。协议树是 QTreeWidgetItem 对象每个节点都有不小的内存开销五千包每包十个节点就是五万个对象内存和刷新都扛不住。所以协议树放到点击行的时候再构建void MainWindow::onTableRowSelected(int row) { PacketMeta m metaList_[row]; tree_-clear(); QTreeWidgetItem* eth new QTreeWidgetItem(tree_, {以太网帧头}); eth-addChild(new QTreeWidgetItem( {QString(目标MAC: %1).arg(m.dstMac)})); eth-addChild(new QTreeWidgetItem( {QString(源MAC: %1).arg(m.srcMac)})); if (!m.netProto.isEmpty()) { QTreeWidgetItem* ip new QTreeWidgetItem(tree_, {m.netProto 层}); ip-addChild(new QTreeWidgetItem( {QString(源IP: %1).arg(m.srcIp)})); ip-addChild(new QTreeWidgetItem( {QString(目的IP: %1).arg(m.dstIp)})); ip-addChild(new QTreeWidgetItem( {QString(TTL: %1).arg(m.ttl)})); } hexView_-setPlainText(formatHex(m.rawData)); }formatHex 每行输出 16 字节左边是十六进制、右边是 ASCII明文协议的内容能直接读出来。三栏布局里表格、协议树、十六进制面板就是 WireShark 的 Inspector 范式做可视化抓包器这套界面结构是最容易让用户上手的。点击行时重新解析既省内存又保证协议树和当前表格行永远一致不会出现两边数据对不上的情况。4. 过滤与回放BPF 表达式、pcap 落盘和离线验证抓包器能收包只是第一步真正干活靠过滤。WinPcap 的过滤在内核里完成也就是说驱动层就把不匹配的包丢掉了不会往上抛。这个性质决定了过滤必须写对因为不匹配的包永远不会出现在你的抓包循环里。WireShark 里按 ip.addr 过滤是界面层的显示过滤跟这里的内核 BPF 不是一回事很多人在这两个概念上栽过跟头。4.1 BPF 过滤pcap_compile 的写法与 Wi-Fi 网卡陷阱给已打开的网卡设置 BPF 过滤器标准的写法是三步bool applyBpfFilter(pcap_t* handle, const QString expression) { bpf_program fp; memset(fp, 0, sizeof(fp)); int rc pcap_compile(handle, fp, expression.toStdString().c_str(), 1, // optimize PCAP_NETMASK_UNKNOWN); // netmask if (rc ! 0) { qWarning() pcap_geterr(handle); return false; } if (pcap_setfilter(handle, fp) ! 0) { qWarning() pcap_geterr(handle); return false; } pcap_freecode(fp); return true; }optimize 传 1 让 BPF 编译器做优化netmask 传 PCAP_NETMASK_UNKNOWN 表示不关心本机地址段做网段匹配时够用。pcap_compile 失败时错误信息在 pcap_geterr 里最常见的报错是 syntax error说明表达式写错了。pcap_freecode 一定要调用多次设置过滤器时不释放会在这个句柄上累积代码段属于慢性内存泄漏。常用的表达式就这么几类host 192.168.1.100 只看某台主机tcp port 443 只看某个端口src 10.0.0.0/8 and udp 组合网段和协议arp 只看 ARP。把这些做成输入框每次抓包前先想清楚要什么再启动抓包循环。Wi-Fi 网卡是过滤器最容易踩的坑。pcap_findalldevs 返回的无线网卡链路层类型可能是 DLT_IEEE802_11 而不是 DLT_EN10MBBPF 过滤器按以太网头偏移解析tcp port 443 在这种网卡上经常匹配不到任何包。表现是表达式没问题、驱动也正常但就是一包都抓不到属于看着玄学、实则链路类型不对的典型案例。解决办法是抓无线流量时优先用有线网卡或者接受无线网卡只能抓到发给本机的帧。4.2 落盘与回放pcap_dump 一行保存离线再用 WireShark 验证保存抓包结果最省事的方式是直接调 WinPcap 的 dump 接口pcap_dumper_t* dumper pcap_dump_open(handle, capture.pcap); if (dumper) { // 每抓到一个包时调用 pcap_dump(dumper, header, data); pcap_dump_flush(dumper); // 实时落盘程序崩溃也能保住前面数据 }pcap_dump_open 会从 handle 里读链路层类型自动写出 24 字节的 PCAP 全局头所以保存期间别关闭 handle。pcap_dump_flush 是实时写盘代价是每包一次系统调用批量抓包时建议改成每 100 包 flush 一次平衡可靠性和吞吐。要做实时分片保存的话加一个包数计数到阈值就 pcap_dump_close 再重新打开新文件文件名带时间戳就行这样长时间抓包不会撑爆单个文件。如果不想依赖 dumper也可以自己写 PCAP 文件头结构很固定字段值说明magic0xa1b2c3d4固定魔数小端序version_major2主版本version_minor4次版本thiszone0时区修正通常为 0sigfigs0时间戳精度通常为 0snaplen65535抓包截断长度linktype1DLT_EN10MB以太网写完文件头后每条记录按 pcap_pkthdr 的格式写入时间戳秒、时间戳微秒、包实际长度、包抓取长度然后跟原始字节。这样存出来的文件可以直接用 WireShark 打开这是验证自己的解析器有没有解析错的最佳方式把同一个包在两边打开对比字段即可。离线回放只需要换一个入口pcap_t* off pcap_open_offline(filepath.toStdString().c_str(), errbuf);打开后走和在线抓包完全相同的解析分发逻辑只是把网卡读取换成文件读取。离线模式让调试特定请求变得可复现线上抓一份 pcap 存下来回到本地慢慢分析不用再对着屏幕抢包。4.3 拖拽打开 pcapsetAcceptDrops 与事件过滤器很多人写 Qt5 程序时拖拽没反应是因为只调了 setAcceptDrops(true) 但没重写 dragEnterEvent 和 dropEvent。更省事的做法是给 MainWindow 安装事件过滤器统一拦截拖拽事件void MainWindow::setupDrop() { setAcceptDrops(true); installEventFilter(this); } bool MainWindow::eventFilter(QObject* obj, QEvent* ev) { if (ev-type() QEvent::DragEnter || ev-type() QEvent::DragMove) { auto* e static_castQDragEnterEvent*(ev); if (e-mimeData()-hasUrls()) { e-acceptProposedAction(); } return true; } if (ev-type() QEvent::Drop) { auto* e static_castQDropEvent*(ev); QString path e-mimeData()-urls().first().toLocalFile(); if (path.endsWith(.pcap, Qt::CaseInsensitive)) { openOfflineFile(path); } return true; } return QMainWindow::eventFilter(obj, ev); }QTreeWidget、QTableWidget 这些子控件会消化 drop 事件在 MainWindow 上统一拦截比在每个子类里重写一遍可靠。DragMove 也要接受否则拖到区域边缘再回来会出现 DragLeave 的怪现象。文件路径先检查后缀再交给 openOfflineFile这个函数内部就是 pcap_open_offline 加解析循环代码和在线模式共用同一套解析器。5. 避坑WinPcap 装不上、拖拽失效、界面卡死怎么办下面这几条是实际跑这个工程时最容易踩的坑每一条我都见过不止一个人卡住写出来当后悔药。环境问题永远排在代码问题前面先把驱动和权限搞清楚再回头看代码。5.1 WinPcap 装不上多数是驱动签名和 64 位路径问题现象安装到最后一步报错设备管理器里看不到 NPF 驱动设备程序打开网卡时提示 error opening adapter。原因WinPcap 4.1.3 的驱动在 Win10/11 上签名校验不通过新系统直接拒绝加载。很多工具链自带的 WinPcap 安装包又是 32 位路径系统是 64 位时也容易出问题。搞 FPGA 开发的人会在装 Vivado 时遇到 vivado winpcap 安装失败本质就是同一个驱动兼容问题。解决把系统里的 WinPcap 卸载干净装 Npcap安装时勾选 WinPcap API 兼容模式工程里继续用 wpcap.lib 和 pcap.h 编译代码不用改。如果 Vivado 这类工具找不到 Npcap把它安装目录里的 wpcap.dll 和 packet.dll 手动复制到工具自带的 winpcap 目录里一般能救回来。5.2 明明有流量却抓不到包混杂模式不等于全收现象网卡列表正常pcap_open_live 成功但列表里只有零星的 ARP 或广播包。原因两种情况。第一种是权限不足WinPcap/Npcap 需要管理员权限运行程序普通权限下驱动层收包受限第二种是现在基本都是交换网络混杂模式只能让本网卡收下经过它的所有帧交换机会把别的机器的包直接送到目标口不会广播到所有口所以看不到别人的流量是正常的。解决右键管理员身份运行程序确认选择的网卡描述不是无线网卡无线网卡抓包链路层类型不对很多过滤器会失效想抓别的机器流量需要在交换机上配置端口镜像这不是软件层面的玄学是网络结构决定的。5.3 抓到几千个包界面直接卡死现象包量到几千以后界面响应越来越慢CPU 居高不下滚动列表像拖泥。原因每包都 insertRow、每包都 scrollToBottom、协议树提前构建这三个操作单独看都不重叠加起来就是 O(n²) 级别的刷新开销。另一个常见原因是 2.2 节提到的 to_ms 设 0内核缓冲容易满pcap_next_ex 陷在驱动读取里出不来抓包线程看起来像死掉。解决批量插入时先 setUpdatesEnabled(false)插入完再恢复滚动条每 200 包滚一次不要每包都滚到底协议树只在点击行时构建绝不在抓包循环里创建树节点。这几个改动做完同样五千包界面流畅度是两个世界。5.4 中文路径打不开 pcap 文件现象程序里通过文件对话框选了一个 D:\抓包\test.pcappcap_open_offline 返回空指针离线回放打不开文件。原因老 pcap API 的 char* 参数按本地代码页解释Qt 的 QString 默认转成 UTF-8 之后中文路径编码不一致底层文件创建函数找不到路径。这个坑在线抓包时不出现只有离线回放时冒出来排查起来很隐蔽。解决传参前用 toLocal8Bit() 转成系统本地编码更省事的办法是先用 QFile::copy 把文件复制到临时目录的 ASCII 路径再打开。从那以后我写离线导入功能路径转换永远是第一行代码。5.5 过滤器加上后包量为零现象在界面输入 tcp port 443一包都抓不到去掉过滤就正常。原因先怀疑表达式语法。语法错误时 pcap_compile 返回非零错误信息在 pcap_geterr 里再怀疑网卡链路类型。无线网卡的 802.11 头比以太网头长BPF 过滤器按以太网偏移解析过滤出来全是无效结果。解决先用简单表达式验证从 ip 开始再加 arp最后叠加 tcp port 443这样能定位是表达式问题还是链路层问题。过滤结果为零时可以把抓包数据落盘用 WireShark 打开看链路层类型是不是 Ethernet比对着代码猜快得多。6. 进阶按五元组做会话重组还原一条 TCP 对话包列表是一堆散件日常排错更想看的是这条连接里到底说了什么。WireShark 的 Follow TCP Stream 就是这个功能Sniffer 也可以自己实现一个简化版。思路不复杂按五元组把包归到同一组再按 TCP 序号拼回载荷。6.1 五元组会话表把乱序的包归成一条对话QString sessionKey(const PacketMeta m) { QStringList parts; parts m.srcIp QString::number(m.srcPort) m.dstIp QString::number(m.dstPort) m.transProto; parts.sort(); // 双向方向统一成同一条会话 return parts.join(|); }排序是关键一步。客户端到服务器和服务器到客户端的包方向相反不排序会被拆成两条会话。用一个 QHashQString, QList 存会话界面左侧加一个会话列表显示“源IP:端口 → 目的IP:端口”的摘要选中某条会话就触发一次重组。6.2 简化版 Follow TCP Stream按序号拼 payload做重组前PacketMeta 里要补两个字段tcpSequint32和 payloadQByteArray。在 parse_packet 的 TCP 分支里TCP 头偏移值在 data[offset12] 的高 4 位乘以 4 就是 TCP 头长度从 TCP 头之后截取的就是应用层载荷。QByteArray reassembleTcp(const QListPacketMeta pkts) { QListPacketMeta sorted pkts; std::sort(sorted.begin(), sorted.end(), [](const PacketMeta a, const PacketMeta b) { return a.tcpSeq b.tcpSeq; }); QByteArray out; for (const PacketMeta m : sorted) { out.append(m.payload); } return out; }这是最简实现没有处理 TCP 重传、乱序重叠和 SACK生产级重组逻辑是 WireShark 里最复杂的部分之一。但对私有协议调试只要确认抓包环境没有丢包这个简化版已经能把一个 HTTP 请求还原成 GET / HTTP/1.1 这样看得懂的东西。验证方法很简单抓一个访问本机服务的包在会话列表里点开这条看到的就是完整的请求头和响应体。从那以后我调协议栈的习惯固定成三步先看包列表掌握全局再用会话重组看内容最后用 BPF 过滤定位到具体连接绝大多数玄学丢包都能变成可解释的日志。这份 Sniffer.zip 下载下来从 main.cpp 开始读把你自己的协议解析器挂进 packetparser.cpp比空谈框架管用得多。希望帮到你。本文还有配套的精品资源点击获取