이번에는 64비트 기반 리눅스 커널에서 구동되는 라즈비안에서 시스템 콜 번호를 확인해 보겠습니다.
이를 위해 바이너리 유틸리티를 활용해 libc 라이브러리를 어셈블리 명령어로 분석할 필요가 있습니다.
root@raspberrypi:/usr# find . -name libc.a
./lib/aarch64-linux-gnu/libc.a
root@raspberrypi:/usr# objdump -d ./lib/aarch64-linux-gnu/libc.a > code_libc.c
위 명령어로 '/usr/lib/aarch64-linux-gnu/libc.a' 라이브러리 파일을 어셈블리 코드로 변환하게 됩니다.
x8 레지스터에서 시스템 콜 번호를 지정
__libc_write() 함수를 분석하면 x8 레지스터에 시스템 콜 번호를 지정한다는 사실을
알 수 있습니다.
0000000000000000 <__libc_write>:
0: a9bd7bfd stp x29, x30, [sp, #-48]!
4: 90000003 adrp x3, 0 <__libc_multiple_threads>
8: 910003fd mov x29, sp
c: a90153f3 stp x19, x20, [sp, #16]
10: 93407c13 sxtw x19, w0
14: b9400060 ldr w0, [x3]
18: 35000160 cbnz w0, 44 <__libc_write+0x44>
1c: aa1303e0 mov x0, x19
20: d2800808 mov x8, #0x40 // #64
24: d4000001 svc #0x0
svc 명령어의 윗 부분을 보면 x8 레지스터에 64(0x40)을 지정하는 코드를 볼 수 있습니다.
이어서 __libc_read() 함수를 분석하면 x8 레지스터에 시스템 콜 번호인 63을 지정한다는 사실을
알 수 있습니다.
0000000000000000 <__libc_read>:
0: a9bd7bfd stp x29, x30, [sp, #-48]!
4: 90000003 adrp x3, 0 <__libc_multiple_threads>
8: 910003fd mov x29, sp
...
50: 94000000 bl 0 <__libc_enable_asynccancel>
54: aa1503e1 mov x1, x21
58: 2a0003e3 mov w3, w0
5c: aa1403e2 mov x2, x20
60: aa1303e0 mov x0, x19
64: d28007e8 mov x8, #0x3f // #63
68: d4000001 svc #0x0
svc 명령어의 윗 부분에 있는 'mov x8, #0x3f' 명령어를 보면 x8 레지스터에 64(0x40)을 지정하는 코드를 볼 수 있습니다.
/usr 디렉터리에 있는 헤더 파일을 통해 시스템 콜 번호 알아보기
이번에는 시스템 콜 번호를 소스 코드 분석으로 알아보겠습니다.
시스템 콜 번호는 아래 헤더 파일에서 확인할 수 있습니다.
/usr/include/asm-generic/unistd.h
root@raspberrypi:/usr# vi include/asm-generic/unistd.h
#define __NR_io_setup 0
__SC_COMP(__NR_io_setup, sys_io_setup, compat_sys_io_setup)
#define __NR_io_destroy 1
__SYSCALL(__NR_io_destroy, sys_io_destroy)
...
#define __NR_read 63
__SYSCALL(__NR_read, sys_read)
#define __NR_write 64
__SYSCALL(__NR_write, sys_write)
위 코드와 같이 63번 시스템 콜에 대한 시스템 콜 핸들러는 sys_read,
64번 시스템 콜에 대한 시스템 콜 핸들러는 sys_write란 사실을 알 수 있습니다.
리눅스 커널에서는 시스템 콜 번호를 어떻게 읽을까?
유저 공간에서 x8 레지스터에 시스템 콜 번호를 지정했으면,
커널에서 이를 읽는 코드가 있겠죠. 관련 코드를 분석하겠습니다.
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/entry.S
...
SYM_CODE_START_LOCAL_NOALIGN(el0_sync)
kernel_entry 0
mov x0, sp
bl el0_sync_handler
b ret_to_user
SYM_CODE_END(el0_sync)
EL0에서 svc 명령어를 실행하면 익셉션 벡터로 프로그램 카운터가 브랜치되는데,
이 후 el0_sync 레이블이 실행됩니다.
이어서 el0_sync_handler 함수를 분석하겠습니다.
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/entry-common.c
asmlinkage void noinstr el0_sync_handler(struct pt_regs *regs)
{
unsigned long esr = read_sysreg(esr_el1);
switch (ESR_ELx_EC(esr)) {
case ESR_ELx_EC_SVC64:
el0_svc(regs);
break;
익셉션 신드롬 레지스터를 esr 로컬 변수로 읽은 다음에, 익셉션 클래스를 파싱한 결과에
따라 switch~case 문을 실행합니다.
이어서 el0_svc() 함수를 보겠습니다.
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/entry-common.c
static void noinstr el0_svc(struct pt_regs *regs)
{
enter_from_user_mode();
do_el0_svc(regs);
}
enter_from_user_mode() 함수와 do_el0_svc() 함수를 호출하는데,
do_el0_svc() 함수를 분석하겠습니다.
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/syscall.c
01 void do_el0_svc(struct pt_regs *regs)
02 {
03 sve_user_discard();
04 el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
05 }
04번째 줄을 보면 유저 공간에서 실행된 레지스터를 나타내는 pt_regs 구조체의
regs[8]에 저장된 값을 2번째 인자로 삼아 el0_svc_common() 함수를 호출합니다.
다음으로 el0_svc_common() 함수를 분석하겠습니다.
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/syscall.c
01 static void el0_svc_common(struct pt_regs *regs, int scno, int sc_nr,
02 const syscall_fn_t syscall_table[])
03 {
04 unsigned long flags = current_thread_info()->flags;
05
06 regs->orig_x0 = regs->regs[0];
07 regs->syscallno = scno;
...
08 invoke_syscall(regs, scno, sc_nr, syscall_table);
07번째 줄은 el0_svc_common() 함수의 2번째 인자로 전달된 scno(시스템 콜 번호)를
'regs->syscallno'에 저장하는 동작입니다.
이어서 08번째 줄은 invoke_syscall() 함수를 호출하는 동작인데,
2번째 인자로 scno(시스템 콜 번호)를 전달합니다.
invoke_syscall() 함수의 구현부는 다음과 같습니다.
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/syscall.c
01 static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
02 unsigned int sc_nr,
03 const syscall_fn_t syscall_table[])
04 {
05 long ret;
06
07 if (scno < sc_nr) {
08 syscall_fn_t syscall_fn;
09 syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)];
10 ret = __invoke_syscall(regs, syscall_fn);
11 } else {
12 ret = do_ni_syscall(regs, scno);
13 }
14
15 syscall_set_return_value(current, regs, 0, ret);
16 }
07번째 줄에서는 시스템 콜 번호가 최대 시스템 콜 번호보다 작은지 체크합니다.
이어서 09번째 줄은 시스템 콜 핸들러 함수의 주소가 있는 syscall_table 심벌을 통해
시스템 콜 핸들러 함수의 주소를 syscall_fn 변수로 로딩하는 동작입니다.
마지막으로 10번째 줄은 __invoke_syscall() 함수를 호출해 시스템 콜 핸들러를 호출합니다.
__invoke_syscall() 함수의 구현부를 볼까요?
https://elixir.bootlin.com/linux/v5.10.60/source/arch/arm64/kernel/syscall.c
static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn)
{
return syscall_fn(regs);
}
syscall_fn() 함수를 리턴합니다. 그런데 소스를 서칭하면 syscall_fn() 함수가
어디인지 확인할 수 없는데요.
아래 코드를 보면 그 의문을 풀 수 있습니다.
https://elixir.bootlin.com/linux/v5.13/source/arch/arm64/include/asm/syscall.h
typedef long (*syscall_fn_t)(const struct pt_regs *regs);
extern const syscall_fn_t sys_call_table[];
syscall_fn_t는 시스템 콜 테이블의 구조체이자 시스템 콜 핸들러를 호출하는
함수 포인터 형식의 타입으로 정의돼 있습니다.
정리하면 'return syscall_fn(regs)' 구문을 실행하면 regs 인자와 함께
시스템 콜 핸들러 함수로 분기합니다.
정리
이번 포스트에서는 시스템 콜 번호를 유저 공간인 EL0에서 커널 공간인 EL1으로,
어떤 방식으로 전달하는지 알아봤습니다. 코드 분석 결과 x8 레지스터에 시스템 콜 번호가 전달됩니다.
참고로 Armv7(Aarch32) 기반 리눅스 커널은 r7 레지스터에 시스템 콜 번호를 지정한다는 사실을
함께 기억하면 좋겠습니다.
Written by <디버깅을 통해 배우는 리눅스 커널의 구조와 원리> 저자

최근 덧글