Регистры и модель памяти. Виды адресации

Вступление: понятие о конвенциях

Конвенции

Конвенциядоговорённость о стиле написания программ, в которой ограничивается использование возможностей ЭВМ

Конвенции, как явно задокументированные требования к оформлению программ, плавно перетекают в менее явные и совсем неявные договорённости, на уровне «так не делают». Общий смысл таких договорённостей — удобство (совместной) разработки.

Регистры

Как мы уже знаем, регистр — это особый «быстрый» вид оперативной памяти:

Регистр

Программное имя

Регистр

Программное имя

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

Соглашения по использованию:

Плоская модель памяти 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

Зарезервированная область

FrBrGeorge/MyDict/speech_balloon_question.png Можно ли в RARS прочитать байт из раздела .text по нечётному адресу? (Почему :) ?)

Директивы размещения данных в памяти

В программе на языке ассемблера возникает необходимость описать содержимое сегмента памяти. Для этого код программы помечается .text, а данные — .data . При трансляции в RARS код размещается с адреса 0x400000 (если не сказано иное), а данные — с адреса 0x10010000 (опять-таки, если не сказано иное).

В секции .data помещают директивы (указания ассемблеру) по размещению данных в памяти.

Результат трансляции пословно (секция .data начинается по умолчанию с адреса 0x10010000). Обратим внимание на little endian: младший байт в слове имеет меньший адрес!

10010000: deafbeef d0feeded 000aceba 56781234 0f0e0d0c 77663344

Для того, чтобы обращаться к соответствующим ячейкам памяти, можно использовать и адреса, и метки. Метка — символическое имя, оно заменяет адрес в программе на языке ассемблера и транслируется в соответствующий адрес в машинных кодах. В подавляющем большинстве случаев ассемблер RISC-V заменяет метку на смещение относительно исполняемой инструкции, то есть регистра «program counter», pc.

Разберём, как работают псевдоинструкции li (load immediate) и la (load address):

   1 .data
   2 var:    .word   0xbadface
   3 
   4 .text
   5         li      t5 0x10010000
   6         la      t6 var

Несмотря на то, что метка 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

Такой подход называется позиционно-независимым кодированием. Смысл его в том, что одну и ту же программу можно загрузить в произвольный адрес памяти (а не только в конвенциональные 0x400000 - 0x10000000 - 0x10010000). При этом в регистр t5 попадёт число 0x10001000 — потому что именно число мы и хотели туда положить, а в t6 — адрес, отстоящий от данной инструкции на 0xfc0fff8 байтов, каким бы ни был её адрес.

Регистр pc не является регистром общего назначения, но для ориентации в программах на RISC-V его всегда надо иметь в виду. Получить значение pc можно, например, с помощью auipc  t0 0; больше ничего напрямую с ним делать нельзя.

Начиная со значения, указанного в директиве .data (или с адреса по умолчанию), ассемблер высчитывает адрес, который будет соответствовать метке, каждый раз прибавляя размер очередной отведённой ячейки. Обращение к ячейке памяти размером N байтов в архитектуре RISC-V возможно при условии, что адрес этой ячейки кратен N (следствие little endian). Исключение — 64-разрядные значения, которым достаточно границы слова и расширение «C», посвящённое плотной упаковке инструкций. Поэтому при размещении ассемблером ячеек разного размера происходит выравнивание: если очередная ячейка имеет размер, которому не кратен текущий предполагаемый адрес её размещения, к этому адресу дополнительно прибавляется от одного до трёх байтов, чтобы обеспечить кратность. Пример разнообразных данных в памяти с автоматическим и ручным выравниванием.

   1 .data
   2         .word   0x76543210
   3         .dword  0x1212343456567878
   4         .half   0x2468
   5         .word   0x76543210
   6         .byte   1
   7         .word   0x76543210
   8         .byte   3
   9         .half   0x2468, 0x0ac4
  10         .byte   5, 7, 9
  11         .align  2
  12         .byte   1, 3, 5
  13         .align  3
  14         .byte   6
  15         .align  1
  16         .byte   0xaa

Выравнивание можно вызвать директивно, с помощью .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, это никак не повлияет на размещение данных:

   1 .data
   2         .word   0x123456
   3         .word   1
   4 .text
   5         mv      t1 zero
   6 .data
   7         .word   0x7890a
   8         .word   2
   9 
  10 .data   0x10010040
  11         .word   0x334455
  12 .text
  13         nop

Память в области данных в результате:

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

Полученный код:

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

Базовые инструкции вида «сравнить и перейти» — это beq/bne и blt/bge с их беззнаковыми аналогами btlu/bgeu. Все остальные инструкции вида b*, включая безусловный переход b, — псевдо, потому что получаются простой перестановкой регистров-операндов или подстановкой регистра zero в нужное место. Непосредственный 12-битный операнд используется для хранения смещения относительно текущего адреса. Вычислением этого смещения из метки занимается ассемблер, оно бывает положительное (вперёд, как в примере выше) и отрицательное (назад, как в следующем). 12 битов хватает на то, чтобы сделать переход на 4 килобайта кода (4, а не 2, потому что адрес перехода всегда кратен 2 и самый младший бит просто не хранится).

Простейший цикл со счётчиком — это условный переход назад в коде:

   1         li      s2 10           # Граница счётчика
   2         li      s1 1            # Счётчик
   3 loop:   li      a7 1            # Вывод счётчика
   4         mv      a0 s1
   5         ecall
   6         addi    s1 s1 1         # Увеличение
   7         blt     s1 s2 loop      # Сравнение счётчика и границы и переход
   8         li      a7 10           # Останов
   9         ecall

Строго говоря, для этой программы не нужно вносить инструкцию li a7 1 внутрь цикла — значение a7 не меняется. Метку loop: можно былдо сдвинуть на следующую инструкцию. Однако этого не сделано намеренно: поскольку a7 — это номер внешнего вызова, всегда может случиться, что внутри цикла нам потребуется ещё один, другой (например, вывод символа «перевод строки»). Что же тогда, снова двигать метку?

FrBrGeorge/MyDict/speech_balloon_question.png что произойдёт, если сдвинуть 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

Если надо перейти дальше, чем это допустимо в b*, используйте отдельно сравнение, отдельно безусловный переход псевдоинструкцией j: она имеет тип U (с особенностями, о которых ниже), и поле непосредственной константы занимает 20 битов. Но сначала напишите 4 килобайта кода в одном из условий. А лучше — наоборот, не пишите так☺.

FrBrGeorge/MyDict/speech_balloon_question.png На сколько четырёхбайтных инструкций можно перейти командой j (число может быть отрицательным, младший бит равен нулю и не хранится)?

Цикл с постусловием — штука ненадёжная, так что идеологически верно было бы переписать пример выше в соответствии с «канонической схемой цикла»:

  1. Инициализация
  2. Проверка условия
  3. Тело
  4. Изменение

При этом придётся сделать ещё один (безусловный) переход.

   1         li      s2 10
   2         li      s1 1            # Инициализация
   3 loop:   bge     s1 s2 final     # Проверка условия
   4         li      a7 1            # Тело
   5         mv      a0 s1
   6         ecall                   # Вывод целого
   7         li      a7 11
   8         li      a0 10
   9         ecall                   # Вывод перевода строки
  10         addi    s1 s1 1         # Изменение
  11         j       loop            # Дополнительный переход
  12 final:  li      a7 10
  13         ecall

Про отсутствие регистра флагов

Операция «сравнить и перейти» в трёхадресной архитектуре RISC-V атомарна. Это значит, что нет необходимости заводить отдельный регистр флагов, и сверяться с ним относительно результатов сравнения. Атомарность сравнения-перехода также делает более эффективной микропрограммную реализацию.

Регистр флагов бывает нужен также для определения переполнения, но в спецификации справедливо замечают, что переполнение в большинстве случаев проверяется одной операцией «сравнить-перейти» (зачем флаги тогда?), и только в случае, когда мы не знаем знаков обоих слагаемых, проверка потребует дополнительных вычислений.

Про беззнаковые операции и особенности арифметики

Операции сложения и вычитания целых чисел в дополнительном коде работают независимо от того, считаем мы эти числа знаковыми или беззнаковыми. Побитовые операции вроде OR вообще ничего не знают о знаках. Операции умножения и деления (из расширения «M») и операции сравнения бывают как знаковыми, так и беззнаковыми, причём для умножения в «M» предусмотрен и знаково-беззнаковый вариант. Беззнаковые инструкции обозначаются дополнительным суффиксом U.

Классический алгоритм целочисленного деления вычисляет одновременно частное и остаток, но в отличие от большинства архитектур, в RISC-V нет возможности задать два регистра-приёмника (это привело бы к услжнению и замдлению логики). Поэтому делением занимается инструкуция div (беззнаковым — divu), а остатком — rem (remu).

Алгоритмы умножения и деления требуют на порядок больше тактов, чем сложение, а в программе часто требуется и частное, и остаток. Очевидная аппаратная оптимизация состоит в том, чтобы запоминать этот остаток где-то в укромном уголке (теневом регистре) и немедленно доставать оттуда, не выполняя деления ещё раз. На этот счёт есть в расширении есть конвенция: рассчитывать на такой порядок инструкций:

   1         div     приёмник1, делимое, делитель
   2         rem     приёмник2, делимое, делитель

Результат умножения двух целых может оказаться размером в два машинных слова, поэтому в RISC-V предусмотрено два типа инструкций — mul, которая возвращает младшее слово результата, и mulh, которая возвращает старшее слово — с похожими конвенциями относительно возможной оптимизации.

Особенности кодирования инструкций в секции кода

Относительно инструкций типа B и J:

На схеме взята за основу инструкция типа R, она содержит только номера регистров и дополнительные поля (funct7 и funct3, одно 7 битов, другое — 3). Что делать с этими полями, если в инструкцию необходимо вписать некоторое непосредственное значение-число?

Биты ячейки:  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

Немного о свойствах числа, которое в результате получается:

Немножко очень заморочено, но «если постоять, повтыкать, всё понятно становится»™…

Косвенная адресация и массивы

Ещё один способ обращаться к памяти — это записать полный абсолютный адрес в регистр, и воспользоваться инструкцией, которая работает с памятью, находящейся по этому адресу. Вот как раскрываются псевдоинструкции lw регистр метка и sw регистр метка вспомогательный_регистр:

   1 .data
   2         .word   0x1223344
   3 var:    .word   0xdeadbeef
   4 addr:   .word   var
   5 .text
   6         lw      t1 var
   7         lw      t2 addr
   8         lw      t3 (t2)
   9         lw      t4 4(t2)
  10         lw      t5 -4(t2)
  11         sw      t5 var t0

Что даёт:

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)

В программистских задачах часто приходится работать с записанными в память последовательностями однотипных данных — массивами.

Массив
это набор элементов данных одного размера, расположенных в оперативной памяти строго подряд.

Для работы с массивом необходима знать адрес начала массива в памяти, и длину массива — количество элементов * размер одного элемента. Обращение к элементу массива № k — это обращение к памяти по адресу адрес + размер * k. Например, 42-й элемент целочисленного массива по адресу 0x10010020 это четыре байта (одно машинное слово) по адресу 0x10010020 + 4 * 42 = 0x100100c8.

Если нужно обработать массив данных, косвенная адресация — единственный способ. В примере массив слов заполняется последовательными значениями:

   1 .data
   2 array:  .space  64
   3 arrend:
   4 .text
   5         la      t0 array
   6         la      t1 arrend
   7         li      t2 1
   8 loop:   sw      t2 (t0)
   9         addi    t2 t2 1
  10         addi    t0 t0 4
  11         bltu    t0 t1 loop

0x10010000    0x00000001 0x00000002 0x00000003 0x00000004 0x00000005 0x00000006 0x00000007 0x00000008
0x10010020    0x00000009 0x0000000a 0x0000000b 0x0000000c 0x0000000d 0x0000000e 0x0000000f 0x00000010

Ещё один пример:

   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

В некоторых архитектурах популярна двойная косвенная адресация — это когда в ячейке памяти лежит адрес ячейки памяти, в которой лежит нужное значение (как в переменной addr: в одном из примеров выше). Это удобно для организации таблиц ссылок, и, наверное, можно как-то оптимизировать, чтобы оно работало быстрее двух последовательных инструкций обычной косвенной адресации. Но поскольку с точки зрения скорости двойное обращение к памяти — очень медленная операция, идеологии RISC она не соответствует.

О проверке решений Д/З

Решения можно запускать из командной строки, как это делает EJudge (первая строка — команда, следующая — числа, третья — вывод суммы цифр, последняя — диагностика о причине останова RARS):

$ rars nc sm me DigitSum.asm
2341234
19
Program terminated by calling exit

Фактически примерно так работает проверка Д/З в EJudge:

  1. Решение запускается на эталонном вводе (тесте), а вывод помещается в файл:
    • $ rars nc sm me  решение.asm < тест.in > результат.out

  2. Если ошибка (например, ошибка компиляции) возникла ещё при запуске — результат теста отрицательный и в журнале можно посмотреть диагностику (которая не попадает в результат.out, потому что параметр me заставляет rars выводить диагностику на stderr)

  3. Полученный результат сравнивается с ожидаемым эталонным выводом. Если различия есть — результат теста отрицательный, и в журнале можно эти различия посмотреть. Формат будет примерно такой:
    • $ diff -u результат.out эталон.out

Таким образом можно проверять правильность работы решения перед посылкой на EJudge

Д/З

Ввод целого числа, напоминаю — системный вызов № 5. Не забывайте выводить переводы строки, как в примере выше.

В последних двух задачах предполагается хранить в памяти массив данных неизвествного размера. Поскольку он такой один, вы просто заводите все переменные, какие хотите (я обошёлся без переменных вообще, регистров хатило), ставите в конце секции .data метку и пишете туда сколько влезет. В плоской модели памяти доступно всё адресное пространство.

  1. Всем, кто ещё не успел, зарегистрироваться на EJudge и решить задачку про вывод двух "Hello" (системный вызов № 4 ☺)

  2. EJudge: DigitSum 'Сумма цифр'

    Ввести целое число (возможно, отрицательное) и посчитать сумму его цифр в десятичной записи; вывести как целое.

    Input:

    -12345
    Output:

    15
  3. EJudge: PlusMinus 'Плюс-минус'

    Ввести натуральное N, затем N целых чисел ai; посчитать формулу a0-a1+a2-…±aN-1 . Вывести результат.

    Input:

    4
    22
    13
    14
    15
    Output:

    8
  4. EJudge: EvenBack 'Чётные назад'

    Ввести целое N, затем N целых чисел. Вывести из этих чисел только чётные, причём в обратном порядке (стеком пользоваться запрещается :) )

    Input:

    6
    12
    -11
    3
    88
    0
    1
    Output:

    0
    88
    12
  5. EJudge: NoDups 'Без повторений'

    Ввести целое N, затем N целых чисел. Вывести эти числа, пропуская уже выведенные, если встретятся повторы.

    Input:

    8
    12
    34
    -12
    23
    12
    -12
    56
    9
    Output:

    12
    34
    -12
    23
    56
    9

LecturesCMC/ArchitectureAssembler2024/02_MemoryRegisters (последним исправлял пользователь FrBrGeorge 2024-07-12 18:48:39)