简介:在Windows下用C++实现ping命令的网络编程资源,适合刚开始接触WinSock2或原始套接字的开发人员,也适用于网络课程实验与小型诊断工具设计。资源包共五个文件,包含两个C++源文件、一个头文件和两个工程配置文件,压缩包大小仅5KB,轻量便于本地查看;其中头文件与源文件配合构成了Ping功能的主要实现。截至目前已有3033人学习,说明该示例在同类资源中有一定参考价值。代码完整覆盖WSAStartup初始化、SOCK_RAW套接字创建、gethostbyname或getaddrinfo解析地址、ICMP回显请求构造、sendto发送及recvfrom接收应答等环节,还处理了序列号、时间戳、超时判断和应答类型校验,并在程序结束时正确关闭套接字和调用WSACleanup。对于希望理解网络命令底层原理的学习者,这份代码能提供一条清晰的入门路径,稍加修改即可用于其他ICMP应用。
1. 手写C++ ping:把网络探测从黑匣子变成可控的代码
日常排查网络,大家习惯在命令行敲ping然后盯结果。但在Windows上,ping.exe背后做了什么、超时时间怎么定、TTL字段怎么解读,对多数人来说就是个黑匣子。这个项目用C++和原始套接字把ping的完整链路自己实现了一遍:ICMP回显请求的构建、校验和计算、发送等待、超时重发、回显应答解析,全部落在本地代码里,每一步都能打断点观察。它适合刚接触Winsock的开发者做第一个网络层实战,也适合想摆脱命令行依赖、把网络探测能力嵌进自己工具链的人参考。改几行配置,就能变成内网主机存活扫描、弱网质量统计这类实用模块。
2. 网络层拆解:ICMP报文与Windows原始套接字的底层逻辑
2.1 两条实现路线,为什么选SOCK_RAW
Windows下实现ping,有两条主流路线。第一条是直接调用系统封装好的ICMP API,比如IcmpSendEcho,由系统帮你构造报文、维护超时和解析应答,代码量小,不要求管理员权限,但核心逻辑完全在黑盒里,拿不到原始收发过程。第二条是使用原始套接字SOCK_RAW + IPPROTO_ICMP手动构造ICMP报文,链路全在自己手里,能自定义载荷内容、控制超时策略、观察IP头信息,代价是需要管理员权限,且所有协议细节都要自己处理。
这个项目选的是第二条路线。原因很直接:它的目标不是实现一个功能等价的ping,而是把ICMP的工作机制拆开来看,同时给二次开发留下控制面。在Windows上创建原始套接字需要管理员终端权限,否则socket()调用会失败,这个约束后面避坑章节会展开讲。先记住一个结论:从学习价值和可控性角度,自绘报文比调API更有意义;从生产效率和权限友好角度,系统API是更好的选择。项目之所以选前者,就是要把网络层的控制权拿到代码层面。
// 创建原始套接字的三种关键参数 SOCKET sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock == INVALID_SOCKET) { // Windows下常见错误码: // WSAGetLastError() 返回 10013 时表示没有管理员权限 // 返回 10047 时表示地址族与套接字类型不匹配 return -1; }这段代码的逻辑不复杂,但三个参数缺一不可。AF_INET声明使用IPv4地址族,SOCK_RAW告诉系统这个套接字不走TCP/UDP协议栈的自动封装,IP头之后的内容完全由用户代码填充,IPPROTO_ICMP指定上层协议是ICMP,系统收到ICMP报文时会把这个套接字作为投递目标。后两个参数的组合是ping程序能在Windows上发声的前提,后续报文构造、发送、接收都基于这个句柄展开。
2.2 ICMP回显报文的字段设计
ICMP工作在IP层之上,不依赖TCP或UDP的端口概念,所以ping天然没有端口号,通信双方靠的是报文里的标识符和序号来匹配请求与应答。回显请求报文的标准结构是:类型字段占1字节,回显请求固定为8,回显应答固定为0;代码字段占1字节,回显类型下固定为0;紧跟2字节校验和;再后面是2字节标识符和2字节序列号,最后是可变长度的数据区,常见做法是放发送时刻的时间戳和一段填充字节。
// ICMP回显报文头结构定义,按1字节对齐 #pragma pack(push, 1) typedef struct { unsigned char type; // 8代表回显请求,0代表回显应答 unsigned char code; // 回显类型下恒为0 unsigned short checksum; // 校验和,发送前必须计算 unsigned short identifier; // 用于匹配发送端和接收端 unsigned short sequence; // 序号,用于统计丢包和乱序 } ICMPHeader; #pragma pack(pop)字段设计里有两个容易被忽视的细节。identifier在Windows的ping.exe里通常使用进程ID,这样多个ping进程同时跑也不会互相串包;而sequence从0开始递增,接收方回复时会原样带回,发送方就能通过序号判断是第几个请求的应答,也能检测到乱序。代码里的#pragma pack(push, 1)让结构体按1字节对齐,避免编译器插入填充字节导致报文长度和协议预期不一致,这是网络编程中常见的细节陷阱。
2.3 Winsock初始化与网络字节序
Windows环境动socket前必须先调用WSAStartup完成Winsock库版本协商与内部资源初始化,否则后续所有socket函数都会返回错误,这在Linux下不存在对应步骤,很多从Linux转过来的开发者第一次就会在这里翻车。版本协商参数是MAKEWORD(2, 2),即请求2.2版本Winsock,这是Windows XP之后的标准做法。
WSADATA wsaData; // 请求Winsock 2.2版本 int ret = WSAStartup(MAKEWORD(2, 2), &wsaData); if (ret != 0) { // 返回非0表示初始化失败,错误原因不是WSAGetLastError std::cerr << "WSAStartup failed: " << ret << std::endl; return -1; }字节序处理上,标识符、序列号、校验和的填充都要用htons把主机字节序转成网络字节序。Pentium这类CPU是小端序,而IP协议规定多字节整数按大端序传输。这里如果漏掉转换,发出去的报文字段会倒置,对端解析时看到的identifier和sequence就会整段失真,校验和也会怎么算都对不上。htons把无符号短整型转换一次,接收解析时再用ntohs转回来,收发的报文才算闭合。
3. 核心模块实现:从请求构造到应答解析的完整链路
3.1 数据结构和全局参数
程序运行前需要准备三组参数:目标地址、发送间隔、超时阈值。目标地址以字符串形式传入,程序内部通过inet_addr或gethostbyname解析成sockaddr_in结构;发送间隔默认1000毫秒,超时阈值默认3000毫秒,这两个参数决定一次ping探测的节奏。全局统计结构记录发送数、接收数、最大最小RTT,便于最后生成汇总。
// 全局统计信息结构 struct PingStats { int sent; // 已发送请求数 int received; // 已收到应答数 double rttMin; // 最小往返时延 double rttMax; // 最大往返时延 double rttSum; // 往返时延累计值,用于求平均 } gStats; // 网络参数 char gTarget[64]; // 目标主机名或IP int gInterval = 1000; // 发送间隔,毫秒 int gTimeout = 3000; // 超时阈值,毫秒统计结构用全局变量的方式是原型阶段最直接的写法,方便在发送函数和接收函数之间共享数据。rttSum不用数组而是累积总和,是因为最终只需要平均值,没必要保留每一跳的完整序列。gTarget预留64字节是因为IPv4最大文本长度加空字符是16字节,64足够容纳主机名形式的域名。这个阶段的参数后续如果要做成多线程并发,需要改成结构体成员传入线程,避免全局变量竞争,二次开发章节会提到。
3.2 构建回显请求报文
构建报文的核心职责是:填充ICMP头所有字段、把当前系统时间戳嵌入数据区、调用校验和函数完成计算。时间戳放入数据区而不是放在自定义字段里,是因为ICMP头的所有字段都是协议规范的一部分,擅自改动会被对端协议栈丢弃或者解析异常。时间戳用64位整数表示自基准时间以来的毫秒数,存入报文数据区的前8字节。
// 构建一次ICMP回显请求报文 int BuildEchoRequest(char* packet, int seq, int packetSize) { ICMPHeader* header = (ICMPHeader*)packet; header->type = 8; // 回显请求类型 header->code = 0; // 代码固定0 header->checksum = 0; // 先清零,计算校验和时使用 header->identifier = htons(GetCurrentProcessId()); header->sequence = htons(seq); // 数据区前8字节放时间戳,用于计算RTT long long timestamp = GetTimestampMs(); memcpy(packet + sizeof(ICMPHeader), ×tamp, sizeof(timestamp)); // 数据区剩余部分填充固定字节,便于观察响应 memset(packet + sizeof(ICMPHeader) + sizeof(timestamp), 0x61, packetSize - sizeof(ICMPHeader) - sizeof(timestamp)); // 计算校验和,覆盖头和数据全段 header->checksum = CalculateChecksum((unsigned short*)packet, packetSize); return packetSize; }构建过程有几个关键点。标识符使用进程ID后用htons转换,确保本机多个ping实例互不干扰;校验和必须在数据区填充完后再计算,因为校验范围包含整个报文,如果先算校验和再塞数据,计算结果就失效了。填充字节选0x61只是为了让抓包时看起来有规律,方便用Wireshark核对报文内容。GetTimestampMs是自己封装的时间函数,内部调用GetTickCount64或QueryPerformanceCounter得到毫秒级时间戳。
3.3 校验和:最容易写错却最关键的环节
ICMP校验和算法不是简单加法取反,它要求以16位为单位对报文逐字累加,高位溢出部分回卷到低位继续累加,最后对总和取反码。计算时报文长度必须是偶数,如果末尾剩1字节,要补零继续参与累加。这个算法写错通常不会编译报错,而是表现为目标主机接收报文后直接丢弃,因为ICMP层发现校验和错误就静默丢弃不回包。
// ICMP校验和标准算法 unsigned short CalculateChecksum(unsigned short* buffer, int size) { unsigned long checksum = 0; // 每16位一组累加 while (size > 1) { checksum += *buffer++; // 累加一组16位数据 size -= 2; } // 剩下单字节时补齐后累加 if (size > 0) { checksum += *(unsigned char*)buffer; } // 把进位回卷到低位,直到无进位 while (checksum >> 16) { checksum = (checksum & 0xFFFF) + (checksum >> 16); } // 取反码得到最终校验值 return (unsigned short)(~checksum); }这里的回卷循环是整个校验和算法的精髓。第一次累加后高位可能产生多个进位位,回卷要一直循环到高位全为0才停止。网络设备和操作系统协议栈都会用同一套算法校验,发端和收端的计算结果必须一致,否则报文被判定损坏。值得注意的一个边界:计算校验和时传入的size必须是实际报文长度,也就是ICMP头加数据区的总字节数,不是缓冲区容量。不少踩坑案例都是因为传入sizeof(buf)导致多算了一段未初始化内存,算出来的校验和自然不稳定。
3.4 主循环:发送、等待、超时判定
发送循环采用非阻塞加select等待的模式。建立套接字后设置接收超时,每次发送请求前先打点时间戳,发送后调用select等待套接字可读。select返回0表示超时未收到应答,本次发送记为超时;返回正值表示有数据到达,进入接收解析流程。超时阈值不是总循环期限,而是每单次请求的等待期限,这也符合ping命令的行为:连续ping不通时不阻塞整个程序。
while (gStats.sent < maxSendCount) { // 设置本次数据包内容 char packet[64]; int packetSize = sizeof(ICMPHeader) + 8 + 40; BuildEchoRequest(packet, gStats.sent, packetSize); // 发送到目标地址 sockaddr_in destAddr; destAddr.sin_family = AF_INET; destAddr.sin_addr.s_addr = inet_addr(gTarget); destAddr.sin_port = 0; // ICMP没有端口概念,置0 sendto(rawSocket, packet, packetSize, 0, (sockaddr*)&destAddr, sizeof(destAddr)); // select等待应答,参数分别是最大fd+1、读集合、写集合、异常集合、超时时间 fd_set readSet; FD_ZERO(&readSet); FD_SET(rawSocket, &readSet); timeval timeout; timeout.tv_sec = gTimeout / 1000; timeout.tv_usec = (gTimeout % 1000) * 1000; int selectResult = select(0, &readSet, NULL, NULL, &timeout); if (selectResult == 0) { // 超时未收到应答 std::cout << "Request " << gStats.sent << " timed out" << std::endl; gStats.sent++; continue; } else if (selectResult == SOCKET_ERROR) { // select本身出错,按退出处理 break; } // 可读说明收到数据,进入解析流程 recvfrom(rawSocket, recvBuffer, sizeof(recvBuffer), 0, NULL, NULL); ParseReply(recvBuffer); gStats.sent++; }主循环的关键在超时控制上。select的timeval参数传的是临时变量,每次进入循环都要重新赋值,不能放在循环外复用,否则select会修改这个结构剩余值导致后续超时不准确。FD_SET在每次select前也要重新调用,因为select会从读集合中移除不可读的套接字。还有一个细节是Windows下select的第一个参数被忽略,传0即可,但代码里保留该参数便于将来移植到Linux时逻辑一致。
3.5 IP头解析与RTT计算
收到数据后不能直接当成ICMP报文解析。Windows的原始套接字在接收方向上返回的是完整IP包,从recvfrom缓冲区第一个字节开始是IP头。IP头的首部长度字段占用低4位,单位是4字节,需要先读这个值算出IP头字节数,往后偏移才是ICMP报文的起始位置。如果直接按ICMP头解析,所有字段都会错位,这在Windows和Linux的行为差异很大,LinLinux的原始套接字默认不返回IP头,这也是跨平台移植时需要特别关注的点。
// 解析收到的数据包,提取ICMP应答信息 void ParseReply(char* recvBuffer) { // 第一个字节低4位是IP头长度,单位4字节 int ipHeaderLen = (recvBuffer[0] & 0x0F) * 4; // 跳过IP头,指向ICMP报文起点 ICMPHeader* icmp = (ICMPHeader*)(recvBuffer + ipHeaderLen); // 只处理回显应答,类型0 if (icmp->type != 0) { return; } // 读取数据区时间戳,计算RTT long long sendTime; memcpy(&sendTime, recvBuffer + ipHeaderLen + sizeof(ICMPHeader), sizeof(sendTime)); long long now = GetTimestampMs(); // 同步更新统计信息 double rtt = (double)(now - sendTime); gStats.received++; if (gStats.received == 1) { gStats.rttMin = gStats.rttMax = rtt; } else { if (rtt < gStats.rttMin) gStats.rttMin = rtt; if (rtt > gStats.rttMax) gStats.rttMax = rtt; } gStats.rttSum += rtt; std::cout << "Reply from " << gTarget << " time=" << rtt << "ms" << " icmp_seq=" << ntohs(icmp->sequence) << std::endl; }解析逻辑中要留意的点是时间戳的读取偏移。发送时数据区紧接着ICMP头,接收时也沿着相同的偏移读取,就能拿到当初写入的发送时间。应答里携带的时间戳是发送方写入的,不是接收方填的,因此now - sendTime算出的时延包含了对端处理和网络传输两部分。另外recvBuffer[0] & 0x0F取的是IP头长度字段,如果目标回包带了选项字段,IP头会比标准的20字节更长,用这个公式计算才能保证不解析错位。
4. 避坑实录:Windows下ping实现的四个经典故障
4.1 问题一:socket创建报10013权限错误
现象:程序启动后socket()调用返回INVALID_SOCKET,WSAGetLastError得到10013。
原因:Windows从Vista开始限制原始套接字的使用,非管理员权限下创建SOCK_RAW类型的套接字直接拒绝,这是系统安全策略的一部分,不是代码问题。许多初学者在Visual Studio里F5调试,普通权限的运行环境直接撞上这个限制。
解决:用管理员身份打开终端再运行程序,或者给生成的exe文件添加管理员UAC清单。临时验证时,在Visual Studio中将调试会话的启动方式改为“以管理员身份运行”,测试通过后再考虑为发布程序配置requireAdministrator的manifest。类似地,部分杀毒软件也会拦截原始套接字的创建,现象同样是10013或10045,排查时需要顺带确认安全软件策略。
4.2 问题二:校验和算完对端仍不回包
现象:程序发送报文没有任何报错,但目标地址永远不响应,用抓包工具能看到请求发出但没有应答。
原因:校验和的经典错误是把计算时机放在了填充数据区之前,或者把缓冲区容量当作报文长度传入计算函数,导致校验范围内混入未初始化的内存,算出的值不稳定,对端校验失败后静默丢弃。另一个隐藏问题是数据区尾部的填充字节数算错,头加数据的实际总长与sendto的第三个参数不一致,也会让系统按错误边界发送报文。
解决:严格按“先填头部字段、再填数据区、最后计算校验和”的顺序组织构建函数,CalculateChecksum的第二个参数传实际报文长度而不是缓冲区大小。每次改动报文结构后,用抓包工具对照标准回显请求的十六进制内容逐字段比对,确认checksum字段在发出前已是有效值。
4.3 问题三:收到的数据解析错位,RTT异常大
现象:程序能收到数据,打印出来的RTT动辄几十秒,或者icmp_seq显示为乱码一样的值。
原因:把recvfrom拿到的数据直接当ICMP头解析。Windows原始套接字接收数据时带上了IP头,而ICMP头从IP头之后才开始。没有读取首部长度字段而是固定偏移20字节,在目标回包包含IP选项字段时就会错位,序号、时间戳全部解析错误,计算出的RTT自然失真。
解决:解析函数第一行先取recvBuffer[0] & 0x0F得到IP头长度,再以此偏移访问ICMP字段。另外可以给解析流程加断言:ICMP类型必须是0才继续,如果类型值异常,说明偏移错了,辅助快速定位。跨平台移植到Linux时要注意方向性差异,Linux原始套接字默认不返回IP头,需要设置IP_HDRINCL套接字选项才与Windows行为一致。
4.4 问题四:WSAStartup未调用导致socket函数返回SOCKET_ERROR
现象:socket创建和sendto调用都失败,WSAGetLastError报10093。
原因:Windows下使用任何Winsock函数前必须调用WSAStartup完成初始化。代码在调试时逻辑看起来没毛病,但因为初始化函数语句在条件判断中没走到,或者初始化版本号填错,底层库未激活,所有socket相关调用自然全部失败。
解决:在程序入口处最先调用WSAStartup,并在退出时调用WSACleanup。注意WSAStartup返回值非零时错误信息存在返回值本身,而不是通过WSAGetLastError获取。程序结束时调用closesocket关闭套接字句柄再清理Winsock资源,避免进程退出时资源泄漏。
5. 编译运行与参数设计:把程序从源码跑成可用工具
5.1 Windows编译环境配置与链接依赖
源码依赖的头文件有三个:winsock2.h提供Winsock函数声明,ws2tcpip.h补充inet_addr等IPv4函数定义,windows.h提供进程ID等系统接口。顺序上先包含winsock2.h再包含windows.h,因为后者会间接包含旧版winsock.h,先包含winsock2.h可以避免符号重定义。
#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <ws2tcpip.h> #include <windows.h> #include <iostream> #pragma comment(lib, "ws2_32.lib")Visual Studio工程配置时,链接器需要显式依赖ws2_32.lib,代码中通过#pragma comment(lib, "ws2_32.lib")声明即可,不需要在工程属性面板里额外添加。用MinGW环境编译时,链接参数换成-lws2_32,并在编译时注意32位与64位库的匹配。如果编译环境报错说IPPROTO_ICMP未定义,通常是ws2tcpip.h未包含或预处理器宏设置不全,补齐后可解决。
5.2 命令行参数解析与容错
工具型程序需要支持三种运行形态:不带参数默认ping本机回环地址127.0.0.1,带单个参数作为目标IP,带多参数解析频次和计数。简单的手写解析足够应付,不需要引入第三方参数库,保持零依赖是这类小工具的一个隐性要求。
int main(int argc, char* argv[]) { // 参数解析规则: // 无参数 -> ping 127.0.0.1,无限次 // -c 4 -> 只发4次 // -t 2000 -> 超时阈值2000ms // 末尾参数 -> 目标地址 const char* target = "127.0.0.1"; int count = -1; // -1表示无限次,类似系统ping的行为 int timeoutMs = 3000; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "-c") == 0 && i + 1 < argc) { count = atoi(argv[++i]); } else if (strcmp(argv[i], "-t") == 0 && i + 1 < argc) { timeoutMs = atoi(argv[++i]); } else { target = argv[i]; } } // 解析目标地址为二进制形式 sockaddr_in destAddr; destAddr.sin_family = AF_INET; destAddr.sin_port = 0; destAddr.sin_addr.s_addr = inet_addr(target); if (destAddr.sin_addr.s_addr == INADDR_NONE) { std::cerr << "Invalid IP address: " << target << std::endl; return -1; } // 初始化Winsock并创建原始套接字 // 后续进入3.4节的主循环逻辑 }参数解析的容错要点有两个。第一是inet_addr失败时返回INADDR_NONE,但这个返回值同时是合法的广播地址255.255.255.255,所以仅凭这个判断IP是否合法不够严谨,更完善的代码应使用InetPton做严格校验。第二是访问argv[++i]之前必须检查i + 1 < argc,否则用户输了-c却没给数字,程序会越界读空指针,这类小工具最常见的就是参数不完整时崩溃。
5.3 验证方式与预期输出
编译通过后用管理员终端启动,依次测试三类场景。第一类是本机回环127.0.0.1,响应时间通常在1毫秒内;第二类是局域网内主机,响应时间在个位数毫秒或十几毫秒;第三类是跨网段或外网地址,响应时间与网络质量强相关。每次先ping一个在线地址和一个不存在的地址,确认超时分支正常触发。
C:\Users\Dev\ping-demo> pingtool 127.0.0.1 -c 3 -t 2000 Reply from 127.0.0.1 time=0ms icmp_seq=0 Reply from 127.0.0.1 time=0ms icmp_seq=1 Reply from 127.0.0.1 time=0ms icmp_seq=2 === ping statistics === 3 packets transmitted, 3 received, 0% packet loss rtt min/avg/max = 0.000/0.000/0.000 ms验证时推荐配合抓包工具确认报文格式。观察两条关键信息:发出的请求类型是8,返回的应答类型是0,检验和字段在发送前已经填好。如果抓包发现类型字段倒置或者序号与代码内设置不符,优先排查字节序转换遗漏。完成基本功能后,再测试防火墙拦截场景,Windows自带防火墙对ICMP的回显请求默认有放行策略,但某些安全软件会拦截入站ICMP,这时超时分支被触发,属于预期行为而非程序缺陷。
6. 二次开发拓展:从单点ping到并发监测工具
6.1 多线程并发探测多个主机
现在的循环结构是单线程逐个探测,拓展到多主机时有两种思路。第一种是把主循环复制进线程函数,每个目标地址一个线程,线程间通过原子变量汇总统计;第二种是改成单线程非阻塞轮询,维护一张待探测地址表,用一个select同时等待所有套接字可读。前者实现简单但线程数受目标数量限制,后者更接近生产级监测工具的做法,适合目标数百台主机的大规模扫测场景。我一般建议先走线程模型,逻辑改动最小,后续性能瓶颈出现时再重构事件模型。
6.2 统计丢包率与平均RTT
现有的统计字段已经能支撑丢包率和平均RTT的计算,收尾阶段汇总输出即可。丢包率等于(sent - received) / sent * 100,平均RTT等于rttSum / received。需要注意的边界是被零除异常:如果所有请求都超时,received为0,计算平均RTT前必须先做空值判断。更完整的指标还可以加入延时抖动,即相邻两次RTT差值的绝对值平均,这个数值对判断网络稳定性比单纯的平均值更有参考价值。
6.3 自定义载荷数据与服务端配合
ICMP的数据区理论可以放任意内容,把时间戳以外的字节改成自定义协议字段,就能实现简单的加密探测或服务端标识。常见的改造思路是前8字节保持时间戳格式,紧接着写入4字节随机令牌,服务端在应答的ICMP报文里回传这个令牌,验证链路通断的同时也能确认目标确实是自己的探测服务端,避免劫持干扰。这已经偏离标准ping的通用语义,属于特定场景下的定制方案,改造时要清楚接收方必须配套修改解析逻辑。
实际改造中还有一个细节值得养成习惯:把所有的魔数提取成常量或配置项。报文长度、数据区载荷长度、默认超时值这些数字散落在代码各处时,每次调整参数都是一次灾难。我那会儿做这个项目,把packetSize定义在构建函数内部,后续想扩展载荷长度得翻三个地方,后来全部提到文件头部的constexpr常量列表里,加一个载荷尺寸变化只改一处,重编后行为就正确对齐了。从那以后我每次写网络层代码都强制走一遍“参数常量先声明、再使用”的习惯。希望这些实现细节能帮你在自己的ping工具上少踩几个坑,顺利跑出第一份完整的网络探测数据。
本文还有配套的精品资源,点击获取