Программирование на языке 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?
- Чем принципиально отличается условный и безусловный переход?