2021. 10. 28. 22:53ㆍc,c++ 기본/next step
안녕하세요 대학생 개발자입니다.
이번 글에서는 c언어의 기본을 공부할 때 간략하게 듣긴 했지만 정확한 원리에 대한 생각을 해보지 않았을 부분에 대해서 언급을 하려고 합니다.
게임 서버를 개발하실 정도의 실력이 가지고 계신 분들이라면 다 아실법한 내용들이 위주로 나오게 됩니다.
그러니까 아시는 분들은 어느 정도 걸러서 들으셔도 좋습니다.
게임서버를 개발하며 가장 중요한건 최적화와 안정성입니다.
알고리즘에 대한 최적화 부분도 상당부분 중요합니다.
하지만 당연히도 알고리즘만으로 최적화 할 수 있는 부분은 한계가 있습니다.
우선 우리는 메모리라는 녀석이 어떻게 생겨먹은 녀석이고 어떻게 사용할때 효율적인지를 알아보기 위해서 메모리에 대해 알아보고 갈겁니다.
그러다 보니 자연스럽게 언어와 관계없이 메모리 관점에서의 내용이 잦을 것이고 c언어 관점에서 보이지 않았던 부분을 찾아나가게 되므로 처음 접하시는 분들은 조금 난해할 수 있다는 점 유의해주시길 바랍니다.
변수에 대한 얘기를 하고 가겠습니다.
변수라는 것은 언어에서 만들어준 개념입니다.
실제 visual studio의 디버깅 도구인 메모리 뷰를 보게된다면 변수는 존재하지 않습니다.
사실 메모리뷰 라는 것은 가상 메모리를 순수하게 보여주는 것이기 때문에 파악에 힘들 수 있습니다.
변수라는것을 언어에서 만들어줬다는 부분은 어셈블리만 봐도 확인할 수 있습니다.
위의 어셈블리는 메인함수에 a라는 변수를 선언하고 0을 대입하는 코드이고 이를 x86 어셈블리로 가져왔을때 입니다.
실제 코드는 int a = 0; 이지만 실제 명령어들이 나열되어있는 어셈블리엔 a라는 이름은 보이지 않습니다.
그저 esp, ebp, ecx와 같은 레지스터 이름만 보이고 있습니다.
그리고 특이한 부분은 ebp - 4라는 곳에다가 0이라는 값을 대입하고 있습니다.
그럼 여기서 우리가 알 수 있는 것은 실제 컴파일러가 어셈블리를 뽑아줄땐 변수이름 같은건 모른다는 것이됩니다.
우리가 가져야할 변수에 대한 관점은 언어적인 느낌보다 컴파일러가 가지고있는 관점과 비슷하게 봐야합니다.
물론 기능만 돌아가면 된다고 생각하신다면 전혀 문제가 없겠습니다.
하지만 알고리즘을 통한 최적화가 끝난다음 더욱 성능을 높여야 하는 상황이라면 위의 관점에서 바라보는게 도움이 될겁니다.
그럼 이어서 계속 정리하면 함수에 선언된 변수들은 그저 스택에 널브러져 있는 하나의 메모리 구간에 불과하고 이를 구분짓기 위해 사용하는 레지스터가 sp레지스터입니다.
함수 호출 규약이나 아키텍쳐나 다양한 외부적 요인에 의해서 달라질 수 있겠지만 x86환경의 msvs에서는 bp와 sp 사이를 그 함수가 사용하는 공간으로 보고있습니다.
그리고 최적화 처리를 하지 않는다면 변수가 선언된 이후 사용하지 않을경우 그냥 비워진 공간이 되는 것입니다.
대부분의 c언어 기본서에서는 변수라는 것은 하나의 타입이 정해져있고 그 타입에 해당하는 값을 저장하는 곳이라고 언급하고 있습니다.
당연히도 c언어 책이기 때문에 언어적인 관점에서 해석을 한 것이고 전혀 틀린말이 아닙니다.
하지만 메모리 관점에서 본다면 똑같은 크기의 공간이라면 어떤게 들어가도 상관이 없으므로 그저 하나의 버퍼라는 용도로 보시는 것이 앞으로의 내용을 받아들이시는데 좋을 것 같다는 생각이 듭니다.
위의 사진에서 보셨듯 a라는 이름은 따로 존재하지 않습니다.
그저 ebp - 4라는 메모리 공간만 존재할 뿐입니다.
ecx를 push해서 esp를 4바이트 내리고 그로 인해 생긴 bp와 sp의 간격 4바이트를 a라는 변수를 담기위한 공간으로 사용하고 있습니다.
물론 이는 빌드 환경에 따라서 달라질 수 있는 부분입니다.
아마 스택을 직접 구현해보셨던 분들은 아실겁니다.
가장 위와 가장 아래를 두고 구현을 하는게 일반적일텐데요.
여기서 가장 아래를 bp라고 생각하시면 되고 가장 상단을 sp라고 생각하시면 될 것 같습니다.
그리고 스택은 노드 하나만을 가지고도 구현할 수 있죠.
노드를 하나만 사용하겠다고 하신다면 head라는 노드 하나만을 이용해서 구현을 하실 수 있을 건데요.
이 경우가 x64에서 사용되는 방법으로 sp만을 이용하는 부분이 된다고 일단은 생각해주시면 되겠습니다.
일단 우리는 메모리를 이런 관점으로 바라볼 필요성이 있고 일단 이정도 시각만 가지셨다고 해도 반은 시작했다고 할수 있겠습니다.(시작이 절반이기 때문입니다)
중간이 좀 생략됬습니다만
0x00D8FAAC 는 bp에 해당하는 부분입니다.
0x00D8FAA8 은 a라는 변수에 해당하는 부분입니다.
a라는 부분을 0으로 초기화 했었기 때문에 4바이트가 0으로 밀려있는 모습을 확인하실 수 있습니다.
위에서 말씀드린것과 같이 저 4바이트를 꼭 int로만 사용해야되는 것은 아닙니다.
변수가 생성되는 영역에 대한 간단한 정리
그럼 변수라는게 뭔지 간단히 정리해봤습니다.
그리고 변수는 꼭 스택에서만 사용할 수 있는 건 아닙니다.
어디 선언하느냐에 따라서 저장되는 곳이 완전 달라지게 되는데요.
가상메모리와 물리메모리에 대한 부분은 나중에 따로 언급하겠습니다만 아래에서 언급될 메모리는 모두 가상메모리라는 점을 고려해주시고 읽어주시길 바랍니다.
간단히 윈도우의 프로세스 메모리 구조를 보면 Stack, Heap, Data, Code영역이 나눠져 있습니다.
그리고 Sp, Bp레지스터와 같은 애들이 가르키고 있는 장소는 그중 Stack에 해당하는 영역입니다.
스택은 메모리 할당은 정적으로 일어나지만 초기화는 런타임에 일어나게 되는 구간입니다
전역 변수는 Data 섹션에 포함이 되며 초기화 되지 않은 변수는 BSS 섹션에 저장된다 라고 되어있습니다.
Data섹션은 메모리 할당과 초기화가 시작과 동시에 일어나는 부분이고,
BSS 섹션의 경우에는 메모리만 잡아두고 초기화는 런타임에 일어나는 구간입니다.
그래서 BSS 섹션에 함수 내에 할당된 static 변수에 대한 메모리가 들어간다고 알고있습니다.
동적 할당으로 얻은 변수들은 모두 Heap 섹션에 포함되게 됩니다.
malloc, _alligned_malloc, HeapAlloc을 통해 나오는 메모리들은 모두 Heap 섹션에 포함되게 됩니다.
Heap 섹션은 메모리 할당과 초기화 두 부분 모두 동적으로 일어나는 부분입니다.
그리고 마지막으로 코드영역입니다.
이 부분은 명령어들이 잔뜩 들어가게 되는데, 추후에 기회가 있으면 언급을 하도록 하겠습니다.
일단 간략히 말씀드리면 코드가 들어가는 부분이라고 생각하시면 될 것 같습니다.
위에서와 같이 이렇게 이름으로 구분을 지어놓긴 했지만 사실 그냥 메모리는 일자로 쭉 이어져있는 값들을 모아놓은 하나의 덩어리에 불과합니다.
그저 그걸 관리하는 방법에 따라서 구분을 지어 놓은것입니다.
변수와 관련된 정보는 이정도만 이해하고 넘어가셔도 충분히 값어치가 있다고 생각합니다.
c언어의 키워드들
c언어에는 키워드가 많이 있습니다.
모든걸 알아보자는게 아니라, 변수 선언과 관련된 키워드만을 언급해보려고 합니다.
키워드로는 auto, extern, static, register, const, volatile 등이 있는데요 하나씩 살펴보도록 하겠습니다.
auto
c언어에서의 auto는 c++의 auto와 다릅니다.
c++의 auto는 타입 추론입니다.
하지만 c언어에서의 auto는 그냥 적당히 보고 컴파일러가 어떤 메모리를 사용할지 맡겨두겠다 라는 개념입니다.
적당한 위치에 변수를 선언해달라고 요구하는것과 거의 동일한 맥락으로 보시면 될 것 같습니다.
auto int a;를 전역에 선언하면 이 때 컴파일러는 적당히 Data 섹션에 넣게 될겁니다.
auto int a;를 지역에 선언하면 이 때 컴파일러는 적당히 Stack 섹션에 넣게 될겁니다.
auto는 그냥 있으나 없으나 동일하다고 보셔도 되는 키워드입니다.
extern
extern키워드는 다른 cpp파일의 전역에 존재하는 변수를 사용하고 싶지만 헤더에 변수를 선언한다면 중복선언이 되기 때문에 문제가 되기 때문에 cpp 파일에 전역변수로 선언을 해두고 헤더에 extern선언을 둬서 중복선언이 나지 않게 사용하는 경우가 대다수입니다.
static
static변수는 전역 변수와 동일하게 데이터 섹션(크게 봤을때)에 들어가게 됩니다.
근데 static 키워드가 어디에 붙느냐에 따라서 살짝 역할이 달라지는데요
1. static이 전역 변수에 붙었을 경우에는 이 소스 내부에서만 사용하는 변수로 취급하겠다는 의미가 됩니다.
2. static이 지역 변수에 붙었을 경우에는 이 함수 내부에서만 접근이 가능하지만 실제 메모리가 잡히는 위치는 데이터 섹션이 될것입니다.
static은 말그대로 정적이라는 의미입니다.
데이터 영역에 한번 박혀서 고정된 채로 변하지 않는 메모리 정도로 해석을 하셨다면 올바른 해석을 하신겁니다.
전역 변수는 당연히 시작과 동시에 초기화가 이뤄질 겁니다.
반면에 로컬 변수는 시작과 동시에 초기화가 이뤄지지 않습니다.
그렇기 로컬 static 변수의 경우에는 매번 플래그 체크를 통해 이 변수가 초기화 되었는지 안되었는지 확인하는 구간이 들어가게 될겁니다.
결론으로는 로컬 static 변수는 매번 함수가 호출될때마다 확인하는 일련의 작업을 하고있기 때문에 아주 약간이라도 퍼포먼스를 깎아먹는 작업을 하게 되겠습니다.
register
register는 cpu가 연산을 위해 저장을 해두는 메모리라고 합니다.
그럼 register 키워드가 붙은 변수는 대략적으로 어떤 역할을 하게 될지 추론을 해볼 수 있을 것 같습니다.
이 변수를 레지스터에 올려놓고 사용하라는 키워드가 됩니다.
물론 레지스터에 올려놓고 쓰라는 것을 강제할 수는 없습니다.
컴파일러가 내부적으로 알아서 필요한 변수들은 레지스터에 올려놓고 사용하고 있기 때문에 거의 무시 당하게 됩니다.
그렇기 때문에 register변수의 경우에는 따로 선언을 하지 않아도 컴파일러가 필요하면 하게되고,
우리가 선언을 해줘도 컴파일러가 필요하지 않다면 무시할 수 있습니다.
const
const는 constant의 줄임말입니다.
말 그대로 상수화 시킨다는 의미입니다.
#define과 같은 문장과 비슷하게 사용되고 있지만 이는 약간 다른점이 있습니다.
전처리 지시문을 통한 처리를 하게 된다면 컴파일러는 알수 없습니다 이미 A는 100으로 치환이 되어있기 때문입니다.
반대로 const 변수를 사용하게 된다면 그 작업을 컴파일러가 스스로 하게 됩니다.
또한 컴파일러 선에서의 상수화지 실제 그 메모리가 상수화가 되는 것은 아닙니다.
즉 컴파일러가 어셈블리를 뽑아줄 때 const 변수가 들어간 코드가 있다면 그 상수값을 그냥 넣어버리는 겁니다.
그런게 가능하다보니 다음과같은 UB가 생기게 되는데요.
const int A = 100;
int * ptr = (int*)&A;
*ptr =20;
printf("% d", A);
...출력값 100
위 코드를 실행시키면 출력값이 100이 나옵니다.
엥?? 변수를 출력했는데 어떻게 그 값이 다르게 나올 수 있다는 건가요?
이부분은 위에서 언급드렸던 컴파일러가 상수화를 시켰기 때문입니다.
앞으로 저 변수가 등장하게 된다면 컴파일러는 그냥 그 변수와 맵핑되어있던 값을 집어넣어 버릴뿐이지 더이상 메모리를 참조하는 행동을 하지 않습니다.
다음 간단한 예시를 보며 다시한번 말씀드리겠습니다.
위 코드에서 int*ptr = (int*)&A까지 진행된 메모리 뷰를 보겠습니다.
제일위의 0x0093FEB0 는 0093FEB4라는 값을 가진걸로 봐서 포인터 변수인것 같습니다.
그러므로 ptr이 되겠습니다.
그리고 0x0093FEB4는 0x12121212라는 값이 들어가있는 것으로 봐서 A 변수인것 같습니다.
그럼 더 진행을 시켜서 *ptr = 0x34343434로 바꾸는 코드를 실행해보겠습니다.
분명 A변수가 저장되어있던 자리에 0x34343434로 값이 변경된 것을 확인하실 수 있습니다.
그렇다면 printf의 결과는 어떻게 될까요?
분명 메모리에서는 0x34343434로 변경이 되어있었습니다.
하지만 실제 출력된 값은 0x12121212로 처음에 저장했던 값이 되어있습니다.
분명 A변수의 값이 변경된걸 메모리 뷰를 통해서 확인했었지만 이상합니다.
위의 어셈블리에서 분명 0x12121212라는 값을 파라미터로 전달해버립니다.
심지어 dword ptr[A]가 전달되지도 않았습니다.
물론 이부분은 컴파일러에 따라서 달라질 수 있는 부분입니다. const int는 컴파일러 선에서의 상수화 정도로 기억하시면 될 것 같습니다.
volatile
volatile에 대한 부분은 조금 생각해볼 부분이 있기때문에 길어질 수도있겠습니다.
volatile이라는 변수를 구글링 해보면 두가지 정도의 내용을 쉽게 찾으실 수 있을겁니다.
1. 실제 메모리에 접근해서 변수를 가져온다.
2. 최적화를 하지 않는다.
우선 1번은 2번에 의해 파생되는 효과이니 우선은 2번으로 가겠습니다.
최적화 하지 않는다는게 무슨 의미일까요.
아마 vs에서 릴리즈 빌드를 했을때 자동으로 적용되는 옵션중 하나가 "최대최적화" 일겁니다.
vs에서만 보자면 이 최대 최적화 기능을 이 변수에게는 적용하지 말라는 의미가 되겠습니다.
조금더 들어가본다면 이 변수는 최적화 되어야 하는 대상이 아니기 때문에 항상 레지스터에 올려놓고 쓰지 말라는 의미가 될겁니다.
flag = true;
while(flag)
{
....
}
간단한 예제 코드를 보며 말씀드리겠습니다.
flag는 true이고 while문 안에서는 flag를 변경하는 코드가 없다고 가정한다면 이 구간은 영원히 반복될 겁니다.
그럼 이런 경우에는 flag를 읽어서 true인지 체크하는 대신에 true를 레지스터에 올려놓고 사용해도 되지 않을까요?
이런 생각으로 부터 나온 개념이 최적화라는 개념입니다.(물론 이 뿐만 아니라 이건 하나의 예시에 불과합니다)
위의 상황에서는 사용하지 않아도 되는 변수를 굳이 매번 읽으로 가는 비효율을 줄이기 위한 방법으로 적용된 건이죠.
그럼 이 상황에서 최적화를 하지 않는다는 말은 무슨 말일까요?
위의 맥락과 동일합니다.
최적화 하지말라고 했으니 레지스터에 올려놓는 대신 매번 이 변수를 읽으러 메모리를 참조하게 될겁니다.
즉 여기서 1번의 효과가 나오는 것입니다.
실제 메모리에 접근해서 변수를 가져오는 효과를 여기서 내게 되는겁니다.
즉 근본적인 얘기를 하자면 최적화를 하지 않기 때문에 나오는 효과로써 실제 메모리에 대한 접근이 이뤄진다가 되는것이지 volatile의 역할이 실제 메모리에 접근해서 변수를 가져온다가 되는건 아니라는 것입니다.
그리고 제가 어디서 봤는지 기억이 안나지만 어느 블로그에는 메인 메모리에 매번 접근한다고 적혀있었습니다.
아주 옛날 cpu 혹은 이론상으로는 가능한 일입니다만 현역 cpu에서는 절대로 이런 일이 일어날 수 없습니다.
이건 현역 cpu의 캐시 정책을 전혀 모르고 하시는 말씀이십니다.
추후에 캐시에 대한 얘기도 언급이 되겠지만 현역 cpu들의 캐시 정책상 절대 메인메모리까지 내려가는 일은 없습니다.(Self Modefying이라는 개념을 제외하고 말입니다)
결론으로 volatile이라는 키워드가 하는 일은 "이 변수는 최적화해서 쓰지 말아라" 라는 의미까지 인것이고 그 이후에 파생되는 효과는 volatile의 역할이 아닌 그외 다른 요소들이 조합되어서 나타나는 효과라는 것입니다.
그리고 최적화가 off되어있는 상황에서는 아무런 역할을 하지 못하는 키워드 입니다.
마무리 한 줄 요약으로 해석을 한다면 이런 식으로 하시면 됩니다. "내가 짠 로직 그대로 돌게 해줘" 이게 가장 괜찮은 해석이라고 생각합니다..
음... 짧은 포스팅이 될 줄 알았는데... 생각보다 긴 글이 되어버렸습니다..ㅠㅠㅠ
c언어와 c++섹션 구간은 제가 추가로 정보를 습득할 때마다 추가적으로 보완을 해서 계속 올리도록 하겠습니다.
그럼 오늘은 여기서 마무리를 짓고 다음번에 더 좋은 정보를 가지고 오도록 하겠습니다..
안녕히 계세요.
'c,c++ 기본 > next step' 카테고리의 다른 글
메모리에 대한 얘기 (0) | 2021.11.07 |
---|---|
바이트 패딩과 캐시에 대한 얘기 (0) | 2021.11.06 |
전처리기, 바이트패딩룰에대한 얘기 (0) | 2021.11.01 |
함수에 대한 얘기 (0) | 2021.11.01 |
연산자와 조건문에 대한 얘기 (4) | 2021.10.29 |