跳转到内容
新建笔记

Python ctypes:动态库原型、类型转换与内存边界

ctypes 是 Python 标准库的外部函数接口:它提供与 C 兼容的类型,并让 Python 调用动态库导出的函数。正确调用需要同时匹配库文件、处理器位数、调用约定、参数类型、返回类型和内存使用规则。本文以 Python 3.11 为基线。

先区分库格式与调用约定

跳转到“先区分库格式与调用约定”

ctypes.CDLL 用于 C 调用约定;Windows 的 ctypes.WinDLL 用于 Windows stdcall 接口。不能简单写成“Windows 一律使用 WinDLL”:同一台 Windows 机器上的 C 库仍可能需要 CDLL。应依据头文件和接口文档选择,尤其不要在 32 位接口中混用约定。Windows 64 位采用统一的主要调用约定,也不意味着参数和返回值声明可以省略。

DLL 或 SO 只是动态库文件;C++ 导出的名字和接口布局未必能直接按普通 C 函数调用。常见做法是提供 extern "C" 的 C 接口包装,并使用简单、明确的 ABI 类型。Python 进程和库的架构也必须兼容,例如 64 位 Python 不能直接载入 32 位 DLL。

提供实际可构建的 C 库

跳转到“提供实际可构建的 C 库”

原记录调用了 example.dll 中的 function_name,但没有提供这个库。下面补齐一个演示库,保留两整数相加的入口,另加数组、结构体、字符串和较大返回值的例子。

保存为 example.c。function_name 的契约要求两个参数的和能用 C int 表示,Python 侧将在调用前检查这个条件;数组接口还要求调用者提供至少 count 个有效元素。

example.c
#include <stddef.h>
#include <stdint.h>
#if defined(_WIN32)
#define API __declspec(dllexport)
#else
#define API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct Point {
double x;
double y;
} Point;
API int function_name(int a, int b)
{
return a + b;
}
API int scale_values(double *values, size_t count, double factor)
{
if (values == NULL && count != 0) {
return 1;
}
for (size_t i = 0; i < count; ++i) {
values[i] *= factor;
}
return 0;
}
API int move_point(Point *point, double dx, double dy)
{
if (point == NULL) {
return 1;
}
point->x += dx;
point->y += dy;
return 0;
}
API const char *greeting(void)
{
return "hello from C";
}
API uint64_t big_value(void)
{
return UINT64_C(1099511627776);
}
#ifdef __cplusplus
}
#endif

已安装匹配架构的 GCC 时,Windows MinGW 可在该文件所在目录执行:

终端窗口
gcc -std=c11 -Wall -Wextra -Werror -pedantic-errors -shared -o example.dll example.c

Linux 的对应 GCC 构建命令为:

终端窗口
gcc -std=c11 -Wall -Wextra -Werror -pedantic-errors -fPIC -shared -o libexample.so example.c

本文动态库调用已在 Windows 64 位、GCC 13.1 与 CPython 3.11.5 下执行验证;Linux 命令展示该平台的构建方式,不能据此声称已验证每一种系统或编译器。

声明原型,再调用函数

跳转到“声明原型,再调用函数”

将下面保存为与库同目录的 call_example.py,然后执行 python call_example.py。通过脚本所在目录定位库,避免当前工作目录变化时载入另一个同名文件。加载失败时还要检查库的依赖文件,而不只是检查主 DLL 是否存在。

call_example.py
from pathlib import Path
import ctypes
import math
import os
class Point(ctypes.Structure):
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
library_name = "example.dll" if os.name == "nt" else "libexample.so"
library_path = Path(__file__).resolve().with_name(library_name)
lib = ctypes.CDLL(str(library_path))
function = getattr(lib, "function_name")
function.argtypes = [ctypes.c_int, ctypes.c_int]
function.restype = ctypes.c_int
def add_checked(a, b):
bits = ctypes.sizeof(ctypes.c_int) * 8
lower, upper = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
if type(a) is not int or type(b) is not int:
raise TypeError("arguments must be Python integers")
if not (lower <= a <= upper and lower <= b <= upper
and lower <= a + b <= upper):
raise OverflowError("arguments and sum must fit C int")
return function(a, b)
assert function(1, 2) == 3
assert add_checked(10, 20) == 30
print("函数返回的结果是:", add_checked(10, 20))
lib.scale_values.argtypes = [ctypes.POINTER(ctypes.c_double),
ctypes.c_size_t, ctypes.c_double]
lib.scale_values.restype = ctypes.c_int
values = (ctypes.c_double * 3)(1.0, 2.0, 3.0)
assert lib.scale_values(values, len(values), 2.5) == 0
assert list(values) == [2.5, 5.0, 7.5]
assert lib.scale_values(None, 1, 2.0) == 1
lib.move_point.argtypes = [ctypes.POINTER(Point), ctypes.c_double, ctypes.c_double]
lib.move_point.restype = ctypes.c_int
point = Point(1.0, 2.0)
assert lib.move_point(ctypes.byref(point), 0.5, -1.0) == 0
assert math.isclose(point.x, 1.5) and math.isclose(point.y, 1.0)
assert lib.move_point(None, 1.0, 1.0) == 1
lib.greeting.argtypes = []
lib.greeting.restype = ctypes.c_char_p
message = lib.greeting()
assert message == b"hello from C"
assert message.decode("utf-8") == "hello from C"
lib.big_value.argtypes = []
lib.big_value.restype = ctypes.c_uint64
assert lib.big_value() == 2**40
print("array, structure, string and return-type checks passed")

argtypes 声明参数转换规则,restype 声明返回值规则;未设置返回类型时默认按 C int 解释,不能用它接收任意指针或 64 位整数。返回 void 的函数应使用 restype = None。类型声明也不会自动验证某个整数范围、数组长度或指针背后的可用内存。

这里数组和结构体由 Python 持有,C 函数只在调用期间使用它们。greeting 返回静态只读字符串,c_char_p 将其读取为 bytes,没有需要调用者释放的分配。若实际库返回需释放的内存,就必须保留正确的地址并调用同一个库规定的释放函数,不能照搬这个字符串例子丢失所有权信息。

基础类型、字符串指针和可写缓冲区

跳转到“基础类型、字符串指针和可写缓冲区”
ctypes 类型或工厂对应概念常见边界
c_int、c_float、c_doubleC int、float、double位宽和精度依对应 C 类型,不等于 Python 任意精度整数
c_size_t、c_uint64size_t、64 位无符号整数按头文件声明,不能凭数值大小随意替换
c_char_p以 NUL 结尾的 char * 字符串指针读取为 bytes;不是可以随意让 C 改写的 Python 字符串
create_string_buffer有容量的可写字符数组容量要包含需要的终止符;数据长度和分配容量不同
Structure、类型乘整数形成的数组结构体、定长数组布局、对齐、成员顺序必须与 C 端一致
POINTER(T)、byref(value)指针类型、传入对象地址C 若保存地址,Python 对象必须保持存活
import ctypes
import math
integer = ctypes.c_int(10)
integer.value = 20
assert integer.value == 20
approximation = ctypes.c_float(0.1).value
assert math.isclose(approximation, 0.1, rel_tol=1e-6)
text = ctypes.c_char_p(b"hello")
assert text.value == b"hello"
text.value = b"world"
assert text.value == b"world"
buffer = ctypes.create_string_buffer(b"hello", 8)
assert ctypes.sizeof(buffer) == 8
assert buffer.value == b"hello"
buffer.value = b"hi"
assert buffer.value == b"hi"
assert buffer.raw == b"hi\x00lo\x00\x00\x00"
buffer[0] = b"H"
assert buffer.value == b"Hi"
print("ctypes value and buffer checks passed")

修改 c_char_p.value 会重新指向数据,不代表改写了先前那块内存。buffer.value 按 NUL 结尾读取;buffer.raw 才展示整个缓冲区,旧内容不一定在较短字符串赋值后全部清零。结构体不能随意增加 _pack_ 来“解决”布局问题,应以真实 ABI 的对齐规则为准。

错误要按库的约定解释

跳转到“错误要按库的约定解释”

原记录中的“调用 C 函数出错都会抛出 OSError”不成立。几类失败应分别判断:

  • 找不到库、依赖缺失或架构不兼容,加载通常抛出 OSError。
  • 找不到导出名称,属性访问通常抛出 AttributeError;getattr 不能创造缺失的符号。
  • 参数转换失败可能抛出 ctypes.ArgumentError,但错误地址或错误原型仍可能导致进程崩溃。
  • 原生函数自己的失败,需要按返回状态、errno、Windows LastError 或文档指定的机制处理。

上例的两个可变数据接口返回 0 表示成功、1 表示不允许的空指针;返回 1 本身不会变成 Python 异常。若要统一处理,可以为函数设置 errcheck,或在 Python 包装函数中检查返回值。需要 errno 的接口可按文档启用 use_errno=True 并读取 ctypes.get_errno();Windows LastError 对应 use_last_error=True,两者不能互换,也不能假定所有 C 库都按这两种机制报告错误。

原型、回调存活期和库加载的完整规则见 Python ctypes 文档。如果需求只是把字节按格式打包,不需要执行外部函数,可以使用标准库 struct。