如果您想明智地解决这个问题,唯一的方法就是使用指针(或 C++ 中的引用)。
正如目前所写的那样,这个问题有点不妥,因为没有要求x,y,z 实际存在于程序的内存中任何地方,因为它们未被使用。一个体面的优化编译器会注意到这一点,不会发出冗余代码并在此过程中警告您。例如,x86 上的 gcc 为 foo 生成以下代码:
foo:
rep ret
即该函数所做的就是返回。 (它甚至不调用recover,因为可证明调用recover 还没有做任何事情)。
让我们假设我们已经禁用了所有优化并且有一个非常幼稚的编译器。我们也将不得不忽略标准 C 和可移植性的所有概念,而是依靠我们对特定编译器实现细节、调用约定和特定平台的了解才能走得更远。
在带有 gcc 4.8.2 的 Linux x86 上,默认调用约定是将参数压入堆栈,这样最左边的参数 (a) 位于顶部,然后是 b,直到最右边的参数 (c) 最低在堆栈中。所以调用 `foo(1,2,3) 你会期望这样的代码:
push 3
push 2
push 1
call foo
要生成。请注意,尽管类型的大小不同,但生成的代码对于每个都是相同的,也就是说,char 获得的堆栈空间量与 int 在作为这样的参数传递时所获得的堆栈空间量相同。 (这是有利的,因为它允许堆栈的对齐保持可预测,从而确保不需要缓慢的未对齐加载,并且我们总是可以依靠对齐的指令就足够了)。
实际上,我的 GCC 版本实际生成的代码是:
subl $12, %esp
movl $3, 8(%esp)
movl $2, 4(%esp)
movl $1, (%esp)
call foo
这很可能是对推送的优化,即使优化已关闭,但它具有完全相同的语义,只是使用显式堆栈指针寻址。 (可能它需要更少的字节来表示,或者至少在某些情况下它允许现代处理器快几个时钟周期)。
这里还有一点需要注意,那就是在 x86 上,堆栈会“向下”增长。 (这只是很久以前就存在的规则,它可能曾经允许有效使用有限的地址空间,并使加载/存储指令在所需字节方面更短)。
所以一旦我们输入函数foo我们的堆栈看起来像:
ESP + 0 ==> a
ESP + 4 > b
ESP + 8 > c
在函数foo 内部,因为我们已经告诉编译器不要进行任何优化,所以代码看起来很粗略(我删除了一些与本次讨论无关的部分),例如:
foo:
pushl %ebp ; Save EBP as it was when we were called
movl %esp, %ebp ; Update EBP to be what the stack pointer was
subl $24, %esp ; Reserve 24 bytes of stack space, for locals and to maintain 16 byte alignment
movl 12(%ebp), %edx ; copy 'b' into register edx
movl 16(%ebp), %eax ; copy 'c' into register eax
movw %dx, -20(%ebp) ; copy just the low 16 bytes of edx into a temporary variable on the stack
movb %al, -24(%ebp) ; copy just the low byte of eax into a temporary variable on the stack
movl 8(%ebp), %eax ; copy 'a' into register eax
movl %eax, -12(%ebp); copy eax into stack variable x
movswl -20(%ebp), %eax; copy first temporary into eax (as short)
movl %eax, -8(%ebp) ; copy eax to y
movsbl -24(%ebp), %eax; copy second tempoary into eax (as char)
movl %eax, -4(%ebp) ; copy eax to z
call recover
leave
Ebp 与 esp 的作用相似,只是它指向调用当前函数时栈顶的位置,而不是当前栈顶的位置。这在 x86 上很有用且很常见,因为我们可以使用这个基指针轻松地处理我们的参数,如 ebp+(4*n) 和局部变量如 ebp-(4*n)。
从上面的代码我们可以总结出几件事:
- 在禁用优化的情况下进行编译是一个糟糕的主意——即使是一些无用的冗余代码最终也会浪费堆栈空间和时间。 (这项额外的工作可能只是将所有内容始终存储在安全的地方,并避免试图弄清楚是否有任何其他代码可以以某种方式覆盖输入)。
- 在
recover 内部,我们无法仅通过读取寄存器来恢复所有变量。 (在这种情况下,寄存器分配在内部重用了它们)。
如果我们在 Windows 上,使用 fastcall 并且只有 2 个参数来恢复,我们可以通过读取 ecx 和 edx 来做到这一点提供内部不会被覆盖。在 x86_64 上,有更多的寄存器可供使用,并且不同的参数传递约定然后从寄存器读取也可能是可行的,即使在这种情况下也是如此。
所以从我们目前阅读的代码中可以看出,我们所追求的变量存储在堆栈中,就在(因为它向下增长)ebp 之后。
因此,对于我们的恢复函数,我们希望直接从堆栈中读取,使用我们从生成的汇编代码中学到的内容来查找 x、y 和 z。有两种方法可以做到这一点,它们都是完全不可移植的,并且对堆栈布局和编译器的假设远远超出了标准 C 中的任何内容。
方法一:
对于这个方法,我们将使用一些内联汇编来直接“捕获” ebp 的值,就像在前一个函数中一样。除了被压入堆栈的参数之外,还有两个条目不太明显。首先,当call 指令发生时,它实际上保存了eip,即隐式指向堆栈的指令指针。 (这需要让返回指令找出实际返回的位置)。其次,大多数函数做的第一件事就是将 ebp 保存在堆栈中以便以后恢复。 (虽然请注意并非所有函数都这样做,但有些函数根本不使用 ebp 或将其用作通用寄存器,这基本上由编译器决定)。
所以在recover 内,最早我们实际上可以编写堆栈实际上看起来像的任何代码:
ESP ==>
ESP+4 ==> Old ebp value
ESP+8 ==> Old eip value
ESP+12 ==> Padding/local variables for previous function
.... More local variables, followed by padding and arguments to previous function
所以我们要做的是从堆栈中读取旧的 Ebp 值,然后分别找到相对于 -12、-8 和 -4 的 x、y、z 值:
#include <stdio.h>
void recover() {
register void *old_ebp;
asm("mov (%%ebp), %0"
: "=g" (old_ebp));
printf("old ebp was: %p\n", old_ebp);
int *locals = ((int*)old_ebp) - 5; // Note 5*4
printf("%d %d %d\n", locals[0], locals[1], locals[2]);
}
int foo(int a, short b, char c) {
int x, y, z;
x = a;
y = b;
z = c;
recover();
}
int main() {
foo(1,2,3);
return 0;
}
运行时会打印如下内容:
old ebp was: 0xbfd51d38
1 2 3
请注意,在上面的代码中,我们用来查找局部变量开始的偏移量是 -5。这是因为当我在上面写代码时,堆栈布局从我开始展示的带注释的代码中改变了。这应该会给出另一个很好的暗示,说明找到这样的变量确实是一个坏主意。
方法二
我们可以相对于当前堆栈帧中的局部变量进行搜索,而不是显式读取 ebp 并找到旧的 ebp 值。我对这个方法的实现是:
void recover() {
int tmp;
int *locals = &tmp + 11;
printf("%d %d %d\n", locals[0], locals[1], locals[2]);
}
我使用调试器凭经验找到了此处所需的特定值 (11) - 它是堆栈上从我的函数中的本地人到我关心的调用者中的本地人的测量距离。您可以在 GDB 中使用类似以下的方式来执行此操作:
Breakpoint 1, foo (a=1, b=2, c=3 '\003') at test.c:5
5 x = a;
(gdb) i r ebp
ebp 0xbffff528 0xbffff528
(gdb) p &x
$1 = (int *) 0xbffff514
(gdb) p &y
$2 = (int *) 0xbffff518
(gdb) p &z
$3 = (int *) 0xbffff51c
(gdb) c
Continuing.
Breakpoint 2, recover () at test.c:13
13 int local=0;
(gdb) i r ebp
ebp 0xbffff4f8 0xbffff4f8
(gdb) p &local
$4 = (int *) 0xbffff4f4
这使用 'info registers' (ir)、'print' (p)、'breakpoint' (b) 在正确的位置停止。
所以在示例调试会话中,堆栈上local 和x 之间的距离为 0xbffff514-0xbffff4f4 为 0x20。将其除以 4(因为我们需要指针运算,而不是所写表达式的字节数),我们得到给定编译器 + 程序 + 平台的堆栈距离。
请注意,使用此方法编译器可以很高兴地生成任何代码,因为根据 C 标准这是未定义的。至少对于方法 1,它符合 GCC 特定的 asm 语法。
方法三:
我们还可以使用另一个技巧从前一个函数中读取局部变量。回想一下,我们函数的参数在函数入口处相对于 ebp/esp 是正数。我们正在寻找的局部变量相对于 ebp/esp 也是正数,所以如果我们欺骗编译器认为我们的函数的参数比调用者提供的参数多,我们可以将旧的局部变量读取为参数:
#include <stdio.h>
int foo(int a, short b, char c) {
int x, y, z;
x = a;
y = b;
z = c;
recover();
}
void recover(int l1, int l2, int l3, int l4, int l5, int l6, int l7, int l8, int l9, int l10, int l11, int l12, int l13, int l14, int l15) {
printf("%d %d %d\n", l13, l14, l15);
}
int main() {
foo(1,2,3);
return 0;
}
在上面的代码中,我们简单地定义了足够的参数来跳过堆栈中我们不关心的东西,直到我们找到上一个函数调用的局部变量。
这依赖于这样一个事实,即 C 假设函数 recover 返回一个 int 并且不接受任何参数,但定义与该假设不匹配。
结论
这完全依赖于编译器/平台,您真的不想这样做。即使对于给定的情况,编译器也不需要做出任何承诺。
如果您真的想在实践中这样做,请不要这样做!您可以通过以下两种方式之一获得相同的行为:
- 将指针/引用传递到
recover(推荐)
- 在
foo 中使用全局变量而不是局部变量
- 使用来自编译器的调试信息(如果需要,以编程方式)详细说明“这个变量在哪里?”再次编译器的问题,不用担心的不仅仅是DWARF/PDB。