들어가며
"표준 라이브러리는 커널 기능을 사용하기 위한 것" "시스템 콜은 커널 기능을 위한 인터페이스"라는 말을 모두 들어보셨을 겁니다. 언뜻 보면 비슷해 보이는 두 개념, 과연 어떤 차이가 있고 어떻게 연결되어 있을까요? 저도 이 부분이 헷갈려서 직접 파헤쳐 보기로 했습니다.
"표준 라이브러리는 시스템 콜을 좀 더 편리하게 사용할 수 있게 해준다" 정도로 알고 있었지만, 실제로는 좀 더 구체적인 역할을 하고 있었습니다. 리눅스에서 시스템 콜을 호출하려면 특정 레지스터에 값을 설정해야 하는데, 이러한 복잡한 작업을 glibc와 같은 표준 라이브러리가 대신 처리해줍니다.
이제 대표적인 표준 라이브러리인 glibc의 read 함수가 실제로 어떻게 시스템 콜을 호출하는지 살펴보겠습니다.
📌 TL;DR (요약)
✅ 표준 라이브러리는 시스템 콜을 보다 쉽게 사용하기 위해 복잡한 레지스터 설정을 대신 수행한다.
탐구 환경
- 운영체제: 리눅스
- 아키텍처: x86-64
- glibc 버전: 2.31
🍩 glibc의 read 함수 분석
우리가 흔히 사용하는 C 표준 라이브러리 함수 read
가 내부적으로 어떻게 시스템 콜을 호출하는지 glibc
코드를 통해 따라가 보겠습니다.
1. 시작점: read.c
__libc_read (int fd, void *buf, size_t nbytes)
{
return SYSCALL_CANCEL (read, fd, buf, nbytes);
}
__libc_read
함수는 SYSCALL_CANCEL
매크로를 호출하며 read
시스템 콜 이름과 인자들을 넘겨줍니다.
여기서부터 매크로의 연쇄적인 호출이 시작됩니다.
2. 매크로 여정
SYSCALL_CANCEL
매크로는 다음과 같은 과정을 거쳐 실제 시스템 콜을 호출하는 코드로 확장됩니다.
SYSCALL_CANCEL
-> INLINE_SYSCALL_CALL
-> __INLINE_SYSCALL
-> INLINE_SYSCALL
-> INTERNAL_SYSCALL
-> internal_syscallN
(여기서 N은 인자의 개수)
read
함수는 파일 디스크립터(fd
), 버퍼(buf
), 읽을 바이트 수(nbytes
) 이렇게 3개의 인자를 가지므로, 최종적으로 internal_syscall3
매크로가 호출됩니다.
3. 최종 목적지: internal_syscall3
#define internal_syscall3(number, err, arg1, arg2, arg3) \
({
unsigned long int resultvar;
TYPEFY (arg3, __arg3) = ARGIFY (arg3);
TYPEFY (arg2, __arg2) = ARGIFY (arg2);
TYPEFY (arg1, __arg1) = ARGIFY (arg1);
/* x86-64 시스템 콜 호출 규약에 따라 레지스터에 인자 할당 */
register TYPEFY (arg3, _a3) asm ("rdx") = __arg3; /* 세 번째 인자 -> RDX */
register TYPEFY (arg2, _a2) asm ("rsi") = __arg2; /* 두 번째 인자 -> RSI */
register TYPEFY (arg1, _a1) asm ("rdi") = __arg1; /* 첫 번째 인자 -> RDI */
asm volatile ( \
"syscall\n\t" /* 시스템 콜 실행! */
: "=a" (resultvar) /* 반환값은 RAX 레지스터로 */
: "0" (number), "r" (_a1), "r" (_a2), "r" (_a3) /* 입력: 시스템 콜 번호, 인자들 */
: "memory", REGISTERS_CLOBBERED_BY_SYSCALL);
(long int) resultvar;
})
드디어 핵심을 찾았습니다! internal_syscall3
매크로를 보면, 주석으로 표시한 것처럼 시스템 콜을 호출하기 전에 각 인자들을 특정 레지스터(rdi
, rsi
, rdx
)에 할당하는 것을 명확히 볼 수 있습니다.
number
: 호출할 시스템 콜의 고유 번호입니다.arg1
,arg2
,arg3
: 시스템 콜에 전달될 인자들입니다.
x86-64 리눅스에서 시스템 콜을 호출할 때, 시스템 콜 번호는 rax
레지스터에, 인자들은 순서대로 rdi
, rsi
, rdx
, r10
, r8
, r9
레지스터에 저장되어야 합니다. internal_syscall3
매크로는 바로 이 작업을 어셈블리 코드를 통해 수행하고, 마지막으로 syscall
명령어를 실행하여 커널 모드로 전환하고 해당 시스템 콜을 실행합니다.
시스템 콜 번호는 어디에?
read
시스템 콜의 번호는 glibc
내의 헤더 파일에 정의되어 있습니다.
arch-syscall.h 파일을 보면,
// ...
#define __NR_read 0
// ...
__NR_read
가 0으로 정의된 것을 확인할 수 있습니다. 즉, read
함수의 경우 number
인자에는 0이 전달됩니다.
결론: 왜 표준 라이브러리를 사용할까?
이번 탐구를 통해 우리는 표준 라이브러리가 단순히 시스템 콜을 포장하는 것을 넘어, 시스템 콜 호출에 필요한 로우레벨의 복잡한 준비 작업(특히 레지스터 설정)을 대신 처리해준다는 사실을 확인했습니다.
또한 만약 표준 라이브러리가 없다면, 개발자는 모든 시스템 콜 호출마다 직접 어셈블리 코드를 작성하거나, 각 아키텍처와 운영체제에 맞는 방식으로 레지스터를 설정해야 할 겁니다. 이는 매우 번거롭고 오류가 발생하기 쉬운 작업입니다.
따라서 표준 라이브러리는 다음과 같은 중요한 이점을 제공합니다:
- 편의성: 개발자는 복잡한 레지스터 설정이나 시스템 콜 번호를 직접 다룰 필요 없이, 익숙한 함수 형태로 커널 기능을 사용할 수 있습니다.
- 이식성: 표준 라이브러리는 다양한 운영체제와 아키텍처의 차이점을 내부적으로 흡수합니다.
- 추가 기능: 때로는 버퍼링(예:
stdio
의printf
), 에러 처리(errno
설정) 등 시스템 콜 자체에는 없는 유용한 기능을 추가로 제공하기도 합니다.
결국, 시스템 콜이 커널로 통하는 '로우레벨 인터페이스'라면, 표준 라이브러리는 이 인터페이스를 더욱 안전하고, 편리하며, 이식성 높게 사용할 수 있도록 도와주는 '고수준의 추상화 계층'이라고 정리할 수 있겠습니다.
Reference
https://github.com/bminor/glibc/blob/glibc-2.31/sysdeps/unix/sysv/linux/read.c
https://github.com/bminor/glibc/blob/glibc-2.31/sysdeps/unix/sysdep.h
https://github.com/bminor/glibc/blob/glibc-2.31/sysdeps/unix/sysv/linux/x86_64/sysdep.h
https://github.com/bminor/glibc/blob/glibc-2.31/sysdeps/unix/sysv/linux/x86_64/64/arch-syscall.h
'CS > Linux' 카테고리의 다른 글
[Linux] recv()는 어떻게 잠들고 깨어날까? (0) | 2025.05.23 |
---|---|
[Linux] 네트워크 커널 스택: 리눅스 커널 네트워크 패킷 수신 여정 #3 (0) | 2025.05.22 |
[Linux] Ring Buffer 에서 NAPI: 리눅스 커널 네트워크 패킷 수신 여정 #2 (0) | 2025.05.21 |
[Linux] NIC에서 링 버퍼까지: 리눅스 커널 네트워크 패킷 수신 여정 #1 (0) | 2025.05.20 |
[Linux] 페이지 테이블 구조 (0) | 2025.05.15 |