您现在的位置: 365建站网 > 365文章 > C语言中的数组越界问题解决方法

C语言中的数组越界问题解决方法

文章来源:365jz.com     点击数:4064    更新时间:2017-12-03 18:59   参与评论
1.数组越界,是指访问了超出数组定义的内容。
实例:
int  a[2];
数组a定义了2个元素!
a[0],a[1]:没有越界。
a[2]:越界了!
对不对?

2.计算机全部的内存,其地址是从小到大排列。
还是,已经定义的变量(普通变量,指针变量)的内存从小到大排列呢?

3.越界的原理的什么?说说原因?

a[2]是越界了,而且编译器不会警告的,这是C语言的特性,a[2]可以解析成*(a + 2),a是数组的基地址,基地址就是数组里最小的地址,从小到大排列,你如果执行*(a - 2)就是越界了。另外大于一个字节的变量需要注意区分大端和小端存储方式,mcs51、x86都是小端存储,手机上用的芯片比如arm是大端存储的,大端高地址在低字节,小端相反。

上面的a[2]确实越界了。(但不一定会出错)

下面四个输出中的元素全部越界,但前三者并没有出错,最后一个则是不一定会出错。

#include <stdio.h>

void out(int x)
{
    printf("%d\n",x);
}

int main()
{
    int a[3][3]={1,2,3,4,5,6,7,8,9};

    out(a[0][3]);
    out(a[1][3]);

    out(a[0][8]);

    out(a[0][9]);

    return 0;
}


运行结果:



对于a[0][3]来说越界是肯定的,但是越界不代表程序会出错,这个地方越界后访问的正好是a[0][2]的下一个元素4,是一个有效值,所以不会出错。最后一个则是因为“不是一个有效值”,读到了数组内存后的第一个内存,有可能有值(可读时,此时其所读值亦不会事先知道),可能会报错(不可读时)。

因为C语言不检查数组越界,而数组又是我们经常用的数据结构之一,所以程序中经常会遇到数组越界的情况,并且后果轻者读写数据不对,重者程序crash。下面我们来分析一下数组越界的情况:

 

1) 堆中的数组越界

因为堆是我们自己分配的,如果越界,那么会把堆中其他空间的数据给写掉,或读取了其他空间的数据,这样就会导致其他变量的数据变得不对,如果是一个指针的话,那么有可能会引起crash

 

2) 栈中的数组越界

因为栈是向下增长的,在进入一个函数之前,会先把参数和下一步要执行的指令地址(通过call实现)压栈,在函数的入口会把ebp压栈,并把esp赋值给ebp,在函数返回的时候,将ebp值赋给esp,pop先前栈内的上级函数栈的基地址给ebp,恢复原栈基址,然后把调用函数之前的压入栈的指令地址pop出来(通过ret实现)。

栈是由高往低增长的,而数组的存储是由低位往高位存的,如果越界的话,会把当前函数的ebp和下一跳的指令地址覆盖掉,如果覆盖了当前函数的ebp,那么在恢复的时候esp就不能指向正确的地方,从而导致未可知的情况,如果下一跳的地址也被覆盖掉,那么肯定会导致crash。

 

-------------------------

 压入的参数和函数指针

-------------------------

                        aa[4]

                        aa[3]

合法的数组空间   aa[2]

                        aa[1]

                        aa[0]

-------------------------

 

###sta.c###

#include <stdio.h>

void f(int ai)
{
int aa[5]={1,2,3};
int i = 1;
for (i=0;i<10;i++)
  aa[i]=i;
printf("f()/n");
}

void main()
{
f(3);
printf("ok/n");
}

 

 

###sta.s###

         .file   "sta.c"                                 ;说明汇编的源程序
        .section        .rodata                     ;说明以下是只读数据区
.LC0:
        .string "f()"                                   ;"f()" 的类型是string,地址为LC0
        .text                                             ;代码段开始
.globl f                                                  ;f为全局可访问
        .type   f, @function                       ; f是函数
f:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $40, %esp
        movl    $0, -24(%ebp)
        movl    $0, -20(%ebp)
        movl    $0, -16(%ebp)
        movl    $0, -12(%ebp)
        movl    $0, -8(%ebp)
        movl    $1, -24(%ebp)
        movl    $2, -20(%ebp)
        movl    $3, -16(%ebp)
        movl    $1, -4(%ebp)
        movl    $0, -4(%ebp)
        jmp     .L2
.L3:
        movl    -4(%ebp), %edx
        movl    -4(%ebp), %eax
        movl    %eax, -24(%ebp,%edx,4)
        addl    $1, -4(%ebp)
.L2:
        cmpl    $9, -4(%ebp)
        jle     .L3
        movl    $.LC0, (%esp)
        call    puts
        leave
        ret
        .size   f, .-f                                         ;用以计算函数f的大小
        .section        .rodata
.LC1:
        .string "ok"
        .text
.globl main
        .type   main, @function
main:
        leal    4(%esp), %ecx
        andl    $-16, %esp
        pushl   -4(%ecx)
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ecx
        subl    $4, %esp
        movl    $3, (%esp)
        call    f
        movl    $.LC1, (%esp)
        call    puts
        addl    $4, %esp
        popl    %ecx
        popl    %ebp
        leal    -4(%ecx), %esp
        ret
        .size   main, .-main
        .ident  "GCC: (GNU) 4.1.2 20070115 (SUSE Linux)"               ;说明是用什么工具编译的
        .section        .note.GNU-stack,"",@progbits

 

从main函数开始压入f函数的参数开始,堆栈的调用情况如下

图1  压入参数

 

图二  通过call 命令压入下一跳地址 IP

 

图三  函数f 通过pushl   %ebp 把 ebp保存起来

 

图四  函数 f 通过movl    %esp, %ebp让ebp指向esp,这样esp就可以进行修改,在函数返回的时候用ebp的值对esp进行恢复

 

图五  函数 f 通过subl    $40, %esp 给函数的局部变量预留空间

 

图六  int数组 aa[5]占用了20个字节的空间,然后 int i占用了4个字节的空间(紧邻着之前压入栈的%ebp)

 

故,如果aa[5]进行赋值,则会把 i 的值覆盖掉,

如果对aa[6]进行赋值,则会把 栈中的 %ebp 覆盖掉,那么在函数 f 返回的时候则不能对ebp进行恢复,即main函数的ebp变成了我们覆盖掉的值,程序不知道会发生什么事情,但因为我们的程序接下来没有调用栈中的内容,故还是可以运行的。

如果对aa[7]进行赋值,则会把栈中的 %IP 覆盖掉,在函数 f 返回的时候就不能正确地找到下一跳的地址,会crash

 

 

 

对于汇编代码中

leave                   ; 将ebp值赋给esp,pop先前栈内的上级函数栈的基地址给ebp,恢复原栈基址
ret                     ; main函数返回,回到上级调用,隐含了pop %eip操作,返回值由%eax带回

关于C语言中函数调用和参数传递机制的探讨

函数,相信许多人也知道其重要性;一个文件往往由一个或者多个函数构成的。然而可能许多人还不知道函数调用的一些深层问题,所以我写的这篇文章一来是应 了一个好朋友的要求而写,二来希望一些朋友能够从我这篇文章了解函数调用的机制。但是并不是每个人都可以完全读懂这文章,想完全读懂此文,我想必须具备三 个条件:

一、对于C语言有一定的了解,最起码有一个整体的初步了解;

二、能够读懂UNIX/LINUX下的AT&T语法的汇编;AT&T汇编与Intel汇编的差别还是挺大的;这个条件可能一些人就不具备了,但是你通过阅读此文相信也能对函数调用机制有一个大概的了解;

三、看到这么长的文章,一定要有耐心,用心看相信应该多少有点帮助;

好了,不讲废话了,进入主题吧。

一、基本知识框架了解:

这部分主要讲一些基本的东西,主要是关于堆栈的知识。只有了解了堆栈的基础内容,才可以继续往下读。

1.概念性的知识:

所谓堆栈,其实也就是程序使用的一种内存元素;它是内存中用来存放一些数据的区域。我曾经写过一篇文章发表在这个论坛上里面也谈到了堆和栈的区别;平常经常说的堆栈,其实也是栈,而不是堆,所以这里也一样。注意这和数据结构说的栈其实还是有区别的,不要混在一起。

2.堆栈的工作方式:

平常我们所说的数据是怎么存放在内存的?是从低地址开始,然后按照数据占用字节大小往高地址逐个存放的。但堆栈就不一样了。堆栈的工作方式是数据插入堆栈区域然后从堆栈区域删除数据。这是概括的说法。具体是这样的:

在UNIX/LINUX 中,堆栈是从高地址向低地址衍生的。这里得说一个重要的东东,那就是堆栈指针ESP。堆栈指针是什么?它永远指向堆栈中的顶部(但如果按照地址值来说却是 底部),是不是对顶部这个词的理解感觉有点模糊?就是说,比如说你压栈,就压进一个4字节的数据元素,那么ESP就向下移动了4个字节,注意这里是向下移 动,所以ESP应该指向了更低的地址,所以说它是指向了底部。你可以把堆栈想象成一个杯子,倒进水了水平线是不是上升了(这里把杯子最底端假设成高地址, 把顶端设为低地址),倒出水了水平线是不是下降了?就和压栈和进栈的道理一样的。如果还没有
理解也没关系,自己画个图仔细比较就可以了。这里让我偷懒一下就不画图了。

3.压栈和进栈指令简介:

压栈指令 : pushx source

其中, 'x'可以是 'w'(表示字), 或者是'l'(表示长字);source可以是数值或者寄存器值或者内存地址;

出栈指令 : popx des

同样,'x'可以是 'w'(表示字), 或者是'l'(表示长字);des可以是寄存器值或内存值;

关于最最基本的东西已经讲得差不多了,当然还有其他一些基本东西,留给大家去查资料了,这部分讲的都和本文有密切关系的东西。

二、函数如何通过堆栈来解决问题:

这部分是对函数如何通过堆栈解决函数调用以及参数传递的理论性理解,相当重要,只有了解之后才可以进行实例的分析,这一大部分同样分成几个小部分:

1.通过堆栈操作实现参数的传递:

前面说过,堆栈的基本操作可以是压栈和出栈,而参数的传递就是通过这种方式来实现的。ESP永远指向了堆栈顶部,如果这时候压进一个int型的数据元 素,那么ESP向下移动了4个字节,这时候它还是指向了堆栈的顶部(注意了,顶部的地址比移动前的地址低,不要乱了)。假如把一个int型数据元素出栈, 那么ESP向上移动4个字节,这时候它还是指向了堆栈的顶部,只是现在地址是增加了4个字节。所以,如果一个函数需要传递参数过去那么就得在调用函数之前 先把参数压进栈,然后再调用。关于这点后面我会详细说一下,现在你如果没理解也没关系。

2.函数调用的一般汇编指令:

函数调用的一般汇编指令都是那么几条,下面我把他们按一般顺序罗列出来:

#Asm Code

function:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
#...
movl %ebp, %esp
popl %ebp
ret

下面先简单分析这几句一般汇编指令的意思和目的。

pushl %ebp #这句把寄存器%ebp压栈,目的是什么呢?看下一条指令:

movl %esp, %ebp #把寄存器%esp的值给了寄存器%ebp;想想前面说到的%esp寄存器是干什么的?用于指向堆栈的顶部,现在通过这条指令,%ebp都是指向了堆栈 的顶部了;所以看看第一条指令,其实就是为了保护原来在%ebp寄存器中的内容#那么这里为什么又要把%esp的值赋给%ebp呢?这里的巧妙就来了。在 函数的处理过程中,可能一些数据会被压进栈,那么这时候就会破坏栈里面原有的内容了,如果栈的内容被破坏了,指向栈顶的指针%esp指向的地址不准确了 (不知道能不能用“不准确”这个词来形容,可能不太合适),那么到时候要清栈就会发生更多的意外问题了。
清栈?先别管这个词,下面也会给出解释。所以第二条指令是为了保证有一个寄存器永远指向了栈顶而不必担心会
出现刚才所说的问题。现在是寄存器%ebp永远指向栈顶了,而%esp可以移动而不必害怕数据会被破坏了。

subl $8, %esp #看这条指令,为什么无故要把%esp的值减去8呢?也就是说%esp向下移动8个字节,而这8个字节的空间到底用来干什么呢?这8个字节空间其实是为 临时变量留出来的。注意,它会根据临时变量占用的字节大小而留出不同的空间大小,所以不一定是8个字节,可能是24或者36甚至更大的空间;不过临时变量 太多不是好事情,这点注意。

movl %ebp, %esp #这条指令把%ebp复制到%esp了,理由是什么?让%esp重新指向栈顶,这样就可以方便函数调用完毕后的清栈了。

ret #函数调用完毕的返回指令,这句指令其实同时把函数调用刚刚开始压进的IP地址弹出栈。在下面会有详细分析。

关于函数如何通过堆栈来解决问题的基本理论大概就说到这里,假如你对上面的内容不理解也没关系,下面第3个部分通过实例来分析可以让你有
个比较深刻的理解。

三、函数调用和参数传递机制的实例分析:

这是本文的实战分析部分了,通过例子来加深一下理解。我会先列出C代码出来,然后列出反汇编的汇编代码,结合C代码来分析汇编代码。我会尽可能对各种类型的函数调用或参数类型作一个分析,可能会显得比较累赘一点,不介意吧?准备好的话就开始吧:P

1. 函数原型:void function(void);

// C code

void function(void)
{
return;
}

int main(void)
{
function();

return 0;
}

反汇编一下看看汇编代码,下面是Linux 下的gcc反汇编后的代码(注意:是在我的机子上的反汇编代码):

function:
pushl %ebp
movl %esp, %ebp
popl %ebp
ret

看看,因为函数function什么也没有做,所以直接就返回了,上面的指令和第2部分的代码基本上一样,甚至更简单,参照一下前面的分析:P

下面看看main函数的反汇编代码了,相对来说复杂一点,看好了:

main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax
subl %eax, %esp
call function #函数调用指令
movl $0, %eax
leave
ret

看看函数调用指令 : call function,前面居然还有那么多据指令,那些指令到底干什么用?我一句一句分析吧:P

pushl %ebp
movl %esp, %ebp
subl $8, %esp

这三句不分析,和前面第2部分的一样,忘记的回头看一下,其实这也反映了一件事:其实main函数也很普通,它跟其他函数其实差不多,只是地位稍微高一点而已。

andl $-16, %esp

这句可能吓倒一些人了。 andl 是逻辑与指令,而-16其实补码形式是0xfffffff0。为什么要把%esp的值和-16进行逻辑与运算呢?不要小看

这条指令,它的作用不容忽视,%esp指向堆栈顶部。这条指令其实是为了强制让%esp的值是16的倍数。为什么要16的倍数?这里必须懂得一个常识:

Linux下的编译器GCC默认的堆栈是16字节对齐的,可能有些人要问为什么要对齐,对齐其实为了加快CPU的访问效率,这里你记住这点就可以了。

movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax

看到这几句,又有更多人可能被吓到了,干嘛对%eax寄存器进行那么多的操作啊?的确,我也觉得没什么多大的必要,因为仔细看看这几条指令

无非就是为了让%eax的值是0而已。看看刚开始 %eax = 0,经过两次addl之后,%eax的值变成30了,30其实就是0x11110,再下面两条指令

是为了保证%eax最低5位的值全部为0。注意,这只是在我的机子上的反汇编指令,不同机器对此处理

可能不一样,但有一点一样就是保证%eax的值是0。看看下面这条指令:

subl %eax, %esp

看,%esp值减去%eax值后把结果送到%esp,所以经过这条指令后%esp值仍然是16的倍数,这就是保证%eax值是16的倍数的原因了。

call function
movl $0, %eax

这个简单了,调用函数function,最后又把%eax寄存器的值清0,结束整个main函数了。

这就是最简单的函数调用分析了,没有涉及到参数的传递,所以非常简单,下面就要开始讲到参数的传递了,事实上有了这个例子的分析,下面的简单多了。

2.函数原型: int function(int i)

现在有了参数了,也有了返回值了,相对来说更比较复杂了。这里就得引入%esp寄存器值的变化了,不然就难以把问题分析清楚了,如果想形象一点地描述那就画图,自己画个图根据我的数据变化一起分析吧。看看一段简单的C代码:

// C Code

int function(int i)
{
return 2 * i;
}

int main(void)
{
int j = function(10);
return 0;
}

之所以些这么简单只是为了我们分析问题的方便,懂得个原理就算是复杂的其实稍微再分析一下也就懂了。我们从main开始分析吧:

main:
pushl %ebp
movl %esp, %ebp
subl $24, %esp
andl $-16, %esp
movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax
subl %eax, %esp #到这里其实和前面的例子基本一样,就不分析了
movl $10, (%esp)
call function
movl %eax, -4(%ebp)
movl $0, %eax
leave
ret

看看上面的汇编代码,和前面一样的不分析。但是其中有句不一样:subl $24, %esp ; 因为主函数里有两个临时变量i, j;这里为了有足够的空间留给临时变量所以干脆在堆栈里腾出24个字节空间。在看看下面的代码:

movl $10, (%esp) #====> %esp = 800, (800) = 10 ,其中800是我们假设的地址值,(800)表示地址800的内容这里的(%esp)指的是%esp地址里的内容, 刚才我们假设这时候%esp的值是800, 那么地址为800的内容就是10了。执行函数调用了,注意在调用函数前其实是先把函数调用指令 call之后的地址压栈,也就是call之后那条指令的IP值压栈,所以这时候 %esp = 796;这里要弄明白为什么要把下条指令地址压栈,假设如果不把IP值压栈,那么当函数调用完毕后怎么能找到函数调用时的地址呢?也就是说如果没把IP压 栈,那么函数调用完之后就回不到原来的执行地址了,就会造成程序执行顺序的错误!

下面列出函数function的汇编代码:

function:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
addl %eax, %eax
popl %ebp
ret

pushl %ebp; 经过这条指令后 %esp值减4,所以这时候%esp值是792。下面这句:

movl %esp, %ebp #==============> %ebp = 792, %esp = 792, (792) = %ebp ;其中(792)表示地址792的内容

movl 8(%ebp), %eax #========> %eax = 10

上面这句很多人可能不明白了,8(%ebp)指的是什么?8(%ebp)等于 : (%ebp + 8) ,这里注意,%ebp + 8 是表示一个地址值,加上括号表示存储在该地址上的内容。 所以8(%ebp)其实就是地址为800的值,看前面地址800的值刚好是10!所以这句其实是把10复制给%eax寄存器.

addl %eax, %eax #======> %eax = 20

相当于2 * %eax, %eax这时候等于20了,刚好是实现了C代码中的 (2 * i);

popl %ebp #=========> 恢复%ebp寄存器的值, %esp这时候等于796

ret #=========> 函数调用完毕返回,这句其实是把刚才压栈的IP值弹出栈,执行这条指令后 %esp = 800

# 800!想想我们在调用函数的时候%esp也是800啊!这就是实现了“清栈”了,就是把调用函数所在的栈清除了!

好了,函数 function的汇编代码分析完了,现在回头继续看看main函数里的下一条指令了。接下来是这句:

movl %eax, -4(%ebp)

%eax寄存器存放的是什么?看function函数的代码,可以知道其实就是(2 * i)的值,所以返回值其实是通过%eax来传递的!传递到-4(%ebp)里去了,-4(%ebp) = (%ebp - 4); -4(%ebp)到底是什么呢?看看C代码,返回值传给变量j,那么-4(%ebp)会不会就是j呢?答案是肯定的!我们先看看%ebp的值是什么。看看 main函数的汇编代码,可以得出,%ebp其实指向了main函数的栈底部,但记不记得前面说的subl $24, %esp是为临时变量而留出的空间?没错,-4(%ebp) 就是存储在临时变量区域!也就是变量 j 了。

如对本文有疑问,请提交到交流论坛,广大热心网友会为你解答!! 点击进入论坛

发表评论 (4064人查看0条评论)
请自觉遵守互联网相关的政策法规,严禁发布色情、暴力、反动的言论。
昵称:
最新评论
------分隔线----------------------------

快速入口

· 365软件
· 杰创官网
· 建站工具
· 网站大全

其它栏目

· 建站教程
· 365学习

业务咨询

· 技术支持
· 服务时间:9:00-18:00
365建站网二维码

Powered by 365建站网 RSS地图 HTML地图

copyright © 2013-2024 版权所有 鄂ICP备17013400号