《深度剖析CPython解释器》34. 侵入 Python 虚拟机,动态修改底层数据结构和运行时
作者:互联网
楔子
之前分析了那么久的虚拟机,多少会有点无聊,那么本次我们来介绍一个好玩的,看看如何修改 Python 解释器的底层数据结构和运行时。了解虚拟机除了可以让我们写出更好的代码之外,还可以对 Python 进行改造。举个栗子:
是不是很有趣呢?通过 Python 内置的 ctypes 模块即可做到,而具体实现方式我们一会儿说。所以本次我们的工具就是 ctypes 模块(Python 版本为 3.8),需要你对它已经或多或少有一些了解,哪怕只有一点点也是没关系的。
注意:本次介绍的内容绝不能用于生产环境,仅仅只是为了更好地理解 Python 虚拟机、或者做测试的时候使用,用于生产环境是绝对的大忌。
不可用于生产环境!!!
不可用于生产环境!!!
不可用于生产环境!!!
那么废话不多说,下面就开始吧。
使用 Python 表示 C 的数据结构
Python 是用 C 实现的,如果想在 Python 的层面修改底层逻辑,那么我们肯定要能够将 C 的数据结构用 Python 表示出来。而 ctypes 提供了大量的类,专门负责做这件事情,下面按照类型属性分别介绍。
数值类型
C 语言的数值类型分为如下:
int:整型
unsigned int:无符号整型
short:短整型
unsigned short:无符号短整型
long:长整形
unsigned long:无符号长整形
long long:64 位机器上等同于 long
unsigned long long:64 位机器上等同于 unsigned long
float:单精度浮点型
double:双精度浮点型
long double:看成是 double 即可
_Bool:布尔类型
ssize_t:等同于 long 或者 long long
size_t:等同于 unsigned long 或者 unsigned long long
和 Python 以及 ctypes 之间的对应关系如下:
下面来演示一下:
import ctypes
# 下面都是 ctypes 中提供的类,将 Python 中的数据传进去,就可以转换为 C 的数据
print(ctypes.c_int(1)) # c_long(1)
print(ctypes.c_uint(1)) # c_ulong(1)
print(ctypes.c_short(1)) # c_short(1)
print(ctypes.c_ushort(1)) # c_ushort(1)
print(ctypes.c_long(1)) # c_long(1)
print(ctypes.c_ulong(1)) # c_ulong(1)
# c_longlong 等价于 c_long,c_ulonglong 等价于 c_ulong
print(ctypes.c_longlong(1)) # c_longlong(1)
print(ctypes.c_ulonglong(1)) # c_ulonglong(1)
print(ctypes.c_float(1.1)) # c_float(1.100000023841858)
print(ctypes.c_double(1.1)) # c_double(1.1)
# 在64位机器上,c_longdouble等于c_double
print(ctypes.c_longdouble(1.1)) # c_double(1.1)
print(ctypes.c_bool(True)) # c_bool(True)
# 相当于c_longlong和c_ulonglong
print(ctypes.c_ssize_t(10)) # c_longlong(10)
print(ctypes.c_size_t(10)) # c_ulonglong(10)
而 C 的数据转成 Python 的数据也非常容易,只需要在此基础上调用一下 value 即可。
import ctypes
print(ctypes.c_int(1024).value) # 1024
print(ctypes.c_int(1024).value == 1024) # True
字符类型
C 语言的字符类型分为如下:
char:一个 ascii 字符或者 -128~127 的整型
wchar:一个 unicode 字符
unsigned char:一个 ascii 字符或者 0~255 的一个整型
和 Python 以及 ctypes 之间的对应关系如下:
举个栗子:
import ctypes
# 必须传递一个字节(里面是 ascii 字符),或者一个 int,来代表 C 里面的字符
print(ctypes.c_char(b"a")) # c_char(b'a')
print(ctypes.c_char(97)) # c_char(b'a')
# 和 c_char 类似,但是 c_char 既可以传入单个字节、也可以传整型
# 而这里的 c_byte 和则要求必须传递整型
print(ctypes.c_byte(97)) # c_byte(97)
# 传递一个 unicode 字符,当然 ascii 字符也是可以的,并且不是字节形式
print(ctypes.c_wchar("憨")) # c_wchar('憨')
# 同样只能传递整型,
print(ctypes.c_ubyte(97)) # c_ubyte(97)
数组
下面看看如何构造一个 C 中的数组:
import ctypes
# C 里面创建数组的方式如下:int a[5] = {1, 2, 3, 4, 5}
# 使用 ctypes 的话
array = (ctypes.c_int * 5)(1, 2, 3, 4, 5)
# (ctypes.c_int * N) 等价于 int a[N],相当于构造出了一个类型,然后再通过类似函数调用的方式指定数组的元素即可
# 这里指定元素的时候直接输入数字即可,会自动转成 C 中的 int,当然我们也可以使用 c_int 手动包装
print(len(array)) # 5
print(array) # <__main__.c_int_Array_5 object at 0x7f96276fd4c0>
for i in range(len(array)):
print(array[i], end=" ") # 1 2 3 4 5
print()
array = (ctypes.c_char * 3)(97, 98, 99)
print(list(array)) # [b'a', b'b', b'c']
我们看一下数组在 Python 里面的类型,因为数组存储的元素类型为 c_int、数组长度为 5,所以这个数组在 Python 里面的类型就是 c_int_Array_5,而打印的时候则显示为 c_int_Array_5 的实例对象。我们可以调用 len 方法获取长度,也可以通过索引的方式去指定的元素,并且由于内部实现了迭代器协议,我们还可以使用 for 循环去遍历,或者使用 list 直接转成列表等等,都是可以的。
结构体
结构体应该是 C 里面最重要的结构之一了,假设 C 里面有这样一个结构体:
typedef struct {
int field1;
float field2;
long field3[5];
} MyStruct;
要如何在 Python 里面表示它呢?
import ctypes
# C 中的结构体在 Python 里面显然通过类来实现,但是这个类一定要继承 ctypes.Structure
class MyStruct(ctypes.Structure):
# 结构体的每一个成员对应一个元组,第一个元素为字段名,第二个元素为类型
# 然后多个成员放在一个列表中,并用变量 _fields_ 指定
_fields_ = [
("field1", ctypes.c_int),
("field2", ctypes.c_float),
("field3", (ctypes.c_long * 5)),
]
# field1、field2、field3 就类似函数参数一样,可以通过位置参数、关键字参数指定
s = MyStruct(field1=ctypes.c_int(123),
field2=ctypes.c_float(3.14),
field3=(ctypes.c_long * 5)(11, 22, 33, 44, 55))
print(s) # <__main__.MyStruct object at 0x7ff9701d0c40>
print(s.field1) # 123
print(s.field2) # 3.140000104904175
print(s.field3) # <__main__.c_long_Array_5 object at 0x7ffa3a5f84c0>
就像实例化一个普通的类一样,然后也可以像获取实例属性一样获取结构体成员。这里获取之后会自动转成 Python 中的数据,比如 c_int 类型会自动转成 int,c_float 会自动转成 float,而数组由于 Python 没有内置,所以直接打印为 "c_long_Array_5 的实例对象"。
指针
指针是 C 语言灵魂,而且绝大部分的 Bug 也都是指针所引起的,那么指针类型在 Python 里面如何表示呢?非常简单,通过 ctypes.POINTER 即可表示 C 的指针类型,比如:
C 中的 int *,在 Python 里面就是 ctypes.POINTER(c_int)
C 中的 float *,在 Python 里面就是 ctypes.POINTER(c_float)
from ctypes import *
class MyStruct(Structure):
_fields_ = [
("field1", POINTER(c_long)),
("field2", POINTER(c_double)),
]
所以通过 POINTER(类型) 即可表示对应类型的指针,而获取指针则是通过 pointer 函数。
# 在 C 里面就相当于,long a = 1024; long *p = &a;
p = pointer(c_long(1024))
print(p) # <__main__.LP_c_long object at 0x7ff3639d0dc0>
print(p.__class__) # <class '__main__.LP_c_long'>
# pointer 可以获取任意类型的指针
print(pointer(c_float(3.14)).__class__) # <class '__main__.LP_c_float'>
print(pointer(c_double(2.71)).__class__) # <class '__main__.LP_c_double'>
同理,我们也可以通过指针获取指向的值,也就是对指针进行解引用。
from ctypes import *
p = pointer(c_long(123))
# 调用 contents 即可获取指向的值,相当于对指针进行解引用
print(p.contents) # c_long(123)
print(p.contents.value) # 123
# 如果对 p 再使用一次 pointer 函数,那么相当于获取 p 的指针
# 此时相当于二级指针 long **,所以类型为 LP_LP_c_long
print(pointer(pointer_p)) # <__main__.LP_LP_c_long object at 0x7fe6121d0bc0>
# 三级指针,类型为 LP_LP_LP_c_long
print(pointer(pointer(pointer_p))) # <__main__.LP_LP_LP_c_long object at 0x7fb2a29d0bc0>
# 三次解引用,获取对应的值
print(pointer(pointer(pointer_p)).contents.contents.contents) # c_long(123)
print(pointer(pointer(pointer_p)).contents.contents.contents.value) # 123
总的来说,还是比较好理解的。但我们知道,在 C 中数组等于数组首元素的地址,我们除了传一个指针过去之外,传数组也是可以的。
from ctypes import *
class MyStruct(Structure):
_fields_ = [
("field1", POINTER(c_long)),
("field2", POINTER(c_double)),
]
# 结构体也可以先创建,再实例化成员
s = MyStruct()
s.field1 = pointer(c_long(1024))
s.field2 = (c_double * 3)(3.14, 1.732, 2.71)
数组在作为参数传递的时候会退化为指针,所以此时数组的长度信息就丢失了,使用 sizeof 计算出来的结果就是一个指针的大小。因此将数组作为参数传递的时候,应该将当前数组的长度信息也传递过去,否则可能会访问非法的内存。
然后在 C 里面还有 char *、wchar_t *、void *,这些指针在 ctypes 里面专门提供了几个类与之对应。
from ctypes import *
# c_char_p 就是 c 里面字符数组了,其实我们可以把它看成是 Python 中的 bytes 对象
# char *s = "hello world";
# 那么这里面也要传递一个 bytes 类型的字符串,返回一个地址
print(c_char_p(b"hello world")) # c_char_p(140451925798832)
# 直接传递一个字符串,同样返回一个地址
print(c_wchar_p("古明地觉")) # c_wchar_p(140451838245008)
函数
最后看一下如何在 Python 中表示 C 的函数,首先 C 的函数可以有多个参数,但只有一个返回值。举个栗子:
long add(long *a, long *b) {
return *a + *b;
}
这个函数接收两个 long *、返回一个 long,那么这种函数类型要如何表示呢?答案是通过 ctypes.CFUNCTYPE。
from ctypes import *
# 第一个参数是函数的返回值类型,然后函数的参数写在后面,有多少写多少
# 比如这里的函数返回一个 long,接收两个 long *,所以就是
t = CFUNCTYPE(c_long, POINTER(c_long), POINTER(c_long))
# 如果函数不需要返回值,那么写一个 None 即可
# 然后得到一个类型 t,此时的类型 t 就等同于 C 中的 typedef long (*t)(long*, long*);
# 定义一个 Python 函数,a、b 为 long *,返回值为 c_long
def add(a, b):
return a.contents.value + b.contents.value
# 将我们自定义的函数传进去,就得到了 C 语言可以识别的函数
c_add = t(add)
print(c_add) # <CFunctionType object at 0x7fa52fa29040>
print(
c_add(pointer(c_long(22)),
pointer(c_long(33)))
) # 55
类型转换
以上就是 C 中常见的数据结构,然后再说一下类型转化,ctypes 提供了一个 cast 函数,可以将指针的类型进行转化。
from ctypes import *
# cast 的第一个参数接收的必须是某种指针的 ctypes 对象,第二个参数是 ctypes 指针类型
# 这里相当于将 long * 转成了 float *
p1 = pointer(c_long(123))
p2 = cast(p1, POINTER(c_float))
print(p2) # <__main__.LP_c_float object at 0x7f91be201dc0>
print(p2.contents) # c_float(1.723597111119525e-43)
指针在转换之后,还是引用相同的内存块,所以整型指针转成浮点型指针之后,打印的结果乱七八糟。当然数组也可以转化,我们举个栗子:
from ctypes import *
t1 = (c_int * 3)(1, 2, 3)
# 将 int * 转成 long *
t2 = cast(t1, POINTER(c_long))
print(t2[0]) # 8589934593
原来数组元素是 int 类型(4 字节),现在转成了 long(8 字节),但是内存块并没有变。因此 t2 获取元素时会一次性获取 8 字节,所以 t1[0] 和 t1[1] 组合起来等价于 t2[0]。
from ctypes import *
t1 = (c_int * 3)(1, 2, 3)
t2 = cast(t1, POINTER(c_long))
print(t2[0]) # 8589934593
print((2 << 32 & 0xFFFFFFFFFFFFFFFF) + (1 & 0xFFFFFFFFFFFFFFFF)) # 8589934593
模拟底层数据结构,观察运行时表现
我们说 Python 的对象本质上就是 C 的 malloc 函数为结构体实例在堆区申请的一块内存,比如整数是 PyLongObject、浮点数是 PyFloatObject、列表是 PyListObject,以及所有的类型都是 PyTypeObject 等等。那么在介绍完 ctypes 的基本用法之后,下面就来构造这些数据结构来观察 Python 对象在运行时的表现。
浮点数
这里先说浮点数,因为浮点数比整数要简单,先来看看底层的定义。
typedef struct {
PyObject_HEAD
double ob_fval;
} PyFloatObject;
除了 PyObject 这个公共的头部信息之外,只有一个额外的 ob_fval,用于存储具体的值,而且直接使用的 C 中的 double。
from ctypes import *
class PyObject(Structure):
"""PyObject,所有对象底层都会有这个结构体"""
_fields_ = [
("ob_refcnt", c_ssize_t),
("ob_type", c_void_p) # 类型对象一会说,这里就先用 void * 模拟
]
class PyFloatObject(PyObject):
"""定义 PyFloatObject,继承 PyObject"""
_fields_ = [
("ob_fval", c_double)
]
# 创建一个浮点数
f = 3.14
# 构造 PyFloatObject,可以通过对象的地址进行构造
# float_obj 就是浮点数 f 在底层的表现形式
float_obj = PyFloatObject.from_address(id(f))
print(float_obj.ob_fval) # 3.14
# 修改一下
print(f"f = {f},id(f) = {id(f)}") # f = 3.14,id(f) = 140625653765296
float_obj.ob_fval = 1.73
print(f"f = {f},id(f) = {id(f)}") # f = 1.73,id(f) = 140625653765296
我们修改 float_obj.ob_fval 也会影响 f,并且修改前后 f 的地址没有发生改变。同时我们也可以观察一个对象的引用计数,举个栗子:
f = 3.14
float_obj = PyFloatObject.from_address(id(f))
# 此时 3.14 这个浮点数对象被 3 个变量所引用
print(float_obj.ob_refcnt) # 3
# 再来一个
f2 = f
print(float_obj.ob_refcnt) # 4
f3 = f
print(float_obj.ob_refcnt) # 5
# 删除变量
del f2, f3
print(float_obj.ob_refcnt) # 3
所以这就是引用计数机制,当对象被引用,引用计数加 1;当引用该对象的变量被删除,引用计数减 1;当对象的引用计数为 0 时,对象被销毁。
整数
再来看看整数,我们知道 Python 中的整数是不会溢出的,换句话说,它可以计算无穷大的数。那么问题来了,它是怎么办到的呢?想要知道答案,只需看底层的结构体定义即可。
typedef struct {
PyObject_VAR_HEAD
digit ob_digit[1]; // digit 等价于 unsigned int
} PyLongObject;
明白了,原来 Python 的整数在底层是用数组存储的,通过串联多个无符号 32 位整数来表示更大的数。
from ctypes import *
class PyVarObject(Structure):
_fields_ = [
("ob_refcnt", c_ssize_t),
("ob_type", c_void_p),
("ob_size", c_ssize_t)
]
class PyLongObject(PyVarObject):
_fields_ = [
("ob_digit", (c_uint32 * 1))
]
num = 1024
long_obj = PyLongObject.from_address(id(num))
print(long_obj.ob_digit[0]) # 1024
# PyLongObject 的 ob_size 除了表示 ob_digit 数组的长度,此时显然为 1
print(long_obj.ob_size) # 1
# 但是在介绍整型的时候说过,ob_size 除了表示 ob_digit 数组的长度之外,还表示整数的符号
# 我们将 ob_size 改成 -1,再打印 num
long_obj.ob_size = -1
print(num) # -1024
# 我们悄悄地将 num 改成了负数
当然我们也可以修改值:
num = 1024
long_obj = PyLongObject.from_address(id(num))
long_obj.ob_digit[0] = 4096
print(num) # 4096
digit 是 32 位无符号整型,不过虽然占 32 个位,但是只用 30 个位,这也意味着一个 digit 能存储的最大整数就是 2 的 30 次方减 1。如果数值再大一些,那么就需要两个 digit 来存储,第二个 digit 的最低位从 31 开始。
# 此时一个 digit 能够存储的下,所以 ob_size 为 1
num1 = 2 ** 30 - 1
long_obj1 = PyLongObject.from_address(id(num1))
print(long_obj1.ob_size) # 1
# 此时一个 digit 存不下了,所以需要两个 digit,因此 ob_size 为 2
num2 = 2 ** 30
long_obj2 = PyLongObject.from_address(id(num2))
print(long_obj2.ob_size) # 2
当然了,用整数数组实现大整数的思路其实平白无奇,但难点在于大整数 数学运算 的实现,它们才是重点,也是也比较考验编程功底的地方。
字节串
字节串也就是 Python 中的 bytes 对象,在存储或网络通讯时,传输的都是字节串。bytes 对象在底层的结构体为 PyBytesObject,看一下相关定义。
typedef struct {
PyObject_VAR_HEAD
Py_hash_t ob_shash;
char ob_sval[1];
} PyBytesObject;
我们解释一下里面的成员对象:
PyObject_VAR_HEAD:变长对象的公共头部
ob_shash:保存该字节序列的哈希值,之所以选择保存是因为在很多场景都需要 bytes 对象的哈希值。而 Python 在计算字节序列的哈希值的时候,需要遍历每一个字节,因此开销比较大。所以会提前计算一次并保存起来,这样以后就不需要算了,可以直接拿来用,并且 bytes 对象是不可变的,所以哈希值是不变的
ob_sval:这个和 PyLongObject 中的 ob_digit 的声明方式是类似的,虽然声明的时候长度是 1, 但具体是多少则取决于 bytes 对象的字节数量。这是 C 语言中定义"变长数组"的技巧, 虽然写的长度是 1, 但是你可以当成 n 来用, n 可取任意值。显然这个 ob_sval 存储的是所有的字节,因此 Python 中的 bytes 对象在底层是通过字符数组存储的。而且数组会多申请一个空间,用于存储 \0,因为 C 中是通过 \0 来表示一个字符数组的结束,但是计算 ob_size 的时候不包括 \0
from ctypes import *
class PyVarObject(Structure):
_fields_ = [
("ob_refcnt", c_ssize_t),
("ob_type", c_void_p),
("ob_size", c_ssize_t)
]
class PyBytesObject(PyVarObject):
_fields_ = [
("ob_shash", c_ssize_t),
# 这里我们就将长度声明为 100
("ob_sval", (c_char * 100))
]
b = b"hello"
bytes_obj = PyBytesObject.from_address(id(b))
# 长度
print(bytes_obj.ob_size, len(b)) # 5 5
# 哈希值
print(bytes_obj.ob_shash) # 967846336661272849
print(hash(b)) # 967846336661272849
# 修改哈希值,再调用 hash 函数会发现结果变了
# 说明 hash(b) 会直接获取底层已经计算好的 ob_shash 成员的值
bytes_obj.ob_shash = 666
print(hash(b)) # 666
# 修改 ob_sval
bytes_obj.ob_sval = b"hello world"
print(b) # b'hello'
# 我们看到打印的依旧是 "hello",原因是 ob_size 为 5,只会选择前 5 个字节
# 修改之后再次打印
bytes_obj.ob_size = 11
print(b) # b'hello world'
bytes_obj.ob_size = 15
print(b) # b'hello world\x00\x00\x00\x00'
除了 bytes 对象之外,Python 中还有一个 bytearray 对象,它和 bytes 对象类似,只不过 bytes 对象是不可变的,而 bytearray 对象是可变的。
列表
Python 中的列表可以说使用的非常广泛了,在初学列表的时候,有人会告诉你列表就是一个大仓库,什么都可以存放。但我们知道,列表中存放的元素其实都是泛型指针 PyObject *。
下面来看看列表的底层结构:
typedef struct {
PyObject_VAR_HEAD
PyObject **ob_item;
Py_ssize_t allocated;
} PyListObject;
我们看到里面有如下成员:
PyObject_VAR_HEAD: 变长对象的公共头部信息
ob_item:一个二级指针,指向一个 PyObject * 类型的指针数组,这个指针数组保存的便是对象的指针,而操作底层数组都是通过 ob_item 来进行操作的。
allocated:容量, 我们知道列表底层是使用了 C 的数组, 而底层数组的长度就是列表的容量
from ctypes import *
class PyVarObject(Structure):
_fields_ = [
("ob_refcnt", c_ssize_t),
("ob_type", c_void_p),
("ob_size", c_ssize_t)
]
class PyListObject(PyVarObject):
_fields_ = [
# ctypes 下面有一个 py_object 类,它等价于底层的 PyObject *
# 但 ob_item 类型为 **PyObject,所以这里类型声明为 POINTER(py_object)
("ob_item", POINTER(py_object)),
("allocated", c_ssize_t)
]
lst = [1, 2, 3, 4, 5]
list_obj = PyListObject.from_address(id(lst))
# 列表在计算长度的时候,会直接获取 ob_size 成员的值,该值负责维护列表的长度
# 对元素进行增加、删除,ob_size 也会动态变化
print(list_obj.ob_size) # 5
print(len(lst)) # 5
# 修改 ob_size 为 2,打印列表只会显示两个元素
list_obj.ob_size = 2
print(lst) # [1, 2]
try:
lst[2] # 访问索引为 2 的元素会越界
except IndexError as e:
print(e) # list index out of range
# 修改元素,注意:ob_item 里面的元素是 PyObject*,所以这里需要调用 py_object 转一下
list_obj.ob_item[0] = py_object("标签:__,CPython,Python,虚拟机,ob,tp,PyObject,print,nb
来源: https://www.cnblogs.com/traditional/p/15489296.html