AEGLOSLAB ROS 2 Jazzy 대전

제6부 하드웨어 인터페이스

6.1 ros2_control 아키텍처

6.1.1 이 장에서 배우는 것 / 선수 지식

로봇을 움직이려면 하드웨어에 명령을 보내고 상태를 읽어야 한다. 가장 단순한 방법은 직접 노드를 작성하는 것이다 — 시리얼 포트를 열고, 토픽을 구독해 명령을 쓰고, 상태를 읽어 발행한다. 실제로 많은 프로젝트가 이렇게 시작한다.

그런데 곧 문제가 쌓인다. 위치 제어와 속도 제어를 교체하고 싶고, PID를 넣고 싶고, 궤적을 따라가야 하고, 실시간 주기를 지켜야 하고, 시뮬레이션에서도 같은 코드가 실행돼야 한다. 그때마다 드라이버를 고치게 되고, 그 드라이버는 다른 로봇에 재사용되지 않는다.

ros2_control은 이 문제에 "하드웨어 추상과 컨트롤러 재사용"으로 답한다. 이 장은 그 구조를 세우고, 언제 이 프레임워크를 쓰고 언제 안 쓰는 것이 맞는지까지 다룬다.

배우는 것:

  • 문제 정의 — 직접 드라이버를 구현할 때 무엇이 쌓이는가.
  • 구성 요소 — 하드웨어 컴포넌트·리소스 매니저·컨트롤러 매니저·컨트롤러.
  • 제어 루프 — read() → update() → write()의 의미.
  • 인터페이스 모델 — command/state 인터페이스와 점유(claim) 개념.
  • URDF 통합 — <ros2_control> 태그.
  • ROS 그래프와의 관계 — 왜 제어 루프가 executor 밖에 있는가.
  • 채택 판단 — 이 프레임워크가 값을 하는 경우와 아닌 경우.

선수 지식: 5.3.7(URDF 확장 태그), 1.12(라이프사이클), 2.3(executor), 2.5(타이머·지터), 5.5(관절 상태).

정직: ros2_control은 활발히 발전 중이며 API·기능이 배포판마다 달라진다. 특히 체이닝·비동기 컴포넌트·generate_parameter_library 통합은 비교적 최근 도입된 것들이다. 아래 서술은 공개 문서와 저장소 구조 기준이며 이 환경에서 빌드·실행 검증하지 않았다(목차 3). 정확한 API는 ros-controls/ros2_control 문서의 Jazzy 버전이 정본이다.

검증 상태(추가): 이 장의 서술 가운데 1곳은 이후 회차에서 Jazzy 실물과 대조해 확인했다 — 본문의 (실행 확인) 표기가 그것이다. 표기가 없는 서술은 위 고지가 그대로 적용된다(목차 3의 검증 범위 표).

6.1.2 직접 드라이버를 구현할 때 쌓이는 것

단순 드라이버 노드 요구 추가: 속도 제어도 요구 추가: 궤적 추종 요구 추가: 실시간 주기 요구 추가: 시뮬에서도 동작 요구 추가: 다른 로봇에 재사용 드라이버가 비대해지고 제어 로직과 하드웨어 코드가 뒤엉킴
쌓이는 문제설명
제어 로직과 하드웨어 코드의 결합PID를 고치려면 드라이버를 변경해야 한다
재사용 불가로봇이 바뀌면 처음부터
실시간 보장의 어려움executor·콜백에 얽혀 지터(2.5.5)
시뮬·실기 코드 분기두 벌 유지
제어 모드 전환위치↔속도 전환을 직접 구현
안전 처리 중복한계·워치독을 매번 다시

ros2_control의 답은 경계를 긋는 것이다:

층담당로봇이 바뀌면제어가 바뀌면
하드웨어 컴포넌트통신·프로토콜다시 작성그대로
컨트롤러제어 알고리즘그대로교체·추가

이 분리가 프레임워크의 전부이자 핵심이다. 하드웨어 컴포넌트는 "관절 위치를 읽고 명령을 쓴다"만 알고, 컨트롤러는 "위치를 읽어 명령을 계산한다"만 안다. 둘은 서로를 모른다.

6.1.3 구성 요소

토픽 · 액션 명령 인터페이스 쓰기 상태 인터페이스 읽기 사용자 · Nav2 · MoveIt 2 컨트롤러 joint_trajectory_ controller 등 Resource Manager Controller Manager (노드 + 제어 루프) 하드웨어 컴포넌트 read() · write() 실제 장치 시리얼 · CAN · EtherCAT
구성 요소역할관련 장
하드웨어 컴포넌트장치와 통신. read()·write() 구현6.2
Resource Manager인터페이스 등록·점유 관리·충돌 검출6.3
Controller Manager컨트롤러 수명 관리 + 제어 루프 구동6.3
컨트롤러제어 알고리즘. update() 구현6.4·6.5

controller_manager가 중심이다. 이것은 ROS 노드이면서 동시에 실시간 제어 루프의 주인이다 — 이 이중성이 ros2_control 이해의 관건이다(6.1.6).

6.1.4 제어 루프

주기 시작 (예: 1 kHz) read() 하드웨어 → 상태 인터페이스 update() 컨트롤러들이 순서대로 실행 write() 명령 인터페이스 → 하드웨어 다음 주기까지 대기

세 단계의 의미:

단계하는 일누가
read()장치에서 현재 상태를 읽어 상태 인터페이스에 쓴다하드웨어 컴포넌트
update()상태를 읽고 계산해 명령 인터페이스에 쓴다컨트롤러들
write()명령 인터페이스를 읽어 장치에 보낸다하드웨어 컴포넌트

이 순서가 고정이라는 점이 중요하다. 컨트롤러는 항상 그 주기에 방금 읽은 상태를 보고, 계산 결과는 같은 주기에 하드웨어로 나간다. 지연이 예측 가능해진다.

$$t_{\text{센서} \to \text{액추에이터}} \approx T_{\text{주기}} + t_{\text{통신}}$$

이것이 executor 기반 구현과의 결정적 차이다. 콜백으로 구성하면 상태 수신과 명령 계산의 시간 관계가 executor 스케줄링에 좌우되어(2.2.9) 지연이 흔들린다.

주기는 파라미터로 정한다:

controller_manager:
  ros__parameters:
    update_rate: 1000     # Hz

함정: update_rate를 하드웨어가 감당할 수 없게 설정하면 루프가 지연된다. 시리얼 통신이 5 ms 걸리는데 1 kHz(1 ms)로 설정하면 read()·write()가 주기를 넘긴다. 통신 시간을 측정해 정하라(6.6). 그리고 느린 장치 하나가 전체를 끌어내리는 경우에는 그 컴포넌트만 다른 주기·다른 스레드로 뺄 수 있다(rw_rate·is_async — 6.2.13, 실행 확인).

6.1.5 인터페이스 모델과 점유

ros2_control의 데이터 교환은 "이름 붙은 double 값"으로 이루어진다.

종류방향예
state_interface하드웨어 → 컨트롤러joint1/position, joint1/velocity
command_interface컨트롤러 → 하드웨어joint1/position, joint1/effort

이름 규칙은 <이름>/<인터페이스 타입>이다. 표준 타입은 position·velocity·effort·acceleration이며, 커스텀 이름도 가능하다(joint1/temperature, gripper/vacuum).

점유(claim)가 핵심 개념이다:

예 아니오 컨트롤러 활성화 요청 필요한 command_interface 선언 이미 다른 컨트롤러가 점유 중인가? ❌ 활성화 실패 충돌 보고 ✅ 점유 후 활성화
인터페이스점유 규칙
command_interface배타적 — 한 번에 하나의 컨트롤러만
state_interface공유 — 여러 컨트롤러가 동시에 읽기

왜 명령이 배타적인가: 두 컨트롤러가 같은 관절에 동시에 명령을 쓰면 결과가 예측 불가능하다. 프레임워크가 이를 구조적으로 금지한다.

이것이 제어 모드 전환을 안전하게 만든다:

# 위치 컨트롤러를 끄고 속도 컨트롤러를 켠다 — 원자적으로
ros2 control switch_controllers \
  --deactivate position_controller \
  --activate velocity_controller

동시에 일어나므로 중간에 명령이 없는 순간이 없다. 이것을 직접 구현하려면 상당한 노력이 든다.

함정: 같은 관절에 대해 서로 다른 타입의 명령 인터페이스를 쓰는 컨트롤러들도 충돌할 수 있다. joint1/position과 joint1/velocity를 각각 점유하는 두 컨트롤러가 동시에 활성화되면, 하드웨어가 그것을 어떻게 해석할지 모호하다. 하드웨어 컴포넌트가 허용 조합을 선언하는 방법이 있다(확인 필요: prepare_command_mode_switch 등 API 세부).

6.1.6 왜 제어 루프가 executor 밖에 있는가

이것이 ros2_control을 이해하는 핵심이다.

제어 스레드 (실시간) executor 스레드 (비실시간) 락프리 버퍼 락프리 버퍼 read → update → write 고정 주기 서비스 · 파라미터 · 진단 컨트롤러의 토픽 구독 · 발행
축제어 스레드executor 스레드
주기고정(예: 1 kHz)이벤트 구동
실시간성필요불필요
허용 작업계산·인터페이스 접근만ROS 통신 자유
금지할당·락·로깅·ROS 호출(6.6)—

2.5.8에서 예고한 "ROS 2 밖 스레드" 패턴이 바로 이것이다. 제어 루프를 executor가 아닌 자체 스레드에서 실행하고, ROS와는 락프리 자료구조로만 통신한다.

결과: 컨트롤러 코드가 두 부분으로 나뉜다.

// executor 스레드 — 토픽 구독 콜백 (비실시간)
void on_command_received(const Msg::SharedPtr msg) {
  command_buffer_.writeFromNonRT(*msg);      // 락프리 버퍼에 씀
}

// 제어 스레드 — update() (실시간)
controller_interface::return_type update(const rclcpp::Time &, const rclcpp::Duration &) {
  auto cmd = command_buffer_.readFromRT();   // 락프리 버퍼에서 읽음
  // 할당·락·로깅 없이 계산
  command_interfaces_[0].set_value(compute(*cmd));
  return controller_interface::return_type::OK;
}

realtime_tools가 이 통신을 담당한다(6.4·6.6).

견해: 이 구조가 ros2_control을 쓰는 가장 큰 이유일 수 있다. 실시간 제어 루프와 ROS 그래프를 안전하게 잇는 것은 직접 구현하기 까다롭고(2.9의 경쟁 조건·12.3의 무할당), 프레임워크가 그 패턴을 강제해 준다. 제어 알고리즘만 update()에 넣으면 나머지는 구조가 처리한다.

정직: "ros2_control을 쓰면 실시간이 보장된다"는 오해다. 프레임워크는 구조를 제공할 뿐, 실시간성은 RT 커널·우선순위·CPU 격리·무할당 코드가 함께 갖춰져야 얻어진다(6.6·12.2·12.3). 그리고 update() 안에서 할당하거나 로깅하면 그 순간 깨진다.

6.1.7 URDF 통합

<ros2_control> 태그가 하드웨어와 인터페이스를 선언한다(5.3.7).

<ros2_control name="MyRobotSystem" type="system">

  <hardware>
    <plugin>my_robot_driver/MyRobotHardware</plugin>
    <param name="device">/dev/ttyUSB0</param>
    <param name="baud_rate">1000000</param>
    <param name="timeout_ms">50</param>
  </hardware>

  <joint name="shoulder_pan">
    <command_interface name="position">
      <param name="min">-3.14</param>
      <param name="max">3.14</param>
    </command_interface>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
    <state_interface name="effort"/>
  </joint>

  <joint name="shoulder_lift">
    <command_interface name="position"/>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
  </joint>

  <sensor name="ft_sensor">
    <state_interface name="force.x"/>
    <state_interface name="force.y"/>
    <state_interface name="force.z"/>
    <state_interface name="torque.x"/>
    <state_interface name="torque.y"/>
    <state_interface name="torque.z"/>
  </sensor>

</ros2_control>
요소의미
typesystem·actuator·sensor(6.2.2)
<plugin>하드웨어 컴포넌트 클래스(pluginlib — 4.2.6)
<param> (hardware)컴포넌트에 전달되는 설정
<joint>관절과 그 인터페이스
<sensor>센서 상태 인터페이스

시뮬·실기 분기는 xacro 조건부로(5.6.6):

<hardware>
  <xacro:if value="${use_sim}">
    <plugin>gz_ros2_control/GazeboSimSystem</plugin>
  </xacro:if>
  <xacro:unless value="${use_sim}">
    <plugin>my_robot_driver/MyRobotHardware</plugin>
    <param name="device">/dev/ttyUSB0</param>
  </xacro:unless>
</hardware>

관절 인터페이스 선언은 공통이므로 컨트롤러 설정이 그대로 재사용된다. 이것이 5.6.6에서 말한 "단일 xacro 분기"의 실질적 이득이다.

함정: <ros2_control>의 관절 이름은 URDF <joint> 이름과 일치해야 한다. 불일치하면 컨트롤러가 관절을 못 찾는다. 5.7.9의 단일 출처 점검이 이것도 검출한다.

6.1.8 컨트롤러의 라이프사이클

컨트롤러는 라이프사이클 노드다(1.12).

load_controller configure activate (인터페이스 점유) unload deactivate (점유 해제) cleanup Unconfigured Inactive Active Finalized
상태컨트롤러가
Unconfigured로드만 됨
Inactive설정 완료, 인터페이스 미점유, update() 미호출
Active인터페이스 점유, update()가 매 주기 호출

1.12에서 본 Inactive의 가치가 여기서 드러난다 — 설정을 마치고 준비된 상태로 대기하다가, 필요할 때 즉시 활성화한다. 제어 모드 전환이 빠른 이유다.

# 상태 확인
ros2 control list_controllers

# 로드 + 설정 + 활성화 (spawner가 한 번에)
ros2 run controller_manager spawner joint_trajectory_controller

# 개별 조작
ros2 control load_controller my_controller
ros2 control set_controller_state my_controller inactive
ros2 control set_controller_state my_controller active
# Jazzy 의 서브커맨드 전수는 6.3.11 (89차 실행 확인) — 위 이름들은 모두 맞다

6.1.9 전체 그림 — 데이터 흐름

액션 · 토픽 보간 후 명령 시리얼 · CAN 엔코더 /joint_states /tf Nav2 / MoveIt 2 / 사용자 joint_trajectory_ controller command_interface joint1/position 하드웨어 컴포넌트 write() 모터 드라이버 하드웨어 컴포넌트 read () state_interface joint1/position joint_state_ broadcaster robot_state_publisher 5.5 tf 소비자

joint_state_broadcaster가 상태 인터페이스를 /joint_states로 내보내는 컨트롤러라는 점이 중요하다(6.5). 즉 5.5에서 본 관절 상태의 출처가 여기다. 브로드캐스터도 컨트롤러이며, 명령 인터페이스를 점유하지 않고 상태만 읽는다.

6.1.10 채택 판단 — 쓸 것인가

ros2_control에도 비용이 든다. 학습 곡선이 가파르고, 디버깅이 어렵고, 단순한 경우에는 과하다.

값을 하는 경우:

상황이유
관절이 여럿인 로봇궤적 제어·상태 관리를 프레임워크가
MoveIt 2 사용joint_trajectory_controller가 표준 인터페이스(9.1)
Nav2 + 차동 구동diff_drive_controller가 오도메트리까지(6.5)
실시간 제어 필요루프 구조를 프레임워크가 제공
제어 모드 전환점유 모델이 안전하게
시뮬·실기 동일 코드하드웨어 플러그인만 교체(10.4)
표준 컨트롤러로 충분작성할 코드가 적다

과한 경우:

상황이유
단순 센서 발행만컨트롤러 개념이 불필요
관절 1~2개, 고정 제어 모드직접 노드가 단순
하드웨어가 자체 궤적 제어를 함상위 명령만 전달하면 됨
팀이 프레임워크를 감당 못 함잘못 쓰면 더 불리하다
제어 주기가 느림(10 Hz 이하)실시간 구조가 불필요

견해: "관절이 여럿이고 궤적 제어가 필요하면 ros2_control, 그 외에는 검토" 를 기준으로 삼는다. 특히 MoveIt 2를 쓸 계획이면 사실상 필수다 — MoveIt 2는 FollowJointTrajectory 액션을 기대하고, 그것을 직접 구현하는 것보다 joint_trajectory_controller를 쓰는 편이 훨씬 낫다.

견해: "나중에 도입"은 비용이 크다. 직접 드라이버로 시작해 나중에 옮기려면 하드웨어 통신 코드를 재구조화해야 한다. 처음부터 하드웨어 컴포넌트로 작성해 두면(6.2) 나중에 컨트롤러를 연결하는 것은 쉽다. 이것은 1.13.3의 "새 노드는 컴포넌트로 작성하라"와 같은 논리다.

중간 지대: 하드웨어 컴포넌트만 작성하고 표준 컨트롤러(forward_command_controller — 6.5)만 쓰는 구성도 유효하다. 프레임워크의 구조는 얻으면서 컨트롤러 작성 부담은 없다.

6.1.11 함정 카탈로그

#함정증상대처
1"ros2_control = 실시간 보장"으로 오해지터 여전RT 커널·우선순위 필요(6.6)
2update_rate를 통신 속도보다 빠르게루프 밀림통신 시간 측정
3<ros2_control> 관절명과 URDF 불일치컨트롤러가 못 찾음이름 대조(5.7.9)
4같은 관절에 두 컨트롤러 활성화 시도활성화 실패점유 모델 이해
5update()에서 ROS 호출·할당실시간 붕괴락프리 버퍼(6.6)
6시뮬·실기 관절 인터페이스 다름설정 재사용 불가단일 xacro(5.6.6)
7단순한 경우에 도입불필요한 복잡도채택 판단(6.1.10)
8나중에 도입 계획재구조화 비용처음부터 컴포넌트로
9브로드캐스터를 컨트롤러가 아니라고 생각구성 혼란둘 다 컨트롤러
10라이프사이클 무시활성화 안 됨spawner·상태 확인

6.1.12 체크포인트

  • 직접 드라이버를 구현할 때 쌓이는 문제 여섯 가지를 말할 수 있는가. ros2_control이 긋는 경계는 무엇인가.
  • 네 구성 요소와 각각의 역할을 말할 수 있는가. controller_manager의 이중성이란?
  • read() → update() → write() 순서가 고정된 것이 왜 중요한가. executor 기반 구현과의 차이는?
  • state/command 인터페이스의 이름 규칙과 점유 규칙을 말할 수 있는가. 명령이 배타적인 이유는?
  • 제어 모드 전환이 안전한 이유를 점유 모델로 설명할 수 있는가.
  • 제어 루프가 executor 밖에 있는 이유와, 그것이 컨트롤러 코드를 어떻게 나누는지 설명할 수 있는가.
  • "ros2_control을 쓰면 실시간이 보장된다"가 왜 오해인가.
  • <ros2_control> 태그의 주요 요소와, 시뮬·실기 분기 방법을 말할 수 있는가.
  • 컨트롤러의 Inactive 상태가 주는 이점은 무엇인가.
  • /joint_states의 출처가 무엇인지 이 아키텍처에서 설명할 수 있는가.
  • ros2_control이 값을 하는 경우와 과한 경우를 각각 네 가지 들 수 있는가. "나중에 도입"이 비용이 큰 이유는?

다음 장 6.2는 이 구조의 가장 아래 — 하드웨어 컴포넌트 작성이다. 세 가지 타입, 구현해야 할 함수들, 실시간 제약, 그리고 하드웨어 없이 개발하는 법.