파일 입출력

유닉스 시스템에서는 거의 모든 것을 파일로 표현하므로 '파일 입출력'은 매우 중요한 부분입니다. 알다시피 파일은 '읽기'나 '쓰기' 전에 반드시 '열기(open)'를 해야합니다. 그리고 커널은 '파일 테이블'이라고 하는 프로세스별로 열린 파일 목록을 관리합니다. 각 프로세스에는 기본적으로 0, 1, 2 값을 가지는 파일 디스크립터가 open되어 있습니다. 

0 - 표준 입력(stdin)

1 - 표준 출력(stdout)

2 - 표준 에러(stderr)

 

위의 3가지 파일 디스크립터를 직접 참조하는대신 C 라이브러리는 #define 매크로 정의를 제공합니다.

따라서, 해당 파일 디스크립터를 사용하고싶다면 아래 정의된 매크로로 참조하는게 좋습니다.

#define STDIN_FILENO 0
#define STDOUT_FILENO 1
#define STDERR_FILENO 2

 

들어가기에 앞서, '리눅스 시스템 프로그래밍'이란 것은 '리눅스(커널)에서 제공하는 API(이하 시스템콜)'를 사용하여 하드웨어와 직접적으로 연결되는 프로그램을 작성하는 것을 말합니다. 하지만 시스템콜은 아주 Low한 레벨의 동작 방식을 정의해놓은 것이기에 이것만 가지고 작업(프로그래밍)을 하기엔 아주 곤란합니다. 작업량이 상당해지기 때문입니다. 그래서 이러한 시스템콜을 적절히 '래핑(Wrapping)'해서 만들어놓은 것이 그 유명한 'glibc' 라이브러리입니다. 앞으로 함수들을 소개할 때 2가지를 구분하겠습니다.

1. 시스템콜(System Call_줄여서 syscall)

2. glibc 라이브러리(GNU C Library)

 

 

리눅스에서 파일에 접근하는 가장 기본(원론)적인 방법은 open(), read(), write() 시스템콜입니다.

이 4가지를 세세하게 알아보겠습니다.

 

1) open() 시스템콜

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char* name, int flags);
int open(const char* name, int flags, mode_t mode);

첫 번째 매개변수는 'open'할 파일의 경로이름입니다. 여기서 경로는 '절대경로', '상대경로' 모두 가능합니다.

두 번째 매개변수는 해당 파일을 '읽기' 위해 열지 '쓰기' 위해 열지 아님 '읽기쓰기' 둘다 할 것인지를 표기합니다. 각각 'O_RDONLY', 'O_WRONLY', 'O_RDWR'로 나타냅니다. 여기서 간단히 끝나면 좋겠지만, 아까 얘기했듯이 '시스템콜'은 아주 Low한 레벨의 동작 방식을 정의합니다. 이 말은, 아주 디테일한 동작 하나하나를 설정하여 커널에 알려줘야한다는 얘기입니다. 이어서, 두 번째 매개변수 flags에는 '비트 OR 연산(|)'을 통해 열기 동작의 플래그를 설정할 수 있습니다.

설정할 수 있는 플래그 값들을 살펴보겠습니다.

1. O_APPEND: 덧붙이기 모드(Append Mode)로 파일을 오픈합니다.

2. O_ASYNC: 비동기적으로 특정 파일에서 '읽기'나 '쓰기'가 가능해질 때 '시그널'이 발생합니다.(터미널과 소켓에서만 이 옵션을 사용할 수 있습니다.)

3. O_SYNC: 파일을 '동기식 입출력'으로 오픈합니다. 데이터를 물리적으로 디스크에 '쓰기'전까지는 쓰기 연산이 완료되  지 않습니다.

4. O_CREAT: 첫 번째 매개변수 name에 적은 파일이 없으면 새로 생성합니다. 파일이 이미 있다면 'O_EXCL' 플래그를    추가하지 않는 이상 아무런 일도하지 않습니다.

5. O_DIRECT: '직접 입출력'을 수행하기 위해 파일을 오픈합니다.

6. O_DIRECTORY: 첫 번째 매개변수 name이 디렉터리가 아니면 open() 호출이 실패합니다.

7. O_EXCL: 'O_CREAT'와 함께 이 플래그를 사용하면 첫 번째 매개변수 name으로 지정한 파일이 이미 있을 때 open()    호출이 실패합니다. 이것은 파일 생성 과정에서 '경쟁 상태'를 회피하기 위해 사용됩니다.

8. O_LARGEFILE: 2GB를 초과하는 파일을 오픈하기 위해 64-bit 오프셋을 사용합니다.

9. O_NOATIME+: '읽기'에 의해서 파일의 마지막 접근 시간이 갱신되지 않도록 합니다.

10. O_NOCTTY: 첫 번째 매개변수 name이 '터미널 디바이스(/dev/tty)'라면 프로세스에 현재 제어 중인 터미널이 없더    라도 프로세스의 제어 터미널이 되지 않습니다.

11. O_NOFOLLOW: 첫 번째 매개변수 name이 '심벌릭 링크'라면 open() 호출이 실패합니다.

12. O_NONBLOCK: 파일을 논블록킹(Non-Blocking) 모드로 오픈합니다.

13. O_CLOEXEC: 열린 파일에 close-on-exec 플래그를 설정합니다. 새 프로세스를 실행하면 이 파일은 자동으로 닫힙니  다.

14. O_TRUNC: 파일이 존재하고, 일반 파일이며 flags 매개변수에 '쓰기'가 가능하도록 명시되어 있다면 파일 길이를 0    으로 잘라버립니다.

 

2번째 flags의 설정으로 파일이 존재하지않을 때 새로 생성한다면 생각해볼 3가지 문제가 있습니다.

1. 해당 파일의 'uid'는? 프로세스의 'euid(유효 uid)'가 됩니다.

2. 해당 파일의 'guid'는? 기본 동작은 파일을 생성한 프로세스의 'egid(유효 gid)'로 설정합니다.

3. 해당 파일의 '접근 권한'은? 3번째 매개변수를 통해 설정합니다.

 

'접근 권한' 설정을 위한 3번째 매개변수를 살펴보겠습니다.

'0644'와 같은 '접근 권한' 설정은 아주 익숙하리라 생각됩니다. 하지만, 이렇게 단순히 표기한다면 쉽고 가독성도 좋겠지만, 1가지 문제가 있습니다. 타 유닉스 시스템으로의 '이식성'을 보장받을 수 없다는 것입니다. 이를 위해 'POSIX'에서는 유닉스 시스템간의 호환이 가능하도록 '매크로 상수'를 제공합니다.

 S_IRWXU: 소유자에게 읽기, 쓰기, 실행 권한이 있다.

 S_IRUSR: 소유자에게 읽기 권한이 있다.

 S_IWUSR: 소유자에게 쓰기 권한이 있다.

 S_IXUSR: 소유자에게 실행 권한이 있다.

 S_IRWXG: 그룹에게 읽기, 쓰기, 실행 권한이 있다.

 S_IRGRP: 그룹에게 읽기 권한이 있다.

 S_IWGRP: 그룹에게 쓰기 권한이 있다.

 S_IXGRP: 그룹에게 실행 권한이 있다.

 S_IRWXO: 그 외 모든 사용자에게 읽기, 쓰기, 실행 권한이 있다.

 S_IROTH: 그 외 모든 사용자에게 읽기 권한이 있다.

 S_IWOTH: 그 외 모든 사용자에게 쓰기 권한이 있다.

 S_IXOTH: 그 외 모든 사용자에게 실행 권한이 있다.

 

 

2) read() 시스템 콜

#include <unistd.h>

ssize_t read(int fd, void* buf, size_t len);

'fd(파일 디스크립터)'가 참조하는 파일의 현재 '파일 오프셋'에서 len 바이트만큼 buf로 읽어 들입니다. 만약 읽어들일 데이터가 없는데 read()를 호출한다면 프로그램은 '블럭(Block)'됩니다.

호출 성공: buf에 쓴 바이트 숫자를 반환합니다.

호출 실패: -1을 반환하며 'errno'를 설정합니다.

여담으로 '파일 디스크립터'를 매개변수로 받는 시스템콜은 대부분 1번째 매개변수에 위치해있습니다.

 

전체 바이트 읽기 예제입니다.

ssize_t ret;

while (len != 0 && (ret = read(fd, buf, len)) != 0) {
    if (ret == -1) {
        if (errno == EINTR)
            continue;
        perror("read");
        break;
    }
    
    len -= ret;
    buf += ret;
}

 

 

3) write() 시스템콜

#include <unistd.h>

ssize_t write(int fd, const void* buf, size_t count);

count 바이트만큼 fd가 참조하는 파일의 현재 위치에 시작 지점이 buf인 내용을 기록합니다.

호출 성공: 쓰기에 성공한 바이트 수를 반환합니다.

호출 실패: -1을 반환하며 'errno'를 적절한 값으로 설정합니다.

 

'일반 파일'을 대상으로 쓰기 작업을 수행할 경우에는 루프 내에서 돌릴 필요가 없습니다. 하지만 다른 파일 유형(소켓, 파이프 등)을 대상으로 쓰기 작업을 수행할 경우에는 요청한 모든 바이트를 정확히 썼는지 보장하기 위해 루프가 필요합니다. 또한, 루프를 사용함으로써 숨어 있던 에러 코드를 얻을 수도 있습니다.

ssize_t ret, nr;

while(len != 0 && (ret = write(fd, buf, len)) != 0) {
    if (ret == -1) {
        if (errno == EINTR)
            continue;
        perror("write");
        break;
    }
    
    len -= ret;
    buf += ret;
}