lwIP 포팅에서 RX는 어느 정도 붙었는데 송신만 가끔 멈추는 경우가 있다.
- ping reply는 되다가 어느 순간 끊긴다
- UDP send는 성공처럼 보였는데 상대가 못 받는다
- TCP가 조금 보내다가 멈춘다
- TX descriptor가 다시 비지 않는다
이때 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
수신 쪽에서 len과 tot_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이 가득 차고 멈춘다.
증상은 보통 이렇다.
- 부팅 직후 몇 패킷은 나감
- 그 뒤
desc_free=0 - link는 up인데 송신만 안 됨
- 재부팅하면 다시 잠깐 살아남
이건 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 쪽에 아래를 잠깐 붙여 본다.
p->tot_len과 chain node 개수- 사용한 TX descriptor index
- TX descriptor free 개수
- copy 기반인지 zero-copy인지
- zero-copy라면 pbuf ref 증가/감소 시각
- TX complete interrupt 또는 poll 회수 시각
- 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에서 무엇을 회수해야 하는지 코드가 분명해진다.
빠른 체크리스트
linkoutput에서 pbuf chain 전체를 복사하거나 전송하는지 확인- copy 기반 TX에서 드라이버가 전달받은 pbuf를 직접
pbuf_free()하지 않는지 확인 - TX descriptor가 부족할 때 성공처럼 반환하지 않는지 확인
- TX complete interrupt 또는 poll에서 descriptor를 실제로 회수하는지 확인
- zero-copy TX라면 DMA 완료 전까지 pbuf ref와 payload 수명이 유지되는지 확인
- DMA가 읽기 전에 TX buffer cache clean이 끝나는지 확인
함께 보면 좋은 글
- lwIP에서 RX pbuf를 넘긴 뒤 가끔 깨진다: tcpip_input ownership을 먼저 정리하자
- lwIP pbuf가 가끔 터진다: PBUF_REF/POOL/RAM 수명주기와 zero-copy 함정
- lwIP에서 UDP 길이가 가끔 잘못 읽힌다: pbuf 체인에서는 len 말고 tot_len을 봐야 한다
- lwIP에서 RX는 되는데 payload가 가끔 깨진다: DMA 버퍼와 D-Cache 순서를 같이 봐야 한다
한 줄 요약
lwIP에서 송신이 부하 중 가끔 멈추면 TCP보다 먼저 linkoutput의 pbuf chain 처리, TX descriptor 회수, zero-copy pbuf 수명주기, cache clean 순서를 같이 확인하는 편이 빠르다.
추천 키워드
lwIP, TCPIP, pbuf, linkoutput, Ethernet MAC, DMA
DevBJ | 오늘을살자, Log Today