RTFSC

На самом деле реализация TRM настолько проста, что каркасный код NEMU уже её реализует. Давайте посмотрим, как выглядят цифровые схемы, из которых состоит TRM, в коде C NEMU. Для удобства описания компьютер, симулируемый в NEMU, будем называть «гостевым (guest) компьютером», а программу, работающую в NEMU, — «гостевой программой».

Каркасный код, первый взгляд

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

ics2023
├── abstract-machine   # абстрактная машина
├── am-kernels         # приложения, разработанные на основе абстрактной машины
├── fceux-am           # эмулятор NES
├── init.sh            # скрипт инициализации
├── Makefile           # для упаковки и сдачи проекта
├── nemu               # NEMU
└── README.md

Пока нам нужно знать только содержимое подпроекта NEMU, остальные подпроекты будут представлены в будущем. NEMU состоит из 4 модулей: monitor, CPU, memory, device. Функции CPU и memory мы кратко представили в предыдущем подразделе, устройства будут в PA2, так что сейчас о них заботиться не нужно.

Модуль monitor введён, чтобы удобно следить за работой гостевых компьютеров. Помимо взаимодействия с GNU/Linux (например, чтения гостевых программ), он также работает как отладчик, давая удобный способ отлаживать NEMU. Концептуально monitor не необходимая часть компьютера, но для NEMU это необходимая инфраструктура. Без модуля monitor отладка NEMU была бы очень трудной.

Исходные файлы в каталоге nemu/ организованы так (перечислены не все файлы).

nemu
├── configs                    # некоторые заранее предоставленные конфигурационные файлы
├── include                    # заголовочные файлы для глобального использования
│   ├── common.h               # общие заголовки
│   ├── config                 # заголовок, порождаемый системой конфигурации, для поддержания временных меток обновлений опций конфигурации
│   ├── cpu
│   │   ├── cpu.h
│   │   ├── decode.h           # связанное с декодированием
│   │   ├── difftest.h
│   │   └── ifetch.h           # связанное с выборкой
│   ├── debug.h                # некоторые макросы для отладки
│   ├── device                 # связанное с устройствами
│   ├── difftest-def.h
│   ├── generated
│   │   └── autoconf.h         # заголовок, порождаемый системой конфигурации, для определения макросов по информации конфигурации
│   ├── isa.h                  # связанное с ISA
│   ├── macro.h                # некоторые удобные определения макросов
│   ├── memory                 # связанное с доступом к памяти
│   └── utils.h
├── Kconfig                    # правила управления информацией конфигурации
├── Makefile                   # скрипты сборки Makefile
├── README.md
├── resource                   # некоторые вспомогательные ресурсы
├── scripts                    # скрипты сборки Makefile
│   ├── build.mk
│   ├── config.mk
│   ├── git.mk                 # связанное с контролем версий git
│   └── native.mk
├── src                        # исходный файл
│   ├── cpu
│   │   └── cpu-exec.c         # главный цикл выполнения инструкций
│   ├── device                 # связанное с устройствами
│   ├── engine
│   │   └── interpreter        # реализация интерпретатора
│   ├── filelist.mk
│   ├── isa                    # реализации, связанные с ISA
│   │   ├── mips32
│   │   ├── riscv32
│   │   ├── riscv64
│   │   └── x86
│   ├── memory                 # реализация доступа к памяти
│   ├── monitor
│   │   ├── monitor.c
│   │   └── sdb                # простой отладчик
│   │       ├── expr.c         # реализация вычисления выражений
│   │       ├── sdb.c          # обработка команд простого отладчика
│   │       └── watchpoint.c   # реализация точек наблюдения
│   ├── nemu-main.c            # вы знаете...
│   └── utils                  # некоторые общие возможности
│       ├── log.c              # связанное с файлом журнала
│       ├── rand.c
│       ├── state.c
│       └── timer.c
└── tools                      # инструменты
    ├── fixdep                 # исправление зависимостей, используется вместе с системой конфигурации
    ├── gen-expr
    ├── kconfig                # система конфигурации
    ├── kvm-diff
    ├── qemu-diff
    └── spike-diff

Чтобы поддерживать разные ISA, каркасный код делит NEMU на две части: базовый каркас, независимый от ISA, и конкретную реализацию, связанную с ISA. NEMU кладёт код, связанный с ISA, в каталог nemu/src/isa/ и даёт объявления API, связанных с ISA, через nemu/include/isa.h. Так код вне nemu/src/isa/ показывает базовый каркас NEMU. У этого два преимущества.

  • Помогает увидеть, что общего у разных ISA: какой бы ни была ISA гостевого компьютера, у всех один и тот же базовый каркас.
  • Отражает идею абстракции: каркасный код абстрагирует различия между ISA в API, которые вызывает базовый каркас, так что вам не нужно заботиться о конкретике ISA. Если в будущем вы планируете выбрать другую ISA для второго прохода PA, вы явно оцените пользу абстракции: код базового каркаса вообще не нужно менять!

Эта страница упорядочивает указанные API для будущих справок, и сейчас вам не нужно полностью понимать, как они работают. «Абстракция» — очень важное понятие в компьютерных системах, и если сейчас вы не понимаете, что это значит, не беспокойтесь: вы будете снова и снова встречаться с этим в остальном PA.

Когда у вас будет общее понимание дерева репозитория выше, вы готовы начать читать код. С чего начать — очевидно.

Нужно больше слов?

Ну... Если вам кажется, что подсказки мало, вот ударная: вспомните с курса программирования, откуда программа начинает выполняться?

Если вам не хочется отвечать на этот вопрос, сначала успокойтесь. На самом деле это вопрос, который стоит исследовать, и вы вернётесь к нему в будущем.

Система конфигурации и сборка проекта

Прежде чем реально начать читать код, кратко представим систему конфигурации и сборку проекта в проекте NEMU.

Система конфигурации kconfig

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

Система конфигурации в NEMU находится в nemu/tools/kconfig, она происходит из kconfig проекта GNU/Linux, с небольшими упрощениями. kconfig определяет простой язык, которым разработчики могут писать «файлы описания конфигурации». В файле конфигурации разработчик может описать.

  • свойства опции конфигурации, включая тип, значение по умолчанию и т.д.
  • отношение между разными опциями конфигурации
  • иерархию опций конфигурации

В проекте NEMU имя файла описания конфигурации — Kconfig, например nemu/Kconfig. Когда вы вводите make menuconfig, происходят следующие события.

  • Проверяет, существует ли программа nemu/tools/kconfig/build/mconf, и если нет, собирает mconf.
  • Проверяет, существует ли программа nemu/tools/kconfig/build/conf, если нет — компилирует и порождает conf.
  • Запускает команду mconf nemu/Kconfig, тогда mconf разберёт содержимое nemu/Kconfig и покажет опции конфигурации в виде дерева меню, чтобы разработчик выбирал.
  • При выходе из меню mconf запишет результаты выбора разработчика в файл nemu/.config.
  • Запустит команду conf --syncconfig nemu/Kconfig, тогда conf разберёт содержимое nemu/Kconfig и nemu/.config (выбранные опции), объединяя оба, чтобы породить следующий файл.
    • Определения макросов, которые можно включить в код C (nemu/include/generated/autoconf.h), имена макросов вида CONFIG_xxx.
    • Определения переменных, которые можно включить в Makefile (nemu/include/config/auto.conf).
  • Правила зависимостей (nemu/include/config/auto.conf.cmd), связанные с «профилем конфигурации», которые можно включить в Makefile и о которых нам не нужно заботиться, чтобы читать код.
    • дерево каталогов nemu/include/config/, которое поддерживает изменения опций конфигурации по временным меткам, используется вместе с другим инструментом nemu/tools/fixdep, чтобы экономить ненужную компиляцию файлов после обновления опций конфигурации; чтобы читать код, об этом заботиться не нужно

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

  • nemu/include/generated/autoconf.h, используется при чтении кода C.
  • nemu/include/config/auto.conf, используется при чтении Makefile.

Сборка проекта и Makefile

Makefile NEMU немного сложнее, у него следующие возможности.

Связь с системой конфигурации

Связать makefile с системой конфигурации, включив nemu/include/config/auto.conf, что связывает переменные, порождённые kconfig. Поэтому поведение Makefile может измениться после обновления опций конфигурации через menuconfig.

filelist

Список файлов (filelist) определяет, какие исходные файлы будут скомпилированы. В nemu/src и его подкаталогах есть файлы с именем filelist.mk, которые поддерживают следующие четыре переменные согласно конфигурации menuconfig.

  • SRCS-y - кандидатское множество исходных файлов, участвующих в компиляции
  • SRCS-BLACKLIST-y - множество исходных файлов в чёрном списке, которые не будут участвовать в компиляции
  • DIRS-y - набор каталогов, участвующих в компиляции; все файлы этого каталога добавляются в SRCS-y
  • DIRS-BLACKLIST-y - набор каталогов, не участвующих в компиляции; все файлы этого каталога будут добавлены в SRCS-BLACKLIST-y

Makefile включит все файлы filelist.mk в проекте и на основе четырёх переменных выше отфильтрует исходные файлы, которые есть в SRCS-y, но нет в SRCS-BLACKLIST-y, как множество исходных файлов, которые в итоге будут участвовать в компиляции.

Указанные четыре переменные также можно связать с булевыми опциями в результате конфигурации menuconfig. Например DIRS-BLACKLIST-$(CONFIG_TARGET_AM) += src/monitor/sdb, когда мы выбираем связанную с TARGET_AM булеву опцию в menuconfig. kconfig в итоге порождает в nemu/include/config/auto.conf код вида CONFIG_TARGET_AM=y. Раскрытие переменных даст вам DIRS-BLACKLIST-y += src/monitor/sdb; когда мы не выбираем связанную с TARGET_AM булеву опцию в menuconfig. kconfig породит код вроде CONFIG_TARGET_AM=n, или CONFIG_TARGET_AM не определён. Тогда вы получите DIRS-BLACKLIST-n += src/monitor/sdb, или DIRS-BLACKLIST- += src/monitor/sdb, ни один из случаев не влияет на значение DIRS-BLACKLIST-y, что даёт следующий эффект:

Когда TARGET_AM отмечен в menuconfig, все файлы в каталоге nemu/src/monitor/sdb не будут компилироваться.
Компиляция и компоновка

Правила компиляции Makefile определены в nemu/scripts/build.mk:

$(OBJ_DIR)/%.o: %.c
  @echo + CC $<
  @mkdir -p $(dir $@)
  @$(CC) $(CFLAGS) -c -o $@ $<
  $(call call_fixdep, $(@:.o=.d), $@)

Смысл символов $@ и $< можно найти в RTFM. Вызов call_fixdep используется, чтобы породить более осмысленные зависимости, но пока нас в основном интересуют команды компиляции, так что call_fixdep пока можно игнорировать.

Можно начать с того, какие команды запускаются во время процесса make, а затем идти назад, чтобы понять значения переменных вроде $(CFLAGS). Для этого можно ввести make -nB, что заставит программу make собирать цель так, чтобы «выводить команды, но не выполнять их». После запуска вы увидите много такого:

gcc -O2 -MMD -Wall -Werror -I/home/user/ics2023/nemu/include
-I/home/user/ics2023/nemu/src/engine/interpreter -I/home/use
r/ics2023/nemu/src/isa/riscv32/include -O2    -D__GUEST_ISA__
=riscv32  -c -o /home/user/ics2023/nemu/build/obj-riscv32-nem
u-interpreter/src/utils/timer.o src/utils/timer.c

так что вы легко поймёте значения переменных Makefile выше:

$(CC) -> gcc
$@ -> /home/user/ics2023/nemu/build/obj-riscv32-nemu-interpreter/src/utils/timer.o
$< -> src/utils/timer.c
$(CFLAGS) -> остальное

Так вы можете восстановить, как сформировалось значение $(CFLAGS), по выводу выше и Makefile. Поскольку команды компиляции каждого файла похожи, поняв компиляцию одного исходного файла, вы можете по аналогии понять компиляцию других. Аналогично можно понять последнюю команду компоновки тем же способом.

Подготовьте первую гостевую программу

Как мы уже знаем, NEMU — программа, которая выполняет гостевую программу, но гостевой программы сначала нет на гостевом компьютере. Нужно прочитать гостевую программу в гостевой компьютер, и это ответственность monitor. Поэтому когда NEMU начинает работу, сначала вызывается функция init_monitor() (определена в nemu/src/monitor/monitor.c), чтобы сделать некоторую инициализацию, связанную с monitor.

Макросы, порождённые kconfig, и условная компиляция

Как мы упоминали выше, kconfig определит некоторые макросы вида CONFIG_xxx в nemu/include/generated/autoconf.h согласно результату выбранных опций конфигурации; в коде C мы можем через условную компиляцию тестировать эти макросы, чтобы решать, компилировать ли некоторый код. Например, когда макрос CONFIG_DEVICE не определён, код, связанный с устройствами, компилировать не нужно.

Чтобы писать более компактный код, в nemu/include/macro.h мы определяем макросы специально для проверки определений макросов. Например, IFDEF(CONFIG_DEVICE, init_device()); значит, что функция init_device() будет вызвана только если определён CONFIG_DEVICE; а MUXDEF(CONFIG_TRACE, "ON", "OFF") значит, что если определён CONFIG_TRACE, результат препроцессора — "ON" ("OFF" исчезает после препроцессора), иначе результат препроцессора — "OFF".

Эти макросы удивительны, вы знаете, как они работают?

Почему всё это функции?

Прочитайте код функции init_monitor(), и вы увидите, что внутри сплошные вызовы функций. Код должен вести себя так же, если тела функций развернуть в init_monitor(). В сравнении с этим, какая польза от функций здесь?

Прольём свет на эти инициализации. parse_args(), init_rand(), init_log() и init_mem() не очень эзотеричны, просто RTFSC.

Разбор аргументов

parse_args() вызывает функцию, с которой вы, возможно, не знакомы, getopt_long(), которой каркасный код разбирает аргументы; детали поведения см. в man 3 getopt_long.

Обработка аргументов

Другой вопрос: откуда берутся аргументы?

Далее monitor вызывает функцию init_isa() (определена в nemu/src/isa/$ISA/init.c), чтобы сделать некоторую инициализацию, связанную с ISA.

Первая работа — прочитать встроенную гостевую программу в память. Чтобы понять эту задачу, нужно прояснить три вопроса.

  1. Что такое гостевая программа? Мы знаем, что программы состоят из инструкций, а инструкции различаются от ISA к ISA (представьте «привет» на другом языке), поэтому сама программа определённо связана с ISA. Поэтому встроенную гостевую программу мы кладём в nemu/src/isa/$ISA/init.c. Поведение встроенной гостевой программы очень простое: в ней лишь несколько инструкций, и она даже не делает ничего осмысленного.

  2. Что такое память? Память можно мыслить как непрерывный кусок пространства хранения, а поскольку память адресуется по байтам (то есть в одной позиции памяти лежит один байт данных), в C естественно использовать массив типа uint8_t, чтобы моделировать память. NEMU по умолчанию даёт гостевому компьютеру 128MB физической памяти (см. pmem, определённый в nemu/src/memory/paddr.c)

  3. В какую позицию памяти нужно прочитать гостевую программу? Чтобы CPU гостевого компьютера мог выполнить гостевую программу, нужен способ дать CPU гостевого компьютера знать, где гостевая программа. Мы делаем это самым простым способом: по соглашению. Конкретно, мы заставляем monitor читать гостевую программу напрямую в фиксированную позицию памяти RESET_VECTOR. Значение RESET_VECTOR определено в nemu/include/memory/paddr.h.

BIOS и загрузка компьютера

Мы знаем, что память — вид RAM, вид энергозависимой среды хранения, а это значит, что когда компьютер только что запустился, данные в памяти бессмысленны; тогда как BIOS закреплён в ROM/Flash, это энергонезависимые среды хранения, и содержимое BIOS не потеряется при отключении питания.

Поэтому в реальной компьютерной системе после запуска компьютер сначала отдаёт управление BIOS, BIOS выполняет серию инициализации, затем читает осмысленную программу с диска в память для выполнения. Симуляция этого процесса требует много деталей за рамками курса; в PA мы упростили это, приняв соглашение, что CPU начинает выполнение напрямую с оговорённой позиции памяти.

Первый взгляд на последовательность загрузки операционной системы

Когда вы пользуетесь Windows, процесс загрузки обычно сопровождается загрузочной анимацией, а потом как-то вы оказываетесь на экране входа — этого явно мало, чтобы удовлетворить любопытство CS-ера. На самом деле в GNU/Linux легко узнать, что операционная система делает за кулисами. Введя sudo dmesg, вы можете вывести журнал загрузки операционной системы и одним взглядом увидеть, что она делает.

Однако текущих знаний может не хватить, чтобы понять, что происходит. Но пусть это вас не обескураживает: позже в PA вы будете запускать маленькую операционную систему Nanos-lite на NEMU. Хотя Nanos-lite по сравнению с GNU/Linux — капля в море, вы всё равно полностью поймёте некоторые ключевые шаги последовательности загрузки ОС, и дверь в операционные системы для вас откроется.

Вторая задача init_isa() — инициализировать регистры, это делается функцией restart(). Регистры — сильно структурированная часть CPU, и в C естественно использовать соответствующие структуры, чтобы описать структуру регистров CPU. Структура регистров различается от ISA к ISA, поэтому мы определяем структуру регистров CPU_state в nemu/src/isa/$ISA/include/isa-def.h и определяем глобальную переменную cpu в nemu/src/cpu/cpu-exec.c. Важная часть инициализации регистров — задать начальное значение cpu.pc, которое нужно установить в позицию памяти, куда мы только что загрузили гостевую программу, чтобы CPU мог начать выполнять гостевую программу с оговорённой позиции памяти. Для mips32 и riscv32 их регистр 0 всегда хранит 0, поэтому его тоже нужно инициализировать.

Начальный адрес физической памяти

Физическая память x86 адресуется с 0, но для некоторых ISA это не так: например, физический адрес mips32 и riscv32 начинается с 0x80000000. Поэтому для mips32 и riscv32 их CONFIG_MBASE будет определён как 0x80000000. Когда CPU в будущем обращается к памяти, мы отобразим адрес памяти, к которому CPU обратится, на соответствующее смещение в pmem; это реализовано функцией guest_to_host() в nemu/src/memory/paddr.c. Например, если CPU mips32 намерен обратиться к адресу памяти 0x80000000, мы заставим его в итоге обратиться к pmem[0], чтобы он мог корректно взять первую инструкцию гостевой программы. У этого механизма особое имя — отображение адресов, и мы снова встретим его в последующих PA.

Для x86 реализацию структуры регистров мы берём как домашнее задание. Чтобы проверить, что ваша реализация верна, в init_isa() мы также вызываем функцию reg_test() (определена в nemu/src/isa/x86/reg.c). Подробности описаны в обязательных вопросах ниже.

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

pmem:

CONFIG_MBASE      RESET_VECTOR
      |                 |
      v                 v
      -----------------------------------------------
      |                 |                  |
      |                 |    guest prog    |
      |                 |                  |
      -----------------------------------------------
                        ^
                        |
                       pc

NEMU возвращается в функцию init_monitor() и далее вызывает функцию load_img() (определена в nemu/src/monitor/monitor.c). Эта функция читает осмысленную гостевую программу из образа дискаоткрыть в новом окне в память, перекрывая только что бывшую там встроенную гостевую программу. Этот файл образа — необязательный параметр запуска NEMU и задаётся в команде запуска NEMU. Если при запуске NEMU этот параметр не дан, NEMU запустит встроенную гостевую программу.

Остальную инициализацию monitor опишем в последующих лабораторных, но пока вам не нужно заботиться об их деталях; наконец monitor вызовет функцию welcome(), чтобы вывести приветствие. Теперь вы можете скомпилировать и запустить NEMU в каталоге nemu/:

make run

Реализовать структуры регистров для x86

Если вы выбрали x86, каркасный код неверно реализует структуру x86_CPU_state, которая используется для эмуляции регистров x86; сейчас вам нужно её реализовать (структура определена в nemu/src/isa/x86/include/isa-def.h). Функция reg_test(), вызываемая в init_isa(), порождает случайные данные, чтобы тестировать реализацию структуры регистров. Если реализация неверна, сработает assertion fail. Когда реализация верна, NEMU не вызовет assertion fail, а выведет приветствие, упомянутое выше. Если вы выбрали ISA, отличную от x86, можете проигнорировать этот вопрос.

Структура регистров x86 такова:

 31                23                15                7               0
+-----------------+-----------------+-----------------+-----------------+
|                                  EAX       AH       AX      AL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  EDX       DH       DX      DL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  ECX       CH       CX      CL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  EBX       BH       BX      BL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  EBP                BP                |
|-----------------+-----------------+-----------------+-----------------|
|                                  ESI                SI                |
|-----------------+-----------------+-----------------+-----------------|
|                                  EDI                DI                |
|-----------------+-----------------+-----------------+-----------------|
|                                  ESP                SP                |
+-----------------+-----------------+-----------------+-----------------+

где

  • EAX, EDX, ECX, EBX, EBP, ESI, EDI, ESP — 32-битные регистры.
  • AX, DX, CX, BX, BP, SI, DI, SP — 16-битные регистры.
  • AL, DL, CL, BL, AH, DH, CH, BH — 8-битные регистры.

Но физически они не независимы друг от друга: например, младшие 16 бит EAX — это AX, а AX делится на AH и AL. Такая структура иногда удобна при работе с данными разной длины. Подробности о регистрах x86 см. в RTFM.

Hint: Используйте анонимные union.

Что такое анонимный union?

Для вас нормально иметь этот вопрос, но дальше вы должны осознать, что пора STFW.

Как reg\_test() тестирует вашу реализацию?

Прочитайте код reg_test() и подумайте, на основании чего написано условие assert() в коде.

::::: После запуска NEMU вы должны увидеть соответствующее приветствие и выбранную ISA. Обязательно убедитесь, что выведенная информация об ISA совпадает с выбранной вами ISA. Однако вы увидите следующее сообщение об ошибке.

[src/monitor/monitor.c:20 welcome] Exercise: Please remove me in the source code and compile NEMU again.
riscv32-nemu-interpreter: src/monitor/monitor.c:21: welcome: Assertion `0' failed.

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

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

После инициализации Monitor функция main() продолжит вызывать функцию engine_start() (определена в nemu/src/engine/interpreter/init.c). Код входит в главный цикл Simple Debugger sdb_mainloop() (определён в nemu/src/monitor/sdb/sdb.c) и выводит приглашение команд NEMU.

(nemu)

Простой отладчик — ядро monitor: в приглашении можно вводить команды, чтобы следить за состоянием работы гостевого компьютера и отлаживать его. Каркасный код уже реализует несколько простых команд, очень похожих на GDB.

После ввода c в приглашении NEMU входит в главный цикл cpu_exec() (определён в nemu/src/cpu/cpu-exec.c). cpu_exec() в свою очередь вызывает execute(), которая симулирует способ работы CPU: снова и снова выполнять инструкции. Конкретно, код в цикле for постоянно вызывает функцию exec_once(), которая делает то, что мы описали в предыдущей главе: велит CPU выполнить инструкцию, на которую указывает текущий PC, затем обновить PC.

Как долго?

В функции cmd_c() вызов cpu_exec() передаёт аргумент -1, вы знаете, что это значит?

Потенциальные угрозы (думать рекомендуется на 2-м проходе)

«Вызов cpu_exec() был передан с аргументом -1» — это неопределённое поведение? Сверьтесь с руководством C99, чтобы подтвердить свою мысль.

У разных ISA разные форматы и смыслы инструкций, поэтому код, который выполняет инструкции, естественно связан с ISA. Этот код находится в nemu/src/isa/$ISA/inst.c. Про выполнение инструкций много деталей, о которых сейчас заботиться не нужно; мы объясним их в PA2.

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

  • Достигнуто требуемое число циклов.
  • Гостевая программа выполняет инструкцию nemu_trap. Это вымышленная специальная инструкция, добавленная в NEMU, чтобы гостевая программа могла указать конец выполнения. NEMU выбрал в руководстве ISA ряд инструкций для отладки и придал им особый смысл nemu_trap. Например, в руководстве riscv32 NEMU выбрал инструкцию ebreak, чтобы она выступала как nemu_trap. Инструкция nemu_trap также принимает аргумент, указывающий конечное состояние гостевой программы, чтобы показать, успешно ли гостевая программа завершилась. После того как гостевая программа выполнит эту инструкцию, NEMU установит своё конечное состояние по этому параметру и выведет разные сообщения об окончании в зависимости от состояния, в основном включая
    • HIT GOOD TRAP - гостевая программа корректно закончила выполнение.
    • HIT BAD TRAP - гостевая программа закончила выполнение некорректно.
    • ABORT - гостевая программа неожиданно прервалась и не закончила выполнение

Когда вы увидите, что NEMU выводит что-то вроде следующего (значения pc на выводе будут различаться от ISA к ISA).

nemu: HIT GOOD TRAP at pc = 0x8000000c

Гостевая программа успешно закончила работу. NEMU печатает число выполненных инструкций и потраченное время в конце функции cpu_exec() и вычисляет частоту выполнения инструкций. Однако поскольку встроенная гостевая программа так мала и выполнение так быстро заканчивается, сейчас нельзя вычислить осмысленную частоту. В будущем частота, выводимая здесь, может грубо измерять производительность NEMU при запуске сложных программ.

После выхода из cpu_exec() NEMU вернётся в sdb_mainloop(), ожидая ввода команд пользователем. Но чтобы снова запустить программу, нужно ввести q, чтобы выйти из NEMU, затем запустить снова.

Кто отвечает за указание конца программы?

На курсе программирования вам сказали, что программа выходит, когда доходит до точки возврата из функции main(), и вы в это верили. Но задумывались ли вы, почему выполнение программы заканчивается на возврате из main()? Если кто-то скажет, что преподаватель курса программирования неправ, есть ли у вас способ доказать/опровергнуть? Если вам это интересно, ищите в интернете.

Давайте закончим то, что начали (рекомендуется подумать на 2-м проходе)

Что считается началом программы в GNU/Linux? Что считается концом программы в GNU/Linux? Какой ответ для программы, работающей в NEMU?

Связанные вопросы: зачем в NEMU нужен nemu_trap? Зачем нужен monitor?

Наконец, поговорим о некоторых примечательных местах кода.

  • Три макроса, полезных для отладки (определены в nemu/include/debug.h)
    • Log() — обновлённая версия printf(), специально чтобы выводить отладочную информацию, а также исходный файл, номер строки и функцию, где использовали Log(). Когда отладочной информации слишком много, легко найти соответствующее место в коде.
    • Assert() — обновлённая версия assert(), которая при ложном условии теста выводит некоторую информацию перед assertion fail.
    • panic() используется, чтобы вывести информацию и закончить программу, эквивалент безусловного assertion fail.

Примеры использования этих трёх макросов даны в коде; если не знаете, как ими пользоваться, RTFSC.

  • Память симулируется большим массивом pmem, определённым в nemu/src/memory/paddr.c. К симулированной памяти во время работы гостевой программы всегда обращаются через vaddr_read() и vaddr_write() (определены в nemu/src/memory/vaddr.c). vaddr, paddr представляют виртуальный и физический адреса соответственно. Эти понятия понадобятся в будущем, сейчас углубляться не нужно, но с самого начала держать интерфейсы согласованными поможет избежать ненужных неприятностей позже.

Понять каркасный код

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

RTFSC != пялиться на код

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

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

Есть ли инструмент, который поможет симулировать этот огромный конечный автомат? Тут пригодится один из инструментов, упомянутых в PA0, — GDB. В GDB мы можем заставить программу выполнять по одной инструкции за раз пошагово, что равносильно тому, чтобы конечный автомат шёл вперёд по одному шагу, так что мы можем наблюдать состояние программы в любой момент! И траектория конечного автомата — истинный порядок выполнения программы, так что вы можете понимать поведение программы, пока она работает. Это хорошо работает для кода с указателями, особенно указателями на функции, потому что по статическому коду часто не видно, на какую функцию укажет указатель во время работы.

У GDB также есть простой интерфейс TUI. Запустив GDB в достаточно высоком окне, введите layout split, чтобы переключиться в TUI, и вы сможете наблюдать поведение программы и со стороны исходного кода, и со стороны инструкций. Однако чтобы видеть исходный код, нужно добавить отладочную информацию GDB в сборку NEMU, как описано в блоке ниже. Если хотите узнать больше о TUI, STFW.

Чтобы RTFSC был эффективнее, лучше через RTFM и STFW узнать больше команд и приёмов GDB, например.

  • Step into в функции, которые вам интересны
  • Step over функций, которые вам не интересны (например, библиотечных)
  • Исполнить код до конца функции
  • Печатать значение переменной или регистра
  • Сканировать память
  • Смотреть стек вызовов
  • Ставить точки останова (breakpoints)
  • Ставить точки наблюдения (watchpoints)

Если вы раньше не пользовались GDB и потом пропустили связанное с GDB в PA0, сейчас вы пожнёте последствия своей лени.

Добавить отладочную информацию GDB для компиляции NEMU

В menuconfig уже есть соответствующие опции, нужно лишь включить.

Build Options
  [*] Enable debug information

Затем очистите результат компиляции и перекомпилируйте. Попробуйте прочитать связанный код и понять, какие изменения в опциях компиляции NEMU вызывает включение указанной опции menuconfig.

выйти красиво

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

Вот так просто

На самом деле реализация TRM уже содержится во введении выше.

  • Память — большой массив, определённый в nemu/src/memory/paddr.c.
  • PC и регистры общего назначения определены в структурах в nemu/src/isa/$ISA/include/isa-def.h
  • Сумматор определён в... Что ж, эта часть каркасного кода немного сложна, но на понимание TRM это не влияет, так что разберём в PA2!
  • Способ работы TRM отражён в cpu_exec() и exec_once().

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