나는 3주차 과정에서 씨친놈 ( CS에 미친놈 ) 이라 알고리즘 보다 CS 책을 더 메인으로 읽었다.
그렇다고 잘 아는건 아님 ㅎ
그래서 코어타임을 같이 진행할 팀원들을 위해
3.1 부터 3.11 까지 이해하고 넘어가면 좋을만한 내용들을 간단하게 쭉 정리해볼까 한다.
우리가 JAVA 나 C 같은 고급 언어로 프로그래밍 할 때, 우리가 작성한 프로그램이 기계어 수준에서 어떻게 구현되어있는지 자세한 내용은 드러나지 않아요.
그건 우리가 코드를 작성할 때는 추상화가 잘 되어 있어서 너무 좋지만,
가끔 알 수 없는 오류가 발생할 때, 이해가 필요한 프로그램의 런타임 동작이 감춰지곤해요.
대표적인 오버플로우 연산들이나 언더플로우 연산들이요.
그래서 이 장에서는
C 언어가 어떻게 기계어 형태로 컴파일되는지 공부할거예요.
x86-64 이라는 CPU 아키텍쳐 ( 명령어 집합 구조, ISA ) 를 기준으로 공부할거에요.
이 장에서는 x86-64 가 어떻게 발전되어 왔는지 대략적인 설명이 들어있어요.
굳이 년도 별로 어떻게 발전되어왔는지 외울 필요는 없고 그냥 가볍게 한 번 훑는 정도로만 봤어요.
C 언어가 어셈블리어로는 어떻게 작성되었는지 확인하기 위해 gcc라는 명령어를 사용해요.
리눅스에서는 기본 탑재되어 있기 때문에 커맨드라인에 그냥 쓰면 돼요.
이 컴파일에 대한 자세한 내용은 7장에서 확인합니다.
기계수준 프로그램의 동작은 아까 말한 ISA ( Instruction set architectur ) 에 의해 결정되는데
우리는 ISA 중 x86-64 로 공부할거고 다른 걸 사용하면 다른 명령어가 튀어 나올수도 있다.
이 ISA 는 여러 인스트럭션을 동시에 실행하지만 그에 관한 자세한 내용은 9장에서 볼거고
이번 3장에선는 어셈블리 코드를 이해할 수 있고, 어떻게 원래의 C 코드랑 연관되었는지를 더 집중해서 보면 좋아요.
등이예요.
이 장에서는 gcc와 objdump를 사용해서
c 언어를 어셈블리어로 만들거나
기계어를 역어셈블해서 어셈블리어로 만드는 방법이 적혀있어요.
직접 컴퓨터에서 실행하려면 관련 키워드를 검색해서 해보면 될 거 같아요.
어렵진 않아 보입니다.
조금 더 깊게 어셈블리를 이해하기 전에, 어셈블리가 어떻게 적혀있는지에 대한 간단한 설명이 적혀있어요.
1 call mult2
2 movq %rax, (%rdx)
3 ret
4 .size multstroe
5 .ident
이 중 '.' 으로 시작하는 라인은 어셈블러와 링커에 지시하기 위한 디렉티브들로 무시해도 돼요.
앞으로의 코드에서도 '.' 은 생략하겠지만 라인번호와 주석은 남겨둘거예요.
인텔의 프로세서들은 16비트로 시작했다가 나중에 32비트로 확장해서
"16비트 단위를 워드라고 불러요"
이건 앞으로 명령어의 접미사를 보면 자주 나올 거라서 확인해볼게요.
| C Data Type | Assembly Suffix | Size (Bytes) | 설명 |
|---|---|---|---|
char | b (byte) | 1 | 부호 있음: signed char |
short | w (word) | 2 | 16비트 정수 |
int | l (long) | 4 | 32비트 정수 (x86 기준) |
long | q (quad) | 8 | 64비트 정수 (x86-64 기준) |
char * | q (quad) | 8 | 포인터는 64비트 (주소) |
float | s (scalar) | 4 | 단정도 부동소수점 |
double | l (long) | 8 | 배정도 부동소수점 |
C 에서의 short 는 2Byte ( 16bit ) 이기 때문에 어셈블리에서는 w 로 표현돼요.
C 에서의 long 타입과 어셈블리의 약자를 헷갈리면 안돼요.
long 타입도 l 이고
double 타입도 l 이라서 헷갈릴 수 있는데
저건 아예 쓰는 명령어가 달라서 안헷갈리니까 ㄱㅊ아요
3.4 장에서는 C 언어에서 작성된 값들을 어떻게 저장하고, 어떻게 연산하고, 어떻게 쓰는지 공부할 거예요.
우선 레지스터부터 확인할게요.
X86-64 의 주 처리장치 CPU 에는 16개의 정수값을 저장할 수 있는 레지스터가 있어요.
%rax 는 return 값을 주로 저장하는 레지스터예요.
%rsp 는 스택의 top 값을 저장하는 레지스터예요.
보통 그렇게 쓰이고 나머지 레지스터들은 꽤 자유로이 쓰이는 편이예요.
이 내용은 미리 작성해둔 게시글로 대체할게요.
3.4.3 에서는 위 오퍼랜드 식별자와 데이터 이동 인스트럭션을 공부했으니
그걸 연습하는 연습문제들과 그걸 설명하는 내용이 들어있어요.
한번 연습문제 풀어보면서 감을 익혀보세요.
subq $8 , %rsp 스택포인트에서 8을 뺌
movq %rbp, (%rsp) 스택포인터에 %rbp 값을 저장함
이 두 개의 인스트럭션은
%rsp 에서 즉시값 8을 빼고나서
(%rsp) 값에 %rbp 의 값을 이동시키는 연산이예요.
이런식으로 스택도 메모리이기 때문에,
%rsp 값을 더하거나 빼서 해당 위치에 값을 넣어요.
스택 top 은 언제나 %rsp 가 가리키고 있어요.
%rsp 보다 아래주소에 저장된 값은 모두 쓰레기 값 이라고 생각하시면 됩니다.
x86-64 에서 사용되는 덧셈,뺄셈,나눗셈,곱셈 등의 산술연산과
AND OR XOR SHIFT 같은 논리연산의 목록들이 정리되어 있어요.
| 명령어 | 설명 |
|---|---|
add | 덧셈 (예: add rax, rbx) |
sub | 뺄셈 (예: sub rax, rbx) |
inc | 1 증가 (rax = rax + 1) |
dec | 1 감소 (rax = rax - 1) |
imul | 정수 곱셈 (부호 있음) |
mul | 정수 곱셈 (부호 없음) |
idiv | 정수 나눗셈 (부호 있음) |
div | 정수 나눗셈 (부호 없음) |
neg | 부호 반전 (rax = -rax) |
cmp | 두 값 비교 (sub처럼 동작, 결과는 플래그에 저장) |
| 명령어 | 설명 |
|---|---|
and | 비트 AND (예: and rax, rbx) |
or | 비트 OR (예: or rax, rbx) |
xor | 비트 XOR (예: xor rax, rbx) |
not | 비트 반전 (NOT) (not rax) |
test | AND 연산 후 플래그 설정 (결과는 버림) |
shl 또는 sal | 왼쪽 시프트 (곱셈과 유사) |
shr | 오른쪽 시프트 (부호 없음) |
sar | 오른쪽 시프트 (부호 유지) |
두 가지 체크해야할 점 말씀드릴게요.
조건코드 만 바꿔요.addq %rbx, %rax 같은 산술연산 명령어는 a,b 라고 했을 때 a+b 값이 b에 저장돼요.이름 헷갈리게 지어놔서 열받는데 유효주소 적재 leaq 명령어는 movq 명령어의 변형이예요.
첫번째 오퍼랜드는 메모리 참조처럼 보이지만 거기서 값을 읽어오는 대신에
목적지에 주소를 복사해요
movq 9(%rdx) , %rax 9(%rdx) 주소안의 값을 %rax 에 이동
leaq 9(%rdx) , %rax 9(%rdx) 주소 자체를 %rax 에 이동
단항연산과 이항연산은 간단해요.
| 명령어 | 의미 | 예시 코드 | 설명 |
|---|---|---|---|
inc | 1 증가 | inc %rax | rax = rax + 1 |
dec | 1 감소 | dec %rbx | rbx = rbx - 1 |
neg | 부호 반전 | neg %rcx | rcx = -rcx |
| 명령어 | 의미 | 예시 코드 | 설명 |
|---|---|---|---|
add | 덧셈 | add %rax, %rbx | rax = rax + rbx |
sub | 뺄셈 | sub %rcx, %rdx | rcx = rcx - rdx |
imul | 곱셈 | imul %rax, %rbx | rax = rax × rbx |
cmp | 비교 | cmp %rax, 10 | rax - 10 후 결과는 플래그에 저장됨 |
산술연산과 논리연산 중 쉬프트 연산과 관련된 명령어들이예요.
| 명령어 | 설명 |
|---|---|
shl 또는 sal | 왼쪽 시프트 (곱셈과 유사) |
shr | 오른쪽 시프트 (부호 없음) |
sar | 오른쪽 시프트 (부호 유지) |
왼쪽 쉬프트 shl 과 sal 은 사실상 똑같아서 아무거나 쓰면 돼요.
위의 다양한 산술연산은 q ( quad word ) 가 최대단위였는데요.
2장에서 64비트끼리의 곱셈은 결과값을 나타내기 위해 128비트를 필요로 하는 걸 확인했어요.
근데 레지스터의 용량은 최대 64비트잖아요?
그래서 두개의 레지스터에 각각 나눠서 저장하는 방식을 선택했어요.
| 명령어 | 표현식 (요청 형식) | 설명 |
|---|---|---|
mul rbx | R[%rdx]:R[%rax] ← R[%rax] × R[%rbx] | 부호 없는 곱셈, 결과는 128비트 (상위는 RDX, 하위는 RAX) |
imul rbx | R[%rax] ← R[%rax] × R[%rbx] | 부호 있는 곱셈, 결과는 RAX에 저장 |
imul rcx, rbx | R[%rcx] ← R[%rcx] × R[%rbx] | 이항 곱셈 |
imul rdx, rbx, 5 | R[%rdx] ← R[%rbx] × 5 | 3항 곱셈 |
예를 들면 mul rbx 는
rax 와 rbx 를 곱해서 rdx:rbx 에 각각 저장해요.
총 128비트중 상위는 rdx 에 하위는 rax에 저장해요.
지금까지는 인스트럭션이 하나씩 실행되는 간단한 예제를 봤는데요.
이제부터는 C 언어에서 if , while , for 문 등을 작성했을 때, 그게 기계어 코드에서 어떻게 표현되는지 살펴볼거예요.
는 이걸로 대체하기
C 에서 if 문을 기계어 코드로 번역하는 가장 일반적인 방법은
조건부 및 조건점프를 사용하는 거예요.
// 원본 C 코드
if (a > b)
result = 1;
else
result = 0;
movl a(%rip), %eax # eax = a
cmpl b(%rip), %eax # 비교: a - b
jle else_part # a <= b 이면 else로 이동
movl $1, result(%rip) # if true: result = 1
jmp end_if # 분기 종료
else_part:
movl $0, result(%rip) # else: result = 0
end_if:
위 코드는 정말 간단한 C 예제를 통해서 어떻게 변환되었는지 살펴볼거예요.
a 의 값을 %eax 에 담고
%eax 에 담긴 a 값을 b 값과 비교해서
조건코드를 변경해줘요.
그리고나서 jle 명령어로 조건코드를 탐색해서 else_part 로 점프하거나 아니면 그 이후 코드를 그대로 진행해요.
방금 위에서 if 문을 조건점프를 사용하는걸 봤는데요.
이번엔 cmove 라는 데이터 이동을 통해서 어떻게 작동하는지 살펴볼거예요.
조건부 점프는 특정 위치로 점프한후 해당 코드를 계산했잖아요?
조건부 이동은 그냥 전부다 계산하고 그 값을 쓴다고 생각하면돼요.
if (a > b)
result = 1;
else
result = 0;
movl $1, %eax # 기본값을 1로 설정
movl $0, %edx # 대체값 0을 edx에 저장
movl a(%rip), %ecx # ecx = a
cmpl b(%rip), %ecx # ecx(a) - b
cmovle %edx, %eax # if a <= b → eax = 0
movl %eax, result(%rip) # 결과 저장
C 코드 상 result 가 1이 나올수도 0이 나올수도 있잖아요?
1과 0을 모두 저장해놓고 결과값에 따라 하나를 불러와요.
이거 장점을 정리해둔 문장이 있어서 그걸로 대체할게요.
프로세서가 조건부 점프를 만나면
조건에 대한 계산이 완료될 때 까지, 어느쪽으로 분기될 지 결정할 수 없다.
그래서 어떻게 실행될지 추측하기 위한 복잡한 분기예측 회로를 채택하고 있는데
이게 안정적이면 인스트럭션 파이프라인은 인스트럭션들로 채워질 수 있다..
근데 점프 하나 잘못 예측하면, 미래의 인스트럭션을 위해 작업 결과를 버려야하고,
정확한위치에서 인스트럭션들을 파이프라인에 다시 채워야한다.
이 예측오류는 대략 15~30 클럭사이클의 손실을 발생시켜
성능 감소를 야기한다.
반면, 조건부 이동명령( 미리 다 계산해서 값 다 저장해놓음 )을 사용하면
약 8클럭 사이클을 필요로 한다.
C 에서 do while, while, for 같은 반복문을 제공하는데요.
기계어에는 여기에 대응되는 인스트럭션이 없어요.
그 대신에 위에서 본 조건부 테스트나 점프를 함께 사용해서 반복문같은 걸 만들어요.
int i = 0;
do {
i++;
} while (i < 5);
movl $0, %eax # i = 0
loop_start:
addl $1, %eax # i++
cmpl $5, %eax # 비교: i - 5
jl loop_start # i < 5 이면 반복
eax 에 1을 더하고 그 결과에 따라 cmp 로 5를 빼서 확인해요
만약 i < 5 면 jl 로 loop_start 로 돌아가요.
do while 뿐 아니라 while문 for 문도 이렇게 비슷하게 조건부테스트와 jmp 를 활용해서 구현돼요.
switch 문은 정수인덱스 값에 따라 분기를 제공하는데요.
테스트 하는 경우의 수가 많을 때 유용해요.
그리구 점프테이블이라는 자료구조를 사용해서 효율적인 구현을 해줘요.
int x = 2;
switch (x) {
case 1:
x = 10;
break;
case 2:
x = 20;
break;
case 3:
x = 30;
break;
default:
x = 0;
}
.section .rodata
jump_table:
.long case_1
.long case_2
.long case_3
.section .text
movl $2, %eax # x = 2
# 점프 테이블을 사용한 switch 구현
decl %eax # x = x - 1 (인덱스 조정)
cmpl $2, %eax # x가 0~2 범위인지 확인
ja default_case # x > 2 이면 default로 점프
jmp *jump_table(,%eax,4) # 점프 테이블에서 해당 주소로 점프
case_1:
movl $10, %eax # x = 10
jmp switch_end
case_2:
movl $20, %eax # x = 20
jmp switch_end
case_3:
movl $30, %eax # x = 30
jmp switch_end
default_case:
movl $0, %eax # x = 0
switch_end:
# switch 문 종료 후 계속 실행
switch 문을 자동할때는 인덱스 조정을 하는데
이번에 조건분기 값이 case 1,2,3 인데 점프테이블은 배열 인덱스 0,1,2 를 가지기 때문에 조정해주고
만약 그 범위를 벗어나면 바로 default_case 로 가요.
그리고 jump_table 위에 .section .rodata 라는 걸 확인할 수 있는데요.
이건 읽기전용 데이터안에 있다는 걸 뜻해요.
if-else 는 복잡한 조건문을 쓸 때 좋고 범위비교 여러 변수 비교 등
switch 는 연속된 정수값이나 단일 변수의 동일한 값 비교를 할 때 더 좋아요.
C 에서 배열은 가장 작은 단위의 데이터 int a = 10 char a = 'hello'를 보다 큰 단위의 자료형으로 묶어주는데요.
C 의 특이한 점 (ㅈ같은점) 은 배열 원소들에 포인터를 만들고 이 포인터 간에 연산을 할 수 있다는 점이예요.
배열의 인덱스를 사용할 때, 컴파일러는 주소계산을 통해 최적화 하기도해요.
자료형 T (int,char) 와 상수 N에 대해서 다음과 같은 선언에 대해 생각해보자
T A[N]
char a[3]
시작하는 위치를 xA 라고하자.
이건 두 가지를 실행한다. L 은 선언된 자료형의 바이트 단위이다.
배열의 원소 i 는 주소 xA + L * i 에 저장된다.
char a[3] = {'A','B','C'};
printf("%s\n", a); // 문자열처럼 출력 'ABC'
printf("%s\n", a + 1); // a[1]부터 문자열처럼 출력 'BC'
printf("%c\n", *(a + 1)); // 문자 B 출력 'B'
printf("%d\n", *a + 1); // 'A'의 ASCII값(65) + 1 = 66 출력
위 예제를 통해 기계어 코드에서 연산이 어떻게 되는지 알 수 있어요.
문자 a 는 시작점 xA로 생각하면 배열의 첫주소예요.
그래서 %s 로 null 값이 나올때까지 출력되어서 문자열 'ABC' 가 출력돼요.
a+1 는 xA + L(char 형의 크기 ) * 1 이예요.
그래서 두번째 문자인 B 부터 나머지가 출력돼요.
*(a+1) 은 xA + L x 1 의 주소가 가리키는 값 이예요. 그래서 'B'예요.
이런식으로
배열은 메모리 주소에 연속적으로 저장되어있다는 사실을 확인할 수 있어요.
이렇게 배열을 생성할 때, 메모리주소로 계산할 수 있다는 특이한점은
이차원 배열을 생성할 때도 적용돼요.
int A[5][3];
T D[R][C];
이건 사실 배열을 5개만들고
그 안에 배열을 3개 만든거랑 똑같잖아요?
그래서 만약 배열원소 A[i][j] 를 확인하고 싶다면?
&A[i][j] = xA + L(C x i + j) 와 똑같아요!
그냥 계산하는거죠 뭐.
배열이 주소이므로 행렬의 곱셈등을 사용할 때,
고정크기의 배열에서 주소 계산으로 최적화하는 방식이 있는데
설명하고 이해하는데 오래걸리지만 그 중요도는 낮은 거 같아 넘어갑니다.
// 고정크기 배열
a[5][3]
// 가변크기배열
N = int(input())
M = int(input())
a[N][M]
참고용
옛날에는 고정크기배열만 지원했었어요.
그래서 가변크기배열을 만들려면
MALLOC 이나 CALLOC 같은 함수를 사용해서 할당을 해줘야했고, 다차원 배열을
1차원 배열들로 인덱싱을 해줘야했어요.
그래서 ISO C99 버전부터 가변크기배열을 만들 수 있는 방법을 지원했어요.
int N = 3;
int M = 5;
int array[N][M]
그게 이런 방식이예요.
진짜 이름왜 이따구로 짓는거임?
진격의 거인 기행종인줄
쨋든 C에서는 '이' 다를 이 자를 써서
각각 다른 유형의 객체를 연결해서 자료형을 만드는 두 가지 방법을 제공해요.
struct rec {
int i;
int j;
int a[2];
int *p;
}
int i = 4byte
int j = 4byte
int a[2] = 8byte
int *p = 8byte
구조체는 struct 선언으로 만들어줘요.
이 구조체도 배열처럼 시작주소가 가장 처음선언된 int i 의 주소가 돼요.
그리고 차지하고 있는 바이트만큼 더해주면 그 다음 객체의 주소가 돼요.
union rec {
int i;
int j;
int *p;
}
union 으로 선언된 공용체는 struct 와 다른 점은
공간을 공용으로 써요. 뭔말이냐고요?
구조체는 각각 모두 주소를 가지고 있지만
공용체는 주소를 공유해요.
그리고 그 주소는 가장 큰 바이트값을 가진 객체의 크기로 결정돼요.
여기서는 int *p; 가 8byte로 가장크니까 이 rec 공용체는 8byte 의 크기를 차지해요.
많은 컴퓨터 시스템들이 기본 자료형들에 사용 가능한 주소를 제한해요.
어떤 객체의 주소는 어떤 값 k 의 배수가 되게끔하죠. 일반적으로 2,4,8
이렇게 정렬하는 이유는 총 3가지예요.
걍 대충 PADDING 이란거 써서 데이터 크기 맞춰서 정렬한다 정도만 이해중.
이 밑으로는 거의 모름
그냥 지금까지 위에서 나온 내용 잘 짜집어서 하는 챕터
우리가 알고있는 c 포인터에 대한 설명
GDB 디버거로 디버깅하는 방법
버퍼 오버플로우는 너굴맨이 이미 작성했으니 안심하라구~!
위에서 스택관련해서 살펴보았을 때
subq $8 , %rsp 이런식으로 컴파일 단계에서 스택프레임이 정해진 경우만 확인했었는데요.
가변크기 즉 사용자의 입력에 따라서 스택프레임을 가용하는 경우가 있잖아요?
그때는 %rbp라는 베이스포인터 레지스터를 이용해서 현재 위치를 저장해요.
그리고 그 %rbp 값을 기준으로 상대적인 오프셋 값으로 참조해요.
여기부터는 걍 아예 이해못함
컴친놈 ㅇㅈ;