저는 보통 저작권이 교수님한테 저당잡혀 있는 학교 과제에 대하여 글을 작성하지 않습니다. 과제중에 유일하게 포스팅을 하고 싶었던 주제는 Shell Lab입니다. 다행히, 이미 Shell Lab은 블로그에 글들이 널린 공공재입니다.
학교 과제를 하면서 Shell Lab이 가장 재미있엇습니다. 우선은 코딩이고, 저같은 레벨의 학부생 수준에서는 Low-Level에서 개발할 일이 별로 없어, 더더욱 재미를 느꼇던 것 같습니다.
미루고 미루다가, 거의 10개월이 지난 지금 포스팅을 합니다. 과제를 다시 읽어보게 되서 설렜습니다.
Shell Lab은 처음 보면 진입장벽이 높아보입니다. 실제로 Shell Lab은 그 수업을 듣는 학생들의 문맥에서는 요구하는 배경지식이 많은 과제입니다. C 기본 문법만 어느 정도 아는 상태에서 갑자기 fork, execve, waitpid, sigprocmask, setpgid, kill 같은 시스템 콜 계열 함수를 한 번에 써야 합니다.
여기서 시스템 콜 계열 함수라는 말은, 일반 프로세스가 리눅스 커널에게 정해진 창구로 작업을 요청한다는 뜻입니다. 프로세스는 유저 모드에서 실행되므로 직접 다른 프로세스를 만들거나, signal을 보내거나, signal handler 정보를 커널에 등록할 수 없습니다. 그래서 fork(), kill(), waitpid(), sigprocmask() 같은 호출을 통해 커널에게 "이 작업을 대신 해달라"고 요청합니다. 글 맨 뒤에 이 syscall 진입 과정도 따로 정리해두겠습니다.
처음에는 "명령어를 파싱해서 실행하면 되는 거 아닌가?"라고 생각하기 쉽습니다. 그런데 Shell Lab의 핵심은 명령어 실행이 아니라, 실행 중인 프로세스를 shell이 어떻게 추적하고, signal이 들어왔을 때 어떤 job에게 전달해야 하는지를 맞추는 부분이었습니다.
이 글은 제가 작성했던 제출 PDF와 최종 tsh.c 구현, 그리고 trace 00~21을 기준으로 Shell Lab을 공략하듯이 정리한 글입니다. 그대로 답을 외우는 글이라기보다는, 어떤 테스트가 무엇을 요구하는지, 그래서 구현을 어떤 순서로 쌓아야 하는지를 보는 글에 가깝습니다.
서론. 이 과제에서 우리가 채울 함수들
Shell Lab을 처음 열면 함수 이름이 한 번에 많이 나옵니다. 여기서 모든 함수를 같은 무게로 보면 구현 순서를 잡기 어렵습니다. 그런데 다시 보면 이 과제는 결국 eval에서 명령어 한 줄을 처리하고, builtin_cmd에서 shell 내장 명령을 처리하고, signal handler에서 child 상태를 정리하는 흐름입니다.
그래서 저는 함수 원형을 먼저 보고 들어가는 게 낫다고 생각했습니다. 아래 함수들이 handout에서 "여기 구현하세요"라고 비워져 있는 핵심 함수들입니다.
void eval(char *cmdline);
int builtin_cmd(char **argv);
void waitfg(pid_t pid, int output_fd);
void sigchld_handler(int sig);
void sigtstp_handler(int sig);
void sigint_handler(int sig);
그리고 이 글에서 나오는 함수 이름은 출처가 다릅니다. 이걸 전부 똑같은 함수콜로 읽으면 구현 경계가 흐려집니다. sigprocmask()를 호출한다는 말과 eval()을 호출한다는 말은, 생긴 건 비슷해도 의미가 다릅니다.
1. 우리가 직접 채우는 함수
eval(), builtin_cmd(), waitfg(), sigchld_handler(), sigint_handler(), sigtstp_handler()처럼 handout이 비워둔 함수입니다. 글의 큰 흐름은 결국 이 함수들의 구현부를 채우는 것입니다.
2. tsh.c 안의 job helper
addjob(), deletejob(), pid2jid(), fgpid(), getjobjid(), getjobpid()처럼 shell이 들고 있는 jobs 배열을 다루는 함수입니다. 이 글에서는 구현부를 같이 살펴볼 tsh.c 내부 helper로 다룹니다. 중요한 건 외부 헤더에서 가져온 함수가 아니라 우리 shell 코드 안의 도우미라는 점입니다.
3. handout이 제공하는 wrapper/helper
Signal(), parseline(), listjobs()처럼 과제 코드가 이미 제공하는 함수입니다. 표준 C 함수처럼 외워야 하는 게 아니라, handout이 shell 구현을 돕기 위해 미리 만들어둔 도구라고 보는 게 편했습니다.
4. 헤더에서 가져와 호출하는 시스템/libc 함수
fork(), execve(), waitpid(), sigprocmask(), kill(), setpgid(), strcmp(), printf() 같은 함수입니다. 우리가 구현하는 함수가 아니라, 헤더를 include하고 OS/libc에게 일을 시키는 호출입니다.
그래서 코드를 읽을 때는 "이 함수가 어디서 온 함수인가?"를 계속 구분하면 좋습니다. eval() 안에서 fork()를 호출한다면, 앞의 eval은 우리가 작성 중인 shell 함수이고, 뒤의 fork는 OS에게 child process를 만들어달라고 요청하는 함수입니다.
여기서 인자를 변수 사전처럼 외우면 별로 도움이 안 됩니다. 함수 호출을 기준으로, 괄호 안에 들어가는 값이 왜 필요한지부터 보는 편이 좋았습니다.
eval(char *cmdline)
cmdline에는 사용자가 입력한 한 줄 전체가 들어옵니다. 예를 들면 ./myspin 5 & 같은 문자열입니다. eval은 이 한 줄을 받아서, builtin인지 외부 프로그램인지, foreground인지 background인지 판단합니다.
builtin_cmd(char **argv)
argv에는 cmdline을 단어 단위로 쪼갠 결과가 들어옵니다. argv[0]은 명령 이름이고, argv[1]부터는 그 명령의 인자입니다. 그래서 quit인지 볼 때는 argv[0]을 보고, fg %1을 처리할 때는 argv[1]의 %1을 봅니다.
waitfg(pid_t pid, int output_fd)
pid는 shell이 기다려야 하는 foreground child의 process id입니다. output_fd는 출력 대상입니다. 보통은 STDOUT_FILENO로 생각하면 되고, handout의 출력 함수들이 어디로 출력할지 맞추기 위해 같이 넘겨줍니다.
sigchld_handler(int sig), sigint_handler(int sig), sigtstp_handler(int sig)
sig에는 OS가 handler를 호출하면서 넘겨준 signal 번호가 들어옵니다. ctrl-c면 SIGINT, ctrl-z면 SIGTSTP, child 상태 변경이면 SIGCHLD입니다. 우리가 직접 이 함수를 평범하게 호출한다기보다, handout이 등록해둔 handler를 OS가 불러주는 구조에 가깝습니다.
그리고 헤더에서 가져와 쓰는 시스템 함수들도 인자를 보고 읽어야 합니다. 예를 들어 execve(argv[0], argv, environ)은 "이 경로의 프로그램을, 이 인자 목록과, 이 실행 환경으로 실행해라"라는 뜻입니다. waitpid(pid, &status, options)는 "이 child의 상태를 status에 받아오되, options 방식으로 기다려라"에 가깝습니다. 이런 식으로 괄호 안을 문장으로 바꾸면 코드의 역할이 또렷해집니다.
이 글에서는 이 함수들을 한 번에 완성하지 않겠습니다. trace가 요구하는 기능에 맞춰 조금씩 키워가는 식으로 보겠습니다. 먼저 quit과 외부 명령 실행을 만들고, 그 다음 background와 jobs, 그 다음 signal과 race, 마지막으로 bg/fg와 process group을 붙입니다.
0. handout에서 이미 해주는 것부터 확인하기
이 구간에서는 trace를 깨기 전에, handout이 이미 어디까지 해주는지부터 보겠습니다. 특히 trace 00의 EOF 처리와 shell의 입력 루프는 직접 새로 만드는 부분이 아니었습니다. 먼저 main이 어떻게 eval까지 연결되는지 보는 게 목표입니다.
이 과제는 빈 C 파일에서 시작하지 않습니다. tsh.c를 열면 이미 코드가 꽤 많습니다. 그래서 먼저 "내가 새로 짜야 하는 것"과 "handout이 이미 해주는 것"을 나누는 게 좋아보였습니다. 안 그러면 EOF 처리나 prompt 출력 같은 곳에서 괜히 시간을 씁니다.
먼저 볼 것. 아래 코드의 Signal은 표준 C 문법이라기보다 handout이 제공하는 포장 함수라고 보면 됩니다. 뜻은 "이 사건이 오면 이 함수를 대신 실행해줘"라고 OS 쪽에 등록하는 것입니다. 예를 들어 사용자가 ctrl-c를 누르면 평소 흐름을 잠깐 멈추고 sigint_handler를 실행합니다. 지금은 구현하지 말고, handout이 이런 연결만 미리 해뒀다고 보면 됩니다.
아래는 main을 읽을 때 제가 먼저 봤던 흐름입니다. 실제 handout에 있는 코드를 전부 외울 필요는 없고, 주석으로 표시한 위치만 잡고 가도 충분했습니다.
int main(int argc, char **argv)
{
char cmdline[MAXLINE];
/*
* 여기까지는 옵션 처리, prompt 설정 같은 준비 코드입니다.
* 과제 초반에는 거의 건드릴 일이 없습니다.
*/
/*
* 이 세 함수의 "등록"은 handout이 이미 해줍니다.
* 우리가 할 일은 아래 handler 함수들의 구현부를 채우는 것입니다.
*
* Signal은 handout wrapper이고,
* sigint_handler 같은 함수는 우리가 채우는 함수입니다.
*/
Signal(SIGINT, sigint_handler); // 사용자가 ctrl-c를 눌렀을 때
Signal(SIGTSTP, sigtstp_handler); // 사용자가 ctrl-z를 눌렀을 때
Signal(SIGCHLD, sigchld_handler); // child 상태가 바뀌었을 때
initjobs(jobs);
while (1) {
printf("%s", prompt);
fflush(stdout);
/*
* 사용자가 입력한 한 줄을 읽습니다.
* shell은 계속 이 루프를 돌면서 한 줄씩 처리합니다.
*/
if ((fgets(cmdline, MAXLINE, stdin) == NULL) && ferror(stdin)) {
app_error("fgets error");
}
/*
* EOF, 즉 입력 끝입니다.
* trace 00은 보통 이 기본 코드가 이미 처리합니다.
*/
if (feof(stdin)) {
fflush(stdout);
exit(0);
}
/*
* 과제의 핵심 진입점입니다.
* 사용자가 친 한 줄이 전부 eval로 들어갑니다.
*/
eval(cmdline);
fflush(stdout);
}
}
그러니까 처음부터 main을 고치려고 달려들 필요는 없었습니다. 과제의 중심은 eval입니다. eval이 입력 한 줄을 받고, 그게 shell 내부 명령인지, 외부 프로그램인지, foreground인지, background인지 판단합니다.
1. builtin_cmd: shell 안에서 직접 처리하는 명령
이번 구간에서는 trace 01의 quit를 처리하고, 이후 trace 05~18에서 사용할 jobs, bg, fg의 자리를 먼저 잡겠습니다. 외부 프로그램을 실행하기 전에, shell이 직접 처리해야 하는 명령부터 걸러야 합니다.
builtin_cmd는 말 그대로 shell에 내장된 명령을 처리하는 함수입니다. 여기서 핵심은 quit, jobs, bg, fg는 외부 프로그램을 실행하는 게 아니라는 점입니다. shell이 자기 상태를 보고 직접 처리해야 합니다.
핵심 개념. 반대로 ./myspin, ./myenv, /bin/echo 같은 것은 shell 내부 명령이 아닙니다. 이런 명령은 새 process를 만든 다음 그 process에서 실행해야 합니다. 이 구분이 잡히면 builtin_cmd가 왜 따로 있는지 바로 보입니다.
int builtin_cmd(char **argv)
{
char *cmd = argv[0];
/*
* 빈 줄이 들어오면 argv[0]이 NULL일 수 있습니다.
* 이걸 먼저 막지 않으면 strcmp에서 바로 터질 수 있습니다.
*/
if (cmd == NULL) {
return 1;
}
/*
* quit은 실행 파일이 아닙니다.
* shell이 직접 종료해야 하는 내장 명령입니다.
*/
if (!strcmp(cmd, "quit")) {
exit(0);
}
/*
* jobs도 실행 파일이 아닙니다.
* shell이 들고 있는 jobs 배열을 출력하는 명령입니다.
*/
if (!strcmp(cmd, "jobs")) {
listjobs(jobs, STDOUT_FILENO);
return 1;
}
/*
* bg와 fg도 shell 내부 명령입니다.
* 다만 초반 trace 00~07에서는 아직 완성하지 않아도 됩니다.
* 나중에 stopped job을 다시 움직일 때 여기 구현을 채웁니다.
*/
if (!strcmp(cmd, "bg")) {
// Part 7에서 구현합니다.
return 1;
}
if (!strcmp(cmd, "fg")) {
// Part 7에서 구현합니다.
return 1;
}
/*
* 여기까지 왔다는 건 builtin이 아니라는 뜻입니다.
* eval이 fork/execve로 외부 프로그램을 실행해야 합니다.
*/
return 0;
}
여기서 반환값을 대충 정하면 뒤에서 계속 꼬입니다. return 1은 "shell 내부에서 처리했으니 eval이 더 할 일이 없다"는 뜻이고, return 0은 "builtin이 아니니 eval이 외부 프로그램으로 실행해야 한다"는 뜻입니다.
2. eval 첫 번째 구현: 외부 프로그램 실행하기
이번 구간은 trace 02~04입니다. ./myenv, ./myspin, /bin/echo 같은 외부 프로그램을 실제로 실행해야 합니다. 이때 shell이 직접 그 프로그램이 되는 게 아니라, child process를 만들고 그 child를 다른 프로그램으로 바꿉니다.
eval은 사용자가 친 한 줄을 처리하는 함수입니다. 처음부터 signal, job control, process group까지 다 보려고 하면 범위가 너무 커집니다. 일단 trace 00~04부터 깨봅시다.
먼저 볼 것. fork()는 일반 함수처럼 보이지만 결과가 좀 특이합니다. 호출 후에 같은 코드가 parent process와 child process에서 이어서 실행됩니다. 대신 반환값이 다릅니다. child는 0을 받고, parent는 child의 pid를 받습니다. 그래서 if (pid == 0) 안쪽은 child만 실행하는 구역입니다. 그 child 안에서 execve를 호출하면, child가 ./myspin 같은 실제 프로그램으로 바뀝니다.
아래 구현은 eval 구현부에 처음 적어볼 만한 형태입니다. 코드 안에는 "왜 이 줄이 여기 들어가는지"를 주석으로 적어뒀습니다.
void eval(char *cmdline)
{
char *argv[MAXARGS];
int background;
pid_t pid;
/*
* parseline은 입력 한 줄을 argv 배열로 바꿉니다.
*
* 예를 들어 사용자가 이렇게 입력하면:
* ./myspin 1
*
* 대략 이렇게 쪼개집니다:
* argv[0] = "./myspin"
* argv[1] = "1"
* argv[2] = NULL
*
* 명령 끝에 &가 있으면 background = 1을 반환합니다.
*/
background = parseline(cmdline, argv);
/*
* 빈 줄이면 실행할 명령이 없습니다.
*/
if (argv[0] == NULL) {
return;
}
/*
* quit, jobs, bg, fg 같은 내장 명령인지 먼저 확인합니다.
* builtin이면 shell이 직접 처리했으므로 여기서 끝냅니다.
*/
if (builtin_cmd(argv)) {
return;
}
/*
* 여기부터는 외부 프로그램입니다.
* shell 자체가 ./myspin으로 변하면 안 되므로 child를 만듭니다.
*/
pid = fork();
if (pid == 0) {
/*
* child process입니다.
* 이 child가 실제 프로그램으로 바뀝니다.
*
* argv[0]: 실행할 파일 경로
* argv: 새 프로그램에게 넘길 인자 배열, 마지막은 NULL이어야 합니다.
* environ: 현재 shell의 환경 변수 배열을 child에게 넘깁니다.
*/
execve(argv[0], argv, environ);
/*
* execve가 성공하면 여기로 돌아오지 않습니다.
* 여기까지 왔다는 건 실행 실패입니다.
*/
printf("%s: Command not found\n", argv[0]);
exit(0);
}
/*
* parent process, 즉 shell입니다.
* foreground 명령이면 shell은 child가 끝날 때까지 기다립니다.
*/
if (!background) {
waitfg(pid, STDOUT_FILENO);
}
}
여러분들이 가장 낯선 부분은 fork 이후 일 겁니다. fork는 코드 흐름을 두 개로 갈라놓습니다. pid == 0인 쪽은 child이고, 그 child에서 execve를 호출합니다. parent shell은 그대로 남아서 기다리거나 다음 명령을 받습니다.
execve(argv[0], argv, environ)에서 세 인자는 각각 의미가 있습니다. argv[0]은 실행할 파일 이름이고, argv는 새 프로그램이 받을 인자 배열입니다. C 문자열 배열이라 마지막이 NULL로 끝나야 합니다. environ도 그냥 장식이 아닙니다. trace 02의 myenv는 환경 변수가 제대로 전달되는지 확인합니다. 그냥 프로그램 파일만 실행한다고 끝나는 게 아니라, 실행 환경도 같이 넘겨야 했습니다.
핵심 개념. 여기서 실행 환경은 환경변수 쪽 이야기입니다. 개발을 조금 해봤다면 PATH, HOME, LANG 같은 값을 들어봤을 겁니다. execve의 세 번째 인자인 environ은 child가 새 프로그램으로 바뀔 때 이런 환경을 같이 넘겨주는 역할을 합니다. 그래서 trace 02는 단순히 프로그램이 켜지는지만 보는 게 아니라, 실행 환경까지 제대로 이어지는지를 보는 테스트였습니다.
3. waitfg: foreground 명령이면 shell이 기다려야 합니다
이번 구간은 trace 03~04에서 foreground 명령의 순서를 맞추는 부분입니다. foreground 프로그램이 끝나기 전에 shell prompt가 다시 나오면 안 됩니다. 그래서 shell은 "앞에서 실행 중인 child가 아직 끝나지 않았는지"를 봐야 합니다.
foreground 명령은 shell이 기다리는 실행입니다. 사용자가 ./myspin 5를 쳤는데 prompt가 바로 돌아오면 잘못된 동작입니다. 실제 shell을 써봤다면, 명령어가 도는 동안 다음 입력이 막히는 그 느낌입니다.
trace 03~04만 볼 때는 아래처럼 waitpid로 시작해도 됩니다. 이 버전은 초반 trace용입니다. waitpid는 단순히 "기다리는 함수"처럼 보이지만, 실제로는 child가 끝났을 때 그 종료 상태를 parent가 받아오는 함수입니다. 이걸 보통 child를 회수한다고 말합니다.
핵심 개념. 프로세스는 끝났다고 바로 완전히 사라지는 게 아니었습니다. parent가 wait 계열 함수로 종료 상태를 받아줘야 정리가 됩니다. 이 과정을 하지 않으면 종료된 child가 좀비 프로세스처럼 남을 수 있습니다. 그래서 "기다린다"와 "회수한다"는 여기서 거의 같이 붙어 다니는 말이었습니다.
void waitfg(pid_t pid, int output_fd)
{
int status;
/*
* 초반 trace용 버전입니다.
* foreground child가 끝날 때까지 parent shell이 기다립니다.
*/
waitpid(pid, &status, 0);
}
그런데 trace 08 이후 SIGCHLD handler를 구현하는 순간부터는 이 방식을 바꿔야 합니다. 나중에는 handler가 child의 종료 상태를 받아서 job list를 정리합니다. 그러면 waitfg가 또 waitpid로 같은 child를 회수하려고 해서 역할이 겹칩니다. 그러니까 이 코드는 "초반 trace를 위한 버전"이고, 최종 제출에서는 아래 방식으로 갈아타는 게 좋습니다.
그래서 최종 형태의 waitfg는 child를 직접 회수하지 않는 쪽이 좋아보였습니다. 대신 jobs 배열을 계속 보면서 "내가 기다려야 하는 pid가 아직도 foreground인가?"만 확인합니다. child가 끝나거나 멈추면 handler가 실행되고, handler가 job list를 고칩니다. 그러면 waitfg의 반복 조건도 자연스럽게 깨집니다.
단, 아래 버전은 eval이 먼저 addjob(jobs, pid, FG, cmdline)으로 foreground job을 등록하는 Part 5 이후에만 맞습니다. 앞쪽 trace 03~04 코드처럼 job list에 넣지 않고 바로 waitfg(pid)를 부르면, fgpid(jobs)가 찾을 job이 없어서 바로 빠져나올 수 있습니다. 이 부분은 꽤 위험한 함정입니다.
핵심 개념. 여기서 중요한 건 코드가 병렬로 동시에 돈다는 뜻이 아닙니다. 더 정확히는 OS가 실행 흐름의 주도권을 잠깐 가져가서 signal handler를 실행시키는 느낌입니다. shell이 while 안에서 기다리고 있어도, ctrl-c, ctrl-z, child 종료 같은 사건이 생기면 OS가 handler 함수로 흐름을 넘깁니다. handler가 jobs 배열을 수정하고 돌아오면, waitfg는 다시 조건을 확인합니다. 이 흐름에서는 handler가 공유 상태를 바꾸는 타이밍을 특히 조심해야 합니다.
void waitfg(pid_t pid, int output_fd)
{
/*
* 최종 사고방식입니다.
*
* child를 직접 waitpid로 회수하지 않습니다.
* child의 종료/정지는 sigchld_handler가 처리합니다.
*
* waitfg는 "아직 이 pid가 foreground job인가?"만 확인합니다.
*/
while (fgpid(jobs) == pid) {
usleep(1);
}
}
그리고 usleep(1)로 계속 확인하는 방식은 과제 trace를 깨기 위한 간단한 polling 구현에 가깝습니다. 더 정석적인 signal 대기 구조를 공부한다면 sigsuspend로 SIGCHLD를 기다리는 방식까지 봐야 합니다. 여기서는 Shell Lab 공략 흐름을 먼저 잡기 위해 단순 버전으로 설명했습니다.
4. job_t와 helper: shell이 process를 기억하는 방식
background로 넘어가기 전에 jobs 배열을 한 번 보고 가는 게 좋습니다. 이 위치가 적당하다고 생각했습니다. trace 05~07부터는 shell이 child를 그냥 실행하고 끝내면 안 됩니다. shell이 "이 pid가 몇 번 job인지", "지금 foreground인지 background인지", "원래 명령어가 무엇이었는지"를 기억해야 합니다.
제출본 기준으로는 아래 helper들이 실제로 job list를 관리합니다. 과제 handout에 따라 이미 제공된 함수도 있고, 직접 채워야 하는 형태로 보일 수도 있습니다. 중요한 건 이 함수들이 단순 부가 기능이 아니라, 뒤쪽 trace 전체의 기준점이라는 점입니다.
struct job_t {
pid_t pid; // 실제 child process id
int jid; // shell이 보여주는 job id: [1], [2] 같은 번호
int state; // FG, BG, ST 중 하나
char cmdline[MAXLINE]; // 사용자가 입력한 원래 명령어
};
struct job_t jobs[MAXJOBS];
핵심 개념. pid는 OS가 붙인 process 번호이고, jid는 shell이 사용자에게 보여주기 위해 붙인 번호입니다. 사용자는 fg %1, bg %1처럼 jid로 job을 다루지만, 실제 signal을 보낼 때는 pid 또는 process group이 필요합니다. 그래서 pid2jid, getjobjid, getjobpid 같은 변환 함수들이 계속 나옵니다.
먼저 빈 job slot이 어떤 상태인지 정해둬야 합니다. handout마다 이 부분은 제공되어 있을 수도 있지만, 직접 구현해야 하는 버전이라면 아래 두 함수부터 보는 게 좋습니다. initjobs는 처음에 배열 전체를 비우고, clearjob은 한 칸을 빈 칸으로 되돌립니다.
void clearjob(struct job_t *job)
{
/*
* pid가 0이면 비어 있는 job slot으로 봅니다.
* state도 UNDEF로 돌려놓고, command line도 비웁니다.
*/
job->pid = 0;
job->jid = 0;
job->state = UNDEF;
job->cmdline[0] = '\0';
}
void initjobs(struct job_t *jobs)
{
int i;
/*
* shell이 시작될 때 jobs 배열 전체를 비웁니다.
*/
for (i = 0; i < MAXJOBS; i++) {
clearjob(&jobs[i]);
}
}
이걸 먼저 잡아두면 addjob과 deletejob이 훨씬 단순해집니다. 여기서부터 나오는 addjob(), deletejob(), pid2jid()는 fork()나 waitpid()처럼 헤더에서 가져오는 함수가 아닙니다. 이 글 기준으로는 구현부를 같이 확인하는 tsh.c 내부 helper입니다. 빈 칸은 pid == 0, 사용 중인 칸은 pid > 0이라는 기준 하나로 보면 됩니다.
int addjob(struct job_t *jobs, pid_t pid, int state, char *cmdline)
{
int i;
if (pid < 1) {
return 0;
}
/*
* jobs 배열에서 비어 있는 칸을 찾습니다.
* pid가 0이면 비어 있는 job slot으로 봅니다.
*/
for (i = 0; i < MAXJOBS; i++) {
if (jobs[i].pid == 0) {
jobs[i].pid = pid;
jobs[i].state = state;
jobs[i].jid = nextjid++;
if (nextjid > MAXJOBS) {
nextjid = 1;
}
strcpy(jobs[i].cmdline, cmdline);
return 1;
}
}
printf("Tried to create too many jobs\n");
return 0;
}
addjob은 새 child를 shell의 기억 속에 넣는 함수입니다. trace 05에서 background job을 실행했을 때 바로 prompt가 돌아와도, shell은 그 child를 잊으면 안 됩니다. 그래서 addjob으로 pid, state, cmdline을 저장합니다.
int deletejob(struct job_t *jobs, pid_t pid)
{
int i;
if (pid < 1) {
return 0;
}
/*
* 종료된 child의 pid를 찾아서 job slot을 비웁니다.
* 이 함수는 SIGCHLD handler에서 자주 호출됩니다.
*/
for (i = 0; i < MAXJOBS; i++) {
if (jobs[i].pid == pid) {
clearjob(&jobs[i]);
nextjid = maxjid(jobs) + 1;
return 1;
}
}
return 0;
}
deletejob은 끝난 job을 shell의 기억에서 지우는 함수입니다. 단, 멈춘 job은 지우면 안 됩니다. ctrl-z로 멈춘 job은 나중에 bg, fg로 다시 움직일 수 있어야 하므로 ST 상태로 남겨야 합니다.
int pid2jid(pid_t pid)
{
int i;
if (pid < 1) {
return 0;
}
/*
* 출력 형식에는 [jid]가 필요하지만,
* waitpid나 signal handler는 pid를 기준으로 들어오는 경우가 많습니다.
* 그래서 pid를 jid로 바꿔주는 함수가 필요합니다.
*/
for (i = 0; i < MAXJOBS; i++) {
if (jobs[i].pid == pid) {
return jobs[i].jid;
}
}
return 0;
}
pid2jid는 출력 형식을 맞추려고 필요했습니다. 예를 들어 signal 때문에 job이 종료되면
Job [1] (12345) terminated by signal 2처럼 출력해야 합니다.
handler는 pid를 받지만, 출력에는 jid도 필요하니 변환이 들어갑니다.
5. trace 05~07: background job을 기록하고 jobs로 보여주기
이번 구간은 trace 05~07입니다. 이제 &로 실행한 background job을 shell이 기다리지 않게 만들고, jobs 명령으로 현재 job 목록을 보여줘야 합니다.
trace 05부터 &가 들어옵니다. &는 "이 명령을 background로 실행하라"는 뜻입니다. foreground는 shell이 기다리고, background는 shell이 기다리지 않습니다. 쉘에 명령어를 칠 때 뒤에 붙이는 옵션 같은 느낌으로 보면 괜찮습니다. 실제 쉘에서도 그대로 씁니다.
# foreground
# shell이 myspin이 끝날 때까지 기다립니다.
./myspin 5
# background
# myspin은 뒤에서 돌고, shell은 바로 다음 입력을 받습니다.
./myspin 5 &
그런데 background라고 해서 child를 잊어버리면 안 됩니다. 나중에 jobs로 출력해야 하고, 종료되면 지워야 하고, 멈추면 상태를 바꿔야 합니다. 그래서 jobs 배열에 등록해야 합니다.
이제 eval 구현부가 아래처럼 조금 커집니다. 단순히 fork하고 기다리는 함수가 아니라, child를 shell의 job list에 등록하는 함수가 됩니다. 이때부터 Shell Lab이 그냥 실행기가 아니라 job 관리 프로그램처럼 보이기 시작했습니다.
void eval(char *cmdline)
{
char *argv[MAXARGS];
int background;
pid_t pid;
background = parseline(cmdline, argv);
if (argv[0] == NULL) {
return;
}
if (builtin_cmd(argv)) {
return;
}
pid = fork();
if (pid == 0) {
execve(argv[0], argv, environ);
printf("%s: Command not found\n", argv[0]);
exit(0);
}
/*
* 여기부터가 trace 05~07을 위해 추가되는 부분입니다.
*
* shell은 child를 그냥 실행하고 잊으면 안 됩니다.
* child가 foreground인지 background인지 기록해야 합니다.
*/
if (background) {
addjob(jobs, pid, BG, cmdline);
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
} else {
addjob(jobs, pid, FG, cmdline);
waitfg(pid, STDOUT_FILENO);
}
}
jobs 명령은 이 배열을 출력하는 builtin입니다. 외부 프로그램 실행이 아니라 shell 내부 상태를 보여주는 명령이므로 builtin_cmd에 들어가는 게 맞습니다.
6. trace 08 이후: addjob 전에 SIGCHLD가 오는 race 막기
이번 구간은 trace 08 이후에서 나오는 타이밍 문제를 막는 부분입니다. 이제 child가 종료되면 signal handler가 job list를 건드리기 시작합니다. 그래서 parent가 job을 등록하는 중간에 handler가 끼어들면 상태가 꼬일 수 있습니다.
여기까지는 꽤 자연스럽습니다. 그런데 child가 아주 빨리 종료되면 문제가 생깁니다. parent가 아직 addjob을 하기 전에 child가 죽고, shell이 SIGCHLD를 받을 수 있습니다. 그러면 handler는 job을 지우려고 하는데 job list에는 아직 등록조차 안 되어 있습니다. 이 타이밍을 놓치기 쉽습니다.
그래서 최종 eval은 fork와 addjob 사이에 signal이 끼어들지 못하게 잠깐 막습니다. 이 부분은 조각 코드로 나눠 보면 순서를 놓치기 쉬워서, 아래처럼 함수 전체 흐름 안에서 보는 게 좋아보였습니다.
먼저 signal 관련 함수 이름을 조금 풀어보겠습니다. sigset_t는 막거나 풀 signal 목록을 담는 자료형입니다. sigemptyset은 그 목록을 비우고, sigaddset은 목록에 signal을 하나씩 추가합니다. 마지막으로 sigprocmask가 현재 process의 signal mask를 실제로 바꿉니다.
이 구간의 최소 목표는 fork 뒤 addjob 전까지 SIGCHLD가 끼어들지 못하게 막는 것입니다. 다만 원칙은 더 넓습니다. jobs 배열을 읽거나 수정하는 구간과 SIGCHLD handler가 동시에 끼어들면 shell의 기억이 꼬일 수 있습니다.
아래 코드에서는 SIGINT, SIGTSTP도 같이 막고 있는데, 이건 foreground job 상태와 jobs 배열이 바뀌는 동안 다른 signal까지 끼어들지 않게 하려는 확장에 가깝습니다. 처음 구현할 때는 "일단 SIGCHLD race를 막는다"에 초점을 두는 게 좋았습니다.
핵심 개념. sigprocmask(SIG_BLOCK, &mask, &old_mask)는 "mask에 들어있는 signal들을 지금부터 막고, 막기 전 상태는 old_mask에 저장해줘"라는 뜻입니다. 여기서 old_mask를 저장하는 이유가 중요했습니다. 그냥 마지막에 모든 signal을 풀면 되는 게 아니었습니다. 이 함수가 호출되기 전에도 이미 다른 이유로 막혀 있던 signal이 있을 수 있습니다. 그래서 원래 상태를 저장했다가, 끝나면 SIG_SETMASK로 정확히 그 상태로 되돌립니다.
아래 코드에서 sigemptyset(), sigaddset(), sigprocmask()는 우리가 만든 함수가 아닙니다. 헤더를 통해 가져와서 OS의 signal mask를 조작하는 함수입니다. 반대로 같은 코드 안의 builtin_cmd(), addjob(), fgpid()는 우리 shell 코드 안의 함수입니다.
실전 코드라면 fork(), sigprocmask(), setpgid(), kill() 같은 시스템 함수가 실패할 수 있다는 점도 확인해야 합니다. 과제 trace가 항상 실패 상황을 만들지는 않지만, 시스템 콜은 성공한다고 가정하는 습관을 들이면 나중에 디버깅 비용이 커집니다.
void eval(char *cmdline)
{
char *argv[MAXARGS];
pid_t pid;
sigset_t mask;
sigset_t old_mask;
int background = parseline(cmdline, argv);
if (argv[0] == NULL) {
return;
}
/*
* SIGCHLD: child가 너무 빨리 종료되는 상황을 막기 위해 필요합니다.
* SIGINT, SIGTSTP: foreground job과 jobs 배열이 바뀌는 중에 끼어들면 상태가 꼬일 수 있습니다.
*/
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTSTP);
/*
* 여기서부터 잠깐 signal을 막습니다.
* 특히 fork 이후 addjob 전까지가 위험한 구간입니다.
*/
sigprocmask(SIG_BLOCK, &mask, &old_mask);
/*
* builtin도 jobs 배열을 만질 수 있습니다.
* 그래서 signal을 막은 상태에서 먼저 처리합니다.
*/
int builtin_result = builtin_cmd(argv);
if (builtin_result == 1) {
/*
* quit/jobs/bg 같은 명령이 여기로 옵니다.
* 처리 끝났으니 signal mask를 원래대로 돌리고 끝냅니다.
*/
sigprocmask(SIG_SETMASK, &old_mask, NULL);
return;
}
if (builtin_result == 2) {
/*
* fg는 job을 foreground로 바꾼 뒤 기다려야 합니다.
* 기다리는 동안 SIGCHLD를 받아야 하므로 mask를 먼저 풉니다.
*/
sigprocmask(SIG_SETMASK, &old_mask, NULL);
while (fgpid(jobs) != 0) {
usleep(1);
}
return;
}
/*
* builtin이 아니면 외부 프로그램입니다.
*/
pid = fork();
if (pid == 0) {
/*
* child는 parent의 signal mask를 물려받습니다.
* execve 전에 원래대로 돌려야 합니다.
*/
sigprocmask(SIG_SETMASK, &old_mask, NULL);
/*
* 뒤쪽 process group trace까지 생각하면 여기서 분리합니다.
*/
setpgid(0, 0);
execve(argv[0], argv, environ);
printf("%s: Command not found\n", argv[0]);
exit(0);
}
/*
* parent shell은 child를 jobs 배열에 등록합니다.
* signal을 막아둔 상태이므로, addjob 전에 SIGCHLD가 끼어들 수 없습니다.
*/
addjob(jobs, pid, background ? BG : FG, cmdline);
/*
* 이제 job list에 등록했으니 signal을 다시 받습니다.
*/
sigprocmask(SIG_SETMASK, &old_mask, NULL);
if (background) {
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
} else {
waitfg(pid, STDOUT_FILENO);
}
}
이 코드에서 가장 유명한 위험 구간은 fork 이후 addjob 전입니다. 이 사이에 child가 죽으면 shell의 job list와 실제 child 상태가 어긋납니다. 더 넓게 보면 jobs 배열을 만지는 코드와 handler가 충돌하지 않게 만드는 것이 핵심입니다. 그래서 sigprocmask를 쓰는 것이었습니다.
7. trace 08~14: 종료, 중단, 재개 상태 구분하기
이번 구간은 trace 08~14입니다. child가 그냥 끝나는 경우, signal 때문에 죽는 경우, ctrl-z 때문에 멈추는 경우를 shell이 구분해야 합니다. 그래야 job을 삭제할지, ST 상태로 남길지 결정할 수 있습니다.
핵심 개념. Shell Lab trace driver 기준으로는 shell handler가 SIGTSTP나 SIGINT를 받고, 그 signal을 현재 foreground job group에 다시 전달한다고 보면 됩니다. 실제 Linux shell은 terminal foreground process group 설정까지 얽혀 있어서 더 복잡합니다. 이 과제에서는 shell이 signal을 자기 자신에게 적용하고 끝내면 안 되고, 현재 foreground job에게 전달해야 한다는 점이 중요했습니다. ctrl-z는 job을 죽이는 게 아니라, 나중에 bg나 fg로 다시 움직일 수 있게 잠깐 세워두는 동작입니다.
child가 종료되거나, 멈추거나, 다시 실행되면 parent shell은 SIGCHLD를 받습니다. 그러면 sigchld_handler가 실행됩니다. 이 함수는 child 상태를 보고 jobs 배열을 정리하는 곳이라고 보면 됩니다.
void sigchld_handler(int sig)
{
int status;
pid_t pid;
/*
* -1은 "상태가 바뀐 child 아무나"라는 뜻입니다.
*
* WNOHANG:
* 바뀐 child가 없으면 기다리지 않고 바로 빠져나옵니다.
*
* WUNTRACED:
* 종료된 child뿐 아니라 멈춘 child도 잡습니다.
*
* WCONTINUED:
* SIGCONT로 다시 실행된 child도 잡습니다.
*
* while을 쓰는 이유:
* child 여러 개가 거의 동시에 상태가 바뀔 수 있기 때문입니다.
*/
while ((pid = waitpid(-1, &status,
WNOHANG | WUNTRACED | WCONTINUED)) > 0) {
if (WIFEXITED(status)) {
/*
* 정상 종료입니다.
* job list에서 제거합니다.
*/
deletejob(jobs, pid);
}
else if (WIFSIGNALED(status)) {
/*
* signal 때문에 종료됐습니다.
* 예: ctrl-c로 SIGINT를 받은 경우입니다.
*/
printf("Job [%d] (%d) terminated by signal %d\n",
pid2jid(pid), pid, WTERMSIG(status));
deletejob(jobs, pid);
}
else if (WIFSTOPPED(status)) {
/*
* signal 때문에 멈췄습니다.
* 예: ctrl-z로 SIGTSTP를 받은 경우입니다.
*
* 여기서 deletejob을 하면 안 됩니다.
* 멈춘 job은 bg/fg로 다시 실행할 수 있어야 합니다.
*/
struct job_t *job = getjobpid(jobs, pid);
if (job != NULL) {
job->state = ST;
}
printf("Job [%d] (%d) stopped by signal %d\n",
pid2jid(pid), pid, WSTOPSIG(status));
}
else if (WIFCONTINUED(status)) {
/*
* SIGCONT를 받아 다시 실행된 상태입니다.
* 출력 형식은 과제 요구에 맞춰 조정하면 됩니다.
*/
}
}
}
다만 이 코드는 Shell Lab 풀이 문맥에서 보는 것이 좋습니다. handler 안에서 printf를 바로 호출하고, jobs 배열을 수정하는 식의 구현은 일반 프로그램에서 그대로 안전한 패턴이라고 말하기는 어렵습니다. POSIX 기준으로는 signal handler 안에서 호출해도 안전한 함수가 제한되어 있고, 보통은 handler에서 최소한의 일만 하는 쪽이 좋습니다. 이 과제에서는 trace 요구 출력과 job 상태 갱신을 맞추기 위해 이런 형태로 설명했습니다.
핵심 개념. 여기서 특히 조심할 건 WIFSTOPPED였습니다. 멈춘 job은 죽은 게 아닙니다. ST 상태로 남겨야 합니다. 그래야 뒤에서 bg, fg가 의미를 가집니다.
8. trace 15~18: bg와 fg로 stopped job 다시 움직이기
이번 구간은 trace 15~18입니다. 앞에서 ctrl-z로 멈춘 job을 bg나 fg 명령으로 다시 움직이게 만들어야 합니다. 그래서 builtin_cmd의 빈자리였던 bg, fg 구현을 채웁니다.
bg와 fg도 shell 내장 명령입니다. 외부 프로그램이 아닙니다. 둘 다 멈춘 job에게 SIGCONT를 보내서 다시 실행시키지만, shell이 기다리는 방식이 다릅니다. 이 차이는 구현에서 실수하기 쉬운 지점입니다.
bg %1은 1번 job을 background로 다시 실행합니다. shell은 바로 prompt를 돌려줘도 됩니다. fg %1은 1번 job을 foreground로 가져옵니다. 그러면 shell은 그 job이 끝나거나 다시 멈출 때까지 기다려야 합니다.
아래 코드는 핵심 흐름을 보여주는 형태입니다. 실제 trace를 끝까지 맞추려면 bg %1, fg %1뿐 아니라 bg 1234, fg 1234처럼 pid로 들어오는 경우도 처리해야 합니다. 인자가 없는 경우, % 뒤가 숫자가 아닌 경우, 존재하지 않는 jid/pid를 가리키는 경우의 에러 메시지도 handout 요구에 맞춰 정확히 출력해야 합니다. 여기서는 %1 흐름만 처리하면 입력 검증을 빼먹기 쉬우니, 최종 제출 전에 따로 체크하는 게 좋습니다.
최종 제출 체크. bg/fg는 보통 별도 do_bgfg 함수로 빼도 좋습니다. 최종형은 %jid와 pid 입력을 모두 job으로 해석하고, 실패 상황마다 trace가 기대하는 문구를 그대로 출력해야 합니다. 아래 코드는 그중 "찾은 job에 SIGCONT를 보내고 BG/FG 상태로 바꾸는 핵심부"만 보여주는 축약 버전입니다.
int builtin_cmd(char **argv)
{
char *cmd = argv[0];
if (cmd == NULL) {
return 1;
}
if (!strcmp(cmd, "quit")) {
exit(0);
}
if (!strcmp(cmd, "jobs")) {
listjobs(jobs, STDOUT_FILENO);
return 1;
}
if (!strcmp(cmd, "bg") || !strcmp(cmd, "fg")) {
int jid;
/*
* Shell Lab에서는 보통 bg %1, fg %1처럼 들어옵니다.
* argv[1]은 "%1"이고, argv[1] + 1은 "1"입니다.
*/
if (argv[1] == NULL || argv[1][0] != '%') {
return 1;
}
if (sscanf(argv[1] + 1, "%d", &jid) != 1) {
return 1;
}
/*
* jid로 job을 찾습니다.
* 찾지 못하면 더 진행할 수 없습니다.
*/
struct job_t *job = getjobjid(jobs, jid);
if (job == NULL) {
return 1;
}
/*
* 멈춘 job을 다시 실행합니다.
* process group 전체에 보내야 하므로 -pid를 사용합니다.
*/
kill(-job->pid, SIGCONT);
if (!strcmp(cmd, "bg")) {
/*
* background로 다시 실행합니다.
* shell은 기다리지 않습니다.
*/
job->state = BG;
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
return 1;
}
/*
* foreground로 다시 실행합니다.
* 실제 기다림은 eval에서 signal mask를 푼 뒤 처리하게 합니다.
*/
job->state = FG;
return 2;
}
/*
* builtin이 아니므로 eval이 fork/execve로 처리해야 합니다.
*/
return 0;
}
여기서 fg가 return 2를 하는 건 제 구현 방식입니다. fg는 builtin이긴 하지만, 처리 후에 shell이 기다려야 합니다. 그래서 eval이 signal mask를 원래대로 돌린 다음 기다릴 수 있게, 신호를 주는 느낌으로 2를 썼습니다.
9. trace 19~21: process group 전체에 signal 보내기
이번 구간은 trace 19~21까지 마무리하는 부분입니다. 이제 foreground job 하나가 process 하나라고 가정하면 안 됩니다. foreground job 안에서 다시 child process가 생길 수 있으므로, signal을 process 하나가 아니라 process group 전체에 보내야 합니다.
사용자가 ctrl-c를 눌렀다고 shell이 죽으면 안 됩니다. foreground job이 죽어야 합니다. ctrl-z도 shell이 멈추는 것이 아니라 foreground job이 멈춰야 합니다. 이게 shell 입장에서는 꽤 중요한 역할 분리였습니다.
이 설명도 Shell Lab trace driver 기준으로 읽는 게 좋습니다. 실제 shell은 터미널의 foreground process group 자체를 관리하지만, 과제에서는 handler가 signal을 받아 현재 foreground job의 process group에 다시 전달하는 구조를 구현한다고 보면 됩니다.
예를 들어 foreground job이 내부에서 또 다른 child를 만들 수 있습니다. 그러면 kill(pid, SIGINT)처럼 대표 process 하나에만 signal을 보내면, 그 안에서 만들어진 다른 process가 남을 수 있습니다. 그래서 child를 새 process group의 대표로 만들고, kill(-pid, SIGINT)처럼 음수 pid로 group 전체에 signal을 보냅니다.
핵심 개념. handler는 현재 foreground job을 찾고, 그 job의 process group 전체에 signal을 넘깁니다. kill(pid, sig)가 아니라 kill(-pid, sig)인 이유가 여기서 나옵니다. 여기서 음수 pid는 group 전체로 보낸다는 뜻입니다.
void sigint_handler(int sig)
{
/*
* 현재 foreground job의 대표 pid를 찾습니다.
*/
pid_t pid = fgpid(jobs);
if (pid == 0) {
return;
}
/*
* pid 하나가 아니라 process group 전체에 SIGINT를 보냅니다.
* 그래야 foreground job 안의 여러 process가 같이 종료됩니다.
*/
kill(-pid, sig);
}
void sigtstp_handler(int sig)
{
/*
* ctrl-z도 shell이 아니라 foreground job에게 전달해야 합니다.
*/
pid_t pid = fgpid(jobs);
if (pid == 0) {
return;
}
/*
* process group 전체를 멈춥니다.
*/
kill(-pid, sig);
}
이게 되려면 child를 만들 때 process group을 분리해야 합니다. 그래서 최종 eval의 child 부분에 setpgid(0, 0)가 들어갑니다. child의 pid를 그 group의 id로 만드는 식입니다. 후반 trace에서 갑자기 이 코드의 의미가 살아납니다.
void eval(char *cmdline)
{
/*
* 앞부분은 Part 5의 최종 eval과 같습니다.
* 여기서는 child 구현부만 다시 봅니다.
*/
pid = fork();
if (pid == 0) {
sigprocmask(SIG_SETMASK, &old_mask, NULL);
/*
* child를 shell과 다른 process group으로 분리합니다.
* 후반 trace에서는 foreground job 안에 process가 여러 개 생길 수 있습니다.
*/
setpgid(0, 0);
execve(argv[0], argv, environ);
printf("%s: Command not found\n", argv[0]);
exit(0);
}
/*
* parent 구현부는 addjob, signal mask 복구, waitfg로 이어집니다.
*/
}
10. trace를 깨는 순서
정리하면 trace를 번호로만 외우지 말고, 필요한 개념을 붙이는 순서로 보는 게 좋았습니다. 처음부터 sigprocmask, setpgid까지 한 번에 다루면 범위가 너무 커집니다. 먼저 eval과 builtin_cmd가 무엇인지 잡고, 그 위에 하나씩 올리는 편이 낫습니다.
trace 00~04: quit과 외부 명령 실행
EOF는 기본 코드 확인, quit은 builtin, 외부 명령은 fork/execve로 실행합니다.
trace 05~07: background job과 jobs 출력
&를 background로 보고 addjob, jobs 출력을 붙입니다.
trace 08~14: signal로 종료/정지 상태 정리
SIGCHLD, SIGINT, SIGTSTP handler를 붙이고, sigprocmask로 race를 막습니다.
trace 15~18: stopped job을 bg/fg로 재개
bg/fg로 stopped job에게 SIGCONT를 보내고, foreground 대기를 처리합니다.
trace 19~21: process group 전체 제어
setpgid와 kill(-pid, sig)로 process group 전체에 signal을 보냅니다.
이 과제는 코드 없이 개요만 보면 핵심 흐름이 늦게 보입니다. parseline, builtin_cmd, fork, execve, waitfg를 표처럼 요약하는 것보다, 함수 구현부 위에 한 줄씩 주석을 달아가면서 보는 쪽이 훨씬 낫다고 생각했습니다.
추가. 우리가 한것은 syscall을 가지고 논 것 밖에 없다. 이거하나가 힘들다.
Shell Lab에서 계속 호출한 fork(), waitpid(), kill(), sigprocmask() 같은 함수들은 일반 함수처럼 생겼지만, 최종적으로는 커널에게 작업을 요청하는 통로로 이어집니다. 일반 프로세스는 유저 모드에서 실행됩니다. 유저 모드 코드는 다른 프로세스를 마음대로 만들거나, 커널의 process table을 직접 고치거나, 다른 process group에 signal을 직접 꽂아 넣을 권한이 없습니다.
그래서 CPU와 OS는 정해진 입구를 둡니다. 프로세스는 레지스터에 "어떤 작업을 할지"와 "인자값"을 채운 뒤, CPU가 제공하는 특수한 명령어로 커널 모드 진입을 요청합니다. x86-64 Linux 기준으로 그 명령어가 보통 syscall입니다.
핵심 개념. 프로세스가 커널 안으로 걸어 들어가는 게 아닙니다. 유저 모드에서 syscall 명령어를 실행하면, CPU 하드웨어가 권한을 커널 모드로 바꾸고, 커널이 부팅 때 미리 등록해둔 진입점으로 점프합니다. 프로세스는 커널에게 직접 명령하는 게 아니라, CPU가 보장하는 합법적인 문을 통해 요청하는 것입니다.
1. 커널은 부팅 때 syscall 입구를 CPU에 등록합니다
x86-64에서는 커널이 부팅 과정에서 CPU의 특수 레지스터인 MSR에 syscall 진입 주소를 적어둡니다. 대표적으로 IA32_LSTAR에는 syscall이 실행됐을 때 점프할 커널 엔트리 주소가 들어갑니다. 이 주소는 일반 유저 프로세스가 마음대로 바꿀 수 없습니다.
2. 유저 프로세스는 레지스터에 요청 내용을 채웁니다
glibc 같은 라이브러리 함수는 syscall 번호와 인자를 정해진 레지스터에 넣습니다. x86-64 Linux에서는 보통 rax에 syscall 번호를 넣고, 인자는 rdi, rsi, rdx, r10, r8, r9 순서로 둡니다. 예를 들어 kill(pid, sig)라면 pid와 signal 번호가 인자로 들어갑니다.
3. syscall 명령어가 유저 모드에서 커널 모드로 넘깁니다
syscall은 그냥 함수 이름이 아니라 CPU instruction입니다. 이 명령어가 실행되면 CPU는 현재 유저 코드로 돌아올 주소와 플래그를 저장하고, 실행 권한을 Ring 3에서 Ring 0으로 바꾼 뒤, 앞에서 MSR에 등록해둔 커널 진입점으로 점프합니다.
4. 커널은 syscall table을 보고 실제 작업을 실행합니다
커널 진입점은 레지스터에 들어 있는 syscall 번호를 보고, 내부 syscall table에서 해당 작업을 찾습니다. 작업이 끝나면 결과값을 보통 rax에 담고, sysret 계열 경로로 다시 유저 모드로 돌아갑니다. 유저 코드 입장에서는 fork()나 kill() 함수 호출이 return된 것처럼 보입니다.
; x86-64 Linux syscall 호출 모양의 개념도입니다.
; 실제 glibc wrapper와 커널 엔트리는 더 많은 보존/검증 코드를 포함합니다.
mov rax, 62 ; syscall number: kill
mov rdi, pid ; 1번째 인자
mov rsi, sig ; 2번째 인자
syscall ; CPU가 커널 모드로 전환하고 커널 엔트리로 점프
Shell Lab의 함수들과 연결하면 아래처럼 볼 수 있습니다. fork()는 새 child process를 만들라고 커널에 요청합니다. Linux 내부에서는 현대적으로 clone 계열 구현으로 이어지는 경우가 많습니다. waitpid()는 child 상태 변화를 기다리고 회수하는 요청입니다. kill()은 특정 pid나 process group에 signal을 보내달라는 요청입니다.
sigprocmask()는 현재 프로세스가 어떤 signal을 잠깐 막을지 커널에 반영하는 요청입니다. Signal() 같은 handout wrapper도 결국에는 signal이나 sigaction 계열을 통해 커널에 handler 등록 정보를 알려주는 흐름으로 이어집니다.
정리. 유저 프로세스는 힘이 센 존재가 아닙니다. 커널이 허용한 syscall 입구와 CPU의 모드 전환 규칙을 통해서만 process 생성, signal 전달, wait, signal mask 변경 같은 일을 요청할 수 있습니다. Shell Lab은 이 syscall들을 조합해서 작은 shell처럼 보이게 만드는 과제였습니다.
참고
'황현석 어록' 카테고리의 다른 글
| 시스템 프로그래밍 번외 - Kernel Linux Posix VirtualMem (0) | 2026.06.22 |
|---|---|
| 소프트웨어 마에스트로 17기 잡담 (3) | 2026.03.26 |
| 컴퓨터 구조 - 고찰편 (1) | 2025.12.17 |
| 객체 지향 설계 프로그래밍 C++, 중간 겸 고찰 (4) | 2025.10.20 |
| C의 Array Pointer 에 대한 고찰 및 응용 그리고 연습 (1) | 2025.06.20 |