smp_setup_processor_id()

<kernel v5.0>

리눅스는 물리 cpu 번호(id)를 사용하지 않고 로지컬 cpu 번호(id)를 사용하여 관리한다. 현재 부트된 물리 cpu를 로지컬 cpu id 0번으로 배치하여 사용한다.

  • DTB를 사용하는 경우 setup_arch() -> arm_dt_init_cpu_maps() 함수에서 로지컬 cpu id가 재조정된다.

 

smp_setup_processor_id() – ARM

arch/arm/kernel/setup.c

void __init smp_setup_processor_id(void)
{
        int i;
        u32 mpidr = is_smp() ? read_cpuid_mpidr() & MPIDR_HWID_BITMASK : 0;
        u32 cpu = MPIDR_AFFINITY_LEVEL(mpidr, 0);

        cpu_logical_map(0) = cpu;
        for (i = 1; i < nr_cpu_ids; ++i)
                cpu_logical_map(i) = i == cpu ? 0 : i;

        /*
         * clear __my_cpu_offset on boot CPU to avoid hang caused by
         * using percpu variable early, for example, lockdep will
         * access percpu variable inside lock_release
         */
        set_my_cpu_offset(0);

        pr_info("Booting Linux on physical CPU 0x%x\n", mpidr);
}

현재 로지컬 cpu 0번에 대한 물리 cpu 번호를 읽어 매핑한다. (처음 부팅 시 로지컬 cpu는 항상 0번이다.)

  • 코드 라인 4~7에서 MPIDR의 하위 3바이트(Aff0, Aff1, Aff2)를 가져오기 위해 비트마스킹을 한 후 가장 단계가 낮은 affinity level 0번 값을 읽어온다. 읽어온 값은 물리 cpu 번호이며 이를 로지컬 cpu 0번에 해당하는 cpu_logical_map에 저장한다.
    • CPU Affinity는 여러 개의 CPU core 중에서 각각의 cpu가 가지는 고유 번호같은 것.
    • Affinity는 계층적으로 표현된다.
    • x86의 하이퍼스레딩과 같이 arm에서도 가상 코어를 상용화하려 하였다가 포기하였다.
    • 만일 상용화가 되었으면 affinity 0번 레벨 값은 가상 코어 id별로 cpu 번호가 부여된다. 현재의 ARM은 물리 core id 별로 cpu 번호를 부여한다.
  • 코드 라인 8~9에서 임시로 로지컬 cpu 1번 부터 나머지 로지컬 cpu에 대해 물리 cpu 번호와 동일하게 구성한다.
  • 코드 라인 11에서 현재 cpu가 로지컬 cpu 0 이므로 per-cpu에서 사용할 현재 cpu에 대한 offset을 0으로 설정한다.
  • 코드 라인 13에서 어떤 물리 CPU로 리눅스 부팅이 되었는지 안내하는 정보 출력한다.

 

MPIDR_AFFINITY_LEVEL() 매크로

arch/arm/include/asm/cputype.h

#define MPIDR_AFFINITY_LEVEL(mpidr, level) \
        ((mpidr >> (MPIDR_LEVEL_BITS * level)) & MPIDR_LEVEL_MASK)
#define MPIDR_LEVEL_BITS 8
#define MPIDR_LEVEL_MASK ((1 << MPIDR_LEVEL_BITS) - 1)
  • MPIDR_LEVEL_BITS
    • 하나의 affinity level 값이 몇 비트로 이루어져 있는지 나타내고 8로 설정되어 있다 (1바이트).
  • MPIDR_LEVEL_MASK
    • 하위 MPIDR_LEVEL_BITS개의 비트만 1이고 나머지는 0인 값이다.

 

다음 그림은 arm 시스템에서 8개의 cpu가 있는 경우를 가정하여 위의 함수가 처음 동작한 경우이다.

 

smp_setup_processor_id() – ARM64

arch/arm64/kernel/setup.c

void __init smp_setup_processor_id(void)
{
        u64 mpidr = read_cpuid_mpidr() & MPIDR_HWID_BITMASK;
        cpu_logical_map(0) = mpidr;

        /*
         * clear __my_cpu_offset on boot CPU to avoid hang caused by
         * using percpu variable early, for example, lockdep will
         * access percpu variable inside lock_release
         */
        set_my_cpu_offset(0);
        pr_info("Booting Linux on physical CPU 0x%010lx [0x%08x]\n",
                (unsigned long)mpidr, read_cpuid_id());
}

현재 로지컬 cpu 0번에 대한 물리 cpu 번호를 읽어 매핑한다. (처음 부팅 시 로지컬 cpu는 항상 0번이다.)

  • 코드 라인 3~4에서 MPIDR로부터 최대 4개 레벨에 해당하는 비트 값만을 마스킹하여 읽어와서 cpu_logical_map에 저장한다.
  • 코드 라인 11에서 현재 cpu가 로지컬 cpu 0 이므로 per-cpu offset을 0으로 설정한다.
  • 코드 라인 12~13에서 어떤 물리 CPU로 리눅스 부팅이 되었는지 안내하는 정보 출력한다.
    • 예) “Booting Linux on physical CPU 0x0”

 

다음 그림은 arm64 시스템에서 8개의 cpu가 있는 경우를 가정하여 위의 함수가 처음 동작한 경우이다.

  • arm64에서는 로지컬 cpu 0번에 해당하는 매핑만 설정해둔다.

 

__cpu_logical_map[] – ARM64

arch/arm64/kernel/setup.c

u32 __cpu_logical_map[NR_CPUS] = { [0 ... NR_CPUS-1] = MPIDR_HWID };
  • Designated Initializers 라고 불리는 배열 초기화 방법.
  • MPIDR_HWID:
    • ulong_max 값
    • arm
      • 0xffff_ffff
    • arm64
      • 0xffff_ffff_ffff_ffff

 

NR_CPUS
  • 가능한 CPU의 최대 개수로 configuration 할 때 static하게 정해지는 값.
    • nr_cpu_ids
      • 런타임에 사용하는 cpu의 수
      • 런타임에 설정되기 전까지는 NR_CPUS 값과 동일하다.
  • 커널 버전에 따라 범위와 default 값이 약간씩 다르다.
  • 커널 버전이 높아질 때마다 조금씩 증가되는 추세이다.
  • arm
    • 2 ~ 32의 범위에 default는 4이다.
  • arm64
    • 2 ~ 4096의 범위에 default는 64이다.
  • x86_32
    • 2 ~ 8의 범위에 default는 8이다.
    • 추가로 X86_BIGSMP 설정을 사용하는 경우 2 ~ 32의 범위에 default는 32이다.
  • x86_64
    • 2 ~ 522의 범위에 default는 64이다.
    • 추가로 MAXSMP 설정을 사용하는 경우 2 ~ 8192 범위에 default는 8192이다.

 

set_my_cpu_offset() – ARM

arch/arm/include/asm/percpu.h

/*
 * Same as asm-generic/percpu.h, except that we store the per cpu offset 
 * in the TPIDRPRW. TPIDRPRW only exists on V6K and V7
 */
#if defined(CONFIG_SMP) && !defined(CONFIG_CPU_V6)
static inline void set_my_cpu_offset(unsigned long off)
{
        /* Set TPIDRPRW */

        asm volatile("mcr p15, 0, %0, c13, c0, 4" : : "r" (off) : "memory");
}

현재 cpu의 per-cpu offset을 @offset 값으로 설정한다

  • per-cpu 자료구조에서 사용되는 cpu마다 개별적으로 가지는 offset 값.
  • ARMv7에서는 속도 향상을 위해서 TPIDRPRW register를 사용함.
    • ARMv7 이전 아키텍처에서는 이 레지스터를 사용하지 않고 메모리를 사용하여 연산하느라 메모리에 대한 접근이 2번 필요하여 느렸었고 이를 극복하기 위해 본래의 목적으로 사용하지 않는 레지스터인 TPIDRPRW를 사용하여 메모리 접근을 한 번으로 줄이기 위해 사용한다.
    • 참고: ARM: implement optimized percpu variable | LWN.net

 

set_my_cpu_offset() – ARM64

arch/arm64/include/asm/percpu.h

static inline void set_my_cpu_offset(unsigned long off)
{
        asm volatile(ALTERNATIVE("msr tpidr_el1, %0",
                                 "msr tpidr_el2, %0",
                                 ARM64_HAS_VIRT_HOST_EXTN)
                        :: "r" (off) : "memory");
}

현재 cpu의 per-cpu offset을 @offset 값으로 설정한다

  • TPIDR 레지스터를 사용하여 현재 cpu에 대한 per-cpu offset 값을 저장하여 per-cpu 변수에 대한 빠른 access를 가능하게 한다.
  • 부팅 cpu를 제외한 나머지 cpu들에 대해 ARM64_HAS_VIRT_HOST_EXTN라고 불리는 cpu capability가 있는 경우 두 번째 인자에 있는 명령이 수행된다.

 

MPIDR(Multiprocessor Affinity Register)

멀티프로세서 시스템의 스케줄링을 위해 어떠한 코어들간에 친화력(affinity)이 있는지 레벨별로 제공한다.

 

Process Affnity(프로세스 친화력)

프로세스 스케쥴링시 한 번 배정되었던 프로세스를 어떤 CPU 코어를 사용하게 할 지 결정하기 위해 필요.

  • 리눅스 스케줄러는 가능하면 캐시 데이터 재활용을 위해 같은 코어에 배정
  • 로드 밸런스를 위해 같은 코어에 배정을 하지 않는 경우 affinity 0 레벨에서 검토하고 배정할 core가 busy한 경우 점차 상향하여 위로 올라간다.

 

1) ARM(AArch32)

다음 그림은 32bit ARM의 MPIDR 레지스터를 보여준다.

  • cpu가 UP 및 SMP 용도 두 가지로 표현되어 있다.
  • 최대 3단계의 affinity 레벨을 제공한다.

mpidr

 

2) ARM64(AArch64)

다음 그림은 ARM64의 MPIDR 레지스터를 보여준다.

  • cpu가 UP 및 SMP 용도 두 가지로 표현되어 있다.
  • 최대 4단계의 affinity 레벨을 제공한다.

 

U(Uniprocessor):
  • 0=Multiprocessor
  • 1=Uniprocessor

 

MT(Multi-Thread):

멀티스레딩 타입 접근으로 구현된 논리 프로세서의 밀결합 최소 레벨

  • 0
    • 최소 affinity 레벨에서의 프로세스 성능이 최대 독립적
      • affinity 레벨 0은 독립적인 core id를 사용하므로 각각의 core 성능은 최대한 독립적으로 운영된다.
  • 1
    • 최소 affinity 레벨에서의 프로세스 성능이 매우 의존적
      • affinity 레벨 0은 virtual core id를 사용하므로 각각의 virtual core 성능은 같은 affinity 레벨의 virtual core의 성능에 영향을 끼친다.
      • x86의 하이퍼스레드와 동일한 개념이다.

 

ARM CPU Topology

구현에 따라 보통 2개 중 하나를 사용한다. ARM의 경우 virtual core를 개발하여 상용화하려다 계획을 포기하였다. 따라서 현재 모든 arm cpu들은 MT=0 모드만 사용한다.

  • 3단계 affinity 레벨 사용 (MT=1)
    • affinity 2:
      • socket id
    • affinity 1:
      • core id
    • affinity 0:
      • thread id
  • 2단계 affinity 레벨 사용 (MT=0)
    • affinity 2:
      • (reserved)
    • affinity 1:
      • socket id
    • affinity 0:
      • core id

 

다음 그림은 ARM CPU topology에서 MT(MultiThread) 지원 여부에 따라 구성된 모습을 보여준다.

  • 리눅스는 MT=1일 때 8개의 cpu로 인식하고, MT=0일 때 4개의 cpu로 인식한다.

ARM64 CPU Topology

리눅스 커널 구현에 따라 보통 2개 중 하나를 사용한다. ARM64의 경우도 virtual core를 개발하여 상용화하려다 계획을 포기하였다. 따라서 현재 모든 arm64 cpu들은 MT=0 모드만 사용한다.

  • 3/4단계 affinity 레벨 사용 (MT=1)
    • affinity 2 + (affnity 3 << 8):
      • package id
    • affinity 1:
      • core id
    • affinity 0:
      • thread id
  • 2단계 affinity 레벨 사용 (MT=0)
    • affinity 1 + (affnity 2 << 8) + (affnity 3 << 16) :
      • package id
    • affinity 0:
      • core id

 

다음 그림은 ARM64 CPU topology에서 MT(Multi Thread) 지원 여부에 따라 구성된 모습을 보여준다.

  • 리눅스는 MT=1일 때 8개의 cpu로 인식하고, MT=0일 때 4개의 cpu로 인식한다.

 

TPIDRPRW (Thread ID-R)

  • Multiprocessor Extension에서 사용하며 PL1(Previlidge Level 1으로 커널 레벨) 이상에서만 사용가능하다.
  • Security Extension에서는 레지스터는 뱅크된다.
  • TPID(스레드 ID) 정보가 기록된 레지스터
    • 현재 리눅스에서는 TPID를 저장하는 목적으로 사용하지 않고 각 cpu의 per-cpu offset를 저장하여 더 빠른 per-cpu data의 access를 위해 사용된다.

 

참고

 

Priority Inversion & Priority Inheritance

Priority Inversion

    • A 스레드는 C의 작업이 끝날때 까지 lock을 얻지 못해 기다리는데 C 작업 중 B에 의해 선점당하면서 A는 더 오랜 시간을 기다려야 한다.
    • A 스레드가 B 스레드보다 우선 순위가 높은 상황인데도 위와 같은 상황에서 우선 순위가 더 낮은 B 스레드가 먼저 처리되는 불합리한 상황이 벌어진다.
    • 이러한 점을 보강하기 위하여 Priority Inheritance Protocol이 개발되었고, 리눅스 커널은 priority가 중요한 RT 태스크들 사이에서 사용된다.

priority_inversion

 

Priority Inheritance

  • Priority Inversion 상황과 다르게 A 스레드가 lock을 얻다 실패하는 경우 현재 해당 리소스 lock을 얻어 동작하는 스레드 C의 우선 순위를 A 스레드와 같이 높은 우선 순위로 상속시키면서 그 보다 낮은 우선 순위의 B 스레드에게 선점되지 않게 막는다. 결국 A 스레드는 보다 빠르게 공유된 S 자원의 할당을 받아 처리할 수 있다.
  • 리눅스 커널은 RT 태스크들 사이에서 사용되며 RT Mutex API를 통해 구현되었다.

priority_inheritance

priority_inversion2

 

참고

Lock Problem(Dead-Lock)

Lock이 의도되지 않은 상태에 빠져 나오지 못하는 상태가 여러 가지 있는데 그 중 대표적인 3가지를 알아본다.

Dead-Lock

  • 두 개의 태스크간에 자원을 하나 씩 소유하고 상대방의 자원에 접근하려 하는 경우 교착 상태에 빠져 나올 수 없는 상태가 된다.

lock_problem1

Circular Lock Dependency

  • 같은 lock을 두 번 사용하는 경우 두 번째 lock 호출 시 무한 대기 상태가 된다.

lock_problem2

Interrupt Safety

  • 루틴에서 같은 lock을 두 번 호출하지 않았는데도 불구하고 인터럽트 루틴에 의해 같은 lock이 호출되어 결국 두 번 사용되어지는 경우가 발생하는 경우에 두 번째 lock 호출에서 무한 대기 상태가 된다.

lock_problem3

  • 인터럽트 루틴에 의해 우선 순위가 더 높은 태스크로 스케쥴 되어 이미 호출된 lock을 또 사용하게 되는 경우에도 같은 상황이 발생한다.

lock_problem4a

참고

 

User stack vs Kernel stack

user task가 생성될때마다 스택이 각각 유저 스택과 커널 스택이 하나씩 만들어진다.

user stack

  • 유저 스택의 크기는 스레드 생성 시 지정될 수 있고, default 크기는 역시 아키텍처마다 다른다. 최대 사이즈 확인 방법은 “ulimit -a”를 사용한다.(32bit ARM은 8MB)
$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 14846
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 14846
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

 

kernel stack

  • task 생성 시 마다 kernel stack이 생성된다.
    • 사이즈는 유저 스택보다 훨씬 적으며 커널 버전 및 커널 옵션에 따라 조금씩 다르며 보통 1 ~ 4 페이지를 사용한다.
    • 예)
      • arm: 2 page (8K)
      • 4K 페이지를 사용하는 arm64: 4 page (16K)
      • 16K 페이지를 사용하는 arm64: 1 page (16K)

 

Exception 처리용 stack

  • arm 아키텍처에서는 각 exception에 대해 하나씩 있는 mini 스택을 사용한다.
    • exception이 발생되어 해당 exception 핸들러에 진입한 경우에는 각 exception에서 사용하는 3 word의 mini 스택을 이용하고 곧바로 svc 모드로 바꾼 후에는 svc용 스택(커널 스택)을 이용해 처리를 계속한다.

 

context switch시 스택 사용

  • user mode에서 task(thread)가 수행되고 있을 때 syscall을 하여 kernel mode로 jump하고 context switching을 하게되는데 이 때 부터는 해당 유저 task(thread)가 소유한 kernel stack을 사용한다.
  • 다시 kernel mode에서 user mode로 context switching을 하여 돌아가게 되면 task(thread)용 user stack을 사용한다.
  • 참고로 arm에서 sp 레지스터는 각 모드에서 각각 운영되며 arm64에서는 exception level별로 각각 운영된다.
    • arm: sp_usr, sp_svc, sp_irq, …
    • arm64: el0_sp, el1_sp, el2_sp

stack2

 

인터럽트 발생 시 스택

  • 유저 모드에서 swi나 인터럽트 등이 발생하는 경우는 조금 전 설명한 것과 같이 해당 태스크가 소유한 커널 스택을 이용한다.
    • arm 커널은 유저 모드에서 irq 모드를 통해 svc 모드로 전환될 때 irq 모드용 12바이트의 미니 스택을 운용하는데 이의 설명은 생략한다
  • 커널 모드에서 인터럽트 등이 발생하는 경우는 다음과 같이 2개로 분리된다.
    • 커널 스레드 동작 중 인터럽트가 발생한 경우
      • arm: 커널 스레드가 소유한 스택을 그대로 사용한다.
      • arm64: 인터럽트 스택을 사용한다.
    • 인터럽트 context 수행 중 더 높은 우선 순위 등의 인터럽트가 nest 된 경우
      • arm: 현재 사용하고 있는 상태의 커널 스택을 사용한다.
      • arm64: 인터럽트 스택을 사용한다.
  • 아키텍처에 따라 hardirq, softirq용 스택을 분리하여 운용하기도 한다.
    • arm의 경우 hardirq는 위에서 설명한 조건에 따라 적절한 커널 스택을 사용하고 softirq는 ksoftirqd가 소유한 커널 스택을 사용한다.
    • arm64의 경우 hardirq는 인터럽트용 스택을 사용하고 softirq는 커널옵션에 따라 ksoftirqd가 소유한 커널 스택을 사용하게 할 수 있다.

 

예) arm 시스템, 어떠한 태스크도 스케줄되지 않은 cpu에서 인터럽트 발생하는 경우?

  • 이미 커널 스레드인 idle 태스크가  스케줄링되어 동작하고 있다. arm의 경우 wfi등이 수행되어 절전 모드로 내부 클럭이 정지된 상태이다.
    • 인터럽트가 발생하는 경우 wfi에 의해 멈춘 cpu가 계속 동작하고 idle 태스크가 소유한 커널 스택을 사용하여 인터럽트 context가 진행된다.
  • idle 태스크는 커널 부트업 시  각 cpu별로 초기화 수행 후 idle 태스크로 이름이 바뀌어 idle 스케줄러에 등록되어 어떠한 태스크도 스케줄되지 않을 때에만 동작하는 특수한 태스크이다.
    • idle 태스크의 pid는 모든 cpu에서 공통으로 0이다.
  • idle 태스크는 부트업시 사용하던 init_task가 나중에 변경된 태스크이므로 인터럽트가 발생하면 레지스터 백업은 이 스택을 사용한다.

참고