관리 메뉴

Bull

[ARM] test_and_set_bit() : 커널 크래시 덤프 분석 본문

Computer Science/ARM

[ARM] test_and_set_bit() : 커널 크래시 덤프 분석

Bull_ 2024. 8. 19. 19:12

크래시 덤프 분석

크래시가 발생하면 운영체제는 메모리 덤프를 생성한다. 이 덤프 파일은 크래시 당시의 메모리 상태를 기록한 파일로 분석하여 문제의 원인을 파악할 수 있다. 윈도우 시스템에서는 우리가 흔히 아는 Blue Screen of Death(BSOD) 가 있으며 보통 .dmp 파일 형태로 덤프가 저장된다. 리눅스 가은 경우 kexeckdump를 통해 커널 덤프를 생성할 수 있다. /var/log/messages 혹은 /var/log/kern.log 와 같은 로그 파일에서 커널 메시지를 확인할 수 있다.

콜 스택 분석

콜 스택은 크래시 시점의 함수 호출 순서를 나타낸다. 콜스택을 통해 어떤 함수에서 오류가 발생했는지 알 수 있다. 예를 들어 다음과 같은 콜 스택이 있다고 가정하자.

-000|do_DataAbort() 
-001|__dabt_svc(asm) -->|exception 
-002|test_and_set_bit() 
-003|bit_spin_lock(inline) 
-003|hlist_bl_lock(inline)
...

이 콜 스택은 test_and_set_bit() 함수가 호출되었을 때 Data Abort 예외가 발생했음을 알 수 있다. 공부를 하던 도중 나의 지식으로 일반 코드에서 예외(익셉션)를 발생시키기 위한 함수로 비유된 것으로만 보인다. 우선 기초 지식이 없기 때문에 test_and_set_bit() 이 어떻게 생겼고 그 코드만을 먼저 알아보고자 하였다.

test_and_set_bit

이 함수는 리눅스 커널에서 사용되는 유틸리티함수로 특정 비트의 상태를 테스트하고 그 비트를 원자적 작업을 수행한다. 주로 멀티스레드 환경에서 동기화 관련된 작업에 자주 사용되며 목적은 Race Condition을 방지하는 것이다.

static inline int test_and_set_bit(int nr, volatile unsigned long *addr);

nr : 테스트하고 설정할 비트의 인덱스이다.
addr : 비트맵이 저장된 주소이다.

 

함수가 호출되면 addr 이 가리키는 비트맵에 nr 번째에 해당하는 비트를 검사한다. 해당 비트가 0이면 1로 설정 후 0을 반환한다. 1이면 상태를 유지하고 1을 반환한다.

unsigned long bit_map = 0b00000000000000000000000000000000;

위 주소라고 할 때 nr 이 3(0번째부터)이면 설정 후 비트 상태는 0b00000000000000000000000000001000이 된다.

주로 커널 내부에 lock을 구현하거나 여러 스레드가 공유하는 자원에 대한 접근을 제어할 때 사용한다.

define CODE

코드를 제대로 살펴보겠다.
https://elixir.bootlin.com/linux/v6.10-rc1/source/arch/alpha/include/asm/bitops.h#L130에서 확인할 수 있다.

 

bitops.h - arch/alpha/include/asm/bitops.h - Linux source code v6.10-rc1 - Bootlin

/ arch / alpha / include / asm / bitops.h /* SPDX-License-Identifier: GPL-2.0 */ #ifndef _ALPHA_BITOPS_H #define _ALPHA_BITOPS_H #ifndef _LINUX_BITOPS_H #error only can be included directly #endif #include #include /* * Copyright 1994, Linus Torvalds. */ /

elixir.bootlin.com

 

static inline int
test_and_set_bit(unsigned long nr, volatile void *addr)
{
    unsigned long oldbit;
    unsigned long temp;
    int *m = ((int *) addr) + (nr >> 5);

    __asm__ __volatile__(
#ifdef CONFIG_SMP
    "    mb\n"
#endif
    "1:    ldl_l %0,%4\n"
    "    and %0,%3,%2\n"
    "    bne %2,2f\n"
    "    xor %0,%3,%0\n"
    "    stl_c %0,%1\n"
    "    beq %0,3f\n"
    "2:\n"
#ifdef CONFIG_SMP
    "    mb\n"
#endif
    ".subsection 2\n"
    "3:    br 1b\n"
    ".previous"
    :"=&r" (temp), "=m" (*m), "=&r" (oldbit)
    :"Ir" (1UL << (nr & 31)), "m" (*m) : "memory");

    return oldbit != 0;
}

volatile : 컴파일러가 최적화 하지 않도록 한다. 보통 외부에서 메모리 접근을 최적화하기 위해 변수의 값을 레지스터에 저장해두고 메모리에서 값을 다시 읽지 않도록 할 수 있는데 일르 막는 것이다. 메모리의 값이 외부에서 변경할 것이기 때문이다. 컴파일러는 항상 이 변수를 메모리에서 직접 읽게 된다.


static inline : 여러 파일에 같은 함수가 정의되어도, 이 함수가 내부적으로만 사용되고 각 파일에서 독립적으로 인라인화되도록 한다.

local variable

unsigned long oldbit;
unsigned long temp;
int *m = ((int *) addr) + (nr >> 5);

oldbit : 선택된 비트의 기존 값을 저장한다.
temp : 어셈블리 코드에서 임시로 사용할 변수이다.
m : addr 을 시작으로 몇 번째에 속한 바이트가 되는 지 계산한다. nr>>5nr/32로 나눈 몫이다. 예를 들어 64 bit가 있다고 할 때 43번째가 읽고 싶다면 43/32=1 만큼 인덱스를 건너 뛴다. 즉 32bit 단위로 읽고 32+11번째 bit를 읽는다.

| 0000 0000 0000 0000 0000 0000 0000 0000 | | 0000 0000 0001 0000 0000 0000 0000 0000 |
|-------------------0---------------------| |-------------------1---------------------|

*m 은 1이니까 addr을 시작 지점으로 1 인덱스에서 11번째를 읽게 되는 것이다. addr이 어느 메모리에 위치해 있든, 예를들어 343번째 bit도 바꿀 수 있겠지만 특정 범위 이상이 되면 막히지 않을까 생각한다. 왜냐하면 보안상으로 문제가 될 수도 있기 때문이다.

Assembly

#ifdef CONFIG_SMP
    "    mb\n"
#endif

mb : memory barrier로 SMP(Symmetric Multiprocessing, 다중 프로세서 시스템)에서 메모리 접근 순서를 보장하기 위해 사용된다.

"1:    ldl_l %0,%4\n"  // 메모리에서 값을 로드 (ldl_l 명령어는 원자적 로드)
"    and %0,%3,%2\n"  // 특정 비트(`nr`)를 테스트
"    bne %2,2f\n"     // 해당 비트가 1이면 2로 점프

ldl_l %0, %4 : 메모리 위치 m(%4)에서 값을 로드하여 temp(%0)에 저장한다.


and %0, %3, %2 : 로드된 값 temp(%0)와 (1UL << (nr & 31))(%3)과 and 연산 후 결과를 oldbit(%2)에 저장한다.
만약 nr이 3번째라면,
nr & 31 : 0011,
1UL << 0011 : 0110,
temp & 0110 : 0000, oldbit에는 0이 들어간다.


bne %2, 2f : oldbit가 0이 아니면 (비트가 설정되어 있다면) 레이블을 2로 점프한다.

"    xor %0,%3,%0\n"  // 비트를 반전 (설정 또는 해제)
"    stl_c %0,%1\n"   // 설정된 값을 메모리에 저장 (stl_c 명령어는 원자적 저장)
"    beq %0,3f\n"     // 저장 실패 시 레이블 3으로 점프

xor %0,%3,%0 : temp(%0) = (1UL << (nr & 31))(%3) ^ temp (%0).


stl_c %0,%1 : m(%1) = temp(%0), 메모리 위치에 값을 저장한다. 조건부 저장으로 성공하려면 해당 메모리 위치가 다른 프로세서에 의해 수정되지 않은 상태여야한다. 그래서 ldl_l와 함께 사용한다.


beq %0,3f : stl_c 명령어를 실패하면 레이블 3으로 간다.

".subsection 2\n"
"3:    br 1b\n"  // 저장이 실패하면 다시 1번으로 돌아가 반복
".previous"

br 1b : 1번 레이블로 다시 점프하여 과정을 반복한다.


.subsection 2 : 2번 하위 섹션으로 이동시킨다.


.previous : 2번 하위 섹션 반복을 종료하고 원래 코드 흐름으로 되돌린다.

오퍼랜드

__asm__ __volatile__(
    :"=&r" (temp), "=m" (*m), "=&r" (oldbit) // 출력 오퍼랜드
    :"Ir" (1UL << (nr & 31)), "m" (*m) : "memory"); // 입력 오퍼랜드

출력/입력 오퍼랜드를 각 순서대로 %0,%1,%2,%3,%4로 매핑된다. 즉
%0 : temp
%1 : m
%2 : oldbit
%3 : 1UL << (nr & 31)
%4 : m
"memory" : 어셈블리 코드가 메모리 상태를 변경할 수 있음


r : 일반 레지스터(register), 일반적인 CPU 레지스터에 할당되어야함을 의미.
=r : 쓰기 전용 레지스터(출력 오퍼랜드), 쓰기 전용 오퍼랜드로 할당된다.
=&r : 조기 클로버(early clobber) 제약, 해당 오퍼랜드가 어셈블리 명령어가 실행되기 전에 다른 입력 오퍼랜드와 겹쳐서는 안됨을 의미, 데이터 보장, 데이터 무결성을 유지.(출력 오퍼랜드)

 

m : 메모리 오퍼랜드, 해당 오퍼랜드는 메모리 위치를 참조해야함.
Ir: 즉시값(Immediate register) 혹은 레지스터 오퍼랜드로 사용할 수 있음.