Регистры и модель памяти. Виды адресации
Вступление: понятие о конвенциях
- Risc-V наследует «Правило MIPS»:
- машинный код предназначен для эффективного декодирования/выполнения
- инструкции языка ассемблера — для удобного написания
- ⇒ Не только 1:1 отображение содержательных обозначений в код, но и
- некоторое заранее оговорённое преобразование программы
- дисциплина написания программ
- Дисциплина:
- Использование регистров общего назначения
- Моделирование полезных структур данных (например, стека)
- …
- Обратное следствие: соблюдение дисциплины поможет ещё эффективнее проектировать процессор
- если в коде используются оговорённые дисциплиной регистры (например, для перехода на подпрограмму), можно организовать скрытый аппаратный стек для их хранения (стек RISC-V находится в медленно оперативной мапяти)
- можно попытаться изваять в камне длинную псевдоинструкцию, если окажется, что это выгодно
- …
Конвенции
Конвенция — договорённость о стиле написания программ, в которой ограничивается использование возможностей ЭВМ
Большинство конвенций предполагают, что будут использованы определённые приёмы программирования.
- Например, использование некоторых регистров ограничено для передачи параметров и получения значений функции, для указателей на различные области памяти (стек, фрейм, глобальную…); требуется или не требуется сохранение исходных значений при завершении подпрограммы и т. п. В действительности все регистры MIPS, кроме двух, работают одинаково
- Некоторые конвенции предполагают наличие типичных программных механизмов на работающей ЭВМ.
- Например, часть регистров используется для взаимодействия с ядром ОС, хотя его может и не быть.
Тому же служит карта памяти, в которой задано расположение .text, .data, .ktext и .kdata и т. п. На самом деле значения «по умолчанию» можно переключить— но об этом следует договориться в рамках всех программ, запускаемых на данной ЭВМ
- Множество конвенций порождены спецификой архитектуры ЭВМ
- Например, $1 ($at) используется для хранения адреса при трансляции псевдоинструкций. Псевдоинструкции образовались в борьбе с избыточностью системы команд
- Некоторые «возможности» таковыми не являются.
Например, нельзя надеяться, что в памяти или регистре при старте находится определённое число (а хоть бы и 0), поэтому считается, что там лежит что угодно
- Конвенции нужны для удобства совместной работы. В этом случай они могут быть совсем локальными, используемыми в рамках одной разработки.
Конвенции, как явно задокументированные требования к оформлению программ, плавно перетекают в менее явные и совсем неявные договорённости, на уровне «так не делают». Общий смысл таких договорённостей — удобство (совместной) разработки.
Регистры
Как мы уже знаем, регистр — это особый «быстрый» вид оперативной памяти:
- Располагается непосредственно на процессоре (время доступа несравненно меньше времени доступа к ОЗУ)
- Имеет небольшой адрес — номер регистра (в одной инструкции может уместиться несколько таких адресов)
- ⇒ Нужно меньше регистров
- При грамотном планировании большинство вычислений можно производить на регистрах, изредка обращаясь к памяти
RISC: все вычисления производятся только на регистрах, операции с памятью всего две: прочитать и записать
- ⇒ Нужно больше регистров
Регистр |
Программное имя |
Регистр |
Программное имя |
x0 |
zero |
x16 |
a6 |
x1 |
ra |
x17 |
a7 |
x2 |
sp |
x18 |
s2 |
x3 |
gp |
x19 |
s3 |
x4 |
tp |
x20 |
s4 |
x5 |
t0 |
x21 |
s5 |
x6 |
t1 |
x22 |
s6 |
x7 |
t2 |
x23 |
s7 |
x8 |
s0, fp |
x24 |
s8 |
x9 |
s1 |
x25 |
s9 |
x10 |
a0 |
x26 |
s10 |
x11 |
a1 |
x27 |
s11 |
x12 |
a2 |
x28 |
t3 |
x13 |
a3 |
x29 |
t4 |
x14 |
a4 |
x30 |
t5 |
x15 |
a5 |
x31 |
t6 |
Соглашения по использованию:
Только один регистр особенный — zero (x0, всегда равен 0)
Теоретически в любых целях можно использовать любой регистр, но это сильно затрудняет написание работающих программ
⇒ возникает понятие конвенции (договорённости)
Регистры a0 - a7 (x10 - x17) используются для передачи параметров подпрограммам и для возврата значений из них
- Очевидно, у подпрограмм может быть более 8 параметров, так что здесь тоже вступают в силу конвенции, и очень разнообразные
Регистры t0 - t6 (x5-x7, x28-x31) можно использовать без ограничений (как и a0 - a7)
Регистры s0 - s11 (x8,x9,x18 - x27) по договорённости необходимо восстанавливать в исходные значения перед выходом из подпрограммы. При этом даже если они не используются вне подпрограммы, код сохранения и восстановления обязан присутствовать.
Регистр ra (x1) используется для хранения адреса возврата из подпрограммы
Регистр sp (x2) содержит ссылку на вершину стека (stack pointer)
Регистры gp (x3) и tp (x4) — (global pointer и thread pointer). В RISC-V много внимания уделено аппаратной поддержке многопоточности: программа может состоять из «нитей» — нескольких участков, выполняемых параллельно<<Footnote(В действительности параллельность необязательна, достаточно время от времени без предупреждения переключаться между нитями.)>>. Регистр gp указывает на область данных, доступных всем нитям одновременно (например, на массив), а регистр tp — на данные, уникальные для каждой нити
Регистр s0 (x8) в некоторых конвенциях организации подпрограмм используется для хранения ссылки на область данных текущей подпрограммы, поэтому он носит ещё одно название — fp (frame pointer)
Плоская модель памяти RARS
Это тоже такая конвенция. В RARS таких моделей памяти три, на самом деле их ещё больше
- Формально процессу доступна «вся» оперативная память
- Адресное пространство разделено на части, использование которых задаётся конвенциями
- …и может вдобавок проверяться аппаратно (например, запрет на запись, чтение или исполнение)
- На внешнем — аппаратном — уровне адресное пространство отвечает определённой конвенции; у процессов внутренних уровней (гипервизор, ядро, пользователь) оно виртуализовано
⇒ виртуальные адреса легко подогнать под ту же конвенцию
0xffffffff |
highest address in kernel (and memory) |
Память устройств |
Последний адрес, доступный ядру |
memory map limit address |
Конец памяти устройств |
||
0xffff0000 |
MMIO base address |
Начало памяти устройств |
|
0x80000000 |
kernel space base address |
Область памяти ядра |
Начало памяти ядра |
0x7fffffff |
highest address in user space |
Область данных |
Последняя ячейка, доступная пользователю |
data segment limit address |
Последняя ячейка области данных |
||
0x7ffffffc |
↓ stack base address |
Адрес исчерпания стека |
|
0x7fffeffc |
↓ stack pointer sp |
Сюда указывает регистр стека (растёт вниз) |
|
0x10040000 |
stack limit address |
Стек может расти досюда |
|
↑ heap base address |
Начало кучи (растёт вверх) |
||
0x10010000 |
.data base Address |
Начало статических данных |
|
0x10008000 |
global Pointer gp |
Сюда указывает регистр глобальных данных |
|
0x10000000 |
.extern base address |
Область глобальных данных |
|
DATA Segment base address |
Начало области данных |
||
0x0ffffffc |
text limit address |
Область программного кода |
Последняя ячейка области программного кода |
0x00400000 |
.text base address |
Начало программы |
|
TEXT segment base address |
Начало области программного кода |
||
0x00000000 |
|
Зарезервированная область |
|
- Резервированная память (до 0x400000) может быть использована операционной системой для различных нужд.
- Доступ к ней со стороны программы приведёт к исключению
TEXT — область для инструкций программы (представьте себе, это когда-то называлось текстом ☺!). Теоретически никто не мешает иметь несколько директив .text адрес, размещающих код по различным адресам в пределах 0x400000 - 0x1000000.
Обычно после загрузки программы, когда она начала работать, запись по адресам 0x400000 - 0xffffff по умолчанию запрещена. (привет «гарвардской архитектуре»)
DATA — область для всевозможных данных программы (глобальных переменных, статических локальных переменных, кучи и стека)
.extern base address — область для внешних данных (нужна для взаимодействия с ОС). В этой обрасти размещает данные директива .extern
.data base address — начало области, в которую обычно раскладываются данные директивами .data. Именно там лежат переменные, объявленные массивы и прочее. Традиционно имеется зазор между началом области данных (0x10000000) и непосредственно статическими данными (0x10010000 - 0x10040000). Обычно в процессе работы программы нельзя переходить по адресам из области данных и декодировать их как инструкции.
Heap (Куча) — область данных, в которую принято помещать динамические данные. Идея в повторном использовании одних и тех же областей памяти для различных нужд. Для этого служат процедуры выделения памяти, в которых запоминается размер и адрес запрошенного фрагмента, и освобождения, в которых эти данные объявляются устаревшими (можно совсем забыть, а можно область пометить как свободную), после чего очередная процедура выделения вполне может выдать ту же самую область. Механизмы выделения/освобождения памяти (т. н. memory managment) обычно довольно непросты, и соответствующие функции предоставляет ОС. Добавление и освобождение данных в куче обычно происходит в сторону увеличения адреса.
Stack — область динамических данных особого вида, реализующая абстракцию «стек» и используемая при вызове подпрограмм и передачи им параметров. Добавление и освобождение данных в стеке обычно происходит в сторону уменьшения адреса. Бесконтрольное снятие данных со стека может привести к тому, что регистр стека начнёт указывать за пределы пользовательской памяти, поэтому (и по каким-то ещё соображениям) изначально sp указывает не на самое «дно» стека, а существенно ниже (под 0x7ffff000). Стек и куча растут навстречу друг другу. Строго говоря, нельзя понять, где кончается стек и начинается куча, но это и не важно, лишь бы не пересекались.
Память ядра ОС. Начиная с адреса 0x80000000 идёт область, недоступная программе пользователя. Это область кода и данных ядра. Безотносительно к тому, запущена программа под управлением ОС или «на голом железе», для исполнения кода и доступа к памяти требуется особый режим работы процессора. Чтение, запись и переход с использованием адресов ядра пользовательской программе запрещены.
Область MMIO служит для адресации ячеек, вообще не принадлежащих оперативной памяти. Обращение по этим адресам приведёт к взаимодействию с данными на внешних устройствах (обычно с регистрами ввода-вывода или собственной памятью устройств)
Можно ли в RARS прочитать байт из раздела .text по нечётному адресу? (Почему ?)
Ограничение на доступ к «адресам ядра» 0x80000000 - 0xefffffff — специфика плоской модели памяти RARS. В действительности ничто не мешает предоставить доступ программе ко всей адресуемой в RV32I памяти. Непонятно только, в какой памяти при этом будет находиться ядро и другие программы, если система многозадачная ☺
Ограничение на доступ к «зарезервированному пространству» 0x0 - 0x003fffff — также специфика плоской модели памяти RARS, но она имеет практическое применение.
Например, для описания загружаемого процесса. В RARS директивы .text и .data приводят к заполнению памяти непосредственно по указанным адресам. При наличии операционной системы деле чаще всего результат трансляции записывается в исполняемый файл, который имеет довольно сложный формат, а при необходимости загружается в память в соответствии со специальными таблицами размещения, динамической компоновкой и т. п. Некоторые из этих данных нужны для работы программы под управлением ОС, они-то и размещаются в младших адресах памяти. Чтение и запись в эту область со стороны пользователя запрещены.
Удобно запретить обращаться к адресам, которые могут возникнуть в инструкциях косвенной адресации по ошибке (забыли положить адрес в регистр, там осталось предыдущее значение, пошли в память…)— это в первую очередь все маленькие числа, типа 0, 10, 100, 1000, 2, 4, 8, 1024, 42, 1337, 100500 и прочие. Немедленно получить ошибку (вместо того, чтобы, например, самоуверенно расписывать это случайно подвернувшееся вместо в памяти) — очень полезно.
Директивы размещения данных в памяти
В программе на языке ассемблера возникает необходимость описать содержимое сегмента памяти. Для этого код программы помечается .text, а данные — .data . При трансляции в RARS код размещается с адреса 0x400000 (если не сказано иное), а данные — с адреса 0x10010000 (опять-таки, если не сказано иное).
В секции .data помещают директивы (указания ассемблеру) по размещению данных в памяти.
.word число — одно или несколько 4-байтовых чисел
.dword число — одно или несколько 8-байтовых чисел
.half число — одно или несколько 2-байтовых чисел
.byte число — одно или несколько однобайтовых чисел
.ascii "строка" — последовательность символов в кодировке ASCII
.asciz "строка" — то же, только после последнего символа обязательно записывается нулевой байт (конец строки, договорённость, например, для языка Си) Пример размещения данных различного размера:
Результат трансляции пословно (секция .data начинается по умолчанию с адреса 0x10010000). Обратим внимание на little endian: младший байт в слове имеет меньший адрес!
10010000: deafbeef d0feeded 000aceba 56781234 0f0e0d0c 77663344
Для того, чтобы обращаться к соответствующим ячейкам памяти, можно использовать и адреса, и метки. Метка — символическое имя, оно заменяет адрес в программе на языке ассемблера и транслируется в соответствующий адрес в машинных кодах. В подавляющем большинстве случаев ассемблер RISC-V заменяет метку на смещение относительно исполняемой инструкции, то есть регистра «program counter», pc.
Разберём, как работают псевдоинструкции li (load immediate) и la (load address):
Несмотря на то, что метка var соответствует адресу 0x10010000, и значения регистров t5 и t6 будут равны (проверьте!), действия эти псевдоинструкции задают разные. Посмотрим на получившийся код:
0x00400000 0x10010f37 lui x30,0x00010010 5 li t5 0x10010000 0x00400004 0x000f0f13 addi x30,x30,0x00000000 0x00400008 0x0fc10f97 auipc x31,0x0000fc10 6 la t6 var 0x0040000c 0xff8f8f93 addi x31,x31,0xfffffff8
Псевдоинструкция li t5 0x10010000 превратилась в загрузку старших 20 битов числа 0x10010000 с помощью инструкции типа U (lui) и добавление младших 12 (которые оказались нулями)
Псевдоинструкция la t6 var сначала воспользовалась другой инструкцией типа U — auipc.
Инструкция auipc регистр, смещение работает так:
Как полагается инструкции U, заполняет 20 старших битов регистра смещением. Младшие 12 битов зануляет. В нашем случае получается 0xfc10000.
Добавляет в регистр адрес текущей исполняемой инструкции. В нашем случае — 0x00400008. Получается 0x10010008
Это 0x10010008 — примерный адрес метки, и следующей инструкцией к нему прибавляется оставшееся расстояние (все узнали в 0xfffffff8 число -8?). Получается 0x10001000
Такой подход называется позиционно-независимым кодированием. Смысл его в том, что одну и ту же программу можно загрузить в произвольный адрес памяти (а не только в конвенциональные 0x400000 - 0x10000000 - 0x10010000). При этом в регистр t5 попадёт число 0x10001000 — потому что именно число мы и хотели туда положить, а в t6 — адрес, отстоящий от данной инструкции на 0xfc0fff8 байтов, каким бы ни был её адрес.
Регистр pc не является регистром общего назначения, но для ориентации в программах на RISC-V его всегда надо иметь в виду. Получить значение pc можно, например, с помощью auipc t0 0; больше ничего напрямую с ним делать нельзя.
Начиная со значения, указанного в директиве .data (или с адреса по умолчанию), ассемблер высчитывает адрес, который будет соответствовать метке, каждый раз прибавляя размер очередной отведённой ячейки. Обращение к ячейке памяти размером N байтов в архитектуре RISC-V возможно при условии, что адрес этой ячейки кратен N (следствие little endian). Исключение — 64-разрядные значения, которым достаточно границы слова и расширение «C», посвящённое плотной упаковке инструкций. Поэтому при размещении ассемблером ячеек разного размера происходит выравнивание: если очередная ячейка имеет размер, которому не кратен текущий предполагаемый адрес её размещения, к этому адресу дополнительно прибавляется от одного до трёх байтов, чтобы обеспечить кратность. Пример разнообразных данных в памяти с автоматическим и ручным выравниванием.
Выравнивание можно вызвать директивно, с помощью .align номер (где номер 0,1,2 и 3 соответствует байту, полуслову, слову и двойному слову соответственно). Вот во что превращается пример выше. Обратите внимание на нулевые байты, добавленные для выравнивания; не забываем про little endian: для выравнивания на границу двойного слова к адресу 0x1001002a пришлось добавить пять байтов!
0x10010000 0x76543210 0x56567878 0x12123434 0x00002468 0x76543210 0x00000001 0x76543210 0x24680003 0x10010020 0x07050ac4 0x00000009 0x00050301 0x00000000 0x00090007 0x00000000 0x00000000 0x00000000
Директива .data автоматически заполняет область данных, начиная с 0x10010000, причём последующие директивы .data продолжают заполнение с последнего незанятого адреса. Однако можно указывать адрес размещения данных явно в виде параметра .data. Директивы .data можно перемежать с директивами .text, это никак не повлияет на размещение данных:
Память в области данных в результате:
0x10010000 0x00123456 0x00000001 0x0007890a 0x00000002 0x00000000 0x00000000 0x00000000 0x00000000 0x10010020 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x10010040 0x00334455 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000
Адресация в секции кода
Адресация в секции кода нужна для переходов — условных и безусловных. Для этого используются команды типа S и U соответственно, но их машинный вид разбирать мы будем позже, а пока просто напишем проверку какого-нибудь условия.
Пример с условным переходом:
1 .data
2 odd: .asciz "Odd\n"
3 even: .asciz "Even\n"
4
5 .text
6 la t1 odd # Считаем, что число нечётное
7 li a7 5 # Чтение целого числа в регистр a0
8 ecall
9 andi t0 a0 1 # Проверяем, чётно ли число
10 bnez t0 isodd # Нечётно
11 la t1 even
12 isodd: mv a0 t1 # Вывод строки, адрес которой находится в a0
13 li a7 4
14 ecall
- Здесь мы использовали внешний вызов №5 (чтение целого числа) и №4 (вывод строки)
Полученный код:
Address Code Basic Line Source 0x00400000 0x0fc10317 auipc x6,64528 6 la t1 odd 0x00400004 0x00030313 addi x6,x6,0 0x00400008 0x00500893 addi x17,x0,5 7 li a7 5 0x0040000c 0x00000073 ecall 8 ecall 0x00400010 0x00157293 andi x5,x10,1 9 andi t0 a0 1 0x00400014 0x00029663 bne x5,x0,12 10 bnez t0 isodd 0x00400018 0x0fc10317 auipc x6,64528 11 la t1 even 0x0040001c 0xfed30313 addi x6,x6,-19 0x00400020 0x00600533 add x10,x0,x6 12 isodd: mv a0 t1 0x00400024 0x00400893 addi x17,x0,4 13 li a7 4 0x00400028 0x00000073 ecall 14 ecall
В инструкции условного перехода (типа B) практически невозможно вручную отыскать смещение:
оно хранится в битах 8, 9, 10, 11, 25, 26, 27, 28, 29, 30, 7, 31 (именно в такой последовательности!) + младший бит смещения в коде всегда 0, и не хранится
0002966316 — это 000000000000001010010110011000112 ; биты смещения, начиная с первого, — 0, 1, 1, 0, 0, 0, … (далее все нули), то есть …0001102 + нулевой бит 0 — …00011002 == 12 (это число показывает дизассемблер) == 000c16
Действительно, адрес инструкции bne = 0040001416, а адрес метки isodd: = 0040001416 + 000c16 = 0040002016
Базовые инструкции вида «сравнить и перейти» — это beq/bne и blt/bge с их беззнаковыми аналогами btlu/bgeu. Все остальные инструкции вида b*, включая безусловный переход b, — псевдо, потому что получаются простой перестановкой регистров-операндов или подстановкой регистра zero в нужное место. Непосредственный 12-битный операнд используется для хранения смещения относительно текущего адреса. Вычислением этого смещения из метки занимается ассемблер, оно бывает положительное (вперёд, как в примере выше) и отрицательное (назад, как в следующем). 12 битов хватает на то, чтобы сделать переход на 4 килобайта кода (4, а не 2, потому что адрес перехода всегда кратен 2 и самый младший бит просто не хранится).
Простейший цикл со счётчиком — это условный переход назад в коде:
Строго говоря, для этой программы не нужно вносить инструкцию li a7 1 внутрь цикла — значение a7 не меняется. Метку loop: можно былдо сдвинуть на следующую инструкцию. Однако этого не сделано намеренно: поскольку a7 — это номер внешнего вызова, всегда может случиться, что внутри цикла нам потребуется ещё один, другой (например, вывод символа «перевод строки»). Что же тогда, снова двигать метку?
что произойдёт, если сдвинуть loop: на инструкцию mv a0 s1, а внутри цикла вызвать другой ecall, например, №11?
Полученный код:
Address Code Basic Source 0x00400000 0x00100493 addi x9,x0,0x00000001 1 li s1 1 0x00400004 0x00a00913 addi x18,x0,0x0000000a 2 li s2 10 0x00400008 0x00100893 addi x17,x0,0x00000001 3 loop: li a7 1 0x0040000c 0x00900533 add x10,x0,x9 4 mv a0 s1 0x00400010 0x00000073 ecall 5 ecall 0x00400014 0x00148493 addi x9,x9,0x00000001 6 addi s1 s1 1 0x00400018 0xff24c8e3 blt x9,x18,0xfffffff0 7 blt s1 s2 loop 0x0040001c 0x00a00893 addi x17,x0,0x0000000a 8 li a7 10 0x00400020 0x00000073 ecall 9 ecall
Обратите внимание на переход на -16 байтов назад — это как раз адрес метки loop.
(более ранние версии RARS показывали в этом месте действительное значение поля «destination», -8. Добавляем всегда нулевой младший бит, получаем -16)
Если надо перейти дальше, чем это допустимо в b*, используйте отдельно сравнение, отдельно безусловный переход псевдоинструкцией j: она имеет тип U (с особенностями, о которых ниже), и поле непосредственной константы занимает 20 битов. Но сначала напишите 4 килобайта кода в одном из условий. А лучше — наоборот, не пишите так☺.
На сколько четырёхбайтных инструкций можно перейти командой j (число может быть отрицательным, младший бит равен нулю и не хранится)?
Цикл с постусловием — штука ненадёжная, так что идеологически верно было бы переписать пример выше в соответствии с «канонической схемой цикла»:
- Инициализация
- Проверка условия
- Тело
- Изменение
При этом придётся сделать ещё один (безусловный) переход.
- Заодно вставим вывод перевода строки (понадобится для Д/З)
Безусловный переход может называться b, а может j — это одна и та же псевдоинструкция.
Это инструкция «длинного» перехода типа J (20 битов, т. е. по мегабайту в обе стороны с учётом ещё одного младшего бита).
Строго говоря — это инструкция перехода на подпрограмму с сохранением адреса возврата в регистре zero (где он и пропадает).
Про отсутствие регистра флагов
Операция «сравнить и перейти» в трёхадресной архитектуре RISC-V атомарна. Это значит, что нет необходимости заводить отдельный регистр флагов, и сверяться с ним относительно результатов сравнения. Атомарность сравнения-перехода также делает более эффективной микропрограммную реализацию.
Регистр флагов бывает нужен также для определения переполнения, но в спецификации справедливо замечают, что переполнение в большинстве случаев проверяется одной операцией «сравнить-перейти» (зачем флаги тогда?), и только в случае, когда мы не знаем знаков обоих слагаемых, проверка потребует дополнительных вычислений.
Про беззнаковые операции и особенности арифметики
Операции сложения и вычитания целых чисел в дополнительном коде работают независимо от того, считаем мы эти числа знаковыми или беззнаковыми. Побитовые операции вроде OR вообще ничего не знают о знаках. Операции умножения и деления (из расширения «M») и операции сравнения бывают как знаковыми, так и беззнаковыми, причём для умножения в «M» предусмотрен и знаково-беззнаковый вариант. Беззнаковые инструкции обозначаются дополнительным суффиксом U.
Классический алгоритм целочисленного деления вычисляет одновременно частное и остаток, но в отличие от большинства архитектур, в RISC-V нет возможности задать два регистра-приёмника (это привело бы к услжнению и замдлению логики). Поэтому делением занимается инструкуция div (беззнаковым — divu), а остатком — rem (remu).
Алгоритмы умножения и деления требуют на порядок больше тактов, чем сложение, а в программе часто требуется и частное, и остаток. Очевидная аппаратная оптимизация состоит в том, чтобы запоминать этот остаток где-то в укромном уголке (теневом регистре) и немедленно доставать оттуда, не выполняя деления ещё раз. На этот счёт есть в расширении есть конвенция: рассчитывать на такой порядок инструкций:
В конвенции также сказано, что регистр приёмник1 не должен совпадать с регистрами делимое1 или делитель1
Результат умножения двух целых может оказаться размером в два машинных слова, поэтому в RISC-V предусмотрено два типа инструкций — mul, которая возвращает младшее слово результата, и mulh, которая возвращает старшее слово — с похожими конвенциями относительно возможной оптимизации.
Особенности кодирования инструкций в секции кода
Относительно инструкций типа B и J:
В базовом RV32I размер инструкции — 4 байта, но в «упакованном» расширении C адрес инструкции может быть кратным не 4, а только 2.
- При использовании непосредственного значения в командах перехода один младший бит адреса всегда равен 0. Его можно не хранить вообще, а приписывать к адресу в процессе декодирования.
Таким образом, например, инструкция типа B совпадает по формату с типом S, а тип J — с типом U, но непосредственное значение будет интерпретироваться по-другому.
На схеме взята за основу инструкция типа R, она содержит только номера регистров и дополнительные поля (funct7 и funct3, одно 7 битов, другое — 3). Что делать с этими полями, если в инструкцию необходимо вписать некоторое непосредственное значение-число?
Если непосредственное значение выступает как «источник», его битами можно занять поля funct7 и source2 (инструкция типа I)
Если непосредственное значение выступает как «приёмник», его битами можно занять поля funct7 и destination (инструкции типа S и B)
В инструкциях типа U и J непосредственное значение занимает 20 старших битов.
Биты ячейки: 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 Инструкция R: funct7 ↓ source2 ↓ source1 ↓ funct3 ↓ destination ↓ opcode Инструкция I: 11 10 9 8 7 6 5 4 3 2 1 0 ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· Инструкция S: 11 10 9 8 7 6 5 ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· 4 3 2 1 0 ·· ·· ·· ·· ·· ·· ·· → число: 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 9 8 7 6 5 4 3 2 1 0 Инструкция B: 12 10 9 8 7 6 5 ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· 4 3 2 1 11 ·· ·· ·· ·· ·· ·· ·· → число: 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 10 9 8 7 6 5 4 3 2 1 =0 Инструкция U: 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· → число: 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 =0 =0 =0 =0 =0 =0 =0 =0 =0 =0 =0 =0 Инструкция J: 20 10 9 8 7 6 5 4 3 2 1 11 19 18 17 16 15 14 13 12 ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· ·· → число: 20 20 20 20 20 20 20 20 20 20 20 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 =0
- В представлении инструкции показано, какой бит числа где хранится
- В представлении получившегося числа наиболее старший из хранящихся битов считается знаковым и распространяется вплоть до 31-го
=0 означает, что соответствующий бит всегда равен 0
Немного о свойствах числа, которое в результате получается:
- Бит, который в получившемся числе станет знаковым, в инструкции всегда 31-й (т. е. тоже знаковый).
Это свойство полезно также и в 64-разрядном варианте RV64I.
Если непосредственное значение — смещение адреса в инструкции типа B, младший бит его всегда равен нулю и не хранится, а вместо него хранится самый старший, не считая знакового — 11-й.
Инструкция типа U нужна для работы со старшими битами слова (см. разложение псевдоинструкции lw), т. е. с 31-го по 12-й, которые просто переносятся туда один к одному.
Инструкция типа J интерпретирует эти 20 битов как биты с 20-го по 1-й адресного смещения (0-й бит равен 0). Получается, что:
- 31-й бит — знаковый (расширяется вплоть до 20-го)
Биты с 19-го по 12-й просто совпадают с теми же самыми битами числа (с 19-го по 12-й), как в типе U
В битах с 30-го по 21-й хранятся биты числа с 10-го по 1-й, как в типе I
- Нулевой бит числа равен нулю, а в 20-м бите хранится оставшийся 11-й бит числа
Немножко очень заморочено, но «если постоять, повтыкать, всё понятно становится»™…
Косвенная адресация и массивы
Ещё один способ обращаться к памяти — это записать полный абсолютный адрес в регистр, и воспользоваться инструкцией, которая работает с памятью, находящейся по этому адресу. Вот как раскрываются псевдоинструкции lw регистр метка и sw регистр метка вспомогательный_регистр:
Что даёт:
Address Code Basic Line Source 0x00400000 0x0fc10317 auipc x6,0x0000fc10 6 lw t1 var 0x00400004 0x00432303 lw x6,4(x6) 0x00400008 0x0fc10397 auipc x7,0x0000fc10 7 lw t2 addr 0x0040000c 0x0003a383 lw x7,0(x7) 0x00400010 0x0003ae03 lw x28,0(x7) 8 lw t3 (t2) 0x00400014 0x0043ae83 lw x29,4(x7) 9 lw t4 4(t2) 0x00400018 0xffc3af03 lw x30,0xfffffffc(x7) 10 lw t5 -4(t2) 0x0040001c 0x0fc10297 auipc x5,0x0000fc10 11 sw t5 var t0 0x00400020 0xffe2a423 sw x30,0xffffffe8(x5)
Сначала идёт уже знакомая нам инструкция auipc, которая формирует в регистре t1 (он же x6) адрес, по которому лежит интересующее нас значение
Затем lw выбирает это значение из памяти, попутно скорректировав его смещением 4, и кладёт в тот же регистр t1
По метке addr мы положили метку var, то есть адрес 0x10010004
Этот адрес оказывается в регистре t2 тем же способом, каким 0xdeadbeef оказалось в t1
После чего с помощью явно указанного смещения в инструкции (не псевдо) lw получаем в разных регистрах содержимое памяти по адресам 0x10010004, 0x10010008 и 0x10010000 сооответственно.
При записи регистра в память трюк с временным хранением адреса в том же самом регистре не срабатывает: там лежит записываемое значение. Поэтому приходится использовать вспомогательный регистр (в примере — t0)
В программистских задачах часто приходится работать с записанными в память последовательностями однотипных данных — массивами.
- Массив
- это набор элементов данных одного размера, расположенных в оперативной памяти строго подряд.
Для работы с массивом необходима знать адрес начала массива в памяти, и длину массива — количество элементов * размер одного элемента. Обращение к элементу массива № k — это обращение к памяти по адресу адрес + размер * k. Например, 42-й элемент целочисленного массива по адресу 0x10010020 это четыре байта (одно машинное слово) по адресу 0x10010020 + 4 * 42 = 0x100100c8.
Если нужно обработать массив данных, косвенная адресация — единственный способ. В примере массив слов заполняется последовательными значениями:
- Обратите внимание на т. н. «адресную арифметику» — на каждом проходе цикла для доступа к следующему элементу массива к адресу надо прибавлять размер элемента
Адреса можно сравнивать на > и <, потому что память линейна. Это сравнение должно быть беззнаковое, потому что знаковый бит для самого адреса (а не для смещения) значения не имеет; например, все адреса секции ядра — «отрицательные», но адрес 0x80001000, конечно, меньше адреса 0x80001100.
- Память в результате выглядит так:
0x10010000 0x00000001 0x00000002 0x00000003 0x00000004 0x00000005 0x00000006 0x00000007 0x00000008 0x10010020 0x00000009 0x0000000a 0x0000000b 0x0000000c 0x0000000d 0x0000000e 0x0000000f 0x00000010
Если вместо sw использовать sh (store half), а счётчик увеличивать не на 4, а на 2, получится массив полуслов, вмещающий 32 коротких целых. Если использовать sb и 1 соответственно — массив на 64 байта.
Ещё один пример:
1 .data
2 sep: .asciz "--------\n" # Строка-разделитель (с \n и нулём в конце)
3 .align 2 # Выравнивание на границу слова
4 array: .space 64 # 64 байта
5 arrend: # Граница массива
6 .text
7 la t0 array # Счётчик
8 la s1 arrend
9 li t2 1 # Число, которое мы будем записывать в массив
10 fill: sw t2 (t0) # Запись числа по адресу в t0
11 addi t2 t2 1 # Изменим число
12 addi t0 t0 4 # Увеличим адрес на размер слова в байтах
13 bltu t0 s1 fill # Если не вышли за границу массива
14
15 la a0 sep # Выведем строку-разделитель
16 li a7 4
17 ecall
18 la t0 array
19 out: li a7 1
20 lw a0 (t0) # Выведем очередной элемент массива
21 ecall
22 li a7 11 # Выведем перевод строки
23 li a0 10
24 ecall
25 addi t0 t0 4
26 blt t0 s1 out
27
28 li a7 10 # Останов
29 ecall
Если мы хотим хранить в array: слова, Между строкой из байтов и array: нужно выравнивание на границу слова. Было бы там на .space, а .word, выравнивание произошло бы автоматически.
- Мы использовали системные вызовы 4 «вывести строку» и 11 «вывести символ»
В некоторых архитектурах популярна двойная косвенная адресация — это когда в ячейке памяти лежит адрес ячейки памяти, в которой лежит нужное значение (как в переменной addr: в одном из примеров выше). Это удобно для организации таблиц ссылок, и, наверное, можно как-то оптимизировать, чтобы оно работало быстрее двух последовательных инструкций обычной косвенной адресации. Но поскольку с точки зрения скорости двойное обращение к памяти — очень медленная операция, идеологии RISC она не соответствует.
О проверке решений Д/З
Решения можно запускать из командной строки, как это делает EJudge (первая строка — команда, следующая — числа, третья — вывод суммы цифр, последняя — диагностика о причине останова RARS):
$ rars nc sm me DigitSum.asm 2341234 19 Program terminated by calling exit
Фактически примерно так работает проверка Д/З в EJudge:
- Решение запускается на эталонном вводе (тесте), а вывод помещается в файл:
$ rars nc sm me решение.asm < тест.in > результат.out
Если ошибка (например, ошибка компиляции) возникла ещё при запуске — результат теста отрицательный и в журнале можно посмотреть диагностику (которая не попадает в результат.out, потому что параметр me заставляет rars выводить диагностику на stderr)
- Полученный результат сравнивается с ожидаемым эталонным выводом. Если различия есть — результат теста отрицательный, и в журнале можно эти различия посмотреть. Формат будет примерно такой:
$ diff -u результат.out эталон.out
Таким образом можно проверять правильность работы решения перед посылкой на EJudge
Д/З
Ввод целого числа, напоминаю — системный вызов № 5. Не забывайте выводить переводы строки, как в примере выше.
В последних двух задачах предполагается хранить в памяти массив данных неизвествного размера. Поскольку он такой один, вы просто заводите все переменные, какие хотите (я обошёлся без переменных вообще, регистров хатило), ставите в конце секции .data метку и пишете туда сколько влезет. В плоской модели памяти доступно всё адресное пространство.
Всем, кто ещё не успел, зарегистрироваться на EJudge и решить задачку про вывод двух "Hello" (системный вызов № 4 ☺)
EJudge: DigitSum 'Сумма цифр'
Ввести целое число (возможно, отрицательное) и посчитать сумму его цифр в десятичной записи; вывести как целое.
-12345
15
EJudge: PlusMinus 'Плюс-минус'
Ввести натуральное N, затем N целых чисел ai; посчитать формулу a0-a1+a2-…±aN-1 . Вывести результат.
4 22 13 14 15
8
EJudge: EvenBack 'Чётные назад'
Ввести целое N, затем N целых чисел. Вывести из этих чисел только чётные, причём в обратном порядке (стеком пользоваться запрещается )
6 12 -11 3 88 0 1
0 88 12
EJudge: NoDups 'Без повторений'
Ввести целое N, затем N целых чисел. Вывести эти числа, пропуская уже выведенные, если встретятся повторы.
8 12 34 -12 23 12 -12 56 9
12 34 -12 23 56 9