Arm Linux Kernel Hacks

rousalome.egloos.com

포토로그 Kernel Crash


통계 위젯 (화이트)

83112
549
416223


[리눅스커널] GCC 지시어

[부록-A] GCC 지시어

리눅스 커널 코드를 읽다 보면 낯선 구문을 만날 가능성이 높습니다. 이 중 하나가 GCC 컴파일러 지시어입니다. 이번 시간에는 리눅스 커널에서 자주 쓰는 GCC 지시어를 소개합니다.

1. __init과 __section()

__init 키워드가 함수 선언부에 있으면 해당 함수는 init.text 섹션에 위치합니다. 이해를 돕기 위해 __init 키워드로 선언된 함수를 봅시다.

https://elixir.bootlin.com/linux/v4.19.30/source/kernel/watchdog.c
01 void __init lockup_detector_init(void)
02 {
03 set_sample_period();
01번째 줄과 같이 lockup_detector_init() 함수 옆에 보이는 __init 구문입니다. 함수 선언부에 __init 키워드가 보이면 부팅 과정에서 1번 호출되는 함수라고 해석하면 됩니다.

__init 키워드로 선언된 함수는 언제 호출될까요? 다음 코드와 같이 do_one_initcall() 함수에서 부팅 과정에서 1번 호출됩니다.

https://elixir.bootlin.com/linux/v4.19.30/source/init/main.c
static void __init do_initcall_level(int level)
{
initcall_entry_t *fn;
...
trace_initcall_level(initcall_level_names[level]);
for (fn = initcall_levels[level]; fn < initcall_levels[level+1]; fn++)
do_one_initcall(initcall_from_entry(fn));
}

init과 __section 매크로 코드 분석

이번에는 __init 키워드의 정체를 확인해보겠습니다. __init 키워드는 매크로 타입으로 다음과 같이 정의돼 있습니다.

https://elixir.bootlin.com/linux/v4.19.30/source/include/linux/init.h
#define __init          __section(.init.text) __cold notrace __latent_entropy

이어서 include/linux/compiler.h 파일을 열어보면 '__section(S)'는 '__attribute__ ((__section__(#S)))' 구문으로 치환됩니다.

# define __section(S) __attribute__ ((__section__(#S)))

여기서 __section(S) 매크로의 'S'를 유심히 볼 필요가 있습니다. 좀 복잡해 보입니다만 조금 풀어서 설명해 보겠습니다.

__init 키워드는 매크로 타입으로 __section(.init.text) 코드로 치환됩니다.

__section(.init.text)와 같이 입력이 .init.text이므로 #S 대신 .init.text로 치환됩니다. 이는 다음과 같이 표현할 수 있습니다. 

   * __attribute__ ((__section__(.init.text)))

여기서 __attribute__ 지시자는 컴파일러에게 함수 컴파일 속성을 지정하는 기능입니다. 그러면 __attribute__ ((__section__(.init.text)))는 어떤 의미일까요? 
   
이는 ‘.init.text’라는 섹션에 해당 함수의 코드를 위치시키라는 의미입니다. 여기서 섹션이란 비슷한 역할을 수행하는 코드 묶음을 의미합니다. 이 같은 방식으로 비슷한 속성의 코드나 변수들을 특정 섹션에 위치시키는 경우가 많습니다. 

.init.text 섹션 정보 확인

__init 키워드를 분석하다 보니 자연히 다음과 같은 의문이 생깁니다.

.init.text 섹션의 정체는 무엇일까?

리눅스에서 기본으로 제공하는 objdump 바이너리 유틸리티 프로그램을 다음 명령어로 실행하면 섹션 정보를 확인할 수 있습니다.

objdump -x vmlinux  | more

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .head.text    0000026c  80008000  80008000  00008000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .text         00607798  80100000  80100000  00010000  2**6
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .fixup        0000001c  80707798  80707798  00617798  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  3 .rodata       001c2c84  80800000  80800000  00618000  2**12
                  CONTENTS, ALLOC, LOAD, DATA
...
 17 .stubs        000002c0  ffff1000  80b00020  008a1000  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 18 .init.text    0004275c  80b002e0  80b002e0  008a82e0  2**5
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

18번째 섹션인 .init.text는  0x80b002e0부터 위치해 있고 그 크기는 0x4275c라는 것을 알 수 있습니다. 당연히 .init.text 섹션은 0x80b002e0 ~ 0x80b42a3c 메모리 공간에 위치했다고 볼 수 있습니다.

2. inline

함수 선언부에 키워드로 inline을 지정하면 GCC 컴파일러는 함수 심벌을 만들지 않습니다. 이 같은 유형의 함수를 인라인 함수라고 부릅니다.

인라인 함수에 대해 알아보기에 앞서 인라인 함수를 지정하는 이유는 무엇인지 먼저 살펴봅시다. 커널 함수에서 어떤 함수를 호출하면 다음과 같은 동작을 수행합니다.

1. 스택 프레임의 매개변수를 메모리에 저장 
2. 함수 인자를 레지스터에 복사 
3. 실행 흐름 변경 

물론 이 동작은 어셈블리 코드로 확인할 수 있습니다. 그런데 만약 1초에 수십 번 이상 자주 호출되는 함수가 있다고 가정해보겠습니다. 함수에서 수행할 코드가 얼마 되지 않는데 위 동작을 반복하면 오버헤드라 볼 수 있습니다. 즉, 배보다 배꼽이 더 큰 상황입니다.

이해를 돕기 위해 예제 코드를 봅시다.

https://elixir.bootlin.com/linux/v4.19.30/source/kernel/time/timer.c
static inline unsigned calc_index(unsigned expires, unsigned lvl)
{
expires = (expires + LVL_GRAN(lvl)) >> LVL_SHIFT(lvl);
return LVL_OFFS(lvl) + (expires & LVL_MASK);
}

calc_index() 함수의 구현부를 보니 '논리 연산을 수행'하는 2줄밖에 되지 않습니다. 그런데 calc_index() 함수는 커널 타이머를 처리할 때 매우 자주 호출됩니다. 인라인으로 선언하기에 좋은 함수입니다.
리눅스 커널 코드에서 inline 키워드로 선언된 함수를 보면 다음과 같이 해석하면 됩니다.

   * GCC는 이 함수에 대한 심벌을 생성하지 않는다.
   * 자주 호출될 가능성이 높다.

3. noinline

noinline 키워드로 함수를 선언하면 GCC 컴파일러는 이 함수를 인라인으로 처리하지 않습니다. 이해를 돕기 위해 예제 코드를 소개합니다.

https://elixir.bootlin.com/linux/v4.19.30/source/mm/slub.c
static noinline int alloc_debug_processing(struct kmem_cache *s,
struct page *page,
void *object, unsigned long addr)
{
if (s->flags & SLAB_CONSISTENCY_CHECKS) {
if (!alloc_consistency_checks(s, page, object, addr))
goto bad;
}

alloc_debug_processing() 함수에 noinline 키워드를 지정했습니다. 참고로 alloc_debug_processing() 함수는 슬럽 오브젝트를 할당할 때 오브젝트 오염을 점검하는 역할을 수행합니다.

그런데 GCC는 컴파일 과정에서 inline 키워드로 함수를 선언하지 않아도 '인라인으로 처리해도 적합한 함수'라고 판단하면 함수를 인라인 타입으로 컴파일합니다. 물론 GCC 컴파일러가 알아서 인라인으로 함수를 처리해주니 고맙다는 생각이 들 수 있습니다. 하지만 문제는 코드를 작성한 의도와 다르게 함수가 오동작할 수 있다는 점입니다.

한 가지 예를 들어 봅시다. 어떤 개발자가 __builtin_return_address() 매크로 함수를 써서 자신을 호출한 함수의 주소에 따라 다르게 처리하려고 합니다. 그런데 해당 함수가 인라인으로 처리되면 어떻게 될까요? 인라인으로 처리되니 함수의 주소나 심벌이 사라지게 됩니다. 따라서 __builtin_return_address() 매크로는 다른 결과를 반환해서 예상치 못한 동작을 하게 됩니다.


예전에 필자가 개발 도중 도저히 설명이 불가능한 문제를 만난 적이 있습니다. 수많은 시행착오 끝에 알아낸 근본 원인은 GCC가 자동으로 함수를 인라인으로 처리하는 것이었습니다.


 4. __noreturn

리눅스 커널에서는 자신을 호출한 함수로 되돌아가지 않는 함수가 있습니다. 이런 종류의 함수에 __noreturn 키워드를 붙이면 컴파일러가 최적화 작업을 추가로 수행합니다.

__noreturn 키워드로 선언한 예는 다음과 같습니다.

https://elixir.bootlin.com/linux/v4.19.30/source/kernel/exit.c
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
...

do_exit() 함수는 프로세스를 종료하는 동작입니다. 당연한 이야기지만 함수를 실행하는 주인공인 프로세스가 소멸하니 이전 함수로 되돌아 갈 수 없습니다.
 
5. unused

GCC 컴파일러는 특정 함수를 호출하는 코드가 없을 때 함수를 호출한 적이 없다는 경고 에러 메시지를 출력합니다. 그래서 함수 선언부에 unused 키워드를 붙이면 GCC 컴파일러에게 함수가 호출되지 않는 듯해도 커널이 해당 함수를 사용한다고 알려줍니다. 

이해를 돕기 위해 관련 코드를 소개합니다.

https://elixir.bootlin.com/linux/v4.19.30/source/arch/arm/mach-omap2/pm.c
int __maybe_unused omap_pm_nop_init(void)
{
return 0;
}

omap_pm_nop_init() 함수는 어느 코드에서도 호출하지 않지만 omap_pm_nop_init() 함수에 __maybe_unused 키워드를 붙이면 컴파일러는 경고 메시지를 출력하지 않습니다.

이 밖에도 함수 선언부에 unused 키워드를 지정하는 이유는 다음과 같습니다.

   * 어셈블리 코드에서 C 코드로 구현된 함수를 호출할 때
   * 함수에 전달된 인자를 받아서 해당 인자를 쓰지 않을 때

6. __builtin_return_address() 함수

__builtin_return_address 매크로를 사용하면 자신을 호출한 함수의 주소를 알 수 있습니다. 커널에서는 __builtin_return_address 매크로를 활용해 다양한 방식으로 디버깅 메시지를 출력합니다.

다음은 __builtin_return_address 매크로를 써서 디버깅 메시지를 출력하는 패치 코드입니다.

diff --git a/kernel/workqueue.c b/kernel/workqueue.c
--- a/kernel/workqueue.c
+++ b/kernel/workqueue.c
@@ -2904,6 +2904,10 @@ bool __flush_work(struct work_struct *work)
01 {
02        struct wq_barrier barr;
03
04 +       long unsigned int caller_func_address = 0;
05 +       caller_func_address = (long unsigned int)__builtin_return_address(0);
06 +
07 +       trace_printk("caller: %pS [0x%08lx] \n", (void *)caller_func_address, (long unsigned int)caller_func_address);
08    if (WARN_ON(!wq_online))
09     return false;

이 패치 코드를 입력 방법을 소개하겠습니다. 다음 코드에서 “/* 패치 코드를 입력하세요 */” 부분에 04~07번째 줄의 패치 코드를 입력하면 됩니다.

https://elixir.bootlin.com/linux/v4.19.30/source/kernel/workqueue.c
static bool __flush_work(struct work_struct *work, bool from_cancel)
{
struct wq_barrier barr;
/* 패치 코드를 입력하세요 */
if (WARN_ON(!wq_online))
return false;

위와 같은 패치 코드를 적용하면 어느 함수에서 __flush_work() 함수를 호출하는지 확인할 수 있습니다.

7. container_of

커널 코드에서 container_of는 많이 활용하는 매크로입니다. container_of() 매크로 함수는 구조체 필드의 주소로 구조체 시작 주소를 계산하는 기능을 제공합니다.

container_of란 매크로를 쉽게 표현하면 다음과 같습니다.

  * 구조체 시작 주소 = container_of(입력주소, 구조체, 해당 구조체 필드)

다음 예제 코드를 함께 보면서 container_of 매크로를 어떻게 활용하는지 살펴보겠습니다.

https://elixir.bootlin.com/linux/v4.19.30/source/kernel/workqueue.c
01 static struct workqueue_struct *dev_to_wq(struct device *dev)
02 {
03 struct wq_device *wq_dev = container_of(dev, struct wq_device, dev);
04
05 return wq_dev->wq;
06 }

03번째 줄을 보면 container_of를 써서 struct wq_device *wq_dev 지역변수에 어떤 값을 대입합니다. 여기서 container_of(dev, struct wq_device, dev)는 코드는 다음과 같이 해석할 수 있습니다.

   * dev: 입력 주소
   * struct wq_device: 구조체
   * dev: wq_device 구조체에 위치한 필드

결과적으로 container_of 매크로를 쓰면 wq_device 구조체의 주소를 반환합니다.


덧글

댓글 입력 영역