程序运行
基本知识
内存
以下假设内存空间类似一个梯子,从上到下,地址值从小到大。
程序运行时内存主要分3种区域:
- 静态内存,存储全局变量,静态变量 即BSS和Data段
- 堆,malloc动态分配的内存,使用free释放
- 栈,函数调用过程中动态分配的内存段。每个函数有自己的栈帧,包括函数的局部变量和返回值信息。可以通过alloca函数扩展当前栈帧。
这三个区域在系统中的大小是预设好的,需要根据应用的情况进行分配各个区域的大小。如果一个区域分配的不合理,可能出现堆空间耗尽或栈溢出(stackoverflow)
ARM 汇编学习
https://azeria-labs.com/writing-arm-assembly-part-1/
问题
作者发现getaddrinfo()
在他的树莓派系统初始化过程中占用了大量的栈空间,所以写了一个测试程序
1 |
|
编译运行
gcc test-getaddrinfo.c -o test-getaddrinfo -g
分析过程
查看Linux给程序分配的栈开始和结束位置
/proc/<pid>/maps
文件中列出了内存的所有分段,/proc/
文件系统可以看作是查看内核数据的一个UI界面。
也可以在一个运行的gdb会话中执行info proc map
对于Nucleo的实时系统,这个地址区间可能在他的链接控制脚本(.ld)文件中
Linux中的栈使用从高地址向低地址方向,即从End到Start的方向使用。
一个栈帧包含了函数运行需要的所有信息,例如暂时保存寄存器中的值,局部变量,函数参数。ARM EABI (Embedded Application Binary Interfac)规定函数的第一个参数通过寄存器传递。
栈区域在进程创建时全部初始化为0.所以可以从栈的开始地址找第一个值为非0的地址,就可以找到当前程序执行的栈的最大深度(从栈底到栈顶的长度)
SP (Stack Pointer)当前栈顶指针,gdb中对应变量$sp
FP (Frame Pointer)当前栈帧地址,gdb中对应变量$r11
函数调用时,通过对SP的值进行减法操作(从高地址向低地址使用),例如当前函数执行需要20字节空间,就对sp=sp-20
,让sp指向当前栈空间的顶部。这个操作只是移动了sp指向的位置,对其中的内存并没有执行初始化,所以如果对函数的局部变量不进行初始化就使用,局部变量的值可能就是原来这个内存区域的值,很有可能造成bug。
gdb调试程序
-q
选项去掉gdb的启动信息 gdb -q ./test-getaddrinfo
使用(gdb) list
命令查看当前的源代码
1 | 1 |
在main函数打断点 (gdb) b main
在main返回之前的17行打断点(gdb) b 17
开始运行程序(gdb) r
在程序在main中断点停止后,查看栈地址信息(gdb) info proc map
1 | process 10163 |
可以看到栈的结束位置在0x7f000000
,大小为0x21000
,可以算出来栈的开始位置为0x7EFDF000
注意:这里的栈大小不是Linux系统默认的8M,是132K,这是系统默认给当前进程分配的大小,当进程中的使用的栈空间更多时,系统会扩大这个区域的大小。例如在一个函数中使用了2M的局部变量,系统会把stack区域范围调大,即把低地址0x7efdf000再像低地址区域扩大,例如编程0x7bf00000
查看当前栈执行最大深度
1 | (gdb) scan_stack 0 $stack_size |
可以出当前使用栈的最大深度是8.9K,而栈顶的历史最大值比当前SP的值还小了4660字节。这是因为系统在执行我们的程序的main函数之前进行的库和数据段的初始化,例如把二进制程序中的.data
段数据拷贝到静态内存区域,初始化全局变量和静态变量。
查看当前栈顶的深度
1 | (gdb) stack_offset $sp |
查看当前程序的汇编
1 | (gdb) disassemble |
如果我们有当前程序的源代码,可以匹配使用(gdb) disassemble /s
匹配到源代码
每一个函数的汇编由序言,正文和结尾组成,序言用来保存返回上一个函数的地址以及分配当前函数的栈帧空间,正文是函数内容的实现,结尾返回值并跳转回上一级地址。
- ARM汇编函数的序言
1 | 0x00010474 <+0>: push {r11, lr} |
- 把当前FP和LR(Link Register)这两个寄存器的值依次压入栈中,LR中是上一级函数中调用当前函数后的下一个指令地址
- 把SP的值+4,然后把结果存入FP中,此时FP指向的是当前栈帧的开始
- 让sp-40,给当前栈帧分配空间
- ARM汇编函数的结束
1 | 0x000104c8 <+84>: mov r0, r3 |
- 把返回值存入r0
- 让sp指向FP-4的位置
- 依次把当前栈中的值弹出到pc和FP中,把进入函数时的LR填入PC,从而让处理器执行下一行指令
- 函数调用
1 | 0x000104bc <+72>: bl 0x10334 <getaddrinfo@plt> |
bl
是branch-and-link指令,跳转到新的函数地址,并把当前PC的值存入LR寄存器作为返回地址。
plt Procedure Linkage Table,库加载的函数,参见
https://www.technovelty.org/linux/plt-and-got-the-key-to-code-sharing-and-dynamic-libraries.html
继续执行程序到main函数返回前的17行后,在查看当前栈的最大深度
1 | (gdb) scan_stack 0 $stack_size |
此时的最大深度变为了15.7KB,说明执行过程某一个函数栈顶指向到了0x7effc130
的位置
重启程序,并在执行到在main函数的断点后,增加一个数据断点,当指定的地址值发生变化时,触发断点
(gdb) watch *(int*)0x7effc130
继续执行(gdb) c
后,程序断点在
1 | Hardware watchpoint 3: *(int*)0x7effc130 |
说明执行到这个check_match
函数时,栈深度增加到了最大值。此时需要分析包括这个函数在内的所有函数的栈帧空间大小。
1 | (gdb) set height 0 |
由于这个stack_walk
函数每次输出的是上一个函数的栈帧大小,所以frame 16的size of last 1224说明了frame15的大小为1224字节。切换到frame 15,查看这个函数具体做了什么
1 | (gdb) f 15 |
可以看到这个函数在开始时分配了1184字节的栈空间sub sp, sp, #1184
从 https://code.woboq.org/userspace/glibc/sysdeps/posix/getaddrinfo.c.html 找到源代码
感觉frame14知道这个函数接下来调用的是gaih_inet
,而这个函数在2265行,说明代码已经有了一些差异了,不过不影响。
1 | 2263 struct scratch_buffer tmpbuf; |
在这个函数之前有个结构体buffer,从名字上看就是要占用很大空间。转到这个结构体的定义
1 | struct scratch_buffer { |
还真有1024字节的数组buffer。
Frame 14的输出记录了Frame 13占用了2168的栈空间
1 | (gdb) f 13 |
但是看函数栈初始化只是增加了76字节,没有2000多啊 ,通过查看_nss_dns_gethostbyname4_r
的函数实现,其中有一句
1 | host_buffer.buf = orig_host_buffer = (querybuf *) alloca (2048); |
根据Linux手册描述alloca
函数分配栈上的空间 https://linux.die.net/man/3/alloca
The alloca() function allocates size bytes of space in the stack frame of the caller. This temporary space is automatically freed when the function that called alloca() returns to its caller.
剩下的几个函数中都使用了char tname[MAXDNAME+1]
这样的buffer来存储最大域名,但是每一个函数都有一份这个buffer,导致累加起来中共就有11K了。
所以,对于嵌入式的平台,一般有特定的库,而不是通用的Linux库,不然栈都不够用的。
GDB工具脚本
作者写了几个函数用来查看函数的栈帧大小,以及栈空间的深度,即运行过程中栈顶的最大值
https://sourceware.org/gdb/onlinedocs/gdb/Define.html#index-user_002ddefined-command
1 | # Functions for examining and manipulating the stack in gdb. |