8 분 소요

글에 들어가기 앞서…

이 포스팅은 서울시립대학교 인공지능학과 백형부 교수님의 ‘컴퓨터시스템’ 강좌의 중간고사 이전 범위 수업 내용에 대한 정리를 담고 있습니다.

수업 자료 출처: Bryant and O’Hallaron, Computer Systems: A Programmer’s Perspective, 3rd Edition

기계 수준 프로그래밍 I: 기초

C, 어셈블리, 기계어 코드

C언어가 어셈블리로 어떻게 변환되는지, 어셈블리는 기계어로 어떻게 변환되는지에 대해서 살펴보겠습니다.

어셈블리/기계어 코드 훑어보기

image-20240414125514392

컴퓨터의 구조를 이해하기 위해 메모리, CPU, 그리고 이들 사이의 정보 교환 방식에 대해 살펴봅시다. 메모리는 코드, 데이터, 스택 등을 저장하는 공간으로 구성되어 있으며, 이와 별개로 힙 영역도 존재합니다. 스택과 힙은 자료구조에서의 용어와 동일하게 들릴 수 있지만, 실제 메모리 구조 내에서는 특정 자료구조 형태로 존재하지 않고 단지 데이터를 저장하는 영역으로 이해됩니다.

메모리와 CPU 사이의 정보 교환은 버스를 통해 이루어집니다. 버스는 주소, 데이터, 명령어 등을 전달하는 역할을 합니다. 이를 통해 CPU는 연산을 수행하게 되는데, CPU의 구성 요소에는 레지스터, 프로그램 카운터(PC), 그리고 조건 코드가 포함됩니다.

  1. 레지스터: 프로그램 데이터가 저장되어 연산이 수행되는 임시 저장소입니다. 레지스터는 고유한 이름과 특정 용도가 있으며, 이를 통해 어떤 연산에서 어떤 레지스터를 사용할지 결정할 수 있습니다.

  2. 프로그램 카운터(PC): 다음에 실행될 명령어의 주소를 저장하는 레지스터입니다. 때때로 RIP(Register Instruction Pointer)로 불리기도 합니다. 프로그램 카운터는 CPU가 수행할 다음 작업을 지시하는 중요한 요소입니다.

  3. 조건 코드: CPU 내부의 상태 레지스터 또는 플래그 레지스터에 저장되어 있는 비트들입니다. 이들은 프로그램의 실행 도중 발생한 특정 조건(예: 연산 결과가 0인지, 양수인지 등)을 나타내며, 이를 기반으로 조건문이나 반복문 등의 분기 처리가 이루어집니다.

이러한 구성 요소들은 컴퓨터가 데이터를 처리하고, 프로그램의 흐름을 제어하는 데 핵심적인 역할을 합니다. 메모리에 저장된 명령어와 데이터가 CPU로 전달되어, 레지스터에서의 연산을 통해 결과가 생성되고, 이 결과는 다시 메모리에 저장되거나 다른 연산의 입력으로 사용됩니다.

C가 Object 코드로 변환되는 과정

image-20240414130708499

위의 예시에서, p1.c에는 메인 함수가, p2.c에는 사용자 정의 함수가 포함되어 있습니다. 이 두 소스 파일은 컴파일러를 통해 어셈블리 코드로 변환되는데, 이 변환 과정은 사용하는 컴파일러의 종류에 따라 결과가 달라질 수 있습니다. 어셈블리 코드와 C코드는 서로 일대일 대응이 아니기에, 컴파일러의 최적화와 구현 방식에 따라 차이가 발생합니다.

변환된 어셈블리 코드는 어셈블러를 사용하여 바이너리 파일로 컴파일됩니다. 그 후, p1.cp2.c에서 생성된 바이너리 파일들을 하나의 실행 가능한 프로그램으로 통합하기 위해 링커가 사용됩니다. 링커는 이러한 파일들을 적절히 결합하여 실행 파일을 생성하는 역할을 합니다.

프로그램의 실행 파일 생성 과정에서는 정적 라이브러리(static libraries)가 프로그램에 포함될 수 있습니다. 이 과정은 실행 파일에 필요한 추가 기능이나 코드를 제공하는 라이브러리를 포함시키는 것을 의미합니다. 이와 대조적으로, 동적 링크 라이브러리(DLL, Dynamic Link Library)는 실행 파일이 실행되는 시점, 즉 런타임에 동적으로 연결됩니다. DLL은 링커와 다른 점이 여기에 있습니다. 링커가 컴파일 타임에 작업을 수행하는 반면, DLL은 프로그램이 실제로 실행될 때 필요한 코드나 데이터를 불러오는 동적 과정을 담당합니다.

예시 01(C 코드 어셈블리 변환)

image-20240414131658810sumstore 함수의 어셈블리 코드는 매우 간단한 프로세스를 따릅니다. 기본적으로, 이 함수는 입력된 두 값을 더한 후, 그 결과를 지정된 메모리 주소에 저장합니다.

어셈블리 코드의 실행 과정을 자세히 살펴보겠습니다:

  1. 레지스터 저장: 함수의 시작 부분에서 pushq %rbx 명령어를 통해 rbx 레지스터의 현재 값을 스택에 백업합니다. 이는 rbx 레지스터를 함수 내에서 임시적으로 사용하기 위함이며, 함수 실행이 끝난 후 원래 상태를 복원해야 하기 때문입니다.

  2. 매개변수 전달: C언어 함수 호출 규약에 따라, sumstore 함수에 전달된 매개변수 x, y, dest는 각각 rdi, rsi, rdx 레지스터에 저장됩니다. 여기서 rdx에 저장된 dest 주소를 rbx에 복사합니다.

  3. 함수 호출: call plus 명령어는 plus 함수를 호출하여 실행합니다. 이때 plus 함수에 전달되는 매개변수는 이미 rdirsi 레지스터에 저장되어 있으므로, 추가적인 작업 없이 바로 plus 함수로 이동하여 계산을 수행합니다.

  4. 결과 저장: plus 함수의 실행 결과는 rax 레지스터에 저장됩니다. 이후 movq %rax, (%rbx) 명령어를 통해 rax에 저장된 결과 값을 rbx 레지스터가 가리키는 메모리 주소(즉, dest가 가리키는 주소)에 저장합니다.

  5. 레지스터 복원 및 함수 종료: 마지막으로 popq %rbx 명령어를 통해 스택에 백업해둔 rbx 레지스터의 원래 값을 복원하고, ret 명령어로 함수를 종료합니다.

이 과정을 통해 sumstore 함수는 두 입력 값을 더한 결과를 지정된 메모리 주소에 저장하며, 함수 실행 전후로 레지스터 상태를 유지하여 다른 함수나 연산에 영향을 주지 않습니다.

image-20240414134347316

image-20240414134515266

이어서, 어셈블러는 위와 같이 기계어로 하나하나 변환됩니다.

어셈블리 기초: 레지스터, 피연산자, move

레지스터의 구성, 어셈블리 코드에 따른 연산과정에 대해서 살펴보겠습니다.

X86-64 정수 레지스터

image-20240414134806593

x86-64 아키텍처에서 사용되는 레지스터들은 64비트, 즉 8바이트로 구성되어 있으며, 이 중 eax, ebx와 같은 레지스터들은 4바이트 크기를 가집니다. 예를 들어, rax 레지스터의 하위 32비트는 eax로 사용되는데, 이는 전체 레지스터 공간이 필요하지 않은 경우에 유용합니다.

각 레지스터에는 특정 용도가 권장되는 규약이 있지만, 이는 엄격한 규칙이 아닌 권장 사항일 뿐입니다. 프로그램은 이러한 규약을 따르지 않아도 정상적으로 작동할 수 있지만, 대부분의 개발자들이 이 규약을 준수하는 이유는 어셈블리 코드의 이해도를 높이고 호환성을 보장하기 위해서입니다. 예를 들어, rax 레지스터는 함수의 반환 값이나 산술 연산에 주로 사용되며, rbx는 메모리 주소를 저장하는 데, rcx는 반복문의 카운터로 자주 사용됩니다. 함수의 매개변수 전달에는 rdi, rsi, rdx, rcx, r8, r9 순으로 레지스터가 사용되며, rsp 레지스터는 스택 포인터로서 스택의 시작 위치를 가리키는 데 사용됩니다. 처음에는 이러한 규약에 따라 레지스터를 사용하는 것이 번거로울 수 있으나, 규약에 맞춰 코드를 작성하면 다른 사람이 내 코드를 이해하기 쉽고, 나 또한 다른 사람의 코드를 이해하기 쉬워집니다.

image-20240414135558215

과거에는 64비트 운영체제가 존재하지 않았고, 따라서 ‘r’ 접두사가 붙은 레지스터도 없었습니다. 당시에는 eax, ecx와 같은 32비트 레지스터만이 존재했으며, 이러한 레지스터 내부에는 ax, ah, al과 같은 더 작은 하위 레지스터들이 있었습니다. 현재 64비트 운영체제에서 rax, rbx 등의 이름을 사용하며, eax, ebx 등의 레지스터도 사용할 수 있는 것은 이러한 역사적 배경 덕분입니다.

데이터 이동(movq)

movq는 어셈블리 언어에서 중요한 명령어 중 하나로, 값을 한 위치에서 다른 위치로 이동시키는 데 사용됩니다. 이 명령어의 사용 방법을 자세히 살펴보겠습니다.

  1. 상수 표현: 상수 값을 movq 명령어에 사용하려면, 달러 사인($)을 앞에 붙여 표현합니다. 예를 들어, $0x400 또는 $-533과 같이 작성할 수 있습니다. 이렇게 표현된 상수는 직접적으로 데이터로 사용됩니다.
  2. 레지스터 표현: 레지스터를 사용할 때는 퍼센트 기호(%)를 앞에 붙여 표현합니다. 예를 들어, %rax, %r13 등과 같이 나타낼 수 있습니다. 특별한 경우로, %rsp 레지스터는 스택 포인터로 사용되며, 스택의 관리에 중요한 역할을 합니다.
  3. 메모리 접근: 메모리 주소에 접근하거나, 메모리 주소에 값을 저장할 때는 괄호를 사용합니다. 메모리 접근은 주로 레지스터에 저장된 주소를 통해 이루어집니다. 예를 들어, %rax 레지스터에 특정 메모리 주소가 저장되어 있다면, (%rax)를 사용하여 그 주소가 가리키는 메모리 공간의 값을 읽거나 쓸 수 있습니다.

image-20240414140936414

이러한 방식을 통해 movq 명령어는 다양한 형태의 데이터 이동을 가능하게 합니다. 예를 들어, movq $0x400, %rax$0x400이라는 상수 값을 %rax 레지스터로 이동시키는 명령이며, movq %rax, (%rbx)%rax 레지스터에 있는 값을 %rbx가 가리키는 메모리 주소로 복사하는 명령입니다.

image-20240414142421771

어셈블리 언어에서 메모리 주소를 지정하는 방법은 다양한 형태로 표현될 수 있으며, 이를 통해 프로그래머는 데이터에 보다 유연하게 접근할 수 있습니다. 이러한 방식은 크게 단순 메모리 주소 지정 모드(Simple Memory Addressing Modes)와 완전 메모리 주소 지정 모드(Complete Memory Addressing Modes)로 분류할 수 있습니다.

  1. 단순 메모리 주소 지정 모드(Simple Memory Addressing Modes): 이 모드는 D(R) 형태로 표현되며, 여기서 D는 상수(displacement), R은 레지스터를 의미합니다. 이 표현은 Mem[Reg[R] + D]에 해당하는 메모리 주소를 나타냅니다. 즉, R 레지스터에 저장된 주소에 D 값을 더한 위치의 메모리를 가리키는 것입니다.

  2. 완전 메모리 주소 지정 모드(Complete Memory Addressing Modes): 좀 더 복잡한 형태로, 여러 구성 요소를 포함할 수 있습니다.

    • (Rb, Ri) 형식은 Mem[Reg[Rb] + Reg[Ri]]를 의미합니다. 이는 RbRi 레지스터에 저장된 값을 더한 결과로 계산된 메모리 주소를 가리킵니다.

    • D(Rb, Ri) 형식은 Mem[Reg[Rb] + Reg[Ri] + D]를 나타냅니다. 여기서 D는 상수(displacement)로, 기준 주소(RbRi 레지스터 값의 합)에 추가로 더해집니다.

    • (Rb, Ri, S)D(Rb, Ri, S) 형식은 Mem[Reg[Rb] + S*Reg[Ri]]Mem[Reg[Rb] + S*Reg[Ri] + D]를 각각 나타냅니다. 여기서 S는 스케일(scale)로, Ri 레지스터 값에 곱해지는 값입니다.

이러한 다양한 메모리 주소 지정 모드를 통해 프로그래머는 메모리 내의 특정 위치를 편하게 지정하고, 데이터 구조체에 보다 효율적으로 접근할 수 있습니다.

예시 03(swap)

image-20240414151504493

image-20240414151520815

  1. %rdi 레지스터에 저장된 주소 (*xp가 가리키는 주소)로부터 값을 읽어와 %rax 레지스터에 저장합니다.
  2. %rsi 레지스터에 저장된 주소 (*yp가 가리키는 주소)로부터 값을 읽어와 %rdx 레지스터에 저장합니다.
  3. %rdx 레지스터에 저장된 값을 %rdi 레지스터에 저장된 주소에 쓰기를 통해 *xp가 가리키는 메모리 위치에 저장된 값과 *yp가 가리키는 메모리 위치에 저장된 값을 스왑합니다.
  4. %rax 레지스터에 저장된 값을 %rsi 레지스터에 저장된 주소에 쓰기를 통해 *yp가 가리키는 메모리 위치에 저장합니다.

이 과정을 통해 *xp*yp가 가리키는 메모리 위치에 저장된 값들이 스왑됩니다.

산술, 논리 연산

다양한 어셈블리 연산 코드들을 살펴보겠습니다.

Two Operand Instructions

image-20240414152201284

salqshlq 명령어는 어셈블리 언어에서 동일한 연산을 수행하는데, 이는 두 명령어 모두 왼쪽으로 비트를 시프트(Shift)하는 연산을 수행하기 때문입니다. 왼쪽 비트 시프트 연산에서는 산술적인 개념이 적용되지 않습니다.

왼쪽 비트 시프트 연산은 주어진 수의 이진 표현을 왼쪽으로 지정된 수만큼 이동시킵니다. 이 과정에서 왼쪽 끝에서 넘치는 비트는 버려지고, 오른쪽 끝은 0으로 채워집니다. 이 연산의 결과는 원래 수에 2의 지정된 지수를 곱한 것과 동일합니다.

salq 명령어는 “Shift Arithmetic Left Quadword”의 약자로, 64비트 레지스터의 값을 왼쪽으로 시프트하는 산술 시프트 연산을 수행합니다. 반면, shlq 명령어는 논리적 왼쪽 시프트 연산을 수행합니다. 그러나 왼쪽 시프트의 경우 산술 시프트와 논리 시프트의 결과가 동일하기 때문에, 이 두 명령어는 실제로 동일한 연산을 수행합니다.

One Operand Instructions

image-20240414152736909

위 명령어들은 한 개의 피연산자에 대한 산술 연산을 수행하는 어셈블리 코드입니다.

예시 04(산술 표현)

image-20240414152958576

arith 함수는 여러 어셈블리 명령어를 사용하여 복잡한 산술 연산을 수행합니다. 각 명령어의 작동 원리는 다음과 같습니다:

  1. leaq (%rdi,%rsi), %rax: 이 명령어는 %rdi%rsi 레지스터의 값을 더하여 %rax 레지스터에 저장합니다. 여기서 %rdi%rsi는 함수의 첫 번째와 두 번째 인자를 나타냅니다.

  2. addq %rdx, %rax: 이 명령어는 %rdx 레지스터의 값을 %rax에 더합니다. %rdx는 함수의 세 번째 인자입니다. 이 시점에서 %rax는 첫 번째, 두 번째, 그리고 세 번째 인자의 합을 갖게 됩니다.

  3. leaq (%rsi,%rsi,2), %rdx: %rsi 값을 2배 하고, 그 결과에 다시 %rsi를 더하여 총 3배를 한 다음, 그 결과를 %rdx에 저장합니다. 즉, %rdx는 이제 %rsi * 3의 값을 갖습니다.

  4. salq $4, %rdx: %rdx에 저장된 값을 왼쪽으로 4비트 시프트합니다. 왼쪽으로 1비트 시프트는 값에 2를 곱하는 것과 동일하므로, 이 연산은 %rdx의 값을 16배 증가시킵니다. 결과적으로, %rdx는 이제 %rsi * 3 * 16의 값을 갖게 됩니다.

  5. leaq 4(%rdi,%rdx), %rcx: %rdi%rdx를 더한 결과에 4를 추가하여 그 결과를 %rcx에 저장합니다. 이는 %rdi + (%rsi * 3 * 16) + 4의 계산 결과입니다.

  6. imulq %rcx, %rax: %rcx에 저장된 값을 %rax에 저장된 값과 곱합니다. 이 시점에서 %rax는 첫 번째, 두 번째, 그리고 세 번째 인자의 합을 갖고 있으며, %rcx%rdi, %rsi를 사용한 복잡한 계산의 결과를 갖습니다. 따라서 최종적으로 %rax는 이 두 값의 곱을 갖게 됩니다.

  7. ret: 함수의 실행을 마치고, 최종 계산 결과가 저장된 %rax를 반환합니다.

요약하면, 이 함수는 주어진 세 인자를 사용하여 (a + b + c) * (a + (b * 3 * 16) + 4)의 결과를 계산하고, 그 결과를 반환합니다. (여기서 a, b, c는 각각 첫 번째, 두 번째, 세 번째 인자를 나타냅니다.)

댓글남기기