System Programming(ARM)

11. System-Level I/O

suhwanc 2020. 12. 15. 14:07

이 글은 “Computer Systems: A Programmer’s Perspective, Third Edition” 교재를 바탕으로 정리한 내용입니다.

 

이번에 배울 시스템 수준의 I/O (input-output) 장치는 Unix I/O 를 중심으로 알아갈 것이고

추가로 교재에서 나오는 RIO(robust), standard I/O 와 비교해 설명합니다.

 

 

1. Unix File

 

Unix file은 바이트의 연속으로 구성되어 있다.  ex. (B0 , B1 , · · · , Bk , · · · , Bm−1)

이 "파일"이라는 단어에는 우리가 사용하는 많은 I/O 디바이스가 포함되어 있는데,

대표적으로 디스크, 터미널, 커널 등이 있다.

 

Unix File Types

 

유닉스 파일의 타입은 여러개로 나뉜다.

  • Regular file : 일반 파일로, 2진수, text 등 데이터를 담고 있다. OS에서 이 파일을 보면, 정확한 포맷은 알지 못한 채, 그저 "일련의 바이트"라고 생각한다고 한다.
  • Directory file : 문서 파일이다. 파일의 이름과 주소를 담고 있다.
  • Character special and block special file : 말그대로 문자/블록 특수 파일이다. 간단히 설명하자면, 키보드로 입력된 입력값과, 모니터에 나타낼 블록 단위의 값을 담고 있다고 보면 된다.
  • FIFO : 프로세스 간 커뮤니케이션 용도로 사용된다.
  • Socket : 네트워크 커뮤니케이션 용도로 사용된다.

 

2. Unix I/O

 

Unix I/O의 가장 큰 특징은 "simple interface", "입출력 형식의 균일" 이 있다.

Unix I/O의 기본적인 I/O 오퍼레이션(system call)은 다음과 같다.

  • 열기/닫기 : open() / close()
  • 읽기/쓰기 : read() / write()
  • 현재 읽고 있는 위치 변경 (seek) : lseek()

 

Open

  • 기본 형식 : open("path", mode)  ex. open("/etc/", RDONLY)
  • 리턴 값 : file descriptor (만약 에러 발생 시 -1 리턴)
  • std input, std output, std err 파일을 기본적으로 열어줄 수 있다.

 

Close

  • 기본 형식 : close(file descriptor)
  • 리턴 값 : 음수이면 에러 발생. 그 외엔 이상 없음을 의미

이 이후에는 file descriptor를 fd로 설명하겠습니다.

 

Read

  • 기본 형식 : read(fd, buf, sizeof(buf))
  • 리턴 값 : 읽은 바이트의 크기(부호 있는 정수). 만약 에러 발생 시 음수 리턴
  • short counts : 3번째 파라미터인 sizeof(buf) (예상 파일 크기) 보다 작게 리턴된 경우이다. 이 경우 여러 가지 경우가 있는데, 이후에 설명할 것이고 지금은 크게 문제가 되지 않는다고만 알아두자.

Write

  • 기본 형식 : write(fd, buf, sizeof(buf))
  • 리턴 값 : write 한 바이트 수. 만약 에러 발생 시 음수 리턴
  • short counts 발생 가능

 

Short counts

 

보통 short counts는 다음과 같은 상황일 때 발생한다.

  • EOF (end-of-file)을 읽는 도중 만난 경우
  • text lines 을 터미널로부터 읽어올 때 (예측이 힘듦)
  • 네트워크 소켓 통신 시

반면 절대 발생하지 않는 경우도 있는데 아래와 같은 상황일 때이다.

  • 디스크 파일을 읽고 쓸 때 (크기가 정해져 있는 상황)

이 책에서는 저자가 직접 만든 Robust I/O라는 패키지를 이용해 short counts를 다루는 방법을 설명한다.

우리도 똑같이 따라가 보자.

 

 

RIO Package

 

이제부턴 Robust I/O는 RIO라고 정의하겠다.

이름이 Robust인 이유는 이 I/O 장치의 특성 때문인데, 입출력 장치를 견고하게 만들어준다는 의미를 지니고 있다.

 

RIO는 2가지 형태를 띠고 있다.

 

1) 2진 데이터로 이루어진 Unbuffered input / output

-> 아래에선 rio_readn, rio_writen 함수로 이를 처리할 것이다.

 

2) 2진 데이터와 텍스트로 이루어진 Buffered input

-> 아래에선 rio_readlineb, rio_readnb 함수로 설명할 것이다.

* 특히 2번 buffered는 그 전 (unix, standard)에서 하지 못하는 스레드 관리를 해주니 자세히 보도록 하자.

 

 

Unbuffered RIO Input and Output

 

함수 정의

1
2
3
4
5
#include "csapp.h"
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
Return: # of bytes transferred if OK,
0 on EOF (rio_readn only), −1 on error
cs

설명

  • 우선 사용하기 위해 "csapp.h"를 불러온다.
  • 함수는 읽기(readn), 쓰기(writen) 2 가지가 있다.
  • rio_readn은 eof 파일을 만나게 되면 short count를 리턴 하지만, rio_writen은 short count를 리턴하진 않는다.
  • 추가로 interleaved (중간 간섭)을 허용한다.

 

Buffered I/O

 

Buffered I/O는 RIO에서 버퍼를 관리한다는 의미를 지닌다.

 

보통 프로그램에서는 1개의 글자를 읽는데 1번의 시간(cycle)이 걸린다.

예를 들어 hello를 출력하려면, 'h', 'e', 'l', 'l', 'o'를 각각 따로 출력을 해줘야 한다는 의미로 매우 비효율적이다.

이에 대한 해답은 버퍼를 사용하는 읽기 방식이 되겠는데 이게 Buffered read이다.

 

Buffer image

위 그림은 버퍼를 시각화한 그림인데, 간단하다.

초록색은 이미 읽은 부분, 빨간색은 아직 읽지 않은 부분을 뜻하고

rio_buf는 버퍼가 시작된 지점. rio_bufptr은 현재 읽는 부분을 나타내는 포인터, rio_cnt는 읽을 부분의 수를 나타낸다.

 

함수 정의

1
2
3
4
5
6
#include "csapp.h“
void rio_readinitb(rio_t *rp, int fd);
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
Return: num. bytes read if OK,
0 on EOF, −1 on error
cs

설명

  • rio_readinitb : 초기화 함수이다.
  • rio_readlineb : 한 줄 단위로 입력을 받는다. 이때 파라미터로 입력의 최대 바이트 크기를 넣어주어야 한다.
  • rio_readnb : 파일 fd에서 n바이트 크기를 읽어온다.

위 코드가 종료되는 조건은 파라미터로 입력된 maxlen 만큼 읽었을 때, 읽는 도중 EOF가 발생한 경우

그리고 한 줄 입력 같은 경우는 newline(개행) 문자를 만난 경우가 되겠다.

 

 

File Metadata

 

메타 데이터는 실제 파일의 내용은 아니지만, 실제 관리하다 보면 파일의 작성 시간, 수정 시간 등 여러 다른 정보들을 메타 데이터라 부르고 "데이터의 데이터"라고도 한다.

 

 

Accessing Directory

 

디렉터리로 접근하는 c언어 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/types.h>
#include <dirent.h>
{
DIR *directory;
struct dirent *de;
...
if (!(directory = opendir(dir_name)))
error("Failed to open directory");
...
while (0 != (de = readdir(directory)))
printf("Found file: %s\n", de->d_name);
...
closedir(directory);
}
cs

여기서 눈여겨봐야 할 것은 두 구조체 DIR, dirent이다.

 

  • DIR : 파일의 경로 및 이름에 대한 정보를 담는 구조체이다.
  • dirent : 일종의 database로, 디렉터리에 대한 기록을 남겨두는 구조체이다.

 

3. Unix 커널이 열려있는 파일을 나타내는 방법

이번에는 Unix 커널에서 파일을 열고, 그 파일을 어떤 형식으로 포인터 지정하는지에 대해서 알아볼 것이다.

다음 그림을 살펴보자.

 

왼쪽부터 차례대로 설명하자면

 

  • Descriptor table : fd(파일 디스크립터) 안의 정보를 담는 테이블이다. 우선 0, 1, 2는 미리 정해진 값으로 고정이며, 수정할 수 없다. 만약 우리가 처음으로 파일을 연다면 그 파일의 디스크립터는 fd 3에 저장될 것이다.
  • Open file table : fd가 가리키는 파일 테이블로서, 파일 포인터를 담고 있다. 
  • v-node table : 파일 포인터가 가리키는 테이블로, 파일에 대한 직접적인 정보들 (크기, 타입 등)을 담고 있고, 파일을 최종적으로 읽는 부분은 이곳이 된다.

-> 위 형식은 파일 process마다 존재하는 것으로, 파일의 비연속적인 특성을 보여주는 사례이기도 하다.

 

 

File sharing

물리적으로는 한 file이지만, 다른 이름으로 open을 한 경우를 말한다.

 

 

 

4. Standard I/O Function

 

우리가 가장 자주 쓰는 I/O 함수이다.

우리는 항상 #include <stdio.h>를 코드 위에 사용하여 I/O Function을 사용하는데,

이 함수는 fopen fclose, fputs 등의 api를 제공한다.

 

추가적으로 버퍼링도 지원하며, 이 버퍼는 fflush()를 통해 지울 수 있다.

 

 

5. 각 I/O 장단점

 

unix io

 

장점

  • 가장 일반적이고 효율적이며 저렴하다.
  • 메타데이터를 제공한다.
  • async-signal-safe (stack을 사용하여 signal handler 안에서도 안전하다. = reentrant)

단점

  • short count 이 발생할 수 있다.

std io

 

장점

  • 버퍼링을 효율적으로 사용할 수 있다.
  • short count 핸들링이 가능하다.

단점

  • 파일 메타데이터를 제공하지 않는다.
  • not async-signal-safe (시그널 핸들링에 부적합하다.)
  • network socket을 사용할 때 불편한 점이 많이 생긴다.

 

선택지

std io 쓸 경우 : 디스크나 터미널 파일에 사용한다.

unix io : 효율이 좋기 때문에 시그널 핸들링 코드를 만들 때 사용한다.

rio : 네트워크 소켓 통신에 사용한다.