QUESTION
write()는 평범한 C 함수인가, 커널 진입점인가?
응용 프로그램은 보통 glibc의 write()를 호출한다. 이 함수는 인자를 architecture ABI에 맞춰 레지스터에 싣고 syscall instruction을 실행한다. 커널은 syscall 번호로 구현 함수를 선택하고 음수 errno 값을 반환한다.
libc wrapper와 kernel syscall 구현을 같은 함수로 생각하면 디버깅 위치를 잘못 잡는다. 사용자 공간에서는 -1과 errno를 보고, 커널 경계에서는 음수 오류 코드와 실제 반환 byte 수를 본다.
STRUCTURE
구조 그림
사용자 공간의 -1/errno와 커널의 음수 오류 코드는 같은 위치에 존재하지 않는다. 각 층이 바로 아래 층에 넘기는 값을 표시했다.
CALL PATH
호출 흐름
사용자 함수의 반환값과 커널 내부 반환값은 표현 방식이 다르다. libc가 음수 커널 오류를 -1과 errno로 바꾸는 지점을 분리해서 본다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
Linux 6.18.37 LTS 소스 위치
glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.
| 파일 | 함수·구조체 | 여기서 볼 것 |
|---|---|---|
| arch/x86/entry/entry_64.S | entry_SYSCALL_64 | CPU가 userspace register를 pt_regs로 바꾸는 첫 지점 |
| fs/read_write.c | ksys_write(), vfs_write() | 정수 fd가 struct file과 file_operations로 바뀌는 과정 |
| include/linux/syscalls.h | SYSCALL_DEFINE3(write) | syscall prototype과 argument type |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 syscall_write.c -o syscall_write01#include <errno.h>
02#include <stdio.h>
03#include <string.h>
04#include <unistd.h>
05
06int main(void)
07{
08 const char message[] = "syscall boundary\n";
09 size_t done = 0;
10
11 while (done < sizeof(message) - 1) {
12 ssize_t n = write(STDOUT_FILENO, message + done,
13 sizeof(message) - 1 - done);
14 if (n > 0) {
15 done += (size_t)n;
16 continue;
17 }
18 if (n < 0 && errno == EINTR)
19 continue;
20 fprintf(stderr, "write: %s\n", strerror(errno));
21 return 1;
22 }
23 return 0;
24}
CODE NOTES
코드 조각별 설명
const char message[]문자열 literal의 끝에는 NUL이 붙지만 write()로 내보낼 데이터 길이에는 NUL을 포함하지 않는다.
while (done <write()는 요청한 길이보다 적게 쓸 수 있으므로 완료 byte를 별도로 누적한다.
ssize_t n = write성공 시 byte 수, 실패 시 -1이므로 size_t가 아니라 음수를 표현할 수 있는 ssize_t로 받는다.
errno == EINTRsignal handler 실행 때문에 실제 쓰기 전에 중단된 경우 같은 위치에서 다시 시도한다.
strerror(errno)오류를 출력하기 전에 errno를 바꾸는 다른 함수를 호출하지 않는다. 멀티스레드에서는 errno가 thread-local이다.
DETAILS
세부 동작
호출 규약과 syscall 규약은 별개다
C 함수 호출은 compiler ABI를 따르고, syscall 진입은 architecture가 정한 별도 레지스터 배치를 따른다. x86-64에서는 syscall 번호가 rax에 있고 네 번째 인자는 함수 호출 때의 rcx가 아니라 r10으로 옮겨진다.
wrapper를 우회해 inline assembly를 쓰면 cancellation point, errno 변환, vDSO 선택 같은 libc 정책도 직접 책임져야 한다. 단순히 instruction 한 줄을 줄이는 최적화가 아니다.
fd는 커널 포인터가 아니다
STDOUT_FILENO 값 1은 프로세스의 파일 디스크립터 표를 찾기 위한 작은 정수다. ksys_write()가 fdget_pos()로 struct file 참조를 잡은 뒤에야 open file description에 도달한다.
dup()나 fork() 뒤에는 서로 다른 fd가 같은 struct file을 가리킬 수 있다. 이때 file offset과 status flag가 공유된다.
반환값은 데이터 흐름의 일부다
regular file에서도 signal, resource limit, filesystem error 때문에 짧은 쓰기가 생길 수 있다. pipe, socket, nonblocking fd에서는 더 흔하다.
n == 0인 write 결과를 무한 재시도하지 않도록 실제 프로그램은 대상 종류와 진행 보장 조건도 정해야 한다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
int fd | open 또는 상속으로 생기고 close/exec close-on-exec에서 사라진다 | FD_CLOEXEC, fd table slot |
struct file | open file description이며 마지막 참조가 떨어질 때 해제된다 | f_pos, f_flags, f_op |
errno | 실패한 libc 호출이 현재 thread의 값을 기록한다 | 반환값이 -1일 때만 읽기 |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| write()가 -1 | EBADF, EPIPE, EFAULT, filesystem 오류 | strace 반환값과 signal(SIGPIPE) 확인 |
| 일부 byte만 기록 | pipe/socket 공간, signal, quota, limit | 반환 byte를 누적했는지 확인 |
| 프로그램이 갑자기 종료 | 닫힌 pipe/socket에 써 SIGPIPE 수신 | SIGPIPE disposition과 EPIPE 확인 |
LAB
직접 확인
- 예제를 터미널에서 실행하고 strace가 write(1, ..., 17)처럼 fd, buffer, count를 표시하는지 확인한다.
- stdout을 head -c 1에 pipe한 뒤 큰 buffer를 반복 전송하여 EPIPE와 SIGPIPE의 관계를 본다.
- objdump -d로 실행 파일의 write@plt 호출을 찾고, gdb에서 해당 wrapper와 syscall 직전 레지스터를 비교한다.
./syscall_writestrace -e trace=write ./syscall_writePRIMARY REFERENCES