Работа с машинным кодом в Python: от JIT до ассемблера

Раздел: Python -> Продвинутое программирование

Способы взаимодействия Python с машинным кодом

Python выполняется в виртуальной машине, которая интерпретирует байткод. Однако для повышения производительности или низкоуровневого доступа иногда требуется получить настоящий машинный код или сгенерировать его. Рассмотрим основные подходы: от JIT-компиляции до ручного вызова скомпилированных библиотек.

Как скомпилировать функцию Python в машинный код с помощью Numba?

Numba – это JIT-компилятор, который преобразует подмножество Python и NumPy в оптимизированный машинный код. Основной инструмент – декоратор @jit или @njit (без режима объекта).

Пример:

from numba import njit
import numpy as np

@njit
def sum_squares(arr):
    total = 0
    for x in arr:
        total += x * x
    return total

arr = np.arange(1000000)
result = sum_squares(arr)
print(result)

Python машинный код (машинный код python)

333332833333500000

Пошаговое объяснение:

  • Импорт: njit из Numba.
  • Типы: Numba выводит типы из аргументов. Если массив NumPy, он компилирует цикл в машинный код, работающий с сырыми данными.
  • Выполнение: Первый вызов компилирует функцию (возможна задержка), последующие – выполняют скомпилированный код.

Возможные проблемы:

  • Numba не поддерживает все возможности Python (например, динамические объекты, исключения). Если функция содержит неподдерживаемые операции, Numba переключается в «object mode» (медленно). Для принудительной компиляции используйте @njit – при ошибке возникнет исключение.
  • Требовалась установка: pip install numba. Некоторые платформы (ARM) могут иметь ограничения.

Как просмотреть байткод Python, который исполняет интерпретатор?

Модуль dis дизассемблирует код Python в мнемоники байткода. Хотя это не машинный код, понимание байткода помогает оптимизировать алгоритмы.

import dis

def multiply(x, y):
    return x * y

dis.dis(multiply)
  2           0 RESUME                   0
              2 LOAD_FAST                0 (x)
              4 LOAD_FAST                1 (y)
              6 BINARY_OP                5 (*)
             10 RETURN_VALUE

Пояснение: каждая инструкция байткода соответствует операции виртуальной машины.

Проблемы: байткод не является машинным, он зависит от версии Python. Для реального ускорения требуется компиляция в машинный код.

Как преобразовать Python-код в расширение C и получить машинный код с помощью Cython?

Cython компилирует файлы .pyx в C-код, который затем собирается в общую библиотеку (.so или .pyd).

Пример создания файла fast.pyx:

def factorial(int n):
    cdef int i
    cdef long result = 1
    for i in range(2, n+1):
        result *= i
    return result

Сборка через setup.py:

from setuptools import setup
from Cython.Build import cythonize

setup(ext_modules=cythonize('fast.pyx'))

После выполнения python setup.py build_ext --inplace появляется fast.cpython-*.so. Импорт:

import fast
print(fast.factorial(20))
2432902008176640000

Пошагово:

  • Объявление типов (cdef) ускоряет код.
  • Cython транслирует цикл в C-код, который компилируется в машинный.

Проблемы:

  • Требуется компилятор C (gcc, MSVC).
  • Синтаксис Cython частично отличается от Python (ключевые слова cdef, cpdef).
  • Ошибки типизации могут приводить к неверным результатам или падениям.

Как запустить Python-программу с JIT-компиляцией без изменения кода с помощью PyPy?

PyPy – альтернативная реализация Python, которая включает JIT-компилятор. Достаточно установить интерпретатор и запустить скрипт:

# example.py
def heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == '__main__':
    print(heavy(10**7))
# В терминале:
pypy3 example.py

PyPy компилирует горячие участки кода в машинный код на лету.

Проблемы:

  • Не все C-расширения (NumPy, pandas) корректно работают с PyPy.
  • Первое выполнение может быть медленнее пока JIT разогреется.
  • PyPy потребляет больше памяти.

Как вызвать скомпилированную C-функцию из Python с помощью ctypes?

Если у вас есть готовая библиотека libmylib.so, можно загрузить её и вызвать функцию как машинный код.

Пример библиотеки на C (mylib.c):

#include <stdint.h>

int64_t square(int64_t x) {
    return x * x;
}

Сборка: gcc -shared -fPIC -o libmylib.so mylib.c

Использование в Python:

import ctypes

lib = ctypes.CDLL('./libmylib.so')
lib.square.argtypes = [ctypes.c_int64]
lib.square.restype = ctypes.c_int64

print(lib.square(12))
144

Пояснение: ctypes-C интерфейс передаёт управление на машинный код библиотеки.

Проблемы:

  • Необходимость вручную задавать типы аргументов и возврата.
  • Управление памятью (освобождение malloc-буферов).
  • Ошибки сегментации при несовпадении типов.

Как генерировать и выполнять произвольный машинный код на лету?

Для этого используются модули вроде keystone-engine (ассемблер) и unicorn (эмулятор) или asmjit. Пример с Keystone + Unicorn:

from keystone import *
from unicorn import *
from unicorn.x86_const import *

# Машинный код: mov eax, 42; ret
CODE = b'\xB8\x2A\x00\x00\x00\xC3'  # альтернативно через asm

mu = Uc(UC_ARCH_X86, UC_MODE_64)
ADDRESS = 0x1000000
mu.mem_map(ADDRESS, 2 * 1024 * 1024)
mu.mem_write(ADDRESS, CODE)
mu.emu_start(ADDRESS, ADDRESS + len(CODE))
result = mu.reg_read(UC_X86_REG_EAX)
print(f"EAX = {result}")
EAX = 42

Пошагово:

  • Keystone компилирует ассемблерный код в байты.
  • Unicorn эмулирует выполнение.

Проблемы:

  • Сложность настройки и высокая вероятность ошибок.
  • Не предназначено для production, только для исследований.
  • Безопасность: запуск произвольного кода может быть опасен.

Расширенные примеры работы с машинным кодом

1. Numba с многомерными массивами и параллелизмом

Пример
from numba import njit, prange
import numpy as np

@njit(parallel=True)
def matrix_mult(A, B):
    n, m = A.shape
    m2, p = B.shape
    C = np.zeros((n, p), dtype=np.float64)
    for i in prange(n):
        for j in range(p):
            total = 0.0
            for k in range(m):
                total += A[i, k] * B[k, j]
            C[i, j] = total
    return C

A = np.random.rand(200, 300)
B = np.random.rand(300, 400)
C = matrix_mult(A, B)
print(C.shape, C[0,0])
(200, 400) 23.456789...

Параллельная версия использует все ядра CPU. Numba автоматически распределяет итерации prange.

2. Дизассемблирование сложного класса с методами

Пример
import dis

class Calculator:
    def add(self, a, b):
        return a + b
    
    @staticmethod
    def multiply(a, b):
        return a * b

dis.dis(Calculator.add)
dis.dis(Calculator.multiply)
Disassembly of Calculator.add:
  2           0 RESUME                   0
              2 LOAD_FAST                1 (a)
              4 LOAD_FAST                2 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

Disassembly of Calculator.multiply:
  6           0 RESUME                   0
              2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                5 (*)
             10 RETURN_VALUE

Показывает, что статические методы тоже имеют собственный байткод.

3. Cython с использованием массивов NumPy и типизации

Создайте fast_norm.pyx:

Пример
import numpy as np
cimport numpy as cnp

@cython.boundscheck(False)
@cython.wraparound(False)
def euclidean_norm(cnp.ndarray[cnp.float64_t, ndim=2] arr):
    cdef Py_ssize_t i, j
    cdef double total, val
    for i in range(arr.shape[0]):
        total = 0.0
        for j in range(arr.shape[1]):
            val = arr[i, j]
            total += val * val
        arr[i, 0] = total ** 0.5
    return arr

Скомпилируйте с cythonize и соберите. Вызов:

Пример
import numpy as np
import fast_norm
data = np.random.randn(10000, 10).astype(np.float64)
result = fast_norm.euclidean_norm(data.copy())
print(result[:3, 0])
[1.234 2.345 0.678]

Обратите внимание: отключение проверок границ ускоряет код, но может привести к выходу за пределы массива при ошибке.

4. Загрузка самодельной DLL с функциями из Python через ctypes

Код на C (mathlib.c):

Пример
#include <stdint.h>

double mean(double* arr, int n) {
    double sum = 0;
    for (int i = 0; i < n; i++) sum += arr[i];
    return sum / n;
}

Сборка: gcc -shared -o mathlib.so mathlib.c

Python:

Пример
import ctypes
import numpy as np

lib = ctypes.CDLL('./mathlib.so')
lib.mean.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int]
lib.mean.restype = ctypes.c_double

data = np.array([1.2, 3.4, 5.6], dtype=np.float64)
ptr = data.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
print(lib.mean(ptr, len(data)))
3.4000000000000004

Используется указатель на массив NumPy.

5. Генерация и выполнение машинного кода с Keystone + Unicorn (полный пример)

Пример
from keystone import *
from unicorn import *
from unicorn.x86_const import *

# Код: вычислить сумму чисел 10 и 20 через стек
CODE_ASM = """
    mov eax, 10
    push eax
    mov eax, 20
    pop ebx
    add eax, ebx
    ret
"""

ks = Ks(KS_ARCH_X86, KS_MODE_64)
code_bytes, count = ks.asm(CODE_ASM)
print(f"Скомпилировано {count} инструкций")

mu = Uc(UC_ARCH_X86, UC_MODE_64)
ADDR = 0x1000000
mu.mem_map(ADDR, 0x1000)
mu.mem_write(ADDR, bytes(code_bytes))

# Устанавливаем стек (RSP = ADDR + 0x800)
stack_addr = ADDR + 0x800
mu.reg_write(UC_X86_REG_RSP, stack_addr)
mu.mem_map(stack_addr, 0x1000)

mu.emu_start(ADDR, ADDR + len(code_bytes))
eax = mu.reg_read(UC_X86_REG_EAX)
print(f"Результат в EAX: {eax}")
Скомпилировано 5 инструкций
Результат в EAX: 30

Важно: код должен быть написан для архитектуры, которую поддерживает Unicorn.

Машинный код Python - comments

En
Python машинный код (python)