ГлавнаяБлогPyAsm: пишем на Python-ассемблере и запускаем код
Python

PyAsm: пишем на Python-ассемблере и запускаем код

PyAsm — язык на основе байткода Python. Пишите код как в dis.dis(), запускайте, получайте ошибки с указанием строки. Попробуйте прямо сейчас!

Al
Редакция Algolitalgolit.ru
8 мин чтения28 августа 2026 г.

Что такое PyAsm и зачем он нужен

Каждый Python-разработчик хотя бы раз видел вывод dis.dis() — байткод, в который интерпретатор превращает ваш код. Обычно это просто текст для чтения, «квитанция» о работе интерпретатора. Но что, если пойти в обратную сторону: написать этот ассемблерный код и получить работающую программу? Именно это делает PyAsm — язык, который выглядит как ассемблер, но выполняется как обычный Python.

Как PyAsm превращает ассемблер в работающий код

Главная хитрость в том, что PyAsm не собирает байткод вручную — не использует types.CodeType, не упаковывает опкоды в байты и не зависит от версии CPython. Вместо этого каждая программа транслируется в обычный Python-модуль, который эмулирует стек значений интерпретатора.

# Исходный файл main.pya
LOAD_GLOBAL 1 (print + NULL)
LOAD_CONST 0 ('Hello, World!')
CALL 1
POP_TOP
LOAD_CONST 1 (None)
RETURN_VALUE

С помощью флага --dump-python можно посмотреть, во что превращается этот код:

def _pyasm_main(_ns):
    _st = []
    # LOAD_GLOBAL 1 (print + NULL)
    _st.append(_pyasm_load_name(_ns, 'print'))
    _st.append(NULL)
    # LOAD_CONST 0 ('Hello, World!')
    _st.append('Hello, World!')
    # CALL 1
    _st.append(_pyasm_call(_st, 1))
    # POP_TOP
    _st.pop()
    # LOAD_CONST 1 (None)
    _st.append(None)
    # RETURN_VALUE
    return _st.pop()

Весь трюк — в списке _st, который играет роль стека. Каждая инструкция — это несколько строк Python, которые кладут или забирают значения. Сгенерированный файл полностью автономен:

$ python hello.py
Hello, World!

Никаких импортов PyAsm, никаких зависимостей — просто Python-файл, написанный стековой машиной.

Как обрабатываются переходы в PyAsm

В Python нет goto, поэтому для переходов вроде JUMP_BACKWARD программа разбивается на базовые блоки и генерируется цикл диспетчеризации. Каждый блок — это case, а _ip — указатель инструкции:

_ip = 0
while True:
    match _ip:
        case 0:
            # LOAD_CONST 3
            _st.append(3)
            # STORE_NAME (n)
            _ns['n'] = _st.pop()
            _ip = 1
            continue
        case 1:
            # loop: LOAD_NAME (n)
            _st.append(_pyasm_load_name(_ns, 'n'))
            # TO_BOOL
            _t0 = _st.pop()
            _st.append(bool(_t0))
            # POP_JUMP_IF_FALSE (done)
            _t0 = _st.pop()
            if not _t0:
                _ip = 3
                continue

Обратные и прямые переходы, циклы, FOR_ITER — всё превращается в _ip = n; continue. Для программ без переходов цикл не нужен — они остаются линейными.

Метки вместо байтовых смещений в PyAsm

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

LOAD_CONST 3
STORE_NAME n
LOAD_SMALL_INT 0
LOAD_CONST None
IMPORT_NAME time
STORE_NAME time
loop:
    LOAD_NAME n
    TO_BOOL
    POP_JUMP_IF_FALSE done
    LOAD_NAME print
    PUSH_NULL
    LOAD_NAME n
    CALL 1
    POP_TOP
    LOAD_NAME n
    LOAD_CONST 1
    BINARY_OP (-)
    STORE_NAME n
    LOAD_NAME time
    LOAD_ATTR sleep
    PUSH_NULL
    LOAD_CONST 1
    CALL 1
    POP_TOP
    JUMP_BACKWARD loop
done:
    LOAD_NAME print
    PUSH_NULL
    LOAD_CONST ('Liftoff!')
    CALL 1
    POP_TOP
    RETURN_CONST (None)
$ pyasm examples/countdown.pya
3
2
1
Liftoff!

Да, этот код импортирует time и реально ждёт секунду между числами. Ассемблер, написанный вручную, вызывает стандартную библиотеку — и всё это на языке, где синтаксис — «одна инструкция на строку».

PyAsm работает в обе стороны: дизассемблер

В PyAsm есть и обратная команда: dis превращает обычный Python в ассемблерный файл, который затем можно запустить:

$ pyasm dis fizzbuzz.py -o fizzbuzz.pya
$ pyasm fizzbuzz.pya
1
2
Fizz
4
Buzz
...

Сгенерированный файл выглядит так, как и ожидалось:

1   2   LOAD_NAME 0 (range)
4   PUSH_NULL
6   LOAD_SMALL_INT 1
8   LOAD_SMALL_INT 16
10  CALL 2
18  GET_ITER
L1: 20 FOR_ITER 83 (to L5)
24  STORE_NAME 1 (n)

Тесты проверяют, что вывод Python → ассемблер → Python совпадает с исходным для циклов, списковых включений, f-строк, импортов, распаковки и срезов — посимвольно.

Ошибки указывают на ваш файл в PyAsm

Одна из ключевых деталей — ошибки показывают строку в исходном ассемблере, а не в сгенерированном Python. Например:

$ pyasm hi.pya
hi.pya:3: RuntimeError: stack underflow, needed 2 values
    CALL 1
    ^^^^^^

Даже если ошибка возникла глубоко внутри хелпера, она приписывается инструкции, которая её вызвала. Это касается NameError, ValueError и других исключений.

Особенно полезно сообщение о NULL:

main.pya:4: RuntimeError: NULL reached a callable or argument slot;
a call wants the callable and its NULL/self slot directly below the arguments
(callable, NULL, arg1, ..., argN)
    CALL 1
    ^^^^^^

Дело в том, что CALL n забирает n+2 значения, а не n+1. Под аргументами находится слот NULL (в дизассемблере это print + NULL). Если поставить его не туда, он молча станет аргументом, и вы получите Hi NULL вместо Hi. Теперь это явная ошибка с указателем.

Чего PyAsm не умеет (честно)

Есть два ограничения, и оба по одной причине: текстовый дизассемблер не сохраняет всю информацию объекта кода.

  1. Исключения. try/except в CPython управляется таблицей исключений объекта кода, а dis не выводит её в восстанавливаемом виде. Поэтому try/except не поддерживается. Зато raise работает и нормально распространяется.
  2. Определения функций, классов, лямбд. Они компилируются в отдельные объекты кода, которые dis показывает как <code object f at 0x7f...>. Восстановить объект из repr нельзя, поэтому нельзя объявлять функции внутри .pya. Но можно импортировать и вызывать любые Python-функции.

Из первого ограничения есть приятное следствие: CPython генерирует недостижимые хвосты обработчиков исключений после каждого спискового включения. PyAsm доказывает, что эти блоки недостижимы обычным потоком управления, заменяет их заглушками и выдаёт предупреждение. Так что включения работают корректно.

Текущее состояние PyAsm

  • 152 опкода: поддержка версий 3.11–3.14, обе конвенции вызова, LOAD_SMALL_INT, TO_BOOL, POP_ITER и другие.
  • Ноль зависимостей, Python 3.11+.
  • Более 70 тестов, включая полные циклы Python → ассемблер → Python.
  • Настоящий CLI на argparse: run, dump, check, dis, opcodes.

Стоит ли это публиковать?

Я не уверен, хорошая это идея или глупая. С одной стороны, это отличный инструмент для обучения устройству стековой машины CPython. С другой — возможно, это решение проблемы, которой не существует. Поэтому я спрашиваю вас: стоит ли опубликовать PyAsm? Если да — для чего его использовать? Для обучения байткоду, отладки, код-гольфа или чего-то ещё?

А пока — попробуйте написать на PyAsm что-нибудь своё. Начните с простого: выведите Hello, World!, затем цикл, затем что-то посложнее. Ошибки подскажут, где вы ошиблись, а --dump-python покажет, как ваш ассемблер превращается в обычный Python.

#PyAsm#байткод Python#ассемблер#dis#стековая машина
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →