程序的虚拟地址空间分为多个区域,栈(Stack)是其中地址较高的一个区域。
栈(Stack)可以存放函数参数、局部变量、局部数组等作用范围在函数内部的数据,它的用途就是完成函数的调用。
栈内存由系统自动分配和释放:发生函数调用时就为函数运行时用到的数据分配内存,函数调用结束后就将之前分配的内存全部销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部。
栈的概念
在计算机中,栈可以理解为一个特殊的容器,用户可以将数据依次放入栈中,然后再将数据按照相反的顺序从栈中取出。也就是说,先放入的数据最后才能取出,而最后放入的数据必须先取出。这称为先进后出(First In Last Out)原则。
放入数据常称为入栈或压栈(Push),取出数据常称为出栈或弹出(Pop)。
可以发现,栈底始终不动,出栈入栈只是在移动栈顶,当栈中没有数据时,栈顶和栈底重合。
从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。在现代计算机中,通常使用ebp寄存器指向栈底,而使用esp寄存器指向栈顶。随着数据的进栈出栈,esp 的值会不断变化,进栈时 esp 的值减小,出栈时esp的值增大。
ebp 和 esp 都是CPU中的寄存器:ebp 是 Extend Base Pointer 的缩写,通常用来指向栈底;esp 是 Extend Stack Pointer 的缩写,通常用来指向栈顶。
栈的大小以及栈溢出
对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这在编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。
一个程序可以包含多个线程,每个线程都有自己的栈,严格来说,栈的最大值是针对线程来说的,而不是针对程序。
栈内存的大小和编译器有关,编译器会为栈内存指定一个最大值,在 VC/VS 下,默认是 1M,在 C-Free 下,默认是 2M,在 Linux GCC 下,默认是 8M。
当程序使用的栈内存大于默认值(或者修改后的值)时,就会发生栈溢出(Stack Overflow)错误。
栈帧/活动记录
当发生函数调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。活动记录一般包括以下几个方面的内容:
1. 函数的返回地址,也就是函数执行完成后从哪里开始继续执行后面的代码。
C语言代码最终会被编译为机器指令,确切地说,返回地址应该是下一条指令的地址,这里之所以说是下一条C语言语句的地址,仅仅是为了更加直观地说明问题。
2. 参数和局部变量。有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中,我们暂时不考虑这种情况。
3. 编译器自动生成的临时数据。例如,当函数返回值的长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。
当返回值的长度较小(char、int、long 等)时,不会被压入栈中,而是先将返回值放入寄存器,再传递给函数调用者。
4. 一些需要保存的寄存器,例如 ebp、ebx、esi、edi 等。之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。
当发生函数调用时:
实参、返回地址、ebp 寄存器首先入栈;
然后再分配一块内存供局部变量、返回值等使用,这块内存一般比较大,足以容纳所有数据,并且会有冗余;
最后将其他寄存器的值压入栈中。
关于数据的定位
由于 esp 的值会随着数据的入栈而不断变化,要想根据 esp 找到参数、局部变量等数据是比较困难的,所以在实现上是根据 ebp 来定位栈内数据的。ebp 的值是固定的,数据相对 ebp 的偏移也是固定的,ebp 的值加上偏移量就是数据的地址。
函数调用惯例(Calling Convention)
一个C程序由若干个函数组成,C程序的执行实际上就是函数之间的相互调用。
函数的参数(实参)由调用方压入栈中供被调用方使用,它们之间要有一致的约定。例如,参数是从左到右入栈还是从右到左入栈,如果双方理解不一致,被调用方使用参数时就会出错。
函数调用方和被调用方必须遵守同样的约定,理解要一致,这称为调用惯例(Calling Convention)。
一个调用惯例一般规定以下两方面的内容:
1. 函数参数的传递方式,是通过栈传递还是通过寄存器传递(这里我们只讲解通过栈传递的情况)。
2. 函数参数的传递顺序,是从左到右入栈还是从右到左入栈。
3. 参数弹出方式。函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。
4. 函数名修饰方式。函数名在编译时会被修改,调用惯例可以决定如何修改函数名。
函数调用惯例在函数声明和函数定义时都可以指定,语法格式为:
返回值类型 调用惯例 函数名(函数参数)
在函数声明处是为调用方指定调用惯例,而在函数定义处是为被调用方(也就是函数本身)指定调用惯例。
__cdecl是C语言默认的调用惯例,在平时编程中,我们其实很少去指定调用惯例,这个时候就使用默认的 __cdecl。
详细分析一个函数进栈出栈的例子
void func(int a, int b){
int p = 12, q = 345;
}
int main(){
func(90, 26);
return 0;
}
函数使用默认的调用惯例 cdecl,即参数从右到左入栈,由调用方负责将参数出栈。
函数进栈
1. main() 是主函数,也需要进栈,如步骤①所示。
2. 在步骤②中,执行语句func(90, 26);,先将实参 90、26 压入栈中,再将返回地址压入栈中,这些工作都由 main() 函数(调用方)完成。这个时候 ebp 的值并没有变,仅仅是改变 esp 的指向。
3. 到了步骤③,就开始执行 func() 的函数体了。首先将原来 ebp 寄存器的值压入栈中(也即图中的 old ebp),并将 esp 的值赋给 ebp,这样 ebp 就从 main() 函数的栈底指向了 func() 函数的栈底,完成了函数栈的切换。由于此时 esp 和ebp 的值相等,所以它们也就指向了同一个位置。
4. 为局部变量、返回值等预留足够的内存,如步骤④所示。由于栈内存在函数调用之前就已经分配好了,所以这里并不是真的分配内存,而是将 esp 的值减去一个整数,例如 esp - 0XC0,就是预留 0XC0 字节的内存。
5. 将 ebp、esi、edi 寄存器的值依次压入栈中。
6. 将局部变量的值放入预留好的内存中。注意,第一个变量和 old ebp 之间有4个字节的空白,变量之间也有若干字节的空白。
为什么要留出这么多的空白,岂不是浪费内存吗?这是因为我们使用Debug模式生成程序,留出多余的内存,方便加入调试信息;以Release模式生成程序时,内存将会变得更加紧凑,空白也被消除。
至此,func() 函数的活动记录就构造完成了。可以发现,在函数的实际调用过程中,形参是不存在的,不会占用内存空间,内存中只有实参,而且是在执行函数体代码之前、由调用方压入栈中的。
未初始化的局部变量的值为什么是垃圾值
为局部变量分配内存时,仅仅是将 esp 的值减去一个整数,预留出足够的空白内存,不同的编译器在不同的模式下会对这片空白内存进行不同的处理,可能会初始化为一个固定的值,也可能不进行初始化。
虽然编译器对空白内存进行了初始化,但这个值对我们来说一般没有意义,所以我们可以认为它是垃圾值、是随机的。
函数出栈
步骤⑦到⑨是函数 func() 出栈过程:
7. 函数 func() 执行完成后开始出栈,首先将 edi、esi、ebx 寄存器的值出栈。
8. 将局部变量、返回值等数据出栈时,直接将 ebp 的值赋给 esp,这样 ebp 和 esp 就指向了同一个位置。
9. 接下来将 old ebp 出栈,并赋值给现在的 ebp,此时 ebp 就指向了 func() 调用之前的位置,即 main() 活动记录的 old ebp 位置,如步骤⑨所示。
这一步很关键,保证了还原到函数调用之前的情况,这也是每次调用函数时都必须将 old ebp 压入栈中的原因。
最后根据返回地址找到下一条指令的位置,并将返回地址和实参都出栈,此时 esp 就指向了 main() 活动记录的栈顶, 这意味着 func() 完全出栈了,栈被还原到了 func() 被调用之前的情况。
遗留的错误认知
经过上面的分析可以发现,函数出栈只是在增加 esp 寄存器的值,使它指向上一个数据,并没有销毁之前的数据。前面我们讲局部变量在函数运行结束后立即被销毁其实是错误的,这只是为了让大家更容易理解,对局部变量的作用范围有一个清晰的认识。
栈上的数据只有在后续函数继续入栈时才能被覆盖掉,这就意味着,只要时机合适,在函数外部依然能够取得局部变量的值。
栈溢出攻击的原理
局部数组也是在栈上分配内存,当输入"12345678901234567890" 时,会发生数组溢出,占用“4字节空白内存”、“old ebp”和“返回地址”所在的内存,并将原有的数据覆盖掉,这样当 main() 函数执行完成后,会取得一个错误的返回地址,该地址上的指令是不确定的,或者根本就没有指令,所以程序在返回时出错。
C语言不会对数组溢出做检测,这是一个典型的由于数组溢出导致覆盖了函数返回地址的例子,我们将这样的错误称为“栈溢出错误”。
这里所说的“栈溢出”是指栈上的某个数据过大,覆盖了其他的数据。
局部数组在栈上分配内存,并且不对数组溢出做检测,这是导致栈溢出的根源。除了上面讲到的 gets() 函数,strcpy()、scanf() 等能够向数组写入数据的函数都有导致栈溢出的风险。
栈溢出一般不会产生严重的后果,但是如果有用户精心构造栈溢出,让返回地址指向恶意代码,那就比较危险了,这就是常说的栈溢出攻击。
静态、动态内存分配
在进程的地址空间中,代码区、常量区、全局数据区的内存在程序启动时就已经分配好了,它们大小固定,不能由程序员分配和释放,只能等到程序运行结束由操作系统回收。这称为静态内存分配。
栈区和堆区的内存在程序运行期间可以根据实际需求来分配和释放,不用在程序刚启动时就备足所有内存。这称为动态内存分配。
使用静态内存的优点是速度快,省去了向操作系统申请内存的时间,缺点就是不灵活,缺乏表现力,例如不能控制数据的作用范围,不能使用较大的内存。而使用动态内存可以让程序对内存的管理更加灵活和高效,需要内存就立即分配,而且需要多少就分配多少,从几个字节到几个GB不等;不需要时就立即回收,再分配给其他程序使用。
栈和堆的区别
栈区和堆区的管理模式有所不同:栈区内存由系统分配和释放,不受程序员控制;堆区内存完全由程序员掌控,想分配多少就分配多少,想什么时候释放就什么时候释放,非常灵活。
程序启动时会为栈区分配一块大小适当的内存,对于一般的函数调用这已经足够了,函数进栈出栈只是 ebp、esp 寄存器指向的变换,或者是向已有的内存中写入数据,不涉及内存的分配和释放。当函数中有较大的局部数组时,比如 1024*10 个元素,编译器就会在函数代码中插入针对栈的动态内存分配函数,这样函数被调用时才分配内存,不调用就不分配。
我们经常听说“栈内存的分配效率要高于堆”就是这个道理,因为大部分情况下并没有真的分配栈内存,仅仅是对已有内存的操作。
动态内存分配函数
堆(Heap)是唯一由程序员控制的内存区域,我们常说的动态内存分配也是在这个区域。在堆上分配和释放内存需要用到C语言标准库中的几个函数:malloc()、calloc()、realloc()和free()。
1. malloc()
原型:void * malloc(size_t size);
作用:在兑取分配size字节的内存空间。
返回值:成功返回分配的内存地址,失败则返回NULL。
注意:分配内存在动态存储区(堆区),手动分配,手动释放,申请时空间可能有也可能没有,需要自行判断,由于返回的是void *,建议手动强制类型转换。
2. calloc()
原型:void * calloc(size_t n, size_t size);
功能:在堆区分配n*size字节的连续空间。
返回值:成功返回分配的内存地址,失败则返回NULL。
注意:calloc()函数是对malloc()函数的简单封装,参数不同,使用时务必小心,第一参数是第二参数的单元个数,第二参数是单位的字节数。
3. realloc()
原型:void * realloc(void *ptr, size_t size);
功能:对ptr指向的内存重新分配size大小的空间,size可比原来的大或者小,还可以不变。
返回值:成功返回更改后的内存地址,失败则返回NULL。
4. free()
原型:void free(void * ptr);
功能:释放由malloc()、calloc()、realloc()申请的内存空间。
注意点:
1. 每个内存分配函数必须有相应的free函数,释放后不能再次使用被释放的内存。
2. 在分配内存时最好不要直接用数字指定内存空间的大小,这样不利于程序的移植。因为在不同的操作系统中,同一数据类型的长度可能不一样。为了解决这个问题,C语言提供了一个判断数据类型长度的操作符,就是sizeof。
3. free(p)并不能改变指针p的值,p依然指向以前的内存,为了防止再次使用该内存,建议将p的值手动置为NULL。
sizeof是一个单目操作符,不是函数,用以获取数据类型的长度时必须加括号,例如sizeof(int)、sizeof(char)等。
malloc()背后的实现原理-内存池
相对于栈而言,堆这片内存面临着一个稍微复杂的行为模式:在任意时刻,程序可能发出请求,要么申请一段内存,要么释放一段已经申请过的内存,而且申请的大小从几个字节到几个GB都有可能,我们不能假设程序一次申请多少堆空间,因此,堆的管理显得较为复杂。
那么,使用malloc()在堆上分配内存到底是如何实现的呢?
一种做法是把malloc()的内存管理交给系统内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让malloc()使用这个系统调用去申请内存,不就可以了吗?当然这是一种理论上的做法,但实际上这样做的性能比较差,因为每次程序申请或者释放堆空间都要进行系统调用。我们知道系统调用的性能开销是比较大的,当程序对堆的操作比较频繁时,这样做的结果会严重影响程序的性能。
比较好的做法就是malloc()向操作系统申请一块适当大小的堆空间,然后由malloc()自己管理这块空间。
malloc()相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”、当然malloc()在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是malloc()需要一个算法来管理堆空间,这个算法就是堆的分配算法。
malloc()和free()的分配算法
在程序运行过程中,堆内存从低地址向高地址连续分配,随着内存的释放,会出现不连续的空闲区域,如下图所示:
带阴影的方框是已被分配的内存,白色方框是空闲内存或已被释放的内存。程序需要内存时,malloc()首先遍历空闲区域,看是否有大小合适的内存块,如果有,就分配,如果没有,就向操作系统申请(发生系统调用)。为了保证分配给程序的内存的连续性,malloc()只会在一个空闲区域中分配,而不能将多个空闲区域联合起来。
内存块(包括已分配和空闲的)的结构类似于链表,它们之间通过指针连接在一起。在实际应用中,一个内存块的结构如下图所示:
next是指针,指向下一个内存块,used表示当前内存块是否已被使用。这样,整个堆区就会形成如下图所示的链表:
可以看到,malloc()和free()所做的工作主要是对已有内存块的分拆和合并,并没有频繁地向操作系统申请内存,这大大提高了内存分配的效率。
另外,由于单向链表只能在一个方向搜索,在合并或拆分内存块时不方便,所以大部分malloc()实现都会在内存块中增加一个pre指针指向上一个内存块,构成双向链表,如下图所示。
链表是一种经典的堆内存管理方式,经常被用在教学中,很多C语言教程都会提到“栈内存的分配类似于数据结构中的栈,而堆内存的分配却类似于数据结构中的链表”就是源于此。
链表式内存管理虽然思路简单,容易理解,但存在很多问题,例如:
一旦链表中的pre或next指针被破坏,整个堆就无法工作,而这些数据恰恰很容易被越界读写所接触到。
小的空闲区域往往不容易再次分配,形成很多内存碎片。
经常分配和释放内存会造成链表过长,增加遍历的时间。
针对链表的缺点,后来人们提出了位图和对象池的管理方式,而现在的malloc()往往采用多种方式复合而成,不同大小的内存块往往采用不同的措施,以保证内存分配的安全和效率。
内存池
不管具体的分配算法是怎样的,为了减少系统调用,减少物理内存碎片,malloc()的整体思路是先向操作系统申请一块大小适当的内存,然后自己管理,这就是内存池(Memory Pool)。
内存池的研究重点不是向操作系统申请内存,而是对已申请到的内存的管理,这涉及到非常复杂的算法。
我们知道,C/C++是编译型语言,没有内存回收机制,程序员需要自己释放不需要的内存,这在给程序带来了很大灵活性的同时,也带来了不少风险,例如C/C++程序经常会发生内存泄漏,程序刚开始运行时占用内存很少,随着时间的推移,内存使用不断增加,导致整个计算机运行缓慢。
内存泄漏的问题往往难于调试和发现,或者只有在特定条件下才会复现,这给代码修改带来了不少障碍。为了提高程序的稳定性和健壮性,后来的Java、Python、C#、JavaScript、PHP等使用了虚拟机机制的非编译型语言都加入了垃圾内存自动回收机制,这样程序员就不需要管理内存了,系统会自动识别不再使用的内存并把它们释放掉,避免内存泄漏。可以说,这些高级语言在底层都实现了自己的内存池,也即有自己的内存管理机制。
池化技术
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
指向没有访问权限的内存
局部变量的值是不确定的,是随机的,不知道指向那块内存。一般情况下,这块内存要么没有访问权限,要么还没有分配。所以此时有函数试图操作这块内存时,就会发生错误。
指向释放掉的内存
free() 只是释放掉了动态分配的内存,但并未改变 str 的值,str 的值不是 NULL,它仍然指向被释放掉的内存,所以会执行 if 语句里面的 puts() 函数。但由于此时的内存已经被释放掉了,原来的字符串已经不在了,所以输出的数据是未知的。
这就提醒我们,使用 free() 释放内存的同时要将指针置为NULL,否则下次就无法判断指向的内存是否有效。
规避野指针
要想规避野指针,就要养成良好的编程习惯:
1. 指针变量如果暂时不需要赋值,一定要初始化为NULL,因为任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的。
2. 当指针指向的内存被释放掉时,要将指针的值设置为NULL,因为free()只是释放掉了内存,并未改变指针的值。
C语言内存泄漏(内存丢失)
使用malloc()、calloc()、realloc()动态分配的内存,如果没有指针指向它,就无法进行任何操作,这段内存会一直被程序占用,直到程序运行结束由操作系统回收。
#include <stdio.h>
#include <stdlib.h>
int main(){
char *p = (char*)malloc(100 * sizeof(char));
p = (char*)malloc(50 * sizeof(char));
free(p);
p = NULL;
return 0;
}
该程序中,第一次分配100字节的内存,并将p指向它;第二次分配50字节的内存,依然使用p指向它。
这就导致了一个问题,第一次分配的100字节的内存没有指针指向它了,而且我们也不知道这块内存的地址,所以就再也无法找回了,也没法释放了,这块内存就成了垃圾内存,虽然毫无用处,但依然占用资源,唯一的办法就是等程序运行结束后由操作系统回收。这就是内存泄漏,可以理解未程序和内存失去了联系,再也无法对它进行任何操作。
内存泄漏形象的比喻是“操作系统可提供给所有程序使用的内存空间正在被某个程序榨干”,最终结果是程序运行时间越长,占用的内存空间越来越多,最终用尽全部内存空间,整个系统崩溃。
free()函数的用处在于实时地回收内存,如果程序很简单,程序结束之前也不会使用过多的内存,不会降低系统的性能,那么也可以不用写free()函数。当程序结束后,操作系统会释放内存。
但是如果在开发大型程序时不写free()函数,后果是很严重的。这是因为很可能在程序中要重复一万次分配10MB的内存,如果每次进行分配内存后都使用 free() 函数去释放用完的内存空间, 那么这个程序只需要使用10MB内存就可以运行。但是如果不使用 free() 函数,那么程序就要使用100GB 的内存!这其中包括绝大部分的虚拟内存,而由于虚拟内存的操作需要读写磁盘,因此,这样会极大地影响到系统的性能,系统因此可能崩溃。
因此,在程序中使用 malloc() 分配内存时都对应地写出一个 free() 函数是一个良好的编程习惯。这不但体现在处理大型程序时的必要性,并能在一定程度上体现程序优美的风格和健壮性。
C语言变量的存储类别和生存期
我们知道,变量是有数据类型的,用以说明它占用多大的内存空间,可以进行什么样的操作。
除了数据类型,变量还有一个属性,称为“存储类别”。存储类别就是变量在内存中的存放区域。在进程的地址空间中,常量区、全局数据区和栈区可以用来存放变量的值。
常量区和全局数据区的内存在程序启动时就已经由操作系统分配好,占用的空间固定,程序运行期间不再改变,程序运行结束后才由操作系统释放;它可以存放全局变量、静态变量、一般常量和字符串常量。
栈区的内存在程序运行期间由系统根据需要来分配(使用到变量才分配内存;如果定义了变量但没有执行到该代码,也不会分配内存),占用的空间实时改变,使用完毕后立即释放,不必等到程序运行结束;它可以存放局部变量、函数参数等。
我们可以通过C语言中的关键字来控制变量的存放区域。C语言共有 4 个关键字用来指明变量的存储类别:auto(自动的)、static(静态的)、register(寄存器的)、extern(外部的)。
知道了变量的存储类别,就可以知道变量的生存期。通俗地讲,生存期指的是在程序运行过程中,变量从创建到销毁的一段时间,生存期的长短取决于变量的存储类别,也就是它所在的内存区域。
auto变量
auto 是自动或默认的意思,很少用到,因为所有的变量默认就是 auto 的。也就是说,定义变量时加不加 auto 都一样,所以一般把它省略,不必多次一举。
static 变量
static 声明的变量称为静态变量,不管它是全局的还是局部的,都存储在静态数据区(全局变量本来就存储在静态数据区,即使不加 static)。
静态数据区的数据在程序启动时就会初始化,直到程序运行结束;对于代码块中的静态局部变量,即使代码块执行结束,也不会销毁。
注意:静态数据区的变量只能初始化(定义)一次,以后只能改变它的值,不能再被初始化,即使有这样的语句,也无效。
静态局部变量虽然存储在静态数据区,但是它的作用域仅限于定义它的代码块。
register 变量
一般情况下,变量的值是存储在内存中的,CPU 每次使用数据都要从内存中读取。如果有一些变量使用非常频繁,从内存中读取就会消耗很多时间,例如 for 循环中的增量控制。
为了解决这个问题,可以将使用频繁的变量放在CPU的通用寄存器中,这样使用该变量时就不必访问内存,直接从寄存器中读取,大大提高程序的运行效率。
不过寄存器的数量是有限的,通常是把使用最频繁的变量定义为 register 的。
为了提高精度,循环的次数越多越好,可以将循环的增量控制定义为寄存器变量。
关于寄存器变量有以下事项需要注意:
1. 为寄存器变量分配寄存器是动态完成的,因此,只有局部变量和形式参数才能定义为寄存器变量。
2. 局部静态变量不能定义为寄存器变量,因为一个变量只能声明为一种存储类别。
3. 寄存器的长度一般和机器的字长一致,只有较短的类型如 int、char、short 等才适合定义为寄存器变量,诸如 double 等较大的类型,不推荐将其定义为寄存器类型。
4. CPU的寄存器数目有限,即使定义了寄存器变量,编译器可能并不真正为其分配寄存器,而是将其当做普通的auto变量来对待,为其分配栈内存。当然,有些优秀的编译器,能自动识别使用频繁的变量,如循环控制变量等,在有可用的寄存器时,即使没有使用 register 关键字,也自动为其分配寄存器,无须由程序员来指定。