open(), read(), write(), close()과 같은 시스템 콜 함수와 fopen(), fread(), fwrite(), fclose() 같은 c언어 표준 함수와 정확히 어떤 차이가 있는지 잘 몰라서 이에 대해 공부 해보기로 하였다. 특히, 이번 포스트에서는 fopen()과 open()에 대해 다루어 보기로 한다.
open()과 fopen()의 차이는 KLDP 포럼 https://kldp.org/node/1350 에서 그 구체적인 차이와 용도에 대해 시니어 개발자들이 깊게 토론 한 적이 있을 정도로, 겉보기에는 쉬울 것 같지만 깊게 들어가면 심오한 주제라고 생각한다. "Robert Love, 리눅스 커널구조의 심층분석" 을 참고하자면 아래와 같은 구조로 되어있다고 짐작할 수 있다.
fopen() 호출
fopen() 함수 래퍼 C 라이브러리의 함수 래퍼
open() 시스템 콜
sys_open()
사용자 공간
커널공간
<그림 1 > "Robert Love, 리눅스 커널구조의 심층분석"에 따른 open() 함수 작동방식
이에 대해 자세히 살펴보기 위해 우리가 C/C++에서 자주 쓰는 fopen()의 문법과 용도에 대해서 살펴 보았다.
FILE* fopen(const char*filename, const char* mode) // 성공 시 해당 파일의 파일 포인터, 실패시 NULL 반환
filename : c 문자열로 된 파일 이름.
mode : "r" (읽기 전용), "w" (쓰기 전용), "a"(덧붙이기) 등이 있다.
<그림 2> fopen의 사용 예시 fopen_study.c
컴파일이 된다. 어딘가에 fopen() 함수가 선언 및 정의되어 있다고 생각할 수 있다.
어셈블리 코드를 살펴보면, fopen함수를 stdio.h 에서 import 하여 사용하고 있다.
<그림 3> fopen_study.asm
그렇다면 이 fopen() 함수는 어떻게 선언 및 정의 되어 있을까?
우선fopen()는 Windows10 MSVC 2019 기준으로 <stdio.h>에 함수가 정의 되어있다.
<그림 4> fopen()의 선언 stdio.h
그렇다면 구현코드는 어떻게 씌여져 있을까? API마다 조금씩 다르겠지만, Dennis Ritchie & BrianKernighan의 저서 "The C Programming Language" 에서 설명하는 fopen() 예제에서 크게 벗어나지 않을 것이라고 생각한다. 그들이 책에 소개한 예제 소스 코드를 살펴보자.
구현 과정에서 open(), creat(), lseek()와 같은 system call 함수를 사용하고 있음을 알 수 있다.
그렇다면, 이런 시스템 콜은 또 어디에 선언 및 정의되어 있을까? 윈도우 커널소스는 공개되어 있지 않으므로 linux 커널 소스를 통해 궁금증을 해결해 보기로 한다.
static __attribute__((unused))
int open(const char *path, int flags, mode_t mode)
{
int ret = sys_open(path, flags, mode);
if (ret < 0) {
SET_ERRNO(-ret);
ret = -1;
}
return ret;
}
<그림 6> 리눅스 커널 tools/include/nolibc/nolibc.h에 정의된 open() 함수의 구현
<그림 6>에서 보듯, open은 sys_open()이라는 시스템 콜을 수행하고 있었다. 그렇다면 sys_open()은 어떻게 작동하는가? sys_open()의 구현부분을 보자.
시스템 입출력 : 메모리와 외부장치 (디스크 드라이브, 터미널, 네트워크) 간에 데이터를 복사하는 과정.
리눅스에서 모든것은 파일 ("Everything is a file.")로 간주되고, 디바이스들 또한 파일로 모델링 된다.
파일
- 파일은 연속된 바이트들의 집합이다. 사용자가 디바이스에 접근하고자 하면, 커널은 File Descriptor 값을 반환한다.
- 디바이스, 소켓 등도 파일이다.
- 운영체제는 다루는 파일이 이진파일인지 텍스트 파일인지 알지 못한다.
디렉토리
- 디렉토리는 링크들의 집합이다.
- 점 한개 = (자신의 디렉토리), 점 두개 (부모의 디렉토리)
유닉스 운영체제 에서 File Descriptor는 files_struct 구조체의 *fd_array[] 배열의 인덱스 번호이며 inode를 가르키고 있다. open() 함수를 이용하여 값을 받으며, 실패시 -1를 받는다. 표준입출력과 표준에러를 위해 고정적으로 쓰이는 File Descriptor 값은 아래와 같다.
식별자
의미
Descriptor 값
STDIN_FILENO
표준입력
0
STDOUT_FILENO
표준출력
1
STDERR_FILENO
표준에러
2
유닉스 시스템 수준의 open(), read(), write(), close() 등을 사용하여 파일 입출력을 할 수 있다.
read()는open() 으로 받은FileDescriptor 인자값을이용하여 파일 읽기 작업을 수행한다.
write()도 마찬가지로 FileDescriptor 값을 사용한다. close() 할때 인자 값으로File Descriptor값을 넣어주어야 한다.
이런 시스템 콜을 사용하여 fopen(), fclose() 등과 같은 C 표준 라이브러리 함수를 구현한 것이다.
이에 대해서는 다음 포스트에서 서술해 보기로 한다.
참고
임성락, 리눅스 시스템 프로그래밍
Randal E Bryant, David R O'Hallaron <Computer Systems : A Programmer's Perspective> 3rd edition
링킹(linking)은 컴파일된 오브젝트 파일 (.obj)을 모아서 하나의 실행 파일을 만드는 과정이다. 요즘 시대의 IDE(MSVC, Xcode 등)에는 빌드시에 자동적으로 링킹이 수행되지만, 과거에는 컴파일 후에 수동으로 링킹을 해주었다. 다음과 같이 크게 두 종류의 링킹이 있다.
Static Linking
- 컴파일시에 코드를 링킹하는 방법이다.
- 재배치 가능 목적파일들을 연결하는 과정이다.
- 링킹시 연결할 목적코드당 코드를 중복하여 복사 붙여 넣기하기 때문에 실행파일의 크고,
컴파일에 소요되는 시간이 증가하지만, 프로그램 실행시 동적 링킹보다는 빠르게 기능을 수행한다.
- 프로그램 빌드에 필수적인 정적 파일은 이미 포함되어 있지만, 이외의 추가적인 파일은 별도로 설정 해주어야한다.
예 ) MSVC의 경우, 빌드 전에 프로젝트 속성 > 링커 > 일반 > 추가 라이브러리 디렉터리 // 경로추가
프로젝트 속성 > 링커 > 입력 > 추가 종속성 // lib 파일 추가
Dynamic Linking
- 런타임시에 코드를 링킹하는 방법이다.
- 컴파일시 소요되는 시간이 줄어들고 실행파일 크기가 작지만,
프로그램 실행시 정적 링킹보다는 느리게 기능을 수행한다.
- 필요한 .dll 파일을 실행파일과 같은 경로에 두면 된다.
참고
Randal E Bryant, David R O'Hallaron <Computer Systems : A Programmer's Perspective> 3rd edition