본문 바로가기

개발공부(C, C++)

따배씨(따라 배우는 C언어_홍정모) Chapter12

CHAPTER 12 Storage Classes, Linkage and Memory Management

"I can do this all day!" _Captain America

"모든 것이 완벽하다면, 배울 수도 없고 성장할 수도 없다." _비욘세 지젤 놀스

12.3 변수의 영역과 연결 상태, 객체의 지속시간

전역변수는 file scope variable, 즉 파일 영역 변수라고도 하는데, 전역변수라고 주로 부르는 이유는 기본적으로 main() 함수 밖에 있는 변수나 함수는 다른 파일에서도 쓸 수 있기 때문이다. 즉, 내가 어떤 파일의 file scope(main() 밖)에 정의한 변수나 함수는 다른 파일에서 앞에 extern 이란 키워드를 붙여 다른 파일에서 가져다 쓸 것임을 표시할 수 있다. 이 때 처음 정의된 곳은 defining declaration 했다고 하고, 다른 파일에서 그 전역변수나 함수를 불러다 쓰기 위해 extern 키워드를 붙여 다시 선언한 것은 referencing declaration이라고 한다. 이런 전역변수간의 외부 연결은 컴파일러가 파일들을 링킹할 때 알아서 다른 파일에서 찾아다 연결해준다. 이 extern 키워드는 외부 파일 뿐 아니라 같은 파일 내에서도 새로 지역변수를 선언하는 게 아니라 전역변수를 쓸 것임을 알리기 위해서도 붙인다. 그런데 이 extern 키워드는 대부분의 경우 생략되며, 문맥을 명확히 하기 위해 명시적으로 붙일 때만 쓴다. 붙이는 습관을 들이는 게 좋다.

static 키워드를 붙여 전역변수를 선언해주면, 외부 연결이 막힌다. static 키워드가 file scope with internal linkage 로 linkage type 을 제한하는 것이다.

12.4 ~ 12.9 저장 공간의 다섯 가지 분류 (Storage Classes)

  1. 자동(Automatic)

    • 메모리 위치 : 스택
    • 지속 시간 : 자동적으로 결정됨
    • 영역 : 블록 안
    • 연결 : 없음
    • 선언 방법 : 블록 안 선언 (지역 변수)

    자동 변수임을 나타내기 위해 auto 라는 키워드를 앞에 쓸 수 있고, 실제로 이게 생략되어 있다고도 볼 수 있는데,(예: auto int a;) 이건 c++ 에서 자료형을 추론하기 위해 사용하는 auto 랑은 전혀 다른 것이다.. 주의하자.

    자동 변수(지역 변수) 는 반드시 초기화해주어야 한다.

  2. 레지스터(Register)

    • 메모리 위치 : 레지스터(또는 스택)
    • 지속 시간 : 자동적으로 결정됨
    • 영역 : 블록 안
    • 연결 : 없음
    • 선언 방법 : register 키워드 선언(컴파일러가 알아서 하기도 함)

    레지스터는 CPU 의 작업공간. cpu 와 메모리는 물리적으로 분리되어 있고, bus 를 통해 데이터를 주고 받는데, 레지스터는 cpu 가 가지고 있는 일종의 메모리라, bus 를 통할 필요가 없어서 엄청나게 빠르다. 문법적으로 차이가 있다면, 레지스터에 들어가는 것이기에 & 를 통해 주소값을 가져올 수 없다.

    요즘은 잘 안 쓴다.

  3. 고정적, 내부 연결(Static with internal linkage)

    • 메모리 위치 : 데이터 또는 BSS
    • 지속 시간 : 고정적 (정적)
    • 영역 : 파일 안
    • 연결 : 번역 단위의 내부에서만 사용
    • 선언 방법 : 모든 함수들 밖에서 static 키워드 사용
  4. 고정적, 외부 연결(Static with external linkage)

    • 메모리 위치 : 데이터 또는 BSS
    • 지속 시간 : 고정적
    • 영역 : 파일 안
    • 연결 : 번역 단위의 외부로 연결 가능
    • 선언 방법 : 모든 함수들 밖 선언 (전역 변수)
  5. 고정적, 연결 없음(Static with no linkage)

    • 메모리 위치 : 데이터 또는 BSS
    • 지속 시간 : 고정적
    • 영역 : 블록 안
    • 연결 : 없음
    • 선언 방법 : 블록 안에서 static 키워드 사용

12.11 함수의 저장 공간 분류

모든 함수는 기본적으로 extern 임이 가정되어 있다. (main()함수 밖에 있으니까.) 따라서 외부 링킹을 막고 싶다면 static 키워드를 붙여주어야 한다.
static 키워드는 바디부분 까지 포함된 함수에 붙여주든, 그 함수의 프로토타입에 붙여주든 상관없이 작동한다.

12.12 난수 생성 모듈 만들기 예제

컴퓨터는 기계이기 때문에 진정한 랜덤을 만들 능력이 없다. 그렇게 보일 정도로 만들 뿐.

보통 랜덤시드(씨앗)를 만들어 그 시드에 따라 다른 랜덤 숫자를 뽑아낸다. 시드 넘버가 같다면 같은 난수를 만들어내기 때문에 보통 시시각각 변하는 '시간' 을 시드 넘버로 넣어 랜덤하게 값이 나오도록 한다. 소스 코드를 뜯어보면, 오버플로우를 이용해서 예측하기 힘든 숫자를 만들고 있음을 알 수 있다.

12.13 메모리 동적 할당 (Dynamic Storage Allocation)

프로그래머가 운영체제에게 요청해서 힙 메모리에서 이용. 사용한 동적 메모리는 제때 반납하는 게 중요함.

주로 <stdlib.h> 가 가지고 있는 malloc()(memory allocation) 을 이용해서 만들어 씀.

int main()
{
  int n = 0;
  // n from files, internet, scant, etc.
  char* arr = (char*)malloc(sizeof(char) * n);
  // ...
  free(arr); // 꼭 메모리 해제!
  return 0;
}

malloc() 은 void type 의 포인터를 반환한다. 즉 프로그래머가 요청한 메모리 공간만을 제공할 뿐이고, 프로그래머가 원하는 타입으로 캐스팅해서 써야 한다. malloc() 이 반환하는 이 void type pointer 를, generic pointer 라고 한다. '일반적인' 이란 사전적 뜻보다는 '순수한(메모리공간)' 이란 뉘앙스에 가깝다.

메모리 할당에 실패할 정도면 뭔가 큰 문제가 있는 경우이기 때문에 대부분의 경우엔 시스템을 종료한다.

동적 할당 메모리를 사용한 이후에는 free(ptr) 를 해주어야 하는데, free(ptr) 해준 후에도 그 포인터 변수는 여전히 할당받았던 주소를 가지고 있다. 따라서 혹시나 생길 문제를 방지하고자 free() 후에 그 포인터가 NULL 을 가리키도록 한다.

double* ptr = NULL;
ptr = (double*)malloc(30 * sizeof(double));

if (ptr == NULL)
{
  puts("Memory allocation failed.");

  // exit(EXIT_FAILURE) is similar to return 1 in main().
  // exit(EXIT_SUCCESS) is similar to return 0 in main().

  exit(EXIT_FAILURE);
}

printf("Before free %p\n", ptr);
free(ptr); // no action occurs when ptr is NULL.
printf("After free %p\n", ptr);
ptr = NULL; // optional

VLA(Variable Length Array) 와 용도가 비슷하다고 생각할 수 있지만, 아래와 같은 차이가 있다.

  1. 동적할당은 VLA 와 달리 스택이 아닌 힙을 이용한다.
  2. VLA 는 스택을 사용하기에 duration 이 있고, 한 번 초기화 하면 사이즈를 재조정할 수도 없다.
  3. 애초에 VLA 를 지원하지 않는 컴파일러가 많다.

12.14 메모리 누수leak와 free() 의 중요성

힙 메모리를 이용한다는 건, 스택과 달리 영역을 벗어날 때 자동으로 사라지지 않고 할당 받은 메모리를 계속해서 가질 수 있다는 장점과, 그 메모리를 제대로 free() 해주지 않으면 그 공간을 프로그램 내에서든, 다른 프로그램에서든 사용할 수 없다는 위험성이 공존한다.

동적 할당 메모리는 힙 메모리 공간을 사용하지만, 그 동적 할당 메모리를 가리키고 있는 포인터 변수는 스택에 저장된다. 그래서 포인터가 선언된 스코프를 벗어날 때 그 포인터를 잃어버리는 문제가 많이 있다. 즉, 집은 그대로 있지만 집 키를 잃어버린 꼴이다. 이 경우에 할당받았던 메모리에 접근할 방법이 없어 메모리를 운영체제에게 돌려주지 못하고, 이런 상황을 메모리 누수 가 생겼다고 한다.

12.15 동적 할당 메모리를 배열처럼 사용하기

배열의 인식자가 마치 포인터처럼 메모리 주소를 가리키고 있었듯이, 반대로 포인터를 배열처럼 사용하는 것도 가능하다. 다만 동적할당 포인터는 반드시 free() 해주어야 한다는 차이점이 있다.

int n = 3;
int* ptr = (int*)malloc(sizeof(int)*n);
if(!ptr) exit(1);

ptr[0] = 123; // *(ptr +0) 과 유사.
*(ptr +1) = 456;
*(ptr +2) = 789;

free(ptr);
ptr = NULL;

다차원 배열도 결국 1차원 메모리에 저장되는 것이기에, 이 특성을 활용하면 포인터로 다차원 배열을 흉내낼 수 있다. row * col 좌표계의 (r,c) 2차원 좌표를 1차원 배열에서 찾고자 한다면 c + col * r 이 된다.

int row = 3, col = 2;
int* ptr = (int*)malloc(row * col * sizeof(int));
if (!ptr) exit(1);

for (int r = 0; r < row; r++)
{
  for (int c = 0; c < col; c++)
    { 
      ptr[c + col * r] = c + col * r;
    }
}
for (int r = 0; r < row; r++)
{
  for (int c = 0; c < col; c++)
  { 
    printf("%d ", *(ptr + c + col * r));
  }
  printf("\n");
}

3차원 배열도 마찬가지다. 3차원 좌표 row*col*depth 좌표계의 (r,c,d) 좌표를 1차원 배열에서 찾고자 한다면 c + col * r + (col * row) * d 가 된다. 더 고차원일 경우에도 비슷하게 찾게 된다.

12.16 calloc(), realloc()

malloc() 과 유사하나 다른 역할을 할 수 있는 함수가 있다.

calloc() 은 contiguous allocation 의 약자인데, 사용법 자체는 malloc() 과 큰 차이는 없으나(매개변수를 2개로 쪼개놓기는 함) 처음에 0 으로 초기화를 해준다.

int n = 10;
int* ptr = NULL;

// ptr = (int*)malloc(sizeof(int)*n);
ptr = (int*)calloc(n, sizeof(int));

realloc() 은 더 크거나 더 작은 메모리로 재할당 받는 데 쓰인다. 다음과 같은 특징이 있다.

  1. 재할당 받기 전 먼저 할당받았던 메모리를 알아서 free() 해주고, 그 메모리에 있던 값들을 복사해서 옮겨준다.
  2. 첫번째 매개변수로 NULL 을 넘겨주면 malloc() 과 같은 역할을 수행한다. (단순 동적 메모리 할당)
  3. 두번째 매개변수로 0 을 넘겨주면, free() 와 같은 역할을 수행한다.
  4. 더 큰 메모리를 할당받았을 때, 추가된 메모리를 0으로 초기화해주진 않는다. (garbage 값으로 남아있음)
  5. 문제가 있어 더 큰 메모리를 할당받을 수 없다면 NULL을 반환한다.
int n = 10;
int* ptr = NULL;

ptr = (int*)calloc(n, sizeof(int));

for (int i = 0; i < n; i++)
  ptr[i] = i+1;

n=20;
int* ptr2 = NULL;
ptr2 = (int*)realloc(ptr, n*sizeof(int)); // ptr2 만들 필요 없이 그냥 ptr을 재활용해도 된다!

if (!ptr2)
  exit(1);
else
  ptr = NULL;

free(ptr2); // 할당이든 재할당이든 까먹지 않는 습관! ptr 은 `realloc()` 을 통해 알아서 `free()` 됨.

12.18 자료형 한정자들 const, volatile, restrict, _Atomic

영어로는 Type Qualifiers 로, qualifier 라는 단어는 한정짓다보다는 자격을 준다는 의미가 있기 때문에, 오역이라고 볼 여지가 있다.

const

  1. const 를 연속으로 몇 개를 쓰든 한 개로 인식된다.
    const const const int i =0;
  2. 포인터의 const 는 어디에 붙이느냐에 따라 제한되는 것이 다르다. 맨 앞에 붙이면 포인터 변수가 가리키는 주소가 가진 값의 변경은 불가능하지만 (de-referencing 후 값 변경이 안 됨), 포인터 변수가 가리키는 주소 자체는 변경이 가능하다.

반면 포인터 변수의 식별자 앞에 const 를 붙이면, 포인터 변수가 가리키는 주소가 가진 값의 변경이 가능하고, 포인터 변수가 가리키는 주소 변경이 불가능해 진다.

const 두 개를 써서 모든 변경이 불가능 하게 할 수 있다.

float f1 = 3.14f;

const float* ptr1 = &f1;
// *ptr1 = 5.0f; // 에러발생
ptr1 = &f2; // 이건 가능함.

float* const ptr2 = &f1;
*ptr2 = 6.0f; // 가능
// ptr2 = &f2; // 에러발생

const float* const ptr3 = &f1;
// 모든 변경 불가능

전역변수와 const

const 로 어떤 파일 내에서 전역 변수를 만든 후 다른 파일에서 extern 키워드로 가져다 쓸 생각할 수 있다. 하지만 이 방법보다는, 별도의 header 파일을 만들어 그 곳에 아예 static const 로 모든 상수를 초기화해놓고, 필요한 파일에서 해당 헤더 파일을 #include 해서 쓰는 게 낫다.

volatile

컴파일러에게 컴파일러가 예상하지 못한 이유로 이 변수의 값이 달라질 수 있다는 걸 알려주는 역할을 한다. 즉, 컴파일러가 함부로 이 변수와 관련된 최적화를 하는 것을 막는다.

restrict

Visual Studio 에서는 __restrict 로 써야 한다. 주로 포인터와 같이 쓰며, 그 포인터가 가리키는 메모리 주소는 반드시 이 포인터로만 접근하겠다고 컴파일러에게 알려주어서, 컴파일러가 최적화하는 것을 돕는다.

12.9 멀티 쓰레딩

멀티 쓰레딩에 관한 더 자세한 사용방법은 C++ 강의를 참고하자.

멀티 쓰레딩을 위한 C 언어 자체 표준 라이브러리는 없기 때문에 윈도우의 <windows.h> 나 리눅스의 <pthread.h> 같이 운영체제가 제공해주는 멀티쓰레딩 라이브러리를 많이 쓴다.

어떤 함수를 여러 개의 쓰레드로 나눠 돌리면, 그 함수는 각 쓰레드에 복사되어 사용된다. 만약 여러 쓰레드에서 공통적으로 접근해야되는 전역 변수가 있다면, 접근 순서에 따라 다른 값을 얻거나 충동하는 문제가 생길 수 있어 _Atomic 같은 한정자를 같이 쓴다.