Skip to content
오늘을살자
Go back

lwIP에서 tcp_write()가 ERR_MEM을 돌려준다: free RAM보다 snd_buf와 snd_queuelen을 먼저 보자

Edit page

lwIP RAW TCP로 데이터를 보내다 보면 이런 코드가 있다.

err_t err = tcp_write(pcb, data, len, TCP_WRITE_FLAG_COPY);

처음에는 잘 된다.
그런데 부하를 조금 주거나 작은 데이터를 자주 보내면 ERR_MEM이 돌아온다.

이때 free heap부터 보게 된다.
하지만 lwIP TCP에서 tcp_write()ERR_MEM은 항상 “시스템 RAM이 다 떨어졌다”는 뜻만은 아니다.

오늘 메모는 tcp_write()ERR_MEM을 반환할 때 snd_buf, snd_queuelen, tcp_seg pool, ACK 흐름을 어떻게 나눠 봐야 하는지다.

결론부터

tcp_write()는 데이터를 바로 wire로 밀어 넣는 함수가 아니다.
먼저 TCP 송신 큐에 데이터를 올린다.

그래서 ERR_MEM은 이렇게 보는 편이 빠르다.

일반 heap 부족
-> MEM_SIZE, heap fragmentation, copy buffer 부족

TCP 송신 큐 부족
-> TCP_SND_BUF, TCP_SND_QUEUELEN, MEMP_NUM_TCP_SEG, unsent/unacked 누적

특히 RAW API에서는 두 번째가 자주 나온다.

free RAM이 남아 있어도
해당 pcb의 송신 byte 예산이나 segment 큐 예산이 꽉 차면
tcp_write()는 실패할 수 있다.

흔한 패턴 1: 작은 write를 너무 많이 쪼갠다

가장 흔한 경우는 작은 payload를 반복해서 tcp_write()하는 코드다.

tcp_write(pcb, "A", 1, TCP_WRITE_FLAG_COPY);
tcp_write(pcb, "B", 1, TCP_WRITE_FLAG_COPY);
tcp_write(pcb, "C", 1, TCP_WRITE_FLAG_COPY);

byte 수만 보면 작다.
하지만 TCP 큐에서는 segment, pbuf, metadata가 같이 필요하다.

그래서 TCP_SND_BUF는 남아 있는데
TCP_SND_QUEUELEN이나 MEMP_NUM_TCP_SEG 쪽이 먼저 막힐 수 있다.

로그가 이런 식이면 이 패턴을 의심한다.

tcp_write err=ERR_MEM len=8 sndbuf=2048 sndqueuelen=38

남은 byte 예산은 있어 보이는데 큐 항목 수가 거의 찼다면
payload를 조금 모아서 쓰거나,
application framing을 조정하는 편이 낫다.

TCP 버퍼 옵션의 역할은 lwIP TCP 버퍼 설정 읽는 법: TCP_SND_BUF, TCP_WND, MEM_SIZE, MEMP_NUM_TCP_SEG, PBUF_POOL_SIZE와 같이 보면 좋다.

흔한 패턴 2: ACK를 기다리지 않고 계속 밀어 넣는다

tcp_write()가 성공했다는 것은
상대가 데이터를 받았다는 뜻이 아니다.

아직 아래 큐에 남아 있을 수 있다.

unsent
-> 아직 전송되지 않은 데이터

unacked
-> 전송됐지만 ACK를 못 받은 데이터

상대가 느리거나 네트워크가 흔들리면
이 큐가 비워지는 속도보다 애플리케이션이 쓰는 속도가 빨라진다.

그 상태에서 loop로 계속 쓰면 결국 ERR_MEM이 난다.

while (has_data()) {
  err_t err = tcp_write(pcb, next, next_len, TCP_WRITE_FLAG_COPY);
  if (err != ERR_OK) {
    continue;
  }
}

이런 busy loop는 좋지 않다.
큐가 찼는데 같은 문맥에서 계속 재시도하면 CPU만 쓰고 상황은 나아지지 않는다.

보통은 sent callback이나 별도 TX task에서
ACK 이후 다시 보낼 수 있게 흐름을 만든다.

재시도 loop를 태스크 지연과 같이 보는 이유는 lwIP TCP 재시도 루프에서 RTOS delay가 필요한 이유: busy loop를 막고 CPU를 양보하기와 이어진다.

흔한 패턴 3: tcp_output() 호출 지점이 흐리다

tcp_write()는 큐에 올리는 쪽이고,
tcp_output()은 가능한 데이터를 실제 송신 흐름으로 밀어내는 쪽이다.

프로젝트마다 호출 패턴은 다르지만
아래처럼 모호하면 디버깅이 어려워진다.

tcp_write success
-> tcp_output 호출 안 함
-> 데이터가 나중 이벤트에 밀려 나가길 기대
-> unsent 큐가 예상보다 오래 남음

반대로 매 byte마다 tcp_output()을 호출하면
작은 segment가 너무 많이 생겨 큐 압박이 커질 수 있다.

무난한 출발점은 이렇다.

application data를 적당히 모음
-> tcp_write()
-> 성공한 묶음 뒤 tcp_output()
-> ERR_MEM이면 sent callback 이후 재시도

중요한 것은 “언제 큐에 올리고, 언제 밀어내고, 언제 다시 시도하는지”가 코드에서 보이는 것이다.

흔한 패턴 4: COPY 플래그와 payload 수명주기를 같이 안 본다

TCP_WRITE_FLAG_COPY를 쓰면 lwIP가 payload를 내부 메모리로 복사한다.
이 방식은 애플리케이션 버퍼 수명주기 부담이 줄지만 메모리 압박은 늘 수 있다.

반대로 copy 없이 쓰면 메모리 복사는 줄어들 수 있다.
하지만 ACK가 올 때까지 원본 payload가 살아 있어야 한다.

COPY 사용
-> lwIP 내부 메모리 사용 증가
-> payload lifetime 단순

COPY 미사용
-> 원본 payload lifetime 책임이 애플리케이션에 남음
-> stack buffer나 재사용 buffer를 넘기면 위험

ERR_MEM을 줄이려고 copy를 끄는 건 선택지가 될 수 있다.
하지만 그 순간부터 pbuf나 애플리케이션 버퍼 수명주기를 훨씬 엄격하게 봐야 한다.

bring-up 초기에는 copy 기반으로 동작 기준을 잡고,
그 다음에 no-copy 최적화를 보는 편이 디버깅이 쉽다.

tcp_close()의 ERR_MEM과도 연결된다

송신 큐가 비워지지 않은 상태에서 종료까지 겹치면
나중에는 tcp_close()에서도 ERR_MEM이 보일 수 있다.

이때도 free RAM만 보면 오래 헤맨다.

tcp_write ERR_MEM
-> unsent/unacked가 계속 남음
-> 애플리케이션이 close 시도
-> tcp_close ERR_MEM

종료 시점의 ERR_MEMlwIP RAW TCP에서 close가 가끔 실패한다: tcp_close()의 ERR_MEM은 free RAM보다 송신 잔여 상태를 먼저 봐야 한다에서 따로 정리해 둔 흐름과 이어진다.

로그를 이렇게 남기면 빨라진다

ERR_MEM 한 줄만 있으면 원인을 못 좁힌다.

나는 tcp_write() 실패 지점에 아래 항목을 같이 남긴다.

  1. write 요청 길이
  2. tcp_sndbuf(pcb)
  3. tcp_sndqueuelen(pcb)
  4. 내부 TX queue에 남은 application data 크기
  5. 마지막 sent callback 시각과 ACK된 byte 수
  6. tcp_output() 호출 여부
  7. ERR_MEM 직전 작은 write가 얼마나 반복됐는지

예를 들면 이런 식이다.

tcp_write ret=ERR_MEM len=64 sndbuf=1460 sndqueuelen=39 appq=2048 last_sent=420ms ago

이 정도만 있어도
“heap이 부족한가?”보다
“ACK가 안 돌아와서 큐가 안 비는가?” 또는
“작은 write가 queue length를 태우고 있는가?”로 빨리 좁힐 수 있다.

구현 쪽에서 무난한 형태

아래는 단순화한 예시다.

static err_t raw_tcp_try_write(struct tcp_pcb *pcb, const void *data, u16_t len)
{
  if (tcp_sndbuf(pcb) < len) {
    return ERR_MEM;
  }

  err_t err = tcp_write(pcb, data, len, TCP_WRITE_FLAG_COPY);

  if (err == ERR_OK) {
    return tcp_output(pcb);
  }

  return err;
}

실제 코드에서는 이것만으로 충분하지 않다.
tcp_sndqueuelen(pcb), application TX queue, sent callback, 재시도 타이밍까지 같이 봐야 한다.

그래도 출발점은 분명하다.

쓸 수 있는 만큼만 queue에 올림
-> 실패하면 같은 loop에서 태우지 않음
-> ACK 또는 sent callback 이후 다시 시도

빠른 체크리스트

  1. ERR_MEM을 free RAM 부족으로 바로 단정하지 않는지 확인
  2. tcp_sndbuf(pcb)tcp_sndqueuelen(pcb)를 같은 로그에 남기는지 확인
  3. 작은 tcp_write()가 너무 자주 반복되어 queue length를 먼저 태우지 않는지 확인
  4. sent callback이나 TX task 없이 busy loop로 재시도하지 않는지 확인
  5. tcp_output() 호출 지점이 코드에서 분명한지 확인
  6. TCP_WRITE_FLAG_COPY를 끌 경우 payload lifetime을 ACK 시점까지 보장하는지 확인

함께 보면 좋은 글

한 줄 요약

lwIP RAW TCP에서 tcp_write()ERR_MEM을 반환하면 free RAM보다 먼저 TCP_SND_BUF, TCP_SND_QUEUELEN, MEMP_NUM_TCP_SEG, unsent/unacked 큐가 비워지는 흐름을 확인하는 편이 빠르다.

추천 키워드

lwIP, TCPIP, tcp_write, ERR_MEM, TCP_SND_QUEUELEN, Embedded Network


DevBJ | 오늘을살자, Log Today


Edit page
Share this post on:

Next Post
DoIP에서 NRC 0x14가 뜬다: Response Too Long을 TCP 문제로 착각하지 말자