Migrating from Legacy Systems

Appeler une DLL 32 bits depuis un plug

Gardez une DLL 32 bits en service sur un poste TofuPilot 64 bits en l'hébergeant dans un serveur 32 bits msl-loadlib derrière un plug.

JJuliette Lansoy
intermediate9 min de lecture9 octobre 2026

Un poste de test qui tourne avec le CLI TofuPilot reçoit un Python 64 bits, et un processus 64 bits ne peut pas charger une DLL 32 bits. Apprenez à garder une bibliothèque fournisseur 32 bits en service en l'hébergeant dans son propre processus 32 bits avec msl-loadlib, et en l'exposant à vos phases comme un plug ordinaire.

Pourquoi un processus 64 bits ne peut pas charger une DLL 32 bits

Une DLL est chargée dans l'espace mémoire du processus qui l'appelle. Les pointeurs font 8 octets dans un processus 64 bits et 4 octets dans un processus 32 bits, donc les deux ne peuvent pas partager un espace d'adressage. Windows refuse le chargement avant qu'une seule ligne de code ne s'exécute :

error.txt
OSError: [WinError 193] %1 is not a valid Win32 application

L'interpréteur du poste est 64 bits par construction. Le CLI provisionne l'environnement virtuel de chaque procédure avec uv venv --python 3.12, qui prend l'architecture native de la machine, et aucun réglage ne change cela. Les vieilles bibliothèques fournisseur, les DLL maison dont la source est perdue et les bibliothèques partagées compilées avec un ancien LabVIEW sont les survivantes 32 bits habituelles.

Deux issues existent :

OptionNécessiteRésultat
Recompiler la DLL en 64 bitsLa source et une version 64 bits de chaque dépendanceLa dépendance disparaît pour de bon
Héberger la DLL dans un processus 32 bitsRien d'autre que la DLLLe séquenceur reste 64 bits, la DLL continue de tourner

Ce guide suit la seconde. L'isolation repose sur la même idée qu'un optocoupleur entre deux domaines de tension : les signaux traversent, les fils non.

Prérequis

  • Un poste de test Windows avec le CLI TofuPilot installé
  • La DLL 32 bits, avec son fichier d'en-tête ou sa documentation, pour connaître le prototype de chaque fonction et la convention d'appel (__cdecl ou __stdcall)
  • msl-loadlib 1.1 ou plus récent, qui livre un serveur 32 bits précompilé pour Windows, donc aucun Python 32 bits à installer sur le poste

Étape 1 : ajouter msl-loadlib aux dépendances de la procédure

Le CLI installe les dépendances d'une procédure dans son environnement virtuel lors d'un tofupilot run et à chaque déploiement :

requirements.txt
msl-loadlib>=1.1

msl-loadlib est une fine surcouche de ctypes. La différence tient à l'endroit où le code ctypes s'exécute : dans un processus serveur 32 bits que la bibliothèque démarre pour vous, au lieu de votre Python 64 bits.

Étape 2 : déplacer le code ctypes dans une classe serveur 32 bits

Le module serveur est importé par le serveur 32 bits, jamais par le Python du poste. Mettez-y chaque appel ctypes, et chaque tampon que la DLL remplit. L'exemple enveloppe une bibliothèque I2C fictive à quatre fonctions :

plugs/busbridge_server32.py
57 lines
import ctypesfrom msl.loadlib import Server32class BusBridgeServer(Server32):    def __init__(self, host, port, **kwargs):        # kwargs vient du constructeur de Client64. msl-loadlib transforme        # chaque valeur en chaîne, il faut donc les reconvertir ici.        dll_path = kwargs["dll_path"]        device_index = int(kwargs.get("device_index", "0"))        # "cdll" pour une DLL __cdecl, "windll" pour une DLL __stdcall.        super().__init__(dll_path, "cdll", host, port)        lib = self.lib        lib.BB_Open.argtypes = [ctypes.c_int]        lib.BB_Open.restype = ctypes.c_int        lib.BB_I2CWrite.argtypes = [            ctypes.c_int,            ctypes.c_int,            ctypes.POINTER(ctypes.c_ubyte),            ctypes.c_int,        ]        lib.BB_I2CWrite.restype = ctypes.c_int        lib.BB_I2CRead.argtypes = [            ctypes.c_int,            ctypes.c_int,            ctypes.POINTER(ctypes.c_ubyte),            ctypes.c_int,        ]        lib.BB_I2CRead.restype = ctypes.c_int        lib.BB_Close.argtypes = [ctypes.c_int]        lib.BB_Close.restype = None        self._handle = lib.BB_Open(device_index)        if self._handle < 0:            raise RuntimeError(f"BB_Open({device_index}) failed with code {self._handle}")    def i2c_write(self, address, data):        buf = (ctypes.c_ubyte * len(data))(*data)        rc = self.lib.BB_I2CWrite(self._handle, address, buf, len(data))        if rc != 0:            raise RuntimeError(f"BB_I2CWrite(0x{address:02X}) failed with code {rc}")    def i2c_read(self, address, length):        buf = (ctypes.c_ubyte * length)()        count = self.lib.BB_I2CRead(self._handle, address, buf, length)        if count < 0:            raise RuntimeError(f"BB_I2CRead(0x{address:02X}) failed with code {count}")        # Une liste d'entiers traverse la frontière entre processus. Le tampon, non.        return list(buf[:count])    def close(self):        if self._handle >= 0:            self.lib.BB_Close(self._handle)            self._handle = -1

Trois règles s'appliquent à chaque méthode de la classe serveur :

  • Les arguments et les valeurs de retour sont des valeurs Python simples : nombres, chaînes, listes, dictionnaires. Elles sont sérialisées (pickle) entre les deux processus.
  • Les pointeurs ne traversent jamais. Un tampon que la DLL remplit est alloué dans la méthode, lu dans la méthode, et renvoyé sous forme de liste.
  • Le handle de la bibliothèque est self.lib. C'est un ctypes.CDLL pour cdll et un ctypes.WinDLL pour windll. Définissez argtypes et restype une fois dans __init__, comme avec ctypes seul.

Une exception levée dans une méthode serveur ne traverse pas sous sa propre forme. Le client lève msl.loadlib.exceptions.Server32Error, dont le message contient le traceback côté serveur, y compris le texte que vous avez écrit. La phase voit une seule exception avec une cause lisible, ce qu'il faut à une phase qui échoue.

Étape 3 : écrire le plug comme un client 64 bits

Le plug est la classe que le moteur instancie. Elle hérite de Client64, démarre le serveur 32 bits dans son constructeur, et relaie chaque méthode avec request32 :

plugs/busbridge.py
32 lines
import osfrom msl.loadlib import Client64SERVER_MODULE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "busbridge_server32.py")class BusBridge(Client64):    def __init__(self, dll_path, device_index=0):        # Démarre le serveur 32 bits (quelques secondes) et importe        # SERVER_MODULE dedans. Les arguments nommés supplémentaires        # arrivent dans BusBridgeServer.__init__ sous forme de chaînes.        super().__init__(SERVER_MODULE, dll_path=dll_path, device_index=device_index)        self.dll_path = dll_path    def i2c_write(self, address, data):        self.request32("i2c_write", address, list(data))    def i2c_read(self, address, length):        return self.request32("i2c_read", address, length)    def __del__(self):        # Le moteur appelle __del__ quand le plug est détruit. Sans cet        # arrêt, un processus serveur 32 bits survivrait à chaque run.        try:            self.request32("close")        except Exception:            pass        try:            self.shutdown_server32()        except AttributeError:            pass  # le serveur n'a jamais démarré, rien à arrêter

request32 prend le nom d'une méthode serveur et ses arguments positionnels, et renvoie ce que la méthode a renvoyé. Les arguments nommés passés à Client64.__init__ atteignent le constructeur du serveur, avec une réserve : host, port, timeout, rpc_timeout, server32_dir, append_sys_path, append_environ_path et add_dll_directory sont les paramètres propres de msl-loadlib, nommez les vôtres autrement.

Le moteur garantit l'appel de __del__ à la destruction du plug. La méthode arrête le serveur 32 bits, donc aucun processus orphelin ne reste entre deux runs.

Étape 4 : déclarer le plug et l'utiliser dans une phase

procedure.yaml
21 lines
name: EEPROM readback through a 32-bit bus bridge DLLversion: 0.1.0plugs:  - name: Bus Bridge    key: bus_bridge    description: 32-bit vendor DLL hosted in a msl-loadlib 32-bit server    python: plugs.busbridge:BusBridge    scope: station    config:      dll_path: "C:\\BusBridge\\busbridge.dll"      device_index: 0main:  - name: EEPROM header readback    python: phases.i2c_readback    measurements:      - name: eeprom_header_byte0        validators:          - operator: "=="            expected_value: 165

scope: station conserve une seule instance pendant toute la vie du processus du poste, donc le démarrage du serveur (deux à trois secondes mesurées sur une machine Windows) et l'appel BB_Open ne sont payés qu'une fois, pas à chaque run. Le bloc config est passé au constructeur du plug en arguments nommés, et c'est là que le chemin de la DLL a sa place : un chemin propre à un poste n'a rien à faire dans du code Python.

La phase reçoit le plug par sa clé et l'appelle comme n'importe quel plug :

phases/i2c_readback.py
EEPROM_ADDRESS = 0x50def i2c_readback(bus_bridge, measurements, log):    # Les arguments d'un plug sont positionnels et sérialisables en JSON :    # ici des entiers et des listes d'entiers, jamais des bytes ni des    # tampons ctypes.    bus_bridge.i2c_write(EEPROM_ADDRESS, [0x00, 0x00])    header = bus_bridge.i2c_read(EEPROM_ADDRESS, 4)    log.info(f"EEPROM header: {[hex(b) for b in header]}")    measurements.eeprom_header_byte0 = header[0]

Deux frontières de processus séparent la phase de la DLL : le processus de plug du moteur, puis le serveur 32 bits. Les deux transportent le même genre de valeurs, donc la règle est la même des deux côtés. Passez des nombres, des chaînes et des listes, jamais des tampons ni des handles.

Étape 5 : réutiliser des modules Python 32 bits existants sans les réécrire

Si le code ctypes existe déjà sous forme de modules Python écrits pour un Python 32 bits, vous n'avez pas à le déplacer dans la classe serveur. La classe serveur peut importer ces modules tels quels et leur déléguer :

plugs/busbridge_server32.py
import sysfrom msl.loadlib import Server32# Retire le site-packages 64 bits que le client transmet, pour que# l'import ci-dessous ne tombe pas sur un numpy ou un comtypes 64 bits.Server32.remove_site_packages_64bit()import legacy_busbridge  # le module existant, intactclass BusBridgeServer(Server32):    def __init__(self, host, port, **kwargs):        super().__init__(kwargs["dll_path"], "cdll", host, port)        self._dev = legacy_busbridge.BusBridge(int(kwargs["device_index"]))    def i2c_read(self, address, length):        return list(self._dev.read(address, length))

Deux choses changent par rapport à la première version :

  1. Le serveur doit trouver les modules existants. Passez leur répertoire avec append_sys_path dans le constructeur de Client64.
  2. Le serveur doit contenir leurs dépendances. Le serveur précompilé ne livre que la bibliothèque standard. Si les modules existants importent numpy, pyserial ou comtypes, construisez une fois un serveur personnalisé depuis l'environnement Python 32 bits qui les fait déjà tourner :
build-server32.txt
# Dans l'environnement Python 32 bits existant (3.8 ou plus récent),# lancé depuis le répertoire qui contient les modules existantspip install msl-loadlib pyinstallerfreeze32 --imports numpy serial legacy_busbridge --dest server32-custom

La commande écrit un server32-windows.exe dans server32-custom. Chaque module nommé après --imports y est gelé, module existant compris, donc append_sys_path n'est plus nécessaire pour eux. Pointez le plug vers ce répertoire avec l'argument server32_dir de Client64.__init__, et le code existant tourne comme il l'a toujours fait, dans son propre interpréteur. Un piège : freeze32 signale un module qu'il ne peut pas importer mais sort quand même avec le code 0, vérifiez donc que l'exécutable existe avant de le livrer.

Garder les données volumineuses côté 32 bits

Chaque appel request32 sérialise ses arguments et son résultat à travers une socket locale. Quelques centaines d'octets ne coûtent rien. Une longue acquisition, si : une forme d'onde d'un million d'échantillons en 16 bits pèse 2 Mo par appel, et une phase qui en tire cinquante pour moyenner un niveau de bruit passe plus de temps à copier qu'à mesurer.

Déplacez plutôt le calcul vers les données. Calculez la mesure dans la méthode serveur et renvoyez les nombres :

plugs/daq_server32.py
def rms_noise(self, acquisitions):    """Acquiert `acquisitions` formes d'onde et renvoie le bruit RMS, calculé ici."""    import math    total = 0.0    count = 0    for _ in range(acquisitions):        buf = (ctypes.c_int16 * self._samples)()        self.lib.DAQ_Acquire(self._handle, buf, self._samples)        mean = sum(buf) / self._samples        total += sum((s - mean) ** 2 for s in buf)        count += self._samples    return math.sqrt(total / count) * self._volts_per_lsb

La phase reçoit un seul flottant. Les échantillons ne quittent jamais le processus 32 bits.

Pièges courants

Passer un pointeur ou un objet ctypes à travers request32. L'appel échoue à la sérialisation, ou pire, le côté 32 bits lit une adresse 64 bits. Allouez dans la méthode serveur, renvoyez des valeurs.

Utiliser la mauvaise convention d'appel. Une DLL __stdcall chargée en cdll corrompt la pile au premier appel. L'en-tête dit laquelle, et windll est le remède.

Oublier que les kwargs du serveur sont des chaînes. device_index=0 arrive sous la forme "0". Convertissez dans le constructeur du serveur.

Importer numpy dans le serveur sans build personnalisé. Le serveur précompilé n'a pas numpy, et le site-packages 64 bits dont il hérite contient le mauvais. Appelez remove_site_packages_64bit() et gelez un serveur qui contient les paquets dont vous avez besoin.

Déclarer le plug en scope: slot sur un bus partagé. Plusieurs slots démarreraient plusieurs serveurs 32 bits contre une seule DLL et une seule interface physique. Utilisez station ou execution pour que les appels se sérialisent à travers une seule instance.

Supprimer l'appel shutdown_server32(). Chaque run laisse un processus 32 bits derrière lui. Gardez-le dans __del__.

Envoyer des acquisitions entières à travers la frontière. Calculez dans le serveur, renvoyez des nombres.

Points clés

  • Un Python 64 bits ne peut pas charger une DLL 32 bits, et l'interpréteur du poste est 64 bits sans possibilité de le changer.
  • msl-loadlib exécute votre code ctypes dans un serveur 32 bits précompilé et donne au côté 64 bits une classe client.
  • Le plug est le client. Les phases voient un plug ordinaire avec des méthodes ordinaires.
  • Les pointeurs et les tampons restent dans le serveur. Seuls les nombres, chaînes, listes et dictionnaires traversent.
  • Des modules Python 32 bits existants peuvent être hébergés tels quels, avec un serveur gelé personnalisé quand ils ont besoin de paquets.
  • Gardez les données lourdes côté 32 bits et renvoyez la mesure, pas les échantillons bruts.

Plus de guides

Mettez ce guide en pratique