OS2의 DDS와 raw DDS는 왜 그냥 붙지 않는가

이 글은 영어판도 있습니다.

ROS2는 DDS 위에서 돌아갑니다. 그런데 같은 CycloneDDS를 링크한 프로그램이 같은 도메인으로 발행해도 ROS2 노드는 그 데이터를 받지 못합니다. 두 방식이 실제로 갈라지는 네 군데와, 한 프로그램에서 둘을 함께 쓸 때 치르는 대가를 정리했습니다.

"ROS2는 DDS를 쓴다"는 말이 빠뜨리는 것

ROS2의 통신 계층은 DDS입니다. 문서에도 그렇게 적혀 있고, rmw_cyclonedds_cpp를 쓰면 실제로 CycloneDDS가 패킷을 나릅니다. 그래서 이런 기대를 하게 됩니다. DDS로 발행하면 ROS2 노드가 받겠지.

받지 않습니다.

같은 CycloneDDS 라이브러리, 같은 도메인, 같은 네트워크인데 ros2 topic list에 아무것도 뜨지 않습니다. ROS2는 DDS를 그대로 쓰지 않습니다. DDS 위에 ROS2만의 규칙을 한 겹 덮어서 씁니다. 그 규칙을 모르고 발행하면 양쪽은 서로를 보지 못합니다.

같은 데이터를 ROS2 노드에도 보내고 ROS2가 깔려 있지 않은 프로그램에도 보내야 하는 상황이 되면, 결국 두 방식을 다 다뤄야 합니다. 한쪽은 ROS2 규칙을 지켜서 발행하는 방식이고, 다른 한쪽은 IDL 파일만 공유하고 DDS를 직접 쓰는 방식입니다. 이 글에서는 뒤쪽을 raw DDS라고 부르겠습니다.

양쪽을 다 만들어보면 차이가 정확히 네 군데로 좁혀집니다. 그 네 군데만 알면 나머지는 똑같은 DDS입니다.

실제로 다른 네 군데

1. 타입 이름이 변형된다

IDL을 이렇게 썼다고 하겠습니다.

module my_msgs {
  struct Location {
    string send_time;
    double lat;
    double lon;
  };
};

raw DDS에서 이 타입의 이름은 my_msgs::Location입니다. IDL에 쓴 그대로입니다. 그런데 ROS2가 같은 메시지를 보낼 때는 이름을 그대로 두지 않습니다. 이렇게 바꿔서 내보냅니다.

my_msgs::msg::dds_::Location_

중간에 msg::dds_가 끼어들고 뒤에 밑줄이 붙습니다. 서비스라면 srv::dds_::Command_Request_Command_Response_가 됩니다. 이름을 기계적으로 바꾸는 이 규칙을 name mangling이라고 합니다.

DDS는 타입 이름이 같은지를 보고 발행자와 구독자를 연결합니다. 한 글자만 달라도 서로를 찾는 단계에서 짝이 맞지 않습니다.

2. 토픽 이름 앞에 rt/ 가 붙는다

ROS2에서 /my/location으로 보이는 토픽은, DDS 수준에서 실제로 오가는 이름이 rt/my/location입니다. rt는 ROS topic의 약자입니다. 서비스는 요청에 rq/, 응답에 rr/를 씁니다. 그리고 ROS2에서 보이던 맨 앞의 /는 DDS 쪽에서는 없어집니다.

여기서 실수하기 쉽습니다. 설정 파일에 토픽 이름을 적을 때 습관적으로 /를 붙이면, 코드가 "rt/" + "/my/location"을 만들어 rt//my/location이 됩니다. 슬래시가 두 개인 토픽은 어느 쪽과도 연결되지 않는데, 로그에는 오류가 하나도 남지 않습니다. 그래서 설정에 적는 값은 슬래시 없이 시작하도록 규칙을 정해두는 편이 안전합니다.

location_topic = my/location     ; O — raw DDS는 그대로, ROS2는 앞에 rt/ 를 붙임
location_topic = /my/location     ; X — ROS2에서 rt//my/location 으로 깨짐

3. QoS 기본값이 다르다

CycloneDDS의 C API에서 QoS 자리에 NULL을 넘기면 DDS 기본값이 적용됩니다. BEST_EFFORT, VOLATILE입니다. 반면 ROS2의 기본 설정은 RELIABLE, KEEP_LAST(10)입니다.

DDS에서는 구독자가 요구하는 조건과 발행자가 제공하는 조건이 맞아야 연결됩니다. 구독자가 RELIABLE을 요구하는데 발행자가 BEST_EFFORT만 제공하면 둘은 아예 연결되지 않습니다. 타입과 토픽을 다 맞췄는데도 조용하다면 여기를 봐야 합니다.

어느 쪽을 쓸지 정하는 기준은 단순합니다. 다음 값이 앞 값을 덮어쓰는 데이터라면 BEST_EFFORT로 충분합니다. 주기적으로 올라오는 위치나 상태값이 그렇습니다. 한 번 잃으면 사람이 다시 조작해야 하는 데이터라면 RELIABLE과 KEEP_LAST를 걸어야 합니다. 명령이 그렇습니다. 주기 데이터와 명령에 같은 QoS를 쓸 이유가 없습니다.

4. 서비스는 토픽 두 개가 아니다

가장 크게 갈리는 지점입니다.

raw DDS에는 요청과 응답이라는 개념 자체가 없습니다. 토픽에 쓰고 읽는 것뿐입니다. 그래서 명령을 만들려면 요청용 토픽 하나와 응답용 토픽 하나를 두고, 응답이 어느 요청에 대한 것인지를 식별자로 찾아야 합니다. 주고받는 메시지에 이미 고유 ID가 있다면 그것을 쓰면 됩니다. 요청과 응답을 짝지을 목적으로 필드를 새로 만들 필요는 없습니다.

ROS2 서비스는 이걸 직접 만들지 않습니다. rmw 계층이 정해둔 방식이 있습니다. rq/rr/ 토픽을 쓰고, 요청과 응답은 GUID로 짝지어집니다. 이 방식을 직접 구현하는 것은 권할 일이 아닙니다. rclcpp로 서비스 서버를 열면 ROS2 명령줄 도구가 그대로 붙습니다.

ros2 service list                    # /my/cmd 가 보입니다
ros2 service call /my/cmd my_msgs/srv/Command \
  "{command: 'arm', payload_json: '{\"cmd_id\":\"t1\"}'}"

이때 서비스는 ros2 topic list나오지 않습니다. 토픽 목록만 보고 "서비스가 안 떴다"고 판단하면 한참 헤맵니다. ros2 service list로 봐야 합니다.

한 장으로

raw DDSROS2
타입 이름my_msgs::Locationmy_msgs::msg::dds_::Location_
토픽 이름my/locationrt/my/location
QoS 기본값BEST_EFFORT / VOLATILERELIABLE / KEEP_LAST(10)
요청·응답토픽 2개 + 식별자로 짝짓기ROS2 서비스
상대에게 주는 것.idl 파일 (idlc로 생성)colcon 패키지
ROS2와 호환안 됨

어느 쪽을 고를 것인가

ROS2를 씁니다. 상대가 ROS2 노드일 때, ros2 topic echo로 들여다보며 디버깅하고 싶을 때, rviz나 rosbag 같은 도구를 쓰고 싶을 때입니다. 서비스와 액션과 파라미터를 따로 만들지 않고 얻습니다.

raw DDS를 씁니다. 상대가 ROS2를 설치할 수 없거나 설치하고 싶지 않을 때입니다. C로 짠 프로그램, 임베디드 타겟, 다른 언어. 넘겨줄 것이 .idl 파일 하나면 끝납니다. ROS2 쪽은 상대가 colcon 워크스페이스를 빌드해야 하고, 이게 상대의 개발 환경에 따라 만만치 않습니다. 리눅스에서 apt로 깐 환경은 무난했지만, macOS에서는 CMake 버전을 4 미만으로 내리고 시스템 clang을 지정해야 겨우 통과했습니다.

ros2 topic echomessage type invalid를 뱉는 경우가 있습니다. 이건 구독하는 쪽 PC에 메시지 패키지가 없거나 source를 하지 않은 것입니다. 발행하는 쪽 문제가 아닙니다.

최소한의 코드

raw DDS 쪽

IDL을 쓰고, idlc로 C 소스를 만들고, C API로 발행합니다.

idlc -l c my_msgs.idl        # my_msgs.c / my_msgs.h 생성
participant = dds_create_participant(0, NULL, NULL);
topic  = dds_create_topic(participant, &my_msgs_Location_desc, "my/location", NULL, NULL);
writer = dds_create_writer(participant, topic, NULL, NULL);

my_msgs_Location s;
s.send_time = (char *)ts.c_str();     // dds_write 가 내부에서 복사합니다
dds_write(writer, &s);

받는 쪽은 dds_take입니다. 여기 함정이 하나 있습니다. dds_take로 받은 샘플은 내 메모리가 아니라 라이브러리가 잠시 빌려준 메모리입니다. dds_return_loan으로 돌려주지 않으면 받은 샘플만큼 메모리가 새어나갑니다.

int n = dds_take(reader, samples, infos, MAX, MAX);
for (int i = 0; i < n; i++) {
  if (!infos[i].valid_data) continue;
  handle((my_msgs_Command *)samples[i]);
}
if (n > 0) dds_return_loan(reader, samples, n);   // 빠뜨리면 누수

정리는 dds_delete(participant) 한 번이면 그 아래에 만든 것들이 전부 함께 정리됩니다. 다만 데이터를 읽는 스레드를 먼저 join으로 끝낸 다음에 지워야 합니다.

ROS2 쪽

메시지 패키지를 colcon으로 빌드하고 source합니다.

mkdir -p ~/ws/src && cp -r my_msgs ~/ws/src/
cd ~/ws && colcon build --packages-select my_msgs && source install/setup.bash

ros2 interface show my_msgs/srv/Command
ros2 topic echo /my/location

ROS2에서는 필드 이름을 반드시 snake_case로 써야 합니다. rosidl의 규칙입니다. 기존에 주고받던 JSON이 camelCase라면 그 이름 그대로 필드로 올릴 수 없습니다.

이때 선택지가 둘입니다. 필드를 전부 snake_case로 바꿔 IDL에 하나씩 풀어 쓰거나, JSON 문자열을 담는 필드 하나만 두고 그 안에서는 원래 이름을 유지하는 것입니다. 뒤쪽은 메시지 종류가 늘어나도 IDL을 고치지 않아도 되지만, 대신 타입 검사를 포기합니다. 무슨 값이 들어가는지가 IDL이 아니라 문서에만 남습니다.

종류가 수십 개이고 딸려오는 값이 제각각인 명령이라면 뒤쪽이 낫습니다. 필드가 고정된 주기 데이터라면 풀어 쓰는 쪽이 낫습니다. 한 시스템 안에서 둘을 섞어 써도 됩니다.

한 프로그램에서 둘을 함께 쓸 때

이게 제일 아팠습니다. 같은 프로그램에서 rmw와 raw DDS participant를 같은 도메인에 띄우려면 초기화 순서가 정해져 있습니다.

환경변수 설정 → ROS2 쪽 먼저 시작(rmw 가 도메인을 만든다) → raw DDS participant 가 합류

순서를 거꾸로 하면 이렇게 죽습니다.

rmw_create_node: failed to create domain, Precondition Not Met

도메인은 rmw가 먼저 잡아야 하고, 직접 만든 participant는 이미 만들어진 도메인에 합류하는 쪽이어야 합니다.

종료할 때 터지는 것들

rclcpp::init은 기본값이 shutdown_on_signal = true입니다. 이러면 rclcpp가 만든 스레드가 SIGINT를 받아서 Context::shutdown()따로, 비동기로 실행합니다. 프로그램이 자체적으로 SIGINT를 처리하고 있으면 정리 경로가 두 개가 되고, 로깅 라이브러리를 내리는 과정에서 겹쳐서 Bus error가 납니다. 해결은 간단합니다. 시그널 처리를 프로그램이 혼자 맡도록 끕니다.

rclcpp::InitOptions opts;
opts.shutdown_on_signal = false;

정리를 다 끝내고 exit하는 순간에도 터졌습니다. 이번엔 전역 객체가 소멸되는 단계였습니다. ROS2가 쓰는 로깅 라이브러리와, 프로그램에 정적 링크된 같은 로깅 라이브러리가 이름이 같은 심볼을 각각 갖고 있어서 충돌했습니다. 급한 대로 정리가 끝난 뒤 std::_Exit()로 그 소멸 단계를 건너뛰었습니다. 제대로 고치려면 심볼을 내부용으로 감춰야 하고(--exclude-libs), 아직 숙제로 남아 있습니다.

문자열 수명 — 조용히 깨지는 버그

가장 찾기 어려웠던 것입니다.

IDL의 문자열은 C에서 char *입니다. 그래서 보낼 값을 채울 때 std::string을 어딘가에 담아두고 c_str()을 넘기게 됩니다. 그 보관용 그릇을 std::vector<std::string>으로 잡았다가 사고가 났습니다.

push_back으로 원소를 더하다가 벡터가 커지면, 내부 버퍼를 새로 잡고 원소들을 새 자리로 옮깁니다. 문자열이 길면 실제 글자는 힙에 따로 있으니 주소가 그대로여서 살아남습니다. 그런데 짧은 문자열은 글자가 std::string 객체 안에 들어 있습니다. Small String Optimization입니다. 객체가 옮겨지는 순간, 먼저 받아둔 c_str() 포인터는 아무것도 없는 옛 자리를 가리킵니다.

그래서 증상이 이상했습니다. 짧은 값이 들어가는 필드만 쓰레기가 실리고 긴 값은 멀쩡했습니다. 게다가 망가진 문자열이 그 뒤의 데이터까지 밀어버려서, 받는 쪽에는 문자열 문제로 보이지 않고 deserialization failed로 나타났습니다. 처음에는 엉뚱한 곳을 뒤졌습니다.

원소가 옮겨지지 않는 컨테이너로 바꾸면 끝납니다.

std::deque<std::string> pool;   // vector 는 커질 때 원소를 옮긴다

다른 PC에서 안 보일 때

타입과 토픽과 QoS를 다 맞췄는데도 다른 PC에서는 조용할 수 있습니다. CycloneDDS 설정에서 멀티캐스트를 어디까지 허용했는지 때문입니다.

<AllowMulticast>spdp</AllowMulticast>   <!-- 서로를 찾는 단계만 멀티캐스트 -->
<AllowMulticast>true</AllowMulticast>   <!-- 데이터까지 멀티캐스트 -->

true로 두면 같은 네트워크, 같은 도메인의 모든 PC가 자동으로 받습니다. 같은 PC 안에서도 받게 되니 로컬 도구로 확인하기 편합니다. 대신 스위치가 IGMP로 멀티캐스트를 통과시켜야 하고, 방화벽에서 UDP 멀티캐스트가 열려 있어야 합니다.

멀티캐스트가 막힌 망이라면 spdp로 두고 상대 IP를 하나씩 적어줍니다.

<Peers><Peer address="10.0.0.11"/><Peer address="10.0.0.12"/></Peers>

IDL을 여러 벌 두면 반드시 어긋난다

양쪽을 다 지원하면 같은 타입의 정의가 여러 벌로 늘어납니다. raw DDS용 IDL, ROS2로 내보내기 위한 IDL, 그리고 ROS2 메시지 패키지. 필드 이름과 순서가 전부 같아야 합니다. DDS는 필드를 순서대로 바이트에 쌓기 때문에, 순서가 한 칸만 밀려도 오류 없이 값이 엉뚱한 필드로 들어갑니다. 타입이 안 맞아서 연결이 안 되는 것보다 나쁩니다. 연결된 채로 값이 틀립니다.

생성된 코드가 두 경로로 나오는 것도 위험합니다. 빌드할 때마다 자동으로 만들어지는 쪽과, 스크립트로 한 번 만들어 저장소에 넣어둔 쪽이 함께 있으면, IDL을 고친 뒤 스크립트를 다시 돌리지 않는 순간 한쪽만 옛 타입을 씁니다. 빌드는 성공하기 때문에 이 어긋남은 조용히 남아 있습니다.

"동기화할 곳이 세 곳"이라고 문서에 적어두고 사람이 지키는 방식은 해결이 아닙니다. 한 벌을 원본으로 정하고 나머지는 거기서 생성하는 쪽이 맞습니다.

연결이 안 될 때 보는 순서

  1. 도메인 번호가 양쪽 같은지. ROS2는 ROS_DOMAIN_ID, DDS는 participant를 만들 때 넘기는 값입니다.
  2. RMWrmw_cyclonedds_cpp인지. 상대가 Fast DDS면 구현이 달라집니다.
  3. 타입 이름. ROS2 쪽이라면 msg::dds_::..._ 형태가 맞는지.
  4. 토픽 이름. rt/ 접두어와 슬래시 두 개.
  5. QoS. RELIABLE 요구 대 BEST_EFFORT 제공.
  6. 서비스는 토픽 목록에 없습니다. ros2 service list로 봅니다.
  7. 멀티캐스트. 다른 PC에서만 안 보이면 여기입니다.

바이트는 오는데 데이터가 없다면, DDS 아래쪽(네트워크)이 아니라 위쪽(타입과 QoS)을 의심합니다. 반대로 서로를 찾는 것부터 안 되면 아래쪽을 의심합니다.

정리

  • ROS2는 DDS 위에 얇지만 분명한 규칙을 덮습니다. 타입 이름 변형, rt/ 접두어, 다른 QoS 기본값, rmw의 서비스 방식. 네 가지입니다.
  • 이걸 알면 양쪽을 오갈 수 있고, 모르면 "같은 DDS인데 왜 연결이 안 되지"를 며칠 반복합니다.
  • 한 프로젝트에서 둘 다 필요하면 함께 쓸 수 있습니다. 대신 초기화 순서, 종료할 때 시그널을 누가 처리하는지, IDL을 어떻게 한 벌로 유지하는지를 감당해야 합니다.
  • 상대가 전부 ROS2라면 raw DDS를 따로 만들 이유가 없습니다.
  • 상대 중 하나라도 ROS2를 설치할 수 없다면, .idl 파일 하나를 건네는 쪽이 남의 PC에서 colcon 빌드를 붙잡고 있는 것보다 훨씬 낫습니다.

Comments