程序语言的底层描述(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~
一:数组的汇编实现
对指针和数组的理解程度是衡量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~
相关文章推荐
- 程序语言的底层描述(4)——递归函数汇编栈帧实现
- 程序语言的底层描述(1)——汇编基础概念的开始之入门
- 程序语言的底层描述(2)——栈指针esp和帧指针ebp
- PL/0语言编译程序整理实现:(1)、语法描述EBNF
- (数据结构与算法分析 三)------栈的实现(包括链栈和数组实现栈 Java语言描述)
- 数据结构(java语言描述)-- 表的简单数组实现
- 32位汇编语言学习笔记(21)--用NASM实现Hello World小程序
- 汇编语言裸机实现时钟程序-BCD转ASCII,修改中断向量表
- 编译器是如何用汇编语言实现C++的虚函数表和隐式传递this指针(二)
- 数据结构(java语言描述)-- 队列的循环数组实现
- 读王爽老师汇编语言笔记---[bx]loop指令以及多个段的程序
- 【汇编语言/底层开发】6、程序中包含多个segment
- 技巧:用 C 语言实现程序的多态性: C中只有两种类型int 和void *, 万能指针void *
- C指针通过编译器实现汇编语言类似内存间接寻址功能,编程语言的差异主要来自编译器的解释
- 比较数组下标操作与指针操作的速度(将程序转换成汇编程序)
- Intel汇编语言实现的演奏音乐程序
- C语言动态分配数组程序实现
- C 语言中的左值和右值。以及对比数组名和指针取数组元素的区别。
- 用数组和指针实现公司职员信息的管理程序
- 汇编语言中常用进制数据输出的程序实现