In RISC-V Rust
Средний 16 мин Rust *FPGA *Программирование микроконтроллеров *Производство и разработка электроники *DIY или Сделай сам Кейс
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.
Теги:
- rust
- fpga
- verilog
- riscv
Хабы:
- Rust
- FPGA
- Программирование микроконтроллеров
- Производство и разработка электроники
- DIY или Сделай сам