목차
npm run build나 go build를 치고 기다린 적이 있다면, 그 시간 동안 무슨 일이 벌어지는지부터 봐야 한다. 시작은 “CPU는 우리 말을 모른다”는 사실 하나다.
사무실에 온 외국어 문서
이 시리즈는 처음부터 끝까지 번역 사무실을 따라간다.
우리 사무실에는 한국어만 읽는 직원이 있다. 그런데 손에 들어온 원고는 전부 다른 나라 말로 쓰여 있다. 직원이 그 원고대로 일하게 하려면 방법은 하나다. 누군가 옮겨 줘야 한다.
코드가 실행되는 일이 정확히 이 구조다. 우리가 쓴 소스는 사람이 읽으라고 만든 글이고, 그 글을 실제로 수행하는 CPU는 완전히 다른 말만 안다. 시리즈 전체가 이 옮기는 일을 언제, 어떻게, 몇 번에 나눠서 하느냐를 다룬다.
비유가 처음부터 어긋나는 자리가 하나 있다. 사람의 번역은 뜻이 조금 달라져도 문서가 굴러가지만, 기계어로 옮기는 일은 뜻이 한 글자도 달라지면 안 된다. 반올림 하나, 조건 하나가 어긋나면 그건 “의역”이 아니라 버그다. 번역이라는 낱말은 “말을 바꾸는 일”까지만 빌려 쓰고, “느슨해도 된다”는 부분은 빌리지 않는다.
CPU가 아는 말은 수뿐이다
CPU에게 if도 for도 없다. 있는 것은 수의 나열뿐이다.
메모리 어딘가에 c3이라는 바이트가 놓여 있고 CPU가 그 자리를 읽으면, CPU는 “지금 하던 함수를 끝내고 부르던 자리로 돌아가라”로 알아듣는다. c3이라는 수 자체에 그런 뜻이 적혀 있는 게 아니라, 그 CPU를 만들 때 그렇게 알아듣도록 회로를 짜 둔 것이다.
이 수들의 나열이 기계어(machine code) 다. 파일로 보면 바이트가 쭉 늘어선 것일 뿐이고, 어느 바이트가 명령이고 어느 바이트가 데이터인지도 겉으로는 안 보인다. 그걸 아는 건 그 자리를 읽는 CPU뿐이다.
명령 하나가 하는 일은 아주 작다
기계어 명령 하나가 하는 일은 우리 상상보다 훨씬 작다. 대략 이런 것들이다.
- 메모리의 어느 자리에서 값을 하나 꺼내 온다
- 꺼내 둔 값 둘을 더한다
- 결과를 메모리의 어느 자리에 넣는다
- 값이 0이면 다른 자리로 건너뛴다
total = a + b;처럼 우리에게는 한 덩어리인 문장도, CPU 쪽에서는 꺼내고 - 더하고 - 넣는 여러 걸음으로 흩어진다. 우리가 한 줄이라고 부르는 것과 CPU가 한 걸음이라고 부르는 것의 크기가 다른 것이다.
그래서 옮기는 일은 낱말을 바꿔 끼우는 수준이 아니다. 한 문장을 여러 문장으로 풀어 쓰는 일에 가깝다.
어셈블리는 그 수에 이름표를 붙인 것
기계어를 사람이 직접 읽는 건 거의 불가능하다. 그래서 명령마다 짧은 이름을 붙여 놓은 표기법이 있다. 이것이 어셈블리(assembly) 다.
mov는 값을 옮기는 명령, add는 더하는 명령, ret은 아까 본 c3이다. 어셈블리 한 줄은 대체로 기계어 명령 하나와 그대로 맞물린다. 뜻을 바꾸는 번역이 아니라, 같은 것을 사람이 읽을 수 있게 적어 준 이름표다.
디버거나 프로파일러에서 가끔 마주치는 그 낯선 화면이 이것이다. 어셈블리를 읽을 줄 몰라도 상관없지만, 그게 새로운 언어가 아니라 기계어에 붙인 이름표라는 것만 알면 화면이 덜 무섭다.
같은 한 줄이 기계마다 다른 말이 된다
여기서부터가 중요하다. 기계어는 하나가 아니다.
CPU를 만드는 쪽이 “어떤 수가 어떤 명령인지”를 정해 두는데, 이 약속을 명령어 집합이라고 부른다. 그리고 이 약속은 CPU 계열마다 다르다. 서버와 데스크톱에 흔한 x86-64가 있고, 요즘 노트북과 휴대폰에 쓰이는 arm64가 있다.
한 줄이 셋이 되기도 하고 넷이 되기도 한다. 뜻은 같은데 기계가 알아듣는 말이 서로 다르다.
두 기둥은 우열이 아니라 종류가 다르다. x86-64는 메모리에 있는 값을 바로 더할 수 있어 명령이 셋이면 되고, arm64는 값을 먼저 꺼내 둔 다음에만 계산하는 식이라 넷이 된다. 어느 쪽이 더 좋은 게 아니라 설계 철학이 다른 것이다.
중요한 건 결론이다. 같은 소스를 옮겨도 나오는 결과물이 기계마다 다르다. 번역 사무실로 치면, 읽는 직원이 프랑스어를 아느냐 독일어를 아느냐에 따라 아예 다른 원고를 만들어야 하는 셈이다.
그래서 실행 파일은 아무 데나 못 간다
이 사실이 곧바로 우리가 겪는 일이 된다. 실행 파일 안에 든 것은 특정 CPU를 향해 적힌 수의 나열이므로, 다른 계열의 CPU에 그대로 갖다 놓으면 알아들을 수가 없다.
리눅스에서 아키텍처가 안 맞는 실행 파일을 돌리면 나오는 말이 exec format error다. 파일이 깨진 것도, 권한이 없는 것도 아니다. 알아들을 수 없는 말로 적혀 있다는 뜻이다.
애플 실리콘 노트북에서 만든 컨테이너 이미지를 x86 서버에 올렸다가 이 에러를 보는 일이 흔하다. 이미지는 파일을 그대로 담아 옮겨 주지만, 그 안에 든 기계어가 향하는 CPU까지 바꿔 주지는 못한다. 그래서 이미지에는 아키텍처라는 꼬리표가 따로 붙는다.
운영체제도 자기 규칙을 얹는다
기계어가 맞아도 아직 하나가 남았다. 실행 파일은 명령만 덩그러니 든 파일이 아니다. 어디서부터 실행을 시작하는지, 코드와 데이터가 각각 어디에 놓이는지를 적은 목차가 앞에 붙는다.
이 목차의 형식을 운영체제가 정한다. 리눅스는 ELF, 윈도우는 PE, macOS는 Mach-O를 쓴다. 형식이 다르면 같은 CPU라도 못 읽는다. 그래서 실행 파일은 CPU 계열과 운영체제, 두 가지가 다 맞아야 돈다.
거기에 하나 더 있다. 화면에 글자를 찍거나 파일을 여는 일은 CPU 명령만으로는 못 한다. 운영체제에 부탁해야 하고, 그 부탁하는 방법이 시스템 콜이다. 부탁하는 규칙 역시 운영체제마다 다르다.
정리하면 실행 파일은 어느 CPU에게, 어느 운영체제 위에서 말하는지를 이미 정해 놓고 만들어진 물건이다.
실무에서: 기계어가 얼굴을 내미는 자리
기계어를 직접 읽을 일은 거의 없다. 그런데도 이 층이 튀어나오는 자리가 있다.
- 아키텍처가 안 맞을 때 - 앞에서 본
exec format error가 대표다. 요즘은 개발 장비와 서버의 CPU 계열이 다른 경우가 흔해서, 빌드할 때 어느 아키텍처를 향할지 못 박아 두거나 여러 벌을 함께 만들어 둔다. - 네이티브 의존성이 깔릴 때 - 파이썬이나 Node 패키지 중에는 안에 기계어 조각을 품은 것들이 있다. 설치할 때 “미리 만들어 둔 바이너리”를 내려받거나, 없으면 그 자리에서 빌드한다. 설치가 유난히 오래 걸리거나 컴파일러가 없다고 화내는 패키지가 대개 이 부류다.
- 환경이 갈릴 때 - 내 컴퓨터에선 되는데가 벌어지는 밑바닥 이유 중 하나가 이것이다. 같은 소스라도 옮겨 만든 결과물은 만든 장소의 CPU와 운영체제를 향한다.
셋 다 기계어를 읽을 줄 알아야 하는 일은 아니다. 기계어가 특정 기계를 향해 적힌 말이라는 걸 알면 설명이 되는 일들이다.
정리
- CPU가 아는 것은 수의 나열뿐이다. 어떤 수가 어떤 명령인지는 CPU를 만들 때 회로에 박아 둔 약속이다.
- 명령 하나가 하는 일은 아주 작아서, 소스 한 줄이 여러 명령으로 흩어진다.
- 어셈블리는 새로운 언어가 아니라 기계어에 붙인 이름표다.
- 명령어 집합은 CPU 계열마다 다르다. 같은 소스를 옮겨도 기계마다 다른 결과물이 나온다.
- 실행 파일은 CPU 계열과 운영체제 둘 다 정해 놓고 만들어진다. 그래서 아무 데나 옮겨 놓아도 돌지 않는다.
다음 글은 그 옮기는 일을 언제 하느냐로 간다. 미리 통째로 옮겨 두는 방식과 그때그때 읽어 가며 처리하는 방식이 무엇을 주고받는지가 주제다.