-
C 상식프로젝트/운영체제 만들기 2023. 8. 8. 02:14
글의 참고
글의 전제
- 내가 글을 쓰다가 궁금한 점은 파란색 볼드체로 표현했다. 나도 모르기 때문에 나중에 알아봐야 할 내용이라는 뜻이다.
- 밑줄로 작성된 글은 좀 더 긴 설명이 필요해서 친 것이다. 그러므로, 밑 줄 처친 글이 이해가 안간다면 링크를 따라서 관련 내용을 공부하자.
- `글의 참조`에서 빨간색 볼드체로 체크된 링크는 이 글을 작성하면 가장 많이 참조한 링크다.
- `운영체제 만들기` 파트에서 퍼온 모든 참조 글들과 그림은 반드시 `이 글과 그림을 소스 코드로 어떻게 구현을 해야할까` 라는 생각으로 정말 심도있게 잠시 멈춰서 생각해봐야 실력이 발전한다.
글의 내용
- Declare vs Define
: 아래 예시 코드가 모든 걸 말해준다.
void add(int a, int b); // declase void add(int a, int b) // define { return a + b; }
- 구조체 비트 필드
: 구조체 비트 필드는 스펙의 내용 자료 구조로 만들어야 할 때, 많이 쓰이는 문법이다. 참고로, __attribute__((packed)) 속성과는 함께 쓰지 말자. 앞에 속성은 비트 단위로도 정렬을 하기 때문에 퍼포먼스 문제가 발생한다고 알려져 있다.
struct file { unsigned int a : 14; unsigned int b : 4; unsigned int c : 10; };
: a에 14비트, b에 4비트, c에 10비트 까지해서 총 28비트가 할당된다. 마지막 4비트는 버려진다.
- 이중 포인터
: 이중 포인터가 어떻게 쓰는건지는 이미 너무 많은 정보가 인터넷에 나와있다. 여기서는 이중 포인터를 왜 쓰는지 알아본다. 일반적으로 하나의 포인터는 하나의 주소를 가리킨다. 그런데 포인터는 자신의 가리키는 주소를 기준으로 자신의 데이터 크기만큼 연속적으로 앞,뒤 주소들에 접근이 가능하다.
예를 들어, 아래의 코드가 있다고 치자.struct file { int a int b int c };
: struct file f = malloc(sizeof(struct file)×3) 이라고 하면 f 는 총 36바이트를 받게 된다. 그런데 중요한 건 이 데이터 연속적이라는 거다. 즉, 12바이트(struct file)로 정렬되어 있다고 볼 수 있다. 이러한 메모리의 연속성은 CPU에게는 속도 향상을 가져다 주고 소프트웨어 개발자에게는 코드를 쉽고 일관적이게 짤 수 있는 편리함을 가져다 준다.
: 그럼 위의 내용을 기억하고 다시 이중 포인터로 돌아와보자. 이중 포인터는 2가지 이유로 쓰인다.1" 연속적이지 않은 여러 곳의 주소를 가리키고 싶을 때,
2" 함수에 인자로 전달된 포인터가 가리키는 곳에 값을 바꾸고 싶을 때,: 포인터는 하나의 메모리 영역을 바라볼 수 있다고 했다. 그리고 대게는 연속적인 동일한 사이즈의 여러 개 메모리의 첫 주소를 가리킨다. 이 말은 하나의 포인터는 특정 하나의 메모리만 가리킬 수 있다는 말이다. 파일 시스템을 만든다고 해보자. 파일 시스템은 기본적으로 폴더와 파일이 있을 것이다. 이 내용을 코드로 작성해보자.
struct file { char name[256]; uint8_t type; uint8_t time; }; struct dir { char name[256]; struct files[1024]; };
: 폴더 구조체안에 파일 1024개를 담을 수 있다. 이제 아래와 같은 구조의 폴더가 있다고 치자.
$ ls yohda/ abc 123
: yohda/ 라는 폴더에 `abc`과 `123` 파일이 존재한다. 여기서 `abc`의 이름을 `888`로 바꾸면 어떻게 될까? struct dir 구조체안에 files라는 변수는 배열이다. 배열은 주소를 저장하지 않기 때문에, 파일이 이름이 바뀔 경우 값을 다시 재복사 해줘야 한다. 퍼포먼스 문제에서 좋지 않다. 그리고 배열의 사이즈가 정적이라서 더 많은 파일이 필요할 경우, 문제가 발생한다. 그렇면, 포인터로 바꿔보자.
struct file { char name[256]; uint8_t type; uint8_t time; }; struct dir { char name[256]; struct *files; };
: 위의 2가지 문제를 모두 해결해줄까? 1가지는 해결해주지 못한다. 예를 들어, `files = malloc(sizeof(struct file) * 1024);` 라고하면, 사실 이것도 사이즈는 정해져 있는 것과 같다(물론, realloc()이 있지만, 그건 제외하자. realloc은 사실 퍼포먼스에서 굉장히 좋지 않다. 이 글을 참고하자). 대신 주소를 가리키기 때문에 내용이 바뀌어도 문제가 없다. 그런데, 이걸 생각해보자. 저 `files` 변수는 연속적인 메모리에만 접근이 가능하다. 아래 그림과 같이, 일차원 포인터는 연속적인 메모링에만 접근이 가능하다.
: 이걸 다른 의미로 보면 일차원 포인터로 메모리를 관리하려면, 처음에 메모리를 많이 할당 받을 수 밖에 없는 구조이다. 왜냐면, 일차원 포인터는 필요할 때마다 메모리를 할당받을 수 있는 자료 구조가 아니기 때문이다. 메모리 주소 여러군데를 가리킬 수 있긴 하지만, 전제는 해당 메모리 주소들이 전부 연속적이여야 한다는 것이다. 그러면, 파일이 새로 생성할 때마다 메모리를 할당받아서, 해당 파일의 주소를 저장할 수 있는 구조가 있을까? 즉, 이 말은 메모리 주소가 비연속적이여도 저장할 수 있는 자료 구조여야 함을 의미한다. 이중 포인터면 가능하다.
: 위에 보는것과 이중 포인터라면 여러 주소를 가리킬 수 있기 때문에, 그때그때마다 메모리를 할당받아서 사용해야 자료 구조가 필요하다면 이중 포인터를 사용하면 된다. 그리고 포인터에 익숙한 사람들이 자주 깜빡하는 내용이 있다. 아래의 코드를 보자.
void add(int a, int b, int *sum) { sum = malloc(sizeof(int)); *sum = a + b; } int main() { int *sum; add(1, 4, sum); printf("%d\n", *sum); return 0; }
: 위의 코드는 첫 번째 인자와 두 번째 인자의 합을 세 번째 인자에 저장하는 코드다. sum의 값은 5가 출력될까? 안된다. 위의 코드가 앞에 말한 것과 같이 동작하려면, 아래와 같이 변경되어야 한다.
void add(int a, int b, int **sum) { *sum = malloc(sizeof(int)); **sum = a + b; } int main() { int *sum; add(1, 4, &sum); printf("%d\n", *sum); return 0; }
- 인라인
: `inline` 키워드는 주로 함수에 사용되는데, 매크로 함수와 유사한 기능을 한다. 인라인 함수는 함수의 오버헤드를 줄이기 위해 등장한 키워드다. 함수 호출이 아닌, 함수의 내용으로 해당 라인을 대체하기 결국 중복 코드가 많아지고 그 결과 실행 파일의 사이즈가 커질 수 있다는 단점이 있다.
: C99에서 등장한 키워드다
: `extern inline`을 사용해서 인라인 함수를 외부로 노출시킬 수 있다.
: `inline` 키워드를 사용한 함수는 외부로 노출되지 않는다.
- Typedef Struct Array
: 32비트 와 64비트의 가변 인자의 타입이 서로 다르다. 아래 코드를 보자.
typedef struct { unsigned int gp_offset; unsigned int fp_offset; void *overflow_arg_area; void *reg_save_area; } va_list[1];
: 위의 코드에서 마지막에 `var_list[1]`가 보인다. 즉, 이 구조체로 변수를 선언하면 해당 변수는 무조건 배열로 선언된다.
int main() {
va_list ap;
ap.gp_offset = 1; // X
ap->gp_offset = 1; // X
ap[0].gp_offset = 1; // O
return 0;
}: va_list는 배열 구조체이기 때문에, `ap[0].gp_offset = 1`과 같이 접근해야 올바르게 접근할 수 있다. 함수 인자로 전달할 경우 어떻게 해야할까? 기본적으로 C언어에서 배열에는 값을 할당할 수 없다. 배열 요소에는 값을 할당할 수 있지만, 배열 이름자체에는 값을 할당 수 없다.
- Case 괄호
: 오른쪽은 에러, 왼쪽은 성공이다. `case` 문 안에서 변수 선언은 반드시 괄호가 있어야 한다.
case MULTIBOOT_TAG_TYPE_BASIC_MEMINFO:
{
struct multiboot_tag_mmap *mmap = (struct multiboot_tag_mmap *)tag;
struct multiboot_mmap_entry *entries = (struct multiboot_mmap_entry *)mmap->entries;
while((uint8_t *)entries < (uint8_t *)mmap + mmap->size) {
debug("type#0x%x\n", entries->type);
debug("base address#0x%x_%x\n", entries->addr >> 32, entries->addr & 0xFFFFFFFF);
entries = (struct multiboot_mmap_entry *)((uint8_t *)entries + mmap->entry_size);
}
}
break;case MULTIBOOT_TAG_TYPE_BASIC_MEMINFO:
struct multiboot_tag_mmap *mmap = (struct multiboot_tag_mmap *)tag;
struct multiboot_mmap_entry *entries = (struct multiboot_mmap_entry *)mmap->entries;
while((uint8_t *)entries < (uint8_t *)mmap + mmap->size) {
debug("type#0x%x\n", entries->type);
debug("base address#0x%x_%x\n", entries->addr >> 32, entries->addr & 0xFFFFFFFF);
entries = (struct multiboot_mmap_entry *)((uint8_t *)entries + mmap->entry_size);
}
break;- 포인터 메모리 참조
: 아래 2개의 차이가 뭘까? 포인터 타입에 따라, 주소가 건너뛰는 타입이 달라짐에 주의하자.
uint8_t * bda = 0x400;
uint32_t p;
p = bda[14]; // 0x400 + 14(1*14)uint16_t * bda = 0x400;
uint32_t p;
p = bda[14]; // 0x400 + 28(2*14)- 데이터 타입
: `unsigned` 데이터 타입은 `unsigned int`와 동일하다.
- 32비트에서 64비트 정수형 변수
: 32비트 머신에서 long long은 64비트를 의미한다. 어떻게 구현되어 있을까? 32비트 정수 2개가 합쳐져서 구현이 되어 있다.
: 64-bit 정수값을 사용할 때, 주의할 점이 있다. 변수를 선언할 때는 `unsigned long long`으로 선언하면 자동으로 64비트 연산이 적용되지만, 매크로 상수를 정의하거나 명시적인 값을 사용할 때는 반드시 숫자뒤에 `LL`을 작성해야 한다.
#define ABC 1234232323ULL -> unsigned long long 타입의 64-bit 정수형 매크로 값 #define CBA 1234232323LL -> long long 타입의 64-bit 정수형 매크로 값
: 64-bit 정수형 매크로를 사용하기 위해서는 64-bit 타입에 따라 숫자뒤에 `LL` 혹 `ULL`은 작성해야 한다.
u64 chan_01 = vol + 100080000000ULL
: 64비트와 무언가를 연산하면 데이터 크기가 더 큰놈의 타입을 따라간다.
printf("%lld", sizeof(12 + 23LL)); // 8 printf("%lld", sizeof(12 + 23)); // 4 printf("%lld", sizeof(12 / 23LL)); // 8 printf("%lld", sizeof(12 - 23LL)); // 8
- 길이가 0인 배열
: C99에서 도입된 특별한 문법이있다. 길이가 0인 배열이 있다.
struct line { int length; char contents[0]; };
: sizeof 연산자로 사이즈를 측정하면, 길이가 0인 배열의 사이즈는 0으로 나온다. 이 배열은 구조체의 필드의 마지막 위치에서만 의미가 있다. 마치 포인터 같지만, 길이를 차지하지 않는다. 그거 뿐이다. GNU 공식 문서에서는 길이가 0인 배열을 구조체의 마지막 멤버 변수를 제외하고 단독으로 사용하는 것을 추천하지는 않는다.
- 문자열 처리
: 문자열은 C언어에서 알면 알 수록 정말 어렵다. 문자열을 처리하는 방법의 전제가 NULL 문자이기 때문이라고 생각된다.
: 이 글에서는 내가 YohdaOS를 만들면서 문자열에 대한 전반적인 내용과 주의 사항에 대해 적으려고 한다. 일단 문자열에서 가장 먼저 기억해야 할 부분은 NULL이다. 아래의 코드를 보면서 이게 무슨 의미의 에러 처리일까를 생각해보자.
int insert(struct test *root, struct test *new) { ... ... if((!root->name || !new->name) || (!root->name[0] || !new->name[0])) return ERROR; ... ... }
: 위의 에러 처리는 2가지 내용을 포함한다.
1" 포인터가 가리키는 주소가 NULL인지 판단. -> ptr->name
2" 포인터가 가리키는 주소가 NULL은 아니지만, 문자열이 존재하지 않는지를 판단. -> ptr->name[0]: 즉, 포인터가 가리키는 주소가 NULL인 경우는 당연히 걸러야 하고, 문자열이 존재하는지도 검사해서 아무 문자열도 없으면 그것도 거른다.