RTFM

В предыдущем подразделе мы уже концептуально рассказали, как выполняется инструкция; некоторые понятия даже слишком очевидны, чтобы их разворачивать. Но когда мы решили добавлять в TRM всевозможные эффективные инструкции, это значит, что от нудных деталей уже не уйти.

Сначала нужно узнать точное поведение инструкций; для этого читайте связанные с набором инструкций главы руководства по выживанию (официальное руководство ISA). Конкретно, какую бы ISA вы ни выбрали, в соответствующем руководстве обычно есть следующее — попробуйте RTFM и найдите, где это лежит:

  • описание конкретного поведения каждой инструкции
  • таблица кодирования opcode инструкций

В частности, из-за сложности набора инструкций x86 для тех, кто выбрал x86, мы дали простое пособие по чтению.

RISC — другой мир, параллельный CISC

Кажется ли вам формат набора инструкций x86 особенно сложным? Это как раз свойство CISC: не жалеть сложного формата инструкций, жертвовать стоимостью разработки железа, лишь бы одна инструкция делала больше, повышая плотность кода и уменьшая размер программы. Со временем архитекторы увидели, что сложная управляющая логика CISC мешает поднимать производительность процессора, и родился RISC. Цель RISC — простота: мало инструкций, фиксированная длина, единый формат — в том же духе, что и правило KISS. Здесьоткрыть в новом окне короткая статья со сравнением RISC и CISC.

Также стоит рекомендовать эту статьюоткрыть в новом окне: она рассказывает историю рождения мира RISC и его слияния с миром CISC; почувствуйте, какой вехой стало рождение RISC для развития компьютерной архитектуры.

Если вам очень повезло выбрать riscv32, сейчас нужно прочитать лишь малую часть руководства: в PA гостевая программа riscv32 состоит только из двух классов инструкций — RV32I и RV32M. Это благодаря идее дизайна набора инструкций RISC-V — модульности.

RISC-V — изящно спроектированный набор инструкций

RISC-V — очень молодой набор инструкций: первую версию в мае 2011 предложила исследовательская группа UC Berkeley, и с тех пор он облетел мир. Открытость — главный козырь RISC-V; даже ARM и MIPS были потрясены и из-за конкуренции даже грызлись друг с другом... Эта статьяоткрыть в новом окне рассказывает об идеях RISC-V и части истории его роста.

Конечно, к PA в учебной области это не очень относится. Главное:

  • RISC-V правда очень простой.
  • При простоте там очень много продуманных соображений о работе программ. Если читать руководство RISC-V, увидите массу обсуждений дизайна и компромиссов. Кроме того, профессор David Patterson (получил премию Тьюринга 2018 за продвижение RISC, можно сказать, мэтр в области архитектуры) написал для продвижения RISC-V вступительную книгу The RISC-V Readerоткрыть в новом окне: с системной точки зрения изложены многие принципы дизайна RISC-V и сравнение с существующими наборами инструкций — очень стоит прочитать. Трое аспирантов Института вычислительной техники Китайской академии наук сделали китайский переводоткрыть в новом окне (один из них, можно сказать, ваш прямой старший по кафедре), но книга не следует за последним официальным руководством RISC-V, и в ней немало описок (welcome issue в соответствующем github repoоткрыть в новом окне), поэтому всё же советуем читать официальное руководство RISC-V.

RTFSC(2)

Поняв в предыдущем подразделе, как YEMU выполняет инструкции, вы получите базовое представление о каркасе эмулятора. NEMU должен симулировать настоящую ISA, поэтому код гораздо сложнее YEMU, но заложенный принцип тот же. Ниже расскажем, как каркасный код NEMU реализует выполнение инструкций.

В ходе RTFSC вы встретите большую часть API, которыми абстрагируют различия ISA, поэтому советуем сначала прочитать эту страницу, чтобы базово понять функции этих API, и потом сверяться, когда встретите их в коде.

В PA1 мы упоминали:

cpu_exec() в свою очередь вызывает execute(), последний симулирует работу CPU: непрерывно выполнять инструкции. Конкретно код в цикле for постоянно вызывает функцию exec_once(); её функция — то, что мы описали в предыдущем подразделе: дать CPU выполнить одну инструкцию, на которую указывает текущий PC, затем обновить PC.

Конкретно exec_once() принимает указатель s на структуру типа Decode; в ней хранится информация, нужная при выполнении одной инструкции, включая PC инструкции, PC следующей инструкции и т.д. Есть и информация, связанная с ISA: NEMU абстрагирует её типом структуры ISADecodeInfo, конкретное определение в nemu/src/isa/$ISA/include/isa-def.h. exec_once() сначала сохранит текущий PC в члены pc и snpc у s, где s->pc — PC текущей инструкции, а s->snpc — PC следующей; snpc значит "static next PC".

Затем код вызовет функцию isa_exec_once() (определена в nemu/src/isa/$ISA/inst.c), потому что конкретный процесс выполнения инструкции связан с ISA; здесь детали isa_exec_once() пока не разбираем. Можно сказать: по ходу выборки она меняет значение s->snpc, так что после возврата из isa_exec_once() s->snpc как раз равен PC следующей инструкции. Дальше код обновит PC через s->dnpc; dnpc значит "dynamic next PC". Различие snpc и dnpc объясним ниже.

Игнорируя оставшийся связанный с trace код в exec_once(), возвращаемся в execute(). Код прибавит 1 к счётчику гостевых инструкций, затем выполнит некоторые операции, связанные с trace и difftest (пока игнорируем), затем проверит, равно ли состояние NEMU NEMU_RUNNING: если да — продолжит следующую инструкцию, иначе выйдет из цикла выполнения инструкций.

На самом деле функция exec_once() покрывает все этапы цикла инструкции: выборка, декодирование, выполнение, обновление PC. Дальше посмотрим, как NEMU реализует каждый этап.

Выборка инструкции, IF

Первое, что делает isa_exec_once(), — выборка инструкции. В NEMU за выборку отвечает функция inst_fetch() (определена в nemu/include/cpu/ifetch.h). inst_fetch() в итоге по параметру len вызовет vaddr_ifetch() (определена в nemu/src/memory/vaddr.c), а сейчас vaddr_ifetch() через paddr_read() обращается к содержимому физической памяти. Поэтому сущность выборки инструкции — просто одно обращение к памяти.

isa_exec_once() при вызове inst_fetch() передаёт адрес s->snpc, поэтому inst_fetch() в конце ещё обновит s->snpc по len, и s->snpc будет указывать на следующую инструкцию.

Декодирование инструкции, ID

Дальше код войдёт в функцию decode_exec(), сначала там операции, связанные с декодированием. Цель декодирования — получить операцию и объект операции инструкции; это в основном решается просмотром opcode инструкции. У разных ISA opcode стоит в разных местах инструкции; достаточно по формату кодирования распознать соответствующий opcode из выбранной инструкции.

По сравнению с YEMU NEMU использует декодирование более высокого уровня абстракции: сопоставление с образцом (pattern matching). Через строку шаблона NEMU может задать opcode в инструкции; например, в riscv32 есть такой шаблон:

INSTPAT_START();
INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc, U, R(rd) = s->pc + imm);
// ...
INSTPAT_END();

Где INSTPAT (instruction pattern) — макрос (определён в nemu/include/cpu/decode.h), им задают правило сопоставления. Формат такой:

INSTPAT(строка шаблона, имя инструкции, тип инструкции, операция выполнения инструкции);

В строке шаблона разрешены только 4 вида символов:

  • 0 значит, что соответствующий бит может совпасть только с 0
  • 1 значит, что соответствующий бит может совпасть только с 1
  • ? значит, что соответствующий бит может совпасть с 0 или 1
  • пробел — разделитель, только чтобы повысить читаемость строки шаблона, в сопоставлении не участвует

имя инструкции в коде используется только как комментарий, в макроподстановку не входит; тип инструкции нужен для последующего декодирования; а операция выполнения инструкции — код C, симулирующий настоящее поведение выполнения инструкции.

Кроме того, в nemu/include/cpu/decode.h определены макросы INSTPAT_START и INSTPAT_END. INSTPAT ещё использует макросы INSTPAT_INST и INSTPAT_MATCH, они определены в nemu/src/isa/$ISA/inst.c. После макроподстановки кода выше и простой правки получится:

{ const void ** __instpat_end = &&__instpat_end_;
do {
  uint64_t key, mask, shift;
  pattern_decode("??????? ????? ????? ??? ????? 00101 11", 38, &key, &mask, &shift);
  if ((((uint64_t)s->isa.inst.val >> shift) & mask) == key) {
    {
      decode_operand(s, &rd, &src1, &src2, &imm, TYPE_U);
      R(rd) = s->pc + imm;
    }
    goto *(__instpat_end);
  }
} while (0);
// ...
__instpat_end_: ; }

&&__instpat_end_ в коде выше использует расширение GCC адреса метокоткрыть в новом окне; оператор goto перейдёт на последнюю метку __instpat_end_. Кроме того, функция pattern_decode() определена в nemu/include/cpu/decode.h и переводит строку шаблона в три целочисленные переменные.

Функция pattern_decode() извлекает 0 и 1 из строки шаблона в целочисленную переменную key, mask — маска key, а shift — сколько бит opcode отстоит от младшего бита, чтобы помочь компилятору оптимизировать. Конкретно в примере выше:

key   = 0x17;
mask  = 0x7f;
shift = 0;

Рассмотрим следующую инструкцию из встроенной гостевой программы, представленной в PA1.

0x00000297   auipc t0,0

Когда NEMU выбирает инструкцию, она записывается в s->isa.inst.val; тогда инструкция удовлетворяет if после макроподстановки выше, то есть совпала кодировка инструкции auipc, и поэтому будет дальнейшее декодирование.

Пока мы знаем только конкретную операцию инструкции (например, auipc складывает текущее значение PC с непосредственным числом и пишет в регистр), но ещё не знаем объект операции (какое непосредственное число, в какой регистр писать). Чтобы это решить, коду нужно дальнейшее декодирование — через вызов decode_operand(). Эта функция декодирует операнды по переданному типу инструкции type; результат запишется в параметры rd, src1, src2 и imm: номер регистра операнда назначения, два исходных операнда и непосредственное число.

Мы видим, что операнды вроде регистров и непосредственных чисел — очень частые типы. Чтобы дальше развязать декодирование операндов и декодирование инструкций, декодирование этих операндов абстрагировали и обернули:

  • каркасный код определяет два вспомогательных макроса src1R() и src2R(), чтобы записать результат чтения регистра в соответствующую переменную операнда
  • каркасный код также определяет вспомогательные макросы вроде immI, чтобы извлечь непосредственное число из инструкции

С этими вспомогательными макросами decode_operand() писать удобно; например, декодирование инструкций I-типа в RISC-V можно сделать так:

case TYPE_I: src1R(); immI(); break;

Ещё несколько замечаний:

  • в decode_operand используются макросы BITS и SEXT, оба определены в nemu/include/macro.h, для извлечения бит и знакового расширения соответственно
  • decode_operand сначала единообразно декодирует операнд назначения как регистровый, то есть вызывает *rd = BITS(i, 11, 7); разные типы инструкций могут использовать rd по ситуации
  • в конце процесса сопоставления есть правило inv: «если ни одно предыдущее правило не совпало успешно, считать инструкцию недопустимой»

Инструкции переменной длины у x86

Из-за переменной длины инструкций CISC длину и форму инструкции x86 нужно определять, одновременно выбирая и декодируя, не как в RISC, где выборку и декодирование можно чётко разделить; поэтому в процессе декодирования x86 вы увидите вызовы inst_fetch().

История за непосредственными числами

Каркасный код выбирает инструкции через inst_fetch(); не смотрите, что это одна строка: за ней спрятаны осторожные соображения про порядок байт (endianness)открыть в новом окне. У большинства студентов хост — little-endian x86; когда на языке высокого уровня или ассемблере вы пишете 32-битную константу 0x1234, в порождённом двоичном коде последовательность байт такая (пусть начальный адрес константы в памяти — x):

x   x+1  x+2  x+3
+----+----+----+----+
| 34 | 12 | 00 | 00 |
+----+----+----+----+

А большинство PC — little-endian (верим, никто не будет делать PA на мейнфрейме IBM); когда работает NEMU,

imm = inst_fetch(pc, 4);

эта строка как есть прочитает из памяти последовательность байт 34 12 00 00 в переменную imm, CPU хоста разберёт её как little-endian и получит 0x1234 — как мы и ожидали.

Процессоры серии Motorola 68k — big-endian. Теперь вопрос, рассмотрите два случая:

  • предположим, NEMU нужно запустить на машине Motorola 68k (скомпилировать исходники NEMU в машинный код Motorola 68k)
  • предположим, Motorola 68k нужно добавить в NEMU как новую ISA

В этих двух случаях на какие проблемы обратить внимание? Почему они возникают? Как их решать?

На самом деле не только доступ к непосредственным числам: любой доступ к памяти длиннее 1 байта требует похожих соображений. Здесь мы вопрос выкладываем разом и больше отдельно не обсуждаем.

История за непосредственными числами (2)

Длина инструкций mips32 и riscv32 всего 32 бита, поэтому они не могут, как x86, напрямую закодировать 32-битную константу из кода C в одну инструкцию. Подумайте, как mips32 и riscv32 должны решать эту проблему?

Макросы закружили голову, что делать?

Чтобы понять семантику макроса, вы можете попробовать раскрыть его вручную, но столкнётесь с трудностями:

  • чем больше вложенность макросов, тем труднее понять
  • некоторые макросы склейки (##) ломают переход к определению в редакторе

На самом деле для макроподстановки не нужно делать это вручную: наверняка есть инструмент. Достаточно попросить GCC вывести результат препроцессора — и увидите результат макроподстановки. С ним быстро поймёте семантику после раскрытия, а затем обратно — как макрос раскрывался шаг за шагом.

Конечно, удобнее всего, чтобы GCC при компиляции NEMU заодно вывел результат препроцессора; если устройство Makefile вам хоть немного знакомо, это вас не остановит.

Выполнение, EX

После этапа декодирования код выполнит операцию выполнения инструкции, заданную в правиле сопоставления: она использует результат декодирования и кодом C симулирует настоящее поведение инструкции. Например, для auipc: на этапе декодирования непосредственное число U-типа уже записано в операнд imm, достаточно через R(rd) = s->pc + imm сложить его с текущим PC и записать в регистр назначения — выполнение инструкции на этом готово.

После этапа выполнения функция decode_exec() вернёт 0 и по цепочке вернётся в exec_once(). Сейчас этот возвращаемый код не используется, его можно игнорировать.

Обновить PC

Наконец обновление PC. Оно очень простое: достаточно присвоить s->dnpc в cpu.pc. Раньше мы упоминали snpc и dnpc, сейчас объясним различие.

Статические и динамические инструкции

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

100: jmp 102
101: add
102: xor

следующая статическая инструкция после jmp — add, а следующая динамическая — xor.

С понятиями статической и динамической инструкции можно объяснить различие snpc и dnpc: snpc — следующая статическая инструкция, dnpc — следующая динамическая. Для инструкций, выполняемых по порядку, snpc и dnpc совпадают; для инструкций перехода — нет, dnpc должен указывать на инструкцию — цель перехода. Очевидно, PC нужно обновлять через s->dnpc и при выполнении инструкции правильно поддерживать s->dnpc.


Выше в общих чертах описан поток выполнения одной инструкции в NEMU, но часть деталей не покрыта полностью (например, таблица декодирования групп инструкций x86); эти детали оставим вам — попробуйте понять.

Управлять проектом, а не быть управляемым проектом

Отношения с проектом проходят 4 этапа:

  1. Вами управляют: вы ничего о нём не знаете
  2. Половинчатое понимание: основные модули и функции вам уже базово ясны
  3. Свободное владение: детали всего проекта вам как свои пять пальцев
  4. Проект вам служит: вы можете как угодно добавлять в него полезные, на ваш взгляд, возможности

В PA до второго этапа в основном доходят чтением хендаутов и кода, до третьего — самостоятельным выполнением эксперимента и самостоятельной отладкой. До четвёртого — ваша инициатива: где в коде ещё недостаточно хорошо? Что считать достаточно хорошим? Что сделать, чтобы к этому прийти?

Когда после выпуска попадёте в индустрию или академию, увидите: настоящие проекты такие же:

  1. Только взялись за новый проект, не знаете, с чего начать
  2. RTFM, RTFSC, примерно поняли структуру проекта и базовый рабочий процесс
  3. Запустили проект — непредвиденное поведение (ошибка конфигурации или среды, стыковка с уже существующим проектом, или баг самого проекта), затем отладка. В ходе отладки понимание этих модулей постепенно прояснится.
  4. Как-нибудь понадобится добавить в проект новую возможность — и вы обнаружите, что вам это по силам.

Это показывает: если при каждом баге вы сразу ищете гуру, чтобы он отладил за вас, потерянных возможностей и способностей будет гораздо больше, чем вы думаете.

Структурное программирование

Только что мы представили некоторые вспомогательные функции и макросы декодирования: их ввели, чтобы развязать код и повысить сопровождаемость. Чем сложнее набор инструкций, тем больше общих черт между инструкциями; на примере x86:

  • у разных форм одной инструкции этап выполнения одинаков. Например add_I2E и add_E2G: на выполнении обе складывают два операнда и кладут результат в операнд назначения.
  • у одной формы разных инструкций этап декодирования одинаков. Например add_I2E и sub_I2E: на декодировании обе распознают непосредственное число и операнд E.
  • у одной формы одной инструкции с разной шириной операнда этапы декодирования и выполнения очень похожи. Например add_I2E_b, add_I2E_w и add_I2E_l: все распознают непосредственное число и операнд E, затем кладут сумму в операнд E.

Это значит: если независимо реализовать декодирование и выполнение каждой формы и каждой ширины операнда каждой инструкции, появится масса повторяющегося кода. Когда нужно править, править придётся везде по отдельности; пропустите одно место — баг, сложность поддержки проекта резко вырастет. По сути это другая форма правила ODR (one definition rule): по возможности переиспользуйте код, чтобы менять что-то нужно было в одном месте.

Почувствуйте сами

Раньше студент реализовал isa_reg_str2val() таким кодом:

if (strcmp(s, "$0") == 0)
  return cpu.gpr[0]._64;
else if (strcmp(s, "ra") == 0)
  return cpu.gpr[1]._64;
else if (strcmp(s, "sp") == 0)
  return cpu.gpr[2]._64;
else if (strcmp(s, "gp") == 0)
  return cpu.gpr[3]._64;
else if (strcmp(s, "tp") == 0)
  return cpu.gpr[4]._64;
else if (strcmp(s, "t0") == 0)
  return cpu.gpr[5]._64;
else if (strcmp(s, "t1") == 0)
  return cpu.gpr[6]._64;
else if (strcmp(s, "s2") == 0)
  return cpu.gpr[7]._64;
else if (strcmp(s, "s0") == 0)
  return cpu.gpr[8]._64;
else if (strcmp(s, "s1") == 0)
  return cpu.gpr[9]._64;
else if (strcmp(s, "a0") == 0)
  return cpu.gpr[10]._64;
else if (strcmp(s, "a1") == 0)
  return cpu.gpr[11]._64;
else if (strcmp(s, "a2") == 0)
  return cpu.gpr[12]._64;
else if (strcmp(s, "a3") == 0)
  return cpu.gpr[13]._64;
else if (strcmp(s, "a4") == 0)
  return cpu.gpr[14]._64;
else if (strcmp(s, "a5") == 0)
  return cpu.gpr[15]._64;
else if (strcmp(s, "a6") == 0)
  return cpu.gpr[16]._64;
else if (strcmp(s, "a7") == 0)
  return cpu.gpr[17]._64;
else if (strcmp(s, "s2") == 0)
  return cpu.gpr[18]._64;
else if (strcmp(s, "s3") == 0)
  return cpu.gpr[19]._64;
else if (strcmp(s, "s4") == 0)
  return cpu.gpr[20]._64;
else if (strcmp(s, "s5") == 0)
  return cpu.gpr[21]._64;
else if (strcmp(s, "s6") == 0)
  return cpu.gpr[22]._64;
else if (strcmp(s, "s7") == 0)
  return cpu.gpr[23]._64;
else if (strcmp(s, "s8") == 0)
  return cpu.gpr[24]._64;
else if (strcmp(s, "s8") == 0)
  return cpu.gpr[25]._64;
else if (strcmp(s, "s10") == 0)
  return cpu.gpr[26]._64;
else if (strcmp(s, "t2") == 0)
  return cpu.gpr[27]._64;
else if (strcmp(s, "t3") == 0)
  return cpu.gpr[28]._64;
else if (strcmp(s, "t4") == 0)
  return cpu.gpr[29]._64;
else if (strcmp(s, "t5") == 0)
  return cpu.gpr[30]._64;
else if (strcmp(s, "t5") == 0)
  return cpu.gpr[31]._64;

::::<!-- > You should be able to imagine how this student wrote the above code. Now the question is, can you quickly check if the above code is correct?

And moreover, if you have a lot of code like this in your project, would you be willing to read it carefully? --> Вы наверняка представите, как этот студент писал код выше. Вопрос: можете ли вы быстро проверить, верен ли он?

Больше того: если в проекте много такого кода, вам ещё захочется внимательно его читать?

Copy-Paste — скверная привычка программирования

На самом деле, когда вышла первая версия PA, каркасный код как раз вёл к тому, чтобы независимо реализовывать декодирование и выполнение каждой инструкции. Реализуя инструкции, все копировали уже существующий код несколько раз и чуть меняли (например, << на >>). Когда обнаружится баг в этом коде, кошмар только начнётся. Возможно, через несколько дней, выловив ещё один баг, вы вспомните: такой баг вы уже где-то крутили. Вы знаете, что в коде есть похожие баги, но уже не различите, какой кусок когда и откуда скопирован. Тогда каркас недостаточно заботился о стиле программирования, студенты глубоко увязли в болоте отладки — это чёрная страница истории PA.

Эта скверная привычка называется Copy-Paste; после разбора выше, думаем, её страх вы уже ощутили. На самом деле команда профессора Юань Юань Чжоуоткрыть в новом окне в 2004 спроектировала инструмент CP-Miner, чтобы автоматически находить баги из-за Copy-Paste в коде ОС. Этот инструмент также дал профессору Чжоу статью на топовой системной конференции OSDIоткрыть в новом окне — первую статью топовой системной конференции в истории UIUC, где она тогда работала.

Позже профессор Чжоу обнаружила: Copy-Paste в исходниках приложений ещё распространённее, чем в ОС. Команда применила технику CP-Miner к коду приложений и основала PatternInsight. Многие IT-компании покупали продукты PatternInsight и просили кастом, в итоге компанию купила VMWare.

История показывает: привычки программистов в больших компаниях, возможно, не сильно лучше ваших — они тоже пишут трудносопровождаемый Copy-Paste. Но с другой стороны, стиль кодирования, который ценят компании, можно начинать воспитывать уже сейчас.

Хороший подход — отделить код, связанный с декодированием, выполнением и шириной операндов, то есть структурное программирование с курса программирования. В каркасном коде развязка декодирования и выполнения — правила сопоставления, заданные INSTPAT: можно отдельно писать содержимое декодирования и выполнения, затем комбинировать. Такой дизайн легко даёт несколько инструкций с одинаковым выполнением, но разным декодированием. Для x86 развязка ширины операнда с декодированием и выполнением — член width в структуре ISADecodeInfo: он записывает ширину операнда; при декодировании и выполнении по нему делают разные операции, одной и той же копией кода декодирования и выполнения обслуживая разную ширину.

RTFSC: понять процесс выполнения инструкции

В этом подразделе очень много деталей; возможно, хендаут и код придётся прочитать несколько раз, чтобы понять каждую. По отзывам старших курсов эффективный способ — разбирать детали конспектом. На самом деле вместе с GDB ещё лучше.

Чтобы вы долго не топтались на месте в понимании кода, добавим обязательный вопрос:

Опишите процесс выполнения одной инструкции в NEMU.

Кроме nemu/src/device и nemu/src/isa/$ISA/system остальной код NEMU вы уже способны понять. Поэтому не думайте, что файлы, не упомянутые в хендауте, смотреть не нужно: постарайтесь понять каждую деталь! Когда поймаете баг, эти детали станут уликами для отладки.

Запустить первую программу на C

Сказали достаточно — пора руками. Сначала клонируйте новый подпроект am-kernels (возможно, вы уже клонировали его в PA1), в нём есть тестовые программы:

cd ics2023
bash init.sh am-kernels

Первая задача в PA2 — реализовать несколько инструкций, чтобы первая простая программа на C запустилась в NEMU. Эта программа — am-kernels/tests/cpu-tests/tests/dummy.c, она ничего не делает и сразу возвращается.

Подготовить среду кросс-компиляции

Если выбранная ISA — не x86, нужны соответствующие gcc и binutils, чтобы компилировать правильно.

  • mips32
    • apt-get install g++-mips-linux-gnu binutils-mips-linux-gnu
  • riscv32(64)
    • apt-get install g++-riscv64-linux-gnu binutils-riscv64-linux-gnu

В каталоге am-kernels/tests/cpu-tests/ введите

make ARCH=$ISA-nemu ALL=dummy run

чтобы скомпилировать программу dummy и запустить NEMU для её выполнения.

Исправить ошибки компиляции riscv32

Если вы выбрали riscv32 и при компиляции dummy видите такую ошибку:

/usr/riscv64-linux-gnu/include/bits/wordsize.h:28:3: error: #error "rv32i-based targets are not supported"

нужно с правами sudo изменить следующий файл:

--- /usr/riscv64-linux-gnu/include/bits/wordsize.h
+++ /usr/riscv64-linux-gnu/include/bits/wordsize.h
@@ -25,5 +25,5 @@
 #if __riscv_xlen == 64
 # define __WORDSIZE_TIME64_COMPAT32 1
 #else
-# error "rv32i-based targets are not supported"
+# define __WORDSIZE_TIME64_COMPAT32 0
 #endif

Если ошибка такая:

/usr/riscv64-linux-gnu/include/gnu/stubs.h:8:11: fatal error: gnu/stubs-ilp32.h: No such file or directory

нужно с правами sudo изменить следующий файл:

--- /usr/riscv64-linux-gnu/include/gnu/stubs.h
+++ /usr/riscv64-linux-gnu/include/gnu/stubs.h
@@ -5,5 +5,5 @@
 #include <bits/wordsize.h>

 #if __WORDSIZE == 32 && defined __riscv_float_abi_soft
-# include <gnu/stubs-ilp32.h>
+//# include <gnu/stubs-ilp32.h>
 #endif

Как правильно чинить ошибки?

Заметьте: править файлы тулчейна напрямую — обычно быстрый и грязный фикс, который вдолгую может привести к труднонаходимым проблемам. Подумайте, какой способ починить проблему правильный. Можно начать со STFW по тексту ошибки и просмотра github-страницы кросс-компиляторного тулчейна RISC-V.

На самом деле не всякая программа может работать в NEMU; подпроект abstract-machine специально служит, чтобы компилировать программы, способные работать в NEMU; его представим в следующем подразделе.

Запустите dummy в NEMU — увидите такой вывод (на примере riscv32):

invalid opcode(PC = 0x80000000):
        13 04 00 00 17 91 00 00 ...
        00000413 00009117...
There are two cases which will trigger this unexpected exception:
1. The instruction at PC = 0x80000000 is not implemented.
2. Something is implemented incorrectly.
Find this PC(0x80000000) in the disassembling result to distinguish which case it is.

If it is the first case, see
       _                         __  __                         _
      (_)                       |  \/  |                       | |
  _ __ _ ___  ___ ________   __ | \  / | __ _ _ __  _   _  __ _| |
 | '__| / __|/ __|______\ \ / / | |\/| |/ _` | '_ \| | | |/ _` | |
 | |  | \__ \ (__        \ V /  | |  | | (_| | | | | |_| | (_| | |
 |_|  |_|___/\___|        \_/   |_|  |_|\__,_|_| |_|\__,_|\__,_|_|

for more details.

If it is the second case, remember:
* The machine is always right!
* Every line of untested code is always wrong!

Это потому что инструкцию 0x00000413 вы ещё не реализовали; поэтому нужно начать добавлять инструкции в NEMU.

Почему при выполнении нереализованной инструкции появляется сообщение выше

RTFSC: поймите, что конкретно делает NEMU, когда выполняет нереализованную инструкцию.

Какие инструкции реализовать, чтобы dummy заработал в NEMU? Ответ в результате дизассемблирования (am-kernels/tests/cpu-tests/build/dummy-$ISA-nemu.txt): достаточно реализовать те, что ещё не реализованы. Правила сопоставления в каркасе сильно упрощают реализацию гостевых инструкций в NEMU: чтобы добавить новую инструкцию, достаточно добавить правильное правило сопоставления в nemu/src/isa/$ISA/inst.c.

Тулчейн кросс-компиляции

Если выбранная ISA — не x86, смотря двоичную информацию гостевой программы (objdump, readelf и т.д.), нужно использовать соответствующую кросс-версию: mips-linux-gnu-objdump, riscv64-linux-gnu-readelf и т.д. В частности, если ISA — riscv32, можно пользоваться кросс-тулчейном с префиксом riscv64.

Ещё раз подчеркнём: функцию инструкции обязательно сверяйте RTFM, нельзя полагаться на догадки. Руководство даёт полное описание функции инструкции (что делает, как делает, какие эффекты); каждое слово читайте внимательно: неверное или неполное понимание инструкции потом принесёт огромные неприятности при отладке.

Ещё немного подсказок по x86

  • call: у call много форм, в PA понадобятся лишь некоторые; сейчас достаточно реализовать форму CALL rel32. Что до адреса перехода, в каркасном коде уже немало подсказок — считайте упражнением RTFSC.
  • push: сейчас достаточно реализовать формы PUSH r32 и PUSH imm32
  • sub: перед sub сначала нужно реализовать регистр EFLAGS. Достаточно добавить EFLAGS в структуру регистров. EFLAGS — 32-битный регистр, но в NEMU мы используем только 5 бит: CF, ZF, SF, IF, OF; остальные пока можно не реализовывать. Смысл каждого бита EFLAGS смотрите в руководстве i386. Когда EFLAGS будет, можно реализовать sub
  • xor, ret: RTFM

Запустить первую гостевую программу

Реализуйте в NEMU упомянутые выше инструкции; детали обязательно сверяйте с руководством. После успешной реализации запустите в NEMU гостевую программу dummy — увидите HIT GOOD TRAP. Если этого сообщения нет, реализация инструкций неверна; можете пользоваться простым отладчиком из PA1.

Запустить больше программ

Непротестированный код всегда неверен; чтобы тестировать NEMU, нужно больше тестовых случаев. В каталоге am-kernels/tests/cpu-tests/ мы подготовили простые тесты. В этом каталоге выполните

make ARCH=$ISA-nemu ALL=xxx run

где xxx — имя тестового случая (без суффикса .c).

Команда make run выше в итоге запустит NEMU и выполнит соответствующую гостевую программу. Если нужно GDB, чтобы отлаживать, как NEMU выполняет гостевую программу, можно выполнить:

make ARCH=$ISA-nemu ALL=xxx gdb

Реализовать больше инструкций

Нужно реализовать больше инструкций, чтобы пройти указанные тесты.

Порядок реализации инструкций выбирайте свободно. После тренировки PA1 вы уже не должны реализовывать все инструкции и только потом тестировать. Привыкайте тестировать как можно раньше; общий принцип: «реализовать как можно меньше инструкций, чтобы провести следующий тест». Не нужно реализовывать все формы всех инструкций — достаточно пройти эти тесты. Если в будущем снова встретите нереализованную инструкцию, реализуете тогда.

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

Поскольку для string и hello-str нужно ещё дополнительное содержимое (в следующих подразделах), пока тестируйте остальными случаями.

Не думайте, что код нужно писать только у TODO

Раньше студенты часто считали: «мне нужно писать код только там, где есть TODO; если у возможности в каркасе нет соответствующего TODO, это за пределами обязательного, реализовывать не нужно».

В PA такая мысль ошибочна. Если RTFSC, увидите: TODO() — просто макрос, после раскрытия вызывает panic(). Поэтому TODO в каркасе скорее затем, чтобы при работе NEMU дать более читаемый результат (например, xxx не реализовано), а не чтобы NEMU поймал пугающую вас ошибку сегментации.

Когда после выпуска попадёте в компанию/группу, хендаута, который подробно скажет, что делать, уже не будет; когда-нибудь задачу придётся выполнить без хендаута. Хотим, чтобы вы уже сейчас отказались от иллюзии «хендаут и каркас ясно скажут мне каждую деталь того, что я должен сделать», и взяли на себя ответственность «понять, как работает вся система», а не стать слугой каркасного кода. Поэтому когда сомневаетесь, нужно ли реализовывать возможность, судите не по тому, есть ли в каркасе TODO, а по пониманию кода и текущей потребности.

Замечания, связанные с инструкциями x86

  • Дополнение к поведению push imm8. Инструкция push imm8 должна знаково расширять непосредственное число; в руководстве i386 это не сказано явно. В руководстве IA-32открыть в новом окне про push есть такое:

If the source operand is an immediate and its size is less than the operand size, a sign-extended value is pushed on the stack.

  • Строковые инструкции. Например movsb и т.д.: им нужны сегментные регистры DS, ES и флаг DF в EFLAGS. В PA эти регистры реализовывать не нужно; при RTFM считайте, что их значения всегда 0, чтобы понять семантику инструкции.
  • Инструкция endbr32. Подробности здесь

Слот задержки ветвления у mips32

Чтобы поднять производительность процессора, mips использует технику слота задержки ветвления (branch delay slot)открыть в новом окне. С ней порядок выполнения программы немного меняется: статическую инструкцию сразу после инструкции перехода (условного и безусловного) называют слотом задержки; тогда программа, выполнив переход, сначала выполнит инструкцию в слоте задержки, затем — инструкцию в цели перехода. Например

100: beq 200
101: add
102: xor
...
200: sub
201: j   102
202: slt

если результат beq — переход, динамический поток инструкций: 100 -> 101 -> 200; если результат beq — не переходить, поток: 100 -> 101 -> 102; для инструкции j поток: 201 -> 202 -> 102.

Наверняка вы недоумеваете, как эта противоречащая интуиции техника поднимает производительность процессора. Для этого нужны знания микроархитектуры, например конвейер процессораоткрыть в новом окне, но это уже за рамками ICS, подробно не объясняем; если интересно — STFW.

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

Если бы вы были разработчиком компилятора, как бы искали подходящую инструкцию в слот задержки?

Слот задержки ветвления в mips32-NEMU

Раз у mips такое соглашение, и компилятор ему следует, семантику программ, порождённых компилятором mips32, тоже нужно толковать по этому соглашению. Значит, mips32-NEMU как симулируемый CPU mips32 тоже должен реализовать слот задержки ветвления, чтобы корректно поддерживать программы mips32.

На самом деле gcc для порождения программ mips32 даёт опцию -fno-delayed-branch, чтобы в слотах задержки программ mips32 стояли nop. Тогда после инструкции перехода можно сразу выполнять инструкцию цели перехода: в слоте задержки nop, даже не выполняя его, на корректность программы это не повлияет.

Мы уже добавили эту опцию в команду компиляции программ mips32, поэтому при реализации mips32-NEMU можно упростить: слот задержки ветвления реализовывать не нужно.

Для PA убрать слот задержки есть и другие плюсы; обсудим в дальнейшем.

Сопоставление имён инструкций

Небольшое число инструкций в дизассемблере формата AT&T не совпадает с именами в руководстве, например cltd у x86, а у mips32 и riscv32 немало псевдоинструкций (pseudo instruction). Кроме STFW, есть ли способ найти соответствующие инструкции в руководстве? Если есть, почему этот способ работает?

Подсказка

На этом заканчивается этап 1 PA2.