uctypes --- 以结构化方式访问二进制数据

本模块为 MicroPython 实现了"外部数据接口"(foreign data interface)。其背后的思路与 CPython 的 ctypes 模块类似,但实际的 API 有所不同,经过精简并针对小体积进行了优化。该模块的基本思想是,以与 C 语言大致相同的表达能力来定义数据结构布局,然后使用熟悉的点号语法来引用子字段以访问其中的数据。

警告

uctypes 模块允许访问机器的任意内存地址(包括 I/O 和控制寄存器)。不当使用可能导致崩溃、数据丢失,甚至硬件故障。

参见

模块 struct

用于打包和解包二进制数据的标准 Python 模块。struct 使用紧凑的格式字符串(例如 '<HBB4sI')每次对整个缓冲区进行操作,这对于少数固定字段效果很好,但对于庞大或深度嵌套的结构则扩展性较差:每次读写都会重新解析格式字符串,不支持联合体和位域,也无法获得对现有缓冲区的类型化视图。uctypesstruct 形成了补充——你只需描述一次布局,将其附加到某个内存区域(RAM、外设寄存器、一个 bytearray),然后即可将各个字段作为具名属性进行访问,从而避免重复解析和复制,并增加了对嵌套结构、数组、联合体和位域的支持。

用法示例:

import uctypes

# Example 1: Subset of ELF file header
# https://wikipedia.org/wiki/Executable_and_Linkable_Format#File_header
ELF_HEADER = {
    "EI_MAG": (0x0 | uctypes.ARRAY, 4 | uctypes.UINT8),
    "EI_DATA": 0x5 | uctypes.UINT8,
    "e_machine": 0x12 | uctypes.UINT16,
}

# "f" is an ELF file opened in binary mode
buf = f.read(uctypes.sizeof(ELF_HEADER, uctypes.LITTLE_ENDIAN))
header = uctypes.struct(uctypes.addressof(buf), ELF_HEADER, uctypes.LITTLE_ENDIAN)
assert header.EI_MAG == b"\x7fELF"
assert header.EI_DATA == 1, "Oops, wrong endianness. Could retry with uctypes.BIG_ENDIAN."
print("machine:", hex(header.e_machine))


# Example 2: In-memory data structure, with pointers
COORD = {
    "x": 0 | uctypes.FLOAT32,
    "y": 4 | uctypes.FLOAT32,
}

STRUCT1 = {
    "data1": 0 | uctypes.UINT8,
    "data2": 4 | uctypes.UINT32,
    "ptr": (8 | uctypes.PTR, COORD),
}

# Suppose you have address of a structure of type STRUCT1 in "addr"
# uctypes.NATIVE is optional (used by default)
struct1 = uctypes.struct(addr, STRUCT1, uctypes.NATIVE)
print("x:", struct1.ptr[0].x)


# Example 3: Access to CPU registers. Subset of STM32F4xx WWDG block
WWDG_LAYOUT = {
    "WWDG_CR": (0, {
        # BFUINT32 here means size of the WWDG_CR register
        "WDGA": 7 << uctypes.BF_POS | 1 << uctypes.BF_LEN | uctypes.BFUINT32,
        "T": 0 << uctypes.BF_POS | 7 << uctypes.BF_LEN | uctypes.BFUINT32,
    }),
    "WWDG_CFR": (4, {
        "EWI": 9 << uctypes.BF_POS | 1 << uctypes.BF_LEN | uctypes.BFUINT32,
        "WDGTB": 7 << uctypes.BF_POS | 2 << uctypes.BF_LEN | uctypes.BFUINT32,
        "W": 0 << uctypes.BF_POS | 7 << uctypes.BF_LEN | uctypes.BFUINT32,
    }),
}

WWDG = uctypes.struct(0x40002c00, WWDG_LAYOUT)

WWDG.WWDG_CFR.WDGTB = 0b10
WWDG.WWDG_CR.WDGA = 1
print("Current counter:", WWDG.WWDG_CR.T)

定义结构布局

结构布局由一个"描述符"(descriptor)定义——这是一个 Python 字典,它将字段名作为键,并将访问这些字段所需的其他属性作为相应的值进行编码:

{
    "field1": <properties>,
    "field2": <properties>,
    ...
}

目前,uctypes 要求为每个字段显式指定偏移量。偏移量以从结构起始处算起的字节数给出。

以下是各种字段类型的编码示例:

  • 标量类型:

    "field_name": offset | uctypes.UINT32
    

    换言之,该值是标量类型标识符与从结构起始处算起的字段偏移量(以字节为单位)按位或(OR)的结果。

  • 递归结构:

    "sub": (offset, {
        "b0": 0 | uctypes.UINT8,
        "b1": 1 | uctypes.UINT8,
    })
    

    即该值是一个二元组,其第一个元素是偏移量,第二个元素是一个结构描述符字典(注意:递归描述符中的偏移量是相对于它所定义的结构而言的)。当然,递归结构不仅可以通过字面量字典来指定,也可以通过名称引用(先前定义的)结构描述符字典来指定。

  • 基本类型数组:

    "arr": (offset | uctypes.ARRAY, size | uctypes.UINT8),
    

    即该值是一个二元组,其第一个元素是 ARRAY 标志与偏移量按位或(OR)的结果,第二个元素是标量元素类型与数组中元素个数按位或(OR)的结果。

  • 聚合类型数组:

    "arr2": (offset | uctypes.ARRAY, size, {"b": 0 | uctypes.UINT8}),
    

    即该值是一个三元组,其第一个元素是 ARRAY 标志与偏移量按位或(OR)的结果,第二个元素是数组中元素的个数,第三个元素是元素类型的描述符。

  • 指向基本类型的指针:

    "ptr": (offset | uctypes.PTR, uctypes.UINT8),
    

    即该值是一个二元组,其第一个元素是 PTR 标志与偏移量按位或(OR)的结果,第二个元素是标量元素类型。

  • 指向聚合类型的指针:

    "ptr2": (offset | uctypes.PTR, {"b": 0 | uctypes.UINT8}),
    

    即该值是一个二元组,其第一个元素是 PTR 标志与偏移量按位或(OR)的结果,第二个元素是所指向类型的描述符。

  • 位域:

    "bitf0": offset | uctypes.BFUINT16 | lsbit << uctypes.BF_POS | bitsize << uctypes.BF_LEN,
    

    即该值是包含给定位域的标量值的类型(类型名与标量类型类似,但以 BF 为前缀),与包含该位域的标量值的偏移量按位或(OR),再分别与该位域在标量值内的位位置和位长度值(分别左移 BF_POS 和 BF_LEN 位)按位或(OR)。位域位置从标量的最低有效位(位置为 0)开始计数,它是字段最右侧位的编号(换言之,就是为了提取该位域,标量需要右移的位数)。

    在上面的示例中,首先会在偏移量 0 处提取一个 UINT16 值(在访问硬件寄存器时,这一细节可能很重要,因为硬件寄存器对特定的访问大小和对齐有要求),然后提取一个位域,该位域最右侧的位是此 UINT16 的第 lsbit 位,长度为 bitsize 位。例如,如果 lsbit 为 0 且 bitsize 为 8,则实际上将访问 UINT16 的最低有效字节。

    请注意,位域操作与目标的字节序无关,特别是,上面的示例无论在小端还是大端结构中都会访问 UINT16 的最低有效字节。但它依赖于最低有效位被编号为 0 这一约定。某些目标平台在其原生 ABI 中可能使用不同的编号方式,但 uctypes 始终使用上述归一化编号。

模块内容

class uctypes.struct(addr: int, descriptor: dict, layout_type: int = NATIVE, /)

基于内存中的结构地址、描述符(编码为字典)和布局类型(见下文),实例化一个"外部数据结构"对象。

uctypes.LITTLE_ENDIAN: int

小端打包结构的布局类型。(打包意味着每个字段恰好占用描述符中所定义的字节数,即对齐为 1)。

uctypes.BIG_ENDIAN: int

大端打包结构的布局类型。

uctypes.NATIVE: int

原生结构的布局类型——其数据字节序和对齐方式符合 MicroPython 所运行系统的 ABI。

uctypes.sizeof(struct: dict | Any, layout_type: int = NATIVE, /) int

返回数据结构的大小(以字节为单位)。struct 参数既可以是结构类,也可以是某个具体实例化的结构对象(或其聚合字段)。

uctypes.addressof(obj: Any) int

返回对象的地址。参数应为 bytes、bytearray 或其他支持缓冲区协议的对象(实际返回的是该缓冲区的地址)。

uctypes.bytes_at(addr: int, size: int) bytes

将给定地址和大小处的内存捕获为 bytes 对象。由于 bytes 对象是不可变的,内存实际上会被复制到 bytes 对象中,因此如果内存内容随后发生变化,所创建的对象仍保留原始值。

uctypes.bytearray_at(addr: int, size: int) bytearray

将给定地址和大小处的内存捕获为 bytearray 对象。与上面的 bytes_at() 函数不同,内存是按引用捕获的,因此既可以对其进行写入,也能访问到给定内存地址处的当前值。

标量整数类型。每种类型占用显而易见的字节数(1248),并使用该结构布局类型的字节序(NATIVELITTLE_ENDIANBIG_ENDIAN 之一)进行读写。

uctypes.UINT8: int

无符号 8 位整数。范围 0 -- 255

uctypes.INT8: int

有符号 8 位整数。范围 -128 -- 127

uctypes.UINT16: int

无符号 16 位整数。范围 0 -- 65535

uctypes.INT16: int

有符号 16 位整数。范围 -32768 -- 32767

uctypes.UINT32: int

无符号 32 位整数。范围 0 -- 0xFFFFFFFF

uctypes.INT32: int

有符号 32 位整数。范围 -0x80000000 -- 0x7FFFFFFF

uctypes.UINT64: int

无符号 64 位整数。范围 0 -- 0xFFFFFFFFFFFFFFFF

uctypes.INT64: int

有符号 64 位整数。范围 -0x8000000000000000 -- 0x7FFFFFFFFFFFFFFF

uctypes.FLOAT32: int

IEEE 754 单精度浮点数(4 字节)。读写时会与 Python 的 float 相互转换。

uctypes.FLOAT64: int

IEEE 754 双精度浮点数(8 字节)。读写时会与 Python 的 float 相互转换。

uctypes.VOID: int

UINT8 的别名。提供它是为了让 C 风格的 void * 字段能够以惯用方式描述为 (uctypes.PTR, uctypes.VOID)

uctypes.PTR: int

将描述符字段标记为指向另一类型的指针。指针字段写作一个二元组 (offset | PTR, target_type_or_descriptor)。解引用该指针会得到对它所持有地址的类型化视图。

uctypes.ARRAY: int

将描述符字段标记为另一类型的定长数组。数组字段对于标量数组写作 (offset | ARRAY, count | element_type),对于结构数组则写作 (offset | ARRAY, count, element_descriptor)。元素个数在定义描述符时即已固定。

结构没有显式的常量:一个既不使用 PTR 也不使用 ARRAY 的聚合描述符会被视为结构。

结构描述符与实例化结构对象

给定一个结构描述符字典及其布局类型,你可以使用 uctypes.struct() 构造函数在指定的内存地址处实例化一个具体的结构实例。内存地址通常来自以下来源:

  • 预定义地址,用于在裸机系统上访问硬件寄存器。请在特定 MCU/SoC 的数据手册中查找这些地址。

  • 作为对某个 FFI(外部函数接口,Foreign Function Interface)函数调用的返回值。

  • 来自 uctypes.addressof(),当你想向 FFI 函数传递参数时,或者用于访问某些 I/O 数据时(例如从文件或网络套接字读取的数据)。

结构对象

结构对象允许使用标准的点号表示法访问各个字段:my_struct.substruct1.field1。如果某字段是标量类型,获取它会产生与该字段所含值相对应的基本值(Python 整数或浮点数)。标量字段也可以被赋值。

如果某字段是数组,可以使用标准的下标运算符 [] 访问其各个元素——既可读取也可赋值。

如果某字段是指针,可以使用 [0] 语法对其进行解引用(对应于 C 的 * 运算符,不过 [0] 在 C 中也有效)。对指针使用 0 以外的其他整数值进行下标访问也是支持的,其语义与 C 中相同。

总而言之,访问结构字段大体遵循 C 语法,唯一例外是指针解引用——此时你需要使用 [0] 运算符而非 *

局限性

1. Accessing non-scalar fields leads to allocation of intermediate objects to represent them. This means that special care should be taken to layout a structure which needs to be accessed when memory allocation is disabled (e.g. from an interrupt). The recommendations are:

  • 避免访问嵌套结构。例如,不要使用 mcu_registers.peripheral_a.register1,而应为每个外设定义单独的布局描述符,以便以 peripheral_a.register1 的形式访问。或者干脆缓存某个特定的外设:peripheral_a = mcu_registers.peripheral_a。如果某个寄存器由多个位域组成,则需要缓存对该特定寄存器的引用:reg_a = mcu_registers.peripheral_a.reg_a

  • 避免使用其他非标量数据,如数组。例如,不要使用 peripheral_a.register[0],而应使用 peripheral_a.register0。同样,另一种做法是缓存中间值,例如 register0 = peripheral_a.register[0]

2. Range of offsets supported by the uctypes module is limited. The exact range supported is considered an implementation detail, and the general suggestion is to split structure definitions to cover from a few kilobytes to a few dozen of kilobytes maximum. In most cases, this is a natural situation anyway, e.g. it doesn't make sense to define all registers of an MCU (spread over 32-bit address space) in one structure, but rather a peripheral block by peripheral block. In some extreme cases, you may need to split a structure in several parts artificially (e.g. if accessing native data structure with multi-megabyte array in the middle, though that would be a very synthetic case).