函数运行时栈发生了什么

由于网络安全课的缓冲区溢出实验,简单学习了程序执行过程对应的栈的变化过程。接下来叙述一个函数在被调用执行时,栈都为这个函数做了什么。

以以下程序为例,我们主要关注read_req()这个函数。

# include <stdio.h>

int read_req(void) {
    char buf[128];
    int i;
    gets(buf);
    i = atoi(buf);
    return i;
}

int main(int ac, char **av) {
  int x = read_req();
  printf("x=%d\n", x);
}


将上述文件保存成read_req.c,执行以下命令生成可执行文件:readreq

gcc -m32 -static -g -Wno-deprecated-declarations -fno-stack-protector readreq.c -o readreq

其中

  • -m32 编译为32位程序
  • -static 静态链接
  • -g 加入调试信息
  • -Wno-deprecated-declarations 不对deprecated(废弃)内容发出警告,gets()已经被废弃
  • -fno-stack-protector 不注入用于防止缓冲区溢出的代码

紧接着进行gdb调试,在调试前首先说明几点:

1.栈从高地址向低地址增长。

2.x86汇编有两种语法,intel语法和AT&T语法,对于AT&T的mov a,b是将a的值放到b中,而intel语法刚好相反。

3.栈在对函数的参数填充时,从右向左填充,如test(int i,int j),可以理解为j先入栈,位于相对于i的较高地址。

4.linux中对数据的字节序按little-endian填充,比如int型变量i=0xd4c3b2a1,在栈中的存储从高地址向地址依次为d4 4c 3b a1。


通过gdb命令进行调试

$ gdb ./readreq
(gdb) b read_req [在read_req函数设置断点]
Breakpoint 1 at 0x8048e4d: file readreq.c, line 6.
(gdb) r [运行]
Starting program: /home/httpd/lecture1/readreq

Breakpoint 1, read_req () at readreq.c:6
6	    gets(buf);
(gdb) info registers [查看寄存器信息,[十六进制,十进制/翻译,说明]]
eax            0x1	1  [累加器]
ecx            0x52987f86	1385725830  [计数器]
edx            0xbffff704	-1073744124  [数据]
ebx            0x80481a8	     134513064  [基址,ebp]
esp            0xbffff610	0xbffff610  [栈指针]
ebp            0xbffff6b8	0xbffff6b8  [基址指针]
esi            0x0	0  [源索引]
edi            0x80eb00c	135180300  [目标索引]
eip            0x8048e4d	0x8048e4d <read_req+9>  [指令指针]
eflags         0x28 [ SF IF ] [标志寄存器,符号标记,中断允许标记]
cs             0x73	115  [代码段]
ss             0x7b	123  [堆栈段]
ds             0x7b	123  [数据段]
es             0x7b	123  [附加段]
fs             0x0	0    [无明确定义,字母表中f在e之后]
gs             0x33	51   [无明确定义, g在f之后]


反汇编

(gdb) disass read_req   [反汇编,AT&T风格]
Dump of assembler code for function read_req:
   0x08048e44 <+0>:	push   %ebp   [将旧%ebp入栈*]
   0x08048e45 <+1>:	mov    %esp,%ebp [将%esp赋给%ebp,用%esp来设定新%ebp,新的(子程序read_req)ebp在子程序的局部变量和返回值之间]
   0x08048e47 <+3>:	sub    $0xa8,%esp [栈增长此子程序所需的栈帧大小0xa8字节,留出局部变量空间]
=> 0x08048e4d <+9>:	lea    -0x8c(%ebp),%eax [&buf[0],获取buf起始地址顶,向gets传递参数]
   0x08048e53 <+15>: mov    %eax,(%esp) [移入栈顶,向gets传递参数]
   0x08048e56 <+18>: call   0x804fc90 <gets> [将%eip入栈并调用]
   0x08048e5b <+23>: lea    -0x8c(%ebp),%eax [&buf[0],获取buf起始地址顶,向gets传递参数]
   0x08048e61 <+29>: mov    %eax,(%esp)   [移入栈顶,传递参数]
   0x08048e64 <+32>: call   0x804dd10 <atoi>
   0x08048e69 <+37>: mov    %eax,-0xc(%ebp) [将atoi结果写入i]
   0x08048e6c <+40>: mov    -0xc(%ebp),%eax [将i写入函数返回值]
   0x08048e6f <+43>: leave [弹出整个栈帧*]
   0x08048e70 <+44>: ret [弹出栈中%eip,并跳转执行]
End of assembler dump.


  • push指令将操作数压入栈中。在压栈前,将esp值减4(X86栈增长方向与内存地址编号增长方向相反),然后将操作数压入esp指示位置。
  • pop指令与push指令相反。先将esp指示地址中内容出栈,然后将esp值加4。
  • 栈增长(168 bytes)要超过局部变量大小之和(4+128 bytes),并按16 bytes对齐
  • lea: load effective address, 拷贝地址(而不是内容)
  • leave相当于mov %ebp,%esppop %ebp,此时esp指向返回地址
  • ret执行pop %eip,将返回地址写入eip

查看一下寄存器和栈帧中的内容,以此绘制栈帧结构图。

(gdb) p $ebp
$1 = (void *) 0xbffff6b8
(gdb) p $esp
$2 = (void *) 0xbffff610
(gdb) p &i
$3 = (int *) 0xbffff6ac
(gdb) p &buf
$4 = (char (*)[128]) 0xbffff62c
(gdb) x $ebp+4
0xbffff6bc:	0x08048e7

read_req()栈帧示例:

+———————————————————————-+
|        arguments       |
+———————————————————————-+
|     return address     |<——— +4 =0xbffff6bc %eip=0x08048e7f
+———————————————————————-+
|       main() ebp       |<——— %ebp        =0xbffff6b8
+———————————————————————-+
|                        |
+————————————————————————+
|       int  i           |<——— -0x0c (-12) =0xbffff6ac
+————————————————————————+
|buf[127]   ^            |
|           |            |
|           |      buf[0]|<——— -0x8c(-140)=0xbffff62c
+————————————————————————+
|                        |
+———————————————————————-+
|   &buf for gets()      |<——— -0xa8(-168)=0xbffff610 new %esp
+———————————————————————-+
1.这是read_req的完整栈帧,首先是arguments,父函数在调用read_req前,首先将read_req的参数填到栈中。

2.return address是父函数调用子函数read_req的call指令产生的,call指令将父函数的下一条(即执行read_req后的下一条指令)指令地址%eip填到此处(push %eip),放在这里为了read_req执行完成后,返回到调用点后继续执行父函数的下一条指令。

3.main() ebp 即是父函数mian()的基地址,用于返回到父函数时从此处读取父函数基地址,因此填到此处。此处也为read_req的栈帧的基地址位置(mov %esp,%ebp)。

4.接下来应该填充子函数的局部变量i,但是从栈可以看出,i和main()ebp之间存在大小8字节的空间,经过测试发现,当程序中有gets函数时,ebp和局部变量之间就会空出8个字节,不知道是什么作用;当没有gets函数时,ebp之后就直接是局部变量,这其中的区别暂时没搞明白。本程序有gets函数,因此ebp向下扩展8字节后,从0xbffffba0处开始为局部变量分配空间。接着栈为局部变量i和buf分配所需大小,根据i和buf的大小,可以计算buf的起始地址为0xbffff62c。

5.填充完局部变量后,如果read_req中调用了其他函数,应用填充被调用函数的参数,这里read_req调用了gets(buf),gets的参数为buf,因此将buf的地址填到栈中。但是经过调试发现,在填充完read_req的局部变量后,并没有紧接着填充gets函数的参数,而是空出了0xbffff62c-(0xbffff610+4)一共0x18个字节,然后填充gets的参数。测试中将gets函数替换为自定义函数后,空出了0x08个字节,暂时没有搞明白

read_req在调用gets函数时,栈的变化和父函数main调用read_req的过程一样。在第5条已经在栈中设置了gets的参数,然后接下来的栈的变化就和2-5步类似了。

在一层层的函数调用中,栈就是这样增长的。

总的来说,在某个函数被调用前到被调用的函数执行完成,栈的变化依次是:

1.将被调用函数的参数压栈(个人认为它其实还是调用者栈帧的最后一部分)

2.将调用者执行完函数调用后的下一条要执行的指令地址入栈

3.将调用者的栈基地址ebp入栈

4.编译器经过计算后得出被调用函数所需要的栈帧大小stack_f bytes,esp向低地址移动stack_f bytes大小。

5.将被调用者的局部变量填到栈中

6.如果被调用者又调用的其他函数,那么在局部变量之后的栈填上那个函数的参数(同1)。


编译器在计算应该为某个函数分配多大栈帧时,不仅考虑函数的局部变量、函数是否调用其他函数等,还要考虑内存对齐的问题,比如如果函数中定义局部变量

char buf[3];
那么,给buf不仅仅只分配3个字节的空间,至少要是4的倍数,可以使用gdb调试查看。

从1-6的叙述中可以看处,与函数相关的不同类别的数据依次入栈,但是个人认为,“依次入栈”这种表述不合适,其实是编译器为这个函数计算好它所需要的栈大小stack_frame(栈帧),为其分配stack_frame,然后再将各种数据根据计算精确地填到这个栈帧里的指定位置。是一种先布局,后填数据的构建方式,这点可以从反汇编之后对栈的操作可以看到。

另外,笔者并没有对此做深入的研究,文中还有未解决的问题,用红色标出,因此某些地方可能存在错误,甚至误导,欢迎批评指正!

参考资料:
栈帧 Stack Frame

缓冲区溢出:原理与实验




<think>我们面对的问题:在异常发生保存函数调用。 参考引用中提到了在C++环境中,当发生异常(如崩溃),使用Windbg等调试器可以查看函数调用堆。但有在异常发生,我们需要在程序内部自动保存调用信息,以便后续分析,特别是在生产环境中。 实现思路: 1. 捕获异常信号(如SIGSEGV, SIGABRT等)。 2. 在信号处理函数中获取当前的函数调用。 3. 将调用信息保存到文件或日志中。 具体步骤(以Linux环境为例,Windows环境类似但API不同): 步骤1:注册信号处理函数 步骤2:在信号处理函数中使用backtrace和backtrace_symbols函数获取调用 步骤3:将调用信息写入文件 注意:信号处理函数中应避免使用非异步信号安全的函数(如printf, malloc等),但为了简单起见,我们这里可能会使用一些不安全的函数(因为我们的目的是在崩溃记录信息,然后退出)。更安全的方法是使用系统调用(如write)来输出信息。 示例代码(Linux): ```c #include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> #include <execinfo.h> #define STACK_TRACE_SIZE 128 // 信号处理函数 void signal_handler(int sig) { void *array[STACK_TRACE_SIZE]; size_t size; // 获取调用 size = backtrace(array, STACK_TRACE_SIZE); // 打印调用到标准错误 fprintf(stderr, "Error: signal %d:\n", sig); backtrace_symbols_fd(array, size, STDERR_FILENO); // 也可以将调用保存到文件 FILE *fp = fopen("stack_trace.log", "a"); if (fp) { backtrace_symbols_fd(array, size, fileno(fp)); fclose(fp); } // 退出程序 exit(1); } int main() { // 注册信号处理函数 signal(SIGSEGV, signal_handler); // 段错误 signal(SIGABRT, signal_handler); // 异常终止 // 这里故意制造一个段错误 int *p = NULL; *p = 1; return 0; } ``` 在Windows环境下,可以使用`CaptureStackBackTrace`和`SymFromAddr`等函数来获取调用信息。但由于Windows API较为复杂,通常我们会使用第三方库(如dbghelp)来实现。 注意事项: 1. 信号处理函数中应避免复杂操作,尽量只做必要的记录。 2. 在记录调用,注意文件操作可能会失败(比如文件系统已满),所以最好使用最可靠的方式(如直接写入标准错误)。 3. 在Windows下,需要加载符号文件(PDB)才能将地址转换为函数名,否则只能得到地址。 更高级的做法: 1. 使用如Google Breakpad这样的跨平台崩溃报告框架。 2. 在保存调用,同保存其他上下文信息(如寄存器、内存状态等)。 引用说明: 在引用[2]中提到,了解C++函数调用分布对于理解函数调用堆回溯原理很有帮助。因此,在实现保存调用的功能,我们实际上依赖于程序运行结构,通过遍历来获取返回地址,然后通过符号表将地址解析为函数名。 另外,引用[3]中提到了通过Windbg分析dump文件来查看调用堆,这实际上是在程序崩溃后通过外部工具获取调用。而我们这里实现的是在程序崩溃自己记录调用。 引用[4]中提到了Arthas工具可以查看Java线程的调用,但这是在Java虚拟机中,与C++的实现机制不同。 因此,根据上述思路,我们可以实现在异常发生保存函数调用的功能。 相关问题: 1. 如何解析backtrace_symbols输出的地址信息? 2. 在Windows环境下如何实现崩溃自动保存调用? 3. 如何生成和使用程序的符号表(如Linux下的debug符号,Windows下的PDB文件)? 4. 如何将保存的调用信息与源代码对应起来?</think>### 如何在异常发生保存函数调用 在单片机或嵌入式系统中实现异常函数调用保存,需要结合硬件异常处理和软件回溯机制。以下是关键实现步骤: --- #### 一、核心实现原理 1. **结构认知** 函数调用内存布局(以ARM Cortex-M为例): ```plaintext | 参数n | ... | 参数1 | 返回地址 | 前指针 | 局部变量 | 保存寄存器 | └───────────────── 当前 ─────────────────┘ ``` 通过指针(FP)可回溯整个调用链[^2] 2. **异常触发机制** - 硬件异常:HardFault、MemManage、BusFault等 - 软件异常:`assert()`触发或自定义异常 --- #### 二、具体实现步骤(以ARM Cortex-M3/M4为例) 1. **注册异常处理函数** ```c // 在启动文件中重定向异常向量 void HardFault_Handler(void) __attribute__((naked)); void HardFault_Handler(void) { __asm volatile( "TST LR, #4 \n" // 检查EXC_RETURN位2 "ITE EQ \n" "MRSEQ R0, MSP \n" // 主指针 "MRSNE R0, PSP \n" // 进程指针 "B save_call_stack \n" // 跳转到保存函数 ); } ``` 2. **回溯算法实现** ```c // 结构体 typedef struct { uint32_t r0, r1, r2, r3; uint32_t r12, lr, pc, psr; } ExceptionStackFrame; void save_call_stack(uint32_t* stack_ptr) { ExceptionStackFrame* frame = (ExceptionStackFrame*)stack_ptr; uint32_t* fp = (uint32_t*)frame->r0; // 获取当前指针 // 遍历调用链 while(fp != 0 && is_valid_address(fp)) { uint32_t pc = *(fp + 1) - 2; // 返回地址修正 log_stack_frame(pc); // 记录 fp = (uint32_t*)*fp; // 前移指针 } } ``` 3. **有效性校验** ```c #define RAM_START 0x20000000 #define RAM_END 0x2000FFFF int is_valid_address(uint32_t* addr) { return ((uint32_t)addr >= RAM_START) && ((uint32_t)addr < RAM_END) && ((uint32_t)addr % 4 == 0); // 地址对齐检查 } ``` 4. **日志存储实现** ```c #define MAX_STACK_DEPTH 16 uint32_t stack_trace[MAX_STACK_DEPTH]; uint8_t stack_depth = 0; void log_stack_frame(uint32_t pc) { if(stack_depth < MAX_STACK_DEPTH) { stack_trace[stack_depth++] = pc; } } // 异常处理后保存到Flash void save_to_flash(void) { struct { uint32_t magic; // 0xDEADBEEF uint32_t depth; uint32_t stack[MAX_STACK_DEPTH]; uint32_t crc; } crash_log; crash_log.magic = 0xDEADBEEF; crash_log.depth = stack_depth; memcpy(crash_log.stack, stack_trace, sizeof(stack_trace)); crash_log.crc = calculate_crc(&crash_log, sizeof(crash_log)-4); flash_write(CRASH_LOG_ADDR, &crash_log, sizeof(crash_log)); } ``` --- #### 三、关键优化技巧 1. **符号表解析** 在PC值记录后,通过映射表解析函数名: ```c // 符号映射表(编译生成) const SymbolMapEntry sym_table[] = { {0x08001234, "main"}, {0x08005678, "process_sensor"}, // ... }; const char* addr_to_name(uint32_t pc) { for(int i=0; i<SYM_TABLE_SIZE; i++) { if(pc >= sym_table[i].start && pc < sym_table[i].end) return sym_table[i].name; } return "unknown"; } ``` 2. **多场景支持** ```c void log_crash_context(void) { // 记录关键寄存器 log_reg("R0", __get_R0()); log_reg("LR", __get_LR()); // 记录系统状态 log_var("heap_free", xPortGetFreeHeapSize()); log_var("task", pcTaskGetName(NULL)); } ``` 3. **RAM缓存防覆盖** 使用双缓冲机制防止数据被覆盖: ```c __attribute__((section(".noinit"))) uint8_t crash_ram_buf[512]; // 不被初始化的RAM区域 ``` --- #### 四、调试与验证 1. **强制触发异常测试** ```c void trigger_fault(void) { *(volatile int*)0xE0000000 = 0; // 写入非法地址 } ``` 2. **Windbg解析对比** 通过IDE调试器验证保存的与真实的一致性[^1] 3. **CRC校验机制** 添加CRC32校验确保日志完整性: ```c uint32_t calculate_crc(void* data, size_t len) { // 使用硬件CRC单元或软件实现 } ``` > ⚠️ **注意事项** > 1. 使用`naked`函数避免编译器添加额外操作 > 2. 关闭保护编译选项(`-fno-stack-protector`) > 3. 确保优化级别不影响指针(`-fno-omit-frame-pointer`) > 4. 在RTOS中需保存所有任务(通过TCB遍历) ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值