lwIP 포팅에서 RX 경로가 처음에는 멀쩡해 보이다가
부하를 조금 주면 이상해지는 경우가 있다.
- ping은 되는데 UDP payload가 가끔 깨진다
- TCP 연결이 랜덤하게 끊긴다
pbuf_free()쪽 assert가 난다- 시간이 지나면
PBUF_POOL이 마른다
이때 checksum, cache, PHY부터 보게 된다.
그런데 RX 드라이버에서 pbuf ownership을 잘못 넘긴 경우도 많다.
오늘 메모는 Ethernet RX 드라이버가 tcpip_input()으로 pbuf를 넘긴 뒤 누가 free하고 누가 재사용해야 하는지다.
결론부터
RX 드라이버가 pbuf를 만들어 lwIP에 넘기는 구조라면
성공과 실패 경로의 ownership을 분리해야 한다.
tcpip_input(p, netif) == ERR_OK
-> lwIP가 pbuf ownership을 가져갔다
-> driver가 다시 pbuf_free() 하면 안 됨
tcpip_input(p, netif) != ERR_OK
-> lwIP가 받지 못했다
-> driver가 pbuf_free() 해야 함
이 규칙 하나가 흐리면
증상은 랜덤 메모리 문제처럼 보인다.
흔한 패턴 1: 성공했는데 driver가 다시 free한다
가장 위험한 실수는 이 형태다.
struct pbuf *p = low_level_rx();
if (p != NULL) {
tcpip_input(p, &g_netif);
pbuf_free(p);
}
겉으로는 깔끔해 보인다.
받은 버퍼를 마지막에 정리하는 코드처럼 보이기 때문이다.
하지만 tcpip_input()이 성공했다면
그 pbuf는 이미 lwIP 쪽 큐와 프로토콜 처리 흐름으로 넘어갔다.
driver가 다시 free하면 double free나 use-after-free가 된다.
부하가 낮을 때는 우연히 지나가도,
패킷이 몰리면 TCP payload가 깨지거나 assert가 터진다.
흔한 패턴 2: 실패했는데 free하지 않는다
반대 실수도 있다.
err_t err = tcpip_input(p, &g_netif);
if (err != ERR_OK) {
log_error(err);
return;
}
이 경우 tcpip_input()이 큐에 넣지 못한 pbuf가 그대로 남는다.
ERR_MEM이나 mailbox full 상황이 반복되면
작은 누수가 쌓여 PBUF_POOL이 먼저 마른다.
그래서 실패 경로는 명확해야 한다.
err_t err = tcpip_input(p, &g_netif);
if (err != ERR_OK) {
pbuf_free(p);
}
핵심은 “성공하면 넘겼고, 실패하면 내가 치운다”다.
흔한 패턴 3: DMA RX buffer를 너무 빨리 재사용한다
zero-copy 또는 비슷한 구조에서는 더 조심해야 한다.
RX descriptor가 가리키는 DMA buffer를 pbuf payload로 연결한 뒤,
바로 descriptor를 다시 hardware에 돌려주면 문제가 된다.
DMA buffer -> pbuf payload
tcpip_input(p)
descriptor reused
hardware writes new frame
lwIP still parses old pbuf
이러면 lwIP 입장에서는 같은 pbuf payload가 중간에 바뀐 것처럼 보인다.
증상은 꽤 헷갈린다.
- UDP header는 맞는데 payload만 이상하다
- TCP segment 일부가 이전 패킷처럼 보인다
- 캡처에는 정상인데 보드 내부 로그만 깨진다
zero-copy를 쓸 거면
pbuf가 해제되는 시점까지 DMA buffer도 재사용하면 안 된다.
그 규칙을 지키기 어렵다면 bring-up 초기에는 복사 방식으로 기준을 만드는 편이 낫다.
관련 버퍼 수명주기는 lwIP pbuf가 가끔 터진다: PBUF_REF/POOL/RAM 수명주기와 zero-copy 함정와 이어진다.
흔한 패턴 4: ISR에서 바로 input 경로를 밀어 넣는다
프로젝트마다 구조는 다르지만,
ISR에서 너무 많은 일을 하면 ownership 디버깅도 어려워진다.
Ethernet IRQ
-> RX descriptor scan
-> pbuf alloc
-> tcpip_input
이 자체가 항상 틀렸다고 말할 수는 없다.
하지만 RTOS 포팅에서는 보통 ISR과 tcpip_thread 사이의 경계를 분명히 두는 편이 낫다.
특히 아래가 섞이면 문제가 더 잘 숨는다.
- ISR에서 pbuf alloc/free
- task에서 descriptor 회수
- tcpip mailbox가 full인 상황
- link down 중 RX interrupt 잔여 처리
ISR에서는 이벤트만 깨우고,
RX task 또는 driver task에서 pbuf를 만들어 tcpip_input() 결과를 처리하면 로그가 훨씬 읽기 쉽다.
컨텍스트 분리 흐름은 lwIP에서 ISR에서 바로 보내면 가끔 터진다: tcpip_thread로 넘기는 패턴 정리와 같이 보면 좋다.
pbuf ref count를 로그로 보면 빨라진다
이 문제는 마지막 crash 지점만 보면 원인이 멀리 있어 보인다.
나는 RX input 경로에 잠깐 아래 로그를 넣고 본다.
rx p=0x20012000 type=POOL len=60 tot_len=60 ref=1
tcpip_input ret=ERR_OK
owner=lwip
실패 경로라면 이렇게 남긴다.
rx p=0x20012400 type=POOL len=1514 tot_len=1514 ref=1
tcpip_input ret=ERR_MEM
owner=driver-free
이 정도만 있어도
“누가 마지막에 free했는가”를 추적하기 쉬워진다.
가능하면 아래 항목을 같이 남긴다.
- RX descriptor index
- pbuf 주소와 payload 주소
len,tot_len,reftcpip_input()반환값- 성공/실패별 free 여부
- DMA buffer 재사용 시각
구현 쪽에서 무난한 형태
복사 기반 RX라면 이런 모양이 이해하기 쉽다.
struct pbuf *p = low_level_rx_copy();
if (p == NULL) {
return;
}
err_t err = tcpip_input(p, &g_netif);
if (err != ERR_OK) {
pbuf_free(p);
}
성공 경로에는 pbuf_free(p)가 없다.
zero-copy 기반이라면 pbuf custom free callback이나 driver buffer pool 회수 지점까지 같이 설계해야 한다.
그냥 descriptor를 먼저 재사용하면 ownership 규칙이 깨진다.
빠른 체크리스트
tcpip_input()성공 뒤 driver가pbuf_free()를 다시 호출하지 않는지 확인tcpip_input()실패 경로에서는 driver가 반드시pbuf_free()하는지 확인- DMA buffer를 pbuf payload로 넘긴 뒤 너무 빨리 RX descriptor에 돌려주지 않는지 확인
- ISR, RX task,
tcpip_thread중 어느 컨텍스트가 pbuf ownership을 갖는지 정리 pbuf주소, ref count, input 반환값, free 여부를 같은 로그 라인에 남기는지 확인
함께 보면 좋은 글
- lwIP pbuf가 가끔 터진다: PBUF_REF/POOL/RAM 수명주기와 zero-copy 함정
- lwIP에서 RX는 되는데 payload가 가끔 깨진다: DMA 버퍼와 D-Cache 순서를 같이 봐야 한다
- lwIP에서 ISR에서 바로 보내면 가끔 터진다: tcpip_thread로 넘기는 패턴 정리
한 줄 요약
lwIP RX 경로에서 tcpip_input()으로 pbuf를 넘긴 뒤 문제가 난다면, 성공 시 ownership은 lwIP로 넘어가고 실패 시 driver가 free한다는 기준부터 코드와 로그에 분명히 두는 편이 빠르다.
추천 키워드
lwIP, TCPIP, pbuf, tcpip_input, Ethernet MAC, DMA
DevBJ | 오늘을살자, Log Today