Pull to refresh

Немного о JIT-компиляции или пишем оптимизированный интерпретатор Brainfuck

Reading time6 min
Views6.7K
Суть языка Brainfuck в том, что мы всегда бегаем по ячейкам ленты, уменьшая или увеличивая значения в них. В циклах мы можем пробегать из одного конца в другой, что-то подсчитывая, зачастую используя много вложенных циклов. Не трудно догадаться, что интерпретация этого языка относительно медленна. Конечно, на современных компьютерах этого практически не заметно, но… Предлагаю небольшой тест: берите написанный вами интерпретатор, и запускайте вот этот не хитрый код:

>+>+>+>+>++<[>[<+++>-
 >>>>>
 >+>+>+>+>++<[>[<+++>-
   >>>>>
   >+>+>+>+>++<[>[<+++>-
     >>>>>
     >+>+>+>+>++<[>[<+++>-
       >>>>>
       +++[->+++++<]>[-]<
       <<<<<
     ]<<]>[-]
     <<<<<
   ]<<]>[-]
   <<<<<
 ]<<]>[-]
 <<<<<
]<<]>.


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

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

Итак, как всем уже известно, синтаксис языка Brainfuck состоит всего из 8 операторов. В наличии имеется набор ячеек памяти и указатель на текущую ячейку. Фактически, нам необходимо в результате выполнения программы конвертировать код на Brainfuck в ассемблерные команды (их машинные коды) x86 процессора. Для этого неплохо иметь какое-то представление о языке ассемблера, а так же о правилах генерации машинных кодов (опкодов). Каждую команду на Brainfuck мы будем заменять на одну или несколько команд процессора x86.

Прежде всего необходимо создать массив ячеек памяти и получить на него указатель. Это можно сделать с помощью функции динамического выделения памяти. Так, в языке программирования Pascal (а дальнейший код примеров будет на нем), это можно сделать так:
Var
	Stack : Pointer;
Begin
	GetMem(Stack,30000);  //число ячеек в стандартном brainfuck - 30000
End;

Далее, необходимо продумать стратегию обработки команд Brainfuck’a. Я использовал следующую:
Условимся, что в регистре ESI будет храниться указатель на массив ячеек.
mov esi, Stack


Тогда, остальные команды будут:
Brainfuck Assembler Opcode
+ inc byte [esi] FE 06
- dec byte [esi] FE 0E
> inc esi 46
< dec esi 4E
[ cmp byte [esi], 0 80 3E 00
je "]+1" 0F 84 xx xx xx xx или 74 xx
] jmp "[" E9 xx xx xx xx или EB xx


Что касается команд ‘.’ и ‘,’, то тут понадобятся процедура вывода символа и функция вода символа. Их можно реализовать так:

procedure WriteChar(S: Char);
begin
   Write(S);
end;

function ReadChar: Char;
var
   c : char;
begin
   Read(c );
   ReadChar := c;
end;

Затем, можно условиться, что регистр EDX при старте выполнимого кода содержит адрес процедуры печати символа
mov edx, offset WriteChar

а регистр EBX – адрес функции ввода символа:
mov ebx, offset ReadChar

Тогда, обрабатывать ‘.’ Можно следующим образом:
mov al,[esi]  8A 06  ;в регистр AL значение текущей ячейки
cbw   66 98 ;расширить до двойного слова EAX
push eax  50 ;занести в стек параметр процедуры (символ)
call edx  FF D2 ;вызвать процедуру WriteChar

А команду “,” так:
call ebx  FF D3;вызвать процедуру ReadChar
mov [esi],al 88 06; поместить в текущую ячейку код символа

Для простоты заведем статичный массив ExBuf, куда и будем складывать все наши опкоды, а затем передадим управление на начало массива. Переменная ptr будет указателем последнего занесенного опкода.
Var
   ExBuf : Array [1..65535] of Byte;
   Ptr : Word;
   Tmp : LongInt;

Первыми тремя командами в нашем блоке кода будут, как мы условились, установка регистра ESI на указатель ячеек, регистра EDX на указатель процедуры WriteChar и регистра EBX на указатель функции ReadChar. Сделать это можно так:
   ptr := 1;
   ExBuf[ptr] := $BE; inc(ptr);  // mov esi,Stack
   asm
      mov edx,Stack 	
      mov Tmp,edx	
   end;
   Move(Tmp,ExBuf[ptr],4); inc(ptr,4);

   ExBuf[ptr] := $BA; inc(ptr);  // mov edx,offset WriteChar
   asm
      mov edx, offset WriteChar
      mov Tmp, edx
   end;
   Move(Tmp,ExBuf[ptr],4); inc(ptr,4);

   ExBuf[ptr] := $BB; inc(ptr);  // mov ebx,offset ReadChar
   asm
      mov edx, offset ReadChar
      mov Tmp, edx
   end;
   Move(Tmp,ExBuf[ptr],4); inc(ptr,4);


Все, первичная инициализация проведена. Теперь нужно написать процедуру обработки кодов Brainfuck. Для первых команд она может быть такой:
Procedure JITCode(S: Char);
Begin
   Case S of
    '+': Begin
            ExBuf[ptr] := $FE;   //inc byte ptr [esi]
            ExBuf[ptr+1] := $06;
            Inc(ptr,2);
         End;
    '-': Begin
            ExBuf[ptr] := $FE;   //dec byte ptr [esi]
            ExBuf[ptr+1] := $0E;
            Inc(ptr,2);
         End;
    '>': Begin
            ExBuf[ptr] := $46;   //inc esi
            Inc(ptr);
         End;
    '<': Begin
            ExBuf[ptr] := $4E;   //dec esi
            Inc(ptr);
         End;
    '.': Begin
            ExBuf[ptr] := $8A;   //mov al,[esi]
            ExBuf[ptr+1] := $06;
            Inc(ptr,2);
            ExBuf[ptr] := $66;   //cbw
            ExBuf[ptr+1] := $98;
            Inc(ptr,2);
            ExBuf[ptr] := $50;   //push eax
            Inc(ptr);
            ExBuf[ptr] := $FF;   //call edx
            ExBuf[ptr+1] := $D2;
            Inc(ptr,2);
         End;
    ',': Begin
            ExBuf[ptr] := $FF;   //call ReadChar
            ExBuf[ptr+1] := $D3;
            Inc(ptr,2);
            ExBuf[ptr] := $88;   //mov [esi],al
            ExBuf[ptr+1] := $06;
            Inc(ptr,2);
         End;
End;

На вход процедуры поочередно передаем коды на языке Brainfuck, которые преобразуются в машинные кода и постепенно заносятся в массив ExBuf.
Несколько сложнее обстоит дело с командами циклов ‘[‘ и ‘]’. Как все мы помним, у процессоров x86 есть несколько режимов адресации, и соответственно, несколько команд ближних (near) или коротких (short) переходов. Так, если общая длинна опкодов между командой перехода и меткой не превышает 127 байт, можно использовать короткие (short) переходы. Если больше, то уже понадобятся команды ближних переходов (near). Я реализовал это следующим образом: при встрече команды ‘[‘ я генерирую команду je near xxx и запоминаю в дополнительном массиве текущую позицию буфера команд. После, когда в коде встретиться команда ‘]’, я вычисляю разницу между началом цикла и концом (длину байт), и если она меньше 127 байт, я возвращаюсь на запомненную позицию, затираю прежний je near xxx на je short xxx и перебрасываю остаток кода на четыре байта вперед. С учетом этого остается дополнить вышеописанную процедуру JITCode:
    '[': Begin
            ExBuf[ptr] := $80;   //cmp byte ptr [esi],0
            ExBuf[ptr+1] := $3E;
            ExBuf[ptr+2] := $00;
            Inc(ptr,3);
            ExBuf[ptr] := $0F;   //je near xxx
            ExBuf[ptr+1] := $84;
            Inc(ptr,2);
            bstack[bcnt] := ptr;
            inc(bcnt);
            Inc(ptr,4);
         End;
    ']': Begin
            Tmp := ptr - bstack[bcnt-1];
            If Tmp > 122 then
               begin
                  ExBuf[ptr] := $E9;   //jmp
                  Inc(ptr);
                  Inc(Tmp);
                  Move(Tmp,ExBuf[bstack[bcnt-1]],4);
                  Tmp := -Tmp-9;
                  Move(Tmp,ExBuf[ptr],4);
                  Inc(ptr,4);
                  Dec(bcnt);
               end
            else
               begin
                  ExBuf[ptr] := $EB;   //jmp short
                  Inc(ptr);
                  ExBuf[bstack[bcnt-1]-2] := $74; // je short xxx
                  Dec(Tmp,2);
                  Move(Tmp,ExBuf[bstack[bcnt-1]-1],1);
                  Tmp := -Tmp-5;
                  Move(Tmp,ExBuf[ptr],1);
                  Inc(ptr);
                  Move(ExBuf[bstack[bcnt-1]+4],ExBuf[bstack[bcnt-1]],ptr-bstack[bcnt-1]-4);
                  Dec(ptr,4);
                  Dec(bcnt);
               end;
         End;

Вот практически и все, это уже готовая рабочая реализация. Остается совсем не много – записать последней командой ret (возврат из процедуры). Ее опкод – 0xC3:
   ExBuf[ptr] := $C3; //retn

И передать управление на сформированный буфер машинных команд:
   asm
      mov edx,offset ExBuf
      call edx
   end;


Но давайте посмотрим, что можно сделать еще с оптимизацией нашей программы. Идея, которая буквально лежит на поверхности: в коде Brainfuck часто встречаются повторяющиеся однотипные команды, например +++++++++++ или <<<<. Что значит увеличить текущую ячейку на 11 или переместить указатель ячеек на 4 влево. Можно ввести дополнительные символы, которые будут обозначать следующее:

<<<<< = заменяется на p# (prev) сдвиг указателя влево сразу на # значений
>>>>> = заменяется на n# (next) сдвиг указателя вправо сразу на # значений
+++++ = заменяется на i# (inc) увеличение текущей ячейки сразу на #
— = заменяется на d# (dec) уменьшение текущей ячейки сразу на #
[-],[+] = заменяется на z (zero) установка значения ячейки в 0

Такую предобработку можно провести в начале интерпретатора. Порой, это дает существенное уменьшение исходного кода на Brainfuck, и как следствие, уменьшение x86 кода. После данной обработки остается дополнить нашу процедуру JITCode:
    'i': Begin
            ExBuf[ptr] := $80;   //add byte ptr [esi],xx
            ExBuf[ptr+1] := $06;
            ExBuf[ptr+2] := Ord(Buf[i+1]);
            Inc(i);
            Inc(ptr,3);
         End;
    'd': Begin
            ExBuf[ptr] := $80;   //sub byte ptr [esi],xx
            ExBuf[ptr+1] := $2E;
            ExBuf[ptr+2] := Ord(Buf[i+1]);
            Inc(i);
            Inc(ptr,3);
         End;
    'n': Begin
            ExBuf[ptr] := $83;   //add esi,xx
            ExBuf[ptr+1] := $C6;
            ExBuf[ptr+2] := Ord(Buf[i+1]);
            Inc(i);
            Inc(ptr,3);
         End;
    'p': Begin
            ExBuf[ptr] := $83;   //sub esi,xx
            ExBuf[ptr+1] := $EE;
            ExBuf[ptr+2] := Ord(Buf[i+1]);
            Inc(i);
            Inc(ptr,3);
         End;
    'z': Begin
            ExBuf[ptr] := $C6;   //mov byte ptr [esi],0
            ExBuf[ptr+1] := $06;
            ExBuf[ptr+2] := $00;
            Inc(ptr,3);
         End;


Скачать исходный код и скомпилированную Win32 версию можно тут: rghost.ru/4249797

У программы есть ключи:
-c — создать на диске файл OUT.BC, содержащий в себе промежуточный байт-код, после предпроцессора.
-b — создать на диске файл BF.BIN, содержащий в себе сгенерированный массив машинных кодов. Если его открыть в HIEW, то можно увидеть обычные ассемблерные команды.

Всем удачи.
Tags:
Hubs:
+42
Comments37

Articles

Change theme settings