본문 바로가기

프로그래머/C, C++

[포프 tv 복습] 레지스터, 스택 & 힙, 동적 메모리. 다중 포인터

레지스터, 스택 & 힙, 동적 메모리. 다중 포인터


메모리의 종류

  • 프로그램에서 주로 사용하는 부품은
    • CPU
    • 메모리
  • 메모리는 또 다시 나뉘는데...
    • 스택 메모리
    • 힙 메모리
  • 기본은 힙 메모리
    • 힙 메모리가 범용적인 기본 형태
  • 스택은 특별한 용도를 가진 메모리
    • 프로그램마다 특별한 용도에 사용하라고 별도로 떼어놔 준 것이 스택 메모리
    • 엄밀히 말하면 그 프로그램의 thread마다
  • CPU 안에도 저장공간이 있음
    • 레지스터 : CPU에서만 사용할 수 있는 고속 저장 공간
    • 엄밀한 의미의 메모리는 아님

레지스터

메모리를 읽고 쓰는 게 느린 이유

  1. CPU가 메모리에 접근할 때마다 버스를 타야 함
  2. 대부분 컴퓨터에 장착하는 메모리는 DRAM임
    • DRAM은 가격이 저렴한 대신, 한 가지 큰 단점이 있음
      • 기록된 내용을 유지하기 위해서 주기적으로 정보를 다시 써야함
      • 다시 쓰는 동안 또 시간을 소모
    • 이러한 단점이 없는 메모리가 있긴 함
      • SRAM
      • 비용이 DRAM에 비해 매우 비쌈
  • 그래서 나온 방법이 SRAM을 CPU와 메모리 사이에 두는 것

  • CPU랑 가까이 두고 싶어서 아예 CPU 안에 넣어버림

  • 그게 바로 레지스터

  • 레지스터는 CPU가 사용하는 저장 공간 중에 가장 빠른 저장공간

  • CPU가 연산을 할 때 보통 레지스터에 저장되어 있는 데이터를 사용

  • 그 연산 결과도 레지스터에 다시 저장한느 게 보통

  • 다시 강조한느데, 레지스터는 흔히 말하는 메모리가 아님

register 키워드

register <자료형> <변수명>;
  • 저장 유형 지정자 (storage-class specifier)
  • 가능하다면 해당 변수를 레지스터에 저장할 것을 요청
  • 실제로 레지스터를 사용할지 말지는 컴파일러가 결정
  • 레지스터는 메모리가 아님!
  • 따라서, 레지스터 변수들은 몇 가지 제약을 받음

제약1 : 변수의 주소를 구할 수 없음

제약2 : 레지스터 배열을 포인터로 사용 불가

제약3 : 블록 볌위에서만 사용 가능

  • 전역 변수에는 사용할 수 없음

  • 이제는 보통 컴파일러가 release모드에서 알아서 최적화

  • 더 이상 프로그래머가 수동으로 사용하지 않는 키워드


힙 메모리

스택 메모리의 단점1 - 수명

  • 함수가 반환하면 그 안에 있던 데이터가 다 날아감
  • 데이터를 오래 보존하려면 전역 변수, 또는 static 키워드를 사용해야 했음
  • 그 중간 어딘가를 원할 수도?

스택 메모리의 단점2 - 크기

  • 특정 용도에 쓰라고 별도로 떼어 놓은 메모리
  • 그 크기는 컴파일 시에 결정하므로 너무 크게 못 잡음
  • 그래서 엄청 큰 데이터를 처리해야 할 경우 스택 메모리에 못 넣음

힙 메모리

  • 컴퓨터에 존재하는 범용적 메모리
  • 스택 메모리처럼 특정 용도로 떼어 놓은 게 아님
  • 스택과 달리 컴파일러 및 CPU가 자동적으로 메모리 관리를 안 해줌
  • 따라서 프로그래머가 원하는 때 원하는 만큼 메모리를 할당 받아와 사용하고 원할 때 반납(해제)할 수 있음

힙 메모리의 장점

  • 용량 제한이 없음
  • 프로그래머가 데이터의 수명을 직접 제어

힙 메모리의 단점1

  • 빌려온 메모리를 직접 해제 안 하면 누구도 그 메모리를 쓸 수 없음
    • 그 메모리는 계속 '누군가'에게 빌려준 상태
    • 만약, 빌려간 쪽에서 그 메모리 주소를 잃어버리면 메모리 누수 발생

힙 메모리의 단점2

  • 스택에 비해 할당/해제 속도가 느림
    • 스택은 오프셋 개념 vs 힙은 사용/비사용 중인 메모리 관리 개념
    • 메모리 공간에 구멍이 생겨 효율적으로 메모리 관리가 어렵기도 함

정적 메모리 vs 동적 메모리

  • 스택 메모리는 정적 메모리
    • 이미 공간이 따로 잡혀 있음
    • 할당/해제가 자동으로 관리되게 코드가 컴팡리 됨
    • 오프셋 개념으로 정확히 몇 바이트씩 사용해야 하는지 컴파일시 결정
  • 힙 메모리는 동적 메모리
    • 실행 중에 크기와 할당/해제 시기가 결정됨

동적 메모리

  • 프로그램이 동적 메모리를 가져다 사용할 때는 총 세가지 단계를 거침
  1. 메모리 할당
  2. 메모리 사용
  3. 메모리 해제

메모리 할당 및 해제 함수

<stdlib.h>

동적 메모리에만 사용 가능

  • 할당
    • malloc()
    • calloc()
  • 해제
    • free()
  • 재할당
    • realloc()

기타 메모리 관련 함수

<string.h>

동적 메모리 및 정적 메모리에 사용 가능

  • memset()
  • memcpy()
  • memcmp()

malloc()

void* malloc(size_t size);
  • 메모리 할당의 약자
  • size 바이트 만큼의 메모리를 반환해줌
  • 범용적이고 프로그래머가 알아서 사용할 거니까 void*
  • 반환된 메모리에 들어있는 값은 쓰레기 값
  • 메모리가 더 이상 없다거나 해서 실패하면 NULL 반환

free()

  • malloc() 코드를 작성하면 곧바로 free() 코드도 추가하는 습관을 들이는 게 좋음
#include <stdlib.h>
#define LENGTH (10)

size_t i;
int* nums = malloc(LENGTH * siszeof(int));

for(i = 0; i<LENGTH; ++i){
    nums[i] = i * LENGTH;
}

for(i = 0; i<LENGTH; ++i){
    printf("%d ", nums[i]);
}

free(nums);

여러 줄의 입력을 받아 출력하기 예

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#define NUM_LINES (5)
#define LINE_LENGTH (2048)

char* lines[NUM_LINES];
char line[LINE_LENGTH];
size_t i;
size_t j;

for(i=0; i<NUM_LINES; ++i){
    if(fgets(line, LINE_LENGTH, stdin)){
        clearerr(stdin);
        break;
    }

    lines[i] = malloc(strlen(line) + 1);
    if(liens[i] == NULL){
        fpringf(stderr, "%s\n", "out of mem");
        break;
    }

    strcpy(lines[i], line);
}

for(j = 0; j< i; ++j){
    printf("%s", lines[j]);
}

for(j = 0; j< i; ++j){
    free(lines[i]);
}
  • 한계 : 줄 수가 NUM_LINES로 고정

학생 정보 입력 받기 예

#define INCREMENT (2)

typedef struct{
    char firstname[NAME_LEN];
    char lastname[NAME_LEN];
    unsigned int id;
    float gpa;
} student_t;

student_t read_student()
{
    student_t s;

    // 생략

    return s;
}

while(TRUE){
    student = read_student();

    if(current_index == max_students){
        max_students += INCREMENT;
        tmp = malloc(max_students * sizeof(student_t));
        memcpy(tmp, students, current_index * sizeof(student_t));
        free(students);
        students = tmp;
    }

    students[current_index++] = student;
}

free(students);
students = NULL;

파일에서 학생 정보 입력 받기 예

FILE* file;
size_t num_students;
size_t num_read;
student_t* students;

file = fopen("test.dat", "rb");

num_read = fread(&num_students, sizeof(size_t), 1, file);    // 학생 수 읽어옴(파일 형식이 그렇게 되어 있음)
students = malloc(num_students * sizeof(student_t));
num_read = fread(students, sizeof(student_t), num_students, file);
fclose(file);

free(students);

제대로 된 free() 설명

void free(void* ptr);
  • 할당 받은 메모리를 해제하는 함수
  • 즉, 메모리 할당 함수들을 통해서 얻은 메모리만 해제 가능
  • 그 외의 주소를 매개변수로 전달할 경우 결과가 정의되지 않음

동적 메모리 할당 시 문제

할당 받아 온 주소를 그대로 연산에 사용하면

- 메모리 할당 함수가 반환한 주소가 저장된 변수를 그대로 포인터 연산에 사용하면 메모리 해제할 때 문제가 발생할 수도 있음

코딩 표준 : 할당 받은 포인터로 연산 금지

- 메모리 할당 함수에서 받아온 포인터와 포인터 연산에 사용하는 포인터를 분리하자
void* nums;
int* p;
size_t i;

nums = malloc(LENGTH * sizeof(int));
p = nums;

for(i = 0; i < LENGTH; i++){
    *p++  = 10 * (i + 1);
}

free(nums);

해제한 메모리를 또 해제해도 문제

- 잘못하면 크래시가 날 수도...

해제한 메모리르 사용해도.. 문제

코딩 표준 : 해제 후 널 포인터를 대입

  • free()한 뒤에 변수에 NULL을 대입해서 초기화
    • 안 그러면 해제된 놈인지 나중에 모르니
    • 널 포인터를 free()의 매개변수로 전달해도 안전
void* nums;
int* p
size_t i;

nums = malloc(LENGTH * sizeof(int));
p = nums;

// 코드 생략

free(nums);
nums = NULL;
  • malloc()한 뒤 free() 까먹으면 메모리 누수
  • malloc()으로 받아온 주소를 지역 변수에서 저장해놨는데 해제 안하고 함수에서 나가면 주소가 사라져서 지울 방법이 아예 없어짐

free()는 몇 바이트를 해제할지 어떻게 알지? - calloc(), memset(), realloc()

  1. 구현마다 다르지만 보통 malloc(32)하면 그것보다 조금 큰 메모리를 할당한 뒤, 제일 앞부분에 어떤 데이터들을 채워 놓음

  2. 그리고 그만큼 오프셋을 더한 값을 주소로 돌려줌

  3. 나중에 그 주소 해제를 요청하면 free()가 다시 오프셋만큼 빼서 그 앞 주소를 본 뒤, 실제 몇 바이트가 할당됐었는지 확인 후 해제

    calloc()
  4. void* calloc(size_t num, size_t size);
  • 메모리를 할당할 때 자료형의 크기(size)와 수(num)를 따로 지정
  • 모든 바이트를 0으로 초기화 해 줌
  • 잘 안 씀

calloc() = malloc() + memset()

보통 calloc() 대신 malloc()와 memset()을 조합해서 씀
- memset()을 쓰면 0 이외의 값으로도 초기화 가능

void* nums;

nums = calloc(LENGTH, sizeof(int));

free(nums);
nums = NULL;
void* nums;

nums = malloc(LENGTH * sizeof(int));
memset(nums, 0, LENGTH * sizeof(int));

free(nums);
nums = NULL;

memset()

void* memset(void* dest, int ch, size_t count);
- <string.h>에 있음
- char로 초기화(1바이트씩) 됨
- 그 외의 자료형으로 초기화하려면 직접 for문을 서야 함
- 다음과 같은 경우 결과가 정의되지 않음
    - count가 dest의 영역을 넘어설 경우
    - dest가 널 포인터일 경우

char로만 초기화해주는 memset()

void* nums;

nums = malloc(LENGTH * sizeof(int));
memset(NUMS, 1000, LENGTH * sizeof(int));

free(nums);
nums = NULL;

int로 초기화하고 싶다면?

void* nums;
int* p;
size_t i;

nums = malloc(LENGTH * sizeof(int));
p = nums;

for(i = 0; i < LENGTH; i++){
    *p++ = 1000;
}

free(nums);
nums = NULL;

realloc()

void* realloc(void* ptr, size_t new_size);
  • 이미 존재하는 메모리(ptr)의 크기를 new_size 바이트로 변경
  • 새로운 크기가 허용하는 한 기존 데이터를 그대로 유지
  • 아까 봤던 학생 정보 입력 받기 예에서 동적 배열 크기를 늘리던 코드를 이 함수로 대체 가능

크기가 커져야할 때, 두가지 경우가 있음

  1. 지금 갖고 잇는 메모리 뒤에 충분한 공간이 없으면 새로운 메모리를 할당한 뒤, 기존 내용을 복사하고 새 주소 반환
  2. 지금 갖고 잇는 메모리 뒤에 공간이 충분하다면 그냥 기존 주소를 반환(보장은 x). 그리고 추가된 공간을 쓸 수 있게 됨
    str = malloc(LENGTH * sizeof(char)); // length : 4
    str = realloc(str, 2 * LENGTH * sizeof(char));

realloc()의 메모리 누수 문제, memcpy()

void* realloc(void* ptr, size_t new_size);
  • 반환값
    • 성공 시, 새롭게 할당된 메모리의 시작 주소를 반환하며 기존 메모리는 해제됨
    • 실패 시, NULL을 반환하지만 기존 메모리는 해제되지 않음
  • 실패 시 메모리 누수가 발생할 수 있음!!!

메모리 누수가 나는 경우

void* nums;
nums = malloc(SIZE);
nums = realloc(nums, 2 * SIZE); // null 반환
  • 원래 nums에 저장되어 있던 주소가 사라짐
  • NULL이 반환되었다는 이야기는 재할당에 실패했다는 의미
  • 따라서 기존 메모리는 해제되지 않았음
  • 그러나 그 주소를 잃어버려서 해제할 수 없다. 메모리 누수 발생!

올바른 재할당 방법

void* nums;
void* tmp;

nums = malloc(SIZE);
tmp = realloc(nums, 2 * SIZE); 

if(tmp != NULL){
    nums = tmp;
}

realloc() = malloc() + memcpy() + free()

void* nums;
void* tmp;

nums = malloc(LENGTH);

tmp = realloc(nums, 2 * LENGTH); 
if(tmp != NULL){
    nums = tmp;
}

free(nums);
void* nums;
void* tmp;

nums = malloc(LENGTH);

tmp = malloc(2 * LENGTH); 
if(tmp != NULL){
    memcpy(tmp, nums, LENGTH);
    free(nums);
    nums = tmp;
}

free(nums);

memcpy()

void* memcpy(void* dest, const void* src, size_t count);
  • <string.h>에 있음
  • src의 데이터를 count 바이트 만큼 dest에 복사
  • 다음과 같은 경우 결과가 정의되지 않음
    • dest의 영역 뒤에 데이터를 복사할 경우
    • src나 dest가 널 포인터일 경우

메모리 누수 안 나게 코드를 작성할 것!

  • realloc()을 쓸 때는 정말 조심해야 함
  • 그래서 차라리 malloc() + memcpy() + free()로 좀 더 명싲거으로 드러나게 코딩하는 게 나을지도
  • 그냥 신경 안쓰고 realloc()을 쓰는 경우도 많음
    • 메모리 시작 주소가 변하지 않는 경우 데이터 복사를 하지 않아 성능상 이득
    • malloc()에서 실패하는 일이 없다고 가정하고 코딩을 하는 경우가 많은 이유도 마찬가지

realloc()의 특수한 경우

nums = realloc(NULL, LENGTH);
  • 새로운 메모리 할당
  • malloc(LENGTH)와 동일

memcmp(), 정적 vs 동적 메모리

여러 줄 입력 받아 출력하기

#define LINE_LENGTH (2048)
#define INCREMENT (2)

char** lines;
char line[LINE_LENGTH];
size_t max_lines;
size_t num_lines;
size_t i;
char** tmp;

max_lines = 0;
num_lines = 0;
lines = NULL;

while(1){
    if(fgets(line, LINE_LENGTH, stdin) == NULL){
        clearerr(stdin);
        break;
    }

    if(num_lines == max_lines){
        tmp = realloc(lines, (max_lines + INCREMENT) * sizeof(char*));

        if(tmp == NULL){
            fprintf(stderr, "%s\n", "out of memory");
            break;
        }

        lines = tmp;
        max_lines += INCREMENT;
    }

    lines[num_lines] = malloc(strlen(line) + 1);
    if(lines[num_lines] == NULL){
        fprintf(stderr, "%s\n", "out of memory");
        break;
    }
    strcpy(lines[num_lines++], lines);
}

for(i = 0; i < num_lines; ++i){
    printf("[%d] %s", i, lines[i]);
}

for(i = 0; i < num_lines; ++i){
    free(lines[i]);
}

free(lines);
return 0;

memcmp()

int memcpy(const void* lhs, const void* rhs, size_t count);
  • 첫 count 바이트 만큼의 메모리를 비교하는 함수
  • strcmp()와 매우 비슷
  • 단, 널 문자를 만나도 계속 진행
  • 다음의 경우 결과가 정의되지 않음
    • lhs과 rhs의 크기를 넘어서서 비교할 경우
    • lhs이나 rhs이 널 포인터일 경우

구조체 두 개를 비교할 때도 유용

typedef struct{
    char firstname[64];
    char lastname[64];
    unsigned int id;
} student_t;

// 생략

int result;
result = memcmp(&s1, &s2, sizeof(student_t));

단, 구조체가 포인터 변수를 가질 경우에는...

주소를 가진 구조체면 값이 같아도 주소가 다를 수 있음

typedef struct{
    char* firstname;
    char* lastname;
} name_t;

// 동적 메모리 할당을 이용하여 이름을 복사

int result;
result = memcmp(&s1, &s2, sizeof(name_t));

동적 메모리 할당을 이용한 깊은 복사

typedef struct{
    char* firstname;
    char* lastname;
} name_dynamic_t;

name_dynamic_t s1; // {"Pope", "Kim"}
name_dynamic_t s2;
size_t size;

size = strlen(s1.firstname) + 1;
s2.firstname = malloc(size);
// s2.firstname 널 포인터 체크 코드 생략
memcpy(s2.firstname, s1.firstname, size);

size = strlen(s1.lastname) + 1;
s2.lastname = malloc(size);
// s2.lastname 널 포인터 체크 코드 생략
memcpy(s2.lastname, s1.lastname, size);

// free() 호출 생략

구조체 멤버 변수 - 배열 vs 포인터

고정된 길이인 배열

typedef struct{
    char firstname[NAME_LEN];
    char lastname[NAME_LEN];
} name_fixed_t;
  • 그대로 대입 가능
  • 파일에 곧바로 저장 가능
  • memcpy()를 곧바로 사용 가능
  • 낭비하는 용량이 있음
  • 메모리 할당/해제 속도 빠름

동적 메모리를 사용하는 포인터

typedef struct{
    char* firstname;
    char* lastname;
} name_dynamic_t;
  • 그대로 대입 불가
    • 이 경우는 얕은 복사가 되어버림
  • 파일에 곧바로 저장 불가능
  • memcpy()를 곧바로 사용 불가능
  • 낭비하는 용량이 없음
  • 메모리 할당/해제 속도 느림

베스트 프랙티스 : 정적 vs 동적 메모리

  • 정적 메모리를 우선적으로 사용할 것
  • 안 될 때만 동적 메모리

동적 메모리의 소유권 문제

동적으로 할당한 메모리의 큰 문제

  • 동적으로 할당한 메모리의 소유주는 누구인가?
  • 바로, 메모리를 생성한 함수
  • 소유주란? 그 메모리를 반드시 책임지고 해제해야 하는 주체
  • 소유주가 아닐 때는 그냥 빌려 사용할 뿐 해제하면 안 됨!

호출자가 이 함수를 호출하는 순간 내부에서 새 메모리가 할당해서 반환한다는 사실을 어떻게 알지?

const char* combine_string(const char* a, const char* b)
{
    void* str;
    char* p;
    // a와 b의 길이 및 두 길이를 더한 값을 보관한 변수 생략

    str = malloc(size);

    // a와 b를 str에 복사하는 코드 생략

    return str;
}
//...
result = combine_string("Hello", "World");

C++에서는 RAII로 해결

  • 자원 획득은 초기화(RAII, Resource Acquisition Is Initialization)
    • 여기서 자원은 메모리
  • C++은 개체지향을 지원하는 언매니지드 언어
  • 한 개체가 생성될 때 필요한 메모리를 할당(생성자)
  • 그 개체의 수명이 다할 때 그 메모리를 해제(소멸자)
  • 즉, 개체의 수명이라는 범위에 메모리의 수명을 종속시킴
  • 이 원칙을 잘 따르면 실수할 여지가 적음

하지만 C는 개체가 없다

  • C에서 RAII를 할 수 있는 부붑ㄴ
    • 한 함수 안에서 malloc(), free()를 다 호출할 수 있는 경우

하지만 중간에 함수를 탈출할 경우?

  • 코드 중간에 return하면 말짱 도루묵
  • goto 문을 사용
    void do_someting()
    {
      void* nums = NULL;
      nums = malloc(10 * sizeof(int));
      // 코드 생략
      if(조건){
          goto free_and_exit;
      }
      // 코드 생략
      free_and_exit:
          free(nums);
    }

그럼 원래의 문제는 어떻게 해결할까?

  • 이런 함수가 최대한 없게 한다
  • combine_string() 예의 경우, 함수 안에서 할당하는 대신 함수 밖에서 할당 후 매개변수로 전달
    size_t calculated_combined_length(const char* a, const char* b);
    void combine_string (const char* a, const char* b, char* out_str);
    

void combine_string(const char* a, const char* b, char* out_str)
{
// out_str에 a와 b를 복사
}

const char* str_combined;
size_t size;

size = calculate_combined_length(str1, str2)
str_combined = malloc(size + 1);
combined_string(str1, str2, str_combined);

free(str_combined);


## 동적으로 할당 후 반환을 피할 수 없다면?
- 딱히 모두가 동의하는 표준을 없음
- 어떤 함수가 메모리 할당을 한다면 함수에 그 사실을 표기
- 동적 메모리를 할당하는 변수라면 변수명에 표기하는 법도 있음

## 코딩 표준 : 동적 메모리 할당을 하는 함수명
```c
const char* combine_string_malloc(const char* str1, const char* str2)
{
    void* pa_str;
    char* p;

    pa_str = malloc(strlen(str1) + strlen(str2) + 1);
    p = pa_str;

    // 문자열 합치는 코드 생략

    return pa_str;
}

베스트 프랙티스 정리

  1. malloc() 작성한 뒤에 곧바로 free()도 추가하자
  2. 동적 할당을 한 메모리 주소를 저장하는 포인터 변수와 포인터 연산에 사용하는 포인터 변수를 분리해 사용하자
  3. 메모리 해제 후, 널 포인터를 대입하자
  4. 정적 메모리를 우선적으로 사용하고 어쩔 수 없을 때만 동적 메모리를 사용
  5. 동적 메모리 할당을 할 경우, 변수와 함수 이름에 그 사실을 알리자

다중 포인터

이중 포인터

포인터 변수의 주소를 저장한느 변수를 이중 포인터라고 함

int num = 10;
int* p = &num;
int** pp = &p;

이중 포인터는 왜 쓰지?

  • 2차원 배열이 사실 이중 포인터와 비슷

메인 함수의 매개변수에도 있었다!

  • 메인 함수의 매개변수 argv도 엄밀히 말하면 이중 포인터
    int main(int argc, char* argv[]);
    int main(int argc, char** argv);