어플리케이션 2 : 멀티태스크, 파일디스크립터
멀티태스크
터미널 여러개 실행
지금 터미널은 커널의 main 함수가 실행되면서 태스크가 하나 생성되고 TaskTerminal 함수를 태스크의 rip 에 세팅 후 Wakeup() 을 호출해서 실행시킨다.
메인 이벤트루프에서 F2 키 입력이 발생했을때 같은 작업을 수행하면 여러 터미널을 실행시킬 수 있다. 하지만 정상적으로 동작하지는 않는다.
case Message::kKeyPush:
if (auto act = active_layer->GetActive(); act == text_window_layer_id) {
if (msg->arg.keyboard.press) {
InputTextWindow(msg->arg.keyboard.ascii);
}
} else if (msg->arg.keyboard.press && msg->arg.keyboard.keycode == 59 /* F2 */) {
task_manager->NewTask()
.InitContext(TaskTerminal, 0)
.Wakeup();
} else {
점멸 타이머 수정
지금은 커널의 이벤트루프에서 점멸 타이머를 세팅하고 kTimerTimeout 이벤트를 받게 구현되어있는데, 각자의 터미널에서 타이머를 실행하고 터미널의 이벤트루프에서 이벤트를 처리하도록 구현하면 터미널마다 점멸을 따로 관리할 수 있게된다.
어플리케이션 여러개 실행 (페이징)
터미널은 커널의 태스크로 실행되고, 외부에서 빌드한 어플리케이션을 실행시킬때 CallApp을 호출하는데 stack_frame_addr 과 args_frame_addr 의 가상 주소가 이미 테이블에 세팅되어 새로운 주소를 할당받지 못한다.
모든 유저 어플리케이션이 같은 물리주소를 사용해서 크래시가 발생하게 되는데, 어플리케이션 실행할 때마다 메모리주소를 재배치하거나 별도의 페이지테이블을 사용해서 동일한 가상주소에 다른 물리주소를 사용할수 있을것이다.
후자가 현대 OS에서 사용하는 방법이고, 페이지를 완전히 분리해서 다른 앱은 접근할 수 없는 장점이 있다.
태스크마다 CR3를 백업하고 복원하는 과정은 이미 구현되어 있지만, 같은 테이블을 사용하기 때문에 테이블만 나누면 된다.
어플리케이션을 실행하기 직전에 페이지테이블을 만들고 CR3에 있던 테이블(커널의 페이지테이블)을 256개만 복사해서 커널에서 사용하는 페이지를 유지한다.
pml4_table은 512개인데 256개만 복사하면 절반인 커널 영역만 복사되기 때문에 stack_frame_addr 과 args_frame_addr 을 같은 유저영역 가상주소를 세팅해도 유저영역이 비워져 있어서 새로운 물리페이지를 할당 후 테이블에 세팅하게 된다.
종료할때는 어플리케이션의 페이지테이블을 깔끔하게 제거하고 물리메모리도 회수해야한다.
WithError<PageMapEntry*> SetupPML4(Task& current_task) {
auto pml4 = NewPageMap();
if (pml4.error) {
return pml4;
}
const auto current_pml4 = reinterpret_cast<PageMapEntry*>(GetCR3());
memcpy(pml4.value, current_pml4, 256 * sizeof(uint64_t));
const auto cr3 = reinterpret_cast<uint64_t>(pml4.value);
SetCR3(cr3);
current_task.Context().cr3 = cr3;
return pml4;
}
Error FreePML4(Task& current_task) {
const auto cr3 = current_task.Context().cr3;
current_task.Context().cr3 = 0;
ResetCR3(); // 메인태스크의 테이블로 복원
const FrameID frame{cr3 / kBytesPerFrame};
return memory_manager->Free(frame, 1);
}
Error Terminal::ExecuteFile(const fat::DirectoryEntry& file_entry, char* command, char* first_arg) {
...
__asm__("cli");
auto& task = task_manager->CurrentTask();
__asm__("sti");
if (auto pml4 = SetupPML4(task); pml4.error) {
return pml4.error;
}
LinearAddress4Level stack_frame_addr{0xffff'ffff'ffff'e000};
if (auto err = SetupPageMaps(stack_frame_addr, 1)) {
return err;
}
LinearAddress4Level args_frame_addr{0xffff'ffff'ffff'f000};
if (auto err = SetupPageMaps(args_frame_addr, 1)) {
return err;
}
...
return FreePML4(task);
}
애플리케이션 크래시 처리
애플리케이션에서 커널메모리 접근, devide by zero, hlt(특권명령이라 예외발생) 등에 의해 예외가 발생하면 인터럽트가 발생하며 핸들러가 실행된다.
0x00 ~ 0x1F 까지는 이미 CPU가 발생시키는 예외로 인터럽트 번호가 정해져 있다.
(나머지는 XHCI, LAPIC등의 각 장치에 직접 OS가 등록했었다.)
예외 코드 수정
CPU 예외가 발생하면 실행되는 핸들러 FaultHandler~ 매크로 에서 Fault의 원인이 유저앱인 경우(cpl이 0x3인 경우) 앱을 종료(ExitApp)하도록 한다.
스택은 커널 스택으로 돌리고, 종료코드를 128 + SIGSEGV 을 설정한다.
void KillApp(InterruptFrame* frame) {
const auto cpl = frame->cs & 0x3;
if (cpl != 3) {
return;
}
auto& task = task_manager->CurrentTask();
__asm__("sti");
// rsp, ret_val
ExitApp(task.OSStackPointer(), 128 + SIGSEGV);
}
#define FaultHandlerWithError(fault_name) \
__attribute__((interrupt)) \
void IntHandler ## fault_name (InterruptFrame* frame, uint64_t error_code) { \
KillApp(frame); \
PrintFrame(frame, "#" #fault_name); \
WriteString(*screen_writer, {500, 16*4}, "ERR", {0, 0, 0}); \
PrintHex(error_code, 16, {500 + 8*4, 16*4}); \
while (true) __asm__("hlt"); \
}
애플리케이션 종료 코드 작성
SyscallExit 에서 사용했던 코드인데, 예외발생해서 종료되는 경우 ExitApp을 직접 호출하며 터미널 태스크의 커널스택과 ret_val 을 지정해준다.
.exit:
mov rdi, rax ; 시스템콜은 스택을 변경하지 않기 떄문에 앱이 종료되면 태스크의 커널스택으로 옮겨줘야 한다.
mov esi, edx ; exit(ret_val) -> ExitApp(OSStack, ret_val);
jmp ExitApp
global ExitApp ; void ExitApp(uint64_t rsp, int32_t ret_val);
ExitApp:
mov rsp, rdi ; .exit에서 왔으면 커널스택, 그게 아니라면 rsp, ret_val 세팅
mov eax, esi
pop r15
pop r14
pop r13
pop r12
pop rbp
pop rbx
ret ; CallApp의 다음줄로 점프한다. (CallApp에서 RIP를 push해둠)
지금까지 어플리케이션 실행 프로세스
터미널은 커널 메모리의 객체(태스크)이지만, ExecuteFile 함수를 통해 어플리케이션이 실행되면, 잠깐 유저 태스크가 생성되며 페이지테이블을 복사해서 만든 후 CallApp 으로 유저태스크로 전환된다.
이때 터미널 태스크의 OSStackPointer 가 저장된다.
실행중에 태스크의 전환이 발생하는 경우가 있다.
- 태스크 타이머 인터럽트
- IST로 스택이 전환되며 인터럽트 핸들러 실행
- 컨텍스트가 스위칭될때 기존 태스크의 정보를 저장하고 새로운 태스크의 정보를 복원
- ReadEvent 에서 슬립
- 시스템콜 호출 시 터미널 태스크의 커널스택(OSStackPointer)으로 전환
- Sleep에서 SwitchTask 실행
- 새로운 태스크의 정보 복원
- 종료(exit 또는 CPU예외)
- 터미널 태스크의 커널스택로 전환
- 터미널태스크의 CallApp 이후로 코드 실행
- 어플리케이션 페이지 정리
아직까지는 종료가 실제 메모리 해제까지 이뤄지지 않고 터미널태스크가 살아있는 상태로 앱만 종료되는 것이다.
noterm 명령어도 보이지 않는 터미널이 열리고 앱 종료 후에도 열린상태가 유지된다.
파일 읽기
디렉터리 읽기 (ls)
FAT32 파일시스템은 0번 섹터에서 헤더 역할을 하는 VBR이라는 구조를 확인할 수 있고 Reserved Sector와 FAT 이후 RootCluster의 섹터(1262)를 찾아가서 데이터를 읽으면 루트디렉터리의 내용을 읽을 수 있다.
디렉터리 클러스터의 내용은 디렉터리에 저장된 파일들의 정보가 디렉터리 엔트리 배열 형태로 담겨있다.
디렉터리에 저장된 파일들은 각 엔트리를 파싱해서 DIR_Attr로는 타입(디렉터리 or 파일)을 확인할 수 있고, 데이터가 저장된 클러스터의 위치도 파악할 수 있다.
// 전달받은 경로를 직접 조회해서 최종경로까지 유효한지 확인
//
std::pair<DirectoryEntry*, bool> FindFile(const char* path, unsigned long directory_cluster = 0) {
// 경로가 / 로 시작하는 경우 무시
if (path[0] == '/') {
directory_cluster = boot_volume_image->root_cluster;
++path;
} else if (directory_cluster == 0) {
directory_cluster = boot_volume_image->root_cluster;
}
char path_elem[13];
// path : 앞으로 검색할 경로
// path_elem : 이번 FindFile 함수 컨텍스트에서 찾을 폴더. (path에서 맨앞부분 복사됨)
// next_path : 다음 함수 컨텍스트에서 path 값 (path - path_elem)
// post_slash : path 에 / 가 포함되어 있는지
const auto [ next_path, post_slash ] = NextPathElement(path, path_elem);
// 다음 경로가 있는지? == FindFile을 재귀적으로 한번 더 호출해야하는지?
const bool path_last = next_path == nullptr || next_path[0] == '\0';
while (directory_cluster != kEndOfClusterchain) {
auto dir = GetSectorByCluster<DirectoryEntry>(directory_cluster);
// 현재 클러스터에서 path_elem을 찾아본다.
for (int i = 0; i < bytes_per_cluster / sizeof(DirectoryEntry); ++i) {
if (dir[i].name[0] == 0x00) {
goto not_found;
} else if (!NameIsEqual(dir[i], path_elem)) {
continue;
}
// path_elem이 디렉터리라면 재귀로 검색
if (dir[i].attr == Attribute::kDirectory && !path_last) {
return FindFile(next_path, dir[i].FirstCluster());
} else {
return { &dir[i], post_slash };
}
}
directory_cluster = NextCluster(directory_cluster);
}
not_found:
return { nullptr, post_slash };
}
void ListAllEntries(Terminal* term, uint32_t dir_cluster) {
const auto kEntriesPerCluster =
fat::bytes_per_cluster / sizeof(fat::DirectoryEntry);
// 조회할 디렉터리의 마지막 클러스터까지
while (dir_cluster != fat::kEndOfClusterchain) {
auto dir = fat::GetSectorByCluster<fat::DirectoryEntry>(dir_cluster);
// 지워지지 않은 파일 ShortName 만 출력
for (int i = 0; i < kEntriesPerCluster; ++i) {
if (dir[i].name[0] == 0x00) {
return;
} else if (static_cast<uint8_t>(dir[i].name[0]) == 0xe5) {
continue;
} else if (dir[i].attr == fat::Attribute::kLongName) {
continue;
}
char name[13]; fat::FormatName(dir[i], name);
term->Print(name); term->Print("\n");
}
// 다음 클러스터
dir_cluster = fat::NextCluster(dir_cluster);
}
}
// ls 만 입력했을땐 root_cluster
// 인자가 있을때 dir->attr 로 디렉터리인지 확인 후 해당 dir_cluster 조회
} else if (strcmp(command, "ls") == 0) {
if (first_arg[0] == '\0') {
ListAllEntries(this, fat::boot_volume_image->root_cluster);
} else {
auto [ dir, post_slash ] = fat::FindFile(first_arg);
if (dir == nullptr) {
Print("No such file or directory: ");
Print(first_arg);
Print("\n");
} else if (dir->attr == fat::Attribute::kDirectory) {
ListAllEntries(this, dir->FirstCluster());
} else {
...
}
cat 명령과 ExecuteFile 구현에도 fat::FindFile을 사용하도록 수정해야한다.
파일읽기 (fopen, fgets)
대부분의 구현이 Newlib에 포함되어 있기 때문에 open, read, sbrk 만 구현하면 된다.
여기에서는 open, read는 시스템콜 호출로 구현을 넘기고, sbrk는 임시로 static 메모리 공간을 넘기게 구현된다. 나중에 메모리 할당을 구현할때 제대로 구현된다.
// apps/newlib_support.c
int open(const char* path, int flags) {
struct SyscallResult res = SyscallOpenFile(path, flags);
if (res.error == 0) {
return res.value;
}
errno = res.error;
return -1;
}
caddr_t sbrk(int incr) {
static uint8_t heap[4096];
static int i = 0;
int prev = i;
i += incr;
return (caddr_t)&heap[prev];
}
fopen 구현
파일은 태스크마다 고유한 파일디스크립터(fd) 값을 이용해서 관리하며 fd 번호가 fat::FileDescriptor 구조체를 가리켜 파일읽기 정보를 기억하도록 구현할 것이다.
Task 클래스의 멤버로 파일 정보를 가지고있게 한다. 여러 클러스터에 걸쳐서 파일이 저장되어 있기 때문에 rd_off_ 는 연속적으로 보여도 cluster 번호와 cluster에서의 오프셋이 필요하다.
rd_off_ = (rd_cluster_ * CLSUTER_SIZE) + rd_cluster_off_
class FileDescriptor {
public:
explicit FileDescriptor(DirectoryEntry& fat_entry);
size_t Read(void* buf, size_t len);
private:
DirectoryEntry& fat_entry_; // 파일의 엔트리
size_t rd_off_ = 0; // 논리적인 파일의 오프셋
unsigned long rd_cluster_ = 0; // rd_off_ 위치의 실제 클러스터 번호
size_t rd_cluster_off_ = 0; // rd_cluster_ 에서의 오프셋
};
class Task {
...
std::vector<std::unique_ptr<fat::FileDescriptor>>& Files();
private:
std::vector<std::unique_ptr<fat::FileDescriptor>> files_{};
}
SyscallOpenFile 은 인자로 전달된 경로의 파일을 찾고 FD를 할당하는 작업을 한다.
fat::FileDescriptor 생성자는 DirectoryEntry 타입을 인자로 받기 때문에 *dir 을 전달할 수 있다.
중간에 비어있는 FD 가 있다면 그 인덱스를 할당하고, 아니면 맨 마지막을 늘려서 맨 마지막 할당해준다.
namespace {
size_t AllocateFD(Task& task) {
const size_t num_files = task.Files().size();
for (size_t i = 0; i < num_files; ++i) {
if (!task.Files()[i]) {
return i;
}
}
task.Files().emplace_back();
return num_files;
}
} // namespace
SYSCALL(OpenFile) {
const char* path = reinterpret_cast<const char*>(arg1);
const int flags = arg2;
__asm__("cli");
auto& task = task_manager->CurrentTask();
__asm__("sti");
auto [ dir, post_slash ] = fat::FindFile(path);
if (dir == nullptr) {
return { 0, ENOENT };
} else if (dir->attr != fat::Attribute::kDirectory && post_slash) {
return { 0, ENOENT };
}
size_t fd = AllocateFD(task);
task.Files()[fd] = std::make_unique<fat::FileDescriptor>(*dir);
return { fd, 0 };
}
fread 구현
fat::FileDescriptor 클래스의 Read 함수를 구현해서 파일에서 len 만큼 읽어서 buf에 복사하는 작업을 한다.
파일 내용이 클러스터 단위로 나뉘어있기 때문에 경계에 맞춰서 읽어와야한다.
size_t FileDescriptor::Read(void* buf, size_t len) {
// 처음 읽으면 파일의 첫번째 클러스터부터 읽는다.
if (rd_cluster_ == 0) {
rd_cluster_ = fat_entry_.FirstCluster();
}
uint8_t* buf8 = reinterpret_cast<uint8_t*>(buf);
// 입력받은 길이 vs 남은길이 중 짧은것으로 읽음
len = std::min(len, fat_entry_.file_size - rd_off_);
size_t total = 0;
while (total < len) {
uint8_t* sec = GetSectorByCluster<uint8_t>(rd_cluster_);
size_t n = std::min(len - total, bytes_per_cluster - rd_cluster_off_);
memcpy(&buf8[total], &sec[rd_cluster_off_], n);
total += n;
rd_cluster_off_ += n;
// 읽은 후의 클러스터의 오프셋이 클러스터 크기와 같으면 다음 클러스터 세팅
if (rd_cluster_off_ == bytes_per_cluster) {
rd_cluster_ = NextCluster(rd_cluster_);
rd_cluster_off_ = 0;
}
}
// 파일 오프셋 조정, 읽은 길이 리턴
rd_off_ += total;
return total;
}
stdin 구현
파일과 동일한 인터페이스로 TerminalFileDescriptor 를 구현하고 앱 실행 직전에 Task의 파일디스크립터 0번으로 등록 후 Read 함수가 키 입력에서 받도록 구현하면 된다.
파일의 끝이 EOF인 것처럼 표준입력이나 네트워크 입력의 끝은 EOT(End of Transmission)라고 부른다. 리눅스 터미널에서는 Ctrl키와 함께 문자를 입력 시 제어문자를 입력할 수 있는 기능이 있고, Ctrl+D 를 입력할 때 EOT가 입력된다.
class TerminalFileDescriptor : public FileDescriptor {
public:
explicit TerminalFileDescriptor(Task& task, Terminal& term);
size_t Read(void* buf, size_t len) override;
private:
Task& task_;
Terminal& term_;
};
size_t TerminalFileDescriptor::Read(void* buf, size_t len) {
char* bufc = reinterpret_cast<char*>(buf);
while (true) {
__asm__("cli");
auto msg = task_.ReceiveMessage();
if (!msg) {
task_.Sleep();
continue;
}
__asm__("sti");
if (msg->type != Message::kKeyPush || !msg->arg.keyboard.press) {
continue;
}
// Ctrl과 함게 입력된 경우 제어문자로 인식한다.
// EOT의 구현
if (msg->arg.keyboard.modifier & (kLControlBitMask | kRControlBitMask)) {
char s[3] = "^ ";
s[1] = toupper(msg->arg.keyboard.ascii);
term_.Print(s);
// EOT에 도달하면 파일의 끝 처럼 0을 리턴(0byte 읽음)하면 된다.
if (msg->arg.keyboard.keycode == 7 /* D */) {
return 0;
}
continue;
}
// 키보드 입력을 1byte씩 buf에 저장하고 터미널에 출력
bufc[0] = msg->arg.keyboard.ascii;
term_.Print(bufc, 1);
return 1;
}
}
CallApp 전에 TerminalFileDescriptor 를 제일 먼저(0번에) 등록하고, SYSCALL에서 파일명이 @stdin 으로 들어온 경우 0번 디스크립터를 리턴한다.
Terminal::ExecuteFile( ... ) {
...
// CallApp 전 가장먼저 자기자신(터미널)의 TerminalFileDescriptor 등록
task.Files().push_back(std::make_unique<TerminalFileDescriptor>(task, *this));
auto entry_addr = elf_header->e_entry;
int ret = CallApp(...);
task.Files().clear();
...
}
SYSCALL(OpenFile) {
...
// path가 @stdin 이라면 디스크립터 0 리턴
if (strcmp(path, "@stdin") == 0) {
return { 0, 0 };
}
...
}
파일쓰기 (fwrite)
파일 읽기는 이미 저장된 데이터를 구조에 맞춰서 읽으면 되지만 파일을 쓰는 작업은 조금 복잡하다.
파일의 클러스터 영역을 변경하면서 파일의 클러스터 연결 정보인 FAT 영역도 수정해야하고 디렉터리 엔트리의 파일에 대한 메타데이터도 업데이트해줘야 한다. 신규 파일의 경우 디렉터리 엔트리도 수정하면서 재귀적인 클러스터 수정이 발생한다.
파일 생성
open을 호출할 때 O_CREAT 플래그가 있다면 fat::CreateFile 함수가 호출되어 빈 파일이 만들어지게 구현할 것이다.
fopen 를 “w”, “a” 옵션으로 호출하면 내부적으로 open을 호출하면서 파일이 없는경우 생성하는 O_CREATE 를 세팅한다.
SYSCALL(OpenFile) {
...
auto [ file, post_slash ] = fat::FindFile(path);
// 파일이 없는경우 O_CREATE가 세팅되어있다면 CreateFile
if (file == nullptr) {
if ((flags & O_CREAT) == 0) {
return { 0, ENOENT };
}
auto [ new_file, err ] = CreateFile(path); // fat::CreateFile의 래퍼함수
if (err) {
return { 0, err };
}
file = new_file;
}
...
}
// fat::CreateFile
WithError<DirectoryEntry*> CreateFile(const char* path) {
auto parent_dir_cluster = fat::boot_volume_image->root_cluster;
const char* filename = path;
// '/' 문자 기준으로 파일명과 나머지(파일이 포함된 디렉터리 전체 경로)를 분리
if (const char* slash_pos = strrchr(path, '/')) {
// 뒤에서부터 '/' 의 인덱스를 찾으면 다음 글자는 파일명 시작부분이다.
filename = &slash_pos[1];
if (slash_pos[1] == '\0') {
return { nullptr, MAKE_ERROR(Error::kIsDirectory) };
}
// slash 주소에서 path의 시작 주소를 빼서 폴더 전체경로의 길이를 찾아온다.
char parent_dir_name[slash_pos - path + 1];
strncpy(parent_dir_name, path, slash_pos - path);
parent_dir_name[slash_pos - path] = '\0';
// 디렉터리의 클러스터를 찾아온다.
if (parent_dir_name[0] != '\0') {
auto [ parent_dir, post_slash2 ] = fat::FindFile(parent_dir_name);
if (parent_dir == nullptr) {
return { nullptr, MAKE_ERROR(Error::kNoSuchEntry) };
}
parent_dir_cluster = parent_dir->FirstCluster();
}
}
// 엔트리 하나를 만들고
auto dir = fat::AllocateEntry(parent_dir_cluster);
if (dir == nullptr) {
return { nullptr, MAKE_ERROR(Error::kNoEnoughMemory) };
}
// 엔트리에 Short Name만 기록
fat::SetFileName(*dir, filename);
dir->file_size = 0;
return { dir, MAKE_ERROR(Error::kSuccess) };
}
엔트리 추가
DirectoryEntry 라는것은 디렉터리의 클러스터를 읽었을때 배열로 저장된 파일들 정보 중 하나를 의미한다.
엔트리를 늘린다는건 디렉터리의 클러스터에 기록한다는 의미이고, 마지막 클러스터까지 전부 꽉 찼다면 클러스터를 늘려야된다.
DirectoryEntry* AllocateEntry(unsigned long dir_cluster) {
// 현재 디렉터리의 모든 클러스터에서 엔트리 배열을 전부 뒤져본다.
while (true) {
auto dir = GetSectorByCluster<DirectoryEntry>(dir_cluster);
// 삭제되거나 빈 엔트리가 있다면 그 주소를 리턴
for (int i = 0; i < bytes_per_cluster / sizeof(DirectoryEntry); ++i) {
if (dir[i].name[0] == 0 || dir[i].name[0] == 0xe5) {
return &dir[i];
}
}
auto next = NextCluster(dir_cluster);
if (next == kEndOfClusterchain) {
break;
}
dir_cluster = next;
}
// 없다면 클러스터를 하나 늘리고, 첫번째 엔트리 주소를 리턴
dir_cluster = ExtendCluster(dir_cluster, 1);
auto dir = GetSectorByCluster<DirectoryEntry>(dir_cluster);
memset(dir, 0, bytes_per_cluster);
return &dir[0];
}
클러스터를 늘릴 땐 FAT 영역을 확인해서 비어있는 클러스터를 찾고 체인을 업데이트(연결)한다.
unsigned long ExtendCluster(unsigned long eoc_cluster, size_t n) {
// FAT 영역에 클러스터 체인의 정보가 있다.
uint32_t* fat = GetFAT();
// 현재 디렉터리의 클러스터 체인 끝(마지막 유효 클러스터)을 찾아간다.
while (!IsEndOfClusterchain(fat[eoc_cluster])) {
eoc_cluster = fat[eoc_cluster];
}
size_t num_allocated = 0;
auto current = eoc_cluster;
// FAT에서 첫번째 인덱스부터 빈곳(값이 0인곳) 을 찾아간다
for (unsigned long candidate = 2; num_allocated < n; ++candidate) {
if (fat[candidate] != 0) {
continue;
}
// fat[current]는 원래 EndOfClusterchain을 가리키고 있었지만, 위에서 찾은 빈 클러스터를 가리키게 한다.
fat[current] = candidate;
current = candidate;
// 할당량까지 반복
++num_allocated;
}
// 빈 클러스터의 다음 클러스터는 없기 때문에 EOC 값을 집어넣는다.
fat[current] = kEndOfClusterchain;
return current;
}
데이터 쓰기
TerminalFileDescriptor::Write는 입력받은 버퍼를 그대로 터미널에 출력하고, FileDescriptor::Write 는 Read 함수와 비슷하다.
// stdout, stderr 용도
size_t TerminalFileDescriptor::Write(const void* buf, size_t len) {
term_.Print(reinterpret_cast<const char*>(buf), len);
return len;
}
// 파일에 클러스터가 없을때 새로 할당하도록 호출하는 함수
// fat에서 0인 공간을 찾아서 EOC나 클러스터 체인으로 채워넣고 첫 클러스터를 리턴
unsigned long AllocateClusterChain(size_t n) {
uint32_t* fat = GetFAT();
unsigned long first_cluster;
// 첫번째 클러스터부터 빈곳을 찾는다.
for (first_cluster = 2; ; ++first_cluster) {
if (fat[first_cluster] == 0) {
fat[first_cluster] = kEndOfClusterchain;
break;
}
}
// 연속해서 그 위치부터 Extend
if (n > 1) {
ExtendCluster(first_cluster, n - 1);
}
return first_cluster;
}
// 파일 출력
size_t FileDescriptor::Write(const void* buf, size_t len) {
auto num_cluster = [](size_t bytes) {
return (bytes + bytes_per_cluster - 1) / bytes_per_cluster;
};
// 처음 파일을 쓰는경우
if (wr_cluster_ == 0) {
// 클러스터가 있다면 그걸 가져옴
if (fat_entry_.FirstCluster() != 0) {
wr_cluster_ = fat_entry_.FirstCluster();
} else {
// 없으면 len만큼 저장할 수 있는 클러스터 할당 후 entry에 기록
wr_cluster_ = AllocateClusterChain(num_cluster(len));
fat_entry_.first_cluster_low = wr_cluster_ & 0xffff;
fat_entry_.first_cluster_high = (wr_cluster_ >> 16) & 0xffff;
}
}
const uint8_t* buf8 = reinterpret_cast<const uint8_t*>(buf);
size_t total = 0;
while (total < len) {
// 클러스터를 다 읽은 경우 다음 클러스터로 이동
if (wr_cluster_off_ == bytes_per_cluster) {
const auto next_cluster = NextCluster(wr_cluster_);
if (next_cluster == kEndOfClusterchain) {
wr_cluster_ = ExtendCluster(wr_cluster_, num_cluster(len - total));
} else {
wr_cluster_ = next_cluster;
}
wr_cluster_off_ = 0;
}
// 클러스터에 쓸수있는만큼 쓰기
uint8_t* sec = GetSectorByCluster<uint8_t>(wr_cluster_);
size_t n = std::min(len, bytes_per_cluster - wr_cluster_off_);
memcpy(&sec[wr_cluster_off_], &buf8[total], n);
wr_cluster_off_ += n;
total += n;
}
wr_off_ += total;
fat_entry_.file_size = wr_off_;
return total;
}
표준입출력 추가구현
리다이렉트
현재 표준 입력은 @stdin 문자열로 지정할 수 있고, 표준출력은 터미널에 출력된다.
표준입출력 대상을 파일로 지정하는게 리다이렉트이며, <, >, 2> 같은 기호로 터미널에서 사용할 수 있다.
이미 FileDescriptor 로 Read, Write 하는 기능이 구현되어 있기 때문에 포매팅하고 호출하기만 하면 된다.
size_t PrintToFD(FileDescriptor& fd, const char* format, ...) {
va_list ap;
int result;
char s[128];
va_start(ap, format);
result = vsprintf(s, format, ap);
va_end(ap);
fd.Write(s, result);
return result;
}
Terminal에 터미널 파일디스크립터들을 미리 3개 만들어두고, 명령을 실행할때 > 문자를 만나면 stdout을 해당 파일로 돌린다.
이후 터미널에서 모든 출력은 files_[1] 로 출력하면 해결된다.
Terminal::Terminal(Task& task, bool show_window)
: task_{task}, show_window_{show_window} {
for (int i = 0; i < files_.size(); ++i) {
files_[i] = std::make_shared<TerminalFileDescriptor>(*this);
}
// ...
}
void Terminal::ExecuteLine() {
char* command = &linebuf_[0];
char* first_arg = strchr(&linebuf_[0], ' ');
char* redir_char = strchr(&linebuf_[0], '>');
if (first_arg) {
*first_arg = 0;
++first_arg;
}
auto original_stdout = files_[1];
if (redir_char) {
*redir_char = 0;
char* redir_dest = &redir_char[1];
while (isspace(*redir_dest)) {
++redir_dest;
}
auto [ file, post_slash ] = fat::FindFile(redir_dest);
if (file == nullptr) {
auto [ new_file, err ] = fat::CreateFile(redir_dest);
if (err) {
PrintToFD(*files_[2],
"failed to create a redirect file: %s\n", err.Name());
return;
}
file = new_file;
} else if (file->attr == fat::Attribute::kDirectory || post_slash) {
PrintToFD(*files_[2], "cannot redirect to a directory\n");
return;
}
files_[1] = std::make_shared<fat::FileDescriptor>(*file);
}
if (strcmp(command, "echo") == 0) {
if (first_arg) {
PrintToFD(*files_[1], "%s", first_arg);
}
PrintToFD(*files_[1], "\n");
// ...
}
유저앱을 실행할때도 터미널의 파일디스크립터를 전달한다.
Error Terminal::ExecuteFile(fat::DirectoryEntry& file_entry, char* command, char* first_arg) {
// ...
for (int i = 0; i < files_.size(); ++i) {
task.Files().push_back(files_[i]);
}
// ...
int ret = CallApp(...);
}
파이프
터미널에서 프로그램을 실행할 때 파이프를 통해서 표준 입출력 데이터를 순차적으로 주고받을 수 있다.
42에서 minishell을 구현할땐 프로그램 종료까지 표준 출력을 전부 모아놨다가 다음 커맨드로 넘기는 방식으로 구현했었다.
하지만 첫번째 커맨드가 오래걸리는 작업이라면 두번째 커맨드로 표준입력을 받을 수 없게되는데, 어차피 표준 입력은 개행단위이기 때문에 개행 단위로 작업이 끝날때마다 다음 커맨드의 표준입력으로 넘겨주는게 입출력 버퍼 관점에서도 적은 용량으로 처리 가능하기 때문에 훨씬 좋은 구현방법이다.
파이프를 통한 태스크 연결
MikanOS 에서는 파이프 오른쪽 명령어를 subtask 로 해서 하나의 터미널에서 태스크를 동시에 실행시키고, 표준 입출력만 pipe_fd 를 통해 연결한다.
태스크의 이벤트는 layer_task_map 을 통해 현재 활성화 레이어의 태스크에 이벤트를 전달하게 된다.
파이프를 사용할때는 동일 레이어에 태스크가 여러개 실행되지만 서브태스크가 파이프 오른쪽(마지막명령)이기 때문에 서브태스크에 이벤트가 전달되도록 처리해야한다.
void Terminal::ExecuteLine() {
char* command = &linebuf_[0];
char* first_arg = strchr(&linebuf_[0], ' ');
char* redir_char = strchr(&linebuf_[0], '>');
char* pipe_char = strchr(&linebuf_[0], '|');
// ...
std::shared_ptr<PipeDescriptor> pipe_fd;
uint64_t subtask_id = 0;
if (pipe_char) {
*pipe_char = 0;
char* subcommand = &pipe_char[1];
while (isspace(*subcommand)) {
++subcommand;
}
// 태스크를 하나 만들어서 표준입출력 지정 후 실행
auto& subtask = task_manager->NewTask();
pipe_fd = std::make_shared<PipeDescriptor>(subtask);
auto term_desc = new TerminalDescriptor{
subcommand, true, false,
{ pipe_fd, files_[1], files_[2] } // pipe_fd = 표준입력
};
files_[1] = pipe_fd; // 메인태스크의 표준출력은 서브태스크의 표준입력으로 연결
subtask_id = subtask
.InitContext(TaskTerminal, reinterpret_cast<int64_t>(term_desc))
.Wakeup()
.ID();
// 현재 레이어에서 태스크는 subtask 로 등록한다.
// 파이프를 사용할때는 키 입력 등의 이벤트를 오른쪽 태스크가 받도록 해야한다.
(*layer_task_map)[layer_id_] = subtask_id;
}
// ... 터미널 내장 커맨드, CallApp 등 실행
if (pipe_fd) {
// CallApp 종료 이후이기 때문에 더이상 메인태스크에서 표준출력으로 전송이 없음
pipe_fd->FinishWrite();
__asm__("cli");
auto [ ec, err ] = task_maanger->WaitFinish(subtask_id);
// 파이프 실행 끝나면 다시 메인태스크(터미널)로 이벤트 돌리기
(*layer_task_map)[layer_id_] = task_.ID();
__asm__("sti");
if (erro) {
Log(kWarn, "failed to wait finish: %s\n", err.Name());
}
exit_code = ec;
}
last_exit_code_ = exit_code;
files_[1] = original_stdout;
}
파이프 구조
태스크들은 표준 입출력을 PipeDescriptor로 변경해서 읽고 쓰게 되는데, PipeDescriptor는 태스크에 메시지를 주고받는 방식으로 구현되어 있다.
// 태스크 메시지 구조체에 pipe 메시지 추가
struct Message {
// ...
union {
struct {
char data[16]; // 최대 16바이트씩 전송
uint8_t len;
} pipe;
} arg;
};
size_t PipeDescriptor::Write(const void* buf, size_t len) {
auto bufc = reinterpret_cast<const char*>(buf);
Message msg{Message::kPipe};
size_t sent_bytes = 0;
while (sent_bytes < len) {
msg.arg.pipe.len = std::min(len - sent_bytes, sizeof(msg.arg.pipe.data));
memcpy(msg.arg.pipe.data, &bufc[sent_bytes], msg.arg.pipe.len);
sent_bytes += msg.arg.pipe.len;
__asm__("cli");
task_.SendMessage(msg);
__asm__("sti");
}
return len;
}
// 마지막 메시지를 전송했다는 알림을 보내는 함수
void PipeDescriptor::FinishWrite() {
Message msg{Message::kPipe};
msg.arg.pipe.len = 0; // 길이 0짜리 메시지 전송
__asm__("cli");
task_.SendMessage(msg);
__asm__("sti");
}
size_t PipeDescriptor::Read(void* buf, size_t len) {
if (len_ > 0) {
const size_t copy_bytes = std::min(len_, len);
memcpy(buf, data_, copy_bytes);
len_ -= copy_bytes;
memmove(data_, &data_[copy_bytes], len_);
return copy_bytes;
}
// 파이프가 닫혔다면 더이상 Read를 하지 않음
if (closed_) {
return 0;
}
while (true) {
__asm__("cli");
auto msg = task_.ReceiveMessage();
if (!msg) {
task_.Sleep();
continue;
}
__asm__("sti");
if (msg->type != Message::kPipe) {
continue;
}
// 길이 0짜리 메시지를 받으면 종료되는 것을 판단할 수 있음
if (msg->arg.pipe.len == 0) {
closed_ = true;
return 0;
}
const size_t copy_bytes = std::min<size_t>(msg->arg.pipe.len, len);
memcpy(buf, msg->arg.pipe.data, copy_bytes);
len_ = msg->arg.pipe.len - copy_bytes;
memcpy(data_, &msg->arg.pipe.data[copy_bytes], len_);
return copy_bytes;
}
}
태스크의 종료
현재는 noterm으로 명령을 실행하면 보이지 않는 TerminalTask가 생성되며, 명령어를 실행시키고 백그라운드에 터미널태스크는 큐에서 제거되지 않고 남아있게 된다.
태스크가 종료되면 TaskManager::Finish를 호출해서 현재 실행중인 태스크를 큐에서 제거하고 종료 큐에 종료한 태스크의 exit_code를 등록한다.
그리고 그 태스크가 종료되기를 기다리는 태스크는 Sleep 상태로 finish_waiter_ 큐에서 기다리고 있는데, 기다리는 녀석을 꺼내고 깨운다.
void TaskManager::Finish(int exit_code) {
Task* current_task = RotateCurrentRunQueue(true);
const auto task_id = current_task->ID();
auto it = std::find_if(
tasks_.begin(), tasks_.end(),
[current_task](const auto& t){ return t.get() == current_task; });
tasks_.erase(it);
// 종료 태스크에 등록
finish_tasks_[task_id] = exit_code;
// 이 태스크를 기다리는 태스크를 깨움
if (auto it = finish_waiter_.find(task_id); it != finish_waiter_.end()) {
auto waiter = it->second;
finish_waiter_.erase(it);
Wakeup(waiter);
}
RestoreContext(&CurrentTask().Context());
}
기다리는 함수는 WaitFinish 함수 호출을 해서 task_id 에 해당하는 태스크가 종료되는 것을 Sleep 상태로 기다린건데, finish_tasks_ 에 기다리던 태스크가 종료되면 exit_code를 얻어서 리턴된다.
WithError<int> TaskManager::WaitFinish(uint64_t task_id) {
int exit_code;
Task* current_task = &CurrentTask();
while (true) {
if (auto it = finish_tasks_.find(task_id); it != finish_tasks_.end()) {
exit_code = it->second;
finish_tasks_.erase(it);
break;
}
finish_waiter_[task_id] = current_task;
Sleep(current_task);
}
return { exit_code, MAKE_ERROR(Error::kSuccess) };
}