Enterprise Muisc Graphics или история о том как я прикручивал Rust для RISC-V процессора YRV-Plus.
Начало истории
Эта история началось с того, что однажды во время наших воскресных встреч в Zoom Юрий Панчул / Yuri Panchul @YuriPanchul охарактеризовал процессор YRV, как “процессор который стоит где-нибудь в термометре” (за точность цитаты не ручаюсь). Сам кейс мне показался интересным: почему бы не взять DHT-11 и не вывести данные от него через ModBus. Так как у нас ПЛИС, то работу с датчиком можно реализовать на SystemVerilog и получаем достаточно простой учебный проект - берем данные из параллельного порта и передаем в последовательный, оба вида портов в YRV есть.
Но в сборнике лабораторных работ Basic Graphics Music (https://habr.com/ru/articles/762108/) есть более интересный пример - это распознавание нот и мелодии. Почему бы в качестве источника данных не использовать флейту, а в качестве последовательного порта не использовать MIDI порт - это ведь последовательный порт? Тем более, что подобные продукты я использовал для обучения игре на фортепиано. И, так согласовав с Юрием идею, proof of concept проекта получился следующий: звук извлеченный из флейты должен быть отображен нотой в MuseScore 4.
Параллельно другая проблема не давала покоя - компилятор и среда разработки для проекта YRV-Plus. При работе над проектом “Ретро-компьютер уровня «Радио-86РК» с RISC-V процессором на плате OMDAZZ” сразу же возник вопрос - где брать компилятор, тем более, что их два. Для себя я конечно его собрал из исходников, но рекомендовать делать это другим - это чересчур. Брать сборки у других проектов - стыдно. Также необходимо было решить вопрос со средой, должно быть не хуже чем у SiFive, но в проекте кроме C, в режиме “умного паяльника” используется еще и Verilog.
Один из руководителей одной из серьезных компаний обмолвился, что для низкоуровневого программирования они начинают потихоньку использовать Rust. Про Rust я на тот момент ничего не знал, кроме того, что это какая-то крутая штука, да и Линус Торвальдс не против Rust. Быстрое гугление показало, что Rust для RISC-V вроде бы есть, и вроде бы источник один, а ставится все одной командой - то что нужно, ну а со средой как нибудь разберемся, главное что это не C/C++ c Eclipse Embedded CDT.
И так решено: в проекте будет RISC-V ядро, а код будет написан на Rust, а все это в шутку назовем - Enterprise Graphics Music.
Аппаратное обеспечение
Про память
После работы с платой OMDAZZ, мне захотелось побольше памяти.
Для реализации проекта была выбрана плата Terasic DE0-CV, любезно предоставленная FPGA-Systems.ru. Современные ПЛИС содержат достаточное количество логических элементов для синтеза RISC-V ядра микроконтроллерного уровня. Ограниченным ресурсом является блочная память. Установленная в плате DE0-CV ПЛИС Cyclone V содержит достаточное количество встроенной памяти - 3080 Kbits.
При всем интересе к GOWIN, на мой взгляд, для лабораторного домашнего процессоростроения и ретрокомпьютинга где основной лимит ресурсов - это блочная память ПЛИС, отладки с микросхемой Cyclone V - самое удачное вложение средств,а вот для конечных коммерческих изделий, как видно из таблицы, GOWIN возможно будет лучшим вариантом.
Плата |
Чип |
Память (бит) |
DE0-CV |
5CEBA4F23C7 |
3383K |
DE10 |
5CSXFC6D6F31C6N |
5440K |
DE1-SoC |
5CSEMA5F31C6 |
4450K |
DE0-Nano-Soc |
5CSEMA4U23C6 |
2640K |
DE10-Nano |
5CSEBA6U23I7 |
5440K |
Tang Nano 9K |
GW1NR-9 |
468K |
Tang Nano 20K |
GW2AR-18 QN88 |
828K |
Tang Primer 20K |
GW2A-LV18PG256C8/I7 |
828K |
Отдельно необходимо сказать про серию Nano (и пусть Вас не пугает название SoC). Эти платы значительно беднее в части обвеса, что затрудняло выполнения базовых лабораторных работ например по книге “Цифровой синтез. Практический курс”, но зато продаются по более привлекательной цене. В проекте Basic Graphics Music было сделано подключение модуля клавиатуры и светодиодной индикации TM1638, что позволило получить необходимое количество переключателей и семисегментных индикаторов необходимых для выполнения лабораторных работ, а также был подключен VGA 666 модуль. (Подробнее тут https://habr.com/ru/articles/762108/)
Если SDRAM припаяли, значит это кому-то нужно
Пример Станислава Жельнио @SparF по подключению SDRAM к MIPSfpga долгое время не давал покоя (да и сейчас не дает). Но при реализации проекта хотелось соблюсти два условия: первое - проект должен быть синтезируем в “железе”, второе - кэш будет все таки необходим(но совсем не для конвейера).
Конечно хочется увидеть собственный процессор в реале, не на ПЛИС,и тогда конечно же на ум приходит Sky130 PDK, а также Skywater / Efabless. Но после знакомства со статьей “Yosys - A Free Verilog Synthesis Suite” (https://yosyshq.net/yosys/files/yosys-austrochip2013.pdf) становится понятно что для синтенза процессора небходимо всего четыре элемента, например: буфер, NOR, NAND и NOT. Таким образом ядро может быть синтезировано на 74 или 155 серии. И да, в исходниках Yosys есть такой пример https://github.com/YosysHQ/yosys/blob/master/examples/cmos .
Спроектировать SDRAM-контроллер достойный синтеза в железе задача сложная, поэтому SDRAM можно просто оставить для видео адаптера в виде отдельного модуля, и работать с ним через порты ввода-вывода по определенному протоколу, не изменяя архитектуру микроконтроллера, как это например сделано в Марсоходе - https://marsohod.org/projects/mcy112-prj/429-vga-framebuffer-mcy112
В микроконтроллере в качестве RISC-V ядра используется YRV с 16 битной шиной данный, такое ядро в полтора раза медленней по тестам CoreMark по сравнению с 32 битной версией, но зато реально синтезируемо для работы со статической памятью. На первом этапе решено было не менять шину микроконтроллера и использовать 64Кб ОЗУ. По документации на Embedded Rust такого объема должно хватить на начальном этапе.
Последовательные порты
Было решено заменить дизайн последовательного порта от Монте идущего в составе микроконтроллера на дизайн из книги Понг Чу. Монте свой дизайн в книге не объясняет и, как он сам сказал, дизайн приведен исключительно для примера. А в книге Понг Чу кроме объяснения еще есть и полноценный FIFO, что существенно облегчает работу с консолью. В дальнейшем мне стало интересно, как YRV может использовать другие дизайны последовательных портов, и после некоторых экспериментов я остановился на простом варианте от Ben Marshall.
Сигнал TX enable (wr_uart, uart_tx_en) для модуля UART формируется из сигнала HWRITE шины AHB-Lite, в YRV это сигнал mem_write. Сигнал mem_trans[1:0] равный 2’b11 показывает что выполняется инструкция чтения или сохранения.
io_wr_reg <=mem_write && &mem_trans && (mem_addr[31:16] == `IO_BASE);
Производится запись только последнего байта, выбор которого осуществляется сигналом mem_ble
ld_wdata = io_wr_reg && port7_dec && mem_ble_reg[0] && mem_ready;
Вывод данных из последовательного порта, младший байт данные, старший состояние порта:
assign port7_dat = {5'h0, bufr_ovr, bufr_full, bufr_empty, rx_rdata};
MIDI интерфейс не подразумевает вывода по этому там все проще:
assign portMIDI_dat = {7'h0, midi_bufr_full, 8'b0};
Последовательных портов в проекте два: первый - консоль ввода вывода, вторая - MIDI интерфейс. Но ниже будет показано, что специальные параметры в виде странного baud rate 32500 для MIDI интерфейса будут не важны.
Общая архитектура
Так как в проекте загрузка программы осуществляется через последовательный порт, для сокращения хвостов загрузка программы осуществляется через консольный порт, режимы работы порта определяются переключателем.
Модуль распознавания нот был заимствован из лабораторной работы 11_note_recognition Basics graphics music , оформлен отдельным модулем и соединен с параллельным портом YRV.
module note
(
input clk,
input rst,
input [ 23:0] mic,
output [w_note - 1:0] o_note
);
Rust и RISC-V
Сборка и загрузка программы
Для сборки кода на Rust под архитектуру RISC-V необходимо переключится на ночной канал выпуска Rust
$ rustup toolchain install nightly
$ rustup override set nightly
После этого необходимо установить поддержку архитектуры RISC-V. Поддерживаемые архитектуры можно увидеть командой
$ rustc --print target-list
В списке поддерживаемых архитектур нет архитектуры riscv32ic, поэтому на этом этапе остановимся архитектуре riscv32i
$ rustup target add riscv32i-unknown-none-elf
Дальнейшая сборка осуществляется командами
$ cargo build -Z build-std=core --target riscv32i-unknown-none-elf --release
Для изготовление прошивки воспользуемся objcopy из состава GCC и утилитой bin2hex от от SiFive:
$ riscv-none-elf-objcopy.exe -O binary ../target/riscv32i-unknown-none-elf/release/app app.bin
$ python bin2hex/freedom-bin2hex.py -w16 app.bin >code.mem16
В этот раз я решил всю сборку сделать под WIndows, поэтому загрузка прошивки осуществляется скриптом load.bat следующего содержания
rem The port number should be adjusted
set a=6
mode com%a% baud=57600 parity=n data=8 stop=1 to=off xon=off odsr=off octs=off dtr=off rts=off idsr=off
type code.mem16 >.COM%a%
Runtime для YRV-Plus
В качестве runtime используется модифицированный крейт riscv-rt (https://github.com/rust-embedded/riscv-rt) достаточно старой версии 0.11.0. В отличии от текущей master ветки, в данной версии стартовый код выполнен в виде отдельного ассемблерного файла asm.S, что позволило обеспечить совместимость с ассемблерным кодом примеров из книги Inside an Open-Source Processor. Конечный стартовый код оформлен в виде файла yrv.S и соответствует системе старта и прерываний процессора YRV. Также был изменен линкер скрипт link.x под вектора прерываний процессора.
Подключение крейта производится стандартно, в файле Cargo.toml проекта указываем зависмость:
[dependencies]
riscv-rt = { path = "../riscv-rt" }
Там же создаем директорию .cargo в которой создаем файл config следующего содержания:
[target.riscv32i-unknown-none-elf]
rustflags = [
"-C", "link-arg=-Tlink.x"
]
[build]
target = "riscv32i-unknown-none-elf"
Так как в RISC-V порты это просто память, то с ними можно работать напрямую в unsafe блоках. Самый просто способ работать не будет, точнее будет, но только один раз:
pub fn tm()-> u16 {
let buffer: &mut u16 = unsafe { &mut *(0xFFFF000A as *mut u16) };
*buffer
}
Для того чтобы это работало необходимо вызвать метод read_volatile()
pub fn get_data_mem()-> u16 {
let addr = 0xFFFF0016u32;
unsafe {
(addr as *mut u16).read_volatile()
}
}
Но мне больше понравился способ работы с портами используя ассемблер, так как анализ кода при помощи objdump показал что компилятор в генерирует такой код:
pub fn get_data()-> u16 {
let mut out_half;
unsafe {
core::arch::asm!(
"lui t0,0xffff0",
"lh {}, 16(t0)",
out(reg) out_half
); }
out_half
}
Запись производится аналогично:
unsafe {
core::arch::asm!(
"lui t0,0xffff0",
"sb {0}, 14(t0)",
in(reg) byte
); }
В отличии от первых двух примеров в данном случае использование unsafe блока хоть как-то оправдано- все таки используем ассемблер, да и работа с портом в этом примере более прозрачна. Необходимый минимум для использования Rust с процессором YRV-Plus получен.
Атомный макрос println!
И так у нас есть последовательный порт, и конечно же жизнь без макроса println! скучна. Тут я обратился к проекту Writing an OS in Rust (https://os.phil-opp.com/), статья VGA Text Mode. И тут выяснилось самое интересное - макрос lazy_static! не реализуем на архитектуре RV32I ввиду отсутствия Atomic операций.
И следующий код из примера не скомпилируется под архитектуру RV32I, так как для реализации Mutex необходима поддержка atomic инструкций
// in src/vga_buffer.rs
use spin::Mutex;
...
lazy_static! {
pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
column_position: 0,
color_code: ColorCode::new(Color::Yellow, Color::Black),
buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
});
}
Ядро YRV частично поддерживает стандарт “A”, но только в части Atomic Memory Operations, но для работы с Mutex необходимо чтобы присутствовала поддержка Load-Reserved/Store-Conditional инструкций.
Инструкция lr.w rd,rs1 загружает слово по адресу в rs1, помещает расширенное знаком значение в rd и регистрирует резервирование по адресу памяти. Инструкция sc.w rd,rs1,rs2 записывает слово в rs2 по адресу в rs1, при условии, что по этому адресу все еще существует действительная резервация, sc записывает ноль в rd в случае успеха или ненулевой код в случае неудачи. Вот тут то и нужен кэш, эти инструкции реализуются при наличии кэша, в случае же его отсутствия в любом случае необходима какая-то структура сохраняющая результаты резервирования. Так как у нас система однопоточная, то результаты резервирования нам не интересны и всегда могут быть успешными.
Декодер для AMO инструкций в процессоре присутствует, а сами AMO инструкции реализованы на выделенном ALU.
always @ (imm_6_reg or ls_amo_add or ls_amo_reg or ls_data_reg or mem_rdat) begin
case ({ls_amo_reg, imm_6_reg[11:7]})
6'b100000: ls_amo_out = ls_amo_add;
6'b100100: ls_amo_out = ls_data_reg ^ mem_rdat;
6'b101000: ls_amo_out = ls_data_reg | mem_rdat;
6'b101100: ls_amo_out = ls_data_reg & mem_rdat;
default: ls_amo_out = ls_data_reg;
endcase
end
Формат LR/SC и AMO идентичен, и если мы посмотрим описание инструкции AMO, то все они загружают данные в регистр по адресу и сохраняют результат арифметической операции обратно в память. Так как у нас однопоточная система, то мы можем реализовать LR/SC на основе AMO.
lr.w
31-27 |
26 |
25 |
24-20 |
19-15 |
14-12 |
11-7 |
6-2 |
1-0 |
00010 |
aq |
rl |
00000 |
rs1 |
010 |
rd |
01011 |
11 |
amoadd.w
31-27 |
26 |
25 |
24-20 |
19-15 |
14-12 |
11-7 |
6-2 |
1-0 |
00000 |
aq |
rl |
rs2 |
rs1 |
010 |
rd |
01011 |
11 |
Таким образом lr.w rd,rs1 можно представить в виде amoadd.w rd,zero,(rs1) Например если мы модифицируем ALU следующим образом то получим необходимую инструкцию. Недостаток состоит в том что будет производится сохранения в память.
always @ (imm_6_reg or ls_amo_add or ls_amo_reg or ls_data_reg or mem_rdat) begin
case ({ls_amo_reg, imm_6_reg[11:7]})
6'b100000: ls_amo_out = ls_amo_add;
6'b100100: ls_amo_out = ls_data_reg ^ mem_rdat;
6'b101000: ls_amo_out = ls_data_reg | mem_rdat;
6'b101100: ls_amo_out = ls_data_reg & mem_rdat;
6'b100010: ls_amo_out = mem_rdat;
default: ls_amo_out = ls_data_reg;
endcase
end
Если мы посмотрим на ALU для AMO то увидим что, инструкция sc.w является разновидностью amoswap только в регистре должно сохранятся нулевое значение. Основное ALU ядра YRV имеет две команды сквозной передачи регистра, именно так и работает передача результатов AMO. Хотя по умолчанию ALU возвращает ‘0 сделаем отдельный сигнал для ALU если исполняется команда sc.w , то необходимо возвращать 0 для этого добавим регистр a_zero_5_reg
assign amo_4_dec_sc = (opc_4_amo && fn3_4_2 && fn7_4_sc);
a_zero_5_reg <= amo_4_dec_sc;
always @ (...) begin
casex ({a_ext_5_reg, a_tst_5_reg, a_xor_5_reg, a_and_5_reg,
a_or_5_reg, a_add_5_reg, a_bin_5_reg, a_ain_5_reg, a_zero_5_reg}) //synthesis parallel_case
9'bxxxxxxxx1: alu_5_out = 32'h0; /* zero for sc */
9'bxxxxxxx1x: alu_5_out = alu_5_ain; /* pass a */
9'bxxxxxx1xx: alu_5_out = alu_5_bin; /* pass b */
9'bxxxxx1xxx: alu_5_out = alu_5_add; /* add/sub */
9'bxxxx1xxxx: alu_5_out = alu_5_ain | alu_5_bin; /* or */
9'bxxx1xxxxx: alu_5_out = alu_5_ain & alu_5_bin; /* and */
9'bxx1xxxxxx: alu_5_out = alu_5_ain ^ alu_5_bin; /* xor */
9'bx1xxxxxxx: alu_5_out = {31'h0, alu_5_tst}; /* test */
9'b1xxxxxxxx: alu_5_out = alu_5_ext; /* external */
default: alu_5_out = 32'h0;
endcase
end
Нестандартная архитектура
Так как в поддерживаемых архитектурах не архитектуры RC32IA, то для этого необходимо создать свой конфигурационный JSON файл
rustc -Z unstable-options --target=riscv32i-unknown-none-elf --print target-spec-json
В полученный JSON файл необходимо добавить флаг atomic операций "features": "+a", так же необходимо указать что это не типовая архитектура "is-builtin": false.
{
"arch": "riscv32",
"cpu": "generic-rv32",
"data-layout": "e-m:e-p:32:32-i64:64-n32-S128",
"eh-frame-header": false,
"emit-debug-gdb-scripts": false,
"features": "+a",
"is-builtin": false,
"linker": "rust-lld",
"linker-flavor": "ld.lld",
"llvm-target": "riscv32",
"max-atomic-width": 32,
"panic-strategy": "abort",
"relocation-model": "static",
"target-pointer-width": "32"
}
Сборка осуществляется командой с указанием конфигурационного JSON файла.
$ cargo build -Z build-std=core --target riscv32ia-unknown-none-elf.json --release
Пример с lazy_static! собирается , и в результате objdump показывает что в результирующем коде есть инструкции LR/SC. Что и требовалось получить.
Global allocator
Для полного счастья нам не хватает только кучи. Для этого необходимо настроить global allocator. Так как наш процессор теперь поддерживает мьютексы то можно использовать стандартный linked_allocator.
Для этого в линкер скрипт добавим раздел heap
.heap (NOLOAD) :
{
_sheap = .;
. += _heap_size;
. = ALIGN(4);
_eheap = .;
} > BRAM
И добавим allocator
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
extern "C" {
static _sheap: u8;
static _heap_size: u8;
}
pub fn init_heap() {
unsafe {
let heap_start = &_sheap as *const u8 as *mut u8;
let heap_size = &_heap_size as *const u8 as usize;
ALLOCATOR.lock().init(heap_start, heap_size);
}
}
Так же необходимо указать чтобы собрался alloc
cargo build -Z build-std=core,alloc --target riscv32ia-unknown-none-elf.json --release
И Ура! у нас собирается следующая конструкция
let mut xs: Vec<u32> = Vec::new();
MIDI
Сама реализация отправки команд MIDI очень проста и не претендует на эталон. Команду MIDI мы сохраним в следующей структуре
pub struct Message {
pub size: usize,
pub command: [u8;3],
}
И инициализируем команды включения - выключения нот:
let note_A_on = Message::new( [0x90,0x45,0x40], 3);
let note_B_on = Message::new( [0x90,0x47,0x40], 3);
...
let note_A_off = Message::new( [0x80,0x45,0x00], 3);
let note_B_off = Message::new( [0x80,0x47,0x00], 3);
И в конструкции match будем включать и выключать ноты (более подробнее в коде)
match note {
4 => midi::send_message(¬e_A_on),
1 => midi::send_message(¬e_B_on),
2048 => midi::send_message(¬e_C_on),
512 => midi::send_message(¬e_D_on),
128 => midi::send_message(¬e_E_on),
64 => midi::send_message(¬e_F_on),
16 => midi::send_message(¬e_G_on),
_ => sleep(1),
};
pub fn write(byte: u8) {
for _ in 0..700 {
unsafe { core::arch::asm!("nop"); }
}
unsafe {
core::arch::asm!(
"lui t0,0xffff0",
"sb {0}, 20(t0)",
in(reg) byte
); }
}
pub fn send_message(message:&Message) {
for n in 0..message.size {
write(message.command[n]);
}
}
На этапе концепции не стал контролировать состояние порта и сделал синхронизацию через NOP.
Основная сложность - это сделать так чтобы MIDI устройство появилось в Windows. Для этого нам необходимо два инструмента loopMIDI (https://www.tobias-erichsen.de/software/loopmidi.html) и Hairless MIDI<->Serial Bridge который мы подключаем в loopMIDI:
Для отладки используем MIDI-OX , и можем увидеть включение - выключения нот
А также получаем работающую консоль
Вывод
Мы модифицировали процессор YRV для того чтобы не иметь проблем с основными примерами на Rust. Как мы увидели поддержка atomic инструкций куда более важна чем поддержка аппаратного умножения или деления. В тоже время если интересует изучение запуска Rust на голом железе, например для написания операционных систем или просто для прохождения курса по алгоритмам и структурам данных на чем-то экзотическом, то нет необходимости покупки микроконтроллера, микроконтроллер может быть синтезирован самостоятельно используя ПЛИС. Более того в этом случае вы можете сконфигурировать любое интересующее вас количество портов, так как ПЛИС в отличие от ASIC отличается развитой разлапистостью.
Ну и конечно видео!
Автор выражает благодарность @YuriPanchul, @KeisN13 , а также команде OTUS по курсу Rust Developer. Professional.
Автор: Дмитрий