본문 바로가기

언어/C

[C언어] What is scanf()?

728x90
반응형

INDEX

1. 함수 원형

2. 주소 연산자(&)

3. 형식 지정자

4. 위험성

 

printf 함수와 함께 가장 많이 접한 함수 중 하나일 것이다. 사용자로부터 값을 입력받을 수 있는 꽤나 유용한 함수이기 때문에 gets(), fgets()와 더불어 PS를 할 때 가장 많이 사용되는 함수임은 과언이 아니다. 입력받는 데이터의 구분은 줄 바꿈, 탭, 띄어쓰기가 있다. 하지만 scanf는 정확히 어떤 원리로 동작할까?

1. 함수 원형

#include <stdio.h>
int scanf(const char* restrict format, ...);

원형과 인자의 원리(고정 인수, 가변 인자)는 printf와 크게 다르지 않다. 데이터를 입력받기 위해서는 일단 데이터 타입에 해당하는 크기의 메모리는 먼저 할당받아야 하기 때문에 형식 지정자(%c, %s, %d...)가 사용됨이 printf와 크게 다르지 않은 이유이다. 하지만 다소 독특하지만 그냥 넘어간 사실이 있다. 그것은 바로 주소 연산자(&)의 사용이다.

 

2. 주소 연산자(&)

왜 주소 연산자를 사용할까? scanf의 목적은 엄연히 변수한테 값을 지정해주는 것이 아니라 변수의 값을 변형시키는 것에 더 가깝다. 변수를 먼저 선언한 후에 scanf를 사용하는 행위는 결국 선언과 동시에 들어간 쓰레기 값을 대신해서 어떤 값을 사용자가 대입하고 싶은지 scanf를 통해 묻는 것이다. scanf도 엄연히 함수이기 때문에 함수 밖에 있는 변수 값에 영향을 미치려면 포인터, 즉 주소를 이용해서 전달해야 한다. 아래의 예를 살펴보면 이해하기 쉽다.  

 

값 변경 성공 코드

#include <stdio.h>
void add_ten(int* n){
    *n+=10;
}
int main(){
    int num=10;
    add_ten(&num);
    printf("%d", num);
    return 0;
}

값 변경 실패 코드

#include <stdio.h>
void add_ten(int n){
    n+=10;
}
int main(){
    int num=10;
    add_ten(num);
    printf("%d", num);
    return 0;
}

두 코드의 차이점은 뭔가? 함수에서 인자를 전달 받게 되면 인자는 전달받은 변수의 값만 날름 갖고 오기 때문에 변수 자체에 할당된 값에는 아무런 영향을 미치지 못한다. 물론 배열은 예외지만...

 

따라서 printf와는 다르게 변수의 값을 변경시키기 때문에 주소값을 통해 메모리에 접근해야 한다. 그래서 &가 사용된 것이다.

 

3. 형식 지정자

#include <stdio.h>
int main(){
    double num=3.141;
    printf("%f\n", num); // 출력 : 3.141000
    scanf("%f", &num); // 3.141 입력
    printf("%f\n", num); // 출력 : 3.140999
}

위 코드의 문제점이 뭘까? 소수점을 다루고 있기 때문에 분명 형식 지정자 쪽에 문제가 있음을 빠르게 눈치챌 수 있다. 

 

가변 인자는 값을 넘기면 일정한 규칙에 따라 자료형이 넘어가게 된다.

char, short는 int로, float는 double로 넘어가는 규칙이 있다. 하지만 포인터의 경우 이러한 자료형 변형이 일어나지 않는다. 따라서 딱 맞게 형식을 맞춰줘야 하기 때문에 float는 %f, double은 %lf를 사용해야 한다. 

4. 위험성

scanf를 이용해서 유저로부터 데이터를 입력 받는 프로그램을 짜보면 바로 드러나는 특성 중 하나가 버퍼 오버런(오버플로우), 메모리 오염(stack corruption)이다. 심지어 비주얼 스튜디오(VS)에서는 scanf를 적는 것만으로도 unsafe 하다고 질색한다(그래서 일단 사용이라도 하고 싶으면 코드의 헤더 파일보다 위에 #define _CRT_SECURE_NO_WARNINGS를 적어야 한다). 이러한 문제는 왜 발생할까?

 

사실 모든 원흉은 scanf는 입력을 읽는 것이 아니라 형식 지정자에 따라 입력을 파싱하기 때문이다. 파싱이란 일방적으로 텍스트에서 유의미한 데이터를 얻는 것이다. 즉 사용자가 형식 지정자에 맞지 않는 입력을 해도 일단 받아들인다는 사실이다. 아래의 예시를 보면 이해하기 쉽다.

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h> 

int main(void)
{
    int a;
    char garbage[10];
    printf("enter a : ");
    scanf("%d", &a);
    printf("enter garbage : ");
    scanf("%s", garbage);
    printf("a : %d\ngarbage : %s", a, garbage);

    return 0;
}

 

여기서 a의 값을 입력 받을 때 정수가 아닌 "34df\n"(엔터키까지 입력 스트림에 남아 있을 것이다)를 적으면 어떻게 될까? 상식대로라면 잘못된 값이라서 프로그램을 멈춰야 될 텐데 scanf는 위에서도 강조했듯이 파싱 할 수 없는 문자를 마주쳤을 때 더 이상 읽으려 하지도 않고 끝내버린다.

 

즉 위 코드에서 "34df\n"를 입력하면 다음과 같은 절차가 일어난다.

  1. "34df\n"를 읽다가 숫자가 아닌 'd'를 마주친 시점에서 34까지의 값을 a에 저장한다.
  2. 입력 스트림에 남은 "df\n"은 전부 garbage 배열로 옮겨 진다. 왜냐하면 '\n'을 만났기 때문이다.
  3. 즉 "34df\n"을 입력한 시점에서 프로그램은 종료되고 아래의 결과를 반환한다.

 

그렇다면 fflush를 이용해서 입력 버퍼를 지워버리면 되지 않을까 생각이 든다. 하지만 개념 교재에서 많이 보이는 fflush는 사실 비표준이기 때문에 VS에서는 아예 효력이 없다. 그래서 더 자주 이용하는 표현은 입력 스트림에서 특정 문자를 지속적으로 흡수하게끔 getchar()을 이용하는 방법이다.

#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h> 

int main(void)
{
    int a;
    char garbage[10];

    printf("enter a : ");
    while (scanf("%d", &a) != 1) { // 정수를 정상적으로 받았으면 scanf는 1을 반환할 것
        while (getchar() != '\n'); // 입력 스트림 '\n' 만날 때까지 비우기
        printf("enter a : ");
    }

    while (getchar() != '\n'); // 또 비우기
    printf("enter garbage : ");
    scanf("%s", garbage);
    printf("a : %d\ngarbage : %s", a, garbage);
    while (getchar() != '\n');

    return 0;
}

 

이제 아래와 같은 결과를 볼 수 있다.

 

또 존재하는 위험성은 다음와 같다. 이 특성은 VS보다 CLion에서 더 두드러지게 보이니 CLion을 가지고 해 보겠다.

#include <stdio.h>

int main() {
    char var[3]="hi";
    char arr[3];
    printf("Address of arr: %p\n", &arr);
    printf("Address of var: %p\n", &var);
    scanf("%s", arr);
    printf("arr : %s\nvar : %s", arr, var);
    return 0;
}

 

스택은 아래에서부터 쌓이므로 후입선출(Last In First Out) 성격을 띤다. 그래서 var이 arr보다 먼저 선언되었으므로 주소값이 작을 것이다. 

 

위 결과를 바탕으로 스택의 현 구조를 그려보면 다음과 같다.   

16진수의 주소 차이 d(13)-a(10)을 통해서 3바이트의 차이가 있을을 알 수 있다

 

참고로 이미 var에는 "hi"가 저장되어 있다. 이때 arr를 scanf로 받을 때 길이가 2(널 문자 때문에)보다 큰 문자열을 입력하면 arr에 3바이트 이상의 데이터가 들어가기 때문에 var 메모리를 침범해서 메모리 오염(stack corruption)이 일어난다.

 

결론적으로 var에 미리 저장되어 있던 "hi"는 다른 값으로 바뀌게 된다.

 

참고로 세 글자 "ove"만 입력해도 var의 값이 변동한다.

 

메모리 관점에서 보았을 때 arr에 'o', 'v', 'e'까지 담기고 입력의 마지막이기 때문에 널 문자를 담는데 그 공간이 하필이면 var[0]이다. 배열은 문자열의 종료를 널 문자로 인식하기 때문에 var을 통째로 출력할 때 널 문자 앞에 아무것도 없기 때문에 아무것도 출력하지 않는다. 하지만 var[1]에는 여전히 "hi"의 'i'가 저장되어 있다!

 

이제 arr 뒤에 var이 아니라 중요한 데이터들이 실제로 포진되어 있다고 생각해 보자. 입력 하나만 잘못해도 프로그램 전체가 돌아가지 않게 된다. 그래서 사용할 수 있는 해결책은 입력 크기 자체를 제한하는 것이다.

#include <stdio.h>
int main() {
    char var[3]="hi";
    char arr[3];
    printf("Address of arr: %p\n", &arr);
    printf("Address of var: %p\n", &var);
    scanf("%2s", arr); // 문자 2개까지만 받기 
    printf("arr : %s\nvar : %s", arr, var);
    return 0;
}

728x90
반응형

'언어 > C' 카테고리의 다른 글

[C언어] 포인터란?  (0) 2024.02.21
[C언어] What is printf()?  (0) 2023.09.11