Изучаем RISC-V с нуля, часть 1: Ассемблер и соглашения


    Издеваться мы будем над микросхемой GD32VF103CBT6, являющейся аналогом широко известной STM32F103, с небольшим, но важным отличием: вместо ядра ARM там используется ядро RISC-V. Чем это грозит нам, как программистам, попробуем разобраться.


    Кратко перечислю характеристики контроллера:


    • Напряжение питания: 2.6 — 3.6 В
      • Максимальная тактовая частота: 108 МГц
      • Объем ПЗУ (flash): 128 кБ
      • Объем ОЗУ (ram): 32 кБ
      • Объем Backup регистров (сохраняемых после сброса): 42 х 16 бит = 84 байта.
      • АЦП+ЦАП: 2 штуки АЦП по 10 каналов и 12 бит каждый плюс 2 ЦАП по 12 бит.
      • Разумеется, куча прочей периферии вроде таймеров, SPI, I2C, UART и т. д.

    Итак, разбираться с контроллером предлагаю с самого нуля — разработки платы и программирования ячеек памяти наэлектризованной иглой… Хотя нет, это уж слишком, обойдемся ассемблером.


    Все исходники, включая схему отладочной платы и примеры кода, можно найти здесь: https://github.com/COKPOWEHEU/GD32VF103_tutor


    1. Печатная плата



    Первым делом — почему именно самодельная плата. Во-первых, из спортивного интереса: если заказывать разработку, сборку и все остальное в Китае, то где же твой собственный вклад? Да и просто удовольствие от ручной работы никто не отменял. Во-вторых, на самодельной плате можно вывести все нужные разъемы и элементы управления. Скажем, очень удобно когда в наличии всегда есть хотя бы пара кнопок и пара светодиодов плюс отладочный разъем UART. А вот сложная периферия вроде энкодеров, датчиков или дисплеев на подобной плате не нужна, ее лучше подключать к разъемам.


    В целом ничего особенно нового моя плата не представляет — разведены кварцы, кнопки, светодиоды, разъемы (на угловом висят PA0 — PA7 плюс пятивольтовое питание и земля, на двухрядном PB8 — PB15 плюс трехвольтовое питание и земля). Наличие пятивольтового питания на разъеме позволяет как запитывать плату от внешнего источника в обход usb (например, от переходника usb-uart), так и наоборот, запитывать от самой платы внешние схемы, которым недостаточно 3.3 В.


    Некоторое внимание заострю на разъеме UART, точнее на его распиновке. В отличие от большинства «фирменных» плат он у меня симметричный, то есть в середине земля, а по краям Rx и Tx. Таким образом можно не запоминать «единственно верную» распиновку, и соединять любую пару устройств простым шлейфом без необходимости перекрещивать провода в нем.
    Естественно, ноги Boot0 и Boot1 выведены на джамперы.


    Больше ничего интересного на плате нет.


    0. Настройка программного окружения


    Разработчики данного контроллера предлагают скачать с их сайта некую IDE. Но мы этого делать не будем: только консоль, текстовый редактор и хардкор.


    Вот краткий список используемого софта. Что приятно, весь софт присутствует в репозитории, ничего качать с сайта GigaDevice не пришлось.


    софт описание
    gcc-riscv64-unknown-elf компилятор
    stm32flash, dfu-util Прошивальщики через bootloader
    kicad Трассировка плат
    screen Отладка по UART

    Отдельно остановлюсь на прошивке контроллера. Основных способов три:


    (1). JTAG — теоретически, самый правильный способ. Вот только подобрать правильное заклинание для него мне так и не удалось
    (2). Bootloader.UART — замыкаем вывод Boot0 на питание, ресетим контроллер (можно по питанию, можно вывести кнопку), после чего через stm32flash (да, прошивать можно утилитой, предназначенной для другого семейства!) прошиваем


    $ stm32flash /dev/ttyUSB0 -w firmware.bin

    Ну и наконец притягиваем Boot0 обратно к земле, снова ресетим и смотрим как работает (или как именно не работает) программа
    (3). Bootloader.USB — аналогичный предыдущему вариант, только вместо stm32flash используется dfu-util:


    $ dfu-util -a 0 -d 28e9:0189 -s 0x08000000 -D firmware.bin

    Только надо помнить, что для USB важна стабильность тактовой частоты, поэтому если для наших первых опытов хватит встроенного RC-генератора, для USB придется поставить внешний кварц.


    Внимательный читатель может заметить, что утилите dfu-util передается некий адрес. Он соответствует началу реальной флеш-памяти контроллера. В нормальном режиме работы этот адрес отображается также и на нулевой адрес, и оттуда же начинается выполнение кода. Если же замкнуть Boot0 на питание, то на тот же нулевой адрес отображается либо Bootloader, либо оперативная память в зависимости от Boot1. В результате работать с контроллером можно вообще не задействуя его флеш, только из оперативки.


    0,5. Как можно обойтись без небольшого извращения?


    Совместимость с stm32f103 по выводам и части периферии дает некую надежду на возможность портирования кода оттуда без полной переработки. И действительно, простая периферия вроде SPI или DMA (без прерываний!) вполне успешно запустилась после небольших танцев с бубном.
    В работе с регистрами стоит отметить, что GigaDevice предпочитают использовать макросы, в отличие от STMicroelectronics, которые использовали структуры. Плюс нумерация с нуля вместо единицы. Приведу пару примеров:


    GD32VF103 STM32F103
    RCU_APB2EN |= RCU_APB2EN_SPI0EN; RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
    SPI_DATA(SPI_NAME) = data; SPI1->DR = data;
    DMA_CHCNT(LCD_DMA, LCD_DMA_CHAN) = size; DMA1_Channel3->CNDTR = size;

    Здесь сразу бросается в глаза что в RISCV регистр SPI_DATA представлен макросом, в который можно подставить номер используемого модуля SPI. И это очень классно! Можно где-нибудь в заголовочнике объявить что используем SPI0, что он висит на DMA0 на канале 2 и препроцессор сам все подставит без всяких накладных расходов.


    В результате на основе вот этого проекта (https://habr.com/ru/post/496046/ исходный код тут: https://github.com/COKPOWEHEU/stm32f103_ili9341_models3D) получилась такая демка:



    Исходный код тут: https://github.com/COKPOWEHEU/RISCV-ili9341-3D


    1. Первый проект


    Стартует контроллер сразу после подачи питания, но за неимением стандартных средств ввода-вывода, привычных любому программисту — экрана, клавиатуры или хотя бы терминала — придется приложить некоторые усилия чтобы вообще определить живой камень или нет. Для этого традиционно используется мигание светодиодом (привет отладочным платам, на которых его нет!).


    Однако и это не так просто. В целях экономии энергии сразу при включении вся периферия, включая логику портов ввода-вывода, отключена. Чтобы ее включить надо выставить в регистре RCU_APB2EN (адрес 0x40021018) бит RCU_APB2EN_PxEN, где x — буква порта. В моем случае, поскольку светодиоды висят на PB5 — PB7 это бит RCU_APB2EN_PBEN (3-й бит, он же битовая маска 0x8). Причем было бы неплохо сохранить состояния всех остальных битов.


    la  a5, 0x40021018
    lw  a4, 0(a5)
      ori   a4, a4, 8
    sw  a4, 0(a5)

    Регистры a4, a5 взял просто от балды, никакого хитрого умысла тут нет. Можно было взять любые другие.


    Но если вы покажете такой код программисту, он тут же захочет дать вам по рукам за использование магических констант. Чтож, исправим это:


    .equ RCU_APB2EN, 0x40021018
    .equ RCU_APB2EN_PBEN, (1<<3)
    
    //RCU_APB2EN |= RCU_APB2EN_PBEN
      la a5, RCU_APB2EN
      lw    a4, 0(a5)
        ori a4, a4, RCU_APB2EN_PBEN
      sw    a4, 0(a5)

    Вот теперь код вполне пригоден для чтения. Но он по-прежнему не делает ничего полезного, ведь недостаточно логику портов ввода-вывода включить, ее еще нужно настроить. Согласно документации, режим работы порта задается четырьмя битами регистра GPIOх_CTL. Но поскольку ножек ввода-вывода у нас 16, получается 64 бита, а регистры всего 32-битные. Поэтому вслед за STmicroelectronics разработчики нашего контроллера разбили эту группу битов на два регистра. Для порта B это будут GPIOB_CTL0 и GPIOB_CTL1: в первом настраиваются порты PB0 — PB7, во втором PB8 — PB15. Четыре бита соответствуют 16 возможным состояниям, из которых нас пока интересует только обычный выход на максимальной скорости (на отладочной плате нет смысла экономить энергию). Также сразу укажем, что светодиоды висят на 5 — 7 выводах, а кнопки на 0 и 1:


    .equ GPIOB_CTL0,        0x40010C00
    .equ GPIO_MASK,     0b1111
    .equ GPIO_PP_50MHz, 0b0011
    
    .equ RLED, 5
    .equ YLED, 6
    .equ GLED, 7
    .equ SBTN, 0
    .equ RBTN, 1

    Как было сказано раньше, нулевому выводу соответствуют 4 бита регистра GPIOB_CTL0: [0, 1, 2, 3], первому биту [4, 5, 6, 7]. Соответственно, за интересующий нас 5-й бит, на котором висит красный светодиод, отвечают [20, 21, 22, 23]. Ну а чтобы не высчитывать биты вручную, заставим заниматься этим препроцессор. Если бы мы писали на Си, этот код выглядел бы так:


    GPIOB_CTL0 = (GPIOB_CTL0 &~(0b1111<<(RLED*4))) | 0b0011 << (RLED*4);

    То есть сначала нужные нам 4 биты затираются нулями, а потом на их место побитовым ИЛИ записывается новое значение. Но мы пока пишем не на Си, а на ассемблере, тут эта строчка получается чуть длиннее:


    la a5, GPIOB_CTL0
    lw  a4, 0(a5)
      la  a6, ~(GPIO_MASK << (RLED*4))
      and a3, a4, a6
      la  a4, (GPIO_PP_50MHz << (RLED*4))
      or    a4, a4, a3
    sw  a4, 0(a5)

    Но и этого пока недостаточно для мигающего диода. Мы включили порт, настроили его. Осталось записать туда 0 или 1, подождать какое-то время и записать другое значение и так по кругу. За выходное значение порта отвечает регистр GPIOB_OCTL, который мы будем читать, XOR`ить 5-й бит и записывать обратно. Ну а задержку реализуем тупо вычитанием единицы из регистра счетчика.


    Собственно, вот весь код:
    .equ RCU_APB2EN, 0x40021018
    .equ RCU_APB2EN_PBEN, (1<<3)
    .equ GPIOB_CTL0, 0x40010C00
    .equ GPIO_MASK, 0b1111
    .equ GPIO_PP_50MHz, 0b0011
    .equ GPIOB_OCTL, 0x40010C0C
    
    .equ RLED, 5
    .equ YLED, 6
    .equ GLED, 7
    .equ SBTN, 0
    .equ RBTN, 1
    
    .text
    .global _start
    _start:
      //RCU_APB2EN |= RCU_APB2EN_PBEN
      la a5, RCU_APB2EN
      lw    a4, 0(a5)
        ori a4, a4, RCU_APB2EN_PBEN
      sw    a4, 0(a5)
    
      //GPIOB_CTL0 = (GPIOB_CTL0 & (0b1111<<RLED*4)) | 0b0011 << (RLED*4)
      la a5, GPIOB_CTL0
      lw    a4, 0(a5)
        la  a6, ~(GPIO_MASK << (RLED*4))
        and a3, a4, a6
        la  a4, (GPIO_PP_50MHz << (RLED*4))
        or    a4, a4, a3
      sw    a4, 0(a5)
    
    MAIN_LOOP:
      //GPIO_OCTL(GPIOB) ^= (1<<RLED)
      la a5, GPIOB_OCTL
      lw    a4, 0(a5)
        xori    a4, a4, (1<<RLED)
      sw    a4, 0(a5)
    
      //sleep
      la a5, 200000
    sleep:
      addi  a5, a5, -1
      bnez a5, sleep
    
      j MAIN_LOOP

    Число 200000 в цикле задержки было подобрано экспериментально. Именно столько итераций нужно чтобы светодиод мигал не слишком быстро и не слишком медленно.


    Сразу же отмечу, что при ручном управлении ножками порта использовать OCTL не рекомендуется, поскольку работа с ним возможна только в режиме чтение-модификация-запись. Плюс в середину может вклиниться прерывание (о чем поговорим позже), но для отладочной мигалки сойдет. Правильным же способом является использование регистра GPIOx_BOP: старшие 16 бит отвечают за стирание битов OCTL в 0, а младшие — за выставление в 1. Есть еще регистр GPIOx_BC, эквивалентный старшим битам BOP, так что я не слишком понимаю зачем он нужен. Для оптимизации разве что. Причем важно отметить, что влияние на эти регистры оказывает только запись единиц. Запись нулей ни на что не влияет. То есть если мы запишем


    .equ GPIOB_BOP, 0x40010C10
    …
    la a5, GPIOB_BOP
      la a4, (1<<YLED) | (1<<RLED*16)
    sw a4, 0(a5)

    то желтый светодиод загорится, а красный погаснет.
    Но повторюсь, в ассемблерных примерах мы этого делать не будем, тут хватит OCTL`а.
    Скомпилировать полученный код можно стандартным компилятором gcc:


    $ riscv64-unknown-elf-gcc -march=rv32imac -mabi=ilp32 -mcmodel=medany -nostdlib main.S -o main.elf

    Флаги в начале указывают точную архитектуру и расширения нашего ядра, флаг -nostdlib специфичен для компилятора Си (а не ассемблера) и говорит не подставлять стандартный Си`шный код инициализации памяти. Теперь полученного эльфа надо сконвертировать в бинарный формат чтобы утилита прошивки могла с ним работать. Также дизассемблируем его чтобы посмотреть, как препроцессор развернет наши инструкции, адреса и константы (это более актуально для кода на Си, но и нам сгодится), ну и собственно прошить:


    $ riscv64-unknown-elf-objcopy -O binary main.elf main.bin
    $ riscv64-unknown-elf-objdump -D -S main.elf > main.lss
    $ stm32flash /dev/ttyUSB0 -w main.bin

    Для удобства я позволил себе оформить все эти команды в общий makefile.


    Не забывайте, что в современных системах доступ к COM и USB портам считается опасным действием и разрешен только руту. Впрочем, повседневное написание прошивок для платки под рутом еще опаснее, поэтому лучше добавить своего пользователя в группу dialout. Если же вы предпочтете пользоваться прошивкой по USB через dfu-utils, нужно прописать правило udev для устройства 28e9:0189.


    2. Работа с кнопкой


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


    Заголовок спойлера
    .equ GPIO_MASK,     0b1111 # маска для стирания ненужных битов
    #input
    .equ GPIO_ANALOG,       0b0000 # аналоговый вход
    .equ GPIO_HIZ,      0b0100 # цифровой вход
    .equ GPIO_PULL,     0b1000 # вход с подтяжкой к питанию или земле
    .equ GPIO_RESERVED, 0b1100 # зарезервировано, не использовать
    #output, GPIO, ручное управление
    .equ GPIO_PP10,     0b0001 # push-pull выход, максимальная частота 10 МГц
    .equ GPIO_PP2,      0b0010 # -//- частота 2 МГц
    .equ GPIO_PP50,     0b0011 # -//- частота 50 МГц
    .equ GPIO_OD10,     0b0101 # open-drain выход, максимальная частота 10 МГц
    .equ GPIO_OD2,      0b0110 # -//- частота 2 МГц
    .equ GPIO_OD50,     0b0111 # -//- частота 50 МГц
    #output, AFIO — альтернативная функция, портом управляют аппаратные модули
    .equ GPIO_APP10,        0b1001 # push-pull выход, максимальная частота 10 МГц
    .equ GPIO_APP2,     0b1010 # -//- частота 2 МГц
    .equ GPIO_APP50,        0b1011 # -//- частота 50 МГц
    .equ GPIO_AOD10,        0b1101 # open-drain выход, максимальная частота 10 МГц
    .equ GPIO_AOD2,     0b1110 # -//- частота 2 МГц
    .equ GPIO_AOD50,        0b1111 # -//- частота 50 МГц

    push-pull это режим порта, при котором внутренняя схема замыкает вывод либо на землю, либо на питание. То есть на выходе порта всегда либо 0, либо 1 в зависимости от содержимого регистра GPIOx_OCTL.
    
    open-drain это режим, при котором внутренняя схема может замыкать только на землю, но не на питание. То есть на выходе либо 0, либо неизвестно что. Такой режим используется для соединения выводов в «монтажное И» либо, скажем, для I2C шины. Управляется регистром OCTL.
    
    pull-up, pull-down это дополнительные подтягивающие резисторы, подключаемые либо между выводом и питанием (pull-up), либо между выводом и землей (pull-down). Они также управляются регистром GPIOx_OCTL.
    
    На что влияет частота порта я не особенно особенно. Наверное, на максимальную частоту переключения и соответственно на потребляемый ток. То есть если хотите сделать устройство более экономичным, частоту снижаем. Я же здесь этого делать не буду.

    3. Регистры и функции


    До сего момента мы пользовались регистрами как попало. Настало время поговорить об их роли в нашем контроллере. С одной стороны все они равноправны, для любой цели можно использовать любой. Но с другой, явное назначение некоторым из них специальных функций упрощает стыковку отдельных подпрограмм. Итак:


    псевдоним имя назначение сохранение
    zero x0 Вечный и неизменный ноль n/a
    ra x1 Адрес возврата нет
    sp x2 Stack pointer, указатель стека да
    gp, tp x3, x4 Регистры для нужд компилятора. Лучше их вообще не использовать n/a
    t0-t6 x5-x7, x28-x31 Временные регистры нет
    s0-s11 x8, x9, x18-x27 Рабочие регистры да
    a0-a7 x10-x17 Аргументы функции нет
    a0, a1 x10, x11 Возвращаемое значение функции нет

    Регистр zero предназначен для получения нуля, либо для сбрасывания в него результата вычисления, которое нам не нужно. Аналог /dev/zero и /dev/null в одном лице
    О регистрах ra и sp поговорим чуть-чуть позже.


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


    Временные регистры t0 — t6 предназначены для хранения промежуточных результатов вычислений и не обязаны сохраняться при вызове функций. Это сделано для того чтобы простые функции не заморачивались сохранением всех регистров на стеке, а потом еще и восстановлением.


    Рабочие регистры s0 — s11 напротив сохраняются при вызове функций. Они нужны для обратной задачи — воспользоваться ранее вычисленным значением и как-то объединить его с результатом функции и при этом опять же обойтись без лишнего использования стека.
    Регистры обмена a0 — a7 используются для передачи параметров в функцию и обратно.
    Очевидно, функция их должна менять, так что после вызова функции их значения не сохраняются. Интересно, что для возвращаемого значения используются только a0 и a1, а портить функции разрешено все.


    О соглашениях использования регистров поговорили, пора функцию написать, а потом и вызвать. Примером функции будет задержка в 200`000 циклов, которая пока что вписана прямо в основной цикл программы. Давайте ее оформим как функцию. Принимать она должна время (в циклах) и ничего не возвращать. Отлично, значит аргумент будет храниться в a0. Помимо него можно как угодно портить a1 — a7 а также t0 — t6, но нам это пока без надобности. А вот остальные регистры портить нельзя, не забываем об этом.


    Главное особенностью функций является то, что их код находится только в одном месте, но может вызываться из разных. Как же нам узнать в какую именно из точек вызова вернуться? Для этого соглашением предусмотрен специальный регистр ra, в который при выполнении соответствующей инструкции (jal, jalr или псевдоинструкции call, которая разворачивается в одну из предыдущих) происходит сохранение текущего адреса выполнения, после чего выполнение переходит на функцию. Соответственно, когда функция завершается, ей достаточно перейти по адресу, хранящемуся в ra при помощи инструкции jr ra или обертки ret. Так и запишем, не забыв заменить в теле функции регистр a5 на a0:


    ...  
      la a0, 200000
      call sleep
    ...
    sleep:
      addi  a0, a0, -1
      bnez a0, sleep
    ret

    4. Стек


    Но что же делать если мы пишем рекурсивную функцию, которая должна вызывать сама себя? Ведь все копии будут пользоваться одними и теми же регистрами, что явно не пойдет алгоритму на пользу. Или что делать если функция использует большой объем временных данных, который в регистрах просто не помещается?


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


    Из соображений стандартизации была выработана определенная логика работы стека: начинаться он должен с максимально доступного адреса и расти вниз, то есть в сторону меньших адресов. Адрес последнего элемента хранится в специальном регистре sp. В нашем GD32VF103 оперативная память начинается с адреса 0x2000'0000 и насчитывает 32 килобайта, то есть максимально доступный адрес 0x2000'8000, от него и будет расти наш стек.


    Предположим, мы хотим положить в него байты 0x12, потом 0x34 и 0x56, а потом снять последний элемент со стека. Тогда логика их распределения будет следующей:


    адрес Шаг 0 Шаг 1 Шаг 2 Шаг 3 Шаг 4
    0x2000`8000 ← sp
    0x2000`7FFF 0x12 ← sp 0x12 0x12 0x12
    0x2000`7FFE 0x34 ← sp 0x34 0x34 ← sp
    0x2000`7FFD 0x56 ← sp 0x56

    Обратите внимание, что на 4 шаге значение 0x56 никуда не делось, просто оно «вывалилось» за пределы стека и стало обычным мусором.


    Примерно так работает стек в идеальном мире, но реальность накладывает свои ограничения. Так, у RISC-V имеются проблемы в работе с невыровненными данными, то есть адрес каждой ячейки должен быть кратен ее размеру. Нельзя, например, записать 4-байтное число по адресу 0x2000'0002 — только 0x2000'0000 или 0x2000'0004. Впрочем, основное для чего используется стек — хранение регистров при вызове функций, а регистры у нас как раз 4-байтные, так что просто сделаем так чтобы и стек принимал только 4-байтные значения.


    Вторая проблема — прерывания. Мы до них пока не добрались, но рано или поздно доберемся и не хотелось бы получить граблями по лбу на ровном месте. Дело в том, что прерывания, как следует из названия, прерывают нормальный ход программы и заставляют контроллер в спешном порядке прыгать на специальную функцию обработчика прерываний. А произойти это может в любой момент времени, например между записью значения на стек и изменением sp. Что хуже всего, в системах без разделения прав доступа (а мы занимаемся именно такой) стек у основного кода и обработчиков прерываний общий. Это значит, что основной код должен быть написан так, чтобы прерывание, где бы оно ни возникло, не помешало его работе. В случае стека для этого достаточно всего лишь правильно определить порядок операций: мы сначала резервируем место на стеке (уменьшаем sp) и только потом записываем туда данные. С чтением аналогично: сначала данные читаем и только потом освобождаем память — увеличиваем sp.


    Поскольку операции это частые, оформим их в виде макросов:


    .macro push val
      addi sp, sp, -4
      sw \val, 0(sp)
    .endm
    
    .macro pop val
      lw \val, 0(sp)
      addi sp, sp, 4
    .endm

    Ах да, и не забудем в начале программы инициализировать значение sp верхней границей памяти:


    la sp, 0x20008000

    Теперь, если мы хотим оформить нашу функцию sleep совсем по фун-шую, можно сделать так:


    sleep:
      push ra
      push s0
    
      mv s0, a0
    sleep_loop:
      addi  s0, s0, -1
        bnez s0, sleep_loop
    
      pop s0
      pop ra
    ret

    Правда, смысла в этом именно для функции sleep немного: ей хватает регистра a0. Поэтому для демонстрации давайте сделаем вычисление через рекурсию факториа… НЕТ, нормальные люди факториал через рекурсию не вычисляют! Вместо этого сделаем какой-нибудь световой эффект на доступных нам диодах. Честно говоря, я сам не знаю по какому именно алгоритму они будут переключаться, но тем не менее рекурсия там используется. Пример получился немного странный, так что приводить его здесь я не буду.


    Если развернуть макросы push и pop, можно увидеть, что в начале и конце функции регистр sp меняется по 4 раза. Налицо бесполезный расход машинного времени! Еще больше он станет если мы захотим положить на стек какой-нибудь большой массив данных. Но ведь нас никто не обязывает пользоваться именно этими макросами, мы можем сразу зарезервировать нужный объем памяти, даже с запасом, а потом класть туда данные, не заботясь об sp. Например, это можно сделать так:


    func:
      addi sp, sp, -16
      sw ra, 12(sp)
      sw s0, 8(sp)
      sw s1, 4(sp)
      sw s2, 0(sp)
    …
      lw s2, 0(sp)
      lw s1, 4(sp)
      lw s0, 8(sp)
      lw ra, 12(sp)
      addi sp, sp, 16
    ret

    Стоит отметить, что в отличие от «идеального» стека, которому можно либо положить значение на вершину, либо снять оттуда, но нельзя влезть в середину, наш стек реализован поверх обычной оперативной памяти, то есть мы можем там хранить обычные локальные переменные. Единственная проблема — их адрес зависит от значения sp на момент вызова функции, да плюс еще сама функция этот sp меняет. Чтобы уменьшить риск ошибки при подобном относительном доступе, соглашением предусматривается еще один специальный регистр fp — frame pointer (он же s0, так что сохранять его придется). При входе в функцию в него сохраняют значение sp, после чего больше не трогают. Сам sp, как и раньше, используется для работы со стеком, а вот fp служит опорной точкой, относительно которой вычисляются адреса локальных переменных.
    Допустим, нам нужно выделить на стеке 5 переменных плюс регистры ra, fp и, например, s1, s2 и s3. Тогда код сохранения и восстановления может выглядеть так:


    func:
      addi sp, sp, -10*4
      sw fp, 0(sp)
      addi fp, sp, 10*4
      sw ra, -9*4(fp)
      sw s1, -8*4(fp)
      sw s2, -7*4(fp)
      sw s3, -6*4(fp)
      sw zero, -5*4(fp) # — data[0]
      sw zero, -4*4(fp) # — data[1]
      sw zero, -3*4(fp) # — data[2]
      sw zero, -2*4(fp) # — data[3]
      sw zero, -1*4(fp) # — data[4]
    …
      lw s3, -6*4(fp)
      lw s2, -7*4(fp)
      lw s1, -8*4(fp)
      lw ra, -9*4(fp)
      addi sp, fp, -10*4
      lw fp, 0(sp)
      addi sp, sp, 10*4
    ret

    Обратите внимание что не-регистровые данные мы просто инициализировали нулями (в реальном коде вместо этого можно использовать более осмысленные числа или не инициализировать их вообще), но явным образом освобождать не стали. Вместо этого мы сначала сохранили значение sp в fp, а в конце восстановили. Приятным бонусом оказывается то, что количество push'ей может даже не равняться количеству pop'ов и, если повезет, никто не пострадает.


    Впрочем, работа через fp полезна скорее для людей. Компилятору же не составляет никакого труда все высчитывать через sp, а регистр fp использовать как обычный s0.


    5. Храним данные на флешке


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


    Начнем с простого — хранения массива констант. Для этого используется та же флеш-память, что и для исполняемого кода. Для удобства ее иногда выделяют в отдельный сегмент .rodata, но пока мы этим заниматься не будем. Просто объявим в конце нашей программы массив из 4 значений:


    .text
    led_arr:
      .short (0<<GLED | 0<<YLED | 1<<RLED)
      .short (0<<GLED | 1<<YLED | 0<<RLED)
      .short (1<<GLED | 0<<YLED | 0<<RLED)
      .short (0<<GLED | 1<<YLED | 0<<RLED)
    led_arr_end:

    Директива .short означает, что элемент памяти — короткое целое размером 2 байта. О других директивах резервирования места я расскажу чуть позже.


    Ну и заменяем предыдущую рекурсивную мигалку на последовательное чтение из этого массива с выводом на светодиоды:


    MAIN_LOOP:
      la s0, GPIOB_OCTL
      lh s1, 0(s0)
      la s2, ~(1<<GLED | 1<<YLED | 1<<RLED)
    
      la s3, led_arr
      la s4, led_arr_end
    led_loop:
      lh t0, 0(s3)
      and s1, s1, s2
      or s1, s1, t0
        sh s1, 0(s0)
    
      la a0, 300000
      call sleep
    
      addi s3, s3, 2
      bltu s3, s4, led_loop
    
      j MAIN_LOOP

    Здесь стоит отметить две вещи. Во-первых, замена lw на lh при работе с GPIOB_OCTL. Поскольку элементы данных в массиве 2-байтные, как и регистр GPIOB_OCTL, старшие байты вполне можно не писать, это немного сэкономит память. Во-вторых, увеличение адреса в массиве не на 1, а на размер элемента. Если бы мы использовали 32-битные константы, увеличивать пришлось бы на 4 байта, а если байтовые — то на 1.


    6. Переход к оперативке


    В прошлой главе я обмолвился о сегменте .rodata, еще раньше без объяснений ввел сегмент .text. Теперь введем еще два сегмента: .data и .bss. Они оба предназначены для хранения глобальных переменных, но первый инициализируется при включении заранее заданными данными, а второй — нет. Причем с .bss есть еще некая неопределенность: в некоторых источниках его инициализировать и не надо вообще, в других — надо обязательно, причем нулями. Хотя и не хочется заниматься бесполезным копированием нулей, для совместимости с Си сделать это придется.


    Итак, берем предыдущий пример и вместо .text указываем .data, но не спешим прошивать контроллер. Для начала заглянем в дизассемблерный файл res/firmware.lss чтобы убедиться что массив начинается именно из начала оперативной памяти, 0x2000'0000:


    000110ea <__DATA_BEGIN__>:
       110ea:   0020

    Упс, что-то пошло не так. Очевидно, ассемблер не знает где у нашего контроллера начало оперативной памяти. Чтобы ему это указать, создадим файл lib/gd32vf103cbt6.ld, в котором пропишем следующее:


    MEMORY{
        flash (rxai!w) : ORIGIN = 0x00000000, LENGTH = 128K
        ram (wxa!ri) : ORIGIN = 0x20000000, LENGTH = 32K
    }
    
    SECTIONS{
      .text : {
      } > flash
    
      .data : {
      } > ram
    
      .bss : {
      } > ram
    }

    То есть сначала мы указываем начало определенной памяти и ее размер, а потом принадлежность секций к той или иной памяти. Теперь этот файл нужно подсунуть компилятору (точнее, линкеру) при помощи ключа -T:


    riscv64-unknown-elf-gcc -march=rv32imac -mabi=ilp32 -mcmodel=medany -nostdlib -T lib/gd32vf103cbt6.ld src/main.S -o res/main.elf

    Вот теперь данные попали именно туда, куда надо:


    20000000 <led_arr>:
    20000000:   0020

    Но прошивать полученным кодом контроллер все еще рано, ведь мы знаем, что оперативная память тем и отличается от постоянной, что может не сохраняться при отключении питания. Это значит, что перед работой основного кода нам в эту память надо сначала скопировать данные. Для этого компилятор заботливо сохранил наши константы в безымянном сегменте сразу после .text, это можно увидеть если посмотреть непосредственно res/firmware.hex файл.
    :08 0000 00 2000 4000 8000 4000 D8


    Для большего удобства доступа к этим данным добавим в .ld-файл немного магии


    Заголовок спойлера
    MEMORY{
        flash (rxai!w) : ORIGIN = 0x00000000, LENGTH = 128K
        ram (wxa!ri) : ORIGIN = 0x20000000, LENGTH = 32K
    }
    
    SECTIONS{
      .text : {
        *(.text*)
        *(.rodata*)
        . = ALIGN(4);
      } > flash
    
      .data : AT(ADDR(.text) + SIZEOF(.text)){
        _data_start = .;
        *(.data*)
        . = ALIGN(4);
        _data_end = .;
      } > ram
    
      .bss : {
        _bss_start = .;
        *(.bss*)
        . = ALIGN(4);
        _bss_end = .;
      } > ram
    }
    
    PROVIDE(_stack_end = ORIGIN(ram) + LENGTH(ram));
    PROVIDE(_data_load = LOADADDR(.data));

    Теперь мы можем использовать область флеш-памяти начиная с _data_load чтобы инициализировать собственно оперативку. Ах да, раз уж у нас есть внешний файл с адресами памяти, вынесем туда же стек:


    _start:
      la sp, _stack_end
    #copy data section
      la a0, _data_load
      la a1, _data_start
      la a2, _data_end
      bgeu a1, a2, copy_data_end
    copy_data_loop:
      lw t0, (a0)
      sw t0, (a1)
      addi a0, a0, 4
      addi a1, a1, 4
        bltu a1, a2, copy_data_loop
    copy_data_end:
    # Clear [bss] section
      la a0, _bss_start
      la a1, _bss_end
      bgeu a0, a1, clear_bss_end
    clear_bss_loop:
      sw zero, (a0)
      addi a0, a0, 4
        bltu a0, a1, clear_bss_loop
    clear_bss_end:

    Вот теперь наконец наш массив будет корректно читаться из оперативной памяти.


    При создании переменных в секции .bss было бы странно присваивать им какие-то значения (хотя никто не запрещает, просто использованы они не будут). Вместо этого можно использовать директиву-заполнитель .comm arr, 10 (для переменной arr размером 10 байт). Стоит отметить, что использовать ее можно в любой секции, причем резервировать данные она будет только в .bss. Ниже приведены еще примеры объявления переменных различных размеров:


    .byte 1, 2, 3 # три однобайтные переменные со значениями 0x01, 0x02 и 0x03
    .short 4, 5 # две двухбайтные переменные со значениями 0x0004 и 0x0005
    .word 6, 7 # две четырехбайтные переменные 0x0000'0006 и 0x0000'0007
    .quad 100500 # одна восьмибайтная переменная 0x0000'0000'0001'8894
    .ascii "abcd", "efgh" # две переменные по 4 символа (обратите внимание! Терминирующий ноль не добавляется)
    .asciz "1234" # строка "1234\0" - с терминирующим нулем на конце. Обратите внимание что в имени директивы только одна буква 'i'
    .space 10, 20 # ОДНА переменная размером 10 байт, каждый из которых равен 20. Если второй аргумент опущен, переменная по умолчанию заполняется нулями

    Приложение: описания использованных директив ассемблера и его инструкций


    директива аргументы описание
    .align N выравнивание по 2^N. Например, .align 9 это выравнивание на 2^9 = 512 байт
    .bss секция нулевых данных в ОЗУ
    .data секция ОЗУ
    .equ name, val присвоить макроконстанте name значение val. Например, .equ RLED, 5 заменит везде в тексте RLED на 5
    .global name глобально видимое имя для стыковки с другими модулями
    .macro / .endm name создание макроса по имени name
    .section name войти в подсекцию name
    .short N[, N[, N…]] объявить одну или несколько переменных размером 2 байта с заданными значениями
    .text секция кода
    .weak name “слабое” имя, которое может быть перекрыто другим
    .word N[, N[, N…]] см. .short, только размер 4 байта

    инструкция аргументы описание
    add rd, r1, r2 rd = r1 + r2
    addi rd, r1, N rd = r1 + N
    and rd, r1, r2 rd = r1 & r2
    andi rd, r1, N rd = r1 & N
    beq r1, r2, addr if(r1==r2)goto addr
    beqz r1, addr if(r1==0)goto addr
    bgeu r1, r2, addr if(r1>=r2)goto addr
    bgtu r1, r2, addr if(r1> r2)goto addr
    bltu r1, r2, addr if(r1< r2)goto addr
    bne r1, r2, addr if(r1!=r2)goto addr
    bnez r1, addr if(r1!=0)goto addr
    call func вызов функции func
    csrr rd, csr rd = csr
    csrrs rd, csr, N rd = csr; csr |= N, атомарно
    csrs scr, rs csr |= rs
    csrs scr, N csr |= N
    csrw csr, rs csr = rs
    ecall провоцирование исключения для входа в ловушку
    j addr goto addr
    la rd, addr rd = addr
    lb rd, N(r1) считать 1 байт по адресу r1+N
    lh rd, N(r1) считать 2 байта по адресу r1+N
    li rd, N rd = N
    lw rd, N(r1) считать 4 байта по адресу r1+N
    mret возврат из обработчика исключения
    mv rd, rs rd = rs
    or rd, r1, r2 rd = r1 | r2
    ori rd, r1, N rd = r1 | N
    ret возврат из функции
    sb rs, N(r1) записать 1 байт по адресу r1+N
    sh rs, N(r1) записать 2 байта по адресу r1+N
    slli rd, r1, N rd = r1 << N
    srli rd, r1, N rd = r1 >> N
    sw rs, N(r1) записать 4 байта по адресу r1+N
    xor rd, r1, r2 rd = r1 ^ r2
    xori rd, r1, N rd = r1 ^ N

    Также checkpoint в комментариях дал ссылку на шпаргалку по инструкциям ассемблера.


    Внезапный конец


    Не хотелось разбивать статью на две, но что поделать. В следующей части рассмотрим как работать с отладочным портом UART, с прерываниями и как стыковать код на ассемблере и Си.

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 38

      0
      Вообще то, на С нормального программиста Ваша строка
      GPIOB_CTL0 = (GPIOB_CTL0 &~(0b1111<<(RLED*4))) | 0b0011 << (RLED*4);

      будет выглядеть совсем по другому, что то вроде
      PortB.Pin5=PinMode_Out+PinMode_OpenDrain;

      хотя еще лучше
      SetPinMode(PortB, Pin5, PinMode_Out_OpenDrain);
        +1
        там ниже будет замена магических чисел 0b1111 и 0b0011 на константы. А в следующей части будет файл макросов, где это делается еще проще:
        #define RLED B, 5, 1, GPIO_PP50
        ...
        GPIO_config( RLED );
        GPO_ON( RLED );
          0
          Вот с таким вариантом я склонен согласится, но то, что Вы написали сначала — «трэш, угар и содомия».
            +5
            Это попытка пошагового описания от констант, указанных в даташите, к более-менее нормальному коду. Оно все — переходные варианты, поэтому много внимания им не уделял. Но вы правы, раз уж заменил номер порта на константу, надо было и битовую константу оформить по-человечески
          0

          Это сишка курильщика. Здоровые атлеты используют K&R и KNF.

          +1
          Предположу, что из готовых платок с этим камушком которые можно относительно дешево и доступно купить на ali это Sipeed Longan Nano. А вот где можно было-бы приобрести сами чипы?
            +1
            Вот например
            Ищите по полному имени gd32vf103cbt6. Там на Али какие-то проблемы с поиском
            +1
            Будет ли обзор RISC ассемблера? Большая часть инструкций непонятна, человеку который никогда с этим ядром не работал. Есть ли возможность как в ARM сохранять на стек или снимать с него сразу пачку регистров? Есть ли возможность выбора направления роста стека?
              +1
              Нормального справочника по ассемблеру я тоже не нашел, что вообще-то странно. Кое-что можно найти в спецификации ядра (например, вот)
              Если вы имеете в виду что приведенные в статье исходные коды непонятны без расшифровки, то да, надо будет не забыть сделать приложение с описанием инструкций.
              Есть ли возможность как в ARM сохранять на стек или снимать с него сразу пачку регистров?

              А вот это в статье описано. Собственно стека в RISCV нет, так что все манипуляции делаются ручками. Хотите чтобы рос вверх — пожалуйста, увеличивайте sp вместо уменьшения. Хотите вместо sp использовать, скажем, a5 — пожалуйста. Просто другие процедуры этого могут не понять. Как и компилятор Си.
                0
                добавил в конец список использованных команд ассемблера
                  +3
                  Собственно стека в RISCV нет, так что все манипуляции делаются ручками.

                  Так же, как и нет всем привычной команды mov. В RISC-V пересылка содержимого одного регистра в другой делается через АЛУ, т.е. mov x1, x2 является частным сучаем инструкции сложения: add x1, x0, x2 — что инструктирует процессор сложить содержимое регистра x2 с содержимым регистра x0, которые всегда содержит константу 0, и сохранить результат в регистр x1.

                  Всем начинающим настоятельно рекомендую прочитать базову спецификацию RISC-V — она очень короткая, всего 218 страниц, с четким и понятным описанием всех основных (unprivileged) инструкций, в отличии от сорока томов ARM-овской или Intel-овской. :-)

                  А вот тут есть хорошая шпоргалка по RISC-V ISA в формате PDF на два листочка.
                    0

                    ниоткуда не следует, что физически процессор будет эту команду выполнять через ALU. он вполне может декодировать сразу такую команду как mov и далее выполнить её тупо переименованием регистров, не доводя до конвеера ALU.

                      0
                      Мощные Риски это может и умеют, но контроллеры вряд ли. Да и чтобы команда добралась до процессорного «мозга» ее надо как-то закодировать, пусть и через addi
                        0

                        Да. В доке на риск-в во многих местах говорится, что вот эти команды можно 'фьюзить' в одну макрооперацию (в данном случае это будет загрузка 32-битной константы в регистр). Ессно в мелочи типа как у вас никто этим не занимается, а вот в жирных суперскалярах такое вполне можно делать. Ну как х86 может mov+op фьюзить в одну 3-адресную внутреннюю операцию.

                        +1
                        ниоткуда не следует, что физически процессор будет эту команду выполнять через ALU.


                        Это хорошо прослеживается из формата инструкции.

                        Теоритически, никто не запрещает реализовать микроархитектуру так, как вам это угодно, главное обеспечить совместимость по системе команд. Но, основной посыл RISC-V это минимизация количества внутренних цепей и повторное использование существующих блоков. Зачем тратиться на дополнительные цепи при реализации копирования регистров если можно прогнать через существующий AЛУ. По этой причение ядра RISC-V очень компактные и очень быстрые (до 5ГГц в железе).
                        0
                        Вы правы, я не делал акцента на псевдоинструкциях вроде mv, call, ret, scrw, la. Не думаю, что с ними будут какие-то проблему у изучающих, особенно если они будут смотреть дизассемблерный файл.
                        А вот за шпаргалку по инструкциям спасибо, добавлю ее в статью и доки на гитхабе.
                    0
                    А готовых библиотек для работы с периферией и поддержкой gcc нет? Про примеры использования и т.п. не спрашиваю, это даже производители армов не всегда предоставляют. Ассемблер чаще нужен для старта контроллера и выскоптимизированных вставок. Для новых архитектур более интересен готовый комплект библиотек и примеров, на котором можно пощупать работу с периферией.
                      0
                      Вот тут не оно? github.com/riscv-mcu/GD32VF103_Firmware_Library
                      Но там нет скриптов сборки, так что непонятно как их вообще компилировать, какие флаги, какой стартап код, как подключить стандартные библиотеки и т.п. В общем, не разобрался я с тамошними примерами, пришлось ковырять с нуля.
                        +1
                        Да один из вариантов для работы с ним.
                        Тут описание как компилить. Я обычно такие проекты пересобираю под qbs, qtcreator как по мне работает получше, чем eclipse. У меня была статейка по qbs можете попробовать по ней сделать сборку в qtcreator. По вопросам могу проконсультировать.
                          0
                          За makefile спасибо, ознакомлюсь. Адаптировать его под eclipse, qbs или qtcreator не буду точно: меня вполне устраивает текстовый редактор и вызов make && make prog && screen из консоли.
                          А вот по чему не отказался бы от консультации, так это по подключению стандартных Си-шной библиотек вроде math или string.
                            0
                            Стандартные библиотеки стандартно идут в комплекте с компилятором. Подключаться должны как и в других случаях.
                            Текстовый редактор удобно, когда у вас один файл. Когда в проекте появляется полный обвес периферии, прокладки между драйвером и основным кодом, в этом случае будет уже тяжело. Но тут каждый сам себе злой ТОС-1.
                              0
                              https://gitlab.com/wicrus/gd32vf103-qbs
                              Накидал примерный проект на основе ссылки выше, проверить не смог готовый gcc не нашёл, а компилить было лень. Может кому пригодиться.
                      0
                      (1). JTAG — теоретически, самый правильный способ. Вот только подобрать правильное заклинание для него мне так и не удалось

                      а мне удалось, как раз на sipeed lognan nano (наверное). достаточно было найти патченный под рискв openocd, собрать под него же gdb и всё. железкой был клон jlink'а. удалось в gdb прошагать работающую программу и полюбоваться на дизасм рискв. очевидно что и остальное получилось бы, но я дальше не стал копать, плату отдал.

                        0
                        Патченный это не интересно :) Но, наверное это действительно единственный способ его завести.
                        +4
                        Автору спасибо, однако он не раскрыл интереснейший ньюанс об который могут споткнуться начинающие программисты-ассемблеристы. Дело в том, что RISC-V, как и большинство других настоящих RISC машин, не имеет инструкций для загрузки 32-битного слова в регистр. Автор использует команду:
                        la  a5, 0x40021018
                        

                        для загрузки 32-х битного адреса порта управления GPIO. На самом деле это не инструкция процессору, а макрокоманда ассемблеру которая при компиляции расширится в нечто вроде:
                        lui a5,0x40021
                        addi a5,a5,24
                        

                        Вроде бы все логично и прозрачно, скажете вы, а вот и не совсем. Все непосредственные операнды в RISC-V попадающие на вход АЛУ проходят через блоки расширения знака, причем блоки эти разные и зависят от формата инструкции. В данном случае 12-ти битная непосредственная константа 24 будет расширена до 32-х бит, при этом старший бит будет скопирован и использован в качестве заполнителя для отсутствующих бит, и дальнейшее сложение в АЛУ будет проведено с учетом знака. У обеих приведенных здесь констант старшие биты равны нулю, а значит после расширения и сложения мы получим искомую положительную константу. Но что произойдет если у обеих констант старший бит будет равен единице? Фактически мы получим сложение двух отрицательных чисел и результ будет не совсем очевидным для программистов начинающих изучать RISC-V.

                        Допустим, мы хотим загрузить константу 0xDCBA9876 в регистр a5, мы даем ассемблеру команду li a5, 0xdcba9876 которая генерируется в следующую неочевиную последовательность инструкций:
                        lui     a5,0xdcbaa
                        addi    a5,a5,-1930
                        

                        В более общем виде, макрорасширение команды li выглядит вот так:
                        # sign extend low 12 bits
                        M=(N << 20) >> 20
                        
                        # Upper 20 bits
                        K=((N-M) >> 12) <<12
                        
                        # Load upper 20 bits
                        LUI x2,K
                        
                        # Add lower bits
                        ADDI x2,x2,M
                        

                        По этому будьте внимательны при дизассемблировании своего же когда. Я наткнулся на эти грабли и протоптался на них дня три, пока понял в чем прикол.
                          +1
                          Что-то мне кажется, вы слишком закопались.
                          Команда lui загружает 0xDCBAA в старшие 20 бит, то есть x5 = 0xDBCAA000 = 3'687'489'536
                          Потом addi вычитает прибавляет к нему -1930. Раз уж в команду можно писать отрицательные числа, наверное она умеет с ними работать правильно и расширяет до нужного диапазона
                          x5 = x5 — 1930 = 3'687'487'606 = 0xDBCA'9876.
                          Для меня гораздо большим выносом мозга была загрузка относительно pc.
                          Впрочем, objdump умеет это сам писать в комментариях что же туда будет сохранено и какой метке / константе оно соответствует:
                          19c: 20000597 auipc a1,0x20000
                          1a0: e6458593 addi a1,a1,-412 # 20000000 <_data_end>

                          0x19C + 0x20'00'00'00 — 412 = 0x20'00'00'00
                          22a: 400117b7 lui a5,0x40011
                          22e: c0078793 addi a5,a5,-1024 # 40010c00 <GPIOB_BASE>

                          0x40'01'10'00 -1024 = 0x40'01'0c'00
                            0
                            это не инструкция процессору, а макрокоманда ассемблеру
                            К слову, аналогом в ARM является псевдо-инструкция MOV32.
                            0
                            Разумеется, если у вас изначально есть понимание и вы держите этот алгоритм в голове — то проблем нет. Мне как человеку программировавшему на асме только под х86 с десяток лет назад было не просто вкурить происходящее. :) Я запнулся на том, что обе константы отрицательные.
                              0
                              Отрицательная в lui не влияет вообще ни на что, ведь она сдвигается влево и старшие биты обрезаются.
                              С addi, конечно, посложнее, но, согласитесь, было бы нелогично чтобы addi a0, t0, -5 складывала с 4091.
                              0

                              кстати, возник ещё вопрос. насколько периферия f103 и этого vf103 одинакова? можно ли тут использовать инклуды с адресами регистров и положениями битов от обычного stm32f103? речь ессно не про NVIC, а про обычную периферию типа RCC, GPIO или того же UARTа.

                                0
                                Некоторую можно. Адреса тех же GPIO и UART совпадают, биты регистров тоже. Но полностью я бы на это не полагался. Да и зачем?
                                Ах да, еще как минимум USB отличается. Но насколько — не знаю, не ковырял пока.
                                0
                                Издеваться мы будем над микросхемой GD32VF103CBT6, являющейся аналогом широко известной STM32F103, с небольшим, но важным отличием: вместо ядра ARM там используется ядро RISC-V.
                                В этом месте ожидал увидеть краткие преимущества ядра RISC-V по сравнению с ARM, чтобы понять имеет ли смысл интересоваться RISC-V.

                                Для этого традиционно используется мигание светодиодом (привет отладочным платам, на которых его нет!).
                                Неужто такие платы существуют?

                                Не забывайте, что в современных системах доступ к COM и USB портам считается опасным действием и разрешен только руту.
                                О том, что используемый софт предназначен для Linux следовало упомянуть раньше, в разделе «Настройка программного окружения».
                                  0
                                  В этом месте ожидал увидеть краткие преимущества ядра RISC-V по сравнению с ARM, чтобы понять имеет ли смысл интересоваться RISC-V.
                                  А я их не знаю :) Ну то есть теоретически у risc более приятный ассемблер, но программируют-то все равно чаще на Си, так что какая разница. Теоретически, он быстрее, но именно vf103 в сравнении с f130 выигрывает скорее за счет каких-то аппаратных ухищрений. А аналогов чего-то другого GigaDevice не делает. Так что просто другая архитектура, почему бы ее не рассмотреть.
                                  Неужто такие платы существуют?

                                  Та же bluepill, у которой единственный диод на PC13, та же arduino, у которой диоды на TX, RX и SCK.
                                  О том, что используемый софт предназначен для Linux следовало упомянуть раньше, в разделе «Настройка программного окружения».
                                  Насколько я знаю, под Windows есть порты всего этого, да еще там вроде виртуалку линуксовую завезли.
                                  С другой стороны, сколько статей я здесь читал, никто не делает акцента что софт предназначен, скажем, для windows. Вот разработчики на Mac иногда упоминают.
                                    0
                                    А существенных преимуществ для разработчика систем у RISC-V перед ARM и нет.
                                    Где то чуть (ну совсем чуть-чуть) лучше, где то чуть (тоже чуть-чуть) хуже ARM, потребление сравнимо, быстродействие сравнимо.
                                    Главное преимущество с точки зрения изготовителя — отсутствие необходимости лицензирования, но к нам это не относится, вряд ли готовые кристаллы станут дешевле.
                                      0
                                      Скорее даже наоборот. В микросхемах ведь чем больше партия тем дешевле. А ARM'ов гораздо больше производят.
                                      0

                                      Основное преимущество risc-v — что она реально открытая, можно брать и делать свой проц, не платя денег за лицензию и роялти. А можно купить проц у того, кто его уже сделал. Есть выбор.


                                      И насчёт рута. Можно всегда добавить права доступа в конкретный усб-девайс (по vendor/device ID) в конфиг-файлы udev'а, будет доступно всем. Ну а чтоб в ком лазить, и вовсе достаточно юзера добавить в какую-то группу, забыл как она называется.

                                        +1
                                        dialout, в статье об этом написано, и про usb тоже. В конце первой главы, если точнее.
                                        Основное преимущество risc-v — что она реально открытая, можно брать и делать свой проц, не платя денег за лицензию и роялти.
                                        Хорошо, преимущество для производителей микросхем есть. А для нас преимущество в чем?
                                      0
                                      del

                                      Only users with full accounts can post comments. Log in, please.