很显然这就是gcc的bug。而且高版本也修复了。恭喜题主发现了bug。
那些拿ub说事的我也只能说先去看标准再发言。
至于C语言为什么会被设计得允许不写return。因为最初设计的时候C语言本就允许使用其他方式填返回值。比如早年间只要往AX寄存器写一个值然后函数退出,那个值就是返回值了,而且这个值是可以直接用嵌入式汇编去写的,函数没有return语句。
对最初的C语言来说,甚至你是否声明函数返回值都没关系,不声明的话默认为int,调用方用了返回值就会去检查返回值,调用方没用返回值就不检查。
而很多特性需要保持兼容性,也就是说「只要调用方不使用返回值你就可以不写return」这种C特性是需要保留的。也就意味着这不是ub,而是gcc优化器的bug。
建议大家在说一段代码是UB之前,不说查看一下标准原文,最少也Google一下。
我看了一下C11标准,这段代码应该不是UB,所以我倾向于这是GCC的一个bug。并且我用最新的GCC11试了上面的例子,也无法复现,说明这个bug很大可能已经被修复了。
下面是标准原文[1]Section 6.9.1, P174-12:
If the }
that terminates a function is reached, and the value of the function call is used by the caller, the behavior is undefined.
注意我标粗的那一句,用的是and
。意味着只有在返回值被使用的时候才是UB,显然题主不是这个情况。
更新:
谢谢大家指正:这段代码如果看成是c++的话,确实是UB,与C有别。
从一个返回值类型不为 void 的函数返回,但是却没有指定返回值,是 undefined behavior。
C/C++ 编译器可以利用 undefined behavior 执行非常激进的优化。例如,有一个优化策略是,编译器可以假定程序中任意一条路径中没有任何的 undefined behavior。在本例中,参考这个策略,编译器会认为 test 函数中的 for 循环永不退出(因为只要退出,就会产生 UB)。因此,编译器在高优化级别下可能将 test 中的循环直接优化为一个死循环。
更新:我来尝试详细展开说明一下编译器执行本例中的优化的过程。我的编译环境是 Ubuntu 20.04,编译器是 clang++ 10.0.0。
首先看一下优化之前 clang 生成的 IR:
; Function Attrs: noinline optnone uwtable define dso_local i32 @_Z4testv() #0 { %1 = alloca i32, align 4 store i32 0, i32* %1, align 4 br label %2 2: ; preds = %8, %0 %3 = load i32, i32* %1, align 4 %4 = icmp slt i32 %3, 1010 br i1 %4, label %5, label %11 5: ; preds = %2 %6 = load i32, i32* %1, align 4 %7 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 %6, i32 1010) br label %8 8: ; preds = %5 %9 = load i32, i32* %1, align 4 %10 = add nsw i32 %9, 1 store i32 %10, i32* %1, align 4 br label %2 11: ; preds = %2 call void @llvm.trap() unreachable }
基本块 %2
是检查 loop condition,基本块 %5
是 loop body,基本块 %8
是更新 loop variable。有趣的是基本块 %11
,这是循环结束后第一个执行的基本块,其中除去一条 intrinsic call 外只有一条 unreachable
指令。这条指令告诉 LLVM 优化器,这里的代码在实际运行时不可达,优化器可以借此搞点事情。clang 生成这样的代码正是基于之前介绍的假设,即程序的所有路径均不包含 UB;换言之,如果代码里面包含了 UB,那么这一坨问题代码一定不可达。
接下来优化器开始搞事。这里我们用的是 clang 提供的 -O1
优化级别中的 optimization pass,由于 pass 较多,我只挑重点的介绍。只需要两步就可以优化出死循环(其实只需要一步就可以,但我顺着 clang 的优化顺序来)。
首先,--sroa
尝试将 non-escape 的 allocation 提升为 SSA value。做完这一步的代码会瞬间清爽许多,因为 --sroa
几乎将所有的 allocation 全部消除了:
; Function Attrs: uwtable define dso_local i32 @_Z4testv() #0 { br label %1 1: ; preds = %5, %0 %2 = phi i32 [ 0, %0 ], [ %7, %5 ] %3 = icmp slt i32 %2, 1010 br i1 %3, label %5, label %4 4: ; preds = %1 unreachable 5: ; preds = %1 %6 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 %2, i32 1010) %7 = add nsw i32 %2, 1 br label %1 }
然后,--simplifycfg
将尝试做基本块合并。这是最核心的一个优化过程。首先,--simplifycfg
看到基本块 %4
是 unreachable,而在基本块 %1
中若 %3
为 false 则会跳入基本块 %4
,因此 --simplifycfg
消除基本块 %4
并向 %1
中加入一个 intrinsic call:
; Function Attrs: uwtable define dso_local i32 @_Z4testv() #0 { br label %1 1: ; preds = %5, %0 %2 = phi i32 [ 0, %0 ], [ %7, %5 ] %3 = icmp slt i32 %2, 1010 call void @llvm.assume(i1 %3) br label %5 5: ; preds = %1 %6 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 %2, i32 1010) %7 = add nsw i32 %2, 1 br label %1 }
新加入的 intrinsic call(@llvm.assume(i1 %3)
)即提示优化器 %3
的值应该为 true,优化器可能还可以利用这一点信息进行更进一步的优化。但在本例中这个 intrinsic call 没有发挥作用。
然后,--simplifycfg
进一步执行基本块合并,将基本块 %5
内联进基本块 %1
:
; Function Attrs: uwtable define dso_local i32 @_Z4testv() local_unnamed_addr #0 { br label %1 1: ; preds = %1, %0 %2 = phi i32 [ 0, %0 ], [ %5, %1 ] %3 = icmp ult i32 %2, 1010 call void @llvm.assume(i1 %3) %4 = call i32 (i8*, ...) @printf(i8* nonnull dereferenceable(1) getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i64 0, i64 0), i32 %2, i32 1010) %5 = add nuw nsw i32 %2, 1 br label %1 }
至此优化完成。可以看到,基本块 %1
自身构成一个死循环。
再次更新:
编译器实际上可以利用 undefined behavior 做非常多的优化。CppCon 2016 上面有一个很好的 talk 详细介绍了这个点: