제6부 하드웨어 인터페이스
6.2 하드웨어 컴포넌트 작성
6.2.1 이 장에서 배우는 것 / 선수 지식
하드웨어 컴포넌트는 ros2_control에서 로봇마다 반드시 새로 쓰게 되는 유일한 부분이다. 컨트롤러는 표준을 재사용하고, 매니저는 프레임워크가 제공하지만, "이 모터 드라이버와 어떻게 통신하는가"는 아무도 대신 써 주지 않는다.
그래서 이 층의 코드 품질이 시스템 전체를 좌우한다. read()가 느리면 제어 주기가 지연되고(6.1.4), 할당을 하면 지터가 생기고(2.6), 오류 처리가 없으면 통신 끊김에 로봇이 폭주한다.
배우는 것:
- 세 가지 타입 — System·Actuator·Sensor의 구분과 선택.
- 구현할 함수들 — 초기화·인터페이스 내보내기·읽기·쓰기.
- 라이프사이클 통합 — 자원을 언제 확보하고 언제 놓는가.
- 실시간 제약 —
read()·write()에서 금지되는 것. - 오류 처리 — 반환 코드와 그 결과.
- 모의 하드웨어 — 장치 없이 개발·테스트하기.
- 파라미터 — URDF에서 설정 받기.
- 느린 하드웨어 — 컴포넌트별 주기(
rw_rate)와 비동기 실행(is_async), 그리고 그 대가(6.2.13).
선수 지식: 6.1(아키텍처·인터페이스 모델), 1.12(라이프사이클), 2.6(메모리·할당), 4.2.6(pluginlib), 5.3.7(URDF 확장).
정직: 하드웨어 인터페이스 API는 배포판마다 변경되어 왔다 — 반환 타입 이름, 라이프사이클 통합 방식,
export_*함수 시그니처 등. 2026-08-16에 이 장의 골격을 Jazzy 컨테이너에서 컴파일해 확인했다(hardware_interface4.45.2): 아래 코드는 오류·경고 없이 컴파일되지만,export_*계열은 이미 deprecated이며 대체 API가 있다. 반드시 그 사실과 함께 읽어야 한다 — 6.2.5.1에 실측을 정리했다. 정확한 시그니처는 언제나 설치된hardware_interface헤더가 정본이다(목차 3).
6.2.2 세 가지 타입
| 타입 | command_interface | state_interface | 전형적 대상 |
|---|---|---|---|
system | ✅ | ✅ | 로봇 전체(다관절 팔·모바일 베이스) |
actuator | ✅ | ✅ | 단일 모터·조인트 모듈 |
sensor | ❌ | ✅ | IMU·힘토크 센서·엔코더 |
선택 기준:
| 상황 | 타입 |
|---|---|
| 하나의 통신 채널로 여러 관절을 다룬다 | system |
| 관절마다 독립된 장치·통신 | actuator 여러 개 |
| 명령이 없다 | sensor |
견해: 대부분의 로봇은
system하나가 맞다. 시리얼·CAN 버스 하나로 모든 관절과 통신한다면, 관절마다actuator를 만들면 통신이 관절 수만큼 분할돼 비효율적이다. 통신 채널 단위로 컴포넌트를 나누는 것이 자연스럽다.
여러 컴포넌트를 함께 쓸 수 있다:
<ros2_control name="ArmSystem" type="system">
<hardware><plugin>my_driver/ArmHardware</plugin></hardware>
<joint name="joint1">...</joint>
</ros2_control>
<ros2_control name="GripperActuator" type="actuator">
<hardware><plugin>my_driver/GripperHardware</plugin></hardware>
<joint name="gripper">...</joint>
</ros2_control>
<ros2_control name="FTSensor" type="sensor">
<hardware><plugin>my_driver/FTSensorHardware</plugin></hardware>
<sensor name="ft"><state_interface name="force.x"/>...</sensor>
</ros2_control>
Resource Manager가 이들을 하나의 인터페이스 풀로 합친다(6.3). 컨트롤러는 어느 컴포넌트에서 온 인터페이스인지 신경 쓰지 않는다.
6.2.3 구현 골격
// my_robot_hardware.hpp
#include "hardware_interface/system_interface.hpp"
#include "hardware_interface/types/hardware_interface_return_values.hpp"
#include "rclcpp_lifecycle/state.hpp"
namespace my_robot_driver
{
class MyRobotHardware : public hardware_interface::SystemInterface
{
public:
// ① URDF 정보를 받아 초기화
hardware_interface::CallbackReturn on_init(
const hardware_interface::HardwareInfo & info) override;
// ② 인터페이스 내보내기
std::vector<hardware_interface::StateInterface> export_state_interfaces() override;
std::vector<hardware_interface::CommandInterface> export_command_interfaces() override;
// ③ 라이프사이클 (1.12)
hardware_interface::CallbackReturn on_configure(
const rclcpp_lifecycle::State & previous_state) override;
hardware_interface::CallbackReturn on_activate(
const rclcpp_lifecycle::State & previous_state) override;
hardware_interface::CallbackReturn on_deactivate(
const rclcpp_lifecycle::State & previous_state) override;
hardware_interface::CallbackReturn on_cleanup(
const rclcpp_lifecycle::State & previous_state) override;
hardware_interface::CallbackReturn on_error(
const rclcpp_lifecycle::State & previous_state) override;
// ④ 제어 루프에서 매 주기 호출 (실시간)
hardware_interface::return_type read(
const rclcpp::Time & time, const rclcpp::Duration & period) override;
hardware_interface::return_type write(
const rclcpp::Time & time, const rclcpp::Duration & period) override;
private:
// 인터페이스가 가리킬 저장소 — 주소가 안정적이어야 한다
std::vector<double> hw_positions_;
std::vector<double> hw_velocities_;
std::vector<double> hw_commands_;
// 하드웨어 통신
std::unique_ptr<SerialPort> port_;
std::string device_;
int baud_rate_{};
std::chrono::milliseconds timeout_{};
// 실시간 경로용 사전 할당 버퍼 (2.6)
std::array<uint8_t, 256> tx_buffer_{};
std::array<uint8_t, 256> rx_buffer_{};
};
} // namespace my_robot_driver
실행 확인(2026-08-16): 위 골격을 그대로 컴파일했다 —
hardware_interface4.45.2에서 오류·경고 0으로 통과한다.hardware_interface::CallbackReturn·return_type·HardwareInfo·rclcpp_lifecycle::State의 이름과 시그니처가 전부 맞다. 다만 두 가지를 함께 알아야 한다 — ①export_*계열은 deprecated이고 대체 API가 있다(6.2.5.1), ② 이 중 반드시 구현해야 하는 것은read()와write()둘뿐이며 나머지는 기본 구현이 있다. 위 골격은 "전부 재정의한 최대 형태"다.
6.2.4 on_init — URDF 정보 받기
hardware_interface::CallbackReturn MyRobotHardware::on_init(
const hardware_interface::HardwareInfo & info)
{
if (SystemInterface::on_init(info) != hardware_interface::CallbackReturn::SUCCESS) {
return hardware_interface::CallbackReturn::ERROR;
}
// URDF의 <param> 읽기 (6.1.7)
auto it = info_.hardware_parameters.find("device");
if (it == info_.hardware_parameters.end()) {
RCLCPP_ERROR(get_logger(), "'device' 파라미터가 없습니다");
return hardware_interface::CallbackReturn::ERROR;
}
device_ = it->second;
baud_rate_ = std::stoi(info_.hardware_parameters.at("baud_rate"));
timeout_ = std::chrono::milliseconds(
std::stoi(info_.hardware_parameters.count("timeout_ms")
? info_.hardware_parameters.at("timeout_ms") : "50"));
// 저장소를 여기서 지정한다 — 이후 크기가 변하면 안 된다
const size_t n = info_.joints.size();
hw_positions_.assign(n, std::numeric_limits<double>::quiet_NaN());
hw_velocities_.assign(n, 0.0);
hw_commands_.assign(n, std::numeric_limits<double>::quiet_NaN());
// 인터페이스 구성 검증 — 여기서 걸러야 나중에 조용히 실패하지 않는다
for (const auto & joint : info_.joints) {
if (joint.command_interfaces.size() != 1 ||
joint.command_interfaces[0].name != hardware_interface::HW_IF_POSITION) {
RCLCPP_ERROR(get_logger(),
"관절 '%s': position 명령 인터페이스 하나만 지원합니다", joint.name.c_str());
return hardware_interface::CallbackReturn::ERROR;
}
if (joint.state_interfaces.size() != 2) {
RCLCPP_ERROR(get_logger(),
"관절 '%s': position·velocity 상태 인터페이스가 필요합니다", joint.name.c_str());
return hardware_interface::CallbackReturn::ERROR;
}
}
return hardware_interface::CallbackReturn::SUCCESS;
}
on_init에서 하는 일:
| 작업 | 이유 |
|---|---|
| 파라미터 읽기 | URDF의 <param>이 여기로 온다 |
| 저장소 할당 | 이후 크기가 변하면 인터페이스 포인터가 무효화된다 |
| 인터페이스 구성 검증 | 지원하지 않는 조합을 조기에 거부 |
| 하드웨어 접근 | ❌ 하지 않는다(1.12.3) |
함정:
hw_positions_같은 벡터를on_init이후에 resize하면 인터페이스가 무효 포인터를 갖게 된다.export_state_interfaces()가 벡터 원소의 주소를 넘기기 때문이다. 크기는on_init에서 확정하고 이후 변경하지 않는다.
견해: 인터페이스 구성 검증을 철저히 하라. URDF에
effort명령 인터페이스를 적었는데 하드웨어가 위치만 지원한다면, 여기서 명확한 오류를 내는 것이 나중에 "명령이 적용되지 않는다"를 디버깅하는 것보다 훨씬 낫다. 오류 메시지에 관절 이름과 무엇을 기대하는지 적으라(1.11.8).
NaN 초기화가 관용구다. 아직 읽지 않은 상태·아직 받지 않은 명령을 NaN으로 두면, 실수로 0을 명령하는 사고를 막을 수 있다(6.2.7).
6.2.5 인터페이스 내보내기
std::vector<hardware_interface::StateInterface>
MyRobotHardware::export_state_interfaces()
{
std::vector<hardware_interface::StateInterface> interfaces;
for (size_t i = 0; i < info_.joints.size(); ++i) {
interfaces.emplace_back(hardware_interface::StateInterface(
info_.joints[i].name, hardware_interface::HW_IF_POSITION, &hw_positions_[i]));
interfaces.emplace_back(hardware_interface::StateInterface(
info_.joints[i].name, hardware_interface::HW_IF_VELOCITY, &hw_velocities_[i]));
}
return interfaces;
}
std::vector<hardware_interface::CommandInterface>
MyRobotHardware::export_command_interfaces()
{
std::vector<hardware_interface::CommandInterface> interfaces;
for (size_t i = 0; i < info_.joints.size(); ++i) {
interfaces.emplace_back(hardware_interface::CommandInterface(
info_.joints[i].name, hardware_interface::HW_IF_POSITION, &hw_commands_[i]));
}
return interfaces;
}
인터페이스는 double* 포인터를 넘긴다. 이것이 ros2_control이 빠른 이유다 — 컨트롤러와 하드웨어가 같은 메모리를 직접 읽고 쓴다. 복사도 직렬화도 없다.
함정: 포인터 안정성이 전제다. 벡터 재할당·객체 이동이 일어나면 인터페이스가 무효 메모리를 가리킨다.
std::vector를 쓰되 크기를 고정하거나,std::array·고정 크기 컨테이너를 쓴다.
6.2.5.1 실측 — 이 API는 이미 교체되었다 (Jazzy 패치 기준)
실행 확인(2026-08-16). 위 정직 고지에서 "내보내기 방식이 개선되었을 수 있다"고 예고했던 것이 실제로 그렇다. Jazzy 컨테이너에 설치되는 현재 패치(
ros-jazzy-hardware-interface4.45.2, 2026-06 빌드)의 헤더를 원문 확인하고 컴파일로 검증했다.
| 확인한 것 | 결과 |
|---|---|
export_state_interfaces()·export_command_interfaces() | [[deprecated]] — 헤더 메시지: "Replaced by on_export_state_interfaces() … Exporting is handled by the Framework." |
| 대체 API | on_export_state_interfaces() → std::vector<StateInterface::ConstSharedPtr>, on_export_command_interfaces() → std::vector<CommandInterface::SharedPtr> — 원시 포인터가 아니라 공유 포인터 |
SystemInterface의 위치 | 이제 HardwareComponentInterface를 상속한다. 위 함수들은 그 기반 클래스에 있다 |
| 반드시 구현해야 하는 것 | read()와 write() 둘뿐이다(순수가상). on_init·export_*·라이프사이클 콜백은 전부 기본 구현이 있어 선택이다 — read/write만 재정의한 클래스가 컴파일·인스턴스화된다(실측) |
| 위 절의 코드(구 API) | 여전히 컴파일된다. -Wall -Wextra -Wdeprecated-declarations로도 경고가 출력되지 않는다 — 컴파일러는 deprecated 가상 함수를 재정의하는 것에는 경고하지 않는다 |
실무적 함의 셋.
- 경고가 없으니 낡은 줄 모른다. 이것이 이 변화에서 가장 위험한 부분이다. 구 API로 쓴 하드웨어 컴포넌트는 조용히 잘 동작하다가, 그 함수가 실제로 제거되는 날 컴파일 실패로 한꺼번에 드러난다. 새로 쓰는 컴포넌트는
on_export_*를 쓰는 편이 안전하다. - "내보내기는 프레임워크가 한다." URDF의
<ros2_control>블록에 선언한 인터페이스는 프레임워크가 자동으로 만든다.on_export_*는 URDF에 없는 커스텀 인터페이스를 추가할 때 재정의하는 훅이다 — 즉 대부분의 평범한 로봇은 내보내기 코드를 전혀 안 써도 된다. - 공유 포인터로 바뀐 이유가 위 함정과 직결된다. 원시
double*를 넘기던 방식의 포인터 안정성 문제(바로 위 함정)를 프레임워크가 소유권을 쥐어 완화한 것이다.
정직: 이 결과는 "Jazzy"라는 이름 하나로 뭉뚱그릴 수 없다. 확인한 것은 2026-06 시점의 Jazzy 패치 릴리스이며, Jazzy 최초 릴리스(2024-05) 시점에는 구 API만 있었다. 같은 배포판 안에서도 패치에 따라 다르다는 뜻이고, 이것이 BK-03이 말한 "버전 의존 정보는 시점 의존"의 실제 사례다. 자신의 환경에서
hardware_interface패키지 버전과 헤더를 직접 확인하라 — 이 책이 아니라 설치된 헤더가 정본이다.
6.2.6 라이프사이클 — 자원 관리
1.12.3의 경계 규칙이 그대로 적용된다.
hardware_interface::CallbackReturn MyRobotHardware::on_configure(
const rclcpp_lifecycle::State &)
{
// 실패할 수 있는 일은 전부 여기서 (1.12.3)
port_ = std::make_unique<SerialPort>();
if (!port_->open(device_, baud_rate_)) {
RCLCPP_ERROR(get_logger(), "포트 열기 실패: %s", device_.c_str());
return hardware_interface::CallbackReturn::ERROR;
}
// 장치 확인 — 정말 우리 하드웨어가 맞나
if (!verify_device_identity()) {
RCLCPP_ERROR(get_logger(), "장치 식별 실패 — 다른 장치일 수 있습니다");
port_->close();
return hardware_interface::CallbackReturn::ERROR;
}
// 상태 초기화 (아직 구동은 안 함)
std::fill(hw_positions_.begin(), hw_positions_.end(), 0.0);
std::fill(hw_velocities_.begin(), hw_velocities_.end(), 0.0);
RCLCPP_INFO(get_logger(), "구성 완료: %s @ %d bps", device_.c_str(), baud_rate_);
return hardware_interface::CallbackReturn::SUCCESS;
}
hardware_interface::CallbackReturn MyRobotHardware::on_activate(
const rclcpp_lifecycle::State &)
{
// 현재 위치를 읽어 명령 초기값으로 — ★ 매우 중요
if (!read_current_positions(hw_positions_)) {
RCLCPP_ERROR(get_logger(), "초기 위치 읽기 실패");
return hardware_interface::CallbackReturn::ERROR;
}
hw_commands_ = hw_positions_; // 현재 위치를 명령으로 → 튀지 않는다
if (!enable_motors()) {
return hardware_interface::CallbackReturn::ERROR;
}
RCLCPP_INFO(get_logger(), "활성화 — 모터 인가");
return hardware_interface::CallbackReturn::SUCCESS;
}
hardware_interface::CallbackReturn MyRobotHardware::on_deactivate(
const rclcpp_lifecycle::State &)
{
disable_motors(); // 안전 정지
RCLCPP_INFO(get_logger(), "비활성화 — 모터 해제");
return hardware_interface::CallbackReturn::SUCCESS;
}
hardware_interface::CallbackReturn MyRobotHardware::on_cleanup(
const rclcpp_lifecycle::State &)
{
if (port_) { port_->close(); port_.reset(); }
return hardware_interface::CallbackReturn::SUCCESS;
}
hardware_interface::CallbackReturn MyRobotHardware::on_error(
const rclcpp_lifecycle::State &)
{
RCLCPP_ERROR(get_logger(), "오류 처리 — 안전 정지");
disable_motors();
if (port_) { port_->close(); port_.reset(); }
return hardware_interface::CallbackReturn::SUCCESS;
}
on_activate에서 hw_commands_ = hw_positions_가 중요한 이유:
함정: 이것을 빠뜨리면 활성화 순간 로봇이 갑자기 움직인다. 관절이 1.5 rad에 있는데 명령이 0이면 즉시 0을 향해 달린다. 매니퓰레이터에서는 물리적 사고가 될 수 있다. 활성화 시 "현재 위치를 명령으로" 설정하는 것은 안전 요구사항이다.
견해:
on_configure에서 장치 신원 확인을 넣으라./dev/ttyUSB0은 USB 장치 순서에 따라 다른 장치를 가리킬 수 있다. 펌웨어 버전·모델 ID를 읽어 확인하면, 잘못된 장치에 모터 명령을 보내는 사고를 막는다. 더 근본적으로는 udev 규칙으로 안정적인 심볼릭 링크를 만드는 것이 좋다(6.7).
6.2.7 read()와 write() — 실시간 경로
여기가 제어 루프의 핵심이며, 가장 엄격한 제약이 걸린다.
hardware_interface::return_type MyRobotHardware::read(
const rclcpp::Time & /*time*/, const rclcpp::Duration & /*period*/)
{
// 사전 할당된 버퍼 사용 (2.6.3)
const size_t n = build_read_request(tx_buffer_.data(), tx_buffer_.size());
if (!port_->write_exact(tx_buffer_.data(), n, timeout_)) {
++comm_errors_; // 원자적 카운터 (1.11.7)
return hardware_interface::return_type::ERROR;
}
const size_t got = port_->read_exact(rx_buffer_.data(), expected_len_, timeout_);
if (got != expected_len_) {
++comm_errors_;
return hardware_interface::return_type::ERROR;
}
if (!verify_checksum(rx_buffer_.data(), got)) {
++checksum_errors_;
return hardware_interface::return_type::ERROR;
}
// 파싱 — 할당 없이
parse_positions(rx_buffer_.data(), hw_positions_.data(), hw_positions_.size());
parse_velocities(rx_buffer_.data(), hw_velocities_.data(), hw_velocities_.size());
return hardware_interface::return_type::OK;
}
hardware_interface::return_type MyRobotHardware::write(
const rclcpp::Time & /*time*/, const rclcpp::Duration & /*period*/)
{
// 아직 명령이 없으면(NaN) 쓰지 않는다 — ★ 안전
for (const auto & c : hw_commands_) {
if (std::isnan(c)) {
return hardware_interface::return_type::OK; // 조용히 건너뜀
}
}
const size_t n = build_write_command(
hw_commands_.data(), hw_commands_.size(), tx_buffer_.data(), tx_buffer_.size());
if (!port_->write_exact(tx_buffer_.data(), n, timeout_)) {
++comm_errors_;
return hardware_interface::return_type::ERROR;
}
return hardware_interface::return_type::OK;
}
금지 목록 — read()·write()에서 하면 안 되는 것:
| 금지 | 이유 | 관련 장 |
|---|---|---|
동적 할당(new·vector::push_back·std::string) | 지터·페이지 폴트 | 2.6 |
로깅(RCLCPP_INFO 등) | 할당·락·I/O | 1.11.7 |
| 뮤텍스 대기 | 우선순위 역전 | 2.9.5 |
| ROS 통신(발행·서비스 호출) | 블로킹·비결정적 | 6.1.6 |
| 블로킹 I/O(무한 대기) | 주기 이탈 | 6.6 |
| 예외 던지기 | 비용·비결정성 | — |
| 파일 접근 | I/O 지연 | — |
대신 쓰는 것:
| 대신 | 방법 |
|---|---|
| 로깅 | 원자적 카운터 + 별도 진단 타이머(1.11.7·6.9) |
| 통신 타임아웃 | 논블로킹 I/O 또는 짧은 타임아웃 |
| 버퍼 | 사전 할당(std::array·고정 크기 벡터) |
| ROS 발행 | realtime_tools::RealtimePublisher(6.6) |
함정:
std::string조작이 숨은 할당이다. 오류 메시지를 만들거나 프로토콜 문자열을 조립하면 힙을 쓴다. 실시간 경로에서는 고정 버퍼와 정수 코드를 쓴다.
반환 코드의 의미:
| 반환 | 결과 |
|---|---|
OK | 정상 |
ERROR | 컨트롤러 매니저가 오류로 처리 — 컴포넌트가 비활성화될 수 있다(확인 필요: 정확한 동작) |
DEACTIVATE | 비활성화 요청(확인 필요: 지원 여부) |
정직:
ERROR반환 시 프레임워크의 정확한 동작은 배포판에 따라 다를 수 있다(확인 필요). 통신이 한 번 실패했다고 즉시 비활성화되면 일시적 노이즈에도 로봇이 멈추고, 반대로 무시되면 통신이 끊겼는데 계속 실행되는 위험이 있다. 재시도 정책을 컴포넌트 안에서 관리하고, 연속 실패 임계를 넘을 때만ERROR를 반환하는 편이 실용적이다.
// 연속 실패 임계 관리
if (!comm_ok) {
if (++consecutive_failures_ >= MAX_CONSECUTIVE_FAILURES) {
return hardware_interface::return_type::ERROR; // 실제 문제
}
return hardware_interface::return_type::OK; // 일시적 — 이전 값 유지
}
consecutive_failures_ = 0;
함정: 통신 실패 시 이전 상태 값을 유지하면 "정지한 것처럼 보이는" 위험이 있다. 상위 제어는 로봇이 멈춘 줄 알고 더 큰 명령을 낼 수 있다. 통신 상태를 진단으로 반드시 내보내라(6.9).
6.2.8 모의 하드웨어 — 장치 없이 개발하기
실제 하드웨어 없이 전체 스택을 실행할 수 있다.
<hardware>
<plugin>mock_components/GenericSystem</plugin>
<param name="mock_sensor_commands">false</param>
<param name="state_following_offset">0.0</param>
</hardware>
mock_components/GenericSystem은 명령을 그대로 상태로 반영한다 — 완벽한 로봇을 흉내 낸다.
| 용도 | 설명 |
|---|---|
| 컨트롤러 개발 | 하드웨어 없이 컨트롤러 로직 검증 |
| MoveIt 2 설정 | 계획·실행 파이프라인 시험(9.1) |
| CI 테스트 | 하드웨어 없는 환경(4.10.5) |
| launch 구성 검증 | 배선 확인 |
정직: 플러그인 이름과 파라미터는 배포판에 따라 다르다. Jazzy에는
mock_components만 있다(89차 실행 확인) —hardware_interface패키지가mock_components_plugin_description.xml로mock_components/GenericSystem(클래스mock_components::GenericSystem, 기반hardware_interface::SystemInterface)을 선언하며,fake_components라는 이름은 어디에도 없다. 옛 문서·예제를 그대로 옮기면 플러그인을 못 찾는다.
xacro로 세 가지 모드를 분기한다(5.6.6):
<hardware>
<xacro:if value="${use_mock}">
<plugin>mock_components/GenericSystem</plugin>
</xacro:if>
<xacro:if value="${use_sim}">
<plugin>gz_ros2_control/GazeboSimSystem</plugin>
</xacro:if>
<xacro:unless value="${use_mock or use_sim}">
<plugin>my_robot_driver/MyRobotHardware</plugin>
<param name="device">/dev/robot_arm</param>
</xacro:unless>
</hardware>
| 모드 | 하드웨어 | 물리 | 용도 |
|---|---|---|---|
| mock | 없음 | 없음(명령=상태) | 로직·CI |
| sim | 없음 | Gazebo | 동역학 검증(10.4) |
| real | 있음 | 실제 | 최종 |
견해: 세 모드를 처음부터 갖춰 두면 개발 속도가 크게 달라진다. 하드웨어를 기다리는 동안 mock으로 컨트롤러·MoveIt·launch를 완성하고, 시뮬로 동역학을 확인한 뒤, 실기에서는 통신만 검증하면 된다. 자체 모의 컴포넌트를 만드는 것도 고려할 만하다 — 통신 지연·노이즈·고장을 흉내 내면 오류 처리 경로까지 시험할 수 있다.
6.2.9 등록과 빌드
// my_robot_hardware.cpp 끝
#include "pluginlib/class_list_macros.hpp"
PLUGINLIB_EXPORT_CLASS(
my_robot_driver::MyRobotHardware,
hardware_interface::SystemInterface)
<!-- my_robot_driver.xml (플러그인 기술 파일) -->
<library path="my_robot_driver">
<class name="my_robot_driver/MyRobotHardware"
type="my_robot_driver::MyRobotHardware"
base_class_type="hardware_interface::SystemInterface">
<description>MyRobot 시리얼 하드웨어 인터페이스</description>
</class>
</library>
find_package(hardware_interface REQUIRED)
find_package(pluginlib REQUIRED)
add_library(my_robot_driver SHARED src/my_robot_hardware.cpp)
target_include_directories(my_robot_driver PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include/${PROJECT_NAME}>) # 4.2.4
target_link_libraries(my_robot_driver PUBLIC
hardware_interface::hardware_interface
pluginlib::pluginlib)
pluginlib_export_plugin_description_file(hardware_interface my_robot_driver.xml)
install(TARGETS my_robot_driver
EXPORT export_my_robot_driver
LIBRARY DESTINATION lib)
ament_export_targets(export_my_robot_driver HAS_LIBRARY_TARGET)
ament_package()
함정:
<library path="my_robot_driver">는lib와.so를 뺀 이름이다(4.2.6). 틀리면 런타임에 로드 실패하며, 오류 메시지가 "플러그인을 찾을 수 없다"로 나와 원인이 불명확하다.
# 등록 확인
ros2 control list_hardware_components
# Jazzy 에 있는 이름이 맞다 — 전수는 6.3.11 (89차 실행 확인)
6.2.10 진단 통합
실시간 경로에서 수집한 카운터를 밖으로 내보낸다(1.11.7의 패턴).
// read()/write()에서 — 실시간
std::atomic<uint64_t> comm_errors_{0};
std::atomic<uint64_t> checksum_errors_{0};
std::atomic<uint64_t> read_count_{0};
// 별도 노드·타이머에서 — 비실시간 (6.9)
void publish_diagnostics() {
auto errors = comm_errors_.exchange(0);
auto reads = read_count_.exchange(0);
double error_rate = reads > 0 ? static_cast<double>(errors) / reads : 0.0;
// diagnostic_msgs 로 발행
}
하드웨어 컴포넌트가 보고해야 할 것:
| 항목 | 왜 |
|---|---|
| 통신 오류율 | 케이블·노이즈 문제 조기 탐지 |
| 체크섬 오류 | 전기적 문제 |
read()·write() 소요 시간 | 주기 여유 확인(6.6) |
| 연속 실패 횟수 | 장애 임박 신호 |
| 하드웨어 자체 상태 | 온도·전압·모터 오류 코드 |
| 마지막 성공 시각 | 통신 두절 감지 |
견해: "모터 오류 코드"를 그대로 진단으로 내보내는 것이 현장에서 가장 유용하다. 대부분의 모터 드라이버는 과전류·과열·엔코더 오류 등을 코드로 알려주는데, 이것을 묵살하면 "로봇이 안 움직인다"만 남는다. 코드와 그 의미를 함께 내보내면 현장에서 즉시 원인을 안다(6.9·11.1).
6.2.13 느린 하드웨어 — 자기 주기와 자기 스레드 주기
번호에 대하여: 이 절은 나중에 추가되었다. 규약(CLD-03)에 따라 번호는 끝에 붙이고, 읽는 흐름을 위해 함정 카탈로그 앞에 놓았다.
6.1.4가 함정 하나를 제기하고 답을 주지 않은 채 넘어갔다 — "시리얼 통신이 5 ms 걸리는데 update_rate를 1 kHz로 설정하면 루프가 지연된다." 그 답이 프레임워크 안에 있다.
문제의 구조는 이렇다. 기본적으로 controller_manager는 한 주기 안에서 모든 하드웨어의 read() → 컨트롤러 update() → 모든 하드웨어의 write()를 차례로 호출한다(6.3.4). 그래서 가장 느린 하드웨어 하나가 전체 루프의 상한이 된다. 1 kHz로 실행되는 토크 루프에 5 ms가 걸리는 시리얼 그리퍼가 하나 끼면, 그 그리퍼가 전체를 200 Hz로 끌어내린다.
<ros2_control> 태그의 속성 두 개가 이 문제를 다룬다.
<ros2_control name="SlowGripper" type="system" is_async="true" rw_rate="50">
<hardware>
<plugin>my_driver/GripperHardware</plugin>
</hardware>
…
</ros2_control>
| 속성 | 뜻 | 효과 |
|---|---|---|
rw_rate | 이 컴포넌트만의 read/write 주기(Hz) | 매니저가 1 kHz로 실행돼도 이 하드웨어는 50 Hz로만 접근한다 |
is_async | 이 컴포넌트를 별도 스레드에서 실행한다 | read()/write()가 느려도 매니저 루프를 막지 않는다 |
실행 확인(2026-08-16): 위 속성을 URDF에 넣고
controller_manager를 실제로 실행해ros2 control list_hardware_components출력이read/write rate: 50 Hz·is_async: True로 바뀌는 것을 확인했다. 두 값은<hardware>가 아니라<ros2_control>태그의 속성이며,<hardware is_async="true">로 쓰면 조용히 무시된다(그렇게 써 봤더니is_async: False였다).
대가를 정확히 알아야 한다. hardware_interface의 async_components.hpp 헤더가 직접 경고한다 — 비동기 스레드의 읽기·쓰기는 "현재 컨트롤러 매니저의 update와 동기화되지 않는다"(원문 확인). 즉 컨트롤러가 보는 상태 값이 언제 갱신된 것인지 보장이 없다.
| 축 | 동기(기본) | 비동기(is_async) |
|---|---|---|
| 매니저 루프 차단 | 차단한다 — 느린 하드웨어가 전체를 끈다 | 차단하지 않는다 |
| 데이터 신선도 | 이번 주기의 값임이 보장된다 | 보장 없음 — 직전 값일 수 있다 |
| 결정성(12.5) | 높다 | 낮아진다 — 스레드 스케줄링에 좌우 |
| 적합 | 제어 루프에 직결된 하드웨어 | 주기가 느리고 지연에 둔감한 주변 장치 |
관련 설정도 함께 있다(HardwareAsyncParams, 헤더 원문 확인) — thread_priority(기본 50), scheduling_policy(기본 "synchronized"), cpu_affinity_cores, print_warnings. 참고로 HardwareInfo::thread_priority 필드 자체는 async_params로 대체 예정이라 주석에 표시돼 있다.
견해: 먼저
rw_rate를, 그다음에is_async를 고려하라. 대부분의 "느린 주변 장치" 문제는 주기를 낮추는 것만으로 풀리고, 그쪽은 동기성을 잃지 않는다.is_async는 읽기·쓰기 자체가 블로킹이라 주기를 낮춰도 해결되지 않을 때(예: 응답을 기다려야 하는 프로토콜) 쓰는 수단이며, 그 순간 그 하드웨어의 데이터는 "최신"이 아니게 된다. 안전에 직결된 값(비상 정지 입력·힘 센서)에는 쓰지 말라.
함정: 비동기 컴포넌트가 주기를 놓치면 경고가 출력된다 —
Overrun detected! The async callback missed its desired rate(라이브러리 문자열 확인). 이 로그가 반복되면rw_rate가 하드웨어 실력보다 높다는 뜻이다. 로그를 끄지 말고 주기를 낮추라.
정직: 이 절은 존재·설정·관측까지 확인했고, 실제 지연 개선 효과는 측정하지 않았다
(확인 필요). mock 하드웨어에는 느린 통신이 없기 때문이다. 자신의 하드웨어에서 11.7의 방법으로 재라.
6.2.11 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | on_activate에서 명령 초기화 누락 | 활성화 순간 로봇 급이동 | hw_commands_ = hw_positions_ |
| 2 | on_init 이후 벡터 resize | 인터페이스가 무효 포인터 | 크기 고정 |
| 3 | read()·write()에서 할당 | 지터(2.6) | 사전 할당 버퍼 |
| 4 | read()·write()에서 로깅 | 지터·락 | 카운터 + 진단 타이머 |
| 5 | 무한 대기 블로킹 I/O | 주기 이탈 | 짧은 타임아웃 |
| 6 | 통신 1회 실패에 ERROR | 노이즈에 로봇 정지 | 연속 실패 임계 |
| 7 | 통신 실패를 조용히 무시 | 멈춘 줄 모름 | 진단 필수 |
| 8 | 인터페이스 구성 미검증 | 나중에 조용히 실패 | on_init에서 검증 |
| 9 | on_configure에서 장치 신원 미확인 | 잘못된 장치에 명령 | ID 확인·udev(6.7) |
| 10 | 플러그인 library path 오타 | 로드 실패 | lib·.so 제외 |
| 11 | mock 모드 미구성 | 하드웨어 대기 중 개발 정체 | 세 모드 분기 |
| 12 | 모터 오류 코드 삼킴 | 현장 진단 불가 | 진단으로 노출 |
| 13 | on_deactivate에서 모터 미해제 | 비활성인데 통전 | 안전 정지 |
| 14 | <hardware is_async="true">로 작성 | 조용히 무시됨 | <ros2_control> 태그의 속성이다(6.2.13) |
| 15 | 안전 관련 센서를 is_async로 | 값이 최신이 아님 | 동기 유지(6.2.13) |
1번을 강조한다. 활성화 시 명령 초기화 누락은 이 장에서 가장 위험한 함정이다. 매니퓰레이터에서 물리적 사고로 이어질 수 있으며, 시뮬에서는 잘 드러나지 않다가 실기에서 처음 나타난다.
6.2.12 체크포인트
- 세 가지 컴포넌트 타입과 선택 기준을 말할 수 있는가. 대부분의 로봇에
system하나가 맞는 이유는? on_init에서 하는 일 세 가지와 하지 않는 일을 구분할 수 있는가. 저장소 크기를 여기서 확정해야 하는 이유는?- 인터페이스가
double*를 넘긴다는 사실의 이점과 위험은 무엇인가. on_activate에서hw_commands_ = hw_positions_가 왜 안전 요구사항인가.on_configure에서 장치 신원을 확인하라는 조언의 근거는?read()·write()에서 금지되는 일곱 가지와 각각의 대안을 말할 수 있는가.- 통신 실패 시 즉시
ERROR를 반환하면 안 되는 이유와, 반대로 무시하면 안 되는 이유를 각각 말할 수 있는가. - NaN 초기화 관용구가 막아 주는 사고는 무엇인가.
- mock·sim·real 세 모드를 갖추는 것의 이득은? 자체 모의 컴포넌트를 만들면 추가로 무엇을 시험할 수 있는가.
- 하드웨어 컴포넌트가 진단으로 보고해야 할 것 여섯 가지를 들 수 있는가. 현장에서 가장 유용한 것은?
- 이 장의 골격에서 반드시 구현해야 하는 함수는 무엇이고, 나머지는 왜 선택인가.
export_state_interfaces()가 deprecated인데도 컴파일 경고가 출력되지 않는 이유는? 그것이 왜 위험한가.- 느린 하드웨어 하나가 전체 루프를 끌어내릴 때 쓸 수 있는 두 수단과, 각각이 잃는 것은?
다음 장 6.3은 이 컴포넌트들과 컨트롤러를 관리하는 층 — Resource Manager와 Controller Manager의 내부를 연다. 인터페이스 점유가 어떻게 판정되고, 컨트롤러가 어떤 순서로 실행되며, 제어 스레드가 어떻게 구성되는가.