본문 바로가기

무언가 만들기 위한 지식/SPARC Assembler

Nop의 이해.

어셈블리어 코드를 보다보면 nop이라는 명령어를 종종 볼 수 있을 것이다.
nop은 특별한 경우, 그 부분을 공백처리하기 위해서 사용된다.
한마리로 그 명령어 수행부분을 CPU에서 쉬어가도록 지정하는 것이다.

CPU가 쉬어간다는 말은 곧 명령어 처리부분을 쓸데없이 낭비한다는 의미이기도 한다. 그렇기 때문에 가능하면 nop은 사용하지 않는 편이 좋다. 하지만 코드상 어쩔 수 없이 nop을 써야할 곳이 분명히 등장하기는 한다.

Assembler Beginner의 경우 nop을 생성해가면서 코드를 짠후 마지막에 nop을 제거해나가는 것이 좋고, 습관이 되다보면 아예 nop을 없이 짜게 될 것이다. nop을 제거하는 방법도, nop부분이 loop부분이냐, 아니면 콜 부분이냐 여러 변수에 따라 방법이 바뀌기 때문에 딱히 없애는 방법이란 없다. 단지 전체적인 흐름을 이해하고 명령어 실행 순서를 바꿔주는 것이다.
처음 배울때 "이건 그냥 감으로 없애야 합니다."라는 말을 들은 적이 있는데, 그게 정말 맞는 것 같다. -.-;
코드 짜본 경험에 의해 높은 삭제도 가능하고 아예, 작성하지 않고도 작성이 가능하다.

그럼 먼저 nop을 왜 사용하느냐에 대한 궁금점이 생길 것이다.
이에 대한 이해를 위해서는 파이프라인이라는 기본적인 CPU의 명령어 처리방식에 기인한다.
(이 내용은 [컴퓨터 구조 및 설계]부분에서 한 학기 또는 반학기 분량이라 간단한 설명으로 하겠다.)


위는 기본적인 명령어 구동처리 라인이다.
위에서는 총 9개의 명령어를 12단계에서 실행하고 있다.
(한 명령어가 Fetch, Decode, Execute, WriteBack 4단계로 있다고 가정)
들여다 보면 하나의 명령어는 4가지 색깔로 이루어 지고 있다.
Fetch : 명령어를 가져오는 역할을 한다.
Decode : 명령어를 해독하고 레지스터 파일을 읽는다.
Execute : 실행/주소 계산을 행한다.
WriteBack : 레지스터에 결과값을 넣는다.(메모리 접근도 포함.)

이러한 명령어가 만약 순차적으로 실행되어야 한다면 9개의 명령어는 총 36단계(9*4)에 걸쳐 실행이 될 것이다.
36단계에 걸칠것을 12단계에 실행할 수 있으니, 이것이 바로 파이프 라인의 효율성이다.

조금 더 언급하자면,
사실 4단계로 되어 있지만, 좀더 깊이 들어가면 5단계로 나뉘어져 세부적인 실행이 되어진다.


IF(Instruction Fetch) : 명령어 인출 (명령어를 CPU로 가져온다. 바로전에 Program Counter 증가)
ID(Instruction Decoding) : 명령어 해독/ 레지스터 파일읽기(명령어 코드를 분석한다.)
EX(Excute) : 실행/주소계산 (해당 명령어에 따른 연산-ALU 이용)
MEM(Memory Access) : 메모리 접근-ld,st 명령어를 이용한 메모리(레지스터가 아닌 스택 메모리) 저장
WB(Write Back) : 쓰기 (레지스터에 접근하여 값을 넣어줌-단 레지스터는 ID단계에 존재하기 때문에 ID단계로 역순)

이런 5단계를 통해 명령을 수행한다. 물론 위는 가장 심플한 단계에서 모든 예외를 처리하지 않았을 경우이다. 타이밍에 대한 예외를 처리하면 전체적으로 복잡한 모습을 볼 수 있을 것이다.


<가장 심플한 SPU 구동구조- 녹색 막대는 각 단계를 구분짓는 캐쉬. 일종의 레지스터, 칸막이라고 볼 수 있다.>

위 그림처럼 CPU의 명령이 실행된다. 필자도 처음 보면서 무슨말인지 이해가 가지 않았다. 이것은 상당히 개념적인 CPU의 기본 원리이다. 좀더 눈에 보이는 현실감각을 보고 싶다면, 명령어를 분석해보면 된다.

우리가 작성하는 어셈블리어 코드를 보면 32bit의 정수로 되어 있다. 이 32bit하나의 정수가 명령어 한줄이다.
(숫자는 PCSPIM등 유틸을 이용하면 확인할 수 있다.)
그리고 그 32bit 숫자를 분석하면 우리가 알고 있는 명령어 레지스터, 상수 등 한줄에 쓰이는 명령어를 모두 분석해 낼 수 있다.
예를 들면 0x13485234 등의 수를 add %o0, 3, %o1 처럼 분석이 가능하다.
이런 식으로 모든 어셈블리어의 명령어는 0x???????처럼 32bit로 되어 있고 기계가 이를 분석한다.
이 명령을 가져오는 단계가 IF단계이고 분석하는 단계가 바로 ID단계이다. 그후 ID로 분석한후 해당 명령을 수행한다.

어셈의 명령어는 타입별로 크게 나뉜후 그후 세부적으로 나뉜다.
살짝 설명하면 Branch 와 jump, add, ld 로 볼 수 있다.
여기서 문제가되는 명령어들, 즉 NOP이 쓰이는 명령어들을 언급하자면 바로 branch명령어와 jump와 ld(load/store), call이다.
branch와 jump, call은 모두 다른 위치로 넘어가서 실행을 하게 된다. 즉 현재 라인에서 전이나, 또는 후의 Line으로 가서 해당 명령어를 실행하게 되는데 그 과정에서 위 명령어 다음 번 줄의 명령어는 실행되는 도중에 다른 라인으로 이동해 버리기 때문에 제대로된 연산이 수행되지 않는다.
즉, 위 4개 명령어 다음의 명령은 제대로 실행되지 않기 때문에 nop 처리를 해주는 것이 기본적인 방법이다.
보통 branch계열과, Jump, call 다음에는 nop처리를 해주고, nop을 없애기 위해 지워나가는 것이 정석이다.
(ld/st의 경우 nop을 써주지 않아도 코드상 컴파일러가 알아서 처리해 주지만 사실상 위와 같은 영향을 받는다고 함. - 즉 그닥 신경쓸필요는 없음, 단 최적화를 위해서는 약간의 위치 수정이 필요함.)


자 백문이불여일견, 직접 코드상을 봐야 이해가 갈 것이다.

의 경우 아래와 같이 가능하다.

둘의 차이점을 보면 위에 있던 add명령어 하나가 nop위치로 옮겨진 것을 알 수 있을 것이다.
가장 기본적인 방식으로 nop 위치 가 해당 명령어의 위에 있는 명령어 하나를 내리는 방법이다.

이렇게 위치를 바꿀 경우 브랜치나 점프 콜 명령어실행전에 그 아래 명령어가 먼저 실행되고 해당 레이블(해당 Line)로 이동한다고 생각하면 편할 것이다.
어셈블리어의 반복문의 경우 Lable간의 이동으로 하게 된다. 명령어 수행위치가 바뀌어가며 코드가 반복되는 방식이다.
예를들면 branch 명령이 실행되고 나서 다음 한칸의 명령어가 실행된후 브랜치 대상위치의 명령어가 실행되는 것이다.
이해가 안가면 [브랜치나 콜 다음에는 명령어가 하나더 실행되는 구나]라고 생각하면 될 것이다.

Nop위치에 명령어를 끌어 당기기 위해서는 두가지 방법이 있다.
하나는 윗부분에서 끌어오는 것과, 뒷부분에서 끌어오는 방식이다.
(이제 Branch문을 예로 설명을 하겠다.) - branch는 분기문에 쓰이는 명령어 들임.

윗부분이라함은 branch전의 명령어들로 그 연산결과나 과정이 branch에 영향을 미쳐서는 않된다.
브랜치에 영향을 주는 것은 CC(Condition Code)에 영향을 주는 것을 의미한다.(Condition Code에 대해서는 후에 좀더 언급)
위의 곱셈 예제에서는 두개의 add연산후 그 값 o1과 o0레지스터로 연산하기 때문에 상관이 없다.
단 이동판단시 사용되는 CC에 영향을 주면 안된다. CC에 영향을 주는 명령어들로는 addcc, cmp등등 여러 명령어가 있다.

뒷부분이라함은 branch를 통해 이동할 lable(line)의 명령어를 말한다. 이 경우 유의해야할 점은 만약 조건에 의해 branch가 되지 않고 그냥 넘어갈 경우 실행여부에 영향을 미치게 된다. 이를 고려하여 판단해야 한다.

Branch에 대해서도 Condition Code부분에 대해 언급하면서 자세히 이야기할 것이다.
branch를 이해하여야 nop제거도 이해할 수 있을 것이라는 판단에 NOP부분 제거에 대한 example은 Branch를 설명하면서 병행하겠다.
 
이렇게 NOP을 없애 나가다보면 사실상 효율은 좋아지지만, 가독성이 떨어져서 이해하는데 어느정도 시간이 걸리는 단점이 있다 -.-; 즉 직관성이 떨어지나, 하위(구리다는 말이 아니라 위치가 기계에 더 가깝다는 이야기)수준의 언어이기 때문에 최적화 시켜서 코드를 작성하는 것이 관건이다.