Как Forth реализует исключения

20 сентября 2021 г. · 12 минут чтения

Эта статья является частью серии «Начальная загрузка» , в которой я начинаю с 512-байтного начального источника и пытаюсь загрузить реальную систему.

Предыдущий пост:
Ветвления: сборка не требуется

Следующее сообщение:
Контекстные исключения с метапрограммированием Forth

Учитывая низкоуровневую природу Forth, некоторые могут удивиться тому, насколько хорошо он подходит для обработки исключений. Но действительно, ANS Forth определяет простой механизм обработки исключений. Поскольку в Форте нет системы типов, способной поддерживать такой механизм, как в Rust Result, исключения являются предпочтительной стратегией обработки ошибок. Давайте подробнее рассмотрим, как они используются и как реализуются.

Текстовое поле Pokémon Red с надписью «Ой! Похоже, его поймали!»
Текстовое поле Pokémon Red с надписью «Ой! Похоже, его поймали!»

Точка зрения пользователя

Механизм исключения состоит из двух обращенных к пользователю слов: catchи throw. В отличие от других слов потока управления, которые действуют как дополнительный синтаксис, им catchпросто требуется токен выполнения 1 на вершине стека, что обычно означает, что [']он будет использоваться для его получения непосредственно перед вызовом catch(хотя и вне определения, 'вместо этого используется ).

Если executeпереданный ему токен выполнения catchничего не выдает, a 0чтобы указать на успех:

42 ' dup catch .s ( <3> 42 42 0  ok )

С другой стороны, если throw выполняется , throwаргумент остается в стеке, чтобы указать тип исключения:

: welp 7 throw ;
1 2 ' welp catch .s ( <3> 1 2 7  ok )

Однако элементы стека под этим кодом исключения — это не только то, что было там throw, когда он был запущен — если существует более одного возможного throwместоположения, расположение стека стало бы непредсказуемым. Именно поэтому catchзапоминает глубину стека, чтобы throwможно было его восстановить. В результате, если наш welpпоместит дополнительные элементы в стек, они будут отброшены:

: welp 3 4 5 7 throw ;
1 2 ' welp catch .s ( <3> 1 2 7  ok )

и если он потребляет некоторые элементы стека, их место будет заполнено неинициализированными слотами при перемещении указателя стека:

: welp 2drop 2drop 7 throw ;
1 2 3 4 ' welp catch .s ( <5> 140620924927952 7 140620924967784 56 7  ok )

Чтобы думать об этом, нужно рассматривать эффект стека ' foo catchв целом. Например, если fooимеет эффект стека ( a b c -- d ), то ' foo catchимеет ( a b c -- d 0 | x1 x2 x3 exn ), где - x?слоты, занятые аргументами a b c, которые можно заменить практически любым значением, и поэтому их можно только отбросить.

Ключевым моментом здесь является то, что количество элементов в стеке становится известным, и теперь мы можем безопасно отбросить то, что могло быть затронуто foo, чтобы получить доступ ко всему, что мы могли бы хранить ниже.

Давайте посмотрим на более полный пример того, как все это можно использовать. Предположим, у нас есть /слово, которое реализует деление, но вылетает при попытке разделить на ноль. Давайте завершим его быстрой проверкой, которая вместо этого выдает исключение.

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

  • 0используется для catchобозначения «без исключения»

  • значения в диапазоне {-255...-1}зарезервированы для ошибок, определенных стандартом

  • значения в диапазоне {-4095...-256}зарезервированы для ошибок, определенных реализацией Forth

Поскольку стандарт присваивает идентификатор для «деления на ноль», мы могли бы также использовать его.

-10 constant exn:div0

На самом деле я не смог найти никаких указаний о том, как они обычно выбираются для исключений для конкретных приложений. Если бы мне пришлось угадывать, я бы начал с небольших положительных целых чисел, а не с -4096понижения. Что бы это ни стоило, расширенный механизм исключений Miniforth обходит это, используя адреса памяти в качестве идентификаторов.

Во всяком случае, выдача исключения выглядит именно так, как вы ожидаете:

: / ( a b -- a/b )
  dup 0= if
    exn:div0 throw
  then / ( предыдущее, неоговоренное определение / - не рекурсия )
;

Затем вы можете использовать его так:

: /. ( a b -- )
  over . ." divided by " dup . ." is "
  ['] / catch if
    2drop ( / принимает 2 аргумента, поэтому нам нужно отбросить 2 слота)
    ." infinity" ( sad math pedant noises )
  else . then ;

Это работает так, как вы ожидаете:

7 4 /. 7 разделить на 4 равно 1 ок
 7 0 /. 7 разделить на 0 это бесконечность

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

0 throwи его использование

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

Есть несколько аспектов, почему это так. Во-первых, фактическое выбрасывание нуля может сбивать с толку, так как catchон используется для обозначения того, что исключение не было перехвачено. Но подождите, Форт не совсем в характере проверять это. Есть куча других способов испортить. Они могли бы сказать: «Он съест носок, если вы попытаетесь это сделать» и отпраздновать победу в производительности.

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

Ответ достаточно прост: это не обязательно ошибка. Есть несколько полезных идиом, которые сосредоточены на том 0 throw, чтобы быть бездействующим.

Один касается более краткого способа проверки условия:

: / ( a b -- a/b )
  dup 0= exn:div0 and throw / ;

Это работает, поскольку в Forth каноническое значение trueимеет все установленные биты (в отличие от C, в котором устанавливается только младший бит), поэтому true exn:div0 andоценивается как exn:div0. Конечно, при использовании этой идиомы нужно быть осторожным, чтобы использовать канонически закодированный флаг, а не что-то, что может возвращать произвольные значения, которые оказались правдивыми.

Другая идиома позволяет выставить интерфейс на основе кода ошибки, который удобно использовать как интерфейс на основе исключений. Например, allocate(который выделяет память динамически, как C malloc) имеет эффект стека size -- ptr err. Если вызывающая сторона хочет обработать ошибку распределения здесь и сейчас, она может сделать

... allocate if ( it failed :/ ) exit then
( happy path )

Но для создания исключения при возврате ошибки требуется только allocate throw--- если ошибки не произошло, то она 0будет удалена.

Внутренности

Как же делается эта "колбаса"? jonesforth, очень популярная грамотная программная реализация Forth, предлагает реализовать исключения, по существу, просматривая throwстек возврата для определенного адреса в реализации catch. Это похоже на то, к чему можно прийти после изучения сложных механизмов раскручивания в таких языках, как C++ или Rust 2 — они тоже раскручивают стек, используя очень сложный механизм поддержки, охватывающий всю цепочку инструментов. Однако причина, по которой им это необходимо, заключается в запуске деструкторов объектов в кадрах стека, которые вот-вот будут отброшены.

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

variable catch-rp ( return [stack] pointer at CATCH time )

Помимо простоты, этот подход также имеет преимущества в производительности и надежности — помните, что и>r циклы выполнения также могут помещать данные в стек возврата. Было бы очень обидно, если бы такое значение совпало с адресом специального маркера, который сканируется... 4

Для поддержки вложенных вызовов catchнам нужно сохранить предыдущее значение catch-rpв стеке. Пока мы на этом, это также хорошее место для сохранения указателя стека параметров. Это эффективно создает связанный список «кадров обработки исключений», размещенных в стеке возврата:

Переменная catch-rp указывает на стек возврата, прямо над собственным сохраненным значением.
Переменная catch-rp указывает на стек возврата, прямо над собственным сохраненным значением.

Обратите внимание, что запись «возврат к catch» находится над данными, нажатыми catch. Это связано с тем, что первый запускается только после catchвызова слова, не являющегося ассемблерным, — в данном случае , которое executeв конечном итоге потребляет токен выполнения.

Требуется некоторая сборка

Поскольку указатели стека сами по себе не являются частью модели программирования Forth, нам потребуется написать несколько слов на ассемблере, чтобы манипулировать ими. Слова для указателя стека возврата просты:

:code rp@ ( -- rp ) bx push, di bx movw-rr, next,
:code rp! ( rp -- ) bx di movw-rr, bx pop, next,

(этот синтаксис (как и реализация ассемблера) был объяснен в предыдущей статье )

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

Сохраненный указатель стека указывает на значение чуть выше него в стеке.
Сохраненный указатель стека указывает на значение чуть выше него в стеке.

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

:code sp@ bx push, sp bx movw-rr, next,

sp!работает аналогично, с руководящим принципом, который sp@ sp!должен быть неактивным:

:code sp! bx sp movw-rr, bx pop, next,

Обратите внимание, что на самом деле нет никаких различий в реализации между sp@/ sp!и их аналогами стека возврата (кроме использования spрегистра вместо di). Просто нужно больше думать об одном, чем о другом...

Последнее :codeслово, которое нам понадобится, это execute, которое принимает токен выполнения и переходит к нему.

:code execute bx ax movw-rr, bx pop, ax jmp-r,

Интересно, что executeна самом деле это не нужно реализовывать на ассемблере. С тем же успехом мы могли бы сделать это и на Форте с некоторыми предположениями о том, как компилируется код — записать токен выполнения в скомпилированное представление самого себя execute, как раз перед тем, как мы достигнем точки, когда он будет прочитан:

: execute [ here 3 cells + ] literal !
  ( любое слово может быть здесь, поэтому... ) drop ; ( chosen by a fair dice roll... )

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

Собираем все вместе

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

Сначала помещается сохраненный sp, затем сохраняется catch-rp, а затем указатель стека возврата выбирается и сохраняется в catch-rp.
Сначала помещается сохраненный sp, затем сохраняется catch-rp, а затем указатель стека возврата выбирается и сохраняется в catch-rp.

Давайте построим это тогда:

: catch ( i*x xt -- j*x 0 | i*x n )
  sp@ >r  catch-rp @ >r
  rp@ catch-rp !

Тогда пришло время execute. На самом деле он вернется только в том случае, если не будет выброшено исключение, поэтому затем мы обрабатываем счастливый путь, нажимая 0:

  execute 0

Наконец, мы помещаем то, что запихнули в стек возврата. Предыдущее значение catch-rpдолжно быть восстановлено, но указатель стека данных должен быть удален, так как в этом случае мы не должны восстанавливать глубину стека.

  r> catch-rp ! rdrop ;

throwначинается с проверки того, что код исключения не равен нулю, а затем откатывает стек возврата в сохраненное место.

: throw  dup if
  catch-rp @ rp!

Восстановление catch-rpпроисходит так, как вы ожидаете:

  r> catch-rp !

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

  r> swap >r sp!

Во-вторых, при sp@запуске токен выполнения все еще находился в стеке — нам нужно удалить этот слот стека, прежде чем помещать на его место код исключения:

  drop r>
else ( the 0 throw case ) drop then ;

Но подождите, есть еще!

Мы видели, как работает стандартный механизм исключений в Forth. Предусмотрены средства броска и ловли, но в весьма зачаточном виде. В моем следующем посте я объясню, как Miniforth строит этот механизм для присоединения контекста к исключениям, что приводит к интерактивным сообщениям об ошибках, когда исключение всплывает на верхний уровень. Увидимся там!

Понравилась эта статья?

Возможно, вам понравятся и другие мои посты . Если вы хотите получать уведомления о новых, вы можете подписаться на меня в Твиттере или подписаться на RSS-канал .

Я хотел бы поблагодарить моих спонсоров GitHub за их поддержку: Michalina Sidor и Tijn Kersjes.


1

Проще говоря "указатель на функцию".

2

Это не значит, что jonesforthв целом все плохо. Основа системы прочная, и она довольно хорошо объясняет задействованные концепции. Я определенно рекомендую его как введение во внутренности Форта и даже, возможно, как способ изучения самого Форта.

3

Казалось бы, производительность исключений никогда не должна становиться узким местом. Я согласен, хотя я хотел бы воспользоваться этой возможностью, чтобы указать на стиль программирования, который я недавно видел, в котором производительность обработки исключений действительно имеет значение. А именно, взгляните на примеры в разделе Exceptions руководства ATS . Рекомендуется усмотрение зрителя.

4

Вероятно, это может иметь некоторые последствия для безопасности, но, надеюсь, в любом случае никто не пишет критически важные для безопасности вещи на Forth. Однако, учитывая, где мы находимся с C...

Комментарии (1)


  1. tzlom
    02.06.2023 06:42
    +2

    Если execute переданный ему токен выполнения catch ничего не выдает, a 0 чтобы указать на успех:

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