Cmain 是从 CRT 启动代码调用(间接),而不是直接从内核调用。
main 返回后,该代码调用 atexit 函数来执行诸如刷新 stdio 缓冲区之类的操作,然后将 main 的返回值传递给原始的 _exit 系统调用。或者exit_group 退出所有线程。
你做了几个错误的假设,我认为都是基于对内核如何工作的误解。
-
内核以不同于用户空间的特权级别运行(环 0 与 x86 上的环 3)。即使用户空间知道要跳转到的正确地址,它也无法跳转到内核代码。 (即使可以,它也不会以内核特权级别运行)。
ret 不是魔法,它基本上只是pop %rip,并且不会让你跳转到其他指令无法跳转到的任何地方。也不会更改权限级别1。
-
当用户空间代码运行时,内核地址不能被映射/访问;这些页表条目被标记为仅主管。 (或者它们根本没有映射到缓解 Meltdown 漏洞的内核中,因此进入内核需要通过更改 CR3 的“包装”代码块。)
虚拟内存是内核保护自己免受用户空间影响的方式。 用户空间不能直接修改页表,只能通过mmap 和mprotect 要求内核进行修改系统调用。 (并且用户空间不能执行像 mov cr3, rax 这样的特权指令来安装新的页表。这就是设置 ring 0(内核模式)与 ring 3(用户模式)的目的。)
内核堆栈独立于进程的用户空间堆栈。 (在内核中,每个任务(又名线程)还有一个小的内核堆栈,在该用户空间线程运行时在系统调用/中断期间使用。至少 Linux 是这样做的,IDK 关于其他人。)
-
内核并不是字面上的call 用户空间代码;用户空间堆栈不会将任何返回地址保存回内核。 内核->用户转换涉及交换堆栈指针以及更改特权级别。例如使用iret(中断返回)之类的指令。
另外,在用户空间可以看到的任何地方留下内核代码地址会破坏内核 ASLR。
脚注 1:(编译器生成的ret 将始终是ret 附近的正常值,而不是可以通过调用门或其他方式返回特权cs 值的retf。x86 处理特权级别通过 CS 的低 2 位,但没关系。MacOS / Linux 不要设置用户空间可以用来调用内核的调用门;这是通过 syscall 或 int 0x80 完成的说明。)
在一个新进程中(在execve 系统调用用新的PID 替换之前的进程后),执行从进程入口点开始(通常标记为_start),不是 直接在 C main 函数处。
C 实现附带 CRT (C RunTime) 启动代码,该代码具有(除其他外)_start 的手写 asm 实现,它(间接)调用 main,根据调用约定将 args 传递给 main。
_start 本身不是一个函数。 在进程进入时,RSP 指向argc,而在用户空间堆栈上的上方是argv[0]、argv[1] 等。 (即char *argv[] 数组按值就在那里,在envp 数组之上。)_start 将argc 加载到寄存器中,并将指向argv 和envp 的指针放入寄存器中。 (MacOS 和 Linux 都使用的 x86-64 System V ABI 记录了所有这些,包括进程启动环境和调用约定。)
如果您尝试从_start 到ret,您只需将argc 弹出到RIP,然后从绝对地址1 或2 获取代码(或其他小数字)将出现段错误。例如,Nasm segmentation fault on RET in _start 显示从进程入口点尝试ret(链接没有 CRT 启动代码)。它有一个手写的_start,正好落入main。
当您运行gcc main.c 时,gcc 前端会运行多个其他程序(使用gcc -v 显示详细信息)。这就是 CRT 启动代码链接到您的进程的方式:
- gcc 预处理 (CPP) 并编译 + 汇编
main.c 到 main.o(或临时文件)。在 MacOS 上,gcc 命令实际上是具有内置汇编程序的 clang,但真正的 gcc 确实会编译为 asm,然后在其上运行 as。 (不过,C 预处理器是编译器内置的。)
- gcc 运行类似
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie /usr/lib/Scrt1.o /usr/lib/gcc/x86_64-pc-linux-gnu/9.1.0/crtbeginS.o main.o -lc -lgcc /usr/lib/gcc/x86_64-pc-linux-gnu/9.1.0/crtendS.o 的东西。这实际上简化了很多,省略了一些 CRT 文件,并且规范化了路径以删除 ../../lib 部分。此外,它不直接运行ld,它运行collect2,它是ld 的包装器。但无论如何,这会静态链接那些包含_start 和其他东西的.o CRT 文件,并动态链接 libc (-lc) 和 libgcc(用于 GCC 辅助函数,例如实现 __int128 乘除以 64-位寄存器,以防您的程序使用它们)。
.intel_syntax
.text:
.global _rbp
_rbp:
mov rax, rbp
ret;
这是不允许的,...
不组装的唯一原因是您试图将.text: 声明为标签,而不是使用.text 指令。如果您删除尾随的 :,它确实会与 clang 组合在一起(将 .intel_syntax 视为与 .intel_syntax noprefix 相同)。
要让 GCC / GAS 组装它,您还需要 noprefix 告诉它寄存器名称不以 % 为前缀。 (是的,您可以拥有 Intel op dst、src 命令,但仍然使用%rsp 寄存器名称。不,您不应该这样做!)当然 GNU/Linux 没有'不要使用前导下划线。
不是说如果你调用它,它总是会做你想做的事!如果您在没有优化的情况下编译了main(所以-fno-omit-frame-pointer 有效),那么是的,您会得到一个指向返回地址下方的堆栈槽的指针。
而且你肯定用错了值。 (*p)-4; 加载保存的 RBP 值 (*p),然后偏移四个 8 字节空指针。 (因为这就是 C 指针数学的工作原理;*p 的类型为 void*,因为 p 的类型为 void **)。
我认为您正在尝试获取自己的返回地址并重新运行到达 main 的 call 指令(在 main 的调用者中),最终导致由于推送更多返回地址而导致堆栈溢出。在 GNU C 中,使用 void * __builtin_return_address (0) to get your own return address。
x86 call rel32 指令为 5 个字节,但调用 main 的 call 可能是间接调用,使用寄存器中的指针。所以它可能是一个2字节的call *%rax或一个3字节的call *%r12,除非你反汇编你的调用者,否则你不知道。 (我建议在反汇编模式下使用调试器在main 末尾通过指令(GDB / LLDB stepi)单步执行。如果它有任何主调用者的符号信息,您将能够向后滚动看看之前的指令是什么。
如果没有,您可能必须尝试看看看起来是否正常; x86 机器代码不能明确地向后解码,因为它是可变长度的。您无法区分指令中的字节(如立即数或 ModRM)与指令的开头之间的区别。这一切都取决于您从哪里开始反汇编。如果你尝试几个字节偏移,通常只有一个会产生看起来正常的任何东西。
asm("movq %rax, 0"); //Exit code is 11, so now it should be 0
这是 RAX 到绝对地址 0 的存储,采用 AT&T 语法。 这当然是段错误。退出代码 11 来自 SIGSEGV,即信号 11。(使用 kill -l 查看信号编号)。
也许你想要mov $0, %eax。尽管这在这里仍然毫无意义,但您将通过函数指针进行调用。在调试模式下,编译器可能会将其加载到 RAX 中并踩到您的值。
此外,当您不告诉编译器您正在修改哪些寄存器(使用约束)时,在 asm 语句中编写寄存器永远不会安全。
printf("Main: %p\n", main);
printf("&Main: %p\n", &main); //WTF
main 和 &main 是一回事,因为 main 是一个函数。这就是 C 语法对函数名的作用。 main 不是可以获取其地址的对象。 & operator optional in function pointer assignment
数组也是类似的:数组的裸名可以分配给一个指针,也可以作为指针arg传递给函数。但是&array也是同一个指针,和&array[0]一样。这仅适用于像int array[10] 这样的数组,不适用于像int *ptr 这样的指针;在后一种情况下,指针对象本身有存储空间,可以有自己的地址。