리눅스 기본에 대해서 공부하면서 리눅스가 커널을 기준으로 위아래가 분리된 아키텍처로 구성되어 있다는 것을 파악했다. 그리고 유저 프로세스에서는 직접 하드웨어나 자원을 건드리지 못하고, 커널에게 시스템콜을 요청해서 자원에 액션을 취할 수 있다는 것까지 파악했다. 이런건 이제 아주 명쾌하지!
ㅤ
그 과정에서 혹시 이 코드를 내가 따라가볼 수 있나 궁금해서 열어봤다가, 작게 하나의 소제목으로 넣으려고 했던 내용이 너무 길어지는 것 같아서 아예 새로운 글로 빼버렸다.
ㅤ

ㅤ
그렇다면 내가 C로 작성한 실행파일에서 실제로 이 시스템콜 호출 함수를 사용했을 때, 내려가는 과정을 볼 수 있을까? 가 궁금해서 한 번 따라가봤다.
ㅤ
User Process인 C 파일부터 시작
사용자 쪽에서 아래 코드로 작성된 프로그램을 실행했다고 하자. 그럼 어떤 과정을 거쳐서 이게 실행될까?
#include <unistd.h>
int main() {
char* message = "Hello, Telechips!\n";
write(1, message, 18); // System Call이 호출되는 부분
return 0;
}
ㅤ
먼저 코드에서 write 함수를 호출한다
write(1, message, 18);
ㅤ
System Library 로 내려가자 - glibc 시스템 라이브러리 호출
- unistd 라이브러리에 있는
write()의 wrapper 함수를 호출한다.
ssize_t write(int, const void *, size_t);
ㅤ
glibc의 코드를 살펴볼 수 있는 사이트에서 한 번 이 내부 코드가 어떻게 구현되어있는지를 확인해봤다. 기준은 라즈베리파이 4에서 돌아가는 시스템 콜을 기준으로 했다.
ㅤ
우선 write.c 에서 확인할 수 있는 바는, write 함수는 내부에서 SYSCALL_CANCEL 이라는 놈을 호출한다. 이게 매크로 함수같아서 열심히 찾아봤다.

// sysdeps/unix/sysv/linux/write.c
ssize_t
__libc_write (int fd, const void *buf, size_t nbytes)
{
return SYSCALL_CANCEL (write, fd, buf, nbytes);
}
libc_hidden_def (__libc_write)
// 여기 weak_alias 덕분에 write(...)를 호출하면 __libc_write(...)로 바뀌어 위 함수가 실행된다.
weak_alias (__libc_write, write)
libc_hidden_weak (write)
ㅤ
그러면 SYSCALL_CANCEL 을 호출하고, 여기 내부에서 INLINE_SYSCALL_CALL 으로 바뀌어 호출해준다.
// sysdeps/unix/sysdep.h
#define SYSCALL_CANCEL(...) \
({ \
long int sc_ret; \
if (SINGLE_THREAD_P) \
sc_ret = INLINE_SYSCALL_CALL (__VA_ARGS__); \
else \
{ \
int sc_cancel_oldtype = LIBC_CANCEL_ASYNC (); \
sc_ret = INLINE_SYSCALL_CALL (__VA_ARGS__); \
LIBC_CANCEL_RESET (sc_cancel_oldtype); \
} \
sc_ret; \
})
// 지금까지 변환된 코드
INLINE_SYSCALL_CALL (write, fd, buf, nbytes)
동일한 파일에서, 이 INLINE_SYSCALL_CALL 이 어떻게 변환되는지에 대한 과정이 아래에 나와있다.
// sysdeps/unix/sysdep.h
#define INLINE_SYSCALL_CALL(...) \
__INLINE_SYSCALL_DISP (__INLINE_SYSCALL, __VA_ARGS__)
#define __INLINE_SYSCALL_NARGS_X(a,b,c,d,e,f,g,h,n,...) n
#define __INLINE_SYSCALL_NARGS(...) \
__INLINE_SYSCALL_NARGS_X (__VA_ARGS__,7,6,5,4,3,2,1,0,)
#define __INLINE_SYSCALL_DISP(b,...) \
__SYSCALL_CONCAT (b,__INLINE_SYSCALL_NARGS(__VA_ARGS__))(__VA_ARGS__)
#define __SYSCALL_CONCAT(a,b) __SYSCALL_CONCAT_X (a, b)
#define __SYSCALL_CONCAT_X(a,b) a##b
#define __INLINE_SYSCALL3(name, a1, a2, a3) \
INLINE_SYSCALL (name, 3, a1, a2, a3)
// 지금까지 변환된 코드
INLINE_SYSCALL (write, 3, fd, buf, nbytes)
INLINE_SYSCALL_CALL → __INLINE_SYSCALL_DISP → __INLINE_SYSCALL3 로 변환된다.
ㅤ
여기에서 인자의 개수에 따라서
__INLINE_SYSCALLn처럼 이름을 바꿔주는 너무 놀라운 기법을 사용하고 있다. 사실 매크로를 따라가면서 뭐 이렇게 복잡한 구조로 만들었을까 생각했는데, 위 매크로를 통과하면 뒤에 인자의 개수가 붙어서 딱 원하는 함수를 호출할 수 있게 된다. 나름 머리를 엄청 쓴 코드같아서 놀라웠다.
ㅤ
그러면 이제 INLINE_SYSCALL 이 INTERNAL_SYSCALL 호출이 된다. INTERNAL_SYSCALL 에서는 SYS_ify 라는 녀석을 통해 시스템콜 이름이였던 ‘write’을 시스템콜 번호로 변경해준다. 그리고 인자들을 넘겨서 INTERNAL_SYSCALL_RAW 를 호출한다.
// sysdeps/unix/sysv/linux/arm/sysdep.h
#undef INLINE_SYSCALL
#define INLINE_SYSCALL(name, nr, args...) \
({ unsigned int _sys_result = INTERNAL_SYSCALL (name, , nr, args); \
if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (_sys_result, ), 0)) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (_sys_result, )); \
_sys_result = (unsigned int) -1; \
} \
(int) _sys_result; })
#undef SYS_ify
#define SYS_ify(syscall_name) (__NR_##syscall_name)
#undef INTERNAL_SYSCALL
#define INTERNAL_SYSCALL(name, err, nr, args...) \
INTERNAL_SYSCALL_RAW(SYS_ify(name), err, nr, args)
// 지금까지 변환된 코드
INTERNAL_SYSCALL_RAW(__NR_write, , 3, fd, buf, bytes)

마지막에 __NR_write 를 만들어줬다. 각 시스템콜마다 고유 번호를 붙여둬서 커널에게 어떤 동작을 수행할 지 알려줄 수 있다. 드디어! write에 해당하는 시스템콜은 4번이라고 되어있는 것을 확인할 수 있다.
ㅤ
자 이제 드디어 뭔가 매크로가 끝나고 어셈블리 코드가 왔다. 여기에서 Thumb 모드인지, 아니면 arm 모드인지를 보고 코드를 따로 처리해주고 있다. 우리의 32bit 라즈베리파이 OS는 Thumb 모드로 처리될 확률이 높다고 하기에, thumb 모드의 항목으로 분석해보려고 한다.
// 라즈베리파이 4
// 32bit 운영체제 -> thumb 모드 사용할 가능성이 매우 높음
#if defined(__thumb__)
# undef INTERNAL_SYSCALL_RAW
# define INTERNAL_SYSCALL_RAW(name, err, nr, args...) \
({ \
register int _a1 asm ("a1"); \
int _nametmp = name; \
LOAD_ARGS_##nr (args) \
register int _name asm ("ip") = _nametmp; \
asm volatile ("bl __libc_do_syscall" \
: "=r" (_a1) \
: "r" (_name) ASM_ARGS_##nr \
: "memory", "lr"); \
_a1; })
#else /* ARM */
...
#endif
ㅤ
여기에서부터는 약간 난잡해지기 시작해서, 클로드의 도움을 받았다. 주석을 달고나니 이제는 내가 알고있는 내용들이 눈에 좀 들어오는 듯 해서, 이해하기가 한결 수월했다.
// 지금까지 변환된 코드
({
// 1. 레지스터 변수 선언 (GCC 확장)
// _a1 변수를 r0 (a1) 레지스터에 할당. 첫 번째 인자이자 이게 이후에 최종 반환값이 된다.
register int _a1 asm ("a1");
int _nametmp = __NR_write;
// 드디어, 시스템콜 번호를 담는 변수로, 여기에 4가 들어간다.
// 2. LOAD_ARGS_3(fd, buf, bytes) 매크로의 논리적 확장
// 인자들을 임시 변수에 저장하고, 레지스터에 할당함.
int _a3tmp = (int) (bytes);
int _a2tmp = (int) (buf);
int _a1tmp = (int) (fd);
_a1 = _a1tmp; // r0 (a1) = fd (파일 디스크립터)
register int _a2 asm ("a2") = _a2tmp; // r1 (a2) = buf (데이터 버퍼 주소)
register int _a3 asm ("a3") = _a3tmp; // r2 (a3) = bytes (쓸 바이트 수)
// 3. 시스템 콜 번호를 ip 레지스터(r12)에 할당
register int _name asm ("ip") = _nametmp;
// 4. 인라인 어셈블리 실행 (Thumb 모드 헬퍼 호출)
// __libc_do_syscall 함수로 점프하여 시스템 콜 수행.
asm volatile ("bl __libc_do_syscall"
: "=r" (_a1) // 출력 오퍼랜드: r0 (_a1에 결과 저장)
: "r" (_name), // 입력 오퍼랜드: r12 (_name)
"r" (_a2), "r" (_a3) // 입력 오퍼랜드: r1, r2 (나머지 인자)
: "memory", "lr"); // Clobber: 메모리와 링크 레지스터(lr) 변경 명시
// 5. 최종 결과값 반환
_a1;
})
ㅤ
인터럽트에서 따로 HW적으로 저장하던 r12가 이 목적으로 사용되는 녀석이였구나! Intra-Procedure call Scratch Register 라는 이상하고 기묘한 이름이 붙어있는 레지스터였는데, 이런 방식으로 System Call 등의 인자 전달을 위해 사용되나보다.
ㅤ
그렇다면 드디어 __libc_do_syscall 함수가 어떻게 작성되어있는지 확인할 차례!
// sysdeps/unix/sysv/linux/arm/libc-do-syscall.S
#if defined(__thumb__)
.thumb
.syntax unified
.hidden __libc_do_syscall
#undef CALL_MCOUNT
#define CALL_MCOUNT
ENTRY (__libc_do_syscall)
.fnstart
// r7 (시스템 콜 번호 저장용)과 lr (복귀 주소)를 스택에 저장하여 보호
// 앞으로 r7 이랑 lr 레지스터 쓸거니깐 원래 있던 값을 담아두는 것
push {r7, lr}
// CFI(Call Frame Information) 디렉티브: 디버거/언와인딩을 위한 메타데이터
// 없어도 되는 듯?
cfi_adjust_cfa_offset (8)
cfi_rel_offset (r7, 0)
cfi_rel_offset (lr, 4)
// r12 (ip)에 있는 시스템 콜 번호를 r7로 복사
// r7은 ARM 시스템 콜을 위한 공식 레지스터
mov r7, ip
// Software Interrupt 0 명령어 실행.
// CPU 모드를 사용자 모드에서 커널 모드로 전환하고, 시스템 콜 핸들러로 제어를 넘긴다
// 이건 svc 0 이랑 동일한 것임 (ARM-v7부터는 svc 인데, 문서 자체가 옛날꺼라... ㅋㅋ)
// 나는 여기에서 번호로 System Call 번호를 넘겨주는줄 알았는데,
// 시스템 콜 번호는 r7 레지스터로 넘겨주고 여기 인자는 아무 의미가 없다!
swi 0x0
// 시스템 콜 실행 완료 후, 스택에서 r7 값을 복원하고,
// lr에 저장된 복귀 주소(return address)를 pc (프로그램 카운터)로 복원하여
// 호출자(인라인 어셈블리 코드)의 다음 명령어로 돌아간다
pop {r7, pc}
.fnend
END (__libc_do_syscall)
ㅤ
여기까지 하면 HW에게 인터럽트의 발생을 알리는 시스템콜 요청이 이루어진다.
ㅤ
이제부터는 커널 차례 - vector_swi

여기에서부터는 커널의 영역이기에 glibc 라이브러리를 놓아주고 커널 쪽 코드를 확인해야한다. 걱정을 했지만, 과연 교육용 라즈베리파이 답게 리눅스 커널의 소스코드도 모두 공개가 되어있었다. (압도적인 1,325,069 커밋의 위엄 ㄷㄷㄷ) 위 깃허브에서 해당 내용을 확인할 수 있다! 여기에서 내 라즈베리파이에서 사용중인 리눅스 버전인 6.12.xxx 에 해당하는 브랜치를 선택해서 소스코드를 확인해주었다.
ㅤ
다시 돌아가서, 그렇다면 커널쪽에서는 시스템콜이 어떻게 처리가 될까? 이것도 혹시 들여다볼 수 있는지 한 번 까봤다. 이거는 라즈베리파이 OS의 코드를 올려둔 깃허브에서 내가 사용하는 버전에 맞춰 찾아가서 한 번 열어봤다. swi를 호출했기 때문에 이번에는 SV_Handler가 아니라 vector_swi 로 찾아왔다.
/*
* arch/arm/kernel/entry-common.S
*
* SWI handler (시스템 콜 진입점)
*/
.align 5
ENTRY(vector_swi)
#ifdef CONFIG_CPU_V7M
v7m_exception_entry
#else
/*
* 1. 스택에 레지스터 저장을 위한 공간 할당 (Context Saving 시작)
*/
sub sp, sp, #PT_REGS_SIZE /* 스택 포인터(sp)를 PT_REGS_SIZE만큼 감소시켜 공간 확보 */
/* r0 ~ r12 레지스터들을 커널 스택에 저장 (사용자 문맥 저장) */
stmia sp, {r0 - r12}
3:
/*
* 2. sp, lr 레지스터 상태 저장
* ARM 모드와 THUMB 모드에 따라 다르게 처리됨
*/
ARM( add r8, sp, #S_PC )
ARM( stmdb r8, {sp, lr}^ ) /* ARM 모드: sp, lr 레지스터를 스택에 저장 */
THUMB( mov r8, sp )
THUMB( store_user_sp_lr r8, r10, S_SP ) /* THUMB 모드: sp, lr 레지스터 저장 */
/* 3. 프로세서 상태 레지스터 (PSR) 및 PC 저장 */
mrs saved_psr, spsr /* SPSR (이전 모드의 상태)를 r8에 저장 */
TRACE( mov saved_pc, lr )
str saved_pc, [sp, #S_PC] /* 호출자의 복귀 주소(PC)를 스택에 저장 */
str saved_psr, [sp, #S_PSR] /* SPSR 값을 스택에 저장 */
str r0, [sp, #S_OLD_R0] /* r0의 원래 값(시스템 콜의 첫 인자)을 별도로 저장 */
#endif
/*
* 여기서부터는 시스템콜 번호를 추출해서 분배하는 코드로 이어짐
* Get the system call number.
*/
#if defined(CONFIG_OABI_COMPAT)
/* OABI 호환성 설정 시: swi 값을 확인하여 EABI/OABI 호출을 구분 */
#ifdef CONFIG_ARM_THUMB
tst saved_psr, #PSR_T_BIT
movne r10, #0 @ no thumb OABI emulation
USER( ldreq r10, [saved_pc, #-4] ) @ get SWI instruction
#else
USER( ldr r10, [saved_pc, #-4] ) @ get SWI instruction
#endif
ARM_BE8(rev r10, r10) @ little endian instruction
#elif defined(CONFIG_AEABI)
/*
* Pure EABI user space always put syscall number into scno (r7).
* 순수 EABI 환경: 시스템 콜 번호는 r7에 있습니다. (가장 일반적인 경우)
*/
#elif defined(CONFIG_ARM_THUMB)
/* ... 레거시 Thumb ABI 처리 ... */
tst saved_psr, #PSR_T_BIT @ this is SPSR from save_user_regs
addne scno, r7, #__NR_SYSCALL_BASE @ put OS number in
USER( ldreq scno, [saved_pc, #-4] )
#else
/* ... 레거시 ARM ABI 처리 ... */
USER( ldr scno, [saved_pc, #-4] ) @ get SWI instruction
#endif
/* saved_psr and saved_pc are now dead */
uaccess_disable tbl
get_thread_info tsk
/* 시스템 콜 테이블 주소 로드 */
adr tbl, sys_call_table @ load syscall table pointer
// ... (OABI/레거시 ABI 추가 처리 및 유효성 검사 로직 ... )
local_restart:
ldr r10, [tsk, #TI_FLAGS] @ check for syscall tracing
stmdb sp!, {r4, r5} @ push fifth and sixth args
tst r10, #_TIF_SYSCALL_WORK @ are we tracing syscalls?
bne __sys_trace
/* 실제 시스템 콜 호출 (분배) */
invoke_syscall tbl, scno, r10, __ret_fast_syscall
// ... (이후 시스템 콜 복귀 로직으로 이어짐)
invoke_syscall 매크로는 scno (시스템 콜 번호)를 사용하여 tbl (시스템 콜 테이블 주소)에서 실제 커널 함수의 주소를 계산하고 해당 주소로 점프하는 역할. 즉, 이 invoke_syscall 명령어가 실행되고 나면 커널이 write 에 대한 처리를 시작한다.
ㅤ
커널 모듈에서 작업 수행
그렇다면 이 write 함수는 어디에 있느냐? fs/read_write.c 라는 파일에서 찾을 수 있었다. 핸들러가 내부적으로 여기 주소를 찾아와서 호출해주는 방식으로 구현되어있다고 이해했다. 호출되는 ksys_write 를 열어보면 vfs_write 를 호출해주는 것을 볼 수 있고, 그 내부에 들어가보면 이제 파일을 열고 값들을 써주고 있는걸 볼 수 있다.
// fs/read_write.c
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
return ksys_write(fd, buf, count);
}
ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
if (fd_file(f)) {
loff_t pos, *ppos = file_ppos(fd_file(f));
if (ppos) {
pos = *ppos;
ppos = &pos;
}
ret = vfs_write(fd_file(f), buf, count, ppos);
if (ret >= 0 && ppos)
fd_file(f)->f_pos = pos;
fdput_pos(f);
}
return ret;
}
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
if (!(file->f_mode & FMODE_WRITE))
return -EBADF;
if (!(file->f_mode & FMODE_CAN_WRITE))
return -EINVAL;
if (unlikely(!access_ok(buf, count)))
return -EFAULT;
ret = rw_verify_area(WRITE, file, pos, count);
if (ret)
return ret;
if (count > MAX_RW_COUNT)
count = MAX_RW_COUNT;
file_start_write(file);
if (file->f_op->write)
ret = file->f_op->write(file, buf, count, pos);
else if (file->f_op->write_iter)
ret = new_sync_write(file, buf, count, pos);
else
ret = -EINVAL;
if (ret > 0) {
fsnotify_modify(file);
add_wchar(current, ret);
}
inc_syscw(current);
file_end_write(file);
return ret;
}
static inline void add_wchar(struct task_struct *tsk, ssize_t amt)
{
tsk->ioac.wchar += amt;
}
ㅤ
뭐, 함수 이름부터가 파일에 쓰기 이니깐. 여기에서 호출하는 함수들이 파일 읽고 쓰기 모듈의 코드들을 호출해서 작업들을 수행한다고 생각된다. 만약 내가 요청한 내용이 HW를 건드리는 것이였다면
ㅤ
여기에서 SystemCall을 따라가는 과정을 마쳐야겠다.
ㅤ
사실 SYSCALL_DEFINE3를 따라가보려고 했는데, 매크로를 여는 순간 포기했다. 아직 나의 실력이… 크윽…

ㅤ
확실히 리눅스 커널의 구현은 모듈식으로 되어있다는 말 답게, 각각의 시스템 콜을 통해 호출할 수 있는 다양한 기능들이 각 파일로 분리가 되어있었다. 만약 특정 기능을 수정하고 싶다면 해당 파일만 수정해주면 될터이고, 특정 기능들이 불필요해서 빼버리고 용량을 줄이기 위해서는 필요한 파일만 남기고 나머지는 제거해버리면 될터이다! (라고 예상됨)

ㅤ
정리해보자면
무튼 이것도 결국 펌웨어처럼 핸들러를 호출한다. 복잡해보이지만, SysTick Handler를 호출하는거랑 별반 다를 바는 없다!
- 사용자 프로세스
- 사용자 프로그램에서 시스템콜 wrapper 함수 사용 (
write같은거)
- 사용자 프로그램에서 시스템콜 wrapper 함수 사용 (
- 시스템 라이브러리
- 라이브러리에서 내부적으로 레지스터에 값 쇽쇽 담고
__libc_do_syscall를 호출해서 분기 __libc_do_syscall에서swi(또는svc) 를 호출해서 HW 적으로 시스템콜 처리
- 라이브러리에서 내부적으로 레지스터에 값 쇽쇽 담고
- 커널
- 시스템콜 핸들러에서 몇 번 시스템콜인지 판단하고, 테이블에서 시스템콜 핸들러를 찾아 점프
- 커널 모듈
- 해당 위치에 적혀있는대로 각 커널 모듈들을 호출해 로직 처리
- 시스템콜 마무리 + 반환된 결과를 들고 + 레지스터 복원하고 + 다시 원래 실행 위치로 돌아오기.
ㅤ
'Embedded System > Embedded Linux' 카테고리의 다른 글
| [Embedded Linux] Linux 시그널 (0) | 2025.11.29 |
|---|---|
| [Embedded Linux] 리눅스의 프로세스 타파 (0) | 2025.11.27 |
| [Embedded Linux] 라즈베리파이의 부팅 (1) | 2025.11.25 |
| [Embedded Linux] Linux 커널 아키텍처 (1) | 2025.11.25 |
| [Embedded Linux] UNIX, POSIX 그리고 LINUX (1) | 2025.11.23 |