본문 바로가기
Kernel/BPF & Tracing

[Linux Kernel] kprobe와 kretprobe

by hyeyoo 2021. 11. 2.
※ 이 블로그의 글은 글쓴이가 공부하면서 정리하여 쓴 글입니다.
※ 최대한 내용을 검토하면서 글을 쓰지만 틀린 내용이 있을 수 있습니다.
※ 만약 틀린 부분이 있다면 댓글로 알려주세요.

BPF를 공부하다보니 실행될 함수를 등록할 때 kprobe, kretprobe, uprobe, tracepoint, UDST 등의 다양한 도구를 사용한다고 해서 이러한 도구들을 먼저 정리해보려고 한다. 우선 이 글에선 kprobe와 kretprobe를 정리해본다.

kprobe, kretprobe란

kprobe와 kretprobe는 커널 코드에 동적으로 중단점을 삽입하여 사용자가 정의하는 핸들러 함수가 실행되도록 하는 강력한 도구이다.  kprobe는 함수 또는 함수에서 특정 오프셋만큼 떨어진 곳에서 핸들러 함수를 실행하게 해주는 도구이다. kretprobe는 함수가 끝난 후에 핸들러 함수를 실행하게 해주는 도구이다.

struct kprobe

kprobe의 구조체를 간단하게 살펴보자. 꽤나 직관적이기 때문에 kprobe 동작을 이해한 후에는 문제 없이 이해할 수 있을 것이다.

struct kprobe {
        struct hlist_node hlist;

        /* list of kprobes for multi-handler support */
        struct list_head list;

        /*count the number of times this probe was temporarily disarmed */
        unsigned long nmissed;

        /* location of the probe point */
        kprobe_opcode_t *addr;

        /* Allow user to indicate symbol name of the probe point */
        const char *symbol_name;

        /* Offset into the symbol */
        unsigned int offset;

        /* Called before addr is executed. */
        kprobe_pre_handler_t pre_handler;

        /* Called after addr is executed, unless... */
        kprobe_post_handler_t post_handler;

        /* Saved opcode (which has been replaced with breakpoint) */
        kprobe_opcode_t opcode;

        /* copy of the original instruction */
        struct arch_specific_insn ainsn;

        /*
         * Indicates various status flags.
         * Protected by kprobe_mutex after this kprobe is registered.
         */
        u32 flags;
};

여기서 왜 opcode와 ainsn이 별개로 존재하는지 의문이 드는데 그건 아마도 kprobe가 이후에 나올 jump optimization을 하기 전에 먼저 breakpoint를 삽입하고, 최적화가 가능한 경우에만 최적화를 하기 때문인것 같다.

struct kretprobe

struct kretprobe {
        struct kprobe kp;
        kretprobe_handler_t handler;
        kretprobe_handler_t entry_handler;
        int maxactive;
        int nmissed;
        size_t data_size;
        struct freelist_head freelist;
        struct kretprobe_holder *rph;
};

struct kretprobe_instance

kretprobe_instance는 kretprobe에서 리턴 주소를 저장할때 사용된다.

struct kretprobe_instance {
        union {
                struct freelist_node freelist;
                struct rcu_head rcu;
        };
        struct llist_node llist;
        struct kretprobe_holder *rph;
        kprobe_opcode_t *ret_addr;
        void *fp;
        char data[];
};

kprobe의 동작 방식

https://lwn.net/Articles/132196/

kprobe는 커널의 중단점을 삽입할 주소 (함수 + 오프셋)에 존재하는 명령어를 복사해둔 뒤 중단점을 삽입하는 명령어로 덮어씌운다 (i386에서는 int3 명령어로 1바이트이다.) 그럼 i386에서는 int3 명령어를 실행하는 순간 breakpoint exception이 발생해서 kprobe 핸들러로 넘어가게 된다.

 

아, 이때 코드섹션에 쓰기를 한다는게 가능한가 싶지만 커널에서는 가능하다. 애초에 특정 페이지에 쓰기가 가능한지는 페이지 테이블에 설정이 되어있는데, 쓰기가 가능하게 매핑을 한 다음 코드를 덮어씌우고 매핑만 다시 원래되로 돌려놓으면 된다.

 

그럼 kprobe에 등록된, 중단점 이전에 실행되어야 하는 pre_handler를 실행한다. 그 다음 아까 복사된 명령어를 실행한다. 이때 CPU가 명령어를 단 하나만 실행하는 건 jmp 같은 명령어로는 불가능하므로 특수한 명령어를 사용하는데 이것을 single-step이라고 한다. 복사된 명령어를 실행한 후에는 중단점 이후에 실행되어야 하는 post_handler를 실행한다. (이때 debug exception이 발생한다.) post_handler를 실행한 이후에는 probe 이후의 코드가 다시 실행되어서 본래의 함수 흐름으로 돌아간다. 따라서 kprobe를 사용하면 함수 호출마다 적어도 2번의 exception이 발생한다.

kretprobe 동작 방식

kretprobe는 사실 kprobe로 구현이 된다. 이후에 나올 jump optimization를 사용하는 경우에는 1바이트보다 많은 메모리를 필요하므로 함수의 완전히 끝 부분에 중단점을 삽입할 수가 없다.

 

따라서 kretprobe는 함수가 끝날 때의 동작을 등록하기 위해 함수의 시작 부분 코드에 kprobe를 등록하여 함수의 리턴 주소를 트램펄린이라는 공간의 주소로 덮어씌운다. (트램펄린은 일련의 nop 명령어로 이루어져있는 코드 영역으로, 리눅스가 시작할 때 kprobe가 등록한다.)그럼 리턴 주소가 덮어씌워진 함수는 리턴할 때 트램펄린으로 리턴한다. 트램펄린으로 이동한 후에는 사용자가 등록한 핸들러 함수가 실행된다.

 

참고로 kretprobe는 리턴 주소를 kretprobe_instance에 저장하는데, kretprobe_instace의 개수는 사용자가 kretprobe를 등록할 때 maxactive라는 파라미터로 정해줄 수 있다. 예를 들어서 중단점이 스핀락 내부에 있다면 동시에 2개의 중단점이 실행될 수 없으므로 maxactive는 1이면 충분하다. 그 외에는 보통 선점 가능한 커널이냐에 따라서 NR_CPUS나 2*NR_CPUS를 기본값으로 정해준다. 만약 maxactive가 작아서 kretprobe_instance가 부족하다면 일부는 누락되는데, 이것은 kretprobe 구조체의 nmissed에 기록된다.

 

kretprobe로 중단점을 등록할 때는 함수가 끝날 때 뿐만 아니라 시작할 때도 등록할 수 있다. kretprobe 구조체의 entry_handler를 지정해주면 된다.

Jump Optimization

kprobe는 pre_handler를 실행할 때의 breakpoint exception과 post_handler를 실행한 이후의 debug exception으로 함수가 실행될 때마다 2번의 exception을 처리해야하는 오버헤드가 존재한다. 따라서 이러한 오버헤드를 줄이고자 int3처럼 exception을 발생하는 것이 아니라 jump문으로 덮어씌워서 오버헤드를 줄이고자 한다.

Safety Check

그런데 모든 상황에서 최적화가 가능한 것은 아니다. 최적화를 한 후에 커널이 크래시가 나면 안되기 때문에 kprobe는 re 안정성을 먼저 체크해야한다. 크게 두 가지의 안정성 체크를 한다. 하나는 kprobe로 코드를 덮어쓰는 범위가 함수의 크기를 넘어가지 않는지를 확인한다. jump optimization을 사용하는 경우에는 1바이트를 넘는 크기를 덮어쓰기 때문에, 함수의 마지막 몇 바이트에는 안정성 체크를 통과하지 못하므로 최적화를 할 수 없다.

 

두 번째는 jump optimization을 사용했을 때는 여러 바이트를 덮어씌는데, 이것을 optimized region이라고 하자. kprobe는 optimized region의 중간 지점으로 점프하는 코드가 없는지를 확인한다. 왜냐하면 중간 지점으로 점프할 경우, 해당 주소에 있는 것이 코드가 아닐 수 있기 때문이다. 따라서 함수 내에 probe를 등록하는 지점 근처로 점프하는 명령어가 있다면 등록할 수 없다.

Detour Buffer

안정성 체크가 끝나면 kprobe는 최적화에 사용할 detour buffer를 준비한다.  (detour는 우회로라는 뜻이다. ) detour buffer에는 다음의 항목이 순서대로 들어있다.

 

- CPU 레지스터를 저장하는 코드

- 트램펄린 코드로 이동하는 코드

- CPU 레지스터를 복구하는 코드

- 최적화 하느라 덮어쓴 영역에 원래 있었던 코드

- 기존의 실행 경로로 돌아가는 코드

Pre-Optimization

detour buffer를 준비한 이후 최적화를 하기 전에 kprobe는 다음의 항목을 확인한다. 셋중 하나라도 거짓인 경우에는 최적화를 하지 않는다.

 

- probe가 post_handler를 등록하지 않는다.

- 최적화된 코드에 대한 probe가 존재하지 않는다.

- probe가 활성화된 상태이다.

 

물론 비활성화된 probe가 다시 활성화되는 등 조건이 바뀌면 다시 최적화를 진행한다. 최적화가 가능하다는 것이 확인되면 kprobe는 "최적화 리스트"에 최적화를 해야된다는 걸 기억해두고 최적화를 진행한다.

 

만약 최적화 리스트에는 존재하지만 아직 최적화하지 않은 코드가 실행이 되면 single-step을 방지하기 위해 detour buffer에 저장해두었던 "최적화 하느라 덮어쓴 영역에 원래 있었던 코드"를 실행하도록 한다.

Optimization

kprobe는 무작정 코드를 덮어쓰지는 않는다. 먼저 synchronize_rcu()로 현재 코드에 접근하는 CPU가 접근을 모두 끝낼 때까지 기다린다. (문서에는 정확히 나와있지는 않지만 이걸 구현하려면 아마 스케줄링쪽 코드에서 rcu_read_{lock,unlock}을 사용해서 코드에 접근할 것이다.) 이 최적화 방법은 CONFIG_PREEMPT=y에서는 사용할 수 없다. (이 부분도 다시 확인해볼 필요가 있다.) 그 다음에는 text_poke_smp()라는 함수로 코드 영역을 detour buffer의 시작점으로 점프하는 코드로 덮어씌운다. 이때 i386에서는 5바이트를 덮어씌운다.

Unoptimization

만약 최적화 리스트에는 존재하지만 아직 최적화되지 않았는데 최적화를 취소해야 한다면 최적화 리스트에서 사라진다. 그렇지 않은 경우에는 detour buffer로 점프하는 명령어로 덮어썼던 부분을 원래의 코드로 다시 덮어쓴다.

Blacklist

kprobe를 등록해서는 안되는 함수의 경우에는 include/linux/kprobe.h의 blacklist에 함수를 추가하거나, 함수에 NOKPROBE_SYMBOL() 매크로를 사용해야 한다.

Supported Architectures

- i386 (Supports jump optimization)

- x86_64 (AMD-64, EM64T) (Supports jump optimization)

- ppc64

- ia64 (Does not support probes on instruction slot1.)

- sparc64 (Return probes not yet implemented.)

- arm

- ppc

- mips

- s390

- parisc

Configuration

kprobe는 컴파일시 CONFIG_KPROBES을 설정해서 커널에 추가할 수 있다.

함수의 주소를 계산하는 데 CONFIG_KALLSYMS, CONFIG_KALLSYMS_ALL도 필요할 수 있다.

Kprobe Features and Limitations

kprobe는 같은 주소에 여러 개의 probe를 등록할 수 있다. 그런데 post_handler를 갖는 kprobe는 최적화가 불가능하므로 같은 주소에 존재하는 여러 개의 probe중 하나라도 post_handler를 갖는다면 최적화한 것을 다시 취소해야한다. (unoptimization)

 

일반적으로 kprobe는 커널의 거의 모든 코드에 probe를 등록할 수 있다. 인터럽트 핸들러도 가능하다. 다만 kprobe 자체의 코드와, do_page_fault, notifier_call_chain 함수에는 probe를 등록할 수 없다. NOKPROBE_SYMBOL()을 사용하는 함수에도 등록할 수 없다.

 

만약 당신이 probe를 등록하려는 함수가 인라인 함수라면 예상하던대로 동작하지 않을 수 있으므로 미리 확인하자. kprobe는 인라인 함수의 모든 사본에 probe를 삽입하지는 않는다.

 

kprobe의 핸들러 함수는 커널의 자료구조를 수정할 수 있다. 따라서 ksplice처럼 버그를 고치거나, fault injection 처럼 오류를 테스트 하는 데에도 사용될 수 있다. (대신 주의해야한다.)

 

그리고 kprobe는 핸들러 함수가 probe를 등록한 함수의 호출을 막지 않는다. 예를 들어서 printk에 probe를 등록했는데 핸들러 함수가 다시 printk를 호출하는 경우가 있을 수 있다. 이럴 땐 핸들러 함수가 호출한 printk에 대해서는 다시 핸들러 함수가 호출되지는 않는다. 대신 kprobe 구조체의 nmissed가 증가한다.

 

핸들러 함수를 호출하는 동안에는 선점이나 인터럽트가 비활성화된다. 무엇이 비활성화 되느냐는 최적화를 했냐 안했냐, 또는 아키텍처에 따라 다르다. (x86에서는 선점만 비활성화한다.) 무엇을 비활성화했던 간에 핸들러 함수는 sleep을 해서는 안된다.

 

kretprobe를 등록한 함수에서 __builtin_return_address를 호출하면 함수의 원래 리턴 주소가 아니라 트램펄린 코드의 리턴 주소가 반환된다.

 

그리고 kprobe는 오버헤드가 어느정도 있기 때문에 성능이 매우 크리티컬한 함수에서는 디버깅용 이상으로는 사용하기 어렵다. production 환경에서 성능 측정을 위해 사용했다가는 수용하기 힘든 성능 저하를 겪을 수 있다.

 

참고 문서

https://www.kernel.org/doc/Documentation/kprobes.txt

 

An introduction to KProbes [LWN.net]

Did you know...?LWN.net is a subscriber-supported publication; we rely on subscribers to keep the entire operation going. Please help out by buying a subscription and keeping LWN on the net. April 18, 2005 This article was contributed by Sudhanshu Goswami

lwn.net

 

[Linux] kprobes 동작 방식 (1)

Linux: 2.6.34arch: x86_64kprobes는 커널 코드에 원하는 작업을 동적으로 추가할 수 있는 강력한 기법이다.kprobes는 커널 설정 시 CONFIG_KPROBES 옵션을 선택하면 사용할 수 있으며이를 이용하면 원하는 위치

egloos.zum.com

 

[Linux] kprobes 동작 방식 (3)

 

studyfoss.egloos.com

 

'Kernel > BPF & Tracing' 카테고리의 다른 글

[Linux Kernel] BPF/eBPF란 무엇인가  (0) 2021.10.31

댓글