Программирование на языке Assembler
Стек
Стек - это область памяти, к которой можно обращаться посредством указателя на стек (Stack Pointer, регистр sp). Указатель на стек обычно управляется системой, так что регистр sp на момент запуска программы уже является проинициализированным. Стек в памяти растёт от старших адресов к младшим и заполняется данными сверху вниз.
Для сохранения данных на стеке используется термин "затолкнуть в стек" (push), а для извлечения - "вытолкнуть из стека" (pop).
В примере ниже представим, что регистр sp хранит значение 0xff0:
li t0, 0xbeabdeaf # t0 = 0xbeabdeaf addi sp, sp, -4 # увеличить стек на 4 байта sw t0, 0(sp) # затолкнуть t0 (0xbeabdeaf) в стек
В результате выполнения этой операции указатель на стек хранит значение 0xfec, и значение 0xbeabdeaf сохраняется в памяти по адресу 0xfec. Значение можно извлечь и восстановить значение указателя на стек следующим кодом:
lw t0, 0(sp) # вытолкнуть 0xbeabdeaf из стека в t0 addi sp, sp, 4 # урезать значение стека обратно
Реализация контроля за ходом выполнения программ
Программа начинает выполнение с инструкции, определяемой программным счётчиком. Программный счётчик определяет процесс выполнения программы. Без использования инструкций ветвления и переходов, программа будет исполняться инструкция за инструкцией в том порядке, в котором они расположены в памяти. Использование инструкций ветвления и переходов позволяет контролировать процесс выполнения программ, они позволяют реализовывать операции проверки условий и циклы, которые являются основными действиями в императивном программировании. Использование безусловных переходов (совместно с механизмом сохранения адреса возврата согласно ABI) позволяет использовать функции (процедуры), которые являются основой процедурного программирования. Поскольку эти функциональности определяют то, как мы можем писать код, рассмотрим их реализацию с соответствующими инструкциями языка Assembler.
Условия
Цель условия - проверить, является ли проверяемое выражение истинным (true) или ложным (false), и, в зависимости от получаемого результата, выполнить переход по одной из веток кода. Такой механизм также известен как оператор if-then-else.
Реализация if-then-else в языке Assembler реализуется с помощью инструкций условного ветвления. Если условие (не) верно, управление передаётся на соответствующую ветку кода. Зачастую логика оригинального оператора if-then-else переворачивается таким образом, что код веки "else" выполняется до кода ветки "then", как показано в примере:
# если условие истинно, тогда выполнить 'code for then', иначе 'code for else' # если (t0 == t1), то переходим на ветку 'then' beq t0, t1, then # иначе ... # код для else # перепрыгнуть через then-часть j end then: ... # код для then end:
Простой вариант конструкции if-then может быть реализован с использованием одной инструкции ветвления:
# если t0=t1 - выполнить 'code for then', если t0!=t1 - игнорировать 'code for then' bne t0, t1, skip ... # код для then skip: ...
Более сложные условия реализуются вложенными конструкциями.
Циклы
Циклы также реализуются условными инструкциями. Конструкция while-do повторяет do-часть зацикленно до тех пор, пока проверяемое условие истинно. Для конструкции while-do условие проверяется в начале инструкций, которые необходимо повторить. Если условие не выполняется, зацикливаемый код пропускается (не выполняется). Например:
# инициализация t0 и t1 # цикл должен выполниться 4 раза li t0, 0 li t1, 4 while: # если t0 == t1 перейти на end, выйти из цикла beq t0, t1, end ... # код цикла # инкремент: t0 = t0 + 1 addi t0, t0, 1 # повторять, пока условие выше (t0 == t1) - истиина j while end:
Функции
Любая функция реализуется путём сохранения адреса возврата, перехода на код функции, его выполнения и возврата из функции, при этом необходимо позаботиться о том, чтобы использовались корректные регистры для сохранения адреса возврата. Лучшее решение - слепо следовать ABI, которые регламентирует использование регистров как для передачи аргументов, так и для реализации механизма возврата:
# аргумент в a0 li a0, 0 # вызов функции func, адрес возврата в ra jal ra, func # код, выполняемый после возврата из func # ... # код функции func: ... # в коде функции нельзя менять ra # возращаемое значение в a0, в примере - число 1 li a0, 1 # вернуться по адресу из ra ret
Инструкции, которые сохраняют значения регистров на стеке в начале кода функции называют "пролог"; инструкции, которые выталкивают значения из стека обратно в регистры в конце работы функции называют "эпилог". Пролог и эпилог служат для реализации возможности работы с регистрами внутри тела функции. Простой пример пролога и эпилога показан в следующем разделе в примере использования рекурсии.
Рекурсия
Рекурсивная функция - функция, которая вызывает сама себя. Рекурсивные функции обычно используют локальные переменные, значения которых хранятся в регистрах и которые необходимо сохранять перед вызовом функции и восстанавливать после возврата из неё. Рекурсивные функции - источник большого числа ошибок, поскольку при вызове рекурсивных функций происходит постоянное заталкивание значений регистров в стек и выталкивание значений регистров из стека и при отсутствии контроля может произойти переполнение стека в случае, если размера выделенной под стек памяти окажется недостаточно.
Рассмотрим вычисление n-ного элемента последовательности a(n) = a(n-1) + 3 при начальном значении a(0) = 2. Члены последовательности: a(0) = 2, a(1) = a(0) + 3 = 5, a(2) = a(1) + 3 = (a(0)+3) + 3 = 8 и так далее. Вычисляющая функция вызывает сама себя до тех пор, пока не будет достигнуто начальное значение a(0). Ниже приводится пример программы с рекурсивным вызовом функции compute, вычисляющей a(n) до тех пор, при этом считается, что аргумент n должен храниться в регистре a0. Результат возвращается в регистре a0.
.globl _start _start: li a0, 5 # вычислить для n = 5 call compute # выход li a7, 93 ecall compute: # занять место в стеке под регистр ra # в RV64 регистры 64 бита, следовательно 8 байт addi sp, sp, -8 sd ra, 0(sp) # проверка на окончание рекурсии; да? тогда выходим beq a0, x0, compend # иначе посчитать a(n-1) addi a0, a0, -1 # рекурсивный вызов call compute # рекурсивно посчитать a(n) = a(n-1) + 3 addi a0, a0, 3 # выйти j compret compend: # если (n ==0), вернуть a(0) = 2 li a0, 2 compret: # восстановить ra ld ra, 0(sp) # осводобить стек addi sp, sp, 8 ret
Контрольные вопросы
- Что такое метки?
- Что происходит с метками при ассемблировании?
- Для чего используются функции переразмещения?
- Для чего предназначена секция .bss?
- Как определить точку входа программы?
- Что такое псевдо-инструкция?
- Какие из перечисленных инструкций являются псевдо-инструкциями? sub, nop, subw, ecall, mv, sw, slt.
- В чём преимущество позиционно-независимого кода?
- Что специфицирует соглашение о вызовах?
- В чём отличие пользовательского уровня, аппаратного и уровня супервизора?
- Для чего используется стек?
- Как реализовать if-then конструкцию на языке ассемблера?
- Есть ли специальные инструкции для организации циклов?
- Можно ли в коде функции менять значение регистра x1?
- Чем принципиально отличается условный и безусловный переход?