void __init arm64_memblock_init(void)
{
const s64 linear_region_size = BIT(vabits_actual - 1);
/* Handle linux,usable-memory-range property */
fdt_enforce_memory_region();
/* Remove memory above our supported physical address size */
memblock_remove(1ULL << PHYS_MASK_SHIFT, ULLONG_MAX);
/*
* Select a suitable value for the base of physical memory.
*/
memstart_addr = round_down(memblock_start_of_DRAM(),
ARM64_MEMSTART_ALIGN);
physvirt_offset = PHYS_OFFSET - PAGE_OFFSET;
vmemmap = ((struct page *)VMEMMAP_START - (memstart_addr >> PAGE_SHIFT));
/*
* If we are running with a 52-bit kernel VA config on a system that
* does not support it, we have to offset our vmemmap and physvirt_offset
* s.t. we avoid the 52-bit portion of the direct linear map
*/
if (IS_ENABLED(CONFIG_ARM64_VA_BITS_52) && (vabits_actual != 52)) {
vmemmap += (_PAGE_OFFSET(48) - _PAGE_OFFSET(52)) >> PAGE_SHIFT;
physvirt_offset = PHYS_OFFSET - _PAGE_OFFSET(48);
}
/*
* Remove the memory that we will not be able to cover with the
* linear mapping. Take care not to clip the kernel which may be
* high in memory.
*/
memblock_remove(max_t(u64, memstart_addr + linear_region_size,
__pa_symbol(_end)), ULLONG_MAX);
if (memstart_addr + linear_region_size < memblock_end_of_DRAM()) {
/* ensure that memstart_addr remains sufficiently aligned */
memstart_addr = round_up(memblock_end_of_DRAM() - linear_region_size,
ARM64_MEMSTART_ALIGN);
memblock_remove(0, memstart_addr);
}
/*
* Apply the memory limit if it was set. Since the kernel may be loaded
* high up in memory, add back the kernel region that must be accessible
* via the linear mapping.
*/
if (memory_limit != PHYS_ADDR_MAX) {
memblock_mem_limit_remove_map(memory_limit);
memblock_add(__pa_symbol(_text), (u64)(_end - _text));
}
if (IS_ENABLED(CONFIG_BLK_DEV_INITRD) && phys_initrd_size) {
/*
* Add back the memory we just removed if it results in the
* initrd to become inaccessible via the linear mapping.
* Otherwise, this is a no-op
*/
u64 base = phys_initrd_start & PAGE_MASK;
u64 size = PAGE_ALIGN(phys_initrd_start + phys_initrd_size) - base;
/*
* We can only add back the initrd memory if we don't end up
* with more memory than we can address via the linear mapping.
* It is up to the bootloader to position the kernel and the
* initrd reasonably close to each other (i.e., within 32 GB of
* each other) so that all granule/#levels combinations can
* always access both.
*/
if (WARN(base < memblock_start_of_DRAM() ||
base + size > memblock_start_of_DRAM() +
linear_region_size,
"initrd not fully accessible via the linear mapping -- please check your bootloader ...\n")) {
phys_initrd_size = 0;
} else {
memblock_remove(base, size); /* clear MEMBLOCK_ flags */
memblock_add(base, size);
memblock_reserve(base, size);
}
}
if (IS_ENABLED(CONFIG_RANDOMIZE_BASE)) {
extern u16 memstart_offset_seed;
u64 range = linear_region_size -
(memblock_end_of_DRAM() - memblock_start_of_DRAM());
/*
* If the size of the linear region exceeds, by a sufficient
* margin, the size of the region that the available physical
* memory spans, randomize the linear region as well.
*/
if (memstart_offset_seed > 0 && range >= ARM64_MEMSTART_ALIGN) {
range /= ARM64_MEMSTART_ALIGN;
memstart_addr -= ARM64_MEMSTART_ALIGN *
((range * memstart_offset_seed) >> 16);
}
}
/*
* Register the kernel text, kernel data, initrd, and initial
* pagetables with memblock.
*/
memblock_reserve(__pa_symbol(_text), _end - _text);
if (IS_ENABLED(CONFIG_BLK_DEV_INITRD) && phys_initrd_size) {
/* the generic initrd code expects virtual addresses */
initrd_start = __phys_to_virt(phys_initrd_start);
initrd_end = initrd_start + phys_initrd_size;
}
early_init_fdt_scan_reserved_mem();
if (IS_ENABLED(CONFIG_ZONE_DMA)) {
zone_dma_bits = ARM64_ZONE_DMA_BITS;
arm64_dma_phys_limit = max_zone_phys(ARM64_ZONE_DMA_BITS);
}
if (IS_ENABLED(CONFIG_ZONE_DMA32))
arm64_dma32_phys_limit = max_zone_phys(32);
else
arm64_dma32_phys_limit = PHYS_MASK + 1;
reserve_crashkernel();
reserve_elfcorehdr();
high_memory = __va(memblock_end_of_DRAM() - 1) + 1;
dma_contiguous_reserve(arm64_dma32_phys_limit);
}
이 글에서는 캐쉬의 주소 변환 과정의 종류 그리고 각 종류의 장단점과 ARM architecture에서는 어떻게 그 단점들을 보완하고 있는지 알아보겠다.
캐쉬는 메모리 (DRam) 접근이 CPU의 속도에 비해 너무 느리기에 그 시간을 단축하기 위해서 만든 하드웨어이다. 위 그림과 같이 주소와 그에 상응하는 데이터를 저장하고 CPU의 메모리 읽기, 쓰기 처리 속도 향상에 아주 큰 1등 공식 하드웨어이다. 사실 캐쉬에 대해서 설명하려면
Full associative, Set-way associative
Eviction Policy (LRU)
Data fetch algorithm (Spacial and Tempral Locality)
etc
이와 같은 내용들을 모두 설명해야 하지만, 이 글에서는 Cache에서 주소 관리 방법에 따른 특징들에 대해서 알아보겠다.
컴퓨터에서 주소라하면 가상 주소 (Virtual Address) 와 물리주소 (physical Address)가 있다. 위 그림에서도 주소라는 말만 있을 뿐, 가상 주소와 물리 주소 중 어떤 것을 주소로 사용하고 있는 지에 대한 설명은 빠져있다. 그 이유는 사실 Cache를 만드는 사람이 원하는데로 만들면 되기 때문이다. 그러나 물론 산업에서 주로 사용하는 주소는 정해져있다. 이제부터 어떤 주소를 사용하면 어떤 특징이 있는지, 그리고 내가 주로 일하는 ARM architecture에서는 어떠한 방법으로 주소를 관리하고있는지에 대해서 알아보겠다.
캐쉬에서 주소를 사용하는 방법은 가상주소만을 사용할 수도 있고, 물리주소를 사용할 수도 있고 또는 혼용해서 사용할 수 있다. 이 조합으로 알아보면:
PIPT: Physical Indexed Physical Tagged
VIVT: Virtual Indexed Virtual Tagged
VIPT: Virtual Indexed Physical Tagged
PIVT: Physical Indexed Virtual Tagged
이렇게 크게 4가지의 주소 변환 방법이 있다. Indexed와 Tagged는 위 그림에서 보이는 Tag와 Index를 의미하는 것으로 가상주소나 물리주소의 일부분을 가져와 Tag 또는 Index로 사용하는 것을 뜻한다.
조금 귀찮아져서 공부한 내용을 간략히 정리해보면:
PIPT: 문제 없이 항상 사용할 수 있지만 성능이 조금 느리다
VIVT: MMU (TLB) 접근을 하지 않아도 되서 빠르다. 하지만, synonym[1]과 homonym[2] 문제가 생긴다
VIPT: PIPT와 VIVT의 장점을 가지고 있다. synonym과 homonym의 문제를 어느정도 해결한다
PIVT: 성능은 PIPT 처럼 느리고, synonym과 homonym문제도 그대로 갖고 있어 사실 사용하지않는다
[1] synonym: 하나 이상의 가상 주소가 하나의 물리주소와 매핑되는 문제, 예를 들어 mmap API를 통해서 여러 가상 주소가 하나의 물리 주소에 매핑 횔 수 있게 된다.
[2] homonym: 하나의 가상 주소가 여러 물리주소와 매핑 되는 문제, 예를 들어 A 쓰레드가 context-out되고 B쓰레드가 context-in 되었을 때, A쓰레드가 사용하던 data (가상 주소 0xFFFF_0000에 해당하는)가 캐쉬에 남아있고, B 쓰레드가 0xFFFF_0000에 해당하는 data 를 사용하면 B는 원치 않는 data에 접근하게 된다. 이는 context-switch 할 때, cache clean&invalidation을 통해서 해결할 수 있다.
사실 homonym 문제는 context-switch 할 때 cache operation을 통해서 극복이 가능하므로 문제가 없다. 하지만 synonym의 경우는 cache operation을 통해서 극복이 불가하다.
VIPT 에서 synonym 문제와 그에 해결 방법
64비트 아키택쳐를 기준으로 가상 주소는 64비트를 가지고 있고 아래와 같은 형식으 캐쉬에서 사용된다고 해보자.
이 때, Tag (물리주소로 부터 가져온) 와 Index (가상주소로 부터 가져온) 사이에 겹치는 비트들이 있으면, 그 비트의 2제곱승 만큼 synonym 현상이 발생할 수 있다.
이러한 문제가 있음에도 불구하고 index 비트를 Tag와 겹치면서 까지도 늘리고 싶은 이유는 cache의 총 크기를 늘리기 위해서이다 (cache total size = Page size * # of associatives). 따라서 synonym 현상을 피하기 위해서 운영체제에게 cache coloring [3] 을 통해 극복하게 하거나 Tag와 Index간의 겹치는 부분을 없애므로써 해결할 수 있다.
[3] 간단히 말해, overlapped 된 비트에 대해서 가상 주소와 물리 주소를 같에 할당하는 것이다.
ARM 아키택쳐에서는 Hardware 내부적으로 해결하는 모습을 보이고 있다. 사실 어떻게 이 문제를 해결하는지는 모르지만, Manual 에서 찾아보면 어떤 cache type이든 간에 PIPT의 동작이 똑같음을 보장해 준다고 한다. 뇌피셜로는 아마 Cache access할 때 다른 cache-line간의 look-up을 통해 해결하고 있을 것 같다 (그게 아니면 사실 해결 방법이 따로 있어 보이지는 않는다).
Computer Science에서 atomic operation 이라는 용어는 상당히 자주 접한다.
Wikipedia 에서 atomic [1] 을 찾아보면 Linearizability [2] 이라는 페이지로 redirection 해준다.
그리고 [2] 에서 내용을 (내 마음대로) 해석해보자면:
Multiprocessor system에서 여러 CPU들이 공유자원 (특히 메모리)에 접근 할 때, 접근 순서를 의도적 또는 비 의도적으로 직렬화하여 의도치 않는 결과를 피하는 operation 을 atomic operation이라 한다.
사실 atomic 이라는 뜻만 보면 원자처럼 쪼개질 수 없는 연산 또는 수행 이라고 할 수 있지만 Computer science에서는 단순히 원자처럼 작은 단위라는 뜻 말고도 Mutual Exclusive 의 의미도 포함하고 있는 것이다.
이 글에서는 ARM architecture에서 어떻게 atmoic operation을 지원해주고 있는지 알아볼 것이다.
ARMv8에서는 아래와 같은 방법으로 atmoic operation을 지원하고 있다.
Load-Exclusive/Store-Exclusive instructions
Atomic instructions
Atomic memory operation
Swap
Compare-and-Swap
Load-Exclusive/Store-Exclusive instructions
Load-Exclusive/Store-Exclusive 명령어는 ARMv8 Reference Manual [3] [4] 에 이렇게 설명되어 있다:
The Load-Exclusive instructions mark the physical address being accessed as an exclusive access. This exclusive access mark is checked by the Store-Exclusive instruction, permitting the construction of atomic read-modify-write operations on shared memory variables, semaphores, mutexes, and spinlocks.
해석해보자면, LDREX 명령어를 통해 메모리에서 Register로 data를 읽어오면, 해당 메모리 block (block의 size는 implementation defined이지만 B.2.9.3, Marking and the size of the marked memory block 에 설명 되어 있다)은
mutual exclusive하게 access 되어야 하는 메모리이다" 라고 표시가 되고, 정말 mutual exclusive하게 access가 되었는지는 STREX 명령어를 실행 할 때, 검증된다는 내용이다.
사실 우리가 메모리에서 어떤 주소의 값을 읽어오는 이유는 그냥 값을 확인할 수도 있겠지만, 대부분 읽어와서 그 값을 수정하여 다시 그 메모리에 쓰는 경우가 많고 그 행위를, read-modify-write operation 이라고 부른다. 이 때, read-modify-write 이 3개의 operation을 하나의 atomic하게 수행할 수가 없으니, write할 때, 이 모든 수행이 atmoic (다르게 말하면 mutual exclusive) operation 으로 성공했는지 아니면 실패 했는지 알려준다는 것이다. 그러므로 LDREX 와 STREX 명령어 두개는 항상 하나의 쌍으로 불려야 한다.
이 때, LDREX 명령어에 대해서 좀만 자세히 말하자면, LDREX 는 해당 메모리 블럭에 기존에 있는 TAG를 지우고 새로운 TAG (다른 CPU에서 만들어 놓은 TAG는 지우고, 지금 LDREX 명령어를 수행하고 있는 CPU만을 위한 TAG)를 남겨 놓는다. 따라서 LDREX를 마지막에 수행하는 CPU가 결국 해당 STREX를 성공할 수 있고, 전에 TAG를 만들어 놓은 CPU에서는 STREX 명령어를 실패 하게 되는것이다.
위 그림을 보면, CPU0가 먼저 LDREX 명령어를 수행해서 해당 메모리 block에 TAG를 생성해 놓았다. 그 다음에 CPU1에서 TAG를 생성하면서 기존에 다른 CPU를 위한 TAG는 지운다. 따라서 CPU0 의 STREX 명령어는 실패하고, CPU1의 STREX명령어만 성공하게 되어 shared object (memory)를 여러 CPU들로부터 atomic operation을 보장 할 수 있게 된다. 그러면 CPU0 입장에서는 STREX 명령어가 실패했음을 알고, 다시 LDREX부터 수행하거나, 나중에 하거나 등 결정 하면 된다 (소프트웨어를 어떻게 디자인했느냐에 따라 달라질 것).
그리고 ARMv8 Manual에서도 이러한 atmoic operation support를 통해 아래와 같은 simple lock sample code를 제공하고 있다:
<LOCK>
Px
SEVL
; invalidates the WFE on the first loop iteration
PRFM PSTL1KEEP, [X1]
; allocate into cache in unique state
Loop
WFE
LDAXR W5, [X1] ; read lock with acquire
CBNZ W5, Loop ; check if 0
STXR W5, W0, [X1] ; attempt to store new value
CBNZ W5, Loop ; test if store succeeded and retry if not
; loads and stores in the critical region can now be performed
<UNLOCK>
Px
; loads and stores in the critical region
STLR WZR, [X1] ; clear the lock
Atomic instructions
ARMv8.1 버전부터는 FEAT_LSE2, Large System Extensions v2 이라는 기능이 추가되었고, ID_AA64MMFR2_EL1.AT register를 통해 이 기능이 구현되어있는지 확인 할 수 있다. 이 기능으로 Hardware 적으로 Atomic operation을 지원하게 되었다. 일단 이 기능의 원리를 이해하려면 Load-Acquire 그리고 Store-Release 라는 ARMv8의 메모리 접근 Concpet에 대해서 이해할 필요가 있다.
Load-Acquire
Store-Release
간략히 설명하면, LDR 또는 STR 명령어를 수행하면서 자동적으로 memory barrier를 수행하여, 개발자로 하여금 DMB instruction을 고려하지 않아도 되도록 만든 개념이다.
또한 기존에 atomic operation을 구현하기 위해 등작하였던 Read-Modify-Write operation을 하나의 명령어로 만들었다는 것을 알고 있어야 한다.
Atomic memory operation
LDADD: atomic add instruction w/o Acquire nor Release
LDADDA: atomic add instruction with Acquire
LDADDAL: atomic add instruction with Acquire and Release
이번에는 primary_entry함수 안에 있는 set_cpu_boot_mode_flag 함수에 대해서 알아보겠다.
/*
* Sets the __boot_cpu_mode flag depending on the CPU boot mode passed
* in w0. See arch/arm64/include/asm/virt.h for more info.
*/
SYM_FUNC_START_LOCAL(set_cpu_boot_mode_flag)
adr_l x1, __boot_cpu_mode
cmp w0, #BOOT_CPU_MODE_EL2
b.ne 1f
add x1, x1, #4
1: str w0, [x1] // This CPU has booted in EL1
dmb sy
dc ivac, x1 // Invalidate potentially stale cache line
ret
SYM_FUNC_END(set_cpu_boot_mode_flag)
일단 이 함수는 w0에 (첫번째 register를 의미하며, 32bit만 읽고 싶을때는 w0, 64bit를 모두 읽고 싶을 때는 x0로 읽어 올 수 있다.) 현재 CPU에서 동작하는 Exception Level이 인자로 전달되었다고 가정한다.
결론 부터 이야기 하면 이 함수의 목적은, 커널이 소유하고있는 CPU들이 같은 Exception Level에서 부팅되었음을 확인하고자 만들어졌으며, 알고리즘은 쓰고 같은지 확인 이다.
__boot_cpu_mode 배열은 아래와 같이 선언되었다고 했을 때, 이 함수를 수행하고 있는 CPU가 EL1에서 booting되었다고 한다면, 함수는 아래 배열에서 첫번째 index에 BOOT_CPU_MODE_EL1 값을 쓸 것이며, 결과로는 배열의 element들이 모두 같은 값을 갖게 될 것이다. 반대로 CPU가 EL2에서 booting되었다고 한다면, 함수는 아래 배열에서 두 번째 index에 BOOT_CPU_MODE_EL2 값을 쓸것이고, 배열의 모든 element들이 모두 같은 값을 갖게된다.
/*
* We need to find out the CPU boot mode long after boot, so we need to
* store it in a writable variable.
*
* This is not in .bss, because we set it sufficiently early that the boot-time
* zeroing of .bss would clobber it.
*/
SYM_DATA_START(__boot_cpu_mode)
.long BOOT_CPU_MODE_EL2
.long BOOT_CPU_MODE_EL1
SYM_DATA_END(__boot_cpu_mode)
이는 boot CPU 이후에 secondary CPU들에 대해서도 booting exception level에 따라 배열의 element에 값을 쓰고 배열의 element들이 같은 값을 유지하고 있는지 여부에 따라 모든 CPU들의 booting exception level들이 같은 level에서 부팅되었는지 다른 level을 갖고 부팅 되었는지를 확인할 수 있게 된다.
dc ivac, x1:이 함수가 호출 되었을 때는 아직 CPU의 dcache가 활성화 되어있지 않은 상태로써, dcache에 배열의 주소에 해당하는 data가 있다면 나중에 예기치 못한 상황이 생길 수도있어서, 해당 배열에 대해서 dcache invalidation operation을 해주고 있다.
예전에 kernel code를 공부할 때 버전보다 한참 upgrade되어서, 전에 보던 코드랑 조금 다르지만 현재 코드버전 (v5.9)를 기준으로 다시 공부를 시작한다. 예전코드에서 _stext symbole이 v5.9부터는 primary_entry라는 symbol이름으로 변경되었다. 확인해보지는 않았지만, secondary core들의 entry point는 아마 secondary_entry 가 되지 않을까 싶다.
먼저 SYM_CODE_START라는 macro에 대해서 좀 살펴볼 필요가 있는데, 여기(TODO)에서 다루어 보려고 한다.
SYM_CODE_START(primary_entry)
bl preserve_boot_args
bl el2_setup // Drop to EL1, w0=cpu_boot_mode
adrp x23, __PHYS_OFFSET
and x23, x23, MIN_KIMG_ALIGN - 1 // KASLR offset, defaults to 0
bl set_cpu_boot_mode_flag
bl __create_page_tables
/*
* The following calls CPU setup code, see arch/arm64/mm/proc.S for
* details.
* On return, the CPU will be ready for the MMU to be turned on and
* the TCR will have been set.
*/
bl __cpu_setup // initialise processor
b __primary_switch
SYM_CODE_END(primary_entry)
예전 글에서 kernel을 booting시키기 위해서 register들이 가져야 할 값들이 있다고 설정했는데, 이 값들을 실제로 사용하기 전까지 어딘가 저장해놓고 X0~X3 register들을 최대한 사용하려는 목적으로 preserve_boot_args 함수를 호출한다.
/*
* Preserve the arguments passed by the bootloader in x0 .. x3
*/
SYM_CODE_START_LOCAL(preserve_boot_args)
mov x21, x0 // x21=FDT
adr_l x0, boot_args // record the contents of
stp x21, x1, [x0] // x0 .. x3 at kernel entry
stp x2, x3, [x0, #16]
dmb sy // needed before dc ivac with
// MMU off
mov x1, #0x20 // 4 x 8 bytes
b __inval_dcache_area // tail call
SYM_CODE_END(preserve_boot_args)
kernel에서 초기화 중 제일 중요한 device tree blob이 load된 메모리의 시작 주소를 포함하여 X0~X3을 boot_args라는 버퍼에 저장해 놓는다. 이 코드에서 알아두어야 할 macro가 하가 있는데 adr_l macro 이다.
/*
* @dst: destination register (64 bit wide)
* @sym: name of the symbol
*/
.macro adr_l, dst, sym
adrp \dst, \sym
add \dst, \dst, :lo12:\sym
.endm
<linux/arch/arm64/include/asm/assembler.h>
adrp instruction을 통해서 심볼(\sym)의 주소를 PC 기준 offset를 통해 심볼의 상대 주소로 가져 오는 내용이다. 그리고 그 offset은 page aligned offset이기 때문에 심볼의 하위 12bit는 :lo12:\sym 값을 더해줌으로써 실제 심볼의 상대주소를 가져온다는 부분이다. 이 부분에 대한 상세내용은 여기에서 다루려고 한다.
사실 preserve_boot_args 함수 이름으로만 본다면 2개의 stp instruction들로만 끝나는게 맞다고 생각한다. 그치만 이 함수에서는 나중에 d-cache invalidation도 해주고 있다. 추측컨데, bootloader에서 d-cache를 사용했다면 나중에 d-cache를 enable했을 때 (kernel의 one of the requirements로 d-cache는 꺼져 있어야 한다), 혹시 compulsory cache miss가 생겨야 할 부분에서 안 생기고 unexpected result를 갖게 될까 invalide해 주고 있다고 추측된다.
Barrier instruction 중 하나인 dmb는 barrier instruction 전과 후를 기점으로 memory access instruction들의 순서가 변하지 않음을 보장하는 역할을 하고, 다른 memory system master들이 dmb 명령어 전의 memory access들에 대해서 같은 값을 얻을 수 있게 해준다. 위에 있는 dmb sy는 사실 여기서 어떠한 이유로 사용되었는지 알수가 없다.
저장해 놓아야할 값들은 X0~x3 register들의 값들로 8byte*4 = 32byte이다. 이 부분에 대해서 d-cache를 invalidation해 주기 위해 __inval_dcache_area 함수의 인자로 x0에는 boot_args의 시작 주소, x1에는 size (0x20)을 저장하고 __inval_dcache_area 함수를 부르고 있다.
SYM_FUNC_START_LOCAL(__dma_inv_area)
SYM_FUNC_START_PI(__inval_dcache_area)
/* FALLTHROUGH */
/*
* __dma_inv_area(start, size)
* - start - virtual start address of region
* - size - size in question
*/
add x1, x1, x0
dcache_line_size x2, x3
sub x3, x2, #1
tst x1, x3 // end cache line aligned?
bic x1, x1, x3
b.eq 1f
dc civac, x1 // clean & invalidate D / U line
1: tst x0, x3 // start cache line aligned?
bic x0, x0, x3
b.eq 2f
dc civac, x0 // clean & invalidate D / U line
b 3f
2: dc ivac, x0 // invalidate D / U line
3: add x0, x0, x2
cmp x0, x1
b.lo 2b
dsb sy
ret
SYM_FUNC_END_PI(__inval_dcache_area)
SYM_FUNC_END(__dma_inv_area)
<linux/arch/arm64/mm/cache.S>
이 함수는 invalide해야할 메모리에 대해서 cache line size 단위로 (그리고 cache line size align 기준으로) invalidate해 주고 있다. x0가 cache line size aligned되어 있는지 확인하여 아니라면 아닌 부분(아래 그림에서 A에 해당)만 한번 invalidate해주고, 중간에 cache line size aligned된 chunk(아래 그림에서 B에 해당)들에 대해서 loop를 돌아가며 cache invalidate해주고 있다. 그리고 아래 부분이 cache line size aligned되어 있지 않다면, 안된 부분(아래 그림에서 C에 해당)만 한번 cache invalidation해주고 있다.
위 함수에서 알아두어야 할 점은:
dcache_line_size macro
dc [civac|ivac] instruction
dcache_line_size함수는 2개의 인자를 필요로 하는데 첫 번째 인자인 reg는 dcache line size를 return할 register를 그리고 두 번째로는 연산시 필요한 임시의 register를 받는다.
/*
* dcache_line_size - get the safe D-cache line size across all CPUs
*/
.macro dcache_line_size, reg, tmp
read_ctr \tmp
ubfm \tmp, \tmp, #16, #19 // cache line size encoding
mov \reg, #4 // bytes per word
lsl \reg, \reg, \tmp // actual cache line size
.endm
read_ctr macro는 해당 architecture에서 d-cache line size를 얻을 수 있는 system register를 읽어오는 macro로써 ARM64 architecture에서는 "mrs \reg, ctr_el0" 이 명령어로 치환된다.
<ctr_el0 register bit-fields>
이 register value에서 [19:16] bit field의 설명은 아래와 같다.
따라서 Dminline 값을 통해 우리는 d-cache line size를 읽어 올 수 있으며 위 코드에서는
ubfm \tmp, \tmp, #16, #19 // cache line size encoding를 통해 Dminline의 값만을 얻어 오고 있다.
얻어 온 값이 log2를 취한 값이라는 점과 byte가 아니라 word의 갯수라는 점을 참고해 본다면 위 코드처럼 4<<\tmp을 통해 d-cache line size를 얻어오는 것이 당연해 보인다.
dc [civac|ivac]명령어 들에 대해서 보자면, 타켓 메모리가 아닌 부분과 같은 캐쉬라인에 속해 있는 부분은 혹시 모를 상황에 대비하여 단순 invalidate 뿐만 아니라 clean operation도 해줌으로써 메모리에 태쉬 내용을 반영시켜주고 있다.
dc civac: 는 "DC CIVAC, Data or unified Cache line Clean and Invalidate by VA to PoC"
dc ivac: 는 "DC IVAC, Data or unified Cache line Invalidate by VA to PoC"
다음으로는 primary_entry의 두 번째 함수인 el2_setup에 대해서 보자면,
/*
* If we're fortunate enough to boot at EL2, ensure that the world is
* sane before dropping to EL1.
*
* Returns either BOOT_CPU_MODE_EL1 or BOOT_CPU_MODE_EL2 in w0 if
* booted in EL1 or EL2 respectively.
*/
SYM_FUNC_START(el2_setup)
msr SPsel, #1 // We want to use SP_EL{1,2}
mrs x0, CurrentEL
cmp x0, #CurrentEL_EL2
b.eq 1f
mov_q x0, (SCTLR_EL1_RES1 | ENDIAN_SET_EL1)
msr sctlr_el1, x0
mov w0, #BOOT_CPU_MODE_EL1 // This cpu booted in EL1
isb
ret
...
/* spsr */
mov x0, #(PSR_F_BIT | PSR_I_BIT | PSR_A_BIT | PSR_D_BIT |\
PSR_MODE_EL1h)
msr spsr_el2, x0
msr elr_el2, lr
mov w0, #BOOT_CPU_MODE_EL2 // This CPU booted in EL2
eret
SYM_FUNC_END(el2_setup)
이 함수는 mrs x0, CurrentEL을 통해서 현재 kernel이 동작하고 있는 Exception Level을 읽어온다. 그리고 만약 현재 kernel이 동작하고 있는 CPU Exception Level이 EL1라고 한다면 SCTLR_EL1 시스템 레지스터에서 RES1 와 ENDIAN bitfield들을 1로 set해주고 w0 에 현재 동작하는 CPU mode가 EL1에 해당한다는 BOOT_CPU_MODE_EL1 상수 값을 return해주며 이 함수를 끝낸다. 만약 현재 CPU가 EL2에서 동작하고 있다면, 일단 커널 입장에서는 본인이 KVM을 위해서 동작하고 있을 수도 있다는 가정으로 아래 코드들을 수행한다. 여기서는 kernel이 KVM hypervisor로 동작하는 부분은 생략하기로 한다.
커널이 설정을 확인하며 자신이 EL2에서 동작하고는 있지만 이는 사실 bootloader가 별 의도 없이 올려주었고, 자신은 KVM hypervisor가 아니라 linux kernel로만 동작할 것이라는게 확실해지면 위에 코드처럼 spsr_el2 레지스터에 "PSR_MODE_EL1H"로, elr_el2를 이 함수의 caller로 그리고 eret (exception return)을 해주면서 커널 자신을 EL1으로 level drop 해주면서 이 함수를 끝낸다.
Symbol을 읽을 때, 동작하는 Binary가 compile된 주소에 잘 load되어 있다면, LDR instruction을 통해서 Symbol의 위치를 얻어 올 수 있다. 하지만 Binary가 해당 주소에 적재가 안되었을 때에는 LDR instruction을 통해서 symbol의 주소에 접근하면 잘못 된 값을 가져올 수도 있다.
따라서, ARM은 adrp instruction을 통해 PC-relative addressing mechanism을 제공해준다.
예를 들어 foo라는 심볼의 주소를 register x0에 넣으려면 아래와 같이 foo[63:12]를 adrp 명령어를 통해 pc기준 상대 주소로 가져온 후 foo[11:0]의 주소는 #:lo12:foo를 통해서 얻어 올 수 있다:
adrp x0, :pg_hi21:foo
add x0, x0, #:lo12:foo
만약 foo라는 심볼의 주소를 얻어 오기 보다는 해당 심볼에 바로 접근하여 심볼로 부터 값을 읽어 오려고 한다면 아래와 같이 ldr 명령어를 통해 얻어 올 수 있다.
ENTRY(stext) bl preserve_boot_args bl el2_setup // Drop to EL1, w0=cpu_boot_mode adrp x23, __PHYS_OFFSET and x23, x23, MIN_KIMG_ALIGN - 1 // KASLR offset, defaults to 0 bl set_cpu_boot_mode_flag bl __create_page_tables /* * The following calls CPU setup code, see arch/arm64/mm/proc.S for * details. * On return, the CPU will be ready for the MMU to be turned on and * the TCR will have been set. */ bl __cpu_setup // initialise processor b __primary_switch ENDPROC(stext)
stext 함수는 여기서 ENTRY라는 마치 함수와 같은 것으로 둘려져 있는데, 여기서 ENTRY는 링커스크립트의 것과는 다르게 리눅스 내에서 매크로로 정의되어 있는 function-like macro일 뿐이다.
2번 째 줄의 preserve_boot_args에 대해서 간략하게 알아보자면, 이전 글의 마지막에서 언급한 적이 있는데, 리눅스는 시작 할 때 몇 개의 값을 전달 받고 시작하게 된다고 언급한 적이 있다. 이 값들이 지금 당장은 쓰이지 않지만 나중에 쓰이기 위해 따로 저장하기 위함이라 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
preserve_boot_args: mov x21, x0 // x21=FDT
adr_l x0, boot_args // record the contents of stp x21, x1, [x0] // x0 .. x3 at kernel entry stp x2, x3, [x0, #16]
dmb sy // needed before dc ivac with // MMU off
mov x1, #0x20 // 4 x 8 bytes b __inval_dcache_area // tail call ENDPROC(preserve_boot_args)
linux/arch/arm64/kernel/setup.c 에 위치하고 있는 아래의 boot_args에 저장하게 되는 꼴이 된다.
"Alignment가 무엇인가" 대한 글인데.. 살짝 요약하기 전에 바로 본론으로 들어가보도록 한다.
컴퓨터라는 놈은 특히 ARM architecture에서는 무언가를 실행하기 위해서, CPU 바로 옆에 있는 레지스터와 메모리 사이에 읽고 쓰는
연산이 굉장히 많다. 이때 주의깊게 봐야 할 점은 메모리에서, 어떤 주소에서 얼마 만큼 읽어 올 것인가가 주된 내용이다.
아래 그림에서 각 메모리 주소들은 주소를 나타내고 각 주소마다 1byte 만큼을 의미한다.
각각의 예시를 통해서 개발자가 생각하는 컴퓨터의 예상 행동과 실제 컴퓨터의 행동을 비교해보도록 해보자
먼저, 개발자가 생각하는 컴퓨터의 예상 행동은
READ 주소0 부터 1byte: 아래 색칠 되어있는 0 부분만 읽어오는 것
READ 주소3 부터 2byte: 아래 색칠되어있는 3 그리고 4를 읽어오는 것
READ 주소3 부터 4byte: 아래 색칠되어있는 3, 4, 5 그리고 6를 읽어오는 것
하지만, 실제 컴퓨터의 동작은 프로그래머가 생각하는 방식으로 동작하지 않고 읽거나 쓸 때 Chunk 단위로 동작하는데 이 단위는 CPU에 따라 다르다. 예를 들어 32bit architecutre에서는 32bit chunk 단위로 움직이고, 64bit architecture에서는 64bit chunk 단위로 움직이게 된다. 즉,
READ 주소0 부터 1byte: 0 번째 byte를 읽기 위해서 0,1,2 그리고 3번째 byte를 모두 읽고, 그 중 0번 째 byte 값만 다시 OR 연산을 통해 해당 0 번째 byte만을 결과로 갖는다.
READ 주소3 부터 2byte: 3 번째 부터 2 byte를 읽기 위해서 0,1,2,3 그리고 4,5,6,7 번째 byte를 모두 읽고, 그 중 3번 째, 4번째 byte 값만 다시 OR 연산을 통해 결과로 갖는다
READ 주소3 부터 4byte: 3 번째 부터 4 byte를 읽기 위해서 0,1,2,3 그리고 4,5,6,7 번째 byte를 모두 읽고, 그 중 3,4,5,6번 째 byte 값만 다시 OR 연산을 통해 결과로 갖는다.
이처럼, 프로그래머가 읽고 싶은 번째부터 몇 개의 byte만을 읽기 위해서 실제로는 chunk 단위로 읽어 들여야 하다 보니 생각치 못한 오버헤드가 생긴다. 그래서 CPU가 자체적으로 이렇게 오버헤드가 생기지 않게 하기 위해서, Alignment check를 통해서 자체적으로 오버헤드를 줄일 수 있게 해 놓았다.(읽고자 하는 단위와, 주소를 통해서 Alignment를 체크한다.)
Memory Access
Alignment (8bit)
Alignment (16bit)
Alignment (32bit)
Alignment (64bit)
0x0000_0000
Aligned
Aligned
Aligned
Aligned
0x0000_0001
Aligned
Non-Aligned
Non-Aligned
Non-Aligned
0x0000_0002
Aligned
Aligned
Non-Aligned
Non-Aligned
0x0000_0003
Aligned
Non-Aligned
Non-Aligned
Non-Aligned
0x0000_0004
Aligned
Aligned
Aligned
Non-Aligned
0x0000_0005
Aligned
Non-Aligned
Non-Aligned
Non-Aligned
0x0000_0006
Aligned
Aligned
Non-Aligned
Non-Aligned
0x0000_0007
Aligned
Non-Aligned
Non-Aligned
Non-Aligned
0x0000_0008
Aligned
Aligned
Aligned
Aligned
위에 테이블과 같이 해당 메모리는 접근 할 때, 위에 표를 참조하여 Alignment를 체크하고 올바르지 않은 접근을 하게 되면 Alignment Fault를 발생시킨다.
임베디드 환경(통상 ARM Architecture)에서,리눅스 커널과 같은 운영체제는 메모리에 로드가 된 후 실행된다.
그렇다면 여기서!, 드는 질문 2가지?!
메모리에 적재시켜주는데... 메모리 몇 번지에 로드시켜야 하는가?
커널 이미지는 누가 메모리에 로드시켜 주는가?
첫 번째 질문에 대한 대답을 먼저 하면..
리눅스 커널은 PIC(Position Independent Code)라고 한다. 말 그대로 어느 메모리에 올려도 자기가 알아서 원하는 메모리 주소로 Copy해서 동작할 줄 아는 소프트웨어이다.(물론, 프로그램이 정상적으로 동작 할 수 있는 메모리여야 한다)
결론적으로, 그래서 딱히 어느 메모리에 로드웨어야 한다 이런것은 없다.얕지만 알고 있는 경험에 의하면, 이미지를 로드하는 메모리 주소가 Align(이 부분에 대해서는 나중에 글을 올릴 예정이다)만 되어있다면 어느 주소에도 동작하는 것을 확인했었다. arm32 에서는 zImage가 원하는 주소에 kernel image를 압축해제 했었다.
두 번째 질문에 대한 대답을 하면..
커널을 메모리에 올려주는 놈은 부트로더(Bootloader)라고 불리는 소프트웨어가 해준다. 보드를 몇 번 실행해봤다면 흔히 들어봤을 법한 U-boot, LK(Little Kernel), UEFI 이런 놈들이 부트로더에 해당된다.
부트로더도 크게는 First-bootloader, Second-boorloader 로 나눠진다. 간랸히 설명해보면 First-Bootloader는 ATF(ARM Trusted Firmware)에 해당하는 보드를 만드는 vendor사에서 만드는 펌웨어로 보드를 부팅시키는 놈이다.(사실 하나의 소프트웨어가 아니라 여러개로 나뉘어져있다.)
Second-Booloader가 위에서 언급한 부트로더들이다.
이로써 두 개의 질문과 답을 마치고 이어서 이 글의 주제로 넘어와 보면,(지금부터 말하는 부트로더는 Second-Bootloader 이다.)
부트로더는 크게 2개의 역할로 볼 수 있다.(물론 세부적으로는 많은 일들을 하지만 )
첫 번째는 보드의 디바이스, 메모리 초기화. 두 번째는 운영체제 메모리에 적재이다. 그럼 이 때 부트로더는 내가 로드하고 있는 이 운영체제가 어떠한 운영체제인지, 어떠한 프로토콜(운영체제마다 다르겠지만 리눅스에서는 레지스터에 값을 전달해주는 정도를 뜻한다)을 따라주어야 하는지를 먼저 알아야 한다. 그 때 하는 것이 리눅스커널 이미지를 읽어보는 것이다. (전부다는 아니고 처음 시작 부분을 읽어본다)
그럼 리눅스 커널은 부트로더에게 "내가 커널이미지야!" 라고 말해주는 역할로 헤더를 만들어놓았다. Header에 대한 자세한 설명은 참조[1]에 있지만 간략히 설명해보자면 아래와 같이 생겼다.
u32 code0; /* Executable code */
u32 code1; /* Executable code */
u64 text_offset; /* Image load offset, little endian */
u64 image_size; /* Effective Image size, little endian */
이 헤더는 리눅스 커널 이미지의 처음 시작 64byte 값이다. 부트로더 코드를 읽어보지는 않았지만, 일단 ARM64_IMAGE_MAGIC("ARM\x64") String값을 읽고 일단 리눅스 커널이라는 것을 알아낸 후 사이즈에 대한 정보를 통해서 메모리에 적재하는 것으로 추정된다. 그리고 메모리에 올린 후 제어권을 리눅스 커널로 넘겨주어야 하는데 이 때 아래와 같은 값을 각각 레지스터에 써서 넘겨준다. (이 때, 제어권을 넘겨준다는 말은 PC값을 이미지의 처음 주소로 assign한다는 의미가 주된 의미지만 필요시 CPU mode를 바꿔줌으로써 넘겨준다.)
- Primary CPU general-purpose register settings x0 = physical address of device tree blob (dtb) in system RAM. x1 = 0 (reserved for future use) x2 = 0 (reserved for future use) x3 = 0 (reserved for future use)
(DTB가 무엇인지 대한 설명은 다른 글에서 하도록 한다...하 씨발 쓸거 개 많네)
이 글에서는 리눅스 커널 이미지에는 헤더라는게 왜 있어야 하는지에 대한 배경설명과, 헤더의 모양은 어떠한지 그리고 커널의 실제 헤더의 값은 어떻게 되어있는지에 대해서 보았다.