深入理解 python 虛擬機器:破解核心魔法——反序列化 pyc 檔案

一無是處的研究僧發表於2023-05-17

深入理解 python 虛擬機器:破解核心魔法——反序列化 pyc 檔案

在前面的文章當中我們詳細的對於 pyc 檔案的結構進行了分析,pyc 檔案主要有下面的四個部分組成:魔術、 Bite Filed 、修改日期和 Code Object 組成。在前面的文章當中我們已經對前面三個部分進行了位元組角度的分析,直接從 pyc 檔案當中讀取對應的資料並且列印出來了。而在本篇文章當中我們將主要對 Code Object 進行分析,並且詳細它是如何被反序列化的,透過本篇文章我們將能夠把握整個 pyc 檔案結構。

marshal 模組的魔力

序列化和反序列化 python 物件

marshal 是 python 自帶的一個模組,他可以將一些 python 內建物件進行序列化和反序列化操作,甚至我們可以在一個檔案當中序列話一個函式的 Code Object 物件,然後在另外一個檔案反序列化這個 Code Object 物件並且執行它。

我們可以使用下面的程式碼將 python 當中的一些物件序列化操作,直接將 python 對邊變成一個位元組流,儲存到磁碟當中:

import marshal


if __name__ == '__main__':
    with open("pyobjects.bin", "wb") as fp:
        marshal.dump(1, fp)
        marshal.dump(1.5, fp)
        marshal.dump("Hello World", fp)
        marshal.dump((1, 2, 3), fp)
        marshal.dump([1, 2, 3], fp)
        marshal.dump({1, 2, 3}, fp)
        marshal.dump(1+1j, fp)
        marshal.dump({1: 2, 3: 4}, fp)

在上面的程式碼當中需要注意的是需要使用二進位制方式 rb 開啟檔案,上面的程式執行完成之後會生成一個 pyobjects.bin 的二進位制檔案,我們可以使用 python 程式碼再將上面的 python 物件,比如整數、浮點數、字串和列表元組等等反序列化出來。

import marshal


if __name__ == '__main__':
    with open("pyobjects.bin", "rb") as fp:
        print(marshal.load(fp))
        print(marshal.load(fp))
        print(marshal.load(fp))
        print(marshal.load(fp))
        print(marshal.load(fp))
        print(marshal.load(fp))
        print(marshal.load(fp))
        print(marshal.load(fp))

上面的程式碼輸出結果如下所示:

1
1.5
Hello World
(1, 2, 3)
[1, 2, 3]
{1, 2, 3}
(1+1j)
{1: 2, 3: 4}

從上面程式碼的輸出結果我們可以看到我們可以將所有的被寫入到二進位制檔案當中的資料全部解析了出來。

序列化和反序列化 CodeObject

除了上面使用 marshal 對 python 的基本物件進行序列化和反序列化,我們可以使用 marshal 模組對 CodeObject 進行同樣的操作,如果是這樣的話,那麼就可以將一個檔案的程式碼序列化,然後另外一個程式反序列化再進行呼叫:

import marshal


def add(a, b):
    print("Hello World")
    return a+b


with open("add.bin", "wb") as fp:
    marshal.dump(add.__code__, fp)

在上面的程式碼當中,我們開啟了檔案 add.bin 然後將 add 函式的 CodeObject 物件寫入到檔案當中去,而 CodeObject 當中儲存了函式 add 的所有執行所需要的資訊,因此我們可以在另外一個檔案當中開啟這個檔案,然後將 CodeObject 物件反序列化出來在執行這個程式碼,我們看下面的程式碼:

import marshal


def name():
    pass


with open("add.bin", "rb+") as fp:
    code = marshal.load(fp)
name.__code__ = code
print(name(1, 2))

上面的程式碼執行結果如下所示:

Hello World
3

可以看到反序列化之後的函式 add 複製到了 name 上,然後我們呼叫了函式 name 真的實現了列印和相加的效果,從這一點來看確實實現了我們在前面所提到的效果。

Python 物件反序列化

在本節當中將主要分析 python 物件序列化之後的二進位制檔案格式,我們到底應該如何解析這個檔案,解析檔案的規則是什麼。在 cpython 當中對於每個資料型別的解析都是不一樣的,marshal 支援 python 當中所有的基本資料型別,額外還支援 CodeObject ,在上面的驗證程式碼當中我們已經使用 marshal 去做了一些序列化和反序列化操作。

在對 python 物件進行序列化的時候,每一個 python 物件主要是由兩個部分組成的:

其中 type 佔一個位元組,用於表示接下里啊的 python 的物件型別,比如如字典、元組、集合之類的,而後面的 PyObject 就是實際的 python 資料型別了,需要注意的是對於 None、False、True 這種在虛擬機器當中只有一個備份的物件,PyObject 是沒有的,也就只有 type 這一個欄位。

type 的種類具體如下所示,它只佔用一個位元組:

class TYPE(Enum):
    TYPE_NULL                 = ord('0')
    TYPE_NONE                 = ord('N')
    TYPE_FALSE                = ord('F')
    TYPE_TRUE                 = ord('T')
    TYPE_STOPITER             = ord('S')
    TYPE_ELLIPSIS             = ord('.')
    TYPE_INT                  = ord('i')
    TYPE_INT64                = ord('I')
    TYPE_FLOAT                = ord('f')
    TYPE_BINARY_FLOAT         = ord('g')
    TYPE_COMPLEX              = ord('x')
    TYPE_BINARY_COMPLEX       = ord('y')
    TYPE_LONG                 = ord('l')
    TYPE_STRING               = ord('s')
    TYPE_INTERNED             = ord('t')
    TYPE_REF                  = ord('r')
    TYPE_TUPLE                = ord('(')
    TYPE_LIST                 = ord('[')
    TYPE_DICT                 = ord('{')
    TYPE_CODE                 = ord('c')
    TYPE_UNICODE              = ord('u')
    TYPE_UNKNOWN              = ord('?')
    TYPE_SET                  = ord('<')
    TYPE_FROZENSET            = ord('>')
    FLAG_REF                  = 0x80
    TYPE_ASCII                = ord('a')
    TYPE_ASCII_INTERNED       = ord('A')
    TYPE_SMALL_TUPLE          = ord(')')
    TYPE_SHORT_ASCII          = ord('z')
    TYPE_SHORT_ASCII_INTERNED = ord('Z')

我們接下來對上面的型別進行一一解釋,首先我們需要了解下面幾個方法,我們在後面的解析過程當中會使用到下面的內容:

class ByteStreamReader(object):

    @staticmethod
    def read_int(buf: bytes):
        return struct.unpack("<i", buf)[0]

    @staticmethod
    def read_byte(buf):
        return struct.unpack("<B", buf)[0]

    @staticmethod
    def read_float(buf):
        return struct.unpack("<f", buf)[0]

    @staticmethod
    def read_double(buf):
        return struct.unpack("<d", buf)[0]

    @staticmethod
    def read_long(buf):
        return struct.unpack("<q", buf)[0]

上面的的幾個函式主要是將位元組變成 byte、int 或者浮點數。接下來我們會實現一個類 PyObjectLoader,用於對 marshal 序列化之後的檔案進行解析。類的建構函式如下所示:

class PyObjectLoader(object):

    def __init__(self, filename):
        self.fp = open(filename, "rb")
        self.flag = 0
        self.refs = []

現在來對一個物件進行解析,根據我們前面談到的內容首先我們需要讀入一個位元組的內容用於判斷是那種資料型別:

def do_parse(self):
    c = self.fp.read(1)
    assert len(c) != 0, "can not read more data from file descriptor"
    t = ByteStreamReader.read_byte(c) & (~TYPE.FLAG_REF.value)
    self.flag = ByteStreamReader.read_byte(c) & TYPE.FLAG_REF.value

在上面的程式碼當中使用函式 do_parse 對一個 python 物件進行解析操作,使用到了 TYPE.FLAG_REF,這個欄位的作用表示這個 python 物件是不是一個可引用的,除了 None 、True、False、StopIteration、Ellipsis 是不可引用物件,集合、字典、不可變集合、字串、位元組、CodeObject 等是可引用物件,可引用物件的 type 的最高位是 1(也就是 type 的第 8 個位元位是 1),非可引用物件就是 0 。如果是可引用物件需要將這個物件加入到引用列表當中,因為可能會存在一個物件引用其他物件的情況,需要將物件加入到引用佇列當中,如果需要對物件進行引用操作直接使用下標從引用陣列當中查詢即可。所有的可引用物件在建立完成之後都需要加入到引用列表當中。

  • TYPE_NULL,這個在 cpython 虛擬機器當中就會直接返回 NULL 。
  • TYPE_NONE,返回 python 物件 None 。
  • TYPE_FALSE,返回 python 物件 False 。
  • TYPE_TRUE,返回 python 物件 True 。
  • TYPE_STOPITER,返回 StopIteration 物件。
  • TYPE_ELLIPSIS,返回 物件 Ellipsis 。
  • TYPE_INT,如果是這個資料型別表示接下來的 4 個位元組的資料是一個整數。
  • TYPE_INT64,這個型別表示接下來的 8 個位元組表示一個整數。
  • TYPE_BINARY_FLOAT,浮點數物件,表示接下里啊的 8 個位元組表示一個浮點數。
  • TYPE_BINARY_COMPLEX,複數物件,表示接下來有兩個 8 個位元組的浮點數,分別表示實部和虛部。
  • TYPE_STRING,這個表示一個 bytes 物件,接下來的四個位元組表示一個整數 size ,整數 size 的含義表示還需要讀取的位元組個數,因此接下來的 size 個位元組就是 bytes 物件的內容。
  • TYPE_INTERNED,表示一個需要快取到字串常量池的字串,解析方法和 TYPE_STRING 一樣首先讀取四個位元組得到一個整數 size,然後在讀取 size 個位元組,表示字串的內容,我們在 python 當中可以直接使用 .decode("utf-8") 進行編碼。
  • TYPE_REF,表示需要引用一個物件,讀取四個位元組作為整數 size,然後從引用列表當中獲取下標為 size 的物件。
  • TYPE_TUPLE,表示一個元組,首先讀取四個位元組的資料得到一個整數 size ,然後使用 for 迴圈遞迴呼叫 do_parse 函式獲取 size 的物件。
  • TYPE_LIST,解析方式和 TYPE_TUPLE 一樣,只不過返回列表物件。
  • TYPE_DICT,這個解析的方式不斷的呼叫 do_parse 函式,從 1 開始計數,奇數物件當作 key,偶數物件當中 val,直到遇到 NULL,跳出迴圈停止解析,這個型別可以直接看下面的解析程式碼,非常清晰。
  • TYPE_CODE,這個型別表示一個 CodeObject 物件,見下面的解析程式碼,這部分程式碼可以結合 CodeObject 的欄位分析,前面24 個位元組表示整數物件,用於表示 CodeObject 的 6 個欄位,接下來的是 8 個 PyObject 物件,因此需要呼叫 do_parse 函式進行解析,然後再解析一個 4 位元組的整數表示第一行程式碼的行號,最後再讀取一個 PyObject 物件。
  • TYPE_UNICODE,表示一個字串,讀取方式和 TYPE_INTERNED 一樣。
  • TYPE_SET,前 4 個自己表示集合當中元素的個數 size,接下來使用 for 迴圈讀取(呼叫 do_parse) size 的元素加入到集合當中。
  • TYPE_FROZENSET,和 TYPE_SET 讀取方式一樣,只不過返回 frozen set 。
  • TYPE_ASCII,和 TYPE_UNICODE 讀取方式一樣,也可以使用 utf-8 編碼,雖然讀取的是 ASCII 編碼的字元,但是 utf-8 相容 ASCII 因此也可以。
  • TYPE_ASCII_INTERNED,和 TYPE_ASCII 解析方式一樣。
  • TYPE_SMALL_TUPLE,讀取一個位元組的資料表示元組當中的資料個數,然後讀取對應個數的物件。
  • TYPE_SHORT_ASCII,之前是讀取四個位元組作為長度,現在只讀取一個位元組作為位元組個數。
  • TYPE_SHORT_ASCII_INTERNED,和 TYPE_SHORT_ASCII 讀取方式一樣,只不過加入到字串常量池子。

餘下的物件的解析不在一一解釋,大家可以直接看下方程式碼,都是比較清晰易懂的。

class PyObjectLoader(object):

    def __init__(self, filename):
        self.reader = ByteStreamReader()
        self.fp = open(filename, "rb")
        self.flag = 0
        self.refs = []

    def do_parse(self):
        c = self.fp.read(1)
        assert len(c) != 0, "can not read more data from file descriptor"
        t = ByteStreamReader.read_byte(c) & (~TYPE.FLAG_REF.value)
        self.flag = ByteStreamReader.read_byte(c) & TYPE.FLAG_REF.value
        match t:
            case TYPE.TYPE_NULL.value:
                return None
            case TYPE.TYPE_NONE.value:
                return None
            case TYPE.TYPE_FALSE.value:
                return False
            case TYPE.TYPE_TRUE.value:
                return True
            case TYPE.TYPE_STOPITER.value:
                return StopIteration
            case TYPE.TYPE_ELLIPSIS.value:
                return Ellipsis
            case TYPE.TYPE_INT.value:
                ret = ByteStreamReader.read_int(self.fp.read(4))
                self.refs.append(ret)
                return TYPE.TYPE_INT, ret
            case TYPE.TYPE_INT64.value:
                ret = ByteStreamReader.read_long(self.fp.read(8))
                self.refs.append(ret)
                return TYPE.TYPE_INT64, ret
            case TYPE.TYPE_FLOAT.value:
                raise RuntimeError("Unsupported TYPE TYPE_FLOAT")
            case TYPE.TYPE_BINARY_FLOAT.value:
                ret = ByteStreamReader.read_double(self.fp.read(8))
                self.refs.append(ret)
                return TYPE.TYPE_FLOAT, ret
            case TYPE.TYPE_COMPLEX.value:
                raise RuntimeError("Unsupported TYPE TYPE_COMPLEX")
            case TYPE.TYPE_BINARY_COMPLEX.value:
                ret = complex(self.do_parse(), self.do_parse())
                self.refs.append(ret)
                return TYPE.TYPE_BINARY_COMPLEX, ret
            case TYPE.TYPE_LONG.value:
                raise RuntimeError("Unsupported TYPE TYPE_LONG")
            case TYPE.TYPE_STRING.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = self.fp.read(size)
                self.refs.append(ret)
                return TYPE.TYPE_STRING, ret
            case TYPE.TYPE_INTERNED.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = self.fp.read(size).decode("utf-8")
                self.refs.append(ret)
                return TYPE.TYPE_INTERNED, ret
            case TYPE.TYPE_REF.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                return TYPE.TYPE_REF, self.refs[size]
            case TYPE.TYPE_TUPLE.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = []
                self.refs.append(ret)
                for i in range(size):
                    ret.append(self.do_parse())
                return TYPE.TYPE_TUPLE, tuple(ret)
            case TYPE.TYPE_LIST.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = []
                self.refs.append(ret)
                for i in range(size):
                    ret.append(self.do_parse())
                return TYPE.TYPE_LIST, ret
            case TYPE.TYPE_DICT.value:
                ret = dict()
                self.refs.append(ret)
                while True:
                    key = self.do_parse()
                    if key is None:
                        break
                    val = self.do_parse()
                    if val is None:
                        break
                    ret[key] = val
                return TYPE.TYPE_DICT, ret
            case TYPE.TYPE_CODE.value:
                ret = dict()
                idx = len(self.refs)
                self.refs.append(None)
                ret["argcount"] = ByteStreamReader.read_int(self.fp.read(4))
                ret["posonlyargcount"] = ByteStreamReader.read_int(self.fp.read(4))
                ret["kwonlyargcount"] = ByteStreamReader.read_int(self.fp.read(4))
                ret["nlocals"] = ByteStreamReader.read_int(self.fp.read(4))
                ret["stacksize"] = ByteStreamReader.read_int(self.fp.read(4))
                ret["flags"] = ByteStreamReader.read_int(self.fp.read(4))

                ret["code"] = self.do_parse()
                ret["consts"] = self.do_parse()
                ret["names"] = self.do_parse()
                ret["varnames"] = self.do_parse()
                ret["freevars"] = self.do_parse()
                ret["cellvars"] = self.do_parse()
                ret["filename"] = self.do_parse()
                ret["name"] = self.do_parse()
                ret["firstlineno"] = ByteStreamReader.read_int(self.fp.read(4))
                ret["lnotab"] = self.do_parse()
                self.refs[idx] = ret
                return TYPE.TYPE_CODE, ret
            case TYPE.TYPE_UNICODE.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = self.fp.read(size).decode("utf-8")
                self.refs.append(ret)
                return TYPE.TYPE_INTERNED, ret
            case TYPE.TYPE_UNKNOWN.value:
                raise RuntimeError("Unknown value " + str(t))
            case TYPE.TYPE_SET.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = set()
                self.refs.append(ret)
                for i in range(size):
                    ret.add(self.do_parse())
                return TYPE.TYPE_SET, ret
            case TYPE.TYPE_FROZENSET.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = set()
                idx = len(self.refs)
                self.refs.append(None)
                for i in range(size):
                    ret.add(self.do_parse())
                self.refs[idx] = ret
                return TYPE.TYPE_SET, frozenset(ret)
            case TYPE.TYPE_ASCII.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = self.fp.read(size).decode("utf-8")
                self.refs.append(ret)
                return TYPE.TYPE_INTERNED, ret
            case TYPE.TYPE_ASCII_INTERNED.value:
                size = ByteStreamReader.read_int(self.fp.read(4))
                ret = self.fp.read(size).decode("utf-8")
                self.refs.append(ret)
                return TYPE.TYPE_ASCII_INTERNED, ret
            case TYPE.TYPE_SMALL_TUPLE.value:
                size = ByteStreamReader.read_byte(self.fp.read(1))
                ret = []
                self.refs.append(ret)
                for i in range(size):
                    ret.append(self.do_parse())
                return TYPE.TYPE_SMALL_TUPLE, tuple(ret)
            case TYPE.TYPE_SHORT_ASCII.value:
                size = ByteStreamReader.read_byte(self.fp.read(1))
                ret = self.fp.read(size).decode("utf-8")
                self.refs.append(ret)
                return TYPE.TYPE_SHORT_ASCII, ret
            case TYPE.TYPE_SHORT_ASCII_INTERNED.value:
                size = ByteStreamReader.read_byte(self.fp.read(1))
                ret = self.fp.read(size).decode("utf-8")
                self.refs.append(ret)
                return TYPE.TYPE_SHORT_ASCII_INTERNED, ret
            case _:
                raise RuntimeError("can not parse " + str(t))

    def __del_(self):
        self.fp.close()

我們現在使用下面的程式碼生成一些二進位制檔案:

import marshal


def add(a, b):
    print("Hello World")
    return a+b


if __name__ == '__main__':
    with open("add.bin", "wb") as fp:
        marshal.dump(add.__code__, fp)

    with open("int.bin", "wb") as fp:
        marshal.dump(1, fp)
    with open("float.bin", "wb") as fp:
        marshal.dump(1.5, fp)
    with open("tuple.bin", "wb") as fp:
        marshal.dump((1, 2, 3), fp)
    with open("set.bin", "wb") as fp:
        marshal.dump({1, 2, 3}, fp)
    with open("list.bin", "wb") as fp:
        marshal.dump([1, 2, 3], fp)
    with open("dict.bin", "wb") as fp:
        marshal.dump({1: 2, 3: 4}, fp)
    with open("code.bin", "wb") as fp:
        marshal.dump(add.__code__, fp)

    with open("string.bin", "wb") as fp:
        marshal.dump("Hello World", fp)

當我們使用 marshal 對函式 add 的 code 進行序列化的時候實際上就是序列化一個 CodeObject 物件,這個物件的結果實際上和 pyc 的結構是一樣的。

我們使用下面的程式碼進行反序列化:

if __name__ == '__main__':
    assert sys.version_info.major == 3 and sys.version_info.minor == 10, "only python3.10 works"
    loader = PyObjectLoader("int.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("float.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("set.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("dict.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("tuple.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("list.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("string.bin")
    print(loader.do_parse())
    loader = PyObjectLoader("code.bin")
    pprint(loader.do_parse())

需要注意的是本篇文章程式碼需要在 python 3.10 上執行,如果需要在 3.8 3.9 執行的話可以將 match 語句改成 if-else 語句。但是由於 python 3.11 當中的 CodeObject 物件的欄位發生了一些微小的變化,因此上面的程式碼是不能在 python 3.11 上執行的。上面的程式碼執行結果如下所示:

(<TYPE.TYPE_INT: 105>, 1)
(<TYPE.TYPE_FLOAT: 102>, 1.5)
(<TYPE.TYPE_SET: 60>, {(<TYPE.TYPE_INT: 105>, 1), (<TYPE.TYPE_INT: 105>, 2), (<TYPE.TYPE_INT: 105>, 3)})
(<TYPE.TYPE_DICT: 123>, {(<TYPE.TYPE_INT: 105>, 1): (<TYPE.TYPE_INT: 105>, 2), (<TYPE.TYPE_INT: 105>, 3): (<TYPE.TYPE_INT: 105>, 4)})
(<TYPE.TYPE_SMALL_TUPLE: 41>, ((<TYPE.TYPE_INT: 105>, 1), (<TYPE.TYPE_INT: 105>, 2), (<TYPE.TYPE_INT: 105>, 3)))
(<TYPE.TYPE_LIST: 91>, [(<TYPE.TYPE_INT: 105>, 1), (<TYPE.TYPE_INT: 105>, 2), (<TYPE.TYPE_INT: 105>, 3)])
(<TYPE.TYPE_SHORT_ASCII: 122>, 'Hello World')
(<TYPE.TYPE_CODE: 99>,
 {'argcount': 2,
  'cellvars': (<TYPE.TYPE_REF: 114>, 'print'),
  'code': (<TYPE.TYPE_STRING: 115>,
           b't\x00d\x01\x83\x01\x01\x00|\x00|\x01\x17\x00S\x00'),
  'consts': (<TYPE.TYPE_SMALL_TUPLE: 41>,
             (None, (<TYPE.TYPE_SHORT_ASCII: 122>, 'Hello World'))),
  'filename': (<TYPE.TYPE_SHORT_ASCII: 122>,
               '/Users/xxxxxxx/Desktop/workdir/dive-into-cpython/code/marshal_demos/add.py'),
  'firstlineno': 5,
  'flags': 67,
  'freevars': (<TYPE.TYPE_SMALL_TUPLE: 41>, ()),
  'kwonlyargcount': 0,
  'lnotab': (<TYPE.TYPE_STRING: 115>, b'\x08\x01\x08\x01'),
  'name': (<TYPE.TYPE_SHORT_ASCII_INTERNED: 90>, 'add'),
  'names': (<TYPE.TYPE_SMALL_TUPLE: 41>,
            ((<TYPE.TYPE_SHORT_ASCII_INTERNED: 90>, 'print'),)),
  'nlocals': 2,
  'posonlyargcount': 0,
  'stacksize': 2,
  'varnames': (<TYPE.TYPE_SMALL_TUPLE: 41>,
               ((<TYPE.TYPE_SHORT_ASCII_INTERNED: 90>, 'a'),
                (<TYPE.TYPE_SHORT_ASCII_INTERNED: 90>, 'b')))})

從上面的解析結果來看我們是實現了正確的解析的。

總結

在本篇文章當中主要給大家分析了 python 物件序列化之後我們該如何反序列化這些物件,並且使用 python 對二進位制檔案進行了分析,可以成功的將 python 物件解析出來,但是我們忽略了兩個稍微複雜一點的物件,他們的解析稍微有點複雜,但是我們平時的變成當中很少使用到,因此本文的程式碼解析一般的檔案都是可以的。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章