简介:这款网络流量分析器源码面向网络监控、安全运维人员及VC++学习者,提供一套基于抓包记录的流量分析实现,可用于行业业务流量监控、病毒木马后门流量监控、ARP病毒监控和网络异常监测等场景。程序能够分析单点流量、点到点流量与协议流量排名,支持按流量统计和按占比统计,并展示基于协议及目标端口的累计流量,提示ARP流量占比并给出ARP流量分析;关键技术涉及内存表扫描和BarChart图表控件使用。压缩包共36个文件,约168KB,以h头文件、cpp源文件、rc资源脚本、ico/bmp图标位图、mdb/db数据文件、xlsx表格和vcxproj/sln工程文件为主,工程结构较完整,便于直接打开研究。已有909人学习下载,适合通过现有代码理解流量采集、统计排名、图表展示与监控逻辑,也可作为课程设计或安全监控工具的原型参考。
1. 抓包不等于分析:一份命令行流量统计源码能省掉哪些重复劳动
机房出口带宽半夜被打满,交换机上既没有流量镜像也没有 NetFlow,手边只有一台普通 Linux 机器——这种时候最省事的做法是在网关旁挂一台机器直接抓包,按五元组聚合出 Top 会话。可 tcpdump 只给你原始包,Wireshark 靠人点,真正缺的是一份能把网络流量实时聚合成「每秒带宽 + 包速率 + 活跃会话数」的代码。这份网络流量分析器源码就干这个:从网卡抓包、逐层解析到传输层、按五元组做双向归并、按秒输出统计,命令行能直接跑,也能当成库嵌进自己的采集程序。适合做网络运维、安全事件初筛、给监控体系补一份流量口径的从业者;想要现成图形大屏的,这份源码不伺候,前端采集和展示得自己接。
2. 源码骨架拆解:从网卡到统计表的四段流水线
这类流量分析器源码包,不管作者怎么组织目录,内核基本都是四段式:采集、解析、聚合、输出。四段之间用环形缓冲或者队列串起来,采集线程只管把帧搬进内存,解析线程算偏移,聚合线程维护哈希表,输出线程按固定节奏吐一行统计。把这条流水线看清楚,后面改参数、加协议、定位丢包才有下手的地方。我拿到手的这类源码,模块职责一般是这样切的:
| 模块 | 职责 | 关键动作 | 改动频率 |
|---|---|---|---|
| capture | 打开网卡、装载过滤器、收帧 | open / setfilter / next_ex | 低,除非换采集方式 |
| parse | 以太网→IP→TCP/UDP 逐层解码 | 头部偏移计算、字节序转换 | 中,加协议时改 |
| stats | 五元组归并、按秒快照、老化 | 哈希查找、双向归一、超时淘汰 | 中,调容量和老化时间 |
| output | 文本行 / JSON 行输出 | 格式化、批量写 | 高,接不同后端时改 |
2.1 采集层:pcap 接口和原生套接字怎么选
采集层是整个程序的地基,选错了后面全是补丁。源码里最常见的是 pcap 接口,优点是跨平台、过滤器直接下推到内核、支持离线文件回放,调试时可以完全脱离网卡;代价是多一次内存拷贝,万兆以上线速容易在采集层就丢包。另一条路是 Linux 原生包套接字,可控性强,能上内存映射的环形缓冲,代价是绑定平台、过滤器要自己实现或复用编译出来的字节码。
我的建议是先用 pcap 把整条链路跑通,等统计口径确认没问题、确实卡在采集性能上,再考虑换原生套接字。源码里这层一般长这样:
/* 打开网卡并装载 BPF 过滤器,返回句柄;失败时 errbuf 里是原因 */ pcap_t *cap_open(const char *dev, int snaplen, int timeout_ms, const char *filter, char *errbuf) { pcap_t *h = pcap_open_live(dev, snaplen, 1 /* 混杂模式 */, timeout_ms, errbuf); if (!h) return NULL; struct bpf_program prog; /* 过滤器在编译期就决定了内核丢多少包,写错等于白抓 */ if (pcap_compile(h, &prog, filter, 1 /* 最优代码 */, PCAP_NETMASK_UNKNOWN) < 0) { pcap_close(h); return NULL; } if (pcap_setfilter(h, &prog) < 0) { pcap_freecode(&prog); pcap_close(h); return NULL; } pcap_freecode(&prog); return h; }代码里的snaplen是每个包最多抓多少字节,timeout_ms是内核缓冲到期通知的毫秒数,filter是 BPF 表达式。三个参数里最容易翻车的是snaplen:设小了,TCP 头后面的载荷被截断,遇到带选项的报文算偏移就会错位;设大了,同样缓冲区能装的包变少,突发流量下丢包率反而上升。timeout_ms设成 0 会让内核一直攒到缓冲满才交付,实时性直接崩掉,一般给 100 到 1000 之间。混杂模式那个参数设 1 是为了在交换机镜像口上收全量,如果只抓本机流量,设 0 能省掉一层过滤。
2.2 解析层:头部偏移算错,后面全是脏数据
解析层的活是把一段字节流拆成五元组和长度。以太网头固定 14 字节,但遇到 802.1Q 标签要再加 4 字节,判据是类型字段等于 0x8100;IPv4 头长度不固定,得用首部长度字段乘 4;TCP 头同理,用数据偏移字段乘 4;UDP 固定 8 字节。这些偏移一旦算错,端口号就会读成载荷里的随机字节,统计出来的 Top 会话全是垃圾。
/* 从以太网帧解析出五元组;返回 0 表示成功,负值表示不支持的帧 */ int parse_frame(const uint8_t *f, uint32_t caplen, flow_key_t *k) { if (caplen < 14) return -1; uint16_t eth_type = (f[12] << 8) | f[13]; uint32_t off = 14; /* 802.1Q 标签:类型 0x8100,跳过后真正的类型在后面 */ if (eth_type == 0x8100 && caplen >= 18) { eth_type = (f[16] << 8) | f[17]; off += 4; } if (eth_type != 0x0800) return -2; /* 只处理 IPv4 */ if (caplen < off + 20) return -1; const uint8_t *ip = f + off; uint32_t ihl = (ip[0] & 0x0f) * 4; /* 首部长度,单位是 4 字节 */ if (ihl < 20 || caplen < off + ihl + 4) return -1; /* 分片包只有首片带端口,后续片必须跳过,否则端口是乱码 */ uint16_t frag = ((ip[6] & 0x1f) << 8) | ip[7]; if (frag != 0) return -3; k->proto = ip[9]; memcpy(k->src, ip + 12, 4); memcpy(k->dst, ip + 16, 4); const uint8_t *l4 = ip + ihl; if (k->proto == 6) { /* TCP */ k->sport = (l4[0] << 8) | l4[1]; k->dport = (l4[2] << 8) | l4[3]; } else if (k->proto == 17) { /* UDP */ k->sport = (l4[0] << 8) | l4[1]; k->dport = (l4[2] << 8) | l4[3]; } else { k->sport = k->dport = 0; /* ICMP 等无端口协议 */ } return 0; }这段逻辑说明三件事。第一,caplen是实际抓到的长度,不是报文声明的长度,两者必须分开用,所有越界判断都基于caplen,否则截断包会把程序读崩。第二,分片包必须单独处理,因为非首片没有传输层头,硬读端口就是随机数——很多人统计出来的「Top 端口 0」就是这里来的。第三,字节序手工拼装而不是强转结构体,是为了避免对齐问题和不同平台的字节序差异,源码里如果直接(struct iphdr*)f强转,在 ARM 或开启严格对齐的编译器上会直接崩。IPv6 的扩展头链更长,如果源码只做 IPv4,那就明确只做 IPv4,别在统计口径里混着说。
2.3 聚合层:五元组要做双向归一,否则一条会话拆成两条
同一条 TCP 连接,两个方向的源和目的正好相反。如果哈希键不做归一,一条下载会话会拆成两个条目,被拆成单向统计,看的时候就觉得「上行很大下行很小」,其实是同一条流。常见做法是把五元组规范化:先比较协议类型,再比较地址,地址相同再比较端口,按固定顺序把较小的一方放到前面,统计时再按记录的首包方向区分。老化策略也在这层,一般给 120 秒的会话超时,TCP 收到 FIN 之后单独设一个短超时。
/* 返回 <0 表示 a 应排在 b 前,>0 反之,0 表示完全相等(同一条会话) */ int key_cmp(const flow_key_t *a, const flow_key_t *b) { if (a->proto != b->proto) return (int)a->proto - (int)b->proto; int c = memcmp(a->src, b->src, 4); if (c) return c; c = memcmp(a->dst, b->dst, 4); if (c) return c; if (a->sport != b->sport) return (int)a->sport - (int)b->sport; return (int)a->dport - (int)b->dport; } /* 把任意方向的包归一成同一条会话的键,flags 记录首包方向 */ void key_normalize(flow_key_t *k, uint8_t *first_is_reverse) { flow_key_t rev = *k; uint8_t tmp[4]; memcpy(tmp, rev.src, 4); memcpy(rev.src, rev.dst, 4); memcpy(rev.dst, tmp, 4); uint16_t t = rev.sport; rev.sport = rev.dport; rev.dport = t; *first_is_reverse = (key_cmp(k, &rev) > 0); if (*first_is_reverse) *k = rev; }key_cmp的顺序必须和哈希函数用的顺序一致,否则会出现「比较说是同一条、哈希分到两个桶」的玄学问题。first_is_reverse这个标志位别丢,方向信息是后面算上下行比、判断扫描行为的唯一依据,丢了就只剩总量。表容量按峰值会话数估,一般取预期峰值的 2 倍以上,负载因子超过 0.7 就当机立断扩容——哈希表退化成长链表之后,单核跑满也就几万包每秒。
2.4 输出层:按秒成批吐,别在收包循环里格式化
收包路径上最怕两件事:内存分配和格式化输出。每收到一个包就printf一行,CPU 全耗在系统调用上,包没抓到几个,CPU 先满了。常见做法是聚合线程每秒切一次快照,把这一秒内带宽、包数、活跃会话数、协议分布打包成一个结构,输出线程一次性写成文本行或 JSON 行。
/* 每秒调用一次,把当前窗口的统计吐成一行 JSON */ void flush_window(FILE *out, const stats_t *s) { fprintf(out, "{\"ts\":%lld,\"bytes\":%llu,\"pkts\":%llu," "\"flows\":%u,\"tcp\":%u,\"udp\":%u,\"drops\":%llu}\n", (long long)time(NULL), s->bytes, s->pkts, s->active_flows, s->tcp_pkts, s->udp_pkts, s->drops); fflush(out); /* 行缓冲后端,不刷就攒在库里 */ }JSON 行的好处是下游采集器不用写解析器,一行一条、追加写、天然可切分。字段里drops一定要有,它是判断统计可信度的唯一依据,没有这个字段,你永远不知道现在的数字是真实的还是采样之后的。输出频率按秒给就够,做带宽曲线一秒一个点已经足够密,再细对存储和展示都是负担。
3. 编译与首次运行:依赖、权限、造流量三件事
源码能不能跑起来,卡点从来不在代码本身,而在三件事:依赖装没装全、权限给没给对、有没有流量可抓。这三件事每件都能卡住半小时以上,尤其在没有图形界面的服务器上排查,反馈信息又少又绕。
3.1 依赖与构建:先确认头文件在不在
这类源码通常带一份构建脚本,依赖的库不多,主要是抓包库和它的开发头文件。构建系统常见是 CMake 或 Makefile,两条路的排查顺序一样:先看编译器找不找得到头文件,再看链接器找不找得到库。
# Debian/Ubuntu 系,装开发包而不是只装运行时包 sudo apt-get install -y build-essential cmake libpcap-dev # 先只做配置和编译,不安装,方便看报错 cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j"$(nproc)"编译报pcap.h: No such file or directory,说明只装了运行库没装开发包;链接阶段报undefined reference to pcap_open_live,说明头文件找到了但库没链上,检查构建脚本里有没有把库加进链接目标。Release不是可选项,解析和哈希这段代码在-O0下性能差好几倍,测速之前一定要确认构建类型。构建产物一般落在build/下,先别急着装到系统目录,用绝对路径跑一遍更省事。
3.2 权限与网卡:用能力位比 sudo 靠谱
抓包要原始套接字权限,最简单是sudo,但长期跑一个sudo守护进程不合适。更稳的做法是给二进制加CAP_NET_RAW和CAP_NET_ADMIN两个能力位,程序本身以普通用户运行,出了漏洞影响面也小。
# 只授两个能力位,避免整进程 root sudo setcap cap_net_raw,cap_net_admin=eip ./build/traffic-analyzer getcap ./build/traffic-analyzer # 确认网卡名和状态,别对着 lo 抓半天 ip -br linkip -br link的输出里,UP状态才是能抓的,DOWN的网卡打开会直接报错。多个网卡的时候一定要显式指定设备名,默认选第一个往往会选到回环口或者容器网桥,抓半天什么都没有。容器里跑要注意,容器默认命名空间只有自己的虚拟网卡,想抓宿主机流量得在创建时共享宿主网络命名空间,或者干脆在宿主机上跑。
3.3 没有真实流量:用命名空间造一对打流网卡
开发机上往往没什么流量,抓半天只有几个 ARP 包,根本没法验证统计对不对。最快的办法是用网络命名空间造一对虚拟网卡,一边用nc或者iperf打流,另一边抓。
# 建两个命名空间,用一对 veth 连起来 sudo ip netns add ns-a sudo ip netns add ns-b sudo ip link add veth-a type veth peer name veth-b sudo ip link set veth-a netns ns-a sudo ip link set veth-b netns ns-b # 配地址并拉起 sudo ip netns exec ns-a ip addr add 10.10.0.1/24 dev veth-a sudo ip netns exec ns-b ip addr add 10.10.0.2/24 dev veth-b sudo ip netns exec ns-a ip link set veth-a up sudo ip netns exec ns-b ip link set veth-b up # 一侧起服务,另一侧打流;同时在 ns-a 里抓 veth-a sudo ip netns exec ns-b nc -l 9000 & sudo ip netns exec ns-a dd if=/dev/zero bs=1M count=200 | nc 10.10.0.2 9000这套环境的好处是流量完全可控:打多少字节、用哪个端口、跑多久都心里有数,抓出来的统计可以直接跟dd的字节数对账。对账能对上,说明解析和聚合没错;对不上,问题一定在偏移计算或者双向归一上,排查范围一下缩到两个函数。做完记得ip netns del清理,否则命名空间和虚拟网卡会一直挂在系统里。
4. 参数调优:snaplen、缓冲区、超时与 BPF 的取值边界
同一份源码,参数不同,结果能差出一个数量级。调参的核心不是把所有值调大,而是知道每个参数影响哪一段、边界在哪。下面四个参数是实际部署时改得最多的。
| 参数 | 常见取值 | 影响 | 调错的后果 |
|---|---|---|---|
| snaplen | 96 / 128 / 262144 | 单包截断长度 | 太小算错偏移,太大吃缓冲 |
| buffer | 2MB ~ 64MB | 内核缓冲容量 | 太小突发丢包,太大延迟上升 |
| timeout | 100 ~ 1000 ms | 内核交付节奏 | 0 会攒满才交付,实时性崩 |
| 过滤器 | 按需收窄 | 内核侧预过滤 | 写宽了用户态扛不住 |
4.1 snaplen:只做统计就截断,要做内容分析就留全
snaplen决定每个包最多拷贝多少字节到用户态。只算带宽、端口、会话数的话,96 字节足够覆盖以太网头、IP 头、TCP 头再加一点载荷,设成 96 能把单位缓冲能装的包数提高一个数量级,突发流量下的丢包率明显下降。但有个前提:协议解析只看头部,绝不碰载荷。一旦想做应用层协议识别、看 HTTP 首行或者 TLS 握手,96 就废了,得放到 1500 以上或者干脆 262144 留全。
实际部署我一般给 128,兼顾头部完整性和缓冲利用率。如果源码里有依赖载荷的模块(比如做 DNS 查询名提取),必须把 snaplen 单独提到 512 以上,并且把这块处理放在聚合之后、不在收包路径上做。判断当前值够不够,最直接的办法是看解析失败计数:如果parse返回负值的比例超过千分之一,先怀疑截断,把 snaplen 翻倍再看。
4.2 缓冲区与丢包计数:先看丢包再谈性能
内核缓冲不够是所有流量工具共同的痛。突发流量下,用户态来不及取,内核环形缓冲写满就开始丢,丢的时候通常不报错,只是数字悄悄变小。所以drops这个计数器必须从抓包接口里读出来,而且要周期性输出。
# 调大内核侧缓冲(按网卡名,单位字节) sudo sysctl -w net.core.rmem_max=33554432 sudo ifconfig eth0 mtu 1500 # 顺带确认 MTU,别让巨帧影响分片判断调完rmem_max还要在打开网卡时显式设置缓冲大小,很多源码默认用系统最小值,光改sysctl不生效。判断要不要继续加缓冲,看两条曲线:丢包率随缓冲增大明显下降,说明确实是缓冲不够;加了一倍缓冲丢包率纹丝不动,那瓶颈在解析或哈希,继续加只是浪费内存。还有个容易忽略的点,缓冲变大之后timeout的语义会变,包攒得更多才交付,延迟上升,实时看板会感觉「数字跳得慢了」。
4.3 BPF 过滤器:写窄一点,用户态压力立刻下来
过滤器是性价比最高的优化,因为它在内核里就把不需要的包扔掉,根本不会进用户态。写的时候注意三件事:能用在端口和地址层面的条件就别放到用户态判;注意方向,src port 443和port 443收的量差一半;表达式写错了不报错,只是什么都抓不到。
# 只抓内网网段到外网的 TCP 流量,排除掉本地回环和 SSH 'net 10.0.0.0/8 and tcp and not port 22' # 只统计 DNS,注意 UDP 和 TCP 都要,大响应会走 TCP 'port 53 and (udp or tcp)'第一条写法能砍掉绝大部分无关流量,尤其是备份、监控、内部 RPC 那种大流量场景,收窄之后用户态 CPU 直接降一半。第二条是个常见坑:只写udp port 53会漏掉所有走 TCP 的 DNS 响应,统计出来的域名解析量永远偏低。过滤器不是越复杂越好,复杂表达式在内核里逐包匹配也有成本,几十条规则叠起来反而不如放宽过滤在用户态筛。验证过滤器是否生效,最笨也最可靠的办法是看收包速率:改之前和改之后各跑十分钟,速率没有明显变化就是表达式写错了。
5. 排查与避坑:五条现场记录
流量统计这类程序出问题,表现往往很隐蔽——不崩、不报错,就是数字不对。下面五条是实际部署里踩过的,每条都按现象、原因、解决记,照着排查能省不少时间。
5.1 统计带宽只有实际的一半
现象:跟交换机端口计数对比,程序报的字节数稳定在真实值的一半左右,偶尔 48% 到 52% 之间浮动。
原因:只统计了一个方向。很多源码在收包路径上按源地址判断「是不是本机发出的」,只把入方向计入总量,出方向当成回包丢弃。虚拟网卡和镜像口上尤其明显,因为两个方向都能抓到,但只算了一个。
解决:在聚合层按归一后的会话分别累计bytes_fwd和bytes_rev,总量取两者之和。验证方法是打一条已知大小的流,用dd的字节数对账,误差要在一秒内数据的 5% 以内。对账时注意 TCP 重传会多算,用 UDP 打流对账最干净。
5.2 会话数一直涨,内存几小时后被吃光
现象:活跃会话数单调上升,从不下降,跑几小时内存占用翻十倍,最后被 OOM 杀掉。
原因:哈希表只增不删。老化逻辑依赖「收到 FIN 就删」,但实际网络里大量会话是没有正常关闭的——扫描、超时、RST 中断,这些都不会触发删除。UDP 更是完全没有关闭语义。
解决:加时间轮或者惰性老化,每条会话记录最后活跃时间,快照时顺带扫一遍,超过 120 秒没更新的直接淘汰。别在收包路径上做全表扫描,放到每秒的快照周期里做,代价可以接受。TCP 收到 FIN 或 RST 时把超时缩短到 10 秒,能显著降低表内残留。
5.3 程序报的包数和网卡计数差得离谱
现象:程序每秒统计 3 万包,网卡计数器显示 30 万包,差一个数量级,但 CPU 占用并不高。
原因:过滤器写得太窄,或者混杂模式没开。前者是设计如此,后者是抓不到别人的流量。还有一种情况是网卡做了多队列,程序只绑了一个队列,其他队列的包根本没进程序。
解决:先确认过滤器表达式,把条件去掉跑一分钟看速率是否对齐;再确认混杂模式参数是不是 1;多队列场景要开PACKET_FANOUT或者用多线程各绑一个队列,单线程读多队列网卡只能拿到一部分。判断是不是多队列,看/proc/interrupts里网卡中断是不是分散在多个 CPU 上。
5.4 端口统计里全是 0 和奇怪的高位数字
现象:Top 端口列表里出现 0、65535、还有一堆不像服务端口的值。
原因:三个可能——分片包没跳过、字节序没转换、头部偏移算错。分片包的非首片没有传输层头,硬读就是随机数;字节序问题会让 80 端口读成 20480;偏移错位最常见的原因是 VLAN 标签没处理,14 字节的假设在带标签的帧上直接偏 4 字节。
解决:在解析函数里加失败原因计数,把返回负值的包按原因分类打点。分片包单独计数并跳过;端口读取统一走手工拼装而不是结构体强转;VLAN 判断放在类型字段解析之后。改完拿一个已知端口的流验证,比如打一条 9000 端口的流,看统计里是不是正好出现 9000。
5.5 用 pcap 文件回放,结果和实时抓包对不上
现象:同一段流量,实时抓包统计正常,回放到文件再分析,会话数明显偏少。
原因:回放速度不受控。文件回放时包是瞬间灌进来的,所有包的时间戳几乎相同,按「最后活跃时间」做老化的逻辑会把它们当成同一时刻的会话,本该分开的会话被合并了;反过来,如果按处理时刻而不是包头时间戳打时间窗口,一秒的统计窗口会覆盖整段回放。
解决:回放模式下一律用包头里的时间戳驱动窗口切换,不用系统时钟。源码里如果只有一套时钟源,就得在采集层做个抽象,实时模式用系统时钟,回放模式用包头时间戳。验证方法是同一份文件回放两遍,统计结果必须完全一致,不一致就说明还有地方在读系统时间。
6. 数字可信吗:接上时序链路并做交叉验证
统计程序最怕的不是跑不起来,是跑起来了但数字没人敢信。判断一份流量统计靠不靠谱,我的做法是接两条独立的对账链路,任何一条对不上就不往下游推。
第一条链路对字节数。程序每秒输出的bytes累加,跟网卡自己的计数对比:
# 取网卡累计收发字节,间隔 60 秒采两次做差 awk '/eth0:/ {print $2, $10}' /proc/net/dev两次采样的差值,跟程序 60 秒内的字节总和比,正常情况下误差在 1% 以内。差得多先看drops,丢包计数不为零的话,差额基本就是丢的那部分;drops为零还对不上,问题在统计口径,比如有没有把二层帧头算进去、有没有把 VLAN 标签的 4 字节算进去。这个口径必须写死在文档里,否则跟别人对数据的时候永远扯不清。
第二条链路对连接数。程序报的活跃 TCP 会话数,跟系统自己的连接状态表比:
ss -s | grep -i tcp # 各状态连接总数 ss -tan state established | wc -l程序统计的是全网会话还是仅本机会话,这决定了该跟哪个数字比。如果抓的是镜像口,程序看到的会话数应该远大于本机ss的数字,这时候对账的意义不是等值,而是看变化趋势是否同步——同一时刻两边同时涨、同时落,说明统计跟得上;一边涨一边不动,多半是老化时间设得太长,历史会话没被清掉,把内存和数字一起撑大了。
再往下就是接时序库。输出已经是 JSON 行,用采集器直接读标准输入追加写就行,不用改源码:
./build/traffic-analyzer -i eth0 -F 'tcp or udp' \ | tee -a /var/log/traffic.jsonl \ | your-collector --format jsonl --stdin-i指定网卡,-F传过滤器,输出走标准输出,接管道不影响主循环。注意tee会引入额外写盘,高流量场景下建议只接采集器,日志落盘放到采集器侧做采样。
最后说个验证小技巧:想看解析层有没有算错,别盯着聚合结果,直接开调试模式打印前 20 个包的解析结果,人工核对源目的地址和端口。这比事后对着统计数字猜原因快得多。我一般会在改完解析逻辑后,先跑一遍离线 pcap 文件回放,把前几十个包的解析结果打出来看一眼,确认偏移没错再上真实流量。
从那以后我每次部署这类流量程序,都强制先跑一遍命名空间打流对账,字节数对不上就绝不接下游看板——这个后悔药吃起来太贵,宁可前面多花二十分钟。希望帮到你。
本文还有配套的精品资源,点击获取