简介:一份以 Android 平台 so 文件解析为主题的示例工程,面向具备 Java 基础、希望进一步了解 JNI 与原生动态库的开发者,可用于梳理动态链接库的代码段、数据段、符号表和重定位信息等基本结构,并建立 Java 层与原生层衔接的整体认识。压缩包为 rar 格式,共 30 个文件、约 58KB,主要由 18 个 class、5 个 java 源文件、2 个 C/C++ 头文件和 2 个 so 动态库组成,另含 Eclipse 工程配置,便于对照查看 Java 调用入口、JNI 函数声明、原生实现及编译产物之间的对应关系,目录轻量而链路较完整。已有 3147 人学习,说明 so 解析、JNI 调试与 ABI 兼容性分析在 Android 开发和底层排错中具有持续需求。包内示例适合用来观察工程目录组织、符号与接口的映射方式,也可作为理解 so 加载过程、跨语言调用和不同架构库管理的参考素材,适合课程实践或自学时反复查阅。
1. 别急着开反汇编器:一个 .so 的骨架,三条命令就能看清
线上崩溃日志只有一行:libdemo.so + 0x2a1c4。第一反应是抓起反汇编器,把地址跳过去,然后在一个几千行的函数里绕了半小时,什么都没确认。后来老老实实退回起点,readelf -h看架构、readelf -S看节区、readelf -s看符号,二十分钟就把偏移落到一个具体函数上。
解析 so 文件这件事,顺序比工具重要:先确认“这是什么文件”,再确认“它由哪些块组成”,最后才看“块里写了什么”。这篇写给三种人:手上有个 .so 想知道它导出了什么、依赖了谁;崩溃堆栈只给了一个偏移地址;接手别人的二进制产物,源码和文档都没有。全程落在命令和判读标准上,从 ELF 文件头一路读到重定位表和构造函数入口,最后一章给一份我每次拿到新 .so 都会走的检查清单。
2. 从 ELF 头到段表:先把「这是什么文件」确认清楚
2.1 前 16 个字节:e_ident 里能读出的五件事
ELF 文件最前面 16 个字节是识别区,0x7f加上E、L、F三个字符,这四个字节认错,后面全白搭。接下来每个字节都有明确含义:第 5 个字节是位宽标识,1 表示 32 位、2 表示 64 位;第 6 个字节是字节序,1 是小端、2 是大端;第 7 个字节是 ELF 版本,正常必须是 1;第 8 个字节是 OS ABI 标识;第 9 个字节是 ABI 版本;剩下 7 个字节是填充位,规范要求补零。
这 16 个字节最容易被忽略的是字节序和 ABI 版本。移动端和嵌入式设备上小端占绝大多数,但你要是拿到一块大端芯片上跑出来的产物,直接按小端去读节区偏移,读出来的数字会大到离谱,节区名也会变成乱码。我以前在这种地方翻过车:readelf报出Error: Unable to read in 0x…,第一反应以为是文件被截断,其实是字节序判断错了。
# 读文件头,先确认位宽、字节序、目标类型和架构 readelf -h libdemo.so输出里Class: ELF64、Data: 2's complement, little endian、Type: DYN (Shared object file)、Machine: AArch64这四行就是你要的第一手结论。这四行定了,后面所有工具选型才有依据:选 32 位还是 64 位的反汇编器、按小端还是大端解析、把入口地址当虚拟地址还是当文件偏移。
2.2 e_type / e_machine / e_flags:决定你后面所有工具的选择
文件头里e_type的值最关键。ET_REL(1) 是可重定位目标文件,就是编译中间产物.o;ET_EXEC(2) 是可执行文件,加载地址写死;ET_DYN(3) 既可以是共享库,也可以是位置无关的可执行文件。这一点坑过不少人——拿到一个ET_DYN就断言它是 so,结果人家是开了 PIE 的主程序,导出符号表是空的,怎么找都找不到。
区分办法很简单:看有没有PT_INTERP段、有没有DT_SONAME条目。有解释器段、没有 SONAME,基本就是 PIE 可执行文件;有 SONAME,才是正经共享库。
e_machine决定你用什么指令集的反汇编器。常见值列出来对照着看:
| e_machine 值 | 常量名 | 典型场景 |
|---|---|---|
| 3 | EM_386 | 32 位 x86 |
| 8 | EM_MIPS | 网络设备、嵌入式 |
| 40 | EM_ARM | 32 位 ARM,移动端老产物 |
| 62 | EM_X86_64 | 64 位 x86 |
| 183 | EM_AARCH64 | 64 位 ARM,现在的主流 |
e_flags在 AArch64 上一般是 0,在 32 位 ARM 上就有内容了:硬浮点 ABI、软浮点 ABI、EABI 版本这些标志都在里面。32 位 ARM 的 .so 如果浮点 ABI 跟调用方对不上,运行期不会报“ABI 不匹配”这么友好的错误,而是直接给出莫名其妙的数值错误。所以拿到 32 位 ARM 产物,e_flags那行不能跳。
提示:
readelf -h里Number of section headers和Number of program headers两个字段也要看。节区数为 0 通常意味着文件被工具处理过,或者文件头被改坏了。
2.3 节区表与段表:两个视角,别混用
同一个 .so,readelf -S和readelf -l给出的两张表,很多人混着用,然后地址就算错了。这是两个维度的东西:
段表(program headers)是给加载器看的,描述“运行时要把文件的哪一段映射到内存的哪个虚拟地址、给什么权限”。节区表(section headers)是给链接器和分析工具看的,描述“代码在哪个块、符号表在哪个块、字符串表在哪个块”。
| 视角 | 命令 | 关键字段 | 典型用途 |
|---|---|---|---|
| 段表 | readelf -l | 类型、偏移、虚拟地址、文件大小、内存大小、权限 | 偏移换算、加固特征识别 |
| 节区表 | readelf -S | 名称、类型、地址、文件偏移、大小 | 定位代码段、符号表、初始化数组 |
段表里最常打交道的是LOAD段和GNU_RELRO段。LOAD段告诉你文件偏移和虚拟地址的映射关系,公式很朴素:文件偏移 = 虚拟地址 - p_vaddr + p_offset。崩溃日志里的偏移是相对加载基址的虚拟地址,你要拿它去反汇编器里定位,或者喂给符号还原工具,就得用这个公式换算到文件偏移,反之亦然。LOAD段的权限位也要看:可读可执行段里是代码,可读可写段里是数据,两者的解析思路完全不同。
节区表里我固定会看的几个:.text是代码主体,.rodata是只读常量(字符串大多在这),.data是已初始化的可写数据,.bss是未初始化数据(文件里不占空间,只有大小),.init_array和.fini_array是构造析构函数指针数组,.dynamic是动态链接信息,.dynsym和.dynstr是动态符号表和字符串表。
readelf -S输出的Flg列也值得一看:带A的是需要分配内存的,带X的是可执行的,带W的是可写的。一个.text如果同时带W,那就很反常了,说明这个产物被改造过,自修改代码或者加固壳的痕迹。
2.4 动手:三分钟把骨架过一遍
真正上手的时候,我不会一上来就反汇编,而是先按固定顺序过一遍骨架。这三分钟花得值,后面能省掉大量试错。
# 第一步:文件头,确认架构、位宽、类型 readelf -h libdemo.so # 第二步:段表,找 LOAD 段的偏移与虚拟地址映射 readelf -lW libdemo.so | sed -n '/Program Headers/,/Section to Segment/p' # 第三步:节区表,列出所有节区的名字、大小和偏移 readelf -SW libdemo.so三个命令连着跑,输出正好构成一个从粗到细的漏斗。第一层告诉你“这是 ARM64 的共享库”,第二层告诉你“代码映射在虚拟地址 0x0 起、文件偏移 0x0 起、大小多少”,第三层告诉你“代码段多大、符号表在不在、初始化数组有几项”。
-W参数要加上。不加宽格式,长节区名和长符号名会被截断,.gnu.version_r这种名字显示成.gnu.vers...,你按名字 grep 就永远 grep 不到。这个参数我踩过不止一次坑,现在固定写成readelf -SW,省得回头怀疑人生。
走完这三步,你手上应该有五条确定信息:位宽、字节序、架构、文件类型、代码段范围。这五条信息决定了后面所有工具的参数,一条都不能猜。
3. 符号表与导出清单:谁提供了什么、谁在依赖谁
3.1 .dynsym 和 .symtab:strip 之后还剩下什么
新手最常见的误会是:文件被 strip 过,符号就全没了,只能硬啃汇编。这个判断只对了一半。
ELF 里有两张符号表。.symtab是链接期的完整符号表,包含所有函数、全局变量、静态符号、文件符号,是给链接器和调试器用的。.dynsym是动态符号表,只包含动态链接需要的符号——导出的(给外面用)和导入的(要用外面的)。动态链接器在运行期只认.dynsym,所以它必须留在文件里。
strip 操作删的是.symtab,.dynsym通常保留。这意味着即使一个 .so 被完整 strip 过,你依然能拿到它导出了哪些函数、依赖了哪些外部符号,只是拿不到内部静态函数的名字。
# 看节区表里 .symtab 还在不在,独有一行就是还在 readelf -SW libdemo.so | grep -E "\.symtab|\.dynsym"输出里只有.dynsym没有.symtab,说明被 strip 过,但仍可以用readelf --dyn-syms导出动态符号。两个都在,恭喜,你拿到了未 strip 的产物,readelf -s能看到全部符号,包括内部函数名。
注意:
.dynsym里的符号有三种状态,Ndx列是UND表示本库要用但没实现(导入),是具体节区编号表示本库实现并导出,是ABS表示绝对符号。只看名字不看UND会把导入当导出,责任范围就搞错了。
3.2 绑定、类型、可见性:读符号的三个维度
readelf -s的输出一行七列:编号、值、大小、类型、绑定、可见性、所属节区、名字。真正有判读价值的是中间三列。
类型(Type)里最常见的是FUNC(函数)、OBJECT(数据对象)、NOTYPE(未指定,汇编产物常见)、IFUNC(间接函数,运行期由解析器选实现,常见于性能敏感的字符串和内存操作)、TLS(线程局部存储)。看到IFUNC不要以为是坏符号,它是正规特性。
绑定(Bind)分LOCAL、GLOBAL、WEAK。LOCAL只在文件内可见,通常在.symtab里。WEAK是弱符号,可以被强符号覆盖,__cxa_finalize、__gmon_start__这类导入就是典型的弱符号——它们存在与否不影响加载,只在特定场景生效。
可见性(Vis)分DEFAULT、PROTECTED、HIDDEN、INTERNAL。这一列特别有用:编译时加了隐藏可见性选项的库,导出符号会显著变少,只剩显式标记为公开的接口。拿到一个导出符号只有个位数的 .so,不要急着说“这库没内容”,很可能人家是刻意收紧了导出面。
# 只列已实现的函数符号,排除导入项,按名字排序 readelf --dyn-syms -W libdemo.so | awk '$4=="FUNC" && $7!="UND" {print $8}' | sort这段管道的逻辑是:--dyn-syms只读动态符号表;-W保证列不被截断;awk里第 4 列是类型、第 7 列是所属节区,两个条件同时满足才是“本库实现并导出的函数”;最后排序去重视觉上更快。参数要按你手上的readelf版本核对列号,不同发行版的列顺序可能有细微差别,先不加awk跑一遍看表头再写过滤条件,比凭记忆写靠谱。
3.3 版本符号与 C++ 名字还原
大型项目里同一套接口会出多个版本,符号名后面会带版本后缀:foo@VERS_1.2表示这个符号绑定到 1.2 版,foo@@VERS_1.2里的双 @ 表示这是默认版本。链接时如果版本对不上,报错信息通常是version 'VERS_1.3' not found,看到这种错误,去readelf -V里查版本定义节和版本需求节,就能定位到底缺的是哪个版本。
# 列出符号版本定义与需求 readelf -V libdemo.so输出版本需求节里每一条都对应一个外部依赖库提供的版本集合,版本定义节里每一条是本库提供的版本。前者缺了会在加载期报错,后者多了会影响兼容性判断。
C++ 编译出来的符号是经过名字修饰的,_ZN6Widget6renderEv这种,直接看极难阅读。还原办法有两个:一是nm -D -C,-C参数做解码;二是把符号名喂给c++filt。解码之后能看到命名空间、类名、参数类型,甚至能看出模板实例化。这一步对判断“这个 .so 是不是用了某个第三方库”帮助很大,因为命名空间前缀往往暴露了来源。
3.4 从导出清单反推模块职责
导出清单是 .so 的“自我介绍”,认真读能反推出很多东西。我一般会分四类看:
第一类,Java_或JNI_OnLoad开头的符号,说明这是给上层语言做桥接的原生库,JNI_OnLoad是加载入口,一般在这里做注册。第二类,dlopen、dlsym、dlclose的导入,说明这个库自己会加载别的库,是插件宿主或者模块管理器。第三类,大量pthread_、sem_、mutex相关导入,说明内部有并发逻辑,崩溃分析时要把线程状态一起看。第四类,open、read、ioctl、mmap的导入,加上一堆设备路径字符串,基本能确定是驱动交互层。
这套推断不精确,但足够决定下一步往哪走:有桥接入口就去看JNI_OnLoad的注册表,有插件加载就去看字符串里的库名,有并发就去看线程创建点。比起漫无目的地翻反汇编,这条路快得多。
4. 动态段与重定位:运行期到底发生了什么
4.1 DT_NEEDED 与 SONAME:依赖链的第一手证据
.dynamic节区是一组键值对,记录了动态链接器运行期需要的全部信息。readelf -d直接把它翻译成人能读的形式。
# 打印动态段,重点看 NEEDED、SONAME、RUNPATH、FLAGS readelf -dW libdemo.so输出里NEEDED条目有多少条,这个库就依赖多少个直接库,顺序有意义——动态链接器按顺序解析,前一个库提供的符号会遮蔽后一个。SONAME是它自己被依赖时使用的名字,通常是带主版本号的短名。RUNPATH是运行期搜索路径,这个字段在排查“为什么加载到旧版本”时是唯一线索。
我遇到过的真实场景是:某项目自有的两个产物依赖同一个基础库的不同主版本,NEEDED里一个要 1 系列、一个要 2 系列,运行期只能加载到一个,另一个的符号解析直接失败。日志里只会写“符号未定义”,但根因在NEEDED这一层。所以拿到依赖有问题的产物,第一步永远是readelf -d把NEEDED列全,再逐个核对这些库的实际提供方。
FLAGS和FLAGS_1两个条目容易被略过,里面藏着重要信息:有没有立即绑定、是不是位置无关、有没有启用符号保护。这些标志影响加载行为和内存布局。
4.2 .rela.dyn / .rela.plt:重定位条目怎么读
位置无关的代码在编译时不知道自己的绝对地址,所有需要绝对地址的地方都留了“占位”,由动态链接器在加载时填进去。这些占位点就是重定位条目,存在.rela.dyn和.rela.plt两个节区里。
每条重定位记录三个核心字段:偏移(要填的位置)、信息(类型 + 符号索引)、加数(填充依据)。类型字段是判读的关键:
| 重定位类型(以 64 位 ARM 为例) | 含义 | 解析价值 |
|---|---|---|
R_AARCH64_RELATIVE | 相对基址重定位 | 指向本库内部地址,加数就是目标虚拟地址 |
R_AARCH64_GLOB_DAT | 全局数据符号 | 指向外部变量,符号名能看出依赖了谁 |
R_AARCH64_JUMP_SLOT | 函数跳转槽 | 对应一次外部函数调用,能列出调用面 |
x86-64 上对应的是R_X86_64_RELATIVE、R_X86_64_GLOB_DAT、R_X86_64_JUMP_SLOT,数值编号不同但语义一致。
# 只留跳转槽和全局数据两类,快速摸清外部依赖面 readelf -rW libdemo.so | grep -E "JUMP_SLOT|GLOB_DAT" | head -30这段命令的价值在于:JUMP_SLOT条目一一对应外部函数调用点,条目数量和符号分布能直接告诉你这个库对外部的依赖有多重。如果JUMP_SLOT里出现了大量文件操作和进程操作,那它的职责显然偏底层;如果全是内存和字符串操作,更像一个算法模块。
参数说明:-r打印重定位节,-W防截断,head只是控制输出量,实际分析时不要截断,全量导出到文件里再统计更准。
RELATIVE类型在分析里另有大用。它指向本库内部的绝对地址,加数字段就是目标的虚拟地址。如果你在某个数据区看到一串RELATIVE重定位,那这个区很可能是一张函数指针表——初始化数组、回调注册表、虚函数表都是这个长相。
4.3 init_array 与 fini_array:谁先跑
程序还没进主流程就崩了,查哪儿?答案通常是构造函数。
ELF 提供了两套机制:老的DT_INIT/DT_FINI指向单个函数,新的DT_INIT_ARRAY/DT_FINI_ARRAY指向函数指针数组。现代编译器默认用后者。.init_array节区就是那个数组,按索引顺序依次执行,先于主流程。
# 看初始化数组的大小,推算有多少个构造函数 readelf -dW libdemo.so | grep -E "INIT_ARRAY|FINI_ARRAY" readelf -SW libdemo.so | grep -E "\.init_array|\.fini_array"动态段里INIT_ARRAY给出虚拟地址、INIT_ARRAYSZ给出字节数,两者一除就是条目数(64 位下每项 8 字节)。节区表里给出的是同一块内容,对照着看能确认地址换算没错。
知道条目数之后,把每个条目指向的地址反汇编出来,就是全部的构造逻辑。这部分代码常常做这几件事:注册回调、初始化全局单例、检查运行环境、加载资源。崩溃发生在库加载阶段,第一现场基本都在这里。
提示:构造函数执行顺序在多个库之间有依赖关系时无法保证,跨库依赖初始化是典型的隐患来源。分析多库产物时,把每个库的
.init_array内容都过一遍,比事后猜顺序有用。
4.4 一个常被忽略的开关:RELRO 与立即绑定
段表里的GNU_RELRO段和动态段里的绑定标志是一对。开启部分 RELRO 时,加载器在重定位完成后把.got的一部分设为只读;开启完全 RELRO(配合立即绑定)时,整个.got都被保护,运行期无法修改。
这个信息对分析的用处是:如果产物开启了完全 RELRO,那所有函数跳转槽在加载时就已经填好,你没法通过观察运行期.got变化来判断哪些函数被真正调用过;反之,如果只开了部分 RELRO、采用延迟绑定,那.got里的槽在首次调用前指向解析桩,调用后才变成真实地址。
# 检查是否开启立即绑定和完全 RELRO readelf -dW libdemo.so | grep -E "BIND_NOW|FLAGS" readelf -lW libdemo.so | grep -A1 GNU_RELRO两条命令结合看:动态段出现BIND_NOW标志、段表有GNU_RELRO段,通常就是完全 RELRO。这个结论会影响你选动态分析还是静态分析——完全 RELRO 的产物,跳转槽在静态文件里就是最终形态,静态读.rela.plt就够,不必费劲做运行期观测。
5. 避坑与排查:解析 .so 时最常翻车的五个点
5.1 偏移算错:三个地址概念混为一谈
现象:崩溃日志给了偏移0x2a1c4,喂给符号还原工具返回??,或者反汇编器跳过去是一堆无意义指令。
原因:ELF 里同时存在文件偏移、虚拟地址、运行期加载地址三个概念。崩溃日志里的偏移一般是“相对库加载基址的虚拟地址”,而反汇编器有的按文件偏移定位、有的按虚拟地址定位。中间还隔着一层LOAD段的映射关系。
解决:固定用readelf -lW把每个LOAD段的Offset、VirtAddr、FileSiz抄下来,按文件偏移 = 虚拟地址 - VirtAddr + Offset换算。注意一个 .so 可能有多个LOAD段,要先判断目标地址落在哪一段里,再套对应段的参数。这个换算我做成了一张小抄贴在显示器边上,用一次省十分钟。
5.2 看到 strip 就以为没救了
现象:readelf -s输出为空或者符号极少,判断“符号被删干净了,只能硬看汇编”。
原因:只查了.symtab,没查.dynsym。strip 删的是前者,后者在动态库上必须保留。
解决:先readelf -SW确认两张表在不在,再用readelf --dyn-syms单独读动态符号表。即使全部符号都被剥离,导入符号名和字符串常量还在,靠它们仍能还原出大部分模块职责。真正彻底无符号的产物极少,多数情况下是查错了表。
5.3 工具链位数和架构错配
现象:反汇编器打开文件后报格式错误,或者反汇编出来的指令完全不通顺,寄存器名对不上。
原因:用 32 位工具开 64 位产物,或者用 x86 的指令集去反汇编 ARM 的产物。有些工具会自动识别,有些默认按上次用的架构,不报错但输出全是垃圾。
解决:每次开新文件前先跑readelf -h,把Class和Machine两行记下来,手动指定工具架构。ARM 32 位还有个额外陷阱:指令集有 ARM 和 Thumb 两种编码,混在一起时反汇编器可能整段按错模式解。看函数地址的最低位,奇数表示 Thumb,这个判断要带上。
5.4 把符号表地址当运行期地址用
现象:拿readelf -s里某个函数的Value直接去内存里找,或者直接当崩溃偏移用,结果对不上。
原因:符号表里的值是虚拟地址(未加载时的相对值),不是运行期的绝对地址。运行期实际地址等于库加载基址加上这个值,而加载基址每次运行都可能不同(位置无关的情况下)。
解决:所有符号值都当“相对值”处理,跟崩溃偏移做减法得到段内偏移,再换算文件偏移。只有在明确了加载基址的情况下,才能算绝对地址。这一点在分析位置无关产物时尤其要注意,基址是变的,写死一个数值必然翻车。
5.5 遇到改造过的样本还按常规套路走
现象:节区名乱码或者缺失、.text带写权限、导入符号寥寥无几但文件很大、入口点不在常规位置。
原因:产物被加壳、混淆或者做过自定义处理,标准 ELF 结构被改造过。按常规顺序读节区表,读到的全是无效信息。
解决:改从段表入手。段表是加载器必须认的,改造难度大得多,所以readelf -lW的信息通常更可信。重点看LOAD段的权限组合和大小分布,判断真实代码在哪个段。再看动态段里NEEDED有几个,依赖极少但体积很大,本身就是危险信号。这类样本的分析不在常规流程里,别硬套模板,先把“哪些结构还完好”列清楚,再决定从哪儿切。
6. 从静态到崩溃定位:把解析结果落成一份可复用的检查清单
说一个具体技巧:崩溃偏移落到函数之后,很多人就停下了,其实还差最后一步——确认这个偏移到底在函数的什么位置。addr2line能给出文件和行号,但它依赖调试信息,strip 过的库上必然返回??。所以我一般先用符号表定位到函数区间,再用objdump -d把那个函数单独反汇编出来,看偏移落在哪条指令上。
# 用符号表定位函数区间,确认目标偏移落在哪个函数内 readelf --dyn-syms -W libdemo.so | awk '$4=="FUNC" && $7!="UND" {print $2, $3, $8}' | sort输出是“值 大小 名字”三列,按值排序后就是一张函数地址区间表。目标偏移落在哪个区间,函数就确定了。大小字段是关键,很多时候两个符号值挨得近,靠大小才能分清边界。
# 把目标函数单独反汇编出来,看偏移落在哪条指令 objdump -d --start-address=0x2a000 --stop-address=0x2b000 libdemo.so--start-address和--stop-address接受虚拟地址,范围按上一步的区间给。反汇编输出里每条指令左边是地址,把崩溃偏移对上去,前后几条指令一读,是访存越界、空指针解引用还是栈溢出,基本能看出方向。这一步比看整份反汇编高效得多,也不会被无关代码干扰。
整个流程走下来,我固定检查这几项:
| 检查项 | 命令 | 判读标准 |
|---|---|---|
| 架构与位宽 | readelf -h | Class 与 Machine 决定工具参数 |
| 文件类型 | readelf -h | ET_DYN 加 SONAME 才是共享库 |
| 节区完整性 | readelf -SW | 关键节区缺失说明被处理过 |
| 导出与导入 | readelf --dyn-syms -W | Ndx 为 UND 是导入,其余是导出 |
| 依赖链 | readelf -dW | NEEDED 顺序与条目数 |
| 重定位面 | readelf -rW | JUMP_SLOT 数量反映外部调用规模 |
| 构造入口 | readelf -dW查 INIT_ARRAY | 加载期崩溃的第一现场 |
| 内存保护 | readelf -lW查 GNU_RELRO | 决定能否靠动态观测跳转槽 |
血泪经验是:这八项里任何一项跳过,后面都可能要回头补。我现在的习惯是,拿到任何一个新的 .so,无论多急,都强制走一遍这张表,哪怕只花五分钟。跳过某一步省下的时间,通常会在后面以十倍还回来。比如有一次赶时间没看e_flags,32 位 ARM 的浮点 ABI 对不上,排查了两个小时才发现问题不在代码逻辑上。
希望这套顺序和这张表,能帮你少绕几个弯。
本文还有配套的精品资源,点击获取