通过 Python ctypes 调用 C 函数:为什么将 uint 传递给期望 size_t 的函数有效?
Calling C functions via Python ctypes: why does passing uints to a function expecting size_t work?
我有一些简单的代码可以将两个 size_t
相加:
#include <stdlib.h>
extern "C" __declspec(dllexport) size_t _cdecl add(size_t x, size_t y)
{
return x + y;
}
(注意:此代码是在 64 位系统上编译和 运行。)
当通过 Python 的 ctypes
调用该函数并向其传递 c_uint
类型的参数(大小为 32 位而不是 64 位)时,该函数按预期工作:
import ctypes
lib = ctypes.cdll['./ctypetest.dll']
add = lib.add
add.restype = ctypes.c_uint
add.argtypes = [ctypes.c_uint, ctypes.c_uint]
add(1, 2) # = 3
作为完整性检查,我验证了 uint
和 size_t
的大小不同:
>>> ctypes.sizeof(ctypes.c_size_t)
8
>>> ctypes.sizeof(ctypes.c_uint)
4
给定不同大小的参数,ctypes
如何成功调用此函数?
答案取决于用于编译您的 Python.
的 C 编译器的 ABI 的调用约定
听起来您使用的是 x86-64 Windows.1 如果是这样,您的系统是围绕 Microsoft x64 ABI 构建的。如果不是,那仍然是一个很好的例子,所以让我们假装你是。稍微过于简单化,2 该 ABI 的调用约定如下所示:
- 前四个参数存储在寄存器 RCX、RDX、R8 和 R9 中。
- 任何额外的参数都会被压入堆栈。
因此,您的 c_uint
参数分别存储在 RCX 和 RDX 的低 32 位中,而每个寄存器的高 32 位都被清除为 0。
add
函数将 RCX 和 RDX 添加为无符号 64 位整数,结果正是您所期望的;一切正常。3
但假设您在不同的平台上,具有不同的 ABI。事实上,您的想象力不必走得太远;如果您 运行 在同一台 Windows 机器上运行 32 位程序,您将获得 Microsoft IA-32 ABI 而不是 Microsoft x64。该 ABI 具有三种不同的调用约定,您声明中的 _cdecl
现在选择三种调用约定之一,其工作方式如下:
- 将所有内容压入堆栈。
好的,现在 c_uint
和 size_t
都恰好是 32 位,但是让我们对 c_ushort
做同样的事情。
您的 Python 代码将两个 16 位值压入堆栈。
add
尝试使用您的两个值(如 x | (y<<32)
)作为其 x
参数,然后将堆栈中它旁边的任何值作为其参数y
参数。所以,你得到的是垃圾。
而且情况可能会变得更糟。
如果您使用 _stdcall
会怎样?在不执行任何操作的 Microsoft x64 ABI 中,但在 Windows IA-32 ABI 中,它指定与 _cdecl
相同的参数传递顺序,但由被调用者而不是调用者进行堆栈清理。
因此,在为您生成垃圾后,add
开始清理堆栈,它期望的大小与您给它的大小不同,而且……好吧,实际上,我认为在这种特定情况下因为堆栈的参数区域与 16 字节页面对齐,所以您可以摆脱它,因此清理 16 个字节而不是 8 个字节并不重要。但这只是运气。
也有一些平台在部分寄存器中传递值。例如,IIRC,_fastcall
的 Win32s 版本做了这样的事情:
- 如果是 32 位,EAX 中的第一个参数,如果是 16 位,则为 AX,如果是 8 位,则为 AL。
- 如果是 32 位,则 EDX 中的第二个参数,如果是 16 位,则为 DX,如果是 8 位,则为 DL。
- 如果是 32 位,EBX 中的第三个参数,如果是 16 位,则为 BX,如果是 8 位,则为 BL。
- 堆栈中的所有其他内容。
AL只是AX的下半部分。将一个字节加载到 AL 不会清除高半部分。那么,如果您调用 _fastcall
函数想要添加两个 16 位数字,但您认为它想要添加两个 8 位数字,会发生什么?您会得到 x
、y
、z*256
和 w*256
的总和,其中 z
和 w
就是碰巧剩下的任何东西在 AH
和 DH
之前的一些指令中。
我所有奇怪的示例都来自 32 位和更小的 ABI,这是有原因的。大多数 64 位 ABI 都是最近才设计的,不那么随意,特别是为了使 POSIX/C 代码 and/or Win64/C 代码 运行 很好,所以它们往往非常相似。例如,System V AMD64 ABI(除了 x86_64 上的 Windows 之外几乎所有东西都使用),AArch64 ABI(ARM64 上几乎所有东西都使用)和 PowerPC64(PowerPC64 上所有东西都使用)具有与 Microsoft x64 ABI 基本相同的调用约定,除了一组不同的整数参数寄存器和略有不同的 float-and-stuff 规则。但这并不意味着您可以放心地将参数弄错。这只是意味着你很难找到测试系统来检测和调试你的错误……
1.你没说,但是 __declspec
和 _cdecl
通常只出现在 Windows 代码中。你说 "a 64-bit system",我怀疑你使用的是 Itanium 还是其他一些 64 位平台。
2。浮点数、SSE 向量、大于 64 位的结构、可变参数……有一些额外的复杂性……
3。你可能会有点惊讶 0xffffffff + 0xffffffff
是 0x00000001fffffffe
而不是 0xfffffffe
… 但是因为你把 restype
也弄错了,你会 t运行 将其设置为 32 位(并且您使用的是小端系统,并且在寄存器中有 returns 值的系统——如果两者都不正确,您将得到 1 作为答案……),并且,因为这些是无符号整数,t运行cating 和 rolling over 看起来相同,所以两个错误抵消了,你会看到你期望的 0xfffffffe
。
我有一些简单的代码可以将两个 size_t
相加:
#include <stdlib.h>
extern "C" __declspec(dllexport) size_t _cdecl add(size_t x, size_t y)
{
return x + y;
}
(注意:此代码是在 64 位系统上编译和 运行。)
当通过 Python 的 ctypes
调用该函数并向其传递 c_uint
类型的参数(大小为 32 位而不是 64 位)时,该函数按预期工作:
import ctypes
lib = ctypes.cdll['./ctypetest.dll']
add = lib.add
add.restype = ctypes.c_uint
add.argtypes = [ctypes.c_uint, ctypes.c_uint]
add(1, 2) # = 3
作为完整性检查,我验证了 uint
和 size_t
的大小不同:
>>> ctypes.sizeof(ctypes.c_size_t)
8
>>> ctypes.sizeof(ctypes.c_uint)
4
给定不同大小的参数,ctypes
如何成功调用此函数?
答案取决于用于编译您的 Python.
的 C 编译器的 ABI 的调用约定听起来您使用的是 x86-64 Windows.1 如果是这样,您的系统是围绕 Microsoft x64 ABI 构建的。如果不是,那仍然是一个很好的例子,所以让我们假装你是。稍微过于简单化,2 该 ABI 的调用约定如下所示:
- 前四个参数存储在寄存器 RCX、RDX、R8 和 R9 中。
- 任何额外的参数都会被压入堆栈。
因此,您的 c_uint
参数分别存储在 RCX 和 RDX 的低 32 位中,而每个寄存器的高 32 位都被清除为 0。
add
函数将 RCX 和 RDX 添加为无符号 64 位整数,结果正是您所期望的;一切正常。3
但假设您在不同的平台上,具有不同的 ABI。事实上,您的想象力不必走得太远;如果您 运行 在同一台 Windows 机器上运行 32 位程序,您将获得 Microsoft IA-32 ABI 而不是 Microsoft x64。该 ABI 具有三种不同的调用约定,您声明中的 _cdecl
现在选择三种调用约定之一,其工作方式如下:
- 将所有内容压入堆栈。
好的,现在 c_uint
和 size_t
都恰好是 32 位,但是让我们对 c_ushort
做同样的事情。
您的 Python 代码将两个 16 位值压入堆栈。
add
尝试使用您的两个值(如 x | (y<<32)
)作为其 x
参数,然后将堆栈中它旁边的任何值作为其参数y
参数。所以,你得到的是垃圾。
而且情况可能会变得更糟。
如果您使用 _stdcall
会怎样?在不执行任何操作的 Microsoft x64 ABI 中,但在 Windows IA-32 ABI 中,它指定与 _cdecl
相同的参数传递顺序,但由被调用者而不是调用者进行堆栈清理。
因此,在为您生成垃圾后,add
开始清理堆栈,它期望的大小与您给它的大小不同,而且……好吧,实际上,我认为在这种特定情况下因为堆栈的参数区域与 16 字节页面对齐,所以您可以摆脱它,因此清理 16 个字节而不是 8 个字节并不重要。但这只是运气。
也有一些平台在部分寄存器中传递值。例如,IIRC,_fastcall
的 Win32s 版本做了这样的事情:
- 如果是 32 位,EAX 中的第一个参数,如果是 16 位,则为 AX,如果是 8 位,则为 AL。
- 如果是 32 位,则 EDX 中的第二个参数,如果是 16 位,则为 DX,如果是 8 位,则为 DL。
- 如果是 32 位,EBX 中的第三个参数,如果是 16 位,则为 BX,如果是 8 位,则为 BL。
- 堆栈中的所有其他内容。
AL只是AX的下半部分。将一个字节加载到 AL 不会清除高半部分。那么,如果您调用 _fastcall
函数想要添加两个 16 位数字,但您认为它想要添加两个 8 位数字,会发生什么?您会得到 x
、y
、z*256
和 w*256
的总和,其中 z
和 w
就是碰巧剩下的任何东西在 AH
和 DH
之前的一些指令中。
我所有奇怪的示例都来自 32 位和更小的 ABI,这是有原因的。大多数 64 位 ABI 都是最近才设计的,不那么随意,特别是为了使 POSIX/C 代码 and/or Win64/C 代码 运行 很好,所以它们往往非常相似。例如,System V AMD64 ABI(除了 x86_64 上的 Windows 之外几乎所有东西都使用),AArch64 ABI(ARM64 上几乎所有东西都使用)和 PowerPC64(PowerPC64 上所有东西都使用)具有与 Microsoft x64 ABI 基本相同的调用约定,除了一组不同的整数参数寄存器和略有不同的 float-and-stuff 规则。但这并不意味着您可以放心地将参数弄错。这只是意味着你很难找到测试系统来检测和调试你的错误……
1.你没说,但是 __declspec
和 _cdecl
通常只出现在 Windows 代码中。你说 "a 64-bit system",我怀疑你使用的是 Itanium 还是其他一些 64 位平台。
2。浮点数、SSE 向量、大于 64 位的结构、可变参数……有一些额外的复杂性……
3。你可能会有点惊讶 0xffffffff + 0xffffffff
是 0x00000001fffffffe
而不是 0xfffffffe
… 但是因为你把 restype
也弄错了,你会 t运行 将其设置为 32 位(并且您使用的是小端系统,并且在寄存器中有 returns 值的系统——如果两者都不正确,您将得到 1 作为答案……),并且,因为这些是无符号整数,t运行cating 和 rolling over 看起来相同,所以两个错误抵消了,你会看到你期望的 0xfffffffe
。