Preempt RT에서 실시간 시스템에서의 통신은 매우 중요한 요소이다. 특히, 파이프(pipe)와 소켓(socket)은 프로세스 간 통신(IPC)을 위한 강력한 도구로 사용된다. 이 장에서는 파이프와 소켓을 활용하여 실시간 데이터를 교환하는 방법에 대해 자세히 설명한다.

파이프를 통한 통신

파이프는 두 프로세스 간에 데이터를 전송할 수 있는 일방향 또는 양방향 통신 채널이다. 파이프를 사용하면 하나의 프로세스가 데이터를 쓰고 다른 프로세스가 이를 읽는 방식으로 데이터를 교환할 수 있다.

파이프의 기본 개념

파이프는 커널에 의해 관리되는 버퍼로, 두 프로세스 간에 데이터를 전달하기 위해 사용된다. 파이프는 크게 다음 두 가지 형태로 구분된다:

  1. 익명 파이프(Anonymous Pipe): 부모-자식 프로세스 간에만 사용할 수 있으며, 주로 단일 머신 내에서 프로세스 간 통신에 사용된다.
  2. 이름 있는 파이프(Named Pipe, FIFO): 서로 관련 없는 프로세스 간에도 사용할 수 있으며, 파이프에 이름이 붙어 있어 시스템의 모든 프로세스가 접근할 수 있다.

익명 파이프의 사용법

익명 파이프는 주로 pipe() 시스템 호출을 통해 생성된다. 이 함수는 두 개의 파일 디스크립터를 포함하는 배열을 반환한다. 하나는 파이프의 읽기 끝(read end)을 가리키고, 다른 하나는 쓰기 끝(write end)을 가리킨다.

int fd[2];
pipe(fd);

위 코드에서 fd[0]은 읽기 끝을, fd[1]은 쓰기 끝을 나타낸다. 데이터를 쓰려면 write(fd[1], data, size)를 사용하고, 읽으려면 read(fd[0], buffer, size)를 사용한다.

파이프를 사용한 통신에서 중요한 점은, 실시간성을 보장하기 위해 파이프의 크기와 데이터 전송 속도에 주의를 기울여야 한다는 것이다. 특히, 파이프가 가득 차거나 비어 있을 때 발생할 수 있는 블로킹(blocking) 현상을 피하기 위해서는 비동기 I/O와 같은 기법을 사용하는 것이 필요하다.

이름 있는 파이프의 사용법

이름 있는 파이프는 mkfifo() 시스템 호출을 통해 생성된다. 이 파이프는 파일 시스템의 특별한 파일로 간주되며, 여러 프로세스가 이 파일을 통해 통신할 수 있다.

mkfifo("/tmp/my_fifo", 0666);

위 명령은 /tmp/my_fifo라는 이름의 FIFO 파일을 생성한다. 이 파일을 통해 통신하려는 프로세스들은 일반적인 파일 입출력 함수(open(), read(), write() 등)를 사용하여 데이터를 교환할 수 있다.

소켓을 통한 통신

소켓은 네트워크를 통해 데이터 통신을 할 수 있는 더 강력하고 유연한 도구이다. 소켓을 사용하면 같은 호스트 내의 프로세스 간 통신뿐만 아니라, 다른 호스트에 있는 프로세스와도 통신할 수 있다.

소켓의 기본 개념

소켓은 IP 주소와 포트 번호를 기반으로 네트워크 상에서 데이터를 송수신할 수 있게 해주는 인터페이스이다. 소켓을 생성하고 사용하기 위해서는 다음과 같은 절차를 거친다:

  1. 소켓 생성: socket() 함수로 소켓을 생성한다.
  2. 주소 지정: bind() 함수로 소켓에 IP 주소와 포트 번호를 지정한다.
  3. 연결 대기: 서버 측에서 listen() 함수를 통해 클라이언트의 연결 요청을 대기한다.
  4. 연결 수락: 클라이언트의 연결 요청이 들어오면 accept() 함수로 이를 수락한다.
  5. 데이터 송수신: send()recv() 함수로 데이터를 송수신한다.
  6. 소켓 종료: 통신이 끝나면 close() 함수로 소켓을 종료한다.

TCP 소켓의 사용법

TCP(Transmission Control Protocol) 소켓은 신뢰성 있는 연결 지향형 통신을 제공한다. TCP 소켓을 사용한 통신의 간단한 예제는 다음과 같다.

서버 측:

int server_fd, client_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);

// 소켓 생성
server_fd = socket(AF_INET, SOCK_STREAM, 0);

// 주소 및 포트 지정
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);

bind(server_fd, (struct sockaddr *)&address, sizeof(address));

// 연결 대기
listen(server_fd, 3);

// 연결 수락
client_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);

// 데이터 송수신
read(client_fd, buffer, size);
write(client_fd, data, size);

클라이언트 측:

int sock = 0;
struct sockaddr_in serv_addr;

// 소켓 생성
sock = socket(AF_INET, SOCK_STREAM, 0);

serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);

connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

// 데이터 송수신
send(sock, data, size, 0);
recv(sock, buffer, size, 0);

TCP 소켓은 연결이 수립된 후 데이터가 손실되지 않고 순서대로 도착하도록 보장한다. 그러나 이러한 안정성 때문에 약간의 오버헤드가 발생할 수 있으며, 이는 실시간 시스템에서 주의해야 할 점이다.

UDP 소켓의 사용법

UDP(User Datagram Protocol) 소켓은 비연결 지향형 통신을 제공한다. 이는 TCP와 달리 데이터 전송을 보장하지 않으며, 패킷이 손실되거나 순서가 뒤바뀔 수 있다. 그러나 이러한 특성 덕분에 오버헤드가 적고, 실시간 성능을 더 잘 보장할 수 있다.

서버 측:

int server_fd;
struct sockaddr_in server_addr, client_addr;
char buffer[1024];
socklen_t addr_len = sizeof(client_addr);

// 소켓 생성
server_fd = socket(AF_INET, SOCK_DGRAM, 0);

// 주소 및 포트 지정
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);

bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

// 데이터 수신
recvfrom(server_fd, buffer, 1024, MSG_WAITALL, (struct sockaddr *)&client_addr, &addr_len);

// 데이터 전송
sendto(server_fd, data, size, MSG_CONFIRM, (struct sockaddr *)&client_addr, addr_len);

클라이언트 측:

int sock;
struct sockaddr_in serv_addr;
char buffer[1024];
socklen_t addr_len = sizeof(serv_addr);

// 소켓 생성
sock = socket(AF_INET, SOCK_DGRAM, 0);

serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);

// 데이터 전송
sendto(sock, data, size, MSG_CONFIRM, (struct sockaddr *)&serv_addr, addr_len);

// 데이터 수신
recvfrom(sock, buffer, 1024, MSG_WAITALL, (struct sockaddr *)&serv_addr, &addr_len);

UDP 소켓은 데이터 전송이 매우 빠르고, 연결 설정 및 유지 비용이 없기 때문에 실시간 요구사항이 높은 시스템에서 유용하다. 그러나 데이터 신뢰성을 보장해야 하는 경우 추가적인 프로토콜이나 검증 절차를 구현해야 한다.

파이프와 소켓의 비교

파이프와 소켓은 둘 다 프로세스 간 통신(IPC)에 사용되지만, 그 사용 목적과 특성은 크게 다르다. 실시간 시스템에서 파이프와 소켓을 선택할 때는 다음과 같은 점들을 고려해야 한다.

이와 같은 특성을 이해하고, 실시간 시스템에서 요구되는 통신의 속도, 신뢰성, 범위 등을 고려하여 적절한 통신 방법을 선택해야 한다.

실시간 시스템에서의 비동기 통신

실시간 시스템에서는 통신 채널이 블로킹되는 것을 방지하기 위해 비동기 통신을 구현하는 것이 중요하다. 파이프와 소켓 모두 비동기 I/O 방식으로 구현할 수 있으며, 이를 통해 실시간성을 더욱 강화할 수 있다.

비동기 I/O 개념

비동기 I/O는 프로세스가 I/O 작업을 요청한 후 해당 작업이 완료되기를 기다리지 않고, 즉시 다음 작업을 수행할 수 있도록 한다. 이는 실시간 시스템에서 매우 중요한데, 이는 프로세스가 일정 시간 내에 응답해야 하는 경우가 많기 때문이다.

비동기 I/O를 구현하기 위해서는 다음과 같은 기술을 사용할 수 있다:

Non-blocking I/O 구현 예제

파이프와 소켓에서 비동기 I/O를 구현하는 간단한 예제를 보겠다.

Non-blocking 소켓 설정:

int flags = fcntl(sock_fd, F_GETFL, 0);
fcntl(sock_fd, F_SETFL, flags | O_NONBLOCK);

이 설정을 통해 소켓 sock_fd는 비동기 모드에서 작동하게 된다. 이제 이 소켓에서 데이터를 송수신할 때, 데이터가 준비되지 않았더라도 호출이 즉시 반환된다.

Multiplexing을 통한 비동기 통신

Multiplexing 기법은 하나의 프로세스가 여러 개의 파일 디스크립터(소켓, 파이프 등)를 동시에 감시할 수 있게 한다. 이는 실시간 시스템에서 여러 I/O 소스에서 오는 데이터를 효과적으로 처리할 수 있게 해준다.

select() 함수의 사용법:

select() 함수는 주어진 시간 동안 하나 이상의 파일 디스크립터에서 데이터가 준비될 때까지 대기한다. 준비된 파일 디스크립터를 확인한 후, 해당 디스크립터에서 I/O 작업을 수행할 수 있다.

fd_set readfds;
struct timeval tv;
int retval;

// 파일 디스크립터 집합 초기화
FD_ZERO(&readfds);
FD_SET(sock_fd, &readfds);

// 타임아웃 설정 (2초)
tv.tv_sec = 2;
tv.tv_usec = 0;

// 파일 디스크립터를 감시
retval = select(sock_fd + 1, &readfds, NULL, NULL, &tv);

if (retval == -1) {
    perror("select()");
} else if (retval) {
    // 데이터가 준비됨
    read(sock_fd, buffer, size);
} else {
    // 타임아웃 발생
    printf("No data within two seconds.\n");
}

위의 코드에서 select() 함수는 소켓 sock_fd에서 데이터가 수신될 때까지 최대 2초 동안 대기한다. 데이터가 수신되면 해당 소켓에서 데이터를 읽을 수 있다.

poll() 함수의 사용법:

poll() 함수는 select()와 유사하지만, 파일 디스크립터 집합의 크기에 제한이 없으며 더 많은 기능을 제공한다.

struct pollfd fds[1];
int timeout_msecs = 2000; // 2초

// 감시할 소켓 설정
fds[0].fd = sock_fd;
fds[0].events = POLLIN; // 읽기 가능한지 감시

int ret = poll(fds, 1, timeout_msecs);

if (ret > 0) {
    if (fds[0].revents & POLLIN) {
        // 데이터가 준비됨
        read(sock_fd, buffer, size);
    }
} else if (ret == 0) {
    // 타임아웃 발생
    printf("Poll timed out.\n");
} else {
    perror("poll()");
}

poll() 함수는 파일 디스크립터가 여러 개일 때 유용하며, 각각의 디스크립터에 대해 다양한 이벤트를 감시할 수 있다.

실시간 통신에서의 우선순위 관리

실시간 시스템에서 통신의 우선순위 관리도 중요한 요소이다. 특정 데이터 전송이 다른 데이터 전송보다 더 중요한 경우, 이를 효과적으로 관리해야 한다. 이를 위해 우선순위 기반 스케줄링이나 큐를 사용할 수 있다.

우선순위 기반 통신

우선순위 기반 통신에서는 중요도가 높은 데이터를 먼저 처리할 수 있도록 통신 경로를 설정하거나 프로세스를 스케줄링한다. 예를 들어, 중요한 데이터는 TCP 소켓을 사용해 신뢰성 있는 경로로 전송하고, 덜 중요한 데이터는 UDP 소켓을 통해 빠르게 전송할 수 있다.

또한, 실시간 커널에서 우선순위 기반의 스케줄링 정책을 적용해 높은 우선순위의 프로세스가 I/O 작업을 빠르게 수행할 수 있도록 지원할 수 있다. 이를 통해 실시간 통신의 예측 가능성과 효율성을 높일 수 있다.

공유 메모리와의 통합

파이프와 소켓을 통한 통신 방법은 공유 메모리와 함께 사용할 때 더욱 강력해질 수 있다. 특히, 공유 메모리를 활용하여 파이프나 소켓을 통해 전송되는 데이터를 캐싱하거나, 메모리 맵 파일을 이용해 대용량 데이터를 빠르게 공유할 수 있다.

예시: 공유 메모리와 소켓 통합

예를 들어, 실시간 애플리케이션에서 대용량 데이터를 공유 메모리에 저장하고, 소켓을 통해 해당 데이터의 참조나 작은 업데이트를 전송할 수 있다. 이는 네트워크 대역폭을 절약하고, 데이터를 신속하게 접근할 수 있게 한다.

// 공유 메모리 생성
int shm_fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, SHM_SIZE);
void *shm_ptr = mmap(0, SHM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);

// 공유 메모리에 데이터 쓰기
memcpy(shm_ptr, data, data_size);

// 소켓을 통해 업데이트 알림 전송
send(sock_fd, "update", sizeof("update"), 0);

위 코드는 공유 메모리에 데이터를 쓰고, 소켓을 통해 다른 프로세스에 업데이트가 발생했음을 알리는 방식이다. 이 접근 방식은 특히 실시간 시스템에서 큰 데이터를 빠르게 처리해야 할 때 유용하다.