Код
# 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]