본문 바로가기
CS/컴퓨터구조

[컴퓨터구조] 2 명령어: 컴퓨터 언어 2.11 ~

by dingwoon 2024. 10. 15.

※"컴퓨터 구조 및 설계 6판 MIPS EDITION" 책을 간단하게 정리한 내용의 글입니다.※

2.11 병렬성과 명령어: 동기화

태스크가 병렬로 동작할 때 주의할 점은 태스크들이 경쟁 상태에 놓일 수 있다는 점이다. 이를 위해 태스크 간에 동기화가 필요하다. 동기화란 공유 자원에 접근할 때 서로 간섭 없이 올바르게 작업이 이루어지도록 접근 순서를 조정하는 것이다.
이를 위해 동기화 연산인 lock과 unlock을 통해 단 하나의 프로세서만 작업할 수 있는 영역을 만들어서 상호 배제(mutual exclusion)을 구현할 예정이다.

동기화를 구현하기 위해서는 메모리 주소에서 데이터를 읽고 수정하는 작업을 원자적으로 처리할 수 있는 하드웨어 프리미티브가 필요하다. 중간에 인터럽트나 다른 스레드가 개입하지 않도록 보장되어야 하며, 이러한 기능을 하드웨어가 지원하지 않으면 동기화 프리미티브를 구현하는 데 매우 큰 비용이 발생할 수 있다.

동기화 연산을 위한 전형적인 하드웨어 프리미티브 중 하나는 원자적 교환(atomic exchange 또는 atomic swap)이다. 교환 프리미티브는 나뉘지 않는 연산이기 때문에 여러 스레드나 프로세스가 동시에 교환 작업을 시도하더라도, 하드웨어가 그 순서를 결정하고 오직 하나의 작업만 성공하게 된다. 이를 통해 효율적인 동기화를 구현할 수 있다.

그러나 이러한 방식은 하나의 명령어로 처리되어야 하므로 설계가 복잡할 수 있다. 이를 보완하기 위해 두 개의 명령어 쌍을 사용하는 방식이 있다. 이 경우, 두 명령어가 원자적으로 처리되도록 보장된다.

이 방식은 MIPS 아키텍처에서 LL(load linked)SC(store conditional) 명령어 쌍으로 구현된다. LL은 메모리를 읽고, SC는 그 값이 변하지 않았을 때만 메모리를 저장하는 방식으로 동기화를 구현한다.

2.12 프로그램 번역과 실행

< 컴파일러 >

컴파일러는 C 프로그램을 어셈블리 언어 프로그램으로 바꾼다. 과거 운영체제와 어셈블러는 어셈블리 언어(assembly language)로 작성했지만, 오늘날에는 DRAM의 용량이 훨씬 커졌고 컴파일러가 최적화 되면서 그런 경우가 거의 없다.

< 어셈블러 >

어셈블러는 어셈블리 프로그램을 기계어로 번역하는 역할을 수행하며, 이 번역 과정을 통해 어셈블리 언어 프로그램을 목적 파일(object file)로 바꾼다. 이때 어셈블러는 어셈블리 언어의 명령어를 이진수로 바꾸기 위해 레이블(label)에 해당하는 주소를 모두 알아야 한다. 이를 위해 어셈블러는 분기나 데이터 전송 명령에서 사용된 모든 레이블을 심벌 테이블(symbol table)에 저장한다.

또한 어셈블러는 의사명령어(pseudoinstruction)를 지원해서 하드웨어가 직접 지원하지 않는 명령도 처리할 수 있다. 예를 들어, blt(branch on less than) 명령어는 MIPS와 같은 아키텍처에서 지원하지 않지만, 어셈블러는 이를 slt(set less than)와 bne(branch not equal)같은 실제 명령어로 변환하여 처리한다.

< 링커 >

링커(Linker, 또는 링크 에디터)는 개별적으로 어셈블된 목적 파일들을 결합하고, 프로그램 내의 정의되지 않은 심벌(레이블)의 주소를 찾아내어 실행 파일(executable file)을 생성하는 시스템 프로그램이다. 실행 파일은 컴퓨터에서 바로 실행될 수 있으며, 미해결 참조가 없는 상태이다.

링커를 사용하면, 프로그램을 수정할 때 전체를 다시 컴파일하거나 어셈블할 필요 없이 수정된 부분만을 다시 컴파일 및 어셈블한 후 링킹할 수 있다.
링커의 주요 동작은 다음의 세 가지 단계로 이루어진다.

  1. 코드와 데이터 모듈을 메모리에 심벌 형태로 올려놓는다.
    링커는 개별 모듈(목적 파일)의 코드와 데이터를 심벌 테이블을 사용해 메모리에 로드한다.
  2. 데이터와 명령어 레이블의 주소를 결정한다.
    프로그램 내에서 사용되는 심벌(변수, 함수, 레이블)의 메모리 주소를 결정한다.
  3. 외부 및 내부 참조를 해결한다.
    다른 모듈 또는 외부 라이브러리에서 정의된 심벌에 대한 참조를 해결한다.

링커는 각 목적 모듈의 재배치 정보(relocation information)심벌 테이블(symbol table)을 사용하여 정의되지 않은 심벌의 주소를 결정하고, 외부 참조를 해결하며 각 모듈의 메모리 주소를 배정한다. 또한, 링코는 절대 참조(absolute reference)를 실제 메모리 주소로 재배치하여 올바르게 실행될 수 있도록 한다.

< 로더 >

UNIX 시스템의 로더(loader)는 실행 파일을 읽어서 메모리에 넣고 시작시킨다. 자세한 과정은 다음과 같다.

  1. 실행 파일 헤더를 읽어서 텍스트와 데이터 세그먼트의 크기를 알아낸다.
  2. 텍스트와 데이터가 들어갈 만한 주소 공간을 확보한다.
  3. 실행 파일의 명령어와 데이터를 메모리에 복사한다.
  4. 주 프로그램에 전달해야 할 인수가 있으면 이를 스택에 복사한다.
  5. 레지스터를 초기화하고 스택 포인터는 사용가능한 첫 주소를 가리키게 한다.
  6. 기동 루틴(start-up routine)으로 점프한다. 이 기동 루틴에서는 인수를 인수 레지스터에 넣고 프로그램의 주 루틴을 호출한다. 주 프로그램에서 기동 루틴으로 복귀하면 exit 시스템 호출을 사용하여 프로그램을 종료시킨다.

< 동적 링크 라이브러리 >

  • 기존 링크 방식의 문제
    • 프로그램 실행 전에 라이브러리를 링크한다.
      기존의 정적 링크(static linking) 방식에서는 프로그램이 실행되기 전에 라이브러리를 링크한다. 이 과정에서 라이브러리의 모든 루틴이 실행 파일에 포함된다.
    • 라이브러리 버전 문제
      정적으로 링크된 프로그램은 특정 버전의 라이브러리와 함께 빌드된다. 이후에 라이브러리의 새로운 버전이 나와도, 프로그램은 여전히 빌드 시 포함된 옛날 버전의 라이브러리를 사용하게 된다.
    • 불필요한 메모리 사용
      정적 링크 방식에서는 프로그램이 라이브러리의 루틴을 한 번이라도 호출하면, 해당 라이브러리의 모든 함수와 코드가 메모리에 적재된다. 하지만 실제로는 프로그램에서 그 중 일부 함수만 사용될 수 있기 때문에, 메모리 낭비가 발생한다.
  • 동적 링크 라이브러리(dynamically linked library, DLL)
    기존 링크 방식의 문제로 DLL이 나오게 되었다. 동적 링크는 기존의 정적 링크와 달리, 프로그램이 실행되는 시점에 라이브러리를 링크하고 적재하는 방식이다.
    • 프로그램 실행 시 동적 링크
      DLL은 프로그램이 실행 중일 때, 즉 실행 시간(runtime)에 필요에 따라 라이브러리를 적재하고 링크한다. 프로그램이 실제로 해당 라이브러리 함수를 호출할 때까지 라이브러리를 메모리에 로드하지 않는다.
    • 라이브러리 업데이트 유연성
      DLL 방식은 라이브러리가 프로그램에 포함되는 것이 아니라 라이브러리를 동적으로 로드하기 때문에 라이브러리의 새로운 버전이 나와도 새로 빌드하지 않아도 된다.
    • 지연(lazy) 프로시저 링키지
      DLL은 지연 프로시저 링킹(lazy procedure linking)이 가능해서 라이브러리 루틴이 실제로 호출될 때 해당 루틴을 적재하고 링크한다. 이 방식은 처음 호출 시 약간의 오버헤드가 발생할 수 있다. 이는 처음 호출 시에 라이브러리 루틴의 주소를 찾아서 메모리에 로드하고, 프로그램에 연결하는 과정이 필요하기 때문이다. 하지만 한 번 연결된 이후에는 간점 점프(indirect jump)만으로 루틴에 접근할 수 있다.

오늘날 Microsoft Windows는 DLL에 크게 의존하고 있고, UNIX 계역 시스템도 오늘날 DLL을 디폴트로 사용한다.

< Java 프로그램의 실행 >


Java의 목적은 실행시간은 느리더라도 어느 컴퓨터에서나 동일하게 실행되는 것이다.
이 목적에 맞게 Java는 목표 컴퓨터의 어셈블리 언어로 컴파일하는 것이 아니라 Java 바이트코드 명령어 집합으로 먼저 컴파일한다. 자바 프로그램은 이 바이트코드 형태의 이진수로 배포된다. 이 명령어 집합은 Java 언어와 비슷하게 설계되었기 때문에 컴파일 작업이 아주 단순하다.

이 바이트코드를 JVM(Java virtual machine)이라 불리는 소프트웨어 인터프리터에 의해 실행된다. Java는 번역이 너무 간단해서 주소를 컴파일러나 JVM이 채울 수 있어서 별도의 어셈블리 단계가 필요 없다.
Java 프로그램의 성능 개선을 위해 현재 JIT 컴파일러에서 실행 중인 프로그램의 특성을 파악해서 많이 사용되는 메소드를 찾아내서 기계어로 컴파일을 하고 저장해 놓는다.

2.16 실례: ARMv7(32비트) 명령어

ARM은 MIPS와 같은 해에 나온 ISA(명령어 집합 구조)이다. MIPS는 레지스터가 더 많고, ARM은 주소 지정 방식이 더 많다.(MIPS는 R타입, I타입, J타입으로 3개이지만, ARM는 9개이다.)
두 아키텍처는 레지스터 명렁어나 데이터 이동 명령어가 비슷하다.

ARM에는 MIPS의 addi $0을 대체하는 mov 연산처럼 $zero 레지스터가 별도로 없어서 필요한 명령어를 위한 op코드를 가지고 있다.

2.17 실례: ARMv8(64비트) 명령어

기존 ARMv7은 32비트 주소 체계를 사용하고 있었다. 그로 인한 메모리의 한계를 극복하기 위해 ARM 설계자들은 64비트 주소 체계를 사용하는 완전 새로운 버전의 ARMv8을 만들었다.

ARMv8은 MIPS에 대해 알고 있다면 매우 쉽게 이해할 수 있을 것이다.

2.18 실례: RISC-V 명령어

RISC-V은 MIPS, ARM, x86처럼 특정 회사의 소유물이 아니다. zzz

2.19 실례: x86 명령어

ARM과 MIPS는 작은 연구팀에서 만든 ISA로, 프로세서의 각 부분이 서로 잘 조화를 이루고 있고, 전체 구조도 아주 간결하다. 하지만 x86은 여러 독립적인 그룹이 40년 이상 꾸준히 발전시켜온 결과이다. 간결하고 제한된 명령어만 제공하는 ARM과 MIPS와 달리 x86은 다양하고 복잡한 명령어를 지원한다.
이러한 복잡한 명령어들은 강력한 연산을 제공해서 프로그램이 실행하는 명령어의 개수를 줄일 수 있다.
하지만 동시에 하나의 명령어에서 필요한 클럭 사이클 시간이 길어지고 클럭 사이클 개수가 더 많아지기 때문에 더 느려질 위험이 있다.

2.20 더 빠르게:C로 작성한 행렬 곱셈 프로그램

1장에서는 python으로 행렬 곱셈 프로그램을 작성했다. 이를 C언어로 작성한 경우 C의 타입 선언이 컴파일러로 하여금 훨씬 더 효과적인 코드를 생성할 수 있게 해 주어서 python보다 더 빠르다.

2.21 오류 및 함정

  • 오류1: 강력한 명령어를 사용하면 성능이 좋아진다.
  • 오류2: 최고 성능을 얻기 위해 어셈블리 언어로 프로그램 작성하기
    프로그래머가 컴파일러와 경쟁하려면 파이프라이닝이나 메모리 계층의 개념을 완전히 이해하고 있어야 한다.
    또한 프로그래머가 어셈블리 언어를 직접 사용해서 프로그램을 작성할 경우 이식성이 없고, 유지 보수가 매우 힘들다.
    상위 수준 언어를 사용하고, 그 이후는 컴파일러에게 맡기는 것이 좋아보인다.
  • 오류3: 상업용 프로그램의 이진 호환성이 중요하다는 것은 성공적인 명령어 집합은 변하지 않는다는 것을 의미한다.
    x86은 잦은 명령어 추가가 있었음에도 불구하고 가장 많이 쓰이는 PC 명령어 집합이 되었다.
  • 함정1: 바이트 주소를 사용하는 컴퓨터에서 인접 워드 간의 주소 차이가 1이 아니라는 사실을 잊는 것.
    주소를 하나 증가시킨다고 다음 워드가 아니다.
  • 함정2: 자동 변수가 정의된 프로시저 외부에서 자동 변수에 대한 포인터를 사용하는 것.
    프로시저 내에서 선언한 배열의 포인터를 프로시저의 결과로 전달하면 안된다. 앞서 배웠듯이 해당 프로시저가 끝나면 프로시저에서 선언한 변수에 대한 스택은 없어진다.

2.22 결론

데이터와 명령어를 모두 숫자를 통해 저장하고 사용하고, 이를 변경 가능한 메모리에 저장한다는 내장 프로그램 개념 덕분에 컴퓨터는 다양한 역할을 수행할 수 있게 되었다.

MIPS에서는 다음의 세 가지 설계 원칙을 지켜서 명령어 집합을 설계했다.

  1. 간단하게 하기 위해서는 규칙적인 것이 좋다.
  2. 작은 것이 더 빠르다.
  3. 좋은 설계에는 적당한 절충이 필요하다.