Python 慢在解释执行和动态类型,但慢的部分往往只占代码的一小部分。把性能关键路径交回给 C,是工程上最常用的优化手段之一。ctypes 是 Python 标准库自带的方案:不需要写一行 C 扩展代码,只要把 C 代码编译成动态库,Python 就能直接加载并调用里面的函数。下面我们从选型讲起,覆盖动态库的编译、类型的映射、指针与缓冲区的处理、结构体与数组的绑定,最后用一个完整的例子串起来,再聊聊常见误区和性能边界。看完你就能把存量 C 库或自己写的 C 函数接进 Python。
1. 三条调用路线
需要把 C 代码交给 Python 调用的场景很常见:核心算法用 C 实现过,不想用 Python 重写,逐元素循环太慢,要把整个循环下沉到 C,要对接系统库或第三方 C 库的 API。实现方案主要有三条:
| 方案 | 原理 | 要不要编译 | 学习成本 | 性能 |
|---|---|---|---|---|
| ctypes | 加载动态库,Python 侧声明式绑定 | 只编译 C 侧 | 低 | 调用有装箱开销,粗粒度调用够用 |
| Cython | 类 Python 语法编译成 C | 需要 | 中 | 接近 C,可写类型化热路径 |
| Python/C API | 手写 C 扩展模块 | 需要 | 高 | 最彻底,可精细控制 |
ctypes 是最轻的一条路:C 侧照常编译成动态库,Python 侧不需要任何编译步骤,适合快速把一个现成库用起来。Cython 写起来像 Python,但产物接近 C,适合从 Python 代码渐进式优化出热路径。Python/C API 是写真正扩展模块的路线,控制力最强,但每次改动都要编译安装,工程开销最大。这一篇我们就聚焦 ctypes:它解决的是已经有一份 C 代码或 C 库、怎么最快让 Python 用起来的问题。
2. 编译动态库
ctypes 加载的是操作系统标准的动态库,不是可执行文件。C 源码写好之后,三个平台的编译命令分别是:
macOS: gcc -dynamiclib -o libmy.dylib test.c Linux: gcc -shared -fPIC -o libmy.so test.c Windows: cl /LD test.c # MSVC 编译器
macOS 的 -dynamiclib 产出 .dylib,Linux 的 -shared -fPIC 产出 .so,Windows 的 cl /LD 产出 .dll。编译完可以用符号工具验证函数确实导出了:macOS 上 nm -gU libmy.dylib,Linux 上 nm -D libmy.so,Windows 上 dumpbin /EXPORTS libmy.dll,函数名应该出现在列表里。
这里有两个容易翻车的点。第一,ctypes 只能按函数名调用 C 符号,如果用 C++ 编译器编译,函数名会被名字修饰(name mangling)改写,Python 端按原函数名根本找不到。C++ 源码里的函数要包一层 extern “C”,或把整个头文件用 extern “C” 包起来。第二,调用约定:64 位平台默认约定基本统一,但 32 位 Windows 上 cdecl 和 stdcall 有区别,ctypes 分别用 CDLL 和 WinDLL 加载,后面第 7 节再展开。
3. 类型映射
C 的 int、float 这些类型在 Python 里没有一一对应的概念,ctypes 提供了一组构造器,名字直接沿用 C 类型名。构造一个 c_int(20) 就相当于在 C 里声明 int a = 20,通过 .value 属性读回 Python 的值。常用对照如下:
| ctypes 构造器 | 对应的 C 类型 | 常见宽度 |
|---|---|---|
| c_short | short | 2 字节 |
| c_int | int | 4 字节 |
| c_long | long | 平台相关 |
| c_longlong | long long | 8 字节 |
| c_float | float | 4 字节 |
| c_double | double | 8 字节 |
| c_size_t | size_t | 与指针同宽 |
| c_char_p | char * | 指针宽度 |
| c_void_p | void * | 指针宽度 |
下面这段代码把常用类型各造一个对象,打印对象本身和它的 .value:
from ctypes import *
def test():
a = c_short(10)
b = c_int(20)
c = c_long(30)
d = c_longlong(40)
e = c_float(3.14)
f = c_double(3.14)
print(a, a.value)
print(b, b.value)
print(c, c.value)
print(d, d.value)
print(e, e.value)
print(f, f.value)
if __name__ == '__main__':
test()
运行结果:
c_short(10) 10 c_int(20) 20 c_long(30) 30 c_longlong(40) 40 c_float(3.140000104904175) 3.140000104904175 c_double(3.14) 3.14
值得注意 c_float 那一行:传进去 3.14,读回来变成 3.140000104904175。这不是 bug,是 float32 的精度限制。float 只有约 7 位有效十进制数字,3.14 在 32 位浮点里没有精确表示,存的是最接近的二进制近似值,打印出来就带一长串尾数。c_double 是 64 位浮点,能表示到约 15 位,所以 3.14 看起来还是 3.14。写数值程序时,C 端用 float 还是 double,Python 侧读回的结果精度可能完全不同,这个差异要提前想清楚。
4. 指针与缓冲区
C 函数经常要求调用方传一个地址进来,函数往这块内存写结果,这就是出参。Python 没有指针概念,ctypes 用三个工具补齐:pointer() 创建真正的指针对象,byref() 轻量地把地址传给函数,create_string_buffer() 申请一块可写的字节缓冲区。
pointer() 返回一个 LP_xxx 对象,可以长期持有,用 .contents 读写它指向的值:
from ctypes import * a = c_int(20) p = pointer(a) # 把 a 包成指针对象 print(p) # LP_c_int 对象,0x 后地址每次运行不同 print(p.contents) # 指针指向的值:c_int(20) p.contents.value = 99 # 通过指针改值 print(a.value) # 99,原变量同步变化
pointer() 包装的是同一块内存,通过指针改值,原变量能看到新值。大多数 C 函数的指针参数只是把地址传进去,并不需要长期保存这个指针对象,用 byref(a) 更轻:
/* add.c */
void add_one(int *p) {
if (p) *p += 1;
}
from ctypes import *
lib = CDLL('./libadd.dylib')
lib.add_one.argtypes = [POINTER(c_int)] # 声明参数类型
a = c_int(20)
lib.add_one(byref(a)) # 只传地址,不创建指针对象
print(a.value) # 21
byref() 不创建指针对象,只传地址,是 C 函数形参的最佳搭档,声明 argtypes 之后,ctypes 会在调用前核对参数类型,传错立即抛异常而不是段错误。
字符串和字节缓冲区是另一个高频场景。C 的 char* 分两种:只读的字符串常量可以用 c_char_p 绑定,要写回数据的缓冲区必须用 create_string_buffer() 申请可写内存:
from ctypes import * buf = create_string_buffer(6) # 6 字节可写缓冲区 print(buf.raw) # b'\x00\x00\x00\x00\x00\x00',全部原始字节 print(buf.value) # b'',读到第一个 \x00 就停 buf.value = b'hello' print(buf.raw) # b'hello\x00' print(buf.value) # b'hello',按 C 字符串读
.raw 返回缓冲区全部原始字节,.value 按 C 字符串规则读到第一个 \x00 为止。同一个缓冲区两个视角,得到不同结果,这在调试 C 函数写回的数据时非常有用。
5. 结构体与数组
除了基本类型,ctypes 支持把 C 的结构体、数组、联合体完整映射过来。结构体用继承 Structure 的类描述,_fields_ 列出字段:
from ctypes import *
class Point(Structure):
_fields_ = [('x', c_int), ('y', c_int)]
pt = Point(3, 4)
print(pt.x, pt.y) # 3 4
arr = (c_int * 5)(1, 2, 3, 4, 5) # C 数组:5 个 int
print(arr[2]) # 3
print(list(arr)) # [1, 2, 3, 4, 5]
Point(3, 4) 会在内存里按 C 布局排布字段(默认自然对齐),传给 C 函数时用 byref(pt) 传结构体地址,C 端就能直接读写 x、y。数组用 (c_int * 5) 这种乘法语法创建,下标访问和 Python 列表一致,传给 C 函数时自动退化为指针。
6. 内存拷贝实战
把前面的内容串起来。假设有一个 C 函数,把 src 指向的内存逐字节拷到 dst,带空指针和非法尺寸检查,放在 test.c:
#include <stdlib.h>
#include <string.h>
void memory_copy(const void *src, void *dst, size_t ele_size, size_t ele_num)
{
if (NULL == src || NULL == dst) {
return;
}
if (ele_size <= 0 || ele_num <= 0) {
return;
}
char *new_dst = (char *)dst;
const char *new_src = (const char *)src;
for (size_t i = 0; i < ele_size * ele_num; ++i) {
*new_dst++ = *new_src++;
}
}
函数本身是逐字节拷贝,防御写在前面:src、dst 为空直接返回,尺寸不大于 0 也返回,循环里两个指针都强转成 char*,每次 ++ 恰好走一个字节,循环次数等于 ele_size 乘 ele_num。按第 2 节命令编译成动态库后,Python 端加载并调用:
from ctypes import *
lib = CDLL('./libmy.dylib')
# 调用前把函数签名声明清楚
lib.memory_copy.argtypes = [c_void_p, c_void_p, c_size_t, c_size_t]
lib.memory_copy.restype = None
src = b'hello world' # C 函数要字节,不要 str
dst = create_string_buffer(len(src) + 1) # 多留 1 字节给结束符
print(dst.raw) # b'\x00' * 12
lib.memory_copy(src, dst, 1, len(src)) # 拷贝 11 个有效字节
print(dst.raw) # b'hello world\x00'
print(dst.value) # b'hello world',读到结束符
逐个看关键点。CDLL 把动态库加载进当前进程,返回值上的每个属性对应库里导出的一个函数。src 用 b’hello world’ 而不是 str,因为 C 函数看到的是字节缓冲区,Python 3 的 str 是 Unicode,直接传会报错。dst 用 create_string_buffer(len(src) + 1) 申请 12 字节,比内容长 1 是给结束符留位置。调用时 ele_size=1、ele_num=11,正好拷贝 11 个有效字节,dst 末尾初始化时的 \x00 保留为结束符。
运行结果:拷贝前 dst.raw 是 12 个 \x00、dst.value 是空,拷贝后 raw 是 b’hello world\x00’,value 是 b’hello world’。raw 和 value 两个视角,正好对应 C 端看原始字节和按 C 字符串读两种习惯。
7. 声明函数签名
ctypes 默认把 C 函数的返回值当成 int 解析,也允许任何参数传进来而不做检查——这正是段错误的温床。生产代码里,调用任何 C 函数之前都应该把签名声明清楚:
lib.add.argtypes = [c_int, c_int] # 参数类型 lib.add.restype = c_int # 返回值类型 lib.get_double.restype = c_double # 返回 double,不设会按 int 解析 lib.get_ptr.restype = c_void_p # 返回指针 lib.get_str.restype = c_char_p # 返回 C 字符串
restype 告诉 ctypes 返回值怎么解释。不设 restype,返回 double 的函数会被当成 int 解析,Python 端拿到一个完全错误的值,返回指针的会被截断,后续解引用直接崩。argtypes 的价值不只是文档:设置之后,ctypes 会在每次调用前按声明做类型检查,传错类型立即抛 TypeError,还会做自动转换,比如 Python 的 int 自动转成 c_int、str 按 UTF-8 编码后转成 c_char_p。把错误拦在调用前,比段错误好排查得多。
Windows 上还有一个调用约定问题:32 位下,cdecl(C 默认)用 CDLL 加载,stdcall(Win32 API 常用)用 WinDLL 加载,64 位平台两者统一,CDLL 足够。
8. 常见误区
误区一:以为 c_int 永远是 4 字节。c_int 对应 C 的 int,主流 64 位平台上是 4 字节,但标准只保证至少 2 字节,某些平台可能不同。要固定宽度,用 c_int32、c_uint64 这类带位宽后缀的类型,跨平台才稳。
误区二:传字符串忘了转 bytes。C 函数要 char*,Python 3 的 str 是 Unicode,ctypes 不会自动编码,直接传 str 会抛 ArgumentError。统一用 b’…’ 或 str.encode(‘utf-8’) 转成字节再传。
误区三:缓冲区没多留一个字节。C 端按字符串处理并写结束符时,缓冲区要比有效内容长 1。结束符写到越界位置,轻则串改相邻数据,重则段错误。create_string_buffer(len(s) + 1) 是标准写法。
误区四:没设 restype 就调用。默认按 int 解析返回值,double 或指针的函数拿到的全是错值。写调用代码的第一件事,就是把 argtypes 和 restype 声明好。
9. 性能与边界
ctypes 调用的开销不能忽略:每次调用要把 Python 参数装箱成 C 表示、调用完再拆箱,固定开销在微秒量级。所以正确用法是粗粒度调用——一次调用完成大量工作,而不是在 Python 循环里逐元素调用 C 函数,后者每次调用都付一遍装箱拆箱,往往比纯 Python 还慢。想加速的是整个循环,不是循环里的每个元素。
另一个特点:ctypes 调用外部 C 函数期间会释放 GIL,耗时较长的 C 函数不会阻塞 Python 的其他线程,多线程并行跑 C 计算天然合适,但要操作 Python 对象(比如回调里访问 list),还是回到 GIL 保护下。
什么时候不该用 ctypes:热路径上高频、细粒度的小调用,需要跨很多 Python 对象做复杂交互,追求极限性能。这些场景换 Cython 或 Python/C API 更划算。ctypes 的定位是把存量 C 库和简单 C 函数快速接进 Python,选对场景,它就是性价比最高的方案。
在 Python 里调用 C 函数,核心就四步:把 C 代码编译成动态库,用构造器把 C 类型映射成 Python 对象,用 pointer、byref、create_string_buffer 处理指针和缓冲区,用 argtypes、restype 把函数签名声明清楚。类型映射、指针语义、调用约定这三块想明白,大多数 ctypes 问题都能解决。调用是有开销的,记得粗粒度调用、把热循环整体下沉到 C,收益才最大。

冀公网安备13050302001966号