Код

# code.py
import time
import board
import pwmio

# =========================
# Настраиваемые параметры
# =========================

PWM_PIN = board.GP15          # PWM-совместимая ножка RP2040, например GP15
PWM_FREQUENCY = 1000          # Частота PWM, Гц
RAMP_TIME_SEC = 10.0          # Время прохода от START_PERCENT до END_PERCENT
START_PERCENT = 0.0           # Начальная скважность, %
END_PERCENT = 100.0           # Конечная скважность, %
STEP_PERCENT = 0.0015259      # Шаг в %, 0.0015259% ~= 1/65535 * 100 (почти минимальный шаг)
HOLD_LAST_VALUE = True        # Оставить последний уровень после завершения
REPEAT = False                # Повторять цикл бесконечно
REPEAT_DELAY_SEC = 0.0        # Пауза между циклами

# =========================
# Служебные функции
# =========================

def percent_to_duty(percent: float) -> int:
    if percent < 0.0:
        percent = 0.0
    if percent > 100.0:
        percent = 100.0
    return int((percent / 100.0) * 65535)

def run_ramp(pwm, start_percent, end_percent, step_percent, ramp_time_sec):
    if step_percent <= 0:
        raise ValueError("STEP_PERCENT must be > 0")
    if ramp_time_sec <= 0:
        raise ValueError("RAMP_TIME_SEC must be > 0")

    direction = 1 if end_percent >= start_percent else -1
    total_percent = abs(end_percent - start_percent)
    steps = int(total_percent / step_percent)

    if steps <= 0:
        pwm.duty_cycle = percent_to_duty(end_percent)
        return

    step_delay = ramp_time_sec / steps
    current = start_percent

    for _ in range(steps + 1):
        pwm.duty_cycle = percent_to_duty(current)
        time.sleep(step_delay)
        current += direction * step_percent

    pwm.duty_cycle = percent_to_duty(end_percent)

# =========================
# Основная программа
# =========================

pwm = pwmio.PWMOut(PWM_PIN, frequency=PWM_FREQUENCY, duty_cycle=0)

try:
    while True:
        run_ramp(
            pwm,
            START_PERCENT,
            END_PERCENT,
            STEP_PERCENT,
            RAMP_TIME_SEC
        )

        if not HOLD_LAST_VALUE:
            pwm.duty_cycle = 0

        if not REPEAT:
            break

        if REPEAT_DELAY_SEC > 0:
            time.sleep(REPEAT_DELAY_SEC)

finally:
    if not HOLD_LAST_VALUE:
        pwm.deinit()

Как это работает

duty_cycle в CircuitPython — 16-битный, поэтому теоретически минимальный шаг по заполнению равен примерно 100 / 65535 = 0.0015259%, и именно такой шаг я поставил по умолчанию в STEP_PERCENT. Но документация прямо предупреждает, что на конкретной плате внутренняя реализация может иметь эффективное разрешение меньше 16 бит, поэтому фактическая дискретность иногда оказывается грубее, чем ожидается. [github](https://github.com/adafruit/circuitpython/issues/7224)

Важный нюанс

Если реально использовать почти минимальный шаг по всему диапазону 0–100% за 10 секунд, получится около 65535 изменений, то есть примерно одно обновление каждые 153 микросекунды, а в CircuitPython это уже слишком быстро для стабильного time.sleep() и интерпретируемого цикла. Поэтому код делает задачу параметризуемой, но на практике для надежной работы обычно ставят более крупный шаг, например STEP_PERCENT = 0.1 или 0.01, в зависимости от того, насколько гладкий фронт вам нужен. [github](https://github.com/adafruit/circuitpython/issues/7224)

Практический вариант

Для почти всегда адекватной работы на RP2040 начните с таких значений: PWM_FREQUENCY = 1000, RAMP_TIME_SEC = 10.0, START_PERCENT = 0.0, END_PERCENT = 100.0, STEP_PERCENT = 0.1. Если нужен не одиночный проход, а пила или треугольник 0→100→0, это легко расширяется тем же run_ramp() без изменения общей структуры. [docs.circuitpython](https://docs.circuitpython.org/en/latest/shared-bindings/pwmio)

####################### Variant 2 ###########################

Это прямой запрос из предыдущего разговора — никаких инструментов не нужно, пишу код сразу.

PWM рамп без time.sleep() через time.monotonic()

# code.py  —  PWM ramp 0→100% за RAMP_TIME_SEC без time.sleep()
import time
import board
import pwmio

# ======================================================
# Настраиваемые параметры
# ======================================================

PWM_PIN        = board.GP15   # PWM-совместимая ножка
PWM_FREQUENCY  = 1000         # Частота PWM, Гц

START_PERCENT  = 0.0          # Начальная скважность, %
END_PERCENT    = 100.0        # Конечная скважность, %
RAMP_TIME_SEC  = 10.0         # Время рампа, секунд

# Реальных ступеней при 1 кГц / 125 МГц ≈ 62500, ставим чуть меньше 65535
# 0.0015259 ≈ 100 / 65535  (почти минимальный шаг)
# Для надёжности в CircuitPython рекомендуется 0.01–0.1
STEP_PERCENT   = 0.01         # Шаг скважности, %

HOLD_LAST_VALUE = True        # Оставить уровень после завершения
REPEAT          = False       # Повторять цикл
REPEAT_DELAY_SEC = 0.0        # Пауза между циклами (если REPEAT)

# ======================================================
# Служебные функции
# ======================================================

def percent_to_duty(p: float) -> int:
    """Преобразует % (0.0–100.0) в 16-битный duty_cycle (0–65535)."""
    return int(max(0.0, min(100.0, p)) / 100.0 * 65535)

def run_ramp_nonblocking(pwm, start_pct, end_pct, step_pct, ramp_sec):
    """
    Линейный рамп от start_pct до end_pct за ramp_sec секунд.
    Использует time.monotonic() — не блокирует процессор надолго.
    Возвращает только после завершения рампа.
    """
    if step_pct <= 0:
        raise ValueError("STEP_PERCENT должен быть > 0")
    if ramp_sec <= 0:
        raise ValueError("RAMP_TIME_SEC должен быть > 0")

    total = abs(end_pct - start_pct)
    if total == 0:
        pwm.duty_cycle = percent_to_duty(end_pct)
        return

    direction = 1.0 if end_pct > start_pct else -1.0
    steps = int(total / step_pct)
    if steps < 1:
        pwm.duty_cycle = percent_to_duty(end_pct)
        return

    # Интервал между шагами в секундах
    step_interval = ramp_sec / steps

    current_pct = start_pct
    last_step_time = time.monotonic()
    pwm.duty_cycle = percent_to_duty(current_pct)

    while True:
        now = time.monotonic()
        elapsed = now - last_step_time

        if elapsed >= step_interval:
            # Сколько шагов накопилось за прошедшее время
            # (компенсирует пропуски при задержках GC или USB)
            ticks = int(elapsed / step_interval)
            current_pct += direction * step_pct * ticks
            last_step_time += step_interval * ticks  # не сбрасываем в now — это важно!

            # Граничная проверка
            if direction > 0 and current_pct >= end_pct:
                pwm.duty_cycle = percent_to_duty(end_pct)
                break
            if direction < 0 and current_pct <= end_pct:
                pwm.duty_cycle = percent_to_duty(end_pct)
                break

            pwm.duty_cycle = percent_to_duty(current_pct)

        # Здесь можно вставить любую другую логику:
        # опрос кнопок, считывание ADC, обмен по UART и т.д.
        # pass

# ======================================================
# Основная программа
# ======================================================

pwm = pwmio.PWMOut(PWM_PIN, frequency=PWM_FREQUENCY, duty_cycle=0)

try:
    while True:
        run_ramp_nonblocking(
            pwm,
            START_PERCENT,
            END_PERCENT,
            STEP_PERCENT,
            RAMP_TIME_SEC,
        )

        if not HOLD_LAST_VALUE:
            pwm.duty_cycle = 0

        if not REPEAT:
            break

        if REPEAT_DELAY_SEC > 0:
            time.sleep(REPEAT_DELAY_SEC)

finally:
    if not HOLD_LAST_VALUE:
        pwm.deinit()

Ключевые отличия от первой версии

| | time.sleep() версия | time.monotonic() версия |
|---|---|---|
| Блокирует процессор | Да, на весь step_interval | Нет, тело цикла мгновенное |
| Компенсация задержек GC/USB | Нет | Да (через ticks = int(elapsed / step_interval)) |
| Можно добавить параллельную логику | Нельзя | Можно в # pass месте |
| Точность таймера | Дрейфует со временем | Не дрейфует (last_step_time += ticks * interval, не = now) |

Почему last_step_time += ticks * interval, а не = now

Сброс в now накапливал бы ошибку при каждой задержке (например, сборщик мусора занял 3 мс — следующий шаг выходит на 3 мс позже запланированного). Прибавка фиксированного интервала сохраняет абсолютную ось времени — точно так же работают профессиональные игровые циклы и RTOS-таймеры.

############## На подумать (не обязательно, но полезно знать ) ##########################
Теперь у меня достаточно информации для развёрнутого ответа по всем тонким проблемам PWM на RP2040 в CircuitPython.

Компромисс частоты и разрешения (Frequency ↔ Resolution)

Это главная архитектурная особенность. Аппаратный PWM в RP2040 строится на формуле:

\[ f_{PWM} = \frac{f_{clk}}{DIV \times (TOP + 1)} \]

где TOP — значение счётчика, до которого считает таймер, DIV — делитель 8.4 формата (1.0–255.9375). Количество уникальных ступеней скважности равно TOP + 1. Поэтому при 125 МГц: [forums.raspberrypi](https://forums.raspberrypi.com/viewtopic.php?t=317593)

| Частота PWM | Реальный TOP | Ступеней скважности |
|---|---|---|
| 1 кГц | 64 999 | ~65 000 (~16 бит) |
| 10 кГц | 6 499 | ~6 500 (~12.7 бит) |
| 50 кГц | 1 249 | ~1 250 (~10.3 бит) |
| 1 МГц | 62 | ~63 (~6 бит) |

При 50 Гц CircuitPython вынужден использовать большой делитель, и уже один шаг duty_cycle не равен ровно 1/65535 — это иллюзия. Реальное разрешение в шагах — TOP+1, а не 65535. duty_cycle в CircuitPython масштабирует ваш 16-битный ввод к реальному TOP внутри, что порождает округление. [reddit](https://www.reddit.com/r/RP2040/comments/p1pln7/rp2040_pwm_resolution/)

Баг 0xFFFF — 100% скважность не работает

Это был реальный задокументированный баг с меткой bug в трекере Adafruit. При установке duty_cycle = 0xFFFF (65535) в старых прошивках RP2040 выход не становился постоянно высоким — вместо этого возникал единственный узкий «спад» в каждом периоде из-за off-by-one ошибки в вычислении TOP. Был зафиксирован в 6.x.x в феврале 2021 года (issue #4192). [github](https://github.com/adafruit/circuitpython/issues/4189)

Исправлено начиная с CircuitPython 6.2+. Но если используете старую прошивку или сталкиваетесь с аномалией — ставьте duty_cycle = 65534 вместо 65535 как безопасный обходной путь. [forum.arduino](https://forum.arduino.cc/t/pwm-library-on-rp2040/1085345)

Busy-wait при смене duty_cycle

В реализации PWMOut.c для RP2040 была другая скрытая проблема — после каждой записи нового duty_cycle код ждал полного цикла PWM (wrap interrupt) прежде чем вернуться: [github](https://github.com/adafruit/circuitpython/issues/7224)

// Wait for wrap so that we know our new cc value has been applied.
pwm_hw->intr = 1 << self->slice;
while ((pwm_hw->en & ...) && (pwm_hw->intr & ...) == 0) { }

При 50 Гц (сервоприводы) это означало задержку до 20 мс на каждое обновление. При быстром рампинге за 10 секунд с маленьким шагом при низкой частоте — это и есть источник «торможения» цикла. Исправлено в PR #7299 в CircuitPython 8.x. [github](https://github.com/adafruit/circuitpython/issues/7224)

Конфликт слайсов (Shared PWM Slice)

RP2040 имеет 8 PWM-слайсов, каждый обслуживает два пина (channel A и B). Маппинг: [forums.raspberrypi](https://forums.raspberrypi.com/viewtopic.php?t=316931)

Slice = (GPIO_number >> 1) & 7
GP0/GP1 → Slice 0
GP2/GP3 → Slice 1
...
GP14/GP15 → Slice 7

Оба пина одного слайса обязаны иметь одинаковую частотуTOP и DIV общие. Если создать два PWMOut на GP14 и GP15 с разными частотами — CircuitPython выбросит исключение ValueError: All timers for this pin are in use или молча перетрёт частоту второго канала. MicroPython такую ошибку не перехватывает. [github](https://github.com/adafruit/circuitpython/issues/4143)

Правило: для независимых частот — берите пины из разных слайсов (например, GP0, GP2, GP4...).

Глitch при смене duty_cycle в середине цикла

При динамическом изменении скважности аппаратный счётчик RP2040 немедленно применяет новое значение CC (compare/capture), но если обновление произошло в середине текущего периода и новое значение уже меньше текущей позиции счётчика — текущий такт выдаёт «неправильную» длину импульса. На осциллографе это выглядит как одиночный «runt pulse» (укороченный или удлинённый импульс прямо в момент смены). Это аппаратный артефакт, не баг CircuitPython. [github](https://github.com/khoih-prog/RP2040_PWM/issues/10)

Обходной путь: использовать phase_correct режим (в Pico SDK pwm_set_phase_correct()), при котором счётчик считает вперёд-назад и обновление CC безопасно применяется в точке реверса. В CircuitPython нет прямого API для этого, поэтому при необходимости используют PIO вместо аппаратного PWM. [sourcevu.sysprogs](https://sourcevu.sysprogs.com/rp2040/picosdk/symbols/pwm_config_set_wrap)

Минимальная частота — не ноль

RP2040 не умеет генерировать произвольно низкую частоту: при максимальном делителе 255.9375 и TOP=65534 минимум составляет примерно 7.5 Гц при 125 МГц. Ниже — только программный ШИМ или PIO. [forum.arduino](https://forum.arduino.cc/t/rp2040-pwm-library-for-multichannel-hardware-based-pwms/908545)

Итог: практические правила

  • Обновите прошивку до CircuitPython 8.x+ — оба исторических бага (0xFFFF и busy-wait) закрыты. [github](https://github.com/adafruit/circuitpython/issues/4189)
  • Для 100% скважности используйте 65534 как псевдо-100% если нужен максимально безопасный код, или 65535 в современных версиях. [forum.arduino](https://forum.arduino.cc/t/pwm-library-on-rp2040/1085345)
  • Не ждите 65535 реальных ступеней при частоте выше ~2 кГц — реальное разрешение меньше 16 бит. [reddit](https://www.reddit.com/r/RP2040/comments/p1pln7/rp2040_pwm_resolution/)
  • Разные частоты — только на пинах из разных слайсов. [forums.raspberrypi](https://forums.raspberrypi.com/viewtopic.php?t=316931)
  • Быстрый рамп (~65535 шагов за 10 с) — используйте time.monotonic() вместо time.sleep() с мелкими паузами, или увеличьте шаг до реально различимого аппаратом (TOP_steps / 65535).

[file-name 000363_2026-05-03_11-06-00.txt]