3.5 산술연산과 논리연산
주소 계산 명령어 leaq
movq 가 메모리 주소를 계산해서 그 위치의 값을 읽는 것과 달리, leaq 는 주소 계산만 하고 그 값 자체를 목적지 레지스터에 넣는다. 목적 지는 반드시 레지스터이다. 컴파일러는 이걸 산술 연산 용도로 전용한다.
leaq 7(%rdx,%rdx,4), %rax → %rax = 5*%rdx + 7
즉 C 의 x * 5 + 7 같은 식이 곱셈 명령 없이 한 줄로 변환된다. leaq 는 조건 코드를 설정하지 않는다.
단항, 이항 연산자
- 단항 : inc (+1), dec (-1), neg (부호 반전), not (비트 반전) → C 의 x++, -x, ~x 에 대응
- 이항 : ADD, SUB, IMUL, XOR, OR, AND . 두번째 피연산자가 소스이자 목적지이다.
- subq %rax, %rdx는 %rdx = %rdx - %rax
- 두 피연산자가 동시에 메모리일 수는 없다.
- 목적지가 메모리면 읽기 → 계산 → 쓰기가 한 명령으로 일어난다.
시프트 연산
- 왼쪽으로 밀면 x2이다.
- sal, shl
- 왼쪽 시프트는 SAL 과 SHL 이 완전히 동일하다. (왼쪽으로 밀 때는 부호 여부가 결과에 영향 안 줌)
- 오른쪽으로 밀면 /2 이다.
- sar, shr
- 시프트 양은 즉시값 (immediate) 이거나 단일 바이트 레지스터 %cl 로만 지정할 수 있다.
- 즉시값 : 명령어 안에 상수가 직접 박혀 있는 것이다. 목적지로는 올 수 없다.
- 단일 바이트 레지스터 %cl : %rcx 의 하위 1바이트
- 하나의 물리 레지스터를 크기별로 잘라서 접근할 수 있다. %rax 하나가 이렇게 나뉜다.
- ex) %rax(64비트) → %eax(하위 32비트) → %ax(하위 16비트) → %al (하위 8비트)
왜 부호있는/없는 연산에 같은 명령어를 쓰는가
CPU는 비트만 계산할 뿐, 그 비트가 signed 인지 unsigned 인지는 모른다. 부호는 결과를 읽는 사람이 정하는 해석이다. 그래서 컴파일러는 같은 명령어를 사용한다.
곱셈은 하위 비트만 남기면 같다. C에서 곱셈은 결과를 같은 크기에 담으니까 하위 절반만 필요하다. 하위 절반은 signed/unsigned 둘다 같으므로, 이름이 signed multiply 인 imul 하나로 unsigned 곱셈까지 처리할 수 있다.
위 항목에서 예외 되는 것은 3가지 인데,
- 전체 폭(128비트) 곱셈
- 오른쪽 시프트
- 나눗셈
특수 산술 연산
- 두 64 비트 값을 곱하면 최대 128 비트가 필요. 128비트 결과는 %rdx (상위) + %rax (하위) 두 레지스터에 걸쳐 저장된다.
- 나눗셈은 반대로 피제수가 %rdx:%rax 의 128비트라고 가정한다.
- idivq S: 몫 → %rax, 나머지 → %rdx.
- 64비트 값 하나를 나누려면 먼저 128비트로 확장해야 한다.
⇒ 64비트 레지스터 하나로는 부족할 때, 두 개를 붙여서 128비트 그릇으로 쓴다.
3.6 제어
C 의 if, while, for, switch 가 기계 수준에서는 조건 코드 + 점프로 바뀐다. 기계어에는 블록이나 중첩이 없고, 순차 실행과 점프뿐이다.
조건 코드
CPU 는 최근 연산의 속성을 기록하는 1비트 레지스터들(아래의 깃발들)을 유지한다.
- CF, Carry : 최상위 비트에서 자리올림 발생 → 부호 없는 오버플로
- ZF, Zero : 결과가 0이다.
- SF, Sign : 결과가 음수 (최상위 비트가 1)
- OF, Overflow : 2의 보수 (부호 있는) 오버플로 (양/음 양쪽)
산술, 논리 명령은 부수 효과로 이 플래그들을 설정한다. 나중에 만약 0이면 … 같은 판단을 할 때 이 깃발들을 확인한다.
조건 코드 읽기
이미 계산은 끝났고, 결과만 꺼내 읽는다.
cmpq %rsi, %rdi ← a - b 를 계산 (결과는 버림!) → 플래그만 남김
setl %al ← 남은 플래그를 보고 %al 에 0 또는 1
movzbl %al, %eax ← 나머지 바이트를 0으로 채워서 int 로 만듦
cmp 는 저울에 올려보고 눈금 상태만 기록, set 은 그 눈금을 보고 값을 남김
movzbl 이 따라붙는 이유 : set 계열은 딱 1 바이트에만 써서, 나머지 바이트는 건드리지 않고 이전 쓰레기값이 그대로 남는다. 그래서 int 를 쓰려면 위쪽을 0으로 밀어줘야 하고, 그게 movzbl 이다.
signed, unsigned 가 갈리는 이유 - unsigned 에서는 ...
부호 없는 수에는 부호비트란 개념이 없다.
- SF/OF 안 봄.
- 대신 빌려오기를 본다. a - b를 하는데 빌려와야 했다면, → a < b 라는 의미고, 그게 CF 이다.
SF ^ OF 직관
- 부호 비트는 가끔 거짓말을 한다.
- a < b 를 알고 싶다 → a - b 가 음수인지 보면 된다.
- 그래서 원래는 SF(결과의 부호 비트) 만 보면 끝이다.
- 문제는 뺄셈이 넘칠 때 (오버플로) 결과가 반대로 뒤집혀 버린다.
- OF 는 지금 SF 가 거짓말 중이라는 것을 알려주는 경고등이다. → SF 를 OF 로 한 번 더 뒤집어 주면 항상 올바른 비교결과가 된다.
점프 명령
CPU 는 평소에 명령을 한 줄씩 순서대로 실행한다. 지금 어디를 실행 중인지는 %rip (프로그램 카운터, PC) 가 기억하고 있고, 명령 하나 끝날 때마다 자동으로 다음 줄로 넘어간다.
점프는 이 %rip 값을 강제로 딴 데로 바꿔치기 한다. CPU는 바꿔치기된 위치부터 실행을 이어간다.
직접 점프 vs 간접 점프
- * 가 붙으면 간접 점프이다. 목적지가 실행 시점에 정해진다.
- ex) switch 문의 점프 테이블이다. case 가 촘촘하면 GCC 는 주소 배열을 만들어 놓고 한 번에 뛰어버린다.
- 함수 포인터 호출도 같은 원리이다.
- 조건부 점프는 직접 점프만 된다.
jmp .L1 ← 직접: "저기로 가라" (목적지가 명령어에 박혀 있음)
jmp *%rax ← 간접: "%rax에 적힌 주소로 가라"
jmp *(%rax) ← 간접: "%rax가 가리키는 메모리에서 주소를 읽어서 그리로 가라"
조건부 제어로 분기 구현
기계어의 if else 에는 중괄호가 없다. 컴파일러는 보통 조건을 반대로 뒤집어서 조건이 거짓이면 else 로 점프 하는 형태로 만든다. then 블록을 점프 없이 바로 이어붙이고 싶어서
[안 뒤집으면] [뒤집으면]
조건 참 → then으로 점프 조건 거짓 → else로 점프
아니면 else로 점프 맞으면 그냥 다음 줄 (= then)
→ 점프가 두 번 → 점프가 한 번
조건부 이동으로 분기 구현
CPU 는 명령 하나가 끝나야 다음 걸 시작하는 게 아니라, 여러 명령을 겹쳐서 조립라인처럼 처리한다. (파이프라인) 지금 명령을 실행하는 동안 뒤의 명령들은 이미 읽어오고 해독하는 중이다.
대안: cmovge, cmovle 같은 조건부 이동 명령은 양쪽 결과를 모두 계산해 둔 뒤 조건에 따라 하나를 고릅니다. 제어 흐름이 갈라지지 않으므로 예측 실패가 없다.
→ 쓰지도 않을 쪽까지 무조건 계산 단점
루프
모든 루프는 "몸통 실행 → 조건 검사 → 참이면 뒤로 점프"하는 do-while 한 형태로 수렴한다.
→ 조건 검사를 끝에 한 번만 두면 루프 한 바퀴에 점프가 하나로 끝나기 때문
- continue 예외
switch 문과 점프 테이블
- switch는 "케이스 주소를 담은 배열"을 만들어 두고 인덱스로 한 번에 뛰어드는 것 → 비교를 반복하는 대신 주소를 계산해 버린다.
- if-else 는 케이스를 하나씩 물어보지만, 점프 테이블은 값을 배열 첨자로 써서 주소를 바로 꺼낸다.
- ja 하나로 범위 검사함
3.7 프로시저
프로시저는 코드 + 인자 + 반환값 을 하나로 묶는 추상화이다.
- 제어 전달 : P가 Q 를 호출할 때 PC 를 Q의 시작 주소로, Q 가 끝나면 P의 호출 다음 명령어로 되돌림
- 데이터 전달 : P → Q 인자 전달, Q → P 반환값 전달
- 메모리 할당/해제 : Q가 시작할 때 지역변수 공간을 확보하고, 리턴 전에 반납
스택과 몇 개의 레지스터만으로 최소한으로 구현한다.
런타임 스택
- 스택은 낮은 주소 방향으로 자란다. %rsp 가 스택의 top 을 가리킨다. →
- 스택이 커진다 : %rsp 값이 작아진다
- 스택이 줄어든다 : %rsp 값이 커진다
- 함수 하나(프로시저)가 쓰는 구역을 스택 프레임이라고 한다.

제어
- call : 돌아올 주소를 스택에 밀어 넣고 함수로 점프
- ret : 스택에서 그 주소를 꺼내서 거기로 복귀 (스택에서 주소 pop 해서 pc 에 넣음)
리턴 주소를 레지스터가 아니라 스택에 둔다.
call *Operand 형태 간접 호출도 있음
데이터 전달
인자는 레지스터 6개, 반환은 %rax
- 인자는 순서대로 %rdi, %rsi, %rdx, %rcx, %r8, %r9에 담긴다. 반환값은 %rax
- 7개를 넘어가면 나머지는 스택에 올린다**. 메모리를 거의 안 쓰고 레지스터만으로 호출이 끝나도록 설계**함. 인자 개수가 많은 함수는 손해임.
스택에 지역 저장공간을 두는 경우
기본은 빠른 레지스터에 두고, 아래 경우엔 스택으로 감
- 변수가 너무 많아 레지스터가 모자랄 때
- 변수에 &(주소 연산자)를 쓸 때 - 주소를 만들려면 메모리에 있어야 하니까
- 변수가 배열이나 구조체일 때
- 스택과 레지스터 차이
- 레지스터 : CPU 안에 있는 고정된 개수의 하드웨어 저장소
- 스택 : 메모리 안에 있는, 함수 호출을 따라 늘었다 줄었다 하는 영역
레지스터의 지역 저장 공간
레지스터는 모두가 공유하는 자원이기에, 규칙(→ 여러가지 방법 있음 )이 존재한다. 내가 쓰던 레지스터를 남이 호출하며 덮어쓰면 안되기 때문.
- callee-saved(%rax, %rbp, %r12~%r15) : 불려간 쪽이 쓰려면 미리 백업했다가 복원
- 값이 함수 호출을 가로질러 살아남아야 하면
- caller-saved(%rax,%rdi,%rsi,%rdx, %rcx, %r8~%r11) : 부른 쪽이 알아서 챙김. 불려간 쪽은 마음대로 써도 됨.
- 값이 호출 사이에서만 잠깐 쓰이면
전부 저장하게 되면 곧 버릴 값까지 쓸데없이 백업하게 된다. 책임을 나눠서 낭비를 줄임. 강제하는게 아니라 약속(규약) 이다.
재귀 프로시저
지금까지의 규약만으로 추가 장치 없이 재귀가 자동으로 동작한다. 호출될 때마다 새 스택 프레임이 생기니까 각 호출의 변수, 리턴 주소가 섞이지 않는다.