Работа с машинным кодом в Python: от JIT до ассемблера
Способы взаимодействия 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.