1. 저수준 입출력
저수준 입출력(Low-Level I/O)은 운영 체제의 커널과 직접적으로 상호작용하는 입출력 방식을 의미합니다. 일반적으로 우리가 많이 사용하는 고수준 입출력 함수들(예: fopen, fread, fprintf)은 내부적으로 이러한 저수준 시스템 콜을 호출하여 동작합니다. 저수준 입출력 함수들은 버퍼링이 거의 없거나 전혀 없기 때문에, 세밀한 입출력 제어와 더불어 효율적인 자원 관리가 가능합니다. 이 강좌에서는 대표적인 저수준 시스템 콜인 open(), read(), write(), close()를 중심으로 설명합니다.
① open(), read(), write(), close()
이 함수들은 C 라이브러리 함수보다 낮은 수준에서 동작하는 입출력 함수로, 운영 체제의 커널에 직접 요청을 보냅니다. open() 함수는 파일을 열고, 성공 시 해당 파일을 식별하는 파일 디스크립터(File Descriptor)를 반환합니다. read()와 write()는 각각 파일에서 데이터를 읽고 쓰며, close()는 열린 파일을 닫아 자원을 해제합니다. 이 함수들은 버퍼링을 하지 않으므로, I/O 작업 시 실제 디스크 접근이 바로 이루어집니다.
파일 열기, 읽기, 쓰기 예제
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("testfile.txt", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("파일 열기 실패");
return 1;
}
char buf[100];
ssize_t bytes_read = read(fd, buf, sizeof(buf));
if (bytes_read == -1) {
perror("파일 읽기 실패");
close(fd);
return 1;
}
printf("읽은 내용: %.*s\n", (int)bytes_read, buf);
const char *text = "Hello, Low-Level I/O!";
ssize_t bytes_written = write(fd, text, 19);
if (bytes_written == -1) {
perror("파일 쓰기 실패");
close(fd);
return 1;
}
close(fd);
return 0;
}
위 예제에서 open() 함수는 읽기와 쓰기 모드로 파일을 열며, 파일이 없으면 새로 생성합니다. 이후 read()로 파일 내용을 읽고 출력하며, write()로 텍스트를 파일 끝에 씁니다. 마지막으로 close()를 호출하여 열린 파일을 닫고 시스템 자원을 반환합니다.
② 논블로킹 I/O
기본적으로 저수준 입출력은 블로킹(Blocking) 방식으로 동작하여, 데이터가 준비될 때까지 호출한 프로세스가 대기합니다. 반면, 논블로킹(Non-blocking) I/O는 호출 즉시 반환되어 프로세스가 계속 실행될 수 있게 합니다. 논블로킹 I/O는 특히 네트워크 통신, 실시간 데이터 처리, 고성능 서버 등에서 효율적인 자원 활용과 높은 반응성을 제공합니다. 논블로킹 모드는 open()</code 함수 호출 시 O_NONBLOCK 플래그를 지정하여 활성화할 수 있습니다.
논블로킹 모드로 파일 열기 예제
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("testfile.txt", O_RDWR | O_CREAT | O_NONBLOCK, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("파일 열기 실패");
return 1;
}
// 논블로킹 I/O 작업 수행 가능
// 예: read()가 즉시 반환되어, 읽을 데이터가 없으면 -1 반환 및 errno=EAGAIN 설정
close(fd);
return 0;
}
③ select()와 epoll()
여러 개의 파일 디스크립터를 동시에 감시하며 입출력 준비 상태를 확인하는 데 select()와 epoll() 시스템 콜이 사용됩니다. 이는 특히 네트워크 서버나 이벤트 기반 프로그램에서 많은 소켓과 파일에 대한 비동기 입출력을 효율적으로 처리하는 데 필수적입니다. select()는 오래된 인터페이스로, 감시할 디스크립터 수에 제한이 있고 성능 이슈가 있으나 간단한 프로그램에 적합합니다. epoll()은 리눅스 전용으로, 대규모 다중 입출력 처리에 뛰어난 성능을 발휘합니다.
select() 예제
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>
#include <fcntl.h>
int main() {
int fd = open("testfile.txt", O_RDWR | O_NONBLOCK);
if (fd == -1) {
perror("파일 열기 실패");
return 1;
}
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
struct timeval timeout;
timeout.tv_sec = 5; // 5초 대기
timeout.tv_usec = 0;
int ret = select(fd + 1, &readfds, NULL, NULL, &timeout);
if (ret == -1) {
perror("select 실패");
} else if (ret == 0) {
printf("타임아웃 발생\n");
} else {
if (FD_ISSET(fd, &readfds)) {
printf("파일 읽기 가능\n");
}
}
close(fd);
return 0;
}
epoll() 예제
#include <stdio.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <fcntl.h>
int main() {
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1 실패");
return 1;
}
int fd = open("testfile.txt", O_RDONLY | O_NONBLOCK);
if (fd == -1) {
perror("파일 열기 실패");
close(epoll_fd);
return 1;
}
struct epoll_event event;
event.events = EPOLLIN; // 읽기 이벤트 감시
event.data.fd = fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) {
perror("epoll_ctl 실패");
close(fd);
close(epoll_fd);
return 1;
}
struct epoll_event events[1];
int nfds = epoll_wait(epoll_fd, events, 1, -1); // 무한 대기
if (nfds == -1) {
perror("epoll_wait 실패");
close(fd);
close(epoll_fd);
return 1;
}
printf("파일 읽기 가능\n");
close(fd);
close(epoll_fd);
return 0;
}
④ 저수준 입출력 활용 예: 파이프(Pipe)와 FIFO
저수준 입출력은 단순 파일뿐 아니라 프로세스 간 통신(IPC)에서도 광범위하게 사용됩니다. 대표적인 예가 파이프(pipe)와 FIFO(named pipe)입니다. 파이프는 한 프로세스에서 데이터를 쓰고, 다른 프로세스에서 읽을 수 있는 통로를 제공합니다. FIFO는 이름이 있는 파이프로, 파일 시스템 상에 존재하며 서로 다른 프로세스들이 통신하는 데 사용됩니다.
파이프 예제
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int pipefd[2];
char buf[100];
if (pipe(pipefd) == -1) {
perror("pipe 생성 실패");
return 1;
}
pid_t pid = fork();
if (pid == -1) {
perror("fork 실패");
return 1;
}
if (pid == 0) { // 자식 프로세스: 파이프에서 읽기
close(pipefd[1]); // 쓰기 끝 닫기
ssize_t count = read(pipefd[0], buf, sizeof(buf)-1);
if (count > 0) {
buf[count] = '\0';
printf("자식 프로세스가 읽은 데이터: %s\n", buf);
}
close(pipefd[0]);
} else { // 부모 프로세스: 파이프에 쓰기
close(pipefd[0]); // 읽기 끝 닫기
const char *msg = "Hello through pipe!";
write(pipefd[1], msg, strlen(msg));
close(pipefd[1]);
}
return 0;
}
위 예제는 부모 프로세스가 파이프에 메시지를 쓰고, 자식 프로세스가 이를 읽어 출력합니다. 이처럼 저수준 입출력은 프로세스 간 효율적인 데이터 전달 수단으로도 활용됩니다.
⑤ 입출력 오류 처리와 주의사항
저수준 입출력 함수들은 호출 시 오류가 발생할 가능성이 있으므로, 항상 반환 값을 검사하고 perror()나 strerror()를 이용해 적절한 오류 처리를 해야 합니다. 또한, 논블로킹 I/O에서는 읽기나 쓰기가 즉시 완료되지 않을 수 있으므로, EAGAIN 또는 EWOULDBLOCK 오류를 처리하여 재시도 로직을 구현해야 합니다. 그리고 파일 디스크립터 누수 방지를 위해 close() 호출을 잊지 않는 것이 중요합니다.
⑥ 요약 및 활용 방안
저수준 입출력은 운영 체제와 직접 상호작용하며, 고수준 입출력보다 더 세밀하고 빠른 제어가 가능합니다. 시스템 프로그래밍, 네트워크 서버, 임베디드 시스템, 실시간 애플리케이션 등 다양한 분야에서 필수적으로 사용됩니다. 특히 논블로킹 모드와 select(), epoll() 등의 이벤트 감시 메커니즘을 함께 활용하면, 대규모 입출력 처리에 매우 효과적입니다. 이처럼 저수준 입출력을 이해하고 적절히 활용하는 것은 안정적이고 고성능 시스템 개발의 기반이 됩니다.
'Programming' 카테고리의 다른 글
| C 자료구조와 알고리즘 구현 (51) | 2025.08.27 |
|---|---|
| C 네트워크 프로그래밍 (52) | 2025.08.26 |
| C 시스템 콜과 POSIX API (46) | 2025.08.24 |
| C 컴파일 과정 (42) | 2025.08.23 |
| C 메모리 모델 심층 분석 (66) | 2025.08.22 |