Microsoft的CL编译器与GCC到底有什么区别?

编译器版本

gcc -v:
gcc version 11.2.0 (MinGW-W64 x86_64-ucrt-posix-seh, built by Brecht Sanders)
cl:
用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.29.30136 版

CL作为微软的非开源编译器,听上去似乎比开源的GNU套件GCC编译器更“高级”,但事实真的如此吗?
咱们统一使用普遍的x64架构,看看两个编译器对同一段C代码的汇编输出有何异同。

统一编译、查看命令

gcc -O0 -c src.c -o gcc_obj.o
cl src.c // 输出a.obj,但无法链接

反汇编:
objdump -d [obj.o] -M intel // 我更习惯于intel语法,有需要的可以把intel改成att,即输出AT&T语法。

函数调用

研究底层机制,最重要的就是函数调用相关的差异。

栈帧分配

方便起见,写几个具有代表性的函数,但不实现任何实际功能,让汇编代码更简单,更能突出重点。

void func1(void)
{
	return;
}

int func2(void)
{
	return 0x1234; // 方便反汇编查看
}

int func3(int arg)
{
	return arg++;
}

这段代码中,并不包含任何函数调用语句,也就是说它是用来分析函数栈的。
编译,然后分别反汇编,得到如下结果:

gcc结果

0000000000000000 <func1>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   90                      nop
   5:   5d                      pop    rbp
   6:   c3                      ret

0000000000000007 <func2>:
   7:   55                      push   rbp
   8:   48 89 e5                mov    rbp,rsp
   b:   b8 34 12 00 00          mov    eax,0x1234
  10:   5d                      pop    rbp
  11:   c3                      ret

0000000000000012 <func3>:
  12:   55                      push   rbp
  13:   48 89 e5                mov    rbp,rsp
  16:   89 4d 10                mov    DWORD PTR [rbp+0x10],ecx
  19:   8b 45 10                mov    eax,DWORD PTR [rbp+0x10]
  1c:   8d 50 01                lea    edx,[rax+0x1]
  1f:   89 55 10                mov    DWORD PTR [rbp+0x10],edx
  22:   5d                      pop    rbp
  23:   c3                      ret
  24:   90                      nop
  25:   90                      nop
// 好多个nop(0x90)
  2f:   90                      nop

可以看见,它严格按照x86_64架构调用约定,在函数头部写上跟32位无差异(仅仅寄存器位数变化)的序言代码,采用基址指针压栈->备份栈顶指针->(开辟栈空间,由于我们的函数都没有局部变量所以没有进行SUB ESP,0x28)->函数本体->返回值存RAX(这里是EAX,因为改变低32位就能自动高32位清零,但速度更快)->平栈,返回

关注到0x13->0x1f的代码,翻译成中文即:把RCX里的参数低32位(int)拿出来,放进备用空间,然后从备用空间拿出来,放进EAX寄存器,把EDX换成RAX+1的值,即arg++,然后由于++运算符改变原值,所以需要再把EDX里自增过的值放回去,即使后文没用到了,然后回复基址寄存器并返回。

CL结果

0000000000000000 <func1>:
   0:   c2 00 00                ret    0x0
   3:   cc                      int3
   4:   cc                      int3
   5:   cc                      int3
   6:   cc                      int3
   7:   cc                      int3
   8:   cc                      int3
   9:   cc                      int3
   a:   cc                      int3
   b:   cc                      int3
   c:   cc                      int3
   d:   cc                      int3
   e:   cc                      int3
   f:   cc                      int3

0000000000000010 <func2>:
  10:   b8 34 12 00 00          mov    eax,0x1234
  15:   c3                      ret
  16:   cc                      int3
  17:   cc                      int3
  18:   cc                      int3
  19:   cc                      int3
  1a:   cc                      int3
  1b:   cc                      int3
  1c:   cc                      int3
  1d:   cc                      int3
  1e:   cc                      int3
  1f:   cc                      int3

0000000000000020 <func3>:
  20:   89 4c 24 08             mov    DWORD PTR [rsp+0x8],ecx
  24:   48 83 ec 18             sub    rsp,0x18
  28:   8b 44 24 20             mov    eax,DWORD PTR [rsp+0x20]
  2c:   89 04 24                mov    DWORD PTR [rsp],eax
  2f:   8b 44 24 20             mov    eax,DWORD PTR [rsp+0x20]
  33:   ff c0                   inc    eax
  35:   89 44 24 20             mov    DWORD PTR [rsp+0x20],eax
  39:   8b 04 24                mov    eax,DWORD PTR [rsp]
  3c:   48 83 c4 18             add    rsp,0x18
  40:   c3                      ret

通过func1和func2可以看见,(由于CL默认开始/Od选项,禁止优化),CL编译器确实更高明,发现几乎是个空函数就直接返回,至于那么多int3调试中断不在讨论范围内。
重点看到0x20的func3,首先,把ECX存进备份区,然后开辟0x18的空间(至于为什么是0x18,因为它以为func3里要调用别的函数,所以预先开辟空间,但可以看出0x18模16=8,并不对齐,这是因为call指令会压栈一个0x08字节的返回地址,这就栈对齐了)
然后取值到EAX里面,EAX自增1,放回去,归还栈空间,然后返回。

函数的调用及传参

这个部分按照常理来说,两种编译器应该没多大区别,因为都得遵守64位平台上fastcall调用约定
测试代码:

int func1(int arg)
{
	return arg+1;
}

void func2(void)
{
	int local_varible = 1;
	local_varible = func1(local_varible); // 自增1
}

gcc结果

0000000000000000 <func1>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 4d 10                mov    DWORD PTR [rbp+0x10],ecx
   7:   8b 45 10                mov    eax,DWORD PTR [rbp+0x10]
   a:   83 c0 01                add    eax,0x1
   d:   5d                      pop    rbp
   e:   c3                      ret

000000000000000f <func2>:
   f:   55                      push   rbp
  10:   48 89 e5                mov    rbp,rsp
  13:   48 83 ec 30             sub    rsp,0x30
  17:   c7 45 fc 01 00 00 00    mov    DWORD PTR [rbp-0x4],0x1
  1e:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  21:   89 c1                   mov    ecx,eax
  23:   e8 d8 ff ff ff          call   0 <func1>
  28:   89 45 fc                mov    DWORD PTR [rbp-0x4],eax
  2b:   90                      nop
  2c:   48 83 c4 30             add    rsp,0x30
  30:   5d                      pop    rbp
  31:   c3                      ret
  32:   90                      nop

func2函数开辟了0x30字节空间,然后使用相对于RBP的寻址(也是一种栈寻址方式),把1这个值放进对应的局部变量空间,利用EAX寄存器为过渡,取出来,塞进ECX传参,调用,然后把返回值塞回去(这一行代码,完成了)
最后平栈,回复基址,返回。

CL结果

0000000000000000 <func1>:
   0:   89 4c 24 08             mov    DWORD PTR [rsp+0x8],ecx
   4:   8b 44 24 08             mov    eax,DWORD PTR [rsp+0x8]
   8:   ff c0                   inc    eax
   a:   c3                      ret
   // int3 * n

0000000000000020 <func2>:
  20:   48 83 ec 38             sub    rsp,0x38
  24:   c7 44 24 20 01 00 00    mov    DWORD PTR [rsp+0x20],0x1
  2b:   00
  2c:   8b 4c 24 20             mov    ecx,DWORD PTR [rsp+0x20]
  30:   e8 00 00 00 00          call   35 <func2+0x15>
  35:   89 44 24 20             mov    DWORD PTR [rsp+0x20],eax
  39:   48 83 c4 38             add    rsp,0x38
  3d:   c3                      ret

相比之下CL的代码短得多,它不涉及对RBP的操作,仅仅修改RSP足矣。而且,cl的栈寻址都是采用RSP偏移,更直观(这对于机器来说确实是一句废话),一眼能看出每个局部变量的位置。
这里的func2属于经典调用了……首先开辟常见的0x38空间,存局部变量到对应的栈空间,然后提取到ECX传参,调用,返回,塞回栈空间局部变量区,平栈,退出。

总结

在最基本的逻辑控制——函数调用方面,CL似乎就比不开优化开关的GCC高明了不少,尤其是指令短,如果实验中的func1,func2被平凡调用,即使每个指令的时钟周期再短,累计起来也是客观的时间。仅从这一个方面看起来,CL就拿时间和空间都换掉了,每个都节省了不少。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

dtsroy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值