uctypes — accès structuré aux données binaires

Ce module implémente une « interface de données externes » pour MicroPython. L’idée sous-jacente est similaire à celle du module ctypes de CPython, mais l’API réelle est différente, simplifiée et optimisée pour une faible empreinte mémoire. Le principe de base du module consiste à définir la disposition d’une structure de données avec à peu près la même puissance que celle qu’autorise le langage C, puis à y accéder à l’aide de la syntaxe par points familière pour référencer les sous-champs.

Avertissement

Le module uctypes permet d’accéder à des adresses mémoire arbitraires de la machine (y compris les registres d’entrée/sortie et de contrôle). Une utilisation imprudente peut entraîner des plantages, des pertes de données, voire un dysfonctionnement matériel.

Voir aussi

Module struct

Le module Python standard pour l’empaquetage et le dépaquetage de données binaires. struct opère sur des tampons entiers à la fois à l’aide d’une chaîne de format compacte (par exemple '<HBB4sI'), ce qui convient bien à quelques champs fixes mais s’adapte mal aux structures volumineuses ou profondément imbriquées : chaque lecture ou écriture réanalyse la chaîne de format, les unions et les champs de bits ne sont pas pris en charge, et il n’existe aucun moyen d’obtenir une vue typée sur un tampon existant. uctypes complète struct en vous permettant de décrire la disposition une seule fois, de l’attacher à une région mémoire (RAM, registres de périphériques, un bytearray), puis d’accéder aux champs individuels en tant qu’attributs nommés – ce qui évite les analyses et les copies répétées, tout en ajoutant la prise en charge des structures imbriquées, des tableaux, des unions et des champs de bits.

Exemples d’utilisation

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)

Définition de la disposition d’une structure

La disposition d’une structure est définie par un « descripteur » - un dictionnaire Python qui encode les noms de champs en tant que clés et les autres propriétés nécessaires pour y accéder en tant que valeurs associées

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

Actuellement, uctypes exige la spécification explicite des décalages pour chaque champ. Les décalages sont indiqués en octets à partir du début de la structure.

Voici des exemples d’encodage pour différents types de champs :

  • Types scalaires

    "field_name": offset | uctypes.UINT32
    

    autrement dit, la valeur est un identifiant de type scalaire combiné par OR avec un décalage de champ (en octets) à partir du début de la structure.

  • Structures récursives

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

    c’est-à-dire que la valeur est un tuple de deux éléments, dont le premier est un décalage et le second un dictionnaire descripteur de structure (note : les décalages dans les descripteurs récursifs sont relatifs à la structure qu’ils définissent). Bien entendu, les structures récursives peuvent être spécifiées non seulement par un dictionnaire littéral, mais aussi en se référant par son nom à un dictionnaire descripteur de structure (défini précédemment).

  • Tableaux de types primitifs

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

    c’est-à-dire que la valeur est un tuple de deux éléments, dont le premier est le drapeau ARRAY combiné par OR avec un décalage, et le second le type d’élément scalaire combiné par OR avec le nombre d’éléments du tableau.

  • Tableaux de types agrégés

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

    c’est-à-dire que la valeur est un tuple de trois éléments, dont le premier est le drapeau ARRAY combiné par OR avec un décalage, le second le nombre d’éléments du tableau, et le troisième un descripteur du type d’élément.

  • Pointeur vers un type primitif

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

    c’est-à-dire que la valeur est un tuple de deux éléments, dont le premier est le drapeau PTR combiné par OR avec un décalage, et le second un type d’élément scalaire.

  • Pointeur vers un type agrégé

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

    c’est-à-dire que la valeur est un tuple de deux éléments, dont le premier est le drapeau PTR combiné par OR avec un décalage, et le second un descripteur du type pointé.

  • Champs de bits

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

    c’est-à-dire que la valeur est un type de valeur scalaire contenant le champ de bits donné (les noms de types sont similaires aux types scalaires, mais préfixés par BF), combiné par OR avec le décalage de la valeur scalaire contenant le champ de bits, puis combiné par OR avec les valeurs de la position et de la longueur en bits du champ de bits au sein de la valeur scalaire, décalées respectivement de BF_POS et BF_LEN bits. La position d’un champ de bits est comptée à partir du bit de poids le plus faible du scalaire (qui a la position 0) et correspond au numéro du bit le plus à droite d’un champ (autrement dit, c’est le nombre de bits dont un scalaire doit être décalé vers la droite pour extraire le champ de bits).

    Dans l’exemple ci-dessus, une valeur UINT16 sera d’abord extraite au décalage 0 (ce détail peut être important lors de l’accès aux registres matériels, où une taille d’accès et un alignement particuliers sont requis), puis le champ de bits dont le bit le plus à droite est le bit lsbit de ce UINT16, et dont la longueur est de bitsize bits, sera extrait. Par exemple, si lsbit vaut 0 et bitsize vaut 8, cela accédera effectivement à l’octet de poids le plus faible du UINT16.

    Notez que les opérations sur les champs de bits sont indépendantes du boutisme de la cible ; en particulier, l’exemple ci-dessus accédera à l’octet de poids le plus faible du UINT16 dans les structures little-endian comme big-endian. En revanche, cela dépend du fait que le bit de poids le plus faible porte le numéro 0. Certaines cibles peuvent utiliser une numérotation différente dans leur ABI native, mais uctypes utilise toujours la numérotation normalisée décrite ci-dessus.

Contenu du module

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

Instancie un objet « structure de données externes » à partir d’une adresse de structure en mémoire, d’un descripteur (encodé sous forme de dictionnaire) et d’un type de disposition (voir ci-dessous).

uctypes.LITTLE_ENDIAN: int

Type de disposition pour une structure compactée little-endian. (Compactée signifie que chaque champ occupe exactement le nombre d’octets défini dans le descripteur, c’est-à-dire que l’alignement est de 1).

uctypes.BIG_ENDIAN: int

Type de disposition pour une structure compactée big-endian.

uctypes.NATIVE: int

Type de disposition pour une structure native - dont le boutisme et l’alignement des données sont conformes à l’ABI du système sur lequel MicroPython s’exécute.

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

Renvoie la taille de la structure de données en octets. L’argument struct peut être soit une classe de structure, soit un objet structure instancié spécifique (ou l’un de ses champs agrégés).

uctypes.addressof(obj: Any) int

Renvoie l’adresse d’un objet. L’argument doit être un objet bytes, bytearray ou tout autre objet prenant en charge le protocole de tampon (et c’est l’adresse de ce tampon qui est réellement renvoyée).

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

Capture la mémoire à l’adresse et la taille données sous forme d’objet bytes. Comme un objet bytes est immuable, la mémoire est en réalité dupliquée et copiée dans l’objet bytes ; ainsi, si le contenu de la mémoire change ultérieurement, l’objet créé conserve sa valeur d’origine.

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

Capture la mémoire à l’adresse et la taille données sous forme d’objet bytearray. Contrairement à la fonction bytes_at() ci-dessus, la mémoire est capturée par référence, de sorte qu’elle peut aussi bien être écrite, et vous accéderez à la valeur actuelle à l’adresse mémoire donnée.

Types entiers scalaires. Chacun occupe le nombre d’octets évident (1, 2, 4 ou 8) et est lu/écrit en utilisant le boutisme du type de disposition de la structure (l’un de NATIVE, LITTLE_ENDIAN ou BIG_ENDIAN).

uctypes.UINT8: int

Entier non signé sur 8 bits. Plage 0255.

uctypes.INT8: int

Entier signé sur 8 bits. Plage -128127.

uctypes.UINT16: int

Entier non signé sur 16 bits. Plage 065535.

uctypes.INT16: int

Entier signé sur 16 bits. Plage -3276832767.

uctypes.UINT32: int

Entier non signé sur 32 bits. Plage 00xFFFFFFFF.

uctypes.INT32: int

Entier signé sur 32 bits. Plage -0x800000000x7FFFFFFF.

uctypes.UINT64: int

Entier non signé sur 64 bits. Plage 00xFFFFFFFFFFFFFFFF.

uctypes.INT64: int

Entier signé sur 64 bits. Plage -0x80000000000000000x7FFFFFFFFFFFFFFF.

uctypes.FLOAT32: int

Nombre à virgule flottante IEEE 754 en simple précision (4 octets). Les lectures et les écritures sont converties depuis/vers un float Python.

uctypes.FLOAT64: int

Nombre à virgule flottante IEEE 754 en double précision (8 octets). Les lectures et les écritures sont converties depuis/vers un float Python.

uctypes.VOID: int

Alias de UINT8. Fourni pour que les champs de type C void * puissent être décrits de manière idiomatique sous la forme (uctypes.PTR, uctypes.VOID).

uctypes.PTR: int

Marque un champ de descripteur comme un pointeur vers un autre type. Un champ pointeur s’écrit sous la forme d’un tuple de deux éléments (offset | PTR, target_type_or_descriptor). Le déréférencement du pointeur produit une vue typée sur l’adresse qu’il contient.

uctypes.ARRAY: int

Marque un champ de descripteur comme un tableau de longueur fixe d’un autre type. Un champ tableau s’écrit soit (offset | ARRAY, count | element_type) pour les tableaux de scalaires, soit (offset | ARRAY, count, element_descriptor) pour les tableaux de structures. Le nombre d’éléments est fixé au moment de la définition du descripteur.

Il n’existe pas de constante explicite pour les structures : un descripteur agrégé qui n’utilise ni PTR ni ARRAY est traité comme une structure.

Descripteurs de structure et instanciation d’objets structure

À partir d’un dictionnaire descripteur de structure et de son type de disposition, vous pouvez instancier une structure spécifique à une adresse mémoire donnée à l’aide du constructeur uctypes.struct(). L’adresse mémoire provient généralement des sources suivantes :

  • Une adresse prédéfinie, lors de l’accès aux registres matériels sur un système baremetal. Recherchez ces adresses dans la fiche technique du MCU/SoC concerné.

  • Une valeur de retour d’un appel à une fonction FFI (Foreign Function Interface).

  • Depuis uctypes.addressof(), lorsque vous souhaitez transmettre des arguments à une fonction FFI, ou bien accéder à certaines données pour des entrées/sorties (par exemple, des données lues depuis un fichier ou un socket réseau).

Objets structure

Les objets structure permettent d’accéder aux champs individuels à l’aide de la notation par points standard : my_struct.substruct1.field1. Si un champ est de type scalaire, sa lecture produit une valeur primitive (un entier ou un flottant Python) correspondant à la valeur contenue dans le champ. Un champ scalaire peut également faire l’objet d’une affectation.

Si un champ est un tableau, ses éléments individuels peuvent être accédés avec l’opérateur d’indexation standard [] - aussi bien en lecture qu’en affectation.

Si un champ est un pointeur, il peut être déréférencé à l’aide de la syntaxe [0] (correspondant à l’opérateur * du C, bien que [0] fonctionne aussi en C). L’indexation d’un pointeur avec des valeurs entières autres que 0 est également prise en charge, avec la même sémantique qu’en C.

En résumé, l’accès aux champs d’une structure suit généralement la syntaxe C, à l’exception du déréférencement de pointeur, où vous devez utiliser l’opérateur [0] au lieu de *.

Limitations

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:

  • Évitez d’accéder aux structures imbriquées. Par exemple, au lieu de mcu_registers.peripheral_a.register1, définissez des descripteurs de disposition distincts pour chaque périphérique, à accéder sous la forme peripheral_a.register1. Ou bien mettez simplement en cache un périphérique particulier : peripheral_a = mcu_registers.peripheral_a. Si un registre est composé de plusieurs champs de bits, vous devrez mettre en cache des références à un registre particulier : reg_a = mcu_registers.peripheral_a.reg_a.

  • Évitez les autres données non scalaires, comme les tableaux. Par exemple, au lieu de peripheral_a.register[0], utilisez peripheral_a.register0. Là encore, une alternative consiste à mettre en cache les valeurs intermédiaires, par exemple 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).