자바스크립트를 활성화 해주세요

리눅스 커널 내 generated 헤더 분석

 ·  ☕ 8 min read

리눅스 커널의 소스를 분석하다 보면 include/generated/에 존재하는 헤더들을 볼 수 있다. 경로의 의미 그대로, 여기 있는 헤더들은 빌드 과정에서 자동으로 생성된다.

문제 발견: bounds.h

iamroot 리눅스 커널 스터디 중 zone_sizes_init() 분석 과정에서 MAX_NR_ZONES 상수가 등장한다.

195
196
197
198
199
200
201
202
203
204
205
206
207
208
static void __init zone_sizes_init(unsigned long min, unsigned long max)
{
	unsigned long max_zone_pfns[MAX_NR_ZONES]  = {0};

#ifdef CONFIG_ZONE_DMA
	max_zone_pfns[ZONE_DMA] = PFN_DOWN(arm64_dma_phys_limit);
#endif
#ifdef CONFIG_ZONE_DMA32
	max_zone_pfns[ZONE_DMA32] = PFN_DOWN(arm64_dma32_phys_limit);
#endif
	max_zone_pfns[ZONE_NORMAL] = max;

	free_area_init(max_zone_pfns);
}

해당 상수에 대해 cscope로 검색하면 include/generated/bounds.h의 전처리기 정의가 나타나지만, ctags로 검색하면 kernel/bounds.c의 매크로를 통한 정의 코드로 나타나게 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#ifndef __LINUX_BOUNDS_H__
#define __LINUX_BOUNDS_H__
/*
 * DO NOT MODIFY.
 *
 * This file was generated by Kbuild
 */

#define NR_PAGEFLAGS 23 /* __NR_PAGEFLAGS */
#define MAX_NR_ZONES 4 /* __MAX_NR_ZONES */
#define NR_CPUS_BITS 8 /* ilog2(CONFIG_NR_CPUS) */
#define SPINLOCK_SIZE 4 /* sizeof(spinlock_t) */

#endif
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: GPL-2.0
/*
 * Generate definitions needed by the preprocessor.
 * This code generates raw asm output which is post-processed
 * to extract and format the required data.
 */

#define __GENERATING_BOUNDS_H
/* Include headers that define the enum constants of interest */
#include <linux/page-flags.h>
#include <linux/mmzone.h>
#include <linux/kbuild.h>
#include <linux/log2.h>
#include <linux/spinlock_types.h>

int main(void)
{
	/* The enum constants to put into include/generated/bounds.h */
	DEFINE(NR_PAGEFLAGS, __NR_PAGEFLAGS);
	DEFINE(MAX_NR_ZONES, __MAX_NR_ZONES);
#ifdef CONFIG_SMP
	DEFINE(NR_CPUS_BITS, ilog2(CONFIG_NR_CPUS));
#endif
	DEFINE(SPINLOCK_SIZE, sizeof(spinlock_t));
	/* End of constants */

	return 0;
}

위의 주석을 보면 알겠지만, kernel/bounds.c가 원본이 되는 소스고, 빌드 과정 중에 include/generated/bounds.h가 생성되었기 때문에 수정하지 말라는 것을 확인할 수 있다.

여기서 몇가지 질문이 생겼다.

  1. include/generated/bounds.h 헤더는 언제 생성되었는가?
  2. kernel/bounds.c에서 어떻게 include/generated/bounds.h가 생성되었을까?
  3. 왜 직접 헤더에서 DEFINE() 매크로를 사용하지 않고 헤더를 생성해야 했을까?

include/generated/ 헤더 생성 과정

include/generated/의 헤더를 여러 소스에서 사용하고 있기 때문에, 해당 헤더들의 생성 시기는 본격적인 커널 구성 소스들의 컴파일 이전에 생성되어야 한다.

일단 해당 문제를 제기한 include/generated/bounds.h 관련해서 검색해 본 결과 Kbuild에서 힌트를 찾을 수 있었다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# SPDX-License-Identifier: GPL-2.0
#
# Kbuild for top-level directory of the kernel

#####
# Generate bounds.h

bounds-file := include/generated/bounds.h

always-y := $(bounds-file)
targets := kernel/bounds.s

$(bounds-file): kernel/bounds.s FORCE
	$(call filechk,offsets,__LINUX_BOUNDS_H__)

#####
# Generate timeconst.h

timeconst-file := include/generated/timeconst.h

filechk_gentimeconst = echo $(CONFIG_HZ) | bc -q $<

$(timeconst-file): kernel/time/timeconst.bc FORCE
	$(call filechk,gentimeconst)

#####
# Generate asm-offsets.h

offsets-file := include/generated/asm-offsets.h

always-y += $(offsets-file)
targets += arch/$(SRCARCH)/kernel/asm-offsets.s

arch/$(SRCARCH)/kernel/asm-offsets.s: $(timeconst-file) $(bounds-file)

$(offsets-file): arch/$(SRCARCH)/kernel/asm-offsets.s FORCE
	$(call filechk,offsets,__ASM_OFFSETS_H__)

#####
# Check for missing system calls

always-y += missing-syscalls

quiet_cmd_syscalls = CALL    $<
      cmd_syscalls = $(CONFIG_SHELL) $< $(CC) $(c_flags) $(missing_syscalls_flags)

missing-syscalls: scripts/checksyscalls.sh $(offsets-file) FORCE
	$(call cmd,syscalls)

#####
# Check atomic headers are up-to-date

always-y += old-atomics

quiet_cmd_atomics = CALL    $<
      cmd_atomics = $(CONFIG_SHELL) $<

old-atomics: scripts/atomic/check-atomics.sh FORCE
	$(call cmd,atomics)

해당 부분을 분석해 보면 아래와 같다.

  1. bounds-file := include/generated/bounds.h는 생성될 헤더를 변수로 선언하고 있다.
  2. always-y := $(bounds-file)는 해당 헤더가 언제나 생성되어야 하는 목록에 추가되는 것으로 보인다.
    (여기에서는 :=로 할당되어있지만, 아래 헤더들을 보면 +=로 추가하는 것을 확인할 수 있다.)
  3. targets := kernel/bounds.s는 빌드 타겟에 어셈블리 소스 코드가 추가되는 것으로 보인다.
    (여기에서는 :=로 할당되어있지만, 아래 헤더들을 보면 +=로 추가하는 것을 확인할 수 있다.)
  4. $(bounds-file): kernel/bounds.s FORCE는 헤더를 만들기 위해선 어셈블리 소스를 필요로 한다는 것을 확인할 수 있다.
  5. $(call filechk,offsets,__LINUX_BOUNDS_H__)는 헤더를 만들기 위한 명령어/함수를 서술하는 것으로 보인다.

일단 kernel/bounds.sinclude/generated/bounds.h 순으로 헤더가 생성되는 것으로 보인다. 하지만 리포지터리 내에 kernel/bounds.s가 없으며, 심지어 .gitignore에서 *.s가 있는 것으로 보아, kernel/bounds.s는 생성된 어셈블리 코드로 보인다. (arch/arm64/kernel/head.S 같이 직접 작성한 어셈블리 코드의 확장자는 대문자 S로 구분하는 것 같다.)

위의 targets가 어디서 컴파일되는지는 바로 파악되지 않지만, kernel/bounds.ckernel/bounds.sinclude/generated/bounds.h 순으로 생성된다고 예측할 수 있다.

어셈블리 코드에서 헤더 생성 과정

일단 헤더 생성 과정은 $(call filechk,offsets,__LINUX_BOUNDS_H__)인 것을 확인했으니, 어셈블리 코드를 헤더로 생성하는 과정을 찾아보자. filechk,offsets 관련 코드를 검색해 본 결과 scripts/Makefile.lib에서 예상되는 함수를 찾았다.

453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# Default sed regexp - multiline due to syntax constraints
#
# Use [:space:] because LLVM's integrated assembler inserts <tab> around
# the .ascii directive whereas GCC keeps the <space> as-is.
define sed-offsets
	's:^[[:space:]]*\.ascii[[:space:]]*"\(.*\)".*:\1:; \
	/^->/{s:->#\(.*\):/* \1 */:; \
	s:^->\([^ ]*\) [\$$#]*\([^ ]*\) \(.*\):#define \1 \2 /* \3 */:; \
	s:->::; p;}'
endef

# Use filechk to avoid rebuilds when a header changes, but the resulting file
# does not
define filechk_offsets
	 echo "#ifndef $2"; \
	 echo "#define $2"; \
	 echo "/*"; \
	 echo " * DO NOT MODIFY."; \
	 echo " *"; \
	 echo " * This file was generated by Kbuild"; \
	 echo " */"; \
	 echo ""; \
	 sed -ne $(sed-offsets) < $<; \
	 echo ""; \
	 echo "#endif"
endef
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
	.text
	.section	.text.startup,"ax",@progbits
	.align	2
	.p2align 3,,7
	.global	main
	.type	main, %function
main:
.LFB970:
	.cfi_startproc
	hint	25 // paciasp
	.cfi_window_save
// kernel/bounds.c:19: 	DEFINE(NR_PAGEFLAGS, __NR_PAGEFLAGS);
#APP
// 19 "kernel/bounds.c" 1

.ascii "->NR_PAGEFLAGS 23 __NR_PAGEFLAGS"	//
// 0 "" 2
// kernel/bounds.c:20: 	DEFINE(MAX_NR_ZONES, __MAX_NR_ZONES);
// 20 "kernel/bounds.c" 1

.ascii "->MAX_NR_ZONES 4 __MAX_NR_ZONES"	//
// 0 "" 2
// kernel/bounds.c:22: 	DEFINE(NR_CPUS_BITS, ilog2(CONFIG_NR_CPUS));
// 22 "kernel/bounds.c" 1

.ascii "->NR_CPUS_BITS 8 ilog2(CONFIG_NR_CPUS)"	//
// 0 "" 2
// kernel/bounds.c:24: 	DEFINE(SPINLOCK_SIZE, sizeof(spinlock_t));
// 24 "kernel/bounds.c" 1

.ascii "->SPINLOCK_SIZE 4 sizeof(spinlock_t)"	//
// 0 "" 2
// kernel/bounds.c:28: }
#NO_APP
	mov	w0, 0	//,
	hint	29 // autiasp
	.cfi_window_save
	ret
	.cfi_endproc
.LFE970:
	.size	main, .-main
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#ifndef __LINUX_BOUNDS_H__
#define __LINUX_BOUNDS_H__
/*
 * DO NOT MODIFY.
 *
 * This file was generated by Kbuild
 */

#define NR_PAGEFLAGS 23 /* __NR_PAGEFLAGS */
#define MAX_NR_ZONES 4 /* __MAX_NR_ZONES */
#define NR_CPUS_BITS 8 /* ilog2(CONFIG_NR_CPUS) */
#define SPINLOCK_SIZE 4 /* sizeof(spinlock_t) */

#endif

scripts/Makefile.lib를 확인해 보면 어셈블리 코드의 .ascii로 시작하는 부분을 정규표현식으로 찾아서, 순서대로 문자열을 추출하여 헤더의 정의 방식 대로 출력하게 하는 것을 볼 수 있다.

어셈블리 코드의 생성

이전의 targets := kernel/bounds.s에서 설정한 targets가 어디까지 연결되고, 어디서 사용되는지는 완전히 확인하지 못했지만, 어셈블리 소스 파일을 생성하기 위한 패턴은 scripts/Makefile.build에서 확인할 수 있었다.

110
111
112
113
114
115
116
117
# Compile C sources (.c)
# ---------------------------------------------------------------------------

quiet_cmd_cc_s_c = CC $(quiet_modtag)  $@
      cmd_cc_s_c = $(CC) $(filter-out $(DEBUG_CFLAGS), $(c_flags)) $(DISABLE_LTO) -fverbose-asm -S -o $@ $<

$(obj)/%.s: $(src)/%.c FORCE
	$(call if_changed_dep,cc_s_c)

어셈블리 코드로 컴파일해야 하는 타겟은 cmd_cc_s_c를 사용하게 될텐데, 여기서 사용되는 gcc 옵션들 중 어셈블리 관련 옵션을 확인해보면 아래와 같다.

  • -S
    Stop after the stage of compilation proper; do not assemble. The output is in the form of an assembler code file for each non-assembler input file specified.
    By default, the assembler file name for a source file is made by replacing the suffix ‘.c’, ‘.i’, etc., with ‘.s’.
    Input files that don’t require compilation are ignored.
  • -fverbose-asm
    Put extra commentary information in the generated assembly code to make it more readable. This option is generally only of use to those who actually need to read the generated assembly code (perhaps while debugging the compiler itself).
    -fno-verbose-asm, the default, causes the extra information to be omitted and is useful when comparing two assembler files.

-S로 어셈블리 코드 수준에서 컴파일을 멈추게 하고, -fverbose-asm에서 전처리기의 결과를 생략하지 말고 모두 출력하게 하고 있다. 이를 통해 DEFINE()으로 선언된 상수를 .ascii 형태로 읽을 수 있었던 것이다.

DEFINE()의 정의를 보면, 강제로 .ascii 형태의 문자열 상수화 하는 것을 확인할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/* SPDX-License-Identifier: GPL-2.0 */
#ifndef __LINUX_KBUILD_H
#define __LINUX_KBUILD_H

#define DEFINE(sym, val) \
	asm volatile("\n.ascii \"->" #sym " %0 " #val "\"" : : "i" (val))

#define BLANK() asm volatile("\n.ascii \"->\"" : : )

#define OFFSET(sym, str, mem) \
	DEFINE(sym, offsetof(struct str, mem))

#define COMMENT(x) \
	asm volatile("\n.ascii \"->#" x "\"")

#endif

왜 헤더를 생성해야 하는가?

헤더가 생성되는 과정은 확인할 수 있었지만, 왜 이런 식으로 헤더를 생성해야 하는 지에 대한 부분은 코드 상에 나와있지 않았다. 해당 코드에 관련 내용을 git blame으로 확인해 본 결과 의미있는 commit을 찾을 수 있었다.

kbuild: create a way to create preprocessor constants from C expressions

The use of enums create constants that are not available to the preprocessor
when building the kernel (f.e.  MAX_NR_ZONES).

Arch code already has a way to export constants calculated to the preprocessor
through the asm-offsets.c file.  Generate something similar for the core
kernel through kbuild.

Signed-off-by: Sam Ravnborg <sam@ravnborg.org>
Signed-off-by: Christoph Lameter <clameter@sgi.com>
Cc: Andy Whitcroft <apw@shadowen.org>
Cc: KAMEZAWA Hiroyuki <kamezawa.hiroyu@jp.fujitsu.com>
Cc: KOSAKI Motohiro <kosaki.motohiro@jp.fujitsu.com>
Cc: Rik van Riel <riel@redhat.com>
Cc: Mel Gorman <mel@csn.ul.ie>
Cc: Jeremy Fitzhardinge <jeremy@goop.org>
Signed-off-by: Andrew Morton <akpm@linux-foundation.org>
Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>

해당 commit은 kernel/bounds.c를 추가할 때 생성된 여러 commit 중 하나로 보인다. (해당 commit의 변경분 만으로는 kernel/bounds.c에 들어갈 내용이 모자라다.)

커널에서는 상수 정의에 #define 외에 enum을 통해 정의하기도 한다. enum은 선언 순서에 따라 값을 변경하면서 선언할 수 있기에 #define보다 자주 볼 수 있다.

여기 내용에서 보듯, enum으로 정의된 상수를 전처리기에서 사용하고자 할 때 컴파일 순서에 의해 enum 상수 값을 전처리기가 알 수 없다는 문제가 생긴다.

문제를 제기한 MAX_NR_ZONES를 예시로 들어보자.

345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
enum zone_type {
	/*
	 * ZONE_DMA and ZONE_DMA32 are used when there are peripherals not able
	 * to DMA to all of the addressable memory (ZONE_NORMAL).
	 * On architectures where this area covers the whole 32 bit address
	 * space ZONE_DMA32 is used. ZONE_DMA is left for the ones with smaller
	 * DMA addressing constraints. This distinction is important as a 32bit
	 * DMA mask is assumed when ZONE_DMA32 is defined. Some 64-bit
	 * platforms may need both zones as they support peripherals with
	 * different DMA addressing limitations.
	 *
	 * Some examples:
	 *
	 *  - i386 and x86_64 have a fixed 16M ZONE_DMA and ZONE_DMA32 for the
	 *    rest of the lower 4G.
	 *
	 *  - arm only uses ZONE_DMA, the size, up to 4G, may vary depending on
	 *    the specific device.
	 *
	 *  - arm64 has a fixed 1G ZONE_DMA and ZONE_DMA32 for the rest of the
	 *    lower 4G.
	 *
	 *  - powerpc only uses ZONE_DMA, the size, up to 2G, may vary
	 *    depending on the specific device.
	 *
	 *  - s390 uses ZONE_DMA fixed to the lower 2G.
	 *
	 *  - ia64 and riscv only use ZONE_DMA32.
	 *
	 *  - parisc uses neither.
	 */
#ifdef CONFIG_ZONE_DMA
	ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
	ZONE_DMA32,
#endif
	/*
	 * Normal addressable memory is in ZONE_NORMAL. DMA operations can be
	 * performed on pages in ZONE_NORMAL if the DMA devices support
	 * transfers to all addressable memory.
	 */
	ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
	/*
	 * A memory area that is only addressable by the kernel through
	 * mapping portions into its own address space. This is for example
	 * used by i386 to allow the kernel to address the memory beyond
	 * 900MB. The kernel will set up special mappings (page
	 * table entries on i386) for each page that the kernel needs to
	 * access.
	 */
	ZONE_HIGHMEM,
#endif
	ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
	ZONE_DEVICE,
#endif
	__MAX_NR_ZONES

};

중간에 보면 CONFIG_ZONE_DMA, CONFIG_ZONE_DMA32, CONFIG_HIGHMEM, CONFIG_ZONE_DEVICE의 정의 여부에 따라 enum 중간 값이 추가되거나, 제거되기도 한다. 그리고 마지막으로 __MAX_NR_ZONES는 이전까지 enum 선언된 값의 개수에 따라 바뀐다.

MAX_NR_ZONES가 사용되는 코드를 검색해 보면 코드 내 상수로 사용되는 것이 대부분이지만, 아래와 같이 해당 값이 전처리기에서 사용되는 경우도 존재한다.

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
 * When a memory allocation must conform to specific limitations (such
 * as being suitable for DMA) the caller will pass in hints to the
 * allocator in the gfp_mask, in the zone modifier bits.  These bits
 * are used to select a priority ordered list of memory zones which
 * match the requested limits. See gfp_zone() in include/linux/gfp.h
 */
#if MAX_NR_ZONES < 2
#define ZONES_SHIFT 0
#elif MAX_NR_ZONES <= 2
#define ZONES_SHIFT 1
#elif MAX_NR_ZONES <= 4
#define ZONES_SHIFT 2
#elif MAX_NR_ZONES <= 8
#define ZONES_SHIFT 3
#else
#error ZONES_SHIFT -- too many zones configured adjust calculation
#endif

다른 부분은 코드 내 상수로만 들어가는 것이기 때문에 enum으로 정의된 __MAX_NR_ZONES를 사용해도 큰 문제가 되지 않았다. 하지만 위의 헤더와 같이 MAX_NR_ZONES의 값이 전처리기 시기에 숫자로 치환되지 않으면 해당 부분을 의도한 대로 전처리기 해석이 불가능하다. (값의 크기를 비교해야 되는데 문자열로 치환되기 때문)

즉, 해당 commit에서 언급한 것 처럼 enum으로 생성한 상수를 전처리기에서 사용하기 위해서는 해당 정의에 따른 값으로 완전히 치환해야 했던 것이고, 이를 위해 따로 소스 코드를 생성하고, 컴파일하여 그 내용을 바탕으로 다시 헤더를 생성한 것이다.


JaeSang Yoo
글쓴이
JaeSang Yoo
The Programmer

목차