程序的机器级表示
程序编码
- C预处理器扩展源代码,插入所有#include命令指定的文件,并扩展所有#define声明指定的宏。
- 编译器产生两个源文件的汇编代码,后缀名为.s。
- 汇编器将汇编代码转化为二进制目标代码文件,后缀名为.o。
- 链接器将两个目标代码文件与实现库函数的代码合并,并产生最终的可执行代码文件。
机器级代码
程序是由指令和数据构成的。程序执行时,指令一条接着一条执行。其实是抽象后的结果。其中有两种抽象尤为重要。指令集体系结构。每条指令按顺序执行,一条结束,下一条开始。所有指令都放在一个巨大的字节数组中,若是32位机器,则地址空间为4GB。
计算机系统利用抽象模型来隐藏实现的细节。对于机器级编程有两种重要的抽象:
- 指令集体系结构或指令集架构:定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响. 大多数指令集结构将程序的行为描述的好像每条指令都是按顺序执行的,实际情况要更加复杂一些,很多指令都是并发执行的。
IA32将程序的行为描述成好像每条指令时按顺序执行的,一条指令结束后,下一条再开始。(实际上处理器并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致) - 虚拟地址的抽象,存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
汇编代码中的寄存器
程序计数器(通常称为“PC”,在x86-64中用号%rip表示):给出将要执行的下一条指令在内存中的地址。
- 整数寄存器文件包含16个命名的位置,分别存储64位的值。存储地址或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算数或逻辑指令的状态信息。用来实现控制或数据流中的条件变化。比如说用来实现if和 while语句。
- 一组向量寄存器可以存放一个或多个整数或浮点数值。
程序存储器包含:程序可执行机器代码,操作系统需要的一些信息。用来管理过程调用和返回的运行时栈,以及用户分配的存储器块(比如说用malloc库函数分配的)。程序内存用虚拟地址来寻址。在任意给定时刻,只有有限的一部分虚拟地址是认为合法的。例如,x86-64 的虚拟地址是由 64 位的字来表示的,在目前的实现中,这些地址的最高 16 位必须设置为 0 。所以一个地址实际上能够指定的是 2^48 或 64 TB 范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际的处理器内存中的物理地址。
通用寄存器
寄存器 | 用途 |
---|---|
%rax | 返回值 |
%rbx | 被调用者保存 |
%rcx | 第4个参数 |
%rdx | 第3个参数 |
%rsi | 第2个参数 |
%rdi | 第1个参数 |
%rsp | 栈指针: 最为特殊, 用来指明栈的结束位置(栈顶) |
%rbp | 被调用者保存 |
%r8 | 第5个参数 |
%r9 | 第6个参数 |
%r10 | 调用者保存 |
%r11 | 调用者保存 |
%r12 | 被调用者保存 |
%r13 | 被调用者保存 |
%r14 | 被调用者保存 |
%r15 | 被调用者保存 |
程序存储器(program memory) 包含:
- 程序的可执行机器代码、
- 操作系统需要的一些信息、
- 栈、
- 堆。
程序存储器用虚拟地址来寻址(此虚拟地址不是机器级虚拟地址)。
操作系统负责管理虚拟地址空间(程序级虚拟地址),将虚拟地址翻译成实际处理器存储器中的物理地址(机器级虚拟地址)。
数据格式
Intel用术语“字”表示16位数据类型。因此,称32位数为“双字( double words)”,称64位数为“四字( quad words)
C语言数据类型在X86-64中的大小如下:
C声明 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char* | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
C 语言提供了一种模型,可以在内存中分配和声明各种数据类型的对象,但是实际上机器代码只是简单的将内存视为一个巨大的,按字节寻址的数组。
C 语言的各种数据类型,如数组,结构体,在机器代码中用连续的字节表示,对标量数据类型,汇编代码也不区分有符号和无符号整数,不区分各种类型的指针,甚至不区分指针与整数。
访问信息
一个x86-64的中央处理单元包含一组16个存储64位值的通用目的寄存器。用来存储整数数据和指针.
-
指令可以对这16个寄存器的低位字节中存放的数据进行操作
-
字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,64位访问整个寄存器
-
不同位的寄存器名字不同,使用的时候要注意。
-
x86-64 的指令长度从 1 到 15 个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
-
设计指令格式的方式是,从某个给定位置开始,可以将字节唯一的解码成机器指令。例如,只有指令 push %rbx 是以字节值 53 开头的(更多关于指令编码值相关的翻阅第四章)。
-
反汇编器只是根据机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
操作数指示符
- 大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。
- 有三中类型的操作数:
- 立即数,用来表示常数值。比如,$0x1f。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
- 寄存器,表示某个寄存器的内容。16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位
- 内存引用,根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号 M b [ A d d r ] {M_b}[Addr] Mb[Addr]表示对存储在内存中从地址Addr开始的b个字节值的引用。
数据传送指令
不同后缀的指令主要区别在于它们操作的数据大小不同。
源操作数:寄存器,内存
目的操作数:寄存器,内存。
- 最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。
- 把许多不同的指令划分成指令类,每一类中的指令执行相同的操作,只不过操作数大小不同。
- MOV类:把数据从源位置复制到目的位置,不做任何变化。
- x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置 。即将一个值从内存复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
- 修改目的寄存器高位字节,包括零扩展和符号扩展
数据传送指令
pushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。
pushq %rbp等价于以下两条指令:
subq $8,%rsp Decrement stack pointer
movq %rbp,(%rsp) Store %rbp on stack
popq %rax等价于下面两条指令:
mova (%rsp), %rax Read %rax from stack
addq $8,%rsp Increment stack pointer
算术和逻辑操作
- 操作被分为四组:加载有效地址、一元操作、二元操作和移位
加载载有效地址
- 该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数,C语言中的地址操作符&就是说明这种计算。
IA32指令集中有这样一条加载有效地址指令leal,用法为leal S, D,效果是将S的地址存入D,是mov指令的变形。可是这条指令往往用在计算乘法上,GCC编译器特别喜欢使用这个指令,比如下面的例子
leal (%eax, %eax, 2), %eax
实现的功能相当于%eax = %eax *
括号中是一种比例变址寻址,将第一个数加上第二个数和第三个数的乘积作为地址寻址,leal的效果使源操作数正好是寻址得到的地址,然后将其赋值给%eax寄存器。为什么用这种方式算乘法,而不是用乘法指令imul呢?
这是因为Intel处理器有一个专门的地址运算单元,使得leal的执行不必经过ALU,而且只需要单个时钟周期。相比于imul来说要快得多。因此,对于大部分乘数为小常数的情况,编译器都会使用leal完成乘法操作。
一元和二元操作
- 一条指令可以有一个或者多个操作数
- 只有一个操作数就是一元操作,二个就是二元操作
移位操作
算术左移和逻辑左移是一样的都是将右边填上0
算术右移在左填上符号位,逻辑右移在左边填0
- 左移指令:SAL,SHL
- 算术右移指令:SAR(填上符号位)
- 逻辑右移指令:SHR(填上0)
移位操作的目的操作数是一个寄存器或是一个内存位置。
特殊的算术操作
目前为止%eax有三个特殊用法:
- 整数型返回值,需要放入到%eax中
- imull和mull的单操作数模式,使用%eax中数据作为乘数,结果的低位也存储到%eax中
- idivl和divl被除数的低位在%eax中。
控制
条件码
条件码的定义:
描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。
常用的条件码
- CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个补码溢出—正溢出或负溢出。
C代码 t = a + b, 该指令操作完成后就会设置以上的四个标志位。其中:
CF:(unsigned t) < (unsigned a)
ZF:t == 0
SF:t < 0 负数
OF:(a < 0 == b < 0) && (t < 0 != a < 0)
leal指令不会改变条件码,因为它只操作地址。
对于逻辑操作,xorl (异或)进位标志和溢出标志都设置成0,
移位操作则进位标志设置为最后一个被移除的位,溢出标志则为0 。
cmp指令 根据两个操作数之差来设置条件码,常用来比较两个数,但是不会改变操作数。
test指令 用来测试这个数是正数还是负数,是零还是非零。两个操作数相同
test S2,S1 通过S1&S2的结果(按位与),比如testl %eax,%eax用来检查%eax是正数,负数还是0或者其中一个操作数是掩码,用来指示哪些位应该被测试
test %rax,%rax //检查%rax是负数、零、还是正数 (%rax && %rax)
cmp %rax,%rdi //与sub指令类似,%rdi - %rax。
访问条件码
访问条件码的三种方式
- 可以根据条件码的某种组合,将一个字节设置为0或者1。
- 可以条件跳转到程序的某个其他的部分。
- 可以有条件地传送数据。
对于第一种情况,常使用set指令来设置。
/*
计算a<b的汇编代码
int comp(data_t a,data_t b)
a in %rdi,b in %rsi
*/
comp:
cmpq %rsi,%rdi
setl %al
movzbl %al,%eax
ret
setl %al 当a<b,设置%eax的低位为0或者1。
有些指令是带有后缀的,表示条件跳转,下面解释下这些后缀,有助于记忆。
e == equal,
ne == not equal,
s == signed,
ns == not signed,
g == greater,
ge == greater or equal,
l == less,
le == less or eauql,
a == ahead,
ae == ahead or equal,
b == below,
be == below or equal
直接跳转
jmp .L1 //直接给出标号,跳转到标号处
间接跳转
jmp *%rax //用寄存器%rax中的值作为跳转目标
jmp *(%rax) //以%rax中的值作为读地址,从内存中读出跳转目标
跳转指令的编码
汇编
movq %rdi, %rax
jmp .L2
.L3:
sarq %rax
.L2:
testq %rax, %rax
jg .L3
rep;ret
反汇编
0:48 89 f8 mov %rdi,%raxrdi,
3:eb 03 jmp 8 <loop+0x8>
5:48 d1 f8 sar %rax
8:48 85 c0 test %rax %rax
b:71 f8 jg 5<loop+0x5>
d: f3 C3 repz rete
反汇编器产生的注释中,第2行中跳转指令的跳转目标指明为0x8,
第5行中跳转指令的跳转目标是0x5。观察指令的宇节编码,
第一条跳转指令的目标编码(在第二个字节中)为0x03.把它加上0×5,
也就是下一条指令的地址,得到跳转目标地址0x8,也就是第4行指令的地址。
第二个跳转指令的目标用单字节、补码表示编码为0xf8(十进制-8)。
将这个数加上0xa(十进制13),即第6行指令的地址,我们得到0x5,
即第3行指令的地址。
当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址
基于条件数据传送的代码会比基于条件控制转移的代码性能要好。
主要原因是处理器通过使用流水线来获得高性能,处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到90%以上的成功率),指令流水线中就会充满着指令。另一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。
使用条件传送也不总是会提高代码的效率。例如,如果 then expr或者 else expr的求值需要大量的计算,那么当相对应的条件不满足时,这些工作就白费了。
编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性*能。说实话,编译器井不具有足够的信息来做出可靠的决定;例如,它们不知道分支会多好地遵循可预测的模式。我们对GCC的实验表明,只有当两个表达式都很容易计算时,例如表达式分别都只是条加法指令,它才会使用条件传送。根据我们的经验,即使许多分支预测错误的开销会超过更复杂的计算,GCC还是会使用条件控制转移。
总的来说,条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。它们只能用于非常受限制的情况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。
条件语句的翻译-if-else
C语言的if-else语句的通用形式模板:
if(test-expr)
then-statement
else
else-statement
对于这种通用形式,汇编实现一般采用如下==插入条件(else-statement)和无条件分支(if-statement)==的形式,保证执行正确的代码块。用C语法来描述控制流:
t=test-expr;
if(!t)
goto false;
then-statment;
goto done;
false:
else-statement
done:
对于C语言的if-else语句,创建一个goto版本的紧密遵循汇编代码控制流的等价语句(为了方便构造汇编代码)。基本思路是:首先比较了两个操作数(第3行),设置条件码。如果比较的结果表明x大于等于y,则它会跳转到计算x-y的代码块,否则继续执行计算y-x的代码。如下图所示一个计算两数之差的绝对值函数的C代码和汇编代码。
循环
C语言中提供了3种循环结构,如do-while、while、for。在汇编代码中,绝大多数都可以用条件测试和跳转组合结合起来实现循环的效果。并且大多数汇编器都是根据一个循环的do-while形式来产生循环代码(尽管实际中用的很少),其他的循环都是借助do-while形式再编译成机器代码。
将循环翻译成汇编主要有两种方法,第一种我们称为跳转到中间,
它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。
第二种方法叫guarded-do,
首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-whie循环。当使用较髙优化等级编译时,例如使用命令行选项-O1,GCC会采用这种策略。
do-while循环
do-while语句的通用形式模板:
do
body-statement
while (test-expr);
对于这种通用形式,用C语法的goto语句来描述控制流:
loop:
body-statement
t=test-expr;
if(t)
goto loop;
每次循环,程序都会先执行body-statement,然后再对test-expr表达式求值,如果测试为真,回去继续执行循环,可看到body-statement至少执行一次。
逆向工程循环的基本策略:
如何由汇编代码找到和原始代码的对应关系,核心是找到程序值和寄存器之间的映射关系。在循环前如何初始化寄存器,在循环中如何更新和测试寄存器以及在循环后又如何使用寄存器,把它组合起来就能打开C语言隐秘bug后的大门了。(注:GCC常会做一些寄存器优化的变换,更不易观察出来)
while循环
while语句的通用形式模板:
while (test-expr)
body-statement
对于这种通用形式,用C语法的goto语句来描述控制流:
t=test-expr;
if(!t)
goto done;
loop:
body-statement
t=test-expr;
if(t)
goto loop;
done:
和do-while不同的是它首先要对test-expr求值,第一次执行body时,循环就有可能终止。因而GCC常用的策略是使用条件分支,并且在需要时优化最开始的测试,省略循环的第一次执行,从而转换成do-while循环。
for循环
for循环语句的通用形式模板:
for(init-expr;test-expr;update-expr)
body-statement
对于这种通用形式,用C语法的goto语句来描述控制流:
init-expr;
t=test-expr;
if(!t)
goto done;
loop:
body-statement
update-texpr;
t=test-expr;
if(t)
goto loop;
done:
它的基本步骤是首先对初始表达式init-expr求值,然后进入循环;在循环中先对测试条件test-expr求值,为假则退出,否则执行循环体body-statement,最后再更新表达式。
switch语句
switch语句可以根据一个整数索引值进行多重分支。它们不仅提高了C代码的可读性而且通过使用跳转表这种数据结构使得实现更加高效。
跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。
程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句。当开关情况数量比较多(例如4个以上),并且值的范围跨度比较小时,就会使用跳转表。
跳转表结构的汇编代码每个 case 的顺序应该是与原始代码一致的。
标号只是一个符号,其后面的数字没有任何意义,只是用来区分,L3 并不代表它是第 3 个case
过程
过程是软件中一种很重要的抽象,它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后可以在程序中不同的地方调用这个函数。要提供对过程的机器级支持,需要处理许多不同的属性。
为了讨论方便,假设过程P调用过程Q, Q执行后返回到P。这些动作包含以下机制:
- 传递控制:在进入过程Q的时候,程序计数器(PC,寄存器则为 %rip)必须被设置为Q的代码的起始位置,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据:P 必须能够向 Q 提供一个或者多个参数,Q必须能够向 P 返回一个值。
- 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而返回的时候也必须要释放这些空间。
进程的虚拟地址空间
运行时栈
C语言过程调用机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则,大多数其他语言也是如此。一个典型的运行时栈结构如下图所示:
当 x86-64 过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这部分称为过程的栈帧。
当过程P调用过程Q的时候,会将返回地址压入栈中,指明当Q返回时,要从P的哪个位置开始执行。这个返回地址属于P的栈帧。
通常来说,大多数过程的栈帧都是定长的,在过程开始就分配好了。但是有些过程需要变长的栈帧,如在一个函数中动态分配数组(动态分配数组一般通过malloc或者new,此时是从堆上分配内存,而alloc可以从栈上动态分配内存,且无需手动释放)。
通过寄存器,过程P可以传递最多6个整数值(指针和整数),如果需要更多的参数,则需要在P的栈帧中保存好这些参数。这里也延申出一个问题,非整数型参数是如何传递的呢?实际上也是通过调用者的栈帧去传递的。
转移控制
将控制从函数P转移到函数Q只需要简单的把程序计数器(PC)设置为 Q 的代码的起始位置。不过,当稍后从 Q 返回的时候,处理器必须记录好它需要继续 P 的执行代码的位置。在 x86-64 机器中,这个信息是用指令 Call Q 调用过程 Q 来记录的。该指令会把地址 A (即原先过程函数 P Call Q 指令的下一条指令地址),压入栈中,并且**设置 PC **,压入的地址 A 称为 **返回地址 **。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
call 指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。
call指令的作用是将目标指令的地址压入栈中,并且设置程序计数器,假设目标指令地址为 0x400000, 那么实际操作起来等同如下指令:
sub $0x8, %rsp
mov %rip, %rsp
mov *0x400000, %rip
而 ret 指令则等价于如下指令
mov %rsp, %rip
add $0x8, %rsp
把返回地址压入栈的简单的机制能够让函数在稍后返回到程序中正确的点。C语言标准的调用/返回机制刚好与栈提供的后进先出的内存管理方法吻合。
数据传送
X86-64中,可以通过寄存器来传递最多6个参数。寄存器的使用是有特殊顺序的,会根据参数的顺序为其分配寄存器。
当传递参数超过6个时,会把大于6个的部分放在栈上。
假设过程P调用过程Q,有n个整型参数,且n>6。那么P的代码分配的栈帧必须要能容纳7到n号参数的存储空间(此时意味着,栈上可能有大约两份7~n 号参数的变量),且参数7是位于栈顶,即参数是从右到左,依次入栈的。所有的数据大小都向8的倍数对齐。参数到位以后,程序就可以执行call指令进行控制转移到过程Q了。过程Q可以通过寄存器访问参数,有必要的话也可以通过栈访问。
栈上的局部存储
通常来说,不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符‘&‘,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
寄存器中的局部存储
一般来说,过程都是通过减小栈指针(%rsp)在栈上分配空间。分配的结果作为栈帧的一部分。
寄存器组是唯一被所有过程共享的资源。因此,在某些调用过程中,我们要不同过程调用的寄存器不能相互影响。
根据惯例,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压入栈中,然后在返回前从栈中弹出旧值(pop)。有了这条惯例,P的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),调用Q,然后继续使用寄存器中的值。
递归过程
根据之前的内容可以知道,多个过程调用在栈中都有自己的私有空间,多个未完成调用的局部变量不会相互影响,递归本质上也是多个过程的相互调用。
总结
- 栈规则提供了一种机制,每次函数调用都有存储它自己的私有状态信息(保存的返回位置、栈指针、被调用者保存寄存器-很重要,必须先保存)
- 如果需要,还需要根据分配和释放的栈规则存储局部变量。过程调用中在栈上分配局部变量,返回时自动释放。每个调用在各自栈中私有空间,局部变量都不会相互影响。
- 过程调用中访问信息均是相对于帧指针%ebp而言,$0x4(%ebp)表示的是返回地址,往地址增大的方向$0x8(%ebp)表示的是函数第一个参数,函数如果有多个参数,依次以4递增。本地变量和临时变量则是往地址变小的方向存储
- 在返回前,函数必须将栈恢复到原始条件,可以恢复所有的被调用者保存寄存器和%ebp,并重置%esp使其指向返回地址。
- 如果在调用过程中,使用了malloc函数,需要说明的是:
- 指针变量是分配在栈上的局部变量,调用结束,该变量自动释放。但由malloc分配在堆的内存-该指针指向的堆内存却并未释放,如果不作处理,就会造成内存泄露;
- 为了防止内存泄露,有两种处理情形:作为返回值,返回那段堆内存的指针,从而不会丢失对那段内存的控制;在栈调用结束前使用free操作手动释放那段内存;
- 指针变量的内存随调用结束自动释放,指针指向的那段内存必须使用free或delete操作释放。因而明确一点的是free之后再解引用那个指针是非法的,因为访问已释放的内存地址是无效的,一般建议释放操作后主动置指针为NULL指针就不会造成误解。
参考资料
《深入理解计算机系统》 第三版