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

Интерфейс командной строки

Раньше файл lexer обрабатывал как чтение из командной строки, так и чтение текстового файла. Я перенес чтение командной строки в отдельный скрипт. Это позволяет удалить состояние Failure вывода всех наших функций. Вместо этого, если программа чтения командной строки дает сбой, мы просто завершаем программу.

Я также добавил способ обработки аргументов командной строки. В настоящее время существует только один, -pointer_size, и он пока мало что делает, но он будет использоваться для определения размера архитектуры целевой машины (например, 32-разрядная или 64-разрядная). Для разбора используется следующий код:

else if arg.starts_with(compiler_flags[0])
{
    let arg: &str = &arg[compiler_flags[0].len()..];
    if !arg.starts_with("=")
    {
        errors.push(format!("error: compiler flag \"{}\" requires an argument.", compiler_flags[0]));
    }
    else 
    {
        let parsed_arg: Result<u16, ParseIntError> = arg[1..].parse::<u16>();
        if let Err(ParseIntError{..}) = parsed_arg
        {
            errors.push(format!("error: compiler flag \"{}\" requires a numerical argument.", compiler_flags[0]));
        }
        else 
        {
            ptr_size = parsed_arg.ok().expect("should be valid as error handled earlier.");
        }
        if ptr_size >= 2048
        {
            errors.push(format!("error: compiler flag \"{}\" requires an argument less than 2048.", compiler_flags[0]));
        }
        if ptr_size < 8
        {
            errors.push(format!("error: compiler flag \"{}\" requires an argument that's at least 8.", compiler_flags[0]));
        }
    }
}

Если аргумент начинается с -pointer_size (значение compiler_flags[0]), мы удаляем его, убеждаемся, что следующим символом является =, а следующие символы представляют целое число от 8 до 2048. Затем у нас есть код для хранения этого значения:

let ptr_size_bytes: u8 = (ptr_size / 8).try_into().expect("ptr_size maximum is less than 2048");
if ptr_size % 8 != 0
{
    println!("warning: argument of \"{}\" will be rounded down to the nearest multiple of 8", compiler_flags[0]);
}
let ptr_size: usize = <u8 as Into<usize>>::into(ptr_size_bytes) * 8;
if ptr_size > usize::BITS.try_into().expect("max value of usize must be less than the number of bits")
{
    println!("warning: this program is being compiled for a {ptr_size}-bit machine, while this is only a {}-bit machine.", 
        usize::BITS);
}
else if ptr_size < usize::BITS.try_into().expect("max value of usize must be less than the number of bits")
    && file_size > 1 << ptr_size
{
    errors.push(format!("error: the file is too big to compile for a {ptr_size}-bit machine"));
    return CLIOutput::Error(errors);
}
CLIOutput::CLIInfo { file_path, cli_args: [ptr_size_bytes] }

Мы округляем до ближайшего числа, кратного 8, и выводим предупреждение, если это округление было необходимо. Мы также выдаем предупреждение, если мы компилируем для цели с большим размером указателя, чем наша машина. Ни одно из этих предупреждений не препятствует компиляции, но сообщает пользователю важную информацию. Мы также возвращаем ошибку в случае, если файл слишком велик для чтения. Это происходит только тогда, когда размер целевого указателя меньше текущего размера указателя. В этом случае мы сравниваем file_size с 2 в степени ptr_size (используя оператор сдвига влево <<). Если file_size больше, файл слишком велик, так как в нем больше символов, чем может быть проиндексировано.

Существует больше кода для обработки ошибок и прочего, но это не очень полезно. Проверьте репозиторий git, если вам интересно.

Проверка АСТ

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

fn improve_ast(expr: Box<Expression>, parent: Option<Box<Expression>>, errors: &mut Vec<String>, can_compile: &mut bool)
{
    match *expr
    {
        Expression::Binary { ref left, op: _, ref right } =>
        {
            improve_ast(left.clone(), Some(expr.clone()), errors, can_compile);
            improve_ast(right.clone(), Some(expr.clone()), errors, can_compile);
        },
        Expression::Grouping { expr: ref child } =>
        {
            improve_ast(child.clone(), Some(expr.clone()), errors, can_compile);
        },
        Expression::Literal { token } => 
        {
            if let TokenType::IntLiteral(0x8000_0000u32) = token.token_type
            {
                let mut preceded_by_unary: bool = false;
                if let Some(boxed_expr) = parent
                {
                    if let Expression::Unary { .. } = *boxed_expr
                    {
                        preceded_by_unary = true;
                    }
                }
                if !preceded_by_unary
                {
                    errors.push(format!("error (line {}:{}): the int literal {} must be preceded by a unary \'-\' operator",
                        token.line, token.col, 0x8000_0000u32));
                    *can_compile = false;
                }
            }
        },
        Expression::Unary { op: _, expr: ref child } =>
        {
            improve_ast(child.clone(), Some(expr.clone()), errors, can_compile);
        },
        _ => { return; },
    }
}

Здесь мы проверяем дочерние элементы AST, пока не достигнем токена «2147483648». Затем мы проверяем его родителя: если он унарный, мы продолжаем как обычно. В противном случае записываем ошибку. Эту функцию можно запустить сразу после вызова parse. Позже нам придется модифицировать эту функцию для редактирования AST, а пока нужно просто уметь ее читать. Обратите внимание, что хотя 0xF000_0000_0000_0000 равно -2147483648, его отрицание также приводит к тому же значению из-за переполнения.

Сборник

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

pub enum OpCodes
{
    PushInt,
    PopInt,

    MinusInt,

    AddInt,
    SubtractInt,
    MultiplyInt,
    DivideInt,
}

Когда компьютер занимается математикой, мы используем структуру данных, называемую стеком. При использовании стека вы можете добавить что-либо на вершину стека («поместить» значение) или удалить значение из вершины стека и прочитать его («извлечь» значение). Это цель операционных кодов PushInt и PopInt. Есть и другие возможные варианты работы со стеками, такие как peeking (чтение верхнего значения, но не удаление их), но в данный момент они нам не понадобятся. Обратите внимание, что для кода операции PushInt требуется аргумент, поэтому нам нужно добавить значение в байт-код.

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

pub fn compile(parser_ouput: ParserOutput, cli_args: [u8; 1]) -> CompilerOutput
{
    let mut bytecode: Option<Vec<u8>> = None;
    let mut errors: Vec<String> = parser_output.errors.clone();

    if parser_output.can_compile
    {
        let mut byte_list: Vec<u8> = cli_args.to_vec();
        byte_list.append(&mut generate_bytecode(parser_output.expr, cli_args[0]));
        byte_list.push(OpCodes::PopInt as u8);
        if u32::from(cli_args[0]) * 8 < usize::BITS && byte_list.len() >= 1 << (cli_args[0] * 8)
        {
            errors.push("error: could not compile as bytecode was too large.".to_string());
        }
        else
        {
            bytecode = Some(byte_list);
        }
    }

    CompilerOutput { file_text: parser_output.file_text, bytecode, errors }

Если код может быть скомпилирован, то мы добавляем размер указателя к байт-коду, чтобы мы знали, что это такое, до запуска программы. Затем мы генерируем основной байт-код. Наконец, мы извлекаем значение из стека. Этой последней части не будет в окончательном компиляторе; сейчас это просто для помощи в отладке. Мы следим за тем, чтобы этот код был достаточно коротким, чтобы цель могла его проиндексировать. Это не обязательно, но мне нравится гарантировать, что мой код работает без сбоев, независимо от входных данных. Для генерации основного байт-кода мы используем эту функцию:

fn generate_bytecode(expr: Expression, ptr_size: u8) -> Vec<u8>
{
    let mut bytecode: Vec<u8> = Vec::new();
    match expr
    {
        Expression::Binary { left, op, right } =>
        {
            bytecode.append(&mut generate_bytecode(*left, ptr_size));
            bytecode.append(&mut generate_bytecode(*right, ptr_size));
            match op.token_type
            {
                TokenType::Plus => { bytecode.push(OpCodes::AddInt as u8); },
                TokenType::Minus => { bytecode.push(OpCodes::SubtractInt as u8); },
                TokenType::Star => { bytecode.push(OpCodes::MultiplyInt as u8); },
                TokenType::Slash => 
                {
                     bytecode.push(OpCodes::DivideInt as u8); 
                     bytecode.append(&mut op.col.to_le_bytes()[..(ptr_size as usize)].to_vec());
                     bytecode.append(&mut op.line.to_le_bytes()[..(ptr_size as usize)].to_vec());
                },
                _ => { panic!("invalid token found at head of binary expression.")}
            }
        }
        Expression::Grouping { expr: child } =>
        {
            bytecode.append(&mut generate_bytecode(*child, ptr_size));
        }
        Expression::Literal { token } =>
        {
            if let TokenType::IntLiteral(value) = token.token_type
            {
                bytecode.push(OpCodes::PushInt as u8);
                bytecode.append(&mut value.to_le_bytes().to_vec());
            }
            else 
            {
                panic!("all literals should have been accounted for");
            }
        }
        Expression::Unary { op, expr: child } =>
        {
            bytecode.append(&mut generate_bytecode(*child, ptr_size));
            if op.token_type == TokenType::Minus
            {
                bytecode.push(OpCodes::MinusInt as u8);
            }
        }
        _ => panic!("all expression types should have been accounted for"),
    }
    bytecode
}

Всякий раз, когда мы сталкиваемся с выражением, мы сначала создаем байт-код для дочерних выражений. Затем мы добавляем коды операций для соответствующих операций. В случае литерального выражения мы добавляем байты целочисленного значения после кода операции PushInt. Метод to_le_bytes() преобразует целое число в 4 байта с прямым порядком байтов. Endianness - это просто порядок, в котором хранятся байты. В этом случае мы сначала сохраняем младшие байты. На самом деле это не имеет значения: to_be_bytes(), который преобразует целое число в байты с обратным порядком байтов, дал бы ту же функциональность. Однако прямой порядок байтов упрощает преобразование вниз, поскольку нам не нужно изменять байт, с которого мы начинаем.

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

Наконец, если обнаруживается какая-либо форма выражения ошибки, мы паникуем, так как этого никогда не должно произойти. Я переписал основную функцию для вывода байт-кода. Чтобы продемонстрировать функцию обработки ошибок, я использую тестовый файл с текстом 1/2. После запуска cargo run test.txt -pointer_size=8 отображается следующее:

Первая «1» — это количество байтов, равное размеру указателя, в данном случае 1. Затем «0» представляет операцию проталкивания, за которым следуют «1, 0, 0, 0», представляющие значение 1. Затем у нас есть еще один «0», представляющий другое проталкивание, на этот раз за которым следует «2, 0, 0, 0», представляющий значение 2. операция.

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