Стек, подпрограммы и конвенции относительно использования регистров
Задача повторного использования исходного кода
запрограммировать однажды, использовать часто.
- Разбиение задачи на подзадачи, часть из которых имеют готовые решения
- вывод текстового сообщения и числа,
- сортировка массива,
- вычисление функции
- …
Параметризация этих решений (количество и значение исходных данных)
- текст и число
- размер и адрес массива (возможно, и критерий сортировки)
- параметры функции
- …
Возможность использования исходного кода без его изучения
- ⇒ Конвенции об использовании/неиспользовании ресурсов ЭВМ
- Значения регистров (в т. ч. счётчика инструкций и т. п.) и состояние памяти до и после выполнения соотв. кода
- Способ передачи параметров
- Способ получения результатов работы
Решение на уровне трансляции — макросы
Решение на программном уровне — подпрограммы
Подпрограммы
Подпрограмма — часть программного кода, оформленная таким образом, что
- возможно выполнение этого участка кода более, чем один раз
переход на этот участок кода (вызов подпрограммы) возможен из произвольных мест кода
после выполнения подпрограммы происходит переход «обратно» в место вызова (выход из подпрограммы)
Базовая статья спецификации: Полные конвенции по вызову
Аппаратное решение: атомарная команда записи адреса возврата и перехода:
1 jal адрес
Адрес следующей ячейки записывается в регистр ra (x1)
Происходит переход на адрес
Возврат из подпрограммы — команда перехода на адрес, находящийся в регистре ra:
1 ret
Вместо команды типа U — jal — можно использовать команду типа S — jalr в которой адрес хранится в регистре (см. прошлую лекцию относительно того, почему команды перехода выделяют в отдельные типы команд — J и B соответственно). Рассмотрим пример, в котором подпрограмма сначала вызывается с помощью jal, а затем — с помощью jalr:
В коде:
Address Code Basic Line Source 0x00400000 0x018000ef jal x1,0x00000018 4 jal subr 0x00400004 0x00000497 auipc x9,0 5 la s1 subr 0x00400008 0x01448493 addi x9,x9,20 0x0040000c 0x000480e7 jalr x1,x9,0 6 jalr s1 0x00400010 0x00a00893 addi x17,x0,10 7 li a7 10 0x00400014 0x00000073 ecall 8 ecall 0x00400018 0x0fc10517 auipc x10,0x0000fc10 9 subr: la a0 ping 0x0040001c 0xfe850513 addi x10,x10,0xffffffe8 0x00400020 0x00400893 addi x17,x0,4 10 li a7 4 0x00400024 0x00000073 ecall 11 ecall 0x00400028 0x00008067 jalr x0,x1,0 12 ret
и jal, и jalr работают позиционно-независимо (см. прошлую лекцию) — в одной чётко формируется смещение относительно адреса текущей инструкции, в другой — полный адрес перехода формируется в s0 с помощью уже известного нам auipc (т. е .при помощи опять-таки адреса текущей инструкции и корректирующего сложения)
- Во всех командах пропущен регистр, в котором по умолчанию ожидается адрес перехода
вместо ra можно, формально говоря, использовать любой регистр
одна ко конвенция устроена так, что оптимизация процессора (например, добавление аппаратного хранилища возвратов из подпрограмм) рассчитывает на использование именно ra в качестве регистра адреса
Полный формат команды jalr (как и полагается S/B):
jalr регистр_адреса_возврата регистр адреса_перехода смещение Смещение позволяет, например, вызывать несколько подпрограмм (или несколько разных точек входа в подпрограмму) без изменения адреса перехода
Полный формат команды ret — это… jalr! Как это работает:
В качестве регистра перехода используется ra
В качестве регистра возврата используется zero (это позволяет разработчику оптимизированного процессора сразу определять, что перед нами инструкция не перехода, а именно возврата из подпрограммы)
Смещение ret можно использовать для более высокоуровневых трюков. Пример: подпрограмма, которая проверяет, что регистры s1, s2 и s3 содержат значения, удовлетворяющие неравенству треугольника.
Подпрограмма input работает вполне стандартно — возвращает а a0 введённое число (отличается от соответствующего системного вызова предварительным выводом подсказки).
Подпрограмма check использует трюк
- В случае отрицательного ответа происходит обычный возврат из подпрограммы
В случае положительного ответа в регистр a0 записывается адрес строки «"Is a triangle\n"» и происходит возврат по адресу на две инструкции дальше стандартного адреса возврата (смещение 8).
Трюк в том, что вызывающая программа этими двумя инструкциями может положить в a0 адрес произвольной строки
1 .data
2 yes: .asciz "Is a triangle\n"
3 no: .asciz "Not a triangle\n"
4 .text
5 jal input
6 mv s1 a0
7 jal input
8 mv s2 a0
9 jal input
10 mv s3 a0
11 jal check # проверка неравенства треугольника
12 la a0 no # запись адреса no в регистр a0 (занимает две инструкции)
13 li a7 4 # вывод строки в a0 (сюда происходит возврат в случае успеха)
14 ecall
15 li a7 10
16 ecall
17 .data
18 prompt: .ascii "Enter triangle side: "
19 .text
20 input: la a0 prompt
21 li a7 4
22 ecall
23 li a7 5
24 ecall
25 ret
26 check: add t3 s1 s2
27 add t1 s2 s3
28 add t2 s1 s3
29 ble t1 s1 notri
30 ble t2 s2 notri
31 ble t3 s3 notri
32 la a0 yes
33 jalr zero ra 8
34 notri: ret
К сожалению, даже такой немудрёный трюк слишком сложен для программирования на языке Ассемблера (хотя, возможно, полезен для компиляторов высокоуровневых языков, в которых, например, предусмотрено понятие «статус ошибки в подпрограмме»).
Необходимость конвенций для подпрограмм
Решённые задачи:
- атомарного вызова
- произвольного вызова и возврата
Нерешённые задачи
- «прозрачности» повторного использования
- сохранение регистров
- передача параметров
- возвращение значения
- локальности меток (переменных/адресов перехода):
поскольку поскольку подпрограмма предназначена для повторного использования, нередка ситуация, когда вызывающий не знает / не помнит, какие метки в ней имеются, и может случайно попытаться использовать такие же
- это задача языка ассемблера, а не системы команд, в которой меток уже нет, а есть только адреса
вложенного вызова
в т. ч. рекурсивного вызова (это частный случай вложенного, но он накладывает дополнительные требования)
Обратите внимание на то, что в примере выше изменяются значения регистров t*, а значения регистров s* используются для передачи параметров и возврата значения.
Подпрограмма — это самый обычный программный код, находящийся в памяти там, куда его поместил транслятор. Выполняться она должна только по инструкциям jal или jalr, непосредственный переход на подпрограмму смысла не имеет. В частности, надо предпринять специальные меры, чтобы счётчик команд не дошагал до подпрограммы естественным путём (в примере используется специальный системный вызов «завершить программу»).
- Прозрачность требует
- отдельной конвенции (о протоколе передачи параметров и возвращаемых значений)
- механизма сокрытия локальных меток
Проблема вложенного вызова возникает, когда подпрограмма вызывается из другой подпрограммы:
- текущий адрес возврата надо сохранять перед вложенным вызовом и восстанавливать перед возвратом;
конвенция должна предусматривать цепочку вложенных вызовов
- ⇒ регистров на всю цепочку не хватит, их тоже надо где-то сохранять/восстанавливать
Проблема рекурсивного вызова возникает, когда в цепочке вызовов некоторая подпрограмма встречается более одного раза (т. е. в конечном счёте вызывает сама себя); это не такая редкая ситуация: например, один вызов подпрограммы «разместить в памяти структуру данных» может для сложной структуры привести к нескольким вызовам той же подпрограммы для составных частей этой структуры
локальные данные могут быть изменены во вложенном вызове, поэтому их надо где-то динамически заводить/сохранять/освобождать
Очевидное решение для вложенных и рекурсивных подпрограмм — активно использовать стек. Но для начала рассмотрим простую конвенцию относительно концевых (листовых) подпрограмм, не вызывающих из себя других подпрограмм.
Простая конвенция для концевых подпрограмм
Подпрограмма вызывается с помощью инструкций jal/jalr (которые сохраняют обратный адрес в регистре ra).
Подпрограмма не будет вызывать другую подпрограмму
Подпрограмма возвращает управление вызывающей программе с помощью инструкции ret (пример с «трюком» выше нарушает эту конвенцию).
- Регистры используются следующим образом:
t0 - t6: подпрограмма может изменить эти регистры.
s0 - s11: при выходе из подпрограммы эти регистры должны содержать те же данные, которые были в них при входе
- можно либо вовсе не трогать их, либо сохранить их содержимое, а перед выходом — восстанавливать
рекомендуется не использовать регистр s0 — в более универсальных конвенциях он имеет особое значение
a0 - a7: эти регистры содержат параметры для подпрограммы. Подпрограмма может изменить их.
a0, a1: эти регистры содержат значения, возвращаемые из подпрограммы:
Согласно этой конвенции вызывающая (под)программа может рассчитывать на то, что регистры s0 - s11 не изменятся за время работы подпрограммы, и их можно использовать для хранения «быстрых» данных, переживающих вызов подпрограмм, например, для счётчиков циклов и т. п. Значения t-регистров могут меняться подпрограммой, а значения a-регистров непосредственно меняются (или не меняются).
Разумеется, между вызовами подпрограмм можно пользоваться всеми регистрами (чем же ещё?). Конвенция не ограничивает модификацию оперативной памяти (т. н. «побочный эффект» подпрограммы).
Пример той же подпрограммы (неравенство треугольника), оформленной в соответствие с конвенцией. В действительности её пришлось полностью переписать.
Нам необходимо проверить неравенство. Следовательно, подпрограмма должна возвращать (согласно конвенции — в a0) один результат для треугольников, и другой — для нетреугольников. Конвенция нас ничем в этом плане не ограничивает, поэтому договоримся, что результат — это знаковый бит регистра a0. Это легко проверить, и легко вычислить из разностей вида «сторона-сумма_двух_других_сторон». Для ответа «не треугольник» достаточно, чтобы хотя бы одна из таких разностей была неотрицательной, т. е. имела 0 в знаковом бите. Соответственно, конъюнкция нам в помощь.
1 .data
2 yes: .asciz "Is a triangle\n"
3 no: .asciz "Not a triangle\n"
4 .text
5 jal input
6 mv s1 a0
7 jal input
8 mv s2 a0
9 jal input
10 # третья сторона уже в регистра a0
11 mv a1 s1 # первая сторона
12 mv a2 s2 # вторая сторона
13 jal check
14 bltz a0 true
15 la a0 no
16 b output
17 true: la a0 yes
18 output: li a7 4
19 ecall
20 li a7 10
21 ecall
22 .data
23 prompt: .ascii "Enter triangle side: "
24 .text
25 input: la a0 prompt
26 li a7 4
27 ecall
28 li a7 5
29 ecall
30 ret
31 check: add t0 a1 a2
32 sub t0 a0 t0
33 add t1 a2 a0
34 sub t1 a1 t1
35 add t2 a1 a0
36 sub t2 a2 t2
37 and a0 t0 t1
38 and a0 a0 t2
39 done: ret
Стек
Но для динамического хранения локальных переменных и адресов возврата нам нужен стек.
Абстракция «стек»
- Хранилище «объектов»
- Основные операции — положить на стек, снять со стека
- Основной доступ — последовательный, к верхнему элементу стека
- Иногда разрешается относительный доступ к N-му элементу стека от вершины
- ⇒ «Первым вошёл — последним вышел»
- Может расти бесконечно
- Поведение при исчерпании (снятии с пустого стека) не определено
Реализация стека в машинных кодах
- «Объект» — обычно ячейка
- в некоторых архитектурах можно было бы хранить объекты разной длины (байты, полуслова, слова и т. п.), но задача доступа к N-му элементу стека из константной становится линейной сложности, и надо хранить где-то размеры ячеек
- Хранилище — область памяти, ограниченная только с одной стороны («дно» стека), а с другой — «бесконечно» растущая (пока не достигнет занятой области памяти)
- Рост/снятие: специальная ячейка памяти, хранящая адрес вершины стека + знание адреса дна сетка
- Используется косвенная адресация
- ⇒ Удобно (на некоторых архитектурах — единственно возможно) хранить в регистре
- Переполнение (попытку положить на стек больше значений, чем предусмотрено конвенцией) надо как-то проверять
- Исчерпание (попытку снять со стека больше значений, чем там есть в данный момент) тоже надо как-то проверять
Возможная аппаратная поддержка стека
- Отдельный небольшой стек на регистровой памяти (а чем лучше того же числа регистров?)
- Атомарные операции добавления/снятия (в обычном случае действия два: чтение/запись значения и изменение указателя)
- например, автоувеличение/автоуменьшение указателя прямо в процессе взятия значения (в случае RISC-V — недопустимая двойная арифметическая операция в одной инструкции: вычисление смещения и сложение/вычитание)
Двойная косвенная адресация позволяет напрямую обращаться к данным, адреса которых лежат в стеке (в случае RISC-V — недопустимое «тяжёлое» двойное обращение к памяти)
Реализация стека в RARS
…регулируется соотв. конвенцией:
выделенный регистр sp (x2)
дно стека — 0x7ffffffc (непосредственно под областью ядра)
начальное значение 0x7fffeffc отделено от дна буфером
стек растёт вниз по одному слову (4 байта)
Предел стека — область кучи (0x10040000 или выше, если куча наросла), определяется вручную или ядром операционной системы
Операции добавления и снятия — неатомарные:
- при добавлении сначала уменьшается указатель, затем записывается значение
- при снятии сначала считывается значение, затем увеличивается указатель
- при такой организации не используется исходная ячейка стека — 7fffeffc
- пример:
1 li t1 123 # какое-то значение 2 addi sp sp -4 # положить на стек - 1 3 sw t1 (sp) # положить на стек - 2 4 addi t2 t1 100 # какое-то ещё значение 5 addi sp sp -4 # положить на стек - 1 6 sw t2 (sp) # положить на стек - 2 7 lw t3 (sp) # доступ к вершине стека 8 lw t4 4(sp) # доступ ко второму элементу стека 9 lw t0 (sp) # снятие со стека - 1 10 addi sp sp 4 # снятие со стека - 2 11 addi sp sp -4 # положить на стек - 1 12 sw zero (sp) # положить на стек - 2
- Это довольно эффективный код, не содержащий ни одной составной псевдоинструкции:
Address Code Basic Line Source 0x00400000 0x07b00313 addi x6,x0,0x0000007b 1 li t1 123 # какое-то значение 0x00400004 0xffc10113 addi x2,x2,0xfffffffc 2 addi sp sp -4 # положить на стек - 1 0x00400008 0x00612023 sw x6,0(x2) 3 sw t1 (sp) # положить на стек - 2 0x0040000c 0x06430393 addi x7,x6,0x00000064 4 addi t2 t1 100 # какое-то ещё значение 0x00400010 0xffc10113 addi x2,x2,0xfffffffc 5 addi sp sp -4 # положить на стек - 1 0x00400014 0x00712023 sw x7,0(x2) 6 sw t2 (sp) # положить на стек - 2 0x00400018 0x00012e03 lw x28,0(x2) 7 lw t3 (sp) # доступ к вершине стека 0x0040001c 0x00412e83 lw x29,4(x2) 8 lw t4 4(sp) # доступ ко второму элементу стека 0x00400020 0x00012283 lw x5,0(x2) 9 lw t0 (sp) # снятие со стека - 1 0x00400024 0x00410113 addi x2,x2,4 10 addi sp sp 4 # снятие со стека - 2 0x00400028 0xffc10113 addi x2,x2,0xfffffffc 11 addi sp sp -4 # положить на стек - 1 0x0040002c 0x00012023 sw x0,0(x2) 12 sw zero (sp) # положить на стек - 2
- Операция доступа к N-му элементу стека полностью аналогична доступу к вершине стека (просто смещение не нулевое, а +N*4)
- Наверное, поэтому стек растёт вниз — смещения проще читать
Если использовать другую конвенцию (поменять местами чтение/запись и сдвиг указателя), затруднится адресная арифметика: указатель будет отмечать неиспользованную ячейку, а вершина окажется по адресу 4(sp)
- Память, которую некоторое время занимал стек, а затем указатель ушёл выше, продолжает хранить значения, которые там лежали. Однако надеяться на то, что они там пребудут впредь, нельзя: память считается «свободной» и её, как минимум, меняют последующие добавления в стек.
Хранение данных в стеке
Несколько более эффективно, чем в произвольном месте памяти (lw/sw не превращаются в псевдоинструкции)
Оптимизированные версии процессора могут учитывать конвенцию по вызову и что-то делать с памятью (а особенно с кадром стека, но об это потом)
Использует адресацию относительно постоянно меняющегося sp. Как следствие, требует аккуратного просчёта текущей глубины стека
- Не требует явного указания адреса и заведения метки в программе на языке ассемблера
- Может привести к сбоям в работе при переполнении/исчерпании/неаккуратном использовании стека
Универсальные подпрограммы
Простая конвенция не поддерживает вложенного вызова подпрограмм:
- ⇒ надо сохранять ra (при повторном вызове он изменится)
Неправильное решение: выделить для каждой функции ячейку, в которую сохранять ra (не работает рекурсивный вызов)
Рекурсивный вызов — сохранение в стеке
Динамически выделять память удобнее всего на стеке. В начале подпрограммы все регистры, значения которых согласно конвенции следует сохранить до выхода из подпрограммы, а также регистр возврата ra записываются в стек операцией push. Перед выходом эти значения снимаются со стека (в обратном порядке) операцией pop. Эти значения, как правило, не используются внутри подпрограммы, важна только последовательность сохранения и восстановления.
В самом простом случае сохранять надо только ra:
1 .text
2 # …код программы
3 jal subr1
4 # …код программы
5 # …конец программы
6
7 subr1: addi sp sp -4 # сохраним текущий ra
8 sw ra (sp) # на стеке
9 # …код подпрограммы 1
10 jal subr2 # вызов изменяет значение ra
11 # …код подпрограммы 1
12 lw ra (sp) # восстановим текущий ra
13 addi sp sp 4 # из стека
14 ret
15
16 subr2: addi sp sp -4 # сохраним ra
17 sw ra (sp) # на стеке
18 # …код подпрограммы 2
19 lw ra (sp) # восстановим ra
20 addi sp sp 4 # из стека
21 ret
Строго говоря, подпрограмма subr2 — концевая, и в ней не обязательно сохранять и восстанавливать регистры. Но с точки зрения дисциплины программирования удобнее все подпрограммы оформлять одинаково. Возможно, в процессе работы над subr2 из неё понадобится вызвать ещё подпрограмму, и она перестанет быть концевой. По всей видимости, надо исключить любые попытки подпрограмм сохранять какие-то данные не на стеке, т. к. в случае рекурсивного вызова не только ra и сохраняемые регистры.
Рассмотрим пример подпрограммы, вычисляющей функцию факториала. Вопреки здравому смыслу напишем эту подпрограмму рекурсивно: f(n)! = f(n-1)*n . Возникает одно значение — n — которое должно «переживать» рекурсивный вызов. Воспользуемся конвенцией, гарантирующей нам неизменность регистров s* , и запишем n в регистр s1. Тогда для выполнения условий конвенции текущее значение этого регистра надо сохранять в стеке в начале подпрограммы, и восстанавливать перед возвратом из неё.
1 li a7 5
2 ecall
3 jal fact # Параметр уже в a0 )
4 li a7 1
5 ecall # Параметр уже в a0 ))
6 li a7 10
7 ecall
8
9 fact: addi sp sp -8 ## Запасаем две ячейки в стеке
10 sw ra 4(sp) ## Сохраняем ra
11 sw s1 (sp) ## Сохраняем s1
12
13 mv s1 a0 # Запоминаем N в s1
14 addi a0 s1 -1 # Формируем n-1 в a0
15 li t0 1
16 ble a0 t0 done # Если n<2, готово
17 jal fact # посчитаем (n-1)!
18 mul s1 s1 a0 # s1 пережил вызов
19
20 done: mv a0 s1 # Возвращаемое значение
21 lw s1 (sp) ## Восстанавливаем sp
22 lw ra 4(sp) ## Восстанавливаем ra
23 addi sp sp 8 ## Восстанавливаем вершину стека
24 ret
Здесь мы формально воспользовались конвенцией о сохранении регистров. Сохранив регистр s1, мы обеспечили себе право использовать его как динамическую переменную.
Пролог и эпилог — начальная и завершающая части подпрограммы, которые обслуживают соблюдение конвенции, а к решаемой задаче имеют только косвенное отношение. Так, пролог в примере сохраняет на стеке два регистра, а использовалось бы их больше — сохранял бы больше; эпилог эти два регистра (s0 и ra) восстанавливает (отмечены двойным знаком комментария «##»). Формирование возвращаемого значения в литературе традиционно считается частью эпилога, т. к. её нельзя пропускать ☺.
Похожий код получился бы без использования сохраняемого регистра s*. Пролог и эпилог уменьшатся, но накапливающееся произведение всё равно надо запасать (на стеке, больше негде), и делать это придётся непосредственно перед вызовом подпрограммы, которая может испортить несохраняемые регистры.
1 li a7 5
2 ecall
3 jal fact # Параметр уже в a0 )
4 li a7 1
5 ecall # Параметр уже в a0 ))
6 li a7 10
7 ecall
8
9 fact: addi sp sp -4
10 sw ra (sp) # Сохраняем ra
11 mv t1 a0 # Запоминаем N в t1
12 addi a0 t1 -1 # Формируем n-1 в a0
13 li t0 1
14 ble a0 t0 done # Если n<2, готово
15 addi sp sp -4 ## Сохраняем t1 на стеке
16 sw t1 (sp) ##
17 jal fact # Посчитаем (n-1)!
18 lw t1 (sp) ## Вспоминаем t1
19 addi sp sp 4 ##
20 mul t1 t1 a0 # Домножаем на (n-1)!
21 done: mv a0 t1 # Возвращаемое значение
22
23 lw ra (sp) # Восстанавливаем ra
24 addi sp sp 4 # Восстанавливаем вершину стека
25 ret
Подготовка к вызову подпрограммы, которую делает вызывающая программа, называется преамбулой, а действия после возврата из подпрограммы почему-то не называются никак, так что пускай будут финалом', что ли. В примере преамбула и финал обозначены двойным символом комментария «##».
Отличие конструкции «пролог-эпилог» от «преамбула-финал» — в области ответственности. За исключением того, что явно сказано в конвенции, вызываемая подпрограмма ничего не знает о том, что происходит в преамбуле и финале, а вызывающая программа — о том, что происходит в прологе и эпилоге. Например, не нужно знать, сколько и каких регистров сохраняется на стеке в прологе данной подпрограммы, раз уж конвенция гарантирует нам сохранность регистров типа s*.
Язык ассемблера и понятие «переменная»
В процессе планирования вычислений на языке ассемблера постоянно происходит так, что значение, соответствующее некоторому объекту программы (например, текущее посчитанное значение факториала в примерах), в разное время представлено различными аппаратными средствами: это может быть ячейка памяти, которую затем загрузят в регистр, регистр этот могут положить на стек — и всё это время с точки зрения алгоритма это будет один и тот же объект.
Такое отношение к данным резко отличается от более высокоуровневого понятия «переменная», в котором предполагается, что представление объекта и способ работы с ним всегда одинаковы. Можно сказать, что в языке ассемблера нет переменных, а есть только метки, и это не одно и то же.
В тексте мы будем использовать понятие локальная переменная преимущественно для такого случая:
- Предположим, что в нашей подпрограмме используется так много объектов, что для всех них не хватает регистров, или мы по каким-то другим причинам хотим хранить некоторое значение не в регистре, а в памяти
Понятно, что это значение надо хранить на стеке. И для того, чтобы не путаться, куда в данный момент указывает sp, выделение и инициализацию такой памяти на стеке удобно совмещать с прологом, а освобождение — с эпилогом.
1 subr: addi sp sp -12 ## Выделяем три ячейки — под ra и две переменные
2 sw ra 8(sp) ## Сохраняем ra
3 sw zero 4(sp) ## Инициализируем первую переменную нулём
4 sw zero (sp) ## Инициализируем вторую переменную нулём
5 # …
6 lw t0 4(sp) # Загружаем первую локальную переменную в t0
7 # …
8 sw t1 (sp) # Записываем t1 во вторую локальную переменную
9 # …
10 lw ra 8(sp) # Восстанавливаем ra
11 addi sp sp 12 # Освобождаем две ячейки стека
12 ret
- Можно использовать сколько угодно локальных переменных
Переменные рекомендуется инициализировать:
- В этом месте стека наверняка лежит какой-нибудь мусор, оставшийся от предыдущих вызовов подпрограмм
Популярная ошибка — не проинициализировать переменную, а потом решить, что в ней лежит 0 (как это часто, но не всегда бывает)
Если не изменять стек в процессе работы, у них всегда будут фиксированные смещения относительно вершины стека
Простая конвенция для универсальных подпрограмм
К конвенции для концевого вызова подпрограммы необходимо добавить пролог и эпилог, а также определить возможность преамбулы. Как обычно, конвенция не описывает нечто незыблемое, а предлагает некоторую дисциплину программирования, цель которой — удобство и эффективность.
Передаваемые подпрограмме значения надо заносить в регистры a*
Вызов подпрограммы должен производиться командой jal или jalr
- Никакая вызываемая подпрограмма не модифицирует содержимое стека выше того указателя, который она получила в момент вызова
Подпрограмма обязана сохранить на стеке значение ra
Подпрограмма обязана сохранить на стеке все используемые ею регистры s*
Подпрограмма может хранить на стеке произвольное количество переменных. Количество этих переменных и занимаемого ими места на стеке не оговаривается и может меняться в процессе работы подпрограммы.
Возвращаемое подпрограммой значение надо заносить в регистры a0, a1
- Подпрограмма должна освободить все занятые локальными переменными ячейки стека
Подпрограмма обязана восстановить из стека сохранённые значения s* и ra
Подпрограмма обязана при возвращении восстановить значение sp в исходное. Это случится автомагически при соблюдении всех предыдущих требований конвенции
Возврат из подпрограммы должен производиться командой ret
Некоторые требования конвенции выглядят неоправданно строгими, например, чёткое предписание, где хранить переменные и регистры. Однако такая строгость резко упрощает повторное использование и отладку программ: даже не читая исходный текст программист точно знает, где искать адрес возврата, сохранённые регистры и локальные переменные. Конвенции с хранением данных на стеке крайне чувствительны к нарушению его цельности. Стоит «промахнутся» на ячейку (например, забыть про атомарность процедуры снятия со стека и не увеличить sp), и инструкции эпилога рассуют все значения не по своим местам: адрес возврата останется на стеке, в регистр ra попадёт что-то из сохраняемого регистра, сохраняемые регистры получат «чужое» содержимое и т. д. При попытке выполнить переход на такой ra в лучшем случае возникнет исключение перехода в запрещённую память (хорошо, что малые числа соответствуют зарезервированным адресам и переход на адреса в секции данных запрещён). В худшем случае содержимое ra случайно окажется из допустимого диапазона секции кода, произойдёт возврат по этому адресу и начнётся выполнение лежащего там кода. Отладка такой ситуации (называемой «stack smash», снос стека) — непростая задача для программиста.
Д/З
TODO
EJudge: LeftDigits 'Левые цифры'
Написать программу, которая вводит натуральное число N, а затем — N (возможно, отрицательных) целых чисел, после чего выводит (в строку) N самых левых десятичных цифр этих чисел. Для вычисления одной цифры написать функцию left, которая в a0 получает число, и там же возвращает цифру.
5 -2345 7345623 -4321 2 7543
27427
EJudge: RecursiveGCD 'Наибольший общий делитель'
Написать полную программу, которая вводит два натуральных числа и выводит их наибольший общий делитель. Для вычисления НОД написать рекурсивную функцию nod, которая принимает два числа в a0 и a1 и возвращает значение в a0.
2467080 49360080
9240
EJudge: MinAddr 'Минимум в диапазоне'
Написать программу, на вход которой построчно подаются:
Натуральное число N — размер целочисленного массива, адрес которого должен быть равен 0x10010000 (начало области .data)
- Затем N целых чисел — элементы массива
Затем — неизвестное число пар целых чисел A, B: 0⩽A⩽B<N — индексы массива, по одному в строке
Конец ввода — пара целых чисел A, B: A>B, не учитывается
Для каждой пары A⩽B программа выводит наименьший элемент массива в диапазоне от A до B включительно, пробел, адрес элемента и перевод строки
В программе предусмотреть подпрограмму minaddr,
которой в a0 передаётся адрес массива, в a1 — A, в a2 — B,
а возвращает она в a0 — наименьшее значение массива в диапазоне, а в a1 — адрес элемента, где был найден минимум (если таких несколько, то с наименьшим индексом)
7 1 3 5 7 4 2 1 0 6 1 5 2 4 3 3 1 0
1 0x10010000 2 0x10010014 4 0x10010010 7 0x1001000c
EJudge: FuncSort 'Просто сортировка'
Написать программу, которая построчно вводит натуральное число N, затем — N целых чисел, а затем выводит их в строку через пробел в отсортированном виде (допустим пробел в конце).
В программе должна быть предусмотрена подпрограмма sort, которой передаются два параметра — адрес массива в a0 и размер массива в a1.
Сама подпрограмма sort должна реализовывать алгоритм сортировки выбором (см. в подсказках), для чего должна вызывать подпрограмму minaddr из предыдущего домашнего задания
12 -4 7 -5 8 6 5 1 -5 9 0 6 -6
-6 -5 -5 -4 0 1 5 6 6 7 8 9