C 바깥에서 errno 읽기: Hare로 perror 출력을 가로채 오류 번호를 되찾는 기묘한 꼼수

한눈에 보기
C가 아닌 언어에서 libc 함수를 호출하고 나면 늘 같은 문제가 생깁니다. errno 값을 어떻게 읽을 것인가? 이 글은 Hare 언어로 진행된 실험을 소개합니다. 이 실험은 C 래퍼 함수도, libc 내부 구현도 쓰지 않습니다. 대신 perror가 출력하는 오류 메시지를 가로챈 뒤 숫자 오류 코드로 되돌립니다.
핵심 아이디어는 세 가지입니다.
perror는errno를 직접 읽는 거의 유일한 표준 함수입니다. 그래서 그 출력(stderr)을 임시 파일로 돌려 메시지를 캡처합니다.- 초기화 때
strerror로 오류 메시지 테이블을 만들어 둡니다. 캡처한 문자열과 일치하는 항목의 인덱스가 곧errno값입니다. strerror_r의 GNU/POSIX 버전 차이나 구현마다 다를 수 있는SEEK_SET값 같은 이식성 함정도 하나씩 우회합니다.
원작자도 "괴상하고 엉망인" 방법이라고 부릅니다. 그래도 이게 실제로 된다는 점이 흥미롭고, libc와 FFI의 경계를 이해하는 데도 좋은 사례입니다.
왜 C 밖에서 errno를 읽기 어려울까?
errno는 평범한 전역 변수처럼 보이지만 실제로는 매크로입니다. 확장 결과는 구현마다 다르고(예: 스레드 로컬 변수의 주소를 반환하는 함수 호출), 이식성이나 안정성도 보장되지 않습니다.
그래서 다른 언어에서 errno를 얻으려면 보통 둘 중 하나를 택합니다.
- 구현 세부사항에 의존하기: glibc의
__errno_location, BSD의__error등 - C 래퍼 작성하기:
errno를 반환하는 작은 C 함수를 만들어 링크
이 실험은 두 방법을 모두 쓰지 않는 것이 목표입니다. libc에는 errno를 반환하는 함수가 없습니다. 하지만 오류를 설명하는 문자열을 주는 함수는 있습니다.
| 함수 | 표준 | errno 직접 읽음? |
|---|---|---|
| strerror | C | ❌ (오류 번호를 인자로 받음) |
| perror | C | ✅ |
| strerror_r | POSIX | ❌ |
| strerror_l | POSIX | ❌ |
이 중 errno를 스스로 읽는 함수는 perror뿐입니다. 문제는 결과를 stderr에 출력한다는 점입니다. 그렇다면 stderr에 실제로 찍히지 않게 하면서 그 출력을 캡처하면 됩니다.
1단계: stderr를 임시 파일로 돌려 메시지 캡처하기
초기화와 정리
- 프로그램 초기화 단계에서
dup으로 원래 stderr를 보관합니다. mkstemp로 임시 파일을 만든 뒤,dup2로STDERR_FILENO(2번)를 이 임시 파일에 연결합니다.- 종료할 때는 임시 파일 디스크립터를 닫고 파일을 삭제합니다.
- stderr를 쓰는 다른 libc/시스템 호출 래퍼는 2번 대신 보관해 둔 원래 stderr 디스크립터를 사용하도록 바꿉니다.
💡 주의할 점: 이 전환은 오류가 발생하기 전부터 유지해야 합니다.
perror호출 직전에dup2로 전환하면dup2자체가errno를 덮어쓸 수 있기 때문입니다.
문자열을 반환하는 첫 번째 get_errno()
perror(null)을 호출해 임시 파일에 오류 메시지를 기록합니다.- 뒤에 널 바이트를 씁니다.
lseek로 파일 시작 위치로 이동한 뒤 256바이트 정적 버퍼로 읽어 들입니다.strnlen으로 길이를 구해 문자열을 반환하고, 파일 위치를 다시 처음으로 되돌립니다.- 널 바이트 쓰기나 읽기가 실패하면
"Unknown error"를 반환합니다.
여기까지만 해도 "오류 메시지 문자열"은 얻을 수 있습니다.
2단계: 오류 문자열을 숫자로 역변환하기
문자열만으로는 부족합니다. EBADF인지 ENOENT인지 숫자로 비교할 수 있어야 하니까요.
오류 메시지 테이블 만들기
- 초기화 함수에서 256×256바이트 테이블을 만듭니다.
- 오류 번호 0부터 255까지
strerror가 반환한 문자열을 테이블에 복사합니다.
왜 strerror_r이 아니라 strerror일까?
스레드 안전성만 보면 strerror_r이 더 나아 보입니다. 하지만 이 함수에는 서로 호환되지 않는 두 버전이 있습니다.
- GNU 버전(비표준): 오류 메시지 포인터를 반환하며, 자체 불변 정적 저장 공간을 쓸 수 있습니다.
- POSIX 버전: 항상 사용자 버퍼에 기록하고
int를 반환합니다.
심볼 이름도 libc마다 다릅니다.
- glibc:
strerror_r심볼은 GNU 버전이고, POSIX 버전은__xpg_strerror_r입니다. - musl, BSD libc:
strerror_r심볼로 POSIX 버전만 제공합니다.
결국 POSIX 버전을 확실히 호출하려면 glibc 내부 심볼명에 의존해야 합니다. "libc 독립성"이라는 목표와 맞지 않으므로 단순한 strerror를 택한 것입니다.
숫자를 반환하는 get_errno()
- 캡처한 메시지 끝의 개행 문자를 널 문자로 바꿉니다.
- 테이블 전체와
strcmp로 비교해 일치하는 항목의 인덱스를 정수 기반errno타입으로 반환합니다. - 버퍼는 정적 버퍼 대신 지역 버퍼를 씁니다.
- 쓰기·읽기에 실패하거나 일치하는 항목이 없으면
-1을 반환합니다.
write 래퍼 예시
- 대상이
STDERR_FILENO이면 보관해 둔 원래 stderr 디스크립터로 바꿔서 씁니다. - 실제
write가-1을 반환하면get_errno()를 호출해 오류 코드를 돌려줍니다. - 성공하면 기록한 바이트 수를 반환합니다.
3단계: 범용 POSIX 타깃과 SEEK_SET 우회
실험의 진짜 출발점
원래 목적은 따로 있었습니다. Hare가 공식 지원하지 않는 POSIX 시스템에서도 범용 "posix" 타깃으로 컴파일하고 시스템 libc에 링크해 실행할 수 있는지 확인하는 것이었습니다. 오류 번호는 시스템마다 다르기 때문에 이 타깃에서는 원래 오류 문자열만 반환하려 했습니다. errno 숫자 복원은 그 과정에서 나온 파생 실험입니다.
SEEK_SET도 0이라는 보장이 없다
앞의 코드는 SEEK_SET = 0으로 정의합니다. 하지만 POSIX는 이 값을 보장하지 않고 구현 정의로 남겨 둡니다. 대부분 0이긴 해도, 이 역시 C 밖에서 구현 세부사항에 기대는 지점입니다.
우회 방법은 lseek의 반환값을 확인하는 것입니다.
- 오프셋 0과 후보
whence값을 넣어lseek를 호출합니다. - 반환된 오프셋이 0이면 파일 시작으로 이동한 것으로 보고, 그 값을
SEEK_SET으로 간주합니다. - 예제는 후보를 0부터 2까지 순회하며, 찾은 값을 파일 위치를 되돌릴 때도 사용합니다.
- 0~2 범위는 "아마 이 중 하나일 것"이라는 예상일 뿐이므로 더 큰 값도 검사할 수 있습니다.
실제로 동작할까? EBADF로 검증하기
최종 예제는 일부러 write(-1, ...)를 호출해 잘못된 파일 디스크립터 오류를 일으킵니다.
- 호출이 성공하면 중단합니다(있을 수 없는 상황이므로).
- 오류가 나면 복원한 정수 값이
sys::EBADF와 같은지 단언(assert)합니다.
그리고 이 단언은 통과합니다. 즉 C 래퍼 없이, libc 내부 심볼 없이도 errno 값을 되찾을 수 있다는 것이 증명됩니다.
에디터의 한마디
이 방법을 실무에 그대로 쓰라고 권하기는 어렵습니다. 실제로 적용하기엔 걸리는 점이 많습니다.
- 오류마다 파일 I/O가 발생해 성능 오버헤드가 있습니다.
- 서로 다른 errno가 같은 메시지를 갖거나 로케일에 따라 메시지가 바뀌면 역변환이 모호해질 수 있습니다.
strerror는 스레드 안전성이 보장되지 않고, stderr 리디렉션은 멀티스레드 환경에서 위험합니다.
그래도 이 실험의 가치는 방법이 존재한다는 것을 보여준 데 있습니다. Rust, Zig, Go, Hare 등 C 이외의 언어로 시스템 프로그래밍을 하거나 FFI를 설계해 본 분이라면, 왜 많은 언어가 결국 __errno_location 같은 구현 세부사항에 기대는지 체감할 수 있을 겁니다. "표준만으로 어디까지 갈 수 있는가"를 끝까지 밀어붙인 흥미로운 해킹 기록입니다.