기존 포스팅에선 값을 복사하여 옮기는 인스트럭션에 대해 공부했으니, 이제 본격적으로 연산과 관련된 인스트럭션을 진행할 차례입니다. 어셈블리 코드는 C와 같이 우리가 평소에 진행하는 고수준 언어와 달리, 저수준에서 명령을 형성합니다. 따라서 적어도 지시된 명령에 대해선 무조건적으로 시행 가능한 정교성과(물론 명령 자체가 잘못되었을 수는 있지만요) 자원과 비용 측면의 최적화된 정책이 요구됩니다. 이번 포스팅에서는 해당 부분에 대해 알아볼겁니다.
연산의 종류
먼저, 인스트럭션의 종류에 따라 그 연산 기법의 분류를 구분할 수 있습니다. 오퍼랜드(피연산자)가 하나인 연산의 경우 이를 단항 연산이라고 합니다. 단항 연산의 경우, 피연산자 본인이 소스이며 동시에 목적지가 됩니다. 이는 곧, 소스가 연산 처리 진행 후 소실됨을 의미합니다. 본인이 목적지 또한 맡기 때문에, 결과값으로 덮어씌워지기 때문이죠. 대표적으로 INC 계열, DEC 계열 와 같은 인스트럭션이 존재합니다. 이는, C에서의 ++ 연산자를 맡습니다. INC (%rax)을 지시하면, %rax 레지스터가 가지고 있는 주소에 저장된 값을 1 증가 시키는 동작이 시행됩니다. 오퍼랜드가 두개 오는 연산의 경우에는 이항 연산이 됩니다. 이 경우 두번째 피연산자가 소스와 동시에 목적지 역할을 갖습니다. 예를 들어서 subq %rax, %rdx 로 작성되었다면, %rdx 레지스터에 저장되는 값은 최종적으로, %rdx - %rax 의 값이 됩니다. 따라서 소스로 활용된 %rdx 레지스터가, 최종적으로는 목적지의 역할을 부여받아, 결과값으로 덮어씌워짐을 의미합니다. 이 또한 두번째 오퍼랜드의 소스값은 소실됩니다.
시프트 연산
시프트(shift) 연산이라는 독특한 기법도 있습니다. 옛날, 닌텐도의 변태(positive) 개발자들이 즐겨 쓴 방식이죠. 여러 연산이 드는 과정을, 2진수라는 특징을 이용해, 2의 거듭 제곱 단위를 활용하여 단일 연산으로 단축시키는, 일종의 최적화 기법으로 사용됩니다. 해당 시프트 연산은, 첫번째 오퍼랜드로는 시프트할 크기, 두번째 오퍼랜드로는 해당 시프트를 적용시킬 대상이 옵니다. 산술이나 논리형 우측 시프트가 모두 가능하며, 해당 시프트할 크기에 대해서는, 즉시 값(상수)이나, 특정된(%cl) 단일 바이트 레지스터를 통해 명시가 가능합니다. 원칙적으로 1바이트 시프트 양은, 2^8 -1 된 값인, 255까지 가능합니다. 시프트 양은 2^m( m = %cl 저장값의 하위 비트 수 ) = w(데이터 크기) 로 계산됩니다. 예를 들어, 쉬운 예시로 %cl이 FF(1111 1111이니, 범위가 변할 때마다 무조건 시프트 값이 변합니다.) 일 경우, salb로 시행했을 시, 2^3 = 8 이니, 하위 3비트인 111, 7 만큼 시프트 됩니다. 접미사는 단위별로 2배로 증가하니 salw(2^4), sall(2^5), salq(2^6)은 각각, 15 , 31, 63 으로 시피트 될 것입니다. 해당 시프트 연산은 SAL/SAR 과 SHL/SHR로 나눌 수 있습니다. L R 은 시프트 방향의 구별됩니다. SAL과 SHL은 모두 0으로 채우나, SAR와 SHR는 각각 논리 시프트(0으로 채우기)와 산술 시프트(부호 비트로 채우기)로 구별됩니다.

특수 산술 연산
해당 파트 공부를 진행하다 보면, 의문이 드는 점이 하나 생깁니다. 64비트의 정수 둘을 곱셈을 해버리면 어떻게 될까요? 최대 128비트까지 커지는 이 값들을 방어할 수 있을까요? x86 체제에서는 이를 수용하기 위해, 16바이트워드인 옥트워드(oct word) 단위로 명명하여 인스트럭션을 마련했습니다. C로 예를 들어, 해당 128비트 곱셈을 구현하기 위해선, __int128 과 같은 gcc 지원 비표준 128비트 타입을 사용해 지시할 수 있습니다. 아래를 보면 [레지스터]:[레지스터] 로 표현되는 새 형태를 볼 수 있습니다. 사실 굉장히 간단히(?) 해결하는 방법을 사용한건데 그냥 두 레지스터를 논리적으로 서로 붙여버린겁니다! 물론 어셈블리 코드는 간단하지 않지만요.

우리가 64비트 a * 64비트 b 를 지시하면(결과는 메모리에 저장한다 하면), 아래와 같은 어셈블리 코드가 형성됩니다.
movq %rsi, %rax ; rax에 a 값 저장
mulq %rdx ; a * b 연산(%rax * %rdx ) -> [%rdx]:[%rax]로 128비트 결과값 저장
movq %rax, (%rdi) ; 메모리 주소 (%rdi)에 하위 8바이트(%rax) 저장
movq %rdx, 8(%rdi) ; 메모리 주소 8(%rdi)에 상위 8바이트(%rdx) 저장
ret
먼저 a 를 %rax에 저장합니다. 그리고 mulq %rdx 단항 연산을 돌리나, 이는 암묵적으로 결과값을 담는 %rax와 %rdx를 곱함을 내포하고 있습니다. 이 결과 값은 %rdx 와 %rax를 연속시켜, 각각 상위 8바이트와 하위 8바이트로 이분하여 저장합니다. 그리고 다시 movq를 통해, 이분한 각각의 값을 rdi 레지스터가 특정하는 메모리 주소와 8바이트 만큼 이동한 메모리 주소에 저장하여, 이 128비트 값을 논리적으로 유지하여 저장합니다. 그럼 우리가 이를 꺼내려 하면, 해당 rdi 레지스터가 저장하고 있는 주소값과 거기서 8바이트 떨어진 주소, 두 곳만 조회하면 해당 값을 얻을 수 있죠. 일반 64비트 연산보단 비용이 좀 들 수 있지만, 아예 불가능한 것보단 낫겠죠? 128비트 곱셈 또한 unsinged 연산은 mulq, 부호가 있는 sign 연산은 imulq로 구분해 계산하며, 리틀 엔디안 기법을 고려해, 상위 바이트가 더 높은 주소에 저장됩니다.
나눗셈도 같은 방식으로 divq와 idivq로 연산합니다. 나눗셈이 128비트로 형성될 이유가 있을까요? 모듈러 연산(%)을 고려했기 때문에 그렇습니다. a/b = c, a%b =d로 지시했을 때(그리고 이들을 메모리에 저장할 때), 다음과 같은 어셈블리 코드가 형성됩니다.
movq %rdx, %r8 ; c가 지시한 주소 저장
movq %rdi, %rax ; a를 %rax에 저장
cqto ; %rax를 [%rdx]:[%rax]로 sign-extend
idivq %rsi ; [%rdx]:[%rax] / b
movq %rax, (%r8) ; 몫을 c에 저장되어 있었던 메모리 주소로 저장
movq %rdx, (%rcx) : 나머지를 d의 주소로 저장
우선 %rdx는 차례상 세번째 변수가 저장될 레지스터이나, 128비트에 사용해야 하니 이를 %r8에 백업합니다. 그리고 이전 과정과 같이 a값이 저장된 %rdi를 %rax에 옮기고, cqto 인스트럭션을 통해 부호 확장(sign-extend)을 통해 128비트로 확장시킵니다. 이후 해당 값에 b를 나눠, 몫과 나머지가 각각 %rax와, %rdx에 저장되게 형성합니다. 이후 몫인 하위 8바이트 %rax는 %r8로 백업된 c의 메모리 위치로, 상위 8바이트에 저장된 나머지 %rdx는 d의 메모리 위치로 저장됩니다. 특히 idivq에서, 즉, 부호가 존재하는 수의 나눗셈에서 생성된 나머지는, cqto로 형성된 피제수(나눠지는 대상자, (%rdx:%rax))의 부호를 따릅니다.
'CS' 카테고리의 다른 글
| [CSAPP] 3챕터 - 프로그램의 기계수준 표현(8) - 조건 이동 인스트럭션 (0) | 2025.10.15 |
|---|---|
| [CSAPP] 3챕터 - 프로그램의 기계수준 표현(7) - 조건 연산 인스트럭션 (1) | 2025.09.24 |
| [CSAPP] 3챕터 - 프로그램의 기계수준 표현(5) - 데이터 이동 인스트럭션 (1) | 2025.09.09 |
| [CSAPP] 3챕터 - 프로그램의 기계수준 표현(4) - 오퍼랜드 (0) | 2025.09.06 |
| [CSAPP] 3챕터 - 프로그램의 기계수준 표현(3) - 스택 프레임 규칙 (0) | 2025.09.02 |