회고록 블로그

[C언어 공부] read와 write 함수 본문

6. 42서울/C

[C언어 공부] read와 write 함수

김간장 2022. 3. 28. 16:57

잠깐 하소연을 하자면.. 파일 디스크립터에 대해서 정말 세세하게 공부를 하고 기록을 하고 있었는데 날아갔다.

굉장히 허탈해서 글을 아무것도 남기지 않으려고 했는데, 그래도 공부한건 기록해둬야 하니까...

 

파일 디스크립터는 다음에 다시 글을 써서 올리기로 하고 (ㅜㅜ)

일단 read 함수에 대해서 기록해두려고 한다.


✅ read 함수

C언어의 파일을 읽는데 사용하는 함수 중 하나이다.

 

헤더 : <unistd.h>

매개변수 : int fd(파일 디스크립터), void *buf(파일을 읽어 들일 버퍼), size_t nbytes(읽어들일 데이터 최대 길이)

반환값 : 읽어들인 바이트 수 (타입 : ssize_t)

             (실패 시 -1 반환) 

             (파일의 끝을 만나면 거기까지의 바이트 수를 반환)

 

파일 디스크립터 fd를 열고 그 내용을 버퍼 buf에 넣는다.

이때, 버퍼에 넣는 사이즈는 매개변수 nbytes를 확인하게 된다.

읽어들인 바이트 수를 반환한다.

 

예를 들어서 buf[30]이지만 nbytes가 '1'이라면

fd의 내용을 1바이트만 읽어서 버퍼에 넣고 1 바이트를 반환(return)할 것이다.

 

read 함수는 아래와 같이 사용한다.

#include <stdio.h> // printf
#include <unistd.h> // read, write, close
#include <fcntl.h> // open
#include <sys/types.h> // open
#include <sys/stat.h> // open
#include <stdlib.h> // malloc, free

int main(void)
{
	int fd;
	char *buf;

	buf = (char *)malloc(sizeof(char) * 30);
	fd = open("test.txt", O_RDONLY);

	printf("1) read의 반환값 : %zd\n", read(fd, buf, 10));
	printf("buf의 결과 : %s\n", buf);
	printf("2) read의 반환값 : %zd\n", read(fd, buf, 10));
	printf("buf의 결과 : %s\n", buf);

	free(buf);
    close(fd);
    return (0);
}

// 실행결과
// test.txt 에는 "Hello World!" 라는 문자열만 있음
/*
1) read의 반환값 : 10
buf의 결과 : hello worl
2) read의 반환값 : 3
buf의 결과 : d!
lo worl */
// 2번째의 반환값이 3이지만, "d!\nlo worl"이라는 문자열이 출력된 이유는 사용하기전에 buf를 0으로 초기화 해주지 않았기 때문

 

참고자료 : https://bubble-dev.tistory.com/entry/CC-read-%ED%95%A8%EC%88%98-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%9D%BD%EB%8A%94-%ED%95%A8%EC%88%98

 


✅ read 함수의 재밌는 사실

read의 첫번째 인자를 표준입력(0), 표준출력(1), 표준에러(2)로 하면 어떻게 될까.

⚠️ 표준입력(0)

int main(void)
{
	int fd;
	char *buf;

	buf = (char *)malloc(sizeof(char) * 30);

	read(0, buf, 10); // 0 : 표준입력(stdin)
	printf("%s", buf);
	return 0;
}

 

nbytes(읽어들일 데이터의 크기)가 10이기 때문에 위와 같이 결과나 나왔다.

실행한 후에 첫번째 줄에 있는 "hello world?"는 내가 적은(키보드로 입력한) 문자열이다.

표준입력(0)을 인자로 주면, 입력이 있을 때까지 기다리다가 입력을 받으면 buf에 nbytes만큼 저장하는 것 같다.

 

⚠️ 표준출력(1)

 

int main(void)
{
	int fd;
	char *buf;

	buf = (char *)malloc(sizeof(char) * 30);

	read(1, buf, 10); // 1 : 표준출력(stdout)
	printf("%s", buf);
	return 0;
}

표준입력(0)을 인자로 받았을 때와 동일한 결과가 출력됐다.

 

이렇게 되는 이유는 바로 stdin(표준입력)과 stdout(표준출력)이 같은 파일(터미널)에 연결되어 있기 때문이라고 한다.

(답변의 출처는 아래에 적었다) 

 

직접 확인해보자. lsof (열려있는 파일 목록 확인 명령어)를 통해서 말이다.

일단, c 소스코드를 컴파일하고 다시 실행시키자.

우선 a.out 파일의 0(표준입력)은 /dev/ttys001 (터미널)에 연결되어 있다.

 

1(표준출력)도/dev/ttys001에 연결되어 있다.

 

즉, 두 파일 디스크립터(0, 1)는 같은 파일(터미널)을 가리키고 있다. (물론 2도 마찬가지이다)

read(1, buf, 10)를 실행하면, read에서 block해 있다가

엔터(개행문자)가 입력되는 순간, 터미널에 input(입력)이 발생했으므로 read가 리턴을 한 것이라고 한다.

 

출처 : https://kldp.org/node/162149

 

물론 write 함수도 동일한 결과를 출력한다.

write(0,  "hello world!", 12)과 write(1, "hello world!", 12)를 테스트해보자.

 


✅ read 함수와 다른 함수와의 차이?

read 함수만 공부하기 위해 글을 썼다면 굳이 이렇게 기록으로 남길 필요가 없다.

 

😱 read vs fread

read 함수와 비슷하게 생긴 fread 함수가 있다.

이 함수 fread의 가족들은 fopen, fclose, fwrite 등이 있다.

 

스택오버플로우에 따르면 read 패밀리(read, open, close, write...)와 fread 패밀리(fread, fopen, fclose, fwrite...)의 차이는 아래와 같다고 한다.

read 패밀리는...

1. 시스템콜이다

2. IO 형식이 없다 (%d, %c 등이 없다)

 

fread 패밀리는...

1. 표준 C 라이브러리의 함수이다. (libc) -> 즉, 어플리케이션 시스템 콜이다.

2. 내부의 버퍼를 사용한다.

3. IO 형식이 있다. (예를 들면, %c, %d 등)

4. 항상 Linux의 버퍼 캐시를 사용한다.

 

출처 : https://stackoverflow.com/questions/584142/what-is-the-difference-between-read-and-fread

시스템콜과 라이브러리 콜에 대한 차이는 나중에 다른 글에서 공부해보기로 하자.

 


✅ write 함수

C언어의 파일을 쓰는데 사용하는 함수 중 하나이다.

파일을 쓴다고 하니까 이상한데, 파일에 데이터를 전달한다는 말이 좀 더 어울릴 것 같다.

 

헤더 : <unistd.h>

매개변수 : int fd(파일 디스크립터), void *buf(전송할 데이터가 들어있는 버퍼), size_t nbytes(보낼 데이터의 최대 길이)

반환값 : 전송한 바이트 수 (타입 : ssize_t)

             (실패 시 -1 반환)

 

write도 read와 유사하게 사용한다.

표준출력(1)으로 문자열을 전달하는 것도 write 함수로 할 수 있다.

하지만 위에서 언급한 [read 함수의 재밌는 사실]에서 쓴 것처럼 write가 입력을 받을수도...? ㅎㅎ;

 


✅ read와 write 함수의 반환값 중 -1

read와 write 함수의 반환값은 ssize_t인데, 이는 부호가 있는 int(일 수도 있고 long 일 수도 있다)이다.

size_t가 unsigned라면 ssize_t는 signed이다.

 

어쨌든, signed 데이터 타입이기 때문에 read와 write의 반환값으로 -1이 리턴될 수도 있다.

예를 들어서, 아래의 코드를 실행해보자.

int main(void)
{
	int fd;
	char buf[30];

	fd = open("test.txt", O_RDONLY);

	memset(buf, 0x00, 30); // buf를 모두 0으로 초기화
	printf("%zd", read(fd, buf, 10)); // read해서 값 읽어오기 (출력결과 : 10)
	printf("\n");
    
	close(fd); // fd를 (의도적으로) 닫는다.
    
	memset(buf, 0x00, 30); // buf 모두 0으로 초기화
    
	printf("%zd", read(fd, buf, 10)); // read를 해보자.
	printf("\n");
    
	return 0;
}

결과는 아래와 같다.

이때는 fd가 닫혔기 때문에 작업을 하지 못하고 -1을 반환한 것이다.

오류가 발생한 것이다.

 

이때 상세한 오류는 errno에 설정된다고 한다.

한번 직접 확인해보자.

errno을 사용하려면 errno.h를 인클루드해야한다.

...
#include <errno.h>

int main(void)
{
	int fd;
	char buf[30];

	fd = open("test.txt", O_RDONLY);

	memset(buf, 0x00, 30); // buf를 모두 0으로 초기화
	printf("%zd", read(fd, buf, 10)); // read해서 값 읽어오기 (출력결과 : 10)
	printf("\n");

	close(fd); // fd를 (의도적으로) 닫는다.

	memset(buf, 0x00, 30); // buf 모두 0으로 초기화

	printf("%zd", read(fd, buf, 10)); // read를 해보자.
	printf("\n");

	if (errno)
		printf("Error MSG : %s", strerror(errno));

	return 0;
}

errno은 정수이기 때문에, 상세 오류 내용을 보려면 strerror 함수를 이용하면 된다.

결과는 아래와 같이 확인할 수 있다.

strerror가 아니라 errno.h를 찾아서 확인해도 된다.

이 상황에서 errno은 9이고, 이는 EBADF (유효하지 않은 파일 디스크립터)를 의미한다.

더 궁금한 에러 내용은 아래 글을 참고하면 좋을 것 같다.

혹은 Linux인 경우 man 2 read  명령어를 통해 매뉴얼을 읽을 수 있다.

출처 및 참고 자료 : https://www.it-note.kr/201

 


✅ 그 외 알아두면 좋은 것들

앞에서부터 계속 버퍼, 버퍼 하는데 도대체 버퍼가 무엇일까. 그것에 대해서 알아보자.

⚠️ 버퍼

C언어는 프로그램과 입출력 장치 사이에 스트림이라는 통로가 존재한다.

스트림(stream)은 쉽게 말해, 데이터가 물 흐르듯이 흐르기 위한 통로라고 생각하면 된다.

프로세스와 입출력장치는 서로 전혀 다른 개체라 이 둘을 연결할 통로(중간자 역할)가 필요한데 그게 바로 스트림이다.

아래의 그림을 보면 이해가 될 것이다.

출처 :&nbsp;http://www.tcpschool.com/java/java_io_stream

스트림은 프로세스가 생성될 때 만들어지고, 종료되면 소멸된다고 한다.

그리고, 표준 스트림은 버퍼(buffer)라는 것을 사용한다. (이름이 어렵긴 하지만, 버퍼도 메모리 공간의 일부이다)

 

버퍼는 쉽게 말해서 "데이터를 임시로 모아두는 곳" 정도로 생각할 수 있다.

만약 사용자가 키보드로 데이터를 입력하면, 일단 [입력버퍼]에 저장을 해둔다. (이를 버퍼링이라고 한다. 즉, 버퍼에 데이터를 저장하는 행동을 말한다.)

엔터를 입력하는 순간, 입력버퍼에 있던 데이터가 프로그램으로 전송이 된다. 

 

물론 [출력버퍼]도 있고, 이 또한 비슷한 역할을 한다.

출력 버퍼를 비운다는건 그 데이터가 "목적지(모니터, 파일 등)로 전송"된다는 의미이기도 하다.

 

버퍼를 사용하는 이유는 효율을 위해서이다.

입출력 장치와 CPU는 속도 차이가 꽤나 있다. 입출력 장치가 훨씬 느리다.

입출력 버퍼가 없다면, CPU는 출력 데이터를 출력 장치로 전송해야 할 때마다 그 느린 출력 장치의 작업(CPU에 비해 상대적으로 속도가 느린 출력 장치)에 맞춰 일을 해야한다.

 

일을 처리하는 속도가 1인 A와 일을 처리하는 속도고 0.5인 B가 있다고 하자.

그리고 A와 B는 협업을 해야한다.

 

A가 B에게 어떤 문서를 처리해달라고 요청했다.

A는 B보다 속도가 더 빠르기 때문에 옆의 빈 자리(버퍼)에 문서를 내려놓고

A는 다른 제 할 일을 하러 가면 더 효율적으로 일을 할 수 있다.

 

버퍼가 없다는 것은

A가 B에게 부탁할 문서를 계속 손에 잡고, B에게 한 장 한 장 넘겨주고 있는 것과 같다.

즉, 불가능한 일은 아니지만 굉장히 비효율적인 일이다.

 

버퍼를 사용하면, 데이터를 모아두었다가 처리하기 때문에 CPU를 덜 사용하고 메모리 접근 횟수도 줄어든다고 한다.

 

참고 자료1 : https://crone.tistory.com/444

참고 자료2 : https://itdexter.tistory.com/469

 

 

⚠️ read 함수는 어떻게 어디까지 읽었는지 알고 있을까

open 함수를 사용했을 때, 파일 디스크립터 테이블과 파일 테이블, inode 테이블에 무슨일이 생기는지는 [파일 디스크립터]에 대해서 공부하면서 배웠었다.

2022.03.24 - [6. 42서울/C] - [C언어 공부] 파일 디스크립터

 

만약 아래와 같은 코드가 있다면, 커널은 파일의 접근권한(rwx)도 확인하고, 파일 테이블에 기록된 접근권한(읽기 전용 등)도 확인한다.

파일 디스크립터 테이블의 항목들은 각각 파일 테이블을 가리키는 것도 위의 글에서 공부했었다.

이제 read 함수가 '어디까지 읽었는지 어떻게 기억하고 있는지' 이해할 준비가 됐다.

fd = open("test.txt", O_RDONLY);

 

아래와 같이 read함수를 사용한다고 하자.

read(fd, buf, 10);

위의 코드를 만나면 커널은 파일 디스크립터 테이블에서 fd를 확인하고,

fd가 가리키는 파일 테이블 엔트리로 가서 접근권한, 오프셋 등을 확인하게 된다.

이 [오프셋]이 read 함수를 사용할 때마다 파일의 내용을 처음부터 다시 읽지 않는 이유다.

오프셋(offset)은 읽은 위치를 저장하고 있다. (현재 위치를 저장)

 

※ 읽은 위치를 저장하고 있다고 해서

close 하고 다른 파일을 open 한 후 다시 원래의 파일을 open 하면 안된다.

파일 테이블은 본래 "열려 있는 파일"을 관리하기 때문이다.

자세한 사항은 위의 [파일 디스크립터] 글 확인

 

아래의 사진은 test.txt 파일을 open 함수로 열어서 read 함수로 읽다가,

index.txt 파일을 open 해서 read 함수로 읽고,

다시 test.txt 파일을 read 함수로 읽었을 때의 결과이다.

 

 

 

 

 

'6. 42서울 > C' 카테고리의 다른 글

[C언어 공부] 가변인자  (0) 2022.04.30
[C언어 공부] restrict 키워드  (0) 2022.03.09
[C언어 공부] static 함수와 static 변수  (0) 2022.03.09
Comments