Actually Portable Executables 빌드를 위해 GCC 패치하기
(ahgamut.github.io)- Cosmopolitan Libc로 기존 C 소프트웨어를 더 많이 빌드하려면 빌드 시스템을 속이는 수준을 넘어, 약 2,000줄 규모의 GCC 패치로 컴파일러가 문제 코드를 자동 변환해야 했음
- 장애물은
SIGTERM,EINVAL같은 시스템 값이 컴파일 타임 상수가 아니어서 C의switch case라벨과static/const struct초기화 규칙에 걸리는 점이었음 sed, Python 정규식, GCC 플러그인, 전처리기 매크로 가로채기까지 시도했지만,case라벨 오류가 파싱 중 발생해 플러그인만으로는 막기 어려웠음- 최종 패치는
-fportcosmo플래그로 켜지며, GCC가 오류를 내기 직전에switch를if/goto로 바꾸고 구조체 초기화는 런타임 값 채우기로 보완함 - 익명 구조체,
enum, 일부const int, 초기화식 안의 배열 인덱스 같은 한계는 남지만,bash,curl,git,ninja,gcc같은 소프트웨어를 소스 변경 없이 빌드할 수 있는 길이 열림
GCC 패치의 목표
- Cosmopolitan Libc로 Actually Portable Executables를 더 쉽게 만들기 위해 약 2,000줄의
gcc패치가 작성됨 - 패치 적용 후
./configure또는cmake빌드 시스템으로 다음 소프트웨어를 빌드할 수 있음bashcurlgitninjagcc자체
- 빌드된 실행 파일은 Linux, FreeBSD, MacOS, OpenBSD, NetBSD, Windows에서도 실행될 수 있어야 함
- Windows 실행은 직접 테스트하지 않았다는 단서가 있음
- GitHub Actions로 빌드된 바이너리는 superconfigure releases에서 받을 수 있음
Cosmopolitan Libc 포팅에서 막힌 지점
- Cosmopolitan Libc가 2021년
redbean웹서버로 주목받은 뒤, 기존 C 소프트웨어가 이 libc 위에서 얼마나 잘 동작하는지가 중요한 질문이 됨 - Lua, Wren, Janet,
quickjs같은 언어 런타임을 포팅하는 과정에서 같은 패턴이 반복됨 SIGTERM,EINVAL같은 시스템 상수를switch문의case라벨에 쓰면 C 컴파일 규칙과 충돌함- C 표준은
case라벨이 컴파일 타임 상수여야 한다고 요구함- Cosmopolitan Libc에서는 시스템 인터페이스 관련 C 전처리기 매크로가 symbolic이어야 했음
- 그 결과
switch(errno) { case EINVAL: ... }같은 흔한 코드가 컴파일되지 않을 수 있었음
switch를 if와 goto로 바꾸기
switch(errno)안의case EINVAL,case ENOSYS같은 라벨이 컴파일 타임 상수가 아니면 GCC는case label does not reduce to an integer constant오류를 냄- 패치는
switch문을 자동으로 다음 구조로 변환함- 각
case값마다if (errno == EINVAL) goto caselabel_EINVAL;형태의 분기를 추가 default라벨로 이동하는goto를 추가- 원래
case블록은 라벨과goto endofthis_switch로 재구성
- 각
- 이 방식이 가장 우아하진 않지만, 여러 코드베이스에서 본 사례를 처리할 수 있었음
ifdef와 fallthrough가 섞인switch까지 다뤄야 했기 때문에goto기반 변환이 단순한 선택지였음
struct 초기화도 같은 제약을 받음
static또는const struct초기화에서도 컴파일 타임 상수 문제가 발생함EINVAL이 컴파일 타임 상수가 아니면 다음 초기화 중 일부는 C에서 유효하지 않음- 함수 안의 일반 지역
struct초기화는 가능 const structstatic struct- 전역
const struct - 전역
static struct
- 함수 안의 일반 지역
- 실제 사례로 CPython의
faulthandler모듈이 있음 - 해결책은 구조체를 더미 값으로 초기화한 뒤, 사용 전에 올바른 값을 채우는 코드를 추가하는 방식임
- 함수 안에서는
if문을 추가 - 전역 또는 정적 초기화에는
__attribute__((constructor))를 사용
- 함수 안에서는
텍스트 치환과 GCC 플러그인의 한계
- 처음에는
sed셸 스크립트로 변환을 자동화하려 했고, 이후 Python 스크립트와 정규식으로 fallthrough까지 처리하려 했음 - 하지만 C 전처리기와
ifdef때문에 텍스트 치환은 완전하게 동작하기 어려웠음- 컴파일 시 어떤
ifdef가 활성화될지 미리 알기 어렵기 때문임
- 컴파일 시 어떤
- 다음 시도는 GCC 플러그인을 이용한 AST 변환이었음
- GCC 플러그인은
-fplugin=your-plugin.so로 로드 가능함 PLUGIN_PRE_GENERICIZE,PLUGIN_FINISH_PARSE_FUNCTION,PLUGIN_FINISH_DECL같은 이벤트에서 AST에 접근할 수 있음debug_tree로 AST를 출력하고,walk_tree_without_duplicates로 AST를 순회할 수 있음
- GCC 플러그인은
- 문제는
case라벨 검증이 GCC 파싱 중에 발생한다는 점이었음- 플러그인은 파싱이 끝난 뒤 AST에 접근함
- 잘못된
case SIGTERM:라벨은 AST에 남지 않음 - GCC가 이미 오류를 낸 뒤라 플러그인에서 고치기 어려웠음
전처리기 매크로 가로채기 실험
- 플러그인만으로 파싱 전 오류를 막기 어려워 C 전처리기와 상호작용하는 방법을 시도함
- GCC 플러그인 헤더의
cpp_reader구조를 통해 매크로가#define,#undef, 사용될 때 콜백을 걸 수 있었음 - Cosmopolitan Libc의 시스템 값 매크로 구조를 이용해 임시 상수를 삽입함
- 예:
extern const int SIGTERM; - 예:
#define SIGTERM SYMBOLIC(SIGTERM) - 플러그인용으로
static const int __tmpcosmo_SIGTERM = ...같은 임시 값을 둠
- 예:
- 플러그인은
SYMBOLIC(SIGTERM)사용을 가로채 임시 값으로 바꾸고, 이후PLUGIN_PRE_GENERICIZE단계에서 AST를 다시 올바른VAR_DECL로 바꿈 - 이 매크로 해킹은
switch와struct초기화 오류를 우회하는 데 성공함 - 이 방식으로 최소 CPython 3.11 빌드도 가능해짐
플러그인 대신 GCC 자체를 고친 이유
- Justine Tunney와 논의한 뒤, 문제가 풀릴 수 있다는 점이 확인되자 GCC 코드베이스를 직접 수정하는 더 단순한 접근이 제안됨
- 플러그인과 매크로 해킹은 불필요한 작업이 많고, 엣지 케이스와 크래시 처리가 복잡했음
- 예를 들어
case ENOSYS만 변환하면 되는 상황에서도 매크로 치환 때문에ENOSYS + 1이나printf("ENOSYS = %d", ENOSYS)같은 정상 사용까지 플러그인이 처리해야 했음 - GCC 패치 방식에서는 별도
-fplugin대신-fportcosmo플래그로 기능을 켬 - GCC가
case is not constant오류를 내기 직전에flag_portcosmo활성화 여부를 확인하고, 켜져 있으면 필요한 치환을 수행함 - 기존 플러그인의 AST 변환 코드는 GCC 내부에서 다른 플러그인 콜백을 호출하기 전에 실행되도록 옮겨짐
- 이 변경으로 매크로 관련 해킹 코드를 제거할 수 있었음
실제 빌드와 패치 확장
- 2023년 6월 5일 시점에 패치된 GCC는 이전 플러그인 테스트 케이스를 모두 통과했고, Cosmopolitan Libc 모노레포에 새 바이너리가 추가됨
- 더 많은 코드를 컴파일하면서 수정과 개선이 이어짐
lua빌드는 간단해졌고,g++에서case constant오류가 발생하는 위치를 찾아ninja도 빌드할 수 있게 됨- Python 3.11에
ncurses를 붙이려는 과정에서 새로운 문제가 발견됨ncurses에는DATA2(PARODD | PARENB, PARODD)처럼 구조체 초기화 값에 이항 표현식이 들어가는 코드가 있었음- 기존 패치는 단순 상수 초기화는 처리했지만
PARODD | PARENB같은 표현식은 처리하지 못했음
- 이후 C의
case라벨과 구조체 초기화 요소가 임의 표현식이 될 수 있도록 패치가 확장됨- C 표준상
case라벨은 여전히 컴파일 타임 상수여야 함 - 패치된 컴파일러는 이런 사용에 경고를 내지만 컴파일은 계속함
- C++은
g++11의constexpr처리와 맞지 않아 임의 표현식 치환 대상에서 제외됨
- C 표준상
- 이 변경 뒤
ncurses는 문제 없이 빌드됨
남아 있는 제약과 실용적 결론
- 패치는 완벽하지 않음
- 일부 익명 구조체를 처리하지 못함
enum,const int의 일부 사례를 처리하지 못함- 초기화식 안에서
SIGTERM을 배열 인덱스로 쓰는 사례를 처리하지 못함 - 이상한
static초기화나SIGTERM을static const int8_t에 넣는 경우 드문 컴파일러 크래시가 남을 수 있음
- 그래도 명백한 반례는 많이 제거되었고, 많은 인기 소프트웨어가 매끄럽게 빌드됨
- 더 엄격한 테스트 환경은 추가 개선 지점을 드러낼 수 있음
- 원한다면
switch문과struct초기화식을 손으로 고칠 수도 있지만, 많은 경우 컴파일러가 자동 처리할 수 있음 - C 소프트웨어를 정적으로 빌드할 수 있고 특히
musl로도 빌드된다면, Cosmopolitan Libc로도 빌드될 가능성이 있음
댓글과 토론
Hacker News 의견들
-
이 글을 썼는데, 제목은 “Patching GCC to build Actually Portable Executables”가 되어야 함. Cosmopolitan Libc와 jart의 Actually Portable Executable 형식을 가리키기 때문임
내 GCC 패치로 이제 vim, emacs, ninja, bash, git, gcc 같은 소프트웨어를 Cosmopolitan Libc로, 평소의 autotools/cmake식 빌드 시스템을 통해 빌드할 수 있음. 빌드된 실행 파일은 Linux, FreeBSD, MacOS, OpenBSD, NetBSD, Windows에서 실행될 수 있어야 함. 다만 Windows는 아직 테스트하지 않았음
이 기법으로 빌드해 본 소프트웨어 목록은 여기 있음: https://github.com/ahgamut/superconfigure
superconfigure 스크립트는 소프트웨어 빌드에 쓰는 일반 configure 스크립트를 감싸고,--enable-static같은 플래그를 넣어주는 래퍼일 뿐임
Cosmopolitan Libc로 GCC를 빌드해 보고 싶다면 이 저장소를 써보면 됨: https://github.com/ahgamut/musl-cross-make/tree/gccbuild- 이 패치가 GCC 업스트림에 들어갈 가능성이 있는지 궁금함. APE 실행 파일과 cosmopolitan libc는 엄청 멋진 기술이라 더 쓰기 쉬워지면 좋겠음
- 좋음. Nim처럼 트랜스파일되는 언어 뒤에서 이걸 쓸 때 문제가 생길 가능성이 있어 보이는지 궁금함
- busybox 빌드도 시도해 봤는지 궁금함
- 최근 lobste.rs에서 jart의 sockpuppet이라는 이유로 차단당한 걸 봤는데, 이제야 실제로 다른 사람이라는 걸 깨달았음
HN에서는 더 나은 대우를 받길 바람 - 빌드가 된다는 것만으로는 그렇게 큰 성과는 아니고, 더 흥미로운 건 실제로 동작하느냐임
예를 들어 glibc 버전과 비교했을 때 회귀가 없다는 걸 입증할 만큼 충분한 테스트가 있었는지가 궁금함
-
Go 저장소에 이와 관련된 이슈가 있음: https://github.com/golang/go/issues/51900
-
전체적으로 꽤 멋지긴 한데, 그 이론적 배경이 궁금함. 새 운영체제 대상을 만든다면 논리적으로는 새 상수를 정의하는 게 맞아 보임
하위 운영체제마다 그 운영체제의 상수를 그대로 넘기는 대신, 각 운영체제의 상수를 어디서나 같아지도록 변환하면 됨. 그러면 상수를 배열 인덱스나 case 문에 쓰는 코드도 정상적으로 동작함
글에서 설명한 접근보다 오버헤드는 더 생길 수 있지만, 훨씬 쉽고 Cosmopolitan libc에 새 프로그램을 컴파일하려는 사람 입장에서도 쓰기 단순해 보임- 새 상수를 만들면 컴파일은 훨씬 쉬워지지만, 개인적으로는 실행 중 기존 상수와 새 상수 사이를 변환하는 오버헤드가 너무 크다고 봄
여기 상수 목록을 보면 됨: https://github.com/jart/cosmopolitan/blob/master/libc/sysv/c...
이 상수들 중 하나를 쓸 때마다 많은 상수를 큰 조회 테이블로 바이너리에 넣어야 하고, 프로그램에서 확인이 필요할 때마다 그 테이블을 거쳐야 함. 아주 느리지는 않을 수 있지만 분명 체감될 거라고 봄
목표는 libc나 포팅하려는 소프트웨어의 소스 코드를 많이 바꾸지 않으면서 포팅을 쉽게 만들고, 성능도 비슷하거나 더 나은 바이너리를 만드는 것이었음. 그런 제약에서는 이 GCC 패치가 과정을 단순화하는 최선의 방법처럼 보였음
SIGHUP을 배열 인덱스 초기화에 쓰는 코드베이스를 충분히 많이 만나면, 제안한 방식을 시도해서 절충점을 측정해 볼 생각임. 아니면 직접 시도해 보고 별도 상수 집합이 더 나은지 알려줘도 됨
- 새 상수를 만들면 컴파일은 훨씬 쉬워지지만, 개인적으로는 실행 중 기존 상수와 새 상수 사이를 변환하는 오버헤드가 너무 크다고 봄
-
“명백한 반례를 제거하는 데 꽤 시간을 썼고, 많은 인기 소프트웨어가 매끄럽게 빌드된다”면, 성공적으로 컴파일되는 소프트웨어 목록을 공개하면 도움이 될 듯함. 이미 있을 수도 있음
- vim, emacs, ninja, bash, git, gcc 같은 소프트웨어를 빌드할 수 있음. 이 기법으로 빌드해 본 목록은 여기 있음: https://github.com/ahgamut/superconfigure
superconfigure 스크립트는 소프트웨어 빌드에 쓰는 일반 configure 스크립트를 감싸고,--enable-static같은 플래그를 넣어주는 래퍼일 뿐임
- vim, emacs, ninja, bash, git, gcc 같은 소프트웨어를 빌드할 수 있음. 이 기법으로 빌드해 본 목록은 여기 있음: https://github.com/ahgamut/superconfigure
-
APE의 실용적인 목적이 있는지 궁금함. 여러 운영체제에서 돌아가야 하는 단일 실행 파일을 실제로 배포하는 곳이 있나?
- Logfile Navigator(https://lnav.org)에서 원격 호스트와 통신하는 에이전트로 APE 실행 파일을 사용함
lnav 자체는 APE로 빌드하지 않지만, 그 안에 내장된 에이전트는 APE로 빌드함. 사용자가 해당 호스트의 로그를 읽고 싶을 때 그 에이전트를 원격으로 전송함
이렇게 하면 운영체제 종류를 따로 판별하거나 여러 버전의 실행 파일을 포함하는 추가 단계가 필요 없음. 관련 짧은 글은 여기 있음: https://lnav.org/2021/05/03/tailing-remote-files.html - Java/Clojure/Kotlin 프로그래머, .Net 프로그래머, JS, Python, Ruby, Erlang과 모든 가상 머신 기반 언어 프로그래머들이 떠오름
정적으로 컴파일된 프로그램은 모든 것의 밑바닥에 있지만, 애플리케이션 수준에서는 소수임
APE는 “C처럼 저수준인 선행 컴파일(AOT) 언어가 한 번 컴파일해서 어디서나 실행될 수 있다면 누가 가상 머신을 필요로 하나?”라는 큰 질문을 던짐 - 예를 들어 maven 저장소처럼 같은 id/version 조합에 여러 아티팩트를 두기 번거로운 곳에서 실행 파일 배포를 조금 더 쉽게 만들 수 있을 것 같음. PyPI도 비슷한 문제가 있을 듯함
게다가 크로스 컴파일은 고통스러움. 여러 BSD를 모두 갖춘 빌드 팜이 없는 작은 오픈소스 프로젝트는 그런 플랫폼용 바이너리를 아예 배포하지 않는 경우가 많음
kubectl조차 Linux용 바이너리는 있지만 BSD용은 없음. OSX를 BSD로 치지 않는다면 말임. 그래서 이런 프로젝트에는 손쉬운 이득처럼 보임 - 앞으로의 사이드 프로젝트는 그런 방식으로 릴리스하려고 함. 클라이언트 전용 SPA에도 Redbean 실행 파일을 빌드함. 이런 것들이 여러 시스템에서 그냥 동작한다는 아이디어가 놀라움
- 플랫폼에 구애받지 않고 바로 실행 가능한 실행 파일 형식이 있으면 도움이 되는 셰이더 컴파일러가 있음
사용자의 머신에서 즉석으로 빌드하기에는 너무 큼. 여러 큰 서드파티 C++ 의존성이 들어 있고, 하드웨어에 따라 빌드에 2분에서 20분이 걸림
macOS x86-64+ARM, Linux x86-64, Windows x86-64에서 돌아가야 함. ARM Linux도 되면 좋음
지금은 WASM을 검토 중이지만, 설치된 WASI 런타임이 필요함
- Logfile Navigator(https://lnav.org)에서 원격 호스트와 통신하는 에이전트로 APE 실행 파일을 사용함
-
헷갈리는 사람을 위해: “Actually Portable Executables” https://justine.lol/ape.html
- 이걸 언급하는 댓글을 추가했지만 아직 바뀌지 않았음. 곧 바뀔 거라고 봄. 여기서 @dang을 호출해도 되려나?
-
switch를if로 바꾸는 건 꽤 투박함. 플랫폼 중립 상수 집합으로 매핑해서 런타임 심볼을 함수로 역참조하면 고칠 수 있을 것 같음
예를 들어switch(CosmoMappErrno(EINVAL))와case COSMO_EINVAL:처럼 쓰면 코드를 goto 덩어리로 만들지 않아도 됨- 하지만 그러면 수정하지 않은 코드를 컴파일하는 데는 도움이 안 됨. 컴파일러 내부에서 생성되는 goto는 걱정할 필요가 없음. 컴파일러는 원래 여기저기서 그렇게 함
물론 컴파일러가 이런 switch를 인식해서 if 트리로 다시 쓰는 대신, 각 값을 위한 cosmo 전용 특수 상수로 label을 바꾸고, switch 입력값을 현재 런타임 플랫폼 값에서 해당 cosmo 전용 상수로 매핑하는 함수 호출로 감싸는 식을 말한다면 다름. 다른 값은 변경 없이 통과시키고 말임
그건 실제로 더 단순한 컴파일러 변환일 수도 있음. 다만 여러 상수 집합 중 어떤 것이 쓰이는지 인식하고, switch 입력값에 알맞은 매핑 호출을 적용해야 하는 복잡성이 생김. 라이브러리 쪽에서도 이런 매핑을 만들어야 해서 복잡도가 필요함
이미 다른 변환 방식으로 동작하는 구현이 있으니, 작성자가 이걸 구현하고 싶어할지는 모르겠음
마지막으로 이런 매핑 방식은 잘못 매핑할 위험이 있고, 음수 errno 값을 쓰는 코드에서는 매핑 전에 입력을 반전해야 하는 식의 까다로움도 있음 - 런타임 심볼을 함수로 역참조하는 방식은 흥미롭게 들림. 동작하는 예를 보여주면 기꺼이 시도해 보겠음
if-else-goto 배열이 마음에 드는 이유는 GCC의 다른 부분과 딱 맞아떨어졌기 때문임. 내 패치[1]를 보면 이 기능을 추가하는 데 GCC의 기존 코드를 아주 조금만 바꿨다는 걸 알 수 있음
[1]: https://github.com/ahgamut/gcc/tree/portcosmo-11.2 - 대체가 자동으로 이뤄지는지까지 신경 써야 하나?
- 하지만 그러면 수정하지 않은 코드를 컴파일하는 데는 도움이 안 됨. 컴파일러 내부에서 생성되는 goto는 걱정할 필요가 없음. 컴파일러는 원래 여기저기서 그렇게 함
-
SIGxxx를 인덱스로 쓰는 코드에 그렇게 놀라는 이유를 모르겠음. 시그널 번호는 잘 정의되어 있고, 대체로 연속적이며 1부터 시작한다고 알려져 있음- busybox를 시도하기 전에 git, bash, gcc, curl 등 여러 코드베이스를 빌드해 봤는데, 그런 코드는 본 적이 없었음
게다가 그 오류가 디버깅에 오래 걸린 내 패치의 컴파일러 오류와 같이 나와서 기억에 남는 놀라움이었음
그런 코드 패턴이 흔하다고 보는지 궁금함. busybox 말고 어떤 코드베이스에 있는지도 알고 싶음. 예가 많다면 그런 패턴을 처리하도록 패치를 업데이트하는 데 시간을 써볼 수도 있음
- busybox를 시도하기 전에 git, bash, gcc, curl 등 여러 코드베이스를 빌드해 봤는데, 그런 코드는 본 적이 없었음
-
errno가 그렇게 큰 문제라면, C++ 표준 라이브러리와 std::error_code를 실행할 희망은 무엇인지 모르겠음
거기서는 마법 같은 매크로를 쓸 방법이 없음- 문제는 그 마법 같은 매크로였던 것 같음. 보통 매크로는 상수로 확장되지만, 이제는 그렇지 않아서 일부가 깨짐
std::error_code는 상수가 아니므로 같은 문제가 없을 듯함
- 문제는 그 마법 같은 매크로였던 것 같음. 보통 매크로는 상수로 확장되지만, 이제는 그렇지 않아서 일부가 깨짐