Сообщений 0    Оценка 0        Оценить  
Система Orphus

Об одной реализации рекурсии

Автор: Караваев Дмитрий Юрьевич
Опубликовано: 29.07.2014
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Определение рекурсивности подпрограммы
Реализация рекурсивных вызовов
Пример генерации системных вызовов рекурсии
Исходный текст служебных подпрограмм
Заключение
Литература

Введение

Если бы меня не опередил классик, я начал бы статью словами: «Любите ли Вы рекурсию так, как люблю ее я? Нет, Вы не можете любить рекурсию так, как я!» Но, во-первых, это плагиат, а, во-вторых, неправда. У меня нет причин любить или не любить рекурсию как один из базовых механизмов в программировании. Да и в моей предметной области (инженерные расчеты) необходимость в рекурсивных алгоритмах встречается не часто. И вообще может показаться, что какая-то специальная «реализация» рекурсии в современной компьютерной архитектуре бессмысленна: если для подпрограммы все локальные переменные размещаются в стеке, возможность рекурсивного вызова такой подпрограммы получается «сама собой» и не требует никаких дополнительных действий. Однако такой подход возможен не во всех случаях.

Еще во времена Алгола были сложности с реализацией так называемых «собственных значений процедуры», т.е. локальных переменных, которые должны были сохранять свое значение между вызовами данной процедуры. Очевидно, что для таких переменных при рекурсии нельзя просто выделять место в стеке, поэтому реализация рекурсивного вызова процедур с собственными значениями усложнялась. Есть и менее очевидные трудности. Например, как было показано в [1], при вычислениях выражений транслятору не всегда удается сохранять промежуточные результаты только в регистрах или стеке. В некоторых случаях появляется необходимость использовать служебные переменные (недоступные программисту), что опять-таки делает невозможной простую организацию рекурсивных вызовов.

Цель данной статьи – познакомить с альтернативным подходом к реализации рекурсивных вызовов. Такой подход выполнен в трансляторе с одного конкретного языка [2]. Подчеркну, что рассматривается нестандартный подход только к организации рекурсивных вызовов, а не к рекурсии как таковой. Отчасти нестандартность вызвана необходимостью поддержки широких возможностей, предоставляемых языком, например, такой нетривиальной возможности, как выход из подпрограммы с помощью оператора перехода, а не только через оператор возврата.

Определение рекурсивности подпрограммы

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

В используемом мною языке PL/1 данная проблема решается очень просто: вся ответственность возлагается на программиста. Если он не указывает ключевое слово «RECURSIVE» в заголовке подпрограммы, транслятор не выполняет никаких действий для поддержки рекурсивного вызова. Альтернативным такому подходу является или обеспечение рекурсивного вызова для любой подпрограммы, или (как в первых поколениях Фортрана) запрет на рекурсивный вызов вообще. Лично меня явное указание возможности рекурсии вполне устраивает. И дело не в том, что не рекурсивные процедуры встречаются чаще и потенциально могут быть проще организованы в части данных. Я считаю, что только программист, как автор алгоритма, может (и обязан) определить, допустима ли в данном случае рекурсия, и приведет ли она к правильным результатам. Никакой транслятор не сможет определить допустимость и правильность рекурсии в конкретном алгоритме, даже если он и обнаружит саму эту рекурсию в вызове подпрограмм.

Реализация рекурсивных вызовов

Итак, в рассматриваемом трансляторе с языка PL/1 подпрограммы делятся на явно рекурсивные и все остальные. В не рекурсивных подпрограммах после вызова начинают непосредственно выполняться команды алгоритма. В явно рекурсивных подпрограммах при входе сначала производится обращение к системной подпрограмме, внутри которой выделяется память из «кучи», и в выделенный сегмент переписывается все текущее поколение локальных данных в том состоянии, в котором они были на момент вызова этой рекурсивной подпрограммы.

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

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

Однако в случае PL/1 только этих действий недостаточно. Если происходит выход из подпрограммы не через заключительный оператор возврата, необходимо еще с помощью отдельной системной подпрограммы уничтожить возможные «незакрытые» внутренние вызовы, т.е. убрать из «кучи» все дальнейшие поколения запомненных локальных данных всех вызванных рекурсивных процедур. Для этой цели, кроме памяти под собственно поколение локальных данных, в том же сегменте выделяется еще несколько слов под служебную информацию. Это позволяет, во-первых, все выделенные «под рекурсию» сегменты «кучи» объединить в связанный список, якорь которого известен системной подпрограмме очистки, а во-вторых, вместе с данными запомнить и текущее значение стека. Подпрограмма очистки идет по связанному списку «рекурсивных» сегментов и возвращает их в свободную память «кучи» до тех пор, пока текущее значение стека не станет меньше значения стека, указанного в очередном сегменте.

К сожалению, даже всего перечисленного все равно недостаточно для организации рекурсивного вызова в PL/1 во всех возможных случаях, хотя оставшаяся проблема связана не столько с рекурсией, сколько с организацией транслятора. Когда транслятор генерирует системный вызов запоминания локальных данных, он знает их размер и начальный адрес еще до начала генерации команд рекурсивной подпрограммы потому, что это, так сказать, «рекурсивные данные пользователя». Они явно описаны в программе, их анализ транслятором уже проведен, их размер вычислен и не изменится. А вот размер «рекурсивных служебных данных», которые могут появиться при разборе выражений и генерации команд подпрограммы, в этой точке еще неизвестен и неизвестно даже, потребуются ли такие данные вообще. Транслятор «узнает» об этом только в конце генерации команд подпрограммы, а не в точке входа в подпрограмму, где он уже поставил системный вызов. Чтобы обойти эту сложность и не обрабатывать каждую рекурсивную подпрограмму два раза только для того, чтобы узнать о необходимости дополнительных данных, транслятор генерирует сразу два разных системных вызова запоминания локальных данных: отдельно для «рекурсивных данных пользователя» и отдельно для «рекурсивных служебных данных». В первом случае размер данных определяется командой загрузки константы, во втором случае – использованием служебной переменной, значение которой транслятор определяет лишь в конце генерации команд рекурсивной подпрограммы и записывает в объектный модуль (формата OMF 8086/8087) как дополнительную настройку данных, аналогично тому, как производится начальная инициализация статических переменных. Т.е. собственно запись размера данных в служебную переменную осуществляет уже не транслятор, а редактор связей.

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

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

В результате, в 32-х разрядной системе, если присутствуют только «рекурсивные данные пользователя», то накладные расходы на каждый рекурсивный вызов с учетом организации кучи [3] составляют 28 байт плюс размер самих локальных данных. Если же присутствуют и «рекурсивные служебные данные», то накладные расходы возрастают до 44 байт плюс общая длина всех данных.

Пример генерации системных вызовов рекурсии

Ниже приведены фрагменты служебного вывода работы транслятора вместе с получившимися командами.

В главной программе TEST описана рекурсивная подпрограмма F1. При начале работы программы TEST в служебной переменной @000007F8H запоминается текущее значение стека (т.е. регистра ESP).

1   000000 TEST:PROC MAIN;

    000000 B88C9B4002                  moveax,37788556
    000005 E800000000                  call   ?START
    00000A 8925F8070000                mov    @000007F8h,esp
    000010 E900000000                  jmp    @1

Затем транслятор обрабатывает вход в подпрограмму F1. Он загружает в регистр ECX размер всех локальных данных подпрограммы F1 (в примере 64 байта), а в регистр EDX – начальный адрес (828h) этих данных. Затем следует вызов системной подпрограммы call ?SVBLK, которая и запоминает эти 64 байта, начиная с указанного адреса, в памяти, выделенной из «кучи».

Потом следует загрузка в ECX значения служебной переменной @00000870H, в которой содержится размер дополнительных данных или ноль. В регистр EDX загружается начальный адрес дополнительных данных, если они появятся. Этот адрес (874h) просто следующий за служебной переменной.

Наконец, далее ставится системный вызов call ?SVBLS, который или запоминает ECX байт в дополнительном сегменте, выделенном из «кучи», или просто ничего не делает, если ECX равен нулю.

3   000015 F1:PROC(I,J,K,L,M) RECURSIVE;
4   000015 DCL (I,J,K,L,M) FIXED(31);

    000015 6A40                        push   64
    000017 59                          popecx
    000018 BA28080000                  movedx,offset @000000828h
    00001D E800000000                  call   ?SVBLK
    000022 8B0D70080000                movecx,@0000000870h
    000028 BA74080000                  movedx,offset @000000874h
    00002D E800000000                  call   ?SVBLS
...

В конце подпрограммы F1 транслятор генерирует вызов call ?RSBLK системной подпрограммы, которая переписывает запомненные байты обратно из сегментов в программу. Поскольку все адреса и размеры хранятся внутри выделенной памяти, никаких параметров для этого системного вызова не требуется.

17  0002D5 END F1;

    0002D5 E800000000                  call   ?RSBLK
    0002DA C3                          ret

Из подпрограммы F1 можно также выйти с помощью оператора GOTO. Хотя это может показаться нарушением всяких правил, в данном случае такой прием имеет практический смысл. Например, если по ходу рекурсивного алгоритма обнаружена ошибка, бывает затруднительно вернуться через много поколений «старших» рекурсивных вызовов. В этом случае прямой оператор GOTO и проще, и понятней. В примере метка перехода M1 стоит в главной программе. В этой точке, во-первых, восстанавливается исходное значение стека из служебной переменной, а во-вторых, ставится системный вызов call ?RECOV, который и освобождает память в «куче» от уже ненужных «рекурсивных» сегментов, ориентируясь по текущему значению стека и значению стека, запомненному в каждом сегменте.

…
11  00028E IF I>0 THEN GOTO M1;
    00028E 833D3C08000000              cmp    I,0
    000295 7E02                        jle    @3
    000297 EB6A                        jmps   M1
    000299                         @3:
…
    000303                         M1:
    000303 8B25F8070000                movesp,@000007F8h
    000309 E800000000                  call   ?RECOV

Разумеется, если бы в описании подпрограммы F1 не было бы слова «RECURSIVE», и внутри нее не использовался бы GOTO для выхода, всех этих системных вызовов и переменных транслятор бы не создавал.

Исходный текст служебных подпрограмм

Ниже приведен исходный текст служебных подпрограмм запоминания и восстановления локальных данных при рекурсивных вызовах. Якорь связанного списка хранится в переменной ?RECLST. Также используются внешние системные подпрограммы захвата и освобождения памяти в «куче».

В тексте команда «THR» является не командой процессора, а макроопределением захвата и проверки семафора для возможной работы параллельных потоков. Сам семафор хранится в переменной ?THREAD_REC. Возможность работы параллельных потоков приходится учитывать и при анализе значения стека в подпрограмме восстановления ?RSBLK. «Рекурсивные» сегменты от других потоков будут «сильно» отличаться значением стека, в то время как сегменты одного потока будут иметь близкие значения стека.

      ;========= СОХРАНИТЬ СОДЕРЖИМОЕ ДАННЫХ РЕКУРСИВНОЙ ПРОЦЕДУРЫ =========
      EXTRN ?ALLOA:NEAR;ВЫДЕЛЕНИЕ ПАМЯТИ ИЗ КУЧИEXTRN ?FREOP:NEAR;ОСВОБОЖДЕНИЕ ПАМЯТИ И ВОЗВРАТ В КУЧУ;------------------ ЗАПОМИНАНИЕ ДАННЫХ ПОЛЬЗОВАТЕЛЯ ------------------PUBLIC ?SVBLK:                  ;ВЫЗЫВАЕТСЯ ИЗ PL/1PUSHEBX,EDX,ECX;ЗАПОМНИЛИ РЕГИСТРЫLEAEBX,[ECX]+20       ;ЧИСЛО БАЙТ И СЛУЖЕБНОЙ ИНФОРМАЦИИ;---- ЖДЕМ ОСВОБОЖДЕНИЯ ПОТОКА ПО РЕКУРСИИ ----

      THR    ?THREAD_REC

;---- ВЫДЕЛИЛИ ПАМЯТЬ ИЗ КУЧИ ДЛЯ ЗАПОМИНАНИЯ ----CALL   ?ALLOA             ;ВЫДЕЛИЛИ ПАМЯТЬ ИЗ КУЧИ;---- ВСТАВЛЯЕМ В СВЯЗАННЫЙ СПИСОК ЗАПОМНЕННЫХ ДАННЫХ ----MOVEAX,EBX;НАЧАЛО ВЫДЕЛЕННОЙ ПАМЯТИLEAEDX,[ESP]+4+3*4    ;ЗНАЧЕНИЕ СТЕКАXCHGEAX,?RECLST          ;НОВОЕ ЗНАЧЕНИЕ ВЕРШИНЫ СПИСКАAND    D PTR [EBX]+16,0   ;ПОКА НЕТ СЛУЖЕБНЫХ ДАННЫХMOV    [EBX]+00,EAX;ПРЕДЫДУЩЕЕ ЗНАЧЕНИЕ ВЕРШИНЫ СПИСКА;---- ЗАПОМИНАЕМ ТЕКУЩЕЕ ЗНАЧЕНИЕ СТЕКА ----MOV    [EBX]+04,EDX;ЗАПОМНИЛИ СТЕК;---- ЗАПОМИНАЕМ НАЧАЛО И ОБЪЕМ ДАННЫХ В ПРОГРАММЕ ----POPECX,EDXMOV    [EBX]+08,EDX;АДРЕС ДАННЫХ БЛОКАMOV    [EBX]+12,ECX;ЧИСЛО БАЙТ ДАННЫХ БЛОКАJECXZ  M1                 ;ВООБЩЕ НЕТ ДАННЫХ ПОЛЬЗОВАТЕЛЯ;---- ПЕРЕПИСЫВАЕМ ЭКЗЕМПЛЯР ДАННЫХ ИЗ ПРОГРАММЫ И ЗАПОМИНАЕМ ИХ ----LEAEDI,[EBX]+20       ;КУДА СОХРАНЯЮТСЯMOVESI,EDX;ОТКУДА БЕРУТСЯ ДАННЫЕSHRECX,1
      JNC    @
      MOVSB
@:    SHRECX,1
      JNC    @
      MOVSW
@:    REPMOVSD;СОХРАНЕНИЕ ДАННЫХ;---- ВОССТАНАВЛИВАЕМ СОСТОЯНИЕ EBX И ВЫХОДИМ ----

M1:   POPEBXRET;----------------- ЗАПОМИНАНИЕ СЛУЖЕБНЫХ ДАННЫХ ---------------------PUBLIC ?SVBLS:                  ;ВЫЗЫВАЕТСЯ ИЗ PL/1JECXZ  M2                 ;ВООБЩЕ НЕТ СЛУЖЕБНЫХ ДАННЫХPUSHEBX,EDX,ECX;ЗАПОМНИЛИ РЕГИСТРЫ;---- ВЫДЕЛИЛИ ПАМЯТЬ В КУЧЕ ДЛЯ ЗАПОМИНАНИЯ ----LEAEBX,[ECX]+8        ;ЧИСЛО БАЙТ ДАННЫХ, АДРЕС И ДЛИНАCALL   ?ALLOA             ;ВЫДЕЛИЛИ ПАМЯТЬ;---- ВСТАВЛЯЕМ В СВЯЗАННЫЙ СПИСОК ЗАПОМНЕННЫХ ДАННЫХ ----MOVEAX,?RECLST         ;ВЕРШИНА СПИСКАPOPECX,EDXLEAEDI,[EBX]+8         ;МЕСТО ЗАПОМИНАНИЯ ДАННЫХMOV   [EBX]+00,EDX;АДРЕС ДАННЫХ БЛОКАMOV   [EBX]+04,ECX;ЧИСЛО БАЙТ СЛУЖЕБНЫХ ДАННЫХMOV   [EAX]+16,EBX;МЕСТО ДЛЯ ЗАПОМИНАНИЯMOVESI,EDX;ОТКУДА БЕРУТСЯ ДАННЫЕ;---- ПЕРЕПИСЫВАЕМ СЛУЖЕБНЫЕ ДАННЫЕ В ВЫДЕЛЕННУЮ ПАМЯТЬ ---SHRECX,1
      JNC   @
      MOVSB
@:    SHRECX,1
      JNC   @
      MOVSW
@:    REPMOVSD;СОХРАНЕНИЕ СЛУЖЕБНЫХ ДАННЫХ;---- ВОССТАНАВЛИВАЕМ СОСТОЯНИЕ EBX И ВЫХОДИМ ----POPEBX

M2:   MOV   ?THREAD_REC,CL;ОСВОБОДИЛИ СЕМАФОР ПО РЕКУРСИИRET;======= ВОССТАНОВИТЬ СОДЕРЖИМОЕ ДАННЫХ РЕКУРСИВНОЙ ПРОЦЕДУРЫ ======== PUBLIC ?RSBLK:                  ;ВЫЗЫВАЕТСЯ ИЗ PL/1PUSHEAX,EBX;ЗАПОМНИЛИ ВОЗМОЖНЫЙ ОТВЕТMOVECX,OFFSET ?RECLST

;---- ЖДЕМ ОСВОБОЖДЕНИЯ ПОТОКА ПО РЕКУРСИИ ----

      THR   ?THREAD_REC

;---- ДОСТАЕМ ИЗ ВЕРШИНЫ СВЯЗАННОГО СПИСКА ----

M3:   MOVEBX,[ECX]
      OREBX,EBX;НИЧЕГО НЕТ ?JZ    M6                  ;ВООБЩЕ НЕ БЫЛО ЗАПОМНЕННЫХ ДАННЫХ;---- ПРОВЕРКА ПО ЗНАЧЕНИЮ СТЕКА, ЧТО ЭТО НАШ ПОТОК  ----MOVEAX,ESPSUBEAX,[EBX]+4         ;РАЗНОСТЬ СТЕКОВJNS   @
      NEGEAX;АБСОЛЮТНАЯ РАЗНОСТЬ СТЕКОВ
@:    CMPEAX,0FFFFH          ;ЭТО НАШ ПОТОК ?JB    @                   ;ДА, НАШЛИ СВОЙ ПОТОК;---- ПРОПУСКАЕМ ЭЛЕМЕНТ ДРУГОГО ПОТОКА ----MOVECX,EBX;ПРОПУСКАЕМЫЙ ЭЛЕМЕНТ
      JMPS  M3                  ;БЕРЕМ СЛЕДУЮЩИЙ ЭЛЕМЕНТ;---- СДЕЛАЛИ ВЕРШИНОЙ СЛЕДУЮЩИЙ ЭЛЕМЕНТ В СПИСКЕ ----

@:    MOVEAX,[EBX]           ;АДРЕС СЛЕДУЮЩЕГО БЛОКАXOREDX,EDX;ПОКА НЕТ СЛУЖЕБНЫХ ДАННЫХMOV   [ECX],EAX;ЗАПИСАЛИ ЕЕ КАК ВЕРШИНУ СПИСКА;---- ПЕРЕПИСЫВАЕМ ДАННЫЕ ПОЛЬЗОВАТЕЛЯ ОБРАТНО В ПРОГРАММУ ----MOVECX,[EBX]+12        ;СКОЛЬКО БАЙТ ВОССТАНАВЛИВАТЬMOVEDI,[EBX]+08        ;АДРЕС КУДА ВОССТАНАВЛИВАТЬJECXZ М5                  ;НЕТ ВООБЩЕ ДАННЫХ ПОЛЬЗОВАТЕЛЯLEAESI,[EBX]+20        ;АДРЕС ОТКУДА ВОССТАНАВЛИВАТЬ

M4:   SHRECX,1
      JNC   @
      MOVSB
@:    SHRECX,1
      JNC   @
      MOVSW
@:    REPMOVSD;ВОССТАНОВЛЕНИЕ ДАННЫХ;---- ПЕРЕПИСЫВАЕМ СЛУЖЕБНЫЕ ДАННЫЕ ОБРАТНО В ПРОГРАММУ ----

M5:   XCHGECX,[EBX]+16       ;АДРЕС СЛУЖЕБНЫХ ДАННЫХJECXZ  @                  ;УЖЕ НЕЧЕГО ПЕРЕПИСЫВАТЬLEAESI,[ECX]+08        ;ОТКУДАMOVEDI,[ECX]+00        ;КУДАMOVEDX,[ECX]+04        ;СКОЛЬКОXCHGEDX,ECX;ЕСТЬ СЛУЖЕБНЫЕ ДАННЫЕ
      JMPS  M4

;---- ОСВОБОЖДАЕМ КУЧУ ОТ СЛУЖЕБНЫХ ДАННЫХ ----

@:    OREDX,EDX;БЫЛИ СЛУЖЕБНЫЕ ДАННЫЕ ВООБЩЕ ?JZ    @                   ;НЕ БЫЛОPUSHEBXMOVEBX,EDXCALL  ?FREOP
      POPEBX;---- ОСВОБОЖДАЕМ КУЧУ ОТ ДАННЫХ ПОЛЬЗОВАТЕЛЯ ----

@:    CALL  ?FREOP              ;ОСВОБОДИЛИ ПАМЯТЬ ОТ БЛОКА;---- ВОССТАНАВЛИВАЕМ СОСТОЯНИЕ EAX/EBX И ВЫХОДИМ ----

M6:   POPEBX,EAXMOV   ?THREAD_REC,0       ;ОСВОБОДИЛИ СЕМАФОР ПО РЕКУРСИИRET

      DSEG
EXTRN ?RECLST:D,?THREAD_REC:B

Заключение

Приведенная реализация рекурсивных вызовов в программе требует явных действий и накладных расходов при каждом рекурсивном вызове. Однако это позволяет решить проблему «собственных значений», допустимых в языке PL/1. Эта реализация также удобна в случаях, когда рекурсивный алгоритм требует, чтобы локальные данные принимали значения, равные значениям от предыдущего, т.е. «родительского» вызова, что в свою очередь, позволяет не передавать их все как параметры. В этом случае, если бы память для локальных данных выделялась только в стеке, потребовались бы явные операторы присваивания.

Использование для рекурсии не только аппаратного стека, но и памяти «кучи» (которая в 32-х разрядной среде Windows может достигать 3 Гбайт [4]) позволяет увеличить максимально допустимую «глубину» рекурсивных вызовов.

Литература

  1. Караваев Д.Ю. О реализации метода распределения регистров при компиляции. RSDN Magazine #1, 2012
  2. Караваев Д.Ю. К вопросу о совершенствовании языка программирования. RSDN Magazine #4, 2011
  3. Караваев Д.Ю. О реализации контроля целостности структуры «кучи» при выделении памяти. RSDN Magazine #4, 2012
  4. Караваев Д.Ю. О распределении памяти при выполнении теста Кнута. RSDN Magazine #2, 2012


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