Skip to content

Latest commit

 

History

History
699 lines (476 loc) · 35.2 KB

실제 어셈블리어 흝어보기.md

File metadata and controls

699 lines (476 loc) · 35.2 KB

배경

목표

nand2tetris에서는 강의용으로 만들어진 어셈블리어를 사용했다.

강의 용으로 만든 OS, CPU, Jack 언어가 실제와 차이가 있는 것처럼, 실제 세계에서 제공하는 어셈블리어도 차이가 있다.

실제 어셈블리어는 어떤 기능을 제공할까?

추가 설명

사실 어셈블리어 설명이라고는 하지만, 하드웨어나 OS 부분도 함께 다룰 수밖에 없다.

그래서 어셈블리어보다는 그냥 nand2tetris 하면서 궁금했던 것들 정리하기? 시간이 된 것 같긴 하다.

또 아래 내용 중 상당수가 GPT를 기준으로 조사하고 자료를 찾은 경우이다.

이런 기초적인 부분은 GPT가 실수하지 않을 꺼라고 생각하는데, 너무 맹신하지는 말고 그냥 대충 그렇구나 정도로 이해하고 있자.

결론

어쩌다보니 코드가 많이 없고, 설명만 많이 있는 것 같은데... 그냥 어셈블리어 코드는 GPT한테 물어보거나 NASM Tutorial 같은 인터넷 자료를 찾으면 쉽게 볼 수 있다.

nand2tetris와의 실제 어셈블리어의 차이는 인터럽트와 관련된 부분과 그 외 매크로, section 구분 등 차이가 있었다.

nand2tetris의 CPU, 컴퓨터가 단순해서 그런거였음

nand2tetris에서 만들었던 CPU가 인터럽트 기능을 제공하지 않다 보니 어셈블리어에도 당연히 그런 기능은 없던 것 같다. (I/O 처리를 메모리 매핑을 통해서 구현하기 때문)

section도 CPU나 컴퓨터 아키텍처를 생각하면 굳이 없어도 된다.
애초에 bss 없고, data도 없어서 프로세스 메모리 자체가 text, stack, heap 뿐이다.
stack은 Stack Pointer 사용하고, heap은 OS 메서드에서 그냥 메모리로 직접 접근하니까 시스템 콜(인터럽트) 도 필요 없고 그냥 어셈블리어(기계어)로 구현 가능하다.

매크로나 함수 같은 고급 기능은 어셈블리어라기 보다는 어셈블러의 기능이라고 보는게 맞으니까 없는게 맞고.

컴퓨터 구조

어셈블리어 관련한 내용 이해에 도움을 주는 내용을 정리하였음.

주로 "혼자 공부하는 컴퓨터 구조 + 운영체제"를 참고하였음. 아니면 어셈블리어 유튜브 강의의 컴퓨터 구조 부분이나.

아래 내용으로도 잘 이해가 안가면 "혼자 공부하는 컴퓨터 구조 + 운영체제의 4장 CPU의 작동 원리"를 읽어보자.

CPU 구조

ALU, 제어장치, 레지스터로 구성되어 있다.

  • ALU는 레지스터로부터 피연산자를 받아들이고, 제어장치로부터 제어 신호를 받아들입니다.
  • ALU는 연산 결과와 플래그를 내보냅니다.
  • 제어장치는 클럭, 현재 수행할 명령어, 플래그, 제어 신호를 받아들입니다.
  • 제어장치는 CPU 내부와 외부로 제어 신호를 내보냅니다.

다음은 ALU의 동작을 설명하는 이미지이다.

플래그는 연산 결과에 대한 추가적인 상태 정보를 나타낸다.

대부분 다음과 같은 플래그 값을 가지고 있다.

nand2tetris를 구현하면서 부호/제로 플래그는 분기 연산을 수행할 때 사용했었다.

인터럽트

  • 명령어 사이클은 하나의 명령어가 처리되는 주기로, 인출, 실행, 간접, 인터럽트 사이클로 구성되어 있습니다.
  • 인터럽트는 CPU의 정상적인 작업을 방해하는 신호입니다.
  • 인터럽트의 종류에는 예외와 하드웨어 인터럽트가 있습니다.
  • 인터럽트 서비스 루틴(ISR, Interrupt Service Routine)은 인터럽트를 처리하기 위한 동작들로 이루어진 프로그램입니다.

참고하기

버스

데이터들의 통로

컴퓨터의 각 요소들이 통신할 때, 공유 버스(시스템 버스)를 사용해서 데이터들을 주고 받는다.

아래 3가지를 포함해서 시스템 버스라고 부른다.

  • 주소 버스(Address Bus)
    • CPU나 여러 주변 장치들(DMA, GPU, NIC)이 메모리나 주변 장치로 주소를 전송하는 버스.
    • 메모리 주소나 주변 장치의 레지스터 주소를 지정할 때 사용된다.
    • ex: CPU가 쓰기 동작 시 해당 주소로 데이터를 전송하고, 읽기 동작 시 해당 주소에서 데이터를 가져온다.
  • 데이터 버스(Data Bus)
    • CPU, 메모리, 입출력 장치 간에 데이터를 주고받는 양방향 버스.
    • (헷갈렸던 부분 )CPU의 비트 수는 데이터 처리 능력인 데이터 버스 비트 폭에 따라 결정되며, 주소 버스 비트 수는 메모리 주소 지정 범위를 결정하는 별개의 개념이다.
      • 유튜브 강의에서는 메모리 버스가 8비트면 너무 공간이 적어서, 8비트 연산을 제공하는 컴퓨터여도 주소 버스는 16비트인 경우가 많다고 함 - 참고
  • 제어 버스(Control Bus)
    • CPU나 주변 장치에서 발생하는 제어 신호를 주고받는 양방향 버스.
    • 메모리 읽기/쓰기, 입출력 장치 제어 등에 사용되는 제어 신호가 전송된다.

추가 설명

nand2tetris 에서

nand2tetris에선 이런 식으로 설명하는데, 이런 데이터들이 지나다니는 통로 (in,out)가 버스라고 볼 수 있을 것 같다.

실제로는 더 다양하다.

위키피디아의 설명을 보면, 버스는 실제로는 더 세분화되어지고 있다고 한다.

실제 CPU

실제 CPU

아래는 Z80이라는 CPU 아키텍처이다. 실제로 1980~90년도의 컴퓨터에 많이 사용되었다고 한다.

Z80 CPU의 버스를 확인할 수 있다. 8비트의 데이터 버스를 가지므로 한 번에 8비트의 연산을 수행할 수 있으므로, 8비트 마이크로프로세서이다. (위에서 설명했지만, 주소 버스의 비트 수는 컴퓨터의 연산 비트와 별개다.)

(System Control은 역할을 잘 모르겠다.)

CPU Control의 INT가 인터럽트 레지스터의 값을 넣어주는 부분이다.

아래 이미지를 보면, Z80이 위에서 설명한 3가지의 버스를 가지고 있는 모습을 볼 수 있다.

해당 이미지 출처

(그리고 이런 예전 칩을 사용해서 게임을 만드는 사람들도 있나 봄. 이북이긴 한데 도 있다.)

어셈블리어란?

기계어와 더불어 유이한 저수준 언어. (명확한 기준이 있는지는 잘 모르겠는데, 하드웨어에 밀접한 언어를 저급언어. 사용자 위주의 언어를 고급언어라고 하는 것 같다.)

하드웨어와 밀접한(의존적인) 프로그래밍 언어이다.

주로 다음과 같은 용도로 사용된다.

  • 하드웨어와 관련된 저수준 프로그래밍
  • 운영체제 및 시스템 소프트웨어 개발
  • 퍼포먼스 최적화
    • 다만 요즘엔 컴파일러가 매우 잘 되고 하드웨어가 복잡해져서, 이런 경우는 정말 극 소수라고 한다.
    • 결국 컴파일러보다 최적화를 잘할 수 있어야 하니까...
  • 디버깅 및 리버스 엔지니어링

CISC vs RISC

정확하지 않을 수도 있긴 하지만, 나무위키를 참고하자. 대충 흟어보는 정도로는 충분한 것 같다. (CS 관련 책에서 나온 내용과 크게 다르지 않기도 하고)

어셈블리어와 어셈블러 - 용어 정리하기

한국어로 하면 한 글자 차이라 자주 헷갈렸는데, 서로 다른 개념이다.

어셈블리어 (Assembly Language)

  • CPU의 명령어 집합을 사용하여 프로그램을 작성하는 저수준 프로그래밍 언어
  • 사람 친화적 형태의 기계어
    • 기계어와 일대일 대응
  • 저수준 프로그래밍 언어
  • 특정 CPU 아키텍처에 종속적
  • 어셈블리어 명령어를 제공하는 주체는 CPU 제조사
    • Intel: x86, x86_64
    • AMD: x86_64
    • ARM Holdings: ARM 아키텍처 (ARMv7, ARMv8 등)
    • MIPS Technologies: MIPS 아키텍처

어셈블러 (Assembler)

  • 어셈블리어 코드를 기계어로 변환하는 소프트웨어 도구로, 매크로, 함수, 상수 및 변수 정의, 조건부 어셈블리 등 다양한 고급 기능을 제공
  • 각 아키텍처에 맞는 바이너리 코드를 생성
  • 다양한 어셈블러 도구가 존재 (예: NASM, MASM, GAS)
  • 어셈블러마다 약간의 문법의 차이가 존재할 수 있다. (주로 헷갈렸던 포인트)
  • 어셈블러(Assembler)를 제공하는 주체는 소프트웨어 개발 조직 혹은 커뮤니티
    • NASM (Netwide Assembler): 오픈 소스 프로젝트, x86 및 x86_64 아키텍처 지원
    • GAS (GNU Assembler): GNU 프로젝트의 일부, 여러 아키텍처 지원 (x86, ARM, MIPS 등)
    • MASM (Microsoft Macro Assembler): 마이크로소프트, x86 및 x86_64 아키텍처 지원
    • YASM (Yet Another Assembler): 오픈 소스 프로젝트, x86 및 x86_64 아키텍처 지원
    • Keil: ARM 프로세서를 위한 어셈블러 제공
    • ARM Development Studio (DS-5): ARM Holdings에서 제공하는 어셈블러

어셈블리어의 명령어는 CPU 아키텍처에 따라 다르다.

어셈블리어 문법(+명령어)은 특정 CPU 아키텍처의 명령어 집합(Instruction Set Architecture, ISA)에 따라 정의된다.

그래서 그러한 어셈블리어 문법(+명령어)은 CPU 제조사에서 제공한다.

추가적으로 영향을 받는 부분은 아래와 같다. 다만 명령어 셋(집합) 자체는 CPU 아키텍처에 의해서만 정해진다는 것을 기억하자.

  • 운영체제
    • 다만 운영체제에 따라 시스템 콜 값, 세그먼트 레지스터 사용 여부 등이 달라진다.
  • 문법 스타일(Intel, AT&T)
  • 어셈블러
    • 매크로나 함수 같은 고급 기능을 사용하는 방법에 약간의 차이가 있다.

어셈블리어 명령어의 주요 기능

어셈블리어 명령어는 다음과 같은 구조를 가진다.

  • opcode (연산 코드):
    • 명령어의 종류를 지정한다. 예를 들어, MOV, ADD, SUB, JMP 등이 있다.
    • CPU는 opcode를 읽고, 해당 명령어가 무엇을 해야 하는지 결정한다.
  • operand (피연산자):
    • 명령어가 동작할 데이터나 메모리 주소를 지정한다.
    • 피연산자는 레지스터, 메모리 주소, 즉시 값(상수) 등이 될 수 있다.
    • 일부 명령어는 피연산자가 필요하지 않으며(opcode만 있는 명령어), 다른 명령어는 하나 이상의 피연산자를 가질 수 있다.

어셈블리어 명령어는 다음과 같은 기능을 가진다.

  • 비교 및 조건 분기
  • 산술/논리 연산
  • 메모리/레지스터 읽기/쓰기
  • 레이블(label)
  • (그 외 - 추가적으로 GPT가 필수라고 한 거)
    • 스택 조작
    • 시스템 호출
    • 플래그 조작

비교 및 조건 분기

주로 비교 연산을 수행하고 결과(flag) 값에 따라서 조건 분기를 수행한다.

산술 논리 연산

ALU 자체에서 제공하는 연산들이다.

메모리/레지스터 읽기/쓰기

특정 주소의 메모리 혹은 특정 레지스터에 값을 읽거나 쓸 수 있다.

레이블

레이블은 프로그램 내 특정 위치나 데이터를 가리키는 이름으로 사용되며 분기 명령어, 함수 호출, 데이터 참조 등에 활용된다.
-> (변수, 주소, 함수 등을 나타내는 데 사용된다.)

주로 다음과 같은 이유로 필요하다.

  • 가독성
    • 숫자나 주소 대신 의미 있는 이름을 사용하여 코드의 의도를 더 쉽게 이해할 수 있게 한다.
    • 코드의 특정 위치를 명확하게 표시할 수 있다. 이는 분기와 점프 명령어를 사용하는 경우에 특히 유용하다.
  • 모듈성 혹은 유지보수성
    • 레이블을 사용해 하드웨어에 너무 의존적이지 않고, 분리 가능한 코드를 작성할 수 있다.
      • (실제 메모리 주소로 변환하는건 어셈블러/링커가 수행한다.)

인터럽트

어셈블리어(기계어)는 특정한 명령을 통해 CPU에게 인터럽트를 발생시킬 수 있다.

OS는 인터럽트를 사용해 시스템 콜 기능을 구현한다.

각 OS는 시스템 콜 인터페이스를 구현하는 방법이 다르다.

  • 일반적으로 특정 레지스터에 시스템 콜 번호와 인수를 설정하고, 소프트웨어 인터럽트를 발생시켜 시스템 콜을 처리한다.
  • 시스템 콜 인터페이스의 차이는 주로 다음과 같은 부분에서 발생한다.
    • 시스템 콜 번호를 지정하는 방법
    • 시스템 콜 인수를 전달하는 레지스터
    • 인터럽트를 발생시키는 명령어

GPT의 설명

CPU와 인터럽트 명령어

CPU는 인터럽트를 처리하는 기능을 가지고 있습니다. 이는 CPU의 명령어 집합(ISA) 일부입니다. CPU는 특정 조건이 발생했을 때, 예를 들어 하드웨어 인터럽트나 소프트웨어 인터럽트가 발생했을 때, 현재 실행 중인 작업을 중단하고, 인터럽트 핸들러(interrupt handler)라고 불리는 특별한 코드를 실행할 수 있습니다.

인터럽트와 운영 체제

int 명령어는 CPU에게 인터럽트를 발생시키도록 지시합니다. 예를 들어, int 0x80 명령어는 인터럽트 벡터 0x80에 해당하는 인터럽트 핸들러를 호출합니다. 여기서 중요한 점은, 인터럽트 벡터 0x80에 어떤 코드가 위치해 있는가는 CPU가 아니라 운영 체제가 결정한다는 것입니다.

운영 체제와 시스템 호출

운영 체제는 int 0x80과 같은 특정 인터럽트를 시스템 호출 인터페이스로 사용하도록 정의할 수 있습니다. 예를 들어, 리눅스 운영 체제는 int 0x80을 시스템 호출 인터페이스로 사용하도록 정의합니다. 이는 CPU 자체가 제공하는 기능이 아니라, 리눅스 운영 체제가 int 0x80을 시스템 호출에 사용하기로 결정한 것입니다.

따라서, 다음과 같은 과정이 이루어집니다:

  1. 프로그래머는 어셈블리어 코드를 작성할 때, 특정 시스템 호출을 수행하기 위해 int 0x80 명령어를 사용합니다.
  2. CPUint 0x80 명령어를 실행하면 인터럽트 벡터 0x80에 해당하는 인터럽트 핸들러를 호출합니다.
  3. 운영 체제는 인터럽트 벡터 0x80에 시스템 호출 처리 코드를 위치시킵니다. 이 코드는 시스템 호출 번호와 인수를 확인하고, 해당 시스템 호출을 처리합니다.
요약
  • CPU는 인터럽트를 처리하는 기능을 제공하며, int 명령어는 이를 트리거하는 명령어입니다.
  • 운영 체제는 특정 인터럽트를 시스템 호출 인터페이스로 사용할 수 있도록 정의합니다. 리눅스에서는 int 0x80이 시스템 호출 인터페이스로 사용됩니다.
  • 따라서, int 0x80 명령어 자체는 CPU가 제공하지만, 이를 시스템 호출로 사용하는 것은 운영 체제가 정의한 인터페이스입니다.

인터럽트와 핸들러

어셈블리어를 사용하여 인터럽트 핸들러를 구현할 수도 있다.

인터럽트 핸들러 구현 내용이 나오는 블로그 (RISC-V 아키텍처를 기반으로 설명한다.)

GPT의 설명

네, 어셈블리어에서는 인터럽트 핸들러를 구현할 수 있습니다. 인터럽트 핸들러는 인터럽트가 발생했을 때 CPU가 실행하는 코드를 말하며, 이를 어셈블리어로 작성하여 직접 제어할 수 있습니다. 다음은 인터럽트 핸들러를 구현하는 방법에 대한 자세한 설명과 예제입니다.

인터럽트 핸들러 구현 개요

  1. 인터럽트 벡터 테이블 설정:

    • 각 인터럽트는 인터럽트 벡터 테이블에 정의된 주소로 연결됩니다.
    • CPU는 특정 인터럽트가 발생하면 이 테이블을 참조하여 적절한 핸들러를 호출합니다.
  2. 인터럽트 핸들러 작성:

    • 인터럽트 발생 시 실행될 코드를 작성합니다.
    • 핸들러는 일반적으로 레지스터를 저장하고 복구하며, 인터럽트가 발생한 원인을 처리합니다.
  3. 인터럽트 플래그 설정:

    • CPU가 인터럽트를 받아들일 수 있도록 인터럽트 플래그를 설정합니다.

x86 어셈블리어에서의 인터럽트 핸들러 구현

예제: 키보드 인터럽트 핸들러

다음은 x86 어셈블리어에서 키보드 인터럽트를 처리하는 간단한 예제입니다. 이 예제는 NASM 어셈블러를 사용하여 작성되었습니다.

  1. 인터럽트 벡터 테이블 설정:
section .data
    idt_pointer dw idt_end - idt - 1
                 dd idt

section .bss
    idt resb 256 * 8

section .text
    extern keyboard_handler

start:
    cli                    ; 인터럽트 비활성화
    lidt [idt_pointer]     ; IDT 설정
    sti                    ; 인터럽트 활성화
    hlt                    ; CPU를 대기 상태로 전환

idt_setup:
    ; IDT 엔트리 설정 (키보드 인터럽트는 33번)
    mov dword [idt + 33*8], keyboard_handler
    ; 엔트리의 나머지 부분을 설정
    ; ...

    ret

section .text
keyboard_handler:
    pusha                  ; 모든 레지스터를 스택에 저장
    ; 키보드 입력 처리 코드
    ; ...
    popa                   ; 모든 레지스터를 복구
    iret                   ; 인터럽트 리턴

ARM 어셈블리어에서의 인터럽트 핸들러 구현

예제: 타이머 인터럽트 핸들러

다음은 ARM 어셈블리어에서 타이머 인터럽트를 처리하는 간단한 예제입니다.

  1. 인터럽트 벡터 테이블 설정:
.section .text
.global _start

_start:
    ldr sp, =stack_top    ; 스택 포인터 초기화
    ldr pc, =reset_handler

reset_handler:
    ; 타이머 설정 코드
    ; ...

    ; 인터럽트 활성화
    cpsie i

    ; 메인 루프
main_loop:
    wfi                   ; 인터럽트 대기
    b main_loop

.section .vector_table
.word reset_handler       ; 리셋 벡터
.word undefined_handler   ; 정의되지 않은 명령어 벡터
.word svc_handler         ; SVC 벡터
.word prefetch_abort_handler ; Prefetch abort 벡터
.word data_abort_handler  ; Data abort 벡터
.word unused_handler      ; 사용되지 않음
.word irq_handler         ; IRQ 벡터
.word fiq_handler         ; FIQ 벡터

irq_handler:
    sub lr, lr, #4        ; lr을 현재 명령어 주소로 조정
    stmfd sp!, {r0-r12, lr} ; 레지스터 저장

    ; 타이머 인터럽트 처리 코드
    ; ...

    ldmfd sp!, {r0-r12, pc}^ ; 레지스터 복구 및 인터럽트 리턴

설명

  1. 인터럽트 벡터 테이블 설정:

    • x86의 경우, lidt 명령어를 사용하여 IDT(Interrupt Descriptor Table)를 설정합니다.
    • ARM의 경우, 인터럽트 벡터 테이블에 각 인터럽트 벡터를 설정합니다.
  2. 인터럽트 핸들러 작성:

    • x86의 경우, keyboard_handler 함수가 키보드 인터럽트를 처리합니다.
    • ARM의 경우, irq_handler 함수가 IRQ 인터럽트를 처리합니다.
  3. 인터럽트 플래그 설정:

    • x86의 경우, sti 명령어를 사용하여 인터럽트를 활성화합니다.
    • ARM의 경우, cpsie i 명령어를 사용하여 인터럽트를 활성화합니다.

요약

어셈블리어에서는 인터럽트 핸들러를 구현할 수 있으며, 이는 인터럽트 벡터 테이블 설정, 인터럽트 핸들러 작성, 인터럽트 플래그 설정의 세 단계로 이루어집니다. 각 아키텍처에 따라 인터럽트를 처리하는 방식이 다르지만, 기본적인 개념은 동일합니다. 인터럽트가 발생하면 CPU는 인터럽트 벡터 테이블을 참조하여 적절한 핸들러를 호출하고, 핸들러는 인터럽트를 처리한 후 원래의 작업으로 복귀합니다.

어셈블리어의 섹션과 프로세스 메모리 구조

어셈블리어에서 다루는 섹션(section)은 실제 프로세스 메모리 구조와 일치한다. (GPT 피셜임, 아마 어셈블리어 문법에 따라 다를 수도 있을거 같긴 함)

프로세스 메모리 레이아웃

프로세스 메모리 레이아웃은 일반적으로 다음과 같이 구성됩니다:

+-----------------------+ 높은 주소
|       스택 (Stack)    |
|       ...             |
|   동적 할당 (Heap)    |
|       ...             |
| 초기화되지 않은 데이터 (.bss) |
| 초기화된 데이터 (.data) |
|    코드/텍스트 (.text) |
+-----------------------+ 낮은 주소
  1. 텍스트 섹션 (.text section):
    • 프로그램의 실행 가능한 코드가 저장되는 영역입니다.
    • 일반적으로 읽기 전용으로 설정됩니다.
  2. 데이터 섹션 (.data section):
    • 초기화된 전역 변수와 정적 변수가 저장되는 영역입니다.
    • 읽기/쓰기가 가능합니다.
  3. BSS 섹션 (.bss section):
    • 초기화되지 않은 전역 변수와 정적 변수가 저장되는 영역입니다.
    • 프로그램 시작 시 0으로 초기화됩니다.
  4. 스택 섹션 (Stack section):
    • 함수 호출과 로컬 변수 저장을 위한 영역입니다.
    • 스택은 일반적으로 높은 메모리 주소에서 낮은 메모리 주소로 확장됩니다.
  5. 힙 섹션 (Heap section):
    • 동적 메모리 할당을 위한 영역입니다.
    • 힙은 일반적으로 낮은 메모리 주소에서 높은 메모리 주소로 확장됩니다.
    • 힙 영역은 프로그램 실행 중에 malloc, free 등의 라이브러리 함수 또는 시스템 콜을 통해 동적으로 관리됩니다.

어셈블리어의 섹션 관리

어셈블리어는 주로 .text, .data, .bss 섹션을 직접 정의하여 사용한다.

또한 stack의 경우 Stack Pointer를 사용하여 다룰 수 있다.

단, heap 영역의 경우 직접 어셈블리어가 관리를 하지는 않는데, 힙 영역은 라이브러리 함수 또는 시스템 콜을 통해 런타임 시점에 동적으로 할당되며, 어셈블리어 자체에서 직접 제어할 수 없기 때문이다.

section .data
    msg db 'Hello, World!', 0x0A

section .bss
    buffer resb 64

extern malloc  ; 외부 함수 (malloc) 선언
extern free    ; 외부 함수 (free) 선언

section .text
    global _start

_start:
    ; malloc 호출 (64바이트 할당)
    push 64              ; malloc의 인수 (64바이트)
    call malloc          ; malloc 호출
    add esp, 4           ; 스택 정리

    ; 할당된 메모리 주소가 eax에 반환됨
    mov ebx, eax         ; ebx에 할당된 메모리 주소 저장

    ; free 호출
    push ebx             ; free의 인수 (할당된 메모리 주소)
    call free            ; free 호출
    add esp, 4           ; 스택 정리

    ; 프로그램 종료
    mov eax, 1           ; sys_exit 시스템 호출 번호
    xor ebx, ebx         ; 종료 상태 (0)
    int 0x80             ; 커널 호출
스택 섹션
스택은 자료구조가 아니다.

처음에 헷갈렸던 부분인데, 저레벨이나 프로세스 메모리에서 이야기하는 스택은 스택이 맞지만, 객체나 자료구조는 아니다.

일련의 메모리 공간과 레지스터(SP)를 사용해서, 스택이라는 ADT를 만족하도록 동작하는 방식이라고 이해하면 될 것 같다.

스택 오버플로우에도 비슷한 내용이 있다.

스택은 높은 위치에서 낮은 위치로 쌓인다.

실행파일의 메모리 구조에서 스택이 쌓일 때마다 메모리가 낮아진다.

반대로 힙은 낮은 곳에서 높아진다.

메모리 공간이 동적으로 늘어나는 힙과 스택이 떨어져 있는건 필수적이지만, 왜 스택이 낮아져야 할까?

이는 그냥 이전부터 내려오는 관습 같은 것으로, 꼭 지킬 필요는 없지만 일반적으로 낮아지도록 구현되어 있다고 한다.

콜스택

어셈블리어의 내용까지는 아닌거 같긴 한데, 그래도 다루면 좋을 것 같아서 적었다.

고수준 프로그래밍에서는 함수(서브루틴)라는 기능을 제공한다.

이 경우 지금 상태를 저장하고 함수가 저장된 곳으로 점프한 뒤 연산을 수행하고 결과를 반환해야 한다.

또한, 이런 함수 호출은 재귀적거나 여러 번 발생할 수도 있다.

이를 위해서 스택을 사용한다. 함수 호출 시 파라미터와 지역변수, 돌아가기 위한 Caller의 주소 등을 저장한 자료구조를 프레임(frame) 이라고 한다.

Callee의 연산이 끝나면 리턴 결과를 스택에 저장하고, SP를 반환 결과 직전까지 높여서 스택을 pop한 것과 같이 처리한다.

함수와 메서드의 차이

객체지향 기능을 제공하는 고수준 언어라면 클래스에 의존하는 (사실 상 전역 함수) 정적 함수 인스턴스에 의존하는 함수인 메서드를 제공할 것이다.

함수와 메서드는 사실 저수준 입장에서 동일한 함수로 처리된다.

하지만 메서드는 인스턴스의 상태를 처리해야 하는데 어떻게 이게 가능할까?

컴파일러는 메서드를 내부적으로 this(인스턴스)에 대한 주소를 첫 번째 인자로 받는 함수로 변경시킨다.

따라서 메서드 내부에서 인스턴스에 의존하는 어떤 값을 호출한다면, 이는 첫 번째 인자의 this를 통해서 가져올 수 있다.

분기와 반복문

이것도 어셈블리어라기 보다는 고수준 언어가 어떻게 처리되는가? 에 대한 이야기긴 한데, 있으면 좋을 것 같아서 적었다.

저수준 언어에서는 특정 조건을 만족했을 때, 특정 메모리 위치(혹은 Label)로 이동하는 연산만을 가지고 있다.

이 기능을 사용해서 분기와 반복문을 처리할 수 있다.

참고 자료

예시
class MyClass {
private:
    int secret;
public:
    void setSecret(int s) { secret = s; }
    int getSecret() { return secret; }
};
_MyClass::setSecret:
    push ebp
    mov ebp, esp
    mov eax, [ebp+8]     ; this 포인터
    mov edx, [ebp+12]    ; 인자 s
    mov [eax], edx       ; this->secret = s
    mov esp, ebp
    pop ebp
    ret

_MyClass::getSecret:
    push ebp
    mov ebp, esp
    mov eax, [ebp+8]     ; this 포인터
    mov eax, [eax]       ; eax = this->secret
    mov esp, ebp
    pop ebp
    ret

고수준 언어의 제약(접근 제어자, 함수, 클래스)는 저수준에선 존재하지 않는다

저수준에서는 함수, 클래스, 접근 제어자와 같은 기능이 존재하지 않는다.

이러한 고수준의 개념은 컴파일을 하는 시점에만 유효하다.

프로그래밍해서 작성한 코드는 컴파일 되어서 실행 파일이나 기계어로 변환된다.
실행 파일의 기계어 입장에서는 순차적으로 실행하는 긴 명령어의 집합일 뿐이다. 점프 연산을 수행하긴 하지만, 접근 제어나 함수, 클래스와 같은 기능은 존재하지 않는다.

링커의 역할

(이 부분은 C 컴파일이나 다른 프로그래밍 언어에도 적용 가능한 내용이라 나중에 따로 분리할 수도 있음)

링커는 여러 개의 개별적인 오브젝트 파일을 하나의 실행 가능한 파일로 결합하고, 이 과정에서 다양한 작업을 수행합니다.

주로 컴파일러나 어셈블러에 의해 대부분의 연산이 기계어가 된 후 마지막 단계에서 실행된다.

링커의 주요 역할은 다음과 같습니다:

  • 심볼 해결(Symbol Resolution):
    • 각 오브젝트 파일에는 정의된 심볼(변수, 함수 등)과 참조된 심볼이 있습니다. 링커는 이러한 심볼을 해결하여 올바른 메모리 주소를 할당합니다.
    • 예를 들어, 하나의 오브젝트 파일에서 정의된 변수를 다른 오브젝트 파일에서 사용할 수 있도록 주소를 연결합니다.
  • 주소 재배치(Relocation):
    • 오브젝트 파일에는 코드와 데이터가 상대적인 주소로 저장됩니다. 링커는 실행 파일을 만들 때 각 섹션을 적절한 절대 주소로 재배치합니다.
    • 이를 통해 프로그램이 실행될 때 올바른 메모리 주소를 사용하게 됩니다.
  • 섹션 결합(Section Merging):
    • 여러 오브젝트 파일의 동일한 타입의 섹션(예: .text, .data, .bss)을 결합합니다.
    • 각 오브젝트 파일의 코드 섹션(.text), 데이터 섹션(.data), 초기화되지 않은 데이터 섹션(.bss)을 하나의 실행 파일의 대응 섹션으로 결합합니다.
  • 라이브러리 연결(Library Linking):
    • 프로그램이 사용하는 라이브러리(예: 표준 라이브러리)를 실행 파일에 포함시킵니다.
    • 정적 라이브러리의 경우, 라이브러리의 코드와 데이터를 실행 파일에 포함시키고, 동적 라이브러리의 경우, 실행 시간에 라이브러리를 로드할 수 있도록 정보를 포함시킵니다.
  • 이진 파일 생성(Executable Generation):
    • 모든 작업이 완료되면, 링커는 실행 가능한 이진 파일을 생성합니다.
    • 이 파일은 운영 체제에서 로드되어 실행될 준비가 된 상태입니다.

즉, 기계어로 컴파일 된 여러 파일을 하나로 합치고, 하나의 파일로 만드는 과정에서 심볼을 메모리의 절대 주소로 변경하고 연결한다.

이는 C언어의 컴파일 과정을 보면 대충 알 수 있을 것 같은데, C언어가 컴파일까지 완료된 시점에는 기계어로 변환된 .o 확장자를 가지는 파일을 가지게 된다.

이 파일을 읽어보면 외부 함수 호출과 관련된 부분을 제외한 나머지 부분이 기계어로 컴파일되어 있다.

참고할만한 자료

GPT 설명

링크 과정(Linking)은 여러 개의 오브젝트 파일을 하나의 실행 파일로 결합하는 중요한 단계입니다. 이 단계에서 링커(Linker)는 프로그램이 실행될 수 있도록 필요한 모든 코드와 데이터를 연결하고 결합합니다. 링크 과정 이전과 이후의 파일 형태와 링크 과정의 주요 작업을 자세히 설명하겠습니다.

링크 과정 이전의 파일 형태

링크 과정 이전에는 컴파일러와 어셈블러가 생성한 오브젝트 파일(Object Files)이 존재합니다. 오브젝트 파일은 기계어 코드, 데이터, 심볼 테이블(Symbol Table), 재배치 정보(Relocation Information) 등을 포함하는 이진 파일입니다. 오브젝트 파일의 확장자는 보통 .o (Unix/Linux) 또는 .obj (Windows)입니다.

오브젝트 파일의 주요 구성 요소

  1. 기계어 코드 (Machine Code): CPU가 직접 실행할 수 있는 이진 코드.
  2. 데이터 섹션 (Data Section): 초기화된 전역 변수와 상수가 저장된 영역.
  3. BSS 섹션 (BSS Section): 초기화되지 않은 전역 변수와 정적 변수가 저장된 영역.
  4. 심볼 테이블 (Symbol Table): 함수, 변수 등 프로그램 내에서 정의되거나 참조된 심볼에 대한 정보.
  5. 재배치 정보 (Relocation Information): 코드와 데이터가 메모리 내의 정확한 주소에 배치될 수 있도록 돕는 정보.

링크 과정 이후의 파일 형태

링크 과정 이후에는 완전한 실행 파일(Executable File)이 생성됩니다. 이 파일은 운영 체제에 의해 직접 실행될 수 있는 포맷으로, 일반적으로 다음과 같은 요소를 포함합니다:

  1. 기계어 코드 (Machine Code): CPU가 실행할 준비가 된 이진 코드.
  2. 데이터 섹션 (Data Section): 초기화된 전역 변수와 상수가 저장된 영역.
  3. BSS 섹션 (BSS Section): 초기화되지 않은 전역 변수와 정적 변수가 저장된 영역.
  4. 심볼 테이블 (Symbol Table): 디버깅 및 다른 목적을 위한 심볼 정보.
  5. 재배치 정보 (Relocation Information): 실행 시간에 필요할 경우, 동적 라이브러리 연결을 위한 정보.
  6. 헤더 (Header): 파일의 시작 부분에 있는 메타데이터로, 운영 체제가 파일을 적절히 로드하고 실행할 수 있도록 도와줍니다.

예제: 링크 과정

파일 1: main.c

#include <stdio.h>

void print_message();

int main() {
    print_message();
    return 0;
}

파일 2: print.c

#include <stdio.h>

void print_message() {
    printf("Hello, World!\n");
}

컴파일 명령어

gcc -c main.c -o main.o
gcc -c print.c -o print.o

위 명령어는 main.oprint.o 오브젝트 파일을 생성합니다.

링크 명령어

gcc main.o print.o -o main

위 명령어는 main이라는 실행 파일을 생성합니다. 링크 과정에서 링커는 다음 작업을 수행합니다:

  1. 심볼 해결: main.o에서 print_message 함수를 참조하는 부분을 print.o에서 정의된 print_message 함수로 연결.
  2. 주소 재배치: 각 오브젝트 파일의 상대 주소를 실행 파일의 절대 주소로 변환.
  3. 섹션 결합: main.oprint.o의 .text, .data, .bss 섹션을 결합.
  4. 라이브러리 연결: 표준 라이브러리 printf 함수를 포함.
  5. 실행 파일 생성: 최종 실행 파일 main을 생성.