스택 사용 니모닉

call: 함수를 호출

call의 동작 원리

MASM 6.1 Programmer's Guide, Ch.7 p.180 — "Procedures"

Two instructions control the use of assembly-language procedures. CALL pushes the return address onto the stack and transfers control to a procedure, and RET pops the return address off the stack and returns control to that location.

The CALL instruction pushes the address of the next instruction in your code onto the stack and passes control to a specified address. The syntax is:

CALL {label | register | memory}

The operand contains a value calculated at run time. Since that operand can be a register, direct memory operand, or indirect memory operand, you can write call tables similar to the example code on page 164.

call 니모닉은 함수(Procedure) 를 호출한다.

  • 회귀 주소를 스택에 푸시한다.
  • 함수로 제어권을 넘긴다.

call 니모닉의 피연산자중 “label”을 제외한 나머지 피연산자는 모두 실행 중에 실행할 함수가 결정된다.

  • “The operand contains a value calculated at run time.”라고 표현했다.

Near vs Far

MASM 6.1 Programmer's Guide, Ch.7 p.181 — "Near and Far Calls"

Calls can be near or far. Near calls push only the offset portion of the calling address and therefore must target a procedure within the same segment or group.

Near call 은 2바이트 오프셋을 스택에 푸시한다. Far call 은 오프셋과 CS값(총 4바이트)을 스택에 푸시한다.

far call 하는 방법

call FAR PTR job
far_table[di]
  1. FAR PTR을 명시한다.
  2. DD 타입 변수를 피연산자로 받는다.

두번째 방법 보충 설명

DD로 선언된 4바이트에 리틀 엔디안 주소가 저장되어 있다.

예를 들어 CS=0CAEh, IP=003Fh면

far_table[0] → 3Fh (IP 하위) far_table[1] → 00h (IP 상위) far_table[2] → AEh (CS 하위) far_table[3] → 0Ch (CS 상위)

call far_table[di]가 이 4바이트를 읽어서 CS:IP를 한번에 교체하고, 기존 CS:IP를 스택에 push한다.

ret/retn/retf: 함수로부터 반환

retopcode가 없는 니모닉이다.

Procedures 를 선언할 때 다음과 같이 선언한다.

MASM 6.1 Programmer's Guide, Ch.7 p.180 — "Defining Procedures"

label PROC [[NEAR | FAR]] … RET [[constant]] label ENDP

어셈블러가 PROC 뒤의 NEAR / FAR를 보고 retn / retf 를 결정한다.

retn : 스택에서 오프셋을 뽑아서 ip에 저장한다.

retf : 스택에서 오프셋과 cs를 뽑아서 cs:ip에 저장한다.

ret의 상수 피연산자

피연산자로 상수를 사용하면 스택에서 추가로 해제할 바이트를 지정할 수 있다.

int: 소프트웨어 인터럽트 발행

동작은 서비스 함수를 호출하는 것이다.

int의 피연산자 값의 범위는 [0x00,0xFF]다.

  • 256개의 인터럽트, 각각에 대응하는 서비스 함수

인터럽트 벡터 테이블(IVT) 에 서비스 함수의 주소가 저장되어 있다.

  • 서비스 함수(핸들러) 주소의 크기는 4바이트다.
  • IVT 의 총 크기는 256 * 4바이트
  • IVT의 위치는 [0x0000, 0x003FF]
  • MS-DOS의 부팅 순서 3 참고

핸들러 호출은 far call 에 해당한다.

  • ROM BIOS 의 영역까지 멀리 점프한다.

call과 스택 동작이 다르다.

int 명령어는 플래그도 스택에 넣어준다.

  • call과 차이점

스택에 넣는 순서도 중요하다 FLAGS → CS → IP 순서로 넣는다.

into: 오버플로 인터럽트 발생

조건부로 동작한다.

  • 오버플로 플래그가 1이라는 조건

MS-DOS의 int4는 그냥 return 하는 것이다.

  • 즉 아무것도 안한다.

여기서 말하는 IVT[4]0x0010이다.

  • IVT[0] == 0x0000 에서 16바이트를 더한 결과다.

iret: 인터럽트 함수로부터 반환

[[#call과-스택-동작이-다르다|call과 스택 동작이 다르다.]]에서 int는 스택에 플래그도 넣는다고 했다.

  • 따라서 ret과 동작이 다른 니모닉이 따로 필요한 것이다.
  • 스택에서 플래그를 뺀다.
    • 슬라이드 우측 상단에 초록색 하이라이트를 보면 O D S Z C가 전부 ±로 표시된 것을 확인할 수 있다.

iretIP → CS → FLAGS 순서로 뺀다.

push: 스택에 push

8088에서 PUSH/POP은 항상 워드(16비트) 단위다.

  • 스택에 2바이트 단위로 넣고 뺀다.

push는 워드(16비트) 전용이다

BYTE로 선언된 변수를 push하면 error A2070: invalid instruction operands가 발생한다.

.DATA
gcount DB 5 DUP (0)
 
.CODE
push gcount       ; ❌ error A2070

8086에서 push/pop은 항상 워드 단위로 동작하므로, 피연산자는 reg16, mem16, sreg만 가능하다.

스택의 증가 방향은 6502랑 동일하다.

  • 큰 메모리 주소에서 시작해서 push 하면 2바이트씩 ss:sp가 감소한다.

8086에서 push는 즉시 피연산자를 받지 않는다

push 5            ; ❌ 불가
 
mov ax, 5         ; 레지스터에 먼저 대입
push ax           ; ✅ 레지스터를 push

그래서 어떤 값을 넣고 싶으면, 레지스터에 먼저 대입하자.

실행해서 확인하기

  • push dx
  • push [di]
  • push fcount

아래 코드를 어셈블하고 실행했다.

TITLE Main

.DOSSEG
.8086
.NO87
.MODEL TINY

.DATA
fcount DW 5 DUP (0)

.CODE
.STARTUP

	mov dx, OFFSET fcount
	mov di, 0
	
	push dx
	
	push [di]
	push fcount
	
	push es
	push ss
	push cs

exit:

	mov ah, 4Ch
	xor al, al
	int 21h

END

push dx

SP의 값이 FFFC임을 확인할 수 있다.

그렇다면 SP의 초기값이 FFFE라는 말이다.

  • push 결과 2바이트 줄어드니까

MASM 6.1 Programmer's Guide, Ch.3 p.57 — "Initializing Default Segment Registers"

“For a .COM file, the loader sets SS equal to CS and initializes SP to 0FFFEh.”

TINY 모델(.COM 파일)에서는 SPFFFE로 초기화 되는 것을 확인했다.

ss:sp 주소에 덤프해보았다.

[FFFC,FFFD]0116을 저장한다. 리틀 엔디언에 따라 낮은 자리 16FFFC에 저장된다.

push reg16레지스터 피연산자

레지스터 자체에 들어있는 16비트 값을 그대로 스택에 넣음

push [di]

push [di]의 바이너리는 Code View에서 PUSH WORD PTR [DI]라고 표현한다.

실행 중 결정되는 DI값(주소)에서 2바이트를 읽어서 스택에 푸시하라는 동작이다.

스택에 어떤 값이 들어갔는지 확인해보자.

FFFB에 20, FFFA에 CD가 들어갔다.

낮은 주소에 큰 자리 수가 들어가니 복원하면 CD20이다.

di에 들어온 값으로 최종 주소를 만들어 최종 주소에 저장된 값이 CD20가 맞는지 확인해보자.

그런데 최종 주소를 어떻게 구하지??

MASM 6.1 Programmer's Guide, Ch.3 p.58 — "Near Data"

“The processor assumes all memory references are relative to the segment in the DS register, with the exception of references using BP or SP. The processor associates these registers with the SS register.”

mov     di, [bx]    ; Reads from DS:[bx]
mov     [di], cx    ; Writes to  DS:[di]
mov     [bp+6], ax  ; Writes to  SS:[bp+6]
mov     bx, [bp]    ; Reads from SS:[bp]

[di]의 기본 세그먼트는 ds다. 따라서 ds:di로 최종 주소를 구하면 된다.

레지스터를 확인해보니 di의 값은 0000, ds의 값은 0C9E다.

  • TINY 모델이라 모든 세그먼트 레지스터의 값은 동일하다.

따라서 0C9E:0000에 덤프해서 확인해보자.

ds:di 에 저장된 값은 CD20임을 확인했다.

  • 2바이트를 읽으면 된다. push 니모닉은 2바이트 워드 단위로 동작한다.

메모리 → 레지스터 → 메모리의 바이트 순서 보존 (little endian round trip)

push [di]에서 DS:0000의 값 CD 20이 스택에도 CD 20으로 보이는 이유:

  1. DS:0000에서 워드 읽기 → 바이트 CD 20 → CPU 내부에서 20CDh(리틀 엔디언을 복원)
  2. 20CDh를 스택에 push → 리틀 엔디안 저장 → 바이트 CD 20

리틀 엔디안 → 레지스터 → 리틀 엔디안이라 메모리 덤프상 바이트 순서가 동일하게 보인다.

push [di]는 메모리 간접 피연산자

[]di 레지스터를 사용했기 때문에 알 수 있다.

push fcount

push fcount 의 바이너리는 Code View에서 PUSH WORD PTR [0116]라고 표현한다.

어셈블 시점에서 변수 fcount의 주소를 안다.

  • mov dx, OFFSET fcount가 어셈블되어 Code View에서 MOV DX,0116라고 보여준다.

OFFSET Operator

Programmer's Guide, Chapter 3 — The OFFSET Operator (p.60)

An address constant is a special type of immediate operand that consists of an offset or segment value. The OFFSET operator returns the offset of a memory location.

mov bx, OFFSET var    ; Load offset address

어셈블 시점에 메모리의 offset 주소를 반환한다. (바이너리에 적힌다.)

원본 링크

fcount의 주소에서 2바이트를 읽는 개념이다.

push fcount 는 직접 메모리 피연산자

애초에 스택에 푸시할 때 2바이트 단위로 할 수 밖에 없다.

Circular transclusion detected: pocu-note/COMP2300/011.-x86-16-니모닉/006.-스택-사용-니모닉/스택-사용-니모닉

ds:0016의 값과 스택에 들어간 값이 일치하는지 확인해보자.

ds:dx를 덤프했다.

  • dx값이 0116이니까

00 00 (2바이트)가 연속으로 5개 나열되는 것을 확인했다.

ss:sp를 덤프했다.

스택에도 00 00이 저장됨을 확인했다.

  • ^little-endian-round-trip에서 본 “메모리 → 레지스터 → 메모리의 바이트 순서 보존”가 마찬가지로 적용된다.

push es, push ss, push cs

스택에 2바이트의 주소값이 3연속으로 나오는 것을 확인할 수 있다.

  • TINY 모델이라 모든 세그먼트의 값이 동일하다.

push sreg 는 레지스터 피연산자

세그먼트 값 0C9E9E 0C 묶음으로 3연속 저장되었다.

  • “레지스터 → 메모리”라서 리틀엔디언을 확인할 수 있다.

pop: 스택에서 pop

6502와 차이점은 상태 레지스터를 바꾸지 않는다는 것

  • pop의 피연산자로 데이터는 전송된다.
  • 6502와 차이점에서 본 내용과 종합하면 다음과 같다.”8088에서 데이터 전송은 상태 레지스터를 바꾸지 않는다.”

cs는 피연산자로 사용 불가

pop cs 코드를 작성하고 어셈블하면 error A2070: invalid instruction operands 어셈블 오류가 발생한다.

코드로 확인

TITLE Main
 
.DOSSEG
.8086
.NO87
.MODEL TINY
 
.DATA
fcount DW 5 DUP (0)
myvar DW ?
 
.CODE
.STARTUP
 
mov dx, OFFSET fcount
mov di, 0
 
push dx
 
push [di]
push fcount
 
push es
push ss
push cs
 
pop cx
 
pop myvar
 
pop es
pop ds
pop ss
 
exit:
mov ah, 4Ch
xor al, al
int 21h
 
END

위와 같은 코드를 어셈블 & 실행

push cs까지 실행한 상태를 살펴보자

스택(ss:sp)를 덤프해보면 현재 스택 탑의 값은 9E 0C

  • 0C9E:FFF29E
  • 0C9E:FFF30C

pop cx

pop cx을 실행하고 다음을 확인해보자.

  • ss:sp의 값이 0C9E:FFF4로 증가
  • cx에 값이 0C 9E
    • 메모리에 9E 0C로 저장됬고, 리틀 엔디언을 고려해 복원하면 순서가 뒤집힌다.

ss:sp값이 증가한 것을 확인했다.

cx의 값이 0C 9E가 되었음을 확인했다.

pop myvar

myvar직접 메모리 피연산자이다.

  • 어셈블 시점에 주소 0128이 결정되어 바이너리에 embedded

Code View 에서 보이는 WORD PTR [0128]은 “주소 0128에서 2바이트(워드) 단위로 접근하라”라는 뜻이다.

pop myvar을 실행하고 다음을 확인하자.

  • sp의 값이 FFF6로 증가한다.
  • ds:0128(myvar의 주소)에 9E 0C가 저장된다.

ss:sp의 값이 0C9E:FFF6로 증가한 것을 확인했다.

ds:0128(myvar의 주소)에 9E 0C가 저장된 것을 확인했다.

pop es, pop ss, pop cs

모두 실행하고 다음을 확인하자.

  • ss:sp의 값이 0C9E:FFFC로 증가한다.
  • 각 레지스터에는 다음과 같이 값이 들어가야 한다. (메모리의 값이 레지스터로 전송되기 때문에 리틀엔디언을 복원해 순서가 뒤집힌다.)
    • ES: 0C 9E
    • DS: 00 00
    • SS: 20 CD

우선 실행 전 스택의 상태를 확인하자.

모두 실행 후 ss:sp의 값을 확인하면 20CD:FFFC로 나온다.

  • sp의 값은 예상대로 FFFC가 맞다. 하지만 pop ss 때문에 ss값을 덮어썼다.
  • 따라서 ss:sp를 덤프해서 스택을 확인할 수 없다.

원래 ss의 값 0C9E를 직접 넣어 덤프했다.

Circular transclusion detected: pocu-note/COMP2300/011.-x86-16-니모닉/006.-스택-사용-니모닉/스택-사용-니모닉

실행 전 스택과 비교해보면 9E 0C 00 00 CD 20이 빠지고 1E 01이 남아 있는 것을 확인할 수 있다.

Circular transclusion detected: pocu-note/COMP2300/011.-x86-16-니모닉/006.-스택-사용-니모닉/스택-사용-니모닉

예상대로 레지스터에 들어갔다.

pushf: 플래그를 스택에 push

TITLE Main
 
.DOSSEG
.8086
.NO87
.MODEL TINY
 
.DATA
 
.CODE
.STARTUP
	pushf
	popf
 
exit:
 
	mov ah, 4Ch
	xor al, al
	int 21h
 
END

위 코드를 어셈블하고 실행한다.

pushf 실행 전 플래그의 상태다. (플래그 참고)

  • NV(OF=0, No oVerflow)
  • UP(DF=0, Up)
  • EI(IF=1, Enable Interrupt)
  • PL(SF=0, PLus)
  • NZ(ZF=0,  Not Zero)
  • NA(AF=0, No Auxiliary)
  • PO(PF=0, Parity Odd)
  • NC(CF=0, No Carry)

이를 16비트 FLAGS 레지스터로 조합하여 이 값이 스택에 들어가야한다.

FLAGS 조합결과 0202h가 스택에 들어갔다.

popf: 플래그를 pop

popf 결과 기존 플래그 상태로 돌아왔다.

스택에서 02 02가 빠진 것을 확인하자.

이전 상태는 아래와 같다.

Circular transclusion detected: pocu-note/COMP2300/011.-x86-16-니모닉/006.-스택-사용-니모닉/스택-사용-니모닉