access violation error in Python x64 when using ctypes (caused by pointer)

问题描述

似乎这个问题仅存在于Windows下Python x64中,且Python 2和Python 3中的表现不一致;并且一般是由于调用的C-API包含有指针传递,出现类似如下错误

OSError: exception: access violation reading 0x0000000025F0FA60

示例

C++代码:

#include <string>
struct Foo
{
    char* child = "a child";
};

extern "C"
{
    __declspec(dllexport) char* bar(char*, char*);
    __declspec(dllexport) Foo* getFoo(void);
    __declspec(dllexport) char* getChild(Foo*);
}

char* bar(char* a, char* b)
{
    char* out = new char[strlen(a) + strlen(b) + 1];
    strcpy(out, a);
    strcat(out, b);
    return out;
}
Foo* getFoo()
{
    return new Foo();
}
char* getChild(Foo* foo)
{
    return foo->child;
}

函数bar()期望完成的是两个字符串的连接。getFoo()返回一个类指针,而getChild()接受一个类指针,返回字符串。

测试一

正常的Python代码:

import ctypes
DLL_PATH = 'C:/Test/x64/Release/Test.dll'

lib = ctypes.cdll.LoadLibrary(DLL_PATH)

a = b'wow'
b = b'python'
out = lib.bar(a, b)
text_out = ctypes.string_at(out)
print(text_out)

在Python 2.7.12下执行正确,返回

wowpython

但在Python 3.5.2下执行会提示错误:

Traceback (most recent call last):
  File "C:\Desktop\test_x64.py", line 9, in <module>
    text_out = ctypes.string_at(out)
  File "C:\Python35\lib\ctypes\__init__.py", line 491, in string_at
    return _string_at(ptr, size)
OSError: exception: access violation reading 0x000000002258AF60

ctypes.string_at(out)出现了非法的内存地址访问。

测试二

正常的Python代码:

import ctypes
DLL_PATH = 'C:/Test/x64/Release/Test.dll'

lib = ctypes.cdll.LoadLibrary(DLL_PATH)

foo = lib.getFoo()
out = lib.getChild(foo)
text_out = ctypes.string_at(out)
print(text_out)

在Python 2和Python 3中都会出现非法的内存访问,但出错的语句不一样。

在Python 2.7.12下执行提示错误:

Traceback (most recent call last):
  File "C:\Desktop\test_x64_2.py", line 8, in <module>
    text_out = ctypes.string_at(out)
  File "C:\Python27\lib\ctypes\__init__.py", line 506, in string_at
    return _string_at(ptr, size)
WindowsError: exception: access violation reading 0xFFFFFFFF83823230

ctypes.string_at(out)出现了非法的内存地址访问。

在Python 3.5.2下执行提示错误:

Traceback (most recent call last):
  File "C:\Desktop\test_x64_2.py", line 7, in <module>
    out = lib.getChild(foo)
OSError: exception: access violation reading 0xFFFFFFFFDCCDD220

lib.getChild(foo)出现了非法的内存地址访问。

原因分析

先来看看ctypes.string_at(),在官方文档里给出的描述是

ctypes.string_at(address, size=-1)

This function returns the C string starting at memory address address as a bytes object. If size is specified, it is used as size, otherwise the string is assumed to be zero-terminated.

string_at()接受的参数是内存地址,似乎隐约知道出错的原因了:给string_at()传递了一个错误的内存地址,导致程序执行时试图访问非法的内存地址,提示错误。

但传入string_at()的参数是由API直接返回的,怎么会出错呢?继续看官方文档,官方文档中提到在加载动态链接库时

Functions in these libraries use the standard C calling convention, and are assumed to return int.

意思就是C-API中任何类型的返回值,在Python中统一都是int,这样也容易理解:返回int类型就是int值,返回指针就是内存地址(毕竟指针实际就是记录内存地址的)。那是不是返回的指针地址就是错的?我们来试试看。

现在打印出返回的内存地址

import ctypes
DLL_PATH = 'C:/Test/x64/Release/Test.dll'

lib = ctypes.cdll.LoadLibrary(DLL_PATH)

a = b'wow'
b = b'python'
out = lib.bar(a, b)
print('address:', out)
text_out = ctypes.string_at(out)
print(text_out)

在Python 3.5.2运行几次,看看返回的内存地址和错误提示的关系

address: -1736381920
OSError: exception: access violation reading 0xFFFFFFFF9880EA20

address: -797043632
OSError: exception: access violation reading 0xFFFFFFFFD07E1450

address: 851060880
OSError: exception: access violation reading 0x0000000032BA2890

返回的内存地址和非法内存地址访问是一致的!而且内存地址甚至出现了负数,怎么可能!问题就是出在返回指针(即返回内存地址)上面了,无论是char*还是Foo*都有这种问题!

通过测试32位版本的动态链接库,Python 2和Python 3均未出现问题,可以确定是32位和64位兼容性问题,类似于著名的C语言x86和x64下int的长度问题。

注意:限于自己目前的水平,更深层次的原因还不得而知,但需要注意Python2和Python3下出错的表现不一致,Python2下似乎可以克服一些指针传递问题。

解决方法

通过多次尝试,我发现可以通过设置API的restype来获得正确的内存地址。

restype可以重载API的返回值类型(记得上面说的默认值为int吧),因为64位下内存地址为64位无符号型整数,因此设置为ctypes.c_uint64由于修改了返回值类型,在指针作为函数调用的输入参数时,也可能要作出对应的类型转换。

修正后的测试一

import ctypes
DLL_PATH = 'C:/Test/x64/Release/Test.dll'

lib = ctypes.cdll.LoadLibrary(DLL_PATH)

a = b'wow'
b = b'python'
lib.bar.restype = ctypes.c_uint64  # 修改lib.bar返回类型
out = lib.bar(a, b)
text_out = ctypes.string_at(out)
print(text_out)

Python 2.7.12和Python 3.5.2均能得到正确结果。

修正后的测试一

import ctypes
DLL_PATH = 'C:/Test/x64/Release/Test.dll'

lib = ctypes.cdll.LoadLibrary(DLL_PATH)

lib.getFoo.restype = ctypes.c_uint64  # 修改lib.getFoo返回类型
foo = lib.getFoo()
lib.getChild.restype = ctypes.c_uint64  # 修改lib.getChild返回类型
out = lib.getChild(ctypes.c_uint64(foo))  # 指针作为输入也要进行类型转换
text_out = ctypes.string_at(out)
print(text_out)

Python 2.7.12和Python 3.5.2均能得到正确结果。

小结

如上提到的解决方法只能算作临时的修补,我更倾向于认为这是Python自身的一个bug:没有解决64位环境下的C指针类型问题。

参考

16.16. ctypes — A foreign function library for Python; Python 3.5.2 documentation
Converting python string object to c char* using ctypes - Stack Overflow
python - How to pass pointer back in ctypes? - Stack Overflow
Python ctypes integer pointer - Stack Overflow