임베디드 일기장

리눅스 ISO-TP에서 read()가 "Communication error on send"를 뱉는 이유 — sk_err와 커널 에러 전파 메커니즘 본문

Linux

리눅스 ISO-TP에서 read()가 "Communication error on send"를 뱉는 이유 — sk_err와 커널 에러 전파 메커니즘

jaeuuu 2026. 7. 11. 12:40
반응형

리눅스 ISO-TP(ISO 15765-2) 소켓으로 CAN 통신을 하다 보면 고개를 갸웃하게 되는 에러를 만난다. read()가 실패했는데 errno가 ECOMM, 즉 "Communication error on send"다. 나는 읽기를 했는데 왜 "송신 에러"가 나오나? 커널 소스 net/can/isotp.c를 열어 read 경로를 아무리 뒤져도 ECOMM을 리턴하는 코드가 안 보인다. 이 글은 그 미스터리의 답인 소켓 계층의 sk_err 에러 전파 메커니즘을 정리한 것이다.

 

환경

  • 커널: 5.15.71 (i.MX8MP), net/can/isotp.c
  • 구성: 한 물리 CAN 버스 위에 다채널 ISO-TP 세션

1. ECOMM은 read가 만든 에러가 아니다

isotp.c의 errno 매핑을 정리하면 이렇다.

TX 경로: FC(Flow Control) 수신 타임아웃      → ECOMM
TX 경로: FC 오버플로우(FS=OVFLW)             → EMSGSIZE
TX 경로: 잘못된 FC 레이아웃/패딩              → EBADMSG
RX 경로: Consecutive Frame SN 불일치          → EILSEQ
RX 경로: 수신 타임아웃                        → ETIMEDOUT

ECOMM은 송신 상태머신의 타이머 핸들러에서 만들어진다. 멀티프레임 송신 중 First Frame을 보낸 뒤 상대의 FC를 정해진 시간(N_Bs) 안에 못 받으면, 타이머 핸들러가 sk->sk_err에 ECOMM을 기록한다. read 코드에 ECOMM이 안 보이는 게 당연하다 — read는 만들지 않고 회수만 한다.

2. sk_err: 소켓의 out-of-band 에러 사물함

리눅스 소켓에는 sk_err라는 필드가 있다. 비동기적으로 발생한 에러(타이머 만료, 하위 계층 실패 등)를 임시 보관하는 사물함이다. 규칙은 단순하다.

어떤 syscall이든 다음에 호출되는 놈이 sock_error()로 이 값을 회수해서 자기 에러인 것처럼 반환하고, 사물함을 비운다.

read든 write든 상관없다. isotp_recvmsg()는 진입하자마자 sk_err를 확인하고, 값이 있으면 수신 큐에 데이터가 있든 없든 그 에러를 먼저 반환한다. 이때 큐의 데이터는 소비되지 않고 그대로 남는다.

그러니 read()에서 ECOMM이 떴다는 것의 진짜 의미는 이것이다: "이 소켓이 과거 어느 시점에 송신을 하다 FC를 못 받아 타임아웃됐고, 그 에러가 회수되지 않은 채 남아 있다가 지금 read가 주웠다." 사후 통보다.

[시점 1] write()로 멀티프레임 송신 시작 → FF 송출
[시점 2] N_Bs 안에 FC 미수신 → 타이머 핸들러가 sk_err = ECOMM 기록
[시점 3] 애플리케이션이 read() 호출 → sock_error()가 ECOMM 회수
         → read() == -1, errno == ECOMM  ← 여기서 목격
[시점 4] 다시 read() 호출 → sk_err는 비었음 → 정상 동작 (큐에 데이터 있으면 반환)

시점 4가 중요하다. ECOMM을 받은 직후의 read가 멀쩡히 성공하는, 언뜻 이상해 보이는 동작이 이 메커니즘으로 설명된다.

3. poll()과의 조합에서 생기는 함정

poll 루프 기반 설계라면 한 가지 더 알아야 한다. ISO-TP는 datagram_poll을 쓰는데, sk_err와 수신 큐를 독립적으로 검사해 이벤트 마스크를 만든다.

if (sk->sk_err)
    mask |= EPOLLERR | EPOLLPRI;
if (!skb_queue_empty(&sk->sk_receive_queue))
    mask |= EPOLLIN | EPOLLRDNORM;

즉 revents는 POLLERR만일 수도, POLLIN만일 수도, 둘 다일 수도 있다. POLLIN만 검사하는 루프는 에러를 영원히 놓친다 — 그리고 sk_err가 남아 있는 한 다음 read가 데이터 대신 에러를 반환하므로, "poll은 POLLIN이라고 했는데 read는 실패하는" 이해하기 힘든 증상으로 나타난다. 올바른 처리:

struct pollfd pfd = { .fd = s, .events = POLLIN };
poll(&pfd, 1, timeout);

if (pfd.revents & POLLERR) {
    int err = 0; socklen_t len = sizeof(err);
    getsockopt(s, SOL_SOCKET, SO_ERROR, &err, &len);
    /* err를 로깅/처리 — 이 getsockopt 역시 sk_err를 회수/클리어한다 */
}
if (pfd.revents & POLLIN) {
    n = read(s, buf, sizeof(buf));
}

4. 애초에 왜 FC가 안 오나 — WFT의 부재와 다채널 기아

에러 전파 원리를 알았으니 근본 원인으로 가자. 내 환경에서 ECOMM이 터진 구조적 이유는 두 가지였다.

① 리눅스 ISO-TP에는 송신 측 WFT 처리가 없다. 소켓 옵션의 wftmax는 수신 측에서 "내가 FC.WAIT을 몇 번까지 보내겠다"는 한도일 뿐이다. 반대로 내가 송신자일 때 상대가 FC.WAIT을 보내며 버티면, 리눅스 송신 상태머신은 이를 기다려주는 메커니즘 없이 N_Bs 타임아웃 → ECOMM으로 끝난다. 상대 장치가 WAIT을 활용하는 스택이면 프로토콜상 합법인데도 통신이 깨진다.

② CAN 중재는 프레임을 조정하지, 세션을 조정하지 않는다. 한 버스에 ISO-TP 채널 여러 개를 올리면, CAN ID 중재는 프레임 단위 충돌만 해결한다. 낮은 ID 트래픽이 버스를 점유하면 높은 ID 노드의 FC 프레임이 밀리고, 그 지연이 N_Bs를 넘는 순간 ECOMM이다. 채널 수가 늘수록 이 기아(starvation)는 확률 문제가 아니라 시간 문제가 된다. 참고로 CAN 인터페이스의 txqueuelen 기본값은 10에 불과해서, 다채널 동시 송신이면 ENOBUFS도 함께 따라오기 쉽다.

진단은 candump -tz can0으로 한다. FF(첫 니블 0x1)를 보낸 뒤 FC(첫 니블 0x3)가 제 시간에 들어오는지 타임스탬프로 확인하면, "상대가 FC를 안 보내는" 건지 "보냈는데 버스에서 밀린" 건지 갈린다.

[candump 캡처 삽입: FF 송출 후 FC 지연/부재 사례]

5. 설계 교훈

  1. ISO-TP의 read/write 에러는 "그 호출의 에러"가 아니라 "소켓에 쌓인 에러"일 수 있다 — errno를 호출 단위가 아니라 소켓 단위 이벤트로 해석하라
  2. poll 루프에서는 POLLERR를 반드시 처리하고 SO_ERROR로 회수하라
  3. 다채널 구성이면 채널별 errno와 별개로, 같은 인터페이스에 에러 전용 raw 소켓(CAN_RAW_ERR_FILTER)을 하나 열어 bus-off/error-passive 같은 버스 공통 이벤트를 단일 지점에서 감시하라 — "개별 채널 문제인지 버스가 죽은 건지"를 즉시 구분할 수 있다
  4. 상대 장치가 FC.WAIT을 쓰는 스택이라면 리눅스 커널 ISO-TP와의 궁합을 사전에 검증하라

i.MX8MP, 커널 5.15.71 실측 기반. 커널 소스 인용은 net/can/isotp.c 기준입니다.

반응형