[Week3] CSAPP 3

안나경·2024년 1월 26일

크래프톤정글

목록 보기
22/57

도입

기계어 코드와 어셈블리 코드를 배우는 이유는?

최적화 성능,
즉 어떻게 해야 최적화 되고,
왜 최적화 되는지 알기 위함이다.
(동시에 어떤 것이 비효율적인지 알아볼 수 있다.)

예를 들어,
쓰레드 프로그램의 구성을 파악한다든가,(시스템)
시스템 감염 시 런타임 제어 정보를 어디에 저장한 것을 파악했는지, (보안)

에 쓸 수 있다.

그래서 3장에서 할 것은...

  1. 어셈블리어 배우기.
    실행 순서를 조정하고,
    불필요한 계산을 제거하고,
    느린 연산을 빠른 연산으로 바꾸거나,
    재귀를 반복으로 바꾸면서 최적화를 할 수 있게 될 것이다.
    (이런 걸 알아보는 것을 역엔지니어링이라고 하며,
    C, 어셈블리, 기계어 간의 관계를 파악할 수 있다.)
  2. 데이터 표현, 처리, 제어를 어떻게 구현할 것인지
    알아볼 것이다.
    특히 C언어의 if, while, switch가
    어떻게 돌아가는지를 알아볼 것이다.
  3. 프로시저.
    지역변수와 프로시저 사이의 런타임 스택이 어떻게 구현되는지 확인할 것이다.
  4. 배열, 구조체, 유니온을 기계수준으로 구현.
    하면서, out-of-bound, 메모리 참조에서 생기는 문제와,
    버퍼 오버플로우로 생기는 시스템의 취약성의 이유를 알 수 있다.
    또 GDB 디버깅의 팁도 적어둘 것이다.
  5. 부동 소수점 데이터, 연산, 관련 코드를 볼 것이다.

3.1 역사적 관점

대충 기존에 많은 기능은
이전 버전이랑 호환하기 위해 남은
전유물.....이라
역사를 알아두면 왜 이런 이상한 기능이 있는지....
를 알 수 있다고 하지만, 일련의 나열들이라 생략.
(애초에 그런 이상한 기능이 뭐가 있는지도 모르겠고.)

3.2 프로그램의 인코딩

최적화 정도를 높이면,
컴파일 과정에서 많은게 바뀌기 때문에,
실제 실행 파일에서는 원래의 소스코드를 파악하기 어렵다.
(또, 그렇기에 디버깅 도구를 적용하기도 어렵다.)

그래서 컴파일시 -0g 옵션을 붙여서
좀 더 알아볼 수 있는게 남아있는 어셈블리 파일을 확인할 것이다.

3.2.1 기계 수준의 코드

기계 수준의 코드를 이해하는데에는 두 가지의 추상화가 있다.

  1. ISA(instruction set architecture)
    인스트럭션 집합 구조.
    실제로는 모든 연산을 분리하여 실행하나,
    ISA에 의해 그러한 연산들이 순서대로 실행되어
    전체 동작처럼 보이게 해준다.
  2. 가상 주소.(추상화 일종)

또, 프로세스의 상태는 이러한 요소들로 이루어져있다.

  • PC
    다음에 실행할 메모리 주소를 가리킴.
  • 정수 reg
    주소(ptr같은 역할), 정수 데이터 저장.
    예를 들어, 중요 상태를 추적하거나 임시값을 저장한다.
  • 조건코드 reg
    최근 산술/논리 인스트럭션의 상태를 저장한다
    예를 들어, if-while시 제어, 조건에 의한 변경을 구현한다.
  • 벡터 reg
    하나 이상의 정수나, 부동 소수값을 저장한다.

또, C는 아예 데이터 타입에 따라 메모리를 할당하지만,
기계어는 메모리는 단순히 주소를 저장 가능한 배열일뿐, 데이터 타입에 대한 구분이 전혀 없다.

기계어에서 나오는 가상 주소는
OS가 프로세서 메모리에 주소값 형식으로 번역해준 것뿐이다.

3.2.2 코드 예제

-s로 컴파일시 어셈블리어 파일로 만들 수 있다.

그렇게 보면 나오는,
어셈블리 파일에서

pushq %rbx

라는 것은
레지스터 %rbx가 프로그램스택에 push 되어야한다는 말이다.

또, 이렇게 컴파일 시부터 어셈블리 파일로 만드는 것 말고,
기계어를 어셈블리로 만드는 것도 가능한데,

이를 역어셈블리라고 하고,
(정확한 어셈블리가 나오는 건 아니지만, 유사한 코드 출력)

objdump -d (파일명).o

...등으로 가능하다.

어셈블리어의 특징은

  • 자주 사용하는 인스트럭션의 길이는 짧게 설정.
  • ?
  • 바이트 순서에만 의존하여 어셈블리어 코드를 결정.
  • GCC의 어셈블리와는 약간 다른 명령어를 가짐.
    (예를 들어, 접미어 'q'는 크기를 나타내서 대부분 생략되어도 되는데, 역어셈블리에서는 call, return같은것 끝에 붙여준다.)

3.2.3 형식

어셈블리 코드에서,
'.'으로 시작하는 명령어는
어셈블리와 링커에 지시하기 위한 directive로
대부분 확인할 필요 없다.

책에서는 앞으로 예제 코드를 어떤 식으로 보여줄지
설명하고 있다.

3.3 데이터의 형식

데이터 단위

  • word : 16bit
  • double word : 32bit
  • quad word : 64bit

데이터 타입에 따른 단위

  • int : 32bit
  • pointer : 64bit
  • 부동소수 : 단일 정밀이면 4byte, 이중 정밀이면 8byte
    x86은 부동소수를 80bit로 구현하나 호환도 안 되므로 생략.

GCC -> 어셈블리 코드
에는, 오퍼랜드의 크기를 나타내는 단일문자접미어가 붙어있다.

예를 들어, 이동 인스트럭션은 여러개지만,
데이터 크기에 따라 여러개일 뿐이다.
ex) movb(바이트 이동) movw(워드 이동...)

movl은 더블워드인데 l인 이유는
더블 워드의 32비트가 long word와 같기때문.

또, 접미어 l은 경우에 따라 정수에 붙거나 이중 정밀 부동 소수에 붙지만, 인스트럭션 집합이 달라서 헷갈릴 일은 없을 것이다.

3.4 정보 접근하기

CPU는 16개의 범용 레지스터를 갖고 있다.

범용 레지스터
정수와 포인터를 저장 하고, %r로 시작한다.

본래는 ax...등으로 시작하여 용도별로 레지스터를 나눴었는데, bit단위가 증가하면서 ax, eax(32bit), rax(64bit) + (8개의 레지스터로 r8 ~ r15까지)
로 이루어져있다.

대개 연산은
남는 자기 연산 크기에 따라 가장 덜 중요한
남는 바이트에 접근하여 처리한다.

(ex) 16bit 연산은 2byte에 접근... 32bit 연산은 4바이트에 접근...)

1, 2, 4, 8 바이트 처럼 작은 연산을 할때는
남는 바이트를 어떻게 처리할지가 관건이다.
대개 1, 2바이트만 남으면 그대로 고수한다.(관습적)

? %rsp?

스택 포인터다. 런타임 스택 겉부분을 가리킨다.
일부 인스트럭션만 이걸 쓴다.

나머지는 자유로운 편이고,
인스트럭션에 따라 쓰는 레지스터가 정해져있을 수는 있다.

이러한 레지스터의 이동으로,
스택 관리, 함수 인자 전달, 함수값 리턴, 로컬과 임시 데이터 저장의 메커니즘을 살펴볼 것이다.

3.4.1 오퍼랜드 식별자(specifier)

인스트럭션은....
1개 이상의 오퍼랜드로 이루어져있다.

오퍼랜드는

  • source(연산을 수행할 값)
  • destination(결과를 저장할 목적지 위치)
    (레지스터나 메모리에 저장)

로 이루어져있다.

오퍼랜드 형태는 여러가지인데,
대표적으로 세 가지가 있다.

  1. immediate(상수값)
    ATT 형식 어셈블리에서는, $(상수) 형식으로 정수를 뒤에 적는다. -577이나 0x1f형식을 쓴다.
    서로 다른 인스트럭션에 따라 다양한 범위의 상수값을 사용한다.
    어셈블리는 그 중 가장 컴팩트한 방법을 채택한다.
  2. register(레지스터)
    레지스터의 내용을 나타내며,
    레지스터의 8, 4, 2, 1 바이트 중 하나를 가리킨다.
    표기는 임의의 레지스터 a일때 r(a)<아래 첨자 a의 경우
    배열 R이 임의의 레지스터 a에 저장되었을때 R[ra]등으로 표기.
  3. 메모리 참조, 유효 주소.(effective address)
    유효 주소에 의해 메모리 위치에 접근한다.
    메모리는 거대한 바이트의 배열인데,
    메모리주소가 Addr이라고 할떄,
    Mb[Addr]이라고 하면 메모리 주소 Addr의 저장된 b바이트를 참조했다는 내용이다.
    간소화를 위해 보통 b는 생략된다.

? 여러 가지 유형의 메모리 참조?

lmn(ra,ri,s)
(뒤에 붙은건 아래 첨자) 는
lmn : 상수 오프셋
ra : 베이스 레지스터, 64bit
ri : 인덱스 레지스터, 64bit,
s : 배율(1,2,4,8)

로, 유효 주소를 계산시 lmn+R[rb]+R[ri]*s
식으로 계산할 때 사용한다.

3.4.2 데이터 이동 인스트럭션

가장 많이 쓰는 인스트럭션으로,
데이터를 다른 위치에 복사한다.

소스와 대상 타입에 따라 이동 명령이 갈린다.
가르는 것은 대개 두 가지다.

  • 수행하는 변환
  • 부수 효과

예를 들어,

-MOV 클래스

소스를 목적지에 단순히 복사한다.
다른 데이터 크기에 따라 접미어가 다르다.

소스 오퍼랜드는

  • 상수
  • 레지스터 저장값
  • 메모리 저장값

으로 이루어지고

목적 오퍼랜드는

  • 레지스터
  • 메모리 주소 위치 지정

으로 이루어져있다.

데이터 이동 시, 소스나 목적지 둘 다 메모리에 저장되진 않는다.

그래서 이동 시 쓰는 건 두 개의 인스트럭션이다.

  1. 소스값을 레지스터에 적재.
  2. 이 레지스터를 목적지에 사용.

넣는 레지스터의 크기는 인스트럭션 마지막 문자의 크기와 일치해야한다.

단 특이하게도 movl은 레지스터가 목적지다.

-MOVZ, MOVS 클래스

작은 소스값을 더 큰 목적지로 복사한다.
movz는 목적지의 남은 바이트를 모두 0으로 채우고,
movs는 소스 오퍼랜드의 가장 중요한 비트를 반복해서 복사, 부호 확장으로 채운다.

이 때 뒤에 붙는 마지막 두개의 문자가
데이터 크기를 나타내는데

앞이 소스의 크기(1~2byte)고, 뒤가 목적지의 크기(2~4byte)다.
당연히 목적지의 크기는 항상 소스의 크기보다 크다.

이 클래스는 3개의 인스트럭션을 갖는다.

? cltq?

레지스터를 지정하여, 항상 %eax -> %rax로 부호확장으로 옮기고 다른 명령어로 써도 똑같지만, 더 압축적인 인코딩을 보장한다.

3.4.3 데이터 이동 예제

프로시저가 실행 되면,

long x = xp;
xp = y;
return x;

라고 할때 어셈블리어는

movq (%rdi) %rax
movq %rsi (%rdi)
ret

로 대응된다.

첫번째 줄은 메모리에서 레지스터로 읽어 들이는 것이고,
두번째는 레지스터에서 메모리로 쓰는 코드다.

어셈블리 코드의 특징은

  1. c 언어에서의 포인터는 어셈블리에서는 단순히 주소다.

  2. x같은 지역 변수는 메모리보다 종종 레지스터에 저장된다.\

? 포인터 예제?

long x = xp;
xp로 표시되는 위치에 저장된 값을 읽어서 지역변수 x에 저장한다. (이를 역참조, dereferencing이라고 함)
xp = y;
매개변수 y값을 xp가 지정하는 위치에 쓴다.
&a
지역 변수 a를 저장하고 있는 위치의 주소를 생성한다.

3.4.4 스택 데이터 저장과 추출, push, pop

프로그램 스택에

  • 데이터 저장을 Push
  • 데이터 추출을 pop
    으로 하며,

프로그램 스택은 프로시저 호출 처리에 중요하고,
후입 선출을 따른다.

이 때 쓰는 것은 스택포인터, %rsp로 스택 맨 위의 원소 주소를 저장하고 있다.

popq, pushq는 1개의 오퍼랜드를 사용하며,
popq는 추출을 위한 데이터 목적지를,
pushq는 추가할 소스 데이터를 갖고 있다.

원리적으로는
push는 넣을 데이터값많은 스택 포인터를 -시켜
그 스택 주소에 새로운 값을 넣고,

pop은 스택 포인터를 + 시켜 꺼낸다....

3.5 산술연산과 논리연산

연산은 크게 네 종류가 있다.

  • 유효주소 적재
    lea...
  • 단항(unray)
    1개의 오퍼랜드를 가짐
    INC...
  • 이항(binary)
    2개의 오퍼랜드를 가짐.
    ADD...
  • 쉬프트
    S~ 로 시작.

3.5.1 유효주소 적재(Load Effective Address)

lea는 사실 mov의 변형이다.

메모리에서 반드시 레지스터로 복사하며,
메모리 위치도 그 위치에 있는 값을 읽는 게 아니라,
그 메모리 위치의 유효주소를 목적지에 복사한다.

(즉, 포인터로 치자면)
(포인터 참조를 안 붙이고 포인터 주소를 가져오는것.)

lea의 용도는,

  • 포인터 생성
  • 주소 연산자 사용
  • 유효주사와 무관해도 산술 연산 시 사용.

예제로는 단순 산술 연산을
lea로 계산하는 예제를 보여주고 있다.

3.5.2 단항 및 이항 연산

단항1개의 오퍼랜드를 가진다.
그 1개의 오퍼랜드에 바로 적용하며,

오퍼랜드는
레지스터나 메모리 위치가 자리하게 된다.

예를 들어,
incq (%rsp) 는
스택에 8byte++를 하는 결과로,
일종의 ++ 연산자와 비슷한 역할을 한다.

이항은 소스와 목적지가 반대로 되어있다.
(이항이 소스 목적지 순인데 보통 반대인가봄.)
목적지에는 reg나 메모리 위치가 들어가고,
소스에는 상수, reg, 메모리 위치가 들어가는데,

소스, 목적지 둘 다에 reg와 메모리가 속하진 않는다.

소스, 목적지 순으로 나와있어
다른 연산자와 같은 순이 아니라서
비교환성(noncommunatative)을 가진다고 한다.

예를 들어,
역할은 x -= y 와 비슷하지만,
subq %rax %rdx
라면,
%rdx - %rax 가 실제 결과값이기때문에,
아무튼... 그렇다.

여기서
목적지가 메모리 위치면, 결과를 다시 메모리에 써야한다
라는 구절이 있는데,
이건 메모리 위치로 값을 가져왔어도,
계산한 값을 메모리 위치에 직접 다시 수정해서 넣어야한다는 뜻이다.

3.5.3 쉬프트 연산

쉬프트는
2개의 오퍼랜드로 이루어지는데,

  • 쉬프트할 크기
    (대략 이정도 쉬프트할 거라는 뜻이다.)
    값이나, 단일 바이트 레지스터 %cl이 들어가는데,
    특정 레지스터만 허용한다는 점이 특이하다.
  • 쉬프트 할 값
    레지스터나 메모리 위치가 들어간다.

쉬프트는 산술 연산으로도, 논리형 우측 쉬프트로도 쓰인다.

1바이트의 쉬프트로는
2^8 -1 = 255 의 데이터 길이를 수용할 수 있다.

그래서 대개
하위 m비트(그러니까 남길 값)으로 데이터 길이를
w비트 길이 데이터 값을
2^m = w
만큼 옮긴다고...
(잘 모르겠군, 느낌은 알겠지만 정확히는 모르겠어.)

특이한 건
좌측 쉬프트 SAL, SHL은 동일한 효과로 우측을 모두 0으로 채우지만,

우측 쉬프트 SHR논리 쉬프트,
SAR은 산술 쉬프트로, 부호 비트를 복사하여 확장한다.

3.5.4 토의

대부분 인스트력선은
비부호형, 2의 보수 산술 연산에 사용되지만,
우측 쉬프트부호형, 비부호형 데이터에 따라
데이터를 구분하는 인스트럭션을 요구한다.

(왜냐하면,
우측 쉬프트는
값을 우측으로 옮기는 것인데,
그러면 부호형의 경우
마이너스는 맨 앞의 1로 나타나기때문에
부호인지 아닌지가 중요함.)

(그래서 부호라면 -라면 맨 앞을 1로,
+라면 맨 앞을 0으로 채운다.)

3.5.5 특수 산술 연산

흠 뭔 소리인지 모르겠군

3.6 제어문

반복문과 스위치문은,

시험을 하고,
그 결과에 따라 실행한다.

대개 모든 인스트럭션은
순차적으로 실행되지만,
점프(Jump) 인스트럭션으로 변경이 가능하다.
(일부분에 제어를 넘기는 인스트럭션이다.)

3.6.1 조건 코드

단일 비트 조건 코드로 구성된
레지스터를 운영한다.

조건부 분기를 확인하기 위한 플래그가 네개가 있는데
(조건이 주어지면, 그 조건의 결과를 파악하는 플래그.)
(모두 가장 최근 연산 결과에 따라 표시하는 것이다.)

  • CF : carry flag,
    가장 중요한 비트로부터 받았는데 올림이 발생 시.
    (비부호형의 오버플로우를 감지.)
    (잘 모르겠는데 a>b같은거 감지하는거같고.)
  • ZF : zero flag
    가장 최근 연산 결과가 0일 시 표시.
  • SF : Sign flag
    음수라면 표시.
  • OF : Overflow flag
    양수, 음수의 2의 보수 오버플로우 발생 표시.

예를 들어,
t = a + b의
ADD 인스트럭션을 쓴다면,

각 플래그는 이런걸 판단할 것이다.
CF : t < a
ZF : t == 0
SF: t < 0
OF : (a < 0 == b < 0 ) && ( t < 0 != a < 0 )

lea는 조건 코드를 변경하지 않지만,
그 외는 전부 변경이 가능하다.

(이후 예제로는 특정 연산자에 따라
각 플래그가 어떻게 나와야 판단하는지 설명하고 있다.)

이 때,
레지스터는 변경하지 않으나
조건 코드만 변경해주는
두 개의 인스트럭션 클래스가 있다.

  • CMP
    오퍼랜드 차에 따라 실행.
    (sub와 같은 방법으로 동작하나, 조건 코드만 변경.)
    (두 오퍼랜드 차이가 같으면, OF를 1로 바꿔준다든가.)
  • TEST
    AND와 같이 동작.
    (역시 조건 코드만 변경)

이 클래스는 오퍼랜드가 반복되어 나오는데,
이 중 하나는 시험할 비트 마스크이다.
(하나는 실험용이라는 것 같다.)

3.6.2 조건 코드 사용하기

조건 코드를 사용하는 세 가지 방법이 있다.

  • 조합에 따라 0이나 1을 한 개의 바이트에 기록.
  • 조건에 따라 프로그램 다른 부분으로 이동.
  • 조건에 따라 데이터를 전송.

SET 인스트럭션

SET은 특이하게,
서로 다른 접미어가 데이터 크기가 아니라,
다른 조건 코드 조합으로 서로 다른 동작을 처리한다.

SET의 접미어
오퍼랜드 크기(x)
조합 종류(o)

예를 들어,
setl : set less, setb : set below...다.
(크기가 아니라는 말.)

그리고 목적지 오퍼랜드

  • 하위 단일 바이트 레지스터 중 한 개
  • 단일 바이트 메모리 주소
    (0이나 1로 기록)

가 들어간다.

예를 들어

cmp로 비교 후
set 으로 조건 코드를 설정하고,(오퍼랜드가 역순이라는 부가설명)
mov 로 조건코드에 따라 값을 남기고 지운다.

? 유사어

setg는 크면 1 저장, setnle는 작거나 동일하지 않으면 1 저장인데,
사실 완전히 같은 동작이라, 컴파일러가 랜덤으로 설정한다.

3.6.3 점프 jump 인스트럭션

점프 인스트럭션은
새로운 위치로의 실행을 선언한다.

목적지는 레이블 label로 표시하는데,
jump .L1 식으로 쓰여있는 식이다.

이러한 label이 있어야 하기 때문에,
점프 목적지(jump target)를 인코딩 해야한다.

점프는 목적지에 따라 두 가지가 있다.

  • 직접 점프 : 목적지가 인스트럭션 일부 일 때.
  • 간접 점프 : 레지스터나 메모리 위치로부터 점프 대상을 읽는 경우.

간접 점프는 앞서 나왔 듯이, 식별자 *나 메모리 오퍼랜드를 참조한다.

예를 들어
jmp *%rax
는 rax가 목적지지만,
jmp *(%rax)
는 읽은 다음에, 그 안에 목적지로 이동한다.

그 외에는 조건부 점프라는 게 있는데,
조합에 의해 결정하여 SET과 유사하지만
직접 점프만 가능하다.

3.6.4 점프 인스트럭션 인코딩

실제로 인코딩한다기 보다는,
링커 공부 시 중요한 파트고,
역어셈블어 결과 해석 공부에 도움이 된다.....
(할 일은 별로 없을 거 같지만)
(인생은 모르는 거니까)

인코딩은 방법이 크게 두 가지가 있다.

  • PC 상대적 (PC relative)
    점프 인스트럭션과 대상 인스트럭션 주소의
    차이를 인코딩한다.
    그래서 그 차이만큼 1, 2, 4byte까지 인코딩 됨.
    (특징은 점프 다음 인스트럭션 주소를 쓴다는 건데)
    (목적지 말고 점프 인스트럭션 주소 자체를 안 쓴다는거임)
    (그건 실행시 포인터를 바로 갱신하는 관습때문이라고함)

  • 절대 주소 제공
    4byte를 항상 쓴다.

선택은 어셈블러와 링커가 눈치껏 해준다.

그 외 점프는

  • 루프 구조
  • 조건부 실행(if)

을 구현한다.

3.6.5 조건부 분기를 조건 제어로 구현하기

조건부 수식 문장을,
기계어로 번역할 때,

대개 일반적인 방법은

  • 조건부 + 무조건 점프 함께 사용
  • 일부 조건문은 데이터 이동을 통해 구현

실제로는 워낙 비효율적 코드라 추천 되지 않으나,
코드 제어 흐름을 보여 주기에 용이한 goto 코드를 예제로 쓴다.

(C에서는 조건부 문장 처럼 보이지만)
(어셈블리 수준에서는 조건이 생기면)
(아예 별도의 코드 블록을 만들고)
(jump로 블록을 이동 시킴)

3.6.6 조건부 이동으로 조건부 분기 구현하기

데이터 조건부 전송이라는 방법도 있다.

산출물을 모두 계산하는데,
(조건이 어떻든 코드 전부 계산)
조건에 따라 결과를 하나만 선택하는 것이다.

이 때 쓰는
조건부 이동 인스트럭션move다.

(일단 모두 연산한다음)
(테스트, cmp든 뭐든 한다음)
(cmovge 로 조건 코드에 따라 값 할당.)

? 왜 조건부 데이터 이동이 최신 체제에서 우수한가?

순차적이어도,
연속된 인스트럭션은 중첩하여 계산하여
고성능을 보이는데,

분기가 있으면
분기 계산이 끝나기 전까지 예측이 불가하기 때문에,
미리 계산할 수가 없다.

또, 대개 분기로 나누어질때는
분기 예측 회로를 쓰는데,
예측이 쉬우면 일정하게 8클럭 정도지만,
랜덤이면 17.5 클럭, 어려우면 19클럭 정도 소모하지만,

조건부 이동을 쓰면
항상 8클럭 정도로 해결이 가능하기 때문이다.

....

조건부 이동 move

2개의 오퍼랜드를 갖는다.

  • 소스 reg 또는 메모리 위치 S
  • 목적지 reg R

크기는 10~32~64비트 정도이며,
목적지 reg는 명시된 조건이 만족될 때만 복사된다.

대개 목적지 reg 만으로 오퍼랜드 길이 예측이 가능해서,
move하나로 모든 오퍼랜드 길이에 호환이 가능하다.

...

물론 데이터 이동이라고 항상 좋지는 않다.
연산 자체가 많으면 데이터 이동이 비효율적이거나,
아니면 다 계산하다보니 조건이 안 되는데 계산하면서 오류가 생기기때문이다.

실제로,
컴파일러는 두 수식이 매우 간단한 경우에만 Move를 쓰고,
실제 분기 예측이 틀렸을 때의 비용이 크더라도 그냥 분기 이동을 쓴다.

3.6.7 반복문

C언어에서는
do-while, while, and for 등의 반복문이 있다.

조건부 테스트 + 점프로
반복문 역시 구현할 수 있다.

Do - while 반복문

대개

do body statement
while (test-expr)

이면, test가 성립하면 반복을 수행하는데,
구조상 적어도 1번은 실행하게 되어있다.
이걸... loop와 점프로 쓰면,

loop: 
	body statement

t= test-expr
	if(t)
		goto loop

가 된다.
매 실행마다 본체 문장과 테스트 수식을 계산한다.

while 루프

while (test-expr){
body statement
}

두 가지 방식으로 인코딩 된다.
중간으로 점프조건형-do 이다.

중간으로 점프

goto test
loop:
	body statement
test:
	t = text-expr
	if(t)
		goto loop

조건형-do

t = test-expr
if(!t)
	goto done
loop:
	if(t)
		goto loop
done:

여기서 n>1은 n != 1 처럼 자동 변환된다
하고 신기해하고 있음

for 반복문

for(init;test;update)
	body statement

는 이것과 같은 말이다

init;
while(test){
	body statement
	update
}

중간으로 점프

init
goto test
loop:
	body statement
test:
	if(t)
		goto loop;

조건형-do

init
if(!t)
	goto done
loop:
	if(t)
		goto loop
done:

3.6.8 Switch문

정수 인덱스 값에따라,
다중 분기 기능을 제공한다.

테스트에 대한,
경우의 수가 많을 때 유용하다.

점프 테이블이라는 걸 쓰는데,
원소 i가
switch문 인덱스 i라면,
그 인덱스 i의 코드블록 주소를 담은 배열로 이동한다.

찾기 위해 배열처럼 참조하는 것이다!

if-else 다단계보다, switch를 쓰면
케이스 수에 관계없이 처리가 가능하다.

그 외에는 이러한 경우가 있다.

  • 레이블이 연속적 범위에 분포하지 않은 경우.
  • 다중 레이블이 있는 케이스.
  • case가 다른 case 내부에 들어있음.
    (이 경우는 case가 break로 끝나지않기때문이라는데..??)

(아무튼,
점프 테이블에는,
코드블록 주소가 레이블 형태로 들어가있고,
&&로 시작하고,
jt 원소로 표시된다는 것이다.)

3.7 프로시저

프로시저는...

인자와,
특정 기능 구현 코드,
리턴 값

전반을 통틀어 감싸서 한 추상화다.

다른 언어에서는
함수, method, 서브루틴... 등의 형태로 나타난다.

프로시저에서는 대표적으로 세 가지 일이 일어난다.
프로시저 P가 프로시저 Q를 호출하고, Q를 실행한 다음 다시 P로 리턴한다고 하자.

  • 제어권 전달 : PC가 진입하면서 Q에 대한 코드의 시작 주소로 설정 되고, 리턴 시 P가 Q를 호출하는 인스트럭션 다음 인스트럭션 부터 리턴을 반영해야한다.
  • 데이터 전달 : p는 하나 이상의 매개변수를 Q에게, Q는 P에게 하나의값(딱 한개? 이상)을 리턴할 수 있어야한다.
  • 메모리 할당과 반납 : Q는 시작시 지역변수들을 위한 공간을 할당할 수 있고, 리턴할 때 이 저장소를 반납할 수 있다.

3.7.1 런타임 스택

C언어를 포함한 대부분의 언어는,

LIFO, 후입 선출로,
스택으로 프로시저들이 요구하는 저장 장소를 관리한다.

그리고 이 스택에,
제어, 데이터 전송, 메모리 할당에 관한 정보를 저장한다.

레지스터 저장 개수 이상
저장 공간이 필요할 때는 추가로 스택에 할당하기도 하는데,

이러한 프로시저마다의 구분을
추상화한 개념이 스택 프레임이다.

프로시저 P는 프로시저 Q에
최대 6개의 매개변수를 전달할 수 있고,
그 이상의 값은 프로시저 P의 스택프레임에 할당한다.

이후 return address, 리턴 주소를
스택에 push하는데,
이것까지 프로시저 p의 스택 프레임에 속한다고 간주한다.

3.7.2 제어의 이동

프로시저 P에서
프로시저 Q를 호출할 때는,

PC를 Q 코드 시작 주소로 설정한다.

이때 쓰는 명령어가

call Q인데, (정확히는 call이고 Q는 프로시저 Q를 가리킴)
이 인스트럭션은

  • 주소 A, 즉 리턴 주소(call의 바로 다음주소)를 스택에 푸시
  • PC를 Q의 시작으로 설정

한다.

ret은 반대로,

  • 주소 A를 스택에서 팝,
  • PC를 주소 A로 세팅

한다.

  • 역어셈블리에서는 callq, retq로 나오는데, 이는 x86-64버전임을 밝히기 위함이다.

call은 직접, 간접 형태 두 가지가 존재하는데
직접의 경우 레이블 자체를 제시하지만,
간접은 * 뒤에 식별자가 붙는 형태다.

3.7.3 데이터 전송

레지스터를 통해 일어난다.

프로시저 P의 인자를 레지스터에 복사하여 전달하며,
6 이상일 때는 7 ~ n까지 스택 프레임에 할당한다.

이 때, 매개 변수를 스택에 전달할 때,
모든 데이터 길이는 8의 배수로 반올림한다.

그렇게 배치되고 나서야,
프로시저 P는 프로시저 Q call 인스트럭션을 수행할 수 있다.

그러고 나면,
프로시저 Q는 레지스터와 스택을 통해,
자신의 인자들에 대해 접근할 수 있다.

이러한 공간을, Argument build Area라고 한다.

어셈블리에서는,
인자 6개까지는

  • rsp로 rax를 가리킨다
  • 순차대로 할당한다
  • 6개가 넘으면, 한번더 mov하여(여기선 movl을 하고 있다) ad를 한다.
    이때의 add뒤에 붙는 접미어는 자료의 크기에 따라 갈린다.

3.7.4 스택에서의 지역 저장 공간

대개 레지스터에 저장할 수 있는 것 이상의 지역 저장소를 쓰지 않지만,

때로는 지역 데이터가
메모리에 저장되어야하는 경우가 있다.

이러한 경우가 포함된다.

  • 지역 데이터를 모두 저장하기에는 레지스터 수가 부족.
  • 지역 변수에 연산자 &가 사용되었고, 변수의 주소를 생성할 수 있어야한다.
  • 일부 지역 변수들이 배열 또는 구조체여서, 이들이 배열이나 구조체 참조로 접근되어야한다.

대개는 스택 포인터를 감소시켜서
스택 프레임에 공간을 할당한다.

이렇게 하면
Local variables로 명명된 스택 프레임의 일부분이 생겨난다.

? Argument build area랑 Local Variable의 차이?

앞은 인수로서, 매개변수(paremeter)이며, 지역 변수는 단순히 그 함수 내에서 선언된 변수를 말하는 것임.
그러니까, 앞은 함수 호출 시 인자들이 임시로 저장되는 공간이고,
뒤는 함수 내에서 선언되어 함수 내에서만 유효한 변수를 가리킴.

전자에 저장된 값들이,
함수 내부의 지역 변수나 매개변수에 할당되어 사용됨.

...

대강,
rsp를 16 subq 시켜
스택 공간을 할당하고,
mov로 인자들을 rsp에 따라 할당하고,

leaq로 그 두 인자를 전달할 때의
&인자의 유효주소를 계산하고,
movq로 그 주소를 레지스터로 이동시킨다음에,

call로 swap_add함수(임의의명의 함수)를 불러서
그 계산이 끝났을테니 mov로 리턴값을 받아와서,
sub으로 빼는 연산과(정말 함수내 빼기 연선)
imul로 곱연산을 진행하고

add를 마지막으로 스택 포인터를 움직여서
스택 저장소를 반납한다.
ret으로 끝!

이후의 복잡한 예제는

mov로,
본래의 인자만큼의 지역 변수 저장소를 만들었다가,

call로 넘겨주기전에,
lea와 mov를 반복하여 인자를 전달하기 위해 준비한다.

call이후에는 mov로 인자를 받아와서 필요한 계산을 한다.
sub로 저장소 할당, add로 저장소 반납! 기억해두자!

3.7.5 레지스터를 이용한 지역 저장소

프로그램 레지스터는
프로시저들이 모두 공유하는 단일 자원이다.

대개 프로시저 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으로 다시 꺼내오는 것이다!

3.7.6 재귀 프로시저

각 프로시저 콜은
자신만의 사적 공간을 갖고,
지역 변수들은 서로 간섭하지 않는다.

관습적으로 호출 결과는 %rax에,
인자 n은 %rbx에 보관될 것이다.

3.8 배열의 할당과 접근

C는 배열 원소들에 대한 포인터를 만들고,
포인터 간에 연산을 할 수 있다는 특이한 점이 있다.

3.8.1 기본 원리

자료형 T, 정수형 상수 N이라고 할때.

T A[N];이라고 할떄

시작 위치를 xa라고 하면,

  1. L * N 바이트의 연속적 공간들을 메모리에 할당하고,(L은 자료형 T의 크기)
  2. 새로운 식별자 A를, 배열이 시작하는 위치의 포인터로 사용한다.

이 포인터의 값이 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

3.8.2 포인터 연산

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 처리됨...

3.8.3 다중 배열

배열의 할당과 참조는
배열의 배열을 생성할 때도 적용된다.

예를 들어,

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로 계산한 주소로 값을 불러왔다.

3.8.4 고정 크기의 배열

(C에서 고정적인 상수를 사용해야할때
#define N 16 등으로 변수, 상수로 정의해서
이후 16을 써야할때 N을 쓰는게 정말 좋다고 설명하고 있음)

고정 크기의 배열에서는
다양한 최적화가 생기는데,

result += A[i][j] * B[i][k]; 에서
정수 인덱스 j를 제거하고 모든 배열 참조를 포인터 역참조로 변환한다.

진행은 이렇다.

  1. A의 행 i를 가리키는 포인터 *Aptr 생성.
  2. B의 열 k를 가리키는 포인터 *Bptr 생성.
  3. 끝을 가리키는 Bend 생성.(&B[N][k])

그래서 이걸 이렇게 한다.

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만큼 움직여야하는군!

....

? 왜 배열 참조를 포인터 역참조로 변환하면 최적화일까?

  • 포인터 산술을 사용하면 추가적 연산을 줄일 수 있다.
  • 배열의 포인터를 만들면, 그 포인터가 그 배열의 위치의 요소에 접근할 수 있다.
  • 벡터화와 같은 기법은 배열에 접근하는 루프를 빠르게 만들 . 수있다...

....
뭐 잘모르겠군
코드의 가독성을 떨어뜨리지 않으면서 성능향상을 이끌어낼수 있다네.

3.8.5 가변 크기 배열

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의 각각 움직이는 그값)
루프가 종료했는지를 감지하기 위함이다.

3.9 이기종(Heterogeneous) 자료구조

구조체 struct와
공용체 Union이 있다.

  • 구조체
    다수의 객체를, 하나의 단위로 취급.
  • 공용체
    하나의 객체를, 다른 자료형으로 취급.

3.9.1 구조체

구조체의 모든 요소들은,
메모리의 연속된 영역에 저장된다.
(간단히 말하자면,
구조체의 요소들은 영역적으로 연속하여 저장된다는 것이다.)

전체 자료의 크기는
할당한 자료형들의 크기의 합만큼 할당된다.
(int가 두개면 4+4...)

포인터 역시 첫번째 바이트 주소를 가리킨다.

그렇게, 각 자료형마다
적절한 offset(주소 단위)을 계산하여,
더하는 코드를 생성한다.
(다음 자료형으로 바로 이동하도록 계산)
(int는 4바이트만큼 이동하고...)

예를 들어,
rec 라는 구조체의 변수 r이
%rdi에 저장된다고 할때,

r을 i 필드로 얻어온다음,(mov)
i 필드에 있는걸 j필드에 저장해준다.(mov)

아마 필드 주소값만큼.....
즉 i필드의 주소값만큼 반영해서 주소를 계산하는 과정인듯...(자신없음)

만약 rdl rdi에, i는 rsi에 저장되어있고
&(r->a[i])를 계산한다면
lea로 두 저장 레지스터에 배율까지 해서 .....그렇다고 한다.

마지막 예제는 해독 불가.

3.9.2 공용체(Union)

공용체는
구조체와 달리

전체 자료형의 크기가
공용체 내에 가장 큰 자료형의 크기고,

포인터도,
자료형의 시작점이고 모두 동일하다.

하지만 대개 문제가 생기기 쉽기때문에
서로 다른 두 개의 필드를,
상호 배타적으로 사용하는 게 확실한 경우
전체 할당 공간이 줄어들기때문에 쓴다.

예를 들어,
내부 노드는 데이터 값을 안 가지고
왼쪽 오른쪽 자식 노드만 이어지며,

가장 끝의 노드는 데이터 값을 갖는다고 할때,

Struct로 하면 내부 노드에도
그만큼의 데이터가 할당되지만

Union으로 하면
data가 가장 큰 바이트를 차지할뿐
전체적인 할당 크기는 작다.

단, 이러면 리프 노드와
내부 노드가 구분이 불가해서

{태그필드 + 공용체}구조체
같은 형태로 복합적으로 만든다.

노드 타입(구조체)
	는... : 타입 지정을 갖고
    	공용체 Union으로
        		internal 구조체를 갖고
                	그 구조체는 왼쪽 노드
                    	오른쪽 노드
                data는 여기 속한다

객체지향 그거랑 비슷하게생겼네...
정말이지 추억..

3.9.3 데이터의 정렬

데이터는 정렬 제한을 갖는다.
(자료형마다,사용 가능 주소가 제한되게
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)
식으로 정리해주는 것이다!

3.10 기계수준프로그램에 제어와 데이터의 종합 적용

3.11 부동 소수점 프로그램

3.12 요약

profile
개발자 희망...

0개의 댓글