오차를 지금 값(P)·누적(I)·변화율(D)의 가중합으로 출력을 만드는 게 PID예요. 그런데 교과서 수식에는 적분 기호와 미분 기호가 있고 코드에는 둘 다 없어요. 제어 루프는 일정 주기마다 도는 이산 세계라 연속 수식을 주기 단위 연산으로 바꿔야 하거든요.
규칙은 두 줄이에요. 적분은 매 스텝 오차 곱하기 시간 간격을 누적, 미분은 이번 값과 직전 값의 차이를 간격으로 나누기.
double speed_pid(PidState& s, double target, double meas, double dt) {
const double kp = 8.0, ki = 3.0, kd = 0.4;
double err = target - meas;
s.integral += err * dt; // 적분: 오차×dt를 누적
double dmeas = (meas - s.prev_meas) / dt; // 측정값의 변화율
s.prev_meas = meas;
return kp * err + ki * s.integral - kd * dmeas; // D는 측정값 미분이라 부호가 -
}시간 간격을 곱하고 나누는 이유는 게인을 주기와 독립으로 만들기 위해서예요. 없이 쌓으면 루프를 50Hz에서 100Hz로 바꾸는 순간 적분이 두 배 빨리 차서 잘 맞춰 둔 게인이 전부 어긋나요.
미분항의 함정 둘
첫째는 노이즈 증폭이에요. 차분을 간격으로 나누기 때문에, 간격이 0.01초일 때 측정값에 낀 작은 노이즈가 미분값으로는 100배로 증폭돼요. 엔코더는 특히 취약해요. 한 틱 단위로만 세니 실제 속도가 매끄럽게 변해도 측정값은 계단으로 튀고, 그 계단 하나하나가 스파이크가 돼요. 증상은 정속 주행 중인데 모터가 잘게 떠는 소리로 나타나요.
대책은 미분값에 저역통과 필터를 한 줄 얹는 거예요. 직전 필터값과 이번 원본값을 섞으면 갑작스러운 튐은 눌리고 지속되는 변화만 통과해요. 다만 필터는 공짜가 아니라 지연을 사서 잡음을 파는 것이라, 너무 세게 걸면 미분항이 제 역할인 앞질러 브레이크를 못 하게 돼요.
둘째는 미분 킥이에요. 오차를 그대로 미분하면 목표가 계단으로 바뀌는 순간 미분값이 한 스텝 폭발해요. 그래서 위 코드처럼 오차 대신 측정값을 미분해요. 목표가 일정할 때 두 값은 부호만 반대인 같은 값인데, 목표의 점프만 미분항에서 사라져요.
출력은 유한한데 적분은 무한해요
이 주제에서 가장 중요한 자리예요. 수식의 출력은 무한한데 실제 출력은 유한해요. PWM은 255가 끝이고 드라이버는 최대 토크가 있어요. 그래서 모든 실전 제어기의 마지막 줄은 잘라내기예요.
문제는 잘라내기가 출력만 자르고 적분은 못 자른다는 데서 시작돼요. 멀리 있는 목표를 향해 출력이 최대치에 붙어 있는 동안에도 오차는 크게 남아 있고, 적분은 그 오차를 매 스텝 성실하게 쌓아요. 출력이 이미 벽에 붙어 더 낼 것도 없는데 적분만 커지는 이 상태가 와인드업이에요.
| 시점 | 오차 | 적분 누적 | 이론 출력 | 실제 출력 |
|---|---|---|---|---|
| 0.0s | +10.0 | 0.0 | 300 | 255 (포화 시작) |
| 0.5s | +6.0 | 4.1 | 590 | 255 (적분은 계속 증가) |
| 1.2s | 0.0 (도달) | 7.3 | 730 | 255 — 도달했는데 여전히 최대 |
| 1.8s | −4.0 (지나침) | 5.9 | 470 | 255 — 오차가 반대인데도 못 돌아옴 |
목표에 닿은 시점에 비례항은 0인데 쌓인 적분 하나가 출력을 최대로 밀고 있어요. 로봇은 목표를 크게 지나치고, 반대 방향 오차가 한참 쌓여 적분을 도로 깎아낼 때까지 돌아오지 못해요.
감아 놓은 태엽처럼 쌓인 적분이 도달 후에 풀리면서 사고를 치는 거예요. 적분 게인을 줄이면 완화되지만 정상상태 오차가 돌아오니, 게인이 아니라 구조로 잡는 게 맞아요.
적분을 먼저 확정하지 않아요
처방의 핵심은 순서를 뒤집는 거예요. 후보를 만들어 출력을 계산해 보고, 포화면 후보를 버려요.
double pi_step(double kp, double ki, double err,
double& integral, double dt, double u_max) {
double tentative = integral + err * dt; // 이번 스텝을 반영해 본 적분 후보
double u = kp * err + ki * tentative; // 그 후보로 계산한 출력
if (u > u_max) return u_max; // 포화 → 후보를 버림 (적분 동결)
if (u < -u_max) return -u_max;
integral = tentative; // 비포화일 때만 적분 확정
return u;
}이러면 포화 구간 내내 적분이 포화 직전 값에 머물러서, 목표에 닿는 순간 곧바로 출력이 내려와요.
증상 감별도 알아 두면 시간을 아껴요. 오버슈트에는 원인이 둘 있는데, 비례 게인이 커서 나는 쪽은 크기와 무관하게 늘 비슷한 비율로 나고 몇 번 출렁이다 잦아들어요. 와인드업 쪽은 목표가 멀수록, 즉 포화 구간이 길수록 심해지고 돌아오는 데 유독 오래 걸려요.
“짧은 이동은 멀쩡한데 긴 이동만 크게 지나친다”면 게인이 아니라 적분을 보는 게 맞아요.
튜닝은 하나씩, 안쪽부터
게인 셋을 동시에 만지면 어느 게 무엇을 바꿨는지 알 수 없어요. 비례부터예요. 나머지를 0으로 두고 비례만 올려 목표 부근에서 살짝 진동이 보이는 지점을 찾고, 거기서 조금 물리거나 미분을 더해 진동을 눌러요. 마지막으로 조금 못 미친 채 멈추는 오차가 남으면 적분을 조금씩 올려요.
| 증상 | 처방 |
|---|---|
| 목표 주위에서 좌우로 진동 | 비례 ↓ 또는 미분 ↑ |
| 반응이 느리고 둔함 | 비례 ↑ |
| 목표 근처에서 못 미친 채 정착 | 적분 ↑ |
| 도달 후 크게 지나쳤다 복귀 | 와인드업 의심 — 구조 먼저, 그래도 남으면 적분 ↓ |
| 출력이 잘게 지글거림 | 미분 ↓ 또는 필터 |
전제가 하나 있어요. 목표·측정·출력 세 값의 시계열 로그예요. 진동인지 노이즈인지, 못 미친 건지 늦는 건지는 그래프 없이 못 가르고, 그래서 튜닝의 절반은 로깅 코드예요. 적분값을 같이 찍으면 오버슈트 순간에 적분이 어디까지 차 있었는지가 보여 와인드업인지 게인 문제인지 한눈에 갈려요.
루프가 둘로 겹쳐 있을 때의 순서도 있어요. 로봇 제어는 대개 안쪽에 빠른 속도 루프, 바깥에 느린 위치 루프가 겹친 구조예요. 튜닝은 반드시 안쪽부터예요. 안쪽이 아직 목표 속도를 못 따라가는 상태에서 바깥을 만지면, 흔들리는 원인이 위치 게인인지 속도 루프인지 구분할 수 없거든요. 주기도 안쪽이 몇 배 빨라야 두 루프가 서로 싸우지 않아요.
추종 오차는 두 숫자로 말해요
속도 제어의 오차는 목표 빼기 측정 한 줄인데, 경로 추종의 오차는 “경로에서 얼마나 벗어났나”라 정의부터 정해야 해요.
첫째가 횡방향 오차예요. 로봇 위치에서 경로에 수직으로 내린 거리로, 진행 방향 기준 왼쪽을 +로 부호까지 담아요. 경로가 선분으로 주어지면 2차원 외적 한 줄에 나와요.
double cte(double ax, double ay, double bx, double by, double px, double py) {
double dx = bx - ax, dy = by - ay; // 경로 방향 벡터
double cross = dx * (py - ay) - dy * (px - ax); // 2D 외적의 z 성분
return cross / std::hypot(dx, dy); // 길이로 나눠 거리로
}둘째가 방향 오차예요. 경로가 향하는 방향과 로봇이 바라보는 방향의 차이죠. 위치는 경로 위에 있어도 몸이 비스듬하면 곧 벗어나니, 둘을 짝으로 봐야 상태가 온전히 읽혀요. 횡방향 오차만 0으로 만들려는 제어기는 경로를 가로지르며 지그재그가 되기 쉬워요.
여기엔 각도 특유의 함정이 있어요. 목표 170도와 현재 −170도를 그냥 빼면 340도가 나오는데 실제로는 20도만 돌면 돼요. 그대로 넣으면 로봇이 최단 방향의 반대로 한 바퀴 크게 돌아요. 각도를 빼는 자리에 접어 주는 처리가 없으면 버그라고 봐도 될 만큼 기본이에요. 자세한 건 회전 표현 — 오일러는 UI, 쿼터니언은 데이터에 있어요.
평균을 쓰면 흔들린 주행이 완벽해 보여요
한 바퀴 주행에서 오차 샘플이 수백 개 나오는데 산술 평균은 못 써요. 왼쪽 +0.3과 오른쪽 −0.3을 오가며 심하게 흔들린 주행도 평균은 0이라 완벽한 추종으로 둔갑해요. 최댓값 하나만 보는 것도 스파이크 한 번에 좌우돼 전체 품질을 못 담고요.
그래서 표준이 제곱평균제곱근이에요. 제곱이 부호를 없애 좌우 벗어남을 공평하게 세고, 큰 오차일수록 제곱으로 무겁게 벌점을 매겨요. 평균은 같아도 흔들림이 심한 주행이 더 큰 값을 받아요.
실전 보고에서는 이 값과 최댓값을 함께 적어요. 하나는 전체 품질을, 다른 하나는 안전 여유를 말해 주거든요. 이 숫자가 파라미터 스윕 실험의 세로축이 되는데, 실제 사례는 경로 추종의 전방 주시 거리와 이탈 오차의 최적점에 있어요.
오차가 나야 움직이는 구조의 한계
PID는 피드백이라 정의상 오차가 이미 난 다음에야 움직여요. 속도 명령이 0에서 0.5m/s로 바뀌는 순간 측정 속도는 아직 0이라, 출력은 오차가 커진 뒤에야 따라 올라가요. 명령이 계속 변하는 경로 추종에서는 이 한 박자가 그대로 추종 지연이 돼요.
피드포워드는 반대로 오차를 안 봐요. “이 속도를 내려면 대략 이만큼 출력이 필요하다”를 모델로 미리 계산해 명령이 바뀌는 즉시 그 몫을 내보내요.
double u_ff = kff * v_cmd; // 명령만 보고 미리 내는 몫
double u_fb = kp * err + ki * integral; // 모델 오차·외란만 담당하는 몫
return u_ff + u_fb;이 구조의 좋은 점이 둘이에요. 피드포워드는 측정값을 안 보므로 닫힌 고리 안에 들어가지 않아 안정성에 영향이 없고 진동을 만들 수도 없어요. 그리고 피드백이 잔차만 맡게 되니 게인을 낮게 잡아도 돼서 전체가 순해져요.
계수는 정밀할 필요가 없어요. 일정 출력으로 몇 초 달리게 하고 정상 속도를 재서 나누면 되고, 대략만 맞아도 나머지는 피드백이 흡수해요. 다만 배터리 전압이 떨어지면 같은 출력에서 속도가 줄고 바닥이 바뀌면 마찰이 달라지니, 한 번 재서 박아 두고 끝이 아니라 조정 손잡이로 남겨 둬야 해요.
결정적으로 이기는 자리가 로봇팔이에요. 팔은 자세에 따라 중력이 다르게 걸려서, 피드백만 쓰면 이 중력을 오차로 겪은 다음에야 대응하니 매번 아래로 처졌다가 올라와요. 자세에서 필요한 토크를 계산해 미리 넣는 중력 보상이 전형적인 피드포워드예요.
주기가 흔들리면 미분이 튀어요
시간 간격을 상수로 박아 두는 코드가 많은데 실제 주기는 스케줄링 때문에 흔들려요. 어떤 스텝이 15ms 만에 돌면 변화량은 15ms어치인데 10ms로 나누니 미분값이 1.5배 과대평가돼요. 적분은 누적이라 한두 스텝의 흔들림이 뭉개지지만, 미분은 딱 그 한 스텝의 비율이라 주기 지터에 제일 민감해요.
대응은 두 줄이에요. 간격을 실측해서 쓰고, 비정상적으로 작을 때 미분이 폭발하지 않게 바닥값을 걸어요. 시계는 뒤로 가지 않는 단조 시계를 써야 해요. 벽시계로 재면 시각 보정이 들어온 순간 음수 간격이 나와 적분과 미분이 동시에 뒤집혀요.
다만 이건 증상 완화예요. 근본은 루프 주기 자체를 흔들리지 않게 만드는 것이고, 그 방법은 C++ 실시간 제약 — 핫루프와 링버퍼에 있어요.
PID를 코드로 옮기는 일의 절반은 이산 세계의 뒤처리이고, 추종 품질은 감이 아니라 숫자로 말해요.