← System Programming DUJINLABS.COM

File Descriptor / I/O · Linux userspace / kernel ABI

directory 순회와 getdents64

readdir가 directory entry snapshot을 제공하지 않는다는 점과 d_type, telldir cookie, 순회 중 변경을 안전하게 다루는 법을 봅니다.

Series
15 / 37
Build
cc -std=c17 -Wall -Wextra -O2 list_dir.c -o list_dir
Run
./list_dir .
Kernel
Linux 6.18.37 LTS

readdir로 받은 이름과 metadata는 한 시점의 일관된 snapshot인가?

getdents64는 directory file position부터 여러 linux_dirent64 record를 buffer에 채운다. glibc readdir는 이 buffer를 사용자에게 한 entry씩 보여준다. 호출마다 filesystem directory iterator가 진행하지만 다른 process의 생성·삭제·rename을 잠그지 않는다.

d_type은 편의 정보이며 DT_UNKNOWN일 수 있다. 보안 또는 정확성이 필요한 코드는 이름 문자열을 다시 절대 경로로 붙이기보다 directory fd와 fstatat/openat을 사용한다.

구조 그림

그림 1. getdents64 한 번에 담기는 가변 길이 record
userspace buffer
inooffreclentypealpha\0paddinginooffreclentypelong-name\0padding
record 1
<──────── d_reclen=32 ────────>
record 2
<──────────── d_reclen=40 ────────────>

각 record의 d_reclen을 따라 다음 항목을 찾는다. d_name 길이만 더하면 alignment padding을 건너뛰지 못한다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
opendir directory fd/stream
getdents64 record batch
readdir 한 entry 반환
fstatat dirfd 기준 metadata
closedir fd와 buffer 해제

directory stream은 이름 목록의 immutable snapshot이 아니다. 각 entry 처리 시점에 필요한 객체를 dirfd-relative syscall로 다시 확정한다.

그림 3. 커널 내부에서 지나가는 주요 지점
iterate_dir f_pos와 lock
file->iterate_shared filesystem callback
dir_emit name/inode/type
filldir64 user record
copy_to_user batch 반환

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
fs/readdir.c iterate_dir(), getdents64(), filldir64() directory callback과 userspace record 작성
fs/namei.c vfs_statx(), filename_lookup() entry 이름을 실제 path와 metadata로 다시 확인
include/uapi/linux/dirent.h linux_dirent64 record length, type, offset ABI

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 list_dir.c -o list_dir
01#define _DEFAULT_SOURCE
02#include <dirent.h>
03#include <errno.h>
04#include <stdio.h>
05#include <sys/stat.h>
06
07int main(int argc, char **argv)
08{
09    const char *path = argc > 1 ? argv[1] : ".";
10    DIR *dir = opendir(path);
11    if (dir == NULL)
12        return 1;
13    int dfd = dirfd(dir);
14
15    errno = 0;
16    struct dirent *entry;
17    while ((entry = readdir(dir)) != NULL) {
18        struct stat st;
19        if (fstatat(dfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) < 0) {
20            if (errno == ENOENT)
21                continue;
22            perror("fstatat");
23            break;
24        }
25        printf("%-24s inode=%llu mode=%o\n", entry->d_name,
26               (unsigned long long)st.st_ino, st.st_mode & 07777);
27    }
28    if (entry == NULL && errno != 0)
29        perror("readdir");
30    return closedir(dir) != 0;
31}

코드 조각별 설명

실제 코드 10행DIR *dir = opendir

DIR은 fd뿐 아니라 getdents buffer와 현재 위치를 가진 libc 객체다. 같은 stream을 여러 thread가 동시에 순회하는 방식은 피한다.

실제 코드 13행int dfd = dirfd

pathname join 없이 같은 directory object를 기준으로 fstatat을 호출한다. directory가 rename돼도 기준 객체는 유지된다.

실제 코드 15행errno = 0

readdir의 NULL은 EOF와 오류를 모두 뜻하므로 loop 시작 전에 errno를 0으로 만들고 종료 뒤 구분한다.

실제 코드 19행AT_SYMLINK_NOFOLLOW

entry가 symlink이면 target이 아니라 symlink inode metadata를 읽는다. d_type만 믿지 않는다.

실제 코드 20행errno == ENOENT

readdir와 fstatat 사이 다른 process가 entry를 삭제한 정상 경쟁을 허용하고 다음 항목으로 진행한다.

세부 동작

01

record 길이는 고정이 아니다

linux_dirent64의 d_reclen으로 다음 record를 찾는다. name 길이와 alignment 때문에 sizeof(struct dirent)만큼 더하는 raw parser는 잘못된다.

glibc readdir를 쓰면 ABI parsing을 직접 할 필요가 없지만 반환 pointer는 다음 readdir 호출에서 덮어쓸 수 있다.

02

directory 변경 중 결과는 중복·누락될 수 있다

filesystem은 iterator cookie와 hash/tree position을 사용한다. 순회 도중 rename이나 insertion이 있으면 이미 본 항목이 다시 보이거나 새 항목을 못 볼 수 있으며 portable snapshot 보장은 없다.

정확한 inventory가 필요하면 변경을 막는 상위 protocol, filesystem snapshot, 반복 검증 중 하나가 필요하다.

03

recursive walk는 fd 수와 symlink policy를 관리한다

각 하위 directory를 openat한 뒤 stack/queue에 넣으면 cwd를 바꾸지 않고 순회할 수 있다. 너무 많은 directory fd를 동시에 유지하면 RLIMIT_NOFILE에 닿으므로 depth-first close 정책을 정한다.

mount crossing, symlink follow, bind mount cycle 정책을 dev/inode pair와 openat2 resolve flag로 명시한다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
DIR streamopendir에서 생성되고 closedir에서 내부 fd와 buffer가 해제된다fd, buffer, position
dirent recordreaddir 반환부터 다음 stream 호출 전까지만 유효할 수 있다d_name, d_ino, d_type
directory fddirfd가 빌려 주는 번호이며 closedir가 소유한다별도 close 금지, dup 필요 여부

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
항목이 가끔 사라짐순회 중 concurrent rename/unlink변경 source와 snapshot 요구 확인
파일 종류가 UNKNOWNfilesystem이 d_type 미제공fstatat으로 확인
readdir 오류를 EOF로 처리errno 구분 누락loop 전 errno=0 패턴 확인

직접 확인

  1. 큰 directory를 순회하는 동안 다른 process가 파일을 생성·rename해 결과 중복과 누락 가능성을 기록한다.
  2. strace에서 한 번의 getdents64가 여러 readdir 출력으로 분해되는지 확인한다.
  3. recursive walker를 openat/fstatat만으로 작성하고 symlink와 mount crossing policy를 option으로 분리한다.
실행./list_dir .
추적strace -e trace=openat,getdents64,newfstatat,close ./list_dir .

원문