通过IR代码来分析C++代码语义

IR代码是LLVM生成的Intermediate Code。可以通过IR代码来分析编译器对我们所写的代码是如何解析并执行的,使得分析代码语义变得简洁明了。IR代码的语法语义可参考LLVM Language Reference Manual

通过Clang/LLVM生成IR代码的命令如下:

1
clang++ -S -emit-llvm source.cpp

则会生成一个source.ll文件,即是我们需要分析的IR代码。

先从一个最简单的示例开始:

1
2
int x;
cout<<x<<endl;

我们都知道,如果一个位于automatic/dynamic storage duration的对象没有被初始化则具有不确定的值。

If no initializer is specified for an object, the object is default-initialized. When storage for an object with automatic or dynamic storage duration is obtained, the object has an indeterminate value, and if no initialization is performed for the object, that object retains an indeterminate value until that value is replaced (5.17).

那么编译器是如何做的呢?看以下中间代码,为节省篇幅省去不必要的部分。

1
2
3
4
%2 = alloca i32, align 4
%3 = load i32, i32* %2, align 4
%4 = call dereferenceable(272) %"class.std::basic_ostream"* @_ZNSolsEi(%"class.std::basic_ostream"* @_ZSt4cout, i32 %3)
%5 = call dereferenceable(272) %"class.std::basic_ostream"* @_ZNSolsEPFRSoS_E(%"class.std::basic_ostream"* %4, %"class.std::basic_ostream"* (%"class.std::basic_ostream"*)* @_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_)

可以看到,声明(和定义区别看C++中declaration与define的区别)一个automatic的int型对象并输出执行了以下几步:

  1. 分配一个i32的空间,%2
  2. 加载该空间的地址,%3
  3. 然后%4和%5是调用cout输出%3的那块内存

注意:这里并没有对分配的那块内存做任何操作,所以对其取值得到的值是不确定的。

来对比一下带有初始化的操作:

1
2
int x=111;
cout<<x<<endl;

其IR代码为:

1
2
3
4
5
%2 = alloca i32, align 4
store i32 111, i32* %2, align 4
%3 = load i32, i32* %2, align 4
%4 = call dereferenceable(272) %"class.std::basic_ostream"* @_ZNSolsEi(%"class.std::basic_ostream"* @_ZSt4cout, i32 %3)
%5 = call dereferenceable(272) %"class.std::basic_ostream"* @_ZNSolsEPFRSoS_E(%"class.std::basic_ostream"* %4, %"class.std::basic_ostream"* (%"class.std::basic_ostream"*)* @_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_)

注意增加的第二行:

1
store i32 111, i32* %2, align 4

这句代表存储i32值111到i32* %2所指向的空间。

再来分析一个示例:给数组的部分元素初始化,是否会初始化未被初始化的元素?

1
int iarr[123]={111,222,333};

当然这个我们是知道的,因为标准规定如下:

[ISO/IEC 14882:2014]

1
float y[4][3] = {{ 1 }, { 2 }, { 3 }, { 4 }};

initializes the first column of y (regarded as a two-dimensional array) and leaves the rest zero.
[ISO/IEC 9899:1999]
If there are fewer initializers in a brace-enclosed list than there are elements or members of an aggregate, or fewer characters in a string literal used to initialize an array of known size than there are elements in the array, the remainder of the aggregate shall be initialized implicitly the same as objects that have static storage duration.

那么,编译器是如何做的呢?其IR代码如下:

1
2
3
4
5
6
7
8
9
10
11
%2 = alloca [123 x i32], align 16
%3 = bitcast [123 x i32]* %2 to i8*
call void @llvm.memset.p0i8.i64(i8* %3, i8 0, i64 492, i32 16, i1 false)
%4 = bitcast i8* %3 to [123 x i32]*
// 下标访问,执行赋值操作
%5 = getelementptr [123 x i32], [123 x i32]* %4, i32 0, i32 0
store i32 111, i32* %5
%6 = getelementptr [123 x i32], [123 x i32]* %4, i32 0, i32 1
store i32 222, i32* %6
%7 = getelementptr [123 x i32], [123 x i32]* %4, i32 0, i32 2
store i32 333, i32* %7
  1. 首先,先分配了一个123*i32大小的空间,%2
  2. 将上面分配空间的地址转换为一个指针i8*,%3(数组的起始地址)
  3. 调用memset对%2空间的每一个字节(i8)都置为0,从%3位置起始往后偏移123*i32=492 byte
  4. 依次通过数组起始地址来访问下标为0(%4),1(%5),2(%6)元素的地址
  5. 依次存储初始化列表中的值至该地址(111 to %4 / 222 to %5 / 333 to %6)\

再来分析一下CppQuiz#Q5这个例子吧,原题如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct AClass {
AClass() { std::cout << "AClass"; }
};
struct BClass {
BClass() { std::cout << "BClass"; }
};

class CClass {
public:
CClass() : aobj(), bobj() {}

private:
BClass bobj;
AClass aobj;
};

int main()
{
CClass();
}

问:在C++11下,该代码的执行结果。

假定现在我们不知道它会执行什么(解答可看上面的链接),我们从IR代码角度来分析一下。
我们在主函中构造了一个CClass的临时对象,我们期望于CClass中对象的初始化顺序为aobj/bobj,但是事实是怎么样的呢?
IR代码如下(这里我们只需要看CClass的构造函数即可):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 主函数
; Function Attrs: norecurse uwtable
define i32 @main() #4 {
%1 = alloca %class.CClass, align 1
call void @_ZN6CClassC2Ev(%class.CClass* %1)
ret i32 0
}
// CClass的构造函数
; Function Attrs: uwtable
define linkonce_odr void @_ZN6CClassC2Ev(%class.CClass*) unnamed_addr #0 comdat align 2 {
%2 = alloca %class.CClass*, align 8
store %class.CClass* %0, %class.CClass** %2, align 8
%3 = load %class.CClass*, %class.CClass** %2, align 8
%4 = getelementptr inbounds %class.CClass, %class.CClass* %3, i32 0, i32 0
call void @_ZN6BClassC2Ev(%struct.BClass* %4)
%5 = getelementptr inbounds %class.CClass, %class.CClass* %3, i32 0, i32 1
call void @_ZN6AClassC2Ev(%struct.AClass* %5)
ret void
}
// BClass的构造函数
; Function Attrs: uwtable
define linkonce_odr void @_ZN6BClassC2Ev(%struct.BClass*) unnamed_addr #0 comdat align 2 {
%2 = alloca %struct.BClass*, align 8
store %struct.BClass* %0, %struct.BClass** %2, align 8
%3 = load %struct.BClass*, %struct.BClass** %2, align 8
%4 = call dereferenceable(272) %"class.std::basic_ostream"* @_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc(%"class.std::basic_ostream"* dereferenceable(272) @_ZSt4cout, i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i32 0, i32 0))
ret void
}
// AClass的构造函数
; Function Attrs: uwtable
define linkonce_odr void @_ZN6AClassC2Ev(%struct.AClass*) unnamed_addr #0 comdat align 2 {
%2 = alloca %struct.AClass*, align 8
store %struct.AClass* %0, %struct.AClass** %2, align 8
%3 = load %struct.AClass*, %struct.AClass** %2, align 8
%4 = call dereferenceable(272) %"class.std::basic_ostream"* @_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc(%"class.std::basic_ostream"* dereferenceable(272) @_ZSt4cout, i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str.1, i32 0, i32 0))
ret void
}

可以看到,虽然我们在CClass 的构造函数中写到(期望于先构造aobj再构造bobj):

1
CClass() : aobj(), bobj() {}

但是实际上编译器在构造CClass时的顺序为(先调用了BClass的构造函数,然后再调用了AClass的构造函数):

1
2
3
4
%4 = getelementptr inbounds %class.CClass, %class.CClass* %3, i32 0, i32 0
call void @_ZN6BClassC2Ev(%struct.BClass* %4)
%5 = getelementptr inbounds %class.CClass, %class.CClass* %3, i32 0, i32 1
call void @_ZN6AClassC2Ev(%struct.AClass* %5)

即,类在构造的时候不是按照类的初始化列表的顺序构造的,而是按照类中定义的次序构造的。

[ISO/IEC 14882:2014]non-static data members are initialized in the order they were declared in the class definition(again regardless of the order of the mem-initializers).

先简单写到这里,通过这样的方式来分析语义组合语言的标准文档,可以作为当代码出现底层的逻辑错误时分析的有力工具。

如果想要从IR代码编译到汇编代码可以使用llc:

1
$ llc test.ll -o test.s
全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:通过IR代码来分析C++代码语义
文章作者:查利鹏
发布时间:2017年03月08日 11时46分
本文字数:本文一共有2k字
原始链接:https://imzlp.com/posts/20479/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!