Пользовательские программы и системные вызовы

Разобравшись в процессе выполнения инструкции самоловушки на примере yield test, мы теперь можем понять, как работает простейшая операционная система.

Простейшая операционная система

Лучше один раз увидеть, поэтому для начала покажем, насколько простой может быть операционная система. Операционная система, используемая в PA, называется Nanos-lite — это урезанная версия операционной системы Nanos, разработанной в Nanjing University. Это операционная система, созданная специально для PA. Работая над кодом Nanos-lite, вы поймёте, как операционная система использует механизмы, предоставляемые аппаратным обеспечением (то есть ISA и AM), для поддержки выполнения программ — а это как раз и соответствует конечной цели PA. Что касается полной версии Nanos, её истинное лицо вы увидите на курсе по операционным системам в следующем семестре.

Мы подготовили для вас каркасный код Nanos-lite, получить его можно с помощью следующих команд:

cd ics2023
bash init.sh nanos-lite

Nanos-lite содержит все модули, которые понадобятся в последующих PA, но конкретная функциональность большинства модулей ещё не реализована. Поскольку функциональность аппаратного обеспечения (NEMU) добавляется постепенно, Nanos-lite должна соответствовать этому процессу — вы будете управлять функциональностью Nanos-lite с помощью нескольких макросов в nanos-lite/include/common.h, связанных с ходом выполнения работы. По мере продвижения вы будете постепенно разбирать все модули, и объём работы, выполняемой Nanos-lite, будет расти. Поэтому при чтении кода Nanos-lite вам нужно обращать внимание только на модули, относящиеся к текущему этапу, и не увязать в коде, не относящемся к текущему этапу.

nanos-lite
├── include
│   ├── common.h
│   ├── debug.h
│   ├── fs.h
│   ├── memory.h
│   └── proc.h
├── Makefile
├── README.md
├── resources
│   └── logo.txt    # текст логотипа Project-N
└── src
    ├── device.c    # абстракция устройств
    ├── fs.c        # файловая система
    ├── irq.c       # обработка прерываний и исключений
    ├── loader.c    # загрузчик
    ├── main.c
    ├── mm.c        # управление памятью
    ├── proc.c      # планирование процессов
    ├── ramdisk.c   # драйвер ramdisk
    ├── resources.S # содержимое ramdisk и логотип Project-N
    └── syscall.c   # обработка системных вызовов

Стоит напомнить, что Nanos-lite работает поверх AM, и API AM полностью доступен внутри Nanos-lite. Хотя операционная система для нас — понятие особое, с точки зрения AM это просто обычная C-программа, вызывающая API AM, ничем не отличающаяся от Super Mario. При этом вы вновь ощутите преимущества AM: реализация Nanos-lite может быть архитектурно-независимой, а значит, независимо от того, какую ISA вы выбрали ранее, вы легко сможете запустить Nanos-lite — и даже сможете отлаживать написанную вами Nanos-lite на native, точно так же, как при разработке klib.

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

Изначально в nanos-lite/include/common.h ни один из макросов, связанных с ходом выполнения работы, не определён, и в этом состоянии функциональность Nanos-lite крайне проста. Кратко разберём текущее поведение Nanos-lite:

  1. Вывести логотип Project-N и через Log() вывести приветственное сообщение и время компиляции. Стоит пояснить, что макрос Log(), определённый в Nanos-lite, — это не тот же макрос Log(), что определён в NEMU. Nanos-lite и NEMU — два независимых проекта, их код никак не влияет друг на друга, и при чтении кода нужно это учитывать. В Nanos-lite макрос Log() выводит данные через написанную вами в klib функцию printf(), которая в итоге вызывает putch() из TRM.
  2. Вызвать init_device() для выполнения некоторых операций инициализации устройств. Сейчас init_device() просто напрямую вызывает ioe_init().
  3. Инициализировать ramdisk. Как правило, программы должны храниться на носителях постоянной памяти (например, на диске). Но эмуляция диска в NEMU — довольно сложная задача, поэтому пока пусть Nanos-lite использует в качестве диска участок оперативной памяти. У такого диска есть специальное название — ramdisk.
  4. init_fs() и init_proc() — используются соответственно для инициализации файловой системы и создания процессов; сейчас они не выполняют никаких осмысленных действий, их можно игнорировать.
  5. Вызвать panic(), завершив работу Nanos-lite.

Поскольку Nanos-lite по своей сути тоже является программой AM, мы можем компилировать/запускать её тем же способом. Достаточно выполнить в директории nanos-lite/

make ARCH=$ISA-nemu run

Кроме того, как уже упоминалось ранее, вы также можете скомпилировать Nanos-lite под native и запустить её, чтобы облегчить себе отладку.

Операционная система — это C-программа

Вам, возможно, трудно в это поверить, но .c- и .h-файлы каркасного кода неоспоримо подтверждают этот непреложный факт, и даже способ компиляции не выдаёт ничего особенного. То же самое и с GNU/Linux: если почитать её исходный код, окажется, что GNU/Linux — это просто огромная C-программа.

Так в чём же тогда особенность операционной системы по сравнению с обычной C-программой? После завершения PA3, полагаем, вы получите об этом некоторое представление.

Операционная система, предоставленная каркасным кодом, и правда пока ничего не делает! Не переживайте: вам нужно определить макрос HAS_CTE в nanos-lite/include/common.h, после чего Nanos-lite будет дополнительно выполнять следующее:

  • при инициализации вызывать функцию init_irq(), которая инициализирует CTE через функцию cte_init();
  • перед panic() вызывать yield(), запуская операцию самоловушки.

Реализуйте корректную диспетчеризацию событий для Nanos-lite

Функция обратного вызова для обработки событий в Nanos-lite по умолчанию не обрабатывает никакие события. Вам нужно распознать в ней событие самоловушки EVENT_YIELD, а затем просто вывести какую-нибудь фразу — больше пока ничего делать не нужно.

Заново запустите Nanos-lite. Если ваша реализация верна, вы увидите сообщение, выведенное после распознавания события самоловушки, и в конце по-прежнему сработает panic(), установленный в конце функции main().

После того как вы убедитесь, что Nanos-lite может корректно вызвать операцию самоловушки, пользовательская программа сможет переключить поток исполнения на точку входа, указанную операционной системой. Теперь давайте решим вторую задачу для реализации пакетной системы: как загрузить пользовательскую программу.

Загрузка первой пользовательской программы

В операционной системе за загрузку пользовательских программ отвечает модуль loader (загрузчик). Мы знаем, что программа включает код и данные, которые хранятся в исполняемом файле. Процесс загрузки заключается в размещении кода и данных из исполняемого файла в правильных местах памяти, после чего происходит переход к точке входа программы, и программа начинает выполняться. Более конкретно, чтобы реализовать функцию loader(), нам нужно решить следующие вопросы:

  • Где находится исполняемый файл?
  • В каком месте исполняемого файла находятся код и данные?
  • Сколько там кода и данных?
  • Где находятся «правильные места памяти»?

Чтобы ответить на первый вопрос, сначала нужно пояснить, откуда берутся пользовательские программы. Пользовательские программы работают поверх операционной системы, и из-за различий в среде исполнения мы не можем взять программу, скомпилированную для AM, и запустить её прямо в операционной системе. Для этого мы подготовили новый подпроект Navy-apps, специально предназначенный для сборки пользовательских программ для операционной системы. Каркасный код Navy можно получить, выполнив следующую команду:

cd ics2023
bash init.sh navy-apps

Структура подпроекта Navy организована следующим образом, подробности можно прочитать в README.md:

navy-apps
├── apps            # пользовательские программы
│   ├── am-kernels
│   ├── busybox
│   ├── fceux
│   ├── lua
│   ├── menu
│   ├── nplayer
│   ├── nslider
│   ├── nterm
│   ├── nwm
│   ├── onscripter
│   ├── oslab0
│   └── pal         # «Легенда о мече и фее»
├── fsimg           # корневая файловая система
├── libs            # библиотеки времени выполнения
│   ├── libc        # библиотека Newlib C
│   ├── libam
│   ├── libbdf
│   ├── libbmp
│   ├── libfixedptc
│   ├── libminiSDL
│   ├── libndl
│   ├── libos       # пользовательская обёртка системных вызовов
│   ├── libSDL_image
│   ├── libSDL_mixer
│   ├── libSDL_ttf
│   └── libvorbis
├── Makefile
├── README.md
├── scripts
└── tests           # некоторые тесты

Организация Makefile в Navy очень похожа на abstract-machine, так что разобраться в ней вам будет несложно. В частности, в navy-apps/libs/libc находится проект под названием Newlibоткрыть в новом окне — это C-библиотека, специально созданная для встраиваемых систем, функции которой предъявляют крайне низкие требования к среде исполнения. Это очень удобно для Nanos-lite: нам не нужно реализовывать в ней дополнительную функциональность ради совместимости с этой C-библиотекой. Точка входа пользовательской программы находится в функции _start() в файле navy-apps/libs/libos/src/crt0/start.S, где crt — сокращение от C RunTime, а 0 означает «самое начало». Функция _start() вызывает функцию call_main() из navy-apps/libs/libos/src/crt0/crt0.c, которая затем вызывает функцию main() пользовательской программы, а после возврата из main() вызывает exit(), завершая выполнение.

Код C-библиотеки «всегда» правильный

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

Одним словом, если вы решаетесь на такую уловку, сначала спокойно взвесьте все плюсы и минусы, и только потом принимайте решение.

Первая пользовательская программа, которую мы хотим запустить на Nanos-lite, — это navy-apps/tests/dummy/dummy.c. Чтобы избежать конфликта с содержимым Nanos-lite, мы договариваемся, что на данный момент пользовательская программа должна быть слинкована вблизи адреса памяти 0x3000000 (x86) или 0x83000000 (mips32 или riscv32) — Navy уже настроила соответствующую опцию (см. переменную LDFLAGS в navy-apps/scripts/$ISA.mk). Чтобы скомпилировать dummy, выполните в директории navy-apps/tests/dummy/

make ISA=$ISA

При первой компиляции в Navy будут загружены и скомпилированы такие проекты, как Newlib, с GitHub, и в процессе компиляции появится довольно много предупреждений — пока их можно игнорировать. После успешной компиляции вручную скопируйте и переименуйте navy-apps/tests/dummy/build/dummy-$ISA в nanos-lite/build/ramdisk.img, а затем выполните в директории nanos-lite/

make ARCH=$ISA-nemu

Это создаст исполняемый файл Nanos-lite, и в процессе компиляции образ ramdisk nanos-lite/build/ramdisk.img будет включён в Nanos-lite как её часть (реализовано в nanos-lite/src/resources.S). Сейчас ramdisk устроен очень просто: в нём всего один файл — пользовательская программа dummy, которую мы собираемся загрузить, и это, по сути, уже отвечает на первый из поставленных выше вопросов: исполняемый файл находится по смещению 0 в ramdisk, и, обратившись к нему, можно получить первый байт пользовательской программы.

Как распознать исполняемые файлы разных форматов?

Если вы попытаетесь запустить в GNU/Linux исполняемый файл, скопированный из Windows, система сообщит об «ошибке формата». Подумайте, как GNU/Linux узнаёт о том, что это «ошибка формата»?

ELF-файлы предоставляют два взгляда на организацию исполняемого файла: один — со стороны section (секций), ориентированный на процесс компоновки, он даёт информацию для линковки и перемещения (например, таблицу символов); другой — со стороны segment (сегментов), ориентированный на исполнение, он даёт информацию для загрузки исполняемого файла. С помощью команды readelf также можно увидеть соответствие между секциями и сегментами: один сегмент может состоять из нуля или нескольких секций, но одна секция может не входить ни в один сегмент.

Сейчас нас интересует, как загружать программу, поэтому сосредоточимся на взгляде со стороны сегментов. В ELF для управления сегментами используется таблица заголовков программы (program header table), одна запись которой описывает все атрибуты сегмента, включая тип, виртуальный адрес, флаги, выравнивание, а также смещение в файле и размер сегмента. На основе этой информации мы можем узнать, какие байты исполняемого файла нужно загружать, и заодно увидим, что загрузка исполняемого файла не означает загрузку всего его содержимого — достаточно загрузить лишь то, что относится к времени выполнения, например отладочную информацию и таблицу символов загружать не нужно. Определить, нужно ли загружать сегмент, можно, проверив, равен ли его атрибут Type значению PT_LOAD.

Избыточные атрибуты?

Просматривая информацию о ELF-файле с помощью readelf, вы увидите, что у сегмента есть два атрибута размера — FileSiz и MemSiz. Почему так? Присмотритесь внимательнее — вы обнаружите, что FileSiz обычно не превышает соответствующий MemSiz. Почему так?

Покажем на следующей схеме, как загружать сегмент на основе его атрибутов:

      +-------+---------------+-----------------------+
      |       |...............|                       |
      |       |...............|                       |  ELF file
      |       |...............|                       |
      +-------+---------------+-----------------------+
      0       ^               |              
              |<------+------>|       
              |       |       |             
              |       |                            
              |       +----------------------------+       
              |                                    |       
   Type       |   Offset    VirtAddr    PhysAddr   |FileSiz  MemSiz   Flg  Align
   LOAD       +-- 0x001000  0x03000000  0x03000000 +0x1d600  0x27240  RWE  0x1000
                               |                       |       |     
                               |   +-------------------+       |     
                               |   |                           |     
                               |   |     |           |         |       
                               |   |     |           |         |      
                               |   |     +-----------+ ---     |     
                               |   |     |00000000000|  ^      |   
                               |   | --- |00000000000|  |      |    
                               |   |  ^  |...........|  |      |  
                               |   |  |  |...........|  +------+
                               |   +--+  |...........|  |      
                               |      |  |...........|  |     
                               |      v  |...........|  v    
                               +-------> +-----------+ ---  
                                         |           |     
                                         |           |    
                                            Memory  

Вам нужно найти параметры Offset, VirtAddr, FileSiz и MemSiz для каждого сегмента, который необходимо загрузить. Относительное смещение в файле Offset указывает, что содержимое соответствующего сегмента начинается с байта Offset в ELF-файле, и его размер в файле равен FileSiz; его нужно разместить по виртуальному адресу, начинающемуся с VirtAddr, где в памяти он займёт MemSiz байт. То есть память, используемая этим сегментом, — это непрерывный интервал [VirtAddr, VirtAddr + MemSiz); затем содержимое сегмента считывается из ELF-файла в этот интервал памяти, а физический интервал, соответствующий [VirtAddr + FileSiz, VirtAddr + MemSiz), обнуляется.

Зачем обнулять?

Зачем нужно обнулять физический интервал, соответствующий [VirtAddr + FileSiz, VirtAddr + MemSiz)?

О том, откуда берутся программы, можно почитать в статье: COMPILER, ASSEMBLER, LINKER AND LOADER: A BRIEF STORYоткрыть в новом окне. Если вы хотите узнать больше информации, связанной с ELF-файлами, обратитесь к

man 5 elf

Поскольку ELF-файл находится в ramdisk, каркасный код предоставляет несколько функций, связанных с ramdisk (определены в nanos-lite/src/ramdisk.c), которые вы можете использовать для реализации функциональности loader:

// Прочитать `len` байт по смещению `offset` из ramdisk в `buf`
size_t ramdisk_read(void *buf, size_t offset, size_t len);

// Записать `len` байт из `buf` по смещению `offset` в ramdisk
size_t ramdisk_write(const void *buf, size_t offset, size_t len);

// Вернуть размер ramdisk в байтах
size_t get_ramdisk_size();

На самом деле работа loader показывает нам программу в её самом первозданном виде: битовую строку! Загрузка программы — это, по сути, размещение этой ничем не примечательной битовой строки в правильном месте, но за этим также кроется эпохальная идея «хранимой программы»: когда операционная система передаёт ей управление, компьютер интерпретирует её как инструкции и выполняет их одну за другой. Loader позволяет жизненному циклу компьютера выйти за границы отдельной программы: завершение одной программы не означает, что компьютер прекращает работу, — компьютер будет выполнять свою миссию исполнения программ на протяжении всего своего существования.

Реализуйте loader

Вам нужно реализовать функциональность loader в Nanos-lite, чтобы загрузить пользовательскую программу в правильное место памяти, а затем выполнить её. Функция loader() определена в nanos-lite/src/loader.c, параметр pcb в ней пока не используется, его можно игнорировать, а поскольку сейчас в ramdisk только один файл, параметр filename тоже можно игнорировать. На следующем этапе, после реализации файловой системы, filename пригодится.

После реализации вызовите naive_uload(NULL, NULL) в init_proc() — это вызовет реализованный вами loader для загрузки первой пользовательской программы, а затем произойдёт переход к её выполнению. Если ваша реализация верна, при выполнении программы dummy вы увидите, что в Nanos-lite сработало необработанное событие с номером 4. Это означает, что loader успешно загрузил dummy и успешно перешёл к её выполнению. О необработанных событиях мы расскажем ниже.

Проверка магического числа ELF-файла

Мы знаем, что в начале ELF-файла всегда есть особое магическое число; чтобы loader не загрузил файл не в формате ELF, мы можем проверять это магическое число в loader:

assert(*(uint32_t *)elf->e_ident == 0xBadC0de);

Вам нужно заменить указанное выше 0xBadC0de на правильное магическое число.

Не стоит недооценивать этот на первый взгляд глупый assert() — когда однажды у вас дрогнет рука и вы сами не поймёте, что натворили, а он вас на этом поймает, вы будете ему благодарны.

Проверка типа ISA в ELF-файле

Вполне возможно, что по невнимательности вы заставите native-версию Nanos-lite загрузить и запустить dummy, собранный для x86/mips32/riscv32. С точки зрения спецификации ISA такое поведение, очевидно, является UB, и на практике обычно приводит к труднообъяснимым ошибкам. Чтобы избежать этого, вы можете проверять тип ISA ELF-файла в loader. Мы можем отобрать ожидаемый тип ISA на основе некоторых макросов, определённых в AM:

#if defined(__ISA_AM_NATIVE__)
# define EXPECT_TYPE EM_X86_64
#elif defined(__ISA_X86__)
# define EXPECT_TYPE ...  // смотрите /usr/include/elf.h, чтобы узнать правильный тип
...
#else
# error Unsupported ISA
#endif

А затем сравнить это со значением определённого поля в информации ELF, и если тип ISA загружаемого ELF-файла не совпадает с ожидаемым, сообщить об ошибке. Если вы не знаете, где определены макросы в AM, — RTFSC. Если не знаете, с каким полем в ELF нужно сравнивать, — RTFM.

Компиляция Nanos-lite под native

Вы можете протестировать корректность вашей реализации Nanos-lite на native.

Поскольку native — это 64-битная среда, некоторые структуры данных ELF будут отличаться от 32-битных, но в целом процесс загрузки ELF тот же. Чтобы скрыть различия в структурах данных, в nanos-lite/src/loader.c определены макросы Elf_Ehdr и Elf_Phdr, которые вы можете использовать в реализации loader.

Кроме того, чтобы скомпилировать dummy, способный работать под Nanos-lite на AM native, вам нужно скомпилировать его в Navy командой make ISA=am_native. Дальнейшие действия аналогичны запуску на $ISA-nemu, а именно:

  1. Скомпилировать dummy с ISA=xxx.
  2. Использовать скомпилированный ELF-файл dummy в качестве ramdisk для nanos-lite.
  3. Скомпилировать и запустить nanos-lite с ARCH=xxx.

В Navy также есть ISA под названием native, который отличается от механизма ARCH с именем native в AM, — сейчас он не используется.

Среда исполнения операционной системы

После загрузки программы поговорим о её выполнении. Вспоминая PA2, мы уже знаем, что для выполнения программы требуется поддержка среды исполнения. А поскольку операционная система хочет загружать и выполнять программы, на неё естественным образом ложится обязанность предоставлять функциональность среды исполнения.

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

Так с какой же точки зрения операционной системе следует рассматривать эти среды исполнения?

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

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

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

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

Этим интерфейсом являются системные вызовы. Это миссия, которая была возложена на операционную систему с самого момента её появления: мы уже упоминали, что одной из главных задач GM-NAA I/O была загрузка новых программ, а другой её главной функцией было предоставление программам общего интерфейса ввода-вывода. Общий интерфейс, предоставляемый GM-NAA I/O, можно считать первоначальной формой системных вызовов.

Необходимость системных вызовов

Обязательны ли системные вызовы для системы пакетной обработки? Если напрямую предоставить программам в системе пакетной обработки доступ к API AM, возникнут ли проблемы?

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

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

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

Системный вызов

Так как же конкретно происходит запуск системного вызова?

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

Раз уж речь зашла о том, чтобы «сообщить операционной системе», вы, должно быть, сразу вспомнили, что это реализуется через инструкцию самоловушки. В GNU/Linux пользовательские программы запускают системные вызовы через инструкцию самоловушки, и Nanos-lite следует этому же соглашению. yield() в CTE тоже реализован через инструкцию самоловушки, и хотя они запускают разные события, процесс — от сохранения контекста до диспетчеризации события — у них очень похож. Раз мы запускаем системные вызовы через инструкцию самоловушки, то для пользовательской программы самое удобное средство описать свою потребность операционной системе — это регистры общего назначения, поскольку после выполнения инструкции самоловушки поток исполнения немедленно переключается на заранее заданную точку входа, а регистры общего назначения сохраняются как часть контекста. Обработчику системного вызова достаточно извлечь из контекста необходимую информацию, чтобы узнать, какой именно запрос на обслуживание сделала пользовательская программа.

Navy уже подготовила для пользовательских программ интерфейс системных вызовов. Функция _syscall_(), определённая в navy-apps/libs/libos/src/syscall.c, уже воплощает в себе описанный выше процесс:

intptr_t _syscall_(intptr_t type, intptr_t a0, intptr_t a1, intptr_t a2) {
  // ...
  asm volatile (SYSCALL : "=r" (ret) : "r"(_gpr1), "r"(_gpr2), "r"(_gpr3), "r"(_gpr4));
  return ret;
}

Приведённый выше код сначала по очереди помещает параметры системного вызова в регистры, а затем выполняет инструкцию самоловушки. Поскольку и регистры, и инструкция самоловушки зависят от ISA, здесь для разных ISA определены разные макросы, абстрагирующие их. CTE упаковывает эту операцию самоловушки в событие системного вызова EVENT_SYSCALL и передаёт его для дальнейшей обработки Nanos-lite.

Распознавание системных вызовов

На данный момент dummy уже напрямую запускает системный вызов через _syscall_(). Вам нужно, чтобы Nanos-lite распознавала событие системного вызова EVENT_SYSCALL.

Процессор обычно предоставляет только одну инструкцию самоловушки, и в этом случае EVENT_SYSCALL и EVENT_YIELD реализуются через одну и ту же инструкцию, поэтому CTE нужен дополнительный способ различать их. Если сама инструкция самоловушки может нести параметр, разные параметры могут указывать на разные события — например, так можно поступить в x86 и mips32; если сама инструкция самоловушки не может нести параметр, различать события нужно по другому состоянию, один из способов — по значению некоторого регистра, именно так поступает riscv32.

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

Получив событие системного вызова, Nanos-lite вызывает функцию-обработчик системных вызовов do_syscall() для обработки. do_syscall() сначала через макрос GPR1 извлекает из контекста c ранее установленные пользовательским процессом параметры системного вызова, после чего выполняет диспетчеризацию по первому параметру — номеру системного вызова. Но поскольку на данный момент Nanos-lite не реализует ни одного системного вызова, срабатывает panic.

Добавить системный вызов проще, чем вы думаете, — вся необходимая информация уже подготовлена. Нам достаточно добавить в процесс диспетчеризации соответствующий номер системного вызова, написать соответствующую функцию-обработчик sys_xxx() и затем вызвать её. Вернёмся к программе dummy — она запускает системный вызов SYS_yield. Договоримся, что этот системный вызов должен просто напрямую вызывать yield() из CTE, а затем возвращать 0.

Последнее, что нужно сделать при обработке системного вызова, — установить его возвращаемое значение. Для разных ISA возвращаемое значение системного вызова хранится в разных регистрах, и для абстрагирования этого используется макрос GPRx, так что установить возвращаемое значение системного вызова можно просто через GPRx.

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

Реализуйте системный вызов SYS_yield

Вам нужно:

  1. Реализовать корректные макросы GPR? в соответствующих заголовочных файлах в директории abstract-machine/am/include/arch/, чтобы они правильно извлекали регистры параметров системного вызова из контекста c.
  2. Добавить системный вызов SYS_yield.
  3. Установить возвращаемое значение системного вызова.

Заново запустите программу dummy. Если ваша реализация верна, вы увидите, что dummy запускает ещё один системный вызов с номером 0. Посмотрите в nanos-lite/src/syscall.h — вы обнаружите, что это системный вызов SYS_exit. Это означает, что предыдущий SYS_yield успешно вернулся, а SYS_exit запускается потому, что dummy уже завершила выполнение и готовится завершиться.

Реализуйте системный вызов SYS_exit

Вам нужно реализовать системный вызов SYS_exit, который принимает параметр — статус завершения. Для удобства тестирования пока просто напрямую вызовем halt() с этим параметром. После успешной реализации заново запустите программу dummy — вы увидите сообщение HIT GOOD TRAP.

Передача номера системного вызова в RISC-V

Если вы выбрали RISC-V, вы обнаружите, что номер системного вызова передаётся не через a0. На самом деле мы взяли за основу соглашение RISC-V Linux о передаче параметров системного вызова: в RISC-V Linux номер системного вызова тоже передаётся именно через этот регистр. Как вы думаете, почему RISC-V Linux не использует a0 для передачи номера системного вызова?

Системные вызовы Linux

Вы можете узнать информацию о системных вызовах Linux с помощью следующих команд:

  • man syscall — соглашения о системных вызовах для разных архитектур, включая передачу параметров и возвращаемые значения;
  • man syscalls — уже реализованные в Linux системные вызовы. Ого, как их много, но в PA нам понадобится лишь несколько системных вызовов.

Следы системных вызовов — strace

Мы уже знаем, что пользовательская программа, работающая поверх операционной системы, может делать только две вещи: выполнять локальные вычисления и запрашивать через системные вызовы у операционной системы выполнение того, что не под силу локальным вычислениям. Это значит, что если мы сможем наблюдать за следами системных вызовов (syscall trace), мы сможем глубже понять поведение программы. В Linux есть инструмент под названием strace (устанавливается через apt-get), который может фиксировать следы системных вызовов пользовательской программы, и мы настоятельно рекомендуем вам установить его и попробовать. Например, с помощью strace ls можно узнать поведение ls и даже увидеть, как загружается сама ls; если вам интересен сам strace, с помощью strace strace ls вы можете изучить, как реализован сам strace.

На самом деле мы можем реализовать простой strace и в Nanos-lite: Nanos-lite может получить всю информацию о системном вызове, включая имя, параметры и возвращаемое значение. Именно поэтому мы решили реализовать strace именно в Nanos-lite: системные вызовы несут в себе высокоуровневую семантику программы, тогда как в NEMU видна лишь низкоуровневая машина состояний.

Реализуйте strace

Реализовать strace в Nanos-lite — очень простая задача.

TRM поверх операционной системы

Мы уже реализовали два очень простых системных вызова, так что ещё может делать пользовательская программа на текущей Nanos-lite? Вы, возможно, вспомнили, как в PA2 мы классифицировали потребности программы, — это же AM! На самом базовом уровне TRM показал нам, какие условия нужны для обеспечения базовых вычислительных возможностей программы:

  • машина предоставляет базовые вычислительные инструкции;
  • можно выводить символы;
  • есть область кучи для динамического выделения памяти;
  • можно завершить выполнение.

Базовые вычислительные инструкции по-прежнему должна предоставлять машина — это та самая система команд, которую вы уже реализовали в PA2. Что касается завершения выполнения — системный вызов SYS_exit уже предоставлен. Чтобы дать пользовательским программам возможность выводить символы и динамически выделять память, нам нужно реализовать ещё несколько системных вызовов.

Стандартный вывод

В GNU/Linux вывод реализуется через системный вызов SYS_write. Согласно объявлению функции write (см. man 2 write), после того как вы распознали в do_syscall(), что номер системного вызова — SYS_write, вам нужно проверить значение fd: если fd равен 1 или 2 (что соответствует stdout и stderr), вывести len байт, начиная с адреса buf, на последовательный порт (достаточно использовать putch()). Наконец, необходимо установить корректное возвращаемое значение, иначе вызывающая сторона системного вызова решит, что write выполнился неудачно, и повторит попытку. О том, что представляет собой возвращаемое значение системного вызова write, см. man 2 write. Также не забудьте вызвать функцию интерфейса системного вызова внутри _write() в navy-apps/libs/libos/src/syscall.c.

После реализации системного вызова SYS_write мы устранили самое главное препятствие на пути к «использованию printf()», поскольку после форматирования строки printf() в итоге выводит её через системный вызов write(). Всю эту работу за нас уже подготовила библиотека Newlib в Navy.

Запустите Hello World на Nanos-lite

В Navy есть тестовая программа hello (navy-apps/tests/hello), которая сначала выводит одну фразу через write(), а затем непрерывно выводит через printf().

Вам нужно реализовать системный вызов write(), а затем переключить пользовательскую программу, запускаемую на Nanos-lite, на программу hello.

Управление кучей

Вы наверняка уже использовали библиотечные функции malloc()/free() на курсе программирования — их задача заключается в выделении/освобождении блока памяти в области кучи пользовательской программы. Использование области кучи управляется библиотекой libc, но изменение размера кучи требует обращения к операционной системе через системный вызов. Это потому, что по своей сути область кучи — это участок памяти, и когда нужно изменить размер кучи, на самом деле изменяется область памяти, доступная пользовательской программе. На самом деле область памяти, доступная пользовательской программе, должна выделяться и управляться операционной системой. Представьте, что вредоносная программа могла бы без согласия операционной системы произвольно использовать области памяти других программ, — это привело бы к катастрофическим последствиям. Разумеется, сейчас Nanos-lite — всего лишь однозадачная операционная система, понятия нескольких программ в ней не существует. В PA4 вы получите более глубокое понимание этого вопроса.

Изменение размера кучи реализуется через библиотечную функцию sbrk(), её прототип:

void* sbrk(intptr_t increment);

Она используется для увеличения program break пользовательской программы на increment байт, где increment может быть отрицательным. Так называемый program break — это позиция конца сегмента данных (data segment) пользовательской программы. Мы знаем, что в исполняемом файле есть сегмент кода и сегмент данных; при линковке ld по умолчанию добавляет символ с именем _end, указывающий позицию конца сегмента данных программы. Когда пользовательская программа начинает выполняться, program break находится в позиции, на которую указывает _end, а значит, размер кучи в этот момент равен 0. При первом вызове malloc() через sbrk(0) запрашивается текущая позиция program break пользовательской программы, а затем с помощью последующих вызовов sbrk() можно динамически изменять позицию program break пользовательской программы. Интервал между текущим program break и его начальным значением может служить областью кучи пользовательской программы, которой управляют malloc()/free(). Обратите внимание: пользовательская программа не должна напрямую использовать sbrk(), иначе это нарушит записи учёта кучи, которые ведут malloc()/free().

В Newlib из Navy sbrk() в конечном счёте вызывает _sbrk(), определённую в navy-apps/libs/libos/src/syscall.c. Каркасный код заставляет _sbrk() всегда возвращать -1, обозначая неудачу изменения размера кучи. На самом деле при первом вызове printf() пользовательская программа попытается выделить через malloc() буфер для хранения отформатированного содержимого. Если попытка выделения не удастся, вывод будет происходить посимвольно. Если включить strace в Nanos-lite, вы обнаружите, что при выводе через printf() пользовательская программа действительно вызывает write() посимвольно.

Но если куча всегда недоступна, многие функции библиотеки Newlib окажутся неработоспособны, поэтому теперь вам нужно реализовать _sbrk(). Чтобы реализовать функциональность _sbrk(), нам также нужно предоставить системный вызов для установки размера кучи. В GNU/Linux этот системный вызов называется SYS_brk, он принимает один параметр — addr, указывающий новую позицию program break. _sbrk() управляет позицией program break пользовательской программы, ведя учёт, и работает следующим образом:

  1. Начальная позиция program break находится в _end.
  2. При вызове на основе записанной позиции program break и параметра increment вычисляется новый program break.
  3. Через системный вызов SYS_brk операционной системе поручается установить новый program break.
  4. Если системный вызов SYS_brk завершился успешно, он возвращает 0, после чего обновляется ранее записанная позиция program break, а старая позиция program break возвращается в качестве возвращаемого значения _sbrk().
  5. Если системный вызов завершился неудачей, _sbrk() возвращает -1.

Приведённый выше код реализован в библиотечных функциях пользовательского уровня, но нам ещё нужно реализовать функциональность SYS_brk в Nanos-lite. Поскольку сейчас Nanos-lite всё ещё однозадачная операционная система, свободную память может свободно использовать пользовательская программа, поэтому нам достаточно, чтобы системный вызов SYS_brk всегда возвращал 0, показывая, что изменение размера кучи всегда проходит успешно. В PA4 мы изменим этот системный вызов, реализовав настоящее выделение памяти.

Реализуйте управление кучей

На основе вышеизложенного реализуйте системный вызов SYS_brk в Nanos-lite, а затем реализуйте _sbrk() на пользовательском уровне. Вы можете ознакомиться с поведением brk() и sbrk() в libc через man 2 sbrk, а также узнать, как использовать символ _end, через man 3 end.

Обратите внимание: во время отладки не выводите информацию через printf() внутри _sbrk() — это потому, что printf() всё равно попытается выделить буфер через malloc(), что в итоге снова вызовет _sbrk(), вызвав бесконечную рекурсию. Вы можете сначала вывести отладочную информацию в строковый буфер через sprintf(), а затем вывести её через _write().

Если ваша реализация верна, с помощью strace вы сможете увидеть, что printf() больше не выводит данные посимвольно через write(), а выводит уже отформатированную строку целиком за один раз.

Буферизация и издержки системных вызовов

Вы уже познакомились с процессом системного вызова. На самом деле, если с таким трудом проваливаться в операционную систему через системный вызов только ради вывода одного-единственного символа, это крайне невыгодно. Отсюда и возникла технология пакетной обработки (batching): накапливать несколько простых задач, а затем обрабатывать их все разом. Буфер — это ядро технологии пакетной обработки: функции fread() и fwrite() в libc как раз накапливают данные через буфер, а затем обрабатывают их одним системным вызовом. Например, с помощью буфера в 1024 байта можно вывести сразу 1024 символа одним системным вызовом, вместо того чтобы делать 1024 системных вызова, выводя символы по одному. Очевидно, что издержки во втором случае намного выше, чем в первом.

Желающие могут написать соответствующую программу в GNU/Linux, чтобы грубо оценить издержки одного системного вызова write(), а затем сравнить результат с этой статьёйоткрыть в новом окне.

printf и перевод строки

В PA1 мы уже предупреждали, что при отладке с помощью printf() нужно добавлять \n, и теперь мы наконец можем объяснить почему: в реализации fwrite() есть буфер, и символы, выводимые printf(), не обязательно сразу же выводятся через системный вызов write(), но при встрече \n содержимое буфера принудительно выводится. Желающие могут почитать navy-apps/libs/libc/src/stdio/wbuf.c — в этом файле реализована функциональность буфера.

После реализации этих двух системных вызовов, в принципе, любая программа, способная работать на TRM, теперь может работать и на Nanos-lite. Впрочем, мы пока не стали строго следовать API AM при раскрытии соответствующей функциональности системных вызовов пользовательским программам — ведь по сравнению с AM, для программ, работающих поверх операционной системы, интерфейс libc гораздо более широко используется, так что не будем изобретать велосипед.

Обязательный вопрос (нужно ответить в отчёте о лабораторной работе) — что такое программа hello, откуда она пришла и куда направляется

К этому моменту все компоненты PA уже представлены, и вся компьютерная система начинает приобретать целостность. Вы уже запустили на этой созданной вами компьютерной системе программу hello — первую хоть сколько-нибудь настоящую пользовательскую программу (dummy была лишь для разминки, не в счёт), и хорошая новость в том, что мы уже совсем недалеко от запуска «Легенды о мече и фее» (это будет уже на следующем этапе).

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

Мы знаем, что navy-apps/tests/hello/hello.c — это всего лишь исходный файл на C, который будет скомпилирован и слинкован в ELF-файл. Так где же изначально находится программа hello? Как она оказывается в памяти? Почему она появляется именно в текущей позиции памяти? Где находится её первая инструкция? Как именно происходит переход к выполнению её первой инструкции? Программа hello непрерывно печатает строки — что происходит с каждым символом, прежде чем он в итоге появится на терминале?

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

Аналогично, обязательный вопрос из предыдущего этапа «понимание путешествия сквозь пространство и время» уже охватывает часть содержания — вы можете включить его ответ сюда, но нужно чётко описать, в чём есть отличия. Кроме того, процесс от printf() до write() в C-библиотеке довольно громоздкий и не относится к основному содержанию PA, эту часть подробно расписывать не нужно. К тому же вы уже реализовали собственный printf() в PA2, так что процесс форматирования строк вам, полагаем, понять несложно. Если вас интересует реализация Newlib, вы тоже можете обратиться к RTFSC.

Одним словом, если исключить часть с преобразованием printf() в write() в C-библиотеке, оставшийся код — это как раз то, что вам следует понять досконально. Так что приложите усилия, чтобы понять каждую строку кода!

Поддержка нескольких ELF-файлов в ftrace

Если мы хотим разобраться в процессе от printf() до write() в C-библиотеке, ftrace станет отличным инструментом. Но мы знаем, что Nanos-lite и загружаемая ею пользовательская программа — это два отдельных ELF-файла, а значит, если мы укажем ftrace в NEMU ELF-файл только одной из сторон, ftrace не сможет корректно перевести адреса другой стороны в правильные имена функций. На самом деле мы можем сделать так, чтобы ftrace в NEMU поддерживал несколько ELF-файлов: если адрес не принадлежит ни одной функции в одном ELF-файле, пробуем следующий ELF-файл. Таким образом ftrace сможет одновременно отслеживать вызовы функций как в Nanos-lite, так и в пользовательской программе.

Дружеское напоминание

На этом второй этап PA3 завершён.