Understanding Binary Compatibility through ISA and ABI
바이너리 호환성은 “같은 이진 코드가 서로 다른 구현의 프로세서/운영체제 조합에서 동일하게 동작하는가”의 문제다. 이때 핵심 축은 하드웨어-소프트웨어 경계를 정의하는 ISA(Instruction Set Architecture)와, 동일 ISA 위에서 운영체제/컴파일러/링커/로더가 합의하는 ABI(Application Binary Interface)다. ISA가 “어떤 명령과 자원을 쓸 수 있는가”를 정한다면, ABI는 “그 자원들을 프로그램·라이브러리·OS가 어떤 규칙으로 사용할 것인가”를 정해 이진 수준의 상호 운용성을 보장한다.
하드웨어 추상화의 맥락
컴퓨터에서는 이진 데이터(0과 1)를 전기 신호의 상태로 표현함. 간단한 논리 게이트들은 조합 논리회로의 기본 단위이며, 이를 서로 연결하여 더 복잡한 디지털 회로를 구현할 수 있다. 논리 게이트들을 조합하면 반가산기/전가산기와 같은 산술회로나, 디코더/멀티플렉서 등의 회로를 만들 수 있고, 나아가 레지스터나 계산 논리 장치(ALU), 컴퓨터 메모리까지 구축할 수 있다.
플립플롭(flip-flop)은 논리 게이트를 이용하여 상태를 저장할 수 있는 순차 논리회로를 말한다. 플립플롭은 두 개의 안정된 상태(stable states)를 가져서 내부에 1비트의 상태 정보를 유지할 수 있다. 플립플롭의 동작 원리는 피드백에 있다. 출력의 일부를 자기 자신에게 되먹임(피드백)함으로써, 입력이 변하지 않을 때도 이전 출력 상태를 지속해서 유지할 수 있다.
플립플롭은 레지스터와 파이프라인 래치의 기본 요소이며, 대용량 메모리는 주로 SRAM/DRAM과 같은 메모리 셀 구조로 구현한다(예: 캐시는 일반적으로 6T SRAM 비트셀을 활용). 또한 플립플롭을 통해 순차 논리 회로를 구현하면, 현재 상태와 입력에 의해 다음 상태가 결정되는 Finite State Machine을 만들 수 있다.
이를 이용하여 메모리를 구성하고, 회로를 통해 ALU를 만들수 있다. 이제 이를 어떻게 효율적으로 설계하느냐가 중요한 쟁점으로 자리잡았다.
어셈블리 코드의 구조와 명령 실행 방식
어셈블리어(Assembly language)는 기계어 명령을 사람이 읽기 쉬운 문자열 형태로 표현한 프로그래밍 언어이다. 각 어셈블리 명령어(mnemonic)는 하나의 기계어 명령에 대응하며, CPU가 수행할 연산의 종류(opcode)와 피연산자(레지스터, 메모리 주소, 즉값 리터럴 등)를 명시한다. 예를 들어 ADD R1, R2, R3
라는 어셈블리 명령은 “레지스터 R2의 값과 R3의 값을 더하여 그 결과를 R1에 저장하라”는 기계어 연산을 의미한다.
이 어셈블리 코드를 디자인하여 CPU를 어떻게 디자인하고 이를 효율적으로 처리하는 지 연구하여 최적의 속도를 만드는 것이 굉장히 중요하다. 예를 들어 다음의 상황을 보자.
ADD r2, r2, r3
: armadd eax, edx
: x86
같은 기능을 수행하는 명령어라도 CPU에 따라서 명령어의 구조가 다르다. 파이프라이닝, 주소 지정 등의 효율성을 위해서 이렇게 다른 설계 철학을 가진다. 이에 따라서 word의 단위가 달라지기도 하고 레지스터의 개수 등이 달라지기도 한다. 이렇게 만들어지는 CPU에 따라 달라지는 명령어 셋을 **Instruction Set Architecture (ISA)**라고 한다.
예를 들어 다음과 같다. (다음 예제는 GPT로 생성한 것이므로 정확하지 않을 수 있다.)
1. x86 어셈블리 코드 예제 (Intel 문법)
x86 Assembly (Intel syntax)
section .data
arr db 1,2,3,4,5 ; 5개의 8비트 값
n equ 5 ; 원소의 개수
section .text
global _start
_start:
mov ecx, n ; ECX를 루프 카운터로 사용 (n = 5)
mov esi, arr ; ESI에 배열의 시작 주소 저장
xor eax, eax ; EAX를 0으로 초기화 (합계 초기값)
sum_loop:
movzx edx, byte [esi] ; 메모리에서 1바이트 값을 0 확장하여 EDX에 로드
add eax, edx ; EAX에 EDX 값을 더함 (누적 합)
inc esi ; 다음 원소로 포인터 증가
loop sum_loop ; ECX를 감소시키고 0이 아니면 반복
; 최종 합은 EAX에 저장됨
; 이후 OS 호출 등을 통해 프로그램 종료 처리
x86 코드 특징
- 가변 길이 명령어: x86 명령어는 길이가 일정하지 않아 디코딩 과정이 복잡할 수 있다.
- 전용 명령어 사용:
loop
명령어를 이용해 자동으로 카운터를 감소시키고 분기할 수 있으나, 최신 프로세서에서는 예측 효율이나 파이프라인 측면에서 반드시 최적은 아니다. - 메모리 접근: 단일 명령어(
movzx
)로 메모리에서 바이트를 로드해 32비트 레지스터에 확장하는 방식 사용.
2. ARM 어셈블리 코드 예제
AREA MyCode, CODE, READONLY
ENTRY
LDR r0, =arr ; r0에 배열 주소 로드
LDR r1, =n ; n의 주소 로드
LDR r1, [r1] ; r1에 실제 n 값 (5) 로드
MOV r2, #0 ; r2를 합계(0)로 초기화
loop:
LDRB r3, [r0], #1 ; r0의 현재 주소에서 1바이트 로드 후, r0를 자동 증가
ADD r2, r2, r3 ; r2에 r3 값을 누적
SUBS r1, r1, #1 ; r1을 1 감소시키면서 조건 플래그 설정
BNE loop ; r1이 0이 아니면 반복
; 최종 합은 r2에 저장됨
END
arr DCB 1,2,3,4,5
n DCD 5
ARM 코드 특징
- 고정 길이 명령어: 모든 명령어가 32비트(또는 Thumb 모드에서는 16/32비트)로 고정되어 있어 디코딩 및 파이프라인 처리에 유리하다.
- Post-index addressing:
LDRB r3, [r0], #1
와 같이 한 명령어로 메모리 로드와 포인터 증가를 동시에 수행하여 코드가 간결하다. - 조건 코드 활용:
SUBS
명령어는 결과에 따라 플래그를 설정하고,BNE
분기를 통해 루프를 구현한다.
ISA(Instruction Set Architecture)
위와 같이 CPU가 이해하고 실행할 수 있는 기계어 명령의 집합과 구성을 정의한 것을 명령어 집합 구조(ISA, Instruction Set Architecture) 라고 한다. ISA는 소프트웨어가 하드웨어를 제어하는 방법을 규정하는 추상화 계층으로서, 어떤 명령어들이 존재하고 어떻게 동작하는지, 데이터 타입은 무엇이고 어떻게 표현되는지, 프로세서가 제공하는 자원(레지스터 등)은 무엇인지를 모두 명시한다
ISA 설계시 고려되는 주요 사항으로는 다음과 같은 것들이 있다.
- 지원할 명령어의 종류와 형식: 산술/논리 연산, 데이터 이동, 분기 등 어떤 명령들을 둘 것인지와, 각 명령어의 비트 열 구조 (opcode 비트폭, 피연산자 필드 배치 등).
- CPU의 레지스터 구성: 몇 개의 레지스터를 제공하고 (레지스터 파일 크기), 용도는 어떻게 구분하는지(만능 범용 레지스터인지, 전용 용도의 레지스터인지 등).
- 데이터 타입과 크기: 8비트 바이트, 16/32/64비트 정수, 부동소수점 형식 등을 지원할 경우 이들의 비트표현(예: 2의 보수법, IEEE754 부동소수 표현)을 정의.
- 메모리 주소 지정 방식: 명령어가 피연산자로 메모리를 참조하는 방식을 규정 (즉시값, 직접/간접, 베이스+오프셋, 인덱스 등의 주소지정 모드).
- 메모리 구조 및 입출력 모델: 메모리의 엔디언(Endian) 방식, 메모리 일관성 모델, I/O를 메모리 맵으로 볼 것인지 별도 포트를 둘 것인지 등의 구조적 선택.
이러한 ISA 정의를 통해, 동일한 ISA를 따르는 프로세서들은 이진 코드의 호환성을 갖게 된다.
예를 들어 x86-64 ISA로 컴파일된 프로그램은 Intel의 CPU이든 AMD의 CPU이든 해당 ISA를 구현한 모든 프로세서에서 실행될 수 있다. 서로 다른 모델의 CPU(예: 인텔 Pentium과 AMD Athlon)가 동일 ISA (x86)를 구현한 사례에서 볼 수 있듯이 마이크로아키텍처 설계가 달라도 명령어 집합이 같으면 같은 기계어 코드가 동작한다. 이것이 가능한 이유는 ISA가 하드웨어와 소프트웨어 사이의 표준 인터페이스 역할을 하기 때문이다. 하드웨어 설계자는 ISA 명세에 따라 프로세서를 구현하고, 소프트웨어 개발자(또는 컴파일러)는 ISA에 정의된 명령어만을 사용하여 프로그램을 작성(또는 생성)하면 되므로, 양측이 독립적으로 발전하면서도 호환성을 유지할 수 있다.
ABI(Application Binary Interface)의 핵심
ISA는 하드웨어와 소프트웨어를 연결해주는 약속이지만, 실제 운영체제 환경에서 프로그램이 제대로 동작하려면 보다 구체적인 이진 수준의 약속이 추가로 필요하다. 이것이 Application Binary Interface (ABI) 의 역할이다. ABI는 동일한 ISA상에서, 운영체제와 컴파일러가 어떤 방식으로 함께 동작할지에 대한 규약으로, 이진 모듈 간의 인터페이스를 정의한다. 쉽게 말해, ISA가 “어떤 명령어와 자원을 쓸 수 있는가”를 정한다면 ABI는 “그 자원들을 프로그램에서 어떻게 사용하기로 약속할 것인가”를 정한다고 볼 수 있다.
예를 들어, ABI에서는 함수 호출 시 호출 규약(calling convention) 을 상세하게 정해둔다. 함수에 인자를 전달할 때 어느 레지스터 를 통해 몇 번째 인자까지 주고, 남는 인자는 스택에 넣을 것인지, 연산 결과(리턴값)는 어느 레지스터로 돌려주는지, 함수 호출 전후에 누가 어떤 레지스터를 보존해야 하는지 등을 상세히 규정하는 것이다.
구분 | Linux (System V AMD64 ABI) | Windows (Microsoft x64 ABI) |
---|---|---|
정수·포인터형 인자 전달 | 첫 6개 인자 → RDI, RSI, RDX, RCX, R8, R9그 이후 인자 → 스택에 저장 | 첫 4개 인자 → RCX, RDX, R8, R9그 이후 인자 → 스택에 저장 |
Shadow Space | 없음 | 필수, 호출자가 32바이트(4×8B) 예약 |
리턴값 | RAX (정수·포인터형) | RAX |
callee-saved 레지스터 | RBX, RBP, R12–R15 | RBX, RBP, RDI, RSI, R12–R15 |
스택 정렬 규칙 | 호출 직전 RSP가 16바이트 경계 정렬 | 호출 직전 RSP 16바이트 정렬 + Shadow Space 확보 |
스택 프레임 기본 구조 (위→아래) | Caller 데이터 → 추가 인자 → Return Address → Saved RBP → (선택) Callee-saved Regs → Local Vars | Caller 데이터 → 추가 인자 → Reserved Shadow Space(32B) → Return Address → Saved RBP → (선택) Callee-saved Regs → Local Vars |
또한 기본 데이터 타입의 크기와 메모리 구조 (예: int는 32비트, double은 64비트, 메모리에서 구조체 패딩 정렬 방식 등), 실행 파일의 바이너리 포맷(예: ELF, PE), 운영체제의 시스템 호출 번호와 인자 전달 방식 등도 ABI에 포함된다. 요컨대 ABI는 컴파일은 바이너리 코드가 서로 다른 컴파일러나 운영체제 환경에서도 호환성을 가지도록 보장하는 일련의 규칙이라 할 수 있다.
위와 같이 애플리케이션 바이너리 인터페이스(ABI)는 컴파일된 바이너리 코드 수준에서 소프트웨어 구성 요소(예: 애플리케이션-운영체제, 라이브러리-라이브러리) 간의 상호작용 규칙을 정의하는 계약이다. API가 소스 코드 호환성을 제공하는 반면, ABI는 다음과 같은 기계 수준 세부사항을 명시하여 바이너리 호환성을 보장한다.
운영체제 | 바이너리 포맷 | 시스템 콜 인터페이스 | 동적 라이브러리 관리 | 기타 특징 |
---|---|---|---|---|
Linux | ELF (.text, .data, .bss) | syscall 명령, 인자: RDI, RSI, RDX, R10, R8, R9 | .so 파일, LD_LIBRARY_PATH 경로 지정 | 아키텍처별 ABI(EABI/OABI), ARMv7 vs ARMv8 구분 |
Windows | PE (.exe, .dll) | 64비트: syscall 명령 사용, 32비트: sysenter 사용 | DLL(LoadLibrary), COM 인터페이스 | Win32 API 하위 호환, 버전 트램펄린 |
macOS | Mach-O | BSD 계열 syscall + 일부 Mach IPC | .dylib, dyld 동적 링커 | Fat Binary(32/64비트 통합), Swift ABI 안정화 |
Android | ELF 기반 | 리눅스 커널 syscall + Bionic libc | .so 파일, NDK 크로스 컴파일 | ARMv7a/ARMv8-a 분리, Thumb-2, AAPCS 규약 |
타겟 트리플릿
위의 내용과 같이, ISA뿐만 아니라 ABI까지 모두 규약이 결정되어야 올바르게 바이너리를 호환하여 실행시킬 수 있다. 이를 바탕으로 실무적으로는 '타겟 아키텍쳐 트리플릿'라는 내용으로 이를 표현하여 호환되는 바이너리를 표현한다.
타겟 트리플릿은 아키텍처-벤더-OS 형식으로 구성된다. 실제 바이너리를 빌드할 때 컴파일러에게 해당 타겟을 전달해야 정확하게 원하는 컴퓨터에서 실행될 수 있는 바이너리를 생성할 수 있다.
예시
i686-elf
: x86 아키텍처, ELF 바이너리 포맷, 베어 메탈 환경arm-linux-gnueabi
: ARM 아키텍처, Linux OS, EABI ABI
타겟 트리플릿은 ABI/ISA 선택을 명시화하여 크로스 컴파일 시 컴파일러가 올바른 바이너리 포맷, 함수 호출 규약, 시스템 콜 인터페이스를 적용하도록 빌드한다. 따라서 ABI가 프로그램의 이식성을 결정하는 핵심 요소이다. 이를 이용하면 크로스플랫폼의 코드에서 바이너리 함수를 실행할 수 있다. 이에 관한 내용은 다음에 작성해보겠다.