Skip to content
오늘을살자
Go back

lwIP에서 송신이 가끔 멈춘다: linkoutput에서 TX descriptor와 pbuf 수명을 같이 보자

Edit page

lwIP 포팅에서 RX는 어느 정도 붙었는데 송신만 가끔 멈추는 경우가 있다.

이때 TCP window나 앱 버퍼부터 보게 된다.
하지만 MAC 드라이버의 linkoutput 경로에서
TX descriptor와 pbuf 수명주기를 잘못 다루는 경우도 많다.

오늘 메모는 lwIP netif->linkoutput에서 송신 pbuf를 어떻게 다뤄야 TX가 멈추지 않는지다.

결론부터

송신 경로는 크게 두 방식으로 나눠서 봐야 한다.

copy 기반 TX
-> pbuf chain을 driver TX buffer로 복사
-> linkoutput return 이후 pbuf를 driver가 들고 있지 않음

zero-copy 기반 TX
-> pbuf payload를 DMA가 직접 참조
-> DMA 완료 전까지 pbuf와 payload가 살아 있어야 함

두 방식을 섞으면 문제가 생긴다.

복사 기반처럼 return해 놓고 실제로는 DMA가 pbuf payload를 나중에 읽거나,
zero-copy처럼 포인터를 잡아 놓고 ref/free 규칙이 없으면
증상은 랜덤 송신 정지처럼 보인다.

linkoutput에서 pbuf를 free하면 안 되는 경우가 많다

가장 흔한 오해는 이거다.

static err_t low_level_output(struct netif *netif, struct pbuf *p)
{
  copy_to_tx_buffer(p);
  start_dma();
  pbuf_free(p);
  return ERR_OK;
}

겉으로는 “보냈으니 free”처럼 보인다.
하지만 일반적인 lwIP TX 경로에서 linkoutput으로 넘어온 pbuf
드라이버가 마음대로 소유권을 가져간 버퍼가 아니다.

복사 기반 송신이라면 드라이버는 필요한 데이터를 TX buffer로 복사하고 return하면 된다.
pbuf_free()는 호출자 쪽 수명주기에서 처리된다.

즉 copy 기반에서는 보통 이렇게 본다.

lwIP owns pbuf
driver copies bytes
driver returns ERR_OK or failure
driver does not free pbuf

RX의 tcpip_input() ownership과 방향이 다르기 때문에
둘을 같은 규칙으로 외우면 쉽게 틀린다.

RX 쪽 흐름은 lwIP에서 RX pbuf를 넘긴 뒤 가끔 깨진다: tcpip_input ownership을 먼저 정리하자와 비교해서 보면 좋다.

흔한 패턴 1: pbuf chain 첫 조각만 복사한다

TX에서도 pbuf는 chain일 수 있다.

그런데 드라이버가 첫 노드만 복사하면
작은 패킷에서는 통과하다가 큰 패킷이나 TCP segment에서 깨진다.

memcpy(tx_buf, p->payload, p->len);
tx_len = p->len;

이 코드는 p->len == p->tot_len일 때만 우연히 맞다.

무난한 copy 기반 TX는 체인을 순회해야 한다.

offset = 0
for each q in pbuf chain:
  copy q->len bytes from q->payload
  offset += q->len
tx_len = p->tot_len

수신 쪽에서 lentot_len을 헷갈리는 문제는
lwIP에서 UDP 길이가 가끔 잘못 읽힌다: pbuf 체인에서는 len 말고 tot_len을 봐야 한다와 같은 원리다.

흔한 패턴 2: TX descriptor full을 성공처럼 돌려준다

TX ring이 꽉 찼는데도 ERR_OK를 반환하면
lwIP와 애플리케이션은 패킷이 드라이버로 넘어갔다고 믿는다.

하지만 실제로는 descriptor에 걸리지 못했을 수 있다.

linkoutput called
-> no free TX descriptor
-> driver drops packet
-> returns ERR_OK

이러면 상위 로그에는 송신 성공처럼 보이고,
상대는 아무것도 받지 못한다.

반대로 실패를 반환하더라도
그 실패가 어느 조건에서 났는지 로그가 없으면 디버깅이 어렵다.

나는 보통 아래를 같이 남긴다.

tx ret=ERR_MEM desc_free=0 pbuf_tot=1514 queue=full

정확히 어떤 err_t를 쓸지는 포팅 코드와 호출 경로에 맞춰야 하지만,
핵심은 descriptor 부족을 성공으로 숨기지 않는 것이다.

흔한 패턴 3: TX complete에서 descriptor를 회수하지 않는다

송신이 처음에는 되다가 어느 순간 멈춘다면
TX complete 처리도 같이 봐야 한다.

MAC에 descriptor를 넘기는 것만으로 끝이 아니다.
하드웨어가 송신을 끝낸 뒤
드라이버가 descriptor를 다시 free 상태로 돌려야 한다.

linkoutput
-> descriptor owned by DMA
-> packet transmitted
-> TX complete interrupt or poll
-> descriptor reclaimed
-> descriptor becomes reusable

이 마지막 회수 단계가 빠지면
처음 몇 개는 나가다가 ring이 가득 차고 멈춘다.

증상은 보통 이렇다.

이건 TCP 문제가 아니라 TX descriptor lifecycle 문제일 가능성이 크다.

흔한 패턴 4: zero-copy인데 pbuf ref를 잡지 않는다

성능을 위해 TX zero-copy를 쓰면 규칙이 더 엄격해진다.

DMA가 pbuf->payload를 직접 읽는 구조라면
linkoutput이 return한 뒤에도 그 메모리가 살아 있어야 한다.

그런데 lwIP 쪽에서는 linkoutput이 끝난 뒤
해당 pbuf 수명주기가 더 진행될 수 있다.
드라이버가 포인터만 저장해 두면 use-after-free가 된다.

zero-copy를 유지하려면 보통 아래 같은 기준이 필요하다.

driver takes extra pbuf reference
-> stores pbuf pointer in TX descriptor context
-> DMA TX complete
-> driver releases pbuf reference

이 규칙이 없다면 bring-up 초기에는 copy 기반 TX로 안정 기준을 만드는 편이 낫다.

zero-copy는 빠르지만,
TX complete, cache clean, pbuf ref count가 모두 맞아야 한다.

cache clean 위치도 같이 봐야 한다

DMA TX에서는 D-Cache도 빠지지 않는다.

CPU가 TX buffer나 pbuf payload에 데이터를 써도
DMA가 읽는 메모리에 아직 반영되지 않았을 수 있다.

그래서 TX descriptor를 hardware에 넘기기 전에
DMA가 읽을 영역을 clean하는 흐름이 필요하다.

copy or prepare payload
-> clean D-Cache for TX buffer
-> update TX descriptor
-> give descriptor to DMA

이 순서가 뒤집히면
상대는 이전 payload나 깨진 payload를 받을 수 있다.

RX 쪽 cache 문제는 lwIP에서 RX는 되는데 payload가 가끔 깨진다: DMA 버퍼와 D-Cache 순서를 같이 봐야 한다와 이어진다.

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

TX 멈춤은 마지막 에러 한 줄만 보면 원인을 못 좁힌다.

나는 linkoutput과 TX complete 쪽에 아래를 잠깐 붙여 본다.

  1. p->tot_len과 chain node 개수
  2. 사용한 TX descriptor index
  3. TX descriptor free 개수
  4. copy 기반인지 zero-copy인지
  5. zero-copy라면 pbuf ref 증가/감소 시각
  6. TX complete interrupt 또는 poll 회수 시각
  7. cache clean 범위

예를 들면 이런 식이다.

tx submit desc=3 tot=342 chain=2 mode=copy free_after=5
tx done desc=3 reclaim free_after=6

zero-copy라면 이렇게 남긴다.

tx submit desc=4 p=0x20014000 ref++ tot=1514
tx done desc=4 p=0x20014000 ref--

이 정도만 있어도
“TCP가 멈췄다”보다
“descriptor가 회수되지 않는다” 또는 “pbuf 수명이 맞지 않는다”로 빨리 좁힐 수 있다.

구현 쪽에서 무난한 출발점

bring-up 초기에는 copy 기반 TX가 디버깅이 쉽다.

linkoutput:
  validate pbuf chain
  find free TX descriptor
  copy whole pbuf chain into TX buffer
  clean cache for TX buffer
  start DMA
  return result

TX complete:
  reclaim descriptor

이 구조에서 안정화한 뒤
필요할 때 zero-copy로 옮기는 편이 낫다.

zero-copy로 갈 때는 descriptor context에
pbuf pointer, ref 상태, payload address, length를 같이 둬야 한다.
그래야 TX complete에서 무엇을 회수해야 하는지 코드가 분명해진다.

빠른 체크리스트

  1. linkoutput에서 pbuf chain 전체를 복사하거나 전송하는지 확인
  2. copy 기반 TX에서 드라이버가 전달받은 pbuf를 직접 pbuf_free()하지 않는지 확인
  3. TX descriptor가 부족할 때 성공처럼 반환하지 않는지 확인
  4. TX complete interrupt 또는 poll에서 descriptor를 실제로 회수하는지 확인
  5. zero-copy TX라면 DMA 완료 전까지 pbuf ref와 payload 수명이 유지되는지 확인
  6. DMA가 읽기 전에 TX buffer cache clean이 끝나는지 확인

함께 보면 좋은 글

한 줄 요약

lwIP에서 송신이 부하 중 가끔 멈추면 TCP보다 먼저 linkoutput의 pbuf chain 처리, TX descriptor 회수, zero-copy pbuf 수명주기, cache clean 순서를 같이 확인하는 편이 빠르다.

추천 키워드

lwIP, TCPIP, pbuf, linkoutput, Ethernet MAC, DMA


DevBJ | 오늘을살자, Log Today


Edit page
Share this post on:

Next Post
DoIP에서 NRC 0x31이 뜬다: DID와 Routine ID 범위를 세션 문제와 나눠 보자