(주의 : 혼자 책 보며 공부한 내용 정리한 거라 틀린 내용 다소
있을 수 있습니다.)
c에서 조건문, 루프, 스위치 등이 있다. 이들 모두 특정 데이터에 대해 테스트를 하고(자료가 어떤 조건에 부합하는지 ex. a가 b보다 큰지?) 그 결과에 따라 명령을 수행한다.
기계 코드(어셈블리어 코드를 의미하든 바이너리 파일을 의미하든 흐름에 큰 차이 없으므로 기계 코드로 퉁쳐서 얘기할 것)는 조건부 행동을 구현하기 위한 다음의 두 가지 로우레벨 메커니즘을 제공한다.
cf) 제어 흐름 : 프로그램에서 실행되는 각 구문, 명령어, 함수가 호출되는 순서
- 데이터 값을 테스트하고 컨트롤 흐름(제어 흐름;control
flow)을 변경.
- ex)
- if…then…(else)문 : 조건문 만족시 제어 흐름이 ‘then’에 있는 내용 방향으로 흘러가도록
- for문 : for문에서 명시한 조건이 참이 될 때까지 블록 안의 구문을 반복 수행
- switch -> case문 : switch에 들어온 값이 특정 값이면 특정 구문으로 이동하여 해당 구문 수행.
- 그 외 while문 goto문
- 데이터 값을 테스트하고 데이터 흐름을 변경.
- 말그대로 데이터 값을 변경하는 의미?
실제 c의 조건부 행동들도 위의 두 가지 기계수준 명령에 의해 구현되므로 이들에 대해 살펴볼 것.
3.6.1 조건 코드(condition code)
앞에서 계속 배운 16개의 레지스터 외에도 1비트(단일비트; single-bit)의 조건 코드 레지스터 여러 개가 CPU 내에 존재한다.
이러한 조건 코드 레지스터들에는 가장 최근의 산술 연산이나 논리 연산의 특성이 기록된다(즉, 연산의 결과 값을 직접 기록하는 것이 아니라 연산 과정의 어떠한 일부 특성들을 하나의 비트에 담는 것이다).
다음의 네 개의 플래그들이 이러한 레지스터에 해당. (플래그와 조건 코드 레지스터라는 용어가 1to1으로 대응되는지는 다시 알아봐야 할듯..)
(cf.
CF
: Carry flag. 가장 최근 연산에서 ‘가장 중요한 비트’의 자릿수 올림(carry out of the most siginificant bit)이 발생했는지 알려주는 플래그. (1001(2) + 1000(2) = 10001(2)에서 절삭하여 0001(2)가 되는 경우에 가장 높은 자리에서 한 자릿수가 올라간 것으로 볼 수 있는 느낌?) Unsigned 연산에서 오버플로우가 발생했는지 확인하는데 쓰임.ZF
: Zero flag. 가장 최근의 연산 값이 0이었는지 체크SF
: Sign flag. 가장 최근 연산 값이 음수였는지 체크OF
: Overflow flag. 가장 최근 연산이 2의 보수 오버플로우를 일으켰는지 체크
이 네 가지 조건 코드 값들이 t = a + b
라는
연산을 실행시켰을 때 어떤 식으로 설정되는지 다음 표를 통해
보자. 아래 C코드로 작성한 논리 연산이 참이면 조건 코드 값은
1, 거짓이면 조건 코드 값은 0이 될 것이다. (조건 코드에서
‘코드’라는 용어가 코딩할 때 뭔가 명령문을 작성하는
느낌의 코드라기 보다는 그냥 특정 상태를 나타내는 데이터라는
의미로 보는 것이 조금 덜 헷갈리지 않을까 싶다. 아닐 수도).
조건 코드 레지스터 | C표현 논리 연산 | 의미 |
---|---|---|
CF |
(unsigned) t < (unsigned) a |
Unsigned overflow |
ZF |
(t == 0) |
Zero |
SF |
(t < 0) |
Negative |
OF |
(a < 0 == b < 0) && (t < 0 != a < 0) |
Signed overflow |
=> 예를 들어 a + b 를 실행한 값이 0이면 ZF 값이 1로 설정되는 것이다. |
앞에서 설명한 기계 코드의 정수 산술 연산 오퍼레이션 중
leaq
를 제외하고는 모두 조건 코드 값을
결정한다.(leaq
는 주소 값 연산을 위해 만들어진
것이라 조건 코드를 바꾸지 않게 만들어졌나 보다)
(나머지는
INC, DEC, NEG, NOT, ADD, SUB, IMUL, XOR, OR, AND, SAL,
SHL, SAR, SHR
연산. 물론 실제 어셈블리어에서는 왼쪽에 적어둔 형태에 비트
수등을 나타내는 접미 글자가 붙는다).
위의 연산 중 몇몇 개에서는 특정 플래그 값이 어떻게 될 지
유추해볼 수 있다.
예를 들어 XOR
연산에서는
최상위 비트가 한 자리 올라가는 일이 없을테니 캐리 플래그와
오버 플래그는 0으로 설정될 것.
시프트 연산에서는
밀려나는 가장 최상위 비트로 캐리 플래그가 설정된다.
방금 설명한 연산들은 다른 레지스터(기존의 16개
레지스터?)값을 바꾸면서 동시에 조건 코드 레지스터를
변경한다.
하지만 아래 설명하는 연산은 오직 조건 코드
레지스터 값만 변경한다.
이러한 연산은 CMP클래스와
TEST클래스가 있고 각각은 다루는 데이터 크기별로 연산이
존재한다(8비트, 16비트, 32비트, 64비트 형태).
CMP에 속하는 명령들은 두 피연산자의 차이값에 따라 조건
코드의 값을 설정. 하나에서 다른 하나를 뺀 값이 0이면(즉, 두
값이 같으면) ZF(zero flag)를 1로 설정. 나머지 플래그들도
마찬가지로 연산 값에 의해 설정된다. (결국 sub연산을 하는데
데이터가 저장된 레지스터 값은 변경 안 하고 조건 코드
레지스터만 변경한다고 보면 되려나)
(주의 :
어셈블리어에서는 피연산자1, 피연산자2의 순서로 등장하지만
연산은 ‘피연산자2 - 피연산자1’로 순서에 주의)
TEST에 속하는 명령은 AND명령어(비트수준 and연산)와 같은데
연산을 하고 데이터가 저장된 레지스터를 변경하지는 않음.
책에
testq %rax, %rax
와 같이 같은 레지스터 값에 대해
test명령을 실행하여 데이터 값이 음수인지, 0인지, 양수인지
체크한다고 적혀있다. 아마 다음 방식으로 체크하지 않을까
싶다.
- 우선 AND 연산한 값은 자기 자신이 나온다.
- 즉 자기 자신이 음수이면 연산값도 음수여서 음수 플래그인 SF가 1로 설정.
- 자기 자신이 0이면 연산값도 0이어서 ZF가 1로 설정.
- 자기 자신이 양수이면 연산값도 양수여서 SF가 0으로 설정.
3.6.2 조건 코드 사용하기
조건 코드를 그냥 볼 수도 있지만 다음 세 가지 방법으로 이용할 수도 있다.
- 어떤 연산 실행 후 설정된 조건 코드 값들을 조합해서 또 다른 바이트를 0 또는 1로 설정할 수 있다(조건 코드 값들의 조합이 의미를 가질 수 있기 때문)
- 조건 코드 값에 따라 프로그램 다른 부분으로 이동.
- 조건에 따라 데이터를 전송.
우선 여기서는 조건 코드 값들을 조합해서 다른 바이트를 0 또는
1로 설정하는 명령(instruction)에 대해 알아볼 것이다.
이러한
클래스의 명령들을 SET명령으로 부를 것이다. SET클래스에
속하는 명령어들은 뒤에 알파벳이 하나씩 붙는데 여기서는
피연산자의 크기를 뜻하는 것이 아님에 주의하자. (ex. setl은
set long이 아니라 set less의 의미)
다음 그림에 나오는 set명령들은 C표현 a - b
의
연산을 실행하고 난 후 설정된 조건 코드 레지스터 값들을
가지고 D의 바이트를 0 또는 1로 설정하는 것이다.
Synonym(동의어)의
경우 해당 인스트럭션이 다른 명령어를 통해서도 수행될 수
있음을 의미.
또한 Set condition에 적혀 있는 의미는 a를
b와 비교했을 때 어떤 상태인지 보여준다. 실제 a와 b값을
비교했을 때 플래그들이 어떤 값을 갖는지 계산해보고 이를
왼쪽에 있는 플래그의 조합이 0인지 1인지와 비교해보면 왜 해당
연산이 set condition에 적혀있는 의미를 갖는지 알 수
있을듯하다.
)
a가 b보다 크면 참을 반환하는 코드의 어셈블리어를 예시로
보자.
위의 어셈블리어 예시에서는 우선 2번 줄에서 a와
b를 비교하고 이에 따라 플래그 값만 설정해준다.
그 후
3번 줄에서 SF나 OF 둘 중 하나만 1이면 %al(%eax의 하위
바이트)에 1의 값을 저장한다. 그렇지 않으면 0을 저장.
(signed자료형에서 SF가 1이면 a - b가 음수, OF가 0이면 확실히
a가 b보다 작은 케이스. SF가 0이고 OF가 1이면 a - b가
양수인데 오버플로우가 나서 양수인 경우. 오버플로우가 나서
양수이려면 음의 방향으로 오버플로우가 일어나야 한다. 따라서
a - b가 음수인데 오버플로우가 나서 양수로 바뀐 것으로 이해할
수 있다. 결국 두 경우 모두 a < b가 참인 경우로 볼 수
있다.)
그 다음 4번 줄에서 %al에서 %eax로 값을 복사한다.
그런데 둘은 같은 레지스터이므로 %al(1바이트)의 값을
4바이트로 0의 확장한 것과 같다. 그런데 레지스터에서
4바이트를 할당하는 경우 나머지 상위 4바이트도 0으로
설정하므로 결국 전체 레지스터의 하위 1바이트를 제외한 나머지
바이트를 0으로 설정하는 효과.
그 후 그 값을 반환한다.
SET인스트럭션을 볼 때 생각해볼 점은 기계어에서는 C에서처럼
데이터 타입을 각각의 프로그램 값에 연관시키지 않는다.
보통은
signed이든 unsigned이든 연산이 비트 수준에서 같기 때문에
구분하지 않고 연산한다.
하지만 위의 SET클래스나 우측
시프트 연산, 나눗셈, 곱셈 등 구분해야할 때는 구분해서 코드를
작성한다.
3.6.3 점프 명령(jump instruction)
일반적인 프로그램 실행 시에는 코드 작성 순서대로 명령이 실행되지만 점프 명령은 프로그램의 다른 지점으로 이동해서 실행할 수 있도록 한다.
점프 지점은 어셈블리 코드에서 _라벨(label)_로 불려지는 곳으로 명시된다. 다음은 그 예시이다.
jmp
명령어로 인해 movq
를 건너 뛰고
.L1
으로 가서 popq
를 실행하게 된다.
jmp
명령의 경우 Direct jump와 Indirect jump가
존재.
Direct jump는 점프 타겟이 명령의 일부분으로서
명시되어있는 점프이고,
Indirect jump는 메모리나
레지스터에서 읽어와서 점프하는 것이다.
후자의 경우,
jmp *%rax
이면 rax레지스터에 있는 값을 점프
타겟으로 삼는 것이고, jmp *(%rax)
이면
rax레지스터에 있는 값을 메모리 주소로 생각하고 해당 메모리에
저장되어 있는 값을 점프 타겟으로 삼는 것이다.
나머지 명령어들은 조건 코드의 상태에 따라 라벨로 점프할지 아니면 바로 다음 명령어로 자연스럽게 넘어갈 것인지 결정하게 된다.
3.6.4 점프 명령 인코딩
점프 명령의 타겟이 어떻게 인코딩되어서 프로그램에 저장되는지는 7단원에서 링킹에 대해 배우면서 다루고 일단 여기서는 자세하게 다루지는 않을 것.
기본적으로 점프 명령 타겟을 인코딩하는 방법은 상대주소를 이용하는 _PC relative_한 방식이 있고, 절대 주소를 이용하는 방식이 있다.
PC 상대적 방식의 예시를 보자.
)
위의 어셈블리어를 .o파일로 컴파일한 것을 역어셈블러로 다룬
것이 아래 그림이다.
오른쪽 주석에서 첫번째 점프
명령에서는 0x8이라는 절대 주소로 간다고 설명되어 있다.
하지만
해당 줄의 마지막 바이트(바이너리 코드)를 보면 0x03이라는
값으로 점프 타겟을 설명한다. 이는 해당 줄 바로 다음의
주소로부터 03의 오프셋만큼 이동한 곳이 점프 타겟이라는
의미이다(바로 그 자리가 아니라 다음 주소로 이동하는 것은
자기 자신으로 점프할 일은 없으므로?).
그 다음 점프
명령은 0xf8로 2의 보수 표현으로는 -8을 의미한다. 이를 바로
다음 줄 명령어 위치로부터의 오프셋으로 생각하여 점프 타겟을
찾으면 절대 위치 0x5가 나온다.
이를 다른 오브젝트 파일과 링킹한 후 역어셈블러로 다룬 것을 보면 다음과 같다.
그러면 주소값이 더울 커진 것을 알 수 있다. 따라서 PC상대적 인코딩으로 인해 점프 타겟을 인코딩하는데 절대주소 인코딩 방식보다 바이트를 아낄 수 있다는 것을 알 수 있다.
3.6.5 조건부 제어를 통해 조건부 분기 구현하기 (Implementing
Conditional Branches with Conditional Control)
C에서 보통 조건부 표현과 명령문을 기계 코드로 변환할 때는
조건부 점프와 비조건부 점프를 사용한다. (비조건부 점프는
조건 상관없이 무조건 점프하는 jmp
같은 명령어)
C에서 if/else문으로 작성된 것을 기계어로 변환하면 보통 다음과 같은 goto문과 비슷한 형식을 보여준다.
if (test-expr)
then-statement
else
else-statement
이를 기계어로 변환한 코드를 C스타일로 작성하면 아래와 같다.
t = test-expr;
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:
인자로 받은 x
, y
의 값을 비교하여
x
가 y
보다 작으면 전역변수
lt_cnt
값을 올려주고 y - x
를
반환하고, 그렇지 않으면 전역변수 ge_cnt
값을
올려주고 x - y
를 반환하는 함수를 예시로
기계어로 어떻게 변환되는지 살펴 보자.
(a)에 있는 C코드를 기계코드로 변환하면 (c)가 되고, 이를 C코드 스타일의 goto코드로 작성하면 (b)로 볼 수 있다.
어셈블리 코드는 2번 줄에서 x와 y를 비교해서 x >= y이면 .L2로 점프해서 그 밑에 있는 명령을 수행하게 하고 그렇지 않으면 .L2전에 있는 명령들을 실행해서 함수를 종료하게 한다.
3.6.6 조건부 이동을 통해 조건부 분기 구현하기
앞 절에서처럼 조건에 따라 실행경로를 바꿀 수도 있지만 최신 프로세서에서는 비효율적인 측면이 있을 수도 있다.
대신 _데이터의 조건부 전송_방식을 쓸 수 있다. 여기서는 각
조건부 동작에 따른 데이터를 전부 계산하고 조건에 따라 한
가지만 선택해서 가져가는 방식이다.
아래 예시는 이에
해당하는 예시이다.
아래는 조건부 이동 명령어 목록이다. 기존의 mov와의 차이점은 1)명령어에 자료 크기를 명시하지 않고 한 가지 명령어로 여러 자료 크기를 다 다룬다. 자료 크기는 피연산자로 들어오는 것을 보고 판다. 2)조건 코드 레지스터 값을 보고 이동을 시킬지 말지 결정.
조건부 데이터 이동을 통해 어떤 식으로 조건부 분기를
구현하는지 보기 위해 다음과 같은 C 3항 연산을 보자.
v = text-expr ? then-expr : else-expr;
만약 조건부 제어를 통해 구현하면 다음과 같은 C스타일
코드로 작성할 수 있다.
if (!test-expr)
goto false;
v = then-expr;
goto done;
false:
v = else-expr;
done:
위의 조건부 제어에서는 then문이나 else문 중 하나의 문장만 계산된다.
반면 조건부 이동에서는 then문과 else문 모두 계산된다. 따라서 다음과 같은 C스타일 코드로 작성할 수 있다.
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;
조건부 이동을 쓰는 경우 두 계산을 모두 미리 한다는 점에
주의해야 한다. 만약 계산 과정에 에러 조건이 발생하거나
사이드 이펙트가 있는 경우 의도하지 않은 결과가 나올 수 있다.
(보통은 조건이 맞는 경우에만 그 계산이 이루어지게 해서
사이드 이펙트도 그때만 발생하길 원하므로)
또 다음
경우에도 문제가 생길 수 있다.
long cread(long *xp) {
return (xp ? *xp : 0);
}
보통은 xp가 널포인터가 아닌 경우에만 역참조를 하길 바라겠지만 조건부 이동을 쓰는 경우에 일단 두 가지 계산을 다 하면서 널포인터인 경우에도 역참조를 해서 문제가 생길 가능성도 있다.
조건부 제어와 조건부 이동의 장단점
우선 조건부 이동의 장점을 이해하기 위해 프로세서가 명령을 처리하는 과정을 조금 이해해야 한다.
프로세서는 각 명령을 일련의 단계로 처리하는데 이때 각각의
단계들은 명령의 아주 작은 부분만을 처리한다.
그리고
이러한 단계들은 _파이프라인_으로 연결되어 차례대로 처리된다.
이때 하나의 단계가 실행될 때 다른 단계의 명령을 파이프라인에
채워넣는 식으로 속도를 향상시킨다.
그런데 프로세서가
조건부 점프(branch)를 만나면 분기 조건을 계산하기 전까지는
방향을 알 수 없다. 그러면 프로세서는 분기예측을 통해 확률이
높은 방향으로 실행한다. 그런데 만약 예측이 틀리면 미리
채워놓은 파이프라인을 버리고 다시 명령을 채워야 하므로
프로세서 성능의 저하를 가져온다.
조건부 이동의 경우 점프를 사용하는 것이 아니므로 분기예측을 할 필요가 없기 때문에 그러한 성능 저하가 없다는 장점이 있다.
반면 모든 계산을 미리 수행하기 때문에 각각의 계산에 걸리는 시간이 오래 걸리는 경우라면 조건부 이동에도 불리한 측면이 있다고 할 수 있다.
조건부 제어와 조건부 이동 중 뭐가 더 나은지는 이러한 장단점을 비교해서 결정해야할 것이다.
3.6.7 루프문
C에서는 do-while
, while
,
for
문의 루프를 제공한다.
아쉽게도 기계어에
직접적으로 대응되는 명령어는 없다. 따라서 가장 쉬운
do-while부터 하나씩 구현 방법을 살펴볼 것이다.
Do-While 루프
do-while
의 형태는 다음과 같다.
do
body-statement
while (test-expr);
이를 컴파일하면 일단 body-statement를 한 번
실행하고(무조건), 그 다음 text-expr가 참이면 또 실행하는
방식이다.
이를 C스타일의 goto
문으로
바꿔보면 다음과 같다.
loop:
body-statement
t = test-expr;
if (t)
goto loop;
하나의 예시를 보면 다음과 같다.
While 루프
while
의 형태는 다음과 같다.
while (test-expr)
body-statement
do-while과 달리 실행 전 조건을 검사한다는 차이가 있다.
while루프의
경우 _jump to middle_방식으로 구현할 수도 있고, _guarded
do_방식으로 구현할 수도 있다.
‘jump to middle’방식의 구현을 C스타일
goto
문으로 보면 다음과 같다.
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;
예시는 다음과 같다.
‘guarded do’방식의 구현을 보기 전에 while문을 do-while문의 형태로 바꾸면 다음과 같다.
t = test-expr
if (!t)
goto done;
do
body-statement
while (test-expr);
done:
이를 goto
코드로 보면 다음과 같다.
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:
For 루프
일반적인 for루프의 형태는 다음과 같다.
for (init-expr; test-expr; update-expr)
body-statement
몇 가지 예외를 제외하면 C표준은 for문이 다음의 while문과 같은 행동을 하도록 정해놓았다.
init-expr;
while (test-expr) {
body-statement
update-expr;
}
결국 while문이므로 while루프에서 사용했던 ‘jump to
middle’과 ‘guarded do’방식을 사용할 수 있다.
‘jump
to middle’방식을 사용했을 때 goto
코드는
다음과 같다.
init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t = test-expr;
if (t)
goto loop;
‘guarded-do’방식을 사용했을 때
goto
코드는 다음과 같다.
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if (t)
goto loop;
done:
3.6.8 스위치문
스위치 문의 경우 코드 위치를 나타내느 점프 테이블을 사용한다. 점프 테이블에는 각 케이스마다 프로그램의 어느 위치로 이동해야하는지 직접적으로 명시되어 있기 때문에 if-else문보다 효율적일 수 있다.