hello, robot

로봇 모델이 생김새라면 관절을 실제로 돌리는 건 이 스택이에요. 시뮬레이터에 팔을 띄웠는데 안 움직이면 십중팔구 여기가 원인이라, 누가 무엇을 띄우고 데이터가 어디로 도는지만 알아도 진단 시간이 크게 줄어요.

명령이 모터까지 한 바퀴 도는 길

제어 코드는 어느 로봇이든 결국 같은 모양이에요. 센서를 읽고, 목표와 비교해 명령을 계산하고, 모터에 쓰는 일을 일정 주기로 반복하죠. 이 스택은 그 반복을 세 층으로 나누고 층마다 갈아 끼울 수 있게 해요.

하는 일
주기 루프정해진 주기로 읽기 → 계산 → 쓰기를 돌리는 주인
컨트롤러상태를 읽고 명령을 계산. 하드웨어는 직접 안 만짐
하드웨어 인터페이스엔코더 읽기·모터 쓰기. 실물이든 시뮬이든 이 층만 교체

값 하나가 실제로 이동하는 걸 추적하면 감이 잡혀요. 100Hz 루프의 어느 한 주기, 관절이 0.48에 있고 궤적이 0.50을 목표로 주는 순간이에요.

단계어디에 값이 생기나
주기 시작 전엔코더 레지스터0.48
읽기상태 인터페이스0.48
계산 — 샘플링컨트롤러 지역 변수0.50
계산 — 쓰기명령 인터페이스0.50
쓰기모터 드라이버 목표0.50
다음 주기 읽기상태 인터페이스 — 조금 따라간 값0.4831

“명령을 보냈는데 왜 상태가 바로 안 바뀌나”의 답이 이 표에 있어요. 명령은 이번 주기에 나가고 실물이 움직인 결과는 다음 주기 이후의 읽기로 돌아오니, 상태는 항상 반 박자 늦게 따라와요.

컨트롤러가 하드웨어를 직접 만지지 않는다는 게 설계의 요점이에요. 둘 사이에는 이름 붙은 변수들만 있어요. 그래서 같은 컨트롤러가 시뮬 팔에도 실물 팔에도 코드 수정 없이 붙고, 바뀌는 건 하드웨어 층뿐이에요. 같은 궤적을 시뮬과 실물에 보내 비교할 수 있는 근거가 이 구조예요.

띄우는 데도 순서가 있어요. 로봇 서술을 파라미터로 올리고 → 주기 루프를 품은 노드가 뜨면서 하드웨어 층이 로드되고 → 스포너가 컨트롤러를 하나씩 활성화해요. 두 번째가 실패하면 세 번째는 반드시 실패해요. 컨트롤러를 띄울 주인이 없으니까요.

관절 상태를 토픽으로 내보내는 브로드캐스터도 컨트롤러의 일종인데 방향이 반대예요. 명령은 안 쓰고 상태만 읽어 내보내요. 시각화 도구와 좌표 트리가 보는 관절 각도가 전부 여기서 나오는데, 이것도 스포너로 띄워야 한다는 걸 모르면 로봇은 움직이는데 화면만 허공인 상황을 만나요.

이름은 조합으로 생겨요

컨트롤러와 하드웨어 사이의 공유 변수에는 규칙이 있어요. <조인트 이름>/<종류> 형식이고 종류는 위치·속도·힘 셋이 표준이에요.

같은 이름이 상태와 명령 양쪽에 있을 수 있다는 점만 처음에 헷갈려요. 엔코더가 채우는 상태 변수와 컨트롤러가 채우는 명령 변수는 서로 다른 변수예요. 어느 쪽인지는 항상 방향으로 구분해요.

이 이름들은 손으로 나열하는 게 아니라 조합으로 생겨요. 설정의 조인트 목록과 인터페이스 목록을 곱해서 잡을 이름을 만들거든요. 진단 명령의 출력이 전부 이 형식이라 이름을 조인트와 종류로 쪼개 읽는 게 이 스택 로그를 읽는 기본기예요.

잡는 규칙도 방향을 따라요. 상태는 여러 컨트롤러가 같이 읽어도 되지만 명령은 한 번에 한 컨트롤러만 잡을 수 있어요. 같은 관절에 서로 다른 명령을 쓰는 사고를 프레임워크가 원천 차단하는 거예요.

명령을 쓰기 직전에 하는 일이 하나 더 있어요. 관절 한계를 넘는 값은 그대로 보내면 안 되니 한계 안으로 잘라 써요. 실물에서 한계 밖 명령은 기어를 갈거나 프레임을 치는 사고라, 쓰기 전 잘라내기는 명령 경로의 기본 예절이에요.

점 세 개가 400개의 명령이 돼요

궤적 컨트롤러가 하는 일은 한 문장이에요. 한 번 받은 궤적을 매 주기 샘플링해서 명령으로 써 주는 기계요.

궤적의 실체는 점 목록이에요. 점 하나가 “시작 후 몇 초에 각 관절이 어디 있어야 하는가”죠. 그런데 점은 몇 개뿐이고 루프는 100Hz로 돌아요. 그래서 매 주기 “궤적 시작 후 지금이 몇 초인가”를 계산하고 그 시각을 둘러싼 두 점 사이를 보간해요.

계산
지금 시각1.0초
둘러싼 두 점(0.0초, 0.0) ↔ (2.0초, 1.0)
진행 비율0.5
이번 주기 목표0.0 + 0.5 × 1.0 = 0.5

10ms 뒤에는 같은 계산으로 0.505를 써요. 점 세 개짜리 궤적이 400개의 주기 명령으로 바뀌는 게 이 샘플링이에요.

명령 종류가 위치면 샘플한 값을 그대로 쓰고, 속도나 힘이면 오차에 게인을 곱하는 계산을 거쳐요. 그래서 위치 명령 구성에서는 게인 블록이 아예 없어도 팔이 움직여요. 위치 추종을 모터 드라이버가 자기 안에서 하니까요.

완료 판정은 허용 오차로 해요. 마지막 점의 시각이 지났고 오차가 허용 범위 안이면 성공으로 끝내고, 여유 시간을 지나도록 못 들어오면 실패를 돌려줘요. 실물이 명령을 따라갔는가를 컨트롤러가 스스로 판정하는 유일한 지점이라, 결과 코드를 읽을 때 이 기준을 알고 봐야 해요.

평범한 콜백이 아니에요

이 스택에서 가장 중요한 자리예요. 컨트롤러의 갱신 함수는 마감이 있는 함수예요. 일반 콜백은 실행기가 한가할 때 돌지만, 이건 제어 루프 안에서 매 주기 직접 불려요. 100Hz면 10ms마다 한 번인데, 그 10ms를 읽기와 모든 컨트롤러의 계산과 쓰기가 나눠 써야 해요.

여기서 계약이 나와요. 첫째, 매 주기 마감 안에 끝나야 해요. 한 번 15ms를 먹으면 그 주기의 쓰기가 늦고 다음 주기 시작도 밀려요. 루프 전체가 내 함수 하나에 인질로 잡히는 구조예요.

둘째, 실행 시간이 들쭉날쭉해질 일을 하면 안 돼요. 힙 할당, 잠금 대기, 파일이나 콘솔 로그가 금지 목록이에요. 평균이 빨라도 소용없어요. 마감은 최악의 한 번이 깨거든요.

계약을 어기는 코드가 어떤 모양인지 봐 두면 리뷰에서 바로 보여요.

// 나쁜 예 — 세 줄 전부 계약 위반
update(const rclcpp::Time& time, const rclcpp::Duration& period) override {
    std::vector<double> targets;                    // 힙 할당 — 매 주기 새 벡터
    for (auto& j : joints_) targets.push_back(sample(j, time));   // 커지며 재할당
    RCLCPP_INFO(get_node()->get_logger(), "cmd %f", targets[0]);  // 로그 — 블로킹
    std::lock_guard<std::mutex> lk(traj_mutex_);    // 콜백과 공유하는 락 — 대기 무제한
}

고치는 방향은 각각 정해져 있어요. 벡터는 활성화 단계에서 미리 잡아 두고 매 주기 값만 덮어쓰고, 로그는 루프 밖으로 빼고, 락은 잠금 없는 한 칸 상자로 바꿔요. 셋 다 시간이 튈 수 있는 일을 루프 밖으로라는 같은 원칙이에요.

게인을 만지기 전에 볼 곳

계약을 어겼을 때 나오는 증상이 특징적이에요.

증상무엇이 일어난 것먼저 볼 곳
궤적이 거칠고 덜컹 소리명령 간격이 흔들리는 지터갱신 함수 안의 할당·로그
부하가 높은 날만 재현최악 실행 시간이 마감을 가끔 넘김주기별 소요 시간 계측
드라이버가 이유 없이 안전 정지주기를 건너뛴 명령 누락 → 워치독블로킹 호출
주기를 올렸더니 더 나빠짐주기 예산이 줄어 초과가 잦아짐예산 대비 실제 소요

10ms 간격이어야 할 명령이 8ms와 14ms로 출렁이면 관절 속도가 불연속이 되어 모터가 덜컹거려요. 더 심하면 주기를 통째로 건너뛰어 명령 누락이 나는데, 일정 시간 명령이 없으면 멈추는 안전장치가 있는 드라이버는 그 순간 정지로 떨어져요.

고약한 건 로직 에러가 아니라는 점이에요. 코드는 맞는데 궤적이 이유 없이 거칠고 부하가 높은 날만 재현되니, 증상만 보면 게인 문제로 오인하기 쉬워요. 지터나 명령 누락이 보이면 게인을 만지기 전에 갱신 함수 안의 할당과 블로킹부터 의심하는 게 순서예요. 왜 이런 것들이 시간을 튀게 하는지는 C++ 실시간 제약 — 핫루프와 링버퍼에 있어요.

새 궤적이 콜백으로 도착할 때 문제가 하나 생겨요. 콜백은 제어 루프 밖 스레드에서 도는데 루프 쪽은 락을 기다릴 수 없거든요.

[액션 콜백 스레드]                    [제어 루프 스레드]
  새 궤적 도착                          매 주기 갱신
      │ 쓰기(느려도 됨)                      │ 읽기(기다리면 안 됨)
      └──────────► [한 칸 상자] ◄────────────┘
                   최신 것 하나만 보관

상자에 담기는 건 가장 최근에 도착한 궤적 하나뿐이에요. 궤적을 쌓아 두지 않고 최신 것만 남기는 게 “새 궤적이 오면 즉시 갈아탄다”와 같은 이야기고요. C++ 동시성 — 센서 수신과 제어 루프의 최신값 공유 패턴이 정확히 이 자리에 있는 거예요.

세 명령으로 원인을 삼분해요

팔이 안 움직일 때 로그를 헤집기 전에 세 명령을 순서대로 쳐요. 각 명령이 층 하나씩을 검사해서 원인이 셋 중 어디인지 갈라져요.

순서보는 것여기서 걸리면
1컨트롤러가 active인가컨트롤러가 안 떴다 — 설정 오타, 스포너 누락, 점유 충돌
2인터페이스가 available·claimed인가하드웨어가 안 붙었다 — 로봇 모델 선언·플러그인 로드 실패
3관절 상태 값이 갱신되는가읽기 쪽 문제, 브로드캐스터 미기동

1번에서 컨트롤러가 아예 안 보이면 스포너가 안 돌았거나 이름·타입이 틀린 거고, inactive로 보이면 로드는 됐는데 활성화에서 막힌 거예요. 대개 인터페이스 문제라 2번으로 넘어가요.

2번에서 available이 아니면 하드웨어 층이 안 붙은 것이고, available인데 unclaimed면 컨트롤러 쪽 문제예요. 설정의 조인트 목록에서 빠졌거나 이름 철자가 로봇 모델과 다른 경우가 대부분이에요.

3번에서 토픽이 아예 없으면 브로드캐스터가 안 뜬 것이고, 메시지는 오는데 값이 계속 같으면 읽기가 실물에서 못 읽어 오는 거예요.

셋 다 정상인데 팔이 이상하게 움직이면 그때부터가 궤적이나 주기의 영역이에요. 이 순서를 지키면 “팔은 떠 있는데 안 움직인다”의 원인 탐색이 수 시간에서 수 분으로 줄어요.

셋으로 안 갈리는 나머지 함정은 한 줄씩이에요. 조인트 이름은 로봇 모델·설정·궤적 메시지 세 곳에서 철자가 전부 같아야 하고, 궤적의 첫 점 시각을 0으로 주면 이미 지난 시각으로 버려질 수 있고, 시뮬 시간을 쓸 때는 노드마다 설정이 맞아야 해요.

궤적을 보내는 쪽 코드는 C++ rclcpp — 콜백 수명과 스레딩이 만드는 사고의 액션 클라이언트 절에 있어요. 이 글이 다룬 건 그 반대편, 받는 쪽이에요.