C언어는 고급 프로그래밍 언어로, CPU와 직접적으로 소통하지 않더라도 복잡한 작업을 간단히 구현할 수 있습니다. 그러나 C언어로 작성된 코드가 실제로 CPU에서 실행되기 위해서는 컴파일러에 의해 어셈블리 코드로 변환되어야 합니다. 이 과정에서 CPU 명령어가 활용되며, 어셈블리 언어는 이들 명령어와 프로그래밍 언어 사이의 다리 역할을 합니다. 본 기사에서는 C언어와 CPU 명령어, 그리고 어셈블리 언어 간의 관계를 살펴보고, 이를 효과적으로 이해하고 활용하는 방법을 제시합니다.
C언어와 CPU의 기본 연계 구조
C언어는 하드웨어에 독립적인 고급 언어로, 개발자가 복잡한 하드웨어 동작을 몰라도 프로그램을 작성할 수 있도록 설계되었습니다. 그러나 이러한 C언어 코드는 CPU가 직접 이해할 수 없으며, 컴파일러를 통해 변환 과정이 필요합니다.
컴파일러와 변환 과정
C언어 코드는 컴파일러를 통해 다음과 같은 단계를 거칩니다:
- 소스 코드: 사용자가 작성한 C언어 파일(.c).
- 어셈블리 코드: 컴파일러가 생성한 CPU에 특화된 명령어 집합.
- 오브젝트 파일: 어셈블리 코드가 기계어로 변환된 바이너리 파일(.o).
- 실행 파일: 링커가 라이브러리를 결합하여 완성한 실행 가능한 바이너리 파일(.exe, .out).
CPU 명령어와의 연계
컴파일러가 생성한 어셈블리 코드는 특정 CPU의 명령어 집합(ISA, Instruction Set Architecture)을 기반으로 작성됩니다. 이는 CPU가 명령을 해석하고 실행할 수 있는 언어이며, 각각의 명령어는 특정 작업(예: 데이터 이동, 산술 연산, 논리 연산 등)을 수행합니다.
C언어와 하드웨어 간의 다리
C언어 프로그램은 하드웨어에 접근하기 위해 다음의 요소들을 사용합니다:
- 포인터: 메모리 주소를 직접 조작하여 하드웨어 제어.
- 어셈블리 삽입:
asm
키워드를 사용하여 직접 어셈블리 명령어를 포함. - 라이브러리 호출: 표준 라이브러리를 통해 하드웨어 기능을 추상화.
이처럼 C언어는 CPU 명령어와 간접적으로 연계되어 있으며, 컴파일러와 어셈블리 언어를 통해 실제 하드웨어에서 실행됩니다.
어셈블리 언어란 무엇인가
어셈블리 언어는 CPU 명령어 집합(ISA)을 사람이 읽을 수 있는 형태로 표현한 저수준 프로그래밍 언어입니다. 이는 기계어와 1:1 대응 관계를 가지며, 프로그래머가 CPU의 동작을 세부적으로 제어할 수 있도록 합니다.
어셈블리 언어의 특징
- 저수준 언어: CPU 명령어와 직접적인 관계가 있어 하드웨어를 세밀하게 제어 가능.
- 기계어와 1:1 매핑: 어셈블리 언어의 각 명령어는 특정 기계어 명령어와 정확히 일치.
- 가독성 향상: 숫자로만 이루어진 기계어 대신 사람이 읽기 쉬운 텍스트 형식(예:
MOV
,ADD
)으로 표현.
어셈블리 언어의 기본 구성
어셈블리 코드는 다음과 같은 구성 요소로 이루어집니다:
- 명령어(Instruction): CPU가 수행할 작업(예:
MOV
,ADD
,SUB
). - 레지스터(Register): CPU 내부의 데이터 저장소(예:
AX
,BX
). - 메모리 주소: 데이터를 읽거나 쓰기 위한 메모리 위치.
- 연산자 및 오퍼랜드: 명령어에 전달되는 입력 값(예:
MOV AX, 5
).
어셈블리 언어의 역할
- CPU 명령어의 추상화: 기계어를 읽기 쉽고 관리하기 쉽게 만들어 개발 효율성을 높임.
- 하드웨어 최적화: 특정 하드웨어에 최적화된 코드를 작성 가능.
- 디버깅 및 분석 도구: 어셈블리 코드는 실행 파일을 분석하거나 최적화하는 데 유용.
C언어와 어셈블리 언어의 관계
C언어는 고급 언어이지만, 컴파일러는 이를 어셈블리 언어로 변환하여 CPU가 실행할 수 있도록 합니다. 어셈블리 언어는 이 과정에서 중요한 중간 단계 역할을 하며, 특정한 하드웨어 명령어와 직접 연결됩니다.
따라서 어셈블리 언어는 C언어를 통해 작성된 코드를 이해하고 최적화하는 데 매우 유용한 도구입니다.
C언어 컴파일 시 어셈블리 코드 생성
C언어 프로그램은 컴파일러에 의해 여러 단계를 거쳐 최종 실행 파일로 변환됩니다. 이 과정에서 어셈블리 코드는 중요한 중간 산출물로 생성되며, 이는 CPU 명령어 수준에서 코드를 최적화하거나 디버깅하는 데 유용합니다.
컴파일 과정에서의 어셈블리 코드 생성
C언어 코드가 실행 파일로 변환되는 과정은 일반적으로 다음 단계를 포함합니다:
- 전처리(Preprocessing): 매크로 확장, 헤더 파일 포함 처리.
- 컴파일(Compilation): 전처리된 코드를 어셈블리 코드로 변환.
- 어셈블(Assembling): 어셈블리 코드를 기계어로 변환하여 오브젝트 파일 생성.
- 링킹(Linking): 오브젝트 파일과 라이브러리를 결합하여 실행 파일 생성.
어셈블리 코드는 2단계에서 생성되며, 사용자는 컴파일 옵션을 통해 이를 확인할 수 있습니다.
어셈블리 코드 생성 방법
컴파일러에서 어셈블리 코드를 생성하려면 다음과 같은 명령을 사용할 수 있습니다:
예시: GCC를 사용한 어셈블리 코드 생성
gcc -S example.c -o example.s
위 명령은 example.c
파일의 어셈블리 코드를 example.s
파일로 생성합니다.
생성된 어셈블리 코드 분석
어셈블리 코드는 CPU의 명령어 집합에 따라 다르며, 다음과 같은 구문으로 구성됩니다:
.section .text
.global main
main:
pushq %rbp
movq %rsp, %rbp
movl $0, %eax
popq %rbp
ret
위 코드에서 각 명령어는 CPU가 수행할 작업을 나타냅니다.
pushq
,popq
: 스택 조작.movl
: 레지스터나 메모리 간 데이터 이동.ret
: 함수 반환.
어셈블리 코드 생성의 중요성
- 디버깅: 생성된 어셈블리 코드를 통해 코드가 예상대로 컴파일되었는지 확인 가능.
- 최적화: 컴파일러의 최적화 수준(
-O0
,-O2
등)에 따라 생성된 어셈블리 코드를 비교하여 성능을 분석. - 학습 도구: 어셈블리 언어를 학습하고 CPU 동작을 이해하는 데 유용.
C언어 컴파일 시 어셈블리 코드 생성은 코드 최적화와 CPU 명령어 동작을 심층적으로 이해하기 위한 필수 과정입니다.
CPU 명령어의 종류와 구조
CPU 명령어는 프로세서가 수행할 작업을 지정하는 가장 기본적인 단위입니다. 이들은 CPU 아키텍처(Instruction Set Architecture, ISA)에 따라 정의되며, 특정 작업을 실행하기 위한 다양한 형태의 명령어로 구성됩니다.
CPU 명령어의 주요 종류
CPU 명령어는 그 기능에 따라 여러 가지로 분류됩니다:
- 데이터 이동 명령어
데이터를 레지스터, 메모리, I/O 장치 간에 전송하는 명령어입니다.
- 예:
MOV
(데이터 이동),PUSH
(스택에 값 저장),POP
(스택에서 값 꺼내기).
- 산술 연산 명령어
산술 계산을 수행하는 명령어입니다.
- 예:
ADD
(덧셈),SUB
(뺄셈),MUL
(곱셈),DIV
(나눗셈).
- 논리 연산 명령어
비트 수준에서 논리 연산을 수행하는 명령어입니다.
- 예:
AND
(논리곱),OR
(논리합),XOR
(배타적 논리합),NOT
(논리부정).
- 제어 흐름 명령어
프로그램의 실행 흐름을 변경하는 명령어입니다.
- 예:
JMP
(무조건 점프),JE
/JNE
(조건부 점프),CALL
(함수 호출),RET
(함수 반환).
- 비교 명령어
값을 비교하여 조건 플래그를 설정하는 명령어입니다.
- 예:
CMP
(비교),TEST
(테스트).
- 입출력 명령어
외부 장치와 데이터를 교환하는 명령어입니다.
- 예:
IN
(입력),OUT
(출력).
CPU 명령어의 구조
CPU 명령어는 일반적으로 다음과 같은 형식을 가집니다:
명령어 [대상 오퍼랜드], [소스 오퍼랜드]
- 명령어: 실행할 작업의 종류(예:
MOV
,ADD
). - 오퍼랜드: 명령어가 작업을 수행하는 대상.
- 레지스터(예:
AX
,BX
). - 메모리 주소(예:
[0x1000]
). - 즉시 값(예:
10
).
예시 코드:
MOV AX, 5 ; 5를 레지스터 AX에 이동
ADD AX, BX ; 레지스터 AX에 BX 값을 더함
JMP 0x2000 ; 메모리 주소 0x2000으로 실행 흐름 변경
CPU 명령어의 중요성
- 효율성: 고급 언어로 작성된 코드의 실행을 최적화하기 위해 CPU 명령어의 구조와 동작을 이해해야 함.
- 최적화: 특정 명령어 집합을 활용하여 실행 속도를 높이고 자원 사용을 줄일 수 있음.
- 디버깅: 어셈블리 수준에서 명령어를 분석하면 소프트웨어 오류를 해결하는 데 도움이 됨.
CPU 명령어는 프로세서의 핵심 기능을 정의하며, 어셈블리 언어와 C언어 사이의 연결 고리로서 중요한 역할을 합니다.
어셈블리 코드 최적화와 C언어
C언어 프로그램은 컴파일 과정에서 어셈블리 코드로 변환됩니다. 이 단계에서 컴파일러는 성능을 높이기 위해 다양한 최적화 기법을 적용하며, 이는 실행 속도와 자원 사용 효율에 직접적인 영향을 미칩니다.
어셈블리 코드 최적화란?
어셈블리 코드 최적화는 CPU 명령어를 효율적으로 구성하여 프로그램 실행 속도를 향상시키고 메모리 사용량을 줄이는 작업을 의미합니다. 이는 컴파일러가 자동으로 수행하거나, 프로그래머가 수동으로 조정할 수 있습니다.
컴파일러 최적화 옵션
컴파일러는 코드 최적화를 위해 다양한 옵션을 제공합니다. GCC를 예로 들면:
- -O0: 최적화 없음. 디버깅 목적으로 사용.
- -O1: 기본적인 최적화. 불필요한 연산 제거.
- -O2: 고급 최적화. 루프 전개, 명령어 병합 등.
- -O3: 최적화를 극대화. 더 많은 CPU 자원을 활용.
- -Ofast: 규격을 일부 무시하고 성능을 극대화.
예시:
gcc -O2 example.c -o example
위 명령은 -O2
최적화를 적용하여 컴파일합니다.
어셈블리 코드 최적화 기법
컴파일러는 다음과 같은 기법을 활용하여 어셈블리 코드를 최적화합니다:
- 명령어 병합
비효율적인 명령어를 단순화하거나 결합하여 실행 시간을 줄입니다.
- 예: 여러
MOV
명령어를 하나로 통합.
- 루프 최적화
루프 전개(Loop Unrolling)와 같은 기법을 사용하여 반복문의 실행 효율을 높입니다.
- 예:
c for (int i = 0; i < 4; i++) a[i] = b[i];
컴파일러는 이를 전개하여 어셈블리 코드에서 반복을 줄입니다.
- 불필요한 코드 제거
사용되지 않는 변수나 함수 호출을 제거하여 코드 크기를 줄입니다. - 레지스터 활용 최적화
데이터가 메모리 대신 레지스터에서 처리되도록 하여 메모리 접근을 최소화합니다.
프로그램 성능에 미치는 영향
- 실행 속도 향상: 명령어 수를 줄이고 병목 현상을 완화.
- 메모리 사용 감소: 데이터 접근을 최적화하여 메모리 효율을 높임.
- 배터리 효율 개선: 모바일 장치에서 전력 소모 감소.
C언어와 수동 최적화
컴파일러의 자동 최적화 외에도 C언어 코드 작성 시 직접적인 최적화 작업을 수행할 수 있습니다:
- 효율적인 알고리즘 선택
- 사용하지 않는 변수 제거
- 메모리 접근 최소화
- 어셈블리 삽입 사용: 성능이 중요한 부분에 직접 어셈블리 코드를 추가.
예시: 어셈블리 삽입
asm("movl %eax, %ebx"); // 특정 작업을 수동으로 최적화
최적화의 한계
최적화는 항상 장점만 있는 것은 아닙니다.
- 디버깅 어려움: 고급 최적화로 인해 디버깅이 복잡해질 수 있음.
- 코드 가독성 감소: 지나친 최적화는 유지보수를 어렵게 만듦.
C언어의 어셈블리 코드 최적화는 프로그램 성능을 높이는 데 중요한 역할을 하며, 컴파일러 옵션과 수동 최적화를 병행하면 더 큰 효과를 얻을 수 있습니다.
디버깅을 위한 어셈블리 언어 활용
C언어로 작성된 프로그램의 디버깅 과정에서 어셈블리 코드를 활용하면, 소스 코드 수준에서는 파악하기 어려운 문제를 해결할 수 있습니다. 어셈블리 코드는 프로그램의 실제 동작을 CPU 명령어 수준에서 확인할 수 있도록 하며, 특히 성능 문제나 메모리 관련 오류를 진단하는 데 유용합니다.
어셈블리 코드를 활용한 디버깅의 이점
- 정밀한 분석: 변수 값, 레지스터 상태, 메모리 접근 등을 세부적으로 확인 가능.
- 최적화 코드 확인: 컴파일러가 적용한 최적화 결과를 분석하여 의도치 않은 동작을 발견.
- 저수준 문제 해결: 포인터 오류, 메모리 누수 등 고급 언어로는 확인하기 어려운 문제를 해결.
어셈블리 코드 디버깅 방법
- 컴파일러 옵션을 사용하여 어셈블리 코드 확인
디버깅 목적으로 어셈블리 코드를 생성하려면 컴파일 시-g
(디버깅 정보 포함)와-S
옵션을 사용합니다.
gcc -g -S example.c -o example.s
생성된 example.s
파일을 통해 어셈블리 코드를 확인할 수 있습니다.
- 디버거 사용
디버거 도구(gdb 등)를 활용하면 실행 중인 프로그램의 어셈블리 코드를 직접 확인할 수 있습니다.
예시: gdb 명령어
gdb ./example
디버거 내부에서 disassemble
명령을 사용하여 어셈블리 코드를 확인합니다.
(gdb) disassemble main
- 레지스터 및 메모리 상태 확인
디버거에서 다음 명령을 통해 레지스터와 메모리 상태를 확인할 수 있습니다:
info registers
: 현재 레지스터 값 확인.x
명령: 특정 메모리 주소의 값을 확인.gdb (gdb) x/4x $rsp
어셈블리 코드를 활용한 문제 해결
- 최적화로 인한 코드 동작 오류
컴파일러의 최적화가 예상과 다른 코드를 생성하는 경우, 어셈블리 코드를 확인하여 원인을 파악할 수 있습니다.
-O0
옵션을 사용하여 최적화를 비활성화하고 문제를 좁힙니다.
- 포인터와 메모리 접근 문제
잘못된 메모리 주소에 접근하거나 잘못된 값을 참조할 때, 어셈블리 코드를 통해 메모리 읽기/쓰기 동작을 추적할 수 있습니다. - 무한 루프 및 잘못된 분기
어셈블리 명령어(JMP
,JE
등)를 확인하여 프로그램 흐름이 왜곡되는 지점을 분석합니다.
예시: 디버깅 중 분기 문제
main:
cmp $5, %eax ; eax와 5 비교
je 0x200 ; 같으면 0x200으로 점프
...
위 코드에서 조건 분기가 예상과 다르게 동작하는 경우, 디버거를 통해 비교 명령어와 레지스터 값을 확인하여 문제를 해결합니다.
디버깅 도구 예시
- GDB: 강력한 명령줄 디버거로 어셈블리 수준 디버깅 지원.
- IDA Pro: 정적 분석 도구로 실행 파일의 어셈블리 코드를 시각적으로 확인.
- LLDB: LLVM 기반의 디버거로 GDB와 유사한 기능 제공.
실습: GDB를 활용한 어셈블리 디버깅
- 디버깅 준비
gcc -g example.c -o example
gdb ./example
- 브레이크포인트 설정 및 실행
(gdb) break main
(gdb) run
- 어셈블리 코드 확인 및 분석
(gdb) disassemble main
(gdb) info registers
어셈블리 코드를 활용한 디버깅은 복잡한 문제를 해결하고 프로그램의 동작을 심층적으로 이해하는 데 필수적인 도구입니다.
C언어와 어셈블리의 협력 프로그래밍
C언어는 고급 언어의 편의성과 어셈블리 언어의 하드웨어 제어 능력을 결합하여 강력하고 효율적인 프로그램을 작성할 수 있도록 합니다. 이 협력 프로그래밍은 성능이 중요한 애플리케이션(예: 시스템 소프트웨어, 임베디드 시스템)에서 특히 유용합니다.
어셈블리 코드와 C언어의 혼합 사용
C언어는 asm
키워드를 사용하여 어셈블리 코드를 직접 삽입할 수 있습니다. 이는 고성능 연산, 하드웨어 제어, 또는 최적화가 필요한 특정 작업에서 유용합니다.
예시: 어셈블리 코드 삽입
#include <stdio.h>
int add_numbers(int a, int b) {
int result;
asm("addl %%ebx, %%eax" // 어셈블리 명령어
: "=a"(result) // 출력 오퍼랜드
: "a"(a), "b"(b)); // 입력 오퍼랜드
return result;
}
int main() {
printf("Sum: %d\n", add_numbers(5, 3));
return 0;
}
위 코드에서 asm
키워드를 사용하여 두 정수의 합을 어셈블리 명령어로 계산했습니다.
협력 프로그래밍의 응용 사례
- 임베디드 시스템 개발
C언어는 하드웨어 제어를 위해 어셈블리와 함께 사용됩니다.
- 특정 하드웨어 레지스터를 직접 조작.
- 인터럽트 처리 루틴 구현.
- 고성능 연산
성능이 중요한 경우, 어셈블리 코드를 사용하여 계산 속도를 최적화할 수 있습니다.
- 예: 암호화 알고리즘의 핵심 루틴.
- 하드웨어 접근
특정 CPU 명령어나 하드웨어 기능을 호출할 때 사용됩니다.
- 예: CPU의 SIMD 명령어를 활용한 벡터 연산.
어셈블리와 C언어 간 데이터 교환
C언어와 어셈블리 간의 데이터 교환은 컴파일러에 의해 지원되며, 아래와 같은 방식으로 이루어집니다:
- 레지스터를 통한 교환
특정 변수 값을 CPU 레지스터에 저장하여 어셈블리에서 사용. - 메모리를 통한 교환
변수를 메모리에 저장하고 어셈블리 코드가 이를 참조.
예시: 레지스터와 메모리 사용
asm("movl %1, %%eax; addl %%eax, %0"
: "=r"(result)
: "r"(value1), "r"(value2));
어셈블리 삽입 시 유의점
- 호환성
어셈블리 코드는 특정 아키텍처(CPU)에 종속적입니다. 다른 플랫폼에서 작동하려면 수정이 필요할 수 있습니다. - 가독성
코드의 가독성이 낮아지고 유지보수가 어려워질 수 있습니다. 어셈블리 사용을 최소화하고 문서화를 철저히 해야 합니다. - 디버깅
어셈블리 코드는 디버깅이 더 복잡할 수 있으므로, 디버깅 도구(gdb 등)를 적극적으로 활용해야 합니다.
협력 프로그래밍의 장단점
장점
- 성능 극대화 가능.
- 하드웨어와의 직접적인 상호작용.
- 시스템 리소스 제어 용이.
단점
- 플랫폼 종속성 증가.
- 코드 복잡성 증가.
- 유지보수 및 디버깅의 어려움.
C언어와 어셈블리를 함께 사용하는 협력 프로그래밍은 성능과 효율성이 중요한 프로젝트에서 유용합니다. 하지만 이러한 기술은 적절히 제한적으로 사용하고, 문서화를 철저히 하여 유지보수성을 확보해야 합니다.
실습: 간단한 어셈블리 코드 확인
C언어로 작성된 프로그램의 어셈블리 코드를 확인하는 실습은 CPU 동작 원리를 이해하고 성능을 최적화하는 데 유용합니다. 이를 통해 컴파일러가 생성한 저수준 명령어를 분석하고 코드가 올바르게 변환되었는지 검증할 수 있습니다.
실습 준비
- 컴파일러 설치
GCC 또는 Clang과 같은 C언어 컴파일러가 필요합니다.
- GCC 설치(예: Ubuntu):
bash sudo apt update sudo apt install gcc
- 샘플 C 코드 작성
간단한 코드를 작성하여 컴파일 후 어셈블리 코드를 확인합니다. example.c
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(5, 3);
printf("Result: %d\n", result);
return 0;
}
어셈블리 코드 생성
컴파일러의 -S
옵션을 사용하여 C 코드에서 어셈블리 코드를 생성합니다.
명령어:
gcc -S example.c -o example.s
-S
: 어셈블리 코드 생성.-o example.s
: 출력 파일 이름 지정.
생성된 어셈블리 코드 분석
example.s
파일은 다음과 같은 어셈블리 코드를 포함합니다:
example.s
.section .text
.global add
add:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
코드 설명
- 함수 선언 및 스택 초기화
pushq %rbp
: 이전 프레임 포인터 저장.movq %rsp, %rbp
: 새로운 프레임 포인터 설정.
- 매개변수 저장
movl %edi, -4(%rbp)
: 매개변수a
를 스택에 저장.movl %esi, -8(%rbp)
: 매개변수b
를 스택에 저장.
- 연산 수행
movl -4(%rbp), %edx
:a
값을 레지스터로 이동.movl -8(%rbp), %eax
:b
값을 레지스터로 이동.addl %edx, %eax
:a + b
연산 수행.
- 리턴 및 스택 복구
popq %rbp
: 이전 프레임 복원.ret
: 함수 반환.
디버깅과 실행 중 어셈블리 코드 확인
디버깅 도구(GDB)를 사용하여 실행 중 어셈블리 코드를 확인할 수 있습니다.
- GDB 시작
gdb ./example
- 브레이크포인트 설정 및 실행
(gdb) break add
(gdb) run
- 어셈블리 코드 디스어셈블
(gdb) disassemble add
- 레지스터 값 확인
(gdb) info registers
실습을 통한 학습 효과
- 컴파일러 동작 이해: 고급 언어 코드가 저수준 명령어로 변환되는 과정을 학습.
- 최적화 분석: 컴파일러 최적화 수준(
-O0
,-O2
등)에 따른 어셈블리 코드 비교. - 디버깅 능력 향상: 실행 중 어셈블리 코드를 분석하여 문제 해결 능력 강화.
이 실습은 C언어와 어셈블리 간의 관계를 이해하는 좋은 출발점이며, 프로그래밍 성능 및 하드웨어 동작에 대한 깊은 통찰을 제공합니다.
요약
본 기사에서는 C언어와 CPU 명령어, 그리고 어셈블리 언어 간의 관계를 체계적으로 탐구했습니다. C언어 코드는 컴파일러를 통해 어셈블리 코드로 변환되며, 이는 CPU 명령어로 실행됩니다. 컴파일 과정, 어셈블리 언어의 역할, 최적화 기법, 디버깅 활용, 그리고 협력 프로그래밍과 같은 다양한 측면을 다뤘습니다.
이를 통해 C언어가 CPU와 어떻게 상호작용하는지, 어셈블리 언어가 성능 최적화와 디버깅에서 어떤 가치를 제공하는지 이해할 수 있었습니다. 이러한 지식은 시스템 성능을 극대화하고, 복잡한 문제를 해결하며, 더 깊은 프로그래밍 통찰을 얻는 데 필수적입니다.