[C] C lang 레지스터, 스택 & 힙, 동적 메모리. 다중 포인터
메모리의 종류
프로그램에서 자주 사용하는 부품
1 . CPU
- 모든 코드의 로직(연산)을 실행하는 장치
- 컴퓨터에서 가장 핵심적인 두뇌라 할 수 있다.
2 . 메모리
- 실행중인 코드 및 연산의 중간 결과등을 저장하는 공간
변수나 배열 등에 대입되는 데이터가 저장됨
- 무언가를 저장한다는 점에서 메모장과 유사하다.
메모리는 또 다쉬 나뉨
프로그램 중 여러 데이터가 공유하는 메모리는 둘로 나뉨.
- 스택(stack)메모리
- 힙(heap) 메모리
사실상 이 둘 물리적으로 같은 메모리
이 외에 데이터 섹션, 코드 섹션 등 있으나 그건 특정 코드 및 데이터 용으로 고정
기본은 힙 메모리
- 힙 메모리가 범용적인 기본형태
스택은 특별한 용도를 가진 메모리
프로그램 마다 특별한 용도에 사용하라고 별도로 떼어놔준게 스택 메모리
- 엄밀히 말하면 프로그램마다가 아니라 그 프로그램의 쓰레드이다.
그리고 CPU안에도 저장공간이 있다.
레지스터: CPU에서만 사용할 수 있는 고속 저장공간
엄밀한 의미의 메모리는 아님.
스택 메모리
좀 더 알고 싶다면 함수를 호출 할 때 스택에 대해 어떻게 돌아가는 지 는 함수 호출 규약 (calling convention) 에 따라 달라짐
- 구조체 배울 떄 매개변수 4개까지는 값으로 전달하라는 회사 이야기를 했는데 이건 특정 호출 규약에서 성능 향상이 가능한 부분이기 떄문
함수 호출규약에 대한 자세한 정보는 coding convention으로 구글 검색
레지스터
- CPU가 뇌, 메모리가 공책이면 뇌에서 생각한 걸 공책에 옮기려면 시간이 걸리듯, CPU가 생각한 걸 메모리에 적거나 그로부터 읽을때도 시간이 걸림
메모리를 읽고 쓰는 게 느린 이유1
- CPU가 메모리에 접근 할 때마다 버스 타야됨
즉, CPU가 연산할 때마다 메모리에 접근하는 시간이 발생
- 버스가 크면 한번에 많이 읽지만 메모리 낭비가 생기고
- 버스가 작으면 메모리 낭비는 적을 수 있겠지만 여러번 왔다갔다함.
메모리를 읽고 쓰는 게 느린 이유2
대부분 컴퓨터에 장착하는 메모리는 DRAM
- 영어로는 Dynamic Random Access Memory
Dram은 가격이 저렴한 대신 단점이 있다.
- 기록한 내용을 유지하기 위해 주기적으로 정보를 다시 써야함
- 다시 쓰는 동안 또 시간을 소모
이러한 단점이 없는 메모리가 SRAM
- static Ram
- Dram에 비해 훨씬 비쌈
- 이걸 몇기가 달기에는 부담
더 나은 방식을 찾기위해 고민함
그래서 비용은 저렴하지만 속도가 빠른 컴퓨터들을 만들기 위해 고민
그래서 나온 방법이 Sram을 cpu와 메모리 사이에 두는 것
- 단, 너무 비싸니 매우 적은 용량만
- 일반적인 sram과는 다름
- CPU랑 가까이 두고 싶어서 아예 CPU안에 넣어버림
- 그게 레지스터다
레지스터
레지스터는 CPU가 사용하는 저장공간 중 가장 빠른 저장공간
CPU가 연산시 보통 레지스터에 저장된 레지스터를 사용
- 그 연산 결과도 레지스터에 다시 저장하는 것이 보통이다
레지스터는 흔히 말하는 메모리가 아님
- cpu가 레지스터에 접근하는 방식과 메모리에 접근하는 방식이 다르다
어셈블리어로 보는 레지스터
- 레지스터는 CPU가 사용하는 저장 공간 중 가장 빠른 저장공간
- CPU가 연산 할 떄 보통 레지스터에 저장되어있는 데이터를 사용
- 그 연산결과도 다시 레지스터에 저장하는게 보통
- 강조하지만 레지스터는 흔히 말하는 메모리가 아니다.
- cpu가 레지스터에 접근하는 방법과 메모리 접근하는 방법이 다른게 보통이다.
x86 아키텍처 에서 사용하는 레지스터
8개의 범용 레지스터
- ESP, EBP, EAX, EBX, ECX, EDX 등등
- 6개의 세그 먼트 레지스터
- 1개의 플래그 레지스터
- 1개의 명령어 포인터
등
register 키워드
프로그래머가 빠른 레지스터를 직접 쓸 수는 없나? 메모리 안 거치고 레지스터 쓰면 좋을 텐데 -> 어셈블리 쓰면 가능하긴 함
레지스터 사용을 요청하는 예
레지스터 키워드
- 저장 유형 지정자
- 가능하다면 해당 변수를 레지스터에 저장할 것으 ㄹ요청
- 실제로 레지스터 사용 할지말지는 컴파일러가 결정
- 레지스터는 메모리가 아님
- 따라서 레지스터 변수들은 제약을 몇가지 받음
제약1. 변수의 주소를 구할 수 없음
제약2. 레지스터 배열을 포인터로 사용 불가
제약3. 블록범위에서만 사용 가능
근데 사실상 현재 데스크톱 컴파일러들은 register키워드 넣는다고 특별히 해주는 일이 없다. 대부분 무시함
예전 임베디드 시스템에는 이렇게 구분하는 게 의미가 있었다
- CPU용량도 작고 메모리 용량도 적었다
- 최적화를 잘 안해주는 컴파일러 떄문에 프로그래머가 레지스터 사용까지 지시했어야 한다.
근데 위는 예전 이야기
보통 컴파일러가 배포(release)모드에서 알아서 최적화
- 불필요한 스택 메모리 없애고
- 레지스터에만 있으면 빠를거 같은 변수들은 그렇게 해주고
더이상 프로그래머가 수동으로 사용하지 않는 키워드
힙 메모리
스택 메모리의 단점 1 - 수명
함수가 반환하면 그 위에 있던 데이터가 다 날아간다.
- 즉, 함수 안에 있는 변수의 수명은 함수가 끝날 때 까지
그렇지 않고 데이터를 오래 보존하려면 전역변수 또는 static 키워드를 사용해야 했다
- 이런 변수의 수명은 프로그램 실행 내내
- 이건 도 아니면 모?
- 근데 어떤 경우는 그 중간 어딘가에서 타협 원할수도 있다.
- 내가 원할때 만들거나 지울 수 있는 그런 저장공간
스택 메모리의 단점 2 - 크기
- 앞에서도 말했듯 특정 용도에 쓰라고 별도로 떼어놓은 메모리
- 그 크기는 컴파일 시 결정하므로 너무 크게 못 잡음
프로그램을 실행할 시 메모리가 1MB 일수도 있고 4GB일수도 있다.
- 최소한에 맞출 수밖에 없다
- 그래서 엄청 큰 데이터를 처리해야 할 경우 스택 메모리에 못 넣는다.
- 예: 4k로 녹화해서 파일 크기가 2GB인 영상 파일
힙 메모리
- 컴퓨터에 존재하는 일단 범용적인 메모리
- 스택 메모리처럼 특정 용도로 떼어놓은 게 아니다.
- 스택과 달리 컴파일러 및 CPU가 자동적으로 메모리 관리를 안 해줌.
- 따라서 프로그래머가 원할 때에 원하는 만큼 메모리를 할당받아와 사용하고 원하는 때에 반납(해제)할 수 있다.
힙 메모리의 장점
용량 제한이 없음
- 컴퓨터에 남아있는 메모리만큼 사용 가능
프로그래머가 데이터의 수명을 직접 제어
- 스택에 저장되는 변수 처럼 함수호출이 끝나면 사라지지 않음
- 전액 변수처럼 프로그램이 실행 되는 동안 계속 살아있는 것도 아님.
힙 메모리의 단점 1
빌려온 메모리를 직접해제 안하면 누구도 그 메모리를 쓸 수 없다.
- 그 메모리는 계속 누군가에게 빌려준 상태
- 만약 빌려준 상태에서 메모리를 잃어버리면 누수가 발생
그래서 C는 언매니지드 언어
매니지드 언어 (C#, java등)는 메모리 해제를 알아서 해주는 언어
이를 메모리 관리 해준다 해서 (managed) 매니지드 언어라 한다.
이 메모리 관리 기능은 다른 훌륭한 프로그래머들이 구현한 것
- 메모리 누수가 날 가능성은 적음
- 당연히 범용적으로 만든게 아니라 속도 등이 느릴 수 있음
C는 그런 언어가 아니라 직접 관리를 해줘야 한다.
- 훌륭한 프로그래머는 메모리 관리는 할 수 있어야 한다.
- 최대의 효율성을 선택하는 대신
- 실수를 막기 위해서는 여러가지 원칙들을 습관화 한다.
비유: 매니지드 vs 언매니지드 이사
힙 메모리의 단점 2
- 스택에 비해 할당/해제 속도가 느림
- 스택은 오프셋 개념 vs 힙은 사용/비사용 중인 메모리 관리 개념
- 메모리 공간에 구멍이 생겨 효율적으로 메모리 관리가 어렵기도 함.
정적 메모리 vs 동적 메모리
스택 메모리는 정적 메모리
- 이미 공간이 잡혀있음
- 할당/해제가 자동으로 관리되게 코드가 컴파일 됨
- 오프셋 개념으로 정확히 몇 바이트씩 사용해야 하는 지 컴파일 시 결정
힙 메모리는 동적 메모리
- 실행 중 크기와 할당/해제 시기가 결정 됨
동적 메모리
프로그램이 동적 메모리 쓸 떄는 세가지 단계 거침
1 . 메모리 할당 2 . 메모리 사용 3 . 메모리 해제
동적 메모리 사용단계 1: 메모리 할당
- 힙 관리자에게 메모리를 xxx바이트만큼 달라고 요청
- 관리자는 연속되는 그 만큼의 메모리를 찾아서 반환
- 반환된 메모리는 어떤 자료형에 저장 가능? 메모리 주소니 당연히 포인터다.
동적 메모리 사용단계 2: 메모리 사용
- 그 메모리를 원하는 대로 사용
- 예: int 배열에 성적 저장 한 뒤 평균 구해서 float 변수에 저장
동적 메모리 사용단계 3: 메모리 반납/해제
- 힙 관리자에게 그 메모리 주소를 돌려주면서 다 썼다고 알려준다.
- 관리자는 그 메모리 주소를 아무도 사용하지 않는 상태로 바꿈
메모리 할당 및 해제 함수, malloc()
메모리 할당 함수 해제함수, 재할당 함수,
alloc = allocate
그 외 메모리 가져오면 메모리에 쓸수 있는 함수들.
우측 기타 메모리 관련 함수들은 포인터를 가져와서 사용할 수 있는 거. 그리고 우측은 동적 메모리에만 사용가능한게 아님. 정적 메모리에도 사용 가능
malloc()
- 메모리 할당의 약자
- size바이트 만큼 메모리 반환
- void 포인터로 반환.
- 포인터 외 다른 자료형으로 반환 불가
- 반환된 메모리에 들어있는 값은 쓰레기값
- 즉 , 초기화 안해줌
- 메모리가 더 없거나 해서 실패시 널 반환
free(), malloc() 사용 예
malloc 의 짝궁함수 free()
동적 메모리는 프로그래머가 직접 빌리고 반납해야 됨.
- 빌렸으면 메모리 반납해야 함
- 메모리에 건 속박을 풀어준다고 해서 free()
- 안 지우게 되면 누수 발생
malloc()코드 작성 시 바로 free()코드도 추가하는 습관을 들이는 게 좋다.
]
malloc () 쓰는 예시들
제대로 된 free() 설명
- 할당 받은 메모리를 해제하는 함수
- 즉, 메모리 할당함수를 통해 얻은 메모리만 해제 가능
- 그 외 주소를 매개변수로 전달 할 시 결과가 정의 되지 않음
동적 메모리 할당 시 문제
할당 받아온 주소를 그대로 연산에 사용시
메모리 할당 함수가 반환한 주소가 저장된 변수를 그대로 포인터 연산에 사용시 메모리 해제할 떄 문제 발생할 수 도 있다.
최초에 받아온 주소가 아니라 다른 위치를 가리킴 -> 그 주소로 메모리 해제 요청 ->결과가 정의되지 않음->망함
코딩 표준 : 할당 받은 포인터로 연산 금지
- 메모리 할당 함수에서 받아온 포인터와 포인터 연산에 사용하는 포인터를 분리하자.
해제한 메모리를 또 해제해도 문제
코딩표준 : 해제 후 널 포인터를 대입
- free()한 뒤 변수에 NULL을 대입해서 초기화
- 안 그러면 해제된 놈인지 나중에 모르니
- 널 포인터를 free() 매개 변수 전달해도 안전
이렇게 설명할 거 왜 두번에 나눠한건가
- 메모리 누수 떄문
- malloc 한 뒤 free 까먹으면 메모리 누수
malloc으로 받아온 주소를 지역변수에서 저장해놨는데 해제 안하고 함수에서 나갈 시 주소가 사라져 지울 방법이 아예 없어진다.
- 그래서 습관을 잘 들여야 한다.
free()는 몇 바이트를 해제할지 어떻게 알지?, calloc(), memset(), realloc()
free()는 몇 바이트를 해제할 지 어떻게 아는가?
1 . 구현마다 다르지만 보통 malloc(32)하면 그것보다 조금 큰 메모리를 할당한 뒤, 제일 앞 부분에 어떤 데이터들을 채워넣음.
앞에 데이터를 집어넣고 이 데이터는 그 힙 관리자가 볼 그런 데이터
2 . 그리고 그만큼 오프셋을 더한 값을 주소로 돌려준다.
- 돌려받은 주소로부터 원래 요청한 32바이트를 사용.
3 . 나중에 그 주소 해제를 요청하면 free()가 다시 오프셋만큼 빼서 그 앞 주소를 본 뒤 실제 몇바이트가 할당 되었었는지 확인 후 해제
calloc()
- 의미는 아무도 모름
- 메모리 할당시 자료형의 크기(size)와 수(num)을 따로 지정
- 모든 바이트를 0으로 초기화 해줌
- 잘 안씀
memset()
- string.h에 있다
- char로 초기화(1바이트씩)
- 그외 자료형으로 하려면 for을 써야 됨
- 다음과 같은 경우 겨로가 정의 안 될 수 있다.
- count가 dest의 영역 넘어설 경우(소유하지 않은 메모리에 쓰기)
- dest가 널 포인터일 경우 (널 포인터 참조)
realloc()
- 이미 존재하는 메모리(ptr)의 크기를 new_size 바이트로 변경
- 새로운 크기가 허용하는 기존 데이터를 그대로 유지
크기가 커져야 할 떄, 두가지 경우가 있다 1
1 . 지금 갖고 있는 메모리 뒤 충분한 공간이 없으면 새로운 메모리를 할당한 뒤, 기존 내용을 복사하고 새 주소 반환.
크기가 커져야 할 떄, 두가지 경우가 있다 2
2 . 지금 갖고 있는 메모리 뒤에 공간이 충분하다면 그냥 기존 주소를 반환(보장은 없음). 그리고 추가된 공간을 쓸 수 있게 됨.
크기가 작아질 떄도 비슷함
- 기존 주소가 그대로 반환되거나
- 다른곳에 메모리 새로 할당 후 , 새 주소 반환할 수도
realloc()의 메모리 누수 문제, memcpy()
realloc()에서 메모리 누수가 날 수 있다.
반환값
- 성공시 , 새롭게 할당된 메모리 시작 주소를 반환하며 기존 메모리는 해제됨
- 실패시 , NULL을 반환하지만 기존 메모리는 해제되지 않음.
실패시 메모리 누수가 발생할 수 있다!
메모리 누수가 나는 경우
void* nums;
nums = malloc(SIZE)
nums = realloc(nums,2*SIZE);
- 원래 nums에 저장된 주소가 사라짐
- NULL이 반환 됐다는 이야기는 재할당에 실패했다는 의미
- 따라서 기존 메모리는 해제되지 않음
- 그러나 그 주소를 잃어버려서 해제할 수 없다. 메모리 누수 발생.
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 동적 메모리
memcmp()
int memcmp(const void* lhs, const void* rhs, size_t count)
- 첫 count 바이트 만큼 메모리를 비교하는 함수
- strcmp()와 매우 비슷
단, 널 문자를 만나도 계속 진행
- 다음의 경우 결과가 정의 되지 않음
- lhs과 rhs의 크기를 넘어서 비교할 경우(소유하지 않은 메모리에 쓰기)
- lhs이나 rhs이 널 포인터일 경우(널포인터 역 참조)
단, 구조체가 포인터를 가질 경우는
동적 메모리 할당을 이용한 깊은 복사
구조체 멤버 변수 - 배열 vs 포인터
베스트 프랙티스: 정적 메모리 vs 동적 메모리
정적 메모리를 우선적으로 사용할 것
- 훌륭한 C프로그래머 최대한 정적으로 사용하려 함
안 될떄만 동적 메모리
동적 메모리의 소유권 문제
동적으로 할당한 메모리의 큰 문제
- 메모리의 소유주는 누구?
- 바로 메모리를 생성한 함수
- 소유주란? 그 메모리를 반드시 책임지고 해제해야 하는 주체
- 소유주가 아닐 떄는 그냥 빌려 사용할 뿐 해제하면 안됨
- 소유주와 비 소유자가 모두 해제하면 문제
- 근데 한명도 해제 안해도 문제
문제의 예
C++에서는 RAll로 해결
자원 획득은 초기화(RAll, Resource Acquisition Is Intialization)
- 여기서 자원은 메모리
- C++은 개체지향을 지원하는 언매니지드 언어
한 개체가 생성 될 떄 필요한 메모리를 할당(생성자란 특별한 함수)
그 개체의 수명이 다할 떄 그 메모리를 해제(소멸자를 특별한 함수)
- 즉, 개체의 수명이라는 범위에 메모리의 수명 종속 시킴
- 이 원칙을 잘 따르면 실수할 여지가 적다.
근데 C는 개체가 없다
RAll를 최대한 흉내내면 좋긴 하다
C에서 RAll를 할 수 있는 부분
- 한 함수 안에서 malloc(), free()를 다 호출할 수 있는 경우
이런 이유 때문에 앞의 예에서 malloc을 추가시 곧바로 free하라는 이유.
그럼 원래의 문제는 어떻게 해결할까?
- C에서 굉장히 어려운 문제
- 최선의 방법은?
- 이런 함수가 최대한 없게 한다
- combine_string()예 , 함수 안에 할당하는 대신 함수 밖에 할당 후 매개 변수로 전달
동적 할당 후 반환을 피할 수 없다면?
- 딱히 모두가 동의하는 표준은 없다.
어떤 함수가 메모리 할당하면 그 사실을 함수에 표기
- 주석으로 표기하는 법도 있음. 근데 사람들은 주석 잘 안 읽음
- 동적 메모리를 할당하는 변수라면 변수명에 표기하는 방법도 있다.
베스트 프랙티스 정리
1 . malloc 작성 뒤 곧바로 free 추가
2 . 동적 할당을 한 메모리 주소를 저장하는 포인터 변수와 포인터 연산에 사용하는 포인터 변수를 분리해 사용하자
- 원래 포인터 변수를 사용할 시, 주소를 잃어버려서 해제 못할 수 도 있다.
3 . 메모리 해제 후, 널 포인터 대입
4 . 정적 메모리를 우선으로 쓰고, 어쩔수 없을 때만 동적 메모리 사용
5 . 동적 메모리 할당할 경우, 변수와 함수 이름에 그 사실을 알리자
다중 포인터
- 포인터란 뭐였나
- 주소를 저장하는 변수
- 그럼 그 변수의 주소를 또 저장할 수 있나?
그럼 주소를 저장한 변수의 주소는 어디에 저장하나?
- 포인터
- 그럼 포인터에 들어간 데이터의 자료형은?
이중 포인터
int num = 10;
int * p = #
int ** p = &p;
- 포인터 변수의 주소를 저장하는 변수를 이중포인터라고 한다
현실과 실제 데이터 비교
- 사물함 번호, 집 주소 : 메모리 주소
- 친구들 + 파티 : 실 데이터
다중 포인터를 쓰는 이유, 다중 포인터 예
이중 포인터는 주소의 주소
- 즉, 주소를 반복
그럼, 주소의 주소의 주소도 가능할까?
- 가능
int *** r = &q;
- 원한다면 30중 포인터도 가능
int ***************** z = &y;
- 근데 3중 포인터 쓰는 일도 매우 가끔
- 4중 이상은 거의 안씀.
이중 포인터는 왜 쓰나?
- 2차원 배열이 사실 이중 포인터와 비슷
- 2차원 자료가 많이서 2D 배열 많이 씀
- 구구단이라던가, 이미지라던가
메인함수의 매개변수에도 존재
메인함수의 매개변수인 argv도 엄밀히 말하면 이중포인터
포인터의 배열. 각 배열은 포인터
3차원 공간 다루는 곳은?
- 이미지 같은 2차원 다룰 떄는 2차원 배열이나 2중 포이넡 썼으니
- 3차원 공간 다루는 곳에선 3차원 배열 씀.
- 즉, 3중포인터도 사용 가능.
포인터 변수를 서로 교체하기
알아둬야 할 것
1 . 메모리 종류
- 메모리 스택, 힙 메모리, 동적 메모리
- 레지스터 및 CPU(register키워드)
- 스택과 힙 차이
2 . 동적 메모리
스택 메모리는 크기의 단점, 수명의 단점이 있었다.
이거를 프로그래머가 알아서 제어하게 해주는 게 동적 메모리.
동적 메모리 할당에 오는 순간 스택처럼 자동으로 메모리 관리 해주는 사람이 없어서 프로그래머가 기억해서 다 해제해줘야됨.
그리고 동적 메모리 가져오면 느릴 수 있다.
그래서 정적메모리를 가능하면 사용하고 정 안될 때 동적 메모리를 사용하자
동적 메모리는 실수할 부분이 정말 많다.(메모리 누수 같은 부분)
3 . 다중 포인터
포인터의 저장하는 값은? 주소