강의/system programming

[system programming] Program의 표현과 실행(Basic of Assembly Code) - 2

하기싫지만어떡해해야지 2025. 3. 22. 18:34

본 게시글은

서울대학교 데이터사이언스대학원 정형수 교수님의 

데이터사이언스 응용을 위한 시스템 프로그래밍 강의를

학습을 목적으로 재구성하였습니다


 

저번 시간에는 data movement의

assembly의 general form을 배웠었다

잠깐만 되짚어보자면 S는 스케일이라 주소값에 곱하는 값이고

D는 Displacement라 주소값에 더하는 값이다

 

 

지난시간에 배웠던 leaq 연산자

leaq는 주소값에 접근하는 것이 아닌

단순히 주소값끼리만 연산하는 연산자였다

그래서 더하기, 곱셈과도 같은 일반 연산에도

많이 사용된다

 

 

주소값 연산의 예시이다

위에서 봤던 leaq의 규칙과 동일하다

괄호 앞에 오는 값은 D라 더하기

괄호 안에 오는 값은 S라 %rcx에 곱하기를 해준다

 

 

이제 다른 Arithmetic Operation을 알아보자

컴퓨터 프로그래밍을 할 때 보통 가장 많이 말하는 중요한 3가지

1. sequencial (순차적 연산)

2. condition (조건문)

3. for loop (반복문)

이다

 

위는 sequencial의 format이다

 

 

increment와 decrement

negative와 부정 not도

다음과 같이 표현해준다고 한다

 

 

왼쪽의 c언어 코드를 컴파일한 assembly코드를 살펴보자

오른쪽이 assembly코드이다

여기서 salq는 shift 연산

imulq는 곱하기 연산이다

 

 

우선 한 가지 기억해야할 점은

위 arith은 long타입을 return으로 받는 함수이다

그렇다면 위 함수에는 x, y, z의 arguement들이 있는데

이 arguement들은 어디에 담겨오는걸까?

 

자세히는 다음 시간에 배운다고하지만

convention에 따르면

%rdi, %rsi, %rdx, %rax, %rcx .. 이런 순서로 담긴다고한다

또한, 현재 우리가 보통 사용하는

gcc compiler는 함수의 return값은

%rax에 담기도록 약속되어있다고한다

보통 register에 따라서 어떻게 할 것인지

다 약속이 되어있다고 한다

 

그럼 이제 본격적으로 코드를 살펴보자

함수 내부의 첫 번째 줄에서는

long t1 = x + y;인데 이는

arguement로 받은 x와 y를 더한 것이므로

%rdi와 %rsi에 있는 값을 더해준 것이다

따라서 assembly로는

leaq (%rdi, %rsi), %rax가 된다

두 개를 더한 값을 %rax에 저장한다

 

그런 다음 두 번째는 앞서 계산했던 t1 값에

z라는 세 번째 arguement를 더해준다

이를 assembly로 나타내면

addq %rdx, %rax가 된다

t2는 다시 %rax에 저장된다

 

그다음 leaq (%rsi, %rsi, 2), %rdx를 살펴보자

%rsi에는 y가 들어있다

그럼 %rsi + 2* %rsi는 3 * %rsi가 되고

이게 %rdx에 저장된다

그런데 밑에서 salq 연산을 이용해서

rdx를 4만큼 leftshift로 이동하는데

그럼 2의 4제곱만큼 곱해지는 거니깐 16이 곱해지게되고

기존 %rdx는 3 * %rsi의 값인 3y가 들어있으니까

16 * 3y가 되어서 t4의 값인 y * 48이 계산되게 된다

그러고 이 t4값은 %rdx에 저장된다

 

t5의 값은 4(%rdi, %rdx)인데 

이는 %rdi + %rdx + 4로 표현될 수 있고

%rdx에는 t4인 48y값이 들어있고

rdi에는 x값이 들어있으므로 거기에 4를 더해주면 t3값이 만들어져서

t5의 값인 t3 + t4가 되는 것이다

 

위에서 t3을 따로 assembly 코드로 계산해주지않았는데

해주지 않은 이유는 이렇게 만들 수 있기 때문이다

 

그런다음 마지막으로 rval의 값은

%rcx와 %rax의 곱으로 표현하고

이를 return값인 rax를 ret로 내뱉는다

 

 

오늘 볼 내용은

machine programming의 기초라고한다

(지금까지 한게 기초가 아니라고?)

 

그래서 C언어, assembly, machine code를

집중적으로 볼 것이다

 

assembly로 compile 할 때의 모습을 잠깐 살펴보자

왼쪽과 같은 c언어 코드가 있으면

오른쪽은 x86-64의 assembly 코드이다

 

지금 왼쪽 c언어 코드를 보면

위에 plus라는 function을 정의하고

밑의 sumstore라는 void 함수에서

plus 함수를 arguement와 함께 호출한다

 

그래서 오른쪽 assembly 코드를 보면

call plus와 같이 plus함수를 호출해주고

밑에 

movq %rax, (%rbx)가 있는데

%rbx에 담겨있는 주소값에 %rax 값을 옮기는 과정이고

이는 왼쪽에서

*dest = t; 부분이다

dest 주소값에 t를 저장해달라는 의미이다

 

그러고 이 assembly 코드에서는

%rax가 없는데 왜 없냐면

return값이 없기 때문에 %rax를 세팅하지 않는 것이다

 

 

 

사실은 위 c언어의 assembly 코드는

정확하게는 이렇게 생겼다고한다

우리가 위에서 본 것 이외에 .으로 시작하는

(이상한) 구문들이 더 있는 것을 확인할 수 있다

 

이는 directive라고 한다는데

(사실 제대로 못들음 ㅠ)

 

실행되는 코드가 아니라

코드의 구조를 정의하거나

디버깅을 도와주는 역할을 하는

즉, 메타데이터를 추가적으로 주는

그런 역할을 하는 코드라고 한다

 

빠르게 보고 넘어갔다..

 

 

 

c언어 코드인 *dest = t가

assembly로는 movq %rax, (%rbx)로 선언되고

그 assembly가 다시 기계어로 변환되는데

제일 밑에처럼 변환된다고 한다

 

이제부터 본격적으로

machine programming에서 control part에 대해서

살펴본다고 한다

 

control part 중에서도 정말 중요한

condition code

 

쉽게 생각하면 우리가 프로그래밍할 때

자주 쓰는 if문이다

 

본격적으로 배우기 전에 한 가지 짚고 가야할 부분은

condition code는 모든 instruction 수행 때마다

CPU가 알아서 체크하고 flag를 켜놓는다고 한다

예외도 있지만 특수한 경우를 제외하고는

every instruction마다 체크를 한다고 생각하면된다

condition code를 체크한다는 것은

현재보다 앞의 behavior가 어떤 코드인지를 반영한다는 의미이다

 

이걸 하는 방법이 3가지가 있다고 하는데

1. condition code를 저장하기

2. 저장된 special register들이 있는데

condition code를 copy해와서 사용하기

3. 그 condition에 기반해서 jump하기

이렇게 있다고한다

이제부터 찬찬히 살펴보자

 

 

 

저번시간에도 나왔던 중요한 그림이다

CPU에 Condition Code가 있는데

ZF -> zero인지 검사

SF -> signed인지 검사

등등

각종 flag들을 검사하는 코드이다

 

 

condition code는 CPU내부에 이렇게 담겨있고

GDB로 print해보면 eflags를

위 ppt처럼 확인해볼 수 있다고 한다

 

 

condition codes가 instruction을 어떻게 체크하는지 예시이다

위처럼 t = a + b인 addq Src, Dest의 assembly가 있다

 

t가 0인경우 ZF는 1

t가 0보다 큰 경우 SF는 1

t가 2의 보수인경우 OF는 1

t가 MSB인경우 CF는 1을 각각 뱉는다고한다

 

 

sub 연산으로 예시를 한 번 살펴보자

a가 %rsi에 있고 b가 %rax에 있다

여기서 b - a 연산을 해주는 것이다

 

 

여기서 b-a를 수행할 때

condition code로

b-a가 0인지 아닌지를 체크한다

 

그래서 b-a가 0이면 1을

그렇지않으면 0을 내뱉는 것이다

 

이렇게 instruction이 수행될 때마다

conditon code를 체크할 수 있도록

와이어링이 되도록 설계가 되어있다고한다

 

 

그렇다면 아까 위에서 예시로 든 연산자 sub와

cmp 연산자의 차이를 살펴보자

 

둘다 결국 내부에서 하는 일은 동일하다

그렇다면 두 연산자의 차이는 무엇일까?

cmp는 compare 기능을 하기 때문에

똑같이 b-a를 하더라도

cmp는 연산을 한 뒤 상태만 확인하지

저장을 하지 않는다

하지만 sub는 연산 자체가 목적이기 때문에

연산 후에 overwrite까지 수행한다

 

 

 

test 연산자도 마찬가지이다

z와 z가 같은지 아닌지 비교하지만

그 결과를 저장하지는 않는다

 

 

set instruction을 살펴보자

condition code에 따라

set해주는 연산들의 모음이다

 

instuction이 sete이고 condition이 ZF라는 건

set의 뒤에 나온 Register의 위치에

ZF condition value를 써달라는 뜻이다

 

instruction을 조금만 살펴보자면

setl은 less than

setle은 less than equal이다

 

 

jump instruction들을 살펴보자

condition code에 따라

jump하는 연산이다

 

몇 가지만 대표적으로 살펴보자면

jg는 jump greater than이다

 

 

 

cmov라는 instruction이 있는데 이는

conditional move의 약자이다

 

condition code에 따라서

conditional data setting, conditional jump를 하는

용도로 사용하고

이걸 가지고 우리가 보통

branching, control, loop 작업을 수행한다

 

우리가 흔히 코딩할 때 자주 사용하는

for loop도 결국 기본적으로 branching이다

코드 수행 중 특정 부분으로 jump를 시키기 때문이다

 

이러한 방식으로 우리가 작성한 프로그램이

내부에서 구성되게 되는 것이다

 

 

 

그렇다면 set과 jmp를 이용해서

우리의 코드가 어떻게 

내부에서 작성되는지

assembly code를 살펴보며 이해해보자

 

왼쪽으 gt함수는 greater than 함수로

x>y를 비교해서 그 결과를 return하는 함수다

 

우선 함수 인자인 x와 y는 각각

%rdi, %rsi에 들어가있다

 

그런 다음 cmpq instruction을 이용해서

rsi와 rdi를 비교해준다

cmp를 하게 되면 conditional variable에 setting이 되고

아래의 setg %al을 해주면

%al에 이 conditional variable이 저장된다

그런다음 movzbl %al, %eax를 통해

%al에 있던 값을 %eax로 옮겨준다

 

%rax가 아닌 %eax인 이유는

long값이라서 메모리가 많이 필요없기 때문이다

movzbl instruction은

나머지 비트는 전부 0으로 세팅되게하라는 말이라고 한다

혹시나 저장할 메모리 영역에

쓰레기값이 있을 수도 있으니까

남는 비트는 전부 0으로 세팅하는 것이다

 

아무튼 이렇게 cmpq값이 %eax에 저장되고

그럼 이걸 return하게 된다

 

 

 

위에서 설명한 movzbl이

나머지 비트를 0으로 세팅한다는 것에 대한 시각화이다

 

 

코딩을 하다보면 오른쪽과 같은 flow chart를

그려야 할 일이 있었을 것이다

 

그렇다면 이제 컴퓨터가

if문을 어떻게 내부에서 처리하는지 살펴보자

 

오른쪽의 flow chart를 살펴보면

if문이 true이면 op1을

그렇지 않으면 op2를 실행하도록 되어있다

 

 

 

앞에서 봤던 c코드에 대한 assembly이다

 

가장 위의 subq $8, %rsp는

stack pointer를 내리는 말이라는데

stack은 위로 차곡차곡 쌓이는데

공간 확보가 필요하다보니

stack의 주소를 가리키는 pointer를 내려주는거라고한다

보다 자세한 내용은 다음 시간에

제대로 알아보는게 좋을 것 같다고 하셔서

저정도로만 설명해주셨다

 

아무튼 이렇게 subq를 해준 뒤

testl %edi, %edi를 해주는데

%edi를 체크를 하는데

%edi가 0인지 아닌지를 체크하는 것이다

 

그 밑에 je .L2가 있는데

만약 값이 0이면 L2로 이동하라는 의미이다

그렇지 않으면 call op1을 실행해서

op1를 실행시킨다

 

op1()과 op2()는 앞에서 정의된 어떤 함수이고

이걸 실행시키기 위해서는

해당 함수가 위치한 곳을 알아야한다

그래서 call op1, call op2처럼 해주는 거고

이렇게 call instruction은 컴파일러가 생성해주는 것이다

 

여기서 ret할 값이 없는 이유는

void function이기 때문이고

call op2이 끝나면

jmp .L1이 있는데

이는 묻지도 따지지도 말고 

.L1으로 이동하게하는

unconditional jump라고한다

 

이 unconditional jump에 대해서

교수님이 조금 자세하게 언급을 해주셨는데

conditional jump (= if문)이 코드의 퍼포먼스에

좋지 않은 코드인데

unconditional jump는 퍼포먼스를 그렇게

해치지 않는다고 한다

 

conditonal jump가 코드 퍼포먼스상 좋지 않은 이유는

나중에 CPU 아키텍처에서 파이프라이닝을 배우게 되면 알게되는데

원래라면 다음에 실행할 instruction이 있는 위치를 알아야

코드의 수행이 편리해지는데

conditional jump는 다음 instruction의 위치를 알 수 없기 때문이다

 

따라서 우리가 코딩을 할 때

무작정 if를 통해 분기문을 작성하면

성능이 많이 저하되는데

c언어에서는 이를 어느정도 예방해줄 수 있는 것이 존재한다고한다

이 것이 바로 c언어의 likely와 unlikely라고 한다

 

if문을 작성한 다음 조건 전체를 감싸고

likely 혹은 unlikely를 써주면되는데

실행될 가능성이 높은 if조건에는 likely를

실행될 가능성이 낮은 if조건에는 unlikely를 해준다고한다

이는 우리가 컴파일러에게 줄 수 있는 힌트로

분기 예측(branch prediction)을 도와주는 구문이다

 

하지만 당연히 trade-off가 존재하는데

likely로 해서 컴파일러에게 해당 조건은

실행될 가능성이 높다고 알려줬는데

만약 해당 조건이 실행되지 않는다면

기존에 수행했던 것들을 다 버려야한다

이것이 바로 likely, unlikely를 사용할 때

감당해야할 penalty이다

 

실제로 os와 같은 시스템적인 프로그래밍을 하면

코드의 성능을 극대화하기 위해서

if문을 무조건 최소화한다고 한다

미국의 유명 IT기업에서 수행하는 코딩 테스트에서는

linked list에서 어떤 숫자보다 큰 값, 작은 값을 뽑아내는데

conditional code를 최대한 적게 사용해서 코드 퍼포먼스를 최대화하는

문제가 나온다고 한다

(실제로 if문을 단 한번도 안써도 가능하다고한다)

 

아무튼 conditinal code는 이정도로

프로그램의 퍼포먼스에 영향을 많이 주고

실제로 linux의 코드를 뜯어보면

likely와 unlikely의 향연이라고한다

 

 

 

마지막으로 그럼 branching을 하는 대신에

conditional move를 수행해서 jump 하지 않고

if문을 실행시키는 assembly code를 살펴보자

 

왼쪽이 c언어 코드이고 오른쪽이 assembly이다

우선 %rax에다가 %rdi의 값을 저장한다

그다음 subq %rsi, %rax를 통해

result = x-y의 부분을 수행한다

그리고 수행한 결과를 우선 %rax에 저장해놓는다

 

그러고 %rsi를 %rdx에 옮기는데

이는 argument인 y를 %rdx에 옮겨놓는 것이다

 

그런 다음 subq %rdi, %rdx를 통해서

y-x를 수행한 다음

결과를 %rdx에 저장한다

 

왜 이렇게 조건을 확인하지도 않고

미리 x-y와 y-x를 실행하는 것이냐면

if문 안에 있는 구절을 미리 실행해놓는 것이다

 

그런 다음 나중에 조건을 확인하고

조건에 따라서 미리 계산해놓은 값 중

하나를 return하는 것이다

 

그래서 밑에 보면 cmpq %rsi, %rdi를 통해서

%rsi에 있는 값과 %rdi에 있는 값을 비교해주고

cmovle를 통해  작거나 같으면

%rax에 %rdx의 값을 move해준다

그런 다음 return을 해준다

 

 

 

다음주에는 for loop을 처리하는 방법에 대해서

자세하게 배운다고한다

 

이번 주 수업 내용 정리는 여기까지-!