Интепретатор Brainfuck

Всем привет!

Я планировал написать одну большую статью про изотерический язык программирования Brainfuck, но как всегда что-то пошло не так и я не успел дописать код. Так что я решил разбить статью на две и первую выпустить под конец 2021.

Давайте же начнём!

Введение

Прежде чем начать нам нужно немного узнать о языке Brainfuck. Информации из вики достаточно для понимания:

Brainfuck — один из известнейших эзотерических языков программирования, придуман Урбаном Мюллером (нем. Urban Müller) в 1993 году, известен своим минимализмом. Название языка можно перевести на русский как вынос мозга, оно напрямую образовано от английского выражения brainfuck (brain — мозг, fuck — иметь половое сношение (оск.)), т. е. заниматься ерундой. Язык имеет восемь команд, каждая из которых записывается одним символом. Исходный код программы на Brainfuck представляет собой последовательность этих символов без какого-либо дополнительного синтаксиса.

У нас в распоряжении есть 8 команд (brainfuck с функциями не будем рассматривать), а именно:

Этих 8-ми команд хватит чтобы реализовать любую программу, т.к. язык полный по Тьюрингу, но наша задача заключается не в написании, а в интерпретации кода на brainfuck.

Давайте же перейдём к планированию нашего интерпретатора.

Структура интерпретатора

Не будем усложнять себе работу и реализуем самый простой интерпретатор.

Работа выполним в два этапа:

Этап разбора токенов

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

Считайте просто что мы символ заменяем на некоторую константу (хотя мы будем использовать перечисление).

Вроде всё просто, но тут есть две интересные команды, которые немного усложняют наш процесс разбора — [ и ].

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

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

Этап выполнения кода

После того как этап разбора токенов закончен, то можно перейти к выполнению программы.

Данный этап можно разбить на три шага:

  1. берём токен по текущему индексу (ip — instruction pointer)
  2. выполняем его
  3. увеличиваем ip

Только нужно сделать небольшое уточнение — команды [ и ] могут менять ip.

Вроде это всё что нужно знать для того чтобы сделать свой интерпретатор brainfuck.

Реализация

Писать рабочий интерпретатор я буду на Rust, но так же приложу код на C++ в конце статьи.

Базовый код

// набор команд
#[derive(Debug, Clone, Copy)]
pub enum Command {
    // >
    Next,
    // <
    Previous,
    // +
    Increment,
    // -
    Decrement,
    // .
    Put,
    // ,
    Read,
    // [
    LoopBegin(usize),
    // ]
    LoopEnd(usize),
}

// наш интерпретатор
pub struct Emulator {
    // вспомогательный буфер для `[` и `]`
    stack: Vec<usize>,
    // спарсенная программа
    app: Vec<Command>,
    // ячейки с памятью программы
    mem: Vec<u8>,
    // указатель на инструкцию
    ip: usize,
    // указатель на ячейку памяти
    mp: usize,
}

impl Emulator {
    // инициализация переменных
    pub fn new(mem_size: usize) -> Emulator {
        Emulator { stack: Vec::new(), mem: vec![0; mem_size], app: Vec::new(), ip: 0, mp: 0 }
    }
}

// токен валидный для brainfuck?
pub fn is_valid_token(t: char) -> bool {
    t == '>' || t == '<' || t == '+' || t == '-' || t == '.' || t == ',' || t == '[' || t == ']'
}

fn main() {
    // создаём объект интерпретатора
    let mut emulator = Emulator::new(30_000);
    // читаем программу из stdin и выполняем её
    match emulator.from_stdin().and_then(|_| emulator.execute()) {
        Err(err) => println!("error: {}", err),
        _ => (),
    }
}

Парсинг

Начнём с преобразования токена в наш enum через реализацию трейта From:

impl From<char> for Command {
    fn from(token: char) -> Self {
        match token {
            '>' => Command::Next,
            '<' => Command::Previous,
            '+' => Command::Increment,
            '-' => Command::Decrement,
            '.' => Command::Put,
            ',' => Command::Read,
            '[' => Command::LoopBegin(0),
            ']' => Command::LoopEnd(0),
            // если дошло до этого кода, то где-то в коде проблема!
            token => panic!("unknown token `{}`", token),
        }
    }
}

Дальше реализуем парсинг токенов:

// --- все эти функции написаны в блоке impl Emulator ---

// чтение программы с stdin
pub fn from_stdin(&mut self) -> Result<(), String> {
    // вывод > без перевода строки
    print!("> ");
    io::stdout().flush().map_err(|e| e.to_string())?;

    // читаем в буфер весь stdin
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer).map_err(|e| e.to_string())?;

    // и отдаём буфер в парсящую функцию
    self.from_buffer(&buffer)
}

pub fn from_buffer(&mut self, buffer: &str) -> Result<(), String> {
    // цикл по токенам
    for token in buffer.chars() {
        // успешно спарсено?
        self.parse_token(token)?;
    }

    // валидируем количество скобок []
    match self.stack.len() > 0 {
        true => Err("number of brackets does not match".to_string()),
        false => Ok(()),
    }
}

// парсинг отдельного токена
fn parse_token(&mut self, token: char) -> Result<(), String> {
    // нам нужны только токены brainfuck
    if is_valid_token(token) {
        // получаем номер текущей ячейки (длина программы)
        let index = self.app.len();
        // преобразуем char в Command через трейт From
        let cmd = match token.into() {
            // если это `[`
            command @ Command::LoopBegin(_) => {
                // то запоминаем индекс команды
                self.stack.push(index);
                // и возвращаем её
                command
            }
            // если `[`
            Command::LoopEnd(_) => {
                // вытаскиваем последний индекс
                let start = match self.stack.pop() {
                    Some(value) => value,
                    None => return Err("loop start not found".to_string()),
                };
                // правим начало цикла устанавливая  V вот этот индекс (он указывает на конец)
                self.app[start] = Command::LoopBegin(index);
                // и добавляем команду с концом цикла, который смотрит на начало
                Command::LoopEnd(start)
            }
            // любые другие команды просто отдаём дальше
            cmd => cmd,
        };
        // добавляем команду в массив программы
        self.app.push(cmd);
    }
    // всё ок
    Ok(())
}

Интерпретация

Теперь же перейдём к коду интерпретации:

// --- все эти функции написаны в блоке impl Emulator ---

// один шаг выполнения
pub fn step(&mut self) -> Result<(), String> {
    // матчим текущую команду
    match self.app[self.ip] {
        Command::Next => {
            // проверка на выход за границы (верхняя граница)
            if self.mp + 1 == self.mem.len() {
                return Err("out of memory".to_string());
            }
            // увеличиваем индекс на ячейку памяти
            self.mp += 1;
        }
        Command::Previous => {
            // проверка нижней границы памяти
            if self.mp == 0 {
                return Err("cannot access negative memory index".to_string());
            }
            // уменьшаем индекс на ячейку памяти
            self.mp -= 1;
        }
        Command::Increment => {
            // добавим к текущей ячейке единицу с учётом возможного переполнения, т.к. у нас ячейки имеют тип u8
            self.mem[self.mp] = self.mem[self.mp].overflowing_add(1).0;
        }
        Command::Decrement => {
            // уменьшаем значение ячейки на единицу (не забываем про переполнение)
            self.mem[self.mp] = self.mem[self.mp].overflowing_sub(1).0;
        }
        Command::Put => {
            // выводит текущее значение в stdout
            print!("{}", self.mem[self.mp] as char);
            io::stdout().flush().map_err(|e| e.to_string())?;
        }
        Command::Read => {
            // берём из потока stdin один байт и записываем в память
            self.mem[self.mp] = match io::stdin().bytes().next() {
                Some(Ok(value)) => value,
                Some(Err(err)) => return Err(err.to_string()),
                None => return Err("cannot read byte from stdin".to_string()),
            }
        }
        Command::LoopBegin(index) => {
            // если в текущей ячейки ноль, то
            if self.mem[self.mp] == 0 {
                // выходим из текущего цикла установкой нового ip
                self.ip = index;
            }
        }
        Command::LoopEnd(index) => {
            // если не ноль, то
            if self.mem[self.mp] != 0 {
                // возвращаемся в начало цикла
                self.ip = index;
            }
        }
    }
    // увеличиваем указатель на текущую инструкцию
    self.ip += 1;

    Ok(())
}

// выполнить всю программу
pub fn execute(&mut self) -> Result<(), String> {
    // выполняем программу, пока ip не выйдет за границы программы
    while self.ip < self.app.len() {
        self.step()?;
    }

    Ok(())
}

Тест

Теперь можно собрать приложение и проверить работу “Hello World!”:

$ echo "++++++++++[>+++++++>++++++++++>+++>+<<<<-]>++.>+.+++++++..+++.>++.<<+++++++++++++++.>.+++.------.--------.>+.>." | ./app
> Hello World!

Больше примеров для проверки можете найти в интернете или написать самим!

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

Заключение

В следующей статье рассмотрим компиляцию brainfuck в исполняемое приложение!

Весь написанный код можно взять в репозитории и там же можно найти C, C++, Go версии.

Всем пока!

Что почитать

  1. Brainfuck
Назад