В предыдущих частях мы научились работать с 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)
Комментарии (10)
rukhi7
14.02.2025 05:29интересно, кстати, что сам по себе процессор можно рассматривать (ограниченно-гипотетически) как модуль ДМА который способен выполнять те же ДМА операции. Принципиальное отличие состоит в том что процессор управляется инструкциями, а ДМА данными из регистров.
На самом деле инструкции процессора это тоже данные - биты и байты, отличаются временные характеристики управления с которыми данные воздействуют на модуль.
Действие инструкций ограничено несколькими тактами максимум,
действие данных из регистров ДМА может вообще не иметь ограничения по времени, однажды установленные значения регистров будут воздействовать - управлять модулем ДМА вечно(!) или пока их не сменят. И еще связанные комбинации данных в регистрах будут управлять модулем ДМА параллельно-одновременно, тогда как инструкции процессора управляют процессором не зависимо друг от друга - по очереди (хотя может быть некоторая корелляция воздействий от ближайших в последовательности инструкций) то есть способ комбинации воздействий управления отличается тоже во времени - параллельный и накладывающийся по мере поступления воздействий управления.
COKPOWEHEU Автор
14.02.2025 05:29На самом деле каждое устройство ввода вывода с точки зрения процессора это тоже маленький модуль памяти (регистры это тоже память).
Регистры все-таки памятью не считаются. К ним нельзя обратиться по адресу с помощью lw/sw, только жестко прописать номер в коде инструкции. А вот в AVR они таки отображались на память, по нулевым адресам. Правда, ни разу не видел чтобы этим кто-то пользовался.
А отображение периферии на память так и называется, MMIO, memory-mapped input-output.так они на разных шинах наверно висят.
Да не похоже. Раз можно поменять местами. Точнее, оба адреса DMA могут коммутироваться на любую шину. Впрочем, про исключение я в статье написал. Камень, в котором разделение PADDR / MADDR все-таки важно.
интересно, кстати, что сам по себе процессор можно рассматривать как модуль ДМА который способен выполнять те же ДМА операции.
Нет. Процессор **может ** играть роль DMA, но это мизерная часть его возможностей. Собственно, есть же куча процессоров, в которых DMA нет, и ничего, живут.
действие данных из регистров ДМА может вообще не иметь ограничения по времени, однажды установленные значения регистров будут воздействовать - управлять модулем ДМА вечно(!)
Ну... да. Как в принципе и любая другая периферия. ШИМ на таймере тоже может генерироваться вечно. Вообще, я не особо понял о чем эти ваши два поста: большая часть сказанного в них так или иначе дублирует описанное мной.
rukhi7
14.02.2025 05:29Да не похоже. Раз можно поменять местами. Точнее, оба адреса DMA могут ...
ну надо просто какую то вот такую схему найти и посмотреть, или даже более подробную именно относительно конфигурации шин иногда производитель приводит.
COKPOWEHEU Автор
14.02.2025 05:29Вот и посмотрите для тех же gd32 или ch32. Можете сравнить с упомянутым в статье stm32f4.
rukhi7
14.02.2025 05:29ну да память SRAM и EXMC висят на специальных шинах внутренней и внешней памяти, переферия на паре других шин
и есть еще выделенный тип переферии для которого еще одна шина определена.
rukhi7
14.02.2025 05:29Собственно, есть же куча процессоров, в которых DMA нет, и ничего, живут.
ну в больших компах ДМА это отдельная микросхема. Я под процессором имел ввиду аппаратный модуль который выполняет инструкции (в соответствии с класическим определением). Функции работы с памятью действительно
мизерная часть его возможностей
поэтому я и добавил "если" и "рассматривать ограниченно". И "гипотетически" потому что эти функции будут работать маленько по другому чем в настоящем ДМА как раз по причине особенностей управления которые я обозначил.
К ним нельзя обратиться по адресу с помощью lw/sw
это как я понимаю как раз анализ системы команд процессора то есть ассемблера.
Вообще, я не особо понял о чем эти ваши два поста: большая часть сказанного в них так или иначе дублирует описанное мной.
Как же вы не поняли, если определили дублирование? Дублирование (по смыслу, но не по способу и направлению изложения) практически необходимость в изложении таких технических вопросов, посмотрите любую подробную, серьезную ПДФ-ку по процессору, там одно и тоже всегда расписано чуть ли не по 5 раз - именно чтобы развеить малейшую неопределенность формулировок у того кто собирается использовать описанное.
COKPOWEHEU Автор
14.02.2025 05:29ну да память SRAM и EXMC висят на специальных шинах внутренней и внешней памяти, переферия на паре других шин
Как минимум, на тройке - AHB, APB1, APB2, но это не предел. В какой-нибудь stm32h7 шин еще больше. Это никак не отменяет способность и PADDR и MADDR обращаться куда угодно.
ну в больших компах ДМА это отдельная микросхема.
Я говорил скорее про древние восьмибитки вроде AVR или PIC. Весь обмен с периферией только прямыми командами ядра. То есть оно вполне выполняло обязанности DMA.
Как же вы не поняли, если определили дублирование?
Я понял что написано, я не понял зачем. И соответственно как мне на это реагировать. Вы уточняете правильно ли поняли данную тему? Вы нашли в тексте ошибку и озвучиваете более правильную версию? Вы хотите как-то дополнить написанное?
rukhi7
14.02.2025 05:29как вам вариант: завести разговор с коллегой :) ? И таким образом поддержать коллегу?
Наверно в каком то смысле пытался развивать то что у вас написано.
COKPOWEHEU Автор
14.02.2025 05:29Да меня-то устроит любой вариант (кроме, разумеется, неадекватных), главное было понять с каким обратились вы.
rukhi7
так они на разных шинах наверно висят. Память это отдельный обособленный модуль он обычно на отдельной шине висит, устройства ввода вывода- каждое(!) это другие обособленные модули. На самом деле каждое устройство ввода вывода с точки зрения процессора это тоже маленький модуль памяти (регистры это тоже память). Поскольку эти модули обладают сравнительно маленькими объемами памяти их вешают на одну шину, но обычно отдельную от шины настоящей памяти, но с точки зрения ДМА разные шины это разные устройства-контроллеры шин поэтому они и различаются на уровне предназначенных регистров управления, это регистры для разных устройств.
Как это логически отображается в адресное пространство процессора обычно можно понять по набору ассемблерных инструкций процессора. Потому что то что делает ДМА это фактически тот же набор операций по работе с памятью, то есть который закодирован в наборе ассемблерных операций, но с некоторым развитием в сторону готовых процедур которые можно отдельно настраивать.