Макетная плата GD32VF103


Часть 1. Введение


Часть 2. Память и UART


Часть 3. Прерывания


Будем считать, что с архитектурой контроллера и системой команд RISC-V мы более-менее познакомились. То есть не будем их пугаться ни когда надо почитать выхлоп дизассемблера, ни когда надо самостоятельно что-то оптимизировать. Теперь пора наконец перейти к написанию более сложных программ, а значит, и к более компактному языку. Не то чтобы это было сложно делать на ассебмлере, но уж больно много букв.


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


#include <stdint.h>
int main(){
  while(1){
    *((uint32_t*)(0x40010C0C)) ^= (1<<5); //GPIOB_OCTL ^= (1<<RLED);
    for(uint32_t i=0; i<100000; i++)asm volatile("nop"); //цикл задержки, чтобы не слишком быстро мигало
  }
}

Надеюсь, не надо напоминать откуда взялось магическое число 0x40010C0C. По стандарту функция main может не принимать параметров или принимать их два — количество аргументов командной строки и их массив. Командной строки у нас нет, поэтому вызывать будем main(0, NULL); из ассемблерного startup кода:


li a0, 0
li a1, 0
j main

Осталось правильно настроить заклинание компиляции. Для файлов на Си оно, естественно, будет отличаться от ассемблерных. Приводить его здесь смысла не вижу, проще посмотреть в makefile. Ну и разумеется, добавить второй исходник: не только startup (бывший main), но и новый main.


7.1 Человеко-читаемые имена регистров


Контроллеры предназначены для решения реальных задач, а не только изучения ядра RISC-V. Соответственно, производитель заинтересован в как можно более простом написании кода под свою железку, чтобы программист не лазил по даташиту в поисках адресов всех регистров. Увы, некоторые доходят до невменяемости: "вот вам готовые библиотеки, а как они устроены внутри, вас не волнует", или даже "вот вам скриптовый язык, из которого можете дергать наши подпрограммы". К счастью, библиотека для gd32vf103 к таким не относится. Скачать ее можно прямо на официальном сайте производителя в разделе документации по интересующему контроллеру. Причем помимо собственно библиотеки, там находятся еще и примеры, и шаблоны проектов для нескольких IDE (я, правда, не слишком разобрался, как их запускать, да и не слишком-то пытался: makefile и консоль проще). Из них нас интересует каталог Firmware, в котором собственно и прописаны регистры. Причем прописаны достаточно интересным способом. Например, GPIOB_OCTL выглядит как GPIO_OCTL(GPIOB). Если сравнить с записью от stm32, GPIOB->OCTL или ch32, GPIOB->OUTDR, видна некоторая избыточность, но зато gd-шный вариант чуть лучше ложится на макросы.


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


В результате мигалка PB5 становится куда более читаемой: GPIO_OCTL(GPIOB) ^= (1<<5);


7.2 Прерывания


Как мы помним, в RISC-V конвенции для обычных подпрограмм и исключительных ситуаций сильно отличаются. Подпрограммы имеют право не восстанавливать регистры t0t6, a0a7 и возвращаются по ret, тогда как прерывания не имеют права портить вообще никакие регистры общего назначения, а возвращаются по mret.


Чтобы дать компилятору понять, что данная функция является именно прерыванием, в gcc используется атрибут __attribute__((interrupt)), его и используем для описания обработчика прерывания. Логику работы демо-кода менять не будем — пусть отключает сам себя и посылает букву 'U'.


__attribute__((interrupt)) void USART0_IRQHandler(){
  USART_CTL0(USART0) = USART_CTL0_REN | USART_CTL0_TEN | USART_CTL0_UEN;
  USART_DATA(USART0) = 'U';
}

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

Атрибут __attribute__((interrupt)) не обязательно прописывать в самом обработчике, можно вынести список прототипов всех обработчиков в заголовочный файл и прописать его там. Но это неоднозначное решение. С одной стороны, меньше кода потом писать, с другой — с атрибутом прерывания более заметны. Плюс вспомним про __attribute__((interrupt("WCH-Interrupt-fast"))) или __attribute__((naked)), которые в любом случае придется прописывать явно.


Также имеет смысл написать для них заглушки, чтобы компилятор мог подставить в таблицу прерываний хоть какие-то адреса не только для тех, у которых есть нормальный обработчик, но и для незадействованных. А чтобы не пришлось их править каждый раз, когда прерывание становится используемым, пометить их как .weak — это подскажет линкеру заменить их на настоящий обработчик. А вот какой код будет выполнять заглушка — отдельный интересный вопрос. И заключается он в том, как программа будет реагировать на ошибки программиста, иначе говоря, что произойдет, если будет сгенерировано прерывание, а обработчик для него не написан. Самое очевидное — не делать ничего. То есть такой обработчик будет состоять из сброса флага прерывания и mret. Но такой подход только маскирует ошибку. Поэтому второй вариант — напротив, ошибку максимизировать. Зависнуть в бесконечном цикле или ребутнуть контроллер. По-хорошему, еще и в логи что-нибудь написать, но в общем случае мы не знаем каким именно способом они ведутся и ведутся ли вообще. Поэтому добавим еще один weak-обработчик UnhandledInterruptHandler, в котором не будет ничего, кроме бесконечного цикла. А weak он для того, чтобы при написании конкретной программы можно было его заменить чем-то более осмысленным, знающим как именно индицировать ошибки.


7.3 Порядок расположения секций памяти


По большому счету, критичным является только расположение таблицы векторов прерываний, ведь именно там прописан адрес начала кода, и то вместо него допустимо расположить начало ассемблерного (но не Си-шного!) кода. Выполнение начнется с нулевого адреса, а далее будет управляться явными или неявными переходами. Тем не менее, лучше будет явно задать порядок расположения секций в памяти: сначала таблица векторов прерываний, потом стартап, основной код, и в самом конце константы для rodata и инициализации data. Как минимум, приятнее потом будет смотреть в дизасм — сразу понятно что за чем идет. И почти все это в нашем проекте уже реализовано, надо только вынести стартап в отдельную секцию и расположить ее сразу после векторов прерываний. Это делается правкой *.ld файла.


Такой подход кажется гораздо более удачным, чем в stm32. Там таблица векторов обязательно должна быть расположена строго в начале прошивки, поскольку ее первое машинное слово — адрес начала кода, а не первая инструкция, как у нас. Более того, второе машинное слово там тоже играет особую роль, это начальное значение sp. Иначе говоря, в ARM нельзя просто взять и начать писать машинные инструкции с нулевого адреса и пока память не кончится. А в RISC-V можно. Упомянутое мной ограничение, что "лишь бы не Си-шный код" обусловлено только тем, что он не самодостаточен и нуждается в инициализации.

Ну и для полной красоты, теперь, когда в startup.S не осталось ничего интересного, можно спрятать его куда-нибудь в lib/, чтобы не отвлекал. Или просмотреть тот стартап-файл, который предлагает производитель и прописать путь к нему. Возможно, чуть-чуть подправить, не без этого.


7.4 Препроцессорные извращения


ВАЖНО Далее описывается лично мой подход к написанию кода, нигде больше вы его не увидите. Следовать ли ему или нет, личное дело каждого. Я его использую потому, что он достаточно прост, выразителен и компактен, в отличие от ST-шных аналогов вроде ST-HAL или ST-CMSIS. А может, это говорит моя тяга к велосипедизму...


Прямая настройка портов через регистры выглядит примерно так:


// USART_TX=PA9, USART_RX=PA10
GPIO_CTL1(GPIOA) &=~((GPIO_MASK<<(4*(9-8))) | (GPIO_MASK<<(4*(10-8))));
GPIO_CTL1(GPIOA) |= (GPIO_ALT<<(4*(9-8))) | (GPIO_INP<<(4*(10-8)));

Эта запись довольно громоздка и плохо поддается настройке. Ну, например, на отладочной плате у меня светодиод на PB5, а в финальном устройстве на PC13 — это ведь придется весь код перелопачивать в поисках каждого упоминания. Поэтому я давным-давно, еще для AVR, написал библиотеку работы с портами pinmacro.h. Думаю, пример ее использования будет достаточно нагляден:


#define LED B,5,1,GPIO_PP50 //PB5, активный уровень лог.1, режим push-pull 50 МГц
#define BTN B,0,0,GPIO_HIZ //PB0, активный уровень лог.0, режим Hi-Z (высокоомный вход)
...
GPIO_config(LED);
GPIO_config(BTN);
while(1){
  if(GPI_ON(BTN))GPO_ON(LED); else GPO_OFF(LED);
}

Люди, знакомые с макросами, могут заметить, что здесь работа с регистрами портов идет для каждого вывода независимо, то есть по сути многократно дублируется. Но, во-первых, инициализация выполняется единственный раз при старте контроллера, то есть в том месте, где задержки не критичны. И вот на это обращу отдельное внимание: не гоняйтесь за оптимизацией кода, который выполняется единственный раз. А во-вторых, ручная работа с отдельными линиями (так называемый "ногодрыг") был основным занятием на слабых 8-битных контроллерах. А 32-битные уже обладают настолько развесистой периферией, что огромный класс задач способны решать вообще без участия ядра и явной работы с портами. В любом случае, никто не мешает для конкретного применения от макроса отказаться, и дергать регистры вручную.


Исходный код примера доступен на github


8 Таймеры


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


С точки зрения теории жизненно необходимым соответствующий аппаратный модуль не является, ведь всегда можно посчитать такты на каждую инструкцию и, зная тактовую частоту, перевести их в секунды. Однако с усложнением программы, введением прерываний, да и просто удобства, такой подход быстро становится бесполезным на практике. Поэтому разработчики вычислительных схем добавляют в них аппаратные таймеры/счетчики. Далее рассматривать будем в основном GD32VF103, а специфику CH32V303 — упоминать явно.


8.1 Системные таймеры mcycle, mtime (в CH32V303 не поддерживается)


В RISC-V для счета времени используется два счетчика, mcycle для счета тактов, прошедших с момента старта (64-битный счетчик, расположенный в двух регистрах) и mtime — "реальное время", измеряемое в абстрактных единицах и тоже 64-битное. Использование первого никаких проблем не вызывает. В приведенном ниже коде считывается только младшая половина счетчика. При тактовой частоте от 8 до 108 МГц это позволяет измерять интервалы времени примерно от 39 до 356 секунд — для большинства реальных задач более чем достаточно. Более длинные интервалы обычно измеряются уже другими способами. Да хотя бы использованием всех 64 битов.


inline uint32_t read_mcycles(){
  uint32_t res;
  asm volatile("csrr %0, mcycle" : "=r"(res) );
  return res;
}

А вот mtime расположен не в CSR регистрах, а в MMIO начиная с адреса 0xD100'0000. Причем, что самое обидное, этот адрес ни в какой документации не прописан, ковыряйте код примеров. Зато у этого таймера несколько больше возможностей, чем у предыдущего. В качестве тактового сигнала можно задать либо частоту ядра, деленную на 4, либо частоту ядра без деления — за это отвечает бит CLKSRC регистра mtimectl. Также по достижении таймером значения, заданного в регистре mtimecmp может быть сгенерировано прерывание, а значение счетчика может быть сброшено в ноль (бит CMPCLREN регистра mtimectl). Вот только есть одна проблема: ни тот, ни другой биты этого регистра просто не работают! Но хотя бы прерывание генерируется...


Если кто-то хочет подробнее с ними ознакомиться, можно почитать, например, тут:


https://riscv-mcu.github.io/Webpages/CORE_Page2/#3-1 — китайская грамота


https://www.nucleisys.com/upload/file/2020/02/1582893692-9103.pdf — что-то вроде перевода китайской грамоты


https://doc.nucleisys.com/nuclei_spec/isa/timer.html — прочие регистры


Как результат, единственное разумное применение mtime'у, которое я нашел, заключается в таймере для операционной системы. В mtimecmp настраивается период, скажем, 1 кГц, а в обработчике значение mtime сбрасывается в ноль. Ручное управление счетным регистром, разумеется, моментально рушит точность счета, но для переключалки процессов это не критично.


Как бы то ни было, при помощи mcycle можно реализовывать точные задержки вроде вот таких:


#define F_CPU 8000000 //тактовая частота
void delay_cycles(uint32_t cycles){
  uint32_t prev_cycles = read_mcycle();
  uint32_t cur_cycles;
  do{
    cur_cycles = read_mcycle();
  }while( (cur_cycles - prev_cycles) < cycles);
}

void delay_us(uint32_t us){
  uint32_t prev_cycles = read_mcycle();
  uint32_t cur_cycles;
  uint32_t cycles = (us * (F_CPU / 1000000));
  do{
    cur_cycles = read_mcycle();
  }while( (cur_cycles - prev_cycles) < cycles);
}

Обратите внимание на способ задания интервала. Нельзя просто проверить на больше-меньше, потому что 32-битный счетчик переполняется хоть и не быстро, но регулярно. А вот разность переполняться не будет… ну, по крайней мере, пока не достигнет предела 32-битных чисел. Значит, не надо задавать задержку больше 40 секунд.


Аналогично можно выполнять произвольный код с заданным интервалом:


void some_func(){
  const uint32_t period = F_CPU / 1000; //1 kHz
  static uint32_t t_prev = 0;
  uint32_t t_cur = read_mcycle();
  if( (t_cur - t_prev) < period)return;
  t_prev = t_cur;
  //do smth
}

8.2 Системный таймер Systick (специфика CH32V303)


Разработчики WCH пошли другим путем. Вместо реализации требуемых по стандарту RISC-V регистров mcycle, mtime и т.п. они предпочли подход, вероятно, подсмотренный у stm: специальное периферийное устройство systick — таймер, считающий такты ядра (или такты ядра, деленные на 8). Спасибо хоть догадались сделать его 64-битным, а не 24-битным. Таким образом, в использовании он аналогичен рассмотренному выше:


#define SysTick_CTLR_STE    (1<<0) //systick enable
#define SysTick_CTLR_STIE   (1<<1) //interrupt enable
#define SysTick_CTLR_STCLK  (1<<2) //clock source: 0-HCLK/8, 1-HCLK/1
#define SysTick_CTLR_STRE   (1<<3) //auto reload enable
#define SysTick_CTLR_MODE   (1<<4) //up/down. 0-up, 1-down
#define SysTick_CTLR_INIT   (1<<31) //1: Updated to 0 when upcounting, while updated to the comparison value when downcounting.

#define SysTick_SR_CNTIF (1<<0) //interrupt flag

static inline void systick_init(){
  SysTick->SR=0;
  SysTick->CNT=0;

  SysTick->CTLR = SysTick_CTLR_STCLK | SysTick_CTLR_STE;
}

static inline uint32_t systick_read32(){
  return ((volatile uint32_t*)&(SysTick->CNT))[0]; //спасибо наркоманам из WCH, описавшим этот регистр как 64-битный, но не позаботившимся об атомарном доступе
}

static inline uint64_t systick_read64(){
  uint32_t th1 = ((volatile uint32_t*)&(SysTick->CNT))[1];
  uint32_t tl = ((volatile uint32_t*)&(SysTick->CNT))[0];
  uint32_t th2 = ((volatile uint32_t*)&(SysTick->CNT))[1];
  if(th1 != th2)tl = ((volatile uint32_t*)&(SysTick->CNT))[0];

  return(((uint64_t)th2)<<32) | tl;
}

static void delay_ticks(int32_t t){
  uint32_t t_prev = systick_read32();
  while( (systick_read32() - t_prev) < t ){}
}

8.3 Атомарность (внезапное отступление)


Особое внимание обратите на хитрую процедуру чтения CNT[1] : CNT[0]. Дело в том, что счетчик у нас 64-битный, а размер регистров всего 32 бита, и во время чтения счетчик продолжает тикать. Предположим, во время чтения старшего регистра значение было 0x02 FFFF FFFF, и в th1 закономерно попадет 0x02. И тут, во время чтения младшего регистра, произошел очередной тик, и счетчик стал равен 0x03 0000 0000. В tl соответственно попадет 0. То есть считано будет 0x02 0000 0000 — совсем не то, что нужно. Если поменять чтение регистров местами, получится не лучше: 0x03 FFFF FFFF. А вот если считать CNT[1] повторно и таким образом отследить переполнение, уже можно гарантировать правильный результат. По крайней мере, если счетчик не успеет переполниться еще раз, но 32 бита даже на максимальной скорости переполняются редко.


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


Подобный эффект возможен не только при работе с регистрами периферии, но и с обычной памятью, к которой имеют доступ два параллельных процесса. В простейшем случае это основной код и какое-нибудь прерывание. Например, флаг, который вручную взводится в прерывании, а проверяется и сбрасывается в основном коде. И тут проблема не столько в том, что флаг невозможно атомарно считать или записать (это решилось бы запретом прерывания или использованием типов данных, не превышающих машинного слова), а в оптимизациях компилятора. Си предназначен в первую очередь для написания однопоточных программ, то есть его переменные не имеют привычки меняться сами по себе — только из кода. Поэтому компилятор имеет право длинную последовательность операций провести целиком на регистрах, сначала считав туда начальные значения, а в самом конце выгрузив результат обратно в память. К счастью, ему можно указать, что определенные переменные все-таки могут измениться сами по себе, и при каждой операции их надо загружать — сохранять заново. Для этого используется ключевое слово volatile. Если заглянуть в заголовочный файл с описанием регистров периферии, можно увидеть, что они все этим словом помечены. То есть каждая операция с периферией обязательно приводит к операции чтения, записи или обоим. В некоторых случаях этот расход тактов довольно неприятен. Взять хотя бы настройку портов, как в примере выше для настройки ножек UART. Казалось бы, прочитать значение GPIO_CTLx, наложить маску чтобы очистить 4 бита и записать туда новое значение.


GPIO_CTL1(GPIOA) &=~((GPIO_MASK<<(4*(USART_TX-8))) | (GPIO_MASK<<(4*(USART_RX-8))));
GPIO_CTL1(GPIOA) |= (GPIO_ALT<<(4*(USART_TX-8))) | (GPIO_INP<<(4*(USART_RX-8)));

Если посмотреть в дизассемблер, будет видно, что GPIO_CTL1 сначала считывается в регистр, потом на него накладывается маска, потом GPIO_CTL1 перезаписывается (заметьте: неправильным значением!). А потом считывается снова, модифицируется и снова записывается. То есть мало того, что добавились лишнее чтение и запись, но и на короткое время в регистре оказывается промежуточное значение. Именно в случае GPIO это не страшно, но мы ведь его взяли только для примера. Для другой периферии это может быть опасно. Более безопасным будет провести всю работу с регистром за одну операцию. Например, так:


GPIO_CTL1(GPIOA) = ( GPIO_CTL1(GPIOA) &~((GPIO_MASK<<(4*(USART_TX-8))) | (GPIO_MASK<<(4*(USART_RX-8)))) ) | (GPIO_ALT<<(4*(USART_TX-8))) | (GPIO_INP<<(4*(USART_RX-8)));

Это работает, но выглядит ужасно. Поэтому воспользуемся ручной буферизацией:


uint32_t temp = GPIO_CTL1(GPIOA);
temp &=~((GPIO_MASK<<(4*(USART_TX-8))) | (GPIO_MASK<<(4*(USART_RX-8))));
temp |= (GPIO_ALT<<(4*(USART_TX-8))) | (GPIO_INP<<(4*(USART_RX-8)));
GPIO_CTL1(GPIOA) = temp;

Переменная temp — обычная переменная, не volatile, поэтому вот ее компилятор имеет право оптимизировать. Собственно, в реальности под нее даже не будет зарезервировано место в памяти, все операции будут на регистрах.


Подведем итог: все переменные, используемые и в основном коде, и в прерываниях, или в нескольких разных прерываниях, или просто в нескольких потоках многопоточного кода, должны быть объявлены как volatile.


Теоретически, в RISC-V есть еще расширение A (как в наших IMAC, IMAFC), которое добавляет специальные инструкции для обеспечения атомарности. Но это все же опциональное расширение, в некоторых ядрах его может и не быть. Есть специальная инструкция FENCE. Но все это предназначено скорее для процессоров со всеми их оптимизациями, переупорядочиванием инструкций, конвейерами и т.п. В контроллерах я в реальности я ни разу не видел кода, где бы это использовалось. Разве что в операционных системах для межпроцессного взаимодействия?


8.4 Периферийные таймеры-счетчики


Раз уж в контроллере есть примитивный таймер, очевидным представляется и решение производителей как добавить туда еще больше таймеров, так и сделать их более развесистыми. Но тут разнообразие вариантов оказывается уж слишком зашкаливающим, поэтому набор дополнительных таймеров был оставлен на усмотрение производителя конкретной железки, а не ядра. Соответственно и располагаться таймеры/счетчики будут в пространстве MMIO, а не CSR.


Начнем с простого — изучения User Manual'а на наш контроллер. В нем сказано, что таймеров семь: один расширенный, четыре обычных и два урезанных. Для примера рассмотрим Timer3. Регистров у него много, но нас интересуют следующие:


  • TIMER_CNT — собственно регистр счета. Записывать мы туда ничего не будем, но вот считать текущее значение можно. Хотя бы чтобы проверить идет ли вообще счет.
  • TIMER_PSC — предделитель
  • TIMER_CAR — модуль счета
  • TIMER_CTL0 — управляющий регистр, в котором нас интересует бит CEN разрешения работы таймера.

Ну и как и с любой другой периферией, перед использованием на нее надо подать тактирование. В случае Timer3 это шина APB1, именно импульсы ее тактирования таймер и считает. Причем он может считать не каждый импульс, а каждый второй, каждый пятый, каждый 321-й — именно за это отвечает регистр предделителя PSC. Максимальное значение, до которого таймер считает, пока не переполнится, также можно настраивать, для этого служит регистр CAR. Еще стоит упомянуть, что таймер умеет считать не только "вверх", увеличивая CNT с каждым тактом, но и "вниз", уменьшая, и даже в обоих направлениях — пока не достигнет CAR увеличивает, потом начинает уменьшать, пока не достигнет нуля, после чего снова увеличивать. Также при достижении крайних точек счета, может генерироваться прерывание. Зная все это, уже можно воспроизвести как работу mcycle, так и mtime. Но, разумеется, возможности таймера этим не ограничиваются.


8.5 ШИМ


Но прежде, чем рассматривать очередную интересную возможность таймера, стоит сделать небольшое отступление о том, что такое широтно-импульсная модуляция (ШИМ, она же pulse-width modulation, PWM). По сути, это быстрое-быстрое переключение состояния ножки между лог.0 и лог.1 чтобы плавно управлять каким-либо устройством. Например, яркостью светодиода. Допустим, высокий уровень подается на ножку на 20% времени — тогда и светиться диод будет на 20% от максимума. Только надо учитывать, что в случае светодиода видимая яркость обусловлена инерционностью зрения, на самом-то деле он все равно мерцает, просто этого не заметно. А вот лампочка накаливания при определенной частоте может и не успевать остыть. Кроме того, человеческий глаз воспринимает яркость нелинейно, поэтому увеличение заполнения на 1% в начале диапазона будет заметно значительно лучше, чем в конце.


Попробуем реализовать ШИМ руками. Делается это довольно просто: заводится счетчик, непрерывно увеличивающий свое значение. Пока оно не достигло заданного порога, на ножке удерживается высокий уровень, а после достижения — низкий. Когда счетчик досчитывает до своего максимума (модуля счета), он обнуляется. Причем, поскольку счетчик просто увеличивается независимо ни от чего (время, за которое он достигает максимума, определяет частоту ШИМ), его можно сравнивать не с одним порогом, а с двумя, тремя и так далее, задешево получая сразу несколько ШИМов. Что еще замечательно, так это то, что ШИМы разных каналов абсолютно синхронизированы. Это позволяет собирать на контроллере источники питания, контроллеры многофазных двигателей и другие схемы, где надо строго следить за очередностью сигналов. Схематично это изображено на рисунке: переменная CNT линейно увеличивается от нуля до максимума, после чего сбрасывается в ноль. Пороги CV0 и CV1 определяют когда ножки CH0 и CH1 будут менять свое состояние.


рис.1 график ШИМ


Код для ручной демонстрации этого эффекта довольно прост:


#define PERIOD (8000000) //1 сек на итерацию
uint32_t av_cycles = 0, cur_cycles;
uint32_t cnt = 0;
uint32_t pwm = 10;
while(1){
  cnt++;
  if(cnt >= 100)cnt = 0;
  if( cnt < pwm )GPO_ON(YLED); else GPO_OFF(YLED);
  sleep(100000);

  //раз в секунду увеличиваем pwm
  cur_cycles = read_mcycles();
  if( (av_cycles - cur_cycles) <= 0 ){
    av_cycles += PERIOD;
    pwm++;
    if(pwm >= 100)pwm = 0;
  }
}

Задержка здесь нужна чтобы успеть глазом заметить мерцание. Можно поэкспериментировать с ней — при каком значении мерцание совсем пропадет, и станет заметно только плавное изменение яркости.


Во-первых, стоит отметить, что использование mcycle позволило сделать скорость изменения яркости линейной и независимой от скорости выполнения самого цикла, даже если она непостоянна.


А во-вторых, ШИМ здесь программный, то есть будет с одной стороны отъедать ресурсы процессора, а с другой — терять точность, поскольку время задержки может изменяться например от скорости выполнения цикла, возникающих прерываний и всего остального.


Поэтому давайте посмотрим, как реализовать тот же ШИМ, но уже аппаратно, средствами таймера. Но тут возникает первое ограничение: в отличие от программного "ногодрыга", аппаратный привязан к конкретным ножкам, у каждого таймера своим. Если заглянуть в документацию, станет видно, что ножкам PB5 — PB7, на которых висят светодиоды, соответствуют Timer2_ch1 (remap), Timer3_ch0 и Timer3_ch1. С ремапом сейчас разбираться неохота, поэтому возьмем Timer3 и его нулевой канал. Помимо уже знакомых нам регистров добавляются еще несколько.


  • TIMER_CH0CV — регистр сравнения для 0-го канала. Это то самое значение, при котором значение ножки будет меняться. В предыдущем коде его роль играла переменная pwm.
  • TIMER_CHCTL0 — регистр настройки нулевого и первого каналов. В нем нас интересуют биты CH0COMCTL (их надо выставить в 0b110) и CH0MS (0b00), а также бит CH0EN в регистре TIMER_CHCTL2. Почему именно они и почему именно такие значения, лучше почитать в User Manual'е.

Также режим работы ножки надо изменить с GPIO push-pull на alternative-function push-pull, иначе за управление будет отвечать код, а нам ведь нужно поручить это таймеру.


Внезапное отступление:


В регистре TIMER_CHCTL0 нам надо за раз изменить три бита. Не делать же это по одному. К сожалению, разработчики GD32VF103 не слишком-то упростили эту задачу. Придется вспомнить битовую магию! У нас есть регистр, в нем нужно стереть три бита, заданных битовой маской TIMER_CHCTL0_CH0COMCTL, после чего на то же место записать другое число. Стереть биты несложно, интереснее придумать, как сдвинуть число на нужное количество битов. Если бы у нас была маска, хранящая только одну единицу, проблем бы не было, умножить на нее и все — я так уже делал при настройке портов. Но у нас не одна единица, а три, надо придумать, как убрать лишние. Логическая операция, превращающая что угодно в ноль по маске, всего одна — AND. Причем мы заранее знаем, что кроме интересующих нас битов все остальные равны нулю, а наши идут подряд. Для простоты проиллюстрирую на примере 8-битных чисел:


bits    0b01110000 AND
mask    0bx001xxxx
res     0b00010000

Знаками x обозначено, что данные биты могут быть любыми: после побитового умножения на ноль они в любом случае обнулятся. Возникает искушение просто сдвинуть исходную маску на 2 бита вправо. Но это будет не универсальным: вдруг в другом регистре поле будет 5 битов, или 2, или еще сколько-то. А что если сдвинуть на 1 бит влево? Получим 0b11100000, уже почти то, что нужно, только биты инвертированы. Но уж это исправить несложно:


bits    0b01110000
 <<1    0b11100000
 NOT    0b00011111
------------------
mask    0bx001xxxx

Как видим, интересующие нас биты приняли нужные значения, а остальные — какое попало. Отлично! Осталось оформить эту магию в виде макроса:


#define PM_BITMASK( reg, mask, val ) do{ (reg) = ((reg) &~ (mask)) | (((mask) &~((mask)<<1))*(val)); }while(0)

Переходим наконец к коду


#define TIM B,6,1,GPIO_APP50
...
GPIO_config(TIM);

TIMER_PSC(TIMER3) = 0;
TIMER_CAR(TIMER3) = 100;

TIMER_CH0CV(TIMER3) = 80;
PM_BITMASK( TIMER_CHCTL0(TIMER3), TIMER_CHCTL0_CH0COMCTL, 0b110 ); //прямой ШИМ
PM_BITMASK( TIMER_CHCTL0(TIMER3), TIMER_CHCTL0_CH0MS, 0b00 );
TIMER_CHCTL2(TIMER3) |= TIMER_CHCTL2_CH0EN;

TIMER_CTL0(TIMER3) = TIMER_CTL0_CEN;
...
while(1){
  cur_cycles = read_mcycles();
  if( (av_cycles - cur_cycles) <= 0 ){
    av_cycles += PERIOD;
    TIMER_CH0CV(TIMER3)++;
    if(TIMER_CH0CV(TIMER3) >= 100)TIMER_CH0CV(TIMER3) = 0;
  }
}

Вот и все, теперь программно мы меняем только коэффициент заполнения, а импульсы генерирует сам таймер.


8.6 Прочие возможности таймеров/счетчиков


Обилие настроек каналов таймера нам намекает, что возможности одним ШИМом не ограничиваются. Собственно, правильное название этой периферии звучит как "таймер/счетчик", а не просто "таймер": считать он умеет не только импульсы тактирования ядра (пусть даже пропущенные через предделитель), но и импульсы, поступающие на одну из ножек. В таком случае он работает уже не как таймер, а именно как счетчик. Также в нашем контроллере некоторые таймеры/счетчики умеют аппаратно обрабатывать события энкодера — это такая крутилочка, которая выдает цифровой сигнал при каждом повороте на определенный угол. Колесико мышки, например, тоже энкодер. Еще одна способность таймера — захват. По импульсу на ножке значение счетного регистра CNT копируется в регистр сравнения CHxCV — таким способом можно считать длительность внешнего импульса.


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


А вообще, у таймеров-счетчиков возможностей много. Это и управление DMA, и управление АЦП и даже другими таймерами. В общем, изучайте документацию, там много интересного.


8.7 Часы реального времени (real-time clock, RTC)


До сих пор все интервалы времени задавались в терминах импульсов ядра. А что на счет собственно отсчета времени — часы, секунды, месяцы? А это существенно более редкая задача, решить которую в принципе можно и на обычном таймере. Тем не менее, существуют специальные микросхемы вроде DS1307 (или более современные), предназначенные ровно для этого — считать абсолютное время, вести календарь и не потреблять при этом слишком много энергии. Для точности такие микросхемы тактируются не от чего попало, а от специального кварцевого резонатора, называемого часовым (потому что изначально он и задумывался как источник тактирования для часов), плюс специальная батарейка, чтобы счет времени не прекращался, даже если устройство выдернут из розетки. Ну и на сдачу несколько ячеек памяти общего назначения, чтобы и контроллер мог там хранить какие-то настройки, переживающие отключение питания. Вот только… неужели ради простого счета времени еще одну микросхему ставить?


К счастью, нет. Модуль часов реального времени в большинстве контроллеров есть встроенный. Тактируется он все также от часового кварца, для которого предусмотрены специальные ножки и отдельная ножка для подключения резервной батарейки. Даже сохраняемые ячейки памяти предусмотрены! Но разумеется, и здесь есть свои тонкости.


В первую очередь надо помнить, что частота тактирования RTC намного меньше, чем ядра. Соответственно, и обновлять регистры с той же скоростью он не может, поэтому предусмотрены специальные регистры синхронизации. Но мало того, что частота часов низкая, она еще и не привязана к частоте ядра и может выбираться из следующих вариантов: часовой кварц 32768 Гц; встроенный RC-генератор 40 кГц; внешний высокочастотный кварц, деленный на 128. А еще, как следует из логики их применения, на момент включения ядра, RTC скорее всего уже настроен и считает время.


Выбор источника тактирования осуществляется битами RCU_BDCTL_RTCSRC. Но по умолчанию все неиспользуемые блоки, в том числе тактовые генераторы, в контроллере остановлены. Поэтому при инициализации часов надо сначала попытаться запустить LXTAL (низкочастотный кварц, он же LSE), дождаться, пока он выйдет в рабочий режим, и только потом переключаться на него. Но кварцевый резонатор это внешнее устройство, он может сломаться, его ножки могут оказаться закорочены, могут отгнить, с ним могут произойти другие неприятности. Поэтому если кварц не заводится, можно попробовать использовать в качестве тактового генератора встроенный RC-генератор на 40 кГц. Его точность, конечно, значительно хуже, чем у кварца, но это лучше, чем ничего. Кстати, наш контроллер умеет отслеживать исправность кварца не только при старте, но и во время работы, и если тактирование внезапно прекращается — генерировать прерывание. Примерно так может выглядеть инициализация и работа с RTC (в комментариях — для CH32V303):


void RTC_init(){
  //разрешить тактирование модулей управления питанием и управлением резервной областью
  RCU_APB1EN |= RCU_APB1EN_PMUEN | RCU_APB1EN_BKPIEN;   //RCC->APB1PCENR |= RCC_PWREN | RCC_BKPEN;
  //разрешить доступ к области резервных данных
  PMU_CTL |= PMU_CTL_BKPWEN;                            //PWR->CTLR |= PWR_CTLR_DBP;

  //if( !(RCU_BDCTL & RCU_BDCTL_RTCEN) ){               //if( !(RCC->BDCTLR & RCC_RTCEN) ){
  //если часы уже запущены, инициализировать их повторно не требуется
  if(1){ //но у нас не практическая задача, а учебная, поэтому инициализировать будем при каждом включении
    //выполнить сброс области резервных данных
    RCU_BDCTL |= RCU_BDCTL_BKPRST;                      //RCC->BDCTLR |= RCC_BDRST;
    RCU_BDCTL &=~RCU_BDCTL_BKPRST;                      //RCC->BDCTLR &=~RCC_BDRST;

    //некоторые настройки можно проводить только на отключенном RTC. gd32 это прощает, ch32 - нет
    RCU_BDCTL &=~RCU_BDCTL_RTCEN;                       //RCC->BDCTLR &=~ RCC_RTCEN;

    RCU_BDCTL |= RCU_BDCTL_LXTALEN;                     //RCC->BDCTLR |= RCC_LSEON;
    uint32_t i;
    for(i=1; i<100000; i++){
      if(RCU_BDCTL & RCU_BDCTL_LXTALSTB){i=0; break;}   //if(RCC->BDCTLR & RCC_LSERDY){i=0; break;}
    }

    if(i == 0){ //LXTAL success
      i = 32768;
      RCU_BDCTL = (RCU_BDCTL &~ RCU_BDCTL_RTCSRC) | RCU_RTCSRC_LXTAL; //RCC->BDCTLR = (RCC->BDCTLR &~ RCC_RTCSEL) | RCC_RTCSEL_LSE;
    }else{ //LXTAL fail
      RCU_RSTSCK |= RCU_RSTSCK_IRC40KEN;                //RCC->RSTSCKR |= RCC_LSION;
      while( !(RCU_RSTSCK & RCU_RSTSCK_IRC40KSTB) ){}   //while( !(RCC->RSTSCKR & RCC_LSIRDY) ){}
      i = 40000;
      RCU_BDCTL = (RCU_BDCTL &~ RCU_BDCTL_RTCSRC) | RCU_RTCSRC_IRC40K; //RCC->BDCTLR = (RCC->BDCTLR &~ RCC_RTCSEL) | RCC_RTCSEL_LSI;
    }
    RCU_BDCTL |= RCU_BDCTL_RTCEN;                       //RCC->BDCTLR |= RCC_RTCEN;

    RTC_CTL |= RTC_CTL_CMF;                             //RTC->CTLRL |= RTC_CTLRL_CNF;
    RTC_PSCL = i;                                       //RTC->PSCRL = i;
    RTC_CNTL = 0;                                       //RTC->CNTL = 0;
    RTC_CNTH = (TIME_INIT >> 16) & 0xFFFF;              //RTC->CNTH = (TIME_INIT >> 16) & 0xFFFF;
    RTC_CNTL = (TIME_INIT) & 0xFFFF;                    //RTC->CNTL = (TIME_INIT) & 0xFFFF;
    RTC_CTL &=~RTC_CTL_CMF;                             //RTC->CTLRL &=~RTC_CTLRL_CNF;

    //Запускаем синхронизацию RTC с ядром и дожидаемся ее завершения
    RTC_CTL &=~ RTC_CTL_RSYNF;                          //RTC->CTLRL &=~RTC_CTLRL_RSF;
    while( !(RTC_CTL & RTC_CTL_RSYNF) ){}               //while( !(RTC->CTLRL & RTC_CTLRL_RSF) ){}
  }else{
    //IRC40KEN к сожалению сбрасывается по ресету
    if( (RCU_BDCTL & RCU_BDCTL_RTCSRC) == RCU_RTCSRC_IRC40K ){ //if( (RCC->BDCTLR & RCC_RTCSEL) == RCC_RTCSEL_LSI ){
      RCU_RSTSCK |= RCU_RSTSCK_IRC40KEN;                //RCC->RSTSCKR |= RCC_LSION;
      while( !(RCU_RSTSCK & RCU_RSTSCK_IRC40KSTB) ){}   //while( !(RCC->RSTSCKR & RCC_LSIRDY) ){}
    }
  }
}

uint32_t rtc_read(){
  uint16_t res_H1= RTC_CNTH;
  uint16_t res_L = RTC_CNTL;
  uint16_t res_H2= RTC_CNTH;
  if(res_H1 != res_H2)res_L = RTC_CNTL;

  return ((uint32_t)res_H2)<<16 | res_L;
}

Когда RTC запущен, для изменения его настроек тоже приходится использовать синхронизацию:


while( !(RTC_CTL & RTC_CTL_LWOFF) ){}   //while( !(RTC->CTLRL & RTC_CTLRL_RTOFF) ){}
RTC_CTL |= RTC_CTL_CMF;                 //RTC->CTLRL |= RTC_CTLRL_CNF;
//собственно настройка
RTC_CTL &=~RTC_CTL_CMF;                 //RTC->CTLRL &=~RTC_CTLRL_CNF;
RTC_CTL &=~RTC_CTL_RSYNF;               //RTC->CTLRL &=~RTC_CTLRL_RSF;
while( !(RTC_CTL & RTC_CTL_RSYNF) ){}   //while( !(RTC->CTLRL & RTC_CTLRL_RSF) ){}

Разные реализации часов реального времени обладают разными возможностями от простейшей генерации прерывания раз в секунду (тогда как собственно счет — забота программиста) до полноценного календаря с несколькими будильниками. У нас нечто среднее: часы работают автономно, но считают только секунды. Чтобы перевести их в время-дату воспользуемся функцией декодирования unix-time'а (не помню откуда я ее скопировал, если честно; а еще она некорректно работает с годами меньше 2000):


typedef struct{
  uint16_t year;
  uint8_t month;
  uint8_t day;
  uint8_t day_week;
  uint8_t hour;
  uint8_t min;
  uint8_t sec;
}date_time_t;

void utc2date(date_time_t *res, uint32_t time){
  uint32_t a, b, c, d, e, m;
  uint32_t jd = 0;
  uint32_t jdn = 0;
  time += 3*60*60;
  jd = ((time+43200)/(86400>>1)) + (2440587<<1) + 1;
  jdn = jd>>1;
  res->sec = time % 60;
  time /= 60;
  res->min = time % 60;
  time /= 60;
  res->hour = time % 24;
  time /= 24;

  res->day_week = jdn % 7;

  a = jdn + 32044;
  b = (4*a+3)/146097;
  c = a - (146097*b)/4;
  d = (4*c+3)/1461;
  e = c - (1461*d)/4;
  m = (5*e+2)/153;
  res->day = e - (153*m+2)/5 + 1;
  res->month = m + 3 - 12*(m/10);
  res->year = 100*b + d - 4800 + (m/10);
}

Ну и раз уж заговорили о дате, в Си есть макросы текущих даты и времени __DATE__, __TIME__. Удобно отслеживать когда была последняя сборка. Но есть две проблемы. Первая: оба макроса — текстовые строки, и если нужно получить время в численном формате, придется повозиться. Вторая — кривой и неудобный формат даты. Который еще и настроить невозможно. Чтобы ими хоть как-то можно было пользоваться, я написал вот такие макросы:


#define __TIME_S__ ((__TIME__[0]-'0')*36000+(__TIME__[1]-'0')*3600 + (__TIME__[3]-'0')*600+(__TIME__[4]-'0')*60 + (__TIME__[6]-'0')*10+(__TIME__[7]-'0'))
#define __DATE_D__ (((__DATE__[4]==' '?'0':__DATE__[4])-'0')*10 + (__DATE__[5]-'0'))
#define __DATE_M__ (((__DATE__[1]=='a')&&(__DATE__[2]=='n'))?1: \
                    (__DATE__[2]=='b')?2: \
                    ((__DATE__[1]=='a')&&(__DATE__[2]=='r'))?3: \
                    ((__DATE__[1]=='p')&&(__DATE__[2]=='r'))?4: \
                    (__DATE__[2]=='y')?5: \
                    ((__DATE__[1]=='u')&&(__DATE__[2]=='n'))?6: \
                    (__DATE__[2]=='l')?7: \
                    (__DATE__[2]=='g')?8: \
                    (__DATE__[2]=='p')?9: \
                    (__DATE__[2]=='t')?10: \
                    (__DATE__[2]=='v')?11: \
                    (__DATE__[2]=='c')?12: \
                   0)
#define __DATE_Y__ ((__DATE__[7]-'0')*1000 + (__DATE__[8]-'0')*100 + (__DATE__[9]-'0')*10 + (__DATE__[10]-'0'))

#define DATETIME_TIMEZONE_S (3*3600)
#define _DATETIME_UTCOFFSET 951868800
//При расчете продолжительности года удобнее перенести февраль в конец года.
//Тогда длительность до текущего месяца вычисляется из массива
//const uint16_t day_offset[12] = {0, 31, 61, 92, 122, 153, 184, 214, 245, 275,306, 337};
//но в макрос нельзя встроить массив, поэтому длительность будем вычислять через кучу if-ов (снова от января)
#define _DAY_OFFSET(m) ((m)==0?275:(m)==1?306:(m)==2?337:(m)==3?0:(m)==4?31:(m)==5?61:(m)==6?92:(m)==7?122:(m)==8?153:(m)==9?184:(m)==10?214:245)
//длительность текущего года (в днях) с учетом високосности
#define __DATETIME_YEAR_DAYS(y) ((y)*365 + (y)/4 - (y)/100 + (y)/400)
//корректировка на начало года с марта
#define _DATETIME_YEAR_DAYS(m, y) __DATETIME_YEAR_DAYS((y)-2000-((m)<3))
#define UTC(h,m,s,dd,mm,yyyy) (((dd) - 1 + _DAY_OFFSET(mm) + _DATETIME_YEAR_DAYS(mm, yyyy)) * 86400 + (h)*3600 + (m)*60 + (s) + _DATETIME_UTCOFFSET - DATETIME_TIMEZONE_S)
#define __UTC__ UTC(0,0,__TIME_S__, __DATE_D__, __DATE_M__, __DATE_Y__)

(перевод из даты-времени в utc подсмотрен здесь: http://we.easyelectronics.ru/Soft/konvertaciya-vremeni.html)


8.8 WatchDog Timer


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


8.9 Система тактирования


рис.2 система тактирования контроллера


Вот так выглядит система тактирования нашего контроллера. На первый взгляд довольно страшно, но попробуем последовательно разобраться что и для чего нам нужно. Первоначальным источником тактового сигнала может быть либо IRC8M (чаще называемый HSI, high-speed internal clock source), встроенный RC-генератор 8 МГц, включенный по умолчанию, либо внешний кварц (HXTAL / HSE), висящий на выводах HXTAL. В зависимости от битов SCS и PREDIV0SEL в качестве тактирования ядра CK_SYS будет использоваться один из этих сигналов, либо сигнал с делителя-умножителя частоты. Коэффициент умножения задается битами PLLMF, коэффициент деления — PREDIV0. Источником входного сигнала для этого модуля могут быть все те же IRC8M или HXTAL. Ну а после настройки тактирования ядра CK_SYS ее можно еще поделить, чтобы настроить частоту шины AHB, а из нее APB1 и APB2 — обратите внимание на максимальные значения частот этих шин.


Например, мы хотим разогнать ядро до 108 МГц. Тогда из источников тактирования можно выбрать HXTAL (допустим, это кварц на 8 МГц) с разнообразным делителями или IRC8M, деленный на 2 (то есть 4 МГц). 108 на 4 вполне делится, то есть множитель от IRC8M можно выставить в 27. И, чтобы его не перестраивать его между RC-генератором и кварцем, выставим для последнего делитель на 2. Как результат, сначала пытаемся запустить кварц с делителем 2 и множителем 27, а если он неисправен — запускаем IRC8M с неотключаемым делителем на 2 и множителем 27. И получаем желанные 108 МГц.


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


Небольшой совет по выбору делителей для периферии. Если требований по энергоэффективности нет, можно не париться и выставить в 1 (или 2 как шина APB1 в gd32vf103 — там максимальная частота указана всего 54 МГц. В ch32 такого ограничения нет). Практически вся периферия имеет собственные делители достаточной разрядности чтобы выставить любую частоту в рабочем диапазоне. Кроме АЦП. Его максимальная частота ограничена 14 МГц, а делитель выбирается всего лишь из диапазона 2, 4, 6 или 8. То есть максимальная частота шины получается 112 МГц. Поэтому в случае ch32v303 или других быстрых контроллеров частоты шин APB1, APB2 стоит делить на 2 (а то и больше). Все равно такая огромная скорость для периферии не нужна. Впрочем, это лишь совет для первых экспериментов. Как и со всеми остальными советами и рекомендациями, если вы знаете, что и для чего делаете, и можете предсказать побочные эффекты, можно поступать по-своему.


Ну и если эта схема тактирования показалась кому-то излишне сложной, спешу обрадовать: в других контроллерах она бывает еще сложнее!


8.10 Генерация звука


Для демонстрации возможностей таймера я решил воспроизвести на нем отрывок мелодии. Вообще-то, воспроизведение звука при помощи ШИМ — стандартный способ, он применяется даже там, где контроллерами и не пахнет — в усилителях класса D. Идея в том, что сигнал с микрофона подвергается "обратному ШИМ-преобразованию", то есть превращается в последовательность единиц и нулей различной длины, потом напряжение логической единицы усиливается (например, было 3.3 вольта, а станет 15 вольт), благо уж цифровой сигнал можно усиливать, совершенно не заботясь о линейности, искажениях и прочих проблемах аналоговой техники. А потом уже усиленный сигнал прогоняют через фильтр низких частот, чтобы преобразовать обратно в аналоговую форму.


Нам же, при наличии микроконтроллера, необходимости в микрофоне нет, можно цифровой сигнал просто синтезировать. Скажем, чтобы вывести простейший синусоидальный сигнал, надо последовательно выводить в регистр ШИМа значения синусоиды в каждый момент времени. Еще более интересный эффект будет если выводить не просто синусоиду, а содержимое wav-файла, ведь в нем записаны именно последовательности значений сигнала.


Для примера воспользуемся ножкой PA8, на которую выведен Timer0, канал 0. Это "продвинутый" таймер, поэтому и настройка у него чуть сложнее, чем у обычного. Правда, всего на один бит, но я потратил на его поиски довольно много времени, поэтому упомянуть его стоит:


TIMER_CCHP(TIMER0) = TIMER_CCHP_POEN;
//TIM1->BDTR |= TIM_MOE; //для CH32V303

Само собой, с выводом звукового фрагмента я тоже экспериментировал. Если кто-то захочет повторить этот "подвиг" (пф-ф-ф, было бы что повторять, там кода на десяток строчек, не считая уже написанного к настоящему времени), подскажу несколько команд в makefile, которыми я воспользовался. Предварительно при помощи ffmpeg'а я подогнал битрейт и разрядность, а также вырезал нужный кусок. Все-таки без сжатия звук занимает довольно много места. Ну и еще одна подсказка: на частоте ядра 108 МГц звук шакалится чуть менее ужасно, чем на 8 МГц при том же битрейте.


ffmpeg -i input.mp3 -ss 3:0.7 -t 0:1 -f wav -acodec pcm_u8 -ar 8000 output.wav

-ss , -t - начало фрагмента, который попадет в выходной файл, и его длительность
-acodec - количество битов на отсчет. Обратите внимание, что за исключением u8, остальные форматы знаковые
-ar - битрейт, частота оцифровки

CFLAGS += -DAUDIOSRC='"_binary_src_audiodata_wav"'
...
$(builddir)/audiodata.o: $(srcdir)/audiodata.wav
    mkdir -p $(builddir)
    $(CROSS_COMPILE)ld -melf32lriscv -r -b binary -o $@ $<
    $(CROSS_COMPILE)objcopy --rename-section .data=.rodata,alloc,load,readonly,data,contents $@ $@

Благодаря этому создается объектный файл audiodata.o с бинарными данными, начало и конец которых можно получить при помощи несложной конструкции:


extern const uint8_t adata_start[]   asm(AUDIOSRC "_start");
extern const uint8_t adata_end[]     asm(AUDIOSRC "_end");

Это заклинание будет полезным и при добавлении любых других бинарных данных в проект. Собственно, оно было выдернуто из другого моего проекта — "флешки" на контроллере, где таким способом в прошивку вклеивался собственно образ флешки.


Другой, пожалуй даже более простой вариант: xxd -i input.bin > output.h и подключение уже output.h. Но здесь придется чуть-чуть пошаманить с makefile чтобы правильно расставить зависимости.


8.11 Анализ звукового сигнала


Для начала надо определить адрес "микрофона" чтобы с ним работать:


pacmd list-sources | grep -e 'index:' -e device.string -e 'name:' — там будет что-то вроде "alsa_input.usb-COKPOWEHEU_USB_RISCV_programmer_1-05.mono-fallback"


$ cat /proc/asound/cards — в чуть более читаемом виде


 0 [C615           ]: USB-Audio - HD Webcam C615
                      HD Webcam C615 at usb-0000:00:1d.7-3, high speed
 1 [KarakatitsaGD32]: USB-Audio - Karakatitsa_GD32
                      COKPOWEHEU Karakatitsa_GD32 at usb-0000:00:1d.2-1, full speed

ffmpeg -f pulse -i "alsa_input.usb-COKPOWEHEU_USB_RISCV_programmer_1-05.mono-fallback" -ac 2 -y audio.mp3 — собственно запись в файл


ffplay -f pulse "alsa_input.usb-COKPOWEHEU_USB_RISCV_programmer_1-05.mono-fallback" — воспроизведение прямо с микрофона


Если для работы со звуком в системе используется не pulseaudio, а alsa, флаги немного другие: $ ffmpeg -f alsa -i default:KarakatitsaGD32 -t 1:00 -y res.mp3


Ну а посмотреть осциллограмму можно например утилитой xoscope. Синхронизация выбирается кнопкой +, скорость развертки 0, 9. Остальное смотрите в документации, она небольшая.


P.S. Как это делать под виндой я не знаю.


С образцами исходного wav-файла и его записью с самодельного "микрофона" также можно ознакомиться.


Исходный код примера доступен на github


Дополнительная информация


Оригинал на github pages


Видеоверсия на Ютубе (только по gd32), таймеры


CC BY 4.0

Комментарии (8)


  1. devprodest
    14.01.2025 10:18

    Судя по makefile вы собираете под стандарт с11 или с17, а для них функции без аргументов должны быть с void.


    1. COKPOWEHEU Автор
      14.01.2025 10:18

      С пустыми скобками тоже допустимо. Насколько я знаю, с C23 пустые скобки сделали синонимом void, а в более старых стандартах - произвольные аргументы. Ни то, ни другое в данном случае не ошибка.


  1. JackKatch
    14.01.2025 10:18

    Очепятка в начале раздела 8.3 "Обобое внимание"


  1. rukhi7
    14.01.2025 10:18

    похоже на перевод даташита. Жалко что у меня 25 лет назад не было такого перевода.


  1. Mcublog
    14.01.2025 10:18

    Коммент в поддержку) спасибо за статью, прочитал с удовольствием. Особенно понравился хак со звуком!


  1. Glaid
    14.01.2025 10:18

    Спасибо за Ваш труд, помогает всë разложить по полочкам) Недавно начал изучать микроконтроллеры ch32v и есть пара вопросов для бывалых. Вот если использовать C++ код, насколько больше прошивка в отличии от чистого Си (если просто классы добавить, без дин. памяти)? И есть ли смысл изучать другие системы сборки кроме makefile? Просто хочется один раз настроить и забыть. В принципе на линуксе это можно сделать, а что с виндой?


    1. COKPOWEHEU Автор
      14.01.2025 10:18

      Вот если использовать C++ код, насколько больше прошивка в отличии от чистого Си

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

      И есть ли смысл изучать другие системы сборки кроме makefile? Просто хочется один раз настроить и забыть.

      Многие вообще пользуются "проектами" тех или иных IDE и не лезут смотреть что там под капотом. Система сборки - наименьшая из проблем: для тех или иных задач ее приходится иногда подстраивать, но это почти всегда элементарные действия. То же встраивание бинарника wav-файла, которое я описывал, пожалуй, одно из самых сложных - и то меньше десятка строчек. А стандартная задача - добавить в "проект" еще один *.c файл - решается и вовсе элементарно.

      В принципе на линуксе это можно сделать, а что с виндой?

      Во-первых, никто не отменял фирменные IDE. NucleiStudio от GigaDevice, MounRiverStudio от WCH, не говоря уж об обычных Eclipse, Keil и т.п.. Пожалуй, это самый простой способ.
      Во-вторых, можно развернуть всю систему сборки самостоятельно. Найти инсталляторы соответствующих gcc, библиотеки и т.п.
      В-третьих, никто, кажется, не отменял WSL.
      Но с моей стороны это больше теория, поскольку под виндой контроллеры не программировал лет пятнадцать, так что было бы интересно послушать настоящих, опытных виндузятников.


    1. Mcublog
      14.01.2025 10:18

      Не особо виндузятник, но часть коллег на Винде и особенных отличий в настройке того же gcc нет. Вот моя довольно старая статья по настройке gcc для arm на Винде в vscode, может быть будет полезна. Для компилятора под riscv будет аналогично.

      Для быстрого старта использовать лучше те среды и примеры, что предлагает производитель. А потом уже создавать свой воркфлоу, если готовый не зайдет.

      На C++ можно писать по разному, но по моему опыту существенной разницы нет. Если использовать больше всякого разного из стандартной библиотеки, то размер естественно подрастает.