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_MEM은 lwIP RAW TCP에서 close가 가끔 실패한다: tcp_close()의 ERR_MEM은 free RAM보다 송신 잔여 상태를 먼저 봐야 한다에서 따로 정리해 둔 흐름과 이어진다.
로그를 이렇게 남기면 빨라진다
ERR_MEM 한 줄만 있으면 원인을 못 좁힌다.
나는 tcp_write() 실패 지점에 아래 항목을 같이 남긴다.
- write 요청 길이
tcp_sndbuf(pcb)tcp_sndqueuelen(pcb)- 내부 TX queue에 남은 application data 크기
- 마지막
sentcallback 시각과 ACK된 byte 수 tcp_output()호출 여부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 이후 다시 시도
빠른 체크리스트
ERR_MEM을 free RAM 부족으로 바로 단정하지 않는지 확인tcp_sndbuf(pcb)와tcp_sndqueuelen(pcb)를 같은 로그에 남기는지 확인- 작은
tcp_write()가 너무 자주 반복되어 queue length를 먼저 태우지 않는지 확인 sentcallback이나 TX task 없이 busy loop로 재시도하지 않는지 확인tcp_output()호출 지점이 코드에서 분명한지 확인TCP_WRITE_FLAG_COPY를 끌 경우 payload lifetime을 ACK 시점까지 보장하는지 확인
함께 보면 좋은 글
- lwIP TCP 버퍼 설정 읽는 법: TCP_SND_BUF, TCP_WND, MEM_SIZE, MEMP_NUM_TCP_SEG, PBUF_POOL_SIZE
- lwIP RAW TCP가 ‘가끔 멈춘다’: tcp_recved() 안 치면 윈도우가 안 열린다
- lwIP TCP 재시도 루프에서 RTOS delay가 필요한 이유: busy loop를 막고 CPU를 양보하기
- lwIP RAW TCP에서 close가 가끔 실패한다: tcp_close()의 ERR_MEM은 free RAM보다 송신 잔여 상태를 먼저 봐야 한다
한 줄 요약
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