lwIP를 RTOS 위에 올리고 나면 꼭 한 번은 이런 형태를 겪는다.
- 낮은 부하에서는 멀쩡하다
- 부하가 올라가면 간헐적으로 멈춘다
- 재현이 어렵다 (가끔은 며칠 돌다가 죽는다)
- 스택을 보면 netif/udp/tcp 쪽에서 터져 있다
그리고 로그를 보면 이런 코드가 슬쩍 보인다.
- IRQ(ETH IRQ)에서
udp_send()/tcp_write()를 호출 - 혹은 RX 인터럽트에서
netif->input()체인을 끝까지 태움
결론부터 말하면,
ISR에서 lwIP 코어를 “정면으로” 건드리면 랜덤 이슈가 나기 쉽다.
왜 ISR 호출이 위험한가 (실무 관점)
lwIP는 크게 두 가지 모드로 쓰인다.
- RAW API: 빠르지만, 호출 컨텍스트 규칙을 안 지키면 바로 깨짐
- netconn/sockets API: 내부적으로 메시지 패싱이 들어가고, 규칙을 조금 더 지켜줌
RTOS + tcpip_thread 구성에서는
lwIP 내부 상태가 사실상 tcpip_thread 중심으로 돌아간다고 보면 된다.
이 상태에서 ISR이 중간에 치고 들어오면 문제는 보통 이 두 가지다.
- 락 없이 코어 구조체를 만짐 (race)
- ISR에서 블로킹/메모리 할당/큐 포화가 발생 (타이밍 꼬임)
그래서 “가끔만 터지는” 형태가 된다.
목표: ISR에서는 최소만, 나머지는 tcpip_thread로
ISR에서 할 일은 진짜 최소로 잡는 게 편하다.
- DMA descriptor 정리
- RX frame 길이/포인터 확보
- 드라이버 큐에 넣고 빠르게 return
그리고 lwIP API 호출, pbuf 생성/체인, 프로토콜 처리는
tcpip_thread 컨텍스트로 넘겨서 처리한다.
가장 흔한 구조: ISR → queue → net task → tcpip_thread
구조는 대략 이렇다.
ETH IRQ
→ (frame pointer/len만) RX queue push
→ net task가 queue pop
→ tcpip_callback()로 lwIP 컨텍스트에 작업 전달
이 패턴의 장점은 단순하다.
- ISR이 짧아짐 (시스템 전체 안정성↑)
- lwIP 코어 접근이 한 방향으로 정리됨 (재현 어려운 버그↓)
코드 스케치: tcpip_callback으로 넘기기
아래는 “개념”만 잡는 용도다.
프로젝트마다 드라이버/메모리 모델이 다르니 그대로 붙여넣기용은 아니다.
struct rx_item {
void *buf;
uint16_t len;
};
static void lwip_rx_in_tcpip_thread(void *arg) {
struct rx_item *item = (struct rx_item *)arg;
// 여기서 pbuf를 만들고(혹은 REF로 감싸고) netif->input으로 넘긴다.
// pbuf 생명주기(REF 사용 시 free 콜백)가 핵심 포인트.
// struct pbuf *p = pbuf_alloc(PBUF_RAW, item->len, PBUF_POOL);
// memcpy(p->payload, item->buf, item->len);
// if (netif->input(p, &gnetif) != ERR_OK) pbuf_free(p);
}
void ethernet_rx_task(void *arg) {
for (;;) {
struct rx_item item;
// queue_pop(&item); // IRQ가 넣어둔 프레임
tcpip_callback(lwip_rx_in_tcpip_thread, &item);
}
}
여기서 포인트는 두 가지다.
- tcpip_callback으로 들어간 함수는 tcpip_thread 컨텍스트에서 돈다
- pbuf를 “어디서 만들고 어디서 free 할지”를 명확히 해야 한다
자주 깨지는 지점 4개
1) tcpip_callback에 넘긴 인자가 스택 변수다
위 스케치처럼 &item을 넘기면,
콜백이 실행되기 전에 item이 바뀌거나 사라질 수 있다.
실무에서는 보통 이렇게 한다.
- 큐에서 꺼낸 프레임은 “프레임 버퍼 포인터”로 넘김 (버퍼는 풀에서 관리)
- 혹은 별도의 고정 풀에서
rx_item을 할당해서 넘김
2) ISR에서 pbuf_alloc()를 한다
이건 생각보다 많이 본다.
보드/힙/락 구현에 따라 랜덤하게 죽기 딱 좋다.
가능하면 pbuf_alloc()은 tcpip_thread 쪽에서 하자.
3) LWIP_TCPIP_CORE_LOCKING을 켜놓고 규칙을 섞어쓴다
core locking을 켰다고 해서 “아무데서나 호출”이 되는 게 아니다.
- 어떤 API는 코어 락이 필요
- 어떤 API는 호출 컨텍스트 자체가 제한
프로젝트에서는 한 가지 모델로 정리하는 게 덜 아프다.
tcpip_callback/tcpip_try_callback기반으로 코어 접근 단일화- 혹은
LOCK_TCPIP_CORE()규칙을 코드 전반에 강제
4) TCPIP_MBOX_SIZE가 작아서 콜백이 드롭된다
콜백/메시지는 결국 mbox로 쌓인다.
부하가 올라가면 “가끔만” 콜백이 누락될 수 있다.
이때 증상은 보통 이렇다.
- RX는 들어오는데 상위가 처리 못함
- UDP는 간헐적으로 끊기는 것처럼 보임
- 타이밍에 따라 멀쩡해 보이기도 함
이런 경우에는 mbox/큐의 수용량과
net task 우선순위를 같이 봐야 한다.
빠른 디버깅 체크리스트
- lwIP API가 ISR/드라이버 컨텍스트에서 호출되는지부터 grep
LWIP_ASSERT/LWIP_STATS를 켜서 “어디가 막히는지” 숫자로 보기tcpip_thread우선순위/스택,TCPIP_MBOX_SIZE를 같이 확인pbuf누수/고갈이면memp/pbuf통계부터 본다- “가끔만”이면 대부분 컨텍스트/락/큐 포화다 (확률 이슈)
결론
lwIP는 성능도 좋고 유연한데, RTOS 환경에서는 “호출 컨텍스트 규칙”을 어기면 랜덤 이슈가 나온다. ISR에서는 최소만 하고, tcpip_thread로 넘기는 구조로 정리하면 재현 어려운 네트워크 버그가 크게 줄어든다.
DevBJ | No Bio, Just Log 기술 삽질로그