《Python高级编程》9.1 ctypes 与 cffi 调用 C

从 CPython 用 dlopen 加载符号的机制讲起,实测 ctypes 的 errno 传递、内存所有权与 Structure 布局,再对比 cffi 的 ABI/API 两种模式,并给出 ctypes.CDLL、PyDLL 与 cffi 四条调用路径的 GIL 释放与单次调用开销实测数据。

本节目标:讲清 ctypes / cffi 把 Python 调用接到 C 符号上的机制,实测 errno、内存所有权、Structure 布局,以及四条调用路径的 GIL 释放与开销差异。
适用版本:Python 3.12+(实测 3.14.6;cffi 2.1.1,Apple clang 16.0.0 / arm64)

9.1 ctypes 与 cffi 调用 C

站内专题 Python C 扩展与 FFI 已经讲清了「怎么用」:加载 .so、设 argtypes、写 Structure、用 cffi.dlopen。本节换一个落点——CPython 到底怎么把这些调用接到 C 符号上,以及三个踩坑率极高、专题里没展开的机制点:errno 的传递、内存所有权的归属、以及四种调用路径在 GIL 上的真实差异。所有数字都是本机实测。

9.1.1 CDLL 是 dlopen 的薄封装

ctypes.CDLL(path) 内部就是 dlopen。传 None 等价于 dlopen(NULL),拿到的是当前进程的主程序镜像——macOS 上主程序链着 libSystem,所以 libc 的符号能直接取到,不必手写 /usr/lib/libc.dylib:

import ctypes

libc = ctypes.CDLL(None)          # == dlopen(NULL)
print(libc.getpid())
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
print(libc.strlen(b"hello"))

实测输出(PID 每次不同):

53193
5

两个必须记住的机制点。第一,不设 restype 时 ctypes 假设返回值是 C 的 int(32 位有符号)。strlen 若不设 restype,短字符串「看起来是对的」,一旦返回值超过 2³¹ 就静默截断——所以每个用到的函数都要显式设 argtypes / restype。第二,c_char_p 收发的是 bytes 而非 str:传 str 会抛 TypeError。这是刻意的,C 字符串没有编码信息,ctypes 拒绝替你隐式编码。

9.1.2 errno:不传 use_errno 就永远是 0

C 函数的失败信号常藏在全局 errno 里,但 ctypes 默认不帮你读它——必须在建 CDLL 时传 use_errno=True。实测一个失败的系统调用:

import ctypes, os

libc = ctypes.CDLL(None, use_errno=True)
libc.open.argtypes = [ctypes.c_char_p, ctypes.c_int]
libc.open.restype = ctypes.c_int

libc.open(b"/no/such/file/xyz", 0)
err = ctypes.get_errno()
print(err, os.strerror(err))

实测输出:

2 No such file or directory

把 use_errno=True 去掉,同一个失败调用后 ctypes.get_errno() 返回 0(独立进程实测):

get_errno() = 0

机制:use_errno=True 让 ctypes 在每次调用返回后立刻把 C 的 errno 存进一个 per-thread 副本,ctypes.get_errno() 读这个副本,ctypes.set_errno() 写它。默认时副本从不更新,永远是初值 0:

initial get_errno() = 0
after set_errno(123) = 123

一句话:要读 errno,CDLL(..., use_errno=True) 是前提;缺了它你读到的是「碰巧的 0」,不是真实错误码。

9.1.3 内存所有权:c_char_p 复制,POINTER 只是指针

这是 ctypes 最容易出事的地方。同一段 libc 的 getenv,用不同的 restype 声明,行为完全不同:

import ctypes, os
libc = ctypes.CDLL(None)
os.environ["ADV09"] = "hello-from-env"

libc.getenv.argtypes = [ctypes.c_char_p]
libc.getenv.restype = ctypes.c_char_p          # 复制成 bytes
b = libc.getenv(b"ADV09")
print(type(b).__name__, b)

libc.getenv.restype = ctypes.POINTER(ctypes.c_char)   # 原始指针
p = libc.getenv(b"ADV09")
print(p)
print(ctypes.string_at(p))
print(ctypes.string_at(p, 5))

实测输出:

bytes b'hello-from-env'
<ctypes.LP_c_char object at 0x...>
b'hello-from-env'
b'hello'

区别在于:c_char_p 作为 restype 时,ctypes 会当场把 C 字符串拷成新的 Python bytes,之后和 C 侧再无关系,安全但有拷贝开销。POINTER(c_char) 只包住一个地址,不复制、也不知道字符串长度;要用 ctypes.string_at(p) 按 \0 截断读取,或 ctypes.string_at(p, n) 读固定长度。指针的所有权仍属于 C 库——如果那块内存在下一次调用时被复用或释放,你手上就是一个悬垂指针,读它不会报错,只会读到垃圾。规则很简单:只读一次、立刻 string_at 拷走,不要把裸指针存起来跨调用使用。

Structure 是同一套所有权规则的结构体版本。用 libc 的 gettimeofday 实测:

import ctypes

class Timeval(ctypes.Structure):
    _fields_ = [("tv_sec", ctypes.c_long), ("tv_usec", ctypes.c_int)]

libc = ctypes.CDLL(None)
libc.gettimeofday.argtypes = [ctypes.POINTER(Timeval), ctypes.c_void_p]
libc.gettimeofday.restype = ctypes.c_int

tv = Timeval()
rc = libc.gettimeofday(ctypes.byref(tv), None)
print("rc =", rc, "tv_sec =", tv.tv_sec, "tv_usec =", tv.tv_usec)
print("sizeof(Timeval) =", ctypes.sizeof(Timeval))

实测输出:

rc = 0 tv_sec = 1791518910 tv_usec = 813048
sizeof(Timeval) = 16

ctypes.byref(tv) 传的是地址(比 pointer(tv) 少一层临时对象,更快);sizeof 为 16 说明 c_long(8) + c_int(4) 之后按最大对齐补到 8 的倍数。结构体布局必须和 C 头文件逐字段对齐,字段顺序或类型写错,ctypes 不会报错,只会读到错位的数据。

9.1.4 cffi ABI 模式:dlopen + cdef

cffi 的 ABI 模式不需要编译任何东西:用 ffi.cdef 写 C 声明,ffi.dlopen 在运行时解析符号。它和 ctypes 是同一层次的东西,但接口更接近 C 语法:

from cffi import FFI
import os

ffi = FFI()
ffi.cdef("""
    int getpid(void);
    size_t strlen(const char *s);
    char *getenv(const char *name);
    char *strcpy(char *dest, const char *src);
""")
libc = ffi.dlopen(None)
print(libc.getpid(), libc.strlen(b"hello"))

os.environ["ADV09"] = "cffi-abi-value"
p = libc.getenv(b"ADV09")
print(p)
print(ffi.string(p))          # 按 \0 截断,返回 bytes

实测输出:

53738 5
<cdata 'char *' 0xbae838326>
b'cffi-abi-value'

ffi.string(p) 等价于 ctypes.string_at(p);ffi.new("char[16]") 分配由 cffi 持有、随 Python 对象回收而释放的 C 内存,写进去再用 ffi.string 读出来:

dst = ffi.new("char[16]")
libc.strcpy(dst, b"copied")
print(ffi.string(dst))
arr = ffi.new("int[]", [3, 1, 2])
print(arr[0], arr[1], arr[2], len(arr))

实测输出:

b'copied'
3 1 2 3

和 ctypes 相比,ABI 模式的 cdef 是声明式的:类型写在 C 语法里,ffi 在运行期按 ABI 规则转换,省掉了手写 argtypes 的样板,也顺带做了参数个数检查。

9.1.5 cffi API 模式:真的编译

API 模式多走一步:ffi.set_source 把 C 源码和声明编译成一个真正的扩展模块,调用时不再走 ABI 解析,而是生成专门的转换代码。用 emit_c_code 落盘再手动编译(本机无 setuptools,ffi.compile() 会失败,故走 cc):

from cffi import FFI
ffi = FFI()
ffi.cdef("double cpu_loop(long n);")
ffi.set_source("_adv09_api", open("adv09lib.c").read())
ffi.emit_c_code("_adv09_api.c")          # 生成 C,不依赖 setuptools
INC=$(/opt/homebrew/opt/python@3.14/bin/python3-config --includes)
cc -bundle -undefined dynamic_lookup -O2 $INC \
   _adv09_api.c -o _adv09_api.cpython-314-darwin.so

生成后 import _adv09_api 即可调用 _adv09_api.lib.cpu_loop(...)。ABI 与 API 的核心差别是:ABI 模式在运行时按符号名找函数、按平台 ABI 猜类型转换;API 模式把转换逻辑编译进模块,因此单次调用更快,且能拿到编译期类型检查——代价是需要编译、要为目标平台分别构建。

9.1.6 GIL:四条路径实测

「FFI 调用会不会释放 GIL」是并发场景的关键,但流传的说法经常是错的。本机实测:让 4 个线程各自调同一个 C 函数 c_sleep(200000)(在 C 里 usleep 0.2 秒),看墙钟时间——若 GIL 被持有,4 个 sleep 会串行成 ~0.8s;若释放,则并发成 ~0.2s:

import ctypes, threading, time
from cffi import FFI
import _adv09_api

cdll = ctypes.CDLL("./adv09lib.dylib");  cdll.c_sleep.argtypes = [ctypes.c_long]
pydll = ctypes.PyDLL("./adv09lib.dylib"); pydll.c_sleep.argtypes = [ctypes.c_long]
ffi = FFI(); ffi.cdef("void c_sleep(long usec);")
abi = ffi.dlopen("./adv09lib.dylib")
api = _adv09_api.lib

def run(fn):
    b = threading.Barrier(4)
    def w():
        b.wait(); fn()
    ts = [threading.Thread(target=w) for _ in range(4)]
    t0 = time.perf_counter()
    for t in ts: t.start()
    for t in ts: t.join()
    return time.perf_counter() - t0

实测结果(4 线程 × 0.2s):

纯 Python time.sleep         4-thread wall = 0.206s
ctypes.CDLL                  4-thread wall = 0.211s
ctypes.PyDLL                 4-thread wall = 0.831s
cffi ABI (ffi.dlopen)        4-thread wall = 0.211s
cffi API (compiled)          4-thread wall = 0.211s

结论有三条,其中两条和常见认知相反:

路径是否释放 GIL机制
ctypes.CDLL释放调用前后 Py_BEGIN/END_ALLOW_THREADS
ctypes.PyDLL不释放专为「要回调 Python」设计,必须持锁
cffi ABI(ffi.dlopen)释放_cffi_backend 在外呼时放锁
cffi API(编译)释放生成的包装函数里 Py_BEGIN_ALLOW_THREADS

ctypes 默认的 CDLL 是放锁的,真正持锁的是 PyDLL——PyDLL 的存在意义就是给那些会回调进 Python 的 C 库用(放锁期间调 Python 会崩)。而 cffi 的 ABI 模式(cffi 2.1.1)实测也释放 GIL,并不像某些资料说的「ABI 模式必然持锁、要靠 release_gil 手动放」;cffi 2.1.1 的 FFI 对象上根本没有 release_gil 这个名字('release_gil' in dir(ffi) 实测为 False)。要验证自己的扩展到底放不放锁,就用上面这个 sleep 测试,别背结论。

单次调用的固定开销也一并实测(c_sleep(0),每项 20 万次取均值):

路径单次开销
ctypes.CDLL440 ns
ctypes.PyDLL404 ns
cffi ABI319 ns
cffi API262 ns

ctypes 每个参数都要在 Python 侧按 argtypes 转换,最慢;cffi ABI 用 _cffi_backend 直接转换,快一档;cffi API 把转换编译成 C,最快。但这些都是纳秒级常数——只有当 C 函数本身极短、且被调用上百万次时,这几十纳秒的差距才值得在意;真到那个量级,先考虑减少跨语言调用次数,而不是换 FFI 库。

小结

  • ctypes.CDLL 就是 dlopen 的封装,CDLL(None) 走 dlopen(NULL) 拿主程序符号;不设 restype 会被当成 C int 静默截断。
  • 读 errno 必须 CDLL(..., use_errno=True),否则 get_errno() 永远是 0;c_char_p 复制成 bytes,POINTER(c_char) 只是裸地址,要用 string_at 立刻拷走。
  • Structure 的字段顺序/类型必须和 C 头文件逐字段对齐,byref 传址、sizeof 可验证对齐填充。
  • cffi 的 ABI 模式(ffi.dlopen)零编译、声明式;API 模式(set_source + 编译)把转换逻辑编进模块,单次调用更快。
  • GIL 实测:ctypes.CDLL 释放、ctypes.PyDLL 持有;cffi 的 ABI 与 API 模式都释放(cffi 2.1.1 无 release_gil 这个 API)。
  • 单次调用开销:cffi API(262 ns)< cffi ABI(319 ns)< ctypes(约 400–440 ns),量级很小,别为它过度优化。

到这里,「调用现成 C 库」的两条路线就讲透了。下一节我们把目光从「调用」转向「编译」——用 Cython 把 Python 热点直接编译成 C,并和 NumPy 的内存视图配合。

阅读导航:上一节:注解的求值时机(PEP 649/749) · 下一节:Cython 与 NumPy 加速 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时