Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

어느덧 13장째로 리눅스 환경에서의 C 프로그래밍 문서 프로젝트도 종반에 접어들었다. 이 장을 포함해서 2장 정도면 마무리가 될거 같다. 쉬엄쉬엄 한 7개월 정도를 끌어온거 같다. 지금까지의 내용으로 C 언어의 문법적인 내용은 거의 다 다루었다고 보면 된다. 이제 부터는 C 언어의 응용과 관련된 부분을 공부해야 한다. 이 응용이란 네트워크:::프로그래밍(:12), 시스템:::프로그래밍(:12), DB:::프로그래밍(:12), 게임 프로그래밍과 같은 다양한 영역이 된다.

이 문서에서는 위의 내용들을 다루진 않을 것이다. 하나하나가 책한권의 분량으로, 입문서에 해당하는 이문서의 수준을 뛰어넘기 때문이다. 대신 모든 응용분야를 통틀어서, 공통적으로 다루어지는 분야인 입력과 출력에 대해서 간단히 알아보도록 할 것이다.

이 문서에서 다루는 내용은 다음과 같다.
  • 표준입력, 표준출력, 표준에러
  • 형식화된 입출력
  • 파일로 부터의 입력과 출력 처리
  • exit
프로그램이 하는 일의 전부는 입력을 받아서 처리한 다음 그 결과를 출력하는데 있다고 보면 된다. 어떤 프로그램을 짤때 가장 먼저 하는 일이, 프로그램이 하는일을 담은 명세서를 작성하는 것인데, 명세서란 것이 결국은 어떤 데이터를 입력받아서 어떻게 출력할 것인지를 정의 하는 일이다. 예를들자면 시스템 프로그램과 네트워크 프로그램의 5할이 입력과 관련된 일이고 3할이 문자열 처리와 관련된 것이라고 보면된다.

그러므로 입/출력에 대해서 반드시 알고 넘어갈 필요가 있다.

입출력 장치

아마도 컴퓨터의 3가지 구성장치에 대해서 배운기억이 있을 것이다. 이들 구성요소는 다음과 같다.
  1. 중앙연산장치
  2. 입력장치
  3. 출력장치
기본적으로 중앙연산장치를 제외한 모든 것은 입력장치 혹은 출력장치로 구분이 된다. 대표적인 입력장치는 키보드, 마우스, 터치패드, 카메라등이 될 것이다. 출력장치는 모니터, 프린터, 사운드 카드를 예로 들 수 있다.

어떤 형식으로든지 간에 데이터를 받아들이는 장치는 입력장치가 되고, 받아들인 데이터를 소리,문자,이미지의 형태로 다시표현해주는 장치를 출력장치라고 정의 할 수 있다.

키보드로 입력된 정보는 중앙연산장치에서 처리된다음에 적당한 과정을 거쳐서 모니터로 출력이되고, 필요한 경우 프린트를 이용해서 종이로 출력되거나 할 것이다.

모든 입출력은 파일로 처리한다

유닉스 운영체제(:12)는 모든 입출력과 관련된 장치는 파일과 동일하게 보고 처리를 한다. 하드디스크의 경우 C:, D:와 같이 다루어지는 것과는 달리, 유닉스는 장치파일이라는 특수한 파일 형태로 다른다. 뿐만 아니라 프린트, 사운드카드 까지도 파일의 형태로 다룬다. 이러한 장치파일들은 /dev 디렉토리 밑에 존재한다. 예를들어 윈도우즈에서 말하는 C: 드라이브는 /dev/hda1 과 같은 파일이름으로, 프린터기는 /dev/lp0 이라는 파일이름으로 존재한다.

컴퓨터에 사용되는 장치를 파일의 개념으로 놓고 본다는 것은 윈도우즈환경을 주로 사용했던 유저에게는 그리 익숙하지 않을 것이다. 그러나 조금만 생각해 보면 적어도 개발자입장에서는 매우 합리적인 개념임을 알 수 있을 것이다. 파일과 마찬가지로 읽고, 쓴다라는 개념이 그대로 적용되며, 일반 파일에 사용되는 방법과 동일한 방식으로 다양한 종류의 장치를 다룰 수 있기 때문이다. 물론 각 장치들은 읽고 쓰기 위한 전용의 프로토콜을 사용하기 때문에 프로토콜에 대한 학습이 필요하긴 하지만 사용되는 함수등은 일반 파일을 다룰때와 동일하다고 보면 된다.

이렇게 모든걸 파일로 다루게 됨으로써, 장치에 대한 별도의 학습없이도 일관성있게 프로그램을 작성할 수 있게 된다.

하지만 여기에서는 다른 장치들에 대한 입출력을 다루지는 않을 것이다. 가장 기본이 되는 파일에 대한 입출력 만을 다룰 것이다.

file description

"abc.txt" 라는 파일을 읽거나 쓰기 위해서는 파일을 open하는 과정을 거친다. 그러면, 운영체제는 파일을 쉽게 다룰 수 있도록 int 형의 숫자를 넘겨준다. 이후에 파일에 쓰거나 읽는 작업은 파일이름이 아닌 이 숫자를 통해서 이루어지게 된다. 이 숫자를 file description이라고 한다. 혹은 파일지시자라고 부르기도한다.

실제 프로그래밍 과정에서 파일지시자를 어떻게 사용하는지는 나중에 예제를 통해서 알아보도록 하겠다.

표준입력, 표준출력, 표준에러

운영체제에서 주어진 일을 하는 최소단위는 프로세스라는 것을 알고 있을 것이다. 프로세스는 프로그램의 실행된 이미지로 운영체제의 메인메모리에 위치한다.

이렇게 프로그램이 실행되어서, 프로세스가 생성되면 프로세스는 컴퓨터의 여러가지 기본자원을 사용할 수 있게 된다. 이 기본자원에 3개의 파일이 포함되어 있다. 바로 입력출력, 에러를 담당하는 3개의 파일들이다. 이것들을 표준입력, 표준출력, 표준에러라고 한다.

  • 표준입력 : 키보드로 부터 입력을 받기 위해서 사용한다.
  • 표준출력 : 모니터로 출력하기 위해서 사용된다.
  • 표준에러 : 에러도 역시 모니터를 통해서 출력된다는 점에서는 표준출력과 마찬가지다. 그러나 어떠한 구분도 없다면, 화면에 출력된 값이 정상적으로 처리된 메시지인지, 에러메시지 인지 확인할 수 없을 것이다. 때문에 표준에러라는 것을 따로 둬서 구분할 수 있게끔하고 있다.
이들 표준입/출력/에러 역시 파일의 형태로 다루어지며, 운영체제는 이를 위해서 예약된 숫자를 파일지시자로 할당한다. 표준입력은 0, 표준출력은 1, 표준에러는 2로 예약되어 있다.

간단한 예를 들어서 설명해보도록 하겠다.
#include <unistd.h>
#include <string.h>

int main(int argc, char **argv)
{
  char data[80];
  char *msg = "Input Msg : ";
  memset(data, 0x00, 80);

  write(1, msg, strlen(msg));   // 1

  read(0, data, 80);            // 2
  write(1, data, strlen(data)); // 3
  write(2, "Error\n", 6);       // 4
}
  1. "Input Msg"메시지를 모니터에 표준출력 한다.
  2. 표준입력을 통해서 데이터를 읽어들인다. 읽어들인 데이터는 data 에 복사한다.
  3. data에 복사된 내용을 data의 길이 만큼 모니터에 표준출력 한다.
  4. Error라는 메시지를 모니터에 표준에러형태로 출력한다.
이 프로그램을 실행시키면 다음과 같은 결과를 확인할 수 있을 것이다.
# ./write
Input Msg : hello world
hello world
Error
  • write(2)
이 함수는 파일지시자(첫번째 인자)가 가리키는 파일에 "쓰기" 위해서 사용한다.
  • read(2)
이 함수는 파일지시자가 가리키는 파일에서 데이터를 "읽기" 위해서 사용한다.

표준입력과 표준출력을 이해하는데에는 별 어려움이 없을 것이다. 그럼 표준에러와 표준출력을 어떻게 구분할 수 있느냐 하는 문제가 남는다.

표준에러는 쉘의 재지향(:12)을 이용해서 따로 분리해서 읽을 수 있다. 재지향은 >을 쓰면 된다. - 2장 리눅스와 C언어참고 - 또한 2>와 같이 표준에러만을 분리해서 재지향 시킬 수가 있다. 여기에서 2가 바로 표준출력을 가리키는 파일지시자 번호다. 이제 다음과 같이 표준에러만 별도의 파일에 재지향 시켜보도록 하자.
# ./write 2> dump.log
Input Msg : hello world
hello world
# cat dump.log
Error
표준에러가 모니터 화면이 아닌, dump.log 파일에 저장된걸 확인할 수 있을 것이다. 이로써, 표준에러와 표준출력이 모니터를 출력방향으로 하고 있지만, 명백히 구분되는 것임을 이해했을 것이다.

파일 입출력

그러면 유닉스 프로그래밍에 있어서 가장 중요한 파일 입출력에 대해서 알아보도록 하겠다. 기본적으로는 표준입력/출력/에러를 다루는 방식과 동일 하다.

다른점이 있다면, 표준입력 등은 운영체제가 알아서 열어주지만, 다른 파일들은 프로그래머가 함수를 이용해서 파일을 직접 열어주어야 한다는 점이다.

해서 파일 입출력에는 read(2)와 write(2)외에 파일을 열기 위한 open(2)과 닫기 위한 close(2)가 사용된다. 원칙적으로는 이 네가지의 함수만 있으면 거의 대부분의 파일과 관련된 작업을 할 수 있다.

이들 함수에 대한 자세한 설명은 링크로 연결되는 man page를 참고하기 바란다. man page에 각각의 함수에 대한 설명이 자세히 나와있으니, 예제를 설명하는 정도로 넘어가도록 할 것이다.

예제 1 - 전형적인 파일 읽기 프로그램

cat 이라는 프로그램을 알 것이다. 이 프로그램은 인자로 주어진 파일을 화면에 출력하는데, cat과 비슷한 프로그램을 만들어 보도록 하자. 이 프로그램의 이름은 mycat으로 하겠다.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

// 사용하기 쉽도록 표준입력,출력,에러를 다른 이름으로 정의 한다.
#define STDIN 0
#define STDOUT 1
#define STDERR 2

int main(int argc, char **argv)
{
    char *filename;
    int fd;
    int readn;
    char buf[80];

    // 이 프로그램이 실행되기 위해서는 파일이름을 인자로 받는다.
    // 그러므로 인자를 체크해주어야 한다.
    // 만약 인자가 부족하다면, 프로그램의 사용방법을 출력하고 종료한다.
    if (argc != 2)
    {
        printf("Usage : %s [file]\n", argv[0]);
        return 1;
    }

    // filename 이 argv[1] 을 가리킨다.
    // argv[1] 을 그대로 사용해도 되겠지만, 코드의 가독성을 위해서
    // 다른 변수이름을 사용하도록 했다.
    filename = argv[1];

    // 파일을 읽기전용 모드로 연다.
    fd = open(filename, O_RDONLY);
    // 에러 체크를 한다.
    if (fd < 0)
    {
        perror("file open err :");
        return 0;
    }

    // read 함수를 이용해서, 파일지시자로 부터 데이터를 읽어들인다.
    // read 함수는 읽어들인 데이터의 크기를 리턴한다.
    // 더이상 읽을 데이터가 없다면 0을 리턴하니, 그때 while 루프를 빠져나오면 된다.
    while((readn = read(fd, buf, 80)) > 0)
    {
        // 읽어들인 데이터의 크기 만큼을 화면에 출력한다.
        write(STDOUT, buf, readn);
    }
    close(fd);
    return 1;
}
이 프로그램은 기존의 프로그램들과는 달리 꽤 완성된 모습을 보여주고 있다. 어떤 점에서 그런지 살펴보도록 하자.
  1. 프로그램의 실행인자를 검사하고 있다.
  2. 에러를 체크하고 있다.
모든 함수는 리턴값을 이용해서 에러를 체크할 수 있도록 하고 있다. 견고한 프로그램을 만들기 위해서는 반드시 에러에 대한 처리를 해줘야 한다.
  1. main 함수도 실행결과를 값으로 리턴하고 있다.
프로그램의 실행결과는 중요한 정보다. 프로그램이 성공했는지 실패했는지에 대한 정보를 이용해서 다른 일들을 할 수 있기 때문이다. 이는 여러개의 프로그램이 연속적으로 실행되는 배치작업에서 특히 중요하다. 유닉스 프로그램은 전통적으로 제대로 실행이 되었다면 0을 그렇지 않다면 0이 아닌 다른 수를 리턴한다.
  1. 가독성
표준입력,출력,에러 등을 위해서 0,1,2등을 사용하는건 직관적이지 못하다. 직관적이지 못하다는 것은 프로그래머가 실수를 할 수 있음을 의미한다. define 문을 이용하면 가독성 좋은 직관적인 프로그램을 만들 수 있다.

이제 이 프로그램을 컴파일 한다음에 실행시켜 보도록 하자. 프로그램의 이름은 mycat 이라고 하겠다. 내용이 길어서 실행결과는 생략했다.
# ./mycat mycat.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

#define STDIN 0
#define STDOUT 1
#define STDERR 2
....
....

재지향을 이용하면 다음과 같은 응용도 가능 할 것이다.
# ./mycat mycat.c > mycat.bak
결과적으로 mycat.c 를 mycat.bak 라는 파일로 복사를 한것과 같다.

예제 2 - 표준입력으로 부터 읽어들이도록 해보자

위에서 다루었던 예제 프로그램을 약간 수정해서, 표준입력(:12)으로 부터 입력된 데이터를 출력하는 프로그램을 만들어 보도록 하자.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

#define STDIN 0
#define STDOUT 1
#define STDERR 2

int main(int argc, char **argv)
{
    char *filename;
    int readn;
    char buf[80];

    while((readn = read(STDIN, buf, 80)) > 0)
    {
        // 읽어들인 데이터의 크기 만큼을 화면에 출력한다.
        write(STDOUT, buf, readn);
    }
    return 1;
}
프로그램이 훨씬 간단해졌다. 표준입력은 프로세스가 생성될 때 자동으로 열린다. 덕분에 파일을 여는 등의 코드가 필요 없기 때문이다. 위 프로그램을 컴파일 한다음에 실행시켜보도록 하자.
# ./mycat2
hello world
hello world
ok
ok
키보드 입력을 받아들이고 엔터키를 누르면, 입력된 내용이 출력되는걸 확인할 수 있을 것이다. Ctrl+D를 누르면 프로그램을 빠져나올 수 있다.

혹은 파이프를 이용할 수도 있다. 파이프(:12)를 통해서 넘어오는 정보는 표준입력으로 읽을 수 있기 때문이다.
# cat mycat.2 | ./mycat2
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>
...
...
이렇게 파이프와 표준입력을 잘 사용하면, 복잡하게 파일을 열거나 하는 일없이 다른 프로그램으로 부터 생성된 문자열 등의 데이터를 표준입력으로 받아서 간단히 처리할 수 있다.

문제

표준입력으로 문자열을 입력받아서 몇개의 문자가 입력되었는지를 계수하는 프로그램을 작성하라.

파일 복사하기

지금까지의 파일 입출력작업은 키보드나 파일로 부터 입력 받아서, 모니터로 표준출력 하는 것에 대해서만 알아보았다. 이제 표준출력이 아닌 파일로 출력 하는 방법에 대해서 알아보도록 하자.

유닉스에서는 모니터나 파일이나 모두 동일하게 파일로 입력하기 때문에, 표준출력과 마찬가지로 write()함수를 이용해서, 파일로 쓸수 있다. 다른점이라면, 이미 열려있는 표준출력과는 다르게, 파일출력의 경우 쓰고자 하는 파일을 직접 열어주어야 한다는 것이다.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

// 사용하기 쉽도록 표준입력,출력,에러를 다른 이름으로 정의 한다.
#define STDIN 0
#define STDOUT 1
#define STDERR 2

int main(int argc, char **argv)
{
    char *orgfile, *dstfile;
    int rfd, wfd;
    int readn;
    char buf[80];

    // 인자로 원본파일과 복사파일 이름을 받는다.
    // src file이 dst file로 복사된다.
    if (argc != 3)
    {
        printf("Usage : %s [src file] [dst file]\n", argv[0]);
        return 1;
    }

    // filename 이 argv[1] 을 가리킨다.
    // argv[1] 을 그대로 사용해도 되겠지만, 코드의 가독성을 위해서
    // 다른 변수이름을 사용하도록 했다.
    orgfile = argv[1];
    dstfile = argv[2];

    // 원본 파일을 읽기전용 모드로 연다.
    rfd = open(orgfile, O_RDONLY);
    // 에러 체크를 한다.
    if (rfd < 0)
    {
        perror("org file open err :");
        return 0;
    }

    // 복사파일은 쓰기전용으로 연다.
    // 복사파일의 권한은 00700 즉 사용자에게 읽기,쓰기,실행 권한을 준상태로 한다. 
    wfd = open(dstfile, O_WRONLY|O_CREAT,S_IRWXU);
    // 에러 체크를 한다.
    if (wfd < 0)
    {
        perror("dst file open err :");
        return 0;
    }
		
    // 원본파일로 부터 데이터를 읽은다음
    // 읽어들인 데이터의 크기만큼 복사파일에 쓴다.
    while((readn = read(rfd, buf, 80)) > 0)
    {
        write(wfd, buf, readn);
    }
    close(rfd);
    close(wfd);
    return 1;
}
위 코드를 컴파일 한다음 테스트해보도록 하자.
# ./mycopy mycopy.c mycopy.bak