《深度剖析CPython解释器》34. 侵入 Python 虚拟机,动态修改底层数据结构和运行时

2021/10/31 14:09:35

本文主要是介绍《深度剖析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解释器》34. 侵入 Python 虚拟机,动态修改底层数据结构和运行时的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程