【Linux 学习记之二】C++ 构建体系与底层链接机制的深度透视(2)

0 阅读22分钟

项目配套完整开源仓库: github.com/xliulc-lang…

本系列文章记录阶段二「C++ 构建体系与链接原理」的两大核心实战:

  • 【实战项目一】脱离 IDE 黑盒:GCC 编译四大阶段、汇编级优化对抗与工业级编译门禁
  • 【实战项目二】透视二进制黑盒:ELF 核心段剖析、虚函数逆向与 Binutils 生产发布规范(👈 当前正在阅读)

本篇目录


二、实战项目二:透视二进制黑盒——ELF 核心段剖析、虚函数逆向与 Binutils 生产发布规范

在上一篇文章中,我们拆解了 GCC 从源码到可执行文件的预处理、编译、汇编和链接四大阶段。但当一个二进制文件最终生成后,很多工程师便将其视作无法窥探的“黑盒”。

在 Linux 操作系统中,无论是中间目标文件(.o)、共享动态库(.so)、静态库归档(.a)、最终可执行程序,还是程序异常崩溃时生成的核心转储(Core Dump),本质上都遵循统一的二进制规范——ELF(Executable and Linkable Format,可执行与可链接格式)。

一旦进入中高级 C++ 研发或线上系统排障场景,各种底层疑问便会接踵而至:

  • 为什么在全局作用域定义一个 10MB 的未初始化大数组,编译生成的文件体积却“几乎没有增长”?
  • C++ 的动态多态与虚函数表(vtable/vptr),在 CPU 机器指令层面到底是如何完成间接寻址的?
  • 为什么纯 C 编写的代码去调用 C++ 编写的底层库时,明明函数名一模一样,链接器却死活找不到符号?
  • 生产环境发布如果带调试符号会导致体积膨胀数倍甚至十倍,但如果直接把符号全抹掉,线上 Crash 时 GDB 只能抓瞎看到一堆十六进制内存地址,大厂是如何做到“既极致瘦身又无损排障”的?

要解答这些问题,必须借助 Linux 原生强大的底层透视工具链——GNU Binutils(readelf、objdump、nm、strip、objcopy)。本篇将依托配套开源实验项目 projects/stage_2/elf_binutils_lab,带你逐层解密二进制内部世界。


2.1 认知升级:为什么现代 C++ 工程师必须透视 ELF 二进制?

很多开发者习惯于只在源码(Text)和语法层面思考问题,然而操作系统内核与 CPU 并不能直接理解 C++ 的类、继承、模板或重载。高级语言中的抽象实体,最终都会被降维折叠进 ELF 的各个数据段中:

C++ 高级语言世界 (抽象)                Linux ELF 二进制世界 (物理落地)
───────────────────────────            ───────────────────────────────────────
指令语句 (for / if / 调用)   ======>    .text (代码段: 存放机器可执行指令, AX)
只读常量 (const / 字符串字面量) ======>    .rodata (只读数据段: 常量只读, A)
全局 / 静态非零变量          ======>    .data (数据段: 占用磁盘与内存, WA)
全局 / 静态未初始化变量       ======>    .bss (BSS 段: 不占磁盘物理空间, NOBITS, WA)
多态与虚函数表 (vtable)      ======>    .rodata / .data 中的函数指针数组
函数名 / 重载签名 / 变量名    ======>    .symtab / .dynsym (符号表与修饰字符串)
源码行号与变量元信息 (-g)    ======>    .debug_* (DWARF 调试段: 仅辅助排障, 非 Alloc)

掌握了 Binutils 工具链,就相当于拥有了针对二进制程序的“CT 扫描仪”。无论是反汇编逆向、符号依赖审计,还是发布包优化,都能从底层机理上一击击中要害。


2.2 实验拆解一:ELF 文件头与段结构探查(揭秘 .bss 零磁盘占用真相)

实验背景与测试集说明

  • 实验目录:projects/stage_2/elf_binutils_lab/part1_elf_sections/
  • 涉及核心文件:
    • bss_vs_data_demo.cpp:设计了包含 10MB 巨型全局数组的基准代码,通过预编译宏控制该数组是“未赋初值”还是“显式赋非零初值”;
    • run_elf_section_lab.sh:自动化执行编译、段头解析及磁盘空间核验的实操脚本。
  • 核心验证目标:通过显式提取 ELF Header 与 Section Headers,探查可执行文件元数据,并用真实物理尺寸揭秘 .bss 段“有内存大小、无磁盘开销”的底层机理。

2.2.1 ELF 文件头剖析 (readelf -h)

使用 readelf -h 可以直接读取目标文件的第一组元数据——ELF Header:

g++ -std=c++17 bss_vs_data_demo.cpp -o build_elf/app_bss
readelf -h build_elf/app_bss | head -n 18

终端输出的头部关键信息如下:

image.png

核心字段深度解析:

  1. Magic(魔数):7f 45 4c 46。第 1 个字节是十六进制 0x7F(DEL 字符),后面三个字节对应 ASCII 码的 'E', 'L', 'F'。Linux 内核在调用 execve 装载可执行程序时,首先核验这 4 个字节,如果魔数不匹配,内核直接拒绝执行并返回 Exec format error;
  2. Class & Data:声明该文件为 64 位体系结构(ELF64),数据按照小端序(little endian)组织;
  3. Entry point address(入口地址):这里的 0x1060 是程序加载后执行的第一条机器指令地址。注意:这绝不是程序员写的 main 函数,而是 glibc C 运行时库的 _start 函数! 它负责准备环境变量、参数栈、初始化全局对象后,才跳转到 main。

2.2.2 节头表探查 (readelf -S) 与四大核心段

一个 ELF 文件被划分为若干个用途各异的「节(Section)」。使用 readelf -S 可以列出节头表(Section Headers)。我们重点观察四大核心段:

readelf -S build_elf/app_bss | grep -E "Name|\.text|\.rodata|\.data|\.bss"
  [Nr] Name              Type             Address           Off    Size   ES Flg Lk Inf Al
  [14] .text             PROGBITS         0000000000001060  001060 0001b2 00  AX  0   0 16
  [16] .rodata           PROGBITS         0000000000002000  002000 000028 00   A  0   0  4
  [24] .data             PROGBITS         0000000000004000  003000 000010 00  WA  0   0  8
  [25] .bss              NOBITS           0000000000004020  003010 a00020 00  WA  0   0 32

段属性标志(Flags)定义了内核装载时的权限控制:

  • A(Alloc):表示该段在程序运行时必须分配真实的物理/虚拟内存;
  • W(Write):该内存区域可写(如变量);
  • X(Execute):该内存区域包含可执行指令(代码段)。

2.2.3 深度对比:.bss 段的“零磁盘占用”真相

在 bss_vs_data_demo.cpp 中,我们设置了一个对比实验:

  • 版本 A(app_bss):定义一个 10MB 的未初始化大数组 char huge_buffer[10 * 1024 * 1024];;
  • 版本 B(app_data):通过宏定义将该 10MB 大数组赋予初值(每个字节初始化为 0x5A)。

我们编译这两个版本,并使用 ls -lh 直观对比它们在磁盘上的物理大小:

ls -lh build_elf/app_bss build_elf/app_data
-rwxr-xr-x 1 user user  16K  build_elf/app_bss
-rwxr-xr-x 1 user user 9.6M  build_elf/app_data

高频面试考点:同样是 10MB 的数组,为什么 app_bss 只有 16KB,而 app_data 暴增到了近 10MB?

观察上述 readelf -S 输出中 .bss 段的类型(Type)列:

  • .data 段的类型是 PROGBITS(Program Bits),表示该段包含有效程序内容,每一个非零初始值字节都必须真实地写入磁盘文件保存;
  • .bss 段的类型则是特殊的 NOBITS(No Bits)!

底层机理:.bss 段在磁盘的 ELF 二进制文件中,仅仅在节头表中记录了一个长度数值(Size: 10MB),文件体内部完全不分配任何物理扇区! 只有当程序被内核通过 execve 装载时,内核通过映射匿名虚拟内存页(Anonymous Pages)并将其按需清零。因此,无论未初始化的全局变量定义得有多大,都不会导致二进制分发包体积膨胀。


2.3 实验拆解二:objdump 反汇编与 C++ 虚函数表底层间接寻址

实验背景与测试集说明

  • 实验目录:projects/stage_2/elf_binutils_lab/part2_objdump_disasm/
  • 涉及核心文件:
    • disasm_target.cpp:包含两套测试场景——一段基础位运算逻辑(用于对比反汇编语法),以及一套包含 Shape 纯虚基类与 Circle 派生类的面向对象多态体系;
    • run_disasm_lab.sh:自动化编译并执行反汇编提取与源码指令混显的脚本。
  • 核心验证目标:直观感受 AT&T 与 Intel 汇编语法的可读性差距,并利用反汇编还原 C++ 虚函数在机器指令级的真实寻址全貌。

2.3.1 AT&T 语法 vs 现代工程首选 Intel 语法

GCC 默认输出的反汇编采用 AT&T 语法,但因其操作数“源在左、目的在右”且充斥着 % 和 $ 符号,与主流认知习惯相悖。工业界逆向与日常开发排障通常首选 Intel 格式(-M intel):

# AT&T 格式 (默认)
objdump -d -C build_disasm/target_app | grep -A 5 "<bitwise_cipher"

# Intel 格式 (-M intel,推荐)
objdump -d -M intel -C build_disasm/target_app | grep -A 5 "<bitwise_cipher"
# AT&T 语法:
movl    $42, %eax        ; 将立即数 42 传送至寄存器 eax (源在左, 目的在右)

# Intel 语法 (-M intel):
mov     eax, 0x2a        ; 贴近高级语言赋值: eax = 42 (目的在左, 源在右)

2.3.2 源码混显(objdump -S -C -M intel)

当可执行程序在编译时带上了调试符号(-g),objdump 的 -S 参数可以做到将 C++ 源码与汇编指令交织混显,直接看清每一行代码对应的机器指令实现:

objdump -S -C -M intel build_disasm/target_app | sed -n '/<Circle::get_area() const>:/,/^$/p'
0000000000001220 <Circle::get_area() const>:
    // double get_area() const override { return 3.14159 * radius_ * radius_; }
    1220:   f3 0f 10 47 08          movss  xmm0,DWORD PTR [rdi+0x8]
    1225:   f3 0f 5a c0             cvtss2sd xmm0,xmm0
    1229:   f2 0f 59 c0             mulsd  xmm0,xmm0
    122d:   f2 0f 59 05 8b 0d 00 00 mulsd  xmm0,QWORD PTR [rip+0xd8b]
    1235:   c3                      ret

2.3.3 直击底层:C++ 虚函数调用的汇编级寻址

在 disasm_target.cpp 中,我们通过基类指针调用虚函数:shape->get_area()。

我们反汇编 main 函数,观察这句调用对应的底层汇编指令序列:

objdump -d -C -M intel build_disasm/target_app | grep -B 2 -A 5 "call.*\[rax"

终端捕获到了决定性的两行关键机器指令:

mov     rax, QWORD PTR [rdi]        ; 1. 从对象首地址取出虚表指针 (vptr)
call    QWORD PTR [rax+16]           ; 2. 在虚函数表 (vtable) 偏移 16 字节处取出目标函数地址,发起间接调用!

深度原理解析:

  1. rdi 寄存器存放着当前对象的 this 指针;
  2. mov rax, QWORD PTR [rdi]:在 64 位 Linux 下,编译器在拥有虚函数的对象头部自动插入了一个 8 字节的指针,指向属于该类的虚函数表(vtable)。这一步指令完成了 从对象实例获取虚函数表指针(vptr);
  3. call QWORD PTR [rax+16]:虚函数在虚表中的偏移槽位在编译期已固化(例如第 3 个虚函数偏移为 2 * 8 = 16 字节)。CPU 寻址到虚表槽位,取出 Circle::get_area() 的实际内存入口地址并跳转执行。

技术本质:所谓“动态多态的性能开销”,在底层并非什么深奥机理,本质就是多了一次内存间接指针解引用(Dereference),导致 CPU 难以提前进行静态分支预测,从而产生轻微的分支预测惩罚。


2.4 实验拆解三:nm 符号表体系、Name Mangling 与 extern "C" 混编实战

实验背景与测试集说明

  • 实验目录:projects/stage_2/elf_binutils_lab/part3_nm_and_mangling/
  • 涉及核心文件:
    • symbol_target.cpp:覆盖了全局函数、静态内部函数、只读全局常量、初始化与未初始化变量,用于系统梳理 nm 符号代码体系;
    • c_caller.c:纯 C 语言编写的主业务调用端;
    • cpp_callee.cpp:C++ 编写的底层折扣计算逻辑;
    • c_interface.h:用于提供跨语言交互的原型接口头文件;
    • run_symbol_mangling_lab.sh:自动化复现混编链接中断与 extern "C" 修复的验证脚本。
  • 核心验证目标:全面掌握 nm 符号导出状态,并从底层排查由于 C/C++ 编译器修饰规则不一致而引发的经典符号匹配失败故障。

2.4.1 nm 核心符号类型代码全景

使用 nm 可以导出目标文件中的所有符号及其所属段属性:

g++ -c -std=c++17 symbol_target.cpp -o build_symbols/symbol_target.o
nm build_symbols/symbol_target.o
符号代码对应段与属性物理含义与生产场景
T.text (全局可见)导出的全局函数(大写代表对外部目标文件可见,可被链接引用)
t.text (局部可见)static 修饰的内部函数,作用域受限于本文件,外部不可见
UUndefined未决符号:本文件声明并调用、但未提供实现的函数/变量,等待链接期填坑
D.data (全局可见)具有全局可见性的已初始化非零变量
d.data (局部可见)static 修饰的文件级/函数级静态已初始化变量
B / b.bss (全局 / 局部)全局 / 局部未初始化的变量
R / r.rodata (只读段)const 全局常量、字符串字面量

💡 核心记忆口诀:大写字母代表全局外部可见(Global),小写字母代表文件内部私有(Local)。 如果链接时报 undefined reference,用 nm 一查即可发现提供方导出的是小写 t(被意外加了 static 限制了作用域)或者干脆是个 U。

2.4.2 C/C++ 跨语言混编与 extern "C" 的底层机制

在工业界,经常需要用 C 语言主工程(如 Linux 内核模块、Nginx 插件、嵌入式运行时)调用 C++ 开发的复杂算法库。

我们在 c_caller.c(纯 C 语言编写)中调用 cpp_callee.cpp(C++ 编写)中的优惠券计算函数 compute_discount。

1. 故障复现:直接链接引发 undefined reference
gcc -c c_caller.c -o build_symbols/c_caller.o
g++ -c cpp_callee.cpp -o build_symbols/cpp_callee_bad.o

# 查看各自目标文件中的符号表:
nm build_symbols/c_caller.o | grep compute_discount
# 输出:                  U compute_discount  (C 期待纯净符号)

nm build_symbols/cpp_callee_bad.o | grep compute_discount
# 输出: 0000000000000000 T _Z16compute_discountdd (C++ 实际输出了修饰符号)

尝试执行链接:

g++ build_symbols/c_caller.o build_symbols/cpp_callee_bad.o -o build_symbols/bad_app

链接器直接中断并输出:

image.png

2. 根因剖析与标准解法

C 语言不支持函数重载,编译器生成的符号名与源码完全一致(compute_discount);而 C++ 默认开启 Name Mangling,生成了修饰符号 _Z16compute_discountdd。链接器按名字匹配,自然判定为符号缺失。

在跨语言头文件 c_interface.h 中,必须遵循大厂通用的 __cplusplus 条件保护范式:

#pragma once

#ifdef __cplusplus
extern "C" {
#endif

// 强制 C++ 编译器以纯 C 规范输出符号,禁用 Name Mangling
double compute_discount(double original_price, double discount_rate);

#ifdef __cplusplus
}
#endif

重新编译后,C++ 目标文件中生成的符号变成了纯净的 T compute_discount,混编程序顺利完成链接并正常运行。


2.5 实验拆解四:大厂工业级发布规范——strip 瘦身与 Debug Symbol 分离架构

实验背景与测试集说明

  • 实验目录:projects/stage_2/elf_binutils_lab/part4_strip_and_debuglink/
  • 涉及核心文件:
    • crash_app.cpp:设计了包含多层级函数嵌套调用(main -> trigger_level_1 -> trigger_level_2 -> bad_memory_access)的测试靶场,并在内部触发空指针解引用崩溃;
    • run_strip_debuglink_lab.sh:涵盖了剥离符号、发布瘦身、添加 .gnu_debuglink 校验以及 GDB 崩溃抓取的自动化脚本。
  • 核心验证目标:解决生产环境部署体积与线上 Core Dump 排错不可兼得的核心矛盾,完整走通工业级符号分离三步走规范。

2.5.1 生产环境的核心矛盾

  • 矛盾一面:线上服务器集群成千上万,如果直接分发携带 -g 完整调试信息的大包,二进制体积动辄数百兆,分发带宽与磁盘成本难以承受;
  • 矛盾另一面:如果无脑使用 strip --strip-all 将符号全部抹除,线上突发段错误(SIGSEGV)生成 Core Dump 时,GDB 由于缺乏符号映射,打印出来的调用栈全都是十六进制裸内存地址,根本无法定位到具体哪一行代码崩了。

2.5.2 大厂工业级发布标准三步走

通过 objcopy 与 strip 的组合,我们可以实现“线上极轻量发布、本地无损回溯行号”的完美架构:

debug_symbol_split_architecture.jpg

步骤 1:抽取纯 DWARF 调试符号文件
objcopy --only-keep-debug build_strip/crash_app_debug build_strip/crash_app.debug
步骤 2:对正式发布二进制执行彻底瘦身
cp build_strip/crash_app_debug build_strip/crash_app_release
strip --strip-debug --strip-unneeded build_strip/crash_app_release

体积对比立竿见影:

image.png

步骤 3:注入外部符号关联校验段
objcopy --add-gnu-debuglink=build_strip/crash_app.debug build_strip/crash_app_release

此时使用 readelf -S 观察 crash_app_release,可以看到新增了一个特殊的 .gnu_debuglink 节,里面记录了目标 debug 文件的文件名及其 CRC32 校验和。

2.5.3 线上崩溃排错现场实证

crash_app.cpp 中埋伏了一段空指针写异常代码,启动时传参 --crash 必崩。

实验 A:同级目录下存在 crash_app.debug

使用 GDB 批处理加载已被 strip 过的 crash_app_release 并触发崩溃:

gdb -batch -q -ex "file build_strip/crash_app_release" -ex "run --crash" -ex "bt"

终端输出令人振奋:

image.png

虽然运行的程序已被 strip 瘦身,但 GDB 通过读取 .gnu_debuglink 自动寻找并挂载了独立的 crash_app.debug,完美还原出了函数名、参数以及崩溃在第 10 行的精准堆栈!

实验 B:移走 crash_app.debug(模拟无符号盲人摸象)

如果把符号文件移走,再次运行 GDB:

image.png

调用栈彻底退化为一堆不可读的问号与内存物理地址。这种直观对比生动揭示了工业级符号分离架构的巨大工程价值。


2.6 阶段二总结与高频面试八股复盘清单

综合本实战项目对 ELF 与 Binutils 工具链的深入剖析,我们将技术面试与生产实践中最关键的底层考点整理为如下 Q&A:

Q1:ELF 文件由哪几部分组成?程序入口地址 Entry point 是 main 函数吗?

答:ELF 文件主要由 ELF Header(记录文件魔数、目标架构及段表偏移)、Program Header Table(面向操作系统加载,描述虚拟内存段 Segment 的映射规则)、Section Header Table(面向编译器与链接器,描述各个节 Section 的物理属性与权限),以及具体的代码与数据段组成。

程序的入口地址绝对不是 main 函数,而是 glibc C 运行时库的 _start 入口。操作系统内核在执行 execve 装载程序后,首先将 CPU 控制权交给 _start,由它负责初始化栈环境、读取环境变量与命令行参数、触发全局对象的构造函数;准备完毕后,再通过 __libc_start_main 调起我们编写的 main 函数;待 main 返回后,控制权再度移交回运行时库以执行全局析构,并最终通过 exit 系统调用安全退出进程。


Q2:未初始化的全局变量(.bss)到底占不占磁盘物理空间?为什么?

答:完全不占磁盘物理存储空间。在 ELF 规范中,未初始化的全局与静态变量被归入 .bss 段,该段在节头表中的类型被明确定义为 NOBITS(No Bits)。这意味着在磁盘文件体内,根本没有为它分配任何实际存储扇区,节头表仅仅记录了它所需的内存大小数值(Size)。

只有当程序被内核通过 execve 加载进内存时,操作系统才会依据记录的 Size,按需映射一段匿名虚拟内存页(Anonymous Pages)并自动清零。与之相对,已赋初值的全局变量(.data 段)类型为 PROGBITS,由于包含非零的有效数据,必须实打实地写入磁盘文件中保存。


Q3:使用 nm 工具查看符号表时,符号类型字母的大写和小写有什么本质区别?

答:本质区别在于符号的外部可见性(Visibility)与链接作用域。

  • 大写字母代表该符号具备全局可见性(Global / External),属于对外导出的公开接口,可以被其他目标文件在链接期进行跨模块解析和绑定(如普通的全局函数 T、全局已初始化变量 D);
  • 小写字母代表该符号为文件局部私有(Local / Internal),通常由 C/C++ 中的 static 关键字修饰,作用域被严格限制在当前源文件内,外部链接器无法感知或引用。

在排查 undefined reference 时,如果用 nm 发现提供方库里的目标函数类型为小写 t,往往说明该函数被误加了 static 限制了导出权限;若类型显示为 U,则说明当前目标文件本身也只是引用方,并未真正提供实现。


Q4:在生产环境中,如何兼顾“发布包极轻量”与“线上 Crash 时精准排障”?

答:工业界的标准解法是Debug Symbol 分离发布架构,即“编译期全量生成调试信息,发布前剥离存储”。

在 CI/CD 构建期带 -g 编译出完整二进制后,先使用 objcopy --only-keep-debug 将纯 DWARF 调试段单独抽取为 .debug 文件,集中存入公司内部的符号服务器(Symbol Server);随后使用 strip 对正式发布版本进行彻底瘦身(体积通常骤降 90% 以上),并利用 objcopy --add-gnu-debuglink 在轻量发布包内注入该 .debug 文件的校验指纹。

生产服务器仅分发轻量的发布程序,极大节省网络带宽与宿主机存储。一旦线上突发 SIGSEGV 崩溃并生成 Core Dump,研发人员只需将 core 文件与符号服务器中匹配的 .debug 文件一同交由 GDB 分析,GDB 便会自动挂载符号,无损还原出崩溃现场的函数调用栈、传参以及精准的代码行号。


写在最后与下篇预告

写在最后

至此,阶段二「C++ 构建体系与链接原理」的两大核心实战已全部圆满收官:

  1. 在实战项目一中,我们从高级语言视角出发,将编译流水线拆解为预处理、编译、汇编和链接四大阶段,并实测了编译器优化与严苛 CI/CD 编译门禁;
  2. 在实战项目二中,我们转入底层二进制视角,借助 GNU Binutils 工具链(readelf、objdump、nm、strip、objcopy),彻底穿透了 ELF 二进制黑盒,探明了 .bss 零空间、虚函数底层寻址、extern "C" 跨语言链接以及大厂生产级符号分离规范。

这两个实战项目共同构成了现代 Linux C++ 工程师坚实的构建与二进制认知底座。

本项目涉及的所有测试源码、故障注入用例与自动化测试脚本均已开源,强烈建议在本地 WSL 环境中克隆并单步实操验证:

git clone https://github.com/xliulc-lang/linux-notebook.git
cd linux-notebook/projects/stage_2/elf_binutils_lab
chmod +x run_all.sh
./run_all.sh

下篇预告:进入阶段三「调试排错、内存诊断与系统调用观测」

告别了构建与二进制体系,我们将正式迈入每一位系统级 C++ 工程师最硬核的护城河领域——调试排错、动态诊断与系统调用观测。

在接下来的阶段三中,我们将围绕全新实战项目 gdb_mastery_lab 展开:

  1. GDB 进阶断点与硬件监视点:彻底告别 printf 大法,掌握条件断点(b if)、栈帧探查、内存 Examine 扫描(x/<n><f><u>),以及基于 CPU 调试寄存器的硬件写监视点(watch);
  2. 生产在线进程热附加调试(gdb -p <PID>):在绝不重启服务的前提下动态挂载运行中进程,抓取瞬时调用栈,并通过 set var 热修改变量完成优雅脱钩(detach);
  3. 多线程并发死锁与 Futex 内核穿透:利用 thread apply all bt 穿透 Linux 内核 futex 互斥等待队列,秒级定位循环互斥死锁根因,并给出现代 C++ 标准下的根治防范方案;
  4. Core Dump 事后分析:配置 ulimit -c,让崩溃现场瞬间“冻结”,在本地精准复盘事故现场。

敬请期待阶段三的精彩更新!