Изучить принципы работы процессорных архитектур, реализовать цикл «выборка-декодирование-исполнение» (Fetch-Decode-Execute) и разработать программный эмулятор базового набора инструкций RISC-V (RV32I) для запуска программ без операционной системы (bare-metal).
Задача: Описать состояние процессора и подготовить адресное пространство.
Для максимальной простоты память эмулятора будет представлена как плоский массив байтов (буфер). Процессор должен хранить состояние 32 регистров общего назначения и программного счетчика (Program Counter, PC).
Пояснение: В архитектуре RISC-V регистр x0 (zero) аппаратно «зашит» на ноль. Любая запись в него должна игнорироваться, а чтение всегда возвращать 0.
Пример структуры (riscv_cpu.h):
#include <stdint.h>
#include <stdbool.h>
#define MEMORY_SIZE (1024 * 1024) // 1 МБ памяти
#define REG_COUNT 32
typedef struct {
uint32_t regs[REG_COUNT];
uint32_t pc;
uint8_t memory[MEMORY_SIZE];
bool is_running;
} CPU;
// Конструктор инициализации
CPU* cpu_init(void) {
CPU* cpu = malloc(sizeof(CPU));
memset(cpu, 0, sizeof(CPU));
cpu->is_running = true;
return cpu;
}
Критерии приемки: Память и регистры корректно инициализируются, регистр pc указывает на стартовый адрес (например, 0x0).
Задача: Реализовать основной цикл процессора, который читает инструкцию из памяти и передает её на исполнение.
Пояснение: В RISC-V длина стандартной инструкции всегда составляет 32 бита (4 байта), а память адресуется побайтово (Little-Endian). Важный нюанс: счетчик pc должен обновляться с учетом того, что инструкция ветвления (например, JAL или BEQ) может изменить pc напрямую. Самый надежный подход — сдвигать pc на 4 до выполнения инструкции, а инструкциям перехода разрешить перезаписывать это значение.
Пример кода:
uint32_t mem_read_u32(CPU* cpu, uint32_t addr) {
// В реальной реализации здесь нужна проверка выхода за пределы MEMORY_SIZE
return *((uint32_t*)&cpu->memory[addr]);
}
void run_emulator(CPU* cpu) {
while (cpu->is_running) {
// 1. Fetch (Выборка)
uint32_t instr = mem_read_u32(cpu, cpu->pc);
// 2. Сдвиг PC (Делаем до Execute, чтобы переходы могли его перезаписать)
cpu->pc += 4;
// 3. Decode & Execute (Исполнение)
execute(cpu, instr);
}
}
Критерии приемки: Эмулятор успешно читает 32-битные слова из массива памяти и корректно инкрементирует pc, не уходя в бесконечный цикл на одном адресе.
Задача: Написать функцию execute, которая разбирает 32-битную инструкцию на поля (opcode, rd, rs1, rs2, imm) и выполняет соответствующую операцию.
Группы инструкций для обязательной реализации (Минимальный рабочий набор):
Для запуска простых вычислений достаточно реализовать 7 базовых инструкций из спецификации RV32I:
ADD — сложение значений двух регистров.SUB — вычитание.ADDI — сложение регистра с немедленным значением (используется и для псевдоинструкции LI — Load Immediate).LW (Load Word) — загрузка 32-битного слова из памяти в регистр.SW (Store Word) — сохранение 32-битного слова из регистра в память.BEQ (Branch if Equal) — переход по смещению, если значения двух регистров равны (формат B-type).JAL (Jump and Link) — безусловный переход с сохранением адреса возврата (формат J-type).EBREAK (опкод 0x73, инструкция 0x00100073) — использовать как сигнал для завершения работы эмулятора (cpu->is_running = false).Пример декодирования (ADDI и EBREAK):
void execute(CPU* cpu, uint32_t instr) {
uint8_t opcode = instr & 0x7F;
uint8_t rd = (instr >> 7) & 0x1F;
// Защита от записи в zero-регистр
if (rd == 0 && opcode != 0x73 /* не EBREAK */) {
// Выполняем логику, но результат в x0 не сохраняем
}
switch (opcode) {
case 0x13: { // I-type (ADDI)
uint8_t rs1 = (instr >> 15) & 0x1F;
int32_t imm = ((int32_t)instr) >> 20; // Арифметический сдвиг для знакового расширения
if (rd != 0) cpu->regs[rd] = cpu->regs[rs1] + imm;
break;
}
case 0x73: { // System (EBREAK)
cpu->is_running = false;
break;
}
// ... реализация остальных опкодов ...
default:
printf("Неизвестная инструкция: 0x%08X на адресе 0x%08X\n", instr, cpu->pc - 4);
cpu->is_running = false;
break;
}
}
Критерии приемки:
EBREAK.Задача: Встроить скомпилированный машинный код в буфер памяти эмулятора и проверить корректность работы регистров после завершения.
Пояснение: Для тестирования достаточно инициализировать массив заранее подготовленными шестнадцатеричными кодами (машинным кодом), скопировать его в начало памяти memory и запустить цикл.
Тестовая программа:
Реализует эквивалент логики:
li x5, 10 # Загружаем 10 в регистр x5
li x6, 32 # Загружаем 32 в регистр x6
add x7, x5, x6 # x7 = x5 + x6 (10 + 32 = 42)
ebreak # Остановка эмулятора
Машинный код для интеграции в C:
void test() {
CPU* cpu = cpu_init();
// Скомпилированный код тестовой программы выше
uint32_t program[] = {
0x00A00293, // addi x5, x0, 10
0x02000313, // addi x6, x0, 32
0x006283B3, // add x7, x5, x6
0x00100073 // ebreak
};
// Загрузка программы по адресу 0x0
memcpy(cpu->memory, program, sizeof(program));
// Запуск
run_emulator(cpu);
// Проверка результата
assert(cpu->regs[7] == 42);
free(cpu);
}
Критерии приемки: Программа компилируется, эмулятор завершает цикл без зависаний, и в целевом регистре оказывается правильный математический результат.
Написать и запустить программу, которая вычисляет 10-е число Фибоначчи (). Результат должен оказаться в регистре x8.
JAL) и условными переходами (BEQ). Это критический тест для проверки корректности работы программного счетчика (PC). Мы будем использовать итеративный подход.x5 = 0 (F0)
x6 = 1 (F1)
x7 = 10 (Счетчик n)
loop:
if x7 == 0: goto end
x8 = x5 + x6
x5 = x6
x6 = x8
x7 = x7 - 1
goto loop
end:
Ниже представлена скомпилированная программа. Каждая строка — это 32-битное слово, которое нужно поместить в cpu->memory.
| Адрес | Инструкция (ASM) | Комментарий |
|---|---|---|
| 0x00 | addi x5, x0, 0 |
F(n-2) = 0 |
| 0x04 | addi x6, x0, 1 |
F(n-1) = 1 |
| 0x08 | addi x7, x0, 10 |
n = 10 (счетчик) |
| 0x0C | beq x7, x0, 16 |
Если n == 0, прыжок на 16 байт вперед (на ebreak) |
| 0x10 | add x8, x5, x6 |
x8 = x5 + x6 |
| 0x14 | addi x5, x6, 0 |
x5 = x6 (сдвиг) |
| 0x18 | addi x6, x8, 0 |
x6 = x8 (сдвиг) |
| 0x1C | addi x7, x7, -1 |
n = n - 1 |
| 0x20 | jal x0, -20 |
Прыжок назад на адрес 0x0C (смещение -20) |
| 0x24 | ebreak |
Остановка |
JAL (переход назад).ADDI x7, x7, -1.is_running == false) значение в регистре x8 должно быть равно 55 (0x37 в шестнадцатеричной системе), что соответствует 10-му числу Фибоначчи.Совет по отладке: Если эмулятор уходит в бесконечный цикл, проверьте, как ваша функция execute обрабатывает imm в инструкции JAL. Смещение в JAL считается от текущего адреса инструкции. Поскольку в вашем цикле pc увеличивается на 4 до выполнения, убедитесь, что прыжок происходит относительно «старого» адреса или скорректирован под новый.
RISC-V Green Card — официальная памятка с форматами всех инструкций. Обязательна к использованию для определения позиций битов (где находится rs1, rs2, imm). https://www.cs.sfu.ca/~ashriram/Courses/CS295/assets/notebooks/RISCV/RISCV_CARD.pdf
Для расширенного тестирования используйте утилиту riscv64-unknown-elf-objdump -d, чтобы получать Hex-коды из ваших собственных программ на ассемблере.