TLDR 短版本加粗。
对 Python 源代码的引用基于 2.7.6 版本。
Python 通过动态加载导入大多数用 C 编写的扩展。 动态加载是一个深奥的话题,没有很好的文档记录,但它是一个绝对的先决条件。在解释如何Python使用它之前,我必须简要解释一下它是什么以及为什么Python使用它。
从历史上看,Python 的 C 扩展与 Python 解释器本身是静态链接的。这要求 Python 用户每次想要使用用 C 编写的新模块时都重新编译解释器。正如您可以想象的那样,正如 Guido van Rossum describes,随着社区的发展,这变得不切实际。今天,大多数 Python 用户从未编译过一次解释器。我们只需“pip install module”,然后“import module”,即使该模块包含已编译的 C 代码。
链接允许我们跨编译的代码单元进行函数调用。动态加载解决了在运行时决定链接什么时链接代码的问题。也就是说,它允许正在运行的程序与链接器交互并告诉链接器它想要链接的内容。 Python 解释器需要使用 C 代码导入模块。 编写在运行时做出此决定的代码非常少见,大多数程序员会惊讶于它是可能的。简单地说,一个 C 函数有一个地址,它希望你将某些数据放在某些地方,并且它承诺在返回时将某些数据放在某些地方。如果你知道秘密握手,你可以调用它。
动态加载的挑战在于程序员有责任正确握手,并且没有安全检查。至少,它们不是为我们提供的。通常,如果我们尝试使用不正确的签名调用函数名,我们会得到编译或链接器错误。 通过动态加载,我们在运行时通过名称(“符号”)向链接器请求函数。链接器可以告诉我们是否找到了该名称,但无法告诉我们如何调用该函数。它只是给了我们一个地址——一个空指针。我们可以尝试强制转换为某种函数指针,但完全取决于程序员是否正确地进行强制转换。如果我们在强制转换中弄错了函数签名,那么编译器或链接器就太迟了警告我们。在程序失控并最终不恰当地访问内存后,我们可能会遇到段错误。 使用动态加载的程序必须依赖预先安排的约定和在运行时收集的信息才能进行正确的函数调用。在我们处理 Python 解释器之前,这是一个小示例。
文件 1:main.c
/* gcc-4.8 -o main main -ldl */
#include <dlfcn.h> /* key include, also in Python/dynload_shlib.c */
/* used for cast to pointer to function that takes no args and returns nothing */
typedef void (say_hi_type)(void);
int main(void) {
/* get a handle to the shared library dyload1.so */
void* handle1 = dlopen("./dyload1.so", RTLD_LAZY);
/* acquire function ptr through string with name, cast to function ptr */
say_hi_type* say_hi1_ptr = (say_hi_type*)dlsym(handle1, "say_hi1");
/* dereference pointer and call function */
(*say_hi1_ptr)();
return 0;
}
/* error checking normally follows both dlopen() and dlsym() */
文件 2:dyload1.c
/* gcc-4.8 -o dyload1.so dyload1.c -shared -fpic */
/* compile as C, C++ does name mangling -- changes function names */
#include <stdio.h>
void say_hi1() {
puts("dy1: hi");
}
这些文件是单独编译和链接的,但 main.c 知道在运行时去寻找 ./dyload1.so。 main 中的代码假定 dyload1.so 将有一个符号“say_hi1”。它使用 dlopen() 获取 dyload1.so 的符号的句柄,使用 dlsym() 获取符号的地址,假定它是一个不带参数且不返回任何内容的函数,然后调用它。它无法确定“say_hi1”是什么——事先的协议可以防止我们出现段错误。
我上面展示的是 dlopen() 系列函数。 Python 部署在许多平台上,并非所有平台都提供 dlopen(),但大多数平台都有类似的动态加载机制。 Python 通过将多个操作系统的动态加载机制封装在一个通用接口中来实现可移植的动态加载。
Python/importdl.c 中的这条注释总结了策略。
/* ./configure sets HAVE_DYNAMIC_LOADING if dynamic loading of modules is
supported on this platform. configure will then compile and link in one
of the dynload_*.c files, as appropriate. We will call a function in
those modules to get a function pointer to the module's init function.
*/
如前所述,在 Python 2.7.6 中,我们有这些 dynload*.c 文件:
Python/dynload_aix.c Python/dynload_beos.c Python/dynload_hpux.c
Python/dynload_os2.c Python/dynload_stub.c Python/dynload_atheos.c
Python/dynload_dl.c Python/dynload_next.c Python/dynload_shlib.c
Python/dynload_win.c
他们每个人都用这个签名定义了一个函数:
dl_funcptr _PyImport_GetDynLoadFunc(const char *fqname, const char *shortname,
const char *pathname, FILE *fp)
这些函数包含针对不同操作系统的不同动态加载机制。在 10.2 之后的 Mac OS 和大多数 Unix(-like) 系统上动态加载的机制是 dlopen(),在 Python/dynload_shlib.c 中调用。
略读 dynload_win.c,Windows 的类似函数是 LoadLibraryEx()。它的用法看起来很相似。
在 Python/dynload_shlib.c 的底部,您可以看到对 dlopen() 和 dlsym() 的实际调用。
handle = dlopen(pathname, dlopenflags);
/* error handling */
p = (dl_funcptr) dlsym(handle, funcname);
return p;
就在此之前,Python 用它要查找的函数名组成字符串。模块名称在 shortname 变量中。
PyOS_snprintf(funcname, sizeof(funcname),
LEAD_UNDERSCORE "init%.200s", shortname);
Python 只是希望有一个名为 init{modulename} 的函数并向链接器请求它。从这里开始,Python 依靠一小组约定来使 C 代码的动态加载成为可能且可靠。
让我们看看 C 扩展必须做些什么来履行使上述 dlsym() 调用起作用的合同。 对于已编译的 C Python 模块,允许 Python 访问已编译 C 代码的第一个约定是 init{shared_library_filename}() 函数。 对于编译为名为“spam.so”的共享库的a module named spam,我们可能会提供这个 initspam() 函数:
PyMODINIT_FUNC
initspam(void)
{
PyObject *m;
m = Py_InitModule("spam", SpamMethods);
if (m == NULL)
return;
}
如果 init 函数的名称与文件名不匹配,Python 解释器无法知道如何找到它。例如,将 spam.so 重命名为 notspam.so 并尝试导入会得到以下结果。
>>> import spam
ImportError: No module named spam
>>> import notspam
ImportError: dynamic module does not define init function (initnotspam)
如果违反了命名约定,则根本无法判断共享库是否包含初始化函数。
第二个关键约定是,一旦调用,init 函数负责通过调用 Py_InitModule 来初始化自身。此调用将模块添加到由映射的解释器保存的“字典”/哈希表中模块名称到模块数据。它还在方法表中注册 C 函数。调用 Py_InitModule 后,模块可能会以其他方式初始化自己,例如添加对象。 (例如:the SpamError object in the Python C API tutorial)。 (Py_InitModule 实际上是一个宏,它创建真正的 init 调用,但包含一些信息,例如我们编译的 C 扩展使用的 Python 版本。)
如果 init 函数有正确的名称但没有调用 Py_InitModule(),我们会得到:
SystemError: dynamic module not initialized properly
我们的方法表恰好被称为 SpamMethods,看起来像这样。
static PyMethodDef SpamMethods[] = {
{"system", spam_system, METH_VARARGS,
"Execute a shell command."},
{NULL, NULL, 0, NULL}
};
方法表本身和它所包含的函数签名契约是 Python 理解动态加载的 C 所必需的第三个也是最后一个关键约定。方法表是一个结构 PyMethodDef 的数组,带有一个最后的哨兵进入。一个 PyMethodDef 在 Include/methodobject.h 中定义如下。
struct PyMethodDef {
const char *ml_name; /* The name of the built-in function/method */
PyCFunction ml_meth; /* The C function that implements it */
int ml_flags; /* Combination of METH_xxx flags, which mostly
describe the args expected by the C func */
const char *ml_doc; /* The __doc__ attribute, or NULL */
};
这里的关键部分是第二个成员是 PyCFunction。我们传入了一个函数的地址,那么 PyCFunction 是什么?它是一个typedef,也在Include/methodobject.h中
typedef PyObject *(*PyCFunction)(PyObject *, PyObject *);
PyCFunction 是指向函数的指针的类型定义,该函数返回指向 PyObject 的指针,并将两个指向 PyObjects 的指针作为参数。 作为约定三的引理,在方法表中注册的 C 函数都具有相同的签名。
Python 通过使用一组有限的 C 函数签名来规避动态加载的大部分困难。大多数 C 函数都使用一个签名。 指向带有附加参数的 C 函数的指针可以通过强制转换为 PyCFunction 来“潜入”。 (请参阅Python C API tutorial 中的 keywdarg_parrot 示例。)即使是备份 Python 中不带参数的 Python 函数的 C 函数也会在 C 中使用两个参数(如下所示)。所有函数也应该返回一些东西(可能只是 None 对象)。在 Python 中接受多个位置参数的函数必须从 C 中的单个对象中解压缩这些参数。
这就是获取和存储用于与动态加载的 C 函数接口的数据的方式。最后,这是一个如何使用该数据的示例。
这里的上下文是,我们正在逐条评估 Python“操作码”,并且我们遇到了一个函数调用操作码。 (请参阅https://docs.python.org/2/library/dis.html。值得一读。)我们已经确定 Python 函数对象由 C 函数支持。在下面的代码中,我们检查 Python 中的函数是否不带参数(在 Python 中),如果是,则调用它(在 C 中带有两个参数)。
Python/ceval.c.
if (flags & (METH_NOARGS | METH_O)) {
PyCFunction meth = PyCFunction_GET_FUNCTION(func);
PyObject *self = PyCFunction_GET_SELF(func);
if (flags & METH_NOARGS && na == 0) {
C_TRACE(x, (*meth)(self,NULL));
}
它当然需要 C 中的参数 - 正好两个。由于 Python 中的一切都是对象,因此它有一个 self 参数。在底部,您可以看到 meth 被分配了一个函数指针,然后将其取消引用并调用。返回值以 x 结尾。