어셈블리어
어셈블리어(Assembly language)는 중앙 처리 장치가 실행하는 기계어 명령을 사람이 읽고 작성하기 쉬운 기호와 명령어 이름으로 표현하는 저수준 프로그래밍 언어의 총칭이다. 숫자로 된 연산 코드와 메모리 주소를 MOV, ADD, JMP와 같은 명령어 니모닉, 레지스터 이름, 레...
| 영문명 | Assembly language |
|---|---|
| 분류 | 저수준 프로그래밍 언어 |
| 실행 방식 | 어셈블러를 통한 기계어 변환 |
| 추상화 대상 | 명령어 집합 아키텍처 |
| 주요 구성 요소 | 명령어 니모닉, 피연산자, 레지스터, 레이블, 지시어 |
| 주요 계열 | x86, x86-64, Arm, AArch64, RISC-V, MIPS, Power ISA, IBM Z |
| 주요 구현체 | GNU Assembler, NASM, MASM, FASM, LLVM Integrated Assembler |
| 파일 확장자 | .asm, .s, .S |
어셈블리어(Assembly language)는 중앙 처리 장치가 실행하는 기계어 명령을 사람이 읽고 작성하기 쉬운 기호와 명령어 이름으로 표현하는 저수준 프로그래밍 언어의 총칭이다. 숫자로 된 연산 코드와 메모리 주소를 MOV, ADD, JMP와 같은 명령어 니모닉, 레지스터 이름, 레이블과 기호로 나타내며, 작성된 소스 코드는 어셈블러에 의해 기계어 또는 오브젝트 파일로 변환된다.[1]
어셈블리어는 하나의 공통된 언어가 아니다. 각 어셈블리어는 특정 명령어 집합 아키텍처와 밀접하게 연결되어 있으며, x86-64용 코드와 AArch64용 코드는 사용할 수 있는 명령어, 레지스터, 주소 지정 방식과 호출 규약이 서로 다르다. 같은 아키텍처에서도 GNU Assembler의 AT&T 문법과 Intel 문법처럼 어셈블러와 개발 도구에 따라 표기 방식이 달라질 수 있다. Intel의 아키텍처 설명서도 프로세서의 프로그래밍 환경과 명령어 집합을 별도의 참조 체계로 정의한다.[2]
일반적인 어셈블리 명령은 기계 명령 하나와 가까운 관계를 가지지만, 모든 문장이 반드시 하나의 기계 명령으로 직접 대응하는 것은 아니다. 어셈블러 지시어는 실행 명령을 만들지 않고 데이터 배치, 섹션 구성과 심벌 정의를 제어할 수 있으며, 의사 명령어는 어셈블러에 의해 하나 이상의 실제 명령으로 변환될 수 있다. 매크로와 조건부 어셈블리 기능을 제공하는 구현체도 있으므로 어셈블리 소스는 단순한 기계어의 문자 치환보다 넓은 표현 능력을 가진다. GNU Assembler 역시 여러 프로세서용 문법과 기계 의존 기능, 지시어와 매크로 기능을 제공한다.[3]
초기 컴퓨터에서는 프로그램을 수치 형태의 기계어로 직접 작성해야 했으나, 어셈블리어와 어셈블러가 도입되면서 연산 코드와 기억 장소를 숫자 대신 이름으로 나타낼 수 있게 되었다. 다만 초기 어셈블리어도 일반적으로 프로세서 명령 하나마다 소스 코드 한 줄을 작성해야 했으며, 이러한 하드웨어 의존성과 낮은 추상화 수준은 이후 FORTRAN, COBOL, Lisp과 같은 고급 프로그래밍 언어가 발전하는 배경 가운데 하나가 되었다.[4]
현대 소프트웨어의 대부분은 C (프로그래밍 언어), C++, Rust와 같은 고급 언어로 작성되지만, 어셈블리어는 부팅 코드, 운영체제의 아키텍처 의존 부분, 인터럽트와 문맥 교환, 임베디드 시스템, 장치 제어, 암호화 루틴, 벡터 명령 최적화와 역공학 등에서 계속 사용된다. 또한 컴파일러가 생성한 명령을 분석하거나 프로세서의 레지스터, 메모리 모델, 호출 규약과 실행 과정을 이해하기 위한 수단으로 활용된다.
역사
어셈블리어의 역사는 컴퓨터 명령을 숫자와 배선으로 직접 지정하던 방식에서, 연산과 기억 장소에 이름을 붙이는 기호식 프로그래밍으로 전환된 과정에서 시작되었다. 이후 어셈블러가 기호식 원시 프로그램을 기계어로 자동 변환하게 되면서 프로그램의 작성과 수정이 쉬워졌으며, 이러한 자동화는 컴파일러와 고급 프로그래밍 언어가 발전하는 기반이 되었다.
어셈블리어는 특정 시점에 한 사람이 완성한 하나의 언어가 아니다. 1940년대 후반부터 여러 컴퓨터 연구 집단이 각 기계에 맞는 기호식 명령 체계, 상대 주소, 서브루틴 연결 방식과 번역 프로그램을 개발하면서 형성되었다. 초기의 어셈블리 체계는 현대 어셈블러보다 단순했지만, 명령어 니모닉과 기호 주소를 실제 기계 명령과 기억 장치 주소로 바꾸는 핵심 개념을 확립하였다.
기계어 프로그래밍
초기의 전자식 컴퓨터에서는 오늘날과 같은 운영체제, 편집기, 어셈블러와 컴파일러를 사용할 수 없었다. 프로그램은 기계가 직접 해석할 수 있는 수치 명령과 데이터로 표현되었으며, 기계에 따라 스위치, 플러그보드, 천공 카드, 천공 테이프 또는 숫자로 작성된 명령표를 통해 입력되었다.
ENIAC과 같은 초기 컴퓨터는 저장 프로그램 방식이 일반화되기 전에 만들어졌기 때문에, 프로그램을 바꾸려면 배선과 스위치 설정을 변경해야 했다. 이후 명령과 데이터를 기억 장치에 함께 저장하는 저장 프로그램 방식이 도입되면서 프로그램은 기억 장치에 적재되는 일련의 수치 명령으로 표현되었다. 그러나 프로그래머는 여전히 각 연산의 숫자 코드, 피연산자의 위치와 분기 대상 주소를 직접 계산해야 했다.
기계어 프로그램은 프로세서가 직접 실행할 수 있다는 점에서 별도의 번역 과정이 필요하지 않았지만, 사람이 읽고 관리하기에는 불편했다. 같은 연산도 기계마다 다른 숫자 코드로 표현되었고, 프로그램 중간에 명령이나 데이터를 추가하면 뒤따르는 기억 장소의 주소가 달라질 수 있었다. 프로그래머는 변경된 주소를 찾아 분기 명령과 데이터 참조를 수작업으로 수정해야 했으며, 작은 계산 착오도 잘못된 명령이나 전혀 다른 기억 장소에 대한 접근으로 이어질 수 있었다.
1950년대 초까지 프로그래밍은 주로 이러한 기계어 또는 초기 어셈블리어를 다룰 수 있는 소수의 전문가가 수행하였다. IBM은 당시의 기계어 프로그래밍을 복잡하고 번거로운 작업으로 설명하며, 프로그램 작성이 제한된 전문가 집단의 영역이었다고 기록하고 있다.[5]
기계어 프로그래밍에서 사용된 수치 표기는 반드시 이진수만을 뜻하지 않았다. 기계의 구조와 입력 장치에 따라 십진수, 팔진수, 16진수 또는 문자와 숫자를 조합한 표기가 사용되기도 했다. 중요한 점은 프로그래머가 기계 명령의 실제 수치 형식과 기억 장치 배치를 직접 관리했다는 것이다.
기호식 명령 표현의 등장
기계어 작성의 부담을 줄이기 위해 프로그래머들은 숫자로 된 연산 코드와 기억 장소를 사람이 이해하기 쉬운 기호로 나타내기 시작했다. 수치 연산 코드는 ADD, SUB, LOAD, STORE와 같은 짧은 명령어 이름으로 표현되었고, 기억 장소에는 숫자 주소 대신 변수와 분기 목적을 나타내는 이름이 붙었다.
이러한 표현은 기호식 기계 코드 또는 기호식 프로그래밍으로 불렸다. 기호식 명령은 실행 전에 실제 기계 명령으로 바뀌어야 했지만, 프로그래머가 명령어의 수치 코드를 외우거나 모든 기억 장소의 절대 주소를 직접 계산할 필요를 줄였다. Computer History Museum은 초기 프로그래밍의 중요한 발전으로 연산 코드와 기억 장소를 숫자가 아닌 이름으로 작성하는 어셈블리어의 등장을 설명한다.[6]
초기의 기호식 표기는 반드시 독립된 어셈블러 프로그램에 의해 처리되지는 않았다. 프로그래머가 기호와 실제 주소의 대응표를 만들고 수작업으로 기계어를 생성하거나, 적재 프로그램이 제한적인 주소 계산과 코드 변환을 수행하기도 했다. 이후 이러한 작업을 컴퓨터 자체가 수행하도록 만든 프로그램이 어셈블러로 발전하였다.
1949년 가동을 시작한 EDSAC은 초기 기호식 프로그래밍 체계의 중요한 사례다. EDSAC의 프로그램은 문자로 표현된 연산 기호와 수치 주소를 천공 테이프에 기록했으며, 기계가 시작될 때 실행되는 Initial Orders가 이를 읽어 내부 명령 형식으로 변환하였다. 케임브리지 대학교 컴퓨터 연구소의 기록에 따르면 David Wheeler가 1949년 5월 프로그램을 종이테이프에서 적재하고 실행하기 위한 Initial Orders를 작성하였다.[7]
EDSAC의 초기 입력 체계는 현대적인 범용 어셈블러와 동일하지는 않았지만, 문자 연산 기호를 기계 명령으로 바꾸고 프로그램을 기억 장치에 배치하는 기능을 제공했다. 뒤이어 개발된 Initial Orders 2와 coordinating orders에는 상대 주소 지정과 프로그램 조립을 지원하는 기능이 추가되었다. 케임브리지 컴퓨터 연구소의 역사 기록은 coordinating orders가 Initial Orders 2보다 많은 어셈블리 기능을 제공했다고 설명한다.[8]
기호식 주소의 도입은 프로그램 수정 방식도 바꾸었다. 특정 명령의 위치가 바뀌어도 프로그래머는 LOOP, TABLE, RESULT와 같은 이름을 그대로 사용할 수 있었고, 실제 주소의 재계산은 번역 프로그램이 담당할 수 있었다. 이는 프로그램을 개별 수치 명령의 배열이 아니라 이름과 관계로 구성된 원시 프로그램으로 다루게 한 중요한 변화였다.
초기 어셈블러
어셈블러는 기호식 명령어, 주소 이름과 각종 지시 사항을 읽고 해당 컴퓨터가 실행할 수 있는 기계어 프로그램을 생성하는 번역 프로그램이다. 초기에는 이러한 프로그램을 assembly program, assembly routine, automatic coding system 등 여러 이름으로 불렀으며, 어셈블러라는 명칭과 기능은 1950년대에 점차 정착하였다.
초기 어셈블러는 대체로 하나의 기호식 명령을 하나의 기계 명령으로 바꾸고, 프로그램에 사용된 기호에 실제 기억 장소 주소를 할당하였다. 어셈블러는 원시 프로그램을 읽으면서 기호와 주소의 대응을 기록하고, 해당 기호를 참조하는 명령에 계산된 주소를 삽입하였다.
기호가 정의되기 전에 참조될 수 있었기 때문에 많은 어셈블러는 두 번의 처리 과정을 사용하는 2패스 어셈블러 구조를 채택하였다. 첫 번째 패스에서는 명령과 데이터의 크기를 계산하고 레이블의 주소를 수집했으며, 두 번째 패스에서는 완전한 기계 명령을 생성하였다. 메모리가 부족하거나 입력 매체를 여러 번 읽기 어려운 환경에서는 한 번의 패스, 중간 테이프 또는 별도의 기호표를 사용하는 다른 구조도 사용되었다.
초기 어셈블러는 단순한 명령어 치환을 넘어 여러 편의 기능을 발전시켰다. 상수와 데이터 정의, 프로그램의 시작 주소 지정, 상대 주소 계산, 외부 서브루틴 참조와 라이브러리 연결 기능이 추가되었으며, 반복되는 명령열을 이름으로 정의하는 매크로 어셈블러도 등장하였다.
IBM 709와 7090용 FAP(FORTRAN Assembly Program)는 1950년대 말과 1960년대에 사용된 대표적인 어셈블러 가운데 하나였다. FAP 문서는 프로그래머의 기호식 언어 프로그램을 기계어 프로그램으로 번역하는 프로그램을 assembly program 또는 assembler라고 정의한다.[9]
IBM의 Symbolic Programming System과 같은 체계는 니모닉 코드를 기계 명령 코드로 변환하고, 원시 프로그램의 기호에 실제 기억 장소 주소를 할당하였다. 이는 현대 어셈블러가 수행하는 명령어 인코딩과 심벌 해석의 기본 형태를 이미 갖추고 있었다.[10]
어셈블러의 도입으로 컴퓨터는 프로그래머가 작성한 프로그램을 처리하는 도구이면서 동시에 다른 프로그램을 생성하는 도구가 되었다. 프로그램 작성 과정의 일부를 컴퓨터에 맡기는 자동 프로그래밍 개념이 현실화되었으며, 이는 이후 컴파일러, 링커와 개발 도구 체인의 발전으로 이어졌다.
다만 초기 어셈블리어는 특정 기계의 명령어와 기억 장치 구조를 그대로 드러냈다. 새로운 컴퓨터로 프로그램을 옮기려면 명령어, 레지스터, 입출력 방식과 데이터 표현을 다시 작성해야 했다. 서로 다른 제조사의 컴퓨터뿐 아니라 같은 제조사의 후속 기종 사이에서도 완전한 소스 호환성이 보장되지 않았다.
고급 프로그래밍 언어의 등장
어셈블리어는 기계어보다 프로그램을 읽고 수정하기 쉽게 만들었지만, 프로그래머는 여전히 대부분의 기계 명령을 직접 선택해야 했다. Computer History Museum은 초기 어셈블리어에서도 일반적으로 기계 명령 하나마다 프로그램의 한 줄을 작성해야 했다고 설명한다.[11]
수학식 계산, 배열 처리, 반복문과 함수 호출 같은 작업도 레지스터 이동, 산술 명령, 비교와 분기 명령의 조합으로 풀어 써야 했다. 프로그램은 특정 기계의 명령어 수와 기억 장치 크기, 레지스터 구성에 강하게 의존했으며, 대규모 프로그램을 작성할수록 코드의 양과 관리 부담이 증가하였다.
이러한 문제를 해결하기 위해 하나의 문장이 여러 기계 명령으로 번역되는 고급 프로그래밍 언어와 컴파일러가 개발되었다. 1950년대에는 FORTRAN을 비롯하여 FLOW-MATIC, Lisp와 ALGOL 계통의 언어가 등장하였고, 1960년에는 COBOL의 첫 버전이 공개되었다.
John Backus는 1954년 이전에는 거의 모든 프로그래밍이 기계어나 어셈블리어로 이루어졌으며, 당시 프로그래머들이 효율적인 프로그램 작성에 인간의 창의성과 세밀한 기계 지식이 필요하다고 여겼다고 회고하였다.[12]
IBM이 1957년 공개한 FORTRAN은 수학식과 반복 구조를 기계 명령으로 변환하여 과학·공학 프로그램의 작성 비용을 줄였다. 초기에는 컴파일러가 숙련된 어셈블리 프로그래머만큼 효율적인 코드를 생성할 수 있는지를 두고 의문이 있었지만, FORTRAN은 실용적인 성능을 제공하면서 널리 사용되었다. 이를 통해 프로그램 작성의 중심은 개별 명령어 선택에서 알고리즘과 문제 표현으로 점차 이동하였다.
고급 언어의 보급은 어셈블리어를 즉시 대체하지 않았다. 초기 컴파일러와 런타임 라이브러리 자체가 어셈블리어로 작성되었고, 운영체제, 입출력 제어, 수치 연산 라이브러리와 성능이 중요한 부분에서는 계속 어셈블리어가 사용되었다. 고급 언어와 어셈블리어는 서로 배타적인 단계라기보다, 서로 다른 추상화 수준을 담당하는 개발 수단으로 함께 발전하였다.
컴파일러는 고급 언어의 문장을 대상 프로세서의 어셈블리어나 기계어로 변환하였다. 이에 따라 어셈블리어는 사람이 직접 작성하는 언어인 동시에, 컴파일러가 생성하는 중간 또는 최종 출력 형식으로도 사용되기 시작했다. 현대에도 컴파일러가 생성한 코드를 분석하거나 특정 명령을 직접 사용하기 위해 어셈블리 출력을 확인하는 방식이 이어지고 있다.
마이크로프로세서와 개인용 컴퓨터
1970년대에는 중앙 처리 장치의 주요 기능을 하나의 집적 회로에 구현한 마이크로프로세서가 등장하였다. Intel은 1971년 4비트 Intel 4004를 공개했고, 1972년에는 8비트 Intel 8008, 1974년에는 범용 마이크로컴퓨터에 널리 사용된 Intel 8080을 출시하였다.[13][14][15]
마이크로프로세서는 컴퓨터의 가격과 크기를 낮추고, 계산기, 제어 장치, 단말기와 개인용 컴퓨터에 프로그래밍 가능한 처리 장치를 확산시켰다. 각각의 프로세서는 고유한 명령어 집합을 사용했으므로 새로운 어셈블리어, 어셈블러, 모니터 프로그램과 개발 도구도 함께 만들어졌다.
1970년대의 마이크로컴퓨터는 메모리와 저장 장치가 제한적이었으며, 완전한 운영체제와 고급 언어 처리계를 사용할 수 없는 경우가 많았다. 초기 프로그램은 기계어를 전면 패널의 스위치로 입력하거나, ROM에 저장된 모니터 프로그램과 어셈블러를 이용해 작성되었다. 어셈블리어는 작은 메모리 안에서 하드웨어를 직접 제어하고 실행 속도와 코드 크기를 조절할 수 있었기 때문에 초기 마이크로컴퓨터 소프트웨어의 핵심 개발 수단이 되었다.
Intel 8080을 사용한 Altair 8800과 그 호환 기종이 보급되면서 8080 어셈블리어는 초기 개인용 컴퓨터 생태계에서 널리 사용되었다. 이후 Zilog Z80, MOS Technology 6502, Motorola 6800과 Motorola 68000 등의 프로세서가 등장하면서 각 아키텍처에 대응하는 어셈블리어와 개발 문화가 형성되었다.
초기 개인용 컴퓨터의 운영체제, BASIC 인터프리터, 디스크 제어 코드, 게임과 응용 프로그램에는 어셈블리어가 광범위하게 사용되었다. CP/M은 이 시기의 대표적인 운영체제였으며, 처음에는 Intel 8080 계열 컴퓨터를 중심으로 보급되었다. CP/M의 상당 부분은 Gary Kildall이 만든 고급 언어 PL/M으로 작성되어 특정 기계의 어셈블리어에 대한 의존성을 줄였지만, 하드웨어와 직접 연결되는 BIOS 부분과 일부 구성 요소에는 어셈블리어가 사용되었다.[16]
가정용 컴퓨터와 게임기에서는 메모리 용량, 프로세서 속도와 그래픽 하드웨어의 제약 때문에 어셈블리어의 비중이 특히 높았다. Apple II, Commodore 64, Atari 8비트 컴퓨터와 여러 게임기는 6502 계열 프로세서를 사용했으며, 개발자는 화면 출력, 사운드, 입력 처리와 게임 논리를 제한된 자원에 맞추기 위해 어셈블리어를 활용하였다.
1980년대에는 IBM PC와 호환 기종의 확산으로 Intel 8086 계열 어셈블리어가 널리 사용되었다. MS-DOS 프로그램은 BIOS와 DOS 인터럽트를 호출하여 화면, 키보드와 파일 시스템을 제어했으며, 운영체제의 일부, 장치 드라이버, 상주 프로그램, 게임과 성능 중심 응용 프로그램이 어셈블리어로 작성되거나 C와 어셈블리어를 함께 사용하였다.
이 시기에는 Microsoft Macro Assembler, Borland Turbo Assembler, MASM, 각 프로세서 제조사의 개발 시스템과 다양한 크로스 어셈블러가 보급되었다. 매크로, 조건부 어셈블리, 세그먼트 관리, 오브젝트 파일과 링커 연동 기능이 발전하면서 어셈블리어 개발도 초기의 단순한 기호 변환보다 복잡한 소프트웨어 개발 체계로 확장되었다.
현대의 어셈블리어
컴퓨터의 성능과 메모리가 증가하고 C, C++, Rust와 같은 고급 언어 및 최적화 컴파일러가 발전하면서, 일반 응용 프로그램을 전부 어셈블리어로 작성하는 경우는 크게 줄었다. 고급 언어는 소스 코드의 이식성과 유지보수성을 높였고, 현대 컴파일러는 명령어 선택, 레지스터 할당, 명령어 스케줄링과 벡터화 같은 최적화를 자동으로 수행한다.
그러나 어셈블리어는 하드웨어와 소프트웨어의 경계에서 계속 사용된다. 운영체제의 부팅 과정, 인터럽트와 예외 진입부, 문맥 교환, 시스템 호출 경로, 원자적 연산, 펌웨어, 하이퍼바이저와 장치 초기화처럼 프로세서의 실행 상태를 정확히 제어해야 하는 부분은 고급 언어만으로 완전히 표현하기 어려울 수 있다.
현대 운영체제와 시스템 소프트웨어는 일반적으로 대부분을 C, C++ 또는 Rust와 같은 언어로 작성하고, 아키텍처 의존 부분만 별도의 어셈블리 소스나 인라인 어셈블리로 구현한다. GNU Compiler Collection은 C 소스 안에 어셈블리 명령을 삽입하는 asm 확장을 제공하며, 지정된 어셈블리 문자열을 컴파일러의 어셈블리 출력에 포함할 수 있도록 한다.[17]
암호화, 압축, 영상·음성 처리와 수치 계산 분야에서는 SIMD와 벡터 명령을 직접 사용하기 위해 어셈블리어가 활용된다. 다만 현대에는 컴파일러 내장 함수, 벡터 확장과 자동 벡터화가 이러한 역할의 상당 부분을 담당한다. 어셈블리어 구현은 특정 프로세서 세대와 미세구조에 맞춘 최적화가 필요한 경우 또는 컴파일러가 표현하지 못하는 명령을 사용할 때 주로 선택된다.
어셈블리어는 보안과 역공학에서도 중요한 역할을 한다. 실행 파일에는 일반적으로 원래의 고급 언어 구조가 그대로 남지 않기 때문에, 디버거와 디스어셈블러는 기계어를 어셈블리 명령 형태로 복원하여 보여준다. 악성 코드 분석, 취약점 연구, 디지털 포렌식과 바이너리 호환성 분석에서는 프로세서 명령, 호출 규약, 스택과 메모리 동작을 이해해야 한다.
현대의 대표적인 어셈블러로는 GNU Binutils의 GNU Assembler, NASM, MASM, FASM과 LLVM Integrated Assembler가 있다. 이들은 x86, Arm, AArch64, RISC-V, Power ISA와 여러 임베디드 아키텍처를 대상으로 오브젝트 파일을 생성하며, 컴파일러·링커·디버거와 통합된 도구 체인의 일부로 사용된다.
같은 명령어 집합에서도 어셈블러에 따라 문법이 다를 수 있다. x86에서는 Intel 문법과 AT&T 문법이 함께 사용되며, 피연산자의 순서, 레지스터와 즉시값의 표기, 명령어 크기를 나타내는 방식이 다르다. GNU Assembler의 문서도 같은 아키텍처에 대해 서로 호환되지 않는 여러 어셈블리 문법이 존재할 수 있음을 설명한다.[18]
RISC-V와 같은 새로운 개방형 명령어 집합의 등장, Arm 기반 시스템의 확대와 각종 전용 가속기의 발전에 따라 새로운 어셈블리어와 도구 지원도 계속 추가되고 있다. 어셈블리어의 사용 범위는 초기 컴퓨터처럼 프로그램 전체를 작성하는 중심 언어에서, 하드웨어 제어와 분석, 성능 최적화 및 도구 체인의 저수준 표현을 담당하는 전문 언어로 변화하였다.
따라서 현대의 어셈블리어는 과거의 프로그래밍 방식이 단순히 남아 있는 것이 아니라, 기계 명령과 소프트웨어 사이의 가장 직접적인 인터페이스로 기능한다. 고급 언어와 컴파일러가 대부분의 프로그램 작성을 담당하더라도, 프로세서가 실제로 어떤 명령을 실행하고 데이터가 레지스터와 메모리 사이에서 어떻게 이동하는지를 표현하고 분석하는 수단으로서 어셈블리어의 역할은 계속 유지되고 있다.
기본 개념
어셈블리어는 특정 중앙 처리 장치가 제공하는 명령어, 레지스터와 데이터 처리 방식을 기호로 표현한다. 따라서 어셈블리어를 이해하려면 소스 코드의 문법뿐 아니라 대상 명령어 집합 아키텍처, 명령의 기계어 인코딩, 레지스터 구조, 메모리 주소 지정과 어셈블러의 동작을 함께 이해해야 한다.
어셈블리 소스는 일반적으로 명령어와 피연산자, 레이블, 상수, 데이터 정의와 어셈블러 지시어로 구성된다. 어셈블러는 이러한 요소를 해석하여 기계어와 데이터가 포함된 오브젝트 파일을 생성하며, 아직 최종 주소를 결정할 수 없는 심벌 참조는 재배치 정보로 기록하여 링커가 처리할 수 있게 한다.
명령어 집합 아키텍처
명령어 집합 아키텍처(Instruction Set Architecture, ISA)는 소프트웨어가 프로세서를 제어하기 위해 사용할 수 있는 명령어와 프로그래밍 모델을 정의하는 인터페이스다. ISA는 프로세서가 수행할 수 있는 연산, 소프트웨어에 노출되는 레지스터, 데이터 형식, 명령어 인코딩, 주소 지정 방식, 예외와 실행 상태 등의 규칙을 포함한다.
Intel 64와 IA-32의 공식 설명서는 프로그래밍 환경과 명령어 집합을 아키텍처의 일부로 설명하며, 각 명령에 대해 명령 형식, 피연산자와 동작을 정의한다.[19] RISC-V 역시 기본 정수 명령어 집합과 선택적 확장을 규격으로 정의하며, 명령어 형식과 프로그래머에게 보이는 상태를 ISA의 일부로 규정한다.[20]
ISA는 특정 프로세서 제품의 내부 구현과 구분된다. 같은 ISA를 구현하는 프로세서라도 파이프라인의 단계 수, 캐시 구조, 명령 실행 장치, 분기 예측기와 실제 성능은 서로 다를 수 있다. 소프트웨어는 ISA에 정의된 결과를 기준으로 작성되며, 프로세서는 내부적으로 서로 다른 방식으로 그 결과를 구현할 수 있다.
어셈블리어는 ISA의 명령을 사람이 작성할 수 있는 문자 형식으로 표현한다. 따라서 x86-64용 어셈블리어는 x86-64 ISA의 레지스터와 명령어를 사용하고, AArch64용 어셈블리어는 A64 명령어 집합과 X0부터 X30 등의 레지스터를 사용한다. 서로 다른 ISA 사이에서는 명령어 이름이 비슷하더라도 피연산자의 수, 레지스터 구조와 동작이 다를 수 있다.
ISA는 하나의 고정된 명령어 집합으로만 구성되지 않을 수도 있다. x86 계열은 오랜 기간 새로운 명령과 SIMD 확장을 추가해 왔으며, Arm과 RISC-V는 기본 명령어 집합과 여러 확장을 조합할 수 있다. 어셈블러는 대상 아키텍처와 활성화된 확장에 따라 사용할 수 있는 명령을 검사한다.
어셈블리어와 ISA도 완전히 같은 개념은 아니다. ISA는 프로세서가 실행하는 명령과 동작을 정의하고, 어셈블리어는 이를 사람이 읽을 수 있도록 표현하는 문법이다. 하나의 ISA에도 여러 어셈블러 문법이 존재할 수 있으며, 대표적으로 x86에는 Intel 문법과 AT&T 문법이 함께 사용된다.
기계어와 어셈블리어
기계어는 프로세서가 직접 해석하고 실행하는 이진 형식의 명령이다. 하나의 기계 명령에는 수행할 연산을 나타내는 연산 코드, 사용할 레지스터, 즉시값, 주소 지정 방식과 그 밖의 제어 정보가 일정한 비트 구조로 인코딩된다.
명령어의 길이와 형식은 ISA에 따라 다르다. RV32I의 기본 명령은 32비트 고정 길이를 사용하고 R형, I형, S형과 U형을 기반으로 하는 명령 형식을 정의한다.[21] 반면 x86과 x86-64는 접두사, 연산 코드, ModR/M, SIB, 변위와 즉시값 등이 선택적으로 결합되는 가변 길이 명령 형식을 사용한다.
어셈블리어는 이러한 기계 명령을 명령어 니모닉과 피연산자로 표현한다. 예를 들어 AArch64에서 다음 명령은 두 레지스터의 값을 더하여 다른 레지스터에 저장한다.
ADD X0, X1, X2
어셈블러는 ADD라는 니모닉과 X0, X1, X2라는 피연산자를 해석하여 AArch64 ISA에 정의된 기계 명령으로 인코딩한다. Arm에서 X 레지스터를 사용한 연산은 64비트 연산을 나타내고, 같은 번호의 W 레지스터를 사용하면 32비트 연산을 나타낸다.[22]
하나의 어셈블리 명령이 일반적으로 하나의 기계 명령에 대응하지만 항상 그러한 것은 아니다. 의사 명령어는 하나 이상의 실제 기계 명령으로 변환될 수 있고, 데이터 정의와 섹션 전환 같은 어셈블러 지시어는 실행되는 기계 명령을 생성하지 않을 수 있다.
반대로 하나의 기계 명령을 둘 이상의 어셈블리 표기로 나타낼 수도 있다. 어셈블러가 여러 니모닉을 같은 인코딩의 별칭으로 허용하거나, 피연산자 크기와 문맥을 이용하여 생략된 정보를 추론하는 경우가 있기 때문이다.
어셈블된 기계어가 반드시 즉시 실행 가능한 완전한 프로그램이 되는 것은 아니다. 외부 함수나 다른 오브젝트 파일의 심벌을 참조하면 어셈블러는 해당 주소를 확정하지 못한다. 이때 오브젝트 파일에는 임시 값과 재배치 정보가 기록되고, 링커가 여러 오브젝트 파일과 라이브러리를 결합하면서 최종 주소를 결정한다.
명령어 니모닉
명령어 니모닉(instruction mnemonic)은 기계 명령의 연산을 사람이 기억하기 쉬운 짧은 이름으로 나타낸 것이다. MOV, ADD, SUB, MUL, LOAD, STORE, CMP, JMP와 같은 이름이 대표적인 형태다.
니모닉은 일반적으로 명령이 수행하는 동작을 나타내지만, 모든 아키텍처에서 같은 이름과 의미를 사용하는 것은 아니다. x86의 MOV는 레지스터와 메모리 사이의 데이터 이동 등에 사용되지만, RISC-V에서는 기본 정수 명령어 집합에 같은 형태의 실제 MOV 명령이 없으며 다른 명령을 이용한 의사 명령어로 표현할 수 있다.
명령어 이름만으로 전체 동작이 결정되는 것도 아니다. 실제 동작은 니모닉과 피연산자 형식, 피연산자 크기와 현재 실행 모드의 조합으로 정해진다. 같은 ADD 명령이라도 정수 레지스터, 벡터 레지스터 또는 즉시값을 사용하는 형태에 따라 서로 다른 명령 인코딩과 동작을 가질 수 있다.
일부 어셈블리 문법은 피연산자의 크기를 니모닉에 표시한다. GNU Assembler의 x86 AT&T 문법에서는 movb, movw, movl, movq처럼 접미사를 붙여 각각 바이트, 워드, 32비트와 64비트 연산을 나타낼 수 있다. 문맥에서 크기를 판단할 수 있는 경우 일부 접미사는 생략될 수 있다.[23]
다른 문법에서는 같은 니모닉을 사용하고 레지스터 이름이나 별도의 크기 지정자를 통해 피연산자 크기를 표현한다. AArch64의 ADD W0, W1, W2와 ADD X0, X1, X2는 각각 32비트와 64비트 연산을 나타낸다.
니모닉은 어셈블러가 받아들이는 소스 표현이므로 프로세서 내부에 문자 형태로 저장되는 것은 아니다. 어셈블러는 니모닉을 대응되는 연산 코드와 명령 형식으로 변환하며, 프로세서는 생성된 비트 패턴을 해석한다.
피연산자
피연산자(operand)는 명령이 읽거나 변경할 데이터와 그 위치를 지정한다. 피연산자는 레지스터, 메모리 위치, 즉시값, 주소, 레이블 또는 아키텍처가 제공하는 특수한 대상으로 표현될 수 있다.
레지스터 피연산자는 프로세서의 레지스터를 직접 지정한다. 다음 AArch64 명령에서 X0은 결과를 저장할 목적지 피연산자이고, X1과 X2는 입력값을 제공하는 원본 피연산자다.
ADD X0, X1, X2
즉시값(immediate value)은 명령 내부에 직접 포함되는 상수다. 예를 들어 어떤 아키텍처의 ADD 명령에서 레지스터 값에 1을 더하도록 지정할 수 있다. 즉시값으로 표현할 수 있는 범위는 명령어 인코딩에 할당된 비트 수와 형식에 의해 제한된다.
메모리 피연산자는 메모리에서 데이터를 읽거나 메모리에 값을 저장할 위치를 나타낸다. 주소는 고정된 절대 주소로 표현될 수도 있지만, 일반적으로 하나 이상의 레지스터, 변위와 배율을 조합하는 주소 지정 방식으로 계산된다.
피연산자의 수는 명령어 집합과 명령 형식에 따라 달라진다. AArch64와 RISC-V의 여러 산술 명령은 목적지 레지스터와 두 원본 피연산자를 분리하는 3피연산자 형태를 사용한다. x86의 전통적인 산술 명령은 하나의 피연산자가 입력과 출력 역할을 함께 하는 2피연산자 형태를 많이 사용한다.
어셈블리 문법에 따라 피연산자의 표기 순서도 달라질 수 있다. x86 Intel 문법은 보통 목적지 피연산자를 먼저 쓰지만, AT&T 문법은 원본 피연산자를 먼저 쓰고 목적지를 마지막에 쓴다. GNU Assembler는 .intel_syntax와 .att_syntax 지시어를 통해 두 문법을 전환할 수 있다.[24]
피연산자는 임의로 조합할 수 있는 것이 아니다. ISA는 각 명령이 허용하는 레지스터 종류, 피연산자 수, 즉시값 범위와 메모리 사용 여부를 정한다. 어셈블러는 소스 코드가 허용된 피연산자 형식과 일치하는지 검사하고 적절한 명령 인코딩을 선택한다.
레지스터
레지스터는 프로세서 내부에서 명령 실행에 직접 사용되는 저장 공간이다. 메모리보다 수가 적고 소프트웨어에 노출되는 크기와 용도가 ISA에 의해 정해진다. 어셈블리어는 레지스터 이름을 피연산자로 사용하여 계산할 값, 주소와 실행 상태를 지정한다.
일반적인 ISA는 정수 연산과 주소 계산에 사용하는 범용 레지스터, 부동소수점 레지스터, 벡터 레지스터와 여러 특수 목적 레지스터를 제공한다. 아키텍처에 따라 이들이 별도의 레지스터 집합으로 구분되거나 일부 기능을 공유할 수 있다.
특수 목적 레지스터에는 프로그램 카운터, 스택 포인터, 상태 레지스터와 시스템 제어 레지스터 등이 포함될 수 있다. 프로그램 카운터는 현재 또는 다음에 실행할 명령의 위치와 관련되며, 스택 포인터는 함수 호출과 지역 데이터 저장에 사용되는 스택의 현재 위치를 가리킨다.
상태 레지스터 또는 플래그 레지스터는 산술 연산의 결과와 프로세서 상태를 기록할 수 있다. x86의 여러 산술 명령은 영 플래그, 부호 플래그, 캐리 플래그와 오버플로 플래그를 변경하며, 이후의 조건 분기 명령은 이 상태를 검사한다. 다른 ISA는 비교 결과를 별도의 조건 플래그에 저장하지 않고 레지스터 비교와 분기를 하나의 명령으로 처리할 수 있다.
레지스터의 이름과 수는 ISA마다 다르다. AArch64는 일반적인 정수 연산에서 X0부터 X30까지의 64비트 범용 레지스터를 사용하며, 같은 물리적 레지스터의 하위 32비트는 W0부터 W30으로 접근한다. W 레지스터에 값을 쓰면 해당 64비트 레지스터의 상위 32비트는 0으로 설정된다.[25]
ISA가 제공하는 레지스터와 프로그램이 자유롭게 사용할 수 있는 레지스터도 구분해야 한다. 호출 규약은 함수의 인수와 반환값에 사용할 레지스터, 호출자가 보존할 레지스터와 피호출자가 보존해야 하는 레지스터를 정한다. 운영체제나 실행 환경에 따라 일부 레지스터는 특정 목적에 예약될 수 있다.
현대 프로세서는 ISA에 보이는 레지스터보다 많은 내부 물리 레지스터를 가질 수 있다. 레지스터 이름 바꾸기와 비순차 실행을 위해 사용하는 이러한 내부 레지스터는 일반적인 어셈블리 프로그램에서 직접 지정할 수 없으며, ISA에 정의된 논리적 레지스터와 구분된다.
메모리와 주소
메모리는 명령과 데이터를 저장하는 주소 가능한 공간이다. 프로세서는 메모리 주소를 사용하여 특정 위치의 명령을 가져오거나 데이터를 읽고 쓴다. 어셈블리어에서는 메모리 접근 명령과 주소 계산 방식을 직접 표현할 수 있다.
대부분의 현대 범용 시스템은 바이트 단위 주소 공간을 사용한다. 각 바이트에는 주소가 부여되며, 2바이트, 4바이트 또는 8바이트 값은 연속된 여러 주소에 걸쳐 저장된다. 다바이트 데이터가 메모리에 배치되는 순서는 바이트 순서에 따라 달라진다.
프로그램에서 사용하는 주소는 물리 메모리의 위치와 반드시 같지 않다. 가상 메모리를 사용하는 운영체제에서는 프로세스가 가상 주소를 사용하고, 프로세서의 메모리 관리 장치와 운영체제가 이를 물리 주소로 변환한다. 어셈블리 명령은 일반적으로 현재 실행 환경에서 유효한 가상 주소를 이용한다.
메모리에 접근하는 방법은 ISA에 따라 다르다. 전형적인 RISC 구조는 산술 명령이 레지스터만을 대상으로 하고, 별도의 적재 명령과 저장 명령을 통해 메모리와 레지스터 사이에서 값을 이동하는 로드·스토어 구조를 사용한다. x86은 일부 산술 명령의 피연산자로 메모리 위치를 직접 지정할 수 있다.
주소 지정 방식은 명령이 사용할 유효 주소를 계산하는 방법이다. 절대 주소, 레지스터 간접 주소, 기준 레지스터와 변위, 인덱스 레지스터, PC 상대 주소 등이 사용될 수 있다. Arm은 주소 지정 방식을 적재·저장 명령이 사용할 메모리 주소를 생성하는 방법으로 정의한다.[26]
AArch64의 적재·저장 주소 지정은 일반적으로 X0부터 X30까지의 범용 레지스터 또는 스택 포인터를 기준 주소로 사용하고, 즉시 변위나 다른 레지스터를 조합할 수 있다.[27]
주소 자체와 해당 주소에 저장된 값은 구분해야 한다. 레지스터가 주소를 가지고 있더라도 일반 산술 명령은 그 숫자 자체를 계산 대상으로 처리한다. 메모리 접근 명령이나 메모리 피연산자 표기를 사용해야 해당 주소가 가리키는 위치의 값을 읽거나 변경한다.
데이터 크기와 정렬도 메모리 접근에 영향을 준다. 일부 ISA는 정렬되지 않은 접근을 허용하지만 성능이 낮아질 수 있고, 다른 ISA나 특정 명령은 정렬되지 않은 주소에서 예외를 발생시킬 수 있다. 어셈블러 지시어와 링커 스크립트는 코드와 데이터의 배치를 필요한 정렬 경계에 맞추는 데 사용된다.
레이블과 심벌
레이블(label)은 어셈블리 소스의 특정 위치에 붙이는 이름이다. 일반적으로 명령이나 데이터 정의 앞에 이름과 구분 기호를 작성하며, 어셈블러는 현재 위치에 해당 이름을 연결한다.
레이블을 사용하면 분기 대상과 데이터 위치를 숫자 주소로 직접 계산하지 않아도 된다. 다음과 같은 구조에서 분기 명령은 loop라는 이름을 참조하며, 어셈블러와 링커가 실제 거리나 주소를 계산한다.
loop:
레이블은 반복문의 시작, 함수의 진입점, 조건 분기의 목적지와 데이터의 위치를 나타내는 데 사용된다. 소스 코드의 앞부분에서 아직 정의되지 않은 레이블을 참조하는 전방 참조도 일반적으로 허용된다.
심벌(symbol)은 어셈블러와 링커가 이름과 값 또는 위치의 관계를 관리하기 위해 사용하는 더 넓은 개념이다. 레이블은 현재 코드나 데이터 위치를 나타내는 심벌을 정의하지만, 심벌은 상수, 외부 함수, 섹션 또는 어셈블러 식의 결과를 나타낼 수도 있다.
GNU Assembler의 문장은 0개 이상의 레이블로 시작할 수 있으며, 그 뒤에는 명령어 또는 어셈블러 지시어가 올 수 있다. 어셈블러는 소스에 정의된 심벌의 이름, 값과 관련 속성을 관리한다.[28]
심벌은 적용 범위와 연결 속성에 따라 지역 심벌, 전역 심벌과 약한 심벌 등으로 구분될 수 있다. 지역 심벌은 일반적으로 같은 어셈블리 단위 안에서만 사용되며, 전역 심벌은 다른 오브젝트 파일에서 참조할 수 있도록 심벌 테이블에 공개된다.
아직 주소가 결정되지 않은 외부 심벌이나 다른 섹션의 심벌을 참조하면 어셈블러는 필요한 위치에 재배치 항목을 생성한다. 링커는 심벌 정의를 찾고 각 참조 위치에 최종 주소 또는 상대 거리를 반영한다. AArch64에서는 ADRP, ADD, LDR, STR 등의 명령에 레이블을 결합하여 주소 계산에 필요한 재배치를 생성할 수 있다.[29]
심벌 이름의 허용 문자, 대소문자 구분과 지역 레이블 표기 방식은 어셈블러와 대상 아키텍처에 따라 달라질 수 있다. 따라서 같은 ISA의 코드라도 특정 어셈블러 문법에 맞추어 심벌을 작성해야 한다.
어셈블러 지시어
어셈블러 지시어(assembler directive)는 프로세서가 실행할 명령이 아니라 어셈블러에 작업 방법을 지시하는 문장이다. 의사 연산(pseudo-op)이라고도 부르지만, 프로세서 명령을 대신 표현하는 의사 명령어와는 구분해서 사용되기도 한다.
지시어는 코드와 데이터가 들어갈 섹션을 선택하고, 정수·문자열과 빈 공간을 정의하며, 정렬을 지정하고, 심벌의 범위와 형식을 설정할 수 있다. 또한 대상 아키텍처와 명령어 확장 선택, 디버깅 정보, 매크로 정의와 조건부 어셈블리를 제어할 수 있다.
GNU Assembler에서는 일반적인 지시어가 점으로 시작한다. .text는 실행 코드가 들어갈 섹션을 선택하고, .data는 초기화된 데이터를 위한 섹션을 선택하며, .bss는 초기값을 파일에 저장하지 않는 데이터 영역과 관련된다.
.byte, .word, .long, .quad와 같은 지시어는 일정한 크기의 정숫값을 출력에 배치할 수 있다. .ascii와 .asciz는 문자열 데이터를 배치하며, .align 또는 아키텍처별 정렬 지시어는 이후의 코드나 데이터를 특정 경계에 맞춘다.
.global 또는 .globl은 심벌을 다른 오브젝트 파일에서 참조할 수 있도록 전역으로 공개하고, .extern은 외부에서 정의되는 심벌과 관련된 정보를 나타내는 데 사용될 수 있다. 실제 의미와 필요성은 오브젝트 파일 형식과 어셈블러에 따라 달라진다.
지시어는 반드시 기계어 명령을 생성하지 않는다. .text와 .global은 어셈블러와 링커가 사용하는 메타데이터와 상태를 바꾸지만, 그 자체가 프로세서에서 실행되는 명령으로 변환되지는 않는다. 반면 데이터 정의 지시어는 기계 명령은 아니지만 오브젝트 파일에 바이트를 생성한다.
지시어의 이름과 문법은 어셈블러마다 다르다. 같은 역할을 하는 기능도 GNU Assembler, NASM과 MASM에서 서로 다른 표기를 사용할 수 있다. GNU Assembler는 기계 독립적인 공통 지시어와 각 대상 아키텍처에만 적용되는 기계 종속 지시어를 함께 제공한다.[30]
의사 명령어와 매크로
의사 명령어(pseudoinstruction)는 어셈블리 소스에서는 명령어처럼 작성되지만, ISA에 동일한 형태의 실제 기계 명령이 존재하지 않거나 다른 명령의 별칭으로 제공되는 표현이다. 어셈블러는 의사 명령어를 하나 이상의 실제 기계 명령으로 변환한다.
의사 명령어는 자주 사용하는 작업을 간결하게 표현하고 아키텍처의 세부 인코딩을 숨기는 데 사용된다. 예를 들어 레지스터 사이의 값을 복사하거나 큰 상수를 적재하는 동작이 실제로는 다른 산술 명령 또는 여러 개의 명령 조합으로 구현될 수 있다.
하나의 의사 명령어가 생성하는 실제 명령의 수는 피연산자 값, 코드 모델과 대상 확장에 따라 달라질 수 있다. 작은 즉시값은 하나의 명령으로 표현할 수 있지만 큰 상수나 멀리 있는 주소는 상위 비트와 하위 비트를 나누어 여러 명령으로 구성해야 할 수 있다.
일부 의사 명령어는 실제 명령의 읽기 쉬운 별칭이다. 이 경우 별도의 추가 명령 없이 같은 기계어 인코딩으로 변환된다. 조건 반전이나 피연산자 생략과 같은 표현도 어셈블러 수준의 별칭으로 제공될 수 있다.
매크로는 이름과 매개변수로 정의된 소스 코드의 반복 가능한 형식이다. 매크로를 호출하면 어셈블러는 정의된 명령과 지시어의 묶음을 소스 수준에서 확장한 뒤 이를 어셈블한다. 따라서 매크로 하나는 여러 기계 명령뿐 아니라 레이블, 데이터 정의와 다른 지시어도 생성할 수 있다.
매크로와 의사 명령어는 비슷해 보이지만 정의 주체와 확장 방식이 다르다. 의사 명령어는 일반적으로 어셈블러가 대상 아키텍처의 일부처럼 제공하며 정해진 규칙에 따라 실제 명령으로 변환한다. 매크로는 어셈블러가 기본 제공할 수도 있지만 프로그래머가 직접 정의할 수 있고, 문자와 문장의 반복 확장에 더 가깝다.
매크로는 함수 호출과도 다르다. 함수는 실행 중 하나의 코드 위치로 분기하여 재사용되지만, 매크로는 호출한 각 위치에 코드가 확장된다. 이에 따라 함수 호출 오버헤드를 피하거나 반복되는 저수준 패턴을 표현할 수 있지만, 여러 위치에 긴 매크로를 사용하면 생성되는 코드의 크기가 증가할 수 있다.
매크로 어셈블러는 매개변수, 지역 레이블, 반복, 조건부 확장과 문자열 처리 기능을 제공할 수 있다. 이러한 기능을 이용하면 상위 수준의 문법을 일부 구성할 수 있지만, 매크로의 실제 기능과 문법은 어셈블러마다 크게 다르다.
의사 명령어와 매크로는 어셈블리 소스의 표현력을 높이지만, 소스 코드의 한 줄과 생성된 기계 명령 하나가 항상 대응한다는 가정을 성립하지 않게 한다. 디버깅, 성능 분석과 코드 크기 계산에서는 어셈블러가 최종적으로 어떤 명령열을 생성했는지 오브젝트 덤프나 디스어셈블 결과를 통해 확인해야 한다.
명령어와 데이터 표현
어셈블리어에서 실행 동작은 명령어로 표현되고, 프로그램이 처리할 값은 레지스터, 메모리, 즉시값과 데이터 영역에 배치된다. 어셈블러는 명령어 니모닉과 피연산자를 대상 명령어 집합 아키텍처의 비트 형식으로 인코딩하고, 데이터 정의 지시어가 나타내는 상수와 문자열을 오브젝트 파일의 각 섹션에 배치한다.
명령어와 데이터의 실제 표현 방식은 아키텍처에 따라 다르다. AArch64는 기본적으로 모든 A64 명령이 32비트 길이를 사용하는 고정 길이 명령어 집합인 반면, x86과 x86-64는 접두사, 연산 코드, 피연산자 지정 정보, 변위와 즉시값이 조합되는 가변 길이 명령 형식을 사용한다. RISC-V는 32비트 기본 명령 형식을 중심으로 설계되었으며 선택적인 압축 명령 확장을 통해 16비트 명령도 사용할 수 있다.[31][32][33]
명령어 형식
명령어 형식은 하나의 기계 명령 안에서 연산 코드, 레지스터 번호, 즉시값과 주소 관련 정보를 어떤 비트 위치에 배치할지를 정의한다. 어셈블리 소스에서는 이러한 비트 구조가 니모닉과 피연산자 문법으로 추상화되지만, 어셈블러는 최종적으로 각 요소를 ISA에 정의된 명령어 형식에 맞추어 인코딩한다.
기계 명령은 일반적으로 다음과 같은 정보를 포함할 수 있다.
- 수행할 연산을 나타내는 연산 코드
- 원본과 목적지 레지스터 번호
- 즉시값
- 주소 계산에 사용할 기준 레지스터와 변위
- 피연산자의 크기
- 조건이나 실행 모드를 지정하는 비트
- 명령어 집합 확장을 식별하는 부가 필드
모든 명령이 이 요소를 전부 포함하는 것은 아니다. 레지스터끼리 값을 더하는 명령은 여러 레지스터 번호를 포함할 수 있고, 즉시값을 더하는 명령은 한쪽 원본 레지스터 대신 명령 안에 상수를 포함한다. 분기 명령은 목적지 주소 전체가 아니라 현재 명령 위치에서 목적지까지의 상대 거리를 인코딩할 수 있다.
RISC-V의 기본 정수 명령어 집합은 R형, I형, S형, B형, U형과 J형 명령 형식을 사용한다. 이 형식들은 연산 코드와 레지스터 필드의 위치를 가능한 한 공통으로 유지하면서, 즉시값의 비트를 명령 목적에 맞게 배치한다. R형은 주로 레지스터 간 연산에 사용되고, I형은 즉시값 연산과 적재 명령, S형은 저장 명령, B형은 조건 분기, U형과 J형은 큰 즉시값과 긴 분기에 사용된다.[34]
A64 명령은 모두 32비트 길이를 사용하며, 명령어 종류에 따라 해당 비트 안에 연산 코드, 레지스터와 즉시값 필드를 배치한다. 명령 길이가 고정되어 있으므로 프로세서가 다음 명령의 위치를 찾기 쉽고 명령 디코딩 구조도 비교적 규칙적으로 구성할 수 있다.
x86과 x86-64의 명령은 1바이트부터 여러 바이트까지 길이가 달라질 수 있다. 명령에는 하나 이상의 접두사, 연산 코드, ModR/M 바이트, 선택적인 SIB 바이트, 변위와 즉시값이 포함될 수 있다. REX와 VEX, EVEX 같은 접두사는 추가 레지스터, 64비트 피연산자와 벡터 명령 기능을 표현하는 데 사용된다. Intel의 명령어 집합 참조는 각 명령마다 사용 가능한 형식, 연산 코드와 피연산자 조합을 정의한다.[35]
어셈블리 소스의 명령 하나가 어떤 기계 명령으로 인코딩되는지는 니모닉만으로 결정되지 않는다. 피연산자의 종류와 크기, 대상 아키텍처 모드와 활성화된 확장도 함께 고려된다. 예를 들어 같은 ADD 니모닉이라도 레지스터 간 덧셈, 즉시값 덧셈과 벡터 덧셈은 서로 다른 인코딩을 사용한다.
어셈블러가 제공하는 의사 명령어와 명령 별칭은 실제 명령어 형식을 직접 드러내지 않을 수 있다. 따라서 특정 코드가 생성하는 명령의 길이와 바이트 배열을 확인하려면 어셈블 결과를 오브젝트 덤프 또는 디스어셈블러로 살펴봐야 한다.
데이터 이동
프로그램의 계산은 데이터를 레지스터와 메모리 사이에서 이동시키는 과정과 함께 이루어진다. 데이터 이동 명령은 값을 복사하거나 적재하고 저장하며, 주소와 상수를 레지스터에 구성하는 데 사용된다.
데이터 이동이라는 이름은 원본 값이 사라진다는 뜻이 아니다. 대부분의 이동 명령은 원본 값을 유지하면서 목적지에 같은 비트 값을 복사한다. 일부 아키텍처는 복사를 별도의 실제 명령으로 제공하지만, 다른 아키텍처에서는 덧셈이나 논리 연산의 특수한 형태를 이동 의사 명령어로 사용한다.
전형적인 데이터 이동은 다음 범주로 나뉜다.
- 레지스터에서 레지스터로 값 복사
- 메모리에서 레지스터로 값 적재
- 레지스터에서 메모리로 값 저장
- 즉시값을 레지스터에 기록
- 심벌이나 메모리 주소를 레지스터에 구성
- 레지스터의 일부 비트 또는 특정 크기의 값을 이동
- 값을 부호 확장하거나 0 확장하면서 이동
RISC-V와 AArch64 같은 로드·스토어 구조에서는 일반적인 산술 명령이 레지스터를 대상으로 하며, 메모리 접근은 별도의 적재와 저장 명령으로 수행된다. RISC-V의 LW는 메모리에서 32비트 값을 적재하고, SW는 레지스터의 하위 32비트를 메모리에 저장한다. 유효 주소는 기준 레지스터 값과 명령에 포함된 부호 확장 변위를 더하여 계산한다.[36]
AArch64에서는 LDR과 STR 계열 명령을 사용하여 레지스터와 메모리 사이에서 값을 이동한다. LDR X0, [X1]은 X1이 가리키는 주소에서 64비트 값을 읽어 X0에 저장하며, STR X0, [X1]은 반대 방향으로 값을 기록한다. 바이트, 하프워드, 32비트와 64비트 데이터에 따라 서로 다른 명령 형태가 사용된다.
x86에서는 MOV가 레지스터, 메모리와 즉시값 사이의 여러 이동 형식을 제공한다. 다만 일반적인 MOV 명령은 메모리 위치에서 다른 메모리 위치로 직접 값을 복사할 수 없으므로, 보통 레지스터를 중간에 사용하거나 문자열 이동 명령을 이용한다.
작은 데이터형을 더 큰 레지스터로 이동할 때는 상위 비트를 어떻게 처리할지 정해야 한다. 0 확장은 빈 상위 비트를 0으로 채우며 부호 없는 정수에 적합하다. 부호 확장은 원래 값의 최상위 비트를 상위 영역에 복제하여 2의 보수 부호를 유지한다.
큰 상수나 주소는 하나의 명령에 전부 인코딩되지 않을 수 있다. 이 경우 상위와 하위 부분을 나누어 여러 명령으로 구성하거나, 프로그램 카운터 상대 주소 계산과 적재 명령을 조합한다. 실제 명령열은 실행 파일 형식, 위치 독립 코드 여부와 대상 코드 모델에 따라 달라질 수 있다.
데이터 이동 명령은 단순 복사 외에도 메모리 접근 권한, 정렬과 바이트 순서의 영향을 받는다. 유효하지 않은 주소를 사용하거나 현재 실행 권한으로 접근할 수 없는 영역을 참조하면 예외가 발생할 수 있다.
산술과 논리 연산
산술 명령은 정수나 부동소수점 값의 덧셈, 뺄셈, 곱셈, 나눗셈과 부호 변환을 수행한다. 논리 명령은 비트 단위 AND, OR, XOR, NOT과 이동 및 회전 연산을 수행한다.
정수 덧셈과 뺄셈은 일반적으로 고정된 레지스터 크기 안에서 수행된다. 계산 결과가 해당 비트 수로 표현할 수 있는 범위를 벗어나면 상위 비트가 잘리거나, ISA에 정의된 상태 플래그와 예외 처리 규칙에 따라 오버플로가 표시될 수 있다.
같은 비트 패턴을 부호 있는 정수와 부호 없는 정수로 다르게 해석할 수 있으므로, 덧셈과 뺄셈 자체는 두 경우에 같은 회로와 명령을 사용할 수 있다. 차이는 비교, 나눗셈과 오버플로 판단에서 나타나는 경우가 많다.
곱셈 명령은 두 입력과 같은 크기의 하위 결과만 생성하거나, 두 배 크기의 전체 결과를 여러 레지스터에 나누어 저장할 수 있다. 나눗셈은 몫과 나머지를 생성할 수 있으며, 0으로 나누기와 표현할 수 없는 결과에 대한 동작은 아키텍처마다 다르다.
부동소수점 연산은 일반적으로 정수 레지스터와 별도의 부동소수점 또는 벡터 레지스터를 사용한다. IEEE 754 형식을 지원하는 아키텍처에서는 단정도와 배정도 값, 반올림 방식, 무한대, NaN과 부동소수점 예외 상태를 다룬다.
비트 단위 논리 연산은 각 비트 위치를 독립적으로 계산한다.
- AND는 두 입력 비트가 모두 1일 때 1을 생성한다.
- OR은 둘 중 하나 이상의 입력이 1일 때 1을 생성한다.
- XOR은 두 입력 비트가 서로 다를 때 1을 생성한다.
- NOT은 각 비트를 반전한다.
이러한 연산은 비트 마스크를 적용하고 특정 플래그를 설정하거나 제거하며, 여러 필드를 하나의 정수 안에 결합하는 데 사용된다.
시프트는 비트열을 왼쪽이나 오른쪽으로 이동한다. 논리적 오른쪽 시프트는 빈 상위 비트를 0으로 채우고, 산술적 오른쪽 시프트는 부호 비트를 유지하도록 상위 비트를 채운다. 왼쪽 시프트는 표현 범위 안에서는 2의 거듭제곱을 곱하는 효과를 낼 수 있다.
회전은 한쪽 끝에서 밀려난 비트를 반대쪽 끝으로 다시 넣는다. 암호화, 해시, 체크섬과 비트 필드 처리에서 자주 사용된다. 모든 ISA가 회전을 독립된 명령으로 제공하는 것은 아니며, 여러 시프트와 논리 연산으로 구성할 수도 있다.
현대 ISA는 스칼라 값뿐 아니라 하나의 레지스터에 여러 값을 담아 동시에 계산하는 SIMD와 벡터 명령을 제공한다. 같은 덧셈 니모닉 계열이라도 스칼라 정수, 부동소수점과 벡터 데이터에 따라 피연산자 형식과 결과가 달라진다.
비교와 조건 분기
프로그램은 값을 비교한 결과에 따라 다른 명령 경로를 선택할 수 있다. 비교 명령은 두 값의 관계를 계산하고, 조건 분기 명령은 그 결과에 따라 프로그램 카운터를 변경한다.
비교는 보통 두 값을 뺀 결과를 이용하지만, 계산 결과 자체를 일반 레지스터에 저장하지 않고 상태 플래그만 갱신할 수 있다. x86의 CMP는 내부적으로 첫 번째 피연산자에서 두 번째 피연산자를 뺀 것과 같은 방식으로 플래그를 설정하지만 결과 값은 저장하지 않는다.
x86의 조건 분기 명령은 영 플래그, 캐리 플래그, 부호 플래그와 오버플로 플래그의 조합을 검사한다. 같은 비트 패턴이라도 부호 있는 비교와 부호 없는 비교에 사용하는 조건이 다르다. 예를 들어 부호 없는 크기 비교는 캐리와 영 플래그를 중심으로 하고, 부호 있는 비교는 부호와 오버플로 상태의 관계를 사용한다.
AArch64도 CMP와 CMN 같은 비교 별칭을 제공하며, 조건 플래그를 설정한 뒤 B.EQ, B.NE, B.LT와 같은 조건 분기를 사용할 수 있다. 실제로 CMP는 결과 레지스터를 버리는 플래그 설정 뺄셈 명령의 별칭으로 표현된다.
RISC-V의 기본 정수 ISA는 전역 상태 플래그를 사용하지 않는다. BEQ, BNE, BLT, BGE와 같은 분기 명령이 두 레지스터를 직접 비교하고 조건이 참일 때 분기한다. 부호 없는 비교에는 BLTU와 BGEU가 사용된다.[37]
분기 목적지는 절대 주소보다 현재 명령 위치를 기준으로 하는 상대 변위로 인코딩되는 경우가 많다. 이를 통해 코드가 메모리의 다른 위치에 배치되더라도 같은 상대 거리를 유지할 수 있고, 제한된 명령 비트 안에서 가까운 목적지를 효율적으로 표현할 수 있다.
조건 분기 명령이 표현할 수 있는 거리는 명령 형식에 따라 제한된다. 목적지가 범위를 벗어나면 어셈블러나 링커가 더 긴 분기 명령열로 바꾸거나, 별도의 중계 코드를 생성해야 할 수 있다.
비교 결과를 분기 없이 정수 값이나 조건부 선택으로 변환하는 명령도 있다. 조건부 이동, 조건부 선택과 집합 명령을 사용하면 짧은 조건 처리에서 분기를 줄일 수 있다. 다만 어떤 방식이 더 빠른지는 분기 예측과 대상 마이크로아키텍처에 따라 달라진다.
반복과 제어 흐름
제어 흐름은 프로세서가 명령을 실행하는 순서를 의미한다. 별도의 제어 이동 명령이 없다면 프로그램 카운터는 현재 명령 다음의 명령을 가리키며 순차적으로 실행된다. 분기, 함수 호출, 반환, 예외와 인터럽트는 이 기본 흐름을 변경한다.
반복문은 일반적으로 다음 요소의 조합으로 구현된다.
- 반복 전에 카운터나 상태 초기화
- 반복 본문 실행
- 카운터 또는 처리 위치 갱신
- 종료 조건 비교
- 조건이 충족되지 않으면 시작 지점으로 분기
고급 언어의 for, while과 do-while은 어셈블리 수준에서 비교 명령과 조건 분기, 레이블의 배치로 표현된다. 동일한 반복문도 컴파일러 최적화에 따라 명령 배치와 분기 방향이 달라질 수 있다.
일부 ISA는 반복 횟수를 줄이면서 분기하는 전용 명령을 제공하지만, 현대 코드에서는 일반적인 비교와 분기 명령을 조합하는 경우가 많다. x86의 LOOP 명령은 카운터 레지스터를 감소시키고 0이 아니면 분기하지만, 모든 마이크로아키텍처에서 일반 명령 조합보다 효율적인 것은 아니다.
무조건 분기는 조건을 검사하지 않고 지정된 위치로 실행을 이동한다. x86의 JMP, AArch64의 B, RISC-V의 JAL 또는 JALR을 반환 주소를 보존하지 않는 방식으로 사용하는 형태가 대표적이다.
직접 분기는 목적지가 명령 안의 변위나 심벌로 표현되는 방식이고, 간접 분기는 목적지 주소를 레지스터나 메모리에서 가져오는 방식이다. 간접 분기는 함수 포인터, 가상 함수 호출, 점프 테이블과 동적 디스패치에 사용된다.
switch와 같이 여러 목적지 중 하나를 선택하는 구조는 비교와 연속 분기로 구현할 수도 있고, 인덱스를 사용해 분기 주소 테이블을 참조하는 점프 테이블로 구현할 수도 있다. 선택 방식은 값의 범위와 분포, 코드 크기와 컴파일러 최적화에 따라 달라진다.
현대 프로세서는 다음에 실행할 명령을 미리 가져오기 위해 분기 예측을 사용한다. 예측이 맞으면 파이프라인을 계속 활용할 수 있지만, 예측이 틀리면 이미 가져오거나 실행을 시작한 명령을 취소해야 한다. 이에 따라 제어 흐름의 배치는 성능 최적화에서 중요한 요소가 된다.
스택 연산
스택은 가장 나중에 저장한 값을 가장 먼저 꺼내는 후입선출 구조로 사용되는 메모리 영역이다. 어셈블리 수준에서는 보통 스택 포인터 레지스터가 현재 스택의 끝을 가리키며, 값을 저장하거나 제거할 때 스택 포인터를 조정한다.
스택은 다음과 같은 용도로 사용된다.
- 함수의 반환 주소 보관
- 보존해야 하는 레지스터 저장
- 지역 변수와 임시 공간 할당
- 레지스터에 들어가지 않는 함수 인수 전달
- 가변 길이 데이터와 호출 프레임 관리
- 인터럽트와 예외 발생 시 실행 상태 저장
많은 시스템에서 스택은 높은 주소에서 낮은 주소 방향으로 성장하지만, 이는 모든 아키텍처의 필수 규칙은 아니다. 스택의 성장 방향과 정렬, 호출 시 스택 포인터 위치는 ISA와 호출 규약에 의해 결정된다.
x86은 PUSH와 POP 명령을 제공한다. PUSH는 스택 포인터를 감소시킨 뒤 값을 메모리에 저장하고, POP은 스택의 값을 읽은 뒤 스택 포인터를 증가시킨다. 피연산자 크기와 실행 모드에 따라 조정되는 스택 크기가 달라진다.
AArch64에는 전통적인 의미의 단일 PUSH와 POP 명령이 없다. 대신 STP와 LDP 같은 레지스터 쌍 저장·적재 명령, 사전 인덱스와 사후 인덱스 주소 지정을 사용하여 스택 포인터 조정과 값 저장을 함께 수행한다. Arm의 절차 호출 규약은 공개 인터페이스에서 스택 포인터 정렬과 사용 규칙을 정의한다.[38]
RISC-V도 전용 PUSH와 POP 기본 명령을 사용하지 않는다. 일반 산술 명령으로 스택 포인터를 감소시키고 저장 명령으로 레지스터를 기록한 뒤, 함수가 끝날 때 반대 순서로 값을 복원한다.
스택 포인터는 호출 규약이 요구하는 정렬을 유지해야 한다. 정렬 규칙을 지키지 않으면 벡터 데이터 접근이 비효율적이거나 잘못될 수 있고, 다른 함수 또는 운영체제 인터페이스와의 호환성이 깨질 수 있다.
스택에 너무 많은 데이터를 할당하거나 반환 과정에서 스택 포인터를 잘못 복원하면 다른 호출 프레임을 덮어쓰거나 유효하지 않은 메모리에 접근할 수 있다. 반환 주소와 보존 레지스터가 스택에 저장되는 구조에서는 스택 손상이 프로그램 제어 흐름을 변경하는 보안 취약점으로 이어질 수도 있다.
함수 호출과 반환
함수 호출은 현재 실행 위치를 기억하면서 다른 코드 위치로 제어를 이동하는 동작이다. 호출된 함수가 작업을 마치면 저장된 반환 주소를 이용하여 호출 다음 명령으로 실행을 되돌린다.
ISA는 제어를 이동하고 반환 주소를 기록하는 기계적 수단을 제공한다. 그러나 인수 전달, 반환값, 레지스터 보존, 스택 정렬과 함수 프레임의 형태는 대체로 플랫폼의 호출 규약이 정의한다.
x86의 CALL 명령은 반환 주소를 스택에 저장하고 호출 목적지로 분기한다. RET는 스택에서 반환 주소를 읽어 해당 위치로 실행을 이동한다. 직접 호출은 명령에 상대 변위를 포함하고, 간접 호출은 레지스터나 메모리에 저장된 주소를 사용할 수 있다.
AArch64의 BL과 BLR은 호출 다음 명령의 주소를 링크 레지스터 X30에 저장하고 대상 위치로 분기한다. Arm의 BLR 명령은 레지스터에 들어 있는 주소로 분기하면서 X30을 PC+4로 설정한다.[39] 반환은 일반적으로 RET 명령으로 수행하며, 기본적으로 X30에 저장된 주소를 사용한다.
함수가 다른 함수를 다시 호출하면 현재 링크 레지스터의 반환 주소가 덮어써질 수 있다. 따라서 AArch64의 비단말 함수는 자신의 반환 주소를 스택이나 보존 레지스터에 저장한 뒤 하위 함수를 호출해야 한다.
RISC-V의 JAL은 다음 명령 주소를 목적지 레지스터에 기록하고 PC 상대 위치로 분기한다. 일반적인 함수 호출에서는 반환 주소 레지스터 ra를 목적지로 사용한다. JALR은 레지스터와 즉시값으로 계산한 주소로 분기하며 간접 호출과 반환에 사용된다.
호출 규약은 일부 레지스터를 호출자 보존 레지스터와 피호출자 보존 레지스터로 나눈다. 호출자 보존 레지스터는 함수 호출 뒤에도 값이 필요하면 호출하는 쪽이 저장해야 한다. 피호출자 보존 레지스터는 호출된 함수가 값을 변경하려면 원래 값을 저장하고 반환 전에 복원해야 한다.
함수의 인수는 우선 정해진 범용 또는 부동소수점 레지스터를 통해 전달되고, 남는 인수는 스택에 배치되는 경우가 많다. 반환값도 일반적으로 지정된 레지스터에 놓인다. 구조체와 큰 데이터의 전달 방식은 크기와 플랫폼 ABI에 따라 달라진다.
함수 시작 부분의 프롤로그는 필요한 스택 공간을 확보하고 반환 주소와 보존 레지스터를 저장하며, 선택적으로 프레임 포인터를 설정한다. 함수 끝의 에필로그는 이 과정을 반대로 수행한다. 단순한 함수는 별도의 스택 프레임 없이 레지스터만 사용하고 바로 반환할 수 있다.
데이터 정의와 정렬
어셈블리 소스에는 실행 명령뿐 아니라 프로그램이 사용할 데이터도 직접 정의할 수 있다. 데이터 정의 지시어는 정수, 부동소수점 값, 문자열과 초기화되지 않은 저장 공간을 오브젝트 파일의 섹션에 배치한다.
GNU Assembler에서는 .byte, .short, .long, .quad 같은 지시어를 사용하여 일정한 크기의 값을 출력할 수 있다. 지시어 이름과 각 값의 정확한 크기는 대상과 어셈블러 문법의 영향을 받을 수 있으므로 해당 도구의 문서를 확인해야 한다.
데이터는 일반적으로 다음과 같은 섹션에 나뉜다.
- 실행 명령이 들어가는 코드 섹션
- 초기값을 가진 쓰기 가능한 데이터 섹션
- 읽기 전용 상수와 문자열 섹션
- 파일에는 실제 초기 바이트를 저장하지 않는 0 초기화 데이터 영역
- 재배치, 심벌과 디버깅 정보를 담는 메타데이터 섹션
초기화되지 않았거나 0으로 초기화되는 큰 배열은 실행 파일에 모든 0바이트를 저장하지 않고 크기 정보만 기록할 수 있다. 프로그램이 적재될 때 로더가 해당 메모리를 확보하고 0으로 초기화한다.
정렬은 데이터나 명령의 시작 주소가 특정 바이트 경계의 배수가 되도록 배치하는 것이다. 4바이트 값은 4의 배수 주소, 16바이트 벡터 값은 16의 배수 주소에 놓는 식으로 사용된다.
정렬은 아키텍처의 접근 제약을 만족하고 메모리 접근 성능을 높이며, 원자적 연산과 ABI 규칙을 지키는 데 필요하다. 일부 프로세서는 정렬되지 않은 접근을 처리할 수 있지만 여러 메모리 작업으로 분리될 수 있고, 특정 명령이나 장치 메모리에서는 예외가 발생할 수 있다.
어셈블러의 .align, .balign과 .p2align 같은 지시어는 다음 데이터나 명령의 배치를 조정한다. GNU Assembler에서 .balign은 바이트 단위 경계를 직접 지정하고, .p2align은 2의 거듭제곱 지수를 사용한다. .align의 의미는 대상 아키텍처와 호환 문법에 따라 바이트 경계 또는 2의 거듭제곱으로 해석될 수 있다.[40]
정렬을 맞추기 위해 생성되는 빈 공간은 데이터 영역에서 0 또는 지정한 채움값으로 채워질 수 있다. 코드 영역에서는 실행에 영향을 주지 않는 NOP 명령열을 사용해 채우기도 한다.
과도한 정렬은 접근 효율을 높일 수 있지만 파일과 메모리 크기를 증가시킨다. 따라서 데이터 크기, 캐시 구조, 벡터 명령 요구 사항과 ABI를 기준으로 필요한 수준을 선택한다.
문자열과 상수
어셈블리어에서 상수는 소스 코드에 직접 기록되는 고정값이다. 상수는 명령의 즉시값, 데이터 정의, 배열 크기, 비트 마스크, 주소 계산과 조건부 어셈블리에 사용된다.
정수 상수는 십진수, 16진수, 2진수와 때에 따라 팔진수로 작성할 수 있다. 사용 가능한 접두사와 접미사 문법은 어셈블러마다 다르다. GNU Assembler는 일반적으로 0x 접두사를 가진 16진수와 0b 접두사를 가진 이진수를 지원한다.
상수가 명령의 즉시값으로 사용될 때는 명령어 형식이 표현할 수 있는 범위 안에 있어야 한다. 범위를 벗어난 값은 어셈블 오류가 되거나, 어셈블러가 의사 명령어를 여러 실제 명령으로 확장할 수 있다.
심벌에 고정된 값을 연결하여 이름 있는 상수를 만들 수도 있다. 이렇게 하면 같은 숫자의 의미를 문서화하고 여러 위치에서 일관되게 사용할 수 있다. 단순 수치 상수와 달리 심벌 주소는 링커가 최종 값을 결정할 수 있으며 재배치가 필요할 수 있다.
문자열은 연속된 문자 코드의 배열로 저장된다. 어셈블러는 문자열 리터럴을 특정 문자 인코딩의 바이트로 변환하지만, 일반적으로 실행 중 문자열의 길이와 의미를 자동으로 관리하지 않는다.
GNU Assembler의 .ascii 지시어는 문자열의 문자 바이트를 그대로 배치하고, .asciz 또는 .string은 문자열 끝에 값 0인 바이트를 추가할 수 있다. 종료 0은 C 계열 문자열과 호환되는 표현에 사용된다.[41]
문자열은 다음과 같은 방식으로 표현될 수 있다.
- 길이를 별도 상수나 필드로 저장
- 마지막에 0바이트를 붙이는 널 종료 문자열
- 고정된 크기의 문자 배열
- 문자 개수와 데이터가 함께 있는 길이 접두 문자열
- 운영체제나 런타임이 정의하는 별도 객체 형식
어셈블러가 문자열을 배치한다고 해서 출력 함수나 문자 인코딩 변환 기능이 자동으로 제공되는 것은 아니다. 문자열을 화면이나 파일에 출력하려면 운영체제의 시스템 호출, 펌웨어 인터페이스 또는 런타임 라이브러리를 호출해야 한다.
이스케이프 문법을 이용해 줄바꿈, 탭, 따옴표와 특정 바이트 값을 문자열에 포함할 수 있다. 지원되는 이스케이프와 문자 인코딩은 어셈블러에 따라 다르며, 소스 파일의 인코딩과 출력 데이터의 인코딩이 항상 같다고 가정할 수 없다.
숫자나 문자열의 메모리 표현은 대상의 바이트 순서에 영향을 받는다. 다바이트 정수는 리틀 엔디언 또는 빅 엔디언 순서로 배치되지만, 문자열을 구성하는 각 문자 바이트의 순서는 일반적으로 작성된 순서를 유지한다.
부동소수점 상수를 정의할 때는 어셈블러가 문자열 형태의 숫자를 대상 부동소수점 형식으로 변환한다. 원하는 비트 패턴을 정확하게 보존해야 하는 저수준 코드에서는 부동소수점 리터럴 대신 정수 데이터로 원시 비트 패턴을 직접 정의하기도 한다.
문자열과 상수도 코드와 마찬가지로 오브젝트 파일의 심벌과 재배치 체계에 참여할 수 있다. 문자열 시작 위치에 레이블을 붙이고 코드에서 해당 심벌의 주소를 참조하면, 어셈블러와 링커가 실제 배치 주소에 맞는 명령과 재배치 정보를 생성한다.
레지스터와 메모리
레지스터와 메모리는 프로세서가 명령을 실행하면서 코드와 데이터를 보관하는 핵심 저장 구조다. 레지스터는 프로세서의 명령어 집합에 직접 노출되는 제한된 저장 공간이며, 메모리는 주소를 통해 접근하는 더 큰 저장 공간이다. 어셈블리 프로그램은 값을 레지스터에 적재하고 연산한 뒤 다시 메모리에 저장하는 과정을 명시적으로 표현한다.
레지스터의 종류와 수, 주소의 크기, 메모리 접근 방식과 바이트 순서는 명령어 집합 아키텍처에 따라 달라진다. 여기에 운영체제의 가상 메모리, 호출 규약, 실행 파일 형식과 권한 모델이 결합되면서 실제 프로그램이 사용하는 주소 공간과 레지스터 사용 규칙이 형성된다.
범용 레지스터
범용 레지스터(general-purpose register)는 정수, 주소, 비트 패턴과 계산 중간값을 저장하는 데 사용하는 레지스터다. 대부분의 산술·논리 명령, 주소 계산과 데이터 이동 명령은 범용 레지스터를 피연산자로 사용한다.
범용이라는 명칭은 여러 목적에 사용할 수 있다는 뜻이지만, 모든 범용 레지스터가 항상 완전히 동일한 기능을 제공하는 것은 아니다. 일부 명령은 특정 레지스터를 암묵적으로 사용하고, 호출 규약은 각 레지스터에 인수 전달, 반환값, 스택 포인터 또는 임시값과 같은 역할을 부여할 수 있다.
x86-64에서는 RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP와 R8부터 R15까지의 64비트 정수 레지스터를 사용한다. 이 가운데 RSP는 스택 포인터로 사용되며, 나머지 레지스터도 역사적 기능과 명령 인코딩에 따른 차이가 있다.
x86-64의 일부 레지스터는 같은 물리적 레지스터의 하위 영역에 서로 다른 이름으로 접근할 수 있다. 예를 들어 RAX는 전체 64비트, EAX는 하위 32비트, AX는 하위 16비트, AL은 하위 8비트를 나타낸다. 64비트 모드에서 EAX에 값을 기록하면 RAX의 상위 32비트는 0으로 설정된다.[42]
AArch64는 X0부터 X30까지 31개의 64비트 범용 레지스터를 제공한다. 같은 레지스터의 하위 32비트는 W0부터 W30이라는 이름으로 접근한다. W 레지스터에 값을 기록하면 대응하는 X 레지스터의 상위 32비트는 0으로 설정된다.[43]
AArch64의 레지스터 번호 31은 명령 문맥에 따라 스택 포인터 SP 또는 값이 항상 0인 제로 레지스터 XZR·WZR을 나타낸다. 따라서 X31이라는 일반 범용 레지스터를 직접 사용하는 구조는 아니며, 명령 형식에 따라 의미가 결정된다.
RISC-V 기본 정수 ISA는 x0부터 x31까지 32개의 정수 레지스터를 정의한다. x0은 항상 값 0을 읽으며, 이 레지스터에 대한 쓰기는 결과가 보존되지 않는다. 나머지 레지스터의 용도는 ISA가 아닌 호출 규약이 구체적으로 정한다.[44]
RISC-V의 표준 호출 규약에서는 정수 레지스터에 zero, ra, sp, gp, tp, t0부터 t6, s0부터 s11, a0부터 a7 등의 ABI 이름을 부여한다. 같은 레지스터를 x10 또는 a0처럼 번호와 ABI 이름으로 모두 나타낼 수 있다.
범용 레지스터에는 정수뿐 아니라 포인터, 문자, 부동소수점 값의 원시 비트 패턴과 여러 비트 필드를 저장할 수 있다. 레지스터 자체는 값의 고급 언어 자료형을 기억하지 않으며, 해당 값을 어떤 명령으로 처리하는지가 해석 방식을 결정한다.
호출 규약은 범용 레지스터를 호출자가 보존해야 하는 레지스터와 피호출자가 보존해야 하는 레지스터로 나눈다. 함수 호출을 사이에 두고 값을 유지해야 한다면 해당 규칙에 따라 레지스터 값을 스택이나 다른 저장 위치에 보존해야 한다.
특수 목적 레지스터
특수 목적 레지스터는 명령 실행 상태, 메모리 관리, 예외 처리와 시스템 제어처럼 정해진 기능을 담당하는 레지스터다. 범용 명령으로 자유롭게 사용할 수 있는 범용 레지스터와 달리, 읽기와 쓰기가 특정 명령이나 권한 수준으로 제한될 수 있다.
대표적인 특수 목적 레지스터에는 다음과 같은 종류가 있다.
- 다음에 실행할 명령의 위치와 관련된 프로그램 카운터
- 현재 스택 위치를 가리키는 스택 포인터
- 프로세서 상태와 조건 결과를 보관하는 상태 레지스터
- 예외 발생 위치와 원인을 기록하는 레지스터
- 페이지 테이블의 위치와 주소 변환 설정을 관리하는 레지스터
- 인터럽트와 예외 진입 주소를 지정하는 벡터 레지스터
- 디버깅, 성능 측정과 프로세서 기능 제어를 위한 레지스터
프로그램 카운터(program counter)는 현재 또는 다음에 실행할 명령의 주소와 관련된 상태다. 아키텍처에 따라 PC, IP, EIP, RIP 등의 이름이 사용된다.
x86-64의 명령 포인터는 RIP로 불린다. 일반적인 범용 레지스터처럼 임의 값을 직접 기록하지는 못하며, 분기, 호출, 반환과 예외 처리 같은 제어 흐름 명령을 통해 변경한다. x86-64는 현재 명령 위치를 기준으로 데이터와 코드에 접근하는 RIP 상대 주소 지정을 지원한다.
AArch64의 프로그램 카운터도 일반 범용 레지스터 목록에 포함되지 않는다. 분기 명령은 PC를 변경하고, PC 상대 주소 생성 명령은 현재 코드 위치를 기준으로 심벌과 데이터의 주소를 계산한다.
스택 포인터는 현재 스택의 끝을 가리킨다. x86-64에서는 RSP, AArch64에서는 SP, RISC-V 표준 ABI에서는 x2에 해당하는 sp가 사용된다. ISA가 스택 포인터의 모든 용도를 강제하는 것은 아니지만, 호출 규약과 운영체제는 스택의 성장 방향과 정렬 규칙을 정한다.
시스템 제어 레지스터는 운영체제 커널이나 펌웨어가 프로세서의 동작을 설정하는 데 사용한다. x86의 제어 레지스터 CR0, CR2, CR3, CR4 등은 보호 모드, 페이지 변환, 예외 정보와 여러 프로세서 기능을 제어한다. CR3는 일반적인 페이지 변환 환경에서 현재 주소 공간의 페이지 테이블 구조와 관련된 값을 보관한다.
x86은 기능별 설정과 상태를 제공하는 모델별 레지스터도 정의한다. 모델별 레지스터는 RDMSR과 WRMSR 같은 전용 명령으로 접근하며, 지원 여부와 세부 기능은 프로세서 계열에 따라 달라질 수 있다. Intel의 시스템 프로그래밍 설명서는 메모리 관리, 보호, 인터럽트와 모델별 레지스터를 별도로 정의한다.[45]
AArch64는 예외 수준과 시스템 기능을 제어하기 위해 다수의 시스템 레지스터를 제공한다. 레지스터 이름에는 적용되는 예외 수준을 나타내는 접미사가 붙을 수 있으며, 페이지 테이블 기준 주소, 변환 제어, 예외 반환 주소와 예외 원인 등을 저장한다.
RISC-V는 제어·상태 레지스터(Control and Status Register, CSR)를 통해 특권 상태와 시스템 기능을 제공한다. CSR은 별도의 주소 공간을 가지며, CSR 읽기·쓰기 명령을 통해 접근한다. 특권 아키텍처는 예외 처리, 인터럽트, 주소 변환과 실행 상태에 필요한 CSR을 정의한다.[46]
특수 목적 레지스터에 잘못된 값을 기록하면 현재 주소 공간, 인터럽트 상태나 실행 권한이 변경될 수 있다. 따라서 많은 시스템 레지스터는 사용자 모드에서 접근할 수 없으며, 권한이 부족한 상태에서 접근하면 예외가 발생한다.
상태 레지스터와 플래그
상태 레지스터는 산술 연산 결과, 현재 실행 모드, 인터럽트 상태와 프로세서의 여러 조건을 비트 단위로 기록한다. 그중 산술과 비교 결과를 나타내는 비트를 일반적으로 조건 플래그 또는 상태 플래그라고 한다.
대표적인 조건 플래그에는 다음과 같은 의미가 있다.
- 결과가 0인지 나타내는 영 플래그
- 결과의 최상위 비트와 관련된 부호 플래그
- 부호 없는 연산에서 자리올림이나 빌림을 나타내는 캐리 플래그
- 부호 있는 결과가 표현 범위를 벗어났음을 나타내는 오버플로 플래그
- 일부 아키텍처에서 보조 자리올림이나 패리티를 나타내는 플래그
x86의 RFLAGS 레지스터는 산술 상태뿐 아니라 방향 플래그, 인터럽트 허용 플래그와 여러 시스템 상태를 포함한다. ADD, SUB, CMP, TEST와 같은 명령은 정의된 플래그를 변경하며, 조건 분기와 조건부 이동 명령은 그 조합을 검사한다.
캐리와 오버플로는 서로 다른 개념이다. 캐리 플래그는 주로 비트열을 부호 없는 정수로 해석했을 때 범위를 벗어났는지를 나타내고, 오버플로 플래그는 2의 보수 부호 있는 정수 연산 결과가 표현 범위를 벗어났는지를 나타낸다.
AArch64의 PSTATE에는 조건 플래그 N, Z, C, V가 포함된다. 각각 음수, 0, 캐리와 부호 있는 오버플로 조건을 나타낸다. 모든 산술 명령이 플래그를 변경하는 것은 아니며, ADDS, SUBS처럼 플래그 갱신을 명시하는 명령 형태가 사용된다.
AArch64의 CMP는 별도의 결과를 저장하지 않고 조건 플래그만 설정하는 SUBS 계열 명령의 별칭이다. 이후 조건 분기, 조건부 선택과 조건부 비교 명령이 이 플래그를 사용할 수 있다.
RISC-V 기본 정수 ISA는 x86이나 AArch64와 같은 전역 산술 조건 플래그를 두지 않는다. 조건 분기 명령이 두 레지스터를 직접 비교하며, 비교 결과를 일반 정수 레지스터에 기록하는 명령도 사용할 수 있다.
플래그를 변경하는 명령과 변경하지 않는 명령을 구분하는 것은 중요하다. 비교 결과를 사용하기 전에 다른 명령이 상태 플래그를 덮어쓰면 조건 분기의 결과가 달라질 수 있다. 컴파일러와 어셈블리 프로그래머는 플래그 생성 명령과 소비 명령 사이의 의존성을 관리해야 한다.
프로세서 상태 레지스터에는 산술 플래그 외에도 현재 권한 수준, 인터럽트 마스크, 단일 단계 디버깅과 실행 상태가 포함될 수 있다. 이러한 비트의 접근 방법과 변경 권한은 ISA와 실행 모드에 따라 달라진다.
주소 지정 방식
주소 지정 방식(addressing mode)은 명령이 접근할 메모리의 유효 주소 또는 피연산자 값을 계산하는 규칙이다. 어셈블리 소스에서는 레지스터, 즉시값, 변위, 인덱스와 레이블을 조합하여 주소 지정 방식을 표현한다.
대표적인 주소 지정 방식은 다음과 같다.
- 명령 내부에 상수를 포함하는 즉시 주소 지정
- 레지스터 값을 직접 피연산자로 사용하는 레지스터 주소 지정
- 레지스터가 가리키는 메모리에 접근하는 레지스터 간접 주소 지정
- 기준 레지스터에 고정된 변위를 더하는 기준 주소 지정
- 기준 레지스터와 인덱스 레지스터를 결합하는 인덱스 주소 지정
- 현재 프로그램 카운터를 기준으로 계산하는 PC 상대 주소 지정
- 명령에 직접 포함된 절대 주소를 사용하는 절대 주소 지정
즉시값과 레지스터 피연산자는 엄밀히 말하면 메모리 주소를 계산하지 않지만, 명령의 피연산자를 선택하는 방식이라는 넓은 의미에서 주소 지정 방식에 포함되기도 한다.
x86은 기준 레지스터, 인덱스 레지스터, 배율과 변위를 조합하는 주소 형식을 제공한다. 일반적인 유효 주소는 기준 + 인덱스 × 배율 + 변위 형태로 계산할 수 있으며, 배율에는 1, 2, 4 또는 8이 사용된다.
이 구조는 배열 요소와 구조체 필드에 접근할 때 유용하다. 기준 레지스터가 배열 시작 주소를 가리키고, 인덱스 레지스터에 요소 번호가 들어 있다면 요소 크기를 배율로 사용하여 주소를 계산할 수 있다.
x86-64의 RIP 상대 주소 지정은 현재 명령 포인터와 부호 있는 변위를 더해 주소를 계산한다. 실행 코드가 메모리의 어느 위치에 배치되더라도 가까운 코드와 데이터의 상대적 위치를 이용할 수 있으므로 위치 독립 코드에 사용된다.
AArch64의 적재·저장 명령은 기준 레지스터에 즉시 변위나 확장된 레지스터 값을 더하는 주소 지정 방식을 제공한다. 일부 형식은 접근 전이나 접근 후에 기준 레지스터를 갱신하는 사전 인덱스와 사후 인덱스 방식을 지원한다.
사전 인덱스 주소 지정은 기준 레지스터를 먼저 변경한 뒤 변경된 주소에 접근하고, 사후 인덱스 주소 지정은 현재 주소에 접근한 뒤 기준 레지스터를 변경한다. 이러한 기능은 스택 포인터나 배열 순회 주소를 갱신하는 데 활용된다.
RISC-V의 기본 적재·저장 명령은 기준 레지스터와 명령 내부의 12비트 부호 확장 변위를 더하여 주소를 계산한다. 더 복잡한 배열 인덱스 계산은 별도의 산술·시프트 명령으로 주소를 구성한 뒤 적재 또는 저장 명령을 수행한다. RISC-V 기본 정수 ISA는 메모리 접근을 적재와 저장 명령으로 제한하는 로드·스토어 구조다.[47]
주소 지정 방식이 계산한 값이 유효한 메모리 주소라는 보장은 없다. 현재 주소 공간에 매핑되지 않았거나 접근 권한이 없는 주소, 요구되는 정렬을 지키지 않은 주소에 접근하면 예외가 발생할 수 있다.
바이트 순서
바이트 순서(endianness)는 여러 바이트로 구성된 값을 메모리에 어떤 순서로 저장하는지를 나타낸다. 가장 일반적인 방식은 리틀 엔디언과 빅 엔디언이다.
리틀 엔디언에서는 정수의 가장 낮은 자리 바이트가 가장 낮은 메모리 주소에 저장된다. 예를 들어 32비트 값 0x12345678을 주소 A에 리틀 엔디언으로 저장하면 메모리는 다음 순서가 된다.
| 주소 | 값 |
|---|---|
| A | 0x78 |
| A+1 | 0x56 |
| A+2 | 0x34 |
| A+3 | 0x12 |
빅 엔디언에서는 가장 높은 자리 바이트가 가장 낮은 주소에 저장된다.
| 주소 | 값 |
|---|---|
| A | 0x12 |
| A+1 | 0x34 |
| A+2 | 0x56 |
| A+3 | 0x78 |
바이트 순서는 다바이트 값을 메모리나 파일에서 해석할 때 중요하다. 같은 바이트 배열도 어떤 바이트 순서를 적용하느냐에 따라 서로 다른 정숫값이 된다.
x86과 x86-64는 리틀 엔디언 메모리 표현을 사용한다. AArch64는 시스템 구성에 따라 리틀 엔디언과 빅 엔디언을 지원할 수 있지만, 일반적인 운영체제와 소프트웨어 환경에서는 리틀 엔디언이 널리 사용된다.
RISC-V의 기본 ISA 문서는 메모리 접근의 바이트 순서를 실행 환경이 정의할 수 있도록 구성해 왔으며, 현재의 표준 프로파일과 일반적인 플랫폼은 주로 리틀 엔디언 환경을 사용한다. 특정 시스템을 대상으로 할 때는 ISA 이름만으로 판단하지 않고 해당 플랫폼 ABI와 실행 환경을 확인해야 한다.
바이트 순서는 한 바이트 안의 비트 순서를 뒤집는 개념이 아니다. 0x78이라는 바이트 자체의 비트 의미는 그대로 유지되며, 여러 바이트를 나열하는 순서가 달라진다.
문자열은 일반적으로 문자 바이트가 작성된 순서대로 저장된다. 따라서 ASCII 문자열 "ABC"는 엔디언과 관계없이 0x41, 0x42, 0x43 순서로 배치된다. 다만 문자열 안에 다바이트 문자 단위나 길이 필드가 포함되면 해당 형식의 바이트 순서 규칙을 별도로 적용할 수 있다.
네트워크 프로토콜과 파일 형식은 프로세서의 기본 바이트 순서와 별도로 데이터 표현 순서를 정한다. 인터넷 프로토콜의 여러 정수 필드는 네트워크 바이트 순서인 빅 엔디언을 사용하므로, 리틀 엔디언 시스템에서는 송수신 과정에서 변환이 필요할 수 있다.
어셈블리 프로그램이 바이트 단위로 데이터를 직접 읽거나 서로 다른 시스템과 바이너리 데이터를 교환할 때는 바이트 순서를 명시적으로 처리해야 한다. 바이트 교환 명령, 시프트와 마스크 또는 라이브러리 변환 함수를 사용할 수 있다.
메모리 정렬
메모리 정렬은 데이터나 명령의 시작 주소가 특정 크기의 경계에 맞춰 배치되는 성질이다. 예를 들어 4바이트 값이 4의 배수 주소에서 시작하면 4바이트 경계에 정렬되었다고 한다.
자연 정렬은 일반적으로 데이터 크기와 같은 경계를 사용하는 방식이다.
| 데이터 크기 | 일반적인 자연 정렬 |
|---|---|
| 1바이트 | 1바이트 |
| 2바이트 | 2바이트 |
| 4바이트 | 4바이트 |
| 8바이트 | 8바이트 |
| 16바이트 벡터 | 16바이트 |
정렬된 접근은 하나의 메모리 전송이나 캐시 접근으로 처리되기 쉬우며, 일부 아키텍처와 명령에서는 반드시 요구된다. 정렬되지 않은 접근은 데이터가 두 캐시 라인이나 페이지 경계에 걸칠 수 있어 추가 작업이 필요할 수 있다.
x86 계열은 일반적인 정수 명령에서 많은 비정렬 메모리 접근을 허용한다. 그러나 접근이 캐시 라인이나 페이지 경계를 가로지르면 성능 비용이 커질 수 있고, 일부 정렬 전용 SIMD 명령은 정렬되지 않은 주소에서 예외를 발생시킬 수 있다.
Arm과 RISC-V에서는 실행 환경과 명령 종류에 따라 비정렬 접근을 하드웨어가 처리하거나 예외를 발생시킬 수 있다. 장치 메모리, 원자적 명령과 특정 벡터 명령은 일반 메모리 접근보다 엄격한 정렬을 요구할 수 있다.
정렬은 단일 값의 접근뿐 아니라 원자성에도 영향을 준다. 여러 스레드나 프로세서가 공유하는 값을 원자적으로 읽고 변경하려면 ISA가 요구하는 크기와 정렬 조건을 만족해야 하는 경우가 많다.
스택 포인터도 호출 규약에 따른 정렬을 유지해야 한다. AArch64의 공개 함수 인터페이스에서는 스택 포인터가 16바이트 경계에 맞아야 하며, 스택 포인터를 주소 기준으로 사용할 때 하드웨어 정렬 검사가 적용될 수 있다.[48]
데이터 구조에서는 각 필드의 정렬을 맞추기 위해 필드 사이에 사용하지 않는 패딩 바이트가 삽입될 수 있다. 구조체 전체의 크기도 배열로 연속 배치했을 때 각 요소가 올바르게 정렬되도록 가장 큰 정렬 단위의 배수로 조정될 수 있다.
어셈블리 소스에서는 .align, .balign, .p2align과 같은 지시어를 사용하여 다음 코드나 데이터의 정렬을 지정할 수 있다. 다만 .align의 인수 의미는 대상 아키텍처와 어셈블러 호환 모드에 따라 달라질 수 있으므로, 바이트 단위 정렬이 필요하면 의미가 명확한 지시어를 사용하는 편이 적합하다.[49]
정렬을 크게 지정할수록 접근 조건을 만족하기 쉬워지지만, 코드와 데이터 사이에 패딩이 늘어나 파일 크기와 메모리 사용량이 증가한다. 따라서 ABI, 명령 요구 사항과 실제 성능 특성을 기준으로 필요한 정렬을 선택한다.
스택과 힙
스택과 힙은 프로세스의 주소 공간에서 실행 중 데이터를 저장하는 대표적인 메모리 영역이다. 두 영역은 특정 물리 메모리 종류가 아니라 운영체제, 실행 파일과 런타임이 정한 사용 방식이다.
스택은 함수 호출과 밀접하게 연결된 메모리 영역이다. 스택 포인터를 기준으로 함수의 반환 주소, 보존 레지스터, 지역 변수와 임시값을 저장한다. 일반적으로 함수에 진입할 때 필요한 공간을 확보하고 반환할 때 한꺼번에 해제한다.
함수 하나가 사용하는 스택 영역을 스택 프레임 또는 호출 프레임이라고 한다. 스택 프레임에는 다음 항목이 포함될 수 있다.
- 이전 함수로 돌아갈 반환 주소
- 이전 프레임 포인터
- 피호출자 보존 레지스터
- 지역 변수
- 임시 계산 공간
- 레지스터에 들어가지 않은 함수 인수
- 다른 함수를 호출하기 위한 인수와 저장 공간
스택 프레임의 정확한 구조는 ISA만으로 결정되지 않으며 호출 규약, 컴파일러 최적화와 함수의 동작에 따라 달라진다. 프레임 포인터를 별도로 사용하는 함수도 있고, 스택 포인터와 디버깅 정보만으로 프레임을 추적하는 함수도 있다.
많은 현대 플랫폼에서 스택은 높은 주소에서 낮은 주소 방향으로 확장된다. 새로운 공간을 확보하려면 스택 포인터를 감소시키고, 공간을 반환할 때는 증가시킨다. 그러나 스택의 성장 방향은 모든 ISA에 공통된 필수 규칙은 아니다.
스택은 빠른 명령으로 포인터만 조정하여 공간을 확보할 수 있지만 크기가 제한된다. 지나치게 큰 지역 배열, 끝나지 않는 재귀 호출이나 잘못된 스택 포인터 조작은 스택 한계를 넘어서는 스택 오버플로를 발생시킬 수 있다.
힙은 함수 호출 수명과 독립적으로 동적 메모리를 할당하는 데 사용하는 영역이다. 고급 언어의 malloc, new와 여러 메모리 할당기는 운영체제로부터 받은 가상 메모리를 관리하고 요청된 크기의 블록을 프로그램에 제공한다.
힙 할당은 일반적으로 하나의 어셈블리 명령으로 이루어지지 않는다. 프로그램은 런타임 할당 함수를 호출하고, 할당기는 내부 자료구조에서 사용 가능한 블록을 찾거나 운영체제에 더 많은 메모리를 요청한다.
UNIX 계열 운영체제의 메모리 할당기는 brk 계열 인터페이스나 익명 메모리 매핑을 사용할 수 있으며, 실제 전략은 운영체제와 할당기 구현에 따라 달라진다. 프로그램은 반환된 주소를 일반 포인터처럼 사용한다.
힙에 할당된 메모리는 함수가 반환되어도 자동으로 사라지지 않는다. 프로그램이나 런타임이 명시적으로 반환하거나 가비지 컬렉터가 더 이상 사용되지 않는 객체를 회수해야 한다.
스택과 힙이 하나의 연속된 공간에서 서로를 향해 성장하는 그림은 전통적인 프로세스 배치를 설명하는 단순화된 모델이다. 현대 운영체제에서는 스택, 힙, 공유 라이브러리와 메모리 매핑이 서로 떨어진 가상 주소 영역에 배치될 수 있으며, 힙도 여러 매핑으로 구성될 수 있다.
가상 메모리와 주소 공간
주소 공간은 프로그램이나 실행 주체가 사용할 수 있는 메모리 주소의 집합이다. 가상 메모리는 프로그램이 사용하는 가상 주소를 실제 물리 메모리나 다른 저장 자원에 매핑하는 체계다.
가상 메모리를 사용하는 시스템에서 어셈블리 명령이 다루는 주소는 대부분 물리 주소가 아닌 가상 주소다. 프로세서는 페이지 테이블과 주소 변환 장치를 사용하여 가상 주소를 물리 주소로 변환한다.
주소 변환은 일반적으로 주소를 가상 페이지 번호와 페이지 내부 오프셋으로 나누어 처리한다. 페이지 테이블은 가상 페이지에 대응하는 물리 페이지와 접근 권한, 존재 여부와 여러 상태 비트를 기록한다. 페이지 내부 오프셋은 변환 과정에서도 유지된다.
각 프로세스에 독립된 페이지 테이블을 제공하면 서로 같은 가상 주소를 사용하더라도 다른 물리 메모리에 연결할 수 있다. 이를 통해 프로세스는 다른 프로세스의 메모리를 직접 읽거나 변경하지 못하도록 격리된다.
운영체제는 페이지마다 읽기, 쓰기와 실행 권한을 설정할 수 있다. 코드 영역은 읽기와 실행만 허용하고, 일반 데이터 영역은 읽기와 쓰기를 허용하되 실행을 막는 방식으로 구성할 수 있다.
현재 주소 공간에 매핑되지 않은 페이지나 권한이 맞지 않는 페이지에 접근하면 페이지 폴트 또는 이에 대응하는 메모리 예외가 발생한다. 운영체제는 필요에 따라 페이지를 새로 할당하거나 파일에서 읽어 들인 뒤 실행을 재개할 수 있고, 처리할 수 없는 접근이라면 프로세스를 종료할 수 있다.
가상 메모리는 물리 메모리보다 큰 주소 공간을 제공하는 용도에만 한정되지 않는다. 프로세스 격리, 공유 메모리, 메모리 매핑 파일, 지연 할당, 쓰기 시 복사, 실행 파일 적재와 메모리 보호도 가상 메모리 위에서 구현된다.
x86-64의 주소 크기가 64비트라고 해서 현재 프로세서가 모든 64비트 조합을 가상 주소로 사용하는 것은 아니다. 구현은 지원하는 유효 주소 비트 수를 정하며, 나머지 상위 비트에는 정규 주소 형식과 관련된 규칙이 적용된다. 페이지 테이블 단계와 페이지 크기도 사용되는 페이징 모드와 기능에 따라 달라진다.
AArch64는 변환 테이블 기준 레지스터와 변환 제어 레지스터를 이용하여 여러 주소 범위와 페이지 테이블 구성을 관리한다. 운영체제는 예외 수준별 시스템 레지스터를 설정하여 사용자 공간과 커널 공간의 주소 변환을 구성한다.
RISC-V 특권 아키텍처는 Sv32, Sv39, Sv48과 Sv57 등의 페이지 기반 가상 메모리 방식을 정의한다. 이름에 포함된 숫자는 해당 방식에서 사용되는 가상 주소 구조와 관련되며, 여러 단계의 페이지 테이블을 통해 주소를 변환한다.[50]
페이지 테이블 변환 결과는 변환 색인 버퍼(Translation Lookaside Buffer, TLB)에 캐시될 수 있다. 운영체제가 페이지 테이블을 변경하면 대상 아키텍처가 정의한 명령과 동기화 절차를 사용하여 오래된 변환 정보가 사용되지 않도록 해야 한다.
가상 주소에서 물리 주소로 변환되었다고 해서 곧바로 일반 RAM에 접근한다는 뜻은 아니다. 물리 주소 공간에는 RAM뿐 아니라 장치 레지스터, 펌웨어 영역과 예약 영역이 포함될 수 있다. 장치 메모리는 캐시와 접근 순서에 대해 일반 메모리와 다른 규칙을 사용할 수 있다.
사용자 프로그램이 볼 수 있는 주소 공간의 구성은 운영체제와 실행 파일 형식에 따라 달라진다. 일반적인 프로세스에는 실행 코드, 읽기 전용 데이터, 쓰기 가능한 전역 데이터, 동적 할당 영역, 공유 라이브러리, 메모리 매핑과 스택이 각각 별도의 가상 주소 범위에 배치된다.
주소 공간 배치 무작위화는 실행할 때마다 코드, 라이브러리, 스택과 여러 메모리 영역의 가상 주소를 변경한다. 이에 따라 어셈블리 프로그램은 고정된 절대 주소에 의존하기보다 PC 상대 주소 지정, 심벌 재배치와 위치 독립 코드를 사용하는 경우가 많다.
어셈블리어는 주소 계산과 메모리 접근을 직접 표현하지만, 실제로 접근 가능한 범위와 권한은 운영체제와 메모리 관리 장치가 결정한다. 따라서 어셈블리 프로그램에서 보이는 포인터는 단순한 숫자인 동시에 현재 주소 공간과 권한 체계 안에서 해석되는 가상 주소다.
어셈블 과정과 도구 체인
어셈블리 소스는 일반적으로 전처리와 매크로 확장, 어셈블, 링크와 적재 단계를 거쳐 실행 가능한 프로그램이 된다. 각 단계는 소스의 기호식 표현을 점차 구체적인 기계 표현으로 변환한다. 어셈블러는 명령어와 데이터를 오브젝트 파일로 만들고, 링커는 여러 오브젝트 파일과 라이브러리를 하나의 실행 파일이나 공유 라이브러리로 결합하며, 로더는 이를 프로세스의 주소 공간에 배치하여 실행을 시작한다.
이 과정의 세부 구조는 운영체제, 실행 파일 형식과 도구 체인에 따라 달라진다. UNIX 계열 환경에서는 GNU Binutils, LLVM, ELF와 동적 링커가 널리 사용되고, Windows에서는 COFF 오브젝트 파일, PE 실행 파일, Microsoft 링커와 Windows 로더가 사용된다. macOS에서는 Mach-O 형식과 Apple의 링커·동적 로더가 사용된다.
일반적인 처리 흐름은 다음과 같이 나타낼 수 있다.
어셈블리 프로그램의 처리 흐름
- 어셈블리 원시 파일
- 전처리와 매크로 확장
- 어셈블러
- 재배치 가능한 오브젝트 파일
- 링커
- 실행 파일 또는 공유 라이브러리
- 운영체제 로더와 동적 링커
- 메모리에 배치된 실행 프로그램
각 단계가 반드시 독립된 프로그램으로 실행되는 것은 아니다. 컴파일러 드라이버가 전처리기, 어셈블러와 링커를 연속해서 호출할 수 있고, LLVM과 같은 도구 체인은 컴파일러 내부에서 직접 기계어와 오브젝트 파일을 생성할 수 있다. 또한 펌웨어와 운영체제 커널처럼 일반적인 사용자 공간 로더를 사용하지 않는 프로그램은 링커가 생성한 이미지를 부트로더나 펌웨어가 직접 메모리에 배치할 수 있다.
어셈블러
어셈블러는 어셈블리 소스에 작성된 명령어 니모닉, 피연산자, 레이블, 데이터 정의와 지시어를 읽어 기계어와 메타데이터가 포함된 오브젝트 파일을 생성하는 프로그램이다.
어셈블러는 일반적으로 다음 작업을 수행한다.
- 명령어 니모닉과 피연산자의 문법 분석
- 대상 아키텍처의 명령어 인코딩 선택
- 레지스터 번호, 즉시값과 주소 변위의 비트 배치
- 레이블과 심벌의 값 계산
- 코드와 데이터를 섹션별로 배치
- 외부 또는 미확정 심벌 참조에 대한 재배치 정보 생성
- 심벌 테이블과 선택적인 디버깅 정보 생성
- 의사 명령어와 매크로의 확장
GNU Assembler인 as는 GNU Binutils의 일부이며, 여러 아키텍처와 오브젝트 파일 형식을 지원한다. GNU Assembler 설명서는 소스 문법, 심벌, 상수, 표현식, 지시어, 기계 종속 기능과 출력 오브젝트 파일을 다룬다.[51]
어셈블러는 대상 아키텍처에 정의된 실제 명령 형식을 알아야 한다. 같은 ADD나 MOV라는 이름이 있더라도 x86-64, AArch64와 RISC-V에서는 피연산자 형식과 기계어 인코딩이 다르므로, 어셈블러는 선택된 대상 아키텍처에 맞는 명령을 생성한다.
어셈블러는 보통 소스의 명령 하나를 기계 명령 하나로 변환하지만, 의사 명령어는 여러 실제 명령으로 확장될 수 있다. 반대로 지시어는 기계 명령을 만들지 않고 섹션, 정렬, 데이터와 심벌 속성만 변경할 수 있다.
레이블이 정의되기 전에 참조될 수 있으므로 전통적인 어셈블러는 두 번 이상의 패스를 사용할 수 있다. 첫 번째 패스에서 명령과 데이터의 크기 및 심벌 위치를 계산하고, 다음 패스에서 실제 명령과 주소를 생성하는 방식이다. 현대 어셈블러의 내부 구현은 더 복잡할 수 있지만, 심벌 값을 수집한 뒤 참조를 해결한다는 기본 원리는 유지된다.
같은 명령어 집합을 대상으로 하더라도 어셈블러마다 소스 문법이 다를 수 있다. x86에서는 GNU Assembler의 AT&T 문법, Intel 문법, NASM 문법과 MASM 문법이 함께 사용된다. 명령어 피연산자의 순서, 레지스터·즉시값 표기, 메모리 주소 표현과 지시어 이름이 서로 다를 수 있다.
어셈블러의 출력은 대개 바로 실행 가능한 파일이 아니라 재배치 가능한 오브젝트 파일이다. 다른 파일에 정의된 함수와 데이터의 주소는 아직 결정되지 않았으므로, 어셈블러는 해당 참조를 심벌과 재배치 항목으로 남긴다.
전처리와 매크로 확장
전처리는 본격적인 어셈블 전에 소스 텍스트를 변환하는 과정이다. 헤더나 다른 파일의 포함, 조건부 코드 선택, 상수 치환과 매크로 확장이 이 단계에서 수행될 수 있다.
전처리는 어셈블러 내부 기능으로 제공될 수도 있고, C 전처리기와 같은 별도 프로그램이 담당할 수도 있다. UNIX 계열 도구 체인에서는 파일 확장자와 빌드 설정에 따라 .s 파일은 그대로 어셈블러에 전달하고, .S 파일은 C 전처리기를 거친 뒤 어셈블하는 관례가 널리 사용된다.
GNU Assembler에는 간단한 내부 전처리 단계가 있다. 이 단계는 공백을 정리하고 주석을 제거하며 문자 상수를 숫자로 변환하지만, C 전처리기와 같은 매크로 처리나 파일 포함 기능 전체를 수행하지는 않는다.[52]
C 전처리기를 사용하는 어셈블리 소스에서는 #include, #define, #if, #ifdef와 같은 문법을 사용할 수 있다. 이를 통해 같은 소스에서 아키텍처, 운영체제 구성과 빌드 옵션에 따라 다른 코드를 선택할 수 있다.
어셈블러 자체의 매크로는 반복되는 어셈블리 문장과 지시어를 하나의 이름으로 정의한다. GNU Assembler에서는 .macro와 .endm 지시어 사이에 매크로 본문을 작성하고, 인수를 받아 소스 수준에서 반복적으로 확장할 수 있다.[53]
매크로 확장은 함수 호출과 다르다. 함수는 실행 중 하나의 코드 위치로 분기하지만, 매크로는 호출된 위치마다 실제 명령열이 삽입된다. 따라서 호출 비용 없이 반복되는 패턴을 만들 수 있지만, 큰 매크로를 여러 번 사용하면 코드 크기가 증가한다.
매크로는 다음과 같은 작업에 활용된다.
- 함수 프롤로그와 에필로그 생성
- 레지스터 저장과 복원
- 시스템 호출 래퍼 작성
- 반복되는 장치 레지스터 접근
- 아키텍처별 명령 차이 은폐
- 디버깅과 계측 코드 삽입
- 구조체 필드의 오프셋과 상수 관리
조건부 어셈블리는 빌드 시점의 값에 따라 일부 소스만 선택한다. 이는 실행 중 조건 분기와 다르며, 선택되지 않은 코드는 최종 오브젝트 파일에 포함되지 않는다.
전처리기와 매크로가 함께 사용되면 실제 어셈블러가 처리한 소스가 원본 파일과 크게 달라질 수 있다. 빌드 오류나 생성 코드 분석에서는 전처리와 매크로 확장이 끝난 중간 결과 또는 최종 디스어셈블 결과를 확인할 필요가 있다.
섹션과 심벌 테이블
섹션은 오브젝트 파일 안에서 같은 성격과 속성을 가진 코드, 데이터와 메타데이터를 묶는 단위다. 어셈블러는 지시어에 따라 소스의 내용을 서로 다른 섹션에 배치하고, 링커는 입력 섹션을 결합하거나 재배치하여 출력 파일의 섹션과 메모리 세그먼트를 구성한다.
대표적인 섹션은 다음과 같다.
- 실행 명령이 들어가는 코드 섹션
- 초기화된 쓰기 가능 데이터를 담는 데이터 섹션
- 읽기 전용 상수와 문자열을 담는 섹션
- 초기값이 없는 전역·정적 데이터를 나타내는 BSS 섹션
- 심벌 이름과 섹션 이름을 보관하는 문자열 테이블
- 심벌 정보를 보관하는 심벌 테이블
- 주소 수정을 위한 재배치 섹션
- 예외 처리와 스택 되감기 정보 섹션
- 디버깅 정보를 담는 DWARF 등의 섹션
GNU Assembler의 .section 지시어는 이후 생성되는 코드나 데이터를 지정된 이름의 섹션에 배치한다. 다만 임의 이름의 섹션을 지원하는지는 대상 오브젝트 파일 형식에 따라 달라진다.[54]
ELF에서 섹션은 ELF 헤더, 프로그램 헤더 테이블과 섹션 헤더 테이블을 제외한 오브젝트 파일의 대부분 정보를 담는다. 명령, 데이터, 심벌, 문자열과 재배치 정보가 각각의 섹션으로 표현된다.[55]
심벌 테이블은 이름이 있는 함수, 데이터, 섹션과 외부 참조에 대한 정보를 기록한다. 각 심벌에는 이름, 값, 크기, 종류, 바인딩과 소속 섹션 등의 속성이 포함될 수 있다.
심벌의 값은 파일 종류와 상태에 따라 다르게 해석된다. 재배치 가능한 오브젝트 파일에서는 대개 해당 섹션 시작점을 기준으로 한 오프셋이고, 링크된 실행 파일이나 공유 오브젝트에서는 가상 주소를 나타낼 수 있다.
심벌은 일반적으로 다음과 같이 구분된다.
- 현재 오브젝트 파일 안에서만 사용하는 지역 심벌
- 다른 파일에서 참조할 수 있는 전역 심벌
- 강한 정의가 없을 때 선택될 수 있는 약한 심벌
- 현재 파일에서 정의되지 않고 다른 파일에 정의되어야 하는 미정의 심벌
- 절대값을 나타내며 섹션 재배치의 영향을 받지 않는 심벌
ELF 심벌 테이블은 지역 심벌을 먼저 배치하고 그 뒤에 비지역 심벌을 배치한다. 미정의 심벌은 링크 과정에서 다른 입력 파일의 실제 정의와 연결될 수 있다.[56]
ELF에서는 전체 링크와 디버깅에 사용하는 일반 심벌 테이블과 동적 링킹에 필요한 심벌만 담는 동적 심벌 테이블이 구분될 수 있다. 일반 심벌 테이블은 실행 파일을 배포하기 전에 제거할 수 있지만, 동적 심벌 테이블은 공유 라이브러리와 동적 링커가 필요로 하는 항목을 유지해야 한다.
심벌 테이블은 이름 자체를 각 항목에 직접 저장하기보다 별도의 문자열 테이블에 있는 문자열의 인덱스를 기록하는 방식으로 구성되는 경우가 많다. ELF의 섹션 이름과 심벌 이름도 문자열 테이블을 통해 표현된다.[57]
오브젝트 파일
오브젝트 파일은 어셈블러나 컴파일러가 생성하는 기계어, 데이터와 링크 정보를 담은 구조화된 바이너리 파일이다. 아직 다른 모듈과 결합되지 않은 재배치 가능한 파일일 수도 있고, 최종 실행 파일이나 공유 라이브러리일 수도 있다.
재배치 가능한 오브젝트 파일은 일반적으로 다음 정보를 포함한다.
- 대상 아키텍처와 파일 형식 정보
- 기계 명령과 초기화된 데이터
- 섹션의 이름, 크기와 속성
- 정의되거나 참조된 심벌
- 링커가 수정해야 할 위치와 계산 방식을 나타내는 재배치 항목
- 소스 수준 디버깅을 위한 정보
- 예외 처리와 스택 되감기 정보
- 컴파일러와 어셈블러가 생성한 부가 메타데이터
오브젝트 파일은 단순한 기계 명령의 연속이 아니다. 링커와 로더가 코드와 데이터를 올바르게 결합하고 배치하기 위해 필요한 구조와 메타데이터를 함께 포함한다.
주요 오브젝트 파일 계열로는 다음이 있다.
- UNIX와 Linux에서 널리 사용되는 ELF
- Windows의 COFF 오브젝트 파일과 PE 이미지
- macOS와 Apple 플랫폼의 Mach-O
- 과거 UNIX 계열의
a.out - 임베디드 개발에서 사용하는 여러 제조사 전용 형식
- 메모리나 장치에 직접 기록하기 위한 Intel HEX와 Motorola S-record
ELF는 재배치 가능한 오브젝트, 실행 파일과 공유 오브젝트를 하나의 기본 형식 안에서 표현한다. 링크 관점에서는 섹션 헤더 테이블을 중심으로 해석하고, 실행 관점에서는 프로그램 헤더 테이블을 중심으로 메모리에 적재할 세그먼트를 해석한다. ELF 사양은 섹션이 명령, 데이터, 심벌 테이블과 재배치 정보를 포함하는 링크 관점의 핵심 구조라고 설명한다.[58]
Windows의 COFF 파일은 링커 입력으로 사용되는 오브젝트 형식이고, PE는 운영체제 로더가 실행 파일과 DLL을 적재하는 이미지 형식이다. Microsoft의 PE/COFF 문서는 오브젝트 파일의 섹션, 심벌·재배치 정보와 이미지 파일의 헤더, 데이터 디렉터리와 RVA 구조를 정의한다.[59]
하나의 오브젝트 파일에 포함된 주소 중 상당수는 최종 주소가 아니다. 링커가 모든 입력 섹션의 배치를 결정하기 전에는 다른 파일의 함수와 데이터가 어디에 놓일지 알 수 없기 때문이다. 따라서 어셈블러는 수정이 필요한 위치에 재배치 항목을 생성한다.
링커
링커는 여러 오브젝트 파일과 라이브러리를 결합하여 실행 파일, 공유 라이브러리 또는 다른 오브젝트 파일을 만드는 프로그램이다.
링커는 일반적으로 다음 작업을 수행한다.
- 입력 오브젝트 파일과 라이브러리 읽기
- 동일하거나 관련된 입력 섹션 결합
- 출력 섹션과 메모리 배치 결정
- 심벌 정의와 참조 연결
- 재배치 항목 계산과 주소 수정
- 필요하지 않은 코드와 데이터 제거
- 정적 라이브러리에서 필요한 오브젝트 모듈 선택
- 동적 라이브러리 의존 정보 생성
- 실행 진입점과 파일 헤더 생성
- 프로그램 헤더와 적재 세그먼트 구성
GNU 링커 ld는 GNU Binutils에 포함되며 다양한 오브젝트 파일 형식과 아키텍처를 지원한다. GNU 링커는 링커 스크립트를 통해 입력 섹션이 출력 파일의 어디에 배치될지와 출력 메모리 구조를 제어할 수 있다.[60]
링커가 가장 먼저 해결해야 하는 핵심 문제는 심벌 연결이다. 한 오브젝트 파일에서 외부 함수 foo를 참조하고 다른 오브젝트 파일에서 foo를 정의했다면, 링커는 참조를 해당 정의에 연결한다.
동일한 전역 심벌이 여러 파일에서 강하게 정의되면 일반적으로 중복 정의 오류가 발생한다. 약한 심벌과 특수한 연결 규칙이 사용되면 여러 후보 중 하나가 선택될 수 있다.
재배치는 입력 섹션의 최종 위치가 결정된 뒤 명령과 데이터 안의 주소 관련 값을 수정하는 과정이다. 절대 주소, PC 상대 거리, 전역 오프셋 테이블 항목과 아키텍처별 주소 구성 방식에 따라 서로 다른 재배치 종류가 사용된다.
정적 라이브러리는 여러 오브젝트 파일을 하나의 보관 파일로 묶은 것이다. 링커는 일반적으로 라이브러리 전체를 실행 파일에 복사하지 않고, 아직 해결되지 않은 심벌을 정의하는 구성 오브젝트만 선택한다.
링커는 사용되지 않는 함수와 데이터 섹션을 제거할 수 있다. 이를 효과적으로 사용하려면 컴파일러와 어셈블러가 함수나 데이터 단위로 독립된 섹션을 생성해야 할 수 있다.
링커 스크립트는 출력 파일의 섹션 순서, 시작 주소, 정렬, 심벌 정의와 메모리 영역을 제어한다. GNU ld의 모든 링크는 내부적으로 링커 스크립트의 제어를 받으며, 기본 스크립트 대신 사용자 스크립트를 지정할 수 있다. 링커 스크립트의 주요 목적은 입력 파일의 섹션을 출력 파일에 매핑하고 메모리 배치를 결정하는 것이다.[61]
운영체제 커널, 부트로더, 펌웨어와 임베디드 이미지는 일반 응용 프로그램보다 링커 스크립트를 적극적으로 사용한다. 특정 코드가 부팅 주소에 놓이도록 하거나, 읽기 전용 메모리와 RAM에 서로 다른 섹션을 배치하고, 초기화 데이터의 저장 위치와 실행 위치를 구분할 수 있다.
로더
로더는 실행 파일이나 라이브러리를 메모리에 배치하고 실행할 수 있는 상태로 만드는 운영체제 또는 실행 환경의 구성 요소다.
일반적인 사용자 공간 프로그램을 시작할 때 로더는 다음 작업을 수행할 수 있다.
- 실행 파일 헤더와 대상 아키텍처 검사
- 적재 가능한 세그먼트를 가상 주소 공간에 매핑
- 코드와 데이터에 읽기·쓰기·실행 권한 설정
- BSS와 같은 0 초기화 영역 생성
- 프로그램 인터프리터 또는 동적 링커 적재
- 공유 라이브러리 의존성 처리
- 필요한 동적 재배치 수행
- 초기 스택과 실행 환경 구성
- 프로그램의 진입점으로 제어 전달
ELF의 프로그램 헤더 테이블은 실행 파일 또는 공유 오브젝트를 프로세스 이미지로 만드는 데 필요한 세그먼트를 설명한다. 각 적재 세그먼트는 파일의 범위, 메모리 주소, 파일 크기, 메모리 크기와 권한을 지정할 수 있다.[62]
섹션과 세그먼트는 같은 개념이 아니다. 섹션은 주로 링커가 파일 내용을 분류하는 단위이고, 세그먼트는 로더가 메모리에 매핑하는 단위다. 하나의 적재 세그먼트 안에 여러 섹션이 포함될 수 있다.
로더는 파일의 데이터를 항상 별도의 메모리 버퍼에 전부 복사하지는 않는다. 가상 메모리와 파일 매핑을 이용하여 실행 파일의 페이지를 프로세스 주소 공간에 직접 연결하고, 실제 접근이 발생할 때 페이지를 읽어 들일 수 있다.
실행 파일의 파일 크기보다 메모리 크기가 큰 세그먼트는 남는 부분을 0으로 초기화할 수 있다. 이를 통해 BSS와 같이 초기값이 0인 큰 데이터 영역을 실행 파일에 실제 0바이트로 저장하지 않아도 된다.
위치 독립 실행 파일과 주소 공간 배치 무작위화를 사용하는 환경에서는 로더가 프로그램과 공유 라이브러리를 링크 시점의 기본 주소와 다른 위치에 배치할 수 있다. 이때 동적 재배치와 위치 독립 코드가 사용된다.
Windows 로더는 PE 이미지의 헤더와 섹션을 해석하여 실행 파일과 DLL을 프로세스에 매핑한다. PE에서 RVA는 이미지가 메모리에 적재된 기준 주소에 상대적인 위치를 나타내며, 로더는 실제 적재 기준 주소와 RVA를 결합하여 가상 주소를 얻는다.[63]
펌웨어와 독립 실행형 프로그램에서는 운영체제 로더 대신 부트 ROM, 부트로더 또는 펌웨어가 이미지를 적재한다. 일부 이미지는 고정된 물리 주소에 배치되며, 실행 중 사용할 메모리 구성을 링커 단계에서 미리 결정한다.
정적 링크와 동적 링크
정적 링크는 프로그램이 사용하는 라이브러리 코드와 데이터를 링크 시점에 실행 파일 안으로 포함하는 방식이다. 동적 링크는 실행 파일에 라이브러리 전체를 포함하지 않고, 실행 시 또는 프로그램의 요청 시 공유 라이브러리를 연결하는 방식이다.
정적 링크에서는 링커가 정적 라이브러리나 오브젝트 파일에서 필요한 코드를 선택하여 최종 실행 파일에 복사한다. 외부 심벌과 재배치의 대부분이 링크 시점에 해결되므로, 실행 시 해당 정적 라이브러리 파일이 별도로 필요하지 않다.
정적 링크의 특성은 다음과 같다.
- 필요한 코드가 실행 파일에 포함된다.
- 실행 환경의 공유 라이브러리 설치 상태에 덜 의존한다.
- 배포 단위를 하나의 파일로 만들기 쉽다.
- 여러 프로그램이 같은 라이브러리 코드를 각각 포함하면 저장 공간이 중복될 수 있다.
- 라이브러리를 수정해도 프로그램을 다시 링크하지 않으면 반영되지 않는다.
- 완전 정적 링크가 가능한지는 운영체제와 라이브러리 구조에 따라 달라진다.
동적 링크에서는 실행 파일에 필요한 공유 라이브러리의 이름과 심벌 참조 정보가 기록된다. 프로그램 실행 시 동적 링커가 라이브러리를 찾아 주소 공간에 매핑하고, 심벌과 재배치를 해결한다.
ELF 동적 링크 정보에는 필요한 공유 오브젝트, 동적 심벌 테이블, 문자열 테이블과 재배치 테이블의 위치가 포함될 수 있다. 동적 링커는 이 정보를 이용하여 실행 파일과 공유 오브젝트의 심벌을 연결한다.[64]
여러 프로세스가 같은 공유 라이브러리를 사용할 때 읽기 전용 코드 페이지를 물리 메모리에서 공유할 수 있다. 각 프로세스는 자신의 가상 주소 공간에 라이브러리를 매핑하지만, 변경되지 않는 페이지는 동일한 물리 메모리에 연결될 수 있다.
동적 링크는 다음 두 시점으로 나눌 수 있다.
- 프로그램이 시작될 때 필요한 라이브러리를 처리하는 적재 시 동적 링크
- 프로그램 실행 중 API를 호출하여 라이브러리를 여는 실행 시 동적 링크
Windows의 적재 시 동적 링크는 링커가 PE 파일에 기록한 DLL 이름과 가져오기 정보를 이용한다. 실행 시 동적 링크에서는 프로그램이 LoadLibrary 또는 LoadLibraryEx를 호출하여 DLL을 적재하고 GetProcAddress로 함수 주소를 얻을 수 있다.[65]
동적 심벌의 주소는 프로그램 시작 시 모두 해결할 수도 있고, 함수가 처음 호출될 때 지연 해결할 수도 있다. ELF 계열에서는 전역 오프셋 테이블과 프로시저 연결 테이블이 위치 독립 코드와 지연 심벌 해결에 사용될 수 있다.
동적 링크는 라이브러리를 교체하여 여러 프로그램의 구현을 함께 갱신할 수 있지만, 라이브러리의 ABI 호환성이 유지되어야 한다. 심벌 이름, 함수 호출 규약, 자료형 크기와 구조체 배치가 바뀌면 기존 바이너리와 호환되지 않을 수 있다.
정적 링크와 동적 링크는 하나의 실행 파일에서 함께 사용될 수 있다. 일부 라이브러리는 정적으로 포함하고 운영체제의 시스템 라이브러리는 동적으로 연결하는 구성이 가능하다.
실행 파일 형식
실행 파일 형식은 운영체제와 도구 체인이 기계 명령, 데이터, 적재 정보, 심벌과 부가 메타데이터를 해석하기 위한 바이너리 구조다. 단순히 파일 확장자가 실행 가능함을 나타내는 것이 아니라, 로더가 메모리 배치와 실행 시작에 필요한 규칙을 정의한다.
실행 파일 형식은 일반적으로 다음 정보를 포함한다.
- 파일 형식과 버전을 나타내는 식별 정보
- 대상 명령어 집합과 바이트 순서
- 실행 진입점
- 코드와 데이터의 파일 위치와 메모리 주소
- 각 영역의 읽기·쓰기·실행 권한
- 동적 라이브러리 의존 정보
- 심벌과 재배치 정보
- TLS와 예외 처리 정보
- 디버깅과 서명 관련 부가 정보
ELF
ELF(Executable and Linkable Format)는 Linux, 여러 BSD, System V 계열 UNIX와 여러 임베디드 환경에서 사용된다. ELF는 재배치 가능한 오브젝트 파일, 실행 파일, 공유 오브젝트와 코어 덤프를 표현할 수 있다.
ELF 파일의 주요 구조에는 ELF 헤더, 프로그램 헤더 테이블, 섹션과 섹션 헤더 테이블이 있다. 링크 도구는 섹션 중심의 구조를 사용하고, 로더는 프로그램 헤더가 정의하는 세그먼트를 중심으로 파일을 해석한다.[66]
ELF는 32비트와 64비트 클래스, 리틀 엔디언과 빅 엔디언 표현을 구분한다. 대상 아키텍처별 재배치 종류와 호출 규약의 세부 사항은 일반 ELF 규격과 별도의 프로세서별 ABI가 함께 정의한다.
PE와 COFF
COFF는 Windows 도구 체인에서 재배치 가능한 오브젝트 파일을 나타내는 기반 형식이고, PE(Portable Executable)는 실행 파일, DLL과 시스템 이미지에 사용되는 형식이다.
PE 파일은 DOS 호환 헤더, PE 서명, COFF 파일 헤더, 선택적 헤더, 데이터 디렉터리와 섹션 테이블 등의 구조를 가진다. 선택적 헤더라는 이름에도 불구하고 실행 가능한 PE 이미지에는 로더가 필요한 핵심 정보가 포함된다.
PE의 데이터 디렉터리는 가져오기 테이블, 내보내기 테이블, 기준 재배치, 리소스, 예외 정보, TLS와 디버깅 정보 같은 구조의 위치를 가리킨다.[67]
Mach-O
Mach-O는 macOS, iOS와 다른 Apple 플랫폼에서 사용하는 오브젝트와 실행 이미지 형식이다. Mach-O 파일은 헤더 뒤에 여러 적재 명령을 가지며, 각 적재 명령은 세그먼트, 동적 라이브러리 의존성, 심벌과 실행 진입 정보 등을 기술한다.
Mach-O는 여러 아키텍처용 이미지를 하나의 파일에 묶는 범용 바이너리 구조와 함께 사용될 수 있다. 운영체제와 동적 로더는 현재 아키텍처에 맞는 이미지를 선택하여 적재한다.
실행 파일 형식이 같아도 모든 시스템에서 동일한 바이너리를 실행할 수 있는 것은 아니다. 대상 ISA, 운영체제 ABI, 시스템 호출 규약, 동적 라이브러리와 파일 안의 플랫폼 정보가 모두 호환되어야 한다.
원시 바이너리와 펌웨어 형식
부트 섹터, 일부 커널 이미지, 마이크로컨트롤러 펌웨어와 ROM 이미지는 일반 실행 파일 헤더가 없는 원시 바이너리로 배포될 수 있다. 이러한 파일은 코드와 데이터의 배치 주소를 외부 규칙이나 장치 사양이 결정한다.
Intel HEX와 Motorola S-record는 주소와 데이터 바이트를 텍스트 레코드로 표현한다. 마이크로컨트롤러 플래시 기록기와 디버거가 이를 해석하여 지정된 주소에 데이터를 기록한다.
디버거와 디스어셈블러
디버거는 프로그램 실행을 제어하고 현재 상태를 조사하는 도구다. 중단점 설정, 한 명령씩 실행, 레지스터와 메모리 확인, 호출 스택 추적과 변수 검사가 대표적인 기능이다.
어셈블리 수준에서 디버거는 다음 정보를 다룬다.
- 현재 프로그램 카운터와 실행 명령
- 범용·부동소수점·벡터 레지스터 값
- 상태 레지스터와 조건 플래그
- 스택과 메모리 내용
- 함수 호출 프레임
- 심벌 이름과 소스 코드 위치
- 공유 라이브러리 적재 상태
- 예외, 신호와 중단점 상태
소스 수준 디버깅은 오브젝트 파일이나 실행 파일에 포함된 디버깅 정보에 의존한다. DWARF, CodeView와 같은 형식은 기계 명령 주소와 소스 파일의 줄, 함수, 변수, 자료형과 스택 위치의 관계를 기록한다.
최적화된 프로그램에서는 소스 코드와 기계 명령이 일대일로 대응하지 않는다. 함수가 인라인되고, 문장 순서가 변경되며, 변수가 레지스터에서 사라지거나 계산식으로 대체될 수 있다. 따라서 어셈블리와 레지스터를 함께 살펴봐야 정확한 실행 상태를 이해할 수 있다.
대표적인 디버거로는 GNU Debugger인 GDB, LLVM 프로젝트의 LLDB, Microsoft의 WinDbg와 Visual Studio Debugger가 있다. GDB는 실행 제어, 중단점, 레지스터·메모리 검사와 명령 디스어셈블 기능을 제공한다.[68]
디스어셈블러는 기계어 바이트를 해석하여 어셈블리 명령 형태로 표시하는 도구다. 어셈블러와 반대 방향의 작업을 수행하지만, 원래 소스 코드를 완전히 복원하지는 못한다.
어셈블 과정에서 사라질 수 있는 정보에는 다음이 있다.
- 원래 작성한 레이블과 주석
- 매크로와 의사 명령어의 형태
- 지역 변수와 고급 언어의 자료형
- 소스 수준 반복문과 조건문의 구조
- 함수와 파일의 원래 이름
- 어셈블러가 선택하기 전의 명령 별칭
심벌 테이블과 디버깅 정보가 남아 있으면 디스어셈블러는 함수와 심벌 이름, 소스 줄과 명령을 함께 표시할 수 있다. 이러한 정보가 제거된 바이너리에서는 주소와 추론된 명령만 표시되며 함수 경계와 데이터 영역을 판단하기 어려워질 수 있다.
GNU objdump는 오브젝트 파일의 헤더, 섹션, 심벌, 재배치와 기계 명령을 표시할 수 있다. LLVM의 llvm-objdump도 오브젝트 파일과 최종 링크된 이미지의 내용을 출력하고 디스어셈블 기능을 제공한다.[69][70]
디스어셈블러는 코드와 데이터를 항상 완벽하게 구분할 수 있는 것은 아니다. 가변 길이 명령어를 사용하는 아키텍처에서는 시작 위치를 잘못 선택하면 전혀 다른 명령열로 해석될 수 있으며, 코드 섹션 안에 점프 테이블이나 상수가 섞여 있을 수도 있다.
정적 디스어셈블은 파일을 실행하지 않고 분석하며, 동적 디버깅은 실제 실행 중인 명령과 상태를 관찰한다. 역공학, 성능 분석과 취약점 조사에서는 두 방식을 함께 사용한다.
어셈블러, 링커, 로더, 디버거와 디스어셈블러는 서로 독립된 도구처럼 보이지만 같은 오브젝트 파일 구조, ISA, 심벌, 재배치와 ABI 정보를 공유한다. 어셈블리 프로그램의 동작을 정확하게 이해하려면 소스 명령뿐 아니라 각 도구가 파일과 주소를 어떤 단계에서 어떻게 변환하는지를 함께 살펴봐야 한다.
문법과 방언
어셈블리어의 문법은 대상 명령어 집합 아키텍처뿐 아니라 사용하는 어셈블러, 운영체제, 오브젝트 파일 형식과 개발 도구 체인에 따라 달라진다. 같은 기계 명령을 생성하는 소스라도 명령어의 피연산자 순서, 레지스터 표기, 메모리 주소 표현, 지시어와 주석 문법이 서로 다를 수 있다.
따라서 x86 어셈블리어처럼 하나의 명령어 집합에 여러 문법이 존재하는 경우에는 아키텍처, 문법 계열, 어셈블러 구현을 구분해야 한다. Intel 문법과 AT&T 문법은 같은 x86 기계 명령을 서로 다른 방식으로 표현하는 문법 계열이며, NASM, MASM과 GNU Assembler는 각자의 지시어, 매크로 언어와 소스 구성 규칙을 제공하는 어셈블러다.
다른 아키텍처에서도 제조사 설명서의 표준 표기와 GNU Assembler, LLVM 또는 특정 공급자 어셈블러의 실제 문법 사이에 차이가 생길 수 있다. 어셈블리 소스의 이식성은 명령어 니모닉만 같다고 확보되는 것이 아니라, ISA 확장, 문법, 지시어, ABI, 오브젝트 파일 형식과 링커 규칙이 함께 맞아야 한다.
명령어 표기 방식
어셈블리 명령은 일반적으로 명령어 니모닉과 하나 이상의 피연산자로 구성된다. 기본 형태는 다음과 같이 나타낼 수 있다.
레이블: 명령어 피연산자, 피연산자
레이블, 피연산자와 주석이 없는 명령도 작성할 수 있고, 반대로 실행 명령 없이 레이블이나 어셈블러 지시어만 존재하는 문장도 가능하다.
명령어 표기에는 대체로 다음 요소가 포함된다.
- 수행할 연산을 나타내는 니모닉
- 목적지와 원본 레지스터
- 즉시값
- 메모리 주소 표현
- 피연산자의 크기
- 조건 코드와 명령어 접미사
- 명령어 집합 확장에 따른 추가 지정자
같은 ISA 안에서도 명령어의 피연산자 순서는 문법에 따라 달라질 수 있다. x86 Intel 문법은 일반적으로 목적지를 먼저 쓰고 원본을 뒤에 쓰지만, AT&T 문법은 원본을 먼저 쓰고 목적지를 마지막에 쓴다. GNU Assembler 문서는 이 차이를 Intel 문법과 AT&T 문법 사이의 주요 차이로 설명한다.[71]
예를 들어 레지스터 EBX의 값을 EAX로 복사하는 명령은 Intel 문법에서 다음과 같이 표현된다.
mov eax, ebx
같은 동작은 AT&T 문법에서 다음과 같이 표현된다.
movl %ebx, %eax
Intel 문법의 mov eax, ebx에서 첫 번째 피연산자 eax는 목적지이고 두 번째 피연산자 ebx는 원본이다. AT&T 문법의 movl %ebx, %eax에서는 첫 번째 피연산자가 원본이고 마지막 피연산자가 목적지다.
명령어 표기 차이는 단순히 순서만의 문제가 아니다. 레지스터와 즉시값에 붙는 접두사, 명령의 크기 표시, 메모리 피연산자와 간접 분기의 표기도 다르다.
명령어의 피연산자 크기는 다음 방법 가운데 하나 이상으로 표현될 수 있다.
- 레지스터 이름의 크기로 결정
- 명령어 니모닉의 접미사로 표시
- 메모리 피연산자 앞에 크기 지정자 사용
- 어셈블러가 다른 피연산자와 문맥에서 추론
- 별도의 아키텍처 또는 실행 모드로 결정
x86에서는 같은 니모닉이 여러 피연산자 크기를 지원하므로, 어셈블러가 사용할 정확한 명령 인코딩을 결정할 수 있어야 한다. 레지스터가 포함되어 있다면 AL, AX, EAX, RAX와 같은 이름에서 크기를 알 수 있지만, 메모리와 즉시값만 사용하는 명령은 별도의 크기 표기가 필요할 수 있다.
일부 명령은 피연산자를 소스에 명시하지 않더라도 ISA가 정한 레지스터나 상태를 암묵적으로 사용한다. x86의 일부 곱셈, 나눗셈, 문자열 처리와 스택 명령이 대표적인 사례다. 따라서 소스에 나타난 피연산자의 수가 명령이 실제로 읽고 변경하는 모든 상태의 수와 같지는 않을 수 있다.
아키텍처 설명서에서 사용하는 표기법도 실제 어셈블러가 받아들이는 문법과 완전히 같지 않을 수 있다. 명령어 참조는 가능한 피연산자 형식과 동작을 설명하기 위한 메타 문법을 사용할 수 있으며, 실제 소스에서는 특정 어셈블러의 규칙에 맞춰 작성해야 한다.
Intel 문법
Intel 문법은 Intel의 x86 명령어 문서와 MASM, NASM, FASM을 비롯한 여러 어셈블러와 디스어셈블러에서 사용하는 표기 계열이다. GNU Assembler와 LLVM 계열 도구도 Intel 문법 모드를 지원한다.
Intel 문법의 대표적인 특징은 다음과 같다.
- 목적지 피연산자를 먼저 작성한다.
- 레지스터 이름에 일반적으로 접두사를 붙이지 않는다.
- 즉시값에도 특별한 접두사를 붙이지 않는다.
- 메모리 주소 표현을 대괄호로 감싼다.
- 메모리 피연산자의 크기를
BYTE PTR,WORD PTR,DWORD PTR,QWORD PTR등으로 표시할 수 있다. - 명령어 크기는 레지스터나 크기 지정자를 통해 나타내는 경우가 많다.
두 레지스터를 더하는 명령은 다음과 같이 표현할 수 있다.
add eax, ebx
이는 EBX의 값을 EAX에 더하고 결과를 EAX에 저장한다.
메모리에서 32비트 값을 읽는 형식은 다음과 같이 표현할 수 있다.
mov eax, DWORD PTR [rbx + rcx * 4 + 8]
대괄호 안의 식은 유효 주소를 나타낸다. 이 예에서는 RBX + RCX × 4 + 8로 주소를 계산하고, 해당 위치에서 32비트 값을 읽어 EAX에 저장한다.
GNU Assembler의 문서는 Intel 문법의 메모리 참조를 section:[base + index * scale + disp] 형태로 설명하고, 이에 대응하는 AT&T 문법을 section:disp(base, index, scale) 형태로 나타낸다.[72]
Intel 문법에서 메모리 피연산자의 크기를 다른 피연산자에서 판단할 수 없는 경우에는 크기 지정자가 중요하다. 예를 들어 다음과 같은 표현에서는 메모리에 기록할 값의 크기를 명시해야 할 수 있다.
mov BYTE PTR [rax], 1
BYTE PTR은 주소 RAX가 가리키는 위치에 1바이트 값을 기록하도록 지정한다. 어셈블러에 따라 byte [rax]처럼 PTR을 생략하거나 다른 표기를 사용할 수도 있다.
Intel 문법이라는 이름이 하나의 완전히 통일된 소스 문법을 의미하지는 않는다. MASM과 NASM은 모두 Intel식 명령 표기를 사용하지만, 메모리 피연산자의 해석, 심벌 표현, 지시어, 매크로와 자료형 문법은 서로 다르다.
예를 들어 NASM은 대괄호가 있는 표현을 메모리 참조로 구분하고, 피연산자 크기를 byte, word, dword, qword 등의 키워드로 지정한다. NASM의 유효 주소 표현식은 덧셈, 뺄셈, 곱셈과 괄호를 사용할 수 있으며, 어셈블러가 표현식을 허용되는 x86 주소 형식으로 변환한다.[73]
MASM은 Intel식 명령 표기 외에도 자료형, 프로시저, 세그먼트, 구조체와 고수준 제어 지시어를 포함하는 자체 문법을 제공한다. Microsoft는 MASM에 반복, 산술과 문자열 처리를 포함하는 매크로 언어가 있다고 설명한다.[74]
GNU Assembler에서는 .intel_syntax 지시어로 Intel 문법을 선택할 수 있다. prefix 또는 noprefix 선택을 통해 레지스터 이름에 % 접두사를 요구할지 지정할 수 있으며, .att_syntax로 다시 AT&T 문법으로 전환할 수 있다.[75]
GNU Assembler의 Intel 모드는 NASM이나 MASM 전체 문법을 구현한다는 뜻은 아니다. 명령어 표기 방식은 Intel식으로 바뀌지만, 섹션, 심벌, 매크로와 오브젝트 파일을 다루는 지시어는 기본적으로 GNU Assembler의 체계를 따른다.
AT&T 문법
AT&T 문법은 AT&T UNIX 계통의 x86 어셈블러에서 형성된 표기 방식이다. GNU Compiler Collection이 생성하는 전통적인 x86 어셈블리 출력과 GNU Assembler의 기본 x86 문법으로 널리 사용되었다.
AT&T 문법의 대표적인 특징은 다음과 같다.
- 원본 피연산자를 먼저 작성하고 목적지를 마지막에 쓴다.
- 레지스터 이름 앞에
%를 붙인다. - 즉시값 앞에
$를 붙인다. - 명령어 니모닉에 피연산자 크기 접미사를 붙일 수 있다.
- 메모리 주소를
변위(기준, 인덱스, 배율)형식으로 표현한다. - 간접 호출과 간접 분기에
*를 사용할 수 있다.
다음 명령은 값 1을 EAX에 기록한다.
movl $1, %eax
여기서 movl의 l은 32비트 크기를 나타내고, $1은 즉시값 1, %eax는 레지스터 피연산자다.
AT&T 문법에서 전통적으로 사용되는 명령어 크기 접미사는 다음과 같다.
| 접미사 | 일반적인 크기 |
|---|---|
b | 8비트 바이트 |
w | 16비트 워드 |
l | 32비트 롱 |
q | 64비트 쿼드워드 |
s | 단정도 부동소수점 |
l | 일부 부동소수점 문맥에서 배정도 |
t | 확장 정밀도 부동소수점 |
접미사의 정확한 의미는 명령 계열과 문맥에 따라 달라질 수 있다. GNU Assembler는 일부 경우 피연산자에서 크기를 추론하여 접미사 생략을 허용하지만, 모호한 코드를 피하려면 필요한 크기를 명확히 작성하는 편이 적합하다.[76]
AT&T 문법의 메모리 주소는 다음과 같은 일반 형식을 사용한다.
변위(기준 레지스터, 인덱스 레지스터, 배율)
예를 들어 다음 명령은 RBX + RCX × 4 + 8 주소에서 32비트 값을 읽어 EAX에 저장한다.
movl 8(%rbx,%rcx,4), %eax
Intel 문법의 대응 표현은 다음과 같다.
mov eax, DWORD PTR [rbx + rcx * 4 + 8]
기준, 인덱스 또는 변위가 필요하지 않으면 해당 부분을 생략할 수 있다. 예를 들어 (%rax)는 RAX가 가리키는 메모리 위치를 나타내며, 8(%rax)는 RAX + 8을 나타낸다.
AT&T 문법에서는 $symbol과 symbol이 서로 다른 의미를 가질 수 있다. $symbol은 심벌의 주소나 값을 즉시 피연산자로 사용하고, 접두사 없는 symbol은 문맥에 따라 해당 심벌이 가리키는 메모리 내용을 참조할 수 있다.
간접 호출이나 분기에서도 표기가 다르다. 다음 명령은 RAX에 저장된 주소로 간접 호출한다.
call *%rax
GNU Assembler는 Intel 문법과 AT&T 문법의 차이로 레지스터·즉시값 접두사, 피연산자 순서, 메모리 표기와 명령 크기 접미사를 설명한다.[77]
AT&T 문법은 x86에서 주로 논의되지만, GNU Assembler가 지원하는 모든 아키텍처가 x86 AT&T 문법의 특징을 그대로 사용하는 것은 아니다. %, $, 피연산자 순서와 크기 접미사는 x86용 GNU Assembler 문법의 특성으로 보아야 한다.
어셈블러별 확장
어셈블러는 ISA의 실제 명령어를 표현하는 기능 외에도 소스 구성, 데이터 정의, 심벌 관리, 매크로, 조건부 어셈블리와 오브젝트 파일 생성을 위한 자체 확장을 제공한다.
이러한 확장은 프로세서가 실행하는 명령이 아니라 어셈블 시점에 처리된다. 같은 기계 명령을 생성하더라도 사용하는 지시어와 매크로가 다르면 다른 어셈블러에서 그대로 처리되지 않을 수 있다.
GNU Assembler
GNU Assembler는 점으로 시작하는 지시어를 광범위하게 사용한다. 대표적으로 다음 기능이 있다.
.text,.data,.bss,.section을 통한 섹션 선택.byte,.word,.long,.quad등의 데이터 생성.globl,.local,.weak,.hidden등의 심벌 속성 지정.align,.balign,.p2align을 통한 정렬.macro,.endm,.rept,.irp등의 매크로와 반복.if,.ifdef,.else,.endif등의 조건부 어셈블리.type,.size등을 통한 ELF 심벌 정보 생성.cfi_*지시어를 통한 호출 프레임과 스택 되감기 정보 생성
GNU Assembler의 지시어에는 여러 아키텍처에서 공통으로 사용하는 기계 독립 지시어와 특정 대상에서만 제공되는 기계 종속 지시어가 있다.[78]
NASM
NASM은 x86과 x86-64를 대상으로 하는 어셈블러이며 Intel식 명령 표기와 자체 지시어·전처리기를 사용한다.
NASM의 주요 확장에는 다음이 포함된다.
BITS를 통한 16비트·32비트·64비트 모드 선택SECTION또는SEGMENT를 통한 섹션 선택DB,DW,DD,DQ등의 데이터 정의RESB,RESW,RESD,RESQ등의 저장 공간 예약GLOBAL,EXTERN을 통한 외부 심벌 관리EQU를 통한 상수 정의%define,%macro,%if,%include등의 전처리 기능DEFAULT REL과ABS·REL을 통한 주소 해석 제어
NASM의 전처리기는 단일 행 매크로, 여러 행 매크로, 조건부 처리, 반복, 문자열 조작과 문맥 지역 매크로 기능을 제공한다.[79]
NASM에서 $는 현재 어셈블 위치를 나타내고, $$는 현재 섹션의 시작을 나타내는 특수 토큰으로 사용된다. 이는 AT&T 문법에서 $가 즉시값 접두사로 사용되는 것과 다른 의미다.[80]
MASM
MASM은 Microsoft의 x86 및 x64 어셈블러 계열이다. Intel식 명령 표기를 사용하면서 Windows 개발 환경, Microsoft 오브젝트 파일과 호출 규약을 위한 지시어를 제공한다.
MASM은 다음과 같은 확장을 제공한다.
.CODE,.DATA,.CONST,.DATA?등의 단순화된 섹션 지시어PROC,ENDP를 통한 프로시저 선언PROTO,INVOKE를 통한 함수 원형과 호출 지원STRUCT,UNION,RECORD,TYPEDEF등의 자료 구조 선언MACRO,REPT,IRP,IRPC등의 매크로·반복IF,IFDEF,ELSE,ENDIF등의 조건부 어셈블리INCLUDE,INCLUDELIB를 통한 소스와 라이브러리 지정- x64 예외 처리 정보를 생성하는
.PUSHREG,.ALLOCSTACK,.ENDPROLOG등의 지시어
Microsoft의 MASM 지시어 참조에는 코드·데이터 섹션, 프로시저, 조건부 어셈블리, 매크로, 구조체와 x64 스택 되감기 정보를 위한 지시어가 정의되어 있다.[81]
32비트 MASM과 x64용 ml64는 지원하는 문법과 기능이 완전히 같지 않다. 특히 32비트용 고수준 함수 호출 지시어나 인라인 어셈블리 기능이 x64 환경에서는 다르게 처리되거나 제공되지 않을 수 있다.
LLVM Integrated Assembler
LLVM Integrated Assembler는 Clang과 LLVM 도구 체인 내부에서 어셈블리 소스를 처리하고 직접 오브젝트 파일을 생성할 수 있다. GNU Assembler와 호환되는 문법을 폭넓게 지원하지만, 모든 GNU 확장과 진단 동작이 동일한 것은 아니다.
컴파일러가 생성한 어셈블리 코드가 특정 GNU Assembler 기능에 의존하거나, 반대로 LLVM 전용 지시어를 사용하면 다른 어셈블러에서 처리되지 않을 수 있다. 빌드 시스템은 사용할 어셈블러와 대상 삼중항, 오브젝트 형식을 명확히 지정해야 한다.
대소문자와 식별자
어셈블리어에서 식별자는 레이블, 함수, 변수, 상수, 매크로와 섹션 등에 붙이는 이름이다. 허용되는 문자, 첫 글자 규칙, 최대 길이와 대소문자 구분 여부는 어셈블러마다 다르다.
명령어 니모닉과 레지스터 이름은 많은 어셈블러에서 대소문자를 구분하지 않는다. 따라서 MOV, mov, Mov가 같은 명령으로 해석될 수 있다. 그러나 심벌과 사용자 정의 식별자는 어셈블러와 오브젝트 파일 형식에 따라 대소문자를 구분할 수 있다.
GNU Assembler의 심벌 이름은 일반적으로 문자, 숫자, 밑줄과 특정 특수 문자를 포함할 수 있으며, 숫자로 시작하는 이름은 지역 레이블 문법과 충돌할 수 있다. 심벌 이름의 정확한 문자 규칙은 대상과 설정에 따라 확장될 수 있다.[82]
GNU Assembler에서는 1:, 2:와 같은 숫자 지역 레이블을 정의하고 1b, 1f처럼 가장 가까운 이전 또는 다음 정의를 참조할 수 있다. b는 backward, f는 forward를 뜻한다. 같은 숫자 레이블을 한 파일 안에서 여러 번 재사용할 수 있어 짧은 분기 대상에 활용된다.[83]
NASM의 식별자는 문자, 숫자, _, $, #, @, ~, ., ? 등의 문자를 포함할 수 있으며, 첫 글자로 숫자를 사용할 수 없다. $와 $$는 단독으로 특수 의미를 가지지만 더 긴 식별자 안에서는 문자로 사용할 수 있다.[84]
NASM에서는 점으로 시작하는 레이블을 이전의 점 없는 레이블에 종속되는 지역 레이블로 사용할 수 있다. 예를 들어 함수 레이블 아래의 .loop는 다른 함수 아래의 .loop와 별개의 완전한 이름으로 처리된다.
MASM은 예약어를 식별자로 사용할 수 없으며, 명령어, 레지스터, 자료형과 지시어 이름이 예약어에 포함된다. Microsoft의 MASM 오류 설명도 C나 SIZE와 같은 예약어를 식별자로 사용하면 문법 오류가 발생할 수 있다고 설명한다.[85]
식별자 대소문자의 처리 방식은 소스 언어뿐 아니라 링커와 대상 플랫폼에도 영향을 받는다. 한 오브젝트 파일에서 Function을 정의하고 다른 파일에서 function을 참조했을 때, 심벌 이름을 대소문자 구분하여 처리하는 환경에서는 서로 다른 심벌이 된다.
C나 C++ 같은 다른 언어와 연결할 때는 소스 이름이 오브젝트 파일의 심벌 이름으로 어떻게 변환되는지도 고려해야 한다. C++ 이름 맹글링, 호출 규약에 따른 접두사, 32비트 Windows의 장식된 이름과 심벌 가시성 규칙 때문에 소스에 적힌 함수명과 실제 링크 심벌이 다를 수 있다.
주석과 소스 구성
주석은 어셈블러가 실행 코드나 데이터로 변환하지 않는 설명 텍스트다. 주석 기호와 적용 범위는 어셈블러와 대상 아키텍처 문법에 따라 달라진다.
GNU Assembler의 x86 문법에서는 #이 일반적으로 줄 끝까지 이어지는 주석을 시작한다. 줄 첫 글자의 #은 전처리기 지시어나 논리적 줄 번호 정보로 해석될 가능성도 있다. 세미콜론은 x86 GNU Assembler에서 문장 구분자로 사용될 수 있으므로 다른 Intel식 어셈블러처럼 항상 주석 기호라고 가정할 수 없다.[86]
NASM은 세미콜론 ;부터 줄 끝까지를 주석으로 처리한다. 명령과 같은 줄의 뒤쪽에 주석을 작성하거나 주석만 있는 줄을 사용할 수 있다.[87]
MASM에서도 세미콜론은 일반적인 한 줄 주석의 시작 기호로 사용된다. 또한 COMMENT 지시어를 이용하여 선택한 구분 문자 사이의 여러 줄을 주석으로 처리할 수 있다.[88]
다른 아키텍처의 GNU Assembler 문법에서는 #, ;, !, @ 등의 문자가 주석 또는 문장 구분에 사용될 수 있다. 예를 들어 어떤 Arm 문법에서 #은 즉시값 표시와 관련될 수 있으므로, x86 GNU Assembler의 주석 규칙을 그대로 적용할 수 없다.
소스 파일은 일반적으로 다음과 같은 단위로 구성된다.
- 대상 아키텍처와 문법 모드 지정
- 포함 파일과 상수 정의
- 코드·데이터 섹션 선언
- 외부 심벌과 공개 심벌 선언
- 함수와 지역 레이블
- 정적 데이터와 문자열
- 디버깅·예외 처리 정보 지시어
- 파일 종료 또는 부가 메타데이터
소스 구성을 나누는 방식도 어셈블러마다 다르다. GNU Assembler는 .include 지시어나 외부 전처리기의 #include를 사용할 수 있고, NASM은 %include, MASM은 INCLUDE를 사용한다. MASM의 INCLUDE 지시어는 지정한 소스 파일을 현재 파일에 삽입한다.[89]
소스 파일 확장자도 도구 체인에 의미를 가질 수 있다. UNIX 계열 빌드에서는 .s를 전처리되지 않은 어셈블리 파일, .S를 C 전처리기를 거치는 어셈블리 파일로 다루는 관례가 있다. .asm은 NASM, MASM과 여러 독립 어셈블러에서 널리 사용되지만, 확장자 자체가 정확한 문법을 보장하지는 않는다.
하나의 소스 파일 안에서 Intel 문법과 AT&T 문법을 전환할 수 있는 어셈블러도 있다. GNU Assembler의 .intel_syntax와 .att_syntax가 대표적이지만, 문법을 자주 혼합하면 피연산자 순서와 주소 해석을 혼동하기 쉬우므로 전환 범위를 명확히 해야 한다.
이식성과 호환성
어셈블리 소스의 이식성은 여러 층으로 나누어 평가해야 한다. 같은 명령어 니모닉이 보인다고 해서 다른 프로세서, 운영체제나 어셈블러에서 그대로 사용할 수 있는 것은 아니다.
어셈블리 프로그램의 호환성에는 다음 요소가 영향을 준다.
- 대상 명령어 집합 아키텍처
- 활성화된 ISA 확장
- 16비트·32비트·64비트 실행 모드
- 어셈블리 문법과 피연산자 표기
- 어셈블러 지시어와 매크로 언어
- 호출 규약과 ABI
- 운영체제의 시스템 호출 인터페이스
- 오브젝트 파일과 실행 파일 형식
- 링커와 로더의 심벌·재배치 규칙
- 바이트 순서와 데이터 정렬
- 사용한 프로세서 기능의 실제 지원 여부
가장 큰 이식성 경계는 ISA다. x86-64 기계 명령은 AArch64나 RISC-V 프로세서에서 직접 실행할 수 없으므로, 알고리즘을 새로운 ISA의 명령과 레지스터 구조로 다시 구현해야 한다.
같은 ISA에서도 실행 모드가 다르면 사용할 수 있는 레지스터, 주소 크기와 명령어가 달라진다. x86의 16비트, 32비트와 64비트 코드는 서로 다른 기본 피연산자 크기, 주소 지정과 ABI를 사용한다.
ISA가 같아도 문법이 다르면 소스를 변환해야 한다. Intel 문법과 AT&T 문법 사이에서는 피연산자 순서, 레지스터·즉시값 접두사, 명령어 크기와 메모리 표현을 바꿔야 한다.
어셈블러 사이의 차이는 단순한 문자 변환보다 크다. NASM의 %macro, GNU Assembler의 .macro, MASM의 MACRO는 모두 매크로를 정의하지만 매개변수, 지역 이름, 반복과 조건 처리 문법이 다르다. 섹션 선언과 심벌 가시성 지시어도 서로 호환되지 않는다.
GNU Assembler가 Intel 문법을 지원한다고 해서 NASM 소스가 그대로 호환되는 것은 아니다. .intel_syntax는 주로 명령어와 피연산자 표기를 전환하며, NASM의 %include, %define, SECTION, GLOBAL과 같은 전처리·지시어 체계까지 제공하지는 않는다.
운영체제가 다르면 같은 ISA와 어셈블러를 사용해도 시스템 호출과 실행 환경이 달라진다. x86-64 Linux와 x64 Windows는 레지스터 집합은 공유하지만 함수 인수 전달, 스택 사용, 시스템 호출, 실행 파일 형식과 동적 라이브러리 구조가 다르다.
ABI 호환성은 다른 언어와 함수를 연결할 때 특히 중요하다. 함수가 사용하는 인수 레지스터, 반환값 위치, 보존 레지스터, 스택 정렬, 구조체 반환과 예외 처리 정보를 맞추지 않으면 링크가 성공해도 실행 중 잘못된 결과가 발생할 수 있다.
오브젝트 파일 형식도 호환성을 제한한다. ELF 오브젝트 파일을 Windows PE/COFF 링커가 일반적으로 직접 처리할 수 없고, Mach-O 파일은 ELF 기반 도구 체인의 기본 입력이 아니다. 같은 명령어 바이트를 포함하더라도 섹션, 심벌과 재배치 표현 방식이 다르다.
프로세서 확장 명령을 사용하면 같은 ISA 계열 안에서도 실행 가능 범위가 줄어든다. AVX-512, Arm SVE 또는 특정 RISC-V 확장을 사용한 바이너리는 해당 기능을 구현하지 않은 프로세서에서 실행할 수 없다. 어셈블러가 명령을 인식하고 파일을 생성할 수 있다는 사실은 실행할 프로세서가 그 명령을 지원한다는 뜻이 아니다.
소스의 이식성을 높이기 위해 다음과 같은 구조가 사용된다.
- 아키텍처별 소스를 별도 파일이나 디렉터리로 분리
- 공통 인터페이스와 ABI를 고급 언어로 정의
- 전처리 조건으로 ISA와 운영체제별 구현 선택
- 어셈블러 종속 매크로를 얇은 호환 계층으로 제한
- 특정 명령 확장에 대한 대체 구현 제공
- 런타임 기능 검사를 통해 최적화 구현 선택
- 시스템 호출을 직접 사용하지 않고 런타임 또는 C ABI를 경유
- 생성된 오브젝트 파일과 디스어셈블 결과를 대상별로 검증
한 어셈블러 안에서도 버전 차이로 새 명령, 지시어와 진단 규칙이 추가될 수 있다. 새 ISA 확장을 사용하는 소스는 오래된 어셈블러에서 인식되지 않을 수 있으며, 특정 구현의 비표준 확장에 의존한 소스는 다른 도구로 옮기기 어렵다.
따라서 어셈블리 소스의 대상은 단순히 “x86-64”처럼 ISA만 표시하기보다 “x86-64 System V ABI, ELF, GNU Assembler AT&T 문법” 또는 “x64 Windows ABI, PE/COFF, MASM 문법”처럼 구체적으로 정의하는 것이 적합하다. 이 구분을 명확히 하면 같은 기계 명령을 사용하더라도 어떤 문법, 도구 체인과 실행 환경을 전제로 하는지 판단할 수 있다.
주요 명령어 집합 계열
어셈블리어는 하나의 공통 언어가 아니라, 서로 다른 명령어 집합 아키텍처를 사람이 작성할 수 있는 기호로 표현한 언어군이다. 따라서 각 명령어 집합 계열은 사용할 수 있는 명령, 레지스터 구성, 주소 지정 방식, 명령어 인코딩, 예외 처리와 특권 구조가 서로 다르며, 그에 대응하는 어셈블리어도 별도의 언어에 가깝다.
명령어 집합은 일반적으로 하나의 최초 설계에서 시작해 하위 호환성을 유지하면서 확장되거나, 여러 실행 모드와 선택적 확장을 포함하는 계열로 발전한다. x86은 16비트 명령어 집합에서 32비트와 64비트 환경으로 확장되었고, Arm은 AArch32와 AArch64 실행 상태를 포함하는 여러 프로파일로 발전하였다. RISC-V는 작은 기본 명령어 집합 위에 표준 확장을 조합하는 구조를 채택한다.
같은 계열 안에서도 모든 프로세서가 동일한 명령을 지원하는 것은 아니다. SIMD, 벡터, 암호화, 가상화와 원자적 연산 확장은 세대와 구현에 따라 달라질 수 있다. 어셈블리 프로그램은 기본 ISA뿐 아니라 대상 프로세서가 구현하는 확장, 운영체제 ABI와 실행 모드를 함께 지정해야 한다.
x86과 x86-64
x86은 Intel 8086에서 시작된 가변 길이 명령어 집합 아키텍처 계열이다. 초기 16비트 8086과 8088에서 출발하여 80286의 보호 모드, 80386의 32비트 IA-32, 부동소수점·멀티미디어·SIMD 확장을 거쳐 64비트 x86-64로 발전하였다.
x86이라는 이름은 8086, 80186, 80286, 80386과 80486처럼 초기 프로세서 이름이 86으로 끝난 것에서 유래하였다. 이후 제품 이름은 Pentium과 Core 등으로 바뀌었지만, 명령어 집합 계열을 가리키는 이름으로 x86이 계속 사용되었다.
x86 명령은 1바이트의 단순한 명령부터 여러 접두사, 연산 코드, ModR/M 바이트, SIB 바이트, 변위와 즉시값이 결합된 긴 명령까지 가변 길이로 인코딩된다. Intel 64와 IA-32에서는 명령이 최대 15바이트 길이를 가질 수 있으며, 명령 형식과 피연산자 종류에 따라 서로 다른 인코딩을 사용한다.[90]
전통적인 x86은 산술 명령의 목적지 피연산자가 입력과 출력 역할을 함께 하는 2피연산자 구조를 많이 사용한다. 또한 일부 명령은 누산기, 카운터, 데이터 레지스터나 스택 포인터처럼 정해진 레지스터를 암묵적으로 사용한다.
x86은 다음과 같은 특성을 가진다.
- 가변 길이 명령어 인코딩
- 레지스터와 메모리를 피연산자로 사용할 수 있는 여러 명령
- 기준 레지스터, 인덱스, 배율과 변위를 조합하는 주소 지정
- 상태 플래그를 사용하는 비교와 조건 분기
- 전용 스택, 호출과 반환 명령
- 이전 세대와의 광범위한 바이너리 호환성
- 여러 세대에 걸쳐 추가된 SIMD와 시스템 확장
32비트 IA-32에서는 EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP 등의 범용 레지스터를 사용한다. 16비트 레지스터와 일부 8비트 하위 레지스터에도 별도의 이름으로 접근할 수 있다.
x86-64는 AMD가 설계한 x86의 64비트 확장으로, AMD64라는 이름으로 처음 규격화되었다. Intel은 호환되는 아키텍처를 Intel 64라는 이름으로 구현하였다. 운영체제와 도구 체인에서는 x86-64, AMD64, x64와 x86_64 등의 이름이 사용된다.
x86-64는 기존 범용 레지스터를 64비트로 확장하고 R8부터 R15까지 추가하여 일반적인 64비트 실행 환경에서 16개의 범용 레지스터를 제공한다. 64비트 가상 주소와 정수 연산, RIP 상대 주소 지정, 확장된 SIMD 레지스터 사용과 새로운 호출 규약을 지원한다. AMD의 공식 프로그래머 설명서는 AMD64의 응용 프로그래밍, 시스템 프로그래밍과 명령어 체계를 여러 권으로 정의한다.[91]
x86-64의 긴 모드는 64비트 모드와 호환성 모드로 구성된다. 64비트 운영체제는 호환성 모드를 이용해 일정 조건에서 기존 32비트 응용 프로그램을 실행할 수 있지만, 16비트 실모드 프로그램과 오래된 운영체제 인터페이스까지 모두 직접 호환하는 것은 아니다.
x86 계열은 MMX, SSE, AVX, AVX2와 AVX-512 등 여러 SIMD 확장을 추가해 왔다. AVX 계열은 VEX와 EVEX 접두사를 사용하여 더 많은 레지스터와 벡터 길이, 3피연산자 형태와 마스킹 기능 등을 표현한다. 어떤 확장을 사용할 수 있는지는 프로세서와 운영체제의 상태 저장 지원을 런타임에서 확인해야 한다.
현대 x86 프로세서는 복잡한 기계 명령을 내부적으로 더 작은 마이크로 연산으로 변환하여 실행할 수 있다. 그러나 이러한 내부 구현은 ISA에 직접 노출되지 않으며, 어셈블리 프로그램은 Intel 64 또는 AMD64가 정의한 논리적 명령과 레지스터를 대상으로 작성한다.
x86과 x86-64는 개인용 컴퓨터, 워크스테이션과 서버에서 널리 사용된다. BIOS·UEFI 부팅 코드, 운영체제 커널, 하이퍼바이저, 드라이버, 암호화 라이브러리, 성능 최적화 코드와 바이너리 분석에서 x86 어셈블리어가 사용된다.
Arm과 AArch64
Arm은 Acorn에서 시작되어 Arm이 개발·관리하는 RISC 명령어 집합 계열이다. Arm은 프로세서 제품 하나의 이름이 아니라 여러 세대와 프로파일, 실행 상태와 선택적 확장을 포함하는 아키텍처 계열이다.
Arm 아키텍처는 용도에 따라 대체로 다음 프로파일로 나뉜다.
- 운영체제와 가상 메모리를 사용하는 고성능 시스템을 위한 A 프로파일
- 실시간 시스템을 위한 R 프로파일
- 마이크로컨트롤러와 소형 임베디드 시스템을 위한 M 프로파일
프로파일에 따라 명령어 집합, 특권 모델, 예외 처리, 메모리 관리와 실행 환경이 달라진다. 따라서 Cortex-A, Cortex-R과 Cortex-M용 코드를 모두 하나의 동일한 Arm 어셈블리어로 취급할 수는 없다.
32비트 Arm 환경에서는 A32와 T32 명령어 집합이 사용된다. A32는 전통적인 32비트 Arm 명령어 형식이며, T32는 Thumb과 Thumb-2에서 발전한 16비트와 32비트 명령이 혼합된 명령어 집합이다. T32는 코드 밀도를 높이면서도 일반적인 프로그래밍 기능을 제공한다.
AArch64는 Armv8-A에서 도입된 64비트 실행 상태이며, 여기에서 사용하는 명령어 집합을 A64라고 한다. Armv9-A도 AArch64와 A64 명령어 집합을 기반으로 확장된다. Arm의 A 프로파일 아키텍처 설명서는 Armv8과 Armv9의 실행 상태, 명령어, 시스템 레지스터와 메모리 구조를 정의한다.[92]
A64 명령은 기본적으로 32비트 고정 길이로 인코딩된다. 64비트 정수 범용 레지스터 X0부터 X30까지를 제공하며, 같은 레지스터의 하위 32비트는 W0부터 W30으로 접근한다. 별도의 스택 포인터와 제로 레지스터 의미가 명령 문맥에 따라 레지스터 번호 31에 연결된다.
AArch64의 일반 산술 명령은 목적지와 두 원본을 구분하는 3피연산자 형식을 많이 사용한다. 메모리 접근은 LDR, STR 등의 적재·저장 명령으로 수행하며, 일반적인 산술 명령은 메모리 피연산자를 직접 처리하지 않는 로드·스토어 구조를 따른다.
AArch64는 모든 산술 명령이 조건 플래그를 변경하지 않는다. ADD와 SUB는 일반적으로 플래그를 유지하고, ADDS와 SUBS처럼 S가 붙는 명령이 N, Z, C, V 조건 플래그를 갱신한다.
A32의 여러 명령은 조건 코드 필드를 이용해 조건부 실행될 수 있었지만, A64는 일반적인 조건 분기, 조건부 선택과 일부 조건부 명령을 중심으로 더 단순한 구조를 사용한다.
Arm 계열은 NEON이라고도 불리는 Advanced SIMD, 부동소수점, 암호화, 원자적 연산과 가상화 확장을 제공한다. AArch64의 SVE와 SVE2는 구현마다 다른 벡터 길이를 허용하고, 소프트웨어가 특정 길이에 고정되지 않는 벡터 길이 독립 프로그래밍 모델을 지원한다.
Arm 명령어 집합은 스마트폰과 태블릿, 임베디드 시스템, 네트워크 장비, 단일 보드 컴퓨터, 개인용 컴퓨터와 서버에 사용된다. 같은 AArch64 ISA를 사용하더라도 Apple 플랫폼, Linux, Windows와 펌웨어 환경은 서로 다른 ABI와 시스템 인터페이스를 사용할 수 있다.
RISC-V
RISC-V는 캘리포니아 대학교 버클리에서 시작된 개방형 표준 명령어 집합 아키텍처다. 이름의 V는 버클리에서 설계한 다섯 번째 주요 RISC ISA 계통이라는 의미에서 붙었다.
RISC-V는 하나의 고정된 거대한 명령어 집합 대신 작은 기본 정수 ISA와 독립된 표준 확장을 조합하는 모듈식 구조를 사용한다. RISC-V International은 비특권 명령어와 특권 아키텍처를 공개 규격으로 관리한다. 공식 규격은 RISC-V를 확립된 RISC 원칙을 기반으로 하는 개방형 표준 ISA라고 정의한다.[93]
기본 정수 ISA에는 다음과 같은 계열이 있다.
- 32비트 주소와 정수 레지스터를 사용하는 RV32I
- 64비트 주소와 정수 레지스터를 사용하는 RV64I
- 더 큰 정수 레지스터 폭을 위한 RV128I 설계 범주
- 레지스터 수를 줄인 임베디드 기본 ISA인 RV32E와 RV64E
I 기본 명령어 집합은 정수 계산, 분기, 적재·저장과 기본적인 제어 흐름을 제공한다. 곱셈, 원자적 연산, 부동소수점, 압축 명령과 벡터 기능 등은 별도의 확장으로 추가된다.
대표적인 표준 확장에는 다음이 있다.
M: 정수 곱셈과 나눗셈A: 원자적 메모리 연산F: 단정도 부동소수점D: 배정도 부동소수점C: 압축 명령V: 벡터 명령B: 비트 조작Zicsr: 제어·상태 레지스터 명령Zifencei: 명령어 가져오기 동기화- 암호화, 조건부 연산과 캐시 관리 등을 위한 여러
Z·Zv확장
과거에는 IMAFD 조합을 G라는 약칭으로 표현했지만, 실제 플랫폼은 필요한 추가 확장과 ABI를 더 구체적으로 정의한다. 최신 RISC-V 규격은 기본 ISA와 각 확장을 별도의 버전과 상태로 관리한다.[94]
RV32I와 RV64I는 x0부터 x31까지 32개의 정수 레지스터를 정의하며, x0은 항상 0을 읽고 쓰기 결과를 버린다. 정수 명령은 3피연산자 구조를 중심으로 하며, 적재·저장 명령만 일반 메모리에 접근한다.
기본 RISC-V 명령은 32비트 길이를 사용한다. C 확장은 자주 사용되는 동작을 16비트 압축 명령으로 표현하여 코드 크기를 줄인다. 명령 길이 인코딩 체계는 더 긴 명령 형식으로 확장될 여지도 제공한다.
RISC-V의 특권 아키텍처는 머신, 감독자와 사용자 실행 환경, 예외·인터럽트, 제어·상태 레지스터와 가상 메모리 방식을 정의한다. 운영체제를 실행하는 구현에서는 Sv32, Sv39, Sv48과 같은 페이지 기반 주소 변환 방식을 사용할 수 있다.[95]
RISC-V는 ISA 규격을 사용할 때 특정 기업의 독점 명령어 집합 라이선스를 요구하지 않는다는 점에서 개방형으로 불린다. 다만 특정 프로세서 코어, SoC 설계, 상표, 특허 구현과 개발 도구가 모두 자동으로 자유 소프트웨어나 무료가 되는 것은 아니다.
RISC-V는 마이크로컨트롤러, 저장 장치 제어기, 인공지능 가속기 보조 프로세서, 임베디드 Linux 시스템과 고성능 프로세서까지 여러 범위에서 사용된다. 서로 다른 구현의 소프트웨어 호환성을 확보하려면 ISA 문자열뿐 아니라 프로파일, 플랫폼 규격과 ABI를 함께 맞춰야 한다.
MIPS
MIPS는 1980년대 스탠퍼드 대학교의 MIPS 연구와 MIPS Computer Systems에서 발전한 RISC 명령어 집합 계열이다. MIPS라는 이름은 Microprocessor without Interlocked Pipeline Stages에서 유래하였다.
초기 MIPS 설계는 단순한 명령어 형식, 로드·스토어 구조와 컴파일러가 효율적으로 활용할 수 있는 레지스터 중심 실행 모델을 강조하였다. 이후 MIPS I부터 MIPS V, MIPS32와 MIPS64, 여러 임베디드 확장으로 발전하였다.
전통적인 MIPS 명령은 대부분 32비트 고정 길이를 사용한다. 기본 정수 프로그래밍 모델은 32개의 범용 레지스터를 제공하며, 레지스터 0은 항상 0 값을 나타낸다.
MIPS 산술 명령은 대체로 목적지와 두 원본 레지스터를 구분하는 3피연산자 형식을 사용한다. 메모리는 적재·저장 명령을 통해서만 일반적으로 접근하며, 주소는 기준 레지스터와 즉시 변위를 조합하여 계산한다.
고전적인 MIPS 구현은 파이프라인의 제어·데이터 지연을 소프트웨어에 노출하였다. 적재 명령 직후 결과를 바로 사용할 수 없는 로드 지연 슬롯과, 분기 다음 명령이 분기 여부와 관계없이 실행되는 분기 지연 슬롯이 대표적이었다. 이후 MIPS32와 MIPS64의 구현 및 개정에서는 이러한 동작의 일부가 변경되거나 제거되었다.
MIPS는 HI와 LO라는 특수 레지스터를 이용해 곱셈과 나눗셈 결과를 보관하는 전통적인 명령 형식을 사용하였다. 최신 개정과 특정 구현에서는 일반 범용 레지스터를 직접 사용하는 명령도 제공된다.
MIPS32와 MIPS64는 기본 정수 명령 외에 부동소수점, DSP, 압축 명령과 가상화 등의 확장을 포함할 수 있다. microMIPS와 MIPS16 계열은 코드 밀도를 높이기 위한 압축된 명령 표현을 제공하였다.
MIPS는 워크스테이션과 서버, 네트워크 장비, 라우터, 게임기와 임베디드 시스템에서 널리 사용되었다. SGI 워크스테이션, Sony PlayStation 계열의 일부 초기 기종, 라우터와 여러 SoC가 MIPS 기반 프로세서를 사용하였다.
현재에도 기존 임베디드 제품과 PIC32M 계열 일부에서 MIPS32가 사용된다. Microchip의 PIC32MZ 설명서는 해당 코어가 MIPS32 Release 2 아키텍처를 구현한다고 명시한다.[96]
MIPS 계열에서는 32비트와 64비트, 리틀 엔디언과 빅 엔디언, ABI 계열과 ISA 개정에 따라 바이너리 호환성이 달라진다. 같은 MIPS라는 이름만으로 실행 파일을 공통 사용할 수 있는 것은 아니다.
Power ISA
Power ISA는 IBM POWER 프로세서와 여러 호환 구현에서 사용하는 RISC 명령어 집합 아키텍처다. IBM의 POWER 아키텍처와 Apple·IBM·Motorola가 공동으로 발전시킨 PowerPC 계통의 기능이 통합·발전하면서 현재의 Power ISA 규격으로 이어졌다.
Power ISA는 OpenPOWER Foundation을 통해 명령어 집합 규격이 공개된다. OpenPOWER Foundation은 Power ISA 규격을 POWER 프로세서가 사용하는 아키텍처를 설명하는 사양으로 정의한다.[97]
Power ISA는 32비트와 64비트 실행 환경, 정수·부동소수점·벡터 명령, 메모리 관리, 예외 처리와 특권 기능을 포함한다. 실제 구현과 플랫폼은 ISA의 특정 버전과 선택적 기능 집합을 지원한다.
일반적인 64비트 Power 프로그래밍 모델은 32개의 범용 레지스터와 32개의 부동소수점·벡터 스칼라 레지스터를 제공한다. 조건 레지스터는 여러 조건 필드로 나뉘며, 비교와 일부 산술 명령의 결과를 저장한다.
Power 명령은 전통적으로 32비트 고정 길이를 중심으로 한다. 명령어 형식에 따라 연산 코드, 레지스터 번호, 즉시값과 조건 필드를 배치한다. Power ISA 3.1에서는 일부 명령에 더 큰 즉시값과 기능을 제공하기 위한 64비트 접두 명령 형식도 추가되었다.
Power 계열은 레지스터 중심의 3피연산자 산술 명령과 별도의 적재·저장 명령을 사용한다. 여러 명령은 결과를 조건 레지스터에 선택적으로 기록할 수 있으며, 분기 명령은 조건 레지스터 필드와 카운트 레지스터, 링크 레지스터를 활용한다.
함수 호출에서는 링크 레지스터가 반환 주소를 보관하는 데 사용된다. 카운트 레지스터는 간접 분기 목적지나 반복 제어에 사용될 수 있으며, 조건 레지스터는 여러 개의 독립적인 비교 상태를 보관한다.
Power ISA는 AltiVec에서 발전한 벡터 명령과 VSX를 제공한다. 최신 POWER 프로세서는 암호화, 십진 부동소수점과 행렬 연산 등의 확장을 포함한다. Power ISA 3.1은 POWER10에서 사용되는 행렬 곱셈 보조 기능을 포함한다.
Power 계열은 IBM POWER 서버, OpenPOWER 시스템, 일부 고성능 컴퓨팅 환경과 과거 PowerPC 기반 Macintosh, 게임기와 임베디드 시스템에 사용되었다. PowerPC와 현대 Power ISA는 역사적으로 연결되어 있지만, 특정 PowerPC 프로세서의 ISA와 최신 POWER 서버의 ISA를 완전히 동일한 대상으로 취급할 수는 없다.
Linux용 Power 플랫폼에서는 Power ISA 외에도 ELF ABI, 호출 규약, 플랫폼 펌웨어와 시스템 인터페이스 규격이 함께 사용된다. OpenPOWER Foundation은 Linux on POWER 플랫폼을 위한 별도의 아키텍처 참조를 제공한다.[98]
IBM Z
IBM Z의 명령어 집합은 IBM 메인프레임에서 사용되는 z/Architecture다. 이 계열은 System/360에서 시작된 IBM 메인프레임 아키텍처의 호환성을 계승하면서 64비트 주소 지정, 현대적인 가상화, 암호화와 벡터 기능을 추가해 발전하였다.
System/360은 1960년대에 서로 다른 성능의 컴퓨터가 하나의 호환 명령어 집합과 주변 장치 체계를 공유하도록 설계되었다. 이후 System/370, ESA/390과 z/Architecture로 이어지면서 기존 응용 프로그램과 운영 환경을 장기간 유지할 수 있는 호환성이 IBM 메인프레임의 주요 특성이 되었다.
z/Architecture는 16개의 64비트 범용 레지스터, 16개의 접근 레지스터와 부동소수점·벡터 레지스터를 제공한다. 프로그램 상태 워드(Program Status Word, PSW)는 현재 명령 주소, 조건 코드, 인터럽트 마스크와 실행 상태를 포함하는 핵심 제어 구조다.
명령 길이는 일반적으로 2바이트, 4바이트 또는 6바이트이며, 명령 형식에 따라 레지스터, 기준 주소, 인덱스, 변위와 즉시값을 포함한다. 명령은 2바이트 경계에 정렬된다.
z/Architecture는 범용 레지스터와 메모리 피연산자를 조합하는 여러 명령 형식을 제공한다. 문자와 십진수 데이터를 직접 처리하는 명령, 대량 메모리 처리, 트랜잭션 실행, 압축과 암호화 보조 기능 등 메인프레임 작업을 위한 명령도 포함한다.
조건 결과는 PSW의 조건 코드에 기록되며, 조건 분기 명령이 이를 검사한다. 조건 코드의 구체적인 의미는 이를 설정한 명령에 따라 달라질 수 있다.
IBM Z는 여러 운영체제와 가상 머신을 동시에 실행하는 환경을 주요 대상으로 한다. z/OS, z/VM, Linux on IBM Z와 z/VSE 등의 운영체제가 z/Architecture 위에서 동작한다.
메인프레임에서는 HLASM이라고 불리는 IBM High Level Assembler가 널리 사용된다. HLASM은 기계 명령 외에도 매크로, 조건부 어셈블리와 운영체제 인터페이스를 표현하는 기능을 제공한다.
IBM의 z/Architecture Principles of Operation은 명령 형식, 레지스터, 저장 장치, 인터럽트, 시스템 기능과 각 명령의 동작을 정의하는 기준 문서다. IBM의 관련 문서 목록에 따르면 SA22-7832-14판은 2025년 6월판으로 제공된다.[99]
IBM Z의 어셈블리어는 일반 개인용 컴퓨터보다 기업의 대규모 트랜잭션 처리, 데이터베이스, 배치 작업, 운영체제 구성 요소와 장기간 유지되는 업무 프로그램에서 중요한 역할을 한다.
SPARC
SPARC(Scalable Processor Architecture)는 Sun Microsystems에서 개발된 RISC 명령어 집합 계열이다. 버클리 RISC의 영향을 받아 설계되었으며, 워크스테이션과 서버를 중심으로 사용되었다.
SPARC의 주요 버전으로는 32비트 SPARC V8과 64비트 SPARC V9가 있다. SPARC V9는 64비트 주소와 정수 연산, 확장된 레지스터와 시스템 기능을 제공하면서 V8 응용 프로그램과의 호환성을 고려하였다. Oracle의 SPARC V9 문서는 32비트 V8 응용 프로그램이 V9 시스템에서 변경 없이 실행될 수 있다고 설명한다.[100]
SPARC 명령은 기본적으로 32비트 고정 길이를 사용한다. 로드·스토어 구조와 3피연산자 산술 명령을 사용하며, 메모리 접근은 별도의 적재와 저장 명령으로 수행된다.
SPARC의 대표적인 특징은 레지스터 윈도다. 프로그램에는 전역, 입력, 지역과 출력 레지스터 집합이 보이며, 함수 호출 시 현재 윈도가 이동하여 호출자의 출력 레지스터와 피호출자의 입력 레지스터가 겹친다.
이 구조는 일반적인 함수 호출에서 인수 전달과 지역 레지스터 확보를 빠르게 수행하도록 설계되었다. 실제 물리 레지스터 수와 윈도 수는 구현에 따라 달라질 수 있으며, 윈도가 부족하거나 복원해야 할 때 운영체제가 윈도 오버플로와 언더플로를 처리한다.
SPARC V9의 ABI도 더 큰 레지스터 파일 위에서 레지스터 윈도를 사용하여 함수 호출 비용을 줄이는 구조를 유지한다.[101]
고전적인 SPARC는 분기 지연 슬롯을 사용한다. 분기 명령 바로 뒤의 명령은 제어가 분기 목적지로 이동하기 전에 실행될 수 있으며, annul 비트로 특정 조건에서 지연 슬롯 명령을 무효화할 수 있다.
SPARC는 조건 코드 레지스터, 별도의 부동소수점 레지스터와 특권 상태를 제공한다. V9는 64비트 정수 레지스터와 확장된 주소 공간, 새로운 명령과 메모리 모델 기능을 추가하였다.
SPARC는 Sun과 Oracle의 워크스테이션·서버, Solaris 운영체제와 일부 내결함성·우주용 프로세서에서 사용되었다. 현재 범용 서버 시장에서의 비중은 과거보다 줄었지만, 기존 기업 시스템과 방사선 내성 LEON 계열 등에서는 SPARC 기반 소프트웨어가 유지되고 있다.
6502
6502는 MOS Technology가 1975년에 공개한 8비트 마이크로프로세서와 그 명령어 집합 계열이다. 비교적 단순한 구조와 낮은 가격으로 초기 개인용 컴퓨터, 가정용 게임기와 임베디드 장치에 널리 사용되었다.
6502는 8비트 누산기 A, 인덱스 레지스터 X와 Y, 8비트 스택 포인터, 상태 레지스터와 16비트 프로그램 카운터를 가진다. 주소 공간은 16비트이므로 기본적으로 64 KiB의 메모리에 접근할 수 있다.
6502의 산술·논리 연산은 누산기 A를 중심으로 수행된다. X와 Y는 인덱스 계산, 반복과 메모리 접근에 사용된다. 범용 레지스터가 많은 현대 RISC 아키텍처와 달리 소수의 특수화된 레지스터를 사용한다.
하드웨어 스택은 주소 0x0100부터 0x01FF까지의 고정된 256바이트 페이지에 위치한다. 8비트 스택 포인터는 이 페이지 안의 위치를 나타내며, 함수 호출과 반환 주소, 임시 데이터 저장에 사용된다.
6502는 여러 주소 지정 방식을 제공한다.
- 즉시 주소 지정
- 절대 주소와 제로 페이지 주소
X또는Y인덱스 주소 지정- 간접 주소 지정
- 인덱스 간접과 간접 인덱스 방식
- 분기를 위한 상대 주소 지정
제로 페이지는 주소 0x0000부터 0x00FF까지의 첫 256바이트 영역이다. 제로 페이지 주소는 일반 절대 주소보다 짧은 명령 형식으로 접근할 수 있으며, 일부 간접 주소 지정의 포인터 저장에도 사용된다.
상태 레지스터에는 음수, 오버플로, 브레이크, 십진 모드, 인터럽트 금지, 0과 캐리 등의 플래그가 포함된다. 조건 분기 명령은 이 플래그를 검사한다.
6502는 Apple II, Commodore PET와 Commodore 64, Atari 8비트 컴퓨터와 Nintendo Entertainment System 등에 사용된 프로세서 계열의 기반이 되었다. 각 시스템은 기본 CPU 외에도 서로 다른 그래픽·사운드와 메모리 구조를 사용했으므로 같은 6502 계열이라도 시스템 전체의 소프트웨어는 직접 호환되지 않았다.
6502 계열에는 CMOS 기반 65C02와 16비트로 확장된 65C816 등이 있다. 65C02는 기존 6502의 여러 동작을 정리하고 새로운 명령과 주소 지정 방식을 추가하였다.
Western Design Center는 현재에도 W65C02S 프로세서와 코어, 관련 개발 문서를 제공한다. W65C02S는 8비트 프로그래밍 모델을 유지하면서 CMOS 구현과 추가 명령을 제공한다.[102]
6502 어셈블리어는 레트로 컴퓨팅, 게임기용 홈브루 소프트웨어, 하드웨어 교육과 소형 임베디드 프로젝트에서 계속 사용된다. 다만 원래 NMOS 6502, 65C02, Ricoh 2A03와 65C816은 지원 명령과 일부 플래그 동작이 다르므로 정확한 대상을 지정해야 한다.
Z80
Z80은 Zilog가 1976년에 공개한 8비트 마이크로프로세서와 명령어 집합 계열이다. Intel 8080과 높은 수준의 바이너리 호환성을 유지하면서 추가 레지스터, 명령과 인터럽트 기능을 제공하도록 설계되었다.
Z80은 8비트 누산기 A와 플래그 레지스터 F, 범용 레지스터 B, C, D, E, H, L을 제공한다. 이들 일부는 AF, BC, DE, HL의 16비트 레지스터 쌍으로 사용할 수 있다.
Z80은 기본 레지스터 집합 외에 AF', BC', DE', HL'이라는 대체 레지스터 집합을 제공한다. 전용 교환 명령으로 기본 집합과 대체 집합을 빠르게 바꿀 수 있어 인터럽트 처리와 임시 상태 보존에 활용할 수 있다.
IX와 IY는 인덱스 레지스터이며, 변위를 결합하여 메모리에 접근할 수 있다. SP는 스택 포인터, PC는 프로그램 카운터다. I는 인터럽트 벡터와 관련되고 R은 메모리 새로 고침 과정에 사용되는 레지스터다.
Z80은 16비트 주소 버스와 8비트 데이터 단위를 사용하며 기본 주소 공간은 64 KiB다. 메모리와 별도로 16비트 포트 주소를 사용하는 입출력 명령을 제공한다.
명령어 인코딩은 8080 계열의 기본 바이트 위에 여러 접두사를 추가하는 방식으로 확장되었다. CB, ED, DD, FD 등의 접두사는 비트 연산, 확장 명령과 IX·IY 사용 명령을 선택한다.
Z80은 다음과 같은 추가 기능을 제공한다.
- 비트 검사·설정·초기화 명령
- 블록 메모리 복사와 검색
- 반복 입출력 명령
- 16비트 산술의 확장
- 세 가지 인터럽트 모드
- 대체 레지스터 집합
- 동적 메모리의 새로 고침 지원
LDIR과 LDDR 같은 블록 전송 명령은 HL이 가리키는 원본에서 DE가 가리키는 목적지로 데이터를 복사하고, BC를 카운터로 사용해 반복할 수 있다. Zilog의 공식 사용자 설명서는 Z80의 레지스터, 명령어, 인터럽트 응답과 타이밍을 정의한다.[103]
Z80은 CP/M 기반 컴퓨터, Sinclair ZX Spectrum, Amstrad CPC, MSX, TRS-80 계열과 여러 임베디드 장치에 사용되었다. Nintendo Game Boy의 CPU는 Z80과 8080의 영향을 받았지만 완전한 Z80 호환 프로세서는 아니며, 일부 레지스터와 명령이 제거되거나 변경되었다.
Z80 계열은 eZ80 등으로 확장되었으며, 기존 Z80 소프트웨어와의 호환 모드와 더 넓은 주소 공간을 제공하는 구현도 등장하였다. 원래 Z80과 파생 프로세서 사이에는 명령과 타이밍 차이가 있으므로 시스템별 문서를 기준으로 작성해야 한다.
기타 명령어 집합
컴퓨터 역사와 현대 시스템에는 위 계열 외에도 다양한 명령어 집합이 존재한다. 일부는 특정 시대의 범용 컴퓨터 시장에서 사용되었고, 일부는 마이크로컨트롤러, 신호 처리기, 그래픽 처리 장치와 가상 머신을 위해 설계되었다.
Motorola 68000 계열
Motorola 68000 계열은 1979년에 공개된 68000에서 시작되었다. 16비트 외부 데이터 버스를 사용한 초기 68000도 내부적으로 32비트 레지스터와 비교적 평탄한 프로그래밍 모델을 제공하였다.
68000 계열은 8개의 데이터 레지스터 D0부터 D7, 8개의 주소 레지스터 A0부터 A7을 제공하며, A7은 스택 포인터로 사용된다. 명령은 가변 길이를 사용하고 다양한 주소 지정 방식을 지원한다.
68000, 68010, 68020, 68030, 68040과 68060으로 발전하면서 가상 메모리, 캐시, 부동소수점과 고성능 실행 기능이 추가되었다. 초기 Macintosh, Amiga, Atari ST, 여러 UNIX 워크스테이션, 게임기와 임베디드 장치에 사용되었다.
ColdFire는 68000 계열의 프로그래밍 모델을 단순화한 임베디드 프로세서 계열이다. 기존 68k 코드와 유사하지만 모든 명령이 호환되는 것은 아니다.
AVR
AVR은 Atmel에서 개발되어 현재 Microchip이 제공하는 마이크로컨트롤러용 RISC 명령어 집합 계열이다. 고전적인 8비트 AVR은 32개의 8비트 범용 레지스터를 제공하며, 여러 산술 명령이 레지스터 간에 직접 동작한다.
프로그램 메모리와 데이터 메모리가 분리된 하버드 구조를 사용하며, 플래시 메모리의 명령과 SRAM·레지스터·입출력 공간의 데이터를 서로 다른 방식으로 접근한다. Arduino의 초기 대표 보드에 사용된 ATmega 계열이 AVR 기반이다.
PIC
PIC은 Microchip의 마이크로컨트롤러 계열로, 8비트 PIC10·PIC12·PIC16·PIC18, 16비트 PIC24와 dsPIC, 32비트 PIC32 등 여러 서로 다른 아키텍처를 포함한다.
8비트 PIC는 누산기 역할을 하는 작업 레지스터, 뱅크로 나뉜 데이터 메모리와 비교적 작은 명령어 집합을 사용한다. PIC32에는 MIPS 기반 제품과 별도의 32비트 코어를 사용하는 제품이 존재하므로 PIC이라는 제품 계열 이름만으로 하나의 어셈블리어를 의미하지 않는다.
DEC PDP와 VAX
Digital Equipment Corporation의 PDP-8, PDP-11과 VAX는 초기 미니컴퓨터와 32비트 가상 메모리 시스템의 대표적인 명령어 집합 계열이다.
PDP-11은 범용 레지스터 중심 구조와 규칙적인 주소 지정 방식을 제공하였고, 프로그램 카운터와 스택 포인터도 일반 레지스터 번호 체계에 포함하였다. 이러한 설계는 C와 UNIX 초기 구현에 큰 영향을 주었다.
VAX는 PDP-11 계열을 32비트 가상 주소 환경으로 확장하면서 복잡한 데이터형과 다양한 명령을 제공하였다. OpenVMS와 BSD UNIX 등에서 사용되었다.
Alpha
DEC Alpha는 Digital Equipment Corporation이 개발한 64비트 RISC 아키텍처다. 높은 클럭과 단순한 명령 실행을 목표로 설계되었으며, 32개의 정수 레지스터와 32개의 부동소수점 레지스터를 제공한다.
Alpha는 초기부터 64비트 주소와 정수 연산을 중심으로 설계되었고, 정렬되지 않은 메모리 접근과 바이트 단위 연산의 일부를 소프트웨어 조합으로 처리하였다. OpenVMS, Tru64 UNIX, Windows NT와 Linux가 Alpha를 지원하였다.
PA-RISC
PA-RISC는 Hewlett-Packard가 개발한 RISC 아키텍처로 HP 9000 워크스테이션과 서버에서 사용되었다. 32비트 PA-RISC 1.x와 64비트 PA-RISC 2.0 계열이 있으며 HP-UX의 주요 하드웨어 기반 가운데 하나였다.
Itanium
Itanium의 IA-64는 Intel과 Hewlett-Packard가 개발한 64비트 명령어 집합이다. x86-64와는 별개의 아키텍처이며, 명령 수준 병렬성을 컴파일러가 명시적으로 표현하는 EPIC 설계를 사용한다.
IA-64 명령은 여러 연산을 하나의 번들에 배치하고, 어떤 명령이 병렬로 실행될 수 있는지를 정지 비트로 표현한다. 많은 범용·부동소수점 레지스터, 레지스터 스택과 조건부 실행을 위한 프레디케이트 레지스터를 제공하였다.
Itanium은 일부 기업용 서버와 고성능 시스템에서 사용되었지만, x86-64와 직접적인 바이너리 호환성을 갖지 않았고 현재는 주요 신규 범용 프로세서 계열에서 벗어났다.
S/360 이전과 기타 메인프레임 계열
IBM System/360 외에도 UNIVAC, Burroughs, CDC와 여러 메인프레임·슈퍼컴퓨터 제조사는 서로 다른 명령어 집합을 사용하였다. 일부 시스템은 워드 단위 주소 지정, 스택 기반 실행, 태그가 붙은 데이터와 벡터 연산 등 현대 범용 ISA와 다른 구조를 채택하였다.
GPU와 가속기 명령어 집합
그래픽 처리 장치는 대량의 병렬 작업을 실행하기 위한 전용 명령어 집합을 사용한다. NVIDIA의 SASS, AMD의 GCN·RDNA 계열 기계 명령과 여러 가속기 ISA는 일반 CPU와 다른 스레드, 레지스터, 메모리와 동기화 모델을 제공한다.
CUDA의 PTX와 SPIR-V는 특정 실제 프로세서의 최종 기계어와 완전히 동일하지 않은 중간 또는 가상 명령어 집합이다. 드라이버나 컴파일러가 이를 대상 GPU의 실제 기계 명령으로 변환한다.
가상 명령어 집합
Java Virtual Machine 바이트코드, .NET의 CIL, WebAssembly와 eBPF는 물리적 CPU 대신 가상 머신이나 검증된 실행 환경을 대상으로 하는 명령어 집합이다.
이러한 명령어도 니모닉, 피연산자, 스택이나 레지스터 모델과 바이너리 인코딩을 가지므로 넓은 의미에서 어셈블리 형태로 표현할 수 있다. 다만 실제 하드웨어가 직접 실행하는 기계어가 아니라 인터프리터, JIT 컴파일러 또는 검증기와 런타임을 거쳐 실행된다는 점에서 일반적인 CPU 어셈블리어와 구분된다.
명령어 집합 계열은 단순히 CISC와 RISC라는 두 범주로 완전히 구분되지 않는다. 현대 프로세서는 고정·가변 길이, 복잡한 명령, 마이크로 연산 변환, 벡터 처리와 전용 가속 기능을 여러 방식으로 결합한다. 어셈블리어를 사용할 때는 이러한 넓은 분류보다 실제 ISA 버전, 실행 모드, 확장, ABI와 대상 프로세서를 구체적으로 확인하는 것이 중요하다.
프로그래밍 모델
어셈블리어의 프로그래밍 모델은 명령어를 개별적으로 사용하는 방법뿐 아니라, 여러 명령을 절차와 함수로 구성하고 운영체제·런타임·다른 언어의 코드와 상호작용하는 규칙을 포함한다. 프로세서는 분기, 호출, 반환, 예외 진입과 원자적 메모리 연산 같은 기본 기능을 제공하지만, 함수의 매개변수 전달, 레지스터 보존, 스택 프레임의 배치와 시스템 호출 번호는 대체로 응용 프로그램 이진 인터페이스와 운영체제가 정의한다.
따라서 같은 명령어 집합 아키텍처를 사용하더라도 운영체제와 ABI가 다르면 프로그램의 함수 호출 방식과 시스템 인터페이스가 달라질 수 있다. x86-64 Linux의 System V ABI와 x64 Windows ABI는 같은 범용 레지스터를 사용하지만 매개변수 레지스터, 보존 규칙, 스택 사용과 실행 파일 환경이 서로 다르다.
어셈블리 프로그램은 고급 언어가 자동으로 처리하는 다음 작업을 직접 관리해야 할 수 있다.
- 함수의 진입점과 반환 경로
- 매개변수와 반환값의 위치
- 호출 전후에 유지할 레지스터
- 스택 포인터와 정렬
- 지역 변수와 임시 저장 공간
- 운영체제와의 시스템 호출
- 인터럽트와 예외에서 저장할 실행 상태
- 여러 실행 주체 사이의 메모리 동기화
절차와 함수
어셈블리어에서 절차 또는 함수는 특정 작업을 수행하도록 하나의 진입점 아래에 구성한 명령어 집합이다. 고급 언어의 함수와 달리 ISA는 매개변수의 자료형, 지역 변수, 접근 제한이나 함수의 끝을 자동으로 인식하지 않는다. 이러한 구조는 레이블, 호출 규약, 디버깅 정보와 프로그래머의 코드 구성으로 만들어진다.
함수는 일반적으로 다음 흐름을 가진다.
일반적인 함수 실행 흐름
- 호출자가 매개변수 준비
- 호출 명령으로 함수 진입
- 필요한 레지스터와 반환 주소 보존
- 지역 저장 공간 확보
- 함수 본문 실행
- 반환값 준비
- 스택과 보존 레지스터 복원
- 호출 다음 위치로 반환
함수의 시작 위치에는 전역 또는 지역 심벌을 붙일 수 있다. 다른 오브젝트 파일에서 호출해야 하는 함수는 링커가 찾을 수 있도록 전역 심벌로 공개하며, 같은 파일 안에서만 사용하는 보조 절차는 지역 심벌로 유지할 수 있다.
ISA가 제공하는 호출 명령은 주로 제어 이동과 반환 주소 저장만 담당한다. x86의 CALL은 반환 주소를 스택에 저장하고 목적지로 분기한다. AArch64의 BL은 반환 주소를 링크 레지스터 X30에 기록하며, RISC-V의 JAL은 지정한 목적지 레지스터에 반환 주소를 저장한다.
함수가 다른 함수를 호출하지 않는 경우를 단말 함수(leaf function)라고 한다. 단말 함수는 반환 주소를 추가로 보존할 필요가 없거나, 지역 변수가 적으면 별도의 스택 프레임 없이 레지스터만으로 실행될 수 있다.
다른 함수를 호출하는 비단말 함수는 자신의 반환 주소와 호출 이후에도 필요한 값을 보존해야 한다. AArch64나 RISC-V처럼 반환 주소를 레지스터에 저장하는 ISA에서는 하위 함수를 호출하기 전에 기존 반환 주소를 스택이나 보존 레지스터로 옮겨야 한다.
함수는 하나의 고정된 반환 지점만 가져야 하는 것은 아니다. 여러 조건에 따라 서로 다른 반환 경로를 사용할 수 있지만, 모든 경로에서 스택 포인터와 보존 레지스터가 호출 시점의 규칙에 맞게 복원되어야 한다.
재귀 함수는 자신을 다시 호출하므로 각 호출의 매개변수, 반환 주소와 지역 상태를 독립적으로 보존해야 한다. 일반적으로 호출마다 새로운 스택 프레임을 만들며, 재귀 깊이가 커지면 스택 사용량도 증가한다.
함수 경계는 기계어 자체에 명시적으로 기록되지 않을 수 있다. 심벌 테이블과 디버깅 정보가 제거된 실행 파일을 분석할 때는 호출 대상, 반환 명령, 스택 조작과 제어 흐름을 바탕으로 함수 영역을 추론해야 한다.
호출 규약
호출 규약(calling convention)은 함수 호출자와 피호출자가 따라야 하는 이진 수준의 규칙이다. 함수의 매개변수와 반환값을 어디에 둘지, 어떤 레지스터를 보존할지, 스택을 어떻게 정렬할지와 함수 이름을 오브젝트 파일에 어떻게 표현할지 등을 정의한다.
호출 규약은 일반적으로 더 넓은 응용 프로그램 이진 인터페이스의 일부다. ABI는 호출 규약 외에도 자료형의 크기와 정렬, 구조체 배치, 오브젝트 파일 형식, 재배치, 예외 처리와 운영체제 인터페이스 등을 포함할 수 있다.
호출 규약이 필요한 이유는 서로 독립적으로 작성하거나 컴파일한 코드가 공통된 약속을 통해 함수를 호출할 수 있게 하기 위해서다. 어셈블리 함수가 호출 규약을 지키면 C, C++, Rust와 다른 언어에서 생성한 코드와도 연결할 수 있다.
호출 규약은 대체로 다음 사항을 정한다.
- 정수와 포인터 매개변수에 사용할 레지스터
- 부동소수점과 벡터 매개변수에 사용할 레지스터
- 레지스터에 들어가지 않는 매개변수의 스택 배치
- 정수·포인터·부동소수점 반환값의 위치
- 큰 구조체와 복합 자료형의 전달 방식
- 호출자 보존과 피호출자 보존 레지스터
- 함수 진입 시 스택 포인터의 정렬
- 가변 인수 함수의 레지스터 저장과 접근 방법
- 예외 처리와 스택 되감기에 필요한 정보
- 함수 심벌과 이름 맹글링의 일부 규칙
x86-64 System V ABI는 Linux, 여러 BSD와 다른 UNIX 계열 x86-64 환경의 대표적인 호출 규약을 정의한다. 이 ABI의 공식 저장소는 ELF x86-64 프로세서별 ABI 문서와 관련 규칙을 관리한다.[104]
AArch64의 표준 절차 호출 규약은 AAPCS64가 정의한다. AAPCS64는 Arm 64비트 아키텍처용 전체 ABI 규격의 일부로서 함수 호출, 레지스터 역할, 매개변수 전달과 스택 규칙을 정의한다.[105]
RISC-V의 ELF psABI는 표준 호출 규약과 변형, 매개변수와 반환값 전달, ELF 오브젝트 파일과 재배치 규칙을 정의한다.[106]
호출 규약은 ISA와 동일하지 않다. ISA는 호출에 사용할 수 있는 명령과 레지스터를 제공하지만, 어느 레지스터에 첫 번째 매개변수를 둘지는 ABI가 정한다. 따라서 동일한 x86-64 프로세서에서도 System V ABI와 Windows x64 ABI는 다른 호출 규칙을 사용한다.
컴파일러는 특정 함수에 기본 규약과 다른 호출 규약을 지정하는 확장을 제공할 수 있다. 인터럽트 처리 함수, 시스템 펌웨어 인터페이스, 언어 런타임, 그래픽 API와 JIT 컴파일러는 별도의 내부 호출 규약을 사용할 수 있다.
호출 규약을 위반한 코드는 어셈블과 링크에는 성공할 수 있지만 실행 중 잘못된 매개변수, 손상된 스택, 변경된 레지스터나 잘못된 반환 주소를 사용할 수 있다. 이러한 오류는 함수가 단독으로 실행될 때는 드러나지 않고 다른 코드와 연결했을 때만 나타날 수도 있다.
매개변수와 반환값
함수의 매개변수는 호출자가 피호출자에게 전달하는 값이며, 반환값은 피호출자가 호출자에게 돌려주는 결과다. 현대 ABI는 가능한 매개변수를 레지스터로 전달하고, 레지스터에 들어가지 않거나 특별한 처리가 필요한 값은 스택이나 메모리를 사용한다.
정수, 포인터, 부동소수점과 벡터 값은 서로 다른 레지스터 집합으로 전달될 수 있다. 구조체나 배열과 같은 복합 자료형은 크기와 내부 구성에 따라 여러 레지스터로 나뉘거나, 메모리에 저장한 뒤 그 주소가 전달될 수 있다.
x86-64 System V ABI에서는 일반적인 정수·포인터 매개변수의 앞부분을 RDI, RSI, RDX, RCX, R8, R9 순서의 레지스터로 전달한다. 그보다 많은 매개변수와 일부 분류의 값은 스택을 통해 전달한다.
Windows x64 ABI에서는 일반적인 정수·포인터 매개변수의 앞부분을 RCX, RDX, R8, R9에 전달한다. 호출자는 호출된 함수가 이 네 매개변수를 저장할 수 있는 스택 공간을 확보하며, System V ABI와 다른 스택·레지스터 규칙을 사용한다.
AArch64 AAPCS64에서는 일반적인 정수와 포인터 매개변수의 앞부분을 X0부터 X7까지 전달한다. 부동소수점과 SIMD 매개변수에는 V0부터 V7까지의 레지스터가 사용될 수 있다.
RISC-V 표준 정수 호출 규약에서는 a0부터 a7까지의 정수 레지스터가 매개변수 전달에 사용된다. a0과 a1은 반환값 전달에도 사용된다. RISC-V의 레지스터 호출 규칙과 매개변수 분류는 선택한 정수 폭과 부동소수점 ABI에 따라 달라질 수 있다.[107]
정수 반환값은 보통 하나 또는 두 개의 지정된 범용 레지스터에 놓인다. x86-64에서는 RAX와 필요에 따라 RDX, AArch64에서는 X0과 필요에 따라 추가 결과 레지스터, RISC-V에서는 a0과 a1이 사용될 수 있다.
부동소수점 반환값은 일반적으로 부동소수점 또는 벡터 레지스터를 사용한다. 대상 ABI가 하드웨어 부동소수점 호출 규약을 사용하지 않는 경우에는 부동소수점 비트 패턴을 정수 레지스터나 메모리로 전달할 수도 있다.
큰 구조체를 값으로 반환하는 함수는 호출자가 결과를 저장할 메모리를 미리 확보하고 그 주소를 숨겨진 매개변수로 전달할 수 있다. 피호출자는 해당 위치에 결과를 기록하고, ABI에 따라 그 주소를 다시 반환하거나 별도의 값을 반환한다.
복합 자료형은 단순히 크기만으로 처리되지 않을 수 있다. x86-64 System V ABI는 구조체의 각 부분을 정수, SSE, 메모리 등의 클래스로 분류하여 레지스터 전달 여부를 결정한다. AAPCS64도 집합체와 동종 부동소수점 집합 등 자료형의 구성에 따른 전달 규칙을 가진다.
가변 인수 함수는 호출 시점에 매개변수의 전체 자료형과 개수가 고정되어 있지 않다. 이 때문에 ABI는 고정 매개변수 이후의 레지스터와 스택 값을 함수 내부에서 순회할 수 있는 별도 규칙을 정한다.
어셈블리 함수가 고급 언어에서 호출될 때는 매개변수의 자료형, 부호 확장, 상위 비트의 상태와 구조체 배치를 정확히 맞춰야 한다. 같은 크기의 값이라도 부호 있는 정수, 부호 없는 정수, 포인터와 부동소수점 값은 서로 다른 규칙을 적용받을 수 있다.
레지스터 보존 규칙
함수 호출은 여러 레지스터의 값을 변경할 수 있다. 호출자와 피호출자가 모든 레지스터를 매번 저장하면 비용이 커지므로, 호출 규약은 레지스터를 호출자 보존과 피호출자 보존 범주로 나눈다.
호출자 보존 레지스터는 함수 호출로 값이 변경될 수 있는 레지스터다. 호출자가 함수 호출 뒤에도 해당 값이 필요하다면 호출 전에 스택이나 다른 보존 위치에 저장해야 한다.
피호출자 보존 레지스터는 호출된 함수가 원래 값을 유지해야 하는 레지스터다. 피호출자가 이를 사용하려면 함수 진입 시 저장하고 반환 전에 복원해야 한다.
이 구분은 레지스터가 특정 함수에서 실제로 변경되는지를 의미하지 않는다. 호출자 보존 레지스터는 피호출자가 사용하지 않을 수도 있지만, 호출자는 규약상 값이 유지된다고 가정할 수 없다. 반대로 피호출자 보존 레지스터는 피호출자가 전혀 사용하지 않으면 저장할 필요가 없다.
x86-64 System V ABI에서는 RBX, RBP, R12부터 R15와 스택 포인터가 대표적인 피호출자 보존 범주에 속한다. RAX, RCX, RDX, RSI, RDI, R8부터 R11 등은 일반적으로 호출자 보존이다.
Windows x64 ABI는 System V와 다른 보존 집합을 사용하며, 일부 XMM 레지스터도 피호출자 보존 대상으로 지정한다. 따라서 같은 x86-64 어셈블리 함수라도 대상 ABI에 따라 저장해야 하는 레지스터가 달라진다.
AAPCS64에서는 X19부터 X29가 일반적으로 피호출자 보존 범용 레지스터이며, X30은 링크 레지스터로 호출과 반환 주소 관리에 사용된다. X0부터 X18의 세부 역할에는 인수, 임시값과 플랫폼 레지스터 등이 포함되며 환경에 따라 일부 차이가 있다.[108]
RISC-V 표준 호출 규약에서는 s0부터 s11이 피호출자 보존 레지스터이고, t0부터 t6과 a0부터 a7은 호출자가 보존해야 하는 임시·매개변수 레지스터다. sp는 호출 전후에 올바른 값과 정렬을 유지해야 한다.[109]
상태 플래그와 벡터 레지스터에도 보존 규칙이 있을 수 있다. 일부 ABI에서는 조건 플래그를 호출 후 유지되지 않는 상태로 취급하며, 벡터 레지스터의 일부만 피호출자 보존으로 정한다.
레지스터 보존은 일반적으로 스택에 저장하는 방식으로 구현되지만, 다른 피호출자 보존 레지스터나 고정된 메모리 영역을 사용할 수도 있다. 저장 위치와 순서는 ABI, 성능과 디버깅 요구에 따라 달라진다.
비단말 함수가 호출자 보존 레지스터에 중요한 값을 유지하고 있다면 하위 함수 호출 전에 그 값을 저장해야 한다. 컴파일러는 레지스터의 생존 범위를 분석하여 필요한 값만 저장하며, 어셈블리 프로그래머도 같은 원칙을 직접 적용해야 한다.
인터럽트와 예외 처리기의 레지스터 보존 규칙은 일반 함수와 다를 수 있다. 비동기적으로 중단된 코드가 어떤 레지스터를 사용 중인지 알 수 없으므로, 처리기는 자신이 변경하는 실행 상태를 더 넓게 저장해야 할 수 있다.
스택 프레임
스택 프레임은 함수 호출 하나가 스택에서 사용하는 영역이다. 반환 주소, 보존 레지스터, 지역 변수, 임시 공간과 스택으로 전달된 매개변수 등이 포함될 수 있다.
스택 프레임은 고정된 하나의 형식이 아니다. 다음 요소에 따라 모양과 크기가 달라진다.
- 대상 ISA와 ABI
- 함수의 매개변수와 지역 변수
- 다른 함수 호출 여부
- 보존해야 하는 레지스터 수
- 동적 크기의 지역 저장 공간
- 컴파일러 최적화 수준
- 디버깅과 스택 되감기 요구
- 스택 보호와 보안 기능
함수 진입 시 스택 프레임을 구성하는 명령열을 프롤로그, 반환 전에 이를 해제하는 명령열을 에필로그라고 한다.
일반적인 프롤로그는 다음 작업 가운데 필요한 부분을 수행한다.
- 반환 주소 저장
- 이전 프레임 포인터 저장
- 피호출자 보존 레지스터 저장
- 스택 포인터 감소를 통한 지역 공간 확보
- 프레임 포인터 설정
- 스택 보호용 값 저장
- 스택 되감기 메타데이터와 일치하는 상태 구성
에필로그는 지역 공간을 반환하고 보존 레지스터와 반환 주소를 복원한 뒤 호출자에게 돌아간다. 여러 반환 경로가 있는 함수에서는 공통 에필로그로 분기하거나 각 경로에서 동일한 복원 작업을 수행할 수 있다.
프레임 포인터는 현재 함수 프레임 안의 고정된 기준 위치를 가리키는 레지스터다. 스택 포인터가 함수 실행 중 바뀌더라도 매개변수와 지역 변수에 일정한 오프셋으로 접근할 수 있게 한다.
x86 계열에서는 RBP 또는 EBP, AArch64에서는 X29, RISC-V에서는 관례상 s0이 프레임 포인터로 사용될 수 있다. 프레임 포인터는 ABI가 항상 사용하도록 강제하지 않을 수 있으며, 최적화된 함수는 이를 일반 레지스터로 활용하고 스택 포인터를 기준으로 데이터에 접근할 수 있다.
스택 프레임은 호출 규약이 요구하는 정렬을 유지해야 한다. 벡터 값, 구조체와 다른 함수 호출을 위해 스택 포인터를 16바이트 또는 그 이상의 경계에 맞춰야 할 수 있다.
일부 ABI는 호출자가 다음 함수를 위해 사용할 인수 공간이나 임시 저장 공간을 미리 확보하도록 정한다. Windows x64의 홈 공간과 같은 규칙은 함수가 매개변수 레지스터의 값을 스택에 저장할 수 있는 고정 영역을 제공한다.
x86-64 System V ABI는 사용자 공간 함수가 현재 스택 포인터 아래의 일정 범위를 신호나 인터럽트 처리로 훼손되지 않는 레드 존으로 사용할 수 있도록 한다. 단말 함수는 스택 포인터를 조정하지 않고 이 영역에 임시 데이터를 둘 수 있다. 커널 코드나 다른 ABI에서는 같은 규칙을 사용할 수 없으므로 대상 환경을 확인해야 한다.
동적 크기의 배열이나 가변 길이 임시 공간을 할당하면 함수 실행 중 스택 포인터가 추가로 변경될 수 있다. 이 경우 원래 스택 위치를 별도 레지스터에 보존하거나 정렬된 기준점을 다시 계산해야 한다.
디버거와 예외 처리기는 스택 프레임을 따라 호출 경로를 복원한다. 프레임 포인터가 없는 코드에서는 DWARF의 호출 프레임 정보나 Windows의 스택 되감기 메타데이터처럼 프롤로그·에필로그 동작을 설명하는 별도 정보가 사용된다.
스택 프레임에 반환 주소와 제어 데이터가 저장되는 구조에서는 버퍼 범위를 벗어난 쓰기가 제어 흐름을 손상할 수 있다. 스택 카나리, 비실행 스택, 섀도 스택과 포인터 인증 같은 기능은 이러한 공격을 어렵게 만들기 위해 사용된다.
시스템 호출
시스템 호출은 사용자 공간 프로그램이 운영체제 커널의 기능을 요청하는 인터페이스다. 파일 입출력, 프로세스와 스레드 관리, 메모리 매핑, 네트워크, 시간과 장치 접근 같은 특권 작업은 시스템 호출을 통해 수행된다.
시스템 호출은 일반 함수 호출과 비슷하게 번호와 매개변수를 전달하지만, 실행 권한을 커널 수준으로 전환하고 커널이 정한 진입점으로 이동한다는 점에서 다르다.
일반적인 시스템 호출 흐름은 다음과 같다.
시스템 호출 흐름
- 사용자 프로그램이 호출 번호와 매개변수 준비
- 시스템 호출 명령 실행
- 프로세서가 커널 진입 상태 구성
- 커널이 호출 번호 확인
- 대상 커널 함수 실행
- 반환값 또는 오류 코드 준비
- 사용자 실행 상태로 복귀
ISA는 시스템 호출이나 특권 전환에 사용할 명령을 제공할 수 있지만, 호출 번호와 매개변수 레지스터의 배치는 운영체제가 정한다. 같은 x86-64에서도 Linux와 Windows의 시스템 호출 인터페이스는 서로 다르다.
x86-64 Linux에서는 일반적으로 syscall 명령을 사용한다. 시스템 호출 번호와 매개변수를 정해진 레지스터에 배치하고, 반환값은 RAX를 통해 받는다. 함수 호출 ABI와 시스템 호출 ABI는 매개변수 레지스터 일부가 다르므로 서로 혼동할 수 없다.
AArch64에서는 SVC 명령이 동기 예외를 발생시켜 운영체제의 상위 예외 수준으로 진입하는 데 사용된다. Arm의 예외 처리 예시는 낮은 예외 수준의 코드가 특권 작업을 요청하기 위해 시스템 호출 예외를 발생시키는 흐름을 설명한다.[110]
RISC-V에서는 ECALL 명령이 현재 실행 환경에 대한 호출을 요청한다. 사용자 모드에서 실행하면 환경 호출 예외가 발생하며, 운영체제 또는 상위 특권 소프트웨어가 이를 처리한다.
Linux의 syscall() 라이브러리 함수는 지정된 번호와 매개변수를 아키텍처별 어셈블리 시스템 호출 인터페이스에 맞춰 커널에 전달한다. 시스템 호출 번호, 반환값 레지스터와 오류 표시 방식은 아키텍처에 따라 다르다.[111]
응용 프로그램은 일반적으로 시스템 호출을 직접 실행하기보다 C 표준 라이브러리나 운영체제 런타임의 래퍼 함수를 호출한다. 래퍼는 시스템 호출 번호와 레지스터 배치를 처리하고, 커널 오류 코드를 언어와 라이브러리의 오류 표현으로 변환할 수 있다.
모든 라이브러리 함수가 하나의 시스템 호출에 대응하는 것은 아니다. 일부 함수는 사용자 공간에서만 동작하고, 여러 시스템 호출을 조합하거나 캐시와 버퍼를 사용한다. 반대로 하나의 시스템 호출을 여러 라이브러리 API가 공유할 수도 있다.
시스템 호출 중에는 현재 스레드가 대기 상태로 전환되거나 스케줄러가 다른 스레드를 실행할 수 있다. 따라서 시스템 호출을 일반 명령 몇 개의 고정 비용으로만 볼 수 없으며 요청 종류와 시스템 상태에 따라 실행 시간이 달라진다.
운영체제의 시스템 호출 ABI는 사용자 프로그램과 커널 사이의 장기적인 바이너리 인터페이스다. 함수 이름이 같아도 시스템 호출 번호, 구조체 배치와 인수 의미가 아키텍처 또는 운영체제 버전에 따라 달라질 수 있다.
인터럽트와 예외
인터럽트와 예외는 현재 실행 중인 명령 흐름을 중단하고 지정된 처리기로 제어를 전달하는 사건이다. 운영체제는 이를 이용해 장치 요청, 타이머, 시스템 호출, 잘못된 명령, 메모리 접근 오류와 디버깅 사건을 처리한다.
용어의 정확한 구분은 ISA마다 다르지만, 일반적으로 다음과 같이 나눌 수 있다.
- 현재 명령 실행과 직접 관련되어 발생하는 동기 예외
- 외부 장치나 타이머처럼 현재 명령과 독립적으로 발생하는 비동기 인터럽트
- 프로그램이 의도적으로 발생시키는 시스템 호출 또는 소프트웨어 예외
- 재개가 가능한 오류와 프로그램을 종료해야 하는 치명적 오류
0으로 나누기, 존재하지 않는 명령, 권한 위반과 페이지 폴트는 대표적인 동기 예외다. 타이머, 네트워크 장치와 저장 장치의 완료 통지는 대표적인 비동기 인터럽트다.
예외가 발생하면 프로세서는 현재 실행 위치와 상태의 일부를 아키텍처가 정한 방식으로 저장하고, 예외 원인에 대응하는 처리기 주소를 찾는다. 이후 권한 수준, 인터럽트 마스크와 스택이 변경될 수 있다.
처리기는 중단된 프로그램의 레지스터와 상태를 보존하고, 원인을 처리한 뒤 예외 반환 명령으로 원래 실행을 재개할 수 있다. 재개할 수 없는 예외라면 프로세스를 종료하거나 커널 오류 처리 경로로 이동한다.
x86은 인터럽트 기술자 테이블을 통해 예외와 인터럽트 벡터를 처리기 진입점에 연결한다. 예외 종류에 따라 오류 코드가 자동으로 스택에 저장될 수 있으며, 권한 수준이 바뀌면 새로운 스택으로 전환될 수 있다. 자세한 동작은 Intel 64와 IA-32 시스템 프로그래밍 설명서에 정의된다.[112]
AArch64는 EL0부터 EL3까지의 예외 수준을 사용하며, 예외가 발생하면 현재 또는 더 높은 예외 수준의 벡터 테이블로 제어를 이동한다. 예외 반환 주소와 저장된 프로세서 상태는 ELR_ELx, SPSR_ELx 등의 시스템 레지스터에 기록된다.[113]
AArch64의 예외는 동기 예외, IRQ, FIQ와 시스템 오류 계열로 구분된다. 벡터 테이블의 기준 주소는 예외 수준별 VBAR_ELx 시스템 레지스터가 지정하며, 현재 실행 상태와 예외가 발생한 위치에 따라 다른 벡터 항목이 사용된다.[114]
RISC-V에서는 예외와 인터럽트를 합쳐 트랩이라고 부른다. 머신 모드와 감독자 모드는 트랩 벡터, 원인, 예외 프로그램 카운터와 상태를 위한 CSR을 사용한다. 트랩 위임 설정을 통해 일부 사건을 낮은 특권 수준의 운영체제에 전달할 수 있다.
RISC-V의 mstatus와 관련 CSR은 인터럽트 허용 상태와 트랩 진입 전의 권한 정보를 관리한다. 특권 ISA는 인터럽트 활성화 비트를 원자적으로 설정·해제할 수 있도록 정의한다.[115]
인터럽트 처리기는 일반 함수와 다른 진입·반환 규칙을 사용한다. 프로세서가 자동으로 저장한 상태와 소프트웨어가 추가로 저장해야 하는 레지스터를 구분해야 하며, 일반 함수의 RET가 아니라 아키텍처의 예외 반환 명령을 사용해야 할 수 있다.
인터럽트는 일반 코드의 임의 지점에서 발생할 수 있으므로, 처리기는 중단된 코드가 사용하던 레지스터와 메모리 상태를 손상하지 않아야 한다. 중첩 인터럽트를 허용한다면 현재 처리기의 상태도 다시 보존할 수 있어야 한다.
페이지 폴트는 항상 치명적인 오류를 뜻하지 않는다. 운영체제는 아직 물리 메모리가 할당되지 않은 가상 페이지, 파일에서 지연 적재할 페이지나 쓰기 시 복사 페이지에 대한 접근을 처리한 뒤 같은 명령을 다시 실행할 수 있다.
동시성과 원자적 연산
여러 스레드나 프로세서 코어가 같은 메모리를 동시에 접근하면 명령의 실행 순서와 값의 관찰 시점이 중요해진다. 동시성 프로그래밍에서는 단순한 적재와 저장만으로 공유 상태를 안전하게 변경할 수 없는 경우가 많으며, 원자적 연산, 메모리 순서 제약과 동기화 구조를 사용한다.
원자적 연산은 다른 실행 주체가 중간 상태를 관찰하지 못하도록 하나의 분리할 수 없는 메모리 동작으로 처리되는 연산이다. 원자적 읽기·수정·쓰기는 값을 읽고 계산한 뒤 새 값을 기록하는 전체 과정을 하나의 경쟁 단위로 만든다.
대표적인 원자적 연산에는 다음이 있다.
- 원자적 적재와 저장
- 교환
- 비교 후 교환
- 원자적 덧셈과 뺄셈
- 원자적 AND, OR와 XOR
- 검사 후 비트 설정
- 예약 적재와 조건부 저장
원자성은 메모리 접근의 순서까지 모두 보장한다는 뜻이 아니다. 현대 ISA와 컴파일러는 성능을 위해 독립적인 적재와 저장의 순서를 바꾸거나 지연할 수 있으므로, 동기화에는 메모리 순서 규칙도 필요하다.
일반적으로 사용되는 순서 의미에는 다음이 있다.
- 다른 순서를 거의 강제하지 않는 완화된 연산
- 이후 연산이 앞당겨지는 것을 제한하는 획득
- 이전 연산이 뒤로 밀리는 것을 제한하는 해제
- 획득과 해제를 결합한 순서
- 하나의 전역 순서처럼 보이도록 하는 순차적 일관성
x86은 일반 적재·저장에 비교적 강한 메모리 순서를 제공하지만 모든 재배치를 자동으로 막지는 않는다. LOCK 접두사가 붙은 읽기·수정·쓰기 명령, XCHG, CMPXCHG와 메모리 장벽 명령이 동기화 구현에 사용된다.
Arm과 RISC-V는 일반적으로 더 완화된 메모리 모델을 사용하며, 획득·해제 의미를 가진 적재·저장 명령이나 명시적인 장벽을 사용한다. 프로그램이 소스 순서대로 명령을 작성했다고 해서 다른 코어가 항상 같은 순서로 결과를 관찰하는 것은 아니다.
RISC-V의 A 확장은 여러 하트가 공유하는 메모리의 동기화를 위해 원자적 읽기·수정·쓰기 명령을 제공한다. 예약 적재·조건부 저장과 원자적 메모리 연산을 포함하며, aq와 rl 비트를 통해 획득·해제 순서를 지정할 수 있다.[116]
RISC-V의 기본 메모리 모델인 RVWMO는 여러 하트에서 발생한 메모리 연산에 허용되는 순서를 정의한다. 일반 메모리 접근은 완화된 순서를 가질 수 있으며, FENCE와 원자 명령의 순서 지정 기능을 통해 필요한 제약을 추가한다.[117]
예약 적재·조건부 저장 방식에서는 프로세서가 주소의 값을 예약 상태로 읽고, 그 사이 다른 실행 주체의 간섭이 없을 때만 조건부 저장을 성공시킨다. 저장이 실패하면 소프트웨어는 값을 다시 읽고 연산을 반복한다.
비교 후 교환은 메모리의 현재 값이 예상값과 같은 경우에만 새 값을 기록한다. 락, 원자적 카운터, 참조 횟수와 락 없는 자료구조를 구현하는 데 사용된다.
원자적 명령이 지원하는 데이터 크기와 정렬 조건은 ISA와 확장에 따라 다르다. 정렬되지 않은 위치나 지원되지 않는 크기에 원자성을 기대할 수 없으며, 더 작은 자료형을 큰 원자 연산으로 에뮬레이션할 때는 같은 워드 안의 다른 값과 충돌할 수 있다.
RISC-V는 기본 A 확장의 워드·더블워드 원자 연산 외에 바이트와 하프워드 원자 연산을 위한 Zabha, 비교 후 교환을 위한 Zacas 등의 표준 확장을 정의한다.[118][119]
스핀락은 원자 연산으로 잠금 상태를 반복 검사하며, 잠금을 얻을 때까지 현재 스레드가 실행을 계속한다. 대기 시간이 짧거나 운영체제 커널처럼 잠자는 것이 부적합한 상황에서 사용될 수 있지만, 긴 대기에서는 프로세서 시간을 낭비할 수 있다.
뮤텍스와 세마포어는 경쟁이 발생했을 때 운영체제에 스레드를 재우도록 요청할 수 있다. 이 경우 사용자 공간의 원자적 빠른 경로와 시스템 호출을 사용하는 느린 경로를 결합하는 구현이 사용될 수 있다.
인터럽트를 비활성화하는 방식은 현재 프로세서에서 인터럽트 처리기와의 경쟁을 막을 수 있지만, 다른 코어의 동시 접근까지 자동으로 막지는 않는다. 다중 프로세서 환경에서는 인터럽트 제어와 별도로 원자적 연산이나 락이 필요하다.
동시성 코드는 CPU 명령뿐 아니라 컴파일러의 최적화도 고려해야 한다. 어셈블리 인라인 구문에서 메모리 부작용과 레지스터 변경을 정확히 선언하지 않으면 컴파일러가 주변 코드의 접근을 재배치하거나 필요한 값을 잘못 유지할 수 있다.
원자적 연산과 메모리 장벽을 과도하게 사용하면 캐시 일관성 트래픽과 파이프라인 정지로 성능이 낮아질 수 있다. 필요한 공유 상태와 순서만 제한하고, 가능하면 실행 주체별 데이터 분리와 메시지 전달을 사용하는 방식도 함께 활용된다.
고급 언어와의 관계
현대 소프트웨어에서 어셈블리어는 고급 프로그래밍 언어를 완전히 대체하기보다, 컴파일러가 생성하는 최종 기계 코드의 표현이자 고급 언어만으로 다루기 어려운 저수준 기능을 보완하는 수단으로 사용된다. 일반적인 프로그램은 C, C++, Rust, Fortran과 같은 고급 언어로 작성되고, 컴파일러가 이를 대상 명령어 집합 아키텍처의 명령으로 변환한다.
고급 언어와 어셈블리어 사이에는 단순한 일대일 대응이 존재하지 않는다. 하나의 고급 언어 문장이 여러 기계 명령으로 변환될 수 있고, 반대로 여러 소스 문장이 최적화 과정에서 하나의 명령열로 합쳐지거나 완전히 제거될 수 있다. 함수 인라인, 상수 전파, 공통 부분식 제거, 레지스터 할당과 벡터화가 적용되면 생성된 어셈블리 코드는 원래 소스의 구조와 크게 달라질 수 있다.
어셈블리 코드를 고급 언어와 결합하는 방법은 크게 다음과 같이 나뉜다.
- 컴파일러가 고급 언어에서 자동으로 생성하는 어셈블리 코드
- 함수 안에 일부 명령을 삽입하는 인라인 어셈블리
- 별도 파일로 작성하여 링크하는 외부 어셈블리 모듈
- ABI와 외부 함수 인터페이스를 통한 다른 언어와의 연결
- 런타임과 표준 라이브러리 내부의 아키텍처별 구현
- 컴파일러의 최적화와 자동 벡터화를 통한 기계 명령 생성
어떤 방식을 선택하든 대상 ISA뿐 아니라 호출 규약, 자료형 표현, 심벌 이름, 스택 정렬, 레지스터 보존과 오브젝트 파일 형식을 맞춰야 한다. 문법적으로 올바른 어셈블리 코드라도 이러한 규칙을 지키지 않으면 다른 언어의 코드와 안전하게 결합할 수 없다.
컴파일러가 생성하는 어셈블리 코드
컴파일러는 고급 언어의 소스 코드를 분석하여 내부 표현으로 변환하고, 최적화한 뒤 대상 프로세서의 기계 명령을 생성한다. 생성 과정에서 어셈블리 텍스트 파일을 중간 출력으로 만들 수도 있고, 컴파일러 내부의 통합 어셈블러를 통해 곧바로 오브젝트 파일을 생성할 수도 있다.
일반적인 컴파일 과정은 다음과 같이 나타낼 수 있다.
고급 언어에서 기계 코드까지
- 고급 언어 원시 코드
- 구문·의미 분석
- 컴파일러 중간 표현
- 기계 독립적 최적화
- 명령 선택과 레지스터 할당
- 기계 종속적 최적화
- 어셈블리 코드 또는 기계 명령
- 오브젝트 파일
컴파일러의 명령 선택 단계는 중간 표현의 연산을 대상 ISA의 명령으로 변환한다. 같은 덧셈이나 메모리 접근도 x86-64, AArch64와 RISC-V에서는 서로 다른 레지스터와 명령 형식으로 표현된다.
레지스터 할당은 중간 계산값과 변수를 제한된 물리 레지스터에 배치한다. 동시에 사용해야 하는 값이 레지스터 수보다 많으면 일부 값을 스택이나 다른 메모리에 임시로 저장하는 스필이 발생할 수 있다.
컴파일러가 생성하는 어셈블리 코드는 다음 요소의 영향을 받는다.
- 대상 ISA와 세부 프로세서 모델
- 활성화된 명령어 확장
- 운영체제와 ABI
- 최적화 수준
- 디버깅 정보 생성 여부
- 위치 독립 코드 여부
- 부동소수점과 정수의 의미 규칙
- 예외 처리와 스택 되감기 방식
- 보안 강화를 위한 코드 생성 옵션
최적화가 비활성화된 코드에서는 소스 변수와 스택 위치가 비교적 직접적으로 대응하고, 함수 프롤로그와 중간 메모리 접근이 많이 나타날 수 있다. 최적화를 활성화하면 변수는 레지스터에 머물거나 완전히 제거될 수 있고, 함수가 호출 위치에 인라인되거나 반복문이 다른 형태로 바뀔 수 있다.
고급 언어의 연산이 항상 한 개의 어셈블리 명령에 대응하는 것은 아니다. 정수 나눗셈, 구조체 복사, 범위를 벗어난 시프트, 큰 정수 연산과 원자적 연산은 여러 명령 또는 런타임 라이브러리 호출로 구현될 수 있다.
반대로 복잡해 보이는 고급 언어 표현이 하나의 명령으로 변환될 수도 있다. 대상 ISA가 특정 비트 조작, 곱셈과 덧셈의 결합, 조건부 선택이나 벡터 연산을 제공하면 컴파일러가 해당 패턴을 인식하여 전용 명령을 선택한다.
컴파일러의 어셈블리 출력은 다음과 같은 목적으로 확인한다.
- 최적화가 의도대로 적용되었는지 분석
- 불필요한 메모리 접근과 함수 호출 확인
- 호출 규약과 인수 전달 방식 확인
- 자동 벡터화 여부 확인
- 특정 명령어 확장의 사용 여부 확인
- 성능 병목과 코드 크기 조사
- 컴파일러 오류와 잘못된 코드 생성 분석
- 고급 언어와 어셈블리 모듈의 연결 검증
그러나 어셈블리 코드만으로 프로그램의 실제 성능을 완전히 판단할 수는 없다. 명령의 지연 시간과 처리량, 캐시 적중률, 분기 예측, 메모리 대역폭과 실행 중 입력 데이터가 함께 성능에 영향을 준다.
GCC는 -S 옵션을 통해 어셈블리 출력에서 컴파일을 멈출 수 있고, Clang도 같은 방식의 출력을 지원한다. 생성되는 문법은 대상과 옵션에 따라 GNU Assembler 문법이나 Intel식 표기로 변경할 수 있다.
LLVM 기반 컴파일러는 소스 언어를 LLVM IR로 변환한 뒤, 공통 최적화와 대상별 백엔드를 통해 기계 코드를 생성한다. 이 구조를 통해 C, C++, Rust와 여러 다른 언어가 같은 최적화·코드 생성 기반을 공유할 수 있다.
컴파일러 출력은 사람이 작성하기 위한 안정적인 소스 인터페이스로 보장되지 않는다. 컴파일러 버전과 옵션이 달라지면 의미상 같은 프로그램도 전혀 다른 명령열로 생성될 수 있다.
인라인 어셈블리
인라인 어셈블리는 고급 언어의 함수 본문 안에 어셈블리 명령을 삽입하는 기능이다. 별도의 어셈블리 파일을 만들지 않고 주변의 변수와 제어 흐름을 이용할 수 있으므로 짧은 저수준 연산을 구현할 때 사용된다.
대표적인 사용 사례는 다음과 같다.
- 고급 언어가 직접 제공하지 않는 명령 실행
- 특수 목적 레지스터와 시스템 상태 접근
- 메모리 장벽과 저수준 동기화
- 포트 입출력과 장치 제어
- 운영체제 커널의 문맥 전환과 시스템 호출 경로
- 암호화·비트 조작 명령의 시험적 사용
- 컴파일러 내장 함수가 없는 아키텍처 기능 접근
GCC는 C와 C++ 코드 안에 어셈블리 명령을 삽입할 수 있는 기본 asm과 확장 asm 형식을 제공한다. 확장 asm은 입력 피연산자, 출력 피연산자, 변경되는 레지스터와 제어 이동을 선언할 수 있다.[120]
확장 인라인 어셈블리에서 컴파일러는 어셈블리 문자열 자체의 의미를 완전히 이해하지 못한다. 프로그래머가 입력과 출력, 제약 조건, 변경 목록을 정확히 선언해야 컴파일러가 주변 코드와 레지스터를 올바르게 배치할 수 있다.
GCC의 피연산자 제약 조건은 값이 일반 레지스터, 특정 종류의 레지스터, 메모리 또는 즉시값에 놓일 수 있는지를 지정한다. 두 피연산자가 같은 위치를 사용해야 한다는 조건도 표현할 수 있다.[121]
인라인 어셈블리에서 선언해야 하는 정보에는 다음이 포함될 수 있다.
- 어셈블리 명령이 읽는 입력값
- 명령이 생성하거나 변경하는 출력값
- 직접 변경되는 레지스터
- 조건 플래그의 변경
- 코드에 나타나지 않은 메모리 읽기와 쓰기
- 어셈블리 코드가 분기할 수 있는 레이블
- 제거하거나 이동하면 안 되는 부수 효과
volatile 지정은 해당 어셈블리 문장이 출력값만으로 판단해 제거되거나 특정 방식으로 최적화되는 것을 제한할 때 사용한다. 그러나 volatile 하나만으로 모든 컴파일러 재배치와 하드웨어 메모리 순서를 자동으로 제어하는 것은 아니다.
메모리를 읽거나 변경하지만 이를 피연산자로 표현하지 않은 인라인 어셈블리는 컴파일러에 메모리 부작용을 알려야 한다. 그렇지 않으면 컴파일러가 주변의 메모리 접근을 잘못 재배치하거나 이미 읽은 값을 재사용할 수 있다.
Rust는 core::arch::asm! 매크로를 통해 인라인 어셈블리를 지원한다. 이 기능은 컴파일러가 생성하는 어셈블리 출력에 직접 작성한 명령을 삽입하며, 피연산자와 옵션을 통해 레지스터·메모리 사용과 부수 효과를 나타낸다.[122]
Rust의 인라인 어셈블리는 unsafe 문맥에서 사용된다. 컴파일러는 어셈블리 명령이 Rust의 메모리 안전성, 유효한 값의 표현과 호출 규약을 지키는지 검증할 수 없으므로 프로그래머가 이를 보장해야 한다.
Clang은 GCC의 확장 인라인 어셈블리 문법과 높은 수준의 호환성을 제공하지만, 모든 대상과 세부 동작이 완전히 같다고 보장할 수는 없다.[123]
인라인 어셈블리의 주요 한계는 컴파일러 최적화와의 경계다. 컴파일러는 선언된 입출력과 변경 정보만 알 수 있고 명령열의 내부 목적을 고급 수준에서 분석하기 어렵다. 이 때문에 레지스터 할당, 명령 스케줄링, 상수 전파와 벡터화를 방해할 수 있다.
긴 함수나 복잡한 알고리즘을 인라인 어셈블리로 작성하면 다음 문제가 발생할 수 있다.
- 주변 고급 언어 코드의 최적화 제한
- 제약 조건과 변경 목록 누락 가능성
- 특정 컴파일러 문법에 대한 의존
- 코드 검토와 디버깅의 어려움
- ISA와 운영체제별 분기 증가
- 새로운 프로세서 세대에 대한 재최적화 부담
고급 언어가 제공하는 컴파일러 내장 함수나 아키텍처 내장 함수로 같은 기능을 표현할 수 있다면, 컴파일러가 레지스터 할당과 명령 스케줄링을 관리할 수 있다는 점에서 인라인 어셈블리보다 유리할 수 있다.
외부 어셈블리 모듈
외부 어셈블리 모듈은 고급 언어 소스와 분리된 어셈블리 파일로 작성하고, 어셈블러를 통해 독립된 오브젝트 파일로 만든 뒤 링커로 결합하는 방식이다.
외부 모듈은 다음과 같은 경우에 적합하다.
- 함수 전체를 어셈블리어로 구현할 때
- 같은 기능의 아키텍처별 구현을 분리할 때
- 긴 SIMD·암호화 연산을 작성할 때
- 부팅 코드, 문맥 전환과 예외 진입부를 구현할 때
- 여러 고급 언어에서 같은 어셈블리 함수를 공유할 때
- 인라인 어셈블리 문법의 제약을 피할 때
외부 어셈블리 모듈은 독립된 소스 파일이므로 일반 어셈블러의 매크로, 섹션, 심벌과 정렬 기능을 사용할 수 있다. 컴파일러의 인라인 어셈블리 제약 문법에 맞출 필요도 없다.
대신 고급 언어의 지역 변수와 표현식에 직접 접근할 수 없으며, 함수 인터페이스를 통해 값을 전달해야 한다. 외부 모듈은 호출 규약과 ABI를 완전히 직접 구현해야 한다.
외부 어셈블리 함수는 일반적으로 다음 조건을 지켜야 한다.
- 호출 규약에 맞는 매개변수 읽기
- 지정된 레지스터에 반환값 기록
- 피호출자 보존 레지스터 복원
- 스택 포인터와 정렬 유지
- 필요한 예외 처리·스택 되감기 정보 제공
- 심벌의 가시성과 이름을 올바르게 지정
- 위치 독립 코드와 재배치 규칙 준수
고급 언어의 컴파일러가 생성한 오브젝트 파일과 같은 형식을 사용해야 한다. Linux의 ELF, Windows의 COFF·PE, Apple 플랫폼의 Mach-O 등 대상 도구 체인에 맞는 파일을 생성해야 링커가 모듈을 결합할 수 있다.
C 계열 함수와 연결할 때는 어셈블리 파일에서 함수 심벌을 전역으로 공개하고, 고급 언어 코드에서 외부 함수로 선언한다. C++ 함수는 이름 맹글링이 적용되므로 일반적으로 extern "C"를 사용하여 C 연결 이름을 지정하거나 실제 장식된 심벌 이름을 맞춰야 한다.
Windows x64에서는 함수 호출 규칙뿐 아니라 비단말 함수의 스택 되감기와 예외 처리에 필요한 프롤로그 규칙 및 메타데이터를 고려해야 한다. Microsoft의 x64 호출 규약은 인수 전달, 레지스터 보존, 스택 정렬과 프롤로그·에필로그 제한을 정의한다.[124]
외부 모듈은 컴파일러가 함수 내부의 명령을 볼 수 없다는 한계도 있다. 링크 시간 최적화가 적용되더라도 일반 오브젝트 파일의 어셈블리 함수는 고급 언어 함수처럼 쉽게 인라인되거나 내부 연산이 재구성되지 않는다.
이 경계는 최적화 안정성이라는 장점이 될 수도 있다. 컴파일러는 함수 내부를 변경하지 않으므로 세밀하게 설계한 명령열을 그대로 유지하기 쉽고, 인터페이스만 ABI에 맞추면 여러 컴파일러에서 사용할 수 있다.
외부 함수 인터페이스
외부 함수 인터페이스(Foreign Function Interface, FFI)는 서로 다른 프로그래밍 언어와 런타임이 함수를 호출하고 데이터를 주고받는 체계다. 어셈블리 함수는 독립적인 고급 언어의 자료형 체계를 갖지 않으므로, 일반적으로 C ABI 또는 플랫폼의 표준 호출 규약을 공통 경계로 사용한다.
FFI에서 맞춰야 하는 요소는 다음과 같다.
- 함수 심벌의 이름과 연결 방식
- 호출 규약
- 매개변수와 반환값의 레지스터·스택 배치
- 정수와 포인터의 크기
- 구조체·공용체의 필드 배치와 정렬
- 불리언과 열거형의 표현
- 문자열의 인코딩과 길이 표현
- 메모리 소유권과 해제 주체
- 오류와 예외 전달 방식
- 스레드·런타임 상태 요구 사항
C ABI가 자주 사용되는 이유는 대부분의 네이티브 언어와 운영체제 도구 체인이 C 함수 호출과 오브젝트 파일 연결을 지원하기 때문이다. 그러나 C 표준 자체가 하나의 보편적인 바이너리 ABI를 정하는 것은 아니며, 실제 규칙은 플랫폼과 아키텍처별 ABI가 정의한다.
Rust에서는 extern "C"를 사용하여 C 호출 규약을 지정할 수 있고, #[no_mangle] 또는 대응하는 심벌 제어 속성을 사용해 외부에서 찾을 수 있는 이름을 만들 수 있다. 어셈블리 모듈은 해당 심벌과 플랫폼 호출 규약을 사용해 Rust 함수와 연결할 수 있다.
C++은 함수 오버로딩, 네임스페이스와 클래스 정보를 심벌 이름에 반영하는 이름 맹글링을 사용한다. 맹글링 규칙은 ABI에 따라 달라질 수 있으므로, 어셈블리어에서 직접 C++ 맹글링 이름에 의존하기보다 C 연결을 가진 얇은 래퍼를 두는 방식이 일반적이다.
가비지 컬렉션이나 이동 가능한 객체를 사용하는 언어는 네이티브 어셈블리 코드에 포인터를 전달할 때 추가 규칙이 필요하다. 객체가 이동되지 않도록 고정하거나, 런타임 핸들을 사용하고, 네이티브 호출 중 루트 정보를 유지해야 할 수 있다.
관리형 런타임과 네이티브 코드 사이에서는 호출 과정에 마셜링이 포함될 수 있다. 문자열, 배열, 구조체와 콜백은 런타임 표현과 네이티브 ABI 표현 사이에서 변환될 수 있으며, 이러한 변환은 추가 비용과 실패 가능성을 만든다.
Windows의 .NET 네이티브 상호 운용에서는 아키텍처에 따라 호출 규약 선택의 의미가 달라진다. Microsoft 문서는 Windows x86에서는 cdecl과 stdcall의 차이를 명시해야 할 수 있지만, x64와 Arm 계열에서는 플랫폼별 단일 네이티브 호출 규약이 사용된다고 설명한다.[125]
함수 포인터와 콜백도 FFI 규칙을 따른다. 고급 언어의 함수를 어셈블리어나 외부 라이브러리에 콜백으로 전달하려면 해당 함수가 예상 호출 규약을 사용하고, 콜백이 유지되는 동안 관련 코드와 런타임 객체의 수명이 보장되어야 한다.
언어 수준 예외를 ABI 경계를 넘어 직접 전달하는 것은 일반적으로 안전하지 않다. C++ 예외, Rust 패닉과 관리형 예외는 서로 다른 스택 되감기·런타임 모델을 사용하므로, 경계 안에서 오류 코드나 명시적인 결과값으로 변환하는 방식이 사용된다.
런타임과 표준 라이브러리
고급 언어로 작성된 프로그램은 사용자 코드만으로 실행되지 않는다. 프로그램 시작, 함수 호출 보조, 메모리 할당, 입출력, 스레드, 예외 처리와 산술 연산을 제공하는 런타임 시스템과 표준 라이브러리가 함께 사용된다.
런타임과 표준 라이브러리의 대부분은 C, C++, Rust나 다른 고급 언어로 작성될 수 있지만, 다음과 같은 부분에는 어셈블리어 또는 컴파일러 내장 함수가 사용될 수 있다.
- 프로세스와 스레드의 시작 코드
- 스택과 실행 환경 초기화
- 문맥 전환
- 시스템 호출 래퍼
- 원자적 연산과 메모리 장벽
- 함수 호출·반환 보조 코드
- 정수 나눗셈과 큰 정수 연산 보조
- 부동소수점 환경 관리
- 메모리 복사·설정·검색
- 암호화와 체크섬
- 예외 처리와 스택 되감기
- 코루틴과 사용자 수준 스레드 전환
운영체제가 프로그램의 진입점으로 제어를 전달하면 곧바로 고급 언어의 main 함수가 실행되는 것은 아닐 수 있다. 시작 코드는 초기 스택에서 인수와 환경 정보를 읽고, 런타임과 정적 객체를 초기화한 뒤 main 또는 언어별 진입 함수를 호출한다.
C 표준 라이브러리의 memcpy, memset, memcmp, 문자열 처리와 수학 함수는 성능을 위해 여러 아키텍처별 구현을 가질 수 있다. 런타임에서 프로세서 기능을 검사하여 SSE, AVX, NEON, SVE 또는 다른 벡터 명령을 사용하는 구현을 선택하기도 한다.
이러한 함수는 단순히 같은 이름의 어셈블리 명령을 실행하는 것이 아니다. 데이터 크기, 정렬, 주소 중첩, 캐시 구조와 대상 프로세서에 따라 여러 알고리즘과 명령열을 선택할 수 있다.
컴파일러는 표준 라이브러리 함수의 의미를 알고 일부 호출을 직접 최적화할 수 있다. 작은 고정 크기의 memcpy는 함수 호출 대신 적재·저장 명령으로 인라인될 수 있고, 반복문이 라이브러리 함수 호출로 변환될 수도 있다.
언어 런타임은 ISA가 직접 제공하지 않는 연산을 보조 함수로 구현할 수 있다. 예를 들어 대상 프로세서에 특정 크기의 정수 나눗셈이나 원자 연산이 없다면 컴파일러가 런타임 라이브러리 함수를 호출하도록 코드를 생성할 수 있다.
GCC 계열에서는 libgcc가 정수 산술, 부동소수점 변환, 예외 처리와 아키텍처별 보조 기능을 제공한다. Clang과 LLVM 계열에서는 compiler-rt가 유사한 저수준 런타임 기능과 검사 도구의 실행 지원을 제공할 수 있다.
고급 언어의 안전성 기능도 저수준 코드와 연결된다. 스택 오버플로 검사, 정수 오버플로 검사, 배열 범위 검사, 패닉과 예외 처리는 조건 분기와 런타임 호출로 변환될 수 있다.
어셈블리어로 런타임 함수를 구현할 때는 일반 함수보다 더 엄격한 조건이 적용될 수 있다. 프로그램 초기화 전이나 스택이 완전히 준비되지 않은 시점에 실행되거나, 컴파일러가 정상적으로 제공한다고 가정하는 기본 연산 자체를 구현해야 하기 때문이다.
최적화와 자동 벡터화
컴파일러 최적화는 고급 언어의 의미를 유지하면서 실행 시간, 코드 크기, 전력 사용이나 다른 목표에 맞게 생성 코드를 변환하는 과정이다. 어셈블리어는 최적화의 최종 결과를 확인하는 수단이며, 일부 경우 프로그래머가 직접 작성한 최적화 구현의 형태이기도 하다.
대표적인 최적화에는 다음이 포함된다.
- 상수 접기와 상수 전파
- 사용되지 않는 코드 제거
- 공통 부분식 제거
- 함수 인라인
- 루프 불변식 이동
- 반복문 펼치기
- 강도 감소
- 분기 단순화와 조건부 선택
- 레지스터 할당과 스필 감소
- 명령 결합과 스케줄링
- 자동 벡터화
자동 벡터화는 반복되는 스칼라 연산을 SIMD 또는 벡터 명령으로 변환하여 여러 데이터 요소를 한 번에 처리하도록 하는 최적화다.
LLVM은 반복문을 넓은 벡터 연산으로 바꾸는 Loop Vectorizer와, 여러 독립된 스칼라 연산을 묶는 SLP Vectorizer를 제공한다. 두 벡터화기는 서로 다른 형태의 병렬성을 찾으며 기본 최적화 파이프라인에서 사용된다.[126]
반복문 벡터화는 다음과 같은 변환을 수행할 수 있다.
반복문의 자동 벡터화
- 여러 반복에서 같은 스칼라 연산 발견
- 반복 사이의 데이터 의존성 검사
- 벡터 길이와 비용 계산
- 여러 요소를 한 번에 처리하는 벡터 명령 생성
- 남는 요소를 처리하는 스칼라 반복문 생성
자동 벡터화가 가능하려면 반복 사이의 의존성을 분석할 수 있어야 한다. 한 반복에서 기록한 값이 다음 반복의 입력으로 사용되거나, 두 포인터가 같은 메모리를 가리킬 가능성을 배제할 수 없다면 컴파일러는 벡터화를 포기하거나 실행 시 별칭 검사를 추가할 수 있다.
벡터화 여부에 영향을 주는 요소는 다음과 같다.
- 반복 사이의 데이터 의존성
- 포인터 별칭 가능성
- 메모리 정렬
- 연속적이거나 규칙적인 접근 패턴
- 반복 횟수
- 대상 프로세서의 벡터 폭
- 벡터 명령의 비용
- 분기와 함수 호출
- 부동소수점 연산의 의미 제약
- 예외와 오버플로 동작
부동소수점 덧셈은 결합법칙이 엄밀히 성립하지 않으므로, 연산 순서를 바꾸는 벡터화가 결과의 마지막 비트를 변경할 수 있다. 컴파일러는 언어 규칙과 부동소수점 최적화 옵션에 따라 이러한 변환을 허용하거나 제한한다.
컴파일러는 벡터화가 가능하더라도 항상 적용하지 않는다. 반복 횟수가 작거나 정렬 처리와 나머지 반복문의 비용이 더 크다고 판단하면 스칼라 코드를 유지할 수 있다.
SLP 벡터화는 같은 기본 블록 안의 유사한 독립 연산을 묶는다. 구조체의 여러 필드 계산, 펼쳐진 반복문과 연속된 수식에서 벡터 명령을 생성할 수 있다.
자동 벡터화가 생성하는 명령은 대상 옵션에 따라 달라진다. x86-64에서는 SSE, AVX, AVX2 또는 AVX-512, AArch64에서는 Advanced SIMD나 SVE, RISC-V에서는 V 확장을 사용할 수 있다.
특정 프로세서 명령을 직접 사용하기 위해 내장 함수 또는 인스트린식을 사용할 수도 있다. 인스트린식은 고급 언어의 함수 형태로 벡터 레지스터와 명령을 표현하지만, 컴파일러가 피연산자 배치와 주변 명령 스케줄링을 관리할 수 있다.
인스트린식은 일반 스칼라 코드보다 ISA에 종속적이지만 인라인 어셈블리보다는 컴파일러 최적화와 통합되기 쉽다. 다만 하나의 인스트린식이 모든 경우에 정확히 하나의 기계 명령으로 변환된다고 보장할 수는 없다.
수동 어셈블리 최적화는 다음과 같은 경우에 선택될 수 있다.
- 컴파일러가 필요한 명령이나 패턴을 생성하지 못하는 경우
- 처리량과 지연 시간을 매우 세밀하게 조정해야 하는 경우
- 여러 프로세서 세대별 최적 구현을 제공하는 경우
- 암호화처럼 일정한 실행 시간과 명령 배열이 중요한 경우
- 언어와 컴파일러가 표현하지 못하는 저수준 상태를 다루는 경우
그러나 수동 어셈블리 코드가 항상 컴파일러 생성 코드보다 빠른 것은 아니다. 현대 컴파일러는 레지스터 할당, 명령 결합, 벡터화와 여러 마이크로아키텍처의 비용 모델을 적용하며, 프로세서 세대가 바뀌면 과거에 최적이었던 명령열이 불리해질 수 있다.
성능이 중요한 라이브러리는 일반적으로 다음 구조를 사용한다.
- 유지보수가 쉬운 고급 언어의 기본 구현
- 컴파일러 자동 벡터화를 유도하는 구현
- ISA별 인스트린식 구현
- 필요한 경우에만 사용하는 수동 어셈블리 구현
- 런타임 프로세서 기능 검사와 구현 선택
- 정확성과 성능을 비교하는 공통 시험
따라서 어셈블리어와 고급 언어의 관계는 경쟁 관계라기보다 역할 분담에 가깝다. 고급 언어는 프로그램 구조, 자료형, 안전성, 이식성과 유지보수를 담당하고, 어셈블리어는 컴파일 결과를 분석하거나 하드웨어 경계의 제한된 부분을 구현하는 데 사용된다. 현대적인 저수준 개발에서는 가능한 많은 코드를 고급 언어와 컴파일러에 맡기고, 실제 필요성이 검증된 부분에만 어셈블리어를 적용하는 방식이 일반적이다.
개발 도구
어셈블리어 개발에는 원시 코드를 기계어로 변환하는 어셈블러뿐 아니라, 오브젝트 파일과 실행 파일을 결합하는 링커, 실행 상태를 조사하는 디버거, 기계어를 다시 명령어로 표시하는 디스어셈블러와 실제 성능을 측정하는 프로파일러가 함께 사용된다.
각 도구가 처리하는 대상은 서로 다르다. 어셈블러는 작성된 소스를 기계 명령과 데이터로 변환하고, 디버거는 실행 중인 프로그램의 레지스터·메모리·제어 흐름을 조사한다. 디스어셈블러는 이미 생성된 바이너리를 정적으로 분석하며, 프로파일러는 실행 중 수집한 표본과 하드웨어 성능 계수를 바탕으로 실제 병목을 찾는다.
대표적인 도구 체인은 다음과 같이 구성될 수 있다.
어셈블리 개발과 분석 도구
- 어셈블리 원시 코드
- 어셈블러
- 오브젝트 파일
- 링커
- 실행 파일
- 디버거와 디스어셈블러
- 프로파일러와 성능 분석기
하나의 제품이 여러 역할을 수행하기도 한다. GNU Binutils에는 GNU Assembler뿐 아니라 ld, objdump, readelf, nm과 여러 바이너리 도구가 포함되고, LLVM의 MC 계층은 어셈블, 디스어셈블과 오브젝트 파일 처리를 공통 기반에서 제공한다.
개발 도구를 선택할 때는 다음 요소를 함께 고려해야 한다.
- 지원하는 명령어 집합 아키텍처
- 사용하는 어셈블리 문법
- 생성할 수 있는 오브젝트 파일 형식
- 운영체제와 ABI
- 매크로와 전처리 기능
- 디버깅 정보 형식
- 링커와 컴파일러의 호환성
- 크로스 컴파일 지원
- 새 ISA 확장에 대한 지원 시점
- 프로파일링할 하드웨어와 운영체제
GNU Assembler
GNU Assembler는 GNU Binutils에 포함된 어셈블러로, 실행 파일 이름을 따라 흔히 as 또는 GAS라고 부른다. 여러 명령어 집합과 오브젝트 파일 형식을 지원하며 GCC와 GNU 도구 체인의 기본 어셈블러로 널리 사용된다.
GNU Assembler는 하나의 공통 기계어를 대상으로 하는 단일 어셈블러가 아니라, 여러 대상 아키텍처에 대응하는 어셈블러 계열이다. 공통적인 심벌·표현식·지시어 체계를 제공하면서, 실제 명령어와 일부 문법은 선택한 대상에 따라 달라진다. 공식 설명서도 GNU as를 여러 아키텍처에서 유사한 환경을 제공하는 어셈블러 계열로 설명한다.[127]
GNU Assembler는 다음과 같은 아키텍처 계열을 지원한다.
- x86과 x86-64
- Arm과 AArch64
- RISC-V
- MIPS
- PowerPC와 Power ISA
- IBM Z
- SPARC
- AVR
- 여러 임베디드·과거 아키텍처
정확한 지원 대상은 Binutils를 빌드할 때 선택한 구성과 버전에 따라 달라진다. 호스트 컴퓨터와 다른 ISA용 오브젝트 파일을 생성하는 크로스 어셈블러는 일반적으로 대상 이름이 붙은 aarch64-linux-gnu-as, riscv64-linux-gnu-as와 같은 형태로 제공된다.
GNU Assembler는 소스의 심벌, 상수, 표현식, 섹션과 지시어를 처리하여 재배치 가능한 오브젝트 파일을 생성한다. 출력 형식으로는 대상 플랫폼에 따라 ELF, PE/COFF, Mach-O와 여러 임베디드 형식을 사용할 수 있다.
GNU Assembler의 일반적인 특징은 다음과 같다.
- 점으로 시작하는 어셈블러 지시어
- 섹션과 심벌 관리
- 재배치 정보 생성
- 매크로와 조건부 어셈블리
- 데이터와 정렬 지시어
- DWARF 디버깅 정보 생성 지원
- 호출 프레임 정보 지시어
- 아키텍처별 명령과 확장 선택
- 컴파일러가 생성한 어셈블리 소스 처리
GNU Assembler는 전통적으로 x86에서 AT&T 문법을 기본으로 사용한다. 레지스터 앞에 %, 즉시값 앞에 $를 붙이고 원본 피연산자를 목적지보다 먼저 쓰는 방식이다.
x86에서는 .intel_syntax 지시어를 이용해 Intel식 피연산자 표기로 전환할 수 있다. 다만 이 모드는 GNU Assembler의 명령 표기 방식을 바꾸는 것으로, NASM이나 MASM의 매크로·전처리·지시어 문법 전체를 구현하는 것은 아니다.
다른 아키텍처에서는 각 ISA의 관례와 GNU 도구 체인의 문법을 따른다. AArch64, RISC-V와 MIPS 소스에 x86의 %·$ 표기 규칙을 그대로 적용하지 않는다.
GNU Assembler는 직접 호출할 수도 있지만, 일반적으로 GCC나 Clang의 컴파일러 드라이버를 통해 사용한다. 컴파일러 드라이버는 대상 옵션, 포함 경로, 어셈블러와 링커 호출을 전체 도구 체인에 맞춰 구성한다.
전처리가 필요한 소스에는 C 전처리기를 사용할 수 있다. UNIX 계열에서는 소문자 .s를 일반 어셈블리 소스, 대문자 .S를 전처리를 거치는 어셈블리 소스로 취급하는 관례가 있다.
GNU Assembler의 .macro, .rept, .irp와 조건부 지시어는 반복되는 저수준 코드를 생성하는 데 사용된다. 다만 매크로 언어는 C 전처리기나 NASM 전처리기와 별개의 체계다.
as가 생성한 오브젝트 파일은 GNU ld, GCC, Clang, LLVM lld 또는 같은 파일 형식을 지원하는 다른 링커로 결합할 수 있다. 실제 호환성은 오브젝트 형식과 ABI, 재배치 종류 및 심벌 규칙이 일치하는지에 따라 결정된다.
GNU Assembler는 GCC 출력과 Linux·BSD·임베디드 도구 체인에서 폭넓게 사용된다. 여러 ISA를 하나의 도구 계열로 다룰 수 있다는 장점이 있지만, 아키텍처별 문법과 동작 차이를 별도로 이해해야 한다.
NASM
NASM(Netwide Assembler)은 x86과 x86-64를 대상으로 하는 어셈블러다. 여러 운영체제에서 실행할 수 있고, Intel 계열의 비교적 일관된 소스 문법과 독립적인 전처리기를 제공한다.
NASM 프로젝트는 NASM을 거의 모든 현대 플랫폼에서 실행할 수 있으며 오래된 형식과 새로운 형식을 포함한 여러 플랫폼용 코드를 생성하는 x86 어셈블러로 설명한다.[128]
NASM은 x86 전용이라는 점에서 GNU Assembler와 범위가 다르다. GNU Assembler는 여러 ISA를 지원하지만, NASM은 x86 명령어와 Intel식 문법에 집중한다.
NASM의 대표적인 특징은 다음과 같다.
- Intel식 목적지 우선 피연산자 순서
- 대괄호를 사용한 메모리 참조
byte,word,dword,qword등의 크기 지정자- 16비트·32비트·64비트 코드 생성
- 독립적인 매크로 전처리기
- 여러 오브젝트 파일과 원시 바이너리 출력
- 목록 파일 생성
- 위치 독립 주소 표현 지원
- 명확한 메모리와 즉시값 구분
NASM의 기본적인 명령 표기는 다음과 같은 형태를 사용한다.
mov eax, [value]
대괄호 안의 식은 메모리 주소를 나타낸다. 단순히 value라고 작성한 표현과 [value]는 NASM에서 서로 다른 의미를 가질 수 있으므로, 다른 Intel식 어셈블러와 소스를 옮길 때 주의해야 한다.
출력 형식은 -f 옵션으로 지정한다. NASM은 ELF, COFF, Mach-O, 원시 바이너리와 여러 과거 형식을 지원한다. 지원 형식과 세부 옵션은 버전에 따라 달라질 수 있다. 공식 문서는 명령행의 -f 옵션을 출력 오브젝트 형식 선택 기능으로 정의한다.[129]
원시 바이너리 출력은 부트 섹터, 펌웨어와 운영체제 초기 코드처럼 일반적인 오브젝트 파일 헤더가 필요하지 않은 환경에서 사용된다. 이 경우 링크와 재배치의 상당 부분을 소스의 ORG, 섹션과 주소 계산으로 직접 관리해야 한다.
NASM 전처리기는 다음 기능을 제공한다.
%define을 이용한 단일 행 매크로%macro를 이용한 여러 행 매크로%include를 통한 파일 포함%if계열의 조건부 처리- 반복과 토큰 조작
- 매크로 지역 레이블
- 인수 수와 문맥에 따른 매크로 선택
이 전처리기는 어셈블러 본체보다 먼저 소스 텍스트를 확장한다. 따라서 어셈블 오류를 분석할 때는 실제로 확장된 명령열을 확인해야 할 수 있다.
NASM의 목록 파일은 소스와 생성된 기계어 바이트, 주소를 함께 확인하는 데 유용하다. 디스어셈블 결과와 달리 원본 매크로·심벌의 문맥을 보존하면서 생성 코드를 살펴볼 수 있다.
NASM 배포에는 원시 바이너리를 디스어셈블하는 ndisasm이 포함될 수 있다. ndisasm은 파일 형식의 섹션과 심벌을 해석하기보다 바이트 스트림을 지정된 비트 모드의 x86 명령으로 변환하는 단순한 도구다.
NASM은 Linux, BSD, Windows, macOS용 x86 코드와 독립 실행형 프로그램 개발에 사용된다. 다만 Windows API와 Visual C++ 도구 체인에서 사용할 경우 PE/COFF 출력, Windows 호출 규약과 스택 되감기 정보를 직접 맞춰야 한다.
NASM 소스는 Intel 문법을 사용하더라도 MASM이나 FASM과 완전히 호환되지 않는다. 지시어, 매크로, 심벌 표현과 메모리 피연산자 해석이 다르므로 변환 과정이 필요하다.
MASM
MASM(Microsoft Macro Assembler)은 Microsoft의 x86 및 x64 어셈블러다. Visual Studio와 Microsoft C++ 도구 체인에 포함되어 Windows용 COFF 오브젝트 파일과 실행 구성 요소를 만드는 데 사용된다.
MASM은 Intel식 명령어 문법과 자체적인 매크로·자료형·프로시저 지시어를 제공한다. Microsoft는 MASM의 매크로 언어가 반복, 산술과 문자열 처리 기능을 포함한다고 설명한다.[130]
32비트와 이전 x86 코드를 위한 실행 파일은 일반적으로 ml.exe, x64 코드를 위한 실행 파일은 ml64.exe다. 두 도구는 공통된 MASM 계열 문법을 사용하지만 지원하는 지시어와 고수준 기능이 완전히 같지는 않다.
MASM의 주요 기능은 다음과 같다.
- Intel식 x86·x64 명령 표기
- Microsoft COFF 오브젝트 생성
- Visual C++ 링커와의 통합
- 프로시저와 매개변수 선언
- 구조체·공용체·자료형 지시어
- 강력한 매크로 언어
- 조건부 어셈블리
- Windows 호출 규약 지원
- x64 예외 처리와 스택 되감기 정보 생성
MASM은 .CODE, .DATA, .CONST와 같은 단순화된 섹션 지시어를 제공한다. PROC와 ENDP는 프로시저의 시작과 끝을 나타내며, 환경과 옵션에 따라 프롤로그·에필로그 및 심벌 정보를 생성하는 데 관여할 수 있다.
32비트 MASM에서는 PROTO, INVOKE와 같은 지시어를 사용하여 함수 원형과 호출을 고수준 형태로 표현할 수 있다. 하지만 이러한 기능을 일반적인 기계 명령이나 모든 MASM 모드의 공통 기능으로 보아서는 안 된다.
x64 MASM은 Visual Studio의 C++ 프로젝트에 어셈블리 소스 파일을 포함하여 오브젝트 파일로 컴파일하고, C++ 코드와 링크할 수 있다. Microsoft 문서는 ml64.exe가 x64 어셈블리 소스를 오브젝트 파일로 만들고 Visual C++ 대상과 결합할 수 있다고 설명한다.[131]
Windows x64에는 C와 C++에서 사용할 수 있는 전통적인 MSVC 인라인 어셈블리 구문이 제공되지 않는다. 따라서 x64 어셈블리 코드는 별도 MASM 파일이나 컴파일러 내장 함수로 작성하는 방식이 사용된다.
x64 함수가 비휘발성 레지스터를 사용하거나 스택 공간을 확보하면 Windows의 예외 처리와 스택 되감기가 이해할 수 있는 프롤로그 및 메타데이터를 제공해야 할 수 있다. MASM은 .PUSHREG, .ALLOCSTACK, .SETFRAME, .ENDPROLOG 등의 지시어를 제공한다.
MASM은 Windows 시스템 프로그래밍, 드라이버, 런타임 라이브러리, 성능 중심 코드와 기존 Microsoft 어셈블리 프로젝트에서 사용된다. PE/COFF와 Windows ABI에 긴밀하게 통합되어 있다는 장점이 있지만 다른 운영체제와 오브젝트 형식으로의 이식성은 제한적이다.
MASM 문법은 Intel 공식 문서의 표기와 유사하지만 같은 것은 아니다. MASM의 PTR, 자료형, OFFSET, 세그먼트, 매크로와 프로시저 지시어는 어셈블러 고유 기능이다.
FASM
FASM(flat assembler)은 주로 x86과 x86-64를 대상으로 하는 독립형 어셈블러다. 작은 배포 크기, 빠른 처리와 자체 매크로 시스템을 특징으로 하며 Windows, Linux와 여러 실행 환경에서 사용할 수 있다.
FASM은 여러 패스를 수행하여 심벌과 표현식을 해결하고 명령 인코딩의 크기를 결정한다. 공식 설명서는 FASM을 x86 아키텍처를 대상으로 하며 생성되는 기계 코드의 크기를 최적화하기 위해 여러 패스를 수행하는 어셈블러로 설명한다.[132]
FASM의 기본 특징은 다음과 같다.
- Intel식 x86·x86-64 문법
- 다중 패스 어셈블
- 자체 매크로·전처리 언어
- 원시 바이너리 생성
- PE, ELF, COFF 등의 파일 형식 생성
- 외부 링커 없이 일부 완성 이미지 생성
- 소스에서 파일 구조를 세밀하게 제어하는 기능
- 소형 독립 실행 배포
FASM은 어셈블러 자체가 실행 파일이나 오브젝트 파일의 구조를 생성할 수 있다. 소스에서 format과 섹션 관련 지시어를 지정하여 PE 또는 ELF 같은 형식을 만들 수 있으며, 원시 바이너리와 부트 이미지도 생성할 수 있다.
FASM의 매크로 시스템은 단순 문자열 치환보다 복잡한 소스 생성에 사용할 수 있다. 반복, 조건, 토큰 변환과 패턴 기반 매크로를 활용하여 고수준 문법에 가까운 구문을 구성할 수 있다.
FASM은 소스 표현식과 심벌이 해결될 때까지 여러 패스를 수행한다. 앞쪽 명령의 크기가 뒤쪽 심벌의 위치에 영향을 주고 다시 명령 인코딩 선택에 영향을 주는 경우에도 반복적으로 배치를 계산할 수 있다.
분기 명령은 목적지 거리에 따라 짧은 형식과 가까운 형식이 존재할 수 있다. FASM은 여러 패스에서 주소를 계산하여 가능한 인코딩을 선택할 수 있다.
FASM의 기본 소스는 NASM과 유사한 Intel식 외형을 가질 수 있지만 지시어와 매크로 문법은 서로 다르다. NASM 소스를 FASM에서 그대로 어셈블하거나 그 반대 방향으로 사용할 수 있다고 가정해서는 안 된다.
FASM 계열에는 FASM 1 외에도 새로운 전처리·매크로 구조를 중심으로 한 fasmg가 있다. fasmg는 특정 ISA에 고정된 전통적 어셈블러라기보다 매크로로 언어와 인코딩을 구성할 수 있는 어셈블 엔진에 가깝다.
FASM은 운영체제·부트로더·데모·소형 실행 파일과 직접적인 바이너리 배치가 필요한 프로젝트에서 사용된다. 외부 도구에 대한 의존이 적은 대신, 대규모 플랫폼 도구 체인과 통합할 때는 디버깅 정보·ABI·빌드 시스템을 직접 구성해야 할 수 있다.
LLVM Integrated Assembler
LLVM Integrated Assembler는 LLVM의 기계 코드 기반인 LLVM MC를 이용해 컴파일러 내부에서 어셈블리 소스를 기계어와 오브젝트 파일로 변환하는 기능이다.
전통적인 컴파일러는 어셈블리 텍스트를 외부 시스템 어셈블러에 전달하지만, LLVM 통합 어셈블러를 사용하는 Clang은 별도의 as 프로세스를 거치지 않고 내부에서 직접 오브젝트 파일을 생성할 수 있다.
Clang은 통합 어셈블러를 지원하는 대상에서 이를 기본으로 사용한다. 외부 시스템 어셈블러를 사용하려면 -fno-integrated-as 옵션을 지정할 수 있다.[133]
LLVM MC는 다음 영역의 공통 기반을 제공한다.
- 어셈블리 구문 분석
- 명령어 인코딩
- 어셈블리 텍스트 출력
- 명령어 디코딩
- 오브젝트 파일 생성
- 재배치 생성
- 디스어셈블
- 대상 아키텍처별 명령 정보
LLVM MC 프로젝트는 어셈블리, 디스어셈블리와 오브젝트 파일 형식 처리를 공통 기반에서 해결하기 위해 만들어졌다.[134]
통합 어셈블러의 장점은 다음과 같다.
- 외부 어셈블러 실행 과정 감소
- 컴파일러와 진단 체계 통합
- 크로스 컴파일 환경 구성 단순화
- 대상 정보와 명령 정의의 공유
- 동일한 기반을 사용하는 디스어셈블러·오브젝트 도구 제공
- 시스템에 설치된 어셈블러 버전 의존 감소
통합 어셈블러는 x86, Arm, AArch64, RISC-V와 여러 LLVM 대상에서 사용된다. 그러나 모든 LLVM 백엔드가 같은 수준의 어셈블러·오브젝트 파일 지원을 제공하는 것은 아니며, 특정 지시어나 새 ISA 확장의 지원 시점이 GNU Assembler와 다를 수 있다.
LLVM Integrated Assembler는 일반적으로 대상 플랫폼의 관례적인 어셈블리 문법을 지원한다. x86에서는 GNU식 AT&T 문법과 Intel식 표현을 다룰 수 있고, 다른 ISA에서는 해당 LLVM 대상의 문법을 따른다.
GNU Assembler 호환 문법을 폭넓게 처리하지만 모든 GNU 확장이 동일하게 지원되는 것은 아니다. 특정 매크로, 오래된 지시어, 비표준 문법이나 대상별 특수 기능에 의존한 소스는 통합 어셈블러에서 실패할 수 있다.
이 때문에 빌드 시스템은 필요에 따라 통합 어셈블러와 외부 어셈블러를 선택한다. 같은 소스가 두 어셈블러에서 모두 처리되더라도 경고, 재배치 선택과 오브젝트 메타데이터에서 차이가 나타날 수 있다.
LLVM Integrated Assembler는 Clang이 생성하는 코드뿐 아니라 사용자가 작성한 .s와 .S 소스도 처리할 수 있다. llvm-mc 도구를 이용하면 LLVM MC 계층을 직접 사용해 어셈블, 인코딩 표시와 명령 검사를 수행할 수 있다.
LLVM의 어셈블리 도구와 LLVM IR 어셈블리 언어는 구분해야 한다. LLVM IR의 텍스트 형식도 어셈블리 언어라고 불리지만, 이는 실제 CPU 명령을 직접 표현하는 x86·AArch64 어셈블리어가 아니라 LLVM 중간 표현이다.
디버거
디버거는 프로그램의 실행을 시작·중지하고, 특정 명령에서 실행을 멈추며, 레지스터와 메모리 및 제어 흐름을 조사하는 도구다. 어셈블리 개발에서는 고급 언어의 변수보다 실제 명령 주소와 레지스터 상태가 직접적인 분석 대상이 된다.
디버거의 주요 기능은 다음과 같다.
- 프로그램 실행과 일시 정지
- 소스 줄 또는 명령 단위 단계 실행
- 소프트웨어·하드웨어 중단점
- 메모리 감시점
- 범용·벡터·상태 레지스터 표시
- 메모리와 스택 조사
- 기계 명령 디스어셈블
- 호출 스택 추적
- 심벌과 소스 줄 연결
- 예외·신호·인터럽트 상태 조사
- 코어 덤프 분석
- 원격 장치 디버깅
어셈블리 수준에서 한 명령씩 실행할 때는 소스 줄 단위 단계 실행과 명령 단위 단계 실행을 구분해야 한다. 하나의 소스 줄이 여러 명령으로 확장되거나 디버깅 정보가 없으면 명령 단위 실행이 필요하다.
GDB
GDB(GNU Debugger)는 GNU 프로젝트의 디버거로, 여러 언어와 명령어 집합 및 운영체제를 지원한다. 로컬 프로세스, 코어 덤프와 원격 대상에 연결하여 실행 상태를 분석할 수 있다.
GDB의 목적은 다른 프로그램이 실행되는 동안 내부에서 무슨 일이 발생하는지, 또는 프로그램이 충돌했을 때 어떤 상태였는지를 조사할 수 있게 하는 것이다.[135]
GDB에서는 다음과 같은 어셈블리 수준 기능을 사용할 수 있다.
- 주소와 함수에 중단점 설정
stepi와nexti를 통한 명령 단위 실행info registers를 통한 레지스터 확인- 메모리 내용과 형식 지정 출력
- 현재 또는 지정한 범위의 디스어셈블
- Intel·AT&T 등의 디스어셈블 문법 선택
- 스택 프레임과 반환 주소 조사
- 원격 GDB 서버와 연결
- 하드웨어 감시점을 통한 메모리 변경 추적
레지스터 이름은 대상 아키텍처마다 다르며, GDB는 info registers를 통해 현재 대상에서 사용 가능한 레지스터를 보여준다.[136]
소프트웨어 중단점은 일반적으로 대상 명령을 트랩 명령으로 일시 교체하고, 실행이 멈추면 원래 명령을 복구하는 방식으로 구현된다. 읽기 전용 메모리나 ROM에는 이 방식을 사용할 수 없어 하드웨어 중단점이 필요할 수 있다.
하드웨어 중단점과 감시점은 프로세서의 디버그 레지스터를 사용하므로 수가 제한될 수 있다. GDB도 대상 하드웨어가 제공하는 중단점 자원에 영향을 받는다.[137]
GDB는 심벌과 DWARF 디버깅 정보가 있으면 기계 명령을 함수·소스 줄·변수와 연결할 수 있다. 최적화된 코드에서는 변수가 제거되거나 위치가 바뀌므로 일부 값이 표시되지 않을 수 있다.
LLDB
LLDB는 LLVM 프로젝트의 디버거다. Clang과 LLVM 도구 체인, Apple 플랫폼의 개발 환경에 널리 사용되며 C, C++, Objective-C, Swift와 기계 코드 수준의 디버깅을 지원한다.
LLDB는 LLVM의 명령 디코딩과 오브젝트 파일 처리 기반을 활용할 수 있다. 중단점, 명령 단위 실행, 레지스터·메모리 조사, 디스어셈블과 원격 디버깅 기능을 제공한다.
GDB와 LLDB는 비슷한 작업을 수행하지만 명령 문법, 확장 API, 지원 플랫폼과 디버깅 서버 구조가 다르다. IDE는 사용자 인터페이스 뒤에서 GDB 또는 LLDB를 제어할 수 있다.
WinDbg와 Visual Studio Debugger
WinDbg는 Windows의 사용자 모드와 커널 모드 디버깅에 사용되는 Microsoft 도구다. 실행 중인 프로세스, 충돌 덤프, Windows 커널과 드라이버를 분석할 수 있다.
WinDbg는 x86·x64·Arm 계열 명령, Windows 심벌, 스레드, 예외, 가상 메모리와 운영체제 내부 구조를 조사하는 기능을 제공한다.
Visual Studio Debugger는 C++ 프로젝트에서 소스와 어셈블리를 함께 표시할 수 있다. 디스어셈블 창, 레지스터 창, 메모리 창과 호출 스택을 통해 MASM 또는 컴파일러 생성 코드를 분석할 수 있다.
디버거가 보여주는 디스어셈블 결과는 실제 메모리에 존재하는 기계 명령을 기준으로 한다. 소스에 작성한 의사 명령어, 매크로와 원래의 주석은 어셈블 과정에서 사라질 수 있다.
디스어셈블러
디스어셈블러는 기계어 바이트를 해석하여 어셈블리 명령으로 표시하는 도구다. 실행 파일, 공유 라이브러리, 오브젝트 파일, 펌웨어나 메모리 덤프의 코드를 조사하는 데 사용된다.
디스어셈블러는 일반적으로 다음 작업을 수행한다.
- 기계어 바이트를 명령어 니모닉과 피연산자로 변환
- 명령 주소와 바이트 표시
- 심벌 이름과 재배치 표시
- 함수와 섹션별 디스어셈블
- 소스 줄과 명령 결합
- Intel 또는 AT&T 등의 문법 선택
- 오브젝트 파일 헤더와 섹션 조사
- 제어 흐름과 함수 경계 분석
디스어셈블은 어셈블의 완전한 역과정이 아니다. 기계 코드에는 원래 소스의 주석, 매크로 이름, 지역 변수명, 자료형과 의사 명령어 표기가 남지 않을 수 있다.
같은 기계 명령을 여러 어셈블리 별칭으로 표현할 수 있으므로, 디스어셈블러가 출력한 니모닉이 원래 작성된 니모닉과 다를 수도 있다.
GNU objdump
GNU Binutils의 objdump는 오브젝트 파일과 실행 이미지의 여러 정보를 표시하는 도구다. 파일 헤더, 섹션, 심벌, 재배치, 동적 정보와 기계 명령을 출력할 수 있다.
objdump는 하나 이상의 오브젝트 파일 정보를 표시하며, 디스어셈블과 소스 코드 결합 출력 기능을 제공한다.[138]
대표적인 용도는 다음과 같다.
- 실행 섹션 디스어셈블
- 모든 섹션을 명령으로 해석
- 원시 명령 바이트 표시
- 심벌 테이블과 재배치 확인
- 소스와 명령의 혼합 출력
- 특정 함수 또는 주소 범위 분석
- 대상별 디스어셈블 옵션 지정
기본적으로 objdump는 파일의 아키텍처와 오브젝트 형식 정보를 사용해 명령을 해석한다. 원시 바이너리에는 이러한 정보가 없으므로 파일 형식과 대상 아키텍처를 별도로 지정해야 한다.
가변 길이 명령어를 사용하는 x86에서는 시작 주소가 잘못되면 같은 바이트열이 다른 명령으로 해석될 수 있다. 데이터와 코드가 섞여 있는 영역을 무조건 디스어셈블하면 데이터가 유효해 보이는 명령으로 나타날 수도 있다.
llvm-objdump
llvm-objdump는 LLVM의 오브젝트 파일 표시·디스어셈블 도구다. 오브젝트 파일과 최종 링크된 이미지를 읽고 파일 정보와 명령을 출력한다.[139]
LLVM MC의 명령 디코더와 대상 정보를 사용하므로 Clang·LLVM이 지원하는 아키텍처 및 오브젝트 형식과 긴밀하게 연동된다.
llvm-objdump는 다음 정보를 다룰 수 있다.
- 코드 섹션 디스어셈블
- 심벌 기반 함수 선택
- 소스·줄 번호와 명령 결합
- 재배치 표시
- 섹션과 헤더 출력
- Mach-O의 적재 명령과 라이브러리 정보
- Intel식 x86 문법을 포함한 대상별 출력 옵션
GNU objdump와 llvm-objdump는 명령행 옵션과 출력 형식이 비슷한 부분이 있지만 완전히 같은 도구는 아니다. 지원하는 파일 형식, 아키텍처, 디스어셈블 별칭과 세부 옵션에서 차이가 있다.
독립형 역공학 도구
Ghidra, IDA, Binary Ninja, radare2와 같은 역공학 도구는 단순한 선형 디스어셈블을 넘어 함수 경계, 제어 흐름, 데이터 참조, 호출 관계와 자료형을 분석한다.
이러한 도구는 정적 분석 결과를 바탕으로 함수와 변수의 이름을 사용자가 붙이고, 그래프 형태의 제어 흐름과 의사 코드로 프로그램을 조사할 수 있다.
다만 함수 경계와 자료형은 디버깅 정보가 없으면 추론한 결과일 수 있다. 간접 분기, 자체 수정 코드, 난독화와 코드·데이터 혼합은 분석을 어렵게 한다.
동적 디버거와 정적 디스어셈블러는 서로 보완적이다. 정적 분석은 전체 바이너리 구조와 가능한 경로를 보여주고, 동적 분석은 실제 실행된 명령과 런타임 값 및 간접 분기 목적지를 확인한다.
프로파일러와 성능 분석기
프로파일러는 실행 중인 프로그램의 시간 사용, 함수 호출, 메모리 접근과 하드웨어 사건을 측정하여 성능 병목을 찾는 도구다. 어셈블리 코드의 효율은 명령 수만으로 판단하기 어려우므로 실제 하드웨어와 입력에서 측정하는 과정이 필요하다.
프로파일링 방식은 크게 다음과 같이 나뉜다.
- 일정 시간 간격으로 실행 위치를 수집하는 표본 기반 프로파일링
- 함수나 코드에 측정 명령을 삽입하는 계측 기반 프로파일링
- 하드웨어 성능 계수를 이용하는 사건 기반 분석
- 운영체제의 스케줄링과 입출력을 포함하는 시스템 추적
- 명령 단위 실행을 모델링하는 시뮬레이션
- 정적 명령 처리량·지연 시간 분석
표본 기반 프로파일링은 주기적으로 프로그램 카운터와 호출 스택을 수집한다. 자주 실행되거나 오래 걸리는 위치일수록 표본에 많이 나타나므로, 전체 오버헤드를 비교적 낮게 유지하면서 병목을 찾을 수 있다.
계측 기반 방식은 함수 진입·반환이나 특정 코드 지점에 측정 작업을 삽입한다. 호출 횟수와 정확한 시간 관계를 얻기 쉽지만 프로그램 동작과 타이밍에 더 큰 영향을 줄 수 있다.
하드웨어 성능 계수는 프로세서가 제공하는 사건 측정 기능이다. 다음과 같은 값을 기록할 수 있다.
- 실행한 명령 수
- CPU 주기
- 분기와 분기 예측 실패
- 캐시 참조와 캐시 미스
- TLB 미스
- 정지 주기
- 부동소수점·벡터 명령
- 메모리 접근과 대역폭 관련 사건
성능 계수는 프로세서 모델마다 지원하는 사건과 의미가 다를 수 있다. 같은 이름의 사건도 마이크로아키텍처별로 측정 범위가 다를 수 있으므로 제조사 설명서와 도구의 이벤트 정의를 확인해야 한다.
perf
perf는 Linux의 perf_events 인터페이스를 사용하는 성능 분석 도구 모음이다. CPU 하드웨어 차이를 추상화하고 작업·CPU·전체 시스템 단위의 계수, 표본과 추적 정보를 수집할 수 있다.[140]
대표적인 기능은 다음과 같다.
perf stat을 통한 사건 계수perf record를 통한 실행 표본 기록perf report를 통한 함수별 분석perf annotate를 통한 소스·어셈블리별 표본 표시perf top을 통한 실시간 병목 관찰- 스케줄러·시스템 호출·커널 사건 추적
- 사용자 공간과 커널 코드의 통합 분석
perf stat은 프로그램 전체의 명령 수, 주기, 분기와 캐시 관련 계수를 비교하는 데 사용할 수 있다. 명령당 주기와 분기 실패 비율 등을 계산하면 코드 변경 전후의 전체 동작 차이를 확인할 수 있다.
perf annotate는 표본을 함수의 각 기계 명령에 연결하여 어떤 명령이나 주소 부근에서 실행 시간이 집중되는지 보여준다. 최적화된 함수와 어셈블리 루프를 분석할 때 유용하다.
호출 스택을 정확히 수집하려면 프레임 포인터, DWARF 되감기 정보 또는 하드웨어 분기 기록 같은 기능이 필요할 수 있다. 빌드 옵션과 시스템 설정에 따라 호출 그래프의 정확도가 달라진다.
Intel VTune Profiler
Intel VTune Profiler는 CPU, 스레드, 메모리와 가속기 성능을 분석하는 Intel의 도구다. Windows와 Linux 등의 로컬·원격 대상을 프로파일링할 수 있으며 여러 프로그래밍 언어와 병렬 런타임을 지원한다.[141]
VTune은 다음과 같은 분석을 제공할 수 있다.
- CPU 핫스폿
- 마이크로아키텍처 활용
- 메모리 접근과 대역폭
- 스레드 동시성과 대기
- 잠금과 작업 불균형
- NUMA와 메모리 배치
- 벡터화와 명령 집합 사용
- 병렬 프레임워크와 GPU 작업
마이크로아키텍처 분석은 프론트엔드 공급, 잘못된 추측, 백엔드 정지와 정상적인 실행 슬롯의 비율 등을 통해 CPU 파이프라인이 어디에서 제한되는지 조사한다.
어셈블리 함수에서는 명령별 표본, 실행 횟수, 캐시·분기 사건과 소스 매핑을 함께 살펴볼 수 있다. 단순히 가장 많은 주기를 사용한 명령뿐 아니라 그 원인이 데이터 의존성, 메모리 지연, 실행 포트 경쟁인지 구분하는 데 사용된다.
VTune의 최신판 기능과 지원 운영체제·프로세서는 릴리스마다 달라질 수 있으므로 사용 중인 버전의 설명서를 기준으로 해야 한다.[142]
Visual Studio Performance Profiler
Visual Studio의 Performance Profiler는 Windows 응용 프로그램의 CPU, 메모리, 파일 입출력, 데이터베이스와 GPU 등의 동작을 분석하는 도구 집합이다.
Performance Profiler의 도구는 일반적으로 디버거 없이 릴리스 빌드를 실행해 자료를 수집하고, 수집을 중단한 뒤 결과를 분석할 수 있다.[143]
CPU 사용량 분석은 함수와 호출 경로별 표본을 보여주며, 네이티브 코드의 심벌과 디버깅 정보가 있으면 소스 및 기계 명령과 연결할 수 있다.
Windows 전체 시스템의 CPU 스케줄링, 디스크·네트워크 입출력과 지연을 더 깊게 조사할 때는 Windows Performance Recorder와 Windows Performance Analyzer로 구성된 Windows Performance Toolkit을 사용할 수 있다. Microsoft는 일반 응용 프로그램 분석과 복잡한 시스템 수준 분석에 따라 Visual Studio Performance Profiler와 Windows Performance Toolkit을 구분한다.[144]
정적 성능 분석기
일부 도구는 프로그램을 실행하지 않고 주어진 명령열의 이론적인 처리량과 데이터 의존성을 분석한다. 프로세서 실행 포트, 명령 지연 시간과 마이크로 연산 정보를 이용해 루프의 예상 병목을 계산한다.
LLVM의 llvm-mca는 LLVM의 스케줄링 모델을 이용하여 기계 명령 영역의 처리량, 자원 압력과 타임라인을 분석한다. 이러한 결과는 실제 캐시 미스, 분기 예측 실패와 운영체제 영향을 포함하지 않으므로 동적 프로파일링과 함께 사용해야 한다.
정적 분석기는 짧은 계산 커널과 반복문에서 다음 항목을 찾는 데 유용하다.
- 긴 레지스터 데이터 의존성
- 특정 실행 포트의 과도한 사용
- 명령 디코딩 병목
- 이론적인 반복 처리량
- 메모리 명령과 산술 명령의 균형
- 명령 스케줄 변경 가능성
프로파일링 결과를 해석할 때는 측정 오차와 실행 환경도 고려해야 한다. CPU 주파수, 다른 프로세스의 실행, 열 제한, 캐시의 초기 상태, 입력 데이터와 반복 횟수가 결과를 바꿀 수 있다.
성능 비교는 가능한 한 같은 하드웨어와 실행 조건에서 여러 번 수행하고, 준비 실행과 실제 측정을 구분해야 한다. 평균값만 아니라 분산과 이상값도 살펴봐야 한다.
어셈블리 코드를 최적화할 때는 다음 순서가 일반적이다.
어셈블리 성능 분석 절차
- 실제 작업 부하에서 측정
- 프로파일러로 병목 함수 확인
- 명령별 표본과 하드웨어 사건 분석
- 캐시·분기·의존성 원인 구분
- 제한된 코드 수정
- 정확성 검증
- 같은 조건에서 다시 측정
명령 수가 줄었다고 항상 성능이 향상되는 것은 아니다. 더 적은 수의 복잡한 명령이 디코딩과 실행에서 더 큰 비용을 가질 수 있고, 반대로 명령 수가 늘더라도 의존성을 줄이거나 벡터 폭을 넓히면 처리량이 높아질 수 있다.
따라서 어셈블러는 코드를 생성하는 도구이고, 디버거와 디스어셈블러는 코드의 정확한 동작과 구조를 조사하는 도구이며, 프로파일러는 실제 실행 성능을 검증하는 도구로 구분된다. 어셈블리 개발에서는 세 종류를 함께 사용해야 소스, 생성된 기계 코드와 실제 하드웨어 동작 사이의 차이를 확인할 수 있다.
활용 분야
어셈블리어는 초기 컴퓨터처럼 프로그램 전체를 작성하는 주된 언어로 사용되는 경우는 줄었지만, 하드웨어 상태와 명령 실행을 정확히 제어해야 하는 분야에서는 계속 사용된다. 현대 소프트웨어는 일반적으로 대부분을 C, C++, Rust와 같은 고급 언어로 구현하고, 컴파일러만으로 표현하기 어렵거나 아키텍처와 직접 맞닿는 부분을 어셈블리어로 작성한다.
어셈블리어의 필요성은 단순히 높은 실행 속도에서만 발생하지 않는다. 프로세서의 초기 실행 상태, 특권 수준 전환, 호출 규약 밖의 레지스터 저장, 특정 명령의 정확한 배치, 일정한 실행 시간, 인터럽트 진입과 바이너리 분석처럼 고급 언어의 추상화가 아직 적용되지 않거나 오히려 방해가 되는 상황에서 사용된다.
주요 활용 방식은 다음과 같이 구분할 수 있다.
- 부팅과 프로세서 초기화처럼 실행 환경이 아직 준비되지 않은 코드
- 커널 진입, 문맥 전환과 예외 처리처럼 전체 기계 상태를 다루는 코드
- 장치 레지스터와 특수 명령에 직접 접근하는 코드
- SIMD·벡터·암호화 명령을 세밀하게 사용하는 최적화 코드
- 컴파일러와 런타임이 대상 ISA용 기계 코드를 생성·지원하는 부분
- 실행 파일과 악성 코드를 분석하는 역공학·보안 분야
- 명령어 집합과 컴퓨터 구조를 학습하는 교육 분야
어셈블리어를 사용하는 범위는 대상 환경과 컴파일러의 기능에 따라 달라진다. 과거에 어셈블리어로 작성했던 메모리 복사, 산술과 장치 초기화의 상당 부분은 현재 고급 언어, 컴파일러 내장 함수와 표준화된 펌웨어 인터페이스로 옮겨졌다. 반대로 새로운 벡터·암호화·가상화 명령과 보안 기능이 추가되면서 이를 활용하거나 분석하기 위한 새로운 어셈블리 코드도 계속 만들어진다.
운영체제와 커널
운영체제 커널은 프로세서의 특권 상태, 주소 공간, 인터럽트와 스레드 실행 상태를 관리하므로 어셈블리어가 가장 오래 유지되는 소프트웨어 분야 가운데 하나다.
현대 커널의 대부분은 C나 Rust와 같은 시스템 프로그래밍 언어로 작성되지만, 다음 부분에는 아키텍처별 어셈블리 코드가 사용될 수 있다.
- 부팅 직후의 프로세서 초기화
- 커널 진입점
- 시스템 호출 진입과 복귀
- 인터럽트와 예외 진입부
- 사용자 모드와 커널 모드 사이의 전환
- 스레드와 프로세스 문맥 전환
- 페이지 테이블과 주소 공간 전환
- 원자적 연산과 메모리 장벽
- 특수 목적 레지스터 접근
- 사용자 메모리에 대한 보호된 접근
- 가상 머신과 하이퍼바이저 진입·복귀
- 커널의 저수준 수학·문자열 처리
시스템 호출이나 예외가 발생하면 프로세서는 일반 함수 호출과 다른 상태로 커널에 진입한다. 일부 레지스터만 하드웨어가 저장하고 나머지는 커널 진입 코드가 직접 보존해야 할 수 있다. 이 단계에서는 일반적인 함수 호출 규약이 아직 성립하지 않으므로 어셈블리 코드가 기계 상태를 커널의 자료 구조로 변환한다.
Linux의 x86-64 진입 코드는 시스템 호출, 인터럽트와 예외처럼 커널에 들어오는 여러 경로를 처리한다. Linux 커널 문서도 x86 아키텍처에는 커널 코드로 진입하는 여러 방법이 있으며, 진입 경로마다 서로 다른 상태를 정규화해야 한다고 설명한다.[145]
문맥 전환에서는 현재 스레드의 스택 포인터, 보존 레지스터와 프로세서별 상태를 저장하고 다음 스레드의 상태를 복원한다. 부동소수점·벡터 레지스터, 디버그 레지스터와 확장 상태는 비용 때문에 별도의 경로로 관리될 수 있다.
주소 공간 전환은 페이지 테이블 기준 레지스터와 주소 변환 캐시의 동작을 다룬다. 이 과정에는 아키텍처가 요구하는 명령 순서, 메모리 장벽과 보안 완화 절차가 포함될 수 있다.
사용자 모드로 복귀할 때는 저장된 레지스터와 권한 상태가 안전한지 검사해야 한다. 잘못된 세그먼트, 주소, 상태 플래그나 예외 반환 값이 사용되면 커널 권한으로 잘못된 코드를 실행하거나 시스템이 중단될 수 있다.
커널의 어셈블리 파일에는 단순 명령 외에도 함수 경계, 스택 되감기, 심벌 종류와 실행 방지 정보를 나타내는 매크로가 사용된다. Linux 커널은 아키텍처별 어셈블리 코드의 함수와 데이터를 일관되게 표시하기 위한 주석·매크로 체계를 문서화하고 있다.[146]
커널이 직접 모든 저수준 기능을 어셈블리어로 구현하는 것은 아니다. 특수 레지스터와 명령에 접근하기 위한 짧은 인라인 함수나 컴파일러 내장 함수를 제공하고, 대부분의 정책과 자료 구조는 고급 언어로 유지하는 방식이 일반적이다.
부트로더와 펌웨어
컴퓨터 전원이 켜진 직후에는 운영체제, 일반적인 런타임, 가상 메모리와 표준 라이브러리를 사용할 수 없다. 펌웨어와 부트로더는 제한된 초기 환경에서 프로세서와 메모리를 준비하고 다음 실행 단계로 제어를 넘겨야 한다.
초기 부팅 코드에서 어셈블리어가 사용되는 작업에는 다음이 있다.
- 재설정 벡터와 최초 진입점 구현
- 초기 스택 설정
- 프로세서 실행 모드 전환
- 세그먼트·페이지 테이블과 주소 변환 설정
- 캐시와 부동소수점·벡터 기능 초기화
- 보조 프로세서 코어 시작
- 메모리 초기화 전 임시 실행 환경 구성
- 펌웨어 호출 규약에 맞춘 진입·복귀
- 운영체제 커널의 진입 조건 구성
x86은 재설정 직후 현대적인 64비트 운영체제가 사용하는 실행 환경과 다른 상태에서 시작한다. 부팅 코드는 실 모드 또는 펌웨어가 제공한 환경에서 보호 모드와 긴 모드에 필요한 제어 레지스터, 페이지 테이블과 세그먼트 상태를 구성해야 할 수 있다.
Arm과 RISC-V 시스템도 현재 예외 수준, 특권 모드, 메모리 관리 장치, 인터럽트와 캐시의 초기 상태를 확인하고 운영체제가 기대하는 형태로 설정해야 한다.
전통적인 BIOS 부트 섹터처럼 매우 작은 환경에서는 코드 크기와 정확한 배치 때문에 어셈블리어의 비중이 높았다. UEFI 환경에서는 부트로더를 C와 같은 고급 언어로 작성할 수 있는 표준 서비스와 실행 환경이 제공되므로, 어셈블리어는 아키텍처 초기화와 경계 코드로 더 제한될 수 있다.
UEFI는 운영체제 로더와 펌웨어가 사용할 수 있는 부트 서비스, 런타임 서비스와 시스템 테이블을 정의한다. 운영체제 로더는 부트 서비스가 소유한 자원을 이용해 필요한 파일과 메모리를 준비한 뒤 ExitBootServices를 호출하고 시스템 제어를 넘겨받는다.[147]
UEFI 부트 관리자는 펌웨어가 지원하는 장치와 파일 시스템에서 UEFI 응용 프로그램 및 운영체제 로더를 적재할 수 있다.[148]
펌웨어는 일반 컴퓨터의 UEFI뿐 아니라 다음 환경도 포함한다.
- 마이크로컨트롤러의 시작 코드
- 서버의 플랫폼 초기화 펌웨어
- 장치 내부 제어기 펌웨어
- 보안 프로세서와 관리 프로세서
- 그래픽 카드와 네트워크 장치의 옵션 ROM
- 저장 장치 제어기
- 신뢰 실행 환경의 초기 코드
최신 임베디드 도구 체인에서는 재설정 처리기와 데이터 복사 같은 시작 코드를 C로 작성하는 경향도 있다. Arm의 CMSIS v6 장치 지원 지침은 기존의 어셈블리 기반 시작 코드를 사용 중단 방향으로 두고 C 기반 시작 파일을 제공한다.[149]
그럼에도 C 코드가 실행되기 위한 스택, 메모리와 프로세서 상태를 만드는 가장 초기 진입부에는 몇 개의 어셈블리 명령이 필요할 수 있다. 어떤 부분까지 고급 언어로 작성할 수 있는지는 컴파일러, ABI와 펌웨어 실행 환경에 따라 달라진다.
임베디드 시스템
임베디드 시스템은 특정 장치의 기능을 수행하기 위해 프로세서와 소프트웨어를 결합한 시스템이다. 마이크로컨트롤러, 센서, 가전제품, 자동차 제어기, 네트워크 장비, 산업 장치와 저장 장치 내부 제어기가 포함된다.
초기 임베디드 소프트웨어는 작은 메모리와 낮은 처리 성능 때문에 어셈블리어로 전체를 작성하는 경우가 많았다. 현대에는 C, C++, Rust와 모델 기반 생성 코드를 주로 사용하지만 다음 부분에서는 어셈블리어가 계속 사용된다.
- 재설정 처리기와 시작 코드
- 인터럽트 벡터 테이블
- 특수 레지스터와 명령 접근
- 짧고 일정한 주기가 필요한 신호 처리
- 문맥 전환과 실시간 운영체제 포트
- 부트 ROM과 플래시 기록 코드
- 전력 절감·절전 모드 진입
- 제한된 메모리에서의 크기 최적화
- 공급자가 제공하는 수학·DSP 라이브러리
- 하드웨어 시험과 생산 진단 코드
마이크로컨트롤러에서는 메모리 배치가 고정되거나 하드웨어 설명서가 특정 주소에 벡터·설정 값을 요구할 수 있다. 어셈블러 지시어와 링커 스크립트는 이러한 코드를 정확한 플래시 주소에 배치하는 데 사용된다.
일부 장치는 프로그램 메모리, 데이터 메모리와 입출력 공간이 분리되어 있다. AVR, PIC와 여러 DSP에서는 일반적인 데스크톱 CPU와 다른 명령과 주소 지정 방식을 이해해야 한다.
임베디드 코드에서 어셈블리어를 사용한다고 반드시 더 작거나 빠른 것은 아니다. 현대 컴파일러는 특정 마이크로컨트롤러의 명령 비용과 레지스터 구조를 알고 있으며, 수동 코드가 최적화와 링크 시간 제거를 방해할 수도 있다.
소형 장치에서는 유지보수성과 검증 비용도 중요하다. 여러 제품 세대와 프로세서 변형을 지원해야 한다면 공통 로직은 고급 언어로 유지하고, ISA별 차이를 작은 어셈블리 모듈과 헤더로 분리하는 구조가 적합하다.
안전과 보안 인증이 필요한 임베디드 시스템에서는 어셈블리 코드도 명령 단위의 검토, 정적 분석과 시험이 필요하다. 컴파일러가 제공하던 자료형 검사와 정의되지 않은 동작에 대한 진단을 기대할 수 없으므로 수동 검증 부담이 커진다.
장치 드라이버
장치 드라이버는 운영체제와 하드웨어 장치 사이의 인터페이스다. 대부분의 현대 드라이버는 C, C++나 Rust로 작성되며, 장치 레지스터 접근과 DMA 설정도 고급 언어의 메모리 매핑 입출력 함수로 처리할 수 있다.
어셈블리어는 드라이버 전체보다 다음과 같은 제한된 영역에서 사용된다.
- 특수한 입출력 명령 실행
- 메모리 장벽과 입출력 순서 보장
- 인터럽트 진입부
- DMA와 캐시 일관성 보조
- 장치별 펌웨어 적재 코드
- 고속 데이터 경로의 SIMD 처리
- 운영체제가 제공하지 않는 특수 프로세서 기능 접근
x86의 포트 입출력 공간은 IN, OUT 계열 명령으로 접근할 수 있다. 커널은 일반적으로 이를 짧은 인라인 함수로 감싸 드라이버가 직접 어셈블리 문법에 의존하지 않도록 한다.
메모리 매핑 입출력은 일반 메모리와 같은 주소를 사용하더라도 캐시, 접근 폭과 순서 규칙이 다를 수 있다. 드라이버는 운영체제의 I/O 접근 함수를 사용하여 컴파일러와 프로세서가 접근을 잘못 제거하거나 재배치하지 못하도록 해야 한다.
장치 인터럽트가 발생했을 때 최초의 아키텍처 진입 코드는 커널 공통 인터럽트 처리기로 연결된다. 개별 드라이버는 일반적으로 고급 언어 함수로 작성되며, 아키텍처별 어셈블리 코드가 저장한 표준화된 실행 상태를 전달받는다.
네트워크, 저장 장치와 그래픽 드라이버는 데이터 복사, 체크섬과 형식 변환에서 벡터 명령을 사용할 수 있다. 다만 커널 내부의 벡터 레지스터 사용은 사용자 공간 상태 저장, 선점과 인터럽트 규칙 때문에 일반 코드보다 제한될 수 있다.
드라이버에서 직접 작성한 어셈블리 코드는 ISA뿐 아니라 운영체제 내부 API와 커널 빌드 설정에도 종속된다. 따라서 재사용 가능한 장치 정책은 고급 언어로 두고, 저수준 접근은 운영체제가 제공하는 아키텍처별 추상화 계층을 이용하는 경우가 많다.
실시간 시스템
실시간 시스템은 단순히 빠른 평균 성능이 아니라, 정해진 시간 안에 결과를 내는 예측 가능성을 요구한다. 산업 제어, 자동차, 항공전자, 로봇, 통신 장치와 오디오 처리 등이 대표적인 활용 분야다.
실시간 시스템에서 어셈블리어가 사용되는 부분에는 다음이 있다.
- 인터럽트 진입과 복귀
- 실시간 운영체제의 문맥 전환
- 정확한 주기 수가 필요한 지연 루프
- 타이머와 사이클 계수기 접근
- 원자적 임계 구역
- DSP와 신호 처리 커널
- 최악 실행 시간을 세밀하게 분석해야 하는 코드
- 프로세서의 대기·절전 상태 제어
실시간 운영체제의 스케줄러 정책은 대부분 고급 언어로 구현할 수 있지만, 현재 태스크의 레지스터를 스택에 저장하고 다음 태스크의 상태를 복원하는 포트 계층은 ISA별 어셈블리 코드가 필요할 수 있다.
문맥 전환 코드는 일반 함수 호출 규약만으로는 충분하지 않다. 중단된 태스크가 사용 중이던 호출자 보존 레지스터, 상태 플래그, 반환 주소와 선택적인 부동소수점·벡터 상태까지 보존해야 할 수 있다.
어셈블리어가 작성자에게 명령을 직접 선택할 권한을 제공한다고 해서 실행 시간이 자동으로 일정해지는 것은 아니다. 현대 프로세서는 캐시, 파이프라인, 분기 예측, 비순차 실행, 메모리 대기와 인터럽트 때문에 같은 명령열도 서로 다른 시간이 걸릴 수 있다.
엄격한 실시간 환경에서는 캐시 잠금, 메모리 배치, 인터럽트 우선순위, 버스 경쟁과 DMA까지 고려한 최악 실행 시간 분석이 필요하다. 단순한 명령어 수 계산만으로 시간 제한을 보장할 수 없다.
일부 마이크로컨트롤러는 비교적 단순한 파이프라인과 일정한 명령 주기를 제공하여 어셈블리 수준의 실행 시간 계산이 유용하다. 그러나 플래시 대기 상태, 분기와 메모리 접근에 따라 추가 주기가 발생할 수 있으므로 정확한 프로세서 설명서를 따라야 한다.
고급 언어의 컴파일 결과는 컴파일러 버전과 최적화 옵션에 따라 변할 수 있다. 명령열 자체가 검증 대상인 극히 제한적인 코드에서는 어셈블리어로 구현을 고정할 수 있지만, 유지보수와 인증 과정에서 변경 관리 부담이 커진다.
암호학과 SIMD 최적화
암호화, 해시, 인증, 압축과 대량 데이터 변환은 같은 연산을 많은 데이터에 반복하므로 SIMD와 벡터 명령을 활용하기 적합하다. 어셈블리어는 대상 프로세서의 전용 암호 명령과 벡터 레지스터를 직접 조합하는 데 사용된다.
대표적인 활용은 다음과 같다.
- AES 암호화와 복호화
- SHA 계열 해시
- 다항식과 유한체 연산
- 큰 정수 산술
- 공개키 암호
- 체크섬과 CRC
- 블록 단위 데이터 변환
- 여러 메시지의 병렬 처리
- 상수 시간 비교와 조건 처리
x86 계열은 AES-NI, SHA, PCLMULQDQ와 여러 벡터 확장을 제공한다. Arm은 Advanced SIMD, AES, SHA와 다항식 연산 명령 등을 제공할 수 있으며, RISC-V도 표준 암호화와 벡터 암호 확장을 정의한다.
Intel은 C 형태의 인스트린식을 통해 SSE, AVX와 다른 ISA 확장에 접근할 수 있도록 Intrinsics Guide를 제공한다.[150]
현대 암호 라이브러리는 모든 코드를 직접 어셈블리어로 작성하기보다 다음 구현을 함께 유지할 수 있다.
- 이식 가능한 고급 언어 구현
- 컴파일러 인스트린식 구현
- ISA별 어셈블리 최적화
- 프로세서 기능에 따른 런타임 선택
- 하드웨어 가속기를 사용하는 구현
암호화 구현에서는 평균 속도뿐 아니라 실행 시간과 메모리 접근이 비밀값에 따라 달라지지 않도록 하는 것이 중요하다. 조건 분기, 테이블 조회와 캐시 접근이 비밀 데이터에 의존하면 시간·캐시 기반 부채널이 발생할 수 있다.
수동 어셈블리어는 분기와 메모리 접근을 정확히 제어할 수 있지만, 그것만으로 상수 시간 동작이 보장되는 것은 아니다. 프로세서의 명령 지연, 추측 실행, 캐시와 공유 자원도 고려해야 한다.
SIMD 최적화는 여러 독립 데이터 요소를 한 번에 처리한다. 벡터 레지스터 폭이 커질수록 한 명령의 처리량을 높일 수 있지만, 데이터 정렬, 레지스터 압박, 명령 전환 비용과 대상 프로세서의 실행 장치가 실제 효과를 좌우한다.
어셈블리 구현은 특정 프로세서 세대에서 매우 빠를 수 있지만 새로운 ISA와 벡터 폭에 맞춰 별도 구현을 유지해야 한다. 컴파일러의 자동 벡터화와 인스트린식은 이식성과 최적화 통합 측면에서 더 나은 선택이 될 수 있다.
게임과 그래픽스
초기 비디오 게임과 가정용 컴퓨터에서는 제한된 메모리와 처리 성능 때문에 게임 전체를 어셈블리어로 작성하는 경우가 많았다. 6502, Z80, Motorola 68000과 x86 계열의 어셈블리어는 게임 논리, 화면 출력, 사운드와 입력 처리에 사용되었다.
당시 하드웨어는 그래픽·사운드 장치의 레지스터를 특정 주기와 순서로 갱신해야 했으며, 화면 주사와 동기화된 효과를 만들기 위해 명령 실행 시간을 계산하기도 했다.
현대 게임은 대부분 C++, C#, Rust와 셰이더 언어로 작성되고, 어셈블리어는 다음과 같은 제한된 부분에서 사용될 수 있다.
- 수학·물리·애니메이션 라이브러리의 SIMD 최적화
- 메모리 복사와 데이터 변환
- 오디오 믹싱과 코덱
- 압축과 자산 처리
- 게임기 플랫폼의 저수준 런타임
- 프로파일러와 충돌 분석
- 과거 게임과 에뮬레이터 개발
- 셰이더·GPU 기계 코드 분석
3차원 그래픽에서는 행렬, 벡터, 정점과 픽셀 데이터를 대량으로 처리하므로 SIMD가 중요하다. 그러나 일반적으로 엔진 개발자가 CPU 어셈블리어를 직접 작성하기보다 컴파일러 인스트린식, 자동 벡터화와 검증된 수학 라이브러리를 사용한다.
GPU는 CPU와 다른 병렬 실행 모델과 명령어 집합을 사용한다. 셰이더 언어는 SPIR-V, DXIL 또는 공급자별 중간 표현을 거쳐 GPU 기계어로 변환된다. GPU의 저수준 명령을 분석하는 작업은 넓은 의미에서 어셈블리 분석에 속하지만 일반 CPU 어셈블리어와는 구분된다.
게임기와 모바일 장치는 고정된 하드웨어를 오래 사용하므로 세대 초기에 작성한 ISA별 최적화 코드를 장기간 유지할 수 있다. 반면 PC 게임은 여러 CPU 세대와 운영체제를 지원해야 하므로 런타임 기능 검사와 대체 구현이 필요하다.
레트로 게임과 홈브루 개발에서는 6502, Z80와 68000 어셈블리어가 현재도 직접 사용된다. 원래 하드웨어의 제한과 타이밍을 그대로 다루는 것이 개발 목적의 일부이기 때문이다.
컴파일러 개발
컴파일러는 고급 언어를 대상 프로세서의 기계 코드로 변환하므로 어셈블리어와 ISA에 대한 이해가 핵심적이다.
컴파일러 백엔드는 일반적으로 다음 작업을 수행한다.
- 중간 표현을 대상 명령으로 변환
- 적합한 주소 지정 방식 선택
- 가상 레지스터를 물리 레지스터에 할당
- 함수의 스택 프레임 구성
- 호출 규약에 따른 매개변수와 반환값 배치
- 명령 스케줄링
- 분기와 상수 적재 방식 선택
- 재배치와 심벌 정보 생성
- 어셈블리 텍스트 또는 기계어 출력
- 디버깅·예외 처리 정보 생성
LLVM의 대상 독립 코드 생성기는 LLVM 중간 표현을 특정 대상의 어셈블리 코드나 JIT용 기계 코드로 변환하는 공통 구성 요소를 제공한다. 여기에는 레지스터 할당, 명령 스케줄링과 스택 프레임 표현 등이 포함된다.[151]
새로운 ISA용 LLVM 백엔드를 개발하려면 레지스터, 명령어, 주소 지정, 호출 규약, 어셈블리 출력과 오브젝트 파일 생성을 정의해야 한다. LLVM은 새로운 하드웨어 또는 소프트웨어 대상을 위한 백엔드 작성 절차를 별도로 문서화한다.[152]
컴파일러 개발자는 ISA 설명서의 명령 의미만 이해해서는 충분하지 않다. 실제 프로세서의 명령 지연, 처리량, 실행 자원, 분기 비용과 메모리 모델을 비용 모델에 반영해야 한다.
어셈블리 출력은 백엔드 검증의 중요한 수단이다. 특정 중간 표현이 예상한 명령으로 변환되는지, 불필요한 이동과 스필이 발생하는지, 호출 규약과 재배치가 올바른지 확인할 수 있다.
컴파일러 시험에는 작은 입력 코드와 기대 어셈블리 패턴을 비교하는 방식이 사용될 수 있다. 그러나 명령 순서와 레지스터 번호는 최적화 변화로 달라질 수 있으므로 의미상 필요한 특징만 검사하도록 시험을 구성해야 한다.
JIT 컴파일러는 어셈블리 텍스트와 외부 어셈블러를 거치지 않고 메모리에 직접 기계어를 생성할 수 있다. 이 경우 명령 인코딩, 재배치, 코드 메모리 권한과 명령 캐시 동기화를 런타임이 처리한다.
역공학과 보안 분석
역공학은 실행 파일과 기계 코드를 분석하여 프로그램의 구조와 동작을 이해하는 과정이다. 소스 코드가 없거나 디버깅 정보가 제거된 바이너리에서는 디스어셈블된 어셈블리 코드가 가장 직접적인 분석 대상이 된다.
활용 분야에는 다음이 있다.
- 악성 코드 분석
- 취약점 조사
- 충돌과 메모리 손상 원인 분석
- 파일 형식과 통신 프로토콜 복원
- 호환 구현 개발
- 오래된 소프트웨어 유지보수
- 펌웨어와 장치 분석
- 복사 방지와 난독화 분석
- 디지털 포렌식
- 패치와 바이너리 계측
분석가는 명령어의 개별 의미뿐 아니라 호출 규약, 스택 프레임, 운영체제 API, 오브젝트 파일, 컴파일러가 만드는 전형적인 코드 패턴을 이해해야 한다.
정적 분석은 실행하지 않고 코드·데이터·심벌과 제어 흐름을 조사한다. 동적 분석은 디버거, 가상 머신 또는 에뮬레이터에서 프로그램을 실행하면서 실제 레지스터, 메모리와 분기 목적지를 관찰한다.
Ghidra와 같은 역공학 플랫폼은 기계어 디스어셈블, 함수 분석, 데이터형 적용, 참조 추적과 디컴파일을 제공한다. 디컴파일된 의사 코드는 분석을 돕지만 원래 고급 언어 소스를 그대로 복원한 것은 아니다.
악성 코드는 분석을 어렵게 하기 위해 다음 기법을 사용할 수 있다.
- 코드와 데이터를 혼합
- 간접 분기와 계산된 호출
- 자체 수정 코드
- 암호화·압축된 코드
- 디버거와 가상 머신 감지
- 제어 흐름 평탄화
- 의미 없는 명령 삽입
- 예외 처리 기반 제어 흐름
- JIT 또는 런타임 코드 생성
가변 길이 명령을 사용하는 x86에서는 명령 시작 위치가 달라지면 같은 바이트가 다른 명령열로 해석될 수 있다. 선형 디스어셈블만 사용하면 데이터나 의도적으로 삽입한 바이트를 코드로 잘못 판단할 수 있다.
취약점 분석에서는 함수가 입력 길이를 어떻게 검사하는지, 포인터와 정수 변환이 어떻게 이루어지는지, 스택과 힙의 경계가 어디에서 손상되는지를 명령 수준에서 추적할 수 있다.
하드웨어 보안 분석에서는 추측 실행, 캐시, 분기 예측기와 권한 전환 명령도 다룬다. 소스 코드에는 안전해 보이는 접근이라도 실제 생성된 명령과 마이크로아키텍처 동작 때문에 정보가 노출될 수 있다.
어셈블리어 지식은 공격 코드 작성에만 국한되지 않는다. 보안 패치 검증, 컴파일러 완화 기능 확인, 샌드박스와 검증기 구현, 디지털 서명의 실행 경로 조사처럼 방어적인 분석에도 사용된다.
교육과 컴퓨터 구조 학습
어셈블리어는 고급 언어와 실제 프로세서 사이의 관계를 보여주기 때문에 컴퓨터 구조, 운영체제, 컴파일러와 시스템 프로그래밍 교육에 사용된다.
어셈블리어를 통해 학습할 수 있는 내용에는 다음이 있다.
- 명령어의 인코딩과 실행
- 레지스터와 메모리의 차이
- 주소 지정 방식
- 스택과 함수 호출
- 정수와 부동소수점의 기계 표현
- 조건 분기와 반복문의 변환
- 호출 규약과 ABI
- 캐시와 메모리 계층
- 인터럽트와 시스템 호출
- 컴파일러 최적화
- 병렬 실행과 원자적 연산
간단한 산술 함수나 반복문을 고급 언어로 작성하고 컴파일러가 생성한 어셈블리 코드를 비교하면, 변수와 제어 구조가 레지스터·분기·메모리 접근으로 변환되는 과정을 확인할 수 있다.
교육용 ISA는 규칙적인 명령 형식과 비교적 작은 기본 명령어 집합을 갖는 경우가 많다. MIPS와 교육용 DLX가 오랫동안 사용되었고, 최근에는 개방형 규격과 구현 도구를 제공하는 RISC-V가 널리 활용된다.
RISC-V는 본래 컴퓨터 구조 연구와 교육을 지원하기 위해 설계되었으며 산업 구현에도 사용할 수 있는 개방형 ISA로 발전하였다.[153]
RISC-V International은 대학생을 위한 온라인 과정, 오픈 소스 도구와 교재 등 교육 자원을 별도로 제공한다.[154]
시뮬레이터와 에뮬레이터를 사용하면 실제 하드웨어 없이 명령을 한 단계씩 실행하고 레지스터, 메모리와 파이프라인 상태를 관찰할 수 있다. 단일 사이클 프로세서, 파이프라인, 데이터 전달과 분기 위험을 시각적으로 구현하는 실습에도 어셈블리 프로그램이 사용된다.
실제 상용 ISA를 사용하면 운영체제·컴파일러와 연결되는 경험을 얻을 수 있지만, 오래된 호환 기능과 복잡한 명령 인코딩 때문에 처음 학습하기 어려울 수 있다. x86은 현실적인 소프트웨어 분석에 적합하고, RISC-V와 MIPS는 기본 명령과 데이터 경로를 설명하기에 비교적 규칙적이다.
어셈블리 교육의 목적은 모든 프로그램을 수동으로 기계어에 가깝게 작성하게 하는 데 있지 않다. 고급 언어의 추상화 아래에서 실제로 일어나는 데이터 이동, 함수 호출, 메모리 접근과 제어 흐름을 이해하고, 컴파일러·운영체제·하드웨어의 경계를 분석할 수 있게 하는 데 있다.
현대적인 교육에서는 어셈블리 문법 암기보다 다음 활동이 더 중요하게 다뤄질 수 있다.
- 고급 언어와 생성 코드 비교
- 디버거를 이용한 레지스터·스택 관찰
- 오브젝트 파일의 섹션과 재배치 분석
- 간단한 호출 규약 구현
- 캐시와 분기 변화에 따른 성능 측정
- 원자적 연산과 메모리 순서 실험
- 간단한 컴파일러 백엔드 또는 에뮬레이터 구현
이러한 학습은 어셈블리어를 직접 사용하는 직무뿐 아니라 시스템 소프트웨어, 성능 최적화, 보안, 컴파일러와 하드웨어 설계 전반의 기반이 된다.
최적화
어셈블리어 최적화는 단순히 명령어의 수를 줄이거나 짧은 명령으로 바꾸는 작업이 아니다. 실제 실행 성능은 명령어 선택, 데이터 의존성, 파이프라인 구조, 캐시와 메모리 지연, 분기 예측, 벡터 실행 장치와 대상 프로세서의 마이크로아키텍처가 함께 결정한다.
같은 명령어 집합 아키텍처를 구현하는 프로세서라도 명령의 지연 시간, 처리량, 실행 포트와 캐시 구조가 다를 수 있다. 따라서 한 프로세서에서 빠른 어셈블리 코드가 다른 세대나 다른 제조사의 프로세서에서도 가장 빠르다고 보장할 수 없다.
최적화의 목표도 하나로 고정되지 않는다. 일반적으로 다음 요소 가운데 하나 이상을 개선한다.
- 전체 실행 시간
- 반복문의 처리량
- 단일 작업의 응답 지연 시간
- 코드 크기
- 메모리 사용량과 대역폭
- 전력 소비와 발열
- 실시간 시스템의 최악 실행 시간
- 여러 프로세서에서의 일관된 성능
실제 최적화는 먼저 프로파일러로 병목을 확인하고, 제한된 부분을 수정한 뒤 같은 조건에서 다시 측정하는 방식으로 수행해야 한다. 소스나 디스어셈블 결과만 보고 추정한 병목은 실제 실행 시간에 거의 영향을 주지 않을 수 있다.
명령어 선택
명령어 선택은 같은 결과를 만드는 여러 명령열 가운데 대상 프로세서에서 적합한 형태를 고르는 과정이다. 명령 수가 적은 구현, 코드 크기가 작은 구현과 실행 속도가 빠른 구현이 항상 일치하는 것은 아니다.
예를 들어 곱셈을 시프트와 덧셈으로 바꾸는 최적화는 곱셈 명령이 느렸던 초기 프로세서에서 유용했다. 현대 프로세서에서는 정수 곱셈의 처리량이 높을 수 있으며, 여러 시프트와 덧셈으로 바꾸면 오히려 명령 수와 데이터 의존성이 증가할 수 있다.
명령어 선택에서는 다음 요소를 고려한다.
- 명령의 지연 시간
- 연속 명령의 처리량
- 생성되는 마이크로 연산 수
- 사용할 수 있는 실행 장치
- 입력과 출력 사이의 데이터 의존성
- 명령어 인코딩 길이
- 레지스터 사용량
- 상태 플래그 변경 여부
- 메모리 피연산자의 사용 가능 여부
- 대상 프로세서가 지원하는 ISA 확장
지연 시간은 한 명령의 입력이 준비된 뒤 결과를 다음 의존 명령에서 사용할 수 있을 때까지 걸리는 시간을 의미한다. 처리량은 서로 독립적인 같은 종류의 명령을 연속해서 실행할 때 일정 시간 동안 처리할 수 있는 양을 의미한다.
지연 시간이 4주기인 명령도 여러 실행 장치나 파이프라인을 통해 매 주기 새 명령을 시작할 수 있다. 따라서 하나의 긴 의존 사슬에서는 지연 시간이 중요하고, 서로 독립적인 대량 계산에서는 처리량이 더 중요할 수 있다.
Intel의 최적화 설명서는 명령의 지연 시간, 처리량, 실행 장치, 캐시, 분기 예측과 메모리 접근을 함께 고려하여 소프트웨어를 최적화하는 방법을 설명한다.[155]
x86에서는 하나의 복합 명령이 메모리 적재와 계산을 함께 표현할 수 있다. 이러한 명령은 코드 크기와 레지스터 사용을 줄일 수 있지만, 특정 프로세서에서는 여러 내부 마이크로 연산으로 분해될 수 있다.
반대로 적재와 계산을 별도 명령으로 나누면 명령 수는 증가하지만 적재를 더 일찍 시작하거나 같은 값을 여러 계산에서 재사용할 수 있다. 어느 방식이 유리한지는 주변 코드와 대상 마이크로아키텍처에 따라 달라진다.
조건부 이동과 분기 사이의 선택도 대표적인 사례다. 조건이 쉽게 예측된다면 분기가 불필요한 계산을 건너뛰어 더 빠를 수 있다. 조건이 불규칙하다면 조건부 이동이나 조건부 선택이 예측 실패 비용을 줄일 수 있지만, 선택되지 않은 값까지 계산해야 할 수 있다.
나눗셈은 일반적으로 덧셈이나 비트 연산보다 지연 시간이 길다. 나누는 값이 컴파일 시점에 알려진 상수라면 컴파일러나 프로그래머가 곱셈, 시프트와 보정 연산으로 변환할 수 있다. 그러나 이러한 변환은 부호, 반올림과 오버플로 의미를 정확히 유지해야 한다.
같은 명령이라도 피연산자의 크기와 레지스터 부분 사용 방식에 따라 성능이 달라질 수 있다. x86-64에서 32비트 레지스터에 쓰면 상위 32비트가 자동으로 0이 되므로, 별도의 0 확장 명령을 줄일 수 있다.
명령어 선택은 일반적으로 컴파일러가 수행하는 편이 적합하다. 컴파일러는 주변 코드의 자료 흐름과 레지스터 할당을 함께 볼 수 있기 때문이다. 수동 어셈블리 최적화는 컴파일러가 필요한 명령을 생성하지 못하거나 반복적으로 실행되는 핵심 부분에서 측정 가능한 이점이 있을 때 적용한다.
명령어 수준 병렬성
명령어 수준 병렬성(Instruction-Level Parallelism, ILP)은 하나의 실행 흐름 안에서 서로 독립적인 여러 명령을 겹쳐 실행할 수 있는 정도를 의미한다.
현대의 고성능 프로세서는 한 주기에 여러 명령을 가져오고 해석하여 서로 다른 실행 장치로 보낼 수 있다. 또한 프로그램 순서와 다른 순서로 명령을 실행하되, 외부에서 보이는 결과는 원래 순서와 일치하도록 정리하는 비순차 실행을 사용할 수 있다.
명령어 수준 병렬성을 제한하는 주요 요소는 다음과 같다.
- 이전 명령의 결과가 필요한 실제 데이터 의존성
- 같은 레지스터 이름을 재사용하여 생기는 이름 의존성
- 메모리 주소의 중복 가능성
- 분기 결과가 결정되지 않은 제어 의존성
- 실행 장치와 포트의 수
- 명령 가져오기와 디코딩 폭
- 재정렬 버퍼와 스케줄러의 크기
다음 명령들이 앞선 결과를 연속해서 사용한다면 하나의 긴 의존 사슬이 형성된다. 프로세서는 뒤의 명령을 앞당겨 실행할 수 없으므로 전체 시간은 각 명령의 지연 시간에 크게 영향을 받는다.
반면 여러 개의 독립적인 누산기를 사용하면 하나의 누산기에 결과가 집중되는 것을 피할 수 있다. 예를 들어 반복문에서 하나의 합계 레지스터만 갱신하는 대신 여러 부분 합을 독립적으로 계산하고 마지막에 합치면 처리량이 높아질 수 있다.
루프 펼치기는 반복문의 본문을 여러 번 복제하여 분기 횟수를 줄이고 독립적인 연산을 더 많이 노출하는 기법이다. 프로세서는 서로 다른 반복에 속한 명령을 함께 실행할 수 있다.
루프 펼치기는 다음과 같은 비용도 가진다.
- 코드 크기 증가
- 명령 캐시 사용량 증가
- 레지스터 사용량 증가
- 반복 횟수 처리와 나머지 루프의 복잡성 증가
- 너무 큰 본문으로 인한 디코딩·캐시 효율 저하
소프트웨어 파이프라이닝은 서로 다른 반복 단계의 명령을 겹쳐 배치하는 기법이다. 한 반복의 계산을 수행하는 동안 다음 반복의 데이터를 적재하고, 이전 반복의 결과를 저장하도록 구성할 수 있다.
비순차 실행 프로세서는 하드웨어가 일정 부분 명령을 재배치하지만, 프로그램에 독립적인 명령이 충분히 존재해야 한다. 긴 의존 사슬이나 연속적인 캐시 미스는 넓은 실행 장치가 있어도 병렬성을 제한한다.
레지스터 이름 바꾸기는 같은 아키텍처 레지스터를 반복해서 쓰면서 생기는 거짓 의존성을 내부 물리 레지스터로 분리한다. 그러나 실제 결과가 다음 명령의 입력인 참 의존성은 제거할 수 없다.
LLVM의 llvm-mca는 대상 프로세서의 스케줄링 모델을 이용하여 주어진 어셈블리 명령열의 처리량, 명령당 주기, 실행 자원 압력과 의존 관계를 정적으로 분석할 수 있다.[156]
정적 분석 결과는 캐시 미스, 실제 분기 예측, 운영체제 간섭과 입력 데이터의 영향을 모두 반영하지 않는다. 따라서 명령어 수준 병렬성 분석은 실제 하드웨어 프로파일링과 함께 사용해야 한다.
파이프라인과 지연 시간
파이프라인은 명령 실행을 여러 단계로 나누어 서로 다른 명령이 각 단계에서 동시에 처리되도록 하는 구조다. 일반적인 단계에는 명령 가져오기, 디코딩, 실행, 메모리 접근과 결과 반영이 포함될 수 있다.
실제 현대 프로세서의 파이프라인은 이보다 훨씬 복잡하다. 명령 캐시와 분기 예측, 마이크로 연산 변환, 레지스터 이름 바꾸기, 비순차 스케줄링, 여러 실행 포트와 결과 은퇴 단계가 결합될 수 있다.
파이프라인이 깊어지면 여러 명령을 겹쳐 실행하여 높은 클럭과 처리량을 얻을 수 있지만, 분기 예측 실패나 데이터 준비 지연이 발생했을 때 비워야 하는 작업량도 증가할 수 있다.
명령 최적화에서는 다음 시간을 구분해야 한다.
- 명령 자체의 결과 지연 시간
- 같은 명령을 연속으로 시작할 수 있는 간격
- 메모리 피연산자의 적재 지연
- 분기 예측 실패 후 복구 시간
- 실행 장치가 사용 가능해질 때까지의 대기
- 프론트엔드가 명령을 공급하지 못해 발생하는 지연
예를 들어 적재 명령의 결과를 바로 사용하는 명령은 메모리 적재 지연에 의존한다. 데이터가 L1 캐시에 있다면 비교적 빠르지만, 더 낮은 캐시나 주 메모리에서 가져오면 지연이 크게 늘어난다.
이러한 지연을 숨기기 위해 필요한 데이터를 실제 사용보다 먼저 적재할 수 있다. 그러나 너무 일찍 적재하면 레지스터를 오래 점유하고, 필요하지 않은 데이터를 가져오거나 캐시에서 다른 유용한 데이터를 밀어낼 수 있다.
명령 스케줄링은 서로 의존하지 않는 명령을 긴 지연 명령 사이에 배치하여 실행 장치가 쉬는 시간을 줄이는 작업이다. 비순차 실행 프로세서는 이를 하드웨어에서 일부 수행하지만, 명령 창의 범위 밖에 있는 작업이나 강한 의존성은 소프트웨어 구조를 바꿔야 한다.
순차 실행 프로세서와 일부 마이크로컨트롤러에서는 컴파일러나 프로그래머의 명령 배치가 더 직접적으로 성능에 영향을 준다. 특정 결과를 사용할 수 있기 전에 필요한 간격을 다른 유용한 명령으로 채워야 할 수 있다.
Arm의 프로세서별 최적화 문서는 파이프라인과 실행 자원의 세부 구조를 기준으로 소프트웨어 최적화 방법을 제공한다. 예를 들어 Cortex-M85의 마이크로아키텍처 안내서는 소프트웨어 최적화를 돕기 위한 파이프라인 구조를 설명한다.[157]
파이프라인 동작은 ISA가 아니라 마이크로아키텍처의 속성이다. 같은 AArch64 명령어를 실행하더라도 소형 순차 실행 코어와 고성능 비순차 실행 코어는 지연 시간과 최적 명령 배치가 다를 수 있다.
캐시와 메모리 접근
프로세서의 계산 속도에 비해 주 메모리 접근은 매우 느리다. 현대 시스템은 자주 사용하는 명령과 데이터를 프로세서 가까이에 보관하기 위해 여러 단계의 캐시 메모리를 사용한다.
일반적으로 L1 캐시는 작지만 빠르고, L2와 L3 캐시는 더 크지만 접근 시간이 길다. 프로세서와 시스템에 따라 명령 캐시와 데이터 캐시가 분리되거나 여러 코어가 마지막 단계 캐시를 공유할 수 있다.
메모리 성능에는 다음 요소가 영향을 준다.
- 캐시 적중과 미스
- 데이터의 공간적·시간적 지역성
- 캐시 라인 크기
- 접근 순서와 보폭
- 하드웨어 프리페처
- 데이터 정렬
- 캐시 집합 충돌
- TLB와 페이지 크기
- 메모리 대역폭
- 여러 코어 사이의 캐시 일관성
- NUMA 시스템의 메모리 위치
공간적 지역성은 가까운 주소가 연속해서 사용되는 성질이고, 시간적 지역성은 최근 사용한 데이터를 다시 사용하는 성질이다. 배열을 연속된 순서로 처리하면 한 캐시 라인에 함께 들어온 여러 요소를 활용할 수 있다.
행 우선으로 저장된 2차원 배열을 열 방향으로 순회하면 큰 보폭으로 여러 캐시 라인을 건너뛸 수 있다. 같은 계산이라도 반복문의 순서를 바꾸어 연속된 메모리를 접근하면 성능이 크게 향상될 수 있다.
데이터 구조가 실제 작업에 필요하지 않은 필드까지 함께 가져오면 캐시 공간과 메모리 대역폭을 낭비한다. 구조체 배열을 여러 필드별 배열로 분리하면 특정 연산에서 필요한 데이터만 연속적으로 읽을 수 있지만, 다른 작업에는 불리할 수 있다.
블로킹 또는 타일링은 큰 작업을 캐시에 들어가는 작은 블록으로 나누어 데이터를 여러 번 재사용하는 기법이다. 행렬 곱셈, 영상 처리와 과학 계산에서 중요한 최적화다.
정렬된 데이터는 하나의 캐시 라인이나 메모리 전송 단위 안에 들어가기 쉽고, 일부 SIMD 명령과 원자적 연산의 요구 조건을 만족한다. 그러나 지나치게 큰 정렬은 패딩과 메모리 사용량을 증가시킨다.
하드웨어 프리페처는 일정한 패턴의 메모리 접근을 감지하여 앞으로 필요할 데이터를 미리 캐시에 가져올 수 있다. 연결 리스트처럼 다음 주소가 현재 데이터에 들어 있는 포인터 추적은 미리 예측하기 어려우므로 메모리 지연을 숨기기 어렵다.
소프트웨어 프리페치 명령을 사용하여 데이터를 미리 요청할 수도 있다. 프리페치가 너무 늦으면 지연을 숨기지 못하고, 너무 이르면 사용 전에 데이터가 밀려날 수 있다. 이미 하드웨어가 잘 예측하는 연속 접근에서는 추가 프리페치가 오히려 자원을 낭비할 수 있다.
Arm의 캐시 최적화 지침은 명령 캐시 사용량을 줄이고 분기와 코드 배치를 조정하는 것이 명령 공급 효율에 영향을 줄 수 있음을 설명한다.[158]
명령 캐시도 데이터 캐시만큼 중요하다. 과도한 함수 인라인과 루프 펼치기는 분기와 호출 비용을 줄일 수 있지만 코드 크기를 늘려 명령 캐시 미스를 증가시킬 수 있다.
TLB는 가상 주소에서 물리 주소로 변환한 결과를 캐시한다. 매우 큰 데이터 집합을 많은 페이지에 걸쳐 불규칙하게 접근하면 데이터 캐시에 들어 있더라도 TLB 미스가 발생할 수 있다. 큰 페이지는 TLB가 담당할 수 있는 메모리 범위를 늘리지만 메모리 관리와 내부 단편화에 영향을 준다.
여러 코어가 같은 캐시 라인의 서로 다른 변수를 자주 수정하면 실제로 같은 값을 공유하지 않더라도 캐시 라인이 코어 사이를 이동하는 거짓 공유가 발생할 수 있다. 자주 수정되는 스레드별 데이터를 서로 다른 캐시 라인에 배치하면 이를 줄일 수 있다.
메모리 최적화는 어셈블리 명령의 교체보다 자료 구조와 알고리즘의 변경에서 더 큰 효과를 내는 경우가 많다. 메모리 병목 상태에서는 산술 명령을 몇 개 줄여도 전체 성능이 거의 변하지 않을 수 있다.
분기 예측
조건 분기와 간접 분기는 다음에 실행할 명령의 위치를 바꾼다. 현대 프로세서는 분기 결과가 확정되기 전에 다음 경로를 예측하고 해당 명령을 미리 가져오고 실행하기 시작한다.
예측이 맞으면 분기 자체의 비용이 매우 작게 보일 수 있다. 예측이 틀리면 잘못된 경로에서 수행하던 작업을 취소하고 올바른 경로에서 다시 명령을 가져와야 한다.
분기 예측기는 다음 정보를 활용할 수 있다.
- 해당 분기의 과거 실행 결과
- 주변 분기들의 결과 이력
- 분기 목적지 기록
- 호출과 반환의 짝
- 반복문의 전형적인 패턴
- 정적 분기 방향과 코드 배치
규칙적인 반복문 종료 분기는 대부분의 반복에서 같은 방향을 가지므로 쉽게 예측되는 편이다. 반대로 무작위 데이터나 예측하기 어려운 입력에 따라 매번 결과가 바뀌는 분기는 높은 예측 실패율을 보일 수 있다.
Intel의 최적화 설명서는 성공적으로 예측된 조건 분기의 지연이 실행 흐름에서 거의 드러나지 않을 수 있지만, 예측 실패는 파이프라인 복구 비용을 발생시킨다고 설명한다.[159]
분기 최적화에는 다음 방식이 사용된다.
- 자주 실행되는 경로를 연속적으로 배치
- 드문 오류 경로를 본문 밖으로 분리
- 불필요한 조건과 중복 분기 제거
- 여러 작은 조건을 하나의 계산으로 결합
- 예측하기 어려운 짧은 분기를 조건부 이동으로 변경
- 간접 호출의 대상 수를 줄임
- 함수와 기본 블록을 프로파일 정보에 따라 재배치
- 반복문의 종료 조건을 단순화
조건부 이동이 항상 분기보다 빠른 것은 아니다. 분기를 사용하면 선택되지 않은 경로의 비싼 계산을 건너뛸 수 있지만, 조건부 이동은 두 결과를 모두 계산한 뒤 하나를 선택해야 할 수 있다.
예측 가능성이 높은 분기를 무리하게 분기 없는 코드로 바꾸면 데이터 의존성이 길어지고 불필요한 명령이 늘어날 수 있다. 반대로 예측 불가능한 짧은 조건에서는 분기 없는 코드가 안정적인 실행 시간을 제공할 수 있다.
함수 포인터, 가상 함수와 점프 테이블은 간접 분기를 사용한다. 목적지가 자주 바뀌거나 후보가 많으면 직접 분기보다 예측하기 어려울 수 있다. 코드 구조와 자료형 정보가 명확하면 컴파일러가 간접 호출을 직접 호출로 바꾸는 비가상화를 수행할 수 있다.
프로파일 유도 최적화는 실제 실행에서 자주 선택되는 분기와 함수 경로를 수집하고, 컴파일러와 링커가 코드 배치와 인라인 결정을 조정하도록 한다.
분기 예측은 성능 기능인 동시에 추측 실행 보안과도 관련된다. 예측이 틀린 경로의 결과는 아키텍처 상태에 반영되지 않더라도 캐시와 다른 마이크로아키텍처 상태에 흔적을 남길 수 있다. 보안 경계의 코드는 단순한 평균 성능 외에도 이러한 영향을 고려해야 한다.
SIMD와 벡터 명령
SIMD는 하나의 명령으로 여러 데이터 요소에 같은 연산을 수행하는 방식이다. 영상, 음성, 암호화, 과학 계산, 행렬, 물리와 신호 처리처럼 반복적인 데이터 병렬성이 있는 작업에 적합하다.
벡터 레지스터 하나에는 여러 개의 정수 또는 부동소수점 요소가 들어갈 수 있다. 예를 들어 256비트 레지스터는 32비트 값 8개 또는 64비트 값 4개를 동시에 처리할 수 있다.
대표적인 벡터 명령어 계열에는 다음이 있다.
- x86의 SSE, AVX, AVX2와 AVX-512
- Arm의 Advanced SIMD, SVE와 SVE2
- RISC-V의 V 확장
- Power ISA의 VMX·VSX
- IBM Z의 벡터 기능
벡터화에는 다음 작업이 필요할 수 있다.
- 스칼라 데이터를 벡터 레지스터에 적재
- 여러 요소에 같은 계산 수행
- 비교 결과를 마스크로 생성
- 마스크에 따라 일부 요소 선택
- 요소를 재배열하거나 섞음
- 여러 요소의 합이나 최댓값을 하나로 축소
- 처리하고 남은 요소를 별도 처리
단순히 스칼라 명령을 벡터 명령으로 바꾸는 것만으로 항상 속도가 벡터 폭만큼 증가하지는 않는다. 메모리 대역폭, 데이터 재배열, 정렬, 마스크 처리와 결과 축소가 병목이 될 수 있다.
연속된 데이터는 하나의 벡터 적재로 처리하기 쉽다. 불규칙한 위치의 데이터를 모으는 gather와 여러 위치로 흩어 쓰는 scatter 명령은 편리하지만 연속 접근보다 비용이 클 수 있다.
벡터화된 반복문은 전체 요소 수가 벡터 폭의 배수가 아닐 때 남는 요소를 처리해야 한다. 별도의 스칼라 꼬리 반복문을 사용하거나, 마스크가 있는 벡터 명령으로 유효한 요소만 처리할 수 있다.
고정 폭 SIMD는 특정 레지스터 크기를 전제로 한다. Arm SVE와 RISC-V V는 구현마다 다른 벡터 길이에서도 같은 프로그램 구조를 사용할 수 있도록 벡터 길이에 독립적인 반복 방식을 지원한다.
RISC-V V 확장은 벡터 레지스터, 요소 폭, 벡터 길이와 마스크를 정의하며, 구현이 지원하는 벡터 레지스터의 물리적 길이와 독립적인 스트립 마이닝 방식의 코드를 작성할 수 있게 한다.[160]
자동 벡터화는 컴파일러가 반복문의 의존성과 비용을 분석하여 스칼라 연산을 벡터 연산으로 바꾸는 기능이다. LLVM의 벡터화기는 자료형 변환, 메모리 검사와 벡터 명령의 비용을 추정하여 벡터화가 이익이 되는지 판단한다.[161]
수동 벡터 최적화는 인스트린식이나 어셈블리어로 작성할 수 있다. 인스트린식은 컴파일러가 레지스터 할당과 주변 명령 스케줄링을 관리할 수 있으므로, 직접적인 어셈블리 코드보다 유지보수하기 쉬운 경우가 많다.
벡터 명령을 사용하면 레지스터 사용량이 증가하고, 스칼라와 벡터 실행 상태 사이의 전환 비용이나 일부 프로세서의 주파수 정책이 영향을 줄 수 있다. 특정 넓은 벡터 명령이 항상 좁은 벡터 명령보다 빠르다고 단정할 수 없다.
코드 크기와 실행 속도
코드 크기와 실행 속도는 서로 관련되지만 항상 같은 방향으로 움직이지 않는다. 더 많은 명령을 사용하면 일반적으로 코드가 커지지만, 병렬성을 높이고 분기를 줄여 실행 속도를 높일 수 있다.
반대로 짧은 명령과 공통 함수를 재사용하면 코드 크기는 줄지만 호출·반환과 추가 분기가 발생할 수 있다.
코드 크기를 증가시키는 대표적인 최적화에는 다음이 있다.
- 함수 인라인
- 루프 펼치기
- 여러 조건에 대한 특수화
- 프로세서 기능별 구현 복제
- 분기를 줄이기 위한 계산 복제
- 큰 상수와 점프 테이블 사용
코드 크기를 줄이는 대표적인 방법에는 다음이 있다.
- 반복 코드를 공통 함수로 분리
- 루프 펼치기 제한
- 사용되지 않는 함수와 섹션 제거
- 짧은 명령 인코딩 사용
- 압축 명령어 확장 사용
- 동일한 상수와 데이터 공유
- 크기 중심 컴파일러 최적화 사용
작은 코드는 저장 공간만 절약하는 것이 아니라 명령 캐시와 TLB 효율을 높일 수 있다. 자주 실행되는 코드가 작은 범위에 모이면 명령 가져오기와 디코딩이 원활해질 수 있다.
그러나 코드 크기를 줄이기 위해 긴 지연 명령이나 반복 호출을 사용하면 실행 시간이 늘어날 수 있다. 따라서 전체 프로그램의 크기보다 자주 실행되는 핵심 경로의 코드 배치가 더 중요할 수 있다.
함수 인라인은 호출 비용을 제거하고 호출자 문맥에서 상수 전파와 추가 최적화를 가능하게 한다. 반면 너무 많은 인라인은 코드 복제를 일으켜 명령 캐시 성능을 떨어뜨릴 수 있다.
루프 펼치기도 분기 오버헤드 감소와 명령어 수준 병렬성 증가라는 장점이 있지만, 지나치게 펼치면 명령 캐시와 디코더에 부담을 준다.
RISC-V의 C 확장, Arm의 T32와 여러 압축 명령 체계는 자주 사용하는 동작을 짧은 인코딩으로 표현하여 코드 밀도를 높인다. 짧은 명령이 반드시 더 적은 내부 작업을 의미하지는 않지만, 메모리와 명령 캐시 사용량을 줄일 수 있다.
x86은 가변 길이 명령을 사용하므로 같은 동작도 레지스터 번호, 즉시값 크기와 주소 표현에 따라 인코딩 길이가 달라진다. 짧은 명령이 프론트엔드 처리에 유리할 수 있지만, 실행 단계의 처리량과는 별개의 문제다.
서버와 데스크톱에서는 실행 속도를 우선할 수 있지만, 펌웨어·마이크로컨트롤러·부트 ROM에서는 저장 공간 제한 때문에 코드 크기가 최우선일 수 있다. 모바일 시스템에서는 작은 코드가 캐시 효율과 전력 소비에도 영향을 줄 수 있다.
최적의 균형은 전체 프로그램에 동일하게 적용되지 않는다. 자주 실행되는 핵심 경로는 속도 중심으로 최적화하고, 거의 실행되지 않는 오류·초기화 경로는 크기 중심으로 구성할 수 있다.
마이크로아키텍처 의존성
명령어 집합 아키텍처는 소프트웨어가 사용할 수 있는 명령과 결과를 정의하지만, 마이크로아키텍처는 해당 ISA를 프로세서 내부에서 어떻게 구현할지를 결정한다.
같은 ISA를 구현하는 프로세서 사이에도 다음 요소가 다를 수 있다.
- 파이프라인 깊이
- 명령 가져오기와 디코딩 폭
- 마이크로 연산 캐시
- 비순차 실행 창의 크기
- 실행 포트와 연산 장치 수
- 명령별 지연 시간과 처리량
- 분기 예측기 구조
- 캐시 크기와 연결도
- TLB 크기와 페이지 처리
- 하드웨어 프리페처
- 메모리 대역폭
- 벡터 장치의 폭
- 코어 간 캐시 공유 방식
- 전력·주파수 조절 정책
따라서 ISA 설명서만으로는 최적화를 완성할 수 없다. 특정 프로세서의 최적화 안내서, 성능 계수, 실제 측정과 프로파일링이 필요하다.
예를 들어 같은 x86-64 명령이 Intel과 AMD 프로세서에서 다른 수의 마이크로 연산으로 변환되거나 서로 다른 실행 포트를 사용할 수 있다. 같은 제조사의 세대 사이에서도 명령 지연과 프론트엔드 구조가 달라질 수 있다.
AMD도 프로세서 제품군별 소프트웨어 최적화 안내서를 제공하며, 명령 선택, 캐시, 분기와 데이터 배치에 대한 권고는 해당 마이크로아키텍처를 대상으로 한다.[162]
Arm 아키텍처는 여러 제조사와 제품군에서 구현된다. 같은 AArch64 바이너리가 소형 효율 코어, 고성능 코어와 서버 코어에서 실행될 수 있지만, 최적 명령 배치와 벡터 사용 방식은 다를 수 있다.
특정 프로세서에 지나치게 맞춘 최적화는 다른 프로세서에서 성능을 떨어뜨릴 수 있다. 이를 줄이기 위해 다음 전략이 사용된다.
- 여러 프로세서에 무난한 기본 구현 제공
- ISA 확장별 최적 구현 분리
- 런타임에서 프로세서 기능과 모델 감지
- 함수 다중 버전 생성
- 동적 디스패치로 구현 선택
- 컴파일 시 대상 프로세서 옵션 제공
- 프로파일 정보에 따른 코드 배치
- 실제 지원 대상 전체에서 성능 시험
컴파일러의 -march, -mcpu, -mtune 계열 옵션은 사용할 수 있는 명령어와 최적화 대상 마이크로아키텍처를 지정한다. 정확한 의미는 컴파일러와 대상 ISA에 따라 다르지만, 일반적으로 명령 사용 가능 범위와 비용 모델을 구분하여 설정할 수 있다.
가장 낮은 공통 ISA만 사용하면 넓은 호환성을 확보할 수 있지만 최신 벡터·암호화 명령을 활용하지 못한다. 반대로 최신 프로세서를 대상으로 빌드하면 오래된 프로세서에서 잘못된 명령 예외가 발생할 수 있다.
라이브러리는 하나의 공개 함수에 여러 ISA별 구현을 연결하고 실행 시 가장 적합한 구현을 선택할 수 있다. 이 구조는 성능을 높이지만 바이너리 크기, 시험 범위와 유지보수 비용을 증가시킨다.
마이크로아키텍처 최적화는 프로세서 설명서의 권고를 기계적으로 적용하는 작업도 아니다. 실제 프로그램은 캐시, 운영체제, 다른 스레드와 입력 데이터의 영향을 받으므로 독립된 짧은 명령열의 이론적 처리량과 전체 응용 프로그램 성능이 다를 수 있다.
따라서 어셈블리 최적화는 다음 순서로 수행하는 것이 적합하다.
어셈블리 최적화 절차
- 실제 작업 부하와 목표 정의
- 프로파일러로 병목 확인
- 명령·캐시·분기·메모리 원인 구분
- 대상 프로세서와 ISA 범위 결정
- 작은 범위의 코드 수정
- 정확성과 ABI 검증
- 여러 입력과 프로세서에서 재측정
- 효과가 없는 변경 제거
최적화된 어셈블리 코드는 작성 시점의 프로세서에만 맞는 결과물이 아니라, 향후 프로세서와 도구 체인에서 다시 검증해야 하는 유지보수 대상이다. 성능 향상이 측정되지 않는 수동 어셈블리 코드는 고급 언어 구현보다 이식성과 안정성만 낮출 수 있으므로, 제한된 핵심 경로에 선택적으로 사용하는 것이 일반적이다.
장점과 한계
어셈블리어의 장점은 프로세서의 명령, 레지스터와 메모리 동작을 직접 표현할 수 있다는 데 있다. 프로그래머는 고급 언어와 컴파일러가 제공하는 추상화를 거치지 않고 특정 명령어 집합의 기능을 정확하게 사용할 수 있으며, 실행 환경이 완전히 구성되지 않은 단계나 일반적인 호출 규약으로 표현하기 어려운 상태 전환도 구현할 수 있다.
동시에 이러한 직접성은 높은 복잡성과 강한 종속성을 만든다. 어셈블리 소스는 특정 명령어 집합, 문법, 어셈블러, ABI와 운영체제에 의존하며, 자료형 검사, 자동 레지스터 할당과 구조화된 제어문처럼 고급 언어가 제공하는 기능을 대부분 직접 관리해야 한다.
따라서 어셈블리어는 일반적인 응용 프로그램 전체에 적용하는 범용적인 최적 선택이라기보다, 하드웨어와 직접 맞닿은 제한된 부분에서 사용하는 전문 도구로 평가하는 것이 적합하다.
장점
기계 동작의 직접적인 표현
어셈블리어는 대상 ISA의 명령과 프로그래밍 모델을 직접 표현한다. 어떤 레지스터를 읽고 변경하는지, 메모리 주소를 어떻게 계산하는지, 어떤 분기와 특수 명령을 실행하는지를 소스 수준에서 명시할 수 있다.
이러한 직접성은 다음과 같은 상황에서 중요하다.
- 부팅 직후의 프로세서 초기화
- 시스템 호출과 예외 진입
- 인터럽트 처리
- 문맥 전환
- 특수 목적 레지스터 접근
- 메모리 장벽과 원자적 연산
- 펌웨어와 장치 제어
- 특정 벡터·암호화 명령 사용
고급 언어는 프로세서의 모든 실행 상태를 직접적인 문법으로 제공하지 않는다. 컴파일러 내장 함수나 시스템 API가 없는 기능은 인라인 어셈블리 또는 외부 어셈블리 모듈을 통해 접근할 수 있다. GCC도 인라인 asm의 대표적인 용도로 시간에 민감한 코드의 성능 조정과 C에서 직접 사용할 수 없는 명령의 실행을 제시한다.[163]
정확한 명령과 코드 배치 제어
어셈블리 프로그래머는 생성할 명령의 종류와 순서, 레지스터 사용과 데이터 배치를 직접 결정할 수 있다. 링커 스크립트와 섹션 지시어를 함께 사용하면 특정 코드와 데이터를 정해진 주소와 정렬에 배치할 수 있다.
이는 다음 환경에서 필요할 수 있다.
- 부트 섹터와 재설정 벡터
- ROM과 플래시 이미지
- 운영체제 커널의 진입 코드
- 실시간 시스템의 검증 대상 명령열
- 일정한 실행 경로가 필요한 암호 코드
- 프로세서별 수동 최적화
- 바이너리 인터페이스의 얇은 래퍼
고급 언어의 소스는 컴파일러 버전과 최적화 옵션에 따라 다른 명령열로 변환될 수 있다. 특정 명령의 존재와 배치 자체가 요구 사항이라면 어셈블리어가 더 직접적인 표현을 제공한다.
작은 실행 환경에서의 사용 가능성
어셈블리 코드는 반드시 표준 라이브러리, 메모리 할당기나 완전한 런타임을 요구하지 않는다. 스택과 전역 데이터가 아직 준비되지 않은 환경에서도 제한된 레지스터와 메모리만으로 실행을 시작할 수 있다.
이러한 특성은 부트로더, 펌웨어, 마이크로컨트롤러, 운영체제 초기화와 독립 실행형 프로그램에서 유용하다. 프로그램이 실행될 환경을 프로그램 자체가 만들어야 하는 경우에는 고급 언어의 정상적인 실행 조건을 먼저 구성하는 어셈블리 진입부가 필요할 수 있다.
특정 조건에서의 높은 성능
어셈블리어는 특정 프로세서의 명령 지연, 실행 포트, 벡터 장치와 캐시 특성을 고려한 세밀한 최적화를 가능하게 한다. 컴파일러가 생성하지 못하는 명령 조합이나 일정한 작업에 특화된 스케줄링을 직접 작성할 수 있다.
대표적인 활용 분야는 다음과 같다.
- 암호화와 해시
- 메모리 복사와 검색
- 영상·음성 처리
- 행렬과 벡터 연산
- 압축과 체크섬
- 운영체제의 짧은 핵심 경로
수동 최적화는 여러 독립 연산을 배치하여 명령어 수준 병렬성을 높이고, 불필요한 메모리 접근과 분기를 줄이며, 특정 벡터 확장을 사용할 수 있게 한다.
다만 성능 향상은 어셈블리어를 사용했다는 사실만으로 보장되지 않는다. 현대 컴파일러도 명령 선택, 레지스터 할당, 함수 인라인과 자동 벡터화를 수행하므로 실제 작업 부하에서 측정하여 비교해야 한다.
코드 크기의 세밀한 조절
제한된 ROM이나 플래시를 사용하는 시스템에서는 명령 인코딩과 데이터 배치를 직접 조정하여 코드 크기를 줄일 수 있다. 불필요한 런타임 초기화와 라이브러리 의존성을 배제하고, 짧은 명령과 공통 루틴을 선택할 수 있다.
초기의 마이크로컴퓨터와 게임기에서는 작은 메모리 안에 프로그램을 넣기 위해 이러한 장점이 중요했다. 현대에도 부트 ROM, 마이크로컨트롤러와 크기 제한이 엄격한 펌웨어에서 활용될 수 있다.
그러나 일반적인 컴파일러의 크기 최적화와 링크 시 사용하지 않는 코드 제거도 높은 수준으로 발전하였다. 직접 작성한 어셈블리 코드가 항상 컴파일된 고급 언어 코드보다 작다고 볼 수는 없다.
바이너리 분석과 디버깅 능력
어셈블리어를 이해하면 프로그램이 실제로 실행하는 명령을 분석할 수 있다. 이는 직접 어셈블리 프로그램을 작성하지 않는 개발자에게도 유용하다.
어셈블리 수준 분석은 다음 작업에 사용된다.
- 컴파일러 생성 코드 확인
- 충돌 지점과 레지스터 상태 조사
- 호출 규약 오류 분석
- 성능 병목 확인
- 악성 코드와 펌웨어 분석
- 바이너리 호환성 조사
- 보안 완화 기능 검증
- 운영체제와 런타임 내부 동작 이해
고급 언어의 자료형과 제어 구조가 최적화 과정에서 제거되더라도 최종 바이너리에는 프로세서가 실행할 명령이 남는다. 어셈블리어는 이 최종 실행 형태를 해석하는 공통 기반이 된다.
컴퓨터 구조에 대한 명확한 이해
어셈블리어는 명령어 집합, 레지스터, 메모리, 스택, 호출 규약과 제어 흐름의 관계를 직접 보여준다. 고급 언어의 함수, 반복문, 포인터와 자료형이 실제 명령으로 어떻게 변환되는지 학습할 수 있다.
초기의 어셈블리어는 숫자로 된 기계 명령과 기억 장소에 기호 이름을 부여하면서 프로그래밍을 사람이 다루기 쉬운 형태로 바꾸었다. Computer History Museum은 어셈블리어의 초기 발전을 연산 코드와 메모리 위치를 숫자 대신 이름으로 작성하게 된 변화로 설명한다.[164]
한계
특정 명령어 집합에 대한 종속성
어셈블리어의 가장 큰 한계는 이식성이 낮다는 점이다. x86-64용 코드는 AArch64나 RISC-V에서 직접 실행할 수 없으며, 레지스터 구조와 명령어를 대상 ISA에 맞게 다시 작성해야 한다.
같은 계열 안에서도 다음 차이가 호환성을 제한한다.
- 16비트·32비트·64비트 실행 모드
- 선택적인 ISA 확장
- 프로세서 세대
- 바이트 순서
- 특권 구조
- 명령어의 폐기와 추가
AVX-512, Arm SVE나 RISC-V V와 같은 확장을 사용한 코드는 해당 기능이 없는 프로세서에서 실행할 수 없다. 여러 프로세서를 지원하려면 기본 구현과 확장별 구현을 각각 유지하고 실행 시 기능을 선택해야 할 수 있다.
운영체제와 ABI에 대한 종속성
ISA가 같아도 운영체제와 ABI가 다르면 함수 호출과 시스템 인터페이스가 달라진다. x86-64 Linux와 x64 Windows는 같은 명령어 집합을 사용하지만 매개변수 전달, 보존 레지스터, 스택 규칙, 시스템 호출과 실행 파일 형식이 다르다.
어셈블리 코드는 다음 요소를 직접 맞춰야 한다.
- 호출 규약
- 심벌 이름과 가시성
- 스택 정렬
- 자료형과 구조체 배치
- 오브젝트 파일 형식
- 재배치 방식
- 예외 처리와 스택 되감기
- 시스템 호출 ABI
- 동적 링킹 구조
이러한 규칙을 위반하면 어셈블과 링크가 성공해도 실행 중 다른 함수의 레지스터를 손상하거나 잘못된 매개변수를 읽을 수 있다.
낮은 생산성과 많은 코드량
어셈블리어에서는 고급 언어의 하나의 문장이 여러 명령과 지시어로 표현될 수 있다. 자료 구조, 반복, 오류 처리와 함수 호출을 구성하려면 더 많은 소스가 필요하며, 큰 프로그램일수록 전체 제어 흐름을 파악하기 어려워진다.
초기의 어셈블리어는 기계어보다 작성하기 쉬웠지만, 일반적으로 기계 명령 하나마다 소스의 한 줄을 작성해야 했다. 이는 하나의 고급 언어 문장이 여러 기계 명령으로 번역되는 컴파일 언어의 등장 배경이 되었다.[165]
고급 언어는 반복문, 함수, 모듈, 자료형과 표준 라이브러리를 통해 프로그램의 의도를 더 짧고 명확하게 표현한다. 따라서 동일한 기능을 구현하는 데 필요한 개발 시간은 일반적으로 어셈블리어가 더 길다.
가독성과 유지보수의 어려움
어셈블리 코드는 개별 명령의 동작을 자세히 보여주지만, 전체 알고리즘의 의도를 자동으로 드러내지는 않는다. 레지스터와 숫자 오프셋만으로 작성된 코드는 변수와 자료 구조의 의미를 파악하기 어렵다.
유지보수성을 높이기 위해 다음 요소가 필요하다.
- 의미 있는 심벌과 레이블
- 명확한 함수 경계
- 호출 규약 문서화
- 레지스터 용도의 일관된 관리
- 자료 구조 오프셋의 공통 정의
- 매크로의 제한적인 사용
- 충분한 주석
- 자동화된 시험과 디스어셈블 검증
과도한 매크로는 반복을 줄일 수 있지만 실제 생성되는 명령을 숨기고 어셈블러별 종속성을 높인다. 반대로 매크로를 사용하지 않으면 같은 프롤로그, 레지스터 저장과 상수 정의가 여러 위치에 반복될 수 있다.
자료형과 안전성 검사의 부족
어셈블러는 레지스터의 비트 패턴이 정수, 포인터, 문자나 부동소수점 값인지 고급 수준에서 알지 못한다. 명령어 형식과 피연산자 크기는 검사할 수 있지만, 값의 의미와 수명까지 보장하지 않는다.
다음과 같은 오류를 프로그래머가 직접 방지해야 한다.
- 잘못된 주소 접근
- 버퍼 범위 초과
- 스택 포인터 손상
- 잘못된 반환 주소
- 레지스터 보존 규칙 위반
- 부호 확장과 0 확장의 혼동
- 데이터 정렬 오류
- 동시성 경쟁 조건
- 사용 중인 메모리의 조기 해제
- 잘못된 시스템 호출 인수
고급 언어의 정적 자료형, 소유권, 범위 검사와 객체 수명 관리가 제공하는 보호를 기본적으로 기대할 수 없다. 어셈블리 모듈을 안전한 언어와 연결해도 해당 모듈 내부의 정확성은 별도로 검증해야 한다.
컴파일러 최적화와의 단절
컴파일러는 고급 언어와 중간 표현을 분석하여 상수 전파, 함수 인라인, 반복문 변환, 자동 벡터화와 레지스터 할당을 수행한다. 외부 어셈블리 함수의 내부는 일반적으로 이러한 분석 대상이 되지 않는다.
인라인 어셈블리도 입력, 출력과 변경 상태를 정확히 선언해야 한다. GCC의 확장 asm에서 컴파일러는 어셈블리 문자열의 실제 의미를 분석하지 않으므로 프로그래머가 피연산자와 변경 목록을 올바르게 기술해야 한다.[166]
잘못 선언된 인라인 어셈블리는 다음 문제를 만들 수 있다.
- 컴파일러가 사용 중인 레지스터를 덮어씀
- 메모리 접근이 잘못 재배치됨
- 출력값이 사용되지 않는다고 판단되어 코드가 제거됨
- 상태 플래그의 변경이 반영되지 않음
- 제어 흐름을 컴파일러가 인식하지 못함
직접 작성한 어셈블리 함수는 컴파일러가 호출자와 결합하여 내부 명령을 인라인하거나 다른 함수와 함께 최적화하기 어렵다. 이에 따라 국소적으로 빠른 구현이 전체 프로그램에서는 추가 호출과 데이터 이동 때문에 불리할 수 있다.
성능 우위가 보장되지 않음
어셈블리어를 사용하면 명령을 직접 선택할 수 있지만, 사람이 작성한 코드가 현대 컴파일러보다 항상 빠른 것은 아니다.
컴파일러는 다음 정보를 함께 활용한다.
- 전체 함수의 자료 흐름
- 레지스터 생존 범위
- 대상 프로세서의 비용 모델
- 호출자와 피호출자의 최적화
- 자동 벡터화
- 프로파일 유도 정보
- 링크 시간 최적화
LLVM의 코드 생성기는 중간 표현을 특정 대상의 어셈블리 또는 바이너리 기계 코드로 변환하면서 명령 선택, 레지스터 할당과 여러 기계 종속 최적화를 수행한다.[167]
수동 어셈블리 코드는 작성자가 명시한 명령을 유지한다는 장점이 있지만, 컴파일러가 주변 문맥에 따라 더 나은 레지스터 배치나 상수 특수화를 수행할 기회를 제한할 수 있다.
성능은 프로세서 세대, 입력 데이터, 캐시 상태와 호출 위치에 따라 달라진다. 한 벤치마크에서 빠른 명령열이 실제 응용 프로그램의 다른 조건에서는 느릴 수 있으므로 프로파일링과 반복 측정이 필요하다.
마이크로아키텍처 변화에 대한 민감성
같은 ISA를 구현하는 프로세서라도 명령의 지연 시간, 처리량, 실행 포트, 캐시와 분기 예측기가 다르다. 특정 프로세서에 맞춘 명령 스케줄은 후속 세대나 다른 제조사의 구현에서 효과가 없거나 불리할 수 있다.
이에 따라 성능 중심 어셈블리 라이브러리는 여러 구현을 유지해야 할 수 있다.
- 범용 기본 구현
- 프로세서 세대별 구현
- 벡터 확장별 구현
- 작은 입력과 큰 입력을 위한 구현
- 정렬된 데이터와 비정렬 데이터를 위한 경로
이러한 다중 구현은 바이너리 크기와 시험 범위를 늘리고, 새 프로세서가 등장할 때 재검증을 요구한다.
디버깅과 검증의 어려움
어셈블리 코드의 오류는 고급 언어의 소스 수준 예외보다 낮은 수준에서 나타날 수 있다. 잘못된 레지스터 값이 여러 함수 호출 뒤에 문제를 일으키거나, 손상된 스택이 전혀 다른 위치의 반환에서 드러날 수 있다.
디버깅에는 레지스터, 메모리, 호출 규약, 오브젝트 파일과 운영체제의 예외 상태에 대한 이해가 필요하다. 최적화된 고급 언어 코드와 결합된 경우 소스 줄과 명령의 대응도 일정하지 않다.
안전·보안이 중요한 시스템에서는 어셈블리 코드의 모든 실행 경로와 레지스터·메모리 효과를 검토해야 한다. 같은 기능의 고급 언어 구현보다 형식 검증과 정적 분석 도구의 적용이 어려울 수 있다.
도구와 문법의 분산
어셈블리어에는 하나의 통일된 문법이나 도구가 없다. 같은 x86 명령어 집합에서도 Intel 문법과 AT&T 문법이 존재하며, GNU Assembler, NASM, MASM과 FASM은 서로 다른 지시어와 매크로 체계를 사용한다.
소스의 호환성은 다음 경계에서 깨질 수 있다.
- 피연산자 순서
- 레지스터와 즉시값 표기
- 메모리 주소 문법
- 섹션 지시어
- 전역·외부 심벌 선언
- 매크로와 조건부 어셈블리
- 디버깅·예외 처리 정보
- 오브젝트 파일 출력
따라서 명령어 집합이 같다는 사실만으로 다른 어셈블러에서 동일한 소스를 사용할 수 있는 것은 아니다.
평가
어셈블리어의 직접성과 통제력은 실행 환경과 하드웨어의 경계를 다루는 코드에서 뚜렷한 장점이 된다. 반면 전체 프로그램을 어셈블리어로 작성하면 이식성, 생산성, 안전성과 유지보수성이 크게 낮아질 수 있다.
현대 소프트웨어에서 일반적으로 사용되는 방식은 다음과 같다.
- 전체 구조와 알고리즘은 고급 언어로 구현
- ISA별 부분은 작은 모듈로 분리
- 가능하면 컴파일러 내장 함수와 인스트린식 사용
- 실제 성능 병목에서만 수동 어셈블리 적용
- 이식 가능한 대체 구현 유지
- ABI와 레지스터 부작용을 문서화
- 여러 프로세서와 도구 체인에서 자동 시험
- 생성된 기계 코드와 실제 성능을 함께 검증
어셈블리어는 고급 언어보다 낮은 수준이라는 이유만으로 더 우수하거나 열등한 언어가 아니다. 표현하는 문제의 수준이 다르며, 프로세서의 구체적인 동작을 다룰 때 강하고 복잡한 응용 구조와 이식 가능한 소프트웨어를 구성할 때는 한계가 크다.
영향
어셈블리어는 기계어의 수치 명령을 사람이 읽을 수 있는 기호로 바꾸면서 초기 소프트웨어 개발 방식에 근본적인 변화를 만들었다. 연산 코드와 메모리 주소에 이름을 붙이고, 번역 작업을 컴퓨터 자체에 맡기는 방식은 이후의 컴파일러, 링커, 로더와 현대적인 개발 도구 체인으로 이어졌다.
기계어 프로그래밍의 기호화
어셈블리어 이전의 프로그래머는 기계 명령의 수치 코드와 기억 장소 주소를 직접 다뤄야 했다. 명령어 니모닉과 레이블의 도입은 프로그램을 단순한 숫자 배열이 아니라 의미 있는 이름과 관계로 표현할 수 있게 하였다.
Computer History Museum은 초기 프로그래밍 발전의 하나로 연산 코드와 메모리 위치를 숫자가 아닌 이름으로 작성하는 어셈블리어의 등장을 들고 있다.[168]
이 변화는 단순한 표기 편의에 그치지 않았다. 주소 계산, 레이블 해석과 명령 인코딩을 어셈블러가 자동화하면서 프로그램을 수정하고 재배치하기 쉬워졌다.
자동 프로그래밍과 번역 프로그램
어셈블러는 프로그램을 입력받아 다른 프로그램을 생성하는 초기 번역 프로그램이었다. 기호식 원시 코드를 기계어로 변환하고, 심벌 테이블과 주소를 계산하는 과정은 소프트웨어가 다른 소프트웨어를 분석하고 생성하는 개발 모델을 확립하였다.
어셈블러에서 발전한 핵심 개념에는 다음이 있다.
- 소스 언어와 목적 언어의 구분
- 심벌 테이블
- 여러 패스에 걸친 번역
- 전방 참조 해결
- 재배치
- 매크로 확장
- 라이브러리와 외부 심벌 연결
- 오류 진단과 목록 출력
이러한 기능은 이후 컴파일러와 링커의 기본 구조에 영향을 주었다.
고급 프로그래밍 언어와 컴파일러의 발전
어셈블리어는 기계어보다 편리했지만 대부분의 경우 기계 명령 하나마다 한 줄을 작성해야 했다. 프로그램의 규모가 커지면서 수학식, 반복, 함수와 자료 구조를 더 높은 수준에서 표현하려는 요구가 커졌다.
John Backus는 1954년 이전에는 거의 모든 프로그래밍이 기계어나 어셈블리어로 이루어졌으며, 당시 효율적인 프로그래밍은 기계에 대한 세밀한 지식과 창의성이 필요한 작업으로 여겨졌다고 회고하였다.[169]
FORTRAN과 같은 고급 언어는 하나의 표현을 여러 기계 명령으로 자동 변환하고, 숙련된 어셈블리 프로그래머에 가까운 성능을 내는 것을 목표로 하였다. IBM은 FORTRAN이 프로그래밍을 제한된 전문가의 영역에서 더 넓은 사용자층으로 확장하는 데 기여했다고 설명한다.[170]
초기의 고급 언어 컴파일러와 런타임은 어셈블리어로 구현되었으며, 컴파일 결과도 어셈블리어나 기계어 형태로 생성되었다. 이에 따라 어셈블리어는 고급 언어에 의해 단순히 대체된 것이 아니라 컴파일러가 대상을 표현하는 저수준 언어로 역할이 바뀌었다.
컴파일러 백엔드와 중간 표현
현대 컴파일러는 언어별 앞단과 기계별 뒷단을 분리한다. 고급 언어는 공통 중간 표현으로 변환되고, 백엔드는 이를 x86-64, AArch64, RISC-V와 다른 ISA의 명령으로 변환한다.
LLVM의 대상 독립 코드 생성 프레임워크는 LLVM 중간 표현을 지정된 대상의 어셈블리 또는 바이너리 기계 코드로 변환한다.[171]
이 구조에서 어셈블리어와 기계 명령은 다음 요소를 정의하는 기준이 된다.
- 명령 선택
- 레지스터 할당
- 호출 규약
- 스택 프레임
- 명령 스케줄링
- 재배치와 오브젝트 파일 생성
- 디버깅 정보
- JIT 코드 생성
어셈블리어에 대한 이해는 새로운 ISA용 컴파일러 백엔드와 JIT 컴파일러를 개발하는 데 계속 필요하다.
운영체제와 시스템 소프트웨어
어셈블리어는 초기 운영체제와 시스템 소프트웨어의 주요 구현 언어였다. 이후 UNIX와 C의 영향으로 커널 대부분이 고급 언어로 옮겨갔지만, 부팅, 예외, 시스템 호출과 문맥 전환 같은 아키텍처 경계에서는 계속 사용되었다.
이러한 역할 분리는 현대 시스템 프로그래밍의 일반적인 구조가 되었다.
- 정책과 자료 구조는 고급 언어로 작성
- ISA별 진입·복귀 코드는 어셈블리어로 작성
- 공통 인터페이스로 아키텍처 차이를 격리
- 링커와 빌드 시스템으로 하나의 커널 이미지 구성
어셈블리어는 운영체제가 프로세서의 특권 기능을 어떻게 사용하고, 사용자 프로그램의 실행 상태를 어떻게 저장·복원하는지를 표현하는 기반이 되었다.
호출 규약과 ABI의 형성
어셈블리 모듈과 여러 언어의 코드를 연결하려면 레지스터, 스택, 자료형과 심벌에 대한 공통 규칙이 필요하다. 이러한 요구는 호출 규약과 응용 프로그램 이진 인터페이스의 발전으로 이어졌다.
ABI는 다음 요소를 표준화한다.
- 매개변수와 반환값
- 레지스터 보존
- 스택 정렬
- 구조체와 기본 자료형 배치
- 심벌과 이름 연결
- 오브젝트 파일
- 동적 링킹
- 예외 처리와 스택 되감기
이를 통해 서로 다른 컴파일러와 언어가 생성한 오브젝트 파일을 하나의 프로그램으로 연결할 수 있게 되었다.
매크로와 언어 확장
매크로 어셈블러는 반복되는 명령열을 이름과 매개변수로 정의하고 조건에 따라 소스를 생성할 수 있게 하였다. 일부 매크로 체계는 구조화된 제어문, 자료 구조와 함수 선언에 가까운 문법을 제공하였다.
이러한 기능은 이후 전처리기, 템플릿, 코드 생성기와 도메인별 언어의 발전과 공통되는 개념을 가진다. 소스 코드를 실행 전에 변환하여 반복을 줄이고 여러 하드웨어 구성을 지원하는 방식은 현대 빌드 시스템과 메타프로그래밍에서도 이어진다.
디버깅과 바이너리 도구
어셈블리 프로그램을 개발하려면 주소, 심벌, 레지스터와 기계 명령을 확인할 수 있는 도구가 필요했다. 이에 따라 어셈블러와 함께 링커, 로더, 디버거, 심벌 덤프와 디스어셈블러가 발전하였다.
현대의 개발 도구 체인은 다음 정보들을 서로 연결한다.
- 고급 언어의 소스 줄
- 컴파일러 중간 표현
- 어셈블리 명령
- 오브젝트 파일의 섹션과 심벌
- 실행 파일의 가상 주소
- 실행 중의 레지스터와 메모리
- 프로파일링 표본
어셈블리어는 이러한 여러 표현 가운데 프로세서 명령에 가장 가까운 공통 분석 형식으로 남아 있다.
컴퓨터 구조와 ISA 설계
어셈블리어는 하드웨어 설계자가 만든 명령어 집합을 소프트웨어가 실제로 사용하는 형태로 드러낸다. 프로그래머와 컴파일러가 어떤 명령을 자주 사용하고 어떤 구조를 처리하기 어려워하는지가 후속 ISA 설계에 영향을 주었다.
명령어 집합에는 다음과 같은 소프트웨어 요구가 반영되어 왔다.
- 함수 호출과 반환
- 스택과 프레임 관리
- 배열과 구조체 주소 계산
- 원자적 연산
- 운영체제의 특권 전환
- 부동소수점과 SIMD
- 암호화와 비트 조작
- 가상화
- 메모리 보호
초기의 CISC와 RISC 논의도 컴파일러가 실제로 생성하는 명령과 하드웨어 구현 비용의 관계를 중심으로 이루어졌다. 어셈블리 코드와 컴파일러 출력 분석은 ISA와 마이크로아키텍처를 평가하는 중요한 수단이 되었다.
개인용 컴퓨터와 게임 문화
마이크로프로세서와 개인용 컴퓨터의 초기 시기에는 운영체제, 게임, 그래픽과 장치 제어 프로그램이 어셈블리어로 작성되었다. 6502, Z80, 68000과 x86 계열의 어셈블리어는 제한된 메모리와 성능 안에서 하드웨어의 기능을 최대한 활용하는 개발 문화를 형성하였다.
이 시기의 프로그램은 다음 영역에 영향을 남겼다.
- 홈 컴퓨터용 소프트웨어
- 게임기와 아케이드 게임
- 데모신
- 운영체제와 BASIC 인터프리터
- 복사 방지와 바이너리 패치
- 장치와 주변기기 제어
현대에는 게임 전체를 어셈블리어로 작성하는 경우가 드물지만, 레트로 컴퓨팅과 홈브루 개발에서는 원래 하드웨어의 제약을 다루기 위해 계속 사용된다.
역공학과 보안
소스 코드 없이 프로그램을 분석할 때는 최종 기계 코드와 이를 표현한 어셈블리 명령이 핵심 자료가 된다. 이에 따라 어셈블리어는 역공학, 악성 코드 분석과 취약점 연구의 기본 지식이 되었다.
보안 분야에서 어셈블리 분석은 다음 작업에 사용된다.
- 메모리 손상 경로 추적
- 호출 규약과 스택 분석
- 악성 행위 확인
- 난독화와 자체 수정 코드 분석
- 보안 완화 기능 검사
- 추측 실행과 부채널 조사
- 패치 전후의 바이너리 비교
컴파일러와 운영체제의 보안 기능도 실제 생성된 명령을 기준으로 검증된다. 스택 보호, 간접 분기 보호, 섀도 스택과 포인터 인증 등이 원하는 위치에 적용되었는지 디스어셈블 결과로 확인할 수 있다.
교육
어셈블리어는 컴퓨터가 고급 언어를 실제로 어떻게 실행하는지 설명하는 교육 수단으로 사용된다. 변수, 함수, 반복, 포인터와 자료 구조가 레지스터와 메모리 접근으로 변환되는 과정을 보여준다.
교육에서 어셈블리어는 다음 분야를 연결한다.
- 디지털 논리
- 컴퓨터 구조
- 프로그래밍 언어
- 컴파일러
- 운영체제
- 보안
- 성능 분석
어셈블리 교육의 목적은 모든 프로그램을 저수준으로 작성하는 데 있지 않다. 하드웨어와 소프트웨어 사이의 경계를 이해하고, 고급 언어의 추상화가 어떤 기계 동작 위에서 구현되는지 분석하는 능력을 기르는 데 있다.
현대적 위치
어셈블리어의 영향은 사용량만으로 평가하기 어렵다. 일반 응용 프로그램에서 직접 작성되는 비중은 감소했지만, 컴파일러가 생성하는 모든 네이티브 코드와 운영체제의 아키텍처 경계, 디버거와 바이너리 분석 도구는 여전히 어셈블리 명령과 기계 코드의 관계에 의존한다.
현대의 어셈블리어는 다음 역할을 동시에 가진다.
- 특정 ISA를 직접 프로그래밍하는 언어
- 컴파일러 백엔드의 출력 표현
- 디스어셈블러가 바이너리를 설명하는 표기
- 운영체제와 펌웨어의 저수준 구현 수단
- 성능·보안 분석의 공통 언어
- 컴퓨터 구조 교육의 실습 언어
따라서 어셈블리어는 고급 언어의 등장으로 사라진 과거의 기술이 아니라, 고급 언어와 실제 프로세서 사이의 마지막 소프트웨어 표현 계층으로 남아 있다.
관련 문서
- 기계어
- 저수준 프로그래밍 언어
- 프로그래밍 언어
- 명령어 집합 아키텍처
- 중앙 처리 장치
- 마이크로아키텍처
- 레지스터
- 메모리
- 메모리 주소
- 주소 지정 방식
- 가상 메모리
- 스택
- 힙
- 호출 규약
- 응용 프로그램 이진 인터페이스
- 시스템 호출
- 인터럽트
- 예외 처리
- 원자적 연산
- 메모리 모델
- SIMD
- 벡터 프로세서
- 컴파일러
- 컴파일러 백엔드
- 어셈블러
- GNU Assembler
- NASM
- MASM
- FASM
- LLVM
- 링커
- 로더
- 오브젝트 파일
- 실행 파일
- ELF
- Portable Executable
- Mach-O
- 디버거
- 디스어셈블러
- 프로파일러
- 역공학
- 컴퓨터 보안
- 운영체제
- 운영체제 커널
- 부트로더
- 펌웨어
- 장치 드라이버
- 임베디드 시스템
- 실시간 운영체제
- x86
- x86-64
- Arm
- AArch64
- RISC-V
- MIPS
- Power ISA
- IBM Z
- SPARC
- MOS Technology 6502
- Zilog Z80
- C (프로그래밍 언어)
- C++
- Rust
- Fortran
- IBM: The Assembler language on z/OS ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manuals ↩
- GNU Binutils: Using as ↩
- Computer History Museum: Higher Level Languages ↩
- IBM: Fortran ↩
- Computer History Museum: Higher Level Languages ↩
- University of Cambridge Computer Laboratory: EDSAC Initial Orders and Squares Program ↩
- University of Cambridge Computer Laboratory: Events in the Early History of the Computer Laboratory ↩
- Computer History Museum: FORTRAN II Assembly Program ↩
- Computer History Museum: IBM 1620 Symbolic Programming System Specifications ↩
- Computer History Museum: Higher Level Languages ↩
- IBM Research: The History of FORTRAN I, II, and III ↩
- Intel: Announcing a New Era of Integrated Electronics — The Intel 4004 ↩
- Intel: The Intel 8008 ↩
- Intel: Launching a Classic — The 8080 ↩
- Computer History Museum: Early Digital Research CP/M Source Code ↩
- GNU Compiler Collection: Basic Asm ↩
- GNU Binutils: Using as — Overview ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manuals ↩
- RISC-V International: RISC-V Instruction Set Architecture Manuals ↩
- RISC-V International: RV32I Base Integer Instruction Set ↩
- Arm Developer: Registers in AArch64 ↩
- GNU Binutils: i386 Mnemonics ↩
- GNU Binutils: i386 Variations ↩
- Arm Developer: Registers in AArch64 ↩
- Arm Developer: Addressing Modes ↩
- Arm Architecture Reference Manual: Load/store addressing modes ↩
- GNU Binutils: Statements ↩
- GNU Binutils: AArch64 Relocations ↩
- GNU Binutils: Using as ↩
- Arm: The A64 ISA and Compilers ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manuals ↩
- RISC-V International: RISC-V Unprivileged ISA Specification ↩
- RISC-V International: RV32I Base Integer Instruction Set ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manual, Instruction Set Reference ↩
- RISC-V International: RV32I Load and Store Instructions ↩
- RISC-V International: RV32I Conditional Branches ↩
- Arm: Using the Stack in AArch32 and AArch64 ↩
- Arm: BLR — Branch with Link to Register ↩
- GNU Binutils: Assembler Directives ↩
- GNU Binutils: Using as ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 1 ↩
- Arm Architecture Reference Manual: Registers in AArch64 Execution State ↩
- RISC-V International: RV32I Base Integer Instruction Set ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manual ↩
- RISC-V International: Control and Status Registers ↩
- RISC-V International: RV32I Base Integer Instruction Set ↩
- Arm: Using the Stack in AArch32 and AArch64 ↩
- GNU Binutils: Using as ↩
- RISC-V International: Supervisor-Level ISA ↩
- GNU Binutils: Using as ↩
- GNU Binutils: Assembler Preprocessing ↩
- [GNU Binutils:
.macro](https://sourceware.org/binutils/docs/as/Macro.html) ↩ - [GNU Binutils:
.section](https://sourceware.org/binutils/docs/as/Section.html) ↩ - System V ABI: ELF Sections ↩
- System V ABI: ELF Symbol Table ↩
- System V ABI: ELF String Table ↩
- System V ABI: ELF Introduction ↩
- Microsoft: PE Format ↩
- [GNU Binutils: GNU linker
ld](https://sourceware.org/binutils/docs/ld/) ↩ - GNU Binutils: Linker Scripts ↩
- System V ABI: ELF Program Loading ↩
- Microsoft: PE Format ↩
- System V ABI: ELF Dynamic Linking ↩
- Microsoft: Run-Time Dynamic Linking ↩
- System V ABI: ELF Object File Format ↩
- Microsoft: PE Format ↩
- GNU Project Debugger: GDB Documentation ↩
- [GNU Binutils:
objdump](https://sourceware.org/binutils/docs/binutils/objdump.html) ↩ - LLVM: llvm-objdump ↩
- GNU Binutils: AT&T Syntax versus Intel Syntax ↩
- GNU Binutils: x86 Memory References ↩
- NASM Documentation: Effective Addresses ↩
- Microsoft: Microsoft Macro Assembler Reference ↩
- GNU Binutils: AT&T Syntax versus Intel Syntax ↩
- GNU Binutils: x86 Instruction Naming ↩
- GNU Binutils: AT&T Syntax versus Intel Syntax ↩
- GNU Binutils: Assembler Directives ↩
- NASM Documentation: The NASM Preprocessor ↩
- NASM Documentation: Constants and Expressions ↩
- Microsoft: MASM Directives Reference ↩
- GNU Binutils: Symbols ↩
- GNU Binutils: Local Labels ↩
- NASM Documentation: Identifiers ↩
- Microsoft: ML Nonfatal Error A2008 ↩
- GNU Binutils: x86 Special Characters ↩
- NASM Documentation: Layout of a NASM Source Line ↩
- Microsoft: MASM Directives Reference ↩
- Microsoft: INCLUDE ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manuals ↩
- AMD: AMD64 Architecture Programmer’s Manual ↩
- Arm: Architecture Reference Manual for A-profile Architecture ↩
- RISC-V International: RISC-V ISA Specifications ↩
- RISC-V International: Unprivileged ISA Specification ↩
- RISC-V International: Privileged Architecture ↩
- Microchip: MIPS32 microAptiv and M5150 ISA Overview ↩
- OpenPOWER Foundation: Power Instruction Set Architecture ↩
- OpenPOWER Foundation: Linux on POWER Architecture Reference ↩
- IBM: z/Architecture Principles of Operation ↩
- Oracle: SPARC-V9 Instruction Set ↩
- Oracle: SPARC V9 ABI Features ↩
- Western Design Center: W65C02S Datasheet ↩
- Zilog: Z80 CPU User Manual ↩
- x86-64 psABI ↩
- Arm: Procedure Call Standard for the Arm 64-bit Architecture ↩
- RISC-V ELF psABI: Calling Convention ↩
- RISC-V ELF psABI: Procedure Calling Convention ↩
- Arm: AAPCS64 General-purpose Registers ↩
- RISC-V ELF psABI: Integer Register Convention ↩
- Arm: AArch64 Exception Handling Examples ↩
- Linux man-pages: syscall(2) ↩
- Intel: Intel 64 and IA-32 Architectures Software Developer’s Manual ↩
- Arm: AArch64 Exception Model ↩
- Arm: Taking an Exception ↩
- RISC-V Privileged Architecture: Machine-Level ISA ↩
- RISC-V ISA: A Extension for Atomic Instructions ↩
- RISC-V ISA: RVWMO Memory Consistency Model ↩
- RISC-V ISA: Zabha Extension ↩
- RISC-V ISA: Zacas Extension ↩
- GCC: How to Use Inline Assembly Language in C Code ↩
- [GCC: Constraints for
asmOperands](https://gcc.gnu.org/onlinedocs/gcc/Constraints.html) ↩ - The Rust Reference: Inline Assembly ↩
- Clang: Language Compatibility — Inline Assembly ↩
- Microsoft: x64 Calling Convention ↩
- Microsoft: Unmanaged Calling Conventions ↩
- LLVM: Auto-Vectorization ↩
- GNU Binutils: Using as ↩
- NASM: The Netwide Assembler ↩
- NASM Documentation ↩
- Microsoft: Microsoft Macro Assembler Reference ↩
- Microsoft: MASM for x64 ↩
- flat assembler: Programmer’s Manual ↩
- Clang: Assembling a Complete Toolchain ↩
- LLVM Project Blog: Intro to the LLVM MC Project ↩
- GDB: Debugging with GDB ↩
- GDB: Registers ↩
- GDB: Setting Breakpoints ↩
- GNU Binutils: objdump ↩
- LLVM: llvm-objdump ↩
- perf: Linux profiling with performance counters ↩
- Intel: VTune Profiler ↩
- Intel: VTune Profiler Documentation ↩
- Microsoft: Run profiling tools on release or debug builds ↩
- Microsoft: Choosing between Visual Studio Performance Profiler and Windows Performance Toolkit ↩
- Linux Kernel Documentation: Kernel Entries ↩
- Linux Kernel Documentation: Assembler Annotations ↩
- UEFI Specification 2.11: Boot Services ↩
- UEFI Specification 2.11 ↩
- Arm Learning Paths: CMSIS v6 Device Support ↩
- Intel: Intrinsics Guide ↩
- LLVM: The LLVM Target-Independent Code Generator ↩
- LLVM: Writing an LLVM Backend ↩
- RISC-V International: ISA Introduction ↩
- RISC-V International: Educational Resources ↩
- Intel 64 and IA-32 Architectures Optimization Reference Manual ↩
- LLVM: llvm-mca — LLVM Machine Code Analyzer ↩
- Arm: Cortex-M85 Software Optimization Guide — Pipeline Overview ↩
- Arm: Optimizing Instruction Cache Usage ↩
- Intel 64 and IA-32 Architectures Optimization Reference Manual, Volume 2 ↩
- RISC-V International: RISC-V V Vector Extension ↩
- LLVM: Auto-Vectorization ↩
- AMD: Software Optimization Guide for AMD Family 17h Processors ↩
- GCC: Extended Asm ↩
- Computer History Museum: Higher Level Languages ↩
- Computer History Museum: Higher Level Languages ↩
- GCC: Extended Asm ↩
- LLVM: The LLVM Target-Independent Code Generator ↩
- Computer History Museum: Higher Level Languages ↩
- IBM Research: The History of FORTRAN I, II, and III ↩
- IBM: Fortran ↩
- LLVM: The LLVM Target-Independent Code Generator ↩