uctypes --- เข้าถึงข้อมูลไบนารีในรูปแบบโครงสร้าง

โมดูลนี้ใช้งาน "foreign data interface" สำหรับ MicroPython แนวคิดเบื้องหลังคล้ายคลึงกับโมดูล ctypes ของ CPython แต่ API จริงนั้นแตกต่าง กระชับ และปรับให้เหมาะกับขนาดที่เล็ก แนวคิดพื้นฐานของโมดูลคือการกำหนดโครงสร้างข้อมูลด้วยความสามารถใกล้เคียงกับภาษา C แล้วเข้าถึงด้วยไวยากรณ์จุดที่คุ้นเคยเพื่ออ้างอิงฟิลด์ย่อย

Warning

โมดูล uctypes อนุญาตให้เข้าถึงที่อยู่หน่วยความจำใด ๆ ของเครื่อง (รวมถึง I/O และรีจิสเตอร์ควบคุม) การใช้งานอย่างไม่ระมัดระวังอาจนำไปสู่การขัดข้อง การสูญหายของข้อมูล และแม้แต่ฮาร์ดแวร์ทำงานผิดพลาด

See also

โมดูล struct

โมดูล Python มาตรฐานสำหรับการแพ็กและแกะข้อมูลไบนารี struct ทำงานกับบัฟเฟอร์ทั้งหมดในคราวเดียวโดยใช้สตริงรูปแบบที่กระชับ (เช่น '<HBB4sI') ซึ่งทำงานได้ดีสำหรับฟิลด์คงที่จำนวนน้อย แต่ขยายได้ไม่ดีกับโครงสร้างขนาดใหญ่หรือซ้อนกันลึก: ทุกการอ่านหรือเขียนจะวิเคราะห์สตริงรูปแบบใหม่, union และ bitfield ไม่ได้รับการสนับสนุน, และไม่มีทางได้มุมมองแบบมีชนิดข้อมูลเข้าไปในบัฟเฟอร์ที่มีอยู่ uctypes เป็นส่วนเสริมของ struct โดยให้คุณอธิบาย layout ครั้งเดียว แนบกับบริเวณหน่วยความจำ (RAM, รีจิสเตอร์อุปกรณ์ต่อพ่วง, bytearray) แล้วเข้าถึงฟิลด์แต่ละตัวเป็น attribute ที่มีชื่อ -- หลีกเลี่ยงการวิเคราะห์และคัดลอกซ้ำ และเพิ่มการสนับสนุน struct ซ้อน, array, union และ bitfield

ตัวอย่างการใช้งาน:

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)

การกำหนด layout โครงสร้าง

Layout โครงสร้างถูกกำหนดโดย "descriptor" - dictionary ของ Python ที่เข้ารหัสชื่อฟิลด์เป็น key และคุณสมบัติอื่น ๆ ที่จำเป็นในการเข้าถึงเป็น value ที่เกี่ยวข้อง:

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

ปัจจุบัน uctypes ต้องการการระบุ offset สำหรับแต่ละฟิลด์อย่างชัดเจน offset จะระบุเป็นไบต์จากจุดเริ่มต้นของโครงสร้าง

ต่อไปนี้เป็นตัวอย่างการเข้ารหัสสำหรับประเภทฟิลด์ต่าง ๆ:

  • ประเภท scalar:

    "field_name": offset | uctypes.UINT32
    

    กล่าวอีกนัยหนึ่ง value คือตัวระบุประเภท scalar ที่ OR กับ offset ฟิลด์ (เป็นไบต์) จากจุดเริ่มต้นของโครงสร้าง

  • โครงสร้างแบบ recursive:

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

    กล่าวคือ value คือ 2-tuple โดย element แรกคือ offset และ element ที่สองคือ dictionary ของ descriptor โครงสร้าง (หมายเหตุ: offset ใน descriptor แบบ recursive เป็นค่าสัมพัทธ์กับโครงสร้างที่กำหนด) แน่นอนว่าโครงสร้างแบบ recursive สามารถระบุได้ไม่เพียงแค่จาก dictionary literal เท่านั้น แต่ยังอ้างอิง dictionary ของ descriptor โครงสร้างที่กำหนดไว้ก่อนหน้าด้วยชื่อได้

  • Array ของประเภท primitive:

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

    กล่าวคือ value คือ 2-tuple โดย element แรกคือ ARRAY flag ที่ OR กับ offset และ element ที่สองคือประเภท element scalar ที่ OR กับจำนวน element ใน array

  • Array ของประเภท aggregate:

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

    กล่าวคือ value คือ 3-tuple โดย element แรกคือ ARRAY flag ที่ OR กับ offset, element ที่สองคือจำนวน element ใน array และ element ที่สามคือ descriptor ของประเภท element

  • Pointer ไปยังประเภท primitive:

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

    กล่าวคือ value คือ 2-tuple โดย element แรกคือ PTR flag ที่ OR กับ offset และ element ที่สองคือประเภท element scalar

  • Pointer ไปยังประเภท aggregate:

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

    กล่าวคือ value คือ 2-tuple โดย element แรกคือ PTR flag ที่ OR กับ offset และ element ที่สองคือ descriptor ของประเภทที่ถูกชี้ถึง

  • Bitfield:

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

    กล่าวคือ value คือประเภทของ scalar value ที่มี bitfield ที่กำหนด (ชื่อประเภทคล้ายกับประเภท scalar แต่มีคำนำหน้าด้วย BF) ที่ OR กับ offset สำหรับ scalar value ที่มี bitfield และ OR กับค่าสำหรับตำแหน่ง bit และความยาว bit ของ bitfield ภายใน scalar value โดยเลื่อนด้วย BF_POS และ BF_LEN bits ตามลำดับ ตำแหน่ง bitfield นับจาก bit ที่มีนัยสำคัญน้อยที่สุดของ scalar (มีตำแหน่ง 0) และเป็นหมายเลขของ bit ขวาสุดของฟิลด์ (กล่าวอีกนัยหนึ่งคือจำนวน bit ที่ scalar ต้องเลื่อนขวาเพื่อแยก bitfield)

    ในตัวอย่างข้างต้น ก่อนอื่น UINT16 value จะถูกดึงมาที่ offset 0 (รายละเอียดนี้อาจสำคัญเมื่อเข้าถึงรีจิสเตอร์ฮาร์ดแวร์ซึ่งต้องการขนาดและการ alignment ในการเข้าถึงที่เฉพาะเจาะจง) แล้วจะดึง bitfield ที่ bit ขวาสุดคือ bit lsbit ของ UINT16 นี้ และมีความยาว bitsize bits ตัวอย่างเช่น หาก lsbit คือ 0 และ bitsize คือ 8 แล้ว จริง ๆ แล้วจะเข้าถึง byte ที่มีนัยสำคัญน้อยที่สุดของ UINT16

    โปรดทราบว่าการดำเนินการ bitfield ไม่ขึ้นอยู่กับ byte endianness ของเป้าหมาย โดยเฉพาะอย่างยิ่ง ตัวอย่างข้างต้นจะเข้าถึง byte ที่มีนัยสำคัญน้อยที่สุดของ UINT16 ทั้งใน little-endian และ big-endian structures แต่ขึ้นอยู่กับ bit ที่มีนัยสำคัญน้อยที่สุดที่ถูกกำหนดหมายเลขเป็น 0 เป้าหมายบางตัวอาจใช้การกำหนดหมายเลขที่แตกต่างกันใน native ABI ของตน แต่ uctypes ใช้การกำหนดหมายเลขแบบ normalized ที่อธิบายข้างต้นเสมอ

เนื้อหาของโมดูล

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

สร้าง object "foreign data structure" โดยอาศัยที่อยู่ของโครงสร้างในหน่วยความจำ, descriptor (เข้ารหัสเป็น dictionary) และประเภท layout (ดูด้านล่าง)

uctypes.LITTLE_ENDIAN: int

ประเภท layout สำหรับโครงสร้างแบบ packed little-endian (Packed หมายความว่าทุกฟิลด์ใช้ไบต์พอดีตามที่กำหนดใน descriptor กล่าวคือ alignment คือ 1)

uctypes.BIG_ENDIAN: int

ประเภท layout สำหรับโครงสร้างแบบ packed big-endian

uctypes.NATIVE: int

ประเภท layout สำหรับโครงสร้าง native - ที่มี data endianness และ alignment ตาม ABI ของระบบที่ MicroPython ทำงานอยู่

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

คืนค่าขนาดของโครงสร้างข้อมูลเป็นไบต์ อาร์กิวเมนต์ struct สามารถเป็น class โครงสร้างหรือ object โครงสร้างที่สร้างขึ้นเฉพาะเจาะจง (หรือฟิลด์ aggregate ของมัน)

uctypes.addressof(obj: Any) int

คืนค่าที่อยู่ของ object อาร์กิวเมนต์ควรเป็น bytes, bytearray หรือ object อื่นที่รองรับ buffer protocol (และที่อยู่ของบัฟเฟอร์นี้คือสิ่งที่คืนค่าจริง ๆ)

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

จับหน่วยความจำที่ที่อยู่และขนาดที่กำหนดเป็น object bytes เนื่องจาก object bytes ไม่สามารถเปลี่ยนแปลงได้ หน่วยความจำจะถูกทำซ้ำและคัดลอกไปยัง object bytes จริง ๆ ดังนั้น หากเนื้อหาหน่วยความจำเปลี่ยนแปลงในภายหลัง object ที่สร้างขึ้นจะยังคงมีค่าเดิม

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

จับหน่วยความจำที่ที่อยู่และขนาดที่กำหนดเป็น object bytearray ต่างจากฟังก์ชัน bytes_at() ข้างต้น หน่วยความจำจะถูกจับโดยการอ้างอิง ดังนั้นจึงสามารถเขียนได้และคุณจะเข้าถึงค่าปัจจุบันที่ที่อยู่หน่วยความจำที่กำหนด

ประเภทจำนวนเต็ม scalar แต่ละตัวใช้จำนวนไบต์ที่ชัดเจน (1, 2, 4 หรือ 8) และอ่าน/เขียนโดยใช้ endianness ของประเภท layout ของโครงสร้าง (หนึ่งใน NATIVE, LITTLE_ENDIAN หรือ BIG_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

Alias สำหรับ UINT8 ให้มาเพื่อให้ฟิลด์ C-style void * สามารถอธิบายได้อย่างเป็นธรรมชาติว่าเป็น (uctypes.PTR, uctypes.VOID)

uctypes.PTR: int

ทำเครื่องหมายฟิลด์ descriptor ว่าเป็น pointer ไปยังประเภทอื่น ฟิลด์ pointer เขียนเป็น 2-tuple (offset | PTR, target_type_or_descriptor) การ dereference pointer จะให้มุมมองแบบมีชนิดข้อมูลเข้าไปยังที่อยู่ที่มันถือไว้

uctypes.ARRAY: int

ทำเครื่องหมายฟิลด์ descriptor ว่าเป็น array ขนาดคงที่ของประเภทอื่น ฟิลด์ array เป็นทั้ง (offset | ARRAY, count | element_type) สำหรับ array ของ scalar หรือ (offset | ARRAY, count, element_descriptor) สำหรับ array ของโครงสร้าง จำนวน element ถูกกำหนดคงที่เมื่อถึงเวลา descriptor

ไม่มีค่าคงที่ชัดเจนสำหรับโครงสร้าง: descriptor แบบ aggregate ที่ไม่ใช้ทั้ง PTR และ ARRAY จะถูกถือว่าเป็นโครงสร้าง

Descriptor โครงสร้างและการสร้าง object โครงสร้าง

เมื่อกำหนด dictionary ของ descriptor โครงสร้างและประเภท layout แล้ว คุณสามารถสร้าง instance โครงสร้างเฉพาะที่ที่อยู่หน่วยความจำที่กำหนดได้โดยใช้ constructor uctypes.struct() ที่อยู่หน่วยความจำมักมาจากแหล่งต่อไปนี้:

  • ที่อยู่ที่กำหนดไว้ล่วงหน้า เมื่อเข้าถึงรีจิสเตอร์ฮาร์ดแวร์บนระบบ baremetal ค้นหาที่อยู่เหล่านี้ใน datasheet สำหรับ MCU/SoC ที่เฉพาะเจาะจง

  • ค่าที่คืนมาจากการเรียก FFI (Foreign Function Interface) function

  • จาก uctypes.addressof() เมื่อคุณต้องการส่งอาร์กิวเมนต์ไปยัง FFI function หรืออีกทางหนึ่งคือเข้าถึงข้อมูลบางอย่างสำหรับ I/O (ตัวอย่างเช่น ข้อมูลที่อ่านจากไฟล์หรือ network socket)

Object โครงสร้าง

Object โครงสร้างอนุญาตให้เข้าถึงฟิลด์แต่ละตัวโดยใช้ notation จุดมาตรฐาน: my_struct.substruct1.field1 หากฟิลด์เป็นประเภท scalar การดึงค่าจะได้ค่า primitive (Python integer หรือ float) ที่สอดคล้องกับค่าที่มีอยู่ในฟิลด์ ฟิลด์ scalar สามารถกำหนดค่าได้เช่นกัน

หากฟิลด์เป็น array สามารถเข้าถึง element แต่ละตัวด้วย subscript operator มาตรฐาน [] - ทั้งอ่านและกำหนดค่าได้

หากฟิลด์เป็น pointer สามารถ dereference ด้วยไวยากรณ์ [0] (สอดคล้องกับตัวดำเนินการ * ใน C แม้ว่า [0] ก็ใช้ได้ใน C เช่นกัน) การ subscript pointer ด้วยค่าจำนวนเต็มอื่นนอกจาก 0 ก็รองรับเช่นกัน ด้วย semantics เดียวกับใน C

สรุปได้ว่า การเข้าถึงฟิลด์โครงสร้างโดยทั่วไปตาม C syntax ยกเว้นการ dereference pointer เมื่อคุณต้องใช้ตัวดำเนินการ [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 ให้กำหนด descriptor layout แยกต่างหากสำหรับแต่ละอุปกรณ์ต่อพ่วง เพื่อเข้าถึงเป็น peripheral_a.register1 หรือเพียงแคช peripheral ที่เฉพาะเจาะจง: peripheral_a = mcu_registers.peripheral_a หากรีจิสเตอร์ประกอบด้วย bitfield หลายตัว คุณจะต้องแคชการอ้างอิงไปยังรีจิสเตอร์เฉพาะ: reg_a = mcu_registers.peripheral_a.reg_a

  • หลีกเลี่ยงข้อมูลที่ไม่ใช่ scalar อื่น ๆ เช่น array ตัวอย่างเช่น แทนที่จะใช้ 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).