Опубликован: 19.01.2025 | Доступ: свободный | Студентов: 1 / 0 | Длительность: 02:34:00
Лекция 5:

Программирование на языке Assembler

< Лекция 4 || Лекция 5: 12345 || Лекция 6 >

Стек

Стек - это область памяти, к которой можно обращаться посредством указателя на стек (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

Контрольные вопросы

  1. Что такое метки?
  2. Что происходит с метками при ассемблировании?
  3. Для чего используются функции переразмещения?
  4. Для чего предназначена секция .bss?
  5. Как определить точку входа программы?
  6. Что такое псевдо-инструкция?
  7. Какие из перечисленных инструкций являются псевдо-инструкциями? sub, nop, subw, ecall, mv, sw, slt.
  8. В чём преимущество позиционно-независимого кода?
  9. Что специфицирует соглашение о вызовах?
  10. В чём отличие пользовательского уровня, аппаратного и уровня супервизора?
  11. Для чего используется стек?
  12. Как реализовать if-then конструкцию на языке ассемблера?
  13. Есть ли специальные инструкции для организации циклов?
  14. Можно ли в коде функции менять значение регистра x1?
  15. Чем принципиально отличается условный и безусловный переход?
< Лекция 4 || Лекция 5: 12345 || Лекция 6 >