В прошлый раз мы научились создавать в STM32CubeMX новый проект, настраивать тактовый генератор, таймер и порт ввода-вывода, и немного помигали светодиодом. Сегодня мы освоим цифро-аналоговый преобразователь и научимся работать с ним через DMA. В результате у нас должен получиться простой генератор прямого синтеза (Direct digital synthesizer, DDS).
Работа с DAC
Большая часть микроконтроллеров STM32 оснащена 12-bit DAC в количестве одного или двух штук. При этом архитектура DAC одинакова во всех кристаллах, неважно, какое ядро ARM Cortex M там используется. Таким образом, сегодняшний эксперимент можно выполнить на любом микроконтроллере STM32, имеющем хотя бы один DAC.
Какова частота преобразования DAC? Ответ на этот вопрос не совсем прост. Если вы хотите подробностей, рекомендую эти два документа: [1][2]
Если излагать суть кратко, то она заключается в следующем: сам по себе ЦАП может обновлять выход с частотой до 5 MSPS (мегасэмплов в секунду), но буферный операционный усилитель (ОУ) на выходе не обеспечит такой скорости, ограничивая её до 1 MSPS. Если мы хотим больше, нам нужен внешний ОУ, к которому предъявляются некоторые требования по частотным характеристикам, о которых будет сказано ниже. Без буферного ОУ ЦАП использовать нельзя, так как он имеет довольно большое выходное сопротивление (> 10 кОм).
Немного схемотехники
На частоте выше сотни килогерц встроенный буферный усилитель начинает вносить существенные искажения в сигнал, поэтому я сразу поставил на выход внешний буферный усилитель, удовлетворяющий рекомендациям ST.
Для достижения скорости преобразования 5MSPS, ST рекомендует использовать ОУ с частотой единичного усиления не менее 10 MHz, усилением при разомкнутой обратной связи не менее 60 дБ и скоростью нарастания выходного сигнала не менее 16,5 В/мкс. ST в качестве примера рекомендует ОУ LMH6645/6646/6647 производства Texas Instruments.
Я использовал ОУ AD845JN, который имеет частоту единичного усиления 16 МГц, типовое значение коэффициента усиления 500 В/мВ (около 114 дБ) и скорость нарастания 100 В/мкс. Питание ОУ производится от DC/DC преобразователя 5 В/±9 B. Можно питать буферный усилитель однополярным питанием, например, взяв 5 В прямо с платы, но тогда понадобится rail-to-rail усилитель. Схема подключения приведена на рис. 1.
Рис. 1. Схема выходного буферного усилителя
Специальную плату делать не стал, смонтировал проводом на макетной плате, которая вставлена в arduino-совместимое посадочное место на отладке.
Рис. 2. Вид платы буферного усилителя
Внутренний буферный усилитель микроконтроллера начинает вносить заметные нелинейные искажения уже начиная с частоты 100-150 кГц. Если вы не собираетесь использовать DAC для генерации сигналов выше этих частот, можно обойтись и без буфера.
Теперь переходим к программной части.
Конфигурация DAC
Будем считать, что мы уже умеем создавать проект в CubeMX, выбирать микроконтроллер и настраивать тактовый генератор, как в первой части. Можно просто взять проект из первой части и продолжить его.
На плате, которой я пользуюсь, выходы DAC выведены не слишком удобно, к сожалению, DAC_OUT1 (вывод N4) выведен на разъем DCMI, DAC_OUT2 (вывод P4) подключен к интерфейсу USB и вряд ли может быть использован в качестве выхода DAC. Поэтому остаётся только DAC_OUT1. Включаем его во вкладке Pinout:
Во вкладке «Configuration» у DAC есть только одна интересная нам настройка: Output Buffer. Если вы не используете внешний усилитель, он должен быть включен, если используете — выключен.
Можно управлять выходом DAC «вручную» из программы, можно задействовать DMA. Второй способ хорошо подходит для генерации периодического сигнала произвольной формы, и его мы рассмотрим ниже, а сейчас используем первый способ. Просто установить на выходе постоянное напряжение неинтересно, попробуем сгенерировать сигнал. Для этого нам понадобится таймер. Делаем всё как в первой части, только частоту таймера устанавливаем больше, например, 500 кГц. Для этого нужно установить значение Prescaler = 215, тогда мы получим 216 МГц/(215 + 1) = 1 МГц, и Counter Period = 1, что даст 1 МГц / (1 + 1) = 500 кГц. Напоминаю, что 216 МГц — частота тактирования периферии в нашей конфигурации системы тактирования.
Генерация сигнала из обработчика прерываний
Генерируем код, открываем проект и вписываем следующее:
/* USER CODE BEGIN 0 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
static int val = 0;
if (htim->Instance==TIM1) //check if the interrupt comes from TIM1
{
HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, val);
val = val? 0: 4095;
}
}
/* USER CODE END 0 */
...
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&htim1);
__HAL_DAC_ENABLE(&hdac, DAC_CHANNEL_1);
/* USER CODE END 2 */
Ещё раз напоминаю, что весь пользовательский код пишется между строками вида /* USER CODE BEGIN… */… /* USER CODE END… */
Первый участок кода — обработчик прерывания таймера, в котором в регистр DAC записывается попеременно 0 и 4095, т. е. минимальное и максимальное значение DAC. Второй участок кода включает таймер и DAC. Получаем прямоугольные колебания с частотой 250 кГц, но из-за вносимых буферным усилителем искажений они выглядят так:
Пришло время задействовать внешний буфер, отключив внутренний. Для этого в STM32CubeMX заходим на вкладку Configuration, нажимаем кнопку DAC, на вкладке Parameter Settings устанавливаем Output Buffer = Disable. Заново генерируем код и прошиваем в плату. Сейчас импульсы выглядят как меандр, пропущенный через ФНЧ (в силу того, что полоса пропускания системы всё же ограничена):
Можно даже приблизительно оценить частоту среза ФНЧ.
Способ понятен из рисунка: проводим касательную к экспоненте до пересечения с верхним уровнем сигнала. Расстояние по шкале времени от начала импульса до пересечения и будет постоянной времени фильтра τ = 400 нс, частота среза равна fср = 1/2πτ ≈ 0,4 МГц.
Попробуем увеличить частоту в два раза, уменьшив Prescaler до 107, но нас ждёт разочарование: выше 333 кГц частота не поднимается. Вероятно, нужна некоторая оптимизация кода.
В действительности, наибольшую задержку вносит огромный обработчик прерывания таймера в недрах HAL (функция HAL_TIM_IRQHandler). Его можно заменить своим. Для этого находим файл stm32f7xx_it, и в нём изменяем функцию TIM1_UP_TIM10_IRQHandler:
void TIM1_UP_TIM10_IRQHandler(void)
{
/* USER CODE BEGIN TIM1_UP_TIM10_IRQn 0 */
static int val = 0;
/* TIM Update event */
__HAL_TIM_CLEAR_IT(&htim1, TIM_IT_UPDATE);
if (htim1.Instance==TIM1) {
*(uint32_t*)(DAC_BASE + 0x00000008U) = val;
val = val? 0: 4095;
}
return;
/* USER CODE END TIM1_UP_TIM10_IRQn 0 */
HAL_TIM_IRQHandler(&htim1);
/* USER CODE BEGIN TIM1_UP_TIM10_IRQn 1 */
/* USER CODE END TIM1_UP_TIM10_IRQn 1 */
}
HAL_TIM_IRQHandler больше не вызывается. Теперь частоту таймера можно поднять до 2 МГц, а частоту меандра, соответственно, до 1 МГц. Для этого нужно в настройках таймера установить значение Prescaler = 53, и тогда мы получим такую картину:
Это, вероятно, максимальная частота, достижимая на данном микроконтроллере.
Библиотека HAL, конечно, удобная вещь, но внутри неё происходит много разных действий, которых можно избежать. Просто следует помнить, что преждевременная оптимизация — зло, и прибегать к ней только когда мы достигли ограничения, как в этот раз.
Ещё один нюанс. Мы можем заметить, что в сигнале иногда попадаются странные скачки, имеющие период 1мс.
Они получаются в результате того, что у нас в системе есть ещё одно прерывание, имеющее больший приоритет, чем наш таймер. Оно спрятано внутри HAL, и это системный таймер SysTick, имеющий наивысший (нулевой) приоритет прерываний. Для исправления ситуации заходим в STM32CubeMX->Configuration->NVIC->Time base: System Tick Timer->Preeption Priority = 1. Заново генерируем код, искажения сигнала исчезли.
Попробуем сгенерировать синусоидальный сигнал. Для этого нам нужен массив длиной N значений, заполненный значениями функции round(A * cos((pi / 2) * (n / N))), где A — амплитуда сигнала, N — количество точек в массиве, n — номер точки. При выводе будем сдвигать точки на shift = 2048, амплитуда пусть будет А = 2047, тогда значения DAC будут от 1 до 4095. Массив можно заполнить только на четверть периода, от 0 до pi/2, а недостающие значения получать из него путём очевидных арифметических действий. Почему используем функцию косинуса, а не синуса, я напишу дальше.
Как выбрать N? С одной стороны, чем больше N, тем лучше, значения будут более близкими к точным величинам, с другой стороны, ограниченная разрядность DAC делает такое увеличение бесполезным после величины [A * pi / 2] = 3215. В самом деле, при N = 3215 приращение угла составит pi / (2 * 3215) = 4.89e-4, а приращение амплитуды вблизи середины шкалы, где скорость нарастания максимальна, составит 4.89e-4 * 2047 = 1 дискрет DAC.
Мы можем сгенерировать массив заранее и разместить его во flash-памяти, можем сгенерировать его в run-time при инициализации. Первый способ предпочтительнее для практического применения, но мы воспользуемся вторым для большей наглядности:
Я хочу, чтобы массив и формирование сигнала происходили в main, поэтому изменяем код обработчика прерывания в файле stm32f7xx_it на следующий:
void TIM1_UP_TIM10_IRQHandler(void)
{
/* USER CODE BEGIN TIM1_UP_TIM10_IRQn 0 */
/* TIM Update event */
if(__HAL_TIM_GET_FLAG(&htim1, TIM_FLAG_UPDATE) != RESET) {
if(__HAL_TIM_GET_IT_SOURCE(&htim1, TIM_IT_UPDATE) !=RESET) {
__HAL_TIM_CLEAR_IT(&htim1, TIM_IT_UPDATE);
if (htim1.Instance==TIM1) {
HAL_TIM_PeriodElapsedCallback(&htim1);
}
}
}
return;
/* USER CODE END TIM1_UP_TIM10_IRQn 0 */
HAL_TIM_IRQHandler(&htim1);
/* USER CODE BEGIN TIM1_UP_TIM10_IRQn 1 */
/* USER CODE END TIM1_UP_TIM10_IRQn 1 */
}
Сейчас «медленная» функция HAL_TIM_IRQHandler() не вызывается, а вызывается HAL_TIM_PeriodElapsedCallback в main. В main пишем следующее:
/* USER CODE BEGIN Includes */
#include "math.h"
/* USER CODE END Includes */
/* USER CODE BEGIN 0 */
volatile uint32_t * dac;
#define N 3216
#define DAC_SHIFT 2047
static uint16_t cosine[N];
const int delta = 257;
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
static int val = 0;
static int phase = 0;
*(uint32_t*)(DAC_BASE + 0x00000008U) = val;
phase += delta;
phase = phase > N * 4 - 1 ? phase - N * 4 : phase;
if(phase < N) {
val = DAC_SHIFT + cosine[phase];
}
else if(phase < 2 * N) {
val = DAC_SHIFT - cosine[2 * N - 1 - phase];
}
else if(phase < 3 * N) {
val = DAC_SHIFT - cosine[phase - 2 * N];
}
else {
val = DAC_SHIFT + cosine[4 * N - 1 - phase];
}
}
/* USER CODE END 0 */
int main(void)
{
/* USER CODE BEGIN 1 */
const float A = 2047;
const float PI = 3.1415927;
for(int i = 0; i < N; i++) {
cosine[i] = (uint16_t)(round(A * cos((i * PI) / (N * 2.0))));
}
/* USER CODE END 1 */
В этом коде также присутствует величина delta — приращение фазы. Изменяя delta, можно менять частоту сигнала в широких пределах. delta = 1 соответствует наименьшей частоте (и наилучшему приближению синусоидальной функции), максимальная величина delta, имеющая смысл в данном случае равна 2*N. При этом на выходе должен быть меандр, т.к. фаза становится попеременно то 0, то pi, а значения косинуса 1 и -1 соответственно. Поэтому мы и записали в массив не синус, а косинус, при синусе значения функции в этих точках были бы равны 0, и мы бы не увидели никакого сигнала.
В STM32CubeMX устанавливаем частоту срабатываний таймера 500 кГц, например так: Prescaler = 107, Counter Period = 3. Получаем красивую синусоиду с частотой 500e3/(4 * 3216) = 38,868 Гц.
Максимальная частота при данных настройках составит 250 кГц. При этом сигнал превратится в меандр, их мы уже видели. Попробуем получить 10 кГц. Для этого мы должны выставить delta — (1e4/2e5) * 4 * 3216 = 257.28. Округляем до целого значения 257, получаем расчётное значение частоты (5e3 * 257) / (4 * 3216) = 9989 Гц. Получаем такую картинку:
Разница с частотой 10 кГц составляет около 0,1%. Можно ли выставлять частоту более точно? Можно, но для этого нужно считать фазу как float, но на данной частоте дискретизации (500 кГц) микроконтроллер не успевает считать фазу как float. Возможно снизить частоту тактирования таймера или попытаться вручную оптимизировать код, но это уже другая история. Пока достигнутая точность нас устраивает.
Работа с DMA
Встроенный в микроконтроллеры STM32 цифроаналоговый преобразователь (DAC) может работать по сигналам таймера и получать данные напрямую из массива памяти через DMA. Таким образом, можно сконфигурировать контроллер так, что DAC будет работать без участия программы, не затрачивая ресурсы процессора, за исключением инициализации системы.
Недостатком метода с DMA является то, что здесь невозможны трюки с записью в массив четверти периода и вычисление накопления фазы. Мы должны записать в память весь массив и указать нужный период. Преимуществом, как уже упоминалось, является то, что при генерации сигнала через DMA процессор свободен для другой работы.
Итак, откроем новый проект в STM32CubeMX, и проделаем уже знакомую нам процедуру конфигурации тактового генератора. Теперь настроим всё остальное.
Вызывать DMA способны два таймера: TIM6 и TIM7. Задействуем TIM6.
Далее устанавливаем следующие настройки:
Генерируем код, и вставляем в main следующее:
/* USER CODE BEGIN Includes */
#include "math.h"
/* USER CODE END Includes */
//...
/* USER CODE BEGIN 0 */
#define N 3216
static uint16_t sine[N * 4];
/* USER CODE END 0 */
int main(void)
{
/* USER CODE BEGIN 1 */
const float A = 2047;
const float PI = 3.1415927;
for(int i = 0; i < N * 4; i++) {
sine[i] = 2048 + (uint16_t)(round(A * sin((i * PI) / (N * 2.0))));
}
/* USER CODE END 1 */
/* MCU Configuration----------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* Configure the system clock */
SystemClock_Config();
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_DMA_Init();
MX_DAC_Init();
MX_TIM6_Init();
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start(&htim6);
HAL_DAC_Start(&hdac,DAC_CHANNEL_1);
HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)sine, N * 4, DAC_ALIGN_12B_R);
/* USER CODE END 2 */
Конечно, мы можем сделать и массив другого размера. Размер массива должен передаваться в функцию HAL_DAC_Start_DMA четвёртым параметром, после адреса массива.
После запуска программы мы должны получить на выходе синусоиду, точно такую же, как приводилась выше, поэтому приводить скриншот я не буду.
Вот и всё, что я хотел написать про работу с DAC.
Что дальше?
В следующий раз мы кратко обсудим интерфейсы USB и Ethernet.
1. AN3126
Application note «Audio and waveform generation using the DAC in STM32 microcontrollers»
2. AN4566
Application note «Extending the DAC performance of STM32 microcontrollers»
Автор: 32bit_me