Математика в програмуванні: базові прийоми з прикладами на C

Математика в програмуванні: бітові операції, цілочисельне ділення, фіксована кома і фільтри

Математика в програмуванні — це не інтеграли й матриці, а кілька десятків простих прийомів, які щодня рятують від дивних багів: ділення з округленням, бітові маски, кільцевий буфер, масштабування сигналу АЦП, фільтр шуму чи порівняння дробових чисел. Зібрали базові прийоми з прикладами на C, які однаково корисні для прошивок мікроконтролерів, десктопних програм і скриптів.

Математика в програмуванні: бітові операції, цілочисельне ділення, фіксована кома і фільтри

Цілочисельне ділення: округлення вгору і до найближчого

У C і більшості мов ділення цілих чисел просто відкидає дробову частину: 7 / 2 = 3. Коли потрібне інше округлення, додають поправку до діленого:

int div_round(int a, int b) { return (a + b / 2) / b; }   // до найближчого: 7/2 -> 4
int div_ceil(int a, int b)  { return (a + b - 1) / b; }   // вгору: скільки пакетів по b байт

Ці формули правильні для додатних чисел. Для від’ємних пам’ятайте: у C ділення округлює до нуля (-7 / 2 = -3, а -7 % 2 = -1), а в Python — вниз (-7 // 2 = -4, -7 % 2 = 1). Тому при перенесенні алгоритму між мовами від’ємні значення варто перевіряти окремо.

Переповнення змінних і таймер millis()

Змінна uint8_t після 255 стає 0, uint32_t — після 4 294 967 295. Через це лічильник millis() в Arduino обнуляється приблизно через 49,7 доби, і код, який порівнює абсолютні значення часу, дає збій. Правильно — порівнювати різницю: now - last >= PERIOD. А на AVR тип int має лише 16 біт, тож навіть 60 * 1000 переповнюється. Усі деталі, приклади і спосіб перевірки без очікування 49 днів — в окремій статті переповнення millis() в Arduino і 16-бітний int на AVR.

Математика в програмуванні: бітові операції та маски

Регістри мікроконтролерів, прапорці стану і протоколи обміну — це окремі біти. Чотири базові операції варто знати напам’ять:

reg |=  (1u << 5);          // встановити біт 5
reg &= ~(1u << 5);          // скинути біт 5
reg ^=  (1u << 5);          // інвертувати біт 5
if (reg & (1u << 5)) { }    // перевірити біт 5

uint8_t hi = (word >> 8) & 0xFF;   // старший байт 16-бітного числа
uint8_t lo =  word       & 0xFF;   // молодший байт
uint16_t w = ((uint16_t)hi << 8) | lo;   // зібрати назад

Корисні наслідки двійкової арифметики:

  • Зсув замість множення. x << 3 — це x × 8, x >> 2 — ділення на 4 для беззнакових чисел.
  • Перевірка степеня двійки: x && !(x & (x – 1)) — істина для 1, 2, 4, 8…
  • Вирівнювання вгору до кратного A (A — степінь двійки): (x + A – 1) & ~(A – 1). Так вирівнюють адреси й розміри буферів для DMA.
  • Остача від ділення на степінь двійки: x % 16 те саме, що x & 15 для беззнакових — і значно швидше на мікроконтролерах без апаратного ділення.

Модульна арифметика: кільцевий буфер

Кільцевий (циклічний) буфер — основа прийому даних з UART, логування й фільтрів. Індекс після останнього елемента повертається на нуль. Якщо розмір буфера — степінь двійки, операцію % замінюють маскою:

#define N 64                      // обов'язково степінь двійки
static uint8_t buf[N];
static volatile uint16_t head, tail;

void put(uint8_t b) { buf[head] = b; head = (head + 1) & (N - 1); }
int  get(void) {
    if (head == tail) return -1;   // буфер порожній
    uint8_t b = buf[tail]; tail = (tail + 1) & (N - 1);
    return b;
}

Масштабування: від сирого АЦП до вольт

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

int32_t map_range(int32_t x, int32_t in_min, int32_t in_max,
                  int32_t out_min, int32_t out_max) {
    return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}

// 12-бітний АЦП, опорна 3,3 В -> мілівольти без float
uint32_t mv = (uint32_t)raw * 3300u / 4095u;

Порядок операцій важливий: спочатку множимо, потім ділимо, інакше втратимо точність. raw / 4095 * 3300 майже завжди дасть 0. Якщо на вході стоїть дільник напруги, коефіцієнт зручно порахувати нашим калькулятором дільника напруги.

Фіксована кома замість float

На мікроконтролерах без FPU (STM32F0, ATmega, ESP8266) операції з float повільні в десятки разів. Рішення — зберігати дробові числа як цілі, помножені на 2ⁿ. Формат Q16.16 означає 16 біт цілої частини і 16 біт дробової:

typedef int32_t q16;
#define Q16(x)  ((q16)((x) * 65536.0))          // константа на етапі компіляції

q16 q_mul(q16 a, q16 b) { return (q16)(((int64_t)a * b) >> 16); }
q16 q_div(q16 a, q16 b) { return (q16)(((int64_t)a << 16) / b); }

q16 k = Q16(1.25);
q16 y = q_mul(x, k);       // y = x * 1,25 без float

Простіший варіант для побутових задач — масштабований цілий тип: зберігати температуру в десятих частках градуса (235 = 23,5 °C), а напругу — в мілівольтах.

Обмеження, інтерполяція і таблиці

Три маленькі функції, які трапляються майже в кожному проєкті:

static inline int32_t clamp(int32_t x, int32_t lo, int32_t hi) {
    return x < lo ? lo : (x > hi ? hi : x);
}

// лінійна інтерполяція між двома точками (x0,y0)-(x1,y1)
int32_t lerp(int32_t x, int32_t x0, int32_t y0, int32_t x1, int32_t y1) {
    return y0 + (int32_t)((int64_t)(x - x0) * (y1 - y0) / (x1 - x0));
}

// таблиця АЦП -> температура (десяті градуса) для термістора
static const int16_t adc_t[] = { 3500, 2900, 2100, 1300, 700 };
static const int16_t deg_t[] = { -200,    0,  250,  500, 800 };

int16_t ntc_temp(int16_t adc) {
    if (adc >= adc_t[0]) return deg_t[0];
    for (int i = 1; i < 5; i++)
        if (adc >= adc_t[i])
            return lerp(adc, adc_t[i-1], deg_t[i-1], adc_t[i], deg_t[i]);
    return deg_t[4];
}

Таблиця з інтерполяцією (LUT) замінює складну формулу — наприклад, рівняння Стейнхарта-Харта для термістора — кількома точками з даташиту. Значення в прикладі умовні: для свого датчика візьміть точки з його характеристики.

Фільтрація шуму: проста математика в програмуванні датчиків

Сигнал з АЦП завжди шумить. Два найпростіші фільтри:

// ковзне середнє за 8 відліками: сума оновлюється за O(1)
static int16_t win[8]; static int32_t sum; static uint8_t idx;
int16_t avg8(int16_t x) {
    sum += x - win[idx]; win[idx] = x; idx = (idx + 1) & 7;
    return (int16_t)(sum >> 3);              // ділення на 8 зсувом
}

// експоненційний фільтр (EMA): y += (x - y) / 2^K, лише цілі числа
#define K 4
static int32_t acc;                           // зберігає y * 2^K
int16_t ema(int16_t x) {
    acc += x - (acc >> K);
    return (int16_t)(acc >> K);
}

Ковзне середнє рівномірно згладжує, але потребує буфер. Експоненційний фільтр займає одну змінну, а «інерцію» задає K: що більше K, то сильніше згладжування і повільніша реакція. Детальніше про частоту зрізу таких фільтрів — у нашому калькуляторі RC-фільтра: EMA поводиться як цифровий аналог RC-ланки.

Порівняння дробових чисел

0.1 + 0.2 у double дорівнює 0.30000000000000004, тому порівняння через == для float майже завжди помилка. Порівнюйте з допуском, причому відносним для великих чисел:

#include <math.h>
int nearly_equal(float a, float b) {
    float diff = fabsf(a - b);
    float scale = fmaxf(fabsf(a), fabsf(b));
    return diff <= 1e-6f * scale || diff < 1e-9f;
}

Гроші й лічильники ніколи не зберігайте у float: використовуйте цілі копійки чи мілісекунди.

НСД і частоти таймерів

Найбільший спільний дільник (алгоритм Евкліда) стає в пригоді, коли треба підібрати дільник таймера, скоротити дріб передавального відношення або знайти спільний період двох подій:

uint32_t gcd(uint32_t a, uint32_t b) { while (b) { uint32_t t = a % b; a = b; b = t; } return a; }
uint32_t lcm(uint32_t a, uint32_t b) { return a / gcd(a, b) * b; }   // спершу ділимо — менше ризик переповнення

Наприклад, дві задачі з періодами 12 мс і 18 мс збігаються кожні lcm(12, 18) = 36 мс.

Потрібна прошивка для вашого пристрою?

Розробляємо електроніку і програми для мікроконтролерів STM32, ESP32 та AVR: від схеми й плати до робочої прошивки.

Обговорити проєкт

Висновок

Математика в програмуванні мікроконтролерів і звичайних програм зводиться до кількох прийомів: правильне округлення при діленні, обережність з переповненням і порядком операцій, бітові маски, модульна арифметика для буферів, фіксована кома замість float і прості цифрові фільтри. Вони роблять код швидшим і передбачуваним, а значить — позбавляють від помилок, які проявляються раз на 49 днів.

Детальніше: Fixed-point arithmetic, Q (number format), Circular buffer (Wikipedia).

Читайте також

Related Posts

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься.