Инструкции и чтение файла на ассемблере

Последнее обновление: 01.03.2024

Основу программы на ассемблере составляют инструкции - некоторые действия, например, сложение двух значений, помещение в регистр значения и т.д. При выполнении программы процессор выбирает и интерпретирует каждую инструкцию. Как и все данные, каждая инструкция, каждое действие в программе представляет последовательность битов. Каждой инструкции сопоставляется определенный машинный двоичный код, который также называется кодом инструкции или кодом операции (опкод, opcode).

Код операции — это один байт, определяющий основную операцию инструкции. Например, в ARM64 инструкция, которая копирует в регистр X0 число 1, имеет опкод 1100101 в двоичной форме. В зависимости от инструкции, ее операндов опкод меняется. К опкодам инструкций следует добавить коды/значения операндов - регистра и чисел.

Написание машинного кода вручную возможно, но излишне громоздко. На практике вместо опкодов применяются так называемые мнемоники - человекочитаемые названия инструкций. Например, инструкция, которая копирует в регистр некоторое значение, имеет мнемонику mov (от слова "move" - помещать, поместить). А чтобы скопировать в регистр X0 число 1, нам достаточно написано команду

MOV X0, 1

Это довольно удобнее, чем вводить команды в бинарной форме.

Программа состоит из набора подобных инструкций. Процессор запускает программы через цикл выборки-выполнения (fetch-execute cycle). Компьютер считывает по одной инструкции за раз. Для этого процессор обращается к специальному регистру - указателю команд (или регистр IP), который также называется программным счетчиком (или PC - program counter) и который хранит адрес инструкции для выполнения. По сути, компьютер выполняет бесконечный цикл следующих операций:

  1. Считывает инструкцию с адреса памяти, указанного указателем инструкции - регистром IP/PC

  2. Декодирует инструкцию (т. е. выясняет, что означает инструкция)

  3. Перемещает указатель инструкций (регистр IP/PC) к следующей инструкции

  4. Выполняет указанную инструкцию

Различные архитектуры поддерживают различный набор инструкций, различные ассемблеры для этих архитектур могут также различаться по реализованной поддержке тех или иных инструкций, поэтому далее мы рассмотрим использование абстрактного ассемблера без привязки к определенной архитектуре на примере наиболее распространенных инструкций.

Так, наиболее распространенных инструкций в Intel x64/ARM64 является инструкция копирования значения, например, в регистр или инструкция MOV. Обычно это инструкция принимает два операнда:

mov dest, source

Данная инструкция принимает два операнда и помещает значение source в операнд dest.

Для данной статьи используем еще одну инструкцию - ADD - инструкцию сложения. В зависимости от архитектуры она может принимать разное количество операндов. Например, в Intel x86-64 она принимает два операнда:

add dest, source

Инструкция помещает в операнд dest сумму значений операндов dest и source, то есть dest = dest + source

В ARM подобная инструкция принимает три операнда:

add dest, source1, source2

Она помещает в операнд dest сумму операндов source1 и source2. Далее будем ориентироваться на версию для ARM.

Считывание файла программы

Чтобы считать и интерпертировать инструкции ассемблера, нам надо где-то их определить. То есть нам надо определить исходный код программы на ассемблере. Мы можем это сделать внутри программы, либо во вне. Рассмотрим второй вариант как более гибкий, и в этом случае у нас есть различные варианты:

  • Текст программы может быть определен в отдельном файле, например, в файле с расширением *.asm или *.s

  • Инструкции могут вводиться динамически и сразу же интерпретироваться (REPL), как это делается, например, интерпретатора Python или в веб-браузере при вводе команд JavaScript

Сначала возьмем первый вариант. Пусть у нас есть файл hello.s с несколькими инструкциями на условном ассемблере:

// тестовая программа на ассемблере
mov r1, 11          // помещаем в r1 число 11
mov r2, 22          // помещаем в r2 число 22
add r0, r1, r2      // складываем r1 и r2 и помещаем результат в r0
add r0, r0, 2      // складываем r0 и число 2 и помещаем результат в r0

Здесь и во всех последующих примерах будем использовать синтаксис, более близкий к ассемблеру от GNU для ARM. В частности, комментарии начинаются с символа "//". Сначала помещаем в регистр r1 число 11, затем помещаем в регистр r2 число 22, после этого складываем их и результат помещаем в регистр r0. Четвертая инструкция складывает значение из регистра r0 с числом 2. То есть в итоге в регистре r0 должно оказаться число 35 (11 + 22 + 2).

Определим программу на Python, которая пока просто считывает этот файл и обрабатывает его содержимое:

lines = []
# считываем файл в список lines
with open("hello.s", "r", encoding="utf8") as source:
    lines = [s for s in source]

# обрабатываем файл
for i in range(0,len(lines)):
    lines[i] = lines[i].split("//")[0]       # удаляем комментарии
    lines[i] = lines[i].replace(",", " ")  # заменяем запятые на пробелы
    lines[i] = lines[i].strip().rstrip("\n") # удаляем начальные и концевые пробелы и переводы строки
    lines[i] = lines[i].lower()             # переводим в нижний регистр
    while "  " in lines[i]:
        lines[i] = lines[i].replace("  ", " ")  # заменяем несколько пробелов одним

lines =[line for line in lines if line !=""]    # удаляем пустые строки

for line in lines: print(line)

Здесь считываем все содержимое построчно в список lines. То есть данный список будет представлять список строк. Однако после считывания нам надо привести строки к некоторому виду, который более удобен для обработки. Для этого проходим по каждой строке и удаляем из нее комментарии, начальные, конечные и множественные пробелы, завершения строк \n, а также пустые строки. Также заменяем запятые на пробелы (далее мы увидим, как это упростит обработку). Кроме того, чтобы исключить влияние регистра на обработку программы, переводим весь код в нижний регистр (код на ассемблере обычно регистронезависим).

В итоге после выполнения этой программы мы трансформируем код на ассемблере из файла "hello.s" в следующий:

mov r1 11
mov r2 22
add r0 r1 r2
add r0 r0 2

Итак, здесь уже нет комментариев, а отдельные токены - части инструкций - мнемоники и операнды разделены проблеми. Таким образом, чтобы получить отдельные токены инструкци, достаточно разбить строку по пробелам.

Выполнение инструкций ассемблера

После приведения кода ассемблера к некоторому удобному виду мы можем начать интерпретацию команд ассемблера. Для этого определим следующую программу на Python:

instructions = []       # иструкции, разбитые по токенам
r = [0]*4       # значения 4 регистров
# карта сопоставления регистров и их индексов в списке r    
regs32 = {"r0":0, "r1":1, "r2":2, "r3":3}
pc = 0  # указатель на следующую инструкцию

# считываем файл hello.s в список инструкций
with open("hello.s", "r", encoding="utf8") as source:
    lines = source.readlines()
    # обрабатываем строки из файла
    for i in range(0,len(lines)):
        lines[i] = lines[i].split("//")[0] \
                    .replace(",", " ")  \
                    .strip().rstrip("\n") \
                    .lower()
        while "  " in lines[i]:             # заменяем несколько пробелов одним
            lines[i] = lines[i].replace("  ", " ")  
        if(lines[i]) == "": continue        # если получилась пустая строка, переходим к следующей строке

        tokens = lines[i].split(" ")      # разбиваем инструкцию на токены
        instructions.append(tokens)         # добавляем инструкцию в список instructions

# функций вывода состояния программы на консоль
def print_state(instruction):
    print(f"pc:{pc}", end="   ")        # выводим значение PC
    print(f"{instruction:<16}", end=" ")     # выводим текущую инструкцию
    for reg in regs32:                      # выводим регистры
        rInd = regs32[reg]
        print(f"{reg}:{r[rInd]:<3}", end=" ")
    print()

# цикл обработки инструкций
while True:
    if pc >= len(instructions): break  # если инструкции закончились, то выход из цикла
    tokens = instructions[pc]     # получаем текущую инструкцию для выполнения
    pc = pc + 1                 # увеличиваем указатель инструкций
    
    match tokens[0]: 
        case "mov": 
            rd = regs32[tokens[1]]    # получаем индекс регистра- первого операнда
            if(tokens[2]) in regs32:  # если второй операнд - регистр
                rs = regs32[tokens[2]]    # получаем индекс регистра
                r[rd] = r[rs]       # в регистр rd помещаем значение из регистра rs
            else:
                literal = int(tokens[2])  # если простое число
                r[rd] = literal
                
        case "add": 
            rd = regs32[tokens[1]]        # получаем индекс регистра - первого операнда
            rs1 = regs32[tokens[2]]       # получаем индекс регистра - второго операнда
            if(tokens[3]) in regs32:      # если третий операнд - регистр
                rs2 = regs32[tokens[3]]    # получаем индекс регистра
                r[rd] = r[rs1] + r[rs2]       # в регистр rd помещаем сумму из регистров rs1 и rs2
            else:
                literal = int(tokens[3])  # если простое число
                r[rd] = r[rd] + literal
        case _: raise Exception("Invalid instruction")
    
    # логгируем состояние программы на консоль
    print_state(" ".join(tokens))   # объединяем токены инструкции в строку 

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

instructions = []

Для хранения данных регистров определяем список r из условных 4 регистров:

r = [0]*4      # значения 4 регистров
regs32 = {"r0":0, "r1":1, "r2":2, "r3":3}   # названия регистров и индексы их значений в списке r
pc = 0   # указатель на инструкцию

Для сопоставления текстовый обозначений регистров из инструкций со значениями в списке r применяется словарь regs32, где каждому названию регистра сопоставляется его индекс в списке r.

И кроме того, определен регистр PC - указатель на следующую инструкцию. По умолчанию он равен нулю и таким образом указывавет на первую инструкцию.

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

tokens = lines[i].split(" ")      # разбиваем инструкцию на токены
instructions.append(tokens)         # добавляем инструкцию в список instructions

Список токенов каждой отдельной инструкции затем помещается в список инструкций. Подобная разбивка далее позволит легко определить выполняемую инструкцию и ее операнды.

Затем определена функция вывода состояния программы на консоль - функция print_state:

def print_state(instruction):
    print(f"pc:{pc}", end="   ")        # выводим значение PC
    print(f"{instruction:<16}", end=" ")     # выводим текущую инструкцию
    for reg in regs32:                      # выводим регистры
        rInd = regs32[reg]
        print(f"{reg}:{r[rInd]:<3}", end=" ")
    print()

Данная функция в качестве параметра принимает текущую выполняемую инструкцию и выводит состояние регистров после ее выполнения.

Затем в бесконечном цикле собственно начинаем выполнять инструкции:

while True:
    if pc >= len(instructions): break  # если инструкции закончились, то выход из цикла
    tokens = instructions[pc]     # получаем текущую инструкцию для выполнения
    pc = pc + 1                 # увеличиваем указатель инструкций

Поскольку при выполнении инструкции счетчик программы PC увеличивается на 1 и таким образом начинает указывать на следующую инструкцию, то в какой-то момент инструкции могут закончится. В этом случае счетчик PC будет равен количеству строк-инструкций, и тогда выходим из цикла с помощью оператора break.

Если же еще есть инструкции для выполнения, то получаем в переменную inst текущую инструкцию, на которую указывает регистр PC и инкрементируем его.

После обработки файла все токены инструкции разделены пробелами. Начальный токен в этом случае будет представлять мнемонику инструкции:

match tokens[0]: 

С помощью конструкции match сопоставляем мнемонику с двумя инструкциями - "MOV" и "ADD" для определения выполняемых действий. Для прощения условимся, что первый операнд инструкции MOV должен представлять регистр, а второй операнд - или регистр или обычное число (литерал). А в инструкции ADD первые два операнда должны представлять регистры, а третий операнд - регистр или литерал:

mov reg, reg/literal 
add regD, regS1, regS2/literal

Если речь идет об инструкции "MOV", то вначале считываем первый операнд - регистр, куда поместить значение:

case "mov": 
    rd = regs32[tokens[1]]    # получаем индекс регистра- первого операнда
    if(tokens[2]) in regs32:  # если второй операнд - регистр
        rs = regs32[tokens[2]]    # получаем индекс регистра
        r[rd] = r[rs]       # в регистр rd помещаем значение из регистра rs
     else:
        literal = int(tokens[2])  # если простое число
        r[rd] = literal

То есть выражение rd = regs32[tokens[1]] получает в переменную rd индекс регистра в списке r.

Далее считываем второй операнд. Если его название есть в словаре reg64, то получаем его индекс в списке r и значение по этому индексу помещаем в r[rd]

Если второй операнд - литерал, преобразуем его в число и также помещаем в r[rd].

Если же выполняется инструкция ADD, то также считываем целевой регистр из первого операнда и регистр из второго операнда.

case "add": 
    rd = regs32[tokens[1]]        # получаем индекс регистра - первого операнда
    rs1 = regs32[tokens[2]]       # получаем индекс регистра - второго операнда
    if(tokens[3]) in regs32:      # если третий операнд - регистр
        rs2 = regs32[tokens[3]]    # получаем индекс регистра
        r[rd] = r[rs1] + r[rs2]       # в регистр rd помещаем сумму из регистров rs1 и rs2
    else:
        literal = int(tokens[3])  # если простое число
        r[rd] = r[rd] + literal

Третий операнд определяем в зависимости, является он регистром или литералом. И затем выполняем сложение второго и третьего операндов.

Стоит отметить, что данный способ обработки является не самым эффективным, особенно на большом количестве инструкций. Некоторые вещи тут дублируются. И для упрощения здесь опущена валидация. Но далее мы посмотрим, как все это немного оптимизировать. Однако данная программа дает общую идею, как все это может быть реализовано. В итоге при считывании файла "hello.s" и выполнении программы мы получим следующий консольный вывод:

pc:1   mov r1 11       r0:0   r1:11  r2:0   r3:0   
pc:2   mov r2 22       r0:0   r1:11  r2:22  r3:0   
pc:3   add r0 r1 r2    r0:33  r1:11  r2:22  r3:0   
pc:4   add r0 r0 2     r0:35  r1:11  r2:22  r3:0 
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850