1 问题现场
事情是这样的,最近我们在考虑招收一批新鲜血液,首次尝试大规模招收应届生和大三实习生。也是受领导所托,让我出几道笔试题,刚好我最近在项目中遇到一个栈溢出的问题,于是简单写了这么一段函数:
attribute ((noinline)) int test_handler(void)
{
uint8_t msg[16] = {0};
memcpy(msg, "hello6666666666666666666666666666", 100);
return msg[0];
}
笔试的题目是:请问这段代码有没有问题?如果有,请指出其问题所在,并尽可能地阐述其风险所在。
2 简单分析
朋友,要是你看到这样一道笔试题,你可能会第一时间大笑起来,这不就是一道典型的 函数栈溢出 的代码场景题吗?教科书都偶讲过啊!
但是,真的仅仅是这样吗?
我们先来简单分析一下这个函数代码:
1)函数先定义了一个16字节长度的msg数组;
2)仅接着使用memcpy对msg数组进行操作赋值;从字符串 “hello6666666666666666666666666666” 中拷贝100个字节到msg数组中;
3)由于msg数组仅有16字节的长度,且这部分内存是位于栈中,而第2)中拷贝的长度却是100字节,大于了16个字节,这样msg数组就不够空间来存储了,因此触发了很典型的 栈溢出 问题。
以上是很常规的教科书式的分析,如果我在笔试场上遇到这道题,我肯定也是这么描述。
但是这些年,随着我对编译优化有了更多的认知后,我开始有不同的看法。
3 深入分析
本文先不考虑其他编译器的情况,仅以ARM-GCC编译器最为讨论,准确地说,编译器版本是:
arm-none-eabi-gcc -v
gcc version 9.2.1 20191025 (release) [ARM/arm-9-branch revision 277599] (GNU Tools for Arm Embedded Processors 9-2019-q4-major)
根据问题代码,下面按两种情况深入分析:
3.1 假如不考虑编译优化的情况
在GCC编译器中,如果想让代码编译不执行任何优化,可以考虑将CFLAGS中加入 -O0 这个优化级别。
同时,上一小节中,我们仅仅是从C代码(高级语言代码)的角度分析,得出栈溢出的结论,而我们如果想知道这段代码真正在经过编译器编译后,得到什么样的执行代码,还得看最后生成的汇编代码才行。
于是,我在配置优化级别为 O0 后,得到以下汇编代码,它对应的就是test_handler函数。
.LC48:
.ascii "hello6666666666666666666666666666\000"
.section .text.test_handler,"ax",%progbits
.align 1
.global test_handler
.syntax unified
.code 16
.thumb_func
.fpu softvfp
.type test_handler, %function
test_handler:
.LFB36:
.loc 1 299 1
.cfi_startproc
@ args = 0, pretend = 0, frame = 16
@ frame_needed = 1, uses_anonymous_args = 0
push {r7, lr}
.cfi_def_cfa_offset 8
.cfi_offset 7, -8
.cfi_offset 14, -4
sub sp, sp, #16 //定义msg数组,长度为16个字节
.cfi_def_cfa_offset 24
add r7, sp, #0
.cfi_def_cfa_register 7
.loc 1 300 13
movs r3, r7
movs r2, #0
str r2, [r3]
adds r3, r3, #4
movs r2, #12
movs r1, #0
movs r0, r3
bl memset //调用memset函数对msg数组进行清0操作,因为我定义msg数组时,加了 {0} 做初始化
.loc 1 302 5
ldr r1, .L77
movs r3, r7
movs r2, #100
movs r0, r3
bl memcpy //调用memcpy函数对msg数组进行拷贝赋值
.loc 1 304 15
movs r3, r7
ldrb r3, [r3]
.loc 1 305 1
movs r0, r3 //把msg[0]放到r0寄存器,准备退出函数
mov sp, r7
add sp, sp, #16
@ sp needed
pop {r7, pc} //函数退出
.L78:
.align 2
.L77:
.word .LC48
.cfi_endproc
简单分析一下这段汇编代码:
1)一开始定义了一个字符串,内容是”hello6666666666666666666666666666\000”,它的标签号是 .LC48
2)接下来就是test_handler的定义,它的起始地址标签是 .LFB36
3)其他的汇编代码,见代码中的注释
从上面的分析可以看出,基本上就是跟我们分析C代码是一致的;也就是说,这种情况下,这个函数发生栈溢出是一定的。
感兴趣的朋友也可以把这段代码集成编译到你的代码工程中,看看会发生什么事。
3.2 如果编译器执行了编译优化
以上分析只是一种常规的场景,在实际的嵌入式编译中,往往我们采用的是 Os 级别的优化,在这个级别的优化下,启用了很多编译优化选项,并尽可能地为 缩小镜像的size 而服务,这也是 Os 名称的含义来源。
我们把优化级别配置成 Os 后,我们来看下得到的汇编代码是怎么样的:
.section .text.test_handler,"ax",
.align 1
.global test_handler
.syntax unified
.code 16
.thumb_func
.fpu softvfp
.type test_handler,
test_handler:
.LFB36:
.loc 1 299 1 is_stmt 1 view -0
.cfi_startproc
@ args = 0, pretend = 0, frame = 0
@ frame_needed = 0, uses_anonymous_args = 0
@ link register save eliminated.
.loc 1 300 5 view .LVU194
.loc 1 302 5 view .LVU195
.loc 1 304 5 view .LVU196
.loc 1 305 1 is_stmt 0 view .LVU197
movs r0, #104 //整数104赋值给r0寄存器,准备函数退出
@ sp needed
bx lr //函数退出
.cfi_endproc
简单一看,哗,这个汇编代码精简了许多,至少从代码行数上就下降了不少。
细细一看,多了些门道。
1)首先,”hello6666666666666666666666666666\000” 字符串没有再被定义了;
2)其次,msg数组也没有被定义了;
3)这段汇编代码,有效的代码其实就只有2句:movs r0, #104 和 bx lr
啥意思?就这么简单?
原来的在编译优化级别Os的帮助下,这个函数直接就知道了最后返回的ret,其实就是字符串”hello6666666666666666666666666666\000”的第一个字符,即’h’,而这个’h’字符的ASCII值,正好就是 104(10进制数)。
所以这个函数简单地不能再简单,就变成了类似这样的伪代码:
int test_handler(void)
{
return 104;
}
所以,你跟我说,这样的函数代码会发生 栈溢出 吗?这显然是不会的啊!
另外一点,也值得注意的是,细心的朋友可能看到我在 testhandler函数前加了一个 _attribute ((noinline));这个是GCC的扩展功能,目的就是告诉编译器,这个函数需要帮忙保留,不要变成 内联函数。
倘若,我把这个修饰去掉后,会是怎么样呢?显然会把这个函数编译成 内联函数,具体的汇编代码是怎么样的,感兴趣的朋友可以自己去实践一下。
4 经验总结
这个小小的代码片段,告诉我们的道理就是:
别总是盯着眼前的 ”苟且“(代码),必要的时候,还需要看得更深,看得更远,别被眼前的一切所蒙蔽了;
纸上得来终觉浅,绝知此事要躬行;教科书上面的讲解并没有错,但是放到实际的应用场景下,不见得一定会100%争取;
努力提升自己 汇编、反汇编 的技术能力,关键时候也许你助你一臂之力。
原作者:recan