-
[리눅스] - printf & printk프로젝트/운영체제 만들기 2023. 6. 19. 01:59
글의 참고
- https://wiki.osdev.org/Formatted_Printing
- https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attributes.html#index-format-function-attributehttps://gcc.gnu.org/onlinedocs/gcc-3.2/gcc/Function-Attributes.html
- https://en.wikipedia.org/wiki/Control_character
- https://stackoverflow.com/questions/52891546/what-does-va-args-mean
글의 전제
- 내가 글을 쓰다가 궁금한 점은 파란색 볼드체로 표현했다.
- 밑줄로 작성된 글은 좀 더 긴 설명이 필요해서 친 것이다.
글의 내용
- 아스키 코드(ASCII)
" printf 문에 대해 분석하기전에 먼저 `아스키 코드` 에 대해 알아볼 필요가 있ㄷ. 아스키 코드는 `American Standard Code for Information Interchange` 의 약자로 전자 통신 분야에서 문자 인코딩의 표준중 하나이다. 아스키 코드는 총 128 자로 이루어진 문자 테이블이다. 영어를 기반으로 제작되었고, 7비트를 사용하기 때문에 128 자 밖에 표현하지 못한다. 아스키 코드에서 실제 출력이 가능한 문자는 95 자다. 나머지 33 자(십진수로 0 부터 31 까지 총 32 개와, 마지막 127 번까지 해서 총 33 개의 컨트롤 코드가 존재) 는 `컨트롤 코드` 라고 해서 출력이 불가능하다. 아래는 출력이 불가능한 차트를 보여준다. 예를 들어, `Line Feed(LF)`는 우리가 흔히 아는 `줄 바꿈` 을 의미하는 코드다.
" 아스키 코드에서 94 글자(0x20(32) - 0x7E(126)) 까지는 출력 가능한 문자들로 등록되어 있다. 아래표에서 점선 네모 박스로 표시된 33개는 출력이 불가능한 코드이고, 나머지 모두는 출력이 가능하다.
" 줄 바꿈 같은 경우는 주의가 필요하다. 운영 체제마다 줄 바꿈에 대응하는 컨트롤 코드가 다르기 때문이다. 만약, 본인이 줄 바꿈을 명시적으로 사용하면서 플랫폼에 독립적인 프로그램을 만들 계획이라면, 프로그램이 로딩 되는 시점에 운영 체제를 파악해서 줄 바꿈 매크로 값을 변경할 필요가 있다.
- 가변 인자 [참고1 참고2 참고3]
" va_start() 매크로 함수는 compiler 에서 제공하는 함수다. 왜냐면, ABI 에 영향을 받기 때문이다. 예를 들어, 32-bit x86 시절에는 스택을 통해서 함수의 인자를 전달했지만, x86_64 에서 registers + stack 을 이용해서 파라미터를 전달한다. 이 글은 32-bit x86 에서 사용하던 ABI 를 기준으로 가변 인자를 설명한다. 기본적인 형태는 다음과 같다.
void va_start( va_list ap, last_arg);
" 여기서 `last_arg` 가 중요하다. last_arg 는 흔히 `고정 인수` 라고 한다. 아래 코드에서 고정 인수는 어떤 걸까? "I`m %d, and my name is %s\n" 이 고정 인수다. 그렇다면, 이게 왜 고정 인수라고 불릴까? 길이가 고정이기 때문이다.
printf("I`m %d, and my name is %s\n", 10, "yohda");
" 가변 인자의 시작 주소를 구하기 위해서는 고정 인수의 시작 주소와 사이즈를 알고 있어야 한다. 왜 그럴까? 가변 인자의 시작 위치가 고정 인자가 끝나는 위치이기 때문이다. 아래 그림처럼 고정 인수의 시작 주소에다가 고징 인수의 크기를 더하면 가변 인수의 시작 주소를 알 수 있다. 그렇다면, 고정 인수의 시작 주소는 어떻게 구할까? 이건 GCC 의 도움이 필요하다. 이건 뒤에서 다시 설명한다.
Figure 1- Case 1
" 예시 코드를 통해 알아보자. 아래 코드를 보면, va_start() 의 고정 인수로 `format` 이 전달되고 있다. 그런데, format 의 데이터 타입은 `char *` 이다. 그렇다면, pointer type 이니깐 4 바이트를 건너뛰는 것일까? 맞다. 문자열은 다른 곳에 저장되기 때문에 4 바이트면 건너뛰면 된다. 이 내용은 뒤에서 다시 본다. 그리고, va_start(list, format) 함수를 호출하면, `list` 변수에 첫 번째 가변 인자의 시작 주소가 위치하게 된다.
__attribute__ ((format (printf, 1, 2))) int printf (const char* format, ...) { va_list list; va_start (list, format); int i = vprintf (format, list); va_end (list); return i; }
" 예를 들어, 아래 코드를 통해 스택은 어떻게 생성될까?
printf("I`m %d, and my name is %s\n", 10, "yohda");
" 위에 `Figure 1` 과 비교해서 데이터의 위치가 어떻게 배치되었는지 비교해보자. 그리고, 위에서 언급했던 char *(pointer type) 고정 인수는 어떻게 메모리에 배치되는지도 확인하자(문자열은 별도의 영역에 할당).
Figure 2" 문자열을 별도의 영역에 할당하는 이유는 va_start() 함수의 내부 구조 때문이다. 뒤에서 보겠지만, va_start() 함수는 내부적으로 고정 인수의 길이를 구할 때, `INTSIZOF()` 매크로를 사용한다. 이 매크로 함수는 pointer type 을 4 바이트로 계산한다. 그래서, pointer type 은 별도의 영역에 고정 인수를 할당하는 것이다.
- Case 2
" 이번 예제는 printf() 함수에 인자가 3 개다. `num` 을 전달한 이유는 가변 인자의 개수를 직접 전달하기 위해서다.
__attribute__ ((format (printf, 1, 3))) int printf (const char* format, int num, ...) { va_list list; int arg, sum = 0; va_start (list, num); for(int i=0 ; i<num; i++) { arg = va_arg(list, int); sum += arg; } va_end (list); return i; }
" 즉, printf() 함수를 다음과 같이 호출했을 것을 추측할 수 있다. 두 번째 인자인 `2` 가 가변 인자(10, "yohda") 들의 개수를 의미한다.
printf("I`m %d, and my name is %s\n", 2, 10, "yohda");
" 코드의 설명을 이어나가면 va_start(list, format) 함수를 호출해서 변수 `list` 가 가변 인수의 시작 주소를 가리키도록 한다. 그리고, va_arg() 함수는 첫 번째 인자의 시작 주소에서 두 번째 인자의 데이터 타입만큼을 데이터를 읽어오는 함수다.
" 그렇다면, 문자열 어떻게 계산할까? 예를 들어, `va_arg(list, int)` 라면 list 가 가리키는 주소에서 4 만큼을 가져올 것이다. list 의 주소에 4 를 더할 것이다. `va_arg(list, char)` 라면 list가 가리키는 주소에서 1 만큼 가져올 것이다. `va_arg(list, char*)` 는 어떻게 할까? 포인터라서 4만큼 가져올까? `char*` 은 NULL 문자를 만날 때까지가 사이즈가 된다.
- va_start() 내부 구조
" windows32 의 va_start() 함수 내부 구조를 분석해보자. 고정 인수(v)에 일반 data type 을 던지면, 가변 인자의 시작 주소를 정상적으로 추출해 낼 수 있다. 그런데, 아래 구조는 pointer type 을 던지면, 굉장히 이상한 주소가 나올 수 밖에 없는 구조다.
// https://www.codeproject.com/Articles/4181/Variable-Argument-Functions #define va_start (ap,v)(ap = (va_list)&v + _INTSIZEOF(v))
" 그래서, WIN32 에서는 x86 어셈블리 명령어인 `lea` 명령어를 통해서 `v` 의 주소를 추출해낸다. `lea` 명령어를 통해서 v 가 일반 data type or pointer type 이든 상관없이 주소를 추출해 낼 수 있다.
// https://www.codeproject.com/Articles/4181/Variable-Argument-Functions #ifdef va_start #undef va_start #ifdef _WIN32 // for 32-bit #define va_start(ap,v){int var=_INTSIZEOF(v);\ __asm lea eax,v __asm add eax,var \ __asm mov ap,eax \ } #else // for 16-bit #define va_start(ap,v){int var=_INTSIZEOF(v);\ __asm lea ax,v __asm add ax,var \ __asm mov ap,ax\ } #endif #endif
- GCC 확장자 [참고1]
" 위에 예제들에서는 문자열 포맷(`const char *format`) 이 항상 함수의 제일 첫 번째 인자로 왔지만, 반드시 그럴 필요는 없다. 그런데, 문제는 가변 인자의 시작 주소가 고정 인자에 끝을 가리켜야 하기 때문에 함수에 인자를 전달할 때, 고정 인수와 가변 인자의 위치를 고정해야 한다는 것이다. 이 문제를 해결하기 위해 GCC 확장 기능으로 `format` 을 제공한다. 형태는 다음과 같다.
" format (archetype, string-index, first-to-check)
" `format` 속성에는 `printf, scanf, strftime, strfmon` 함수들과 같은 문자열 포맷을 지정할 수 있다. 이 속성을 통해서 전달되는 인수들의 문자열 포맷을 검사한다. 여기서 문자열 포맷이 `printf, scanf, strftime, strfmon` 과 다르다면, 에러를 발생시킨다.
extern int my_printf (void *my_object, const char *my_format, ...) __attribute__ ((format (printf, 2, 3)));
" `archtype` 은 포맷 문자열을 어떻게 해석해야 하는지를 지정한다. 예를 들어, 여기에는 `printf, scanf, strftime, strfmon` 값을 지정할 수 있다. 혹은 `__printf__, __scanf__, __strftime__, __strfmon__` 사용해도 상관잆다.
" `string-index` 는 함수 인자에서 포맷 문자열이 몇 번째 인자 인지를 지정한다. 예를 들어, 위의 my_printf() 함수에서 포맷 문자열은 `my_format` 이다. 함수에서 해당 인자는 2번째 인자로 위치에 있으므로, `string-index`는 2가 된다.
" `first-to-check` 는 함수 인자에서 검사할 인자가 몇 번째 인자 인지를 지정한다. 예를 들어, 위의 my_printf() 함수에서 포맷 문자열이 검사할 인자는 `...` 다. 함수에서 해당 인자는 3번째 인자로 위치에 있으므로, `first-to-check`는 3가 된다.
- printf 재정의
" __FUNCTION__, __LINE__, __FILE__ 등은 컴파일러 차원에서 지워해주는 매크로인데 정말 자주 사용하니 꼭 알아놓자. 아래에서 `fmt`와 `##__VA_ARGS__`의 위치에 따라 출력 내용이 어떻게 변하는지 확인해보자.
#define debug0(fmt, ...) kPrintf("f#%s,l#%d" fmt, __FUNCTION__, __LINE__, ##__VA_ARGS__); #define debug1(fmt, ...) kPrintf(fmt "f#%s,l#%d", ##__VA_ARGS__, __FUNCTION__, __LINE__); int main(void) { debug0("123"); // f#main,l#6 123 debug1("123"); // 123 f#main,l#6 return 0; }
" 아래 두 문장의 차이는 __VA_ARGS__의 `##` 의 존재 유무다. 이 `##` 이 존재하면 앞에 `,` 콤마를 지워준다. 예를 들어, debug("123") 이라는 문장은 아래 식에 따라 각각 다음 주석과 같이 해석된다. 즉, `##`은 포맷에 대한 아규먼트가 없을 경우, 앞에 콤마를 지워줌으로서 컴파일 에러를 방지해준다.
#define debug(fmt, ...) kPrintf("f#%s,l#%d" fmt, __FUNCTION__, __LINE__, ##__VA_ARGS__); // debug("123"); #define debug(fmt, ...) kPrintf("f#%s,l#%d" fmt, __FUNCTION__, __LINE__, __VA_ARGS__); // debug("123",)
- colored printk [참고1]
" kernel 에서 printk() 함수를 사용할 때, ANSI 및 GCC 를 기준으로 colored text 를 출력할 수 있도록 해주는 기능이 있는 것 같다.
printf("\033[1;31mHello, world!\n"); // red printf("\033[1;32mHello, world!\n"); // green printf("\033[1;33mHello, world!\n"); // yellow printf("\033[1;34mHello, world!\n"); // blue
- printk log level [참고1 참고2]
" 출력 레벨이 낮을 수 록, 긴급하고 위험한 수준의 메시지만 출력하고, 레벨이 높을 수 록 대부분의 메시지(디버깅 메시지를 포함한) 를 출력하게 된다. 결론적으로, 디버깅이 아니라면, 여러 메세지를 출력하는 것은 전체적인 시스템 성능을 낮추므로 양산으로 제품이 나갈 때는 출력 레벨을 낮춰서 진행하는 것이 좋다.
https://diggingfun.tistory.com/232" 로그 레벨을 변경하는 방법은 많지만, 아래 3 가지가 자주 사용된다(빌드 레벨에서 설정하는 방법도 있는데, 여기서는 생략).
1. /etc/sysctl.conf 에서 `kernel.printk` 의 내용을 수정
2. dmesg 에 옵션을 함께 전달
- 예를 들어, `dmesg -n 5` 를 실행하면, 출력 레벨에 따른 dmesg 의 내용을 볼 수 있다.
3. echo n > /proc/sys/kernel/printk"