前言
某日在群里看到这张很怪的表情包,想着这不是扯淡么,一看到运行结果顿时傻眼了。。。
尝试复现
1
2
3
4
5
6
7
|
int
main(){
while
(
1
) ;
}
void unreachable(){
printf(
"HelloWorld\n"
);
}
|
先放一个在线编译C代码的网站:https://godbolt.org/
编译选项 |
结果 |
gcc全版本 -O0/O1/O2 |
死循环 |
g++全版本 -O0/O1/O2 |
死循环 |
clang(C语言) 全版本 -O0/O1/O2 |
死循环 |
clang++(C++) >= 13.0.0 -O0 |
死循环 |
clang++(C++) >= 13.0.0 -O1/O2 |
打印并正常退出 |
初步结果:只有在比较新的C++上才会触发,稳定复现,因此选定 clang++ test.cpp -O1
作为目标进行研究。
分析问题引入位置
objdump/gdb 误入歧途
通过 objdump -d a.out
可以看到,_start
函数直接调用了 unreachable
函数。(一口盐汽水就喷了在屏幕上。。。)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
Disassembly of section .text:
0000000000001050
<_start>:
1050
: f3
0f
1e
fa endbr64
1054
:
31
ed xor
%
ebp,
%
ebp
1056
:
49
89
d1 mov
%
rdx,
%
r9
1059
:
5e
pop
%
rsi
105a
:
48
89
e2 mov
%
rsp,
%
rdx
105d
:
48
83
e4 f0
and
$
0xfffffffffffffff0
,
%
rsp
1061
:
50
push
%
rax
1062
:
54
push
%
rsp
1063
:
45
31
c0 xor
%
r8d,
%
r8d
1066
:
31
c9 xor
%
ecx,
%
ecx
1068
:
48
8d
3d
d1
00
00
00
lea
0xd1
(
%
rip),
%
rdi
106f
: ff
15
6b
2f
00
00
call
*
0x2f6b
(
%
rip)
1075
: f4 hlt
1076
:
66
2e
0f
1f
84
00
00
cs nopw
0x0
(
%
rax,
%
rax,
1
)
|
gdb 调试,很诡异,也直接就断在了 unreachable 里,说明二进制确实执行到了这里。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
gdb a.out
(gdb) b main
Breakpoint
1
at
0x1140
(gdb) r
Starting program:
/
tmp
/
a.out
[Thread debugging using libthread_db enabled]
Using host libthread_db library
"/lib/x86_64-linux-gnu/libthread_db.so.1"
.
Breakpoint
1
,
0x0000555555555140
in
unreachable() ()
(gdb) disassemble $pc
Dump of assembler code
for
function _Z11unreachablev:
=
>
0x0000555555555140
<
+
0
>: push
%
rax
0x0000555555555141
<
+
1
>: lea
0xebc
(
%
rip),
%
rdi
0x0000555555555148
<
+
8
>: call
0x555555555030
<puts@plt>
0x000055555555514d
<
+
13
>: pop
%
rax
0x000055555555514e
<
+
14
>: ret
End of assembler dump.
(gdb)
|
什么?还有程序没有main、从别的函数开始执行?这连接器是抽什么风?
readelf 初见端倪
说实话,我还是第一次见到 “没有main函数、但是能运行” 的程序,于是查看符号表,发现main函数还在,和unreachable指向同一个偏移,但函数大小为0
1
2
3
4
5
6
7
8
9
|
readelf
-
s a.out
Symbol table
'.symtab'
contains
37
entries:
Num: Value Size
Type
Bind Vis Ndx Name
22
:
0000000000001140
0
FUNC GLOBAL DEFAULT
15
main
23
:
0000000000001140
15
FUNC GLOBAL DEFAULT
15
_Z11unreachablev
objdump
-
t a.out
0000000000001140
g F .text
0000000000000000
main
0000000000001140
g F .text
000000000000000f
_Z11unreachablev
|
也就是说,main函数的函数体被清空了,恰好指向了unreachable的位置,“越界”执行了下一个函数的代码。
查看汇编
clang++ -c -S test.cpp -O1
得到 test.s
汇编文件。 我去掉了汇编大部分不必要的注释,但故意保留了一部分,才能让读者知道,他读的是汇编 。可以看到,main函数里一条指令都没有,全部都是点开头的或者井号开头的注释。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
.globl main
.p2align
4
,
0x90
.
type
main,@function
main:
.cfi_startproc
.Lfunc_end0:
.size main, .Lfunc_end0
-
main
.cfi_endproc
.globl _Z11unreachablev
.p2align
4
,
0x90
.
type
_Z11unreachablev,@function
_Z11unreachablev:
.cfi_startproc
pushq
%
rax
leaq .Lstr(
%
rip),
%
rdi
callq puts@PLT
popq
%
rax
retq
|
去除全部注释后就是,main 和 _Z11unreachablev 指向同一段汇编代码:
1
2
3
4
5
6
7
|
main:
_Z11unreachablev:
pushq
%
rax
leaq .Lstr(
%
rip),
%
rdi
callq puts@PLT
popq
%
rax
retq
|
查看IR
clang++ -emit-llvm -S -c test.cpp -O1
得到 test.ll
IR文件。可以看出,确实在IR阶段main的内容就发生了变换,由原先的死循环被替换成了 unreachable。注意,这个unreachable是一条名叫UnreachableInstruction的指令,并非函数名
1
2
3
4
|
; Function Attrs: mustprogress nofree norecurse noreturn nosync nounwind readnone uwtable willreturn
define dso_local noundef i32 @main() local_unnamed_addr
unreachable
}
|
对比 -O0
的输出结果。
1
2
3
4
5
6
7
8
9
|
; Function Attrs: mustprogress noinline norecurse nounwind optnone uwtable
define dso_local noundef i32 @main()
%
1
=
alloca i32, align
4
store i32
0
, i32
*
%
1
, align
4
br label
%
2
2
: ; preds
=
%
0
,
%
2
br label
%
2
, !llvm.loop !
6
}
|
最终,可以得到结论,这个反常的现象是在IR层面的编译器优化引起的。
寻找优化的Pass
背景知识
2022年我也遇到过编译器优化引发的逻辑错误,被 dmxcsnsbh 一眼看穿,并推荐我文章:https://www.blackhat.com/eu-20/briefings/schedule/#finding-bugs-compiler-knows-but-doesnt-tell-you-dissecting-undefined-behavior-optimizations-in-llvm-21128 。于是我第一反应就是 LLVM 进行了优化,但这个是不是 UB 引起的,一眼没看出来。
dump llvm ir transform
通过命令:clang++ -mllvm --print-before-all -mllvm --print-after-all test.cpp -O1
拿到每个pass的所有输入和输出。
经过搜索,找到如下日志,表明经过 LoopDeletionPass
的处理,原先的循环被替换成了 unreachable instruction
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
*
*
*
IR Dump Before LoopDeletionPass on Parallel Loop at depth
1
containing:
%
1
<header><latch>
*
*
*
; Preheader:
br label
%
1
; Loop:
1
: ; preds
=
%
0
,
%
1
br label
%
1
, !llvm.loop !
5
*
*
*
IR Dump After LoopDeletionPass on Parallel Loop at depth
1
containing:
%
1
<header><latch> (invalidated)
*
*
*
; ModuleID
=
'test.cpp'
source_filename
=
"test.cpp"
target datalayout
=
"e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple
=
"x86_64-pc-linux-gnu"
@.
str
=
private unnamed_addr constant [
12
x i8] c
"HelloWorld\0A\00"
, align
1
@str
=
private unnamed_addr constant [
11
x i8] c
"HelloWorld\00"
, align
1
; Function Attrs: mustprogress nofree norecurse noreturn nosync nounwind readnone uwtable willreturn
define dso_local noundef i32 @main() local_unnamed_addr
unreachable
}
|
粗略阅读代码
代码位于:https://llvm.org/doxygen/LoopDeletion_8cpp_source.html
1
2
3
4
|
/
/
This
file
implements the Dead Loop Deletion Pass. This
pass
is
responsible
/
/
for
eliminating loops with non
-
infinite computable trip counts that have no
/
/
side effects
or
volatile instructions,
and
do
not
contribute to the
/
/
computation of the function's
return
value.
|
大意就是,删除对执行无影响的死循环。个人观点,已经是死循环了,所以标记为 unreachable 也是说得过去的。
LoopDeletionPass
继承了 LoopPass
,实现 runOnLoop
,调用 deleteLoopIfDead
,内部使用 isLoopDead
判断是否需要被移除,移除时调用来自 LoopUtils.h
的 deleteDeadLoop
来执行。
而 deleteDeadLoop 中,出现了关键的 CreateUnreachable
:
1
2
3
4
5
6
7
8
|
}
else
{
assert
(L
-
>hasNoExitBlocks() &&
"Loop should have either zero or one exit blocks."
);
Builder.SetInsertPoint(OldTerm);
Builder.CreateUnreachable();
Preheader
-
>getTerminator()
-
>eraseFromParent();
}
|
答案:所以答案很简单。。。
- 因为删除了死循环,导致main函数里只有一句 unreachable instruction
- 在codegen阶段,后端认为 main 里只有一句 unreachable,不需要创建对应的汇编指令,只有一个符号,函数长度为0
- 恰好在该位置,生成了与main函数相邻的函数,而main的长度为0,所以main实际指向了相邻的函数(没有人规定两个符号不能指向同一个偏移)
- 后续连接过程不会遇到任何问题,造成诡异的情况发生了
到底是不是Undefined Behavior?
C++里是UB。https://en.cppreference.com/w/cpp/language/ub ,Infinite loop without side-effects 。
C语言里不是UB,见 ISO/IEC 9899 J.2 Undefined behavior
后,并没有找到和loop相关的关键词。
包括官方还给了一个demo,优化后fermat直接返回true(不解释了,不会,自行领悟)
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
|
bool
fermat()
{
const
int
max_value
=
1000
;
/
/
Endless loop with no side effects
is
UB
for
(
int
a
=
1
, b
=
1
, c
=
1
; true; )
{
if
(((a
*
a
*
a)
=
=
((b
*
b
*
b)
+
(c
*
c
*
c))))
return
true;
/
/
disproved :)
a
+
+
;
if
(a > max_value)
{
a
=
1
;
b
+
+
;
}
if
(b > max_value)
{
b
=
1
;
c
+
+
;
}
if
(c > max_value)
c
=
1
;
}
return
false;
/
/
not
disproved
}
int
main()
{
std::cout <<
"Fermat's Last Theorem "
;
fermat()
? std::cout <<
"has been disproved!\n"
: std::cout <<
"has not been disproved.\n"
;
}
|
番外篇:为什么只有cpp会有该优化、而c语言没有该优化(超出能力范围了。。。)
获得两个未经处理的 ll 文件:clang test.c -O0 -emit-llvm -c -S -o test.c.ll
和 clang++ test.cpp -O0 -emit-llvm -c -S -o test.cpp.ll
。
将 optnone
移除掉:sed -i 's/optnone//' test.c.ll
和 sed -i 's/optnone//' test.cpp.ll
。
使用 opt 将 LoopDeletionPass 作用于 C 的 IR 和 CPP 的 IR。(因为需要编译llvm,因为要看LLVM_DEBUG的日志,需要开启 DLLVM_ENABLE_ASSERTIONS=TRUE)
二者执行起来日志分别为:
opt作用于C语言
1
2
3
4
|
opt
-
debug
-
only
=
loop
-
delete
-
passes
=
loop
-
deletion test.c.ll
-
S
-
o
/
dev
/
null
Analyzing Loop
for
deletion: Loop at depth
1
containing:
%
2
<header><latch>
Could
not
compute SCEV MaxBackedgeTakenCount
and
was
not
required to make progress.
Loop
is
not
invariant, cannot delete.
|
opt作用于C++语言
1
2
3
|
opt
-
debug
-
only
=
loop
-
delete
-
passes
=
loop
-
deletion test.cpp.ll
-
S
-
o
/
dev
/
null
Analyzing Loop
for
deletion: Parallel Loop at depth
1
containing:
%
2
<header><latch>
Loop
is
invariant, delete it!
|
显然,二者出现了偏差,略微diff一下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
-
; Function Attrs: noinline nounwind uwtable
-
define dso_local i32 @main()
+
; Function Attrs: mustprogress noinline norecurse nounwind uwtable
+
define dso_local noundef i32 @main()
%
1
=
alloca i32, align
4
store i32
0
, i32
*
%
1
, align
4
br label
%
2
2
: ; preds
=
%
0
,
%
2
-
br label
%
2
+
br label
%
2
, !llvm.loop !
6
}
+
!
6
=
distinct !{!
6
, !
7
}
+
!
7
=
!{!
"llvm.loop.mustprogress"
}
|
不清楚哪里引起的,把这个6和7补上去,C语言就可以正确地被处理为unreachable了,研究了很久,超出知识范围了,看不懂,反正就 LoopPass 认为 MaxBackedgeTakenCount
的数据不对,不给删。
结合上文说的C里不算UB、C++里算UB,编译器这么做也是完全正确的。
总结:绝知此事要躬行
有时我自己也写点死循环,UB竟在我身边,UB竟是我自己。