제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 구성 요소
| 구성 요소 | 역할 | 관련 장 |
|---|---|---|
| 하드웨어 컴포넌트 | 장치와 통신. 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 제어 루프
세 단계의 의미:
| 단계 | 하는 일 | 누가 |
|---|---|---|
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 | 배타적 — 한 번에 하나의 컨트롤러만 |
| 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 스레드 |
|---|---|---|
| 주기 | 고정(예: 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>
| 요소 | 의미 |
|---|---|
type | system·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).
| 상태 | 컨트롤러가 |
|---|---|
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 전체 그림 — 데이터 흐름
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) |
| 2 | update_rate를 통신 속도보다 빠르게 | 루프 밀림 | 통신 시간 측정 |
| 3 | <ros2_control> 관절명과 URDF 불일치 | 컨트롤러가 못 찾음 | 이름 대조(5.7.9) |
| 4 | 같은 관절에 두 컨트롤러 활성화 시도 | 활성화 실패 | 점유 모델 이해 |
| 5 | update()에서 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는 이 구조의 가장 아래 — 하드웨어 컴포넌트 작성이다. 세 가지 타입, 구현해야 할 함수들, 실시간 제약, 그리고 하드웨어 없이 개발하는 법.