Switch/Case 구문은 정수형 표현식의 평가 결과에 대해 실행 흐름을 여러 갈래로 나누는 다중 분기 제어문이다. -동작 원리 : 컴파일러는 switch문을 만났을 때, case 값들을 바탕으로 메모리 공간에 점프 테이블(Jump Table)이라는 일종의 배열을 생성한다. -성능적 이점 : if/else는 위에서부터 조건을 하나하나 검사해야 하므로 최악의 경우 모든 조건을 다 확인해야 하는 상황이 생길 수 있지만, Switch/Case는 점프 테이블의 인덱스를 찾아 '단 한 번의 메모리 참조' 만으로도 해당 코드 위치로 PC(Program Counter)를 즉시 이동시킨다. -제약 사항 : 소괄호 안의 조건식과 case의 값은 반드시 정수형(int, char, enum)이어야 한다. 실수형(float, double)이나 문자열("Hello")은 사용할 수 없다.
2. Switch/Case 구문의 형태
크게 '정상적인 탈출'과 '의도적인 통과' 두 가지 형태로 쓰인다.
1) 정상적인 탈출 각 Case마다 할 일을 마치면 break를 만나 Switch블록 전체를 빠져나온다. 만약 매칭되는 case가 없으면 default가 실행된다.
int gear = 2;
switch (gear) {
case 1:
printf("1단 기어\n");
break;
case 2:
printf("2단 기어\n");
break;
default:
printf("중립 기어 또는 잘못된 기어");
break;
}
2) 의도적인 통과(Intentional Fall-through) break를 일부러 적지 않으면, 아래에 있는 case까지 곧바로 내려가며 모두 실행된다. "여러 조건이 똑같은 행동을 해야 할 때"코드를 줄여준다.
char grade = 'A';
switch (grade) {
case 'A':
case 'B':
case 'C':
// A, B, C 중 하나라도 걸리면 이 문장이 실행됨
printf("합격입니다.\n");
break;
case 'D':
case 'F':
printf("불합격입니다.\n");
break;
}
여기서, 앞의 설명에 약간의 오류가 있다. case값이 연속적인 정수로 구성된 배열인 경우에는 점프 테이블을 생성하지만, 그렇지 않은 경우에는 다른 방법들을 사용한다. 이에 더해 점프 테이블이 컴파일러를 거쳐 기계어(Assembly)로 바뀔 때, 메모리와 레지스터에서 어떠한 과정이 일어나는지도 ARM구조를 기준으로 살펴본다.
A. 점프 테이블의 조건 : "값의 밀도"
점프 테이블은 메모리 시작 주소+(case 값*4bit)라는 단순한 산수 공식으로 작동한다. 따라서 case 값이 배열의 인덱스 역할을 하려면 중간중간 비어있으면 안 된다.
a. 상황 1 : 완벽하게 연속적인 경우(case 0, case 1, case 2, case 3) -점프 테이블이 생성되어 낭비되는 메모리 없이 완벽하게 배열로 만든다. b. 상황 2 : 약간 비어있는 경우(case 1, case 2, case 4, case 5) -점프 테이블이 생성된다. 중간에 비어있는 case 3의 자리를 배열에 만들지만, 그 자리에는 default(어떤 case값에도 해당되지 않을 경우) 문으로 가는 주소를 채워 넣는다. c. 상황 3 : 완전히 흩어진 경우(case 1, case 100, case 10000) -점프 테이블을 포기한다. 배열을 만들면 9997개의 빈 방을 만들어야 하므로 메모리가 많이 낭비된다. 이때 컴파일러는 점프 테이블을 버리고 '다른 계획'을 사용한다.
B. 플랜 b : "이진 탐색(Binary Search)" 점프 테이블을 만들 수 없을 정도로 값이 퍼져있을 때, 컴파일러는 코드를 이진 탐색 트리(Binary Search Tree)방식으로 몰래 재조립한다. 재조립하는 과정에서 세 단계의 과정을 거친다. 예시) case 10, case 50, case 100, case 200, case 500 이 있는 경우(값이 넓게 퍼져있고 많음) i. 중간값 선택 : 컴파일러는 먼저 중간값(100)과 현재 값을 비교한다. ii. 반으로 쪼개기 : 100보다 값이 크다면, 10과 50은 검사할 필요가 없으므로 버려진다. iii. 반복 : 남은 절반 중에서 다시 중간값을 비교한다. 이 방식을 사용하면 조건이 100개더라도 100번 다 검사할 필요 없이, 단 7번의 비교만으로 원하는 코드를 찾아낼 수 있다.
C. 플랜 c : "순차 탐색(Linear Search)" 만약 case가 10, 100, 1000(값이 넓게 퍼져있고 적음)처럼 퍼져있긴 한데, 그 개수가 겨우 몇개밖에 안 된다면 어떨까? 이때는 이진 탐색 트리를 구성하는 복잡한 기계어를 만드는 것 자체가 더 손해이다. 그래서 컴파일러는 최후의 수단으로 switch문을 순차적인 if/else if/else구조로 취급하여 위에서부터 하나씩 정직하게 비교한다.
D. 점프 테이블 처리 과정 다시 점프 테이블이 컴파일러와 레지스터 상에서 어떻게 처리되는지 알아본다. 다음과 같은 switch 구문이 있다. 예시 코드⬇️
int mode = 2;
switch(mode){
case 0:
do_task_A();
break;
case 1:
do_task_B();
break;
case 2:
do_task_B();
break;
case 3:
do_task_B();
break;
default:
do_error();
break;
}
1단계 : 한계값 검사(Bounds Checking) 안전을 위해 가장 먼저 수행하는 작업이다. mode 값이 테이블의 크기(3)을 벗어나는지 확인한다. -R0 레지스터에 현재 mode 값(2)을 가져온다. -R0의 값과 최대값 3을 비교한다. -만약 R0의 값이 3보다 크면, 추가 확인 없이 default 코드가 있는 주소로 점프한다.
2단계 : 목표 주소 계산 mode가 0~3 사이의 정상적인 값임이 확인되면, 점프 테이블에서 목적지 주소를 꺼낸다. -R1 레지스터에 '점프 테이블의 시작 주소(Base Address)'를 넣는다. (시작 주소 가정 : 0x0800_2000) -주소는 보통 4바이트(32비트)크기이므로, R0값에 4를 곱해서 '오프셋(offset)'을 구한다. (현재 mode가 2이므로 offset은 2*4=8이 된다.) -R1 + offset 계산을 한다. (0x0800_2000 + 8 = 0x0800_2008)
3단계 : 주소 가져와 실행 찾아낸 테이블의 특정 방(0x0800_2008)에 접근해서, 그 안에 적힌 진짜 목적지 주소를 꺼내와 프로그램 카운터(PC)에 넣는다. -R1(테이블 시작 주소)에서 R0(2)를 4배한 위치로 가서, 그곳에 적힌 데이터(0x0800_1040)를 읽어온다. -그 읽어온 값을 PC에 덮어쓴다. -PC값이 0x0800_1040으로 바뀌는 순간, CPU는 즉시 case 2: 내의 코드를 실행하기 시작한다.
사실, 점프 테이블에 관한 설명을 제외한 부분은 정리하지 않을 수도 있었다. 하지만 정리하다 보니 연속된 정수의 배열이 아닌 경우에는 처리 방식이 다를 수 있다는 생각이 들었고, 그렇다면 그때는 어떻게 처리를 진행하게 되는지 궁금증이 생겨 추가적인 학습을 진행하게 되었다. 또한 이러한 조건문을 컴파일러와 레지스터 상에서 어떻게 처리하게 되는지 임베디드 시스템적인 관점에서 바라보는 것이 중요하다고 생각해 앞으로도 이러한 방향으로 학습을 진행할 것이다.