为什么调用函数时参数在栈上是这样排列的?

Why are parameters arranged this way on the stack when a function is called?

我正在学习 OS 开发教程。在那里我需要实现一个函数来接收 I/O 端口的地址(2 个字节长),要发送到该端口的数据(1 个字节长),并将给定的数据发送到给定的端口。

这应该在程序集 (NASM) 上实现,并通过定义的函数头在 C 代码中使用。以下是教程中的解决方案:

io.s

global outb             ; make the label outb visible outside this file

; outb - send a byte to an I/O port
; stack: [esp + 8] the data byte
;        [esp + 4] the I/O port
;        [esp    ] return address
outb:
    mov al, [esp + 8]    ; move the data to be sent into the al register
    mov dx, [esp + 4]    ; move the address of the I/O port into the dx register
    out dx, al           ; send the data to the I/O port
    ret                  ; return to the calling function

io.h

#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H

/** outb:
*  Sends the given data to the given I/O port. Defined in io.s
*
*  @param port The I/O port to send the data to
*  @param data The data to send to the I/O port
*/
void outb(unsigned short port, unsigned char data);

#endif /* INCLUDE_IO_H */

我的问题是关于这部分的:

; stack: [esp + 8] the data byte
;        [esp + 4] the I/O port
;        [esp    ] return address

我正在为 32 位环境构建,所以 return address 的地址和 I/O port 的地址之间的 4 字节差异是有道理的——因为 return address 是 4 字节长。但是为什么I/O portdata byte的地址差也是4?

我以为我在C中调用一个函数时,它直接将参数压入堆栈,然后压入return地址并跳转到函数(意思是在我的理解中,data byte应该是[esp + 6]return address的4个字节+I/O port的2个字节)而不是[esp + 8]),但它似乎也在4字节边界上对齐参数,但我不确定关于这个。

发生这种情况是因为 -m32 标志吗?我确实在 GNU 文档中读到了这个标志,它指出:

-m32
-m64
Generate code for a 32 bit or 64 bit environment. The 32 bit environment sets int, long and
pointer to 32 bits. The 64 bit environment sets int to 32 bits and long and pointer to 64
bits.

所以看起来这只改变了 int / long / pointers 的大小。那么为什么汇编端 'sure' 参数将在 4 字节边界上?这只是一个惯例吗?如果是,为什么需要它?

以下是我用于构建的所有标志:

CFLAGS = -m32 -nostdlib -nostdinc -fno-builtin -fno-stack-protector \
         -nostartfiles -nodefaultlibs -Wall -Wextra -Werror

LDFLAGS = -T link.ld -melf_i386
ASFLAGS = -f elf32

So why is assembly side 'sure' that parameters will be on 4 byte boundary? Is this just a convention?

是的,这是约定俗成的。您看到的是 IA32 cdecl 调用约定,这是大多数编译器在 IA32(x86 32 位)上使用的默认调用约定。

来自 the GCC documentation:

cdecl

On the x86-32 targets, the cdecl attribute causes the compiler to assume that the calling function pops off the stack space used to pass arguments. This is useful to override the effects of the -mrtd switch.

此调用约定期望参数由调用者压入到堆栈,然后弹出。由于 pushpop 指令使用寄存器大小,因此 IA32 中的 push/pop 总是导致 4 字节的值是 pushed/popped to/from 堆栈。当然,可以使用 sub esp, x + mov 压入更小的值,从而导致更小的堆栈位移,但这不是本约定的要求。

当然,参数传递也可以用其他指令完成;调用约定不关心 如何 您将数据放入 call 之前的堆栈指针上方的内存中,它只需要在被调用者期望找到它的地方。根据优化或旧 CPU 的 -mtune= 设置,可能会启用 -maccumulate-outgoing-args causing GCC to avoid using push.

And if yes, why is it needed?

这不是真正需要的,它只是 IA32 的标准调用约定。如果需要,您可以指定不同的调用约定:只需将 __attribute__((xxx)) 与我在上面链接的文档中定义的属性之一一起使用,并记住根据选择的调用约定更新您的汇编代码。

但请注意,如果您使用这种方法,您的代码将依赖于编译器(例如,只有与 GNU 兼容的编译器才能理解它,如 GCC 和 clang),而其他默认使用 IA32 cdecl 约定的编译器可能无法识别属性并出错甚至无法生成正确的代码。

例如,__attribute__((regparm(3))) 将使 GCC 有效地在寄存器而不是内存中传递前 3 个参数,即使在 32 位代码中也是如此。 Linux 内核使用 gcc -mregparm=3 进行 32 位构建,因为来自 user-space 的调用必须通过系统调用进行,因​​此没有什么可以阻止内核使用与 user- 不同的调用约定space.