Лабораторная работа №7. Вызов подпрограммы, работа со стеком
7.1. Цель и задачи
Целью работы является разработка простой программы преобразования данных для приобретения практических навыков программирования на языке ассемблера и закрепления знаний по работе с подпрограммами и стеком. Задачи:
- Изучение соглашений о вызовах подпрограмм.
- Освоение принципов работы со стеком.
Презентация к блоку "Ассемблер RISC-V"
7.2. Основные теоретические сведения
7.2.1 Директивы размещения данных в памяти
Определения
- TEXT - область для инструкций программы. Теоретически никто не мешает иметь несколько директив .text адрес, размещающих код по различным адресам
- DATA - область для всевозможных данных программы (глобальных переменных, статических локальных переменных, кучи и стека)
- extern base address - область для внешних данных (нужна для взаимодействия с ОС). В этой области размещает данные директива .extern
- .data base address - начало области, в которую обычно раскладываются данные директивами .data. Именно там лежат переменные, объявленные массивы и прочее. Традиционно имеется зазор между началом области данных и непосредственно статическими данными. Обычно в процессе работы программы нельзя переходить по адресам из области данных и декодировать их как инструкции.
В программе на языке ассемблера возникает необходимость описать содержимое сегмента памяти. Для этого код программы помечается .text, а данные - .data.
В секции .data помещают директивы (указания ассемблеру) по размещению данных в памяти.
- .word число - одно или несколько 4-байтовых чисел
- .dword число - одно или несколько 8-байтовых чисел
- .half число - одно или несколько 2-байтовых чисел
- .byte число - одно или несколько однобайтовых чисел
- .ascii "строка" - последовательность символов в кодировке ASCII
- .asciz "строка" - то же, только после последнего символа обязательно записывается нулевой байт (конец строки, договорённость, например, для языка Си). Пример размещения данных различного размера:
.data .word 0xdeadbeef .dword 0xacebad0feeded .half 0x1234, 0x5678 .byte 12, 13, 14, 15 .half 0x3344 .byte 0x66, 0x77
Результат трансляции пословно (допустим секция .data начинается с адреса 0x10010000). Используется little endian: младший байт в слове имеет меньший адрес:
10010000: deafbeef d0feeded 000aceba 56781234 0f0e0d0c 77663344
7.2.2 Инструкция ecall
Ранее мы рассматривали инструкцию ecall. Остановимся подробнее на специфике ее работы. В общем случае ecall (сокращение от Environment Call) это механизм вызова внешних процедур от некоторой вышестоящей среды исполнения. Когда мы ведем разработку приложений для Linux-подобных ОС то сама ОС и является такой средой. В таком случае, ecall выступает механизмом выполнения вызовов ОС - специальных внешних процедур, реализуемых в ядре ОС.
Важно отметить, что в случае bare metal программирования на RISC-V (то есть работа с устройствами напрямую, без ОС) процедуры из ecall могут быть не поддержаны, так как будет отсутствовать необходимый уровень абстракции для его работы (ядро ОС).
Рассмотрим соглашение о вызовах ecall (ABI) на примере RISC-V ОС:
- В регистр a7 помещается номер системной функции (service number).
- Если есть параметры системного вызова, то они помещаются в регистры a0-a6.
- Инструкция ecall передаёт управление операционной системе (в данном случае - ядру RISC-V ОС).
- Возврат из системного вызова происходит по аналогии с возвратом из подпрограммы, на следующую после ecall инструкцию
- Возвращаемое значение (если есть) помещаются в a0.
Пример: вывести на консоль число, лежащее в регистре t0
li a7 64 # Функция 64 - вывод числа mv a0 t0 ecall # Выведённое число помещается в a0
Для некоторых вызовов вам могут потребоваться строковые аргументы. С точки зрения ассемблера, строки - это последовательности ненулевых байтов, заканчивающихся символом с кодом 0 (однобайтным нулём) согласно формату ASCIZ. Ввод заканчивается символом перевода строки (ASCII-код 10, соответствует нажатию Enter). Этот символ тоже попадает в строку! Так что, если требуется ввести слово не более, чем из 10 букв, размер буфера должен быть на 2 больше - 12. 11-м символом в нём будет '\n', а 12-м - 0.
Полный список поддерживаемых системных вызовов для RISC-V ОС можно найти в таблице (необходимо найти колонку для архитектуры riscv64).
7.2.3 Подпрограммы
Для разработки комплексных приложений может потребоваться не только применение условных переходов, но и разбиение исходного кода на повторно используемые фрагменты (подпрограммы). Подпрограмма - часть программного кода, оформленная таким образом, что
- возможно выполнение этого участка кода более, чем один раз
- переход на этот участок кода (вызов подпрограммы) возможен из произвольных мест кода
- после выполнения подпрограммы происходит переход "обратно" в место вызова (выход из подпрограммы)
В рамках ассемблера подпрограммы могут быть реализованы в качестве меток. Ниже представлен упрощенный пример программы, показывающий общую логику вызова подпрограмм:
.text main: # Подготовка к вызову функции - сохранение аргументов и # сохранение адреса возврата # Аргументы (первые 8) - в a0-a7 # Адрес возврата в ra (x1) .. # Вызов функции - выполнение перехода к метке func j func # Для упрощения мы использовали j, в реальности # используется lalr или jar # Продолжение работы программы .. func: # Код функции стартует с метки func .. # Получение аргументов и действия с ними (если они подразумевались) .. # Формирование возвращаемого значения в a0 .. # выход из функции - переход к адресу возврата jalr zero, ra,0
Показанный выше пример является крайне упрощенным и не иллюстрирует всех сценариев разработки подпрограмм и организации кода. Для того, чтобы такой ценный и ограниченный ресурс как регистры использовался разумно и безопасно для исполняемого кода, в application binary interface (ABI) есть явный набор соглашений о том, как необходимо поступать со значениями регистров при вызовах и возврате из подпрограмм. Одно из соглашений заключается в сохранении значений регистра, когда команда перехода вызывает подпрограмму. Это означает, что обратный адрес инструкции после команды перехода в памяти сохраняется в регистре, а программный счетчик является началом подпрограммы. Подпрограмма состоит из инструкций, которые обрабатываются и возвращаются обратно к инструкции, следующей за ее вызовом. Для возврата подпрограмма использует сохраненный обратный адрес. В таблице 7.1 обобщены псевдонимы и соглашение о хранении, например, регистр x1 имеет псевдоним ra, который обозначает адрес возврата, и вызывающий функцию должен сохранить значение регистра x1 перед вызовом функции и восстановить его после возврата функции.
Номер | Название | Описание | Сохраняется? |
---|---|---|---|
x0 | zero | Константа нуля (zero register) | n/a |
x1 | ra | Адрес возврата (return address) | нет |
x2 | sp | Указатель стека (stack pointer) | да |
x3 | gp | Глобальный указатель (global pointer) | n/a |
x4 | tp | Указатель потока (thread pointer) | n/a |
x5-x7 | t0-t2 | Временные переменные (temporary registers) | нет |
x8 | s0 / fp | Сохраняемая переменная /Указатель фрейма стека (saved register / frame pointer) | да |
x9 | s1 | Сохраняемая переменная (saved register) | да |
x10-x11 | a0-a1 | Аргументы функций/Возвращаемые значения (function arguments / return values) | нет |
x12-x17 | a2-a7 | Аргументы функций (function arguments) | нет |
x18-x27 | s2-s11 | Сохраняемые переменные (saved registers) | дп |
x28-x31 | t3-t6 | Временные переменные (temporary registers) | нет |
Как правило, сохраненные регистры s0-s11 сохраняются при вызовах функций, в то время как регистры аргументов a0-a7 и временные регистры t0-t6 - нет.
В RISC-V для передачи аргументов в подпрограмме используются 8 регистров аргументов, а именно a0-a7. Перед выполнением вызова подпрограммы аргументы подпрограммы копируются в регистры аргументов а0-а7. Стек используется в том случае, если количество аргументов превышает 8.
Для вызова подпрограммы используются команды jal (типа U) или jalr (типа S). Эти команды работают позиционно-независимо - в одной (jal) чётко формируется смещение относительно адреса текущей инструкции (но этот переход ограничен 20-битным адресом), в другой (jalr) - полный адрес перехода формируется в s0 с помощью команды auipc (адрес 32-битный). auipc выполняет формирование адреса при помощи адреса текущей инструкции и корректирующего сложения. Таким образом, в зависимости от величины необходимого смещения относительно счетчика команд (PC) для вызова подпрограмм применяется или jal или комбинация auipc и jalr.
Пример, в котором подпрограмма сначала вызывается с помощью jal, а затем - с помощью jalr:
jal ra,0x00000018 # jal subr auipc s1,0 # la s1 subr addi s1, s1,20 jalr ra, s1,0 # jalr s1 addi x17, zero,10 # li a7 10 ecall # ecall auipc a0,0x0000fc10 # subr: la a0 ping addi a0, a0,0xffffffe8 addi a7, zero,4 # li a7 4 ecall # ecall jalr zero, ra,0 # ret
Примечание: справа в комментариях приведен текст с использованием псевдоинструкций.
В качестве команды возврата используется jalr. Как это работает:
- в качестве регистра перехода используется ra
- в качестве регистра возврата используется zero (это позволяет разработчику сразу определять, что перед нами инструкция не перехода, а именно возврата из подпрограммы)