近来想用实际代码实验来验证《Effective C++》、《深度探索C++对象模型》中的知识,通过反汇编等手段查看编译器生成的代码,原本想着看能不能设置好编译参数,使得编译器可以输出书本中的中间代码,可惜的是暂时还没找到,还一度以为只能通过强行分析汇编码。经过一周的摸索,总算弄出了一个可以接受的方案,这篇博客主要说明几个命令之间生成代码的信息。
环境支持
- Linux
- g++
- gdb
- nm
- objdump
- c++filt
- readelf
其中g++
本身就可以通过添加-S
参数就可以生成汇编码了,nm
命令用来查看生成二进制文件的函数表,objdump
是查看更多关于二进制文件的信息,c++filt
命令使用来demangling
,readelf
用于读取ELF
文件信息。
目标
《深度探索C++对象模型》中的构造语义章节中提到过,如果有一个Object
类,而在声明一个实例的时候:
1 | Object obj; |
编译器可能会生成如下的中间代码:
1 | Object obj; |
而生成的函数原型是:
1 | void Object::Object(Object* const this); |
此实验的目的就是验证编译器确实是生成了这样的中间代码。结论是目前只有gdb
能做到,在呈现成果的结果前,也试试用别的命令看看能够尝试到什么样的结果。
x86_64汇编相关的准备知识
一旦开始做这类C++相关代码分析的工作,汇编是逃不开的,不过现在也基本不用写,会读就可以了。g++
生成的汇编码是AT&T
格式的,64位机的条件下,每个涉及到操作数的命令中后面会带有b
、w
、l
、q
等字母,分别代表操作一个1Byte(8 bit)、2 Byte(16bit)、4 Byte(32bit)以及8 Byte(64bit)。例如:
movb %al, %bl
代表把ax
寄存器的低8bit赋值给bx
寄存器的低8bit。movw %ax, %bx
代表把ax
中16bit的值赋值给bx
寄存器。movl %eax, %ebx
代表把eax
中32bit的值赋值予ebx
寄存器。movq %rax, %rbx
代表把rax
中64bit的值赋予rbx
寄存器。
x86_64有16个64bit寄存器,分别为%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。笔者所用的Linux
下的g++
编译器一般会这样划分寄存器的用途:
- %rax 作为函数返回值使用。
- %rsp 栈指针寄存器,指向栈顶。
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数…
- %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改。
- %r10,%r11 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值。
实验用的代码
1 | class Object |
这里需要定义Object::Object()
,因为编译器在这里不会自动合成Object
类的构造函数。如果连Object
类是个空类,没有函数成员,也没有数据成员,编译器会为这个类生成怎样的代码?这个问题也在《深度探索C++对象模型》中提到过,有兴趣者可以通过搜索空基类优化
关键字获得相关的知识,这里就不展开细说了。
nm命令
nm
命令是用于列出目标文件中的符号,在这个实验中用作输出函数签名,查找看能不能输出期望的void Object::Object(Object * const)
的函数签名。
首先使用
1 | $ g++ main.cpp |
生成目标文件a.out
。随后:
1 | $ nm a.out | c++filt |
可以看到终端输出(经过处理):
1 | 0000000000400566 T main |
信息还不少,可惜没有找到期待的void Object::Object(Object * const)
。
objdump
不改动上述的a.out
文件,执行:
1 | $ objdump -t a.out | c++filt |
也是输出了不少信息(经过处理):
1 | 0000000000400566 g F .text 0000000000000022 main |
当前只关注能不能找到void Object::Object(Object * const)
,所以这里也不展开介绍这个命令输出的内容。
readelf
不改动上述的a.out
文件,执行:
1 | $ readelf -s a.out | c++filt |
也是输出了不少信息(经过处理):
1 | 55: 0000000000400588 11 FUNC WEAK DEFAULT 11 Object::Object() |
也能获得不少关于符号表中的信息,可惜没有期待的void Object::Object(Object * const)
。
直接查看g++生成的汇编码
还是上述的C++
代码,不过不是分析编译器生成目标文件,而是通过:
1 | $ g++ -S main.cpp |
生成汇编文件main.s
,不过需要给这个文件进行一下demangling
,不然没法看:
1 | $ cat main.s | c++filt |
会输出(经过处理):
1 | Object::Object(): |
还是看不到《对象模型》中所说的void Object::Object(Object * const)
函数签名。不过注意到Object::Object():
中有一句:
1 | movq %rdi, -8(%rbp) |
这句汇编的意思是把%rdi寄存器中的值赋予%rbp前移8个字节的内存地址中,即复制了64bit数据。
前面提到:
1 | %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数... |
那是不是有什么参数传入到了这个函数过程中了呢?不过我们现在暂时没有办法得知,需要后面提到的gdb
帮助下才能验证。
强力工具gdb
本实验只用到gdb
的一小部分功能,之前使用搜索引擎的时候,发现都可以用gdb
调试多线程程序了,命令也挺简单,可以进入某一线程中进行调试。
回到本实验上,使用gdb
前编译目标文件:
1 | $ g++ -g main.cpp |
添加-g
是为了让生成的目标文件带有调试信息。
执行:
1 | $ gdb a.out |
进入调试。首先使用:
1 | $ (gdb) info functions |
会看到输出(经过处理):
1 | File main.cpp: |
还是看不到我们想要的void Object::Object(Object * const)
,但是使用了万能的:
1 | $ (gdb) print Object::Object |
看到了输出:
1 | $1 = {void (Object * const)} 0x400588 <Object::Object()> |
这里说明了经过输出美化的Object::Object()
真正的函数签名是void Object::Object(Object * const)
,这个实验的基本目的就达到了。
上面提到生成的汇编码中有暗示传入参数到Object::Object()
的疑点,现在就用步进模式运行程序:
1 | $ (gdb) start |
然后键入s
或者step
一步一步执行,进入了Object::Object()
函数执行内部:
1 | Object::Object (this=0x7fffffffddcf) at main.cpp:5 |
注意到这个this=0x7fffffffddcf
,这个是什么变量的地址值?在这里执行:
1 | $ (gdb) print &obj |
可惜当前Object::Object()
上下文不存在这个变量,只能键入s
进行到下一步回到main()
中执行,得到:
1 | $2 = (Object *) 0x7fffffffddcf |
证明了void Object::Object(Object * const)
是需要传入一个Object * const
参数作为执行的上下文的。到了这里,忘了在Object::Object()
上下文中检查上面提到的寄存器们了,只能用s
或者n
执行完这次,然后再次start
并进入到Object::Object()
的上下文中了,此时执行:
1 | $ (gdb) info registers |
得到:
1 | rax 0x7fffffffddcf 140737488346575 |
好了,根据上面所说的rdi
作为存储函数参数地址的寄存器,那么其中存储的0x7fffffffddcf
跟&obj
的输出值对上了,也证明了Object::Object()
真正的函数签名是void Object::Object(Object * const)
。
证明完了之后,上面还特意贴出了rax
寄存器中的值,也是0x7fffffffddcf
,上面也提到:
1 | %rax 作为函数返回值使用。 |
那么,最终Object::Object真正函数签名是Object* Object::Object(Object * const)
呢?是的,然而这个函数是返回void
,只要我们使用着这个编译器,我们便无法从其提供的语法层面获得这个返回值。
这个简单的实验,也从侧面说明了C++
这个语言是有多复杂,编译器做了多少的小动作,不花点心思和用点工具,使用者只能看着编译出来的黑箱运行,用点printf
或者cout
看看是否正确运行。
至此,这个寻找类构造函数真正的签名的实验到此为止。