LOADING

加载过慢请开启缓存 浏览器默认开启

NEMU PA2 阶段三(打印栈帧链)

2024/9/13

打印栈帧链

理论

寄存器ESP是栈指针,指向的是函数调用栈的栈顶,向下增长。调用函数时通过call和ret这两个汇编指令来进行,其中call指令为跳转到函数的那个位置,并把call指令的下一条指令的位置存到栈里,当函数执行完,返回时,通过ret指令,将栈里面的地址弹出,并赋给eip,也就是当前指令的执行位置。

除此以外,还要想办法把参数传给调用的函数,以及把返回值从函数里面拿出来,这个过程大部分是用寄存器实现的。

在x86-64中,最多可以使用6个寄存器传值,如果多于6个,那么剩余的参数被存储在栈上,这个部分称为栈帧,假设有n个参数传递,那么7~n个参数就在栈帧里面,并且参数7位于栈顶,之后便可以执行call指令了。

栈帧具体就是指的调用某一个函数单独占的栈上面的空间。关于栈帧到地是怎样的,这里不举例子很难说清,一定要看《深入理解计算机系统(第三版)》就是黑皮书上面的170、171、172三页

关于寄存器的使用规则,在173,174两页。总结就是RBX、RBP这两个寄存器是被调用者保存寄存器,意思就是被调用的那个函数要保护它不被改变,如果要改变,那就先push,最后返回之前pop。而剩下的,除了栈指针寄存器RSP,就是调用者保存寄存器,就是调用者要在调用之前保存他,而被调用者就随便用。书上的例子最好还是看一下。

对于EBP这个寄存器,非常特殊,每次调用函数,被调用函数都要储存它,就是先push它,所以,在程序执行到某一步停止的时候,我们可以直接在栈里面寻找这个EBP,存的就是EBP的旧值。而这个旧值,就是调用现在EBP所在的函数的那个函数的栈帧里面,因此,这就像一个链表,从EBP一次一次到最初的那个函数,这就是栈帧链,反应的是函数的调用关系。

这里再强调一下,可能你看了书的173页的例子以后可能会有一点疑问,那个汇编语言的第五行,把x存入了RBP中,明明RBP中应该全是地址才对。但是实际上这种情况也可以,因为有些函数调用比较简单,根本不用开栈帧,在这种情况下(所有的局部变量 都可以保存在寄存器中,而且该函数不会调用任何其他函数,有时被称为叶子过程),RBP根本不会在调用的Q函数里面push,因此可以放心。

所以这个打印栈帧链的实现,打印出来的,可能并不一定是所有函数调用的情况。

接下来我们只讨论有栈帧的情况。

如图所示每个栈帧中包含了EBP旧值、返回地址、函数的参数

(有个小问题,为什么书上说先通过寄存器传参,不够的再开栈,为什么这里全在栈上)

因为这里全在栈帧上的是调用者P,它上面的参数1~n是Q的参数,而Q在自己栈帧上的参数存储是正常的(不知道是不是这个原因)

如图所示,每个函数开始时执行push %ebp为了保存ebp旧值,而movl esp ebp指令则是把esp栈指针赋给了ebp,这样ebp就指向了另一个新的栈帧,由于push的时候,esp向下增长减四,那么现在这个ebp正好在ebp旧值的起始字节位置,函数内部通过

0x(%ebp)

来在栈中存入数据,这个时候不会改变ebp的值。之后在调用另一个函数时,push这个ebp。这就是所谓的栈帧链。

实现

指导手册中给了我们一个结构体,这需要我们自己定义,我是直接在ui.d源文件里面定义的

typedef struct {
    swaddr_t prev_ebp;			//ebp的旧值
    swaddr_t ret_addr;			//返回地址
    uint32_t args[4];			//前4个参数
}PartOfStackFrame;

根据手册的要求,我们只需要打印函数名,返回地址,以及前四个参数即可。

对于Type属性为FUNC的表项, Value属性指示了函数的起始地址, Size属 性指示了函数的大小, 通过这两个属性就可以确定函数的范围了。由于函数的范 围是互不相交的, 因此我们可以通过扫描符号表中Type属性为FUNC的每一个 表项, 唯一确定一个地址在所的函数。为了得到函数名, 你只需要根据表项中的 Name属性在字符串表中找到相应的字符串就可以了。

这是手册上的内容,我们根据这一点来写出cmp_bt函数

static int cmd_bt(char *args) {
    int i = 0;
    PartOfStackFrame now;		//存栈帧信息
    int ebp = reg_l(R_EBP);		//当前栈帧位置
    //第一个栈帧的信息
    //	栈帧(32位)中,最低4字节存旧ebp(prev_ebp),其次4字节存返回地址(ret_addr),上面4个4字节分别为4个参数
    now.ret_addr = cpu.eip;
    while(ebp) {
        int j = 0;
        for (j = 0; j < nr_symtab_entry; j++) {	//扫描all符号表里的函数,看看在不在该函数中
            if ((symtab[j].st_info & 0xf) == STT_FUNC){//是函数
                if(symtab[j].st_value <= now.ret_addr && now.ret_addr < symtab[j].st_value + symtab[j].st_size) {//在里面
                    printf("#%d\t return 0x%08x in %s", i++, swaddr_read(ebp + 4 , 4), strtab + symtab[j].st_name);
                    break;
                }
            }
        }
        //读取当前栈帧信息
        now.prev_ebp = swaddr_read(ebp, 4);
        now.ret_addr = swaddr_read(ebp + 4 , 4);
        int k = 0;	
        for(k = 0; k < 4; k++) 
            now.args[k] = swaddr_read(ebp + 8 + 4*k, 4, R_SS);
        printf("(%d, %d, %d, %d)\n", now.args[0], now.args[1], now.args[2], now.args[3]);
        //更新ebp
        ebp = now.prev_ebp;		//更旧一层栈帧
    }
    return 0;
}

这里我是把别人的代码改了一下,为了符合手册中的要求。

首先是now.ret_addr = cpu.eip 这是原主的代码,在这里还有用,就是判断在函数里面的时候。

根据返回地址的值找函数,具体怎么找,看我引用的指导手册。

先通过sym table确定函数的地址范围,然后看ret_addr是不是在范围里面(返回地址是call执行之后push的内容,算在上一个函数里面,因此是先赋值eip,保证当前的eip在函数里面,之后就通过真正的返回地址查找函数)。

找到之后,打印序号,返回地址,函数名。

其中返回地址在ebp所指的内存再加四个字节那里面,原因就是call完了存的就是返回地址,而它正好在ebp旧值push之前,因此新的ebp指向的ebp旧值地址,那个地址再减四(对于ESP指向的函数调用栈来说,是向下增长,因此是向上减少的)。

然后读取信息

接下来就是取参数,我的前面第二张图片已经说明了怎么取,这里看代码就好,这里不在赘述。

至此阶段三结束。