제3부 디지털 제어
3.3 디지털 PID 구현
제3부 디지털 제어 · 마무리. 2.5의 PID와 3.1·3.2의 이산화를 모아, 실제로 동작하는 디지털 PID를 만든다. 위치형·속도형, 실시간 루프 구조, 수치 주의점, 그리고 제7부 ROS2(7.2)로 가는 다리. 이 장의 참조 구현이 여러분이 로봇에 올릴 코드의 출발점이다.
이 장을 마치면 디지털 PID의 두 형태를 구분하고, 모든 실무 기능(안티와인드업·측정 미분·필터·설정점 가중)을 갖춘 구현을 작성하며, 실시간 루프의 함정(지터·타이밍)을 인지해야 한다.
3.3.1 이론에서 "동작하는 코드"로
지금까지: PID 세 항(2.5) → 차분방정식·z-변환(3.1) → 이산화·샘플링(3.2). 이제 이것을 고정 주기 $\Delta t$로 실행되는 코드로 합친다. 핵심은 0.1.4.2의 루프 그대로다:
다른 점은, 이번에는 실물에서 사고 안 나는 디테일(와인드업·잡음·타이밍)을 전부 고려한다는 것.
3.3.2 두 형태 — 위치형 vs 속도형
같은 PID도 코드로 옮기는 방식이 둘이다.
3.3.2.1 위치형 (positional)
$u[k]$를 매번 절대값으로 계산. 적분기 상태 $I$를 명시적으로 누적(2.5.7).
$$u[k] = K_p\cdot e[k] + I[k] + D[k], \qquad I[k] = I[k-1] + K_i\cdot T\cdot e[k]$$
- 장점: 직관적, 각 항을 따로 들여다보기 쉬움(디버깅·로깅).
- 단점: 적분기 $I$가 명시적이라 와인드업을 직접 막아야 한다(안티와인드업, 2.5.6.3).
3.3.2.2 속도형 (velocity / 증분형)
출력의 변화량 $\Delta u$만 계산하고 누적은 액추에이터(또는 u[k−1])에 맡긴다.
$$\begin{aligned} \Delta u[k] &= u[k] - u[k-1] = q_0\cdot e[k] + q_1\cdot e[k-1] + q_2\cdot e[k-2] \\ q_0 &= K_p + K_i\cdot T + K_d/T \\ q_1 &= -K_p - 2K_d/T \\ q_2 &= K_d/T \\ u[k] &= u[k-1] + \Delta u[k] \end{aligned}$$
- 장점: 명시적 적분기가 없어 와인드업이 부분적으로 자동 해소(포화된
u[k−1]을 이어받음), 모드 전환이 부드러움(범프리스), 속도명령형 액추에이터(스텝모터 등)에 자연스러움. - 단점: 미분이
e[k−2]까지 필요, 측정 미분·필터 적용에 약간 더 신경.
어느 쪽이든 옳다. 명시적 제어·로깅이 중요하면 위치형, 액추에이터가 적분 성격이거나 범프리스가 중요하면 속도형. 이 책 참조 구현(3.3.5)은 기능을 다 보이기 위해 위치형을 쓴다.
3.3.2.3 손으로 계산 — 위치형 수치 예
코드로 가기 전에, 3.3.2.1의 위치형 식을 손으로 몇 스텝 실행해 보자. 미분은 3.2.6의 기본(후진차분) 형태를 쓴다(필터·설정점 가중 같은 실무 보정은 3.3.5에서 더함):
$$\begin{aligned} I[k] &= I[k-1] + K_i\cdot T\cdot e[k] \\ D[k] &= K_d\cdot(e[k] - e[k-1]) / T \\ u[k] &= K_p\cdot e[k] + I[k] + D[k] \end{aligned}$$
$K_p=2.0, K_i=0.5, K_d=0.1, T=0.1 \,\mathrm{s}$, 오차열 $e[0..4] = [1.0, 0.8, 0.6, 0.4, 0.2]$(정상상태로 줄어드는 전형적 오차), 초기조건 I[−1]=0, e[−1]=0(시작 전 정지 가정)으로 두면:
- k=0: $I[0] = 0 + 0.5\cdot0.1\cdot1.0 = 0.05$, $D[0] = 0.1\cdot(1.0-0)/0.1 = 1.00$, $u[0] = 2.0\cdot1.0 + 0.05 + 1.00 = 3.05$
- k=1: $I[1] = 0.05 + 0.5\cdot0.1\cdot0.8 = 0.09$, $D[1] = 0.1\cdot(0.8-1.0)/0.1 = -0.20$, $u[1] = 2.0\cdot0.8 + 0.09 - 0.20 = 1.49$
- k=2: $I[2] = 0.09 + 0.5\cdot0.1\cdot0.6 = 0.12$, $D[2] = 0.1\cdot(0.6-0.8)/0.1 = -0.20$, $u[2] = 2.0\cdot0.6 + 0.12 - 0.20 = 1.12$
- k=3: $I[3] = 0.12 + 0.5\cdot0.1\cdot0.4 = 0.14$, $D[3] = 0.1\cdot(0.4-0.6)/0.1 = -0.20$, $u[3] = 2.0\cdot0.4 + 0.14 - 0.20 = 0.74$
| k | e[k] | I[k] | D[k] | u[k] |
|---|---|---|---|---|
| 0 | 1.0 | 0.05 | 1.00 | 3.05 |
| 1 | 0.8 | 0.09 | −0.20 | 1.49 |
| 2 | 0.6 | 0.12 | −0.20 | 1.12 |
| 3 | 0.4 | 0.14 | −0.20 | 0.74 |
| 4 | 0.2 | 0.15 | −0.20 | 0.35 |
$e$가 매 표본 0.2씩 일정하게 줄어드는 구간이라 $D[k]$가 $k\ge1$부터 −0.20으로 일정해짐을 눈여겨보라(등차 감소 오차 → 일정한 후진차분). $I[k]$는 오차가 줄어도 계속 커진다(누적이므로) — 오차가 완전히 0이 되기 전까지는 $I$는 절대 줄지 않는다는, 적분기의 성질을 손으로도 확인할 수 있다.
⚠️ 정직 — 이 표는 3.3.5 코드와 숫자가 정확히 일치하지 않는다. 위 표는 이번 스텝에 갱신한 $I[k]$를 같은 스텝의 $u[k]$에 곧바로 쓴다($u[k]=K_p\cdot e[k]+I[k]+D[k]$). 그런데 3.3.5의 백계산 안티와인드업은 포화량(
u−u_unsat)을 알아야 보정항을 만들 수 있어, 옛 적분값(I[k−1])으로 먼저u_unsat을 계산하고 그 다음에야 $I$를 이번 스텝 몫만큼 갱신한다 — 그래서 실제로는 $u[k]=K_p\cdot e[k]+I[k-1]+D[k]$가 된다. 예컨대 포화가 전혀 없다고 해도 3.3.5 코드로는 $u[0]=2.0\cdot1.0+0+1.00=3.00$(위 표의3.05가 아님)이 나온다. 오류가 아니라 백계산 안티와인드업 구조상 불가피한 한 스텝 지연이다 — 다만 이 표로 3.3.5를 직접 검산할 때는 반드시 감안해야 한다.
3.3.3 실시간 루프 구조
디지털 제어의 핵심 요건은 일정한 주기다. $\Delta t$가 불규칙하면(지터) 적분($\cdot T$)·미분($/T$)이 그만큼 부정확해진다.
# 타이머/인터럽트 기반 — Δt마다 정확히 호출
on_timer(Δt):
y = read_sensor() # ① 측정 (가능한 한 일정 시점에)
u = pid.update(r, y) # ② 계산 (3.3.5)
write_actuator(u) # ③ 구동
log(t, r, y, u) # ④ 기록 (튜닝·검증용)
- 고정 주기: OS 타이머·하드웨어 인터럽트·RTOS로 $\Delta t$를 일정하게. 바쁜 대기(busy-wait)보다 타이머 콜백이 정확.
- 지터·지연 최소화: 측정→구동 사이 계산을 짧게. 측정과 구동의 시각을 매 주기 일정하게 유지(지연이 일정해야 보상 가능).
- $\Delta t$ 측정 vs 고정: 이상적으로는 고정. 불가피하게 변하면 실측 $\Delta t$를 적분·미분에 써서 오차를 줄인다(단, 잡음 주의).
3.3.4 수치 주의점
- 부동 vs 고정소수점: 현대 MCU·PC는 보통
float/double로 충분. 소형 8/16비트 고정소수점에서는 스케일링·오버플로를 직접 관리(적분기가 특히 위험 — 작은 값이 오래 쌓임). - 적분기 정밀도:
I += Ki·e·T는 작은 항을 길게 누적하므로 충분한 비트가 필요.float보다double권장(특히 작은 $T$). - 미분 잡음·필터(이산형): 순수 후진차분
(e[k]−e[k−1])/T는 잡음을 $1/T$로 증폭한다(작은 $T$일수록 심함). 필터된 미분(2.5.6.2: $K_d\cdot s/(1+\mathrm{s/N})$)을 이산화해 쓴다:
$$D[k] = (T_f\cdot D[k-1] - K_d\cdot(y[k]-y[k-1])) / (T_f + T), \qquad T_f = 1/N \qquad (N\approx 8{\sim}20)$$
(측정값 $y$에 적용 → 도함수 킥 방지, 2.5.6.1. $T_f$는 2.5.6.2와 같은 정의 그대로다 — $K_p$와 무관하게 $N$만으로 정해지는 시정수.)
- 단위·스케일: 센서 단위(엔코더 카운트 등)와 제어 단위(rad/s 등)를 명확히. 이득값이 단위에 묶임을 잊지 말 것.
3.3.4.1 오버플로 수치 예 — 16비트 고정소수점 적분기
"적분기가 특히 위험하다"는 말을 숫자로 보자. 16비트 부호있는 정수를 Q8.8 고정소수점(정수부 8비트·소수부 8비트, LSB=1/256)으로 쓴다고 하자 — 표현 가능한 실수 범위는 대략 $\pm128$이다.
- $K_i=5.0$(1/s), $T=0.01 \,\mathrm{s}$(100 Hz 루프), 지속 오차 $e=10$(예: 좀처럼 안 없어지는 정상상태 오차 10 rpm)이 이어진다고 하면, 매 표본 적분기 증가분은 $K_i\cdot T\cdot e = 5.0 \times0.01 \times10 = 0.5$(엔지니어링 단위/표본).
- 적분기가 $\pm128$을 넘기려면
128 / 0.5 = 256표본이 필요하고, $T=0.01 \,\mathrm{s}$이므로256 × 0.01 s = 2.56초만에 오버플로한다. - 정수 오버플로는 부동소수점처럼
inf로 "포화"하지 않고 2의 보수 랩어라운드로 부호가 반전된다 — 적분기가 최대양수 근처에서 갑자기 최대음수로 급변해 액추에이터에 정반대 명령이 나갈 수 있다. 조용히 지나갈 문제가 아니다.
표준 대응책:
- 더 넓은 누산기: 출력·저장은 16비트라도 누산은 32비트 이상으로 한다. 같은 Q8.8 스케일이면 32비트는 헤더룸이 16비트보다
2^16=65536배 늘어 위 예의 오버플로 시간이 수 시간 단위로 늘어난다. - 안티와인드업과 묶은 명시적 클램핑: 2.5.6.3의 안티와인드업 한계(
u_min·u_max에 대응하는 스케일)를 적분기 자체에도 강제한다 — 포화 시 랩어라운드 대신 정해진 한계에서 멈추게 만드는 것이 핵심. - 주기적 Q-포맷 재조정: 동작 범위가 넓어질 것을 알면(예: 기동 구간) 소수점 위치를 미리 계획적으로 옮겨 여유를 확보한다 — 단, 재조정 순간 출력이 튀지 않도록(범프리스) 값을 함께 스케일해야 한다.
(위 수치는 가정한 $K_i$·$T$·$e$에 대한 직접 산술이다 — 실제 시스템의 $K_i$·루프 주기·예상 오차 범위로 반드시 다시 계산해 볼 것.)
3.3.4.2 엔코더 양자화 — 위치는 정수, 속도는 그 차분
또 다른 흔한 수치 함정은 센서 자체의 해상도다. 증분(incremental) 엔코더는 위치를 정수 카운트로만 준다 — 실수 각도가 아니라 θ[k] = (카운트) × (한 카운트당 각도)처럼 계단(양자화)이다. 문제는 여기서 그치지 않는다: 속도를 흔히 $\omega[k]=(\theta[k]-\theta[k-1])/T$(3.3.2.2의 후진차분)로 구하는데, 이 미분이 위치의 계단을 그대로 증폭한다.
- 카운트당 각도 해상도
Δθ = 2π / (엔코더 라인수 × 쿼드러처 배율). 예: 라인수 100, 쿼드러처 ×4 디코딩 → 유효 400 카운트/회전 → $\Delta\theta= 2\pi/400 \approx0.0157 \,\mathrm{rad}$. - 제어 루프 $T=0.001 \,\mathrm{s}$(1 kHz)에서 후진차분으로 속도를 구하면, 카운트 1개 차이가 만드는 속도 단위는 $\Delta\theta/T = 0.0157/0.001 \approx15.7 \,\mathrm{rad/s}$.
- 실제 속도가 예컨대
2 rad/s(느린 회전)라면, 한 표본 구간($1 \,\mathrm{ms}$)에 카운트가 거의 안 바뀐다(대부분 0개, 가끔 1개) — 계산된 속도가0과15.7 rad/s사이를 불규칙하게 변한다. 참값(2 rad/s)과 무관한 거대한 양자화 잡음이다. - 이 잡음을 그대로 D항이나 속도 PID(7.2)에 입력하면, 실제 신호보다 양자화 계단이 지배해 제어가 떨리거나(chattering) 잘못 반응한다 — 특히 저속에서 심하다(고속은 표본당 카운트가 많아 상대적으로 완만해짐).
표준 대응책:
- 해상도를 높인다: 더 높은 라인수 엔코더, 또는 쿼드러처 배율을 최대(×4)로 활용 — 위 예에서 라인수를 1000으로 높이면 카운트당 속도 단위가
1.57 rad/s로 10배 준다. - 루프 주기를 늘려 카운트를 더 모은다(M법): $T$를 늘리면 표본당 카운트 수가 늘어 상대 양자화가 줄지만, 대신 3.3.6처럼 샘플링이 느려지는 대가가 있다 — 저속 전용으로 $T$를 적응적으로 늘리는 절충도 흔하다.
- 펄스 간격을 재는 방법(M/T·주파수법): 고정 시간에 카운트 수를 세는 대신, 카운트 사이의 경과 시간을 재
ω=Δθ/Δt_측정으로 구한다 — 저속에서 정밀하지만 고속에서는 $\Delta t$가 너무 짧아져 오히려 타이머 분해능 한계에 부딪힌다(저속·고속에 각각 다른 방법을 쓰거나 자동 전환하는 구현이 실무에 많다). - 필터·상태추정으로 흡수: 3.3.4의 필터된 미분이나, 더 원칙적으로는 칼만필터(5.2) 로 양자화된 위치 측정을 여러 표본에 걸쳐 매끄러운 속도 추정으로 바꾼다(측정 잡음 $R$에 양자화 잡음의 분산($\approx\Delta\theta^2/12$, 균일분포 가정)을 반영). 단, 필터는 지연을 대가로 치른다 — 2.4의 위상여유와 절충.
(위 수치도 가정한 라인수·쿼드러처·$T$에 대한 직접 산술이다 — 실제 엔코더 사양표의 라인수와 여러분의 루프 주기로 다시 계산해 볼 것.)
3.3.5 완성형 디지털 PID — 참조 구현
모든 실무 기능을 담은 위치형 PID. 제7부 7.2에서 이 클래스를 ROS2 노드에 통합한다.
class PID:
"""디지털 PID: 측정 미분 + 필터 + 백계산 안티와인드업 + 설정점 가중."""
def __init__(self, Kp, Ki, Kd, dt, u_min, u_max, N=10, b=1.0):
self.Kp, self.Ki, self.Kd, self.dt = Kp, Ki, Kd, dt
self.u_min, self.u_max = u_min, u_max
self.Tf = 1.0 / N # 미분 필터 시정수 (2.5.6.2: Kd·s/(1+s/N) → Tf=1/N)
self.Tt = (Kp / Ki) if Ki else dt # 백계산 추종 시정수≈Ti
self.b = b # 설정점 가중(P)
self.I = 0.0 # 적분기 상태
self.D = 0.0 # 필터된 미분 상태
self.y_prev = None
def update(self, r, y):
dt = self.dt
if self.y_prev is None:
self.y_prev = y
e = r - y
# P: 설정점 가중 (오버슈트 완화, 2.5.6.4)
P = self.Kp * (self.b * r - y)
# D: 측정값 y에 적용 + 필터 (도함수 킥·잡음 방지)
if self.Tf > 0.0:
self.D = (self.Tf * self.D - self.Kd * (y - self.y_prev)) / (self.Tf + dt)
else:
self.D = 0.0
u_unsat = P + self.I + self.D
u = max(self.u_min, min(self.u_max, u_unsat)) # 포화
# I: 적분 + 백계산 안티와인드업 (2.5.6.3)
self.I += self.Ki * e * dt + (u - u_unsat) * (dt / self.Tt)
self.y_prev = y
return u
def reset(self):
self.I = 0.0; self.D = 0.0; self.y_prev = None # 범프리스 재시작
핵심 포인트: ① P는 설정점 가중, ② D는 $y$에 적용 + 필터, ③ I는 백계산 안티와인드업, ④ reset으로 범프리스. 이 네 가지가 "교과서 PID"와 "현장 PID"를 구분한다(2.5.6). (동작·튜닝은 여러분 플랜트에서 재현·검증 — ✅ control 0.10.2 검증됨; 재현 권장.)
3.3.6 디지털 튜닝과 T 의존성
- 연속에서 튜닝한 $K_p, K_i, K_d$를 그대로 쓰되, 이산화로 약간의 위상 지연이 더해짐을 감안(3.2). $T$가 충분히 작으면 무시할 만하다.
- $T$가 바뀌면 유효 이득이 바뀐다: 코드가 $K_i\cdot e\cdot dt$,
Kd/dt처럼 $T$를 곱·나눗하므로, $T$를 바꾸면 재튜닝하거나 이득을 보정해야 한다. (하드웨어·부하에 따라 $T$가 변하면 게인 스케줄링을 고려.) - Z-N 등 연속 규칙(2.5.5)으로 출발해 이산 폐루프에서 미세조정하라 — 시뮬레이션과 실물에서 각각.
3.3.7 시험·검증
구현했다고 끝이 아니다. 다음을 반드시 점검한다(로그·플롯 동반).
- 계단 응답: 오버슈트·정착시간이 사양에 맞나(1.3).
- 외란 응답: 부하·외란 계단에 I항이 오차를 0으로 수렴시키나(2.5.8).
- 포화 시험: 큰 설정점 변화에서 와인드업이 발생하지 않나(안티와인드업 검증).
- 잡음 시험: 측정 잡음에 D항·출력이 과도하게 진동하지 않나(필터 확인).
- 타이밍 검증: 실제 $\Delta t$가 일정한가(지터 측정) — 로그의 타임스탬프로 확인.
3.3.8 ROS2로 가는 다리 (7.2 예고)
제7부에서 이 PID는 ROS2 노드가 된다. 매핑:
on_timer(Δt) → rclpy 타이머 콜백 (self.create_timer(dt, cb))
read_sensor() → 토픽 구독 (/odom, /joint_states 등 최신값)
write_actuator(u) → 토픽 발행 (/cmd_vel 등)
pid.update(r, y) → 위 PID 클래스 그대로
⚠️ ROS2는 기본적으로 경성 실시간(hard real-time)이 아니다. 기본 실행기·DDS·스케줄러는 지터를 허용한다. 고정 주기가 중요하면 타이머 기반 콜백을 쓰고, 엄격한 실시간이 필요하면 실시간 커널·
rclcpp(C++)·실시간 실행기·QoS 설정을 검토한다(7.1·7.2에서 다룸).
3.3.9 정직 — 시뮬레이션과 실물 사이
- 타이밍 지터·마감 초과: 비실시간 OS·ROS2에서는 $\Delta t$가 변동하고 가끔 주기를 놓친다 → 적분·미분 오차, 성능 저하. 실측 $\Delta t$ 사용·여유 있는 설계로 완화.
- 시뮬레이션-실물 격차(sim-to-real): 시뮬레이션에서 완벽해도 실물의 마찰·지연·잡음·포화로 다르게 행동한다(1.1.11). 시뮬레이션은 출발, 실물 튜닝이 완성.
- 부동소수점 비결정성: 플랫폼·최적화에 따라 미세하게 다를 수 있음(엄밀 재현이 필요한 안전계는 유의).
- 이 코드도 출발점이다: 3.3.5는 견고한 골격이되, 여러분의 플랜트·센서·안전요건에 맞춰 검증·수정해야 한다. 맹신하지 말 것.
3.3.10 정리 — 제3부 마무리
- 디지털 PID는 위치형(명시적 적분기·로깅 쉬움)과 속도형(증분·와인드업 자동 완화) 두 형태(3.3.2). 핵심 요건은 고정 주기 실시간 루프다(3.3.3).
- 실무 구현은 측정 미분+필터·백계산 안티와인드업·설정점 가중·범프리스를 갖춰야 한다 — 참조 구현 3.3.5.
- $T$ 의존성·수치 정밀도·타이밍 지터를 고려하고(3.3.4·3.3.6·3.3.9), 계단·외란·포화·잡음·타이밍을 반드시 시험한다(3.3.7).
- 이 루프가 제7부에서 ROS2 타이머 콜백·토픽으로 옮겨간다(3.3.8).
이로써 제3부(디지털 제어)를 마친다. 연속 설계(제1·2부)를 이산 이론(3.1)·샘플링/이산화(3.2)를 거쳐 동작하는 코드(3.3)로 내렸다. 다음 제4부(4.1~)는 입출력(전달함수)을 넘어 내부 상태로 다변수·고성능 제어를 다루는 상태공간으로 — 현대 제어의 문을 연다.
3.3.11 연습·생각해 볼 것
- 두 형태. 위치형과 속도형 PID의 장단을 각각 두 가지씩 들고, 다음에 어느 쪽이 적합한지 고르라: ⓐ 각 항을 로깅하며 튜닝, ⓑ 스텝모터 속도명령, ⓒ 수동↔자동 빈번한 전환. (3.3.2)
- 지터 영향. 목표 $\Delta t=10 \,\mathrm{ms}$인데 실제로 8~14 ms로 변동한다. 적분항($K_i\cdot e\cdot dt$)과 미분항(
/dt)에 각각 어떤 오차가 생기나? 완화책 둘은? (3.3.3) - 필터 미분. 3.3.5의 필터된 미분에서 $N$을 크게(예 50) 하면 잡음과 미분 효과는 각각 어떻게 변하나? $N\to\infty$면 무엇에 수렴하나? (3.3.4)
- 안티와인드업 추적. $Tt = K_p/K_i$로 둔 이유를 설명하라. 백계산 항
(u−u_unsat)·dt/Tt가 포화 중에 적분기를 어떻게 감소시키는지 부호로 따져 보라. (3.3.5) - T 변경. 잘 튜닝된 PID의 $T$를 10 ms→20 ms로 바꿨다. 코드의 어떤 항이 어떻게 변해 성능에 영향을 주나? 이득을 그대로 두면 무엇을 재검증해야 하나? (3.3.6)
- ROS2 매핑. 3.3.5의 PID를 ROS2 노드로 올릴 때,
read_sensor·write_actuator·on_timer가 각각 무엇으로 바뀌나? ROS2가 경성 실시간이 아니라는 점이 왜 중요한가? (3.3.8·3.3.9) - (손으로) 3.3.5의
PID클래스를 1.1.7의 DC모터 모델(이산화)과 연결해, 계단·외란·포화 시험을 실행하고 결과를 플롯하라. 안티와인드업을 끄면($Tt$를 매우 크게) 포화 후 오버슈트가 어떻게 달라지나? (검증 필요)
막히면 "고정 주기로, 측정 미분+필터, 적분은 백계산, 시뮬레이션은 출발 실물이 완성"으로 돌아가라.