운영체제의 구성: user mode와 kernel mode
운영체제는 하드웨어 자원을 효율적으로 관리하고 자원에 대한 인터페이스를 제공한다. 그렇기 때문에 운영체제는 하드웨어와 어플리케이션 사이에서 동작한다. 다음은 운영체제의 기본적인 구성이다.
커널은 하드웨어 자원 직접 관리하고 어플리케이션은 시스템 콜을 통해 커널에 자원을 요청한다.
어플리케이션은 보통 user mode에서 동작하며 시스템 자원에 접근할 수 있는 권한이 제한되어있다. 시스템 자원에 접근할 때 어플리케이션은 시스템 콜을 통해 kernel mode로 전환된다. kernel mode에서 운영체제는 요청된 자원을 처리하고 다시 user mode로 돌아 간다.
운영체제에서 커널 모드와 사용자 모드는 시스템의 안전성을 보장하고 자원을 효율적으로 관리하기 위해 도입되었다.
- CPU에서 지원하는 기능 차이
- X86 아키텍처의 경우, CPU는 Ring 모드를 지원한다.
- 커널 모드: Ring 0 (또는 Ring 1)
- 사용자 모드: Ring 3
- ARM64 아키텍처에서는 EL(예외 레벨, Exception Level)로 구분된다.
- 커널 모드: EL1
- 사용자 모드: EL0
- X86 아키텍처의 경우, CPU는 Ring 모드를 지원한다.
Process
작업을 실행하는 단위이다.
- program: disk 등에 저장되어있는 실행 파일
- process: program이 실행되어 메모리 상에 존재하는 형태
- 윈도우에서 작업관리자로 확인할 수 있는 형태, 리눅스에서의 ps afx로 확인할 수 있는 형태
가상 주소 공간
가상주소는 실제 메모리 상의 주소가 아닌 가상의 주소를 사용하는 것이다. 프로세서는 메모리 위치를 읽거나 쓸 때 가상 주소를 사용한다. 이 작업 중에 프로세서는 가상 주소를 실제 주소로 변환한다.
가상 주소를 사용하여 메모리에 액세스하는 데는 여러가지 이점이 있다.
- 프로그램은 연속된 가상 주소 범위를 사용하여 실제 메모리에 있는 크고 비연속적인 메모리 버퍼에 액세스할 수 있다.
- 프로그램은 가상 주소 범위를 이용하여 실제 메모리보다 큰 메모리 버퍼에 액세스할 수 있다.(실제 메모리가 부족하면 디스크에 저장한다.)
- 다른 프로세서에서 사용하는 가상 주소는 격리된다.
프로세스에서 사용할 수 있는 가상 주소 범위를 프로세스의 가상 주소 공간이라고 한다. 각 user mode process는 자체 개인 가상 주소 공간을 갖는다.
- 32bit process는 일반적으로 0x00000000부터 0x7FFFFFFF까지 2기가바이트 범위 내의 가상 주소 공간을 가진다.
- 64비트 Windows에서 실행되는 64비트 프로세스는 0x000'00000000부터 0x7FFF'FFFFFFFF까지 128테라바이트 범위 내의 가상 주소 공간을 가진다.
가상 주소 범위는 때때로 가상 메모리 범위라고도 한다.
이 다이어그램은 두 개의 64비트 프로세스(Notepad.exe와 MyApp.exe)의 가상 주소 공간과 실제 메모리 페이지 간의 매핑을 보여준다. 각 프로세스는 독립적인 가상 주소 공간을 가지고 있으며, 서로 다른 실제 메모리 페이지에 매핑된다.
- 가상 주소 공간의 독립성:
- 각 프로세스(Notepad.exe와 MyApp.exe)는 독립적인 가상 주소 공간을 가지고 있으며, 0x000'0000000에서 0x7FF'FFFFFFFF까지의 범위를 사용
- 이는 각 프로세스가 동일한 가상 주소를 사용할 수 있지만, 실제로는 서로 다른 물리적 메모리 페이지에 매핑될 수 있음을 의미
- 비연속적인 실제 메모리 매핑:
- Notepad.exe의 세 개의 연속된 가상 주소 페이지(0x7F7'93950000, 0x7F7'93951000, 0x7F7'93952000)는 서로 연속적이지만, 실제 물리 메모리에서는 비연속적인 페이지들(33CE0000, 63A20000, AFDA0000)에 매핑된다.
- 이는 가상 메모리 관리의 일반적인 특징으로, 가상 주소가 물리 메모리의 실제 연속성과 일치하지 않을 수 있음을 보여줌
- 프로세스 간의 가상 주소 공간 격리:
- Notepad.exe와 MyApp.exe 모두 가상 주소 0x7F7'93950000을 사용하고 있지만, 각각의 가상 주소는 서로 다른 물리적 메모리 페이지에 매핑됨
32비트 Windows 운영체제의 가상 주소 분할 방식을 알아보자.
- 사용자 모드와 커널 모드
- 사용자 모드: 응용 프로그램이 실행된다. 이 모드에서는 시스템 공간에 접근할 수 없으며, 사용자 공간만 접근 가능하다.
- 커널 모드: 핵심 운영 체제 구성 요소와 많은 드라이버가 실행된다. 커널 모드는 사용자 공간과 시스템 공간 모두에 접근할 수 있다.
- 가상 주소 공간의 분할 (32비트 Windows 기준)
- 32비트 시스템에서 가용한 총 가상 주소 공간은 4GB다(2^32 바이트).
- 사용자 공간: 하위 2GB 주소 공간(0x00000000 ~ 0x7FFFFFFF)이 사용자 공간으로 할당됨. 각 사용자 모드 프로세스는 독립적인 가상 주소 공간을 가지며, 다른 프로세스의 주소 공간과 격리된다.
- 시스템 공간: 상위 2GB 주소 공간(0x80000000 ~ 0xFFFFFFFF)은 모든 커널 모드 코드가 공유하는 시스템 가상 주소 공간으로 할당됨.
- 사용자 공간 확장 옵션
- 32비트 Windows에서 사용자 공간을 2GB 이상으로 확장할 수 있다. BCDEdit /set increaseuserva 명령어를 사용하여 최대 3GB까지 사용자 공간을 확장할 수 있다. 이 경우 시스템 공간은 1GB로 줄어든다.
- 이 기능은 특정 응용 프로그램이 더 많은 메모리를 필요로 할 때 유용하지만, 시스템 공간이 줄어들어 커널의 메모리 가용량이 줄어드는 단점이 있다.
- 64비트 Windows에서의 가상 주소 공간
- 64비트 Windows에서는 가상 주소 공간의 이론적 크기가 16엑사바이트(2^64 바이트)에 달하지만, 실제로는 이 전체 범위를 사용하지 않고 일부만 사용한다. 이는 물리적 메모리와 제약 조건에 따라 제한된다.
- 커널 모드에서의 사용자 공간 접근 주의사항
- 커널 모드 드라이버가 사용자 공간 주소에서 데이터를 읽거나 쓸 때 주의가 필요하다.
- 예를 들어, 사용자 모드 프로그램이 장치 드라이버에 데이터를 요청하고, 커널 모드에서 읽기 작업을 시작하여 데이터를 사용자 프로그램이 제공한 주소로 보내는 경우, 프로세스 전환에 따른 주소 공간의 변화를 고려해야 한다.
- 특정 사용자 모드 프로세스가 제공한 주소는 해당 프로세스의 주소 공간에만 유효하며, 이후 다른 프로세스에서 동일한 주소에 접근할 수 없기 때문에 커널 모드 드라이버는 인터럽트 처리 시 현재 프로세스와 요청을 보낸 프로세스가 다를 경우 데이터 쓰기를 수행하지 않아야 한다.
System Call
system call은 어플리케이션과 리눅스 커널 사이의 인터페이스이다. 그럼 어떤 종류의 시스템 콜이 있는지 확인해보자.
$ man syscalls
SYSCALLS(2) Linux Programmer's Manual SYSCALLS(2)
NAME
syscalls - Linux system calls
SYNOPSIS
Linux system calls.
DESCRIPTION
The system call is the fundamental interface between an application and the Linux kernel.
System calls and library wrapper functions
System calls are generally not invoked directly, but rather via wrapper functions in glibc (or perhaps some other library). For details of direct invocation of a system call, see intro(2). Often, but not always,
the name of the wrapper function is the same as the name of the system call that it invokes. For example, glibc contains a function chdir() which invokes the underlying "chdir" system call.
Often the glibc wrapper function is quite thin, doing little work other than copying arguments to the right registers before invoking the system call, and then setting errno appropriately after the system call has
returned. (These are the same steps that are performed by syscall(2), which can be used to invoke system calls for which no wrapper function is provided.) Note: system calls indicate a failure by returning a nega‐
tive error number to the caller on architectures without a separate error register/flag, as noted in syscall(2); when this happens, the wrapper function negates the returned error number (to make it positive),
copies it to errno, and returns -1 to the caller of the wrapper.
Sometimes, however, the wrapper function does some extra work before invoking the system call. For example, nowadays there are (for reasons described below) two related system calls, truncate(2) and truncate64(2),
and the glibc truncate() wrapper function checks which of those system calls are provided by the kernel and determines which should be employed.
description에 따르면 시스템 콜은 일반적으로 직접 호출되지 않고, glibc(또는 다른 라이브러리)의 래퍼 함수를 통해 호출되며, 시스템 콜을 호출하기 전에 인자를 올바른 레지스터에 복사하고, 시스템 콜이 반환된 후에 errno를 적절하게 설정하는 등의 작업만 수행한다. (이 작업은 syscall(2)에서도 수행되며, 래퍼 함수가 제공되지 않는 시스템 호출을 호출할 때 사용될 수 있다.)
좀 더 아래로 내리면 system call 리스트를 확인할 수 있다.
System call Kernel Notes
───────────────────────────────────────────────────────────────────────
_llseek(2) 1.2
_newselect(2) 2.0
_sysctl(2) 2.0 Removed in 5.5
accept(2) 2.0 See notes on socketcall(2)
accept4(2) 2.6.28
access(2) 1.0
acct(2) 1.0
add_key(2) 2.6.10
adjtimex(2) 1.0
alarm(2) 1.0
alloc_hugepages(2) 2.5.36 Removed in 2.5.44
arc_gettls(2) 3.9 ARC only
arc_settls(2) 3.9 ARC only
arc_usr_cmpxchg(2) 4.9 ARC only
arch_prctl(2) 2.6 x86_64, x86 since 4.12
atomic_barrier(2) 2.6.34 m68k only
atomic_cmpxchg_32(2) 2.6.34 m68k only
bdflush(2) 1.2 Deprecated (does nothing)
since 2.6
bind(2) 2.0 See notes on socketcall(2)
bpf(2) 3.18
brk(2) 1.0
breakpoint(2) 2.2 ARM OABI only, defined with
__ARM_NR prefix
linux/include/uapi/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_io_submit 2
__SC_COMP(__NR_io_submit, sys_io_submit, compat_sys_io_submit)
...
printf의 system call 분석
#include <stdio.h>
int main() {
printf("hello world!\n");
return 0;
}
간단히 hello world를 출력하는 프로그램을 컴파일 후 system call을 확인해보자.
$ gcc -o a.out temp.c; strace ./a.out
execve("./a.out", ["./a.out"], [/* 19 vars */]) = 0
brk(0) = 0x1bf7000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4233b1f000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=32285, ...}) = 0
mmap(NULL, 32285, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f4233b17000
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\31\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=1819320, ...}) = 0
mmap(NULL, 3933368, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f423353e000
mprotect(0x7f42336f5000, 2093056, PROT_NONE) = 0
mmap(0x7f42338f4000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1b6000) = 0x7f42338f4000
mmap(0x7f42338fa000, 17592, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f42338fa000
close(3) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4233b16000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4233b15000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4233b14000
arch_prctl(ARCH_SET_FS, 0x7f4233b15700) = 0
mprotect(0x7f42338f4000, 16384, PROT_READ) = 0
mprotect(0x600000, 4096, PROT_READ) = 0
mprotect(0x7f4233b21000, 4096, PROT_READ) = 0
munmap(0x7f4233b17000, 32285) = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 9), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4233b1e000
write(1, "hello world!\n", 13hello world!
) = 13
exit_group(0) = ?
- 프로그램 실행
execve("./a.out", ["./a.out"], [/* 19 vars */]) = 0
- 다이나믹 링커 로드
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
mmap(NULL, 32285, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f4233b17000
close(3) = 0
- 라이브러리 로드
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\31\2\0\0\0\0\0"..., 832) = 832
...
mmap(NULL, 3933368, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f423353e000
close(3) = 0
- write system call 실행 후 프로그램 종료
write(1, "hello world!\n", 13hello world!
) = 13
exit_group(0) = ?
strace 를 통해 write 함수의 시스템 콜을 확인해보자. printf와 동일할 것이다.
printf()는 단순히 시스템 호출인 write() 함수를 보다 편리하게 사용할 수 있도록 만들어진 함수일 뿐이다.
$ vi write_test.c
void main() {
write(1, "HACK\n", 5);
}
$ make write_test
$ strace ./write_test
execve("./write_test", ["./write_test"], 0x7ffdd6917ec0 /* 36 vars */) = 0
brk(NULL) = 0x55d755ef8000
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffd1e1381e0) = -1 EINVAL (Invalid argument)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x79e296cbb000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=66291, ...}, AT_EMPTY_PATH) = 0
mmap(NULL, 66291, PROT_READ, MAP_PRIVATE, 3, 0) = 0x79e296caa000
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0 \0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0"..., 48, 848) = 48
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0I\17\357\204\3$\f\221\2039x\324\224\323\236S"..., 68, 896) = 68
newfstatat(3, "", {st_mode=S_IFREG|0755, st_size=2220400, ...}, AT_EMPTY_PATH) = 0
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
mmap(NULL, 2264656, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x79e296a00000
mprotect(0x79e296a28000, 2023424, PROT_NONE) = 0
mmap(0x79e296a28000, 1658880, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x79e296a28000
mmap(0x79e296bbd000, 360448, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1bd000) = 0x79e296bbd000
mmap(0x79e296c16000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x215000) = 0x79e296c16000
mmap(0x79e296c1c000, 52816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x79e296c1c000
close(3) = 0
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x79e296ca7000
arch_prctl(ARCH_SET_FS, 0x79e296ca7740) = 0
set_tid_address(0x79e296ca7a10) = 50946
set_robust_list(0x79e296ca7a20, 24) = 0
rseq(0x79e296ca80e0, 0x20, 0, 0x53053053) = 0
mprotect(0x79e296c16000, 16384, PROT_READ) = 0
mprotect(0x55d7554d8000, 4096, PROT_READ) = 0
mprotect(0x79e296cf5000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x79e296caa000, 66291) = 0
write(1, "HACK\n", 5HACK
) = 5
exit_group(5) = ?
+++ exited with 5 +++
디버깅을 통해 어떤 작업이 이루어지는지 더 구체적으로 살펴보자.
$ radare2 -d ./write_test
ERROR: bin.relocs and io.cache should not be used with the current io plugin
-- I endians swap.
[0x750fc73cc290]> aaa
INFO: Analyze all flags starting with sym. and entry0 (aa)
INFO: Analyze imports (af@@@i)
INFO: Analyze entrypoint (af@ entry0)
INFO: Analyze symbols (af@@@s)
INFO: Analyze all functions arguments/locals (afva@@@F)
INFO: Analyze function calls (aac)
INFO: Analyze len bytes of instructions for references (aar)
INFO: Finding and parsing C++ vtables (avrr)
INFO: Analyzing methods (af @@ method.*)
INFO: Recovering local variables (afva@@@F)
INFO: Skipping type matching analysis in debugger mode (aaft)
INFO: Propagate noreturn information (aanr)
INFO: Use -AA or aaaa to perform additional experimental analysis
[0x750fc73cc290]> s sym.main
[0x5efe4268b149]> pdf
; DATA XREF from entry0 @ 0x5efe4268b078(r)
┌ 41: int main (int argc, char **argv, char **envp);
│ 0x5efe4268b149 f30f1efa endbr64
│ 0x5efe4268b14d 55 push rbp
│ 0x5efe4268b14e 4889e5 mov rbp, rsp
│ 0x5efe4268b151 ba05000000 mov edx, 5
│ 0x5efe4268b156 488d05a70e.. lea rax, str.HACK_n ; 0x5efe4268c004 ; "HACK\n"
│ 0x5efe4268b15d 4889c6 mov rsi, rax
│ 0x5efe4268b160 bf01000000 mov edi, 1
│ 0x5efe4268b165 b800000000 mov eax, 0
│ 0x5efe4268b16a e8e1feffff call sym.imp.write ; ssize_t write(int fd, const char *ptr, size_t nbytes)
│ 0x5efe4268b16f 90 nop
│ 0x5efe4268b170 5d pop rbp
└ 0x5efe4268b171 c3 ret
[0x5efe4268b149]> db 0x5efe4268b16a
[0x5efe4268b149]> dc
INFO: hit breakpoint at: 0x5efe4268b16a
radare2를 디버거 모드로 열고 전체 탐색한 후 main 함수의 심볼의 system call 이 발생하는 주소에서 브레이크 포인트를 찍어 확인해보자.
visual mode로 진입 후(V!) 확인해보면 엄청 긴 libc command들을 확인할 수 있다.
libc의 많은 커맨드를 내리고 나면 mov eax, esi 와 syscall 명령을 확인할 수 있다.
리눅스에서 write() 시스템 호출은 파일 디스크립터 1(표준 출력)에 데이터를 쓰는 기능을 수행하며, 이는 시스템 호출 번호 1번으로 매핑된다.
즉, mov eax, esi는 eax 레지스터에 write() 시스템 호출 번호(1)를 설정하는 역할을 하며, 그 후 syscall 명령어를 통해 실제로 커널에 요청이 전달된다.
System Call 추가하기
- kernel Makefile구조
- obj-y 변수에 추가된 항목만 커널 바이너리에 포함됨
- 목적파일(*.o)의 경우 해당 코드가 빌드되고 포함됨을의미
- 디렉토리의 경우 해당 디렉토리에 있는 Makefile 의 obj-y 변수 내용이 포함됨을 의미
- obj-$(CONFIG): 해당 config가 y로 지정되어야만 obj-y에 포함된다는 의미
- CONFIG_*은 .config 파일에 선언되어있음
- obj-y 변수에 추가된 항목만 커널 바이너리에 포함됨
- printk
- 리눅스 커널 내에서 로그 메시지를 출력할 때 사용하는 함수로 사용자 공간의 printf 함수와 유사하지만, printk는 커널 내부에서 사용된다. stdio.h 대신 linux/kernel.h에 정의되어 있다
- printf와 거의 동일한 방식으로 사용된다. 단, 커널 로그 레벨을 지정하여 로그 메시지를 더 구체적으로 관리할 수 있다.
KERN_EMERG (0): 시스템 불안정을 나타내며, 가장 높은 긴급도
KERN_ALERT (1): 당장 조치가 필요한 상황
KERN_CRIT (2): 심각한 상황
KERN_ERR (3): 오류가 발생
KERN_WARNING (4): 경고
KERN_NOTICE (5): 일반적인 정보로, 상황을 알리기 위한 메시지
KERN_INFO (6): 참고할만한 정보
KERN_DEBUG (7): 디버깅
EX)
printk("<0> Error occurred!"): 긴급한 오류 메시지
printk(KERN_ERR "Error occurred!"): 심각한 오류 메시지
시스템 콜 함수 정의
시스템 콜은 운영 체제 커널과 사용자 프로그램 간의 인터페이스를 제공하는 중요한 기능이다.
SYSCALL_DEFINE 매크로
시스템 콜 함수를 정의하기 위한 매크로로 SYSCALL_DEFINE 매크로를 사용할 때는, 시스템 콜이 몇 개의 인자를 받는지 명시해야 한다. 예를 들어, SYSCALL_DEFINE2는 두 개의 인자를 받는 시스템 콜을 정의하는 것이다.
(예시: SYSCALL_DEFINE2는 두 개의 인자를 받는 시스템 콜을 정의할 때 사용된다.)
인자: 첫 번째 인자는 시스템 콜의 이름이다. 이후의 인자들은 각 인자의 타입과 이름을 쌍으로 지정한다.
매크로로만 시스템 콜을 정의하는 이유
- 함수만이 아니라, 관련 변수들도 같이 선언해야 하기 때문에
- 아키텍처마다 시스템 콜의 구체적인 형태가 다르기 때문에, 이를 일관되게 처리하기 위해
- (ex: ARM64 아키텍처에서는 arch/arm64/include/asm/syscall_wrapper.h에서 매크로 확인 가능)
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long __arm64_sys##name(const struct pt_regs *regs); \
ALLOW_ERROR_INJECTION(__arm64_sys##name, ERRNO); \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long __arm64_sys##name(const struct pt_regs *regs) \
{ \
return __se_sys##name(SC_ARM64_REGS_TO_ARGS(x,__VA_ARGS__)); \
} \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))
시스템 콜은 아래와 같은 과정으로 추가할 수 있다.
1. code 작성 (하나의 정수형 인자를 받고, "Hello world~! %d" 메시지를 출력한 후, 인자 값에 1을 더한 값을 반환)
#include <linux/kernel.h>
#include <linux/syscalls.h>
SYSCALL_DEFINE1(new_syscall, int, code) {
printk(KERN_INFO "Hello world~! %d", code);
return code + 1;
}
이 코드를 kernel 디렉토리 아래(일반적으로 시스템 콜 관련 코드는 kernel디렉토리 아래에 위치) 에 포함시킨다.
2. Makefile 수정 (파일을 빌드할 때 커널 바이너리에 포함하도록 설정)
obj-y += new_syscall.o
3. SYSCALL_DEFINE
#define __NR_new_syscall 463
__SYSCALL(__NR_new_syscall, sys_new_syscall)
// 마지막 syscall을 1증가시키고 추가한다.
#undef __NR_syscalls
#define __NR_syscalls 464
시스템 콜 함수를 추가후 테스트 해보자.
1. 시스템 콜 번호와 함수 정의
모든 시스템 콜은 고유한 번호를 가진다. 이 번호를 통해 커널은 시스템 콜을 호출할 때 해당 함수로 접근할 수 있다.
#define __NR_new_syscall 451
__SYSCALL(__NR_new_syscall, sys_new_syscall)
#undef __NR_syscalls
#define __NR_syscalls 452
<include/uapi/asm-generic/unistd.h>
시스템 콜 번호와 함수를 _SYSCALL 매크로를 사용하여 정의한다.
새 시스템 콜을 추가할 때는 _NR_syscalls 정의 앞에 번호를 지정하는 것이 일반적이다.
_NR_syscalls는 시스템 콜의 개수를 가리키므로, 새로운 시스템 콜을 추가하면 이 값을 증가시켜야 한다.
2. 시스템 콜 호출
시스템 콜을 추가하면 그 시스템콜을 어플리케이션에서 호출 가능하다.
#include <stdio.h>
#include <unistd.h>
#define __NR_new_syscall 451
int main() {
int result = syscall(__NR_new_syscall, 10);
printf("result : %d\n", result);
return 0;
}
3. 빌드
일반적인 gcc가 아닌 toolchain을 이용해서 빌드해야한다.
- <툴체인 디렉토리>/bin/aarch64-none-linux-gnu-gcc -o syscall_test main.c
4. 빌드한 프로그램을 rootfs의 /usr/bin에 복사
buildroot로 만든 rootfs는 ext4 이미지 형태이므로 mount 가능하다.
sudo mount -o loop <빌드루트 디렉토리>/output/images/rootfs.ext4 /mnt
- -o loop 옵션을 사용하여 루프백 장치로 rootfs 이미지를 /mnt 디렉토리에 마운트
- 이렇게 하면 rootfs 이미지의 파일 시스템이 /mnt 디렉토리를 통해 접근 가능해진다.
sudo cp syscall_test /mnt/usr/bin/
- 빌드한 프로그램(hello)을 rootfs의 /usr/bin 디렉토리로 복사한다. 이는 /mnt/usr/bin/ 경로에 복사하는 방식으로 이루어진다.
- 이 작업을 통해 가상 환경에서 hello 프로그램을 실행할 수 있게 된다.
sync; sudo umount /mnt
- sync 명령어로 파일 시스템 버퍼에 있는 데이터를 디스크에 저장하여 데이터 손실을 방지
- sudo umount /mnt로 마운트 해제하여 작업을 완료
5. qemu로 vm 부팅 후 빌드 프로그램 실행
qemu-system-aarch64 \
-kernel <리눅스 디렉토리>/arch/arm64/boot/Image \
-drive format=raw,file=<빌드루트 디렉토리>/output/images/rootfs.ext4,if=virtio \
-append "root=/dev/vda console=ttyAMA0 nokaslr" \
-nographic -M virt -cpu cortex-a72 \
-m 2G \
-smp 2
커널 디버깅
앞서 qemu를 부팅할 때 arch/arm64/boot/Image라는 이미지 파일을 이용하여 부팅했다. 이 이미지 파일에는 모든 정보가 담긴 것이 아니라 커널 실행에 필요한 코드 정보만 담겨 있다.
vmlinux 파일은 커널의 디버깅 정보, 함수, 변수 이름, 코드 등이 모두 포함되어있는 목적 파일이다.
qemu-system-aarch64 \
-kernel <리눅스 디렉토리>/arch/arm64/boot/Image \
-drive format=raw,file=<빌드루트 디렉토리>/output/images/rootfs.ext4,if=virtio \
-append "root=/dev/vda console=ttyAMA0 nokaslr" \
-nographic -M virt -cpu cortex-a72 \
-m 2G \
-smp 2 \
-s -S
- Image 파일
- Image 파일은 압축되지 않은 커널 이미지로, 커널이 실제로 부팅될 때 사용하는 코드가 포함된 실행 파일
- 커널 실행에 필요한 최소한의 코드만 포함되어 있다.
- qemu나 실제 하드웨어에서 커널을 부팅할 때 이 파일을 사용한다.
- vmlinux 파일:
- vmlinux는 커널 빌드 과정에서 생성되는 목적 파일로, 커널의 디버깅 정보를 포함하고 있다.
- 함수와 변수 이름, 코드, 심볼 정보 등 디버깅에 필요한 모든 정보가 포함되어 있다.
- 개발자는 gdb 같은 디버거와 함께 vmlinux 파일을 사용하여 커널을 디버깅할 수 있다.
gdb를 이용해 다음과 같은 절차로 디버깅해보자.
gdb-multiarch <커널 디렉토리>/vmlinux : 디버깅 및 이름 정보를 디버거에 로드하여 준비
(gdb) target remote :1234 : qemu에서 열어둔 디버깅 포트 1234에 접속
(gdb) break start_kernel : start_kernel은 리눅스 커널에서 제일 먼저 실행되는 C 언어 함수
(gdb) break _do_sys_<시스템콜 함수 이름> : 앞서 실습한 시스템콜을 디버깅 가능함
_do_sys_라는 접두사는 SYSCALL_DEFINE 매크로가 붙여준 것임
(gdb) cont : 브레이크 포인트에 다다를 때까지 실행
먼저 커널 설정을 menuconfig로 아래와 같이 enable시키고 빌드해준다.
- Kernel hacking -> Kernel debugging : 선택
- Kernel hacking -> Compile-time checks and compiler options -> Debugging Information : Rely on the toolchain's implicit default DWARF version
Reference
'System Programming > Linux Device Driver' 카테고리의 다른 글
Linux Device Driver 기초 #4 system daemon과 라이브러리 개발 (0) | 2024.10.20 |
---|---|
Linux Device Driver 기초 #3 Linux Device Driver 추가하기 (0) | 2024.10.13 |
Linux Inside #1 Booting: Bootloader에서 Kernel까지 (0) | 2024.10.01 |
BuildRoot 사용법 요약 (1) | 2024.09.20 |
Linux Device Driver 기초 #1 Linux Build System (0) | 2024.08.11 |