您的位置:首页 > 其它

程序语言的底层描述(5)——数组、指针的汇编实现以及C程序嵌入汇编

2014-10-18 00:41 357 查看
这一节我讨论下数组和指针的汇编实现,以及在C程序中嵌入简单汇编代码的内容,至于条件判断、循环、goto等内容,资料很多且比较程式化,就不再涉及了。

一:数组的汇编实现

对指针和数组的理解程度是衡量C语言水平的分水岭,其概念略显抽象,现在要讨论汇编的实现,当然就更抽象,所以在看这节之前,先确保自己对C语言指针和数组的基本概念的明确是很重要的。

汇编语言没有指针和数组的概念,汇编某种程度上可以看成是寄存器语言。寄存器里面存了值,至于这个值代表的是地址还是常数值,CPU在执行汇编时本身是不清楚的,这些都由编译器根据上层语言来确定的。某个空间存的值,不能确定是地址还是常数,人理解起来当然觉得抽象,因此C才有了指针变量这种特殊的对象;同理,内存空间地址的随意跳变和间接引用取值也会让人深感心里无底,因此C又有了数组这种类似“变量串”的对象。

下面是一个数组引用的例子和对应的汇编代码,分析完此例,数组的汇编实现应该就比较清楚了。

int decimal(int *x)

{

int i;

int val = 0;

for (i = 0; i < 5; i++)

val = (10 * val) + *(&x[i]+i);

return val;

}

00000000 <decimal5>:

0: 55 push %ebp

1: 89 e5 mov %esp,%ebp

3: 53 push %ebx

4: 8b 5d 08 mov 0x8(%ebp),%ebx

7: b8 00 00 00 00 mov $0x0,%eax

c: b9 00 00 00 00 mov $0x0,%ecx

11: 8d 14 80 lea (%eax,%eax,4),%edx

14: 8b 04 cb mov (%ebx,%ecx,8),%eax

17: 8d 04 50 lea (%eax,%edx,2),%eax

1a: 41 inc %ecx

1b: 83 f9 04 cmp $0x4,%ecx

1e: 7e f1 jle 11 <decimal+0x11>

20: 5b pop %ebx

21: c9 leave

22: c3 ret

程序计数器3的压栈是实现“被调用者保存”寄存器,接下来计数器4,获取参数x的值,传送给ebx,注意x是指针,里面存了整型变量的地址,因此这个地址值就顺利的被ebx保存鸟,既然都存地址,因此指针变量x和%ebx可以看成是等价的了(如果把mov替换成lea,那ebx得到的可就是参数x本身的地址了,而不是它存的地址O(∩_∩)O~)。

计数器7和c明显是初始化局部变量,eax和ecx明显是“调用者保存”寄存器,所以decimal作为被调用者就随便用啦,从后面的语句就能得知eax对应val,ecx对应i,可千万别仅仅依赖顺序哦!计数器11的操作很奇妙,实际运算效果是%eax+4*%eax=5*%eax,将5*val赋值给edx,不管先往下走,计数器14,%ebx+8*%ecx,就是*(x+8*i)赋值给eax,加*是因为这里是mov了不是上面的lea!是不是很高端?为什么要把x移动8倍的i呢?这就要从*(&x[i]+i)说起。

&x[i]是某个int型变量的地址,由于它的这一特殊属性,使得做加法运算时,你必须按照int型的步进来计算。因此&x[i]+1就是下一个int型变量的地址,也就是&x[i+1],同理&x[i]+i就是&x[2i],而既然int型是4字节的步进,因此我们很轻易的得出x+2i*4 的结论。

好了,%eax现在存储了*(&x[i]+i); 下一步计数器17,计算的是%eax+2*edx,上面我们知道edx存的是5*val,很自然就推出%eax+2*edx计算的是*(&x[i]+i) + 10*val的值,并将其传送给eax保存。好了,计数器1a是让i做自加,1b和1e是循环的判断和跳转,代码的分析暂告一段落。

这里有个问题,为啥要先计算5*val,然后再计算10*val呢?编译器有个很明显的倾向就是,不到万不得已不会用乘法指令imul,因为早期的CPU处理乘法非常费时,而加法运算要快上很多倍,所以才会使用lea的性质代替乘法和加法得出数值,事实上现在的CPU做乘法的速度已经非常接近加法了,不过目前GCC还没有为此作出修改的动作。

上面的例子我们看到,编译器利用lea和mov很好的实现了数组的功能,不过注意这只是一级数组,多级数组的情况又如何呢?看下面的例子。

typedef int row34_t[3][4];

………

int decimal_m(row34_t *x)

{

return x[1][2][3];

}

00000023 <decimal_m>:

23: 55 push %ebp

24: 89 e5 mov %esp,%ebp

26: 8b 45 08 mov 0x8(%ebp),%eax

29: 8b 40 5c mov 0x5c(%eax),%eax

2c: c9 leave

2d: c3 ret

一个很简单的三维数组,row34_t声明了一个二维数组,然后由于是row34_t *x,因此x本身是存储二维数组的指针变量,至于函数调用时传的是指针还是数组,其实没有区别:如果传的是数组,那么一定是三维数组的首地址,本质就是二维数组的地址;如果传的是指针,参数调用只不过多了一步变量引用,将指针抛弃,里面的二维数组地址拿出来用。

这里要理一下数组首地址和数组地址的概念,绝大部分C教材在这方面描述的都很模糊:比如a[10];a是数组的首地址,&a才是数组的地址,a确实代表数组,但由于它在数值上等于&a[0],因此容易把a误认成数组的地址。a数值上等于首地址是为了通过a计算各元素地址,这是特殊变量的特殊应用,比如无符整型数组首地址和数组地址的区别:前者是无符整型变量的地址,后者是无符整型数组的地址。a本身既然是变量,那变量就该有自己的地址,所以&a应该比较好理解,&a+1跳转整个数组也就理所当然了。事实上,当你定义数组指针时,int
a[10],int (*p)[10],p = &a;而不是p = a;只有int *q时,q = a才正确;如果是a[2][10],a[0]是第一个末层数组(10个元素的那个数组)的首地址,那a或者&a[0]就是第一个末层数组的地址,因此p = a,q=a[0]都是合法赋值;而a又是二维数组的首地址,因此&a才是二维数组的地址,因此int (*k)[2][10], k = &a才是合法赋值。写到这里,你应该没有理由再对多维数组、首地址、数组地址、数组指针这些概念有任何问题了吧?

由于多维数组的每一维数组的元素所存储的值,都是下一维数组的首地址,而对数组元素地址(首地址也一样)的加减运算是按该元素所存储内容的大小(下一维的维度越深,要计算跳变步进的层次也越深)进行跳变的,因此x[1][2][3]的寻址就很好计算了。首先是x[1],计算row34的步进,3*4*(sizeof(int)) = 48,也就是x+1或&x[0]+1的跳变步进;接下来是x[1][2],那就是x[1]+2的跳变步进是2*4*(sizeof(int)) = 32;最后是x[1][2]+3的跳变步进:3*(sizeof(int))
= 12。48 + 32 + 12 = 92 = 0x5c,应该不用我多废话了。

接着我们玩一个更狠的,下例:

#define N 16

typedef int fix_matrix

;

int fix_prod (fix_matrix A, fix_matrix B, int i, int k)

{

int j;

int result = 0;

for (j = 0; j < N; j++)

result += A[i][j] * B[j][k];

return result;

}

00000000 <fix_prod>:

0: 55 push %ebp

1: 89 e5 mov %esp,%ebp

3: 8b 55 10 mov 0x10(%ebp),%edx //先用edx存储参数i

6: 8b 45 14 mov 0x14(%ebp),%eax //先用eax存储参数k

9: 56 push %esi

a: 8d 0c 85 00 00 00 00 lea 0x0(,%eax,4),%ecx //ecx = 4*k

11: c1 e2 06 shl $0x6,%edx //64*i

14: 53 push %ebx

15: 31 f6 xor %esi,%esi //result = 0;

17: 03 4d 0c add 0xc(%ebp),%ecx //将参数B加上4*k,存储到ecx

1a: 03 55 08 add 0x8(%ebp),%edx //将参数A加上64*i, 存储到edx

1d: bb 0f 00 00 00 mov $0xf,%ebx

22: 89 f6 mov %esi,%esi

24: 8b 01 mov (%ecx),%eax

26: 0f af 02 imul (%edx),%eax //eax:A[i][j] * B[j][k];

29: 01 c6 add %eax,%esi

2b: 83 c2 04 add $0x4,%edx // edx:A ;ecx:B

2e: 83 c1 40 add $0x40,%ecx

31: 4b dec %ebx

32: 79 f0 jns 24 <fix_prod+0x24>

34: 5b pop %ebx

35: 89 f0 mov %esi,%eax

37: 5e pop %esi

38: c9 leave

39: c3 ret

我们可以明显的看到,fix_matrix本身是定义成N*N二维数组的,但是在函数内部,却是按i、j、k进行维度跳变访问,首先看A[i][j],i作为参数传入是不变的,j随着循环在改变;再看B[j][k],k作为参数是不变的,j随着循环在增加。我们假设i、k都比N小(即使都比N大也无妨,照样玩),好,我们来分析汇编部分。

晕,说得容易,真看看还是有点头晕,我大脑的即时存储差,所以边看边在关键的地方写了注释,这样才勉强看懂,嚯嚯。先看参数,程序计数器3、6是用两个寄存器临时读取i和k,接下来计数器a,利用lea生成4*k的数,用ecx存储。计数器11利用左移6位操作,用edx存储64*i,计数器15利用异或使得esi赋值为0。关键是计数器17和1a,利用add操作,将前面生成的4*k加到参数A上,使得ecx存储A+4*k;同理edx存储B+64*i。也许到这里你还是看不懂编译器的用意,先往下继续。

计数器1d、31、32是实现j的N次循环,我们看到汇编把循环优化成减法,为啥我也不清楚,同样不清楚的是计数器22,干嘛呢?好了关键点又出现:计数器24和26,分别对ecx和edx间接寻址然后相乘,如果ecx和edx真的分别存储了A[i][j]和B[j][k],那么这句自然好理解。接下来计数器29,将乘积结果存储到esi,计数器2b和2e是指针移动,分别将A和B增加4和64,注意这个是汇编,没有步进的概念,是做的纯加法,接着由循环又跳回到24语句执行……

理到这,最需要搞清楚的就是edx和ecx为什么通过4*k和64*i初始化,又为什么每次循环完就通过4和64增加,它们到底如何与A[i][j] * B[j][k]对应上的?如果你对多维数组步进比较熟悉,相信已经心算出原委了^_^

回到C代码,由于是j的递增,因此反映在A[i][j]上时,就是A[i][0]、A[i][1]、A[i][2]……原来就是末层一维数组的跳变,因此只要我们把edx初始化成&A[i][0]的地址数值,以后循环每次加sizeof(int)就能实现这样的计算;

再看B[j][k], 由于是j的递增,因此反映在B[j][k]上时,就是B[0][k]、B[1][k]、B[2][k]……根据上面讲的多维数组原理,B[j]存储的是末层一维数组的首地址,以j进行跳变,步进就应该是N*sizeof(int),因此只要我们把ecx初始化成&B[0][k]的地址数值,以后循环每次加N*sizeof(int),就能实现上面的跳转。

而&A[i][0]就是A + i*N*sizeof(int) = A+64*i,&B[0][k]就是B + k* sizeof(int) = B + 4*k,谜底彻底揭开O(∩_∩)O~注意一点,汇编里代表的&A[i][0]在数值上加上4,和在C语言中写&A[i][0]+4这个语句的结果可以完全不通哦!前者是纯算数,后者是要计算步进的,回忆下之前的讲解。

二:动态分配以及指针的汇编实现

int var(var_matrix A, int i, int j)

{

A = (var_matrix)malloc(N*N);

return A[(i*N) + j];

}

0000003c <var>:

3c: 55 push %ebp

3d: 89 e5 mov %esp,%ebp

3f: 53 push %ebx

40: 83 ec 10 sub $0x10,%esp //esp开辟16字节

43: 8b 5d 0c mov 0xc(%ebp),%ebx //ebx存i

46: 68 00 01 00 00 push $0x100 //存16*16

4b: c1 e3 04 shl $0x4,%ebx //ebx存16*i

4e: e8 fc ff ff ff call 4f <var+0x13> //直接跳转到4e理论的下一个计数器4f

53: 03 5d 10 add 0x10(%ebp),%ebx //ebx存16*i+j

56: 8b 04 98 mov (%eax,%ebx,4),%eax //4*(16*i+j) + %eax

59: 8b 5d fc mov 0xfffffffc(%ebp),%ebx //恢复ebx旧值

5c: c9 leave

5d: c3 ret

5e: 89 f6 mov %esi,%esi

这个简单的例子,汇编看起来也有些意思。我在注释里已经写得比较清楚,计数器46,将16*16作为malloc的参数压入栈,计数器4e调用malloc,由于是动态链接所以.o文件的汇编代码如我注释的解释。计数器56,malloc返回申请的空间地址%eax,经过一维数组的跳变结果再写入eax,作为var函数的返回值。

三:在C中嵌入汇编代码

很多初学者和初级C程序员经常觉得在C语言中嵌入临时汇编语言是多么高大上的牛程,现在有了前面的汇编铺垫,是不是觉得没那么神秘了?事实上,在C里嵌入有效汇编的技巧仍然是高水平的体现,尤其是从场景来看,很少是应用程序,往往是OS原码或者其他的系统级编程,反正我本人写的代码总量非常少而且浅显,自然也没涉及到这种情况,也只能是简单的扯扯教科书上的内容,大家一起作为一个了解。

由于目前的编译器非常强大,单纯追求更高效优化已经不是嵌入汇编的理由了。因此现在嵌入汇编的目的,是诸如访问特殊寄存器中存放的进程状态信息;输入输出操作特殊指令或访问特殊的存储器位置。还有应用程序中可能需要用到的条件码值,例如进位、溢出、符号、零等标志,是不能直接用C读出来的。我们就以应用程序举例。

我们要实现一个乘法函数,当乘数溢出时,返回0,否则返回1,先来看有符数的实现:

int ok_smul(int x, int y, int *dest)

{

int result;

*dest = x*y;

asm("setae %%bl; movzbl %%bl,%0"

: "=r" (result) /* Output */

: /* No inputs */

: "%ebx" /* Overwrites */

);

return result;

}

1、%在嵌入汇编语句表示寄存器,而汇编语句里本身又用%引用寄存器,所以出现两个%

2、%0、%1……表示引用的操作数,此例只有result被引用,因此对应%0

3、输入和输出比较好理解,此例只想通过进位标志改变result,就是输出

4、由于需要借用%ebx的低位%bl,因此%ebx在嵌入汇编时会被覆盖,声明其为overwrites的作用就是让编译器增加对%ebx的备份和保护。

上面的代码,通过setae %%bl,使得溢出信息被设置到%bl中,然后用类似逻辑扩展的movzbl对%bl的其余位进行清零(你用ebx之前也不知道它的旧值是多少),只有%dl才能客观的反映溢出信息0或者1,并且传送给%0,也就是参数result。

00000000 <ok_smul>:

0: 55 push %ebp

1: 89 e5 mov %esp,%ebp

3: 8b 45 0c mov 0xc(%ebp),%eax //y

6: 0f af 45 08 imul 0x8(%ebp),%eax //x*y

a: 8b 55 10 mov 0x10(%ebp),%edx //dest

d: 53 push %ebx

e: 89 02 mov %eax,(%edx) //dest = x*y

10: 0f 93 c3 setae %bl

13: 0f b6 c3 movzbl %bl,%eax

16: 5b pop %ebx

17: c9 leave

18: c3 ret

我们来看对应的汇编代码,很明显,计数器d是对ebx旧值的备份,本来%ebx就是“被调用者保存”,你函数内部嵌入的汇编要修改%ebx,当然也是在函数中执行压栈。如果你不用bl而是用cl,那根据%ecx是“调用者保存”,汇编代码中直接就看不到压栈备份,不信自己试试^_^。计数器10、13就是我们嵌入的汇编语言,计数器16将ebx恢复旧值。

接下来要实现32位无符数乘法,很明显,无符数少了一个符号位,因此比有符数乘法更容易产生溢出,因此对溢出的判断是有区别的,编译器优先选择imull并且它也能够计算出无符数乘法正确值,但为了判断溢出信息,我们必须强制采用mull操作指令。下面是实现代码:

int ok_umul(unsigned x, unsigned y, unsigned *dest)

{

int result;

asm("movl %2,%%eax; mull %3; movl %%eax,%0; setae %%dl; movzbl %%dl,%1"

: "=r" (*dest), "=r" (result) /* Outputs %0、 %1 */

: "r" (x), "r" (y) /* Inputs %2、 %3*/

: "%eax", "%edx" /* Overwrites */

);

return result;

}

1、mull规定,%eax是默认的被乘数寄存器,而mull指令后面跟的是乘数寄存器,乘法计算结果的高32位写入乘数寄存器,低32位写入被乘数寄存器%eax;

2、由于强行使用mull,因此整个乘法的输入和输出全由汇编实现,x、y就是输入,分别用%2、%3表示,dest和resault就是输出,分别用%0、%1表示;

3、%eax和%edx是必用的临时寄存器,申请汇编保护。

根据提示1,先构造被乘数%eax,于是x被传送;接着mull执行32位无符数乘法,乘法结果的低32位在%eax里,然后mov将它的值赋给%0也就是dest,接下来就是设置标志位,以及清零传送给%1(result),打完收工。来看看汇编的实现:

00000018 <ok_umul>:

18: 55 push %ebp

19: 89 e5 mov %esp,%ebp

1b: 8b 4d 0c mov 0xc(%ebp),%ecx //y传送给ecx

1e: 53 push %ebx //看到没?还有“被调用者保存”的ebx寄存器需要压栈备份,其他的随便用

1f: 8b 5d 08 mov 0x8(%ebp),%ebx

22: 89 d8 mov %ebx,%eax //x传送给%eax

24: f7 e1 mul %ecx //做x*y,高位存入%ecx,低位存数eax

26: 89 c3 mov %eax,%ebx //乘法结果的低位传送给ebx

28: 0f 93 c2 setae %dl //%edx低8位获得溢出标志信息

2b: 0f b6 ca movzbl %dl,%ecx //%ecx获得溢出标志

2e: 8b 45 10 mov 0x10(%ebp),%eax //%eax存储参数dest的值(指针里的地址值)

31: 89 18 mov %ebx,(%eax) //将乘法结果低位通过地址间接寻址赋值给dest指向的空间

33: 5b pop %ebx

34: 89 c8 mov %ecx,%eax

36: c9 leave

37: c3 ret

我写的注释应该很清楚明了了。看不懂的请面壁思过O(∩_∩)O~
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: