В предыдущих частях мы научились работать с RISC-V контроллерами в стиле восьмибиток из прошлого тысячелетия. Конечно, периферия у наших посложнее, но все равно управляли мы ей напрямую. Теперь же рассмотрим периферийный модуль, который сам может управлять другими периферийными модулями, пока ядро занято вычислениями. Речь идет о DMA (Direct Memory Access, оно же Прямой Доступ к Памяти, ПДП).
9.1
Как обычно, новый модуль появляется тогда, когда надо решить алгоритмически простую, но трудоемкую задачу. В данном случае — перекладывать байтики из одного места в другое. Например, копирование массивов или их зануление перед использованием. Или чуть посложнее — перекладывать байтики из массива в какой-нибудь UART, чтобы ядро не по одному байту за раз пересылало, а подготовило сообщение целиком: "Вот тебе куча данных, передавай, а я пока новые готовить буду". Разница здесь только в том, что периферия работает довольно медленно, и прежде, чем передавать очередной байт, надо убедиться, что она закончила с предыдущим. Ну и автоматический проход по выходному "массиву" не нужен, ведь USART_DATA
— это единственный регистр.
Таким образом можно сформулировать основные настройки, которые можно в первом приближении ожидать от DMA. Во-первых, это, очевидно, адреса и размеры массивов на прием и передачу. Во-вторых, флаги надо ли писать (читать) в последовательные ячейки или только в нулевую. Ну и выбор триггера, по которому произойдет очередная запись. Периферии в нашем контроллере довольно много, поэтому производители решили, что одного DMA недостаточно, и сделали их несколько, да еще с несколькими каналами у каждого. И привязали каждый канал к своей периферии. Например, событие "UART0 готов к передаче очередного байта" привязано в GD32VF103 к DMA0, каналу 3. Туда же привязано несколько событий от TIMER0, SPI1_RX и I2C1_TX.
Разумеется, каналы настраиваются каждый сам по себе, и их бы можно вообще рассматривать как независимые периферийные модули, если бы не ограниченная пропускная способность шины данных и самого DMA. В реальности отдельные каналы все-таки конкурируют за этот ресурс. Поэтому, если какой-то из них надо выделить, ему назначают больший приоритет, и в список настроек добавляется еще пара бит.
Важно отметить две вещи. Первое: событие для DMA генерируется именно периферией, соответственно и настраивать будет ли оно генерироваться, надо именно в ней, а не в DMA. И второе: адреса буферов приема и передачи не привязаны к триггерам. Например, можно по приему байта по UART копировать байт из ADC в SPI. В частности, триггером может быть событие таймера — для передачи данных с заданной периодичностью. Это позволяет проворачивать с DMA некоторые интересные трюки.
В целом, как видно, DMA штука достаточно простая.
9.2 Простейший пример
Вот список каналов DMA0 и DMA1 контроллера gd32vf103:
DMA0:
DMA1:
Для примера, перешлем строку из памяти по UART0 (как мы помним, это третий канал DMA0):
const char buffer = __DATE__ " " __TIME__;
DMA_CHCTL(DMA0, 3) = 0;
DMA_INTC(DMA0) = DMA_FLAG_ADD(DMA_CHINTF_RESET_VALUE, 3);
DMA_CHMADDR(DMA0, 3) = (uint32_t)buffer;
DMA_CHPADDR(DMA0, 3) = (uint32_t)&USART_DATA(USART0);
DMA_CHCNT(DMA0, 3) = sizeof(buffer) / sizeof(buffer[0]);
DMA_CHCTL(DMA0, 3) = DMA_PERIPHERAL_WIDTH_8BIT | DMA_MEMORY_WIDTH_8BIT | DMA_CHXCTL_MNAGA | DMA_CHXCTL_DIR;
DMA_CHCTL(DMA0, 3) |= DMA_CHXCTL_CHEN;
USART_CTL2(USART0) = USART_CTL2_DENT; //Важно! Генерация событий для DMA - задача периферии
Рассмотрим настройки подробнее. Первое, что бросается в глаза — буферы приемника и передатчика названы MADDR, PADDR — память и периферия. Зачем это сделано, мне неизвестно, ведь и тот, и другой указывают на память (либо обычную ОЗУ, либо MMIO). Вроде бы где-то существуют контроллеры, у которых диапазон адресов ограничен (кажется, к таким относится STM32F4), но вживую я таких не видел. Но именно для такого случая введен бит DMA_CHXCTL_DIR
, который показывает, идет ли копирование из MADDR в PADDR или наоборот.
Настройки DMA_PERIPHERAL_WIDTH_8BIT
, DMA_MEMORY_WIDTH_8BIT
указывают какого размера будет каждая посылка. Необязательно копировать за раз по одному байту, можно по 2 или по 4. Причем размер приемника и передатчика может отличаться, тогда поведение будет как при обычном копировании: данные либо обрезаются, либо дополняются нулями. Бит DMA_CHXCTL_MNAGA
(и бит DMA_CHXCTL_PNAGA
, который в нашем примере равен нулю) отвечает за автоматический инкремент адреса MADDR (и PADDR соответственно). То есть мы каждый раз читаем из следующей ячейки буфера, но пишем в одну и ту же ячейку периферии. DMA_CHCNT
— количество транзакций. А не размер буфера, это важно. И еще одна важная вещь — надо не забывать, что при работе с периферией указывается адрес регистра, а не его значение. Казалось бы, это очевидно, но уж слишком легко по невнимательности пропустить.
DMA_CHPADDR(DMA0, 3) = (uint32_t)&USART_DATA(USART0); // так правильно
DMA_CHPADDR(DMA0, 3) = (uint32_t)USART_DATA(USART0); // так неправильно
Поэтому, если будете оборачивать работу с DMA в макросы, не поленитесь добавить проверку типов, что-нибудь вроде такого (это из моей библиотеки для ch32):
#define dma_cfg_io(dma, dst, src, cnt) \
do{ \
_DMA_CH(dma)->PADDR = (uint32_t)(src); \
_DMA_CH(dma)->MADDR = (uint32_t)(dst); \
_DMA_CH(dma)->CNTR = (uint16_t)(cnt); \
if(0)(( void(*)(volatile void*, volatile void*) )\
"dma_cfg_io(void*, void*)")(dst, src); \
}while(0)
Кстати, здесь же пример того, как можно обеспечить контроль типов в макросах. if(0) гарантирует, что "функция" не будет вызвана, но при этом она все равно находится в коде, и компилятор проследит за синтаксической правильностью.
Напоследок упомяну еще пару битов. Бит DMA_CHXCTL_M2M
отключает привязку к триггерам. Если его выставить, DMA будет пересылать данные на максимальной скорости. Это нужно для копирования из памяти в память.
Бит DMA_CHXCTL_CMEN
, превращает DMA в Сизифа: как только камень счетчик достигает конца, он сбрасывается в ноль, и приходится толкать сначала передача начинается по следующему кругу. Очень удобно для АЦП, когда он в автономном режиме пишет туда данные, а код, когда ему удобно, оттуда забирает, скажем, последние десять измерений. Да и для других целей бит может пригодиться.
Также DMA умеет генерировать прерывания, причем не только по окончании передачи буфера целиком, но и на половине. Это можно использовать для непрервыной передачи. Пока DMA передает одну половину буфера, ядро заполняет вторую, потом происходит прерывание, DMA начинает передавать вторую половину, а ядро — заполнять первую. Чуть расширенный пример можно посмотреть на github
Это было использование DMA "по прямому назначению". А теперь перейдем к чему-нибудь более интересному
9.3 Пример: ШИМ на DMA
С обычным ШИМом мы уже знакомы, делали его и программно на mtime (systick), и на таймерах общего назначения. Теперь представим ситуацию, что одновременно нужно управлять яркостью 16 светодиодов, а выходы таймеров у нас не ко всем ногам подключены. Как несложно догадаться, воспользуемся для этого DMA. Но все, на что он в данном случае способен, это вывести в GPIO_OCTL
последовательно значения массива. Значит, заведем массив, в котором будут перечислены все состояния ножек на всех интервалах.
Для светодиодов достаточно восьми-, а то и шестибитного ШИМ. Пусть будет 8-битный. Это 256 отсчетов, значит будет нужен массив из 256 элементов. Выдавать их в OCTL будем не на максимальной скорости (это слишком большая нагрузка на шину данных, да и на DMA), а по таймеру. Поскольку таймеры подсоединены, кажется, ко всем каналам DMA, выберем произвольно — таймер 3, канал 0 и соответственно DMA0, канал 0.
Но пока не начали писать код, вспомним, что некоторые ноги порта могут быть задействованы под какие-то другие нужды. Ладно еще под периферию — настроил в Input или Alternative function, и запись в OCTL не волнует. А вдруг, скажем, реле. К счастью, у нас есть регистр GPIO_BOP, в котором можно указать только те биты, которые нас интересуют. Вот так может выглядеть соответствующий код для моей отладочной платы, на которой светодиоды расположены на PB5, PB6, PB7:
uint32_t pwm_buf[256];
#define AVAIBLE_LEDS (1<<5 | 1<<6 | 1<<7) //PB5, PB6, PB7
void pwm_init(){
for(int i=0; i<256; i++)pwm_buf[i] = (AVAIBLE_LEDS<<16);
RCU_APB1EN |= RCU_APB1EN_TIMER3EN;
TIMER_PSC(TIMER3) = (1 - 1);
TIMER_CAR(TIMER3) = (108 - 1);
TIMER_CH0CV(TIMER3) = 1;
RCU_AHBEN |= RCU_AHBEN_DMA0EN;
DMA_CHCTL(DMA0, 0) = 0;
DMA_CHPADDR(DMA0, 0) = (uint32_t)&GPIO_BOP(GPIOB);
DMA_CHMADDR(DMA0, 0) = (uint32_t)pwm_buf;
DMA_CHCNT(DMA0, 0) = sizeof(pwm_buf)/sizeof(pwm_buf[0]);
DMA_CHCTL(DMA0, 0) = DMA_PRIORITY_ULTRA_HIGH
| DMA_PERIPHERAL_WIDTH_32BIT // per data size: 32
| DMA_MEMORY_WIDTH_32BIT // mem data size: 32
| (0*DMA_CHXCTL_PNAGA) // Autoincrement per: disable
| (1*DMA_CHXCTL_MNAGA) // Autoincrement mem: enable
| (1*DMA_CHXCTL_CMEN) // Circular mode: enable
| (1*DMA_CHXCTL_DIR); // Direction: mem -> per
DMA_CHCTL(DMA0, 0) |= DMA_CHXCTL_CHEN;
TIMER_DMAINTEN(TIMER3) |= TIMER_DMAINTEN_CH0DEN;
TIMER_CTL0(TIMER3) |= TIMER_CTL0_CEN;
}
void pwm_set(uint8_t chan, uint8_t val){
uint32_t mask = (1<<chan);
for(int i=0; i<val; i++)pwm_buf[i] |= mask;
mask =~mask;
for(int i=val; i<256; i++)pwm_buf[i] &= mask;
}
...
pwm_init();
uint8_t pwm_val[3] = {10, 50, 200};
while(1){
pwm_val[0]++; pwm_set(5, pwm_val[0]);
pwm_val[1]++; pwm_set(6, pwm_val[1]);
pwm_val[2]++; pwm_set(7, pwm_val[2]);
delay_ticks(108000000/100);
}
Исходный код доступен на github
Теперь диодики красиво перемигиваются и не требуют особого присмотра со стороны ядра. Вот только отдавать аж 256 байт под хранение массива ШИМ не очень хочется. Да еще и память дергают постоянно, что тоже не очень хорошо. Попробуем это исправить.
9.4 Пример: BAM на DMA
Как мы помним, суть ШИМ — в формировании импульса и паузы с заданным соотношением времен. Но что если у нас нет требования делать импульс и паузу непрерывными? Давайте наложим на период ШИМ картинку байта, в котором длительность каждого бита пропорциональна его весу. То есть длительность нулевого бита 1, первого — 2, второго — 4 и так далее до 7 с длительностью 128.
А теперь, если значение этого бита равно 0, будем выводить на ножку 0, а если 1, то 1. Допустим, байт заполнения равен 154 (0b10011010 в двоичной системе), то есть выставлены биты 1, 3, 4 и 7. Суммарная длительность лог.1 за период равна 1⋅2⁷ + 0⋅2⁶ + 0⋅2⁵ + 1⋅2⁴ + 1⋅2³ + 0⋅2² + 1⋅2¹ + 0⋅2⁰ = 154. Из 255 возможных. Ничего не напоминает? Да это же стандартная запись позиционной системы счисления. Иначе говоря, суммарная длительность лог.1 на ножке будет равна численному значению переменной — ровно то, что нам и надо. Такой подход называется Binary angle modulation, BAM.
В результате придется делать не 256 операций записи из памяти в GPIO, а только 8. Правда, помимо записи в GPIO придется на лету менять настройки таймера. Результат может выглядеть примерно так (на этот раз для ch32 и с макросами):
uint32_t leds_data[8];
uint16_t tim_top[8] = {256, 128, 64, 32, 16, 8, 4, 512};
#define AVAIBLE_LEDS (1<<5 | 1<<6 | 1<<7)
void pwm_init(){
for(int i=0; i<8; i++)leds_data[i] = (AVAIBLE_LEDS<<16);
timer_init(TIM_ch1, 10-1, 100);
timer_chval(TIM_ch1) = 1;
TIMx(TIM_ch1)->CTLR1 = TIM_ARPE;
dma_clock(timer_dma(TIM_ch1), 1);
dma_cfg_io(timer_dma(TIM_ch1), &GPIOB->BSHR, leds_data, 8);
dma_cfg_mem(timer_dma(TIM_ch1), 32,0, 32,1, 1, DMA_PRI_VHIGH);
dma_enable(timer_dma(TIM_ch1));
dma_enable(timer_dma(TIM_ch1));
timer_chval(TIM_ch2) = 1;
dma_clock(timer_dma(TIM_ch2), 1);
dma_cfg_io(timer_dma(TIM_ch2), &TIMx(TIM_ch1)->ATRLR, tim_top, 8);
dma_cfg_mem(timer_dma(TIM_ch2), 16,0, 16,1, 1, DMA_PRI_VHIGH);
dma_enable(timer_dma(TIM_ch2));
timer_enable(TIM_ch1);
}
void pwm_set(uint8_t chan, uint8_t val){
uint32_t mask = (1<<chan);
uint32_t nmask = ~mask;
for(int i=0; i<8; i++){
if(val & 0x80)leds_data[i] |= mask; else leds_data[i] &= nmask;
val <<= 1;
}
}
Вначале было сказано, что у нас нет требования делать импульс и паузу непрерывными. А это допустимо далеко не для всех задач. Например, при управлении мощной нагрузкой хотелось бы сделать количество переключений как можно меньше, ведь каждое включение / отключение это переходный процесс, во время которого переключатель будет греться. Да и помехи при этом возникают. В общем, как обычно, выбираем алгоритм исходя из задачи.
9.5 Таймерный DMA ("DMA burst feature")
Нередко встречается и задача, когда используется несколько аппаратных ШИМов одного таймера, причем их значения нужно постоянно менять. Скажем, генерация нескольких синусоид для асинхронного двигателя. Можно, конечно, задействовать несколько независимых каналов DMA, но есть и более интересное решение. В stm32 его назвали "DMA burst feature" (в gd32 и ch32 не назвали никак).
Суть в том, что в таймере выделен специальный регистр DMATB (рассматриваем на примере gd32), запись в который приводит к записи в один из "обычных" регистров таймера, после чего тут же генерируется новый триггер DMA, и следующее значение записывается в следующий регистр. И таких связанных триггеров может быть довольно много, до 18 штук, то есть за раз можно переписать вообще все регистры таймера. Впрочем, мы в такие крайности впадать не будем, ограничимся записью в два канала ШИМ, нулевой и первый, на которых в моей плате висят светодиоды.
За количество дополнительных триггеров отвечают биты 8-12 регистра DMACFG, а за номер регистра, начиная с которого пойдет запись — биты 0-4. Обратите внимание: туда записывается не абсолютный адрес регистра, а именно номер. Так, регистру CTL0 соответствует нулевой номер, CTL1 — первый, а, скажем, CHCTL0 — шестой. Номер регистра легко получить, вычтя из его адреса адрес CTL0 и поделив на 4 (потому что регистры таймера считаются 32-битными).
Итак, алгоритм использования этой "пакетной DMAшной фичи" таков: запускаем DMA, а в адрес периферии вместо TIMER_CH0CV записываем TIMER_DMATB. В регистр конфигурации DMACFG записываем начальный номер и количество (счет там с 1, поэтому для двух регистров нужно записать число 1). В регистр количества транзакций записываем суммарное количество транзакций, с учетом добавочных. В общем, код будет выглядеть примерно так:
#define VALS_CNT 1000
struct{
uint16_t a;
uint16_t b;
}vals[VALS_CNT];
...
for(int i=0; i<VALS_CNT; i++){
vals[i].a = 255 - 255*i/VALS_CNT;
vals[i].b = 255*i/VALS_CNT;
}
GPIO_manual(YLED, GPIO_APP50);
GPIO_manual(GLED, GPIO_APP50);
RCU_APB1EN |= RCU_APB1EN_TIMER3EN;
TIMER_PSC(TIMER3) = (100 - 1); //тайминги выбраны от балды, никакого скрытого смысла в них нет
TIMER_CAR(TIMER3) = (256 - 1);
PM_BITMASK( TIMER_CHCTL0(TIMER3), TIMER_CHCTL0_CH0COMCTL, 0b110); //Прямой, неинвертированный ШИМ
TIMER_CHCTL2(TIMER3) |= TIMER_CHCTL2_CH0EN;
PM_BITMASK( TIMER_CHCTL0(TIMER3), TIMER_CHCTL0_CH1COMCTL, 0b110);
TIMER_CHCTL2(TIMER3) |= TIMER_CHCTL2_CH1EN;
TIMER_DMACFG(TIMER3) = ((2-1)<<8) | (((uint32_t)&TIMER_CH0CV(TIMER3)) - ((uint32_t)&TIMER_CTL0(TIMER3)))/4;
RCU_APB1EN |= RCU_APB1EN_TIMER4EN;
TIMER_PSC(TIMER4) = (500 - 1);
TIMER_CAR(TIMER4) = (256 - 1);
RCU_AHBEN |= RCU_AHBEN_DMA1EN;
DMA_CHCTL(DMA1, 1) = 0;
DMA_CHPADDR(DMA1, 1) = (uint32_t)&TIMER_DMATB(TIMER3);
DMA_CHMADDR(DMA1, 1) = (uint32_t)vals;
DMA_CHCNT(DMA1, 1) = VALS_CNT*2;
DMA_CHCTL(DMA1, 1) = DMA_PRIORITY_MEDIUM
| DMA_PERIPHERAL_WIDTH_16BIT // per data size: 16
| DMA_MEMORY_WIDTH_16BIT // mem data size: 16
| (0*DMA_CHXCTL_PNAGA) // Autoincrement per: disable
| (1*DMA_CHXCTL_MNAGA) // Autoincrement mem: enable
| (1*DMA_CHXCTL_CMEN) // Circular mode: enable
| (1*DMA_CHXCTL_DIR); // Direction: mem -> per
DMA_CHCTL(DMA1, 1) |= DMA_CHXCTL_CHEN;
TIMER_DMAINTEN(TIMER4) |= TIMER_DMAINTEN_UPDEN;
TIMER_CTL0(TIMER4) |= TIMER_CTL0_CEN;
TIMER_CTL0(TIMER3) |= TIMER_CTL0_CEN;
Здесь в качестве таймера с ШИМ выбран TIMER3, а в качестве тактирования DMA — TIMER4.
Внимание, грабли! В ch32 и для ШИМ, и для DMA обязательно должен использоваться один и тот же таймер, иначе работать не будет. В gd32 и stm32 такого бага нет. Также не забывайте, что дополнительные транзакции DMA это все-таки дополнительные транзакции DMA, значения будут все также пересылаться по одному. Это скажется и на загрузке шины, и на синхронности обновления (к счастью, у таймера есть свой механизм синхронизации).
9.6 Пример: динамическая индикация
Среди простейших устройств ввода-вывода распространены такие, которые требуют периодического обновления — матричные дисплеи и клавиатуры. В силу своей простоты, они не имеют встроенных "мозгов", и представляют собой именно матрицу светодиодов, кнопок или чего-то подобного. Рассмотрим их на примере светодиодной матрицы 8х8.
Если подать на одну из строк лог.0, а на остальные — лог.1, то комбинацией уровней на столбцах можно зажигать и гасить светодиоды в этой строке в произвольном порядке. Но только в одной за раз. Чтобы на такой матрице получить цельную картинку, придется с достаточно высокой скоростью циклически переключать строки, и для каждой из них выставлять нужную комбинацию битов в столбцах. За счет инерционности зрения как раз и получится картинка.
Важно отметить порядок переключения: если сначала переключить строку, и только потом содержимое столбцов, в новой строке на несколько тактов останутся старые значения. Визуально это выглядит как засветка, "эхо" с предыдущей строки. Чтобы этого избежать, надо сначала погасить все светодиоды, потом переключить строки, и в самом конце зажечь диоды обратно. Или наоборот, отключить все строки, поменять значения столбцов, и включить уже новую строку.
Геометрически такая матрица не обязательно организована в виде квадрата. Одна из типичных конфигураций — семисегментный дисплей. Отдельные светодиоды здесь соответствуют отдельным сегментам (в том числе десятичной точке). Накладывая это на картинку выше, можно придумать два логичных варианта соединения (нелогичные не рассматриваем): сегменты одного разряда расположены в строку (общий катод, зажигаются подачей лог.1) или в столбец (общий анод, зажигаются подачей лог.0). В реальности существуют и такие, и такие индикаторы. И, естественно, не только на 8 разрядов. И даже не обязательно на 8 сегментов.
А вот распиновку у большинства из них как раз логичной назвать нельзя: выводы сегментов и разрядов располагаются, кажется, в случайном порядке, причем у каждого производителя в своем. Поэтому, ради упрощения разводки платы, бывает удобно перекидывать сегменты и разряды между разными выводами контроллера. Например, сегмент A будет на пятой ножке, сегмент B — на восьмой, а сегмент C — на первой. А в следующем проекте ножки будут другими. Поэтому хочется предостеречь от желания прописать коды цифр напрямую двоичными значениями. Лучше сначала сопоставить ножку и сегмент, а уже потом из этих констант формировать коды цифр. Например, так:
#define SEG_A (1<<0)
#define SEG_B (1<<1)
#define SEG_C (1<<2)
#define SEG_D (1<<3)
#define SEG_E (1<<4)
#define SEG_F (1<<5)
#define SEG_G (1<<6)
#define SEG_DOT (1<<7)
#define SEG_0 (SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F)
#define SEG_1 (SEG_B | SEG_C)
#define SEG_2 (SEG_A | SEG_B | SEG_D | SEG_E | SEG_G)
#define SEG_3 (SEG_A | SEG_B | SEG_C | SEG_D | SEG_G)
#define SEG_4 (SEG_B | SEG_C | SEG_F | SEG_G)
#define SEG_5 (SEG_A | SEG_C | SEG_D | SEG_F | SEG_G)
#define SEG_6 (SEG_A | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define SEG_7 (SEG_A | SEG_B | SEG_C)
#define SEG_8 (SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define SEG_9 (SEG_A | SEG_B | SEG_C | SEG_D | SEG_F | SEG_G)
Возвращаясь к исходной матрице 8х8, можно заменить светодиоды на кнопки — получится матричная клавиатура. Подав на одну из строк лог.1, а остальные оставив в Z-состоянии (не в лог.0, иначе при нажатии нескольких кнопок одновременно получите короткое замыкание) и включив на всех столбцах подтяжку к земле (чтобы когда кнопки не нажаты, получить известный уровень, а не хаотичную болтанку), по логическим уровням можно определить какие из кнопок в этой строке нажаты. Как и с дисплеем, чтобы узнать состояния всех кнопок, придется в цикле перебрать все строки.
Важная особенность: если зажать, например, две кнопки на пересечении 2 строки и 3 и 4 столбцов, плюс кнопку на 5 строке 3 столбца, программа будет считать, что нажаты эти три кнопки, но плюс к ним еще кнопка на 5 строке 4 столбца — в оставшемся углу квадрата. Это легко понять, если посмотреть распространение тока. При желании это можно воспроизвести даже на обычной компьютерной клавиатуре. Если такой эффект нежелателен, можно последовательно с каждой кнопкой поставить диод. Но обычно этого не делают.
Как вы уже поняли, для работы с такими устройствами также можно применить DMA. Правда, на практике я такого не видел: скорость обновления относительно невелика, и хватает периодического вызова обычной функции. Пример кода приводить не буду. Чуть подробнее про матричную периферию (без DMA) я описывал здесь (видеоверсия).
9.7 Пример: логический анализатор на SPI
Вообще-то, логический анализатор у цифрового схемотехника и так должен быть, но не всегда он под рукой, не всегда хватает его скорости… не всегда удается удержаться от соблазна изобрести велосипед. Итак, задача: с максимально возможной частотой снять последовательности битов с одной (а лучше с нескольких) ножки, записать в память (все равно скорости имеющихся в наличии интерфейсов не хватит, чтобы эти данные передать в реальном времени), а потом передать на компьютер и отобразить в приемлемом виде.
Первым делом возникает соблазн, как и раньше, по DMA читать из GPIO и писать в буфер. Вот только размер регистра данных GPIO целых 16 бит. Ладно, можно попытаться 8. А нужно один, максимум два бита. То есть 6/8 памяти будет расходоваться впустую. А ее и так мало. Поэтому откажемся от желания читать любую ножку, а ограничимся только теми, с которых данные можно снимать последовательно.
Очень хорошо для этого подходит модуль SPI: в режиме ведущего (master) он побитово выдает данные на MOSI и тактовые импульсы на SCK (но это можно подавить, если не настраивать MOSI и SCK в альтернативную функцию) и побитово же читает с MISO. А в режиме ведомого (slave) побитово, синхронизуясь по внешнему сигналу на SCK, выдает данные на MISO и читает с MOSI. Причем регистр данных у SPI можно настроить как на 8, так и на 16 бит, чем мы воспользуемся впоследствии. Таким образом, получается два варианта:
SPI в режиме ведущего. MOSI и SCK настроены как обычные, не альтернативные, GPIO; MISO как обычный вход. По событию "буфер приемника не пуст" срабатывает DMA, читает оттуда данные и сохраняет в массиве. Вот только пока в буфер передатчика не запишут очередной байт, продолжаться передача не будет, поэтому придется завести второй канал DMA по событию "буфер передатчика пуст" и циклически записывать туда какой-нибудь мусор.
SPI в режиме ведомого, SCK соединен с каким-нибудь источником меандра. Например, выходом таймера. По событию "буфер приемника не пуст" срабатывает DMA. В отличие от предыдущего варианта, здесь нужен всего один канал DMA, но зато добавляется внешняя перемычка, соединяющая ногу таймера с ногой SCK.
Эти варианты можно комбинировать. Например, запустить один SPI в режиме ведущего, а второй — ведомого, и их SCK соединить. Будем снимать два канала одновременно и синхронно. Или можно запустить два SPI в режиме ведомых, а их SCK соединить друг с другом и с выходом таймера.
Внимание, грабли! При работе двух SPI (ведущий — ведомый) на максимальной частоте, скорости шины данных перестает хватать. Ведь за 16 тактов ядра приходится сделать целых три чтения из памяти и три записи, для DMA это слишком быстро. Зато можно настроить SPI в 16-битный режим, тогда те же три транзакции нужно будет сделать за 32 такта, на это скорости уже хватает.
Внимание, еще грабли! Я тестировал эту идею на ch32v307 с настройками распределения памяти на максимум ОЗУ, 128 кБ, в ущерб кешу. И оказалось, что DMA1 не умеет пересекать границу между младшими и старшими 64 кБ блоками. Писать 10 кБ начиная с 0x2000 0100
— пожалуйста, начиная с 0x2001 0000
— пожалуйста, а вот с 0x2000 4000
уже ни в какую. У DMA2 такой проблемы нет. Соответственно, надо либо очень тщательно планировать распределение буферов, либо пользоваться SPI3, который как раз на DMA2 висит. Либо и вовсе таймерами, которых тоже на DMA2 хватает.
А теперь о красивом отображении на экране компьютера. Я использую для логического анализатора утилиту pulseview, но быстро найти как с ней взаимодействовать по UART, я не нашел (а искать тщательно смысла нет — не та задача). Поэтому просто перенаправляем вывод UART в файл и говорим утилите, что там сырые байты, 8 каналов. То есть каждый байт кодирует мгновенное состояние восьми линий.
Соответственно, наш код должен при передаче перекодировать из нескольких независимых буферов SPI в один общий. А поскольку каналов у нас не больше трех, свободные биты можно использовать для чего-нибудь другого. Например, прикинуться UART-ом и писать туда отладочную информацию (обычный-то UART занят). При желании можно еще написать скрипт, который бы дожидался начала — окончания передачи, подсовывал pulseview конфигурационный файл и т.д. В конечном итоге на ch32v307, работающем на частоте 144 МГц и с 128 кБ памяти, можно получить двухканальный анализатор на 504000 отсчетов или 7 миллисекунд времени. Вполне достаточно чтобы проверить какие же биты с какими таймингами этот же контроллер передает или принимает по соседнему интерфейсу.
А на CH32V303 — посмотреть осциллограмму описанного ранее BAM. Собственно, картинка выше получена именно так. Исходный код доступен на github.
Дополнительная информация
Видеоверсия на Ютубе (только по gd32)