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

Лабораторная работа №7. Вызов подпрограммы, работа со стеком

< Лекция 7 || Лекция 8: 12 || Лекция 9 >

7.1. Цель и задачи

Целью работы является разработка простой программы преобразования данных для приобретения практических навыков программирования на языке ассемблера и закрепления знаний по работе с подпрограммами и стеком. Задачи:

  1. Изучение соглашений о вызовах подпрограмм.
  2. Освоение принципов работы со стеком.

Презентация к блоку "Ассемблер 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 ОС:

  1. В регистр a7 помещается номер системной функции (service number).
  2. Если есть параметры системного вызова, то они помещаются в регистры a0-a6.
  3. Инструкция ecall передаёт управление операционной системе (в данном случае - ядру RISC-V ОС).
  4. Возврат из системного вызова происходит по аналогии с возвратом из подпрограммы, на следующую после ecall инструкцию
  5. Возвращаемое значение (если есть) помещаются в 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 перед вызовом функции и восстановить его после возврата функции.

Таблица 7.1. Соглашение об использовании регистров
Номер Название Описание Сохраняется?
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 (это позволяет разработчику сразу определять, что перед нами инструкция не перехода, а именно возврата из подпрограммы)
< Лекция 7 || Лекция 8: 12 || Лекция 9 >