PyAsm — язык на основе байткода Python. Пишите код как в dis.dis(), запускайте, получайте ошибки с указанием строки. Попробуйте прямо сейчас!
Каждый Python-разработчик хотя бы раз видел вывод dis.dis() — байткод, в который интерпретатор превращает ваш код. Обычно это просто текст для чтения, «квитанция» о работе интерпретатора. Но что, если пойти в обратную сторону: написать этот ассемблерный код и получить работающую программу? Именно это делает PyAsm — язык, который выглядит как ассемблер, но выполняется как обычный Python.
Главная хитрость в том, что 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-файл, написанный стековой машиной.
В 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. Для программ без переходов цикл не нужен — они остаются линейными.
Настоящий вывод 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 есть и обратная команда: 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-строк, импортов, распаковки и срезов — посимвольно.
Одна из ключевых деталей — ошибки показывают строку в исходном ассемблере, а не в сгенерированном 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. Теперь это явная ошибка с указателем.
Есть два ограничения, и оба по одной причине: текстовый дизассемблер не сохраняет всю информацию объекта кода.
try/except в CPython управляется таблицей исключений объекта кода, а dis не выводит её в восстанавливаемом виде. Поэтому try/except не поддерживается. Зато raise работает и нормально распространяется.dis показывает как <code object f at 0x7f...>. Восстановить объект из repr нельзя, поэтому нельзя объявлять функции внутри .pya. Но можно импортировать и вызывать любые Python-функции.Из первого ограничения есть приятное следствие: CPython генерирует недостижимые хвосты обработчиков исключений после каждого спискового включения. PyAsm доказывает, что эти блоки недостижимы обычным потоком управления, заменяет их заглушками и выдаёт предупреждение. Так что включения работают корректно.
LOAD_SMALL_INT, TO_BOOL, POP_ITER и другие.run, dump, check, dis, opcodes.Я не уверен, хорошая это идея или глупая. С одной стороны, это отличный инструмент для обучения устройству стековой машины CPython. С другой — возможно, это решение проблемы, которой не существует. Поэтому я спрашиваю вас: стоит ли опубликовать PyAsm? Если да — для чего его использовать? Для обучения байткоду, отладки, код-гольфа или чего-то ещё?
А пока — попробуйте написать на PyAsm что-нибудь своё. Начните с простого: выведите Hello, World!, затем цикл, затем что-то посложнее. Ошибки подскажут, где вы ошиблись, а --dump-python покажет, как ваш ассемблер превращается в обычный Python.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →