안녕하세요, DevBJ입니다. 2026년, 임베디드 및 네트워크 시스템 엔지니어로서 실시간 시스템을 개발하며 가장 뜨거운 감자는 단연 ‘지연 시간(Latency)‘입니다. 특히 자율주행 분야에서는 ‘밀리초’ 단위의 지연조차 시스템의 안정성과 신뢰성에 치명적일 수 있습니다. 오늘 다룰 삽질 주제는 바로 여기에 초점을 맞췄습니다. 🚀
최신 엣지 디바이스들은 온보드에서 AI 가속기를 통해 복잡한 센서 융합 및 객체 인식을 실시간으로 수행하고 있습니다. 문제는, 이 가속기에서 쏟아져 나오는 방대한 추론 결과를 다른 컨트롤러나 중앙 처리 장치로 ‘얼마나 빠르게’, ‘얼마나 효율적으로’ 전달하느냐입니다. 일반적인 네트워크 스택으로는 만족할 만한 성능을 내기 어렵죠. 그래서 저는 LWIP의 Raw API를 활용하여 이 지연 시간을 극단적으로 줄여보는 도전을 했습니다. 🔥
문제 인식: AI 추론 결과, 그놈의 전송 지연!
저의 현재 프로젝트는 자율주행 차량의 엣지 센서 허브에서 NVIDIA Orin 기반의 커스텀 AI 가속기가 주변 환경 데이터를 분석하고, 1ms 주기로 약 1KB 크기의 핵심 센서 융합 결과(객체 좌표, 속도, 분류 등)를 마스터 컨트롤러로 전송해야 하는 시나리오입니다.
초기에는 임베디드 Linux 환경에서 일반적인 UDP 소켓(sendto)을 사용하여 구현했습니다. 간단하고 직관적이지만, 벤치마킹 결과 송신 측에서 패킷당 평균 200~300us, 수신 측까지의 총 왕복 지연 시간은 500us 이상이 나오는 문제가 있었습니다. 일반적인 Linux 커널의 네트워크 스택 오버헤드와 인터럽트 처리 지연이 원인이었습니다. 1ms 주기 안에 이 모든 것을 처리하고 다른 로직까지 돌리려니, 마진이 너무 부족했습니다.
그럼 어떻게 이 지연 시간을 획기적으로 줄일 수 있을까요? 💡
LWIP Raw API: 저수준 네트워크 제어의 힘
해답은 LWIP (Lightweight IP) 스택, 그 중에서도 ‘Raw API’에 있었습니다. LWIP는 리소스 제약이 있는 임베디드 시스템을 위해 설계된 경량 TCP/IP 스택입니다. 일반적인 OS의 커널 스택보다 훨씬 가볍고, OS 추상화 계층(OS Abstraction Layer)을 최소화하여 오버헤드를 극적으로 줄일 수 있습니다.
특히 LWIP의 Raw API는 소켓 API나 netconn API보다 훨씬 더 낮은 수준에서 네트워크 패킷을 직접 제어할 수 있게 해줍니다. UDP pcb(Protocol Control Block)를 직접 생성하고, pbuf(Packet Buffer)에 데이터를 직접 담아 전송하는 방식이죠. 이를 통해 시스템 콜 오버헤드를 줄이고, 불필요한 메모리 복사를 최소화하여 네트워크 처리의 오버헤드를 극단적으로 낮출 수 있습니다.
삽질 기록: LWIP Raw UDP 구현 및 최적화 🛠️
1. LWIP 환경 설정 (lwipopts.h)
가장 먼저 LWIP를 프로젝트에 통합하고 lwipopts.h 파일을 최적화했습니다. 불필요한 기능은 모두 비활성화하고, UDP 및 PBUF 관련 설정을 조정했습니다. 특히 메모리 할당 방식을 효율적으로 설정하는 것이 중요합니다.
// lwipopts.h 핵심 설정 예시
#define LWIP_UDP 1
#define LWIP_IPV4 1
#define LWIP_NETIF_LINK_CALLBACK 1
// MEMP_NUM_UDP_PCB: 동시에 열 수 있는 UDP PCB 개수
#define MEMP_NUM_UDP_PCB 4
// PBUF_POOL_SIZE: PBUF 풀에 미리 할당할 PBUF 개수
// 전송할 패킷 크기와 동시에 처리될 패킷 수를 고려하여 설정
#define PBUF_POOL_SIZE 16
#define PBUF_POOL_BUFSIZE 1536 // 이더넷 MTU에 맞춤
// 메모리 할당 전략 (빠른 할당을 위해 MEM_LIBC_MALLOC 대신 MEM_USE_POOLS를 고려)
// 이 예시에서는 PBUF POOL을 사용하므로 MEM_LIBC_MALLOC 사용해도 무방
#define MEM_LIBC_MALLOC 1
#define MEM_ALIGNMENT 4
// 디버깅 정보 비활성화로 성능 향상
#define LWIP_DBG_MIN_LEVEL LWIP_DBG_OFF
#define UDP_DEBUG LWIP_DBG_OFF
#define ETHARP_DEBUG LWIP_DBG_OFF
// ... 기타 불필요한 기능 OFF ...
#define LWIP_TCP 0
#define LWIP_DHCP 0
#define LWIP_AUTOIP 0
#define LWIP_SNMP 0
// ...
2. LWIP 초기화 및 네트워크 인터페이스 설정
LWIP 스택과 이더넷 인터페이스를 초기화하는 과정입니다. 제 경우에는 전용 이더넷 컨트롤러(MAC)를 LWIP에 연결했습니다.
#include "lwip/init.h"
#include "lwip/netif.h"
#include "lwip/udp.h"
#include "lwip/pbuf.h"
#include "netif/etharp.h" // 이더넷 ARP 지원 (필요시)
static struct netif eth_netif;
extern err_t ethernetif_init(struct netif *netif); // 사용자 정의 이더넷 인터페이스 초기화 함수
void lwip_init_task(void *pvParameters) {
ip4_addr_t ipaddr, netmask, gw;
lwip_init(); // LWIP 스택 초기화
// IP 주소 설정 (환경에 맞게 변경)
IP4_ADDR(&ipaddr, 192, 168, 1, 100);
IP4_ADDR(&netmask, 255, 255, 255, 0);
IP4_ADDR(&gw, 192, 168, 1, 1);
// 네트워크 인터페이스 추가
// ethernetif_init: 실제 이더넷 MAC 드라이버와 LWIP를 연결하는 함수
netif_add(ð_netif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, netif_input);
netif_set_default(ð_netif);
netif_set_up(ð_netif);
printf("LWIP initialized with IP: %s\n", ip4addr_ntoa(&ipaddr));
// 이더넷 트래픽 처리를 위한 루프 (RTOS 태스크 등)
while (1) {
// 이더넷 드라이버의 poll 또는 receive 함수 호출
// eth_netif.input(pbuf, netif); (pbuf는 드라이버에서 받은 패킷)
// FreeRTOS 사용 시 vTaskDelay 등으로 적절히 스케줄링
vTaskDelay(pdMS_TO_TICKS(1)); // 1ms마다 네트워크 처리
}
}
3. UDP Raw API 송신 측 코드
이제 AI 가속기에서 데이터를 받아서 직접 UDP Raw API로 전송하는 핵심 로직입니다. 중요한 것은 pbuf_alloc으로 PBUF_RAM 타입을 할당하고, 데이터를 복사한 후 udp_sendto로 바로 전송하는 것입니다. 메모리 복사를 줄이기 위해, AI 가속기에서 데이터를 생성할 때부터 pbuf에 직접 쓸 수 있다면 더욱 효율적입니다.
#include "lwip/udp.h"
#include "lwip/pbuf.h"
#include <string.h>
// 전역 또는 태스크 내에서 유지되어야 할 UDP PCB
static struct udp_pcb *send_pcb;
static ip_addr_t remote_ip;
static u16_t remote_port = 12345;
// AI 추론 결과 전송 초기화 함수
void ai_data_sender_init(void) {
send_pcb = udp_new();
if (send_pcb == NULL) {
printf("Error: Failed to create UDP PCB.\n");
return;
}
// 전송할 대상 IP 주소 설정
IP4_ADDR(&remote_ip, 192, 168, 1, 101); // 마스터 컨트롤러 IP
printf("AI data sender initialized. Target IP: %s, Port: %d\n", ip4addr_ntoa(&remote_ip), remote_port);
}
// AI 추론 결과 전송 함수
void send_ai_inference_result(const void *data, size_t len) {
if (send_pcb == NULL) {
printf("Error: UDP PCB not initialized.\n");
return;
}
if (len > PBUF_POOL_BUFSIZE) {
printf("Error: Data length (%zu) exceeds PBUF_POOL_BUFSIZE (%d).\n", len, PBUF_POOL_BUFSIZE);
return;
}
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); // PBUF_TRANSPORT 또는 PBUF_RAM
if (p == NULL) {
printf("Error: Failed to allocate pbuf.\n");
return;
}
// 데이터를 pbuf에 복사
memcpy(p->payload, data, len);
err_t err = udp_sendto(send_pcb, p, &remote_ip, remote_port);
if (err != ERR_OK) {
printf("Error: udp_sendto failed with error code %d\n", err);
}
// pbuf는 전송 후 반드시 해제해야 합니다!
pbuf_free(p);
}
// 실제 AI 추론 결과라고 가정할 더미 데이터
typedef struct {
uint32_t frame_id;
float obj_x[10], obj_y[10], obj_z[10]; // 10개 객체 위치
uint8_t obj_class[10];
// ... 기타 센서 융합 데이터
} __attribute__((packed)) AiInferenceResult_t;
// 주기적으로 데이터를 전송하는 태스크 (예: FreeRTOS)
void ai_data_send_task(void *pvParameters) {
ai_data_sender_init();
AiInferenceResult_t result_data;
uint32_t current_frame_id = 0;
// GPIO 토글을 이용한 지연 시간 측정 시작 지점
// GPIO_SetBits(GPIOB, GPIO_Pin_0);
while (1) {
// AI 가속기에서 최신 추론 결과 가져오기 (가정)
result_data.frame_id = current_frame_id++;
for (int i = 0; i < 10; i++) {
result_data.obj_x[i] = (float)current_frame_id + i * 0.1f;
result_data.obj_y[i] = (float)current_frame_id + i * 0.2f;
result_data.obj_z[i] = (float)current_frame_id + i * 0.3f;
result_data.obj_class[i] = (uint8_t)(current_frame_id % 5);
}
// 실제 데이터 전송
send_ai_inference_result(&result_data, sizeof(result_data));
// GPIO 토글을 이용한 지연 시간 측정 종료 지점 (송신 완료)
// GPIO_ResetBits(GPIOB, GPIO_Pin_0);
// 1ms 주기 유지를 위해 대기
vTaskDelay(pdMS_TO_TICKS(1)); // FreeRTOS 예시
}
}
4. UDP Raw API 수신 측 코드
수신 측에서는 udp_recv 콜백 함수를 등록하여 패킷이 도착할 때마다 바로 처리하도록 합니다. 이는 Polling 방식보다 반응성이 좋습니다.
#include "lwip/udp.h"
#include "lwip/pbuf.h"
#include <string.h>
static struct udp_pcb *recv_pcb;
static u16_t listen_port = 12345;
// UDP 패킷 수신 콜백 함수
void udp_receive_callback(void *arg, struct udp_pcb *pcb, struct pbuf *p,
const ip_addr_t *addr, u16_t port) {
if (p != NULL) {
// GPIO 토글을 이용한 지연 시간 측정 시작 지점 (수신 완료)
// GPIO_SetBits(GPIOC, GPIO_Pin_1);
// 데이터 처리 로직
// p->payload에 실제 데이터가 들어있습니다.
// p->len은 데이터의 길이입니다.
printf("Received %d bytes from %s:%d\n", p->len, ip4addr_ntoa(addr), port);
// 수신된 데이터로 AiInferenceResult_t 구조체 복원
if (p->len == sizeof(AiInferenceResult_t)) {
AiInferenceResult_t *received_data = (AiInferenceResult_t *)p->payload;
//printf(" Frame ID: %lu, Obj X[0]: %.2f\n", received_data->frame_id, received_data->obj_x[0]);
}
// pbuf는 처리 후 반드시 해제해야 합니다!
pbuf_free(p);
// GPIO 토글을 이용한 지연 시간 측정 종료 지점
// GPIO_ResetBits(GPIOC, GPIO_Pin_1);
}
}
// AI 추론 결과 수신 초기화 함수
void ai_data_receiver_init(void) {
recv_pcb = udp_new();
if (recv_pcb == NULL) {
printf("Error: Failed to create UDP PCB for receiver.\n");
return;
}
// 특정 포트에서 UDP 패킷 수신 대기
err_t err = udp_bind(recv_pcb, IP_ADDR_ANY, listen_port);
if (err != ERR_OK) {
printf("Error: udp_bind failed with error code %d\n", err);
udp_remove(recv_pcb);
recv_pcb = NULL;
return;
}
// 수신 콜백 함수 등록
udp_recv(recv_pcb, udp_receive_callback, NULL);
printf("AI data receiver initialized. Listening on Port: %d\n", listen_port);
}
// 수신 태스크 (간단화)
void ai_data_recv_task(void *pvParameters) {
ai_data_receiver_init();
// LWIP의 netif_input()을 호출하는 루프가 메인 태스크 또는 별도 태스크에서 실행되어야 합니다.
// 여기서는 단순히 초기화만 하고, 실제 패킷 처리는 lwip_init_task나 OS의 이더넷 인터럽트 핸들러에서 이루어집니다.
while(1) {
vTaskDelay(pdMS_TO_TICKS(1000)); // 무한 대기
}
}
벤치마크 결과 및 개선 효과 ✨
이러한 Raw API 기반의 UDP 통신 최적화를 통해 놀라운 성능 개선을 달성했습니다.
저는 송신 측과 수신 측 보드에 GPIO 핀을 연결하고, 송신 직전과 수신 직후에 핀을 토글시켜 오실로스코프로 물리적인 지연 시간을 측정했습니다.
- 기존 임베디드 Linux 소켓 UDP (
sendto): 약 200~300us (송신측 소프트웨어 오버헤드만) - LWIP Raw UDP (
udp_sendto): 평균 30~50us (송신측 소프트웨어 오버헤드만)
전체 End-to-End 지연 시간(AI 가속기 -> LWIP -> 이더넷 -> 다른 보드 -> LWIP -> 처리 로직)은 네트워크 물리 계층 지연을 포함하여 100us 미만으로 떨어졌습니다. 이는 목표했던 1ms 주기 내에 데이터 처리 및 전송을 충분히 가능하게 하는 획기적인 개선이었습니다.
물론, 이러한 최적화는 전용 하드웨어(고속 이더넷 MAC 컨트롤러)와 최적화된 LWIP 설정, 그리고 RTOS의 스케줄링이 뒷받침되었을 때 가장 빛을 발합니다. 특히 PBUF_RAM을 사용하여 메모리 복사를 최소화하고, pbuf_free를 놓치지 않고 호출하여 메모리 누수를 방지하는 것이 중요합니다.
마치며: “깊게 파고들수록 답이 보인다”
이번 삽질은 겉으로 보기에 단순한 네트워크 통신일지라도, 자율주행과 같은 극한의 실시간성을 요구하는 시스템에서는 기본 스택을 뜯어보고 저수준까지 파고들어야만 원하는 성능을 얻을 수 있다는 것을 다시 한번 깨닫게 해주었습니다. 단순히 AI 가속기의 성능만 높이는 것이 아니라, 그 결과 데이터를 어떻게 효율적으로 전달할 것인가에 대한 고민이 매우 중요하다는 것이죠.
임베디드/네트워크 엔지니어 여러분들도 특정 병목 현상에 부딪혔을 때, “여기까지가 최선인가?”라고 단정 짓기보다, 더 깊은 레이어를 파고들어 보시길 권합니다. 생각보다 혁신적인 해결책이 그 안에 숨어있을 수 있습니다. 오늘도 이 지루하고도 매력적인 삽질의 세계에서 함께 고군분투하는 모든 엔지니어분들을 응원합니다! 💪
DevBJ | No Bio, Just Log 기술 삽질로그