AI-mobility

PWM Class

PWM Class에 대해서 설명하고, 왜 사용하는지?, 그리고 어떤 파라미터가 존재하고 어디까지 생각해볼 수 있는지 정리하였다.

PWM Class

PWM Class란?

PWM은 Pulse Width Modulation의 약자이고, 그대로 해석하자면 펄스의 폭을 변조한다는 뜻이다. 펄스의 폭을 변조시키면 전압을 직접적으로 조절하지 않아도, 평균 전압을 낮출 수 있기 때문에 원하는 결과 값을 얻을 수 있다.

PWM Class가 아닌, 단순 Pin Class와 Time 모듈을 사용하여 PWM을 구현해보겠다.

Pin Class, Time으로 PWM 구현

LED의 HIGH(3.3V)와 LOW(0V)의 주가가 1초에 60번씩 일정하게 반복된다고 해보자. 이는 주파수가 60Hz인 LED라고 한다.

HIGH ─────────┐
              │
LOW           └────────────

위 처럼 HIGH의 소요 시간과, LOW의 소요 시간이 서로 똑같은 주기가 반복된다면, 평균 전압은 1.65V가 될 것이고, 3.3V의 절반 전압으로 출력되는 효과를 얻을 수 있다.

만약, HIGH의 소요 시간을 전체 주기 시간 중 20%로 줄이고, LOW를 80%로 늘린다면 LED 밝기는 어떻게 변할까?

HIGH ────┐
         │
LOW      └────────────────
     20%        80%

위 상황처럼 각 소요 시간을 바꾼다면 다음과 같은 수식이 성립할 것이다.

\[V_{\text{avg}} = (3.3 \mathrm{V} \times 0.2) + (0 \mathrm{V} \times 0.8) = 0.66 \mathrm{V}\]

이렇게 HIGH와 LOW의 소요 시간을 바꾼다면 프로그래머가 원하는 전압으로 조절할 수 있고, 원하는 출력 형태를 얻을 수 있다. 코드로는 다음과 같다.

while True:
    led.value(1)
    time.sleep((1 / hz) * (n / 100))

    led.value(0)
    time.sleep((1 / hz) * (1 - n / 100))

hz는 1초에 반복될 주기의 개수이고, n은 HIGH의 비율을 조절하는 것이다. hz는 60으로 설정하고 n을 20으로 설정한다면 아까 계산했던 수식과 이론상으로는 정확하게 일치한다. 하지만 위 코드는 한계가 있다. time.sleep()을 사용한다는 것 자체에서 오차가 발생할 수 있고, led.value()가 반복문 주기에 포함되기 때문에 정확한 HIGH, LOW의 소요 시간을 만들어낼 수 없다. 아래 구조를 보면 더 이해가 될 것이다.

led.value(1)을 실행하는 시간
+ time.sleep()을 호출하는 시간
+ 실제 대기 시간
+ led.value(0)을 실행하는 시간
+ 반복문이 다시 시작되는 시간
+ 인터럽트나 시스템 처리 시간

이렇게 소프트웨어로 PWM을 구현하게 되면 원하는 결과값을 얻기 힘들다. 그래서 하드웨어 PWM을 사용하는 것이다.

하드웨어 PWM은 소프트웨어 PWM과 다르게, 직접 하드웨어 기기에 설정값을 변경하여 출력하도록 한다.

PWM Class는 어떻게 사용하는가?

PWM의 생성자의 형태는 다음과 같다.

machine.PWM(
    dest,
    *,
    freq,
    duty_u16,
    duty_ns,
    invert=False
)

파라미터를 각각 하나씩 살펴보면서 정리해보겠다.

dest

dest는 PWM 신호를 어떤 GPIO 핀에 출력할 것인지를 지정해주는 파라미터이다. 어렵지 않다.

freq

freq는 frequency의 약자이고, 주파수를 의미한다. 단위는 Hz이며, 1초에 몇 개의 주기를 설정할 것인지를 정하는 것이다.

pwm = PWM(Pin(6), freq=1000)

주파수 설정 값의 범위는 해당 CPU의 클럭 속도에 따라 결정이 된다. 그래서 주파수 설정값이 너무 높으면 클럭 속도가 설정한 주파수를 따라가지 못해 문제가 발생한다. 또한 너무 주파수가 빨라도 문제가 발생한다.

우선 프로그래머는 자신의 CPU 클럭 속도를 알아야 한다.

print(machine.freq())

필자의 RP2040 하드웨어에 대한 출력값은 다음과 같다.

125,000,000

이는 초당 1억 2천5백만번의 클럭을 수행할 수 있다는 것이다. 클럭 한 번의 시간은 1/125,000,000 = 0.000000008s = 8ns 이다. 8ns마다 카운터가 증가하는 것이다. 여기서 카운터란 프로그램 카운터가 아닌, PWM 카운터이다. RP2040 기준 PWM 카운트의 비트 수는 16비트이니 최대 65536번의 카운트가 PWM의 한 주기를 완성시킬 수 있다. 그래서 HIGH와 LOW를 출력할 때 카운트의 개수로 차이를 두고 출력하는 것이다. 그렇다고 해서 RP2040에 프로그램 카운터가 없는 것은 아니다. CPU 안에 있다.

카운터  0 1 2 3 4 5 . . . TOP
출력   ──────────┐
HIGH             │
LOW              └─────────

하지만, 8ns는 카운터가 증가되기에 너무 빠른 속도이므로 제조사가 미리 정해준 클럭 분주기를 이용하여 클럭 횟수에 따른 카운트를 다시 계산한다. 예를 들어, 클럭 분주기가 2라면 125MHz/2 = 62.5MHz이고, 이는 초 당 카운터가 카운트를 증가시키는 횟수라고 생각하면 된다. 즉 시스템 클럭이 두 번 동작해야 카운트가 한 번 증가하는 것이다. 클럭 분주기가 커지면 커질 수록, 시스템 클럭이 많이 돌아야 카운트가 증가한다. 이를 이용하여 주파수의 최솟값을 구할 것이다.

RP2040의 PWM 클럭 분주기는 8.4 고정소수점 형식을 사용하며, 해당 고정소수점으로 표현할 수 있는 최댓값은 255.9375이다. 이를 최대 클럭 분주기로 생각하면, 8ns * 255.9375 = 2047.5ns 이고 2.0475μs 마다 카운터가 증가한다., 아까도 설명했듯이, 카운터의 카운트는 16비트로 이루어져 있고 카운터는 0부터 TOP까지 센다. 즉, 0부터 TOP번 안에 한 주기를 수행하는 것이다. 최솟값을 구하기 위해 TOP을 16비트 표현할 수 있는 가장 큰 수인 65535로 설정하면 총 65536번 카운트가 증가해야 한 번의 주기를 표현할 수 있다. 그럼 PWM의 주파수를 구하기 위해 아래와 같은 공식을 유도할 수 있다.

\[f_{\text{PWM}} = \frac{f_{\text{sys}}}{\text{분주값} \times (\text{TOP} + 1)}\]

아까 구한 최대 클럭 분주기와 TOP의 최댓값을 적용하여 PWM 주파수의 최솟값을 구하면 아래와 같다.

\[\min = \frac{255.9375 \times 65536}{125,000,000} = 7.45\text{Hz}\]

우리는 정수값만 주파수를 취할 수 있기 때문에 정수의 최솟값은 8Hz라고 결론을 지을 수 있다.

그래서 프로그래머는 우선 다음과 같은 규칙을 지키고 코딩을 하여야 한다.

  1. 현재 CPU 클럭 속도는 어떻게 되는가?
  2. PWM카운터의 카운트 비트 수는 어떻게 되는가?
  3. 결론적으로 내가 적을 수 있는 freq의 값의 범위는 어떻게 되는가?

duty_u16, duty_ns

duty_u16은 unsigned 16비트로 듀티 비를 정하는 것이고, duty_ns은 ns로 듀티 비를 정하는 것이다.

듀티 비란, 전체 한 주기 안에 HIGH를 몇 퍼센트로 설정할 것인지에 대한 값이다. 아까 PWM 카운터의 최대 카운트 수는 16비트라고 했고, 카운터는 0부터 TOP까지를 CPU의 클럭 속도에 맞춰서 클럭 분주기를 계산해 카운트를 세고 한 주기를 출력한다고 하였다. 그럼 HIGH의 비율은 다음과 같이 계산할 수 있다.

\[\frac{\text{Duty Value}}{\text{TOP} + 1}\]

이는 하드웨어 관점에서 매우 이론적인 공식이며, TOP과 분주값이 변할 수 있음을 하나도 고려하지 않은 공식이다. 심지어 하드웨어마다 해당 값들이 변할 수 있기 때문에 저 공식을 그대로 사용했다간 호환성에 문제가 생길수도 있다.

그래서 듀티 비는 PWM의 카운트 비트로 표현할 수 있는 최대 수로 나눈 다음 (TOP + 1)로 곱하면 일정한 HIGH의 비율값이 나온다. 식은 아래와 같다.

\[\text{CC} \approx \frac{\text{duty}_{\text{u16}} \times (\text{TOP} + 1)}{65535}\]

아래와 같이 duty_u16을 사용할 수 있다.

pwm.duty_u16(32768)

duty_ns는 ns로 HIGH의 비율을 정할 수 있다. 만약, freq를 1000으로 설정했다면 이는 1000Hz로 생각할 수 있고, 한 번의 주기 시간은 1/1000s, 1ms = 1,000,000ns로 볼 수 있다. freq를 1,000으로 하고 duty_ns를 1,000,000으로 설정했다면 HIGH가 100%, LOW가 0%인 것을 알 수 있다. 즉, 내가 설정한 주파수에 따라 어떤 duty_ns를 설정할 것인지 고려해야 한다는 것이다. duty_u16은 비율 값이기 때문에 어떤 주파수가 오던간에 HIGH의 비율은 달라지지 않지만, duty_ns같은 경우는 비율이 아닌 실제 시간 값이기 때문에 freq값에 따라 HIGH의 비율이 달라질 수 있음을 인지하고 있어야 한다.

machine.PWM(..., freq=1000, duty_ns=500,000)
machine.PWM(..., freq=10000, duty_ns=500,000)

위 코드를 보면 윗 줄보다 아랫줄이 10배 더 HIGH의 유지 시간이 줄어들었음을 알 수 있다.

만약, duty_u16과 duty_ns를 같이 넘기게 되면 어떤 일이 발생할까?

우선, 두 값을 동시에 넘기게 된다 하더라도 오류가 발생하지 않는다.

만약 아래와 같이 코드를 작성했다고 가정해보자.

pwm = PWM(
    Pin(6),
    freq=1000,
    duty_u16=32768,
    duty_ns=250_000
)

프로그램 입장에서는 duty_u16을 취해야 할지, duty_ns를 취해야 할지 모르는 모호한 상황에 놓여 있는 것처럼 보인다. 하지만 내부적으로는 나중에 처리되는 duty_nsduty_u16을 덮어 쓴다. duty_u16 설정 함수가 먼저 실행되고, duty_ns 설정 함수가 실행되기 때문에 환경변수 값이 최근 값으로 덮어씌워지게 되는 것이다.

invert

invert는 PWM 카운터의 논리 출력 값을 반전하는 것이다.

기존에 출력은 아래와 같이 되었을 것이다.

카운터  0 1 2 3 4 5 . . . TOP
출력   ──────────┐
HIGH             │
LOW              └─────────

하지만, invertTrue라면 아래와 같이 반전된다.

카운터  0 1 2 3 4 5 . . . TOP
출력   ──────────┐
LOW              │
HIGH             └─────────

결국 설정되었던 모든 값(freq, duty…) 등은 바뀌지 않아도 논리 값이 반전되었기 때문에 HIGH의 출력 비율이 기존 LOW의 출력 비율로 설정되는 것이다.

Related records

읽던 위치가 있어요.
확대 이미지