메모리에 데이터를 저장할 때, 바이트 저장 순서에 따라 리틀 엔디안, 빅 엔디안으로 나눌 수 있다.
리틀 엔디안 (Little Endian): 낮은 주소에 최하위 바이트(LSB, Least Significant Bit)부터 저장한다
- 사람이 읽고 이해하기엔 직관적이지 않을 수 있다.
- 0x1A2B3C4D라는 4바이트 정수를 저장한다고 하면 아래와 같다. (MSB = 0x1A, LSB = 0x4D)
0x103: 0x1A (MSB)
0x102: 0x2B
0x101: 0x3C
0x100: 0x4D (LSB)
- 무조건 낮은 메모리 주소에 최하위 바이트(LSB)가 위치하므로, 메모리 주소를 다루기가 쉽다. 낮은 주소(0x100)에서 시작해서 데이터를 읽으면, 그 값이 바로 LSB인 것을 알 수 있다.
빅 엔디안 (Big Endian): 낮은 주소에 데이터의 최상위 바이트(MSB, Most Significant Bit)부터 저장한다.
- 메모리에 저장된 순서 그대로 읽을 수 있으며, 이해하기가 쉽다.
- 0x1A2B3C4D라는 4바이트 정수를 저장한다고 하면 아래와 같다.
0x103: 0x4D (LSB)
0x102: 0x3C
0x101: 0x2B
0x100: 0x1A (MSB)
만약 위 예시에서 하위 2바이트('0x3C4D')만 필요하다면, 리틀 엔디안 시스템에서는 시작 주소(0x100)에서 2바이트만 읽으면 된다.
빅 엔디안 시스템에서는 0x102를 시작주소로 한 다음 2 바이트를 읽어야 한다.
사용처
참고로, 애플의 M1 칩은 리틀 엔디안 형식을 사용한다. 인텔 기반이었던 맥부터 유지되어왔고 애플 실리콘 전반이 동일하다고 한다.
빅 엔디안과 리틀 엔디안은 단지 저장해야 할 큰 데이터를 어떻게 나누어 저장하는가에 따른 차이일 뿐, 어느 방식이 더 우수하다고는 단정할 수 없습니다.
물리적으로 데이터를 조작하거나 산술 연산을 수행할 때에는 리틀 엔디안 방식이 더 효율적입니다.
하지만 데이터의 각 바이트를 배열처럼 취급할 때에는 빅 엔디안 방식이 더 적합합니다.
현재 여러분이 사용하는 대부분의 시스템은 인텔 기반의 윈도우이므로 리틀 엔디안 방식을 사용하고 있을 것입니다.
하지만 네트워크를 통해 데이터를 전송할 때에는 빅 엔디안 방식이 사용됩니다.
따라서 인텔 기반의 시스템에서 소켓 통신을 할 때는 바이트 순서에 신경을 써서 데이터를 전달해야 합니다
(출처: TCP School)
조건부 동작을 구현하는 전형적인 방법은 두 가지이다. 1) 조건부 분기(Conditional Branching), 2) 조건부 데이터 이동(Conditional Data Movement)
조건부 분기(Conditional Branching)는 특정 조건이 충족되는지 확인하고, 조건에 따라 프로그램의 실행 흐름을 변경하는 프로그래밍 방식. 이는 프로그램에 논리를 구현하고, 다양한 실행 경로 중 하나를 선택하기 위해 사용된다.
조건부 분기는 일반적으로 if, else, switch, case 등의 구문 또는 논리 연산자와 함께 사용되며, 조건이 참인지 거짓인지에 따라 실행할 코드 블록을 결정한다.
(C언어에서의 간단한 조건부 분기 예제)
int x, y;
if (a > b) {
x = a;
} else {
x = b;
}
이 코드에서 a가 b보다 크면 x에 a의 값을 할당하고, 그렇지 않으면 x에 b의 값을 할당한다.
x86 어셈블리 언어로 이를 구현하면 다음과 같다.
movl a, %eax ; a의 값을 %eax 레지스터로 이동
cmpl b, %eax ; %eax와 b를 비교
jg .Lgreater ; if a > b, '.Lgreater' 레이블로 분기
movl b, %eax ; a <= b인 경우, %eax에 b의 값을 이동
jmp .Ldone ; '.Ldone' 레이블로 분기하여 코드 실행을 계속
.Lgreater:
movl a, %eax ; a > b인 경우, %eax에 a의 값을 이동
.Ldone:
movl %eax, x ; 결과를 x에 저장
여기서 jg (jump if greater) 명령어는 cmp 명령어에 의해 설정된 플래그를 기반으로 a가 b보다 큰지 확인하고,
이 방식은 조건에 따라 프로그램의 실행 흐름을 제어하여 논리를 구현할 수 있다.
내 생각에는, 이 방식이 조금 더 우리가 짜는 코드와 직관적으로 유사한 것 같다.
그러나 분기 예측 오류로 인해 성능 저하가 발생할 수 있다는 단점이 있다.
조건부 데이터 이동 방식이 이러한 오버헤드 없이 조건부 논리를 구현할 수 있는 대안을 제공한다.
조건부 데이터 이동(Conditional Data Movement) 방식은 프로그램이 특정 조건에 따라 데이터를 한 위치에서 다른 위치로 이동시키는 것을 말한다.
이는 분기(조건부 점프 jmp와 레이블)를 사용하지 않고, 프로그램의 실행 흐름을 변경하지 않으면서 데이터를 조건적으로 처리할 수 있게 한다.
위에서와 같은 C 코드 예시.
int x, y;
if (a > b) {
x = a;
} else {
x = b;
}
조건부 데이터 이동 명령어인 cmov를 사용하여 이를 구현할 수 있다.
movl a, %eax ; a의 값을 %eax 레지스터로 이동
cmpl b, %eax ; %eax와 b를 비교
cmovgl b, %eax ; if a > b, %eax에 b의 값을 이동
movl %eax, x ; 결과를 x에 저장
여기서 cmovg (conditional move if greater) 명령어는 cmp 명령어와 함께 사용되는 경우가 많으며, cmp 명령어에 의해 설정된 플래그를 기반으로 조건을 검사한다.
(꼭 붙어있을 필요는 없지만 만약 cmp와 cmovg 사이에 플래그를 설정하는 다른 인스트럭션이 있으면 제대로 작동하지 않는다.)
cmovg 명령어는 cmp가 설정한 플래그를 보고, greater 조건(a>b)이 충족되었는지를 판단한다.
이렇게 x는 a와 b 중 더 큰 값으로 설정된다.
이 방식은 조건부 제어 이동(분기)을 사용하는 대신 조건에 따라 데이터를 레지스터 간에 직접 이동시킴으로써 성능을 향상시킬 수 있다.
[cmp 명령어와 플래그 설정 복습]
cmp (compare) 명령어는 두 값을 비교하기 위해 사용된다. 이 명령어는 두 피연산자를 뺀 결과를 계산하지만, 어딘가에 결과를 저장하지는 않는다. 대신, 계산된 결과에 따라 플래그를 설정한다. (플래그 비트에서 설정은 1, 해제는 0)
(cmp a, b) 명령어의 논리: 우선 a - b 수행 -> 결과를 저장하지는 않고, 결과에 따라 다음을 수행한다.
- a - b = 0 라면 Zero Flag (ZF)를 설정하고, 같지 않으면 해제한다.
- a - b > 0 (a > b)이라면 Carry Flag (CF)와 Sign Flag (SF)를 해제한다.
- a - b < 0 (a < b)라면 Carry Flag (CF)와 Sign Flag (SF)를 설정한다.
(CF는 부호 없는 연산, SF는 부호 있는 연산이라고 보면 됨)
- 오버플로우가 발생하면 Overflow Flag (OF)를 설정하고, 오버플로우가 발생하지 않으면 Overflow Flag를 해제한다.
이 플래그들은 이후에 실행되는 조건부 분기나 조건부 이동 명령어에 의해 해석되어 프로그램의 실행 흐름을 제어한다.
예를 들어, cmovg 명령어는 플래그를 확인하여 첫 번째 값(a)이 두 번째 값(b)보다 큰지 확인하고, 그렇다면 두 번째 값을 목적지로 이동시킨다.
OF는 주로 부호 있는 정수 비교와 연산에서 중요하다. OF가 설정되면 이는 오버플로우가 발생했으며, 연산 결과가 잘못될 수 있다는 것을 나타낸다. OF가 설정되어있는 경우, 필요하면 오류 처리 루틴으로 분기한다.
[cmovg 명령어는 플래그를 어떻게 참고할까?]
cmovg 명령어는 다음 두 가지를 확인한다.
1. Overflow Flag (OF)와 Sign Flag (SF)가 같다.
- 만약 둘 다 1이라면, SF에 의해 a - b < 0이지만 OF에 의해 그 계산 결과를 뒤집어야 함을 의미 -> 결국 a - b >0 을 의미한다.
2. Zero Flag (ZF)가 해제되어 있다 (즉, a != b이다).
이 조건들이 만족하면 a와 b가 부호 있는 정수일 때 a > b임을 확인할 수 있다.
위에서도 몇번 언급했지만, 요즘의 프로세서 구조에서는 조건부 데이터 이동이 더 효율적인 성능을 제공한다. 왤까??
조건부 제어 이동(Conditional Branching)과 조건부 데이터 이동(Conditional Data Movement) 간의 성능 차이는 주로 프로세서의 인스트럭션 파이프라이닝(pipelining)과 분기 예측(branch prediction) 기능과 관련이 있다.
조건부 제어 이동 코드는 분기 예측 실패 시 파이프라인을 무효화하고 다시 채워야 하는 비용이 발생하기 때문에 성능 저하의 가능성이 있다. jmp 예측 하나를 잘못하면 미래의 인스트럭션을 위해 이미 실행한 작업 결과를 상당 부분 버리고 다시 채운다. 이는 15~30 클럭 사이클의 손실을 발생시킨다.
조건 점프를 사용하는 x86-64 코드에서, 분기 패턴을 쉽게 예측할 수 있는 경우에는 함수가 호출당 약 8클럭을, 분기 패턴이 랜덤인 경우에는 약 17.5 클럭 사이클을 소모한다. 이것은 함수의 실행시간이 분기가 정확히 예측되었는지에 따라 8~27 사이클의 범위를 갖음을 의미한다.
반면, 조건부 데이터 이동 명령어는 프로그램의 실행 흐름을 변경하지 않고 조건에 따라 데이터를 이동만 한다. 예를 들어, cmov (conditional move) 인스트럭션은 조건이 참일 때만 데이터를 레지스터로 이동시키고, 거짓일 때는 아무런 작업도 수행하지 않는다.
이에 따라 조건부 이동명령을 사용해서 컴파일한 코드는 테스트하는 데이터와 상관없이 약 8클럭 사이클을 필요로 한다. 제어흐름은 데이터와 관계가 없어지고, 이것은 프로세서가 파이프라인을 꽉 찬 상태로 유지하는 것을 더욱 쉽게 해준다.
즉 파이프라인의 흐름을 중단시키지 않고, 분기 예측에 의존하지 않으므로 현대 CPU의 파이프라이닝과 분기 예측 기능이 부하를 받지 않는다는 장점이 있는 것이다.
조건부 데이터 이동 코드의 가장 큰 장점은 프로그램의 실행 흐름이 일정하게 유지되므로, 현대 CPU의 파이프라이닝과 분기 예측 기능이 부하를 받지 않는다는 것이다. 파이프라인의 흐름을 중단시키지 않고, 분기 예측에 의존하지 않으므로, 분기 예측 실패에 따른 오버헤드가 없어 프로그램 실행이 더 빨라질 수 있다.
이것이 Conditional Branching 보다 일반적으로 더 우수한 성능을 제공하는 이유이다. 조건부 데이터 이동은 프로그램의 실행 흐름을 유지하면서 조건적인 연산을 수행할 수 있기 때문에, 프로세서가 파이프라인을 효율적으로 사용하여 높은 처리량을 유지할 수 있다.
CPU의 클럭(clock)은 프로세서가 명령을 실행하는 속도를 결정하는 신호이다. CPU의 작업 수행을 위한 기본 시간 단위라고 보면 된다. 클럭 속도는 헤르츠(Hz) 단위로 측정되며, Hz는 1초에 클럭이 몇 사이클인지를 뜻한다. 클럭 주기가 0.1초라면, 1초에 10번 사이클이 돌테니 클럭 속도는 10Hz가 될 것이다.
클럭 속도는 매우 빠르기 때문에 일반적으로 기가 헤르츠(GHz)로 측정하는데, 1 GHz는 초당 10억 사이클의 신호를 CPU가 받는다는 걸 의미한다. 요즘 나오는 CPU는 어느정도일까?? 인텔의 13세대 i9-13900K 모델의 경우, P코어는 3.0GHz, E코어는 2.2GHz가 기본 클럭 속도로 설정되어 있는 것 같다. (P코어는 높은 성능이 필요한 경우에 주로 동원된다.)
1 사이클 당 수행할 수 있는 작업의 양은 CPU의 아키텍처에 따라 다르다. 예를 들어 현대의 x86 아키텍처의 경우, 슈퍼스칼라 아키텍처를 사용하여 1 클럭 사이클에 여러개의 명령어를 동시에 수행할 수 있다. 이론상으로 최신 프로세서는 사이클 당 수십 개의 명령어를 처리할 수 있다. 그러나 메모리 대기시간이나 기타 자원의 제한으로 실제 성능은 그보다 낮을 수 있다.
일반적으로 클럭 속도가 빠르다는 건 CPU가 더 많은 작업을 빠르게 수행할 수 있음을 의미한다. 그래서 컴퓨터 광중에 오버 클러킹을 하는 사람들도 많다. 오버 클럭은 제조사가 설정해놓은 기본 클럭 속도보다 높게 해당 CPU의 클럭 속도를 끌어올리는 것을 말한다. 발열이 심해지기 때문에 액화 수소??에 담구면서 오버클럭킹을 한다고 한다?
때문에 오버클럭킹을 극도로 하는 건 스포츠(?)의 영역인 것 같고, 현실적으로는 클럭 수를 높이는 것외에도 다양한 방법으로 프로세서 성능을 끌어올린다. 캐시 크기의 크기를 늘리거나, 코어 수를 늘리는 등 다양한 방법으로 CPU 전반적인 성능을 높일 수 있다.
Q. 클럭 사이클이 왜 필요함??
클럭 사이클은 CPU 내부의 수많은 동작들을 동기화하고 조정하는데 필수적이다. 특히 현대의 복잡한 CPU 구조는 클럭 사이클 없이는 그 동작이 엉망진창으로 꼬여버릴 것이다.
-> 작업 종과 함께 일꾼들이 일사분란하게 움직이는 느낌, 혹은 지휘자가 오케스트라를 조율하는 느낌으로 이해하면 된다.
클럭 사이클이 어떤 역할들을 하냐면:
클럭 사이클 기능 | 설명 |
동기화 | CPU 내의 모든 연산들이 순차적이고 조화롭게 수행되도록 한다. |
정확한 타이밍 제공 | 명령어 실행, 데이터 전송, 메모리 접근 등의 작업을 위한 정확한 타이밍을 제공한다. |
명령어 처리 순서 제어 | 클럭 사이클을 사용하여 명령어를 순서대로 또는 아웃 오브 오더로 처리한다. |
전력 소모 관리 | 필요한 경우 클럭 속도를 낮추면서 전력 소모를 조절한다. 성능과 전력 소모의 trade off |
분기 예측과 스펙큘러티브 실행 | 클럭 사이클은 CPU가 분기 예측과 스펙큘러티브 실행을 수행할 때 중요한 역할을 한다. |
멀티 코어 동기화 | 여러 코어가 동일한 클럭 속도로 동작하여 데이터 처리를 일관되게 유지한다. |
프로시저는 일련의 작업을 수행하는 코드 블록 또는 서브루틴으로, 특정 작업을 완료하기 위해 호출될 수 있는 명령어 집합이다.
다른 곳에서 쉽게 재사용될 수 있도록 만들어진 코드의 모듈식 부분으로, 코드의 중복을 줄이고 유지보수를 용이하게 한다. 프로시저는 입력으로 매개변수를 받아서 처리하고, 결과를 반환할 수도 있다.
뭔가 익숙하다고 ?! ! Yes. 우리가 일반적으로 접하는 자바, 파이썬 등의 프로그래밍 언어에서는 프로시저 개념을 '메소드', '함수'라는 이름으로 구체화해서 사용한다.
프로시저는 프로그램 내에서 호출된 특정 지점에서 실행을 시작하고, 종료 시 다시 그 지점으로 돌아와서 실행을 계속하도록 하는 제어 흐름 메커니즘을 제공한다. 이는 크고 복잡한 프로그램을 작성할 때 코드를 논리적으로 구성하는 데 있어서 도움이 된다.
(이름이 비슷한) 프로세스와 프로시저의 관계:
프로세스는 OS에서 실행 중인 프로그램의 인스턴스를 말하며, 일련의 바이트 명령어로 보조 메모리에 존재하던 프로그램 코드를 연산을 위해 메인 메모리에 적재한 상태이다. OS로부터 가상 메모리, 시스템 자원, 스레드 등을 할당받는다. 현대에는 프로그램을 짤 때, 프로시저를 포함해서 설계하고 코드를 짤 가능성이 높다. 이러한 경우에는 프로세스에 속한 작업을 수행하는 과정에서, 대개 수많은 프로시저들이 호출된다.
프로시저의 실행은 일반적으로 스택을 사용하여 관리된다. 스택은 프로시저가 호출될 때 매개변수, 지연변수, 반환 주소 등을 저장한다.
프로시저가 호출되면, 스택에는 호출된 프로시저의 정보가 push되고, 프로시저가 완료되면 해당 정보를 pop하여 이전 상태로 복귀한다. 이렇게 스택을 사용하면, 프로그램은 여러 프로시저를 중첩하여 호출하고 각각의 실행 컨텍스트를 효과적으로 관리할 수 있다.
x86 시스템에서, 스택은 작은 주소방향으로 성장하며 %rsp 는 스택의 top을 가리킨다.
만일 P 함수가 Q 함수를 호출하고, Q 실행이 끝나면 다시 P 위치로 리턴하는 상황이라면 스택은 위와 같이 구성된다.
그림을 보면 알겠지만 P가 먼저 들어가있고, Q가 가장 최근에 push 되어 이미지상 더 아래(스택의 top)에 위치한다.
그리고 P와 Q가 할당된 각각의 공간을 스택 프레임(Stack Frame) 이라고 한다.
대부분의 프로시저의 스택 프레임들은 프로시저가 시작될 때 할당되는 고정 크기를 갖는다. 그러나 일부 프로시저는 가변크기 프레임을 필요로 한다.
프로시저가 반드시 스택 프레임을 온전히 할당받아 사용하는 건 아니다.
x86-64 프로시저는 시간과 공간 효율성을 위해 요청받은 스택 프레임의 부분만을 할당한다.(굳이 메모리를 빌리지 않고 레지스터에 인자를 전달하는 경우. 최대 6개의 정수나 포인터 인자가 레지스터를 통해 전달될 수 있다.) 이러한 규약은 스택 프레임의 사용을 최소화하여 메모리 접근을 줄이고, 실행 속도를 빠르게 만든다.
-> 우리가 평소에 쓰는 함수들을 생각해보면, 매개변수로 6개 이상을 건네주는 경우가 흔치는 않다.. 그말인즉슨 대부분 레지스터로도 인자를 전달해주기에 충분하다는 뜻
심지어, 많은 함수들은 스택 프레임을 요청하지도 않는다. 이런 경우는 모든 지역변수를 레지스터에 보관할 수 있고, 이 함수가 다른 함수를 하나도 호출하지 않을 때 발생한다. 이를 Register Allocation이라고 한다.
프로시저 P의 어떤 부분에서 call Q 라는 인스트럭션이 나왔다고 하자.
call은 두 가지 역할을 한다.
그 다음에 Q 스택프레임이 설정되겠지? 작업이 진행되고 Q가 모두 실행되면, 마지막 줄에 ret 이라는 인스트럭션이 나올 것이다.
ret은 세 가지 역할을 한다.
이제 프로그램카운터는 P의 call Q 다음 인스트럭션을 가리키게 되고, 시스템은 해당 부분에서 다시 작업을 진행하게 된다.
프로시저가 호출되고 리턴될 때 인자와 데이터를 전달하게 된다. 어셈블리 코드를 살펴보다 보면 인자들이 %rdi, %rsi 등으로 복사되는 모습을 흔히 볼 수 있는데, 이러한 작업은 어떠한 기준에 의해서 수행될까?
x86-64에서는 최대 6개의 정수형(정수, 포인터) 인자가 레지스터로 전달될 수 있다. 이때, 전달되는 데이터 형의 길이, 해당 데이터의 전달 순서에 따라 다른 레지스터를 이용하게 된다. 그 기준은 다음과 같다.
관습적으로, 레지스터 %rbx, %rbp, %r12 ~ r15는 피호출자-저장 레지스터로 구분한다고 한다.
프로시저 P가 Q를 호출할 때, Q는 나중에 P로 돌아갔을 때 정상적으로 재가동될 수 있도록 기존 레지스터 값들을 보존해야 한다.
레지스터 값을 변경하지 않거나, 원래 값을 스택에 푸시해두고 이 값을 변경한 뒤, 나중에 리턴하기 전에 다시 팝해오는 방식이다.
그 외의 모든 레지스터(스택 포인터 %rsp 제외)는 호출자-저장 레지스터로 구분된다. 이는 이들이 함수에 의해 변경될 수 있음을 의미한다.
만약 함수가 6개 이상의 정수형 인자를 가질 때는, 7번째부터 n번째 인자까지는 스택으로 전달된다.
8개의 인자를 갖는 함수를 호출하는 예제 ->
long call_proc()
{
long x1=1; int x2=2;
short x3 = 3; char x4 = 4;
proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4);
return (x1+x2)*(x3-x4);
}
아래와 같이 메모리 공간을 할당받는다.
각 인자의 위치는 스택 포인터(%rsp의 값)를 기준으로 하여 오프셋으로 표현한다.
예를 들어, 7번째 인자(4)와 8번째 인자(&x4)는 %rsp 값 대비 offset 0과 8에 위치한다.
동적 메모리 할당 | Malloc Lab | (1) Malloc은 어떻게 구현되는가? (2) | 2023.11.10 |
---|---|
동적 메모리 할당 | 기본 개념, 메모리 누수, 단편화 (1) | 2023.11.04 |
CS:APP | Chapter 3 | (1) 프로그램의 기계수준 표현 (0) | 2023.10.31 |
CS:APP | Chapter 1 | 컴퓨터 시스템 overview (4) | 2023.10.26 |
[혼공컴운] Ch4. CPU의 작동 원리 (레지스터) (0) | 2023.10.04 |