Перейти до основного вмісту

⏰ Таймери

Таймери — це без перебільшення одна з найважливіших функцій мікроконтролерів (після самого ядра звичайно). В основі таймера лежить простий лічильник, який рахує до певного значення, після чого скидає значення до початкового (наприклад до нуля) і рахує заново. Здавалося б — що тут такого надзвичайного, якщо ми могли б так само рахувати в for циклі простим інкрементом змінної?! Так — могли б, але тоді окрім цього нічого іншого (більш важливого) ядро вже не може виконувати, оскільки витрачає час на простий відлік. Тут на допомогу приходять таймери, які можуть звільнити ядро нашого мікроконтролера (МК) від виконання такої рутинної роботи.

🧩 Проблема

Щоб краще відчути цінність таймерів давайте розглянемо простий приклад.

int main()
{
HAL_Init();
GPIO_PA4_Init(); //Init LED1

while (1)
{
toggleLed1();
HAL_Delay(1000);
}
}

Програма вище це звичайна блималка, яка раз в секунду вмикає чи вимикає світлодіод. На перший погляд ніякої проблеми в коді вище немає і це дійсно так. Але це лише до тих пір поки Ваша програма не повинна виконувати ще якусь періодичну задачу з іншим заданим інтервалом — відмінним від 1 секунди. Наприклад, раз в пів секунди зчитувати показники датчика температури і раз в півтори секунди вмикати/вимикати 2-ий світлодіод на іншому піні. Звичайно, програму можна було б написати наступним чином, щоб це запрацювало:

int main()
{
HAL_Init();
GPIO_PA4_Init(); //Init LED1
GPIO_PA11_Init(); //Init LED2

while (1)
{
readTemp();
HAL_Delay(500);

readTemp();
HAL_Delay(500);

toggleLed1();
HAL_Delay(500);

toggleLed2();
}
}

Оновлений приклад теж працює, але тепер має складнішу логіку. З коду вище не одразу можна зрозуміти чому в циклі while функція readTemp() викликається саме 3 рази чи чому функція toggleLed1() викликається саме після 2-го виклику readTemp(). До того ж приклад вище був ідеально підібраний так, щоб інтервали всіх 3-ох операцій були кратні 500 мілісекундам і тому відносно легко "уживаються" в одному while циклі. Але уявіть не ідеальний приклад: операція зчитування температури має виконуватися раз на 400 мілісекунд, а не 500. В такому разі код буде виглядати так:

int main()
{
HAL_Init();
GPIO_PA4_Init(); //Init LED1
GPIO_PA11_Init(); //Init LED2

while (1)
{
readTemp();
HAL_Delay(400);
readTemp();
HAL_Delay(400);
readTemp();

HAL_Delay(200);
toggleLed1();

HAL_Delay(200);
readTemp();

HAL_Delay(300);
toggleLed2();

HAL_Delay(100);
}
}

Щоб зчитувати температуру кожні 400 мілісекунд нам довелось пройтися по коду в циклі while і виставити інші затримки перед всіма функціями, а не лише readTemp(). А якщо нас попросять знову змінити інтервал — наприклад на 300 мс — то нам доведеться перерахувати і проставити нові затримки між всіма функціями. А що, якщо нам знадобиться перемикати світлодіод Led1 не кожну секунду, а кожні 700 мс?! А якщо ще й змінювати ці інтервали динамічно прямо в процесі роботи Вашого пристрою?! Наприклад, коли якась кнопка натиснута, то блимання світлодіоду Led1 має відбуватися на 100 мс швидше, ніж коли не натиснута. Сподіваюсь, ви зрозуміли суть. Це все звичайно можна було б запрограмувати, але вимагало б немалих зусиль — щоразу узгоджувати затримки викликів інших 2-ох функції.

Було б добре якби ми мали простий механізм, який дозволяв би задати інтервал виклику функції і все — щоб не доводилось зважати на інші функції. Таким механізмом і є таймери.
P.S.: альтернативним рішенням проблеми вище могла б стати RTOS зі своїми періодичними задачами, але це вже інша історія

🔧 Застосування

Таймери це окремі апаратні блоки в МК, які працюють окремо від ядра і часто навіть на іншій частоті. Окрім простої функції лічильника (Приклад №1) вони також можуть:

  • генерувати переривання (Приклад №2)
  • генерувати сигнали на пінах (GPIO - Приклад №3, ШІМ - Приклад №4)
  • керувати моторами (крокові, щіткові, безщіткові)
  • автоматично перезапускати МК у разі несправності (сторожовий* таймер)
  • аналіз вхідних сигналів (тривалість їх пульсів, періодів - Приклад №5)
  • і багато інших, які тільки можна уявити
* Сторожові таймери хоч і називаються таймерами, але все ж відрізняються призначенням від розглянутих в цій статті. Тому розглядати тут їх не будемо

Тобто функціонал таймерів насправді набагато ширший за просто функцію лічильника. Давайте розглянемо кілька прикладів простих застосувань таймерів і почнемо саме з функції лічильника.

⏱️ Приклад №1 - Лічильник

Почнемо з самого простого прикладу, який демонструє як запустити таймер в режимі звичайного лічильника. Але спочатку потрібно познайомитись з параметрами, з якими можна запустити таймер:

  • частота таймера (задається дільником - prescaler)
  • число до якого рахувати - period
  • режим лічильника (рахувати вверх чи вниз, тобто наприклад від 0 до 1000 чи від 1000 до 0)

Частота таймера пофакту означає до скількох лічильник таймера дорахує за секунду. Наприклад, якщо частота таймера буде 8 MHz, це означатиме, що за 1 секунду лічильник теоретично дорахує до числа 8 мільйонів. За замовчуванням частота таймера береться з частоти шини APB (Advanced Peripheral Bus), частота якої в свою чергу теж за замовчуванням береться з частоти ядра. Тобто, якщо частота самого МК дорівнює 16 MHz і дільник дорівнює 0, то частота таймера теж дорівнює 16 MHz. Це означає, що за секунду лічильник таймера теоретично дорахує до 16 мільйонів.

Але чому "теоретично"? Тому що наступний параметр, який ми маємо розглянути - період - обмежить це число з 16 мільйонів до потрібного нам значення. Але не більше 65 535 (21612^{16} - 1) у 16-бітних таймерів і 4 294 967 295 (23212^{32} - 1) у 32-бітних таймерів. Нічого страшного в тому, що ми не можемо порахувати до повних 16 мільйонів немає. На практиці це часто і непотрібно. Тобто така характеристика як розрядність таймера якраз означає до якого максимального значення можна порахувати. Після досягнення таймером числа, значення якого записане в період лічильник переповнюється (overflow) і починає рахувати заново.

На прикладі STM32G030F6P6 (8KB/32KB/TSSOP-20) поглянемо на ініціалізацію і запуск таймера:

void example1()
{
HAL_TIM_Base_Stop(&htim3);
HAL_TIM_Base_DeInit(&htim3);
__HAL_RCC_TIM3_CLK_ENABLE();

htim3.Instance = TIM3;
htim3.Init.Prescaler = 16000 - 1; //downclock timer to 1000Hz
htim3.Init.Period = 6000 - 1; //count to 6000 (6 sec)
htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
HAL_TIM_Base_Init(&htim3);
HAL_TIM_Base_Start(&htim3);
}

У випадку STM32G030F6P6 частота ядра за замовчуваннями 16 MHz (хоча може працювати і на 64 MHz). Виставивши дільник в 16 000 ми тим самим змусили таймер працювати на частоті 1000 Hz (16MHz / 16k), тобто робити 1000 інкрементів за секунду. А виставивши період в 6 000 змусили рахувати до 6 секунд (6000/1000).

Давайте запустимо цей приклад та поглянемо на значення лічильника таймера в дебаг режимі:

На відео видно як лічильник рахує до 6000 і щоразу починає заново. От тільки користі від такого прикладу не дуже багато — лічильник просто рахує так як ми задали і все. Було б добре якби ми ще могли виконувати потрібну нам дію щоразу коли таймер дорахує до заданого числа. Цього можна досягти запустивши таймер в режимі переривання, як в наступному прикладі.

🛑 Приклад №2 - Переривання

Цей приклад схожий на попередній лише з тією різницею, що коли лічильник досягне заданого періоду таймер згенерує переривання, в яке ми помістимо потрібний нам код - наприклад блимання світлодіоду.

void example2()
{
HAL_TIM_Base_Stop(&htim3);
HAL_TIM_Base_DeInit(&htim3);
__HAL_RCC_TIM3_CLK_ENABLE();

htim3.Instance = TIM3;
htim3.Init.Prescaler = 16000 - 1;
htim3.Init.Period = 2000 - 1;

HAL_TIM_Base_Init(&htim3);
HAL_NVIC_SetPriority(TIM3_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(TIM3_IRQn);
HAL_TIM_Base_Start_IT(&htim3);
}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM3)
{
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_4);
}
}

В прикладі ми використали той самий дільник, але тепер рахуємо до 2000, тобто до 2 секунд. Також увімкнули переривання для нашого таймеру (TIM3) з найвищим пріоритетом, тобто 0. І що не менш важливо — запустили таймер в режимі переривання через HAL_TIM_Base_Start_IT. А нижче описали код, який буде викликатися на кожне переривання: блимання світлодіоду на піні PA4.

Такий приклад уже більш корисний ніж просто лічильник. Але його можна покращити якщо і саме блимання світлодіоду звалити на плечі таймера. Для цього розглянемо наступний приклад.

⚖️ Приклад №3 - Режим порівняння

В попередньому прикладі ми вже вивільнили ресурси ядра МК, делегувавши відлік часу таймеру. І лише коли таймер генерує переривання, то ядру доводиться перервати свою роботу, щоб перемкнути світлодіод. Але можна ще краще - можна і роботу зі світлодіодом делегувати таймеру. Оскільки таймер це апаратний модуль, він може мати фізичне з'єднання з GPIO, тобто може керувати і LED. Для цього скористаємось режимом таймера, який називається режимом вихідного порівняння (Output Compare mode). Цей режим має багато під-режимів роботи, які хоч і не сильно між собою відрізняються, але кожен має своє застосування. Наприклад, один з таких під-режимів це TIM_OCMODE_TOGGLE - перемикання. Тобто ми збираємось використати таймер в режимі порівняння з під-режимом перемикання. Тільки не заплутайтесь.

Але і тут не все так просто. Будь-який таймер не можна підключити до будь-якого GPIO, щоб ним керувати. Лише певні канали певних таймерів можуть бути підключені до певних пінів. Щоб дізнатися який таймер можна підімкнути до якого піна треба звернутися до даташита STM32G030F6P6 (сторінка 35) або до STM32CubeIDE, які нам підкажуть, що світлодіод на піні PA4 може бути підключений до 1-го каналу (CH1) таймера 14 (TIM14).

Приклад виглядає наступним чином:

void example3()
{
HAL_TIM_Base_Stop(&htim14);
HAL_TIM_Base_DeInit(&htim14);
__HAL_RCC_TIM14_CLK_ENABLE();

htim14.Instance = TIM14;
htim14.Init.Prescaler = 16000 - 1; //downclock timer to 1000Hz
htim14.Init.Period = 5000 - 1; //count to 5 seconds
HAL_TIM_OC_Init(&htim14);

TIM_OC_InitTypeDef htimOcConfig = {0};
htimOcConfig.OCMode = TIM_OCMODE_TOGGLE; //GPIO Toggle mode
htimOcConfig.Pulse = 2500; //do toggle in 2.5s - middle between 5s
HAL_TIM_OC_ConfigChannel(&htim14, &htimOcConfig, TIM_CHANNEL_1);
HAL_TIM_OC_Start(&htim14, TIM_CHANNEL_1);
}

void GPIO_PA4_TIM_OC_Init()
{
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_4;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; //Init GPIO PA4 as Alternate Function
GPIO_InitStruct.Alternate = GPIO_AF4_TIM14; //Init GPIO PA4 as Alternate Function 4
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

Також потрібно ініціалізувати рін PA4 в режимі альтернативної функції AF4 згідно даташиту (чи STM32CubeIDE). Запустимо приклад:

Як видно з прикладу світлодіод перемикається не на 5-ти секундах як можна було подумати з параметру період, а посередині - на 2.5 секундах. Це все із-за нового параметру пульс, який ми задали.

Приклад №4 - ШІМ

Останній приклад дуже схожий на попередній і теж вважається режимом порівняння. Тут ми змінюємо яскравість світлодіоду виставляючи в змінну пульс потрібний робочий цикл (duty cycle). Починаємо з 50% (500/1000) і потім збільшуємо на 5% кожні 100 мілісекунд. Що важливо, частота роботи таймера в режимі ШІМ має бути значно вищою ніж в попередніх прикладах, щоб блимання світлодіоду стало непомітне для людини. Тому дільником виставляємо частоту в 1 MHz. Період ШІМу можна регулювати частотою таймера (тобто дільником) і періодом.

void example4()
{
HAL_TIM_Base_Stop(&htim14);
HAL_TIM_Base_DeInit(&htim14);
__HAL_RCC_TIM14_CLK_ENABLE();

htim14.Instance = TIM14;
htim14.Init.Prescaler = 16 - 1; //downclock timer to 1 MHz
htim14.Init.Period = 1000 - 1; //count to 1000

TIM_OC_InitTypeDef htimOcConfig = {0};
htimOcConfig.OCMode = TIM_OCMODE_PWM1;
htimOcConfig.Pulse = 500; //startup duty cycle - 50% (500/1000)

HAL_TIM_PWM_ConfigChannel(&htim14, &htimOcConfig, TIM_CHANNEL_1);
HAL_TIM_PWM_Init(&htim14);
HAL_TIM_PWM_Start(&htim14, TIM_CHANNEL_1);

while (1)
{
htimOcConfig.Pulse = htimOcConfig.Pulse + 50;
if (htimOcConfig.Pulse >= 1000)
{
htimOcConfig.Pulse = 0;
}
HAL_TIM_PWM_ConfigChannel(&htim14, &htimOcConfig, TIM_CHANNEL_1);
HAL_Delay(100);
}
}

void GPIO_PA4_TIM_OC_Init()
{
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_4;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Alternate = GPIO_AF4_TIM14;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

Після налаштування таймера і піна в циклі while змінюємо пульс від 0 до 1000, тим самим регулюємо робочий цикл від 0% до 100%.

Ось так за допомогою таймера і ШИМ плавно змініюємо яскравість світлодіоду.

🔚 Висновки

Зізнаюсь, коли я вперше дізнався про таймери в МК мені здалось, що це якась нудна безкорисна функціональність у вигляді простого лічильника. От тільки виявилось, що таймери можуть виконувати певну роботу не гірше за основне ядро. Існує ще дуже багато режимів таймерів і їх застосувань окрім тих, що ми розглянули. Деякий час у своїх проєктах з МК можна обходитись без таймерів, але рано чи пізно по мірі ускладнення виконуваних проєктів доведеться їх освоїти.

Всі приклади зроблено під STM32G030F6P6 та стандартну частоту ядра 16 MHz та доступні на GitHub за посиланням.



Дата публікації
2025.01.15