In RISC-V Rust

в 16:41, , рубрики: fpga, riscv, Rust, Verilog

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, мне захотелось побольше памяти. 

In RISC-V Rust - 1

Для реализации проекта была выбрана  плата 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

In RISC-V Rust - 2
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 интерфейса будут не важны.

Общая архитектура

 Так как в проекте загрузка программы осуществляется через последовательный порт, для сокращения хвостов загрузка программы осуществляется через консольный порт, режимы работы порта определяются переключателем.

In RISC-V Rust - 3

Модуль распознавания нот был заимствован из лабораторной работы 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(&note_A_on),
            1 => midi::send_message(&note_B_on),
            2048 => midi::send_message(&note_C_on),
            512 => midi::send_message(&note_D_on),
            128 => midi::send_message(&note_E_on),
            64 => midi::send_message(&note_F_on),
            16 => midi::send_message(&note_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:

 

In RISC-V Rust - 4

Для отладки используем MIDI-OX , и можем увидеть включение - выключения нот

In RISC-V Rust - 5

А также получаем работающую консоль 

In RISC-V Rust - 6

Вывод

Мы модифицировали процессор YRV для того чтобы не иметь проблем с основными примерами на Rust. Как мы увидели поддержка atomic инструкций куда более важна чем поддержка аппаратного умножения или деления. В тоже время если интересует изучение запуска Rust на голом железе, например для написания операционных систем или  просто для прохождения курса по алгоритмам и структурам данных на чем-то экзотическом, то нет необходимости покупки микроконтроллера, микроконтроллер может быть синтезирован самостоятельно используя ПЛИС. Более того в этом случае вы можете сконфигурировать любое интересующее вас количество портов, так как ПЛИС в отличие от ASIC отличается развитой разлапистостью.

Ну и конечно видео!

Автор выражает благодарность @YuriPanchul, @KeisN13 , а также команде OTUS по курсу Rust Developer. Professional.

Автор: Дмитрий

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js