기계어 코드와 어셈블리 코드를 배우는 이유는?
최적화 성능,
즉 어떻게 해야 최적화 되고,
왜 최적화 되는지 알기 위함이다.
(동시에 어떤 것이 비효율적인지 알아볼 수 있다.)
예를 들어,
쓰레드 프로그램의 구성을 파악한다든가,(시스템)
시스템 감염 시 런타임 제어 정보를 어디에 저장한 것을 파악했는지, (보안)
에 쓸 수 있다.
그래서 3장에서 할 것은...
대충 기존에 많은 기능은
이전 버전이랑 호환하기 위해 남은
전유물.....이라
역사를 알아두면 왜 이런 이상한 기능이 있는지....
를 알 수 있다고 하지만, 일련의 나열들이라 생략.
(애초에 그런 이상한 기능이 뭐가 있는지도 모르겠고.)
최적화 정도를 높이면,
컴파일 과정에서 많은게 바뀌기 때문에,
실제 실행 파일에서는 원래의 소스코드를 파악하기 어렵다.
(또, 그렇기에 디버깅 도구를 적용하기도 어렵다.)
그래서 컴파일시 -0g 옵션을 붙여서
좀 더 알아볼 수 있는게 남아있는 어셈블리 파일을 확인할 것이다.
기계 수준의 코드를 이해하는데에는 두 가지의 추상화가 있다.
또, 프로세스의 상태는 이러한 요소들로 이루어져있다.
또, C는 아예 데이터 타입에 따라 메모리를 할당하지만,
기계어는 메모리는 단순히 주소를 저장 가능한 배열일뿐, 데이터 타입에 대한 구분이 전혀 없다.
기계어에서 나오는 가상 주소는
OS가 프로세서 메모리에 주소값 형식으로 번역해준 것뿐이다.
-s로 컴파일시 어셈블리어 파일로 만들 수 있다.
그렇게 보면 나오는,
어셈블리 파일에서
pushq %rbx
라는 것은
레지스터 %rbx가 프로그램스택에 push 되어야한다는 말이다.
또, 이렇게 컴파일 시부터 어셈블리 파일로 만드는 것 말고,
기계어를 어셈블리로 만드는 것도 가능한데,
이를 역어셈블리라고 하고,
(정확한 어셈블리가 나오는 건 아니지만, 유사한 코드 출력)
objdump -d (파일명).o
...등으로 가능하다.
어셈블리어의 특징은
어셈블리 코드에서,
'.'으로 시작하는 명령어는
어셈블리와 링커에 지시하기 위한 directive로
대부분 확인할 필요 없다.
책에서는 앞으로 예제 코드를 어떤 식으로 보여줄지
설명하고 있다.
데이터 단위
데이터 타입에 따른 단위
GCC -> 어셈블리 코드
에는, 오퍼랜드의 크기를 나타내는 단일문자접미어가 붙어있다.
예를 들어, 이동 인스트럭션은 여러개지만,
데이터 크기에 따라 여러개일 뿐이다.
ex) movb(바이트 이동) movw(워드 이동...)
movl은 더블워드인데 l인 이유는
더블 워드의 32비트가 long word와 같기때문.
또, 접미어 l은 경우에 따라 정수에 붙거나 이중 정밀 부동 소수에 붙지만, 인스트럭션 집합이 달라서 헷갈릴 일은 없을 것이다.
CPU는 16개의 범용 레지스터를 갖고 있다.
범용 레지스터는
정수와 포인터를 저장 하고, %r로 시작한다.
본래는 ax...등으로 시작하여 용도별로 레지스터를 나눴었는데, bit단위가 증가하면서 ax, eax(32bit), rax(64bit) + (8개의 레지스터로 r8 ~ r15까지)
로 이루어져있다.
대개 연산은
남는 자기 연산 크기에 따라 가장 덜 중요한
남는 바이트에 접근하여 처리한다.
(ex) 16bit 연산은 2byte에 접근... 32bit 연산은 4바이트에 접근...)
1, 2, 4, 8 바이트 처럼 작은 연산을 할때는
남는 바이트를 어떻게 처리할지가 관건이다.
대개 1, 2바이트만 남으면 그대로 고수한다.(관습적)
스택 포인터다. 런타임 스택 겉부분을 가리킨다.
일부 인스트럭션만 이걸 쓴다.
나머지는 자유로운 편이고,
인스트럭션에 따라 쓰는 레지스터가 정해져있을 수는 있다.
이러한 레지스터의 이동으로,
스택 관리, 함수 인자 전달, 함수값 리턴, 로컬과 임시 데이터 저장의 메커니즘을 살펴볼 것이다.
인스트럭션은....
1개 이상의 오퍼랜드로 이루어져있다.
오퍼랜드는
로 이루어져있다.
오퍼랜드 형태는 여러가지인데,
대표적으로 세 가지가 있다.
lmn(ra,ri,s)
(뒤에 붙은건 아래 첨자) 는
lmn : 상수 오프셋
ra : 베이스 레지스터, 64bit
ri : 인덱스 레지스터, 64bit,
s : 배율(1,2,4,8)
로, 유효 주소를 계산시 lmn+R[rb]+R[ri]*s
식으로 계산할 때 사용한다.
가장 많이 쓰는 인스트럭션으로,
데이터를 다른 위치에 복사한다.
소스와 대상 타입에 따라 이동 명령이 갈린다.
가르는 것은 대개 두 가지다.
예를 들어,
소스를 목적지에 단순히 복사한다.
다른 데이터 크기에 따라 접미어가 다르다.
소스 오퍼랜드는
으로 이루어지고
목적 오퍼랜드는
으로 이루어져있다.
데이터 이동 시, 소스나 목적지 둘 다 메모리에 저장되진 않는다.
그래서 이동 시 쓰는 건 두 개의 인스트럭션이다.
넣는 레지스터의 크기는 인스트럭션 마지막 문자의 크기와 일치해야한다.
단 특이하게도 movl은 레지스터가 목적지다.
작은 소스값을 더 큰 목적지로 복사한다.
movz는 목적지의 남은 바이트를 모두 0으로 채우고,
movs는 소스 오퍼랜드의 가장 중요한 비트를 반복해서 복사, 부호 확장으로 채운다.
이 때 뒤에 붙는 마지막 두개의 문자가
데이터 크기를 나타내는데
앞이 소스의 크기(1~2byte)고, 뒤가 목적지의 크기(2~4byte)다.
당연히 목적지의 크기는 항상 소스의 크기보다 크다.
이 클래스는 3개의 인스트럭션을 갖는다.
레지스터를 지정하여, 항상 %eax -> %rax로 부호확장으로 옮기고 다른 명령어로 써도 똑같지만, 더 압축적인 인코딩을 보장한다.
프로시저가 실행 되면,
long x = xp;
xp = y;
return x;
라고 할때 어셈블리어는
movq (%rdi) %rax
movq %rsi (%rdi)
ret
로 대응된다.
첫번째 줄은 메모리에서 레지스터로 읽어 들이는 것이고,
두번째는 레지스터에서 메모리로 쓰는 코드다.
어셈블리 코드의 특징은
c 언어에서의 포인터는 어셈블리에서는 단순히 주소다.
x같은 지역 변수는 메모리보다 종종 레지스터에 저장된다.\
long x = xp;
xp로 표시되는 위치에 저장된 값을 읽어서 지역변수 x에 저장한다. (이를 역참조, dereferencing이라고 함)
xp = y;
매개변수 y값을 xp가 지정하는 위치에 쓴다.
&a
지역 변수 a를 저장하고 있는 위치의 주소를 생성한다.
프로그램 스택에
프로그램 스택은 프로시저 호출 처리에 중요하고,
후입 선출을 따른다.
이 때 쓰는 것은 스택포인터, %rsp로 스택 맨 위의 원소 주소를 저장하고 있다.
popq, pushq는 1개의 오퍼랜드를 사용하며,
popq는 추출을 위한 데이터 목적지를,
pushq는 추가할 소스 데이터를 갖고 있다.
원리적으로는
push는 넣을 데이터값많은 스택 포인터를 -시켜
그 스택 주소에 새로운 값을 넣고,
pop은 스택 포인터를 + 시켜 꺼낸다....
연산은 크게 네 종류가 있다.
lea는 사실 mov의 변형이다.
메모리에서 반드시 레지스터로 복사하며,
메모리 위치도 그 위치에 있는 값을 읽는 게 아니라,
그 메모리 위치의 유효주소를 목적지에 복사한다.
(즉, 포인터로 치자면)
(포인터 참조를 안 붙이고 포인터 주소를 가져오는것.)
lea의 용도는,
예제로는 단순 산술 연산을
lea로 계산하는 예제를 보여주고 있다.
단항은 1개의 오퍼랜드를 가진다.
그 1개의 오퍼랜드에 바로 적용하며,
오퍼랜드는
레지스터나 메모리 위치가 자리하게 된다.
예를 들어,
incq (%rsp) 는
스택에 8byte++를 하는 결과로,
일종의 ++ 연산자와 비슷한 역할을 한다.
이항은 소스와 목적지가 반대로 되어있다.
(이항이 소스 목적지 순인데 보통 반대인가봄.)
목적지에는 reg나 메모리 위치가 들어가고,
소스에는 상수, reg, 메모리 위치가 들어가는데,
소스, 목적지 둘 다에 reg와 메모리가 속하진 않는다.
소스, 목적지 순으로 나와있어
다른 연산자와 같은 순이 아니라서
비교환성(noncommunatative)을 가진다고 한다.
예를 들어,
역할은 x -= y 와 비슷하지만,
subq %rax %rdx
라면,
%rdx - %rax 가 실제 결과값이기때문에,
아무튼... 그렇다.
여기서
목적지가 메모리 위치면, 결과를 다시 메모리에 써야한다
라는 구절이 있는데,
이건 메모리 위치로 값을 가져왔어도,
계산한 값을 메모리 위치에 직접 다시 수정해서 넣어야한다는 뜻이다.
쉬프트는
2개의 오퍼랜드로 이루어지는데,
쉬프트는 산술 연산으로도, 논리형 우측 쉬프트로도 쓰인다.
1바이트의 쉬프트로는
2^8 -1 = 255 의 데이터 길이를 수용할 수 있다.
그래서 대개
하위 m비트(그러니까 남길 값)으로 데이터 길이를
w비트 길이 데이터 값을
2^m = w
만큼 옮긴다고...
(잘 모르겠군, 느낌은 알겠지만 정확히는 모르겠어.)
특이한 건
좌측 쉬프트 SAL, SHL은 동일한 효과로 우측을 모두 0으로 채우지만,
우측 쉬프트 SHR은 논리 쉬프트,
SAR은 산술 쉬프트로, 부호 비트를 복사하여 확장한다.
대부분 인스트력선은
비부호형, 2의 보수 산술 연산에 사용되지만,
우측 쉬프트는 부호형, 비부호형 데이터에 따라
데이터를 구분하는 인스트럭션을 요구한다.
(왜냐하면,
우측 쉬프트는
값을 우측으로 옮기는 것인데,
그러면 부호형의 경우
마이너스는 맨 앞의 1로 나타나기때문에
부호인지 아닌지가 중요함.)
(그래서 부호라면 -라면 맨 앞을 1로,
+라면 맨 앞을 0으로 채운다.)
흠 뭔 소리인지 모르겠군
반복문과 스위치문은,
시험을 하고,
그 결과에 따라 실행한다.
대개 모든 인스트럭션은
순차적으로 실행되지만,
점프(Jump) 인스트럭션으로 변경이 가능하다.
(일부분에 제어를 넘기는 인스트럭션이다.)
단일 비트 조건 코드로 구성된
레지스터를 운영한다.
조건부 분기를 확인하기 위한 플래그가 네개가 있는데
(조건이 주어지면, 그 조건의 결과를 파악하는 플래그.)
(모두 가장 최근 연산 결과에 따라 표시하는 것이다.)
예를 들어,
t = a + b의
ADD 인스트럭션을 쓴다면,
각 플래그는 이런걸 판단할 것이다.
CF : t < a
ZF : t == 0
SF: t < 0
OF : (a < 0 == b < 0 ) && ( t < 0 != a < 0 )
lea는 조건 코드를 변경하지 않지만,
그 외는 전부 변경이 가능하다.
(이후 예제로는 특정 연산자에 따라
각 플래그가 어떻게 나와야 판단하는지 설명하고 있다.)
이 때,
레지스터는 변경하지 않으나
조건 코드만 변경해주는
두 개의 인스트럭션 클래스가 있다.
이 클래스는 오퍼랜드가 반복되어 나오는데,
이 중 하나는 시험할 비트 마스크이다.
(하나는 실험용이라는 것 같다.)
조건 코드를 사용하는 세 가지 방법이 있다.
SET은 특이하게,
서로 다른 접미어가 데이터 크기가 아니라,
다른 조건 코드 조합으로 서로 다른 동작을 처리한다.
SET의 접미어
오퍼랜드 크기(x)
조합 종류(o)
예를 들어,
setl : set less, setb : set below...다.
(크기가 아니라는 말.)
그리고 목적지 오퍼랜드는
가 들어간다.
예를 들어
cmp로 비교 후
set 으로 조건 코드를 설정하고,(오퍼랜드가 역순이라는 부가설명)
mov 로 조건코드에 따라 값을 남기고 지운다.
setg는 크면 1 저장, setnle는 작거나 동일하지 않으면 1 저장인데,
사실 완전히 같은 동작이라, 컴파일러가 랜덤으로 설정한다.
점프 인스트럭션은
새로운 위치로의 실행을 선언한다.
목적지는 레이블 label로 표시하는데,
jump .L1 식으로 쓰여있는 식이다.
이러한 label이 있어야 하기 때문에,
점프 목적지(jump target)를 인코딩 해야한다.
점프는 목적지에 따라 두 가지가 있다.
간접 점프는 앞서 나왔 듯이, 식별자 *나 메모리 오퍼랜드를 참조한다.
예를 들어
jmp *%rax
는 rax가 목적지지만,
jmp *(%rax)
는 읽은 다음에, 그 안에 목적지로 이동한다.
그 외에는 조건부 점프라는 게 있는데,
조합에 의해 결정하여 SET과 유사하지만
직접 점프만 가능하다.
실제로 인코딩한다기 보다는,
링커 공부 시 중요한 파트고,
역어셈블어 결과 해석 공부에 도움이 된다.....
(할 일은 별로 없을 거 같지만)
(인생은 모르는 거니까)
인코딩은 방법이 크게 두 가지가 있다.
PC 상대적 (PC relative)
점프 인스트럭션과 대상 인스트럭션 주소의
차이를 인코딩한다.
그래서 그 차이만큼 1, 2, 4byte까지 인코딩 됨.
(특징은 점프 다음 인스트럭션 주소를 쓴다는 건데)
(목적지 말고 점프 인스트럭션 주소 자체를 안 쓴다는거임)
(그건 실행시 포인터를 바로 갱신하는 관습때문이라고함)
절대 주소 제공
4byte를 항상 쓴다.
선택은 어셈블러와 링커가 눈치껏 해준다.
그 외 점프는
을 구현한다.
조건부 수식 문장을,
기계어로 번역할 때,
대개 일반적인 방법은
실제로는 워낙 비효율적 코드라 추천 되지 않으나,
코드 제어 흐름을 보여 주기에 용이한 goto 코드를 예제로 쓴다.
(C에서는 조건부 문장 처럼 보이지만)
(어셈블리 수준에서는 조건이 생기면)
(아예 별도의 코드 블록을 만들고)
(jump로 블록을 이동 시킴)
데이터 조건부 전송이라는 방법도 있다.
산출물을 모두 계산하는데,
(조건이 어떻든 코드 전부 계산)
조건에 따라 결과를 하나만 선택하는 것이다.
이 때 쓰는
조건부 이동 인스트럭션이 move다.
(일단 모두 연산한다음)
(테스트, cmp든 뭐든 한다음)
(cmovge 로 조건 코드에 따라 값 할당.)
순차적이어도,
연속된 인스트럭션은 중첩하여 계산하여
고성능을 보이는데,
분기가 있으면
분기 계산이 끝나기 전까지 예측이 불가하기 때문에,
미리 계산할 수가 없다.
또, 대개 분기로 나누어질때는
분기 예측 회로를 쓰는데,
예측이 쉬우면 일정하게 8클럭 정도지만,
랜덤이면 17.5 클럭, 어려우면 19클럭 정도 소모하지만,
조건부 이동을 쓰면
항상 8클럭 정도로 해결이 가능하기 때문이다.
....
2개의 오퍼랜드를 갖는다.
크기는 10~32~64비트 정도이며,
목적지 reg는 명시된 조건이 만족될 때만 복사된다.
대개 목적지 reg 만으로 오퍼랜드 길이 예측이 가능해서,
move하나로 모든 오퍼랜드 길이에 호환이 가능하다.
...
물론 데이터 이동이라고 항상 좋지는 않다.
연산 자체가 많으면 데이터 이동이 비효율적이거나,
아니면 다 계산하다보니 조건이 안 되는데 계산하면서 오류가 생기기때문이다.
실제로,
컴파일러는 두 수식이 매우 간단한 경우에만 Move를 쓰고,
실제 분기 예측이 틀렸을 때의 비용이 크더라도 그냥 분기 이동을 쓴다.
C언어에서는
do-while, while, and for 등의 반복문이 있다.
조건부 테스트 + 점프로
반복문 역시 구현할 수 있다.
대개
do body statement
while (test-expr)
이면, test가 성립하면 반복을 수행하는데,
구조상 적어도 1번은 실행하게 되어있다.
이걸... loop와 점프로 쓰면,
loop:
body statement
t= test-expr
if(t)
goto loop
가 된다.
매 실행마다 본체 문장과 테스트 수식을 계산한다.
while (test-expr){
body statement
}
두 가지 방식으로 인코딩 된다.
중간으로 점프와 조건형-do 이다.
goto test
loop:
body statement
test:
t = text-expr
if(t)
goto loop
t = test-expr
if(!t)
goto done
loop:
if(t)
goto loop
done:
여기서 n>1은 n != 1 처럼 자동 변환된다
하고 신기해하고 있음
for(init;test;update)
body statement
는 이것과 같은 말이다
init;
while(test){
body statement
update
}
init
goto test
loop:
body statement
test:
if(t)
goto loop;
init
if(!t)
goto done
loop:
if(t)
goto loop
done:
정수 인덱스 값에따라,
다중 분기 기능을 제공한다.
테스트에 대한,
경우의 수가 많을 때 유용하다.
점프 테이블이라는 걸 쓰는데,
원소 i가
switch문 인덱스 i라면,
그 인덱스 i의 코드블록 주소를 담은 배열로 이동한다.
찾기 위해 배열처럼 참조하는 것이다!
if-else 다단계보다, switch를 쓰면
케이스 수에 관계없이 처리가 가능하다.
그 외에는 이러한 경우가 있다.
(아무튼,
점프 테이블에는,
코드블록 주소가 레이블 형태로 들어가있고,
&&로 시작하고,
jt 원소로 표시된다는 것이다.)
프로시저는...
인자와,
특정 기능 구현 코드,
리턴 값
전반을 통틀어 감싸서 한 추상화다.
다른 언어에서는
함수, method, 서브루틴... 등의 형태로 나타난다.
프로시저에서는 대표적으로 세 가지 일이 일어난다.
프로시저 P가 프로시저 Q를 호출하고, Q를 실행한 다음 다시 P로 리턴한다고 하자.
C언어를 포함한 대부분의 언어는,
LIFO, 후입 선출로,
스택으로 프로시저들이 요구하는 저장 장소를 관리한다.
그리고 이 스택에,
제어, 데이터 전송, 메모리 할당에 관한 정보를 저장한다.
레지스터 저장 개수 이상
저장 공간이 필요할 때는 추가로 스택에 할당하기도 하는데,
이러한 프로시저마다의 구분을
추상화한 개념이 스택 프레임이다.
프로시저 P는 프로시저 Q에
최대 6개의 매개변수를 전달할 수 있고,
그 이상의 값은 프로시저 P의 스택프레임에 할당한다.
이후 return address, 리턴 주소를
스택에 push하는데,
이것까지 프로시저 p의 스택 프레임에 속한다고 간주한다.
프로시저 P에서
프로시저 Q를 호출할 때는,
PC를 Q 코드 시작 주소로 설정한다.
이때 쓰는 명령어가
call Q인데, (정확히는 call이고 Q는 프로시저 Q를 가리킴)
이 인스트럭션은
한다.
ret은 반대로,
한다.
call은 직접, 간접 형태 두 가지가 존재하는데
직접의 경우 레이블 자체를 제시하지만,
간접은 * 뒤에 식별자가 붙는 형태다.
레지스터를 통해 일어난다.
프로시저 P의 인자를 레지스터에 복사하여 전달하며,
6 이상일 때는 7 ~ n까지 스택 프레임에 할당한다.
이 때, 매개 변수를 스택에 전달할 때,
모든 데이터 길이는 8의 배수로 반올림한다.
그렇게 배치되고 나서야,
프로시저 P는 프로시저 Q call 인스트럭션을 수행할 수 있다.
그러고 나면,
프로시저 Q는 레지스터와 스택을 통해,
자신의 인자들에 대해 접근할 수 있다.
이러한 공간을, Argument build Area라고 한다.
어셈블리에서는,
인자 6개까지는
대개 레지스터에 저장할 수 있는 것 이상의 지역 저장소를 쓰지 않지만,
때로는 지역 데이터가
메모리에 저장되어야하는 경우가 있다.
이러한 경우가 포함된다.
대개는 스택 포인터를 감소시켜서
스택 프레임에 공간을 할당한다.
이렇게 하면
Local variables로 명명된 스택 프레임의 일부분이 생겨난다.
앞은 인수로서, 매개변수(paremeter)이며, 지역 변수는 단순히 그 함수 내에서 선언된 변수를 말하는 것임.
그러니까, 앞은 함수 호출 시 인자들이 임시로 저장되는 공간이고,
뒤는 함수 내에서 선언되어 함수 내에서만 유효한 변수를 가리킴.
전자에 저장된 값들이,
함수 내부의 지역 변수나 매개변수에 할당되어 사용됨.
...
대강,
rsp를 16 subq 시켜
스택 공간을 할당하고,
mov로 인자들을 rsp에 따라 할당하고,
leaq로 그 두 인자를 전달할 때의
&인자의 유효주소를 계산하고,
movq로 그 주소를 레지스터로 이동시킨다음에,
call로 swap_add함수(임의의명의 함수)를 불러서
그 계산이 끝났을테니 mov로 리턴값을 받아와서,
sub으로 빼는 연산과(정말 함수내 빼기 연선)
imul로 곱연산을 진행하고
add를 마지막으로 스택 포인터를 움직여서
스택 저장소를 반납한다.
ret으로 끝!
흠
이후의 복잡한 예제는
mov로,
본래의 인자만큼의 지역 변수 저장소를 만들었다가,
call로 넘겨주기전에,
lea와 mov를 반복하여 인자를 전달하기 위해 준비한다.
call이후에는 mov로 인자를 받아와서 필요한 계산을 한다.
sub로 저장소 할당, add로 저장소 반납! 기억해두자!
프로그램 레지스터는
프로시저들이 모두 공유하는 단일 자원이다.
대개 프로시저 P를 수행하는 중이면,
다른 건 안 하지만,
프로시저 Q를 호출하면,
프로시저 P의 기존값을 보존한다.
Q는 기존의 값 자체를 변경하는 게 아니라,
원래의 값을 보존해서 스택에 푸시하고,
이 값을 변경한다.
이후 이 값을 pop하여 리턴한느 것이다.
그래서 이 부분을 Saved registers라고 한다.
이때,
스택 포인터를 제외한 모든 레지스터는
호출자-저장 레지스터로 구분된다.
함수에 의해 변경될 수 있다는 것을 의미한다.
(즉, 프로시저 P의 입장에서,
이 지역 데이터는 레지스터에 보관 중이나,
프로시저 Q에 의해 변경될 수 있다는 입장에서의 용어다.)
예제는...
......
처음에 베이스 포인터를 기억하기 위해 %rbp에 push,
혹시 무슨 일이 생기면 원래의 값을 기억하기 위해 %rbx를 push,(이후 다른 함수가 변경하더라도 이걸로 복원가능),
스택 포인터를 sub으로 할당하고,
아직 안 쓰고 보존은 해야하는값(즉 인자로 전달하진 않지만 나중에 써야하는 값)은 rbp에 mov로 저장하고,
이후 쓸 인자를 Mov로 전달한뒤,
call하고,
결과를 rbx에 저장해둔뒤,
rbp에서 쓸 값을 Mov로 가져오고,
또 call하면,
마지막 연산을 바로 진행한뒤,
add로 지역 저장소를 반납하고,
썼던 rbx와 rbp는 pop으로 꺼낸 뒤에 (단, 저장때와 반대순서로 pop.)
ret.
간단히 말하자면,
기억해야하는 변수가 있다면,
push로 저장할 공간을 스택에 남겨두고,
그 공간이 연산이 끝나고 바뀌면,
pop으로 다시 꺼내오는 것이다!
각 프로시저 콜은
자신만의 사적 공간을 갖고,
지역 변수들은 서로 간섭하지 않는다.
관습적으로 호출 결과는 %rax에,
인자 n은 %rbx에 보관될 것이다.
C는 배열 원소들에 대한 포인터를 만들고,
포인터 간에 연산을 할 수 있다는 특이한 점이 있다.
자료형 T, 정수형 상수 N이라고 할때.
T A[N];이라고 할떄
시작 위치를 xa라고 하면,
이 포인터의 값이 xa인 것이다.
배열의 각 원소는 0에서 N - 1사이의 정수 인덱스를 사용해서
접근할 수 있다.
배열의 원소 i는
주소 xa + L * i에 저장된다.
예제로,
char A[12]는 12개의 단일 바이트가,
int c[6] 은 4바이트짜리 가 6개가,
char *B[8]이든 double *D[5]는 둘다 포인터라 8바이트가 배정된다.
예를 들어,
E[i]를 계산한다고 하자.
E는 레지스터 %rdx에, i는 %rcx에 저장된다.
그러면, 주소 계산을
xe + 4i를 수행해서,
메모리 위치를 읽어서 그 결과를 %eax에 저장한다.
인스트럭션으로는 이렇게 쓰인다.
movl (%rdx,%rcx,4), %eax
C는 포인터 간 연산을 허용하고,
계산 되는 정도는 참조하는 자료형의 크기만큼 커진다.
예를 들어,
p가 자료형 T의 데이터에 대한 포인터라면,
p의 값을 xp라고 한다면,
수식 p+i는 xp + L * i 가 된다.
단항연산자(unray) &와 *는
포인터 생성, 역참조를 수행한다.
즉,
&Expr은 Expr의 주소고,
AExpr이 Expr의 주소라면 *AExpr은 그 주소에 위치한 값을 준다.
그래서
Expr = *&Expr이다.
배열참조 A[i]는
식 *(A+i)와 동일하다.
그래서...
정수 배열 E라고 할때
E는 그 자체로 포인터. xe.
E[0]은 그 배열의 포인터 기준 시작점. M[xe]
E[i]는 그 배열의 포인터 기준 시작점에 i만큼, 포인터에 자료형 크기만큼 더함.M[xe + 4i]
&E[2]는 E[2]의 유효주소. xe + 8
E+i-1 은 그 자체로 포인터에 자동으로 자료형크기만큼 i가 빠진다. 1도 자료형 크기만큼 빠짐. xe+4i-4
*(E+i-3)은 안쪽에 있는 주소만큼의 값. M[xe +4i -12]
&E[i] -E 는 배열의 주소에 i만큼 더한것에서 다시 E를 뺐으니 i.
...
특히 마지막은
자료형 자체가 달라진다.
어셈블리에서 E까지는 mov로 뜨지만,
그외 연산하는 주소는 leq로 처리한다는 것을 참조.
마지막은 또 mov 처리됨...
배열의 할당과 참조는
배열의 배열을 생성할 때도 적용된다.
예를 들어,
int A[5][3]은
typedef int row3_t[3]
row3_t A[5]
와 동일하다.
row3_t를 세 정수의 배열로 정의한 후,
A는 다섯 개의 배열을 원소로 가진다.
배열의 원소들은
메모리에 행 우선(row major)으로 저장되는데,
A[0] 원소가 다 저장된뒤에 (+4 +8 +12...)
A[1] 원소들이 이어서 저장되는 것을 뜻한다. (+16 +20...)
이는 다중선언의 결과다.
컴파일러는
이를 위해 원소의 오프셋을 계산하는 코드를 생성,
배열의 시작을 기본 주소로,
오프셋을 인덱스(배율 적용 여부 있음)로 하는,
mov 인스트럭션을 사용한다.
예를 들어,
T D[R][C];
에서,
배열 원소 D[i][j]의 메모리주소는,
&D[i][j] = xp + L(C*i +j)
이다.
A[i][j]를 구한다고 할때,
어셈블리어에서는
lea 첫번째로 앞을,(i)
lea 두반째로 뒤를(j)
계산하여
마지막 mov로 계산한 주소로 값을 불러왔다.
(C에서 고정적인 상수를 사용해야할때
#define N 16 등으로 변수, 상수로 정의해서
이후 16을 써야할때 N을 쓰는게 정말 좋다고 설명하고 있음)
고정 크기의 배열에서는
다양한 최적화가 생기는데,
result += A[i][j] * B[i][k]; 에서
정수 인덱스 j를 제거하고 모든 배열 참조를 포인터 역참조로 변환한다.
진행은 이렇다.
그래서 이걸 이렇게 한다.
for (j = 0; j < N; j++)
result += A[i][j] * B[j][k];
result += *Aptr * *Bptr;
Aptr ++;
Bptr += N;
흠...
(Aptr+1)*(Bptr+N)이 다음 거라...
아 Bptr은 행이 바뀌는거라 N만큼 움직여야하는군!
....
....
뭐 잘모르겠군
코드의 가독성을 떨어뜨리지 않으면서 성능향상을 이끌어낼수 있다네.
C에서는 역사적으로
가변크기 배열은 지원하지 않아서,
배열을 할당할때의
배열 숫자 대신 수식을 넣는 방법을 지원했다.
int A[expr1][expr2]
같이 지역변수나 함수의 인자로 선언할 수 있는 것이다.
예를 들어,
int var_ele(long n, int A[n][n], long i, long j) {
return A[i][j];
}
를 하면,
어셈블리 어에서는
imul로 n 크기 만큼의 i를 계산하고,
lea로 위에 계산한 값으로 포인터 유효 주소를 계산해서,
mov로 그 값으로 값을 불러온다.
고정크기 배열은 그냥 imul말고 lea를 쓴다는 점만 빼면
유사하다.
별개로...
고정 크기 배열과
어셈블리 어의 유형은 좀 달라도
그냥 컴파일러 선택에 의한 것 뿐이다.
단, 이때 루프 변수 j를 유지하는 것은
(배열 A와 배열 B의 각각 움직이는 그값)
루프가 종료했는지를 감지하기 위함이다.
구조체 struct와
공용체 Union이 있다.
구조체의 모든 요소들은,
메모리의 연속된 영역에 저장된다.
(간단히 말하자면,
구조체의 요소들은 영역적으로 연속하여 저장된다는 것이다.)
전체 자료의 크기는
할당한 자료형들의 크기의 합만큼 할당된다.
(int가 두개면 4+4...)
포인터 역시 첫번째 바이트 주소를 가리킨다.
그렇게, 각 자료형마다
적절한 offset(주소 단위)을 계산하여,
더하는 코드를 생성한다.
(다음 자료형으로 바로 이동하도록 계산)
(int는 4바이트만큼 이동하고...)
예를 들어,
rec 라는 구조체의 변수 r이
%rdi에 저장된다고 할때,
r을 i 필드로 얻어온다음,(mov)
i 필드에 있는걸 j필드에 저장해준다.(mov)
아마 필드 주소값만큼.....
즉 i필드의 주소값만큼 반영해서 주소를 계산하는 과정인듯...(자신없음)
만약 rdl rdi에, i는 rsi에 저장되어있고
&(r->a[i])를 계산한다면
lea로 두 저장 레지스터에 배율까지 해서 .....그렇다고 한다.
마지막 예제는 해독 불가.
공용체는
구조체와 달리
전체 자료형의 크기가
공용체 내에 가장 큰 자료형의 크기고,
포인터도,
자료형의 시작점이고 모두 동일하다.
하지만 대개 문제가 생기기 쉽기때문에
서로 다른 두 개의 필드를,
상호 배타적으로 사용하는 게 확실한 경우
전체 할당 공간이 줄어들기때문에 쓴다.
예를 들어,
내부 노드는 데이터 값을 안 가지고
왼쪽 오른쪽 자식 노드만 이어지며,
가장 끝의 노드는 데이터 값을 갖는다고 할때,
Struct로 하면 내부 노드에도
그만큼의 데이터가 할당되지만
Union으로 하면
data가 가장 큰 바이트를 차지할뿐
전체적인 할당 크기는 작다.
단, 이러면 리프 노드와
내부 노드가 구분이 불가해서
{태그필드 + 공용체}구조체
같은 형태로 복합적으로 만든다.
노드 타입(구조체)
는... : 타입 지정을 갖고
공용체 Union으로
internal 구조체를 갖고
그 구조체는 왼쪽 노드
오른쪽 노드
data는 여기 속한다
객체지향 그거랑 비슷하게생겼네...
정말이지 추억..
데이터는 정렬 제한을 갖는다.
(자료형마다,사용 가능 주소가 제한되게
K의 배수가 되도록 요구)
(간단히 말하자면,
주소 계산하기 쉽게 단위 맞추라는 얘기.)
이러한 설계 이유는
프로세서와 메모리 시스템과
하드웨어와의 설계를 단순화 하는데 용이하기 때문이다.
(예를 들어,
단위를 정하면
한번 주소에 갔을 때
그 주소에서 또 탐색할 필요가 없다.)
(8짜리에 전부 저장하면
4, 4로 나누어 저장했을 때보다
한번만 접근하면 됨.)
요즘은 알아서 해준다지만
intel은 여전히 데이터 정렬을 추천하고 있다.
이 과정은,
컴파일러에서 directive로
원하는 정렬로 표시하는 등으로 이루어진다.
.align 8
이라면, 다음 데이터가 8의 배수 주소로 시작하라는 얘기다.
이런 제한이 있으면
9바이트 짜리 struct를 연달아 저장하면
주소 단위가 달라져버리니까
0~4(i), 4~5(c), 5~9(j)에서
0~4(i), 4~5(c), 5~8(빈공간), 8~12(j)같이
시작 주소를 8단위로 ..
데이터가 있는 부분을 맞추는 것이다!
또 만약 단일로는 맞더라도
배열로 만들면 안 맞는 경우,
(4)(4)(1)이라
1의 시작지점은 8이 맞는데
두개를 연달아 만들면
(4)(4)(1)(4)(4)(1)
이 되면 세번째 4가 시작 지점이 맞지 않기때문에,
(4)(4)(1)(3-빈공간)(4)(4)(1)
식으로 정리해주는 것이다!