입출력 전반


  • 입력과 출력은 정보 전달을 위한 핵심 과정이다. 프로세스 간의 정보 전달을 궁극적인 목적으로 하는 소켓 프로그래밍에서도 입출력은 핵심 절차이다.

  • 입출력 시스템의 동작 원리에 대해서 이해한다면 제작하는 프로그램의 완성도를 높일 수 있을 것이다.

파일 디스크립터, 파일 테이블, 파일


  • 파일 디스크립터는 프로세스가 파일에 접근할 때, 파일을 식별하기 위한 이름표이다. 소켓 또한 생성되면 파일 디스크립터 (소켓 디스크립터)가 부여된다.

  • 운영체제는 프로세스가 접근할 수 있는 자원인 파일을 다루기 위해서 프로세스별로 파일 디스크립터 테이블을 관리한다.

  • 예를 들어서 프로세스 A가 open() 함수를 이용하여 파일을 열었다면 프로세스 A의 파일 디스크립터 테이블에 파일에 접근하기 위한 정보가 기록된다.

  • 또한 운영체제는 시스템 전체에 열려 있는 모든 파일 정보(장치 및 소켓 포함)가 저장된다.

  • 디스크립터 테이블에는 다음과 같은 두 가지 정보가 저장된다.

    • 파일 디스크립터의 속성 정보를 저장하고 있는 flags
    • 운영체제가 관리하는 파일 레코드를 가리키는 포인터
  • 파일 테이블에는 다음과 같은 세 가지 정보가 저장된다.

    • file offset: 열려 있는 파일 중 현재 읽기 또는 쓰기가 실행될 위치를 저장
    • status flag: open 시스템 콜에서 지정하는 파일의 정보들을 저장
    • 실제 파일 위치를 가리키는 포인터

파일 디스크립터 테이블과 열린 파일 테이블의 엔트리(Entry)


  • open() 함수 또는 socket() 함수를 이용하면 프로세스 단위로 파일 디스크립터를 관리하기 위해서 사용되는 파일 디스크립터 테이블에 하나의 엔트리가 생성된다.

  • 이 엔트리에는 열린 파일 테이블의 엔트리를 참조하기 위한 포인터가 포함되어 있다. 또한, 앞의 함수들은 시스템 전체의 열린 파일을 관리하기 위해 사용되는 열린 파일 테이블의 엔트리도 생성하는 역할을 한다.

  • 해당 엔트리는 실제 파일이나 소켓이 가리키는 포인터를 가지고 있다. 같은 파일을 서로 다른 두 프로세스가 열 경우에 열린 파일 테이블의 엔트리는 두개가 되지만, 두 엔트리에서 가리키는 실제 저장소는 하나가 된다.

  • 프로세스가 fork() 함수에 의해서 복제되면 같은 파일 테이블 엔트리를 가리키는 파일 디스크립터 테이블 엔트리가 생성된다.

  • dup() 계열 함수를 호출하면 파일 디스크립터를 복사할 수 있다. dup 함수에 의해서 생성된 파일 디스크립터는 원본 파일 디스크립터와 동일한 열린 파일 테이블 엔트리를 참조한다.
int dup(int oldfd);
결과값 : 복사된 파일 디스크립터(새로 부여된 디스크립터)
  • 같은 프로세스 내에서 파일 디스크립터를 복제하는 함수이다.
int dup2(int oldfd, int newfd);

결과값 : 복사된 파일 디스크립터 (새로 부여된 디스크립터)

파일 오프셋이란?


  • 파일 오프셋은 읽기/쓰기의 기준이 되는 포인터 값이다.

  • 오프셋은 읽기/쓰기의 기준이 파일의 시작지점으로부터 몇 바이트 떨어져있는지를 나타낸다. 예를 들어서, read() 함수를 호출하여 5바이트를 읽는다면 오프셋 값을 기준으로 파일에서 5바이트의 데이터가 읽혀진다. 또한 이 경우에 오프셋 값은 5만큼 증가한다.

  • 물론 소켓에서는 오프셋 값은 의미가 없지만, 파일 입출력을 다룰 때는 오프셋 값에 주의해야한다.

  • 여기서 주목할만한 포인트는 오프셋 값은 파일 디스크립터 테이블이 아니라, 열린 파일 테이블에 있다는 점이다.

  • 따라서 dup()로 복제한 파일 디스크립터는 오프셋 값을 원본 디스크립터와 공유한다. 반면에 개별적인 open() 함수를 통해서 열린 파일 테이블에 독립적인 엔트리를 생성한 두 파일디스크립터들은 오프셋을 공유하지 않는다.

#include <sys/types.h>

#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);

인자

  • fd: 오프셋 값을 조회하거나 변경하고자하는 파일을 가리키는 디스크립터
  • offset: 변경할 오프셋의 크기
  • whence: 오프셋 변경의 기준이 되는 지점 (세 가지 매크로를 제공한다.)

결과값

  • 변경된 매크로 값 반환, 실패시 -1
  • whence 값으로 사용가능한 매크로
  • SEEK_SET: 파일의 시작 지점
  • SEEK_CUR: 현재 오프셋 값
  • SEEK_END: 파일의 마지막 지점 + 1

예제

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>

int main(int argc, char *argv[]) {
  int fd1, fd2, fd3;
  char buff[BUFSIZ];
  int read_len, n_write;
  if (argc != 2) {
    printf("usage: %s filename \n", argv[0]);
    return -1;
  }

  fd1 = open(argv[1], O_RDWR | O_CREAT | O_TRUNC);
  fd2 = open(argv[1], O_RDONLY);
  fd3 = dup(fd1);

  fgets(buff, BUFSIZ - 1, stdin);

  read_len = strlen(buff);
  n_write = write(fd1, "abc", 3);
  n_write += write(fd1, buff, read_len + 1);
  if (n_write == -1) printf("error");

  printf("offset (fd1): %d\n", (int) lseek(fd1, 0, SEEK_CUR));
  printf("offset (fd2): %d\n", (int) lseek(fd2, 0, SEEK_CUR));
  printf("offset (fd3): %d\n", (int) lseek(fd3, 0, SEEK_CUR));

  read(fd2, buff, 4);
  write(1, buff, 4);

  printf("-----\n");

  printf("offset (fd1): %d\n", (int) lseek(fd1, 0, SEEK_CUR));
  printf("offset (fd2): %d\n", (int) lseek(fd2, 0, SEEK_CUR));
  printf("offset (fd3): %d\n", (int) lseek(fd3, 0, SEEK_CUR));

  close(fd1);
  fgets(buff, BUFSIZ - 1, stdin);
  read_len = strlen(buff);
  n_write += write(fd3, buff, read_len);

  lseek(fd2, 0, SEEK_SET);
  memset(buff, 0, BUFSIZ);
  read_len = read(fd2, buff, n_write);
  write(1, buff, n_write);
  close(fd2);
  close(fd3);
  return 0;
}

  • 파일 디스크립터 fd1, fd2, fd3를 생성하는데, fd1, fd2는 각각 open() 함수로 열고 fd3는 fd1을 dup() 함수를 통해서 복제한 것이다.

  • 프로그램을 실행하면서 인자로 test.txt 를 넘겼다. 따라서 실행 파일과 같은 폴더에 test.txt 파일이 생성된다.

  • 그 다음으로는 사용자에게 문자열을 입력 받는 부분인데, 여기서는 this i a test file 이라는 문자열이 입력된다.

  • 문자열 입력 후 파일 디스크립터 fd1을 이용하여 파일에 쓰기 작업을 수행한다. 이때 사용자에게 입력 받은 문자열에 앞서 “abc” 라는 문자열을 저장한다. 따라서 문자열 24개와 엔터를 포함한 25개가 오프셋 값으로 저장된다.

  • fd2는 fd1과 같은 파일을 가리키고 있지만 서로 다른 열린 파일 테이블을 가리키고 있으므로 오프셋의 값이 0이라는 것을 확인할 수 있다.

  • 그 후에 fd2를 이용하여 파일의 4글자 값을 읽었을 때 오프셋 값이 증가한 것을 확인할 수 있다.

소켓 전용 입출력 함수


  • 지금까지 TCP 소켓에서는 읽기 쓰기는 저수준 입출력 함수인 read 함수와 write 함수를 이용했다.

  • read(), write() 함수는 리눅스의 추상화된 파일 (파일, 소켓, 디바이스 장치)에서 공통적으로 사용할 수 있는 저수준 입출력 함수이다.

  • 이러한 함수와는 별개로 소켓 전용 입출력 함수에 대해서 알아보도록 하자.

ssize_t send(int socket, const void *buffer, size_t length, int flags);

인자

  • socket: 데이터 전달을 위해서 사용하고자 하는 소켓 디스크립터

  • buffer: 전달하려고 하는 데이터를 저장하고 있는 공간의 주소

  • length: 전달하려고 하는 데이터의 길이

  • flag: 데이터 전송 시 사용할 수 있는 옵션들

  • send() 함수는 write() 함수보다 하나의 인자를 더 가지고 있다.

  • 소켓 입출력에서 필요한 옵션들을 지정하는데 사용할 수 있는 flags 라는 인자이다.

  • flags는 파일을 open 할 때 사용하는 flags 처럼 여러 설정 값의 비트 연산으로 구성된다.

  • flags 인자에 대해서는 읽기 기능을 수행하는 소켓 전용 함수인 recv를 다룬 후에 자세히 알아보자.

ssize_t recv(int socket, void *buffer, size_t length, int flags);

인자

  • socket: 데이터 읽기 작업을 위해 사용하고자하는 소켓 디스크립터
  • buffer: 읽어온 데이터를 저장하려고 하는 공간의 주소
  • length: 읽으려고 하는 데이터의 최대 길이
  • flag: 데이터 읽기 작업을 위해 사용할 수 있는 옵션들

flags

  • 두 함수에서 공통으로 사용하는 flags 인자에 대해서 알아보자.

  • MSG_OOB: Out of Band 데이터 전송을 위한 옵션이다. TCP의 Urgent 기능에 관한 것으로 MSG_OOB가 설정되면 Urgent 데이터로 취급하게 된다.

  • MSG_DONTWAIT: 입출력 함수의 동작이 블록 되지 않게 된다. 즉 논 블록으로 동작하게 설정한다.

  • 다음은 send() 함수에서 사용할 수 있는 flags이다.

  • MSG_DONTROUTE: 메시지가 라이터를 통해 다른 네트워크로 전달되는 것을 막는다.

  • MSG_PEEK: 수신 버퍼에 데이터가 들어있는지를 알아보기 위해서 사용한다. MSG_PEEK 옵션이 활성화 되어 있는 상태에서 recv 함수로 읽어진 데이터는 수신 버퍼에서 지워지지 않는다.

Urgent 데이터

  • TCP 기능중에서는 전송하는 데이터 중에 긴급한 메시지가 있음을 알리는 기능이 있다.

“전송하는 데이터 중에서 긴급한 메시지가 있으니, 이 부분을 확인하시오.”

  • 이러한 긴급한 데이터의 전송은 주목적이 아니라 특수한 목적이기 때문에 In Band의 반대말인 Out of Band(OOB) 전송으로 불린다.

  • 예제를 통해서 알아보자.

예제

  • Urgent 데이터 전송을 위한 서버 / 클라이언트 프로그램
  • 클라이언트 프로그램은 서버 프로그램으로 다음과 같은 데이터 순서대로 전송한다.
    • Normal_MSG1
    • Normal_MSG2
    • Urgent_MSG
    • Normal MSG4
  • 3번째 데이터는 Urgent Flag를 셋팅하여 전송한다.
  • 서버 프로그램은 클라이언트 프로그램이 전송하는 데이터를 화면에 출력한다.

클라이언트 프로그램

#include <stdio.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
  int connect_sd;
  struct sockaddr_in client_addr;
  int client_addr_len, read_len;
  char write_buffer[BUFSIZ];

  if (argc != 3) {
    printf("usage: %s <IP Address> <Port Number>\n", argv[0]);
    return -1;
  }

  connect_sd = socket(PF_INET, SOCK_STREAM, 0);

  printf("=== client program ===\n");

  memset(&client_addr, 0, sizeof(client_addr));
  client_addr.sin_family = AF_INET;
  client_addr.sin_addr.s_addr = inet_addr(argv[1]);
  client_addr.sin_port = htons(atoi(argv[2]));

  connect(connect_sd, (struct sockaddr *) &client_addr, sizeof(client_addr));

  send(connect_sd, "normal_msg1", strlen("normal_msg1"), 0);
  send(connect_sd, "normal_msg2", strlen("normal_msg2"), 0);
  send(connect_sd, "urgent_msg", strlen("urgent msg"), MSG_OOB);
  send(connect_sd, "normal_msg3", strlen("normal_msg3"), 0);
  close(connect_sd);

  return 0;
}

서버 프로그램

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <string.h>
#include <signal.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <unistd.h>

int listen_sd, connect_sd;

void urgent_handler(int sig);

int main(int argc, char *argv[]) {
  struct sockaddr_in server_addr, client_addr;
  int client_addr_len, read_len, str_len, state;
  char read_buffer[BUFSIZ];
  pid_t pid;
  struct sigaction act;

  if (argc != 2) {
    printf("usage: %s [port number]\n", argv[0]);
    return -1;
  }

  act.sa_handler = urgent_handler;
  sigemptyset(&act.sa_mask);
  act.sa_flags = 0;

  printf("server start...\n");
  listen_sd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

  memset(&server_addr, 0, sizeof(server_addr));
  server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(atoi(argv[1]));

  bind(listen_sd, (struct sockaddr *) &server_addr, sizeof(server_addr));
  listen(listen_sd, 5);

  client_addr_len = sizeof(client_addr);
  connect_sd = accept(listen_sd, (struct sockaddr *) &client_addr, &client_addr_len);
  fprintf(stderr, "a client is connected...\n");

  fcntl(connect_sd, F_SETOWN, getpid());
  state = sigaction(SIGURG, &act, 0);

  while (1) {
    read_len = recv(connect_sd, read_buffer, sizeof(read_buffer), 0);
    read_buffer[read_len] = '\0';
    printf("client: %s\n", read_buffer);
    if (read_len == 0) {
      close(connect_sd);
      break;
    }
  }
  fprintf(stderr, "the client is disconnected.\n");
  close(listen_sd);
  return 0;
}

void urgent_handler(int sig) {
  int read_len;
  char read_buffer[BUFSIZ];

  read_len = recv(connect_sd, read_buffer, sizeof(read_buffer), MSG_OOB);
  read_buffer[read_len] = '\0';
  printf("client(urgent): %s\n", read_buffer);
}
  • OOB 전송에 대해서 정리하자면 긴급 데이터 처리를 위해서 OOB 전송을 하더라도 데이터가 빨리 전송되는 것은 아니며 데이터 중에 한 바이트만 긴급 데이터로 취급된다는 것을 알 수 있다.

  • “이 세그먼트에 긴급한 데이터가 있다.” 정도의 정보만 수신자 측에 전달할 수 있는 것이다.

표준 입출력 함수


  • 시스템 콜은 사용자 모드와 커널 모드를 전환하기 때문에 자원을 많이 소모한다. 사용자 A와 사용자 B가 각각 100 바이트의 데이터를 WRITE 함수를 사용하여 파일을 작성한다고 생각을 해보자.
  • 사용자 A는 100 바이트를 작성하는 write() 함수를 한 번 호출하여 100 바이트를 작성한다.
  • 사용자 B는 10 바이트를 작성하는 write() 함수를 10번 호출하여 100 바이트를 작성한다.
  • 파일을 입력할 때마다 write() 함수를 호출하는 대신에 누군가가 입력할 데이터를 모아서 데이터가 충분히 많아졌을 때 write() 함수를 호출하면 시스템 콜을 호출하는 횟수를 줄일 수 있을 것이다.

  • 표준 입출력 함수는 자체적으로 버퍼를 가지고 있기 때문에 시스템 콜 실행 횟수를 줄이는 기능을 할 수 있다.

스트림

  • 표준 입출력 함수들은 시스템 콜의 실행 횟수를 줄이는 기능 이외에 문자열 처리를 쉽게 해준다는 장점이 있다.

  • 소켓 프로그래밍을 할 때도 표준 입출력 함수들을 활용하면 복잡한 문자열 작업을 편하게 할 수 있는 경우가 많다.

  • 스트림은 표준 입출력에서의 파일 시스템을 추상화한 개념이다. fopen(), fclose() 같은 표준 입출력 함수는 파일에 접근하기 위해서 FILE 구조체의 포인터를 사용한다.

  • 초기 제작 의도와는 다르게 스트림을 FILE 구조체의 포인터 자체로 간주하는 사람들도 많다.

  • 표준 입출력 함수를 이용하는 프로그램들은 아래 소스와 같은 흐름을 따른다.

FILE *fp;

fp = fopen("filename.txt", "r");
...
fprintffp, "....");
....
fclose(fp);
  • 위에서 처럼 fscanf(), fprintf(), fgets(), fputs() 등등의 표준 입출력 함수를 사용하려면, 파일을 가리키는 FILE형 포인터 즉, 스트림을 생성해야한다.
FILE *fdopen(int fd, const char *mode);
  • 파일 디스크립터로부터 스트림을 생성하는 방법은 fdopen() 함수를 이용하는 것이다.
int fileno(FILE *stream)
  • 반대로 스트림에서 파일 디스크립터를 추출하는 것도 가능하다. 이때는 fileno 함수를 이용한다.

표준 입출력 버퍼 관리

  • 파일 스트림에 fprintf()fputs() 같은 함수를 이용하여 쓰기 작업을 하면 write() 관련 시스템 콜이 실행되어 파일 쓰기 작업이 수행된다.

  • 그러나 표준 입출력 함수의 사용이 시스템 콜로 바로 이어지는 것은 아니다. 표준 결과물은 표준 입출력 버퍼에 저장되어 있다가 상황에 맞게 한 번의 시스템 콜로 처리된다.

  • 이러한 버퍼링은 시스템 콜의 횟수를 줄일 수 있다는 장점을 제공하는 동시에 버퍼링되어 있는 시간만큼 해당 작업이 지연된다는 단점을 가져올 수 있다.

  • 따라서 경우에 따라 버퍼에 있는 내용을 강제로 배출하는 기능이 필요한다. 이러한 기능은 fflush() 함수가 수행한다.

int fflush(FILE *stream);
  • 또한 표준 입출력에서 사용되는 버퍼의 크기를 설정하는 것이 가능하다.
void setbuff(FILE *stream, char *buf);

예제 - 표준 입출력 함수를 이용한 소켓 프로그래밍

클라이언트

nclude <stdio.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>

int main(int argc, char *argv[]) {
  int connect_sd;
  struct sockaddr_in client_addr;
  int client_addr_len, read_len;
  char write_buffer[BUFSIZ];
  FILE *wfp;

  if (argc != 3) {
    printf("usage: %s <IP Address> <Port Number>\n", argv[0]);
    return -1;
  }

  connect_sd = socket(PF_INET, SOCK_STREAM, 0);
  printf("=== client program ===\n");

  memset(&client_addr, 0, sizeof(client_addr));
  client_addr.sin_family = AF_INET;
  client_addr.sin_addr.s_addr = inet_addr(argv[1]);
  client_addr.sin_port = htons(atoi(argv[2]));

  connect(connect_sd, (struct sockaddr *)&client_addr, sizeof(client_addr));
  wfp = fdopen(connect_sd, "w");
  while (1) {
    printf("send msg: ");
    fgets(write_buffer, BUFSIZ, stdin);
    fputs(write_buffer, wfp);
    fflush(wfp);
    if (!strcmp(write_buffer, "END\n")) break;
  }
  fclose(wfp);
  return 0;
}

서버

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
  int listen_sd, connect_sd;
  struct sockaddr_in server_addr, client_addr;
  int client_addr_len, read_len, str_len, state;

  char read_buffer[BUFSIZ];
  FILE *rfp;

  if (argc != 2) {
    printf("usage %s [port number]\n", argv[0]);
    return -1;
  }

  printf("server start...\n");
  listen_sd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

  memset(&server_addr, 0, sizeof(server_addr));

  server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(atoi(argv[1]));

  bind(listen_sd, (struct sockaddr *)&server_addr, sizeof(server_addr));
  listen(listen_sd, 5);

  client_addr_len = sizeof(client_addr);

  connect_sd = accept(listen_sd, (struct sockaddr*)&client_addr, &client_addr_len);
  fprintf(stderr, "a client is connected...\n");

  rfp = fdopen(connect_sd, "r");

  while (!feof(rfp)) {
    fgets(read_buffer, BUFSIZ, rfp);
    printf("%s", read_buffer);
  }
  fprintf(stderr, "the client is disconnected. \n");
  fclose(rfp);
  return 0;
}

참고 문헌


>> Home