- 献词
- 致谢
- 前言
- 第一部分 IDA 简介
- 第 1 章 反汇编简介
- 第 2 章 逆向与反汇编工具
- 第 3 章 IDA Pro 背景知识
- 第二部分 IDA 基本用法
- 第 4 章 IDA 入门
- 第 5 章 IDA 数据显示窗口
- 第 6 章 反汇编导航
- 第 7 章 反汇编操作
- 第 8 章 数据类型与数据结构
- 第 9 章 交叉引用与绘图功能
- 第 10 章 IDA 的多种面孔
- 第三部分 IDA 高级应用
- 第 11 章 定制 IDA
- 第 12 章 使用 FLIRT 签名来识别库
- 第 13 章 扩展 IDA 的知识
- 第 14 章 修补二进制文件及其他 IDA 限制
- 第四部分 扩展 IDA 的功能
- 第 15 章 编写 IDA 脚本
- 第 16 章 IDA 软件开发工具包
- 第 17 章 IDA 插件体系结构
- 第 18 章 二进制文件与 IDA 加载器模块
- 第 19 章 IDA 处理器模块
- 第五部分 实际应用
- 第 20 章 编译器变体
- 第 21 章 模糊代码分析
- 第 22 章 漏洞分析
- 第 23 章 实用 IDA 插件
- 第六部分 IDA 调试器
- 第 24 章 IDA 调试器
- 第 25 章 反汇编器/ 调试器集成
- 第 26 章 其他调试功能
- 附录 A 使用 IDA 免费版本 5.0
- 附录 B IDC/SDK 交叉引用
8.1 识别数据结构的用法
虽然基本数据类型通常能够与 CPU 寄存器或指令操作数的大小很自然地适应,但是,要访问复合数据类型(如数组和结构体)所包含的各数据项,则需要更加复杂的指令序列。在讨论改善代码(其中包含复杂的数据类型)可读性的 IDA 功能之前,首先简单分析一下相关代码。
8.1.1 数组成员访问
就内存布局而言,数组是最简单的复合数据结构。传统意义上的数组指包含同一数据类型的连续元素的连续内存块。用数组中元素的数量乘以每个元素的大小,即可直接计算出数组的大小。使用 C 语句,以下数组:
int array_demo[100];
所占用的最小字节的计算方法为:
int bytes = 100 * sizeof(int);
各数组元素通过索引值进行访问,这个索引值可能是变量或常量,如下面这些数组引用所示:
➊ array_demo[20] = 15; //fixed index into the array
for (int i = 0; i < 100; i++) {
➋ array_demo[i] = i; //varying index into the array
}
在上面的例子中,假如 sizeof(int)
为 4 字节,那么,➊处的第一个数组访问,访问的是数组中第 80 字节位置的整数值;而➋处的第二个数组访问,则访问的是数组中偏移量为 0、4、8…96 字节位置的连续整数值。在编译时,第一个数组访问的偏移量可通过 20×4
计算出来。多数情况下,第二个数组访问的偏移量必须在运行时计算,因为循环计数器 i
的值在编译时并不固定。因此,每经历一次循环,都必须计算 i×4
的结果,以确定具体的偏移量。最终,访问数组元素的方式,不仅取决于所使用索引的类型,而且取决于数组在程序的内存空间中的位置。
1. 全局分配的数组
如果一个数组在程序的全局数据区内分配(例如,在.data 或.bss 节),编译器在编译时可获知该数组的基址。由于基址固定,编译器可以计算出使用固定索引访问的任何数组元素的固定地址。以下面这个简单的程序为例,它同时使用固定偏移量和可变偏移量访问一个全局数组:
int global_array[3]; int main() { int idx = 2; global_array[0] = 10; global_array[1] = 20; global_array[2] = 30; global_array[idx] = 40; }
这个程序的反汇编代码清单为:
.text:00401000 _main proc near .text:00401000 .text:00401000 idx = dword ptr -4 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push ecx .text:00401004 mov [ebp+idx], 2 .text:0040100B ➊ mov dword_40B720, 10 .text:00401015 ➋ mov dword_40B724, 20 .text:0040101F ➌ mov dword_40B728, 30 .text:00401029 mov eax, [ebp+idx] .text:0040102C ➍ mov dword_40B720[eax*4], 40 .text:00401037 xor eax, eax .text:00401039 mov esp, ebp .text:0040103B pop ebp .text:0040103C retn .text:0040103C _main endp
尽管这个程序只有一个全局变量,但➊ 、➋和➌处的反汇编行似乎表明,它使用了 3 个全局变量。➍处对偏移量的计算( eax×4
)是暗示全局数组 dword_40B720
存在的唯一线索,不过,数组的名称与➊处的全局变量的名称相同。
基于 IDA 分配的哑名,我们知道,全局数组由从地址 0040B720
开始的 12 个字节组成。在编译过程中,编译器使用了固定索引(0 、1 、2 )来计算数组中对应元素的具体地址( 0040B720
、 0040B724
和 0040B728
),它们使用➊、➋和➌处的全局变量来引用。使用上一章讨论的 IDA 数组 104 第 8 章 数据类型与数据结构格式化操作(Edit ▶Array 命令),可将 dword_40B720
转换成一个三元素数组,从而得到下面的反汇编行。注意,这种特殊的格式化体现了数组中偏移量的使用:
.text:0040100B mov dword_40B720, 10 .text:00401015 mov dword_40B720+4, 20 .text:0040101F mov dword_40B720+8, 30
在这个例子中,有两点需要注意。第一,使用常量索引访问全局数组时,在对应的反汇编代码清单中,对应的数组元素将以全局变量的形式出现。换句话说,反汇编代码清单基本上不提供任何数组存在的证据。第二,使用可变索引值将带领我们来到数组的开头,因为在计算要访问的数组元素的具体地址时,我们需要用数组的基址加上相应的偏移量,这时基址即呈现出来(如➍处所示)。➍处的计算提供了另外一条有关数组的关键信息。通过观察与数组索引相乘的那个数(这里为 4),我们知道了数组中各元素的大小(不是类型)。
2. 栈分配的数组
如果数组是作为栈变量分配的,那访问数组会有何不同呢?凭直觉,我们认为这肯定会有所不同,因为编译器在编译时无法获得绝对地址,而且即使是使用常量索引的访问也必须在运行时进行某种计算。但实际上,编译器几乎以完全相同的方式处理栈分配的数组和全局分配的数组。
以下面这个使用一个小型栈分配的数组的程序为例:
int main() { int stack_array[3]; int idx = 2; stack_array[0] = 10; stack_array[1] = 20; stack_array[2] = 30; stack_array[idx] = 40; }
在编译时, stack_array
的地址未知,因此,编译器无法像在前面的全局数组例子中一样,预先计算出 stack_array[1]
的地址。通过分析这个函数的反汇编代码清单,我们了解到编译器如何访问栈分配的数组:
.text:00401000 _main proc near .text:00401000 .text:00401000 var_10 = dword ptr -10h .text:00401000 var_C = dword ptr -0Ch .text:00401000 var_8 = dword ptr -8 .text:00401000 idx = dword ptr -4 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 sub esp, 10h .text:00401006 mov [ebp+idx], 2 .text:0040100D ➊ mov [ebp+var_10], 10 .text:00401014 ➋ mov [ebp+var_C], 20 .text:0040101B ➌ mov [ebp+var_8], 30 .text:00401022 mov eax, [ebp+idx] .text:00401025 ➍ mov [ebp+eax*4+var_10], 40 .text:0040102D xor eax, eax .text:0040102F mov esp, ebp .text:00401031 pop ebp .text:00401032 retn .text:00401032 _main endp
和全局数组例子一样,这个函数似乎也使用了 3 个变量( var_10
、 var_C
和 var_8
),而不是一个包含 3 个整数的数组。根据➊、➋和➌处使用的常量,我们得知,函数似乎引用的是局部变量,但实际上它引用的是 stack_array
数组的 3 个元素,该数组的第一个元素位于 var_10
(内存地址最低的局部变量)所在的位置。
为理解编译器如何引用数组中的其他元素,首先看编译器如何引用 stack_array[1]
,它在数组中的 4 字节位置,或者在 var_10
之后的 4 字节位置。在栈帧里,编译器选择分配 ebp-0x10 处的 stack_array
。编译器知道, stack_array[1]
的地址为 ebp–0x10+4
(可简化为 ebp–0x0C
)。结果,IDA 将其作为局部变量引用显示。最终,与全局分配的数组类似,使用常量索引值会隐藏有栈分配的数组存在这一事实。唯有➍处的数组访问表明, var_10
是数组中的第一个元素,而不是一个简单的整数变量。此外,➍处的反汇编代码清单也有助于我们得出结论:数组中各元素的大小为 4 字节。
因此,编译器处理栈分配的数组和处理全局分配的数组的方式非常类似。但是,从栈分配的数组的反汇编代码清单中,我们还是可以得到其他一些信息。根据栈中 idx
的位置可以推断出,以 var_10
开始的数组最多包含 3 个元素(否则,它将覆盖 idx
)。如果你是一名破解程序开发人员,这些信息可能极其有用,据此可以确定,要使该数组溢出,并破坏其后的数据,到底需要在数组中填充多少数据。
3. 堆分配的数组
堆分配的数组是使用一个动态内存分配函数(如 C 中的 mallo
c 或 C++ 中的 new
)分配的。从编译器的角度讲,处理堆分配的数组的主要区别在于,它必须根据内存分配函数返回的地址值,生成对数组的所有引用。为方便比较,我们以下面这个函数为例,它在程序堆中分配了一个小型数组:
int main() { int *heap_array = (int*)malloc(3 * sizeof(int)); int idx = 2; heap_array[0] = 10; heap_array[1] = 20; heap_array[2] = 30; heap_array[idx] = 40; }
通过研究下面的反汇编代码清单,我们发现它与前面两个代码段的一些相似之处和不同之处:
.text:00401000 _main proc near .text:00401000 .text:00401000 heap_array = dword ptr -8 .text:00401000 idx = dword ptr -4 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 sub esp, 8 .text:00401006 ➎ push 0Ch ; size_t .text:00401008 call _malloc .text:0040100D add esp, 4 .text:00401010 mov [ebp+heap_array], eax .text:00401013 mov [ebp+idx], 2 .text:0040101A mov eax, [ebp+heap_array] .text:0040101D ➊ mov dword ptr [eax], 10 .text:00401023 mov ecx, [ebp+heap_array] .text:00401026 ➋ mov dword ptr [ecx+4], 20 .text:0040102D mov edx, [ebp+heap_array] .text:00401030 ➌ mov dword ptr [edx+8], 30 .text:00401037 mov eax, [ebp+idx] .text:0040103A mov ecx, [ebp+heap_array] .text:0040103D ➍ mov dword ptr [ecx+eax*4], 40 .text:00401044 xor eax, eax .text:00401046 mov esp, ebp .text:00401048 pop ebp .text:00401049 retn .text:00401049 _main endp
数组的起始地址(由 EAX 寄存器中的 malloc
返回)存储在局部变量 heap_array
中。这个例子与前面两个例子不同,每一次访问数组时,首先必须读取 heap_array
的内容,以获得数组的基址,然后再在它上面加上一个偏移值,计算出数组中对应元素的地址。引用 heap_array[0]
、 heap_array[1]
和 heap_array[2]
需要的偏移量分别为 0、4 和 8 字节,如➊、➋和➌处所示。引用 heap_array[idx]
➍处的操作与前面的例子最为相似,它在数组中的偏移量通过将数组索引与数组元素大小相乘计算得出。
堆分配的数组有一个非常有用的特点。如果能够确定数组的总大小和每个元素的大小,我们可以轻松计算出该数组所包含的元素的数量。对堆分配的数组而言,传递给内存分配函数的参数( 0x0C
在➎处传递给了 malloc
)即表示分配给数组的字节总数,用这个数除以元素大小(本例为 4 字节,如➊、➋和➌处的偏移量所示),即可得到数组中元素的个数。前面的例子分配了一个包含 3 个元素的数组。
关于数组的使用,我们能够得出的唯一确定的结论是:只有当变量被用作数组的索引时,我们才最容易确定数组的存在。要访问数组中的元素,首先需要用索引乘以数组元素的大小,计算出相应元素的偏移量,然后将得到的偏移量与数组的基址相加,得到数组元素的访问地址。遗憾的是,如我们在下一节所述,在使用常量索引值访问数组元素时,它们很少能够证明数组的存在,并且看起来与用于访问结构体成员的代码非常类似。
8.1.2 结构体成员访问
C 结构体,这里通称为结构体,是异类数据集合,可将数据类型各不相同的项组合到一个复合数据类型中。结构体的一个显著特点在于,结构体中的数据字段是通过名称访问,而不是像数组那样通过索引访问。不好的是,字段名称被编译器转换成了数字偏移量。结果,在反汇编代码清单中,访问结构体字段的方式看起来与使用常量索引访问数组元素的方式极其相似。
如果编译器遇到一个结构体定义,它会计算出结构体中字段所耗用字节的累计值,以确定结构体中每个字段的偏移量。下面的结构体定义将用在随后的例子中:
struct ch8_struct { //Size Minimum offset Default offset int field1; // 4 0 0 short field2; // 2 4 4 char field3; // 1 6 6 int field4; // 4 7 8 double field5; // 8 11 16 }; //Minimum total size: 19 Default size: 24
分配结构体所需的最小空间,由分配结构体中的字段所需的空间总和决定。但是,你绝不能因此认为编译器会利用所需的最小空间来分配结构体。默认情况下,编译器会设法将结构体字段与内存地址对齐,以最有效地读取和写入这些字段。例如,4 字节的整数字段将与能够被 4 整除的偏移量对齐,而 8 字节的双字则与能够被 8 整除的偏移量对齐。根据结构体的构成,满足对齐要求可能需要插入填补字节,使结构体的实际大小大于字段大小的总和。前面例子中结构体的默认偏移量和最终的结构体大小位于 Default offset
一列中。
通过使用编译器选项来要求特定的成员对齐,可将结构体压缩到最小空间。Microsoft Visual C/C++ 和 GNU gcc/g++ 都将 pack
杂注(pragma)视为控制结构体字段对齐的一种方法。同时,GNU 编译器还使用 packed
属性来控制结构体对齐(在每个结构体的基础上)。要求结构体字段进行 1 字节对齐,编译器会将结构体压缩到最小空间。就我们例子中的结构体而言,这样做将得到 Minimum offset
一列中的偏移量和结构体大小。值得注意的是,如果以这种方式对齐数据,一些 CPU 的性能更加优良;但是,如果有些边界上的数据并未对齐,CPU 可能会产生异常。
了解这些事实后,我们就可以着手分析编译代码是如何处理结构体的。为了进行比较,要注意,和数组一样,结构体成员的访问是通过将结构体的基址加上将要访问的成员的偏移量来实现的。然而,虽然数组元素的偏移量可在运行时由提供的索引值计算出来(因为每个数组元素的大小相同),但结构体成员的偏移量必须预先计算出来,作为固定偏移量出现在编译代码中,因而看起来与使用常量索引的数组引用几乎完全相同。
1. 全局分配的结构体
和全局分配的数组一样,编译器在编译时可获知全局分配的结构体的地址。这使得编译器能够在编译时计算出结构体中每个成员的地址,而不必在运行时进行任何计算。以下面这个访问全局分配的结构体的程序为例:
struct ch8_struct global_struct; int main() { global_struct.field1 = 10; global_struct.field2 = 20; global_struct.field3 = 30; global_struct.field4 = 40; global_struct.field5 = 50.0; }
如果使用默认的结构体对齐选项编译这个程序,在反汇编时,我们可能会得到下面的代码清单:
.text:00401000 _main proc near .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 mov dword_40EA60, 10 .text:0040100D mov word_40EA64, 20 .text:00401016 mov byte_40EA66, 30 .text:0040101D mov dword_40EA68, 40 .text:00401027 fld ds:dbl_40B128 .text:0040102D fstp dbl_40EA70 .text:00401033 xor eax, eax .text:00401035 pop ebp .text:00401036 retn .text:00401036 _main endp
可以看到,在这个反汇编代码清单中,访问结构体成员不需要任何算术计算,如果没有源代码,你根本无法断定这个程序使用了结构体。因为编译器在编译时已经计算出所有的偏移量,这个程序似乎引用的是 5 个全局变量,而不是一个结构体中的 5 个字段。你应该能够注意到,这种情况与前面例子中使用常量索引值的全局分配的数组非常类似。
2. 栈分配的结构体
和栈分配的数组一样(参见 8.1.1 节的第 2 小节),仅仅根据栈布局,同样很难识别出栈分配的结构体。对前面的程序进行修改,使其使用一个栈分配的结构体,并在 main
中进行声明,可得到下面的反汇编代码清单:
.text:00401000 _main proc near .text:00401000 .text:00401000 var_18 = dword ptr -18h .text:00401000 var_14 = word ptr -14h .text:00401000 var_12 = byte ptr -12h .text:00401000 var_10 = dword ptr -10h .text:00401000 var_8 = qword ptr -8 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 sub esp, 18h .text:00401006 mov [ebp+var_18], 10 .text:0040100D mov [ebp+var_14], 20 .text:00401013 mov [ebp+var_12], 30 .text:00401017 mov [ebp+var_10], 40 .text:0040101E fld ds:dbl_40B128 .text:00401024 fstp [ebp+var_8] .text:00401027 xor eax, eax .text:00401029 mov esp, ebp .text:0040102B pop ebp .text:0040102C retn .text:0040102C _main endp
同样,访问结构体中的字段不需要进行任何算术计算,因为在编译时,编译器能够确定栈帧内每个字段的相对偏移量。在这种情况下,我们同样会被误导,认为程序使用的是 5 个变量,而不是一个碰巧包含 5 个字段的变量。实际上, var_18
应该是一个大小为 24 字节的结构体的第一个变量,其他变量应进行某种格式化,以反映它们是结构体中的字段这一事实。
3. 堆分配的结构体
事实上,关于结构体的大小及其字段的布局,堆分配的结构体体现了更多信息。如果一个结构体在程序堆中分配,那么,在访问其中的字段时,编译器别无选择,只有生成代码来计算每个字段在结构体中的正确偏移量。这是结构体的地址在编译时未知所导致的后果。对于全局分配的结构体,编译器能够计算出一个固定的起始地址。对于栈分配的结构体,编译器能够计算出结构体起始位置与相关栈帧的帧指针之间的固定关系。如果一个结构体在堆中分配,那么对编译器来说,引用该结构体的唯一线索就是指向该结构体起始地址的指针。
再次修改上面的例子,使其使用堆分配的结构体,从而得到下面的反汇编代码清单。与 8.1.1 节的第 3 小节的堆分配的数组示例一样,我们在 main 中声明一个指针,并给它分配足够的内存块,以保存我们的结构体:
.text:00401000 _main proc near .text:00401000 .text:00401000 heap_struct = dword ptr -4 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push ecx .text:00401004 ➏ push 24 ; size_t .text:00401006 call _malloc .text:0040100B add esp, 4 .text:0040100E mov [ebp+heap_struct], eax .text:00401011 mov eax, [ebp+heap_struct] .text:00401014 ➊ mov dword ptr [eax], 10 .text:0040101A mov ecx, [ebp+heap_struct] .text:0040101D ➋ mov word ptr [ecx+4], 20 .text:00401023 mov edx, [ebp+heap_struct] .text:00401026 ➌ mov byte ptr [edx+6], 30 .text:0040102A mov eax, [ebp+heap_struct] .text:0040102D ➍ mov dword ptr [eax+8], 40 .text:00401034 mov ecx, [ebp+heap_struct] .text:00401037 fld ds:dbl_40B128 .text:0040103D ➎ fstp qword ptr [ecx+10h] .text:00401040 xor eax, eax .text:00401042 mov esp, ebp .text:00401044 pop ebp .text:00401045 retn .text:00401045 _main endp
在这个例子中,与全局和栈分配的结构体示例不同,我们能够辨别出结构体的实际大小和布局。根据➏处 malloc
所需的内存数量,我们推断出:结构体的大小为 24 字节。该结构体包含以下字段:
一个 4 字节字段(
dword
),偏移量为 0(➊);一个 2 字节字段(
word
),偏移量为 4(➋);一个 1 字节字段,偏移量为 6(➌);
一个 4 字节字段(
dword
),偏移量为 8(➍);一个 8 字节字段(
qword
),偏移量为 16 (10h)(➎)。
根据浮点指令的用法,我们可以进一步推断出 qword
字段实际上是 double
类型的。如果要求结构体进行 1 字节对齐,对其进行压缩,则该程序的反汇编代码清单为:
.text:00401000 _main proc near .text:00401000 .text:00401000 heap_struct = dword ptr -4 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 push ecx .text:00401004 push 19 ; size_t .text:00401006 call _malloc .text:0040100B add esp, 4 .text:0040100E mov [ebp+heap_struct], eax .text:00401011 mov eax, [ebp+heap_struct] .text:00401014 mov dword ptr [eax], 10 .text:0040101A mov ecx, [ebp+heap_struct] .text:0040101D mov word ptr [ecx+4], 20 .text:00401023 mov edx, [ebp+heap_struct] .text:00401026 mov byte ptr [edx+6], 30 .text:0040102A mov eax, [ebp+heap_struct] .text:0040102D mov dword ptr [eax+7], 40 .text:00401034 mov ecx, [ebp+heap_struct] .text:00401037 fld ds:dbl_40B128 .text:0040103D fstp qword ptr [ecx+0Bh] .text:00401040 xor eax, eax .text:00401042 mov esp, ebp .text:00401044 pop ebp .text:00401045 retn .text:00401045 _main endp
这时,这个程序的唯一不同在于,结构体变得更小(现在只有 19 个字节),偏移量有所调整,因为每个结构体字段进行了重新对齐。
不管在编译程序时是否进行了对齐操作,找到在程序堆中分配和操纵的结构体,是确定给定数据结构的大小和布局的最简单方法。但是,需要记住的是,在许多函数中,你不能立即访问结构体的每个成员,以理解该结构体的布局。你可能需要观察结构体中指针的用法,并记下每次指针取消引用时使用的偏移量。这样,你最终将能够了解结构体的完整布局。
4. 结构体数组
一些程序员认为,复合数据结构极具美感,因为你可在大型结构体中嵌入小型结构体,创建复杂程度各不相同的结构体。除其他可能性外,这种能力还允许你创建结构体数组,结构体中的结构体,以及以数组为成员的结构体。在处理这些嵌套结构时,前面有关数组和结构体的讨论同样适用。以下面的这个程序为例,它是一个结构体数组,其中的 heap_struct
指向一个包含 5 个 ch8_struct
元素的数组:
int main() { int idx = 1; struct ch8_struct *heap_struct; heap_struct = (struct ch8_struct*)malloc(sizeof(struct ch8_struct) * 5); ➊ heap_struct[idx].field1 = 10; }
访问➊处的 field1
所需的操作包括:用索引值乘以数组元素的大小(这里为结构体的大小),然后加上 field1
这个字段的偏移量。下面是对应的反汇编代码清单:
.text:00401000 _main proc near .text:00401000 .text:00401000 idx = dword ptr -8 .text:00401000 heap_struct = dword ptr -4 .text:00401000 .text:00401000 push ebp .text:00401001 mov ebp, esp .text:00401003 sub esp, 8 .text:00401006 mov [ebp+idx], 1 .text:0040100D ➋ push 120 ; size_t .text:0040100F call _malloc .text:00401014 add esp, 4 .text:00401017 mov [ebp+heap_struct], eax .text:0040101A mov eax, [ebp+idx] .text:0040101D ➌ imul eax, 24 .text:00401020 mov ecx, [ebp+heap_struct] .text:00401023 ➍ mov dword ptr [ecx+eax], 10 .text:0040102A xor eax, eax .text:0040102C mov esp, ebp .text:0040102E pop ebp .text:0040102F retn .text:0040102F _main endp
从代码清单中可以看出:堆请求了 120 个字节(➋),数组索引乘以 24 (➌),然后加上数组的起始地址(➍ )。为了生成➍ 处对结束地址的引用,没有加上其他的偏移量。从这些事实,我 们可以推断出数组的大小(24),数组中元素的个数( 120/24=5
);同时,在每个数组元素中偏移量为 0 的位置,有一个 4 字节的字段。至于每个结构体中剩余的 20 个字节是如何分配给其他字段的,这个简短的列表并没有提供足够的信息。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论