Espruino:Примеры/Низкоуровневый доступ к различным компонентам STM32
Низкоуровневый доступ к различным компонентам STM32[1]
Espruino значительно упрощает доступ к периферийным устройствам микроконтроллера, но сами эти устройства могут делать гораздо больше того, к чему открыт доступ в JavaScript.
Если вы хотите выжать из этих компонентов максимум, с ними нужно работать напрямую. В этой статье мы расскажем, как получить доступ к таймеру, чтобы измерять продолжительность импульса на микроконтроллерах STM32, используемых в Espruino Original и Pico.
Документация
Лучший источник документации по периферийным компонентам чипа – это сам производитель, то есть ST. Нам понадобятся мануал и даташит для используемого чипа:
Для Espruino Original:
Для Espruino Pico:
В этой статье речь пойдёт об Espruino Pico. Это довольно большие документы, но нам понадобится лишь маленькая их часть.
Что происходит в таймере во время ШИМ
Для начала давайте взглянем в мануал на страницу 241 в раздел «Advanced-control timer (TIM1)». Нам надо выяснить, что происходит в таймере, когда мы используем analogWrite(A8, 0.5, {freq:10}).
Парой страниц ниже есть схема таймера:
Мы подкрасили жёлтым цветом элементы, используемые в analogWrite(A8, 0.5, {freq:10}).
Во-первых, Espruino проверит, какие периферийные устройства доступны на контакте A8. Эту информацию можно найти в даташите в разделе «Pinouts and pin description» («Распиновка и описание контактов») в таблице номер 8 под названием «STM32F401xD/xE pin definitions» (ищите контакт PA8). Информация там трудночитаемая, но более понятно об этом написано в этой статье об Espruino Pico. Найдите там раздел «Pinout» («Распиновка») и наведите мышку на надпись «PWM» рядом с контактом A8. В результате должно появиться сообщение «TIM1_CH1» – это 1-ый канал периферийного устройства TIM1.
Далее Espruino проверит, подаёт ли STM32 питание на TIM1 (по умолчанию питание отключено в целях экономии электроэнергии).
Затем Espruino займётся настройкой самого контакта:
- У чипов STM32F4 (вроде того, что установлен на Pico) у каждого контакта есть 4-битный регистр, связанный с так называемым регистром «альтернативной функции». На внутреннем уровне контакт может быть подсоединён к другим периферийным компонентам (в данном случае – к I2C3, TIM1 и USART1), и число, которое вы записываете в этот регистр, определяет то, к какому именно периферийному компоненту этот контакт будет подключен.
Если заглянуть в даташите в таблицу 9 «Alternate function mapping» («Альтернативные функции контактов»), то вы увидите, что для того, чтобы включить TIM1_CH1 на контакте PA8, вам понадобится AF01 – так что Espruino запишет в этот регистр единицу.
- Чипы STM32F1 (вроде того, что установлен на Espruino Original) чуть старее, и поэтому на них управлять альтернативными функциями чуть менее удобно. У каждого периферийного компонента есть регистр альтернативных функций, поэтому если вы, например, захотите включить TIM1_CH1N на контакте A7, то в регистре альтернативных функций TIM1 нужно задать «1» – но тогда TIM1_CH1 переедет на контакт E9 (вместе со всем остальным!).
Настроив контакт, Espruino займётся настройкой самого таймера:
- Настроит предварительный делитель (предделитель) для тактовой частоты 84 МГц, которую генерирует Pico – так, чтобы 16-битный счётчик CNT отсчитывал от 0 до 65535 чуть меньше 10 раз в секунду (потому что мы задали частоту 10).
- Настроит регистр автоматической перезагрузки (AutoReload Register или ARR) таким образом, чтобы он выполнил подстройку частоты. Но эта подстройка будет заключаться лишь в том, что отсчёт будет вестись до числа, которое будет чуть меньше 16-битного 65535 (одного лишь предделителя для точного результата недостаточно).
- Задаст в регистре захвата/сравнения 1 (Capture/Compare register 1 или CCR1) значение регистра ARR, умноженное на коэффициент заполнения (в данном случае – 0.5).
- Настроит регистр управления выводом (Output control) так, чтобы на канале TIM1_CH1 всегда была единица кроме случаев, когда значение в CNT меньше значения регистра CCR.
- Включаем сам таймер, записав соответствующее значение в бит включения.
Что ж, готово. Теперь контакт A8 будет генерировать квадратную волну в 10 Гц.
Получение доступа к таймеру
Согласны, это была не самая лёгкая для усваивания информация. В принципе, в мануале все необходимые шаги подробно расписаны, но вам также не помешают знания о ШИМ (и желательно побольше).
Все действия выше Espruino выполняет при помощи модификации значений в регистрах периферийных устройств, а если точнее – в битах памяти в разных местах чипа, управляющих работой этих периферийных устройств.
В мануале есть раздел «12.4 TIM1 registers» – в нём описывается, какие биты за что отвечают.
Давайте получим доступ к регистру CRR1 – это регистр, в котором Espruino применяет коэффициент заполнения – и посмотрим, что он такое. Если заглянуть в подраздел «12.4.14 TIM1 capture/compare register 1 (TIMx_CCR1)», то там можно увидеть строчку «Address offset: 0x34» – это смещение адреса для TIM1.
Теперь нам надо найти адрес самого TIM1. Перейдите в раздел «2.3 Memory map» в мануале и посмотрите в таблицу 1. Для TIM1 там должно быть написано «0x40010000 - 0x400103FF».
Отсюда можно рассчитать, что адрес CCR1 – это «0x40010000 + 0x34», то есть «0x40010034». Теперь давайте вернёмся в раздел «TIM1 capture/compare register 1 (TIMx_CCR1)» – там говорится, что это 16-битное значение, так что мы воспользуемся функцией peek16().
Во-первых, давайте напечатаем var CCR1 = 0x40010034, чтобы сохранить адрес, а затем peek16(CCR1) – в ответ должно прийти «0», ведь мы пока не запускали analogWrite().
Возьмите макетную плату и подключите светодиод к контакту A8 при помощи резистора (примерно на 100 Ом или вроде того).
Затем запустите analogWrite(A8, 0.5, {freq:10}) – в результате светодиод должен начать мигать с частотой 10 раз в секунду, и всё это будет выполняться на аппаратном уровне.
Снова вызовите peek16(CCR1), чтобы узнать значение CCR1 – теперь вам должно прийти значение «32307».
Теперь давайте увеличим частоту мерцания при помощи analogWrite(A8, 0.05, {freq:10}), а затем снова вызовем peek16(CCR1). Значение будет в 10 раз меньше – «3230».
Мы можем использовать это значение, чтобы менять коэффициент заполнения.
- poke16(CCR1, 0) задаст коэффициент заполнения «0» и выключит светодиод.
- poke16(CCR1, 40000) сделает так, что светодиод большую часть времени будет включен.
Зная, что число «32307» в CCR1 означает коэффициент заполнения 50%, мы можем догадаться, до какого числа будет вести отсчёт счётчик CNT – это будет примерно 2 x 32307. Но мы можем узнать и точное значение – для этого нужно посмотреть в регистр ARR. В мануале говорится, что смещение адреса у него – это «0x2C», поэтому давайте сначала попробуем вызвать var ARR = 0x4001002C, а затем peek16(ARR). Получится «64615» – примерно как мы и думали.
Но это число можно поменять. Если сделать его меньше, то таймер будет вести отсчёт до более низкого значения, в результате чего светодиод будет мигать чаще.
Вызовите poke16(ARR, 30000) – теперь таймер должен отсчитывать в два раза быстрее. Но в то же время светодиод не будет мигать, а будет постоянно включен, потому что отсчёт будет вестись между 30000 и 0, а в CCR1 у нас задано 40000. Давайте зададим там что-то поменьше – например, poke16(CCR1, 1000) – и теперь светодиод начнёт моргать. Попробуйте поэкспериментировать с разными значениями для ARR, чтобы получить разные частоты.
Теперь давайте попробуем прочесть значение счётчика во время его работы. Отсчёт ведётся очень быстро, так что давайте снова воспользуемся analogWrite(), чтобы замедлить его:
analogWrite(A8, 0, {freq:0.1})
Теперь каждый новый отсчёт будет вестись в течение 10 секунд. В мануале видим, что счётчик CNT таймера TIM1 имеет смещение адреса 0x24 – давайте получим доступ к нему:
var CNT = 0x40010024;
peek16(CNT);
Теперь несколько раз нажмите клавиши «Вверх» и ↵ Enter . Вы увидите в консоли примерно следующее:
>peek16(CNT);
=31807
>peek16(CNT);
=33273
>peek16(CNT);
=34583
>peek16(CNT);
=35900
>peek16(CNT);
=37161
>peek16(CNT);
=38458
>peek16(CNT);
=39767
>peek16(CNT);
Как видите, отсчёт ведётся от меньшего числа к большему.
Подсчёт того, сколько раз менялось состояние контакта
Всё это интересно, но не особо полезно. Но что если нам попробовать считать то, сколько раз было изменено состояние контакта?
Давайте снова взглянем на схему, чтобы увидеть, какие компоненты TIM1 нам понадобятся:
Итак, предположим, Espruino уже настроила таймер для analogWrite(). Что нам надо сделать?
- Задать на контакте A8 правильное состояние.
- Включить детектор фронтов.
- Сделать так, чтобы 3 мультиплексора направляли сигнал от A8 в нужное место.
- Выполнить сброс предделителя (предделителя) частоты, чтобы он вообще не делал никакого деления.
- Выполнить сброс значения в регистре ARR до «0», чтобы вести отсчёт от 0 до 65535.
Итак, вперёд. Во-первых, подключаем контакт A8 к контакту A5 (он находится прямо напротив A8). Чуть позже мы начнём отправлять с контакта A5 импульсы, чтобы протестировать таймер.
Теперь давайте зададим адреса регистров (я, опять же, скопировал их из мануала):
// Регистр управления слейв-режимом (Slave mode control или SMCR):
var SMCR = 0x40010008;
// Регистр генерации событий (Event generation или EGR):
var EGR = 0x40010014;
// Регистр режима захвата/сравнения (CCMR1):
var CCMR1 = 0x40010018;
// Регистр включения каналов в режиме захвата/сравнения
// (Capture/compare enable или CCER):
var CCER = 0x40010020;
// Счётчик (CNT):
var CNT = 0x40010024;
// Предделитель частоты (PSC):
var PSC = 0x40010028;
// Регистр автоматической перезагрузки (ARR):
var ARR = 0x4001002C;
Что ж, а теперь всё по порядку.
Задаём на контакте A8 правильное состояние
Вообще, в A8 уже задано правильное состояние. Он работает в режиме альтернативной функции и при помощи analogWrite() подключен к TIM1. Нам этого достаточно.
Включаем детектор фронтов
Найдите раздел «12.3.4 Clock selection», а в нём – подраздел «External clock source mode 1». В нём будет вот такая схема:
Здесь показано, что входом служит TI2, но TI1 – это, по сути, то же самое. Мы просто поменяем в коде «2» на «1».
Итак, нам надо:
- Настроить канал 1 на определение фронтов сигнала на входе TI1. Для этого в регистр CCMR1 нужно записать CC1S = ‘01’.
- Задать продолжительность работы фильтра, отредактировав биты IC1F[3:0] в регистре CCMR1 – нам фильтр не нужен вообще, так что пишем IC1F = 0000.
В мануале в разделе «12.4.7 TIMx capture/compare mode register 1 (TIMx_CCMR1)» есть таблица, из которой видно, что CC1S – это два самых младших бита регистра CCMR1, а IC1F – биты с 4 по 7.
Однако в том же разделе чуть ниже есть маленький текст, где говорится, что запись в биты CC1S можно делать, только когда канал выключен (CC1E = ‘0’ в TIMx_CCER).
Поскольку канал уже, очевидно, включен (потому что мы использовали его для ШИМ), нам нужно будет его выключить. Ищите в документации регистр CCER, нам понадобится в нём бит CC1E (это бит номер 0).
Итак, нам нужно модифицировать лишь эти биты. Для этого берём текущее значение, при помощи символов «&» и «~» применяем маску к старым битам и записываем новые значения при помощи символа «|». Вот так:
// CC1E[0] = 0 (выключаем канал 1):
poke16(CCER1, peek16(CCER1) & ~1);
// CC1S[1:0]=01 (передний фронт), IC1F[7:4]=0 (без фильтра):
poke16(CCMR1, (peek16(CCMR1) & ~0b11110011) | (0b00000001));
И затем задаём положительную полярность, записав в регистр CCER следующее:
- CC1P=0
- CC1NP=0
Согласно мануалу, CC1P – это бит 1, а CC1NP – это бит 3.
Но чуть ранее мы отключили канал, так что давайте также включим его:
// CC1P=0, CC1NP=0 (определяем передний фронт),
// CC1E[0] = 1 (включаем канал 1):
poke16(CCER, peek16(CCER) & ~(0b1011) | (0b0001));
Делаем так, чтобы 3 мультиплексора направляли сигнал от A8 в нужное место
На этой же странице мануала говорится, что нам надо:
- Настроить таймер в режим внешней частоты 1, записав SMS=111 в регистр SMCR.
- Сделать TI1 источником входящего триггера, записав TS=101 в регистр SMCR
SMS – это биты с 0 по 2, а TS – это биты с 4 по 6, поэтому:
// SMS[2:0]=111 (внешняя частота), TS[6:4]=101 (CH1 как триггер):
poke16(SMCR, (peek16(SMCR) & ~0b1110111) | 0b1010111);
В мануале также говорится, чтобы мы:
- Включили счётчик, записав CEN=1 в регистр CR1.
Но Espruino уже сделала это за нас при помощи analogWrite():
Выполняем сброс регистров
Для этого понадобится вот такой простой код:
// В предделителе частоты задаём «0»,
// чтобы использовать каждый такт:
poke16(PSC, 0);
// Задаём в регистре ARR полный диапазон значений:
poke16(ARR, 65535);
Но в мануале также говорится следующее:
- В регистре PSC содержится значение, которое будет загружаться в активный регистр предделителя при каждом событии обновления (включая ситуацию, когда в регистре TIMx_EGR будет выполняться сброс счётчика при помощи бита UG или при настройке контроллера триггеров при помощи «режима сброса»).
Поэтому нам также нужно будет сделать следующее:
// Выполняем запись в бит UG[0],
// чтобы выполнить сброс счётчика и обновить предделитель:
poke16(EGR, 1);
Всё это вместе будет выглядеть следующим образом:
// Регистр управления слейв-режимом (Slave mode control или SMCR):
var SMCR = 0x40010008;
// Регистр генерации событий (Event generation или EGR):
var EGR = 0x40010014;
// Регистр режима захвата/сравнения 1 (CCMR1):
var CCMR1 = 0x40010018;
// Регистр включения каналов в режиме захвата/сравнения
// (Capture/compare enable или CCER):
var CCER = 0x40010020;
// Счётчик (CNT):
var CNT = 0x40010024;
// Предделитель частоты (PSC):
var PSC = 0x40010028;
// Регистр автоматической перезагрузки (ARR):
var ARR = 0x4001002C;
// Включаем ШИМ на контакте A8 (TIM1 CH1 – таймер 1, канал 1):
analogWrite(A8,0.5,{freq:10});
// CC1E = 0 (выключаем канал 1):
poke16(CCER, peek16(CCER) & ~1);
// CC1S[1:0]=01 (передний фронт), IC1F[7:4]=0 (без фильтра):
poke16(CCMR1, (peek16(CCMR1) & ~0b11110011) | (0b00000001));
// CC1P=0, CC1NP=0 (определяем передний фронт),
// CC1E[0] = 1 (включаем канал 1):
poke16(CCER, peek16(CCER) & ~(0b1011) | (0b0001));
// SMS[2:0]=111 (внешняя частота), TS[6:4]=101 (CH1 как триггер):
poke16(SMCR, (peek16(SMCR) & ~0b1110111) | 0b1010111);
// В предделителе частоты задаём «0»,
// чтобы использовать каждый такт:
poke16(PSC, 0);
// Задаём в регистре ARR полный диапазон значений:
poke16(ARR, 65535);
// Выполняем запись в бит UG[0],
// чтобы выполнить сброс счётчика и обновить предделитель:
poke16(EGR, 1);
Загляните в содержимое счётчика при помощи peek16(CNT) – должно вернуть «0».
Теперь попробуйте включить/выключить контакт A5 (ранее мы подключили его к A8) при помощи digitalWrite(A5,1) и digitalWrite(A5,0) – это должно увеличить значение счётчика.
Снова проверьте счётчик при помощи peek16(CNT) – теперь должно вернуть «1».
Теперь давайте попробуем сгенерировать много импульсов:
for (var i=0;i<1000;i++) {
digitalWrite(A5,1);digitalWrite(A5,0);
}
Снова проверьте счётчик при помощи peek16(CNT) – теперь должно вернуть «1001».
Чтобы измерить частоту, можно воспользоваться функцией setInterval(). С её помощью можно узнать, насколько ежесекундно меняется значение в CNT – это и будет частотой квадратной волны на контакте A8.
TIM1 – это 16-битный таймер, что делает его применение немного ограниченным. Впрочем, есть и другие таймеры вроде TIM2 и TIM5, которые работают точно так же (просто поменяйте базовый адрес «0x40010000» на базовый адрес другого таймера), но зато оснащены 32-битными счётчиками. Единственное, нужно будет использовать peek32() вместо peek16() для считывания значения счётчика.
Захват на входе
Ещё одна полезная функция счётчика CNT – это захват на входе. В этом режиме вы можете задать для CNT любую частоту – при помощи analogWrite(A8, 0.5, {freq : my_frequency}) – но каждый раз, когда бит будет менять состояние, значение CNT будет сохранено в соответствующий регистр CCR.
Чтобы реализовать это, мы воспользуемся вот этой конфигурацией таймера:
В мануале об этом рассказывается в соответствующем разделе «12.3.6 Input capture mode» («Режим захвата на входе»), так что здесь некоторые подробности будут опущены.
Всё, что нам нужно будет сделать, это:
- Включить и настроить счётчик (предделитель, ARR) при помощи analogWrite()
- Перенастроить канал 1 на прослушку событий, а не генерацию ШИМ
Первый шаг, очевидно, делается просто. А чтобы выполнить второй шаг мы украдём немного из кода, который написали в разделе выше, но с одной маленькой модификацией. В этот раз мы воспользуемся предделителем IC1PS канала 1 – нам нужно будет задать в нём «0», чтобы отключить его. Для этого мы лишь зададим «0» в двух битах регистра CCMR1:
// Регистр статуса (SR):
var SR= 0x40010010;
// Регистр включения каналов в режиме захвата/сравнения
// (Capture/compare enable или CCER):
var CCER = 0x40010020;
// Регистр режима захвата/сравнения 1 (CCMR1):
var CCMR1 = 0x40010018;
// Счётчик:
var CNT = 0x40010024;
// Регистр захвата/сравнения 1 (CCR1):
var CCR1 = 0x40010034;
// Включаем ШИМ на контакте A8 (TIM1 CH1 – таймер 1, канал 1):
analogWrite(A8,0.5,{freq:10});
// CC1E = 0 (выключаем канал 1)
poke16(CCER, peek16(CCER) & ~1);
// CC1S[1:0]=01 (передний фронт),
// IC3PSC[3:2]=00 (без предделителя), IC1F[7:4]=0 (без фильтра):
poke16(CCMR1, (peek16(CCMR1) & ~0b11111111) | (0b00000001));
// CC1P=0, CC1NP=0 (определяем передний фронт),
// CC1E[0] = 1 (включаем канал 1):
poke16(CCER, peek16(CCER) & ~(0b1011) | (0b0001));
Вот и всё. Увидеть увеличение счётчика можно при помощи peek16(CNT) – число, возвращаемое этой функцией, должно постоянно меняться.
Но если проверить значение в регистре захвата/сравнения 1 при помощи функции peek16(CCR1), она будет возвращать одно и то же число. Кроме того, при помощи peek16(SR)&2 можно узнать, было ли прерывание на CCR1 (при помощи бита CC1IF) – вам должно вернуть «0».
Но теперь – с условием, что контакты A8 и A5 по-прежнему подключены друг к другу – мы можем подать на A8 единицу и ноль и посмотреть, что получится:
digitalWrite(A5,1);digitalWrite(A5,0);
Теперь peek16(SR)&2 вернёт ненулевое значение, показывая, что что-то было поймано. И если мы заглянем в регистр захвата/сравнения при помощи peek16(CCR1), то увидим, что его значение изменилось.
Акт считывания значения в регистре CCR1 задаст в бите CC1IF значение «0», поэтому peek16(SR)&2 теперь тоже будет возвращать «0».
Но что если перед тем, как мы успеем прочесть последнее значение, таймер сработает дважды? Это мы тоже сможем определить – при помощи бита CC1OF и функции peek16(SR)&512.
Возможно, этот бит уже показывает переполнение, но его можно обнулить при помощи poke16(SR,peek16(SR)&~512). И теперь, если мы подадим на контакт два импульса, не считывая значение в CCR1, в бите переполнения CC1OF будет «1».
Ёмкостный тактильный датчик
Режим захвата на входе может быть ещё более полезен, если использовать его вместе с двумя каналами одного и того же таймера. Давайте воспользуемся контактом A10 на Pico (TIM1_CH3) для генерирования обычной ШИМ, но также активируем режим захвата/сравнения на контакте A8 при помощи кода выше. Это позволит нам измерить, сколько времени пройдёт между началом генерирования ШИМ на A10 и изменением состояния входного контакта A8 на единицу.
А это (помимо прочего) может пригодиться при создании тактильного датчика.
Отключите от макетной платы всё, кроме Pico, а затем подключите между контактами A10 и A8 резистор на 1 МОм (подойдёт и 100 кОм, но результат буде чуть хуже). Контакт A10 – это маленькое контактное отверстие, находящееся на противоположном от USB-разъёма конце платы (см. схему в разделе «Pinout» на этой странице), так что вам будет достаточно просто вставить ножку резистора в это отверстие. Теперь возьмите 10-сантиметровый провод, согните его в петлю и подключите к контакту A8. Итак, теперь к нашей схеме подключены очень мощный резистор и очень слабый конденсатор (петля из провода). Но если поднести к проводу руку, ёмкость этого самодельного конденсатора увеличится.
И что всё это значит? Увеличение ёмкости конденсатора означает, что теперь на его зарядку и разрядку будет требоваться больше времени (что делается через резистор, подключенный к A10). И при помощи режима захвата на входе мы можем это определить (не тратя время на вычисления в JavaScript).
Просто загрузите на Espruino вот этот код:
// Регистр статуса (Status Register или SR):
var SR= 0x40010010;
// Регистр включения каналов в режиме захвата/сравнения
// (Capture/compare enable или CCER):
var CCER = 0x40010020;
// Регистр режима захвата/сравнения (CCMR1):
var CCMR1 = 0x40010018;
// Счётчик:
var CNT = 0x40010024;
// Регистр захвата/сравнения 1 (CCR1):
var CCR1 = 0x40010034;
var PSC = 0x40010028;
// Включаем ШИМ на контакте A8 (TIM1 CH1 – таймер 1, канал 1)
analogWrite(A8,0.5,{freq:1000});
// Включаем ШИМ на контакте A10 (TIM1 CH3 – таймер 1, канал 3)
analogWrite(A10,0.5,{freq:1000});
// CC1E = 0 (выключаем канал 1):
poke16(CCER, peek16(CCER) & ~1);
// CC1S[1:0]=01 (передний фронт),
// IC3PSC[3:2]=00 (без предделителя), IC1F[7:4]=0 (без фильтра):
poke16(CCMR1, (peek16(CCMR1) & ~0b11111111) | (0b00000001));
// CC1P=0, CC1NP=0 (определяем передний фронт),
// CC1E[0] = 1 (включаем канал 1):
poke16(CCER, peek16(CCER) & ~(0b1011) | (0b0001));
function getCap() { return peek16(CCR1); }
Этот код идентичен коду из раздела «Захват на входе», но теперь:
- Частота составляет 1000 Гц
- Квадратная волна также генерируется на контакте A10
- Чтение регистра CCR1 выполняется во вспомогательной функции
Итак, если теперь вызвать getCap(), то в ответ будет получено число, зависящее от мощности используемого резистора и ёмкости провода-конденсатора.
Но если вызвать getCap() в момент поднесения руки к проводу (на расстояние меньше сантиметра), то возвращаемое значение должно увеличиться. А если поместить getCap() в функцию setInterval() вот так...
// Это значение должно быть чуть выше того,
// которое функция getCap() возвращает,
// когда рука не поднесена к проводу-конденсатору:
var thresh = 300;
setInterval(function () {
digitalWrite(LED1, getCap() > thresh);
}, 10);
...то у нас получится ёмкостный тактильный датчик!
См.также
Внешние ссылки