[{"content":" 디스크 I/O란 지금까지 만든 OS에는 프로세스, 메모리 관리, 시스템 콜이 있지만, 모든 데이터가 RAM 위에만 존재한다. 전원을 끄면 전부 사라진다. 영속적인 데이터 저장을 위해서는 디스크에 접근할 수 있어야 하고, 그러려면 디바이스 드라이버커널과 하드웨어 장치 사이의 추상화 계층. 커널의 상위 코드(파일 시스템 등)는 드라이버가 제공하는 통일된 인터페이스만 호출하면 되고, 장치별 레지스터 조작은 드라이버 내부에 캡슐화된다.가 필요하다.\n이 글에서는 QEMU의 가상 디스크 장치인 virtio-blk을 위한 드라이버를 구현한다.\nVirtio란 Virtio는 가상 장치를 위한 디바이스 인터페이스 표준이다. 실제 하드웨어(SATA, NVMe 등)마다 인터페이스가 제각각인데, 가상 환경에서는 Virtio라는 통일된 프로토콜을 사용해서 드라이버를 단순화한다. QEMU, Firecracker 등에서 널리 쓰인다.\nVirtio에는 Legacy와 Modern 두 가지 인터페이스가 있다. 이 구현에서는 좀 더 단순한 Legacy 인터페이스를 사용한다.\nVirtqueue — 드라이버와 장치가 통신하는 방법 Virtio 장치는 virtqueue드라이버(OS)와 장치(QEMU) 사이에서 공유하는 메모리 영역 기반의 큐. 드라이버가 요청을 넣고 장치가 처리 결과를 돌려주는 구조다.라는 자료구조를 통해 드라이버와 통신한다. virtqueue는 세 영역으로 구성된다.\n이름 주체 내용 Descriptor Table 드라이버 요청의 메모리 주소·크기를 기록하는 테이블 Available Ring원형 큐를 Ring이라고 부름. 드라이버 장치에 처리할 요청들을 등록하는 큐 Used Ring 장치 장치가 처리한 요청들을 기록한 큐 하나의 요청(예: 디스크 읽기)은 여러 디스크립터장치에게 전달할 메모리 영역을 기술하는 엔트리. 주소, 크기, 속성(읽기 전용/쓰기 가능), 다음 디스크립터 인덱스를 담고 있다. 하나의 요청을 여러 디스크립터로 나누면 영역마다 다른 속성을 줄 수 있다.를 체인으로 엮어서 표현한다. 이렇게 하면 디스크립터마다 다른 속성(읽기 전용/쓰기 가능)을 줄 수 있고, 메모리에 흩어진 데이터를 하나의 요청으로 묶을 수 있다(Scatter-Gather I/O).\nvirtqueue의 디스크 쓰기 요청의 전체 흐름은 이렇다.\n드라이버가 Descriptor Table에 요청 내용을 기록 디스크립터 체인의 헤드 인덱스를 Available Ring에 추가 드라이버가 장치에 \u0026ldquo;새 요청 있어\u0026quot;라고 알림 (MMIO 레지스터에 쓰기) 장치가 Available Ring에서 요청을 꺼내 처리 장치가 결과를 Used Ring에 기록하고 드라이버에게 알림 이 때, 우리 OS는 하드웨어 인터럽트 처리 방식이 구현되어 있지 않아서 busy-wait 방식으로 드라이버가 신호를 받는다. 실제 OS와의 차이점은 다음과 같다.\n실제 OS: 장치가 Used Ring 기록 -\u0026gt; 인터럽트 발생 -\u0026gt; 드라이버 핸들러 호출 우리 OS: 장치가 Used Ring 기록 -\u0026gt; (아무 알림 없음) -\u0026gt; 드라이버가 루프 돌며 직접 확인QEMU에 virtio-blk 장치 연결 먼저 테스트용 파일을 준비한다.\necho \u0026#34;Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ut magna consequat, cursus velit aliquam, scelerisque odio. Ut lorem eros, feugiat quis bibendum vitae, malesuada ac orci. Praesent eget quam non nunc fringilla cursus imperdiet non tellus. Aenean dictum lobortis turpis, non interdum leo rhoncus sed. Cras in tellus auctor, faucibus tortor ut, maximus metus. Praesent placerat ut magna non tristique. Pellentesque at nunc quis dui tempor vulputate. Vestibulum vitae massa orci. Mauris et tellus quis risus sagittis placerat. Integer lorem leo, feugiat sed molestie non, viverra a tellus.\u0026#34; \u0026gt; lorem.txtQEMU 실행 스크립트에 virtio-blk 장치를 추가한다.\nrun.sh Inline Side $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -kernel kernel.elf $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -drive id=drive0,file=lorem.txt,format=raw,if=none \\ -device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \\ -kernel kernel.elf 추가된 옵션은 다음과 같다.\n-drive id=drive0: drive0QEMU 내부에서 디스크를 식별하기 위한 이름. -device에서 drive=drive0으로 참조해서 장치와 디스크를 연결한다. 이름은 양쪽이 일치하기만 하면 아무거나 써도 된다.이라는 이름의 디스크를 정의한다. lorem.txt를 디스크 이미지로 사용하며, 이미지 형식은 raw이다. -device virtio-blk-device: drive0 디스크를 사용하는 virtio-blk 장치를 추가. bus=virtio-mmio-bus.0QEMU의 virt 머신에 미리 정의된 virtio 장치용 MMIO 버스. .0은 첫 번째 슬롯이며, 물리 주소 0x10001000에 매핑되어 있다. 코드에서 VIRTIO_BLK_PADDR을 0x10001000으로 정의한 이유가 이것이다. 옵션을 통해 해당 장치를 virtio-mmio 버스에 매핑함. C 매크로 및 구조체 정의 kernel.h에 virtio 관련 정의를 추가한다. 양이 많지만 대부분 virtio 사양서에 정의된 상수와 자료구조를 그대로 옮긴 것이다.\nkernel.h #define SECTOR_SIZE 512 #define VIRTQ_ENTRY_NUM 16 #define VIRTIO_DEVICE_BLK 2 #define VIRTIO_BLK_PADDR 0x10001000 #define VIRTIO_REG_MAGIC 0x00 #define VIRTIO_REG_VERSION 0x04 #define VIRTIO_REG_DEVICE_ID 0x08 #define VIRTIO_REG_PAGE_SIZE 0x28 #define VIRTIO_REG_QUEUE_SEL 0x30 #define VIRTIO_REG_QUEUE_NUM_MAX 0x34 #define VIRTIO_REG_QUEUE_NUM 0x38 #define VIRTIO_REG_QUEUE_PFN 0x40 #define VIRTIO_REG_QUEUE_READY 0x44 #define VIRTIO_REG_QUEUE_NOTIFY 0x50 #define VIRTIO_REG_DEVICE_STATUS 0x70 #define VIRTIO_REG_DEVICE_CONFIG 0x100 #define VIRTIO_STATUS_ACK 1 #define VIRTIO_STATUS_DRIVER 2 #define VIRTIO_STATUS_DRIVER_OK 4 #define VIRTQ_DESC_F_NEXT 1 #define VIRTQ_DESC_F_WRITE 2 #define VIRTQ_AVAIL_F_NO_INTERRUPT 1 #define VIRTIO_BLK_T_IN 0 #define VIRTIO_BLK_T_OUT 1 struct virtq_desc { uint64_t addr; // 데이터가 있는 물리 주소 uint32_t len; // 데이터 크기 uint16_t flags; // NEXT, WRITE 등 uint16_t next; // 체인의 다음 디스크립터 인덱스 } __attribute__((packed)); struct virtq_avail { uint16_t flags; // 인터럽트 제어 플래그 uint16_t index; // 다음에 쓸 위치 uint16_t ring[VIRTQ_ENTRY_NUM]; // 디스크립터 헤드 인덱스 배열 } __attribute__((packed)); struct virtq_used_elem { uint32_t id; // 처리한 디스크립터 체인의 헤드 인덱스 uint32_t len; // 장치가 실제로 쓴 바이트 수 } __attribute__((packed)); struct virtq_used { uint16_t flags; uint16_t index; // 장치가 다음에 쓸 위치 struct virtq_used_elem ring[VIRTQ_ENTRY_NUM]; } __attribute__((packed)); struct virtio_virtq { struct virtq_desc descs[VIRTQ_ENTRY_NUM]; // 디스크립터 테이블 struct virtq_avail avail; // Available Ring struct virtq_used used __attribute__((aligned(PAGE_SIZE))); // Used Ring (페이지 정렬 필요) int queue_index; // 이 큐의 번호 volatile uint16_t *used_index; // used.index를 가리키는 포인터 uint16_t last_used_index; // 드라이버가 마지막으로 확인한 used index } __attribute__((packed)); struct virtio_blk_req { uint32_t type; // 요청 타입 (IN=읽기, OUT=쓰기) uint32_t reserved; // 사양에서 예약된 필드 (0) uint64_t sector; // 접근할 섹터 번호 uint8_t data[512]; // 읽기/쓰기할 데이터 (1섹터 = 512바이트) uint8_t status; // 장치가 기록하는 처리 결과 (0=성공) } __attribute__((packed)); 매크로\nSECTOR_SIZE: 디스크의 최소 읽기/쓰기 단위. 한 번에 512바이트씩 접근 VIRTQ_ENTRY_NUM: virtqueue의 디스크립터 슬롯 개수, 최대 16개까지 VIRTIO_REG_*: MMIO 베이스 주소에서의 오프셋을 뜻하는 값 구조체\nvirtq_desc: 디스크립터 테이블 엔트리 구조체 virtq_avail: Available Ring 구현 구조체 virtq_used와 virtq_used_elem: Used Ring 구현 구조체 virtio_virtq: 디스크립터 테이블, Available Ring, Used Ring을 합친 전체 virtqueue 구조체 virtio_blk_req: 드라이버가 장치에 요청하는 request 구조체 __attribute__((packed)):컴파일러가 구조체 멤버 사이에 패딩을 삽입하면 드라이버와 장치가 서로 다른 오프셋에서 값을 읽게 되기 때문이다. MMIO 레지스터 접근 유틸리티 MMIO 레지스터란 장치의 제어/상태 정보가 담긴 메모리 주소로, 일반 RAM처럼 lw/sw로 접근하지만 실제로는 장치가 응답한다. 이 레지스터에 접근하기 위한 유틸리티 함수를 kernel.c에 추가한다.\nkernel.c uint32_t virtio_reg_read32(unsigned offset) { return *((volatile uint32_t *) (VIRTIO_BLK_PADDR + offset)); } uint64_t virtio_reg_read64(unsigned offset) { return *((volatile uint64_t *) (VIRTIO_BLK_PADDR + offset)); } void virtio_reg_write32(unsigned offset, uint32_t value) { *((volatile uint32_t *) (VIRTIO_BLK_PADDR + offset)) = value; } void virtio_reg_fetch_and_or32(unsigned offset, uint32_t value) { virtio_reg_write32(offset, virtio_reg_read32(offset) | value); } 포인터에 volatile이 반드시 필요하다. 일반 메모리와 달리 MMIO에서는 읽기/쓰기 자체가 부수 효과(장치에 명령 전송 등)를 일으킨다. volatile이 없으면 컴파일러가 \u0026ldquo;같은 주소를 두 번 읽는 건 불필요하다\u0026quot;고 판단해서 두 번째 읽기를 제거할 수 있다. 장치 상태가 변할 수 있는 MMIO에서는 치명적이다.\nMMIO 영역을 페이지 테이블에 매핑 커널이 MMIO 레지스터에 접근하려면 해당 물리 주소를 페이지 테이블에 매핑해야 한다.\nkernel.c — create_process Inline Side for (paddr_t paddr = (paddr_t) __kernel_base; paddr \u003c (paddr_t) __free_ram_end; paddr += PAGE_SIZE) map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X); for (paddr_t paddr = (paddr_t) __kernel_base; paddr \u003c (paddr_t) __free_ram_end; paddr += PAGE_SIZE) map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X); map_page(page_table, VIRTIO_BLK_PADDR, VIRTIO_BLK_PADDR, PAGE_R | PAGE_W); Virtqueue 초기화 kernel.c struct virtio_virtq *virtq_init(unsigned index) { paddr_t virtq_paddr = alloc_pages(align_up(sizeof(struct virtio_virtq), PAGE_SIZE) / PAGE_SIZE); struct virtio_virtq *vq = (struct virtio_virtq *) virtq_paddr; vq-\u0026gt;queue_index = index; vq-\u0026gt;used_index = (volatile uint16_t *) \u0026amp;vq-\u0026gt;used.index; // 큐 선택 virtio_reg_write32(VIRTIO_REG_QUEUE_SEL, index); // 디스크립터 개수 설정 virtio_reg_write32(VIRTIO_REG_QUEUE_NUM, VIRTQ_ENTRY_NUM); // 큐의 페이지 프레임 번호를 장치에 알림 virtio_reg_write32(VIRTIO_REG_QUEUE_PFN, virtq_paddr / PAGE_SIZE); return vq; } virtq_init은 virtqueue를 위한 메모리를 할당하여 할당된 물리 페이지 번호를 장치에게 알려주는 역할을 하는 함수이다. 과정은 다음과 같다.\nvirtqueue용 메모리 할당\n캐스팅된 vq의 멤버를 초기화\nMMIO 레지스터에 큐 정보 쓰기\nVirtio 장치 초기화 장치 초기화는 Virtio 사양에 정의된 핸드셰이크 절차를 따른다.\nkernel.c struct virtio_virtq *blk_request_vq; struct virtio_blk_req *blk_req; paddr_t blk_req_paddr; uint64_t blk_capacity; void virtio_blk_init(void) { if (virtio_reg_read32(VIRTIO_REG_MAGIC) != 0x74726976) PANIC(\u0026#34;virtio: invalid magic value\u0026#34;); if (virtio_reg_read32(VIRTIO_REG_VERSION) != 1) PANIC(\u0026#34;virtio: invalid version\u0026#34;); if (virtio_reg_read32(VIRTIO_REG_DEVICE_ID) != VIRTIO_DEVICE_BLK) PANIC(\u0026#34;virtio: invalid device id\u0026#34;); // 1. 장치 리셋 virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, 0); // 2. ACKNOWLEDGE: 장치를 발견함 virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_ACK); // 3. DRIVER: 이 장치를 제어할 수 있음 virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER); // 페이지 크기 설정 virtio_reg_write32(VIRTIO_REG_PAGE_SIZE, PAGE_SIZE); // Virtqueue 초기화 blk_request_vq = virtq_init(0); // 6. DRIVER_OK: 장치 사용 준비 완료 virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER_OK); // 디스크 용량 확인 blk_capacity = virtio_reg_read64(VIRTIO_REG_DEVICE_CONFIG + 0) * SECTOR_SIZE; printf(\u0026#34;virtio-blk: capacity is %d bytes\\n\u0026#34;, (int)blk_capacity); // 요청 버퍼 할당 blk_req_paddr = alloc_pages(align_up(sizeof(*blk_req), PAGE_SIZE) / PAGE_SIZE); blk_req = (struct virtio_blk_req *) blk_req_paddr; } 코드 과정은 다음과 같다.\n전역 변수 세팅 장치 검증 핸드 셰이크 디바이스 리셋 ACK 드라이버 상태 비트 세팅 virtqueue 초기화 드라이버 OK 디스크 용량 read, req 버퍼 할당 전체적인 패턴은 네트워크 핸드셰이크와 비슷하다. 매직 넘버·버전·디바이스 ID를 확인해서 올바른 장치인지 검증하고, 상태 비트를 순서대로 설정(리셋 -\u0026gt; ACK -\u0026gt; DRIVER -\u0026gt; DRIVER_OK)해서 장치를 활성화한다. OS는 장치 내부에서 실제로 어떤 일이 일어나는지 신경 쓸 필요 없이, 사양에 정의된 MMIO 레지스터에 값을 쓰기만 하면 된다.\nkernel_main에 초기화를 추가한다.\nkernel.c — kernel_main Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); ... void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); virtio_blk_init(); ... I/O 요청 보내기 이제 실제로 디스크를 읽고 쓰는 함수를 구현한다.\nkernel.c void virtq_kick(struct virtio_virtq *vq, int desc_index) { vq-\u0026gt;avail.ring[vq-\u0026gt;avail.index % VIRTQ_ENTRY_NUM] = desc_index; vq-\u0026gt;avail.index++; __sync_synchronize(); virtio_reg_write32(VIRTIO_REG_QUEUE_NOTIFY, vq-\u0026gt;queue_index); vq-\u0026gt;last_used_index++; } bool virtq_is_busy(struct virtio_virtq *vq) { return vq-\u0026gt;last_used_index != *vq-\u0026gt;used_index; } void read_write_disk(void *buf, unsigned sector, int is_write) { if (sector \u0026gt;= blk_capacity / SECTOR_SIZE) { printf(\u0026#34;virtio: tried to read/write sector=%d, but capacity is %d\\n\u0026#34;, sector, blk_capacity / SECTOR_SIZE); return; } // 요청 구성 blk_req-\u0026gt;sector = sector; blk_req-\u0026gt;type = is_write ? VIRTIO_BLK_T_OUT : VIRTIO_BLK_T_IN; if (is_write) memcpy(blk_req-\u0026gt;data, buf, SECTOR_SIZE); // 3개의 디스크립터로 체인 구성 struct virtio_virtq *vq = blk_request_vq; vq-\u0026gt;descs[0].addr = blk_req_paddr; vq-\u0026gt;descs[0].len = sizeof(uint32_t) * 2 + sizeof(uint64_t); // type(4바이트) + reserved(4바이트) + sector(8바이트) vq-\u0026gt;descs[0].flags = VIRTQ_DESC_F_NEXT; vq-\u0026gt;descs[0].next = 1; vq-\u0026gt;descs[1].addr = blk_req_paddr + offsetof(struct virtio_blk_req, data); vq-\u0026gt;descs[1].len = SECTOR_SIZE; // 512 vq-\u0026gt;descs[1].flags = VIRTQ_DESC_F_NEXT | (is_write ? 0 : VIRTQ_DESC_F_WRITE); vq-\u0026gt;descs[1].next = 2; vq-\u0026gt;descs[2].addr = blk_req_paddr + offsetof(struct virtio_blk_req, status); vq-\u0026gt;descs[2].len = sizeof(uint8_t); // 1 vq-\u0026gt;descs[2].flags = VIRTQ_DESC_F_WRITE; // 장치에 알리고 완료 대기 virtq_kick(vq, 0); while (virtq_is_busy(vq)) ; if (blk_req-\u0026gt;status != 0) { printf(\u0026#34;virtio: warn: failed to read/write sector=%d status=%d\\n\u0026#34;, sector, blk_req-\u0026gt;status); return; } if (!is_write) memcpy(buf, blk_req-\u0026gt;data, SECTOR_SIZE); } virtq_kick virtq_kick 함수는 장치에 새 요청이 있다는걸 알리는 함수로 코드 흐름은 다음과 같다.\nAvailable Ring에 디스크립터 헤드 인덱스 push and idx++ 메모리 배리어 __sync_synchronize() notify 레지스터에 queue_index(virtqueue 번호) 등록 last_used_index++ (virtq_is_busy에서 장치가 처리 끝냈는지 비교할 기준값) virtq_kick에서 __sync_synchronize()GCC/Clang의 빌트인 메모리 배리어 함수. 이 호출 이전의 모든 메모리 쓰기가 이 호출 이후의 메모리 접근보다 반드시 먼저 완료되도록 보장한다.를 호출하는 이유는, Available Ring에 대한 쓰기가 NOTIFY 레지스터에 대한 쓰기보다 반드시 먼저 장치에게 보여야 하기 때문이다. 이 배리어가 없으면 CPU나 컴파일러의 재배치(reordering)로 인해 장치가 아직 업데이트되지 않은 Available Ring을 읽을 수 있다.\nvirtq_is_busy virtq_is_busy 함수는 장치가 처리 중 인지 체크하는 함수이다.\nvq-\u0026gt;last_used_index != *vq-\u0026gt;used_index;에서 last_used_index와 vq-\u0026gt;used_index가 가리키는 값(역참조)이 같은지 체크하여 장치가 처리 중 인지를 체크한다.\nread_write_disk read_write_disk 함수는 실제 디스크 읽기/쓰기를 담당하는 함수로 코드 흐름은 다음과 같다.\n범위 검사: 요청한 섹터 번호가 디스크 용량을 초과하는지 체크 blk_req 채우기 디스크립터 체인 구성하기 (3개로) descs[0]: type + sector (장치가 읽기만 함) descs[1]: data[512] (읽기 시 WRITE 플래그 -\u0026gt; 장치가 여기에 씀) descs[2]: status (장치가 결과를 씀) virtq_kick로 장치에 요청이 있다고 알리기 virtq_is_busy로 처리가 완료될 때까지 대기하기 결과 확인: blk_req-\u0026gt;status가 0이 아니면 에러 읽기인 경우 데이터 복사: blk_req-\u0026gt;data 를 buf로 요청은 3개의 디스크립터 체인으로 구성된다. virtio_blk_req 구조체의 각 영역을 디스크립터 하나씩에 매핑하는 것이다.\n그림으로 보면 다음과 같다.\n왜 하나의 구조체를 3개로 나누는가? 디스크립터마다 **다른 속성(flags)**을 줄 수 있어야 하기 때문이다. 요청 헤더(type, sector)는 장치가 읽기만 하면 되지만, 데이터 영역은 읽기 작업 시 장치가 써야(VIRTQ_DESC_F_WRITE) 한다.\n현재 구현은 요청을 보낸 뒤 busy-wait장치가 처리를 끝낼 때까지 CPU가 무한 루프를 돌며 확인하는 방식. 단순하지만 CPU를 낭비한다. 실제 OS에서는 인터럽트를 사용해서 CPU를 다른 프로세스에 양보한다.으로 완료를 기다린다. 매번 링의 처음 3개 디스크립터만 쓰는 것도 이 때문이다. 실제 OS에서는 인터럽트 기반으로 비동기 처리하면서 동시에 여러 요청을 처리할 수 있도록 자유 디스크립터를 추적해야 한다. 즉, 위 코드에는 0, 1, 2를 지정했지만, 실제로는 빈 디스크립터 3개를 찾아서 next로 연결짓는 코드가 필요한 것이다.\n동작 확인 kernel.c - kernel_main Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); virtio_blk_init(); idle_proc = create_process(NULL, 0); idle_proc-\u003epid = 0; current_proc = idle_proc; create_process(_binary_shell_bin_start, (size_t) _binary_shell_bin_size); yield(); PANIC(\"switched to idle process\"); } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); virtio_blk_init(); char buf[SECTOR_SIZE]; read_write_disk(buf, 0, false); printf(\"first sector: %s\\n\", buf); strcpy(buf, \"hello from kernel!!!\\n\"); read_write_disk(buf, 0, true); idle_proc = create_process(NULL, 0); idle_proc-\u003epid = 0; current_proc = idle_proc; create_process(_binary_shell_bin_start, (size_t) _binary_shell_bin_size); yield(); PANIC(\"switched to idle process\"); } $ ./run.sh virtio-blk: capacity is 1024 bytes first sector: Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ut magna consequat, cursus velit aliquam, scelerisque odio. Ut lorem eros, feugiat quis bibendum vitae, malesuada ac orci. Praesent eget quam non nunc fringilla cursus imperdiet non tellus. Aenean dictum lobortis turpis, non interdum leo rhoncus sed. Cras in tellus auctor, faucibus tortor ut, maximus metus. Praesent placerat ut magna non tristique. Pellentesque at nunc quis dui tempor vulputate. Vestibulum vitae massa orci. Mauris et tellus quis ri��디스크 이미지인 lorem.txt의 내용이 출력된다. 쓰기 후에는 lorem.txt 파일의 첫 섹터가 덮어씌워진 것도 확인할 수 있다.\n마지막에 깨진 문자 ��를 볼 수 있는데 이는 buf는 512 바이트인데, lorem.txt는 그보다 길어서 buf 범위를 넘어서 \\0이 나올때까지 printf가 읽는 UB가 발생한다. 다른 스터디원은 Page Fault가 발생했다고 한다.\nhead lorem.txt hello from kernel!!! amet, consectetur adipiscing elit. In ut magna consequat, cursus velit aliquam, scelerisque odio. Ut lorem eros, feugiat quis bibendum vitae, malesuada ac orci. Praesent eget quam non nunc fringilla cursus imperdiet non tellus. Aenean dictum lobortis turpis, non interdum leo rhoncus sed. Cras in tellus auctor, faucibus tortor ut, maximus metus. Praesent placerat ut magna non tristique. Pellentesque at nunc quis dui tempor vulputate. Vestibulum vitae massa orci. Mauris et tellus quis risus sagittis placerat. Integer lorem leo, feugiat sed molestie non, viverra a tellus.정리 디바이스 드라이버는 OS와 디바이스를 연결해주는 매개체 역할을 한다. 드라이버가 직접 디스크 헤드를 움직이는 것이 아니라, MMIO 레지스터와 공유 메모리(virtqueue)를 통해 장치에게 \u0026ldquo;이 섹터 읽어줘\u0026quot;라고 부탁하고, 장치가 나머지 물리적 작업을 수행한다.\n이번 챕터에서 구현한 전체 흐름을 애니메이션으로 보면 다음과 같다.\nvirtio-blk I/O Flow 시뮬레이션 virtqueue Device Driver Descriptor Chain [0] Header (NEXT)\n[1] Data (NEXT|W)\n[2] Status (WRITE) Avail Ring idx: 0\nring[0]: ? Used Ring idx: 0\nring[0]: ? Virtio Device (QEMU) blk_req type: ? sector: ? data 영역 status: ? User buf [ ] read_write_disk() Prev Step 0 / 8 Next 다음 글에서는 이 read_write_disk를 활용해서 tar 기반 파일 시스템을 구현한다.\n참고 자료 OS in 1,000 Lines - 챕터 15: 디스크 I/O 로렘 입숨 [Operating System] Memory-mapped I/O Maps - HOOAI Virtio PCI Card Specification ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os09/","summary":"virtio 프로토콜과 virtqueue 구조 이해, MMIO 레지스터 접근, virtio-blk 드라이버 초기화, 디스크립터 체인을 통한 I/O 요청 구현까지 진행","title":"09. 디스크 I/O"},{"content":" 파일 시스템이란 이전 글 에서 virtio-blk 드라이버를 구현해 디스크를 섹터 단위로 읽고 쓸 수 있게 되었다. 하지만 read_write_disk는 \u0026ldquo;몇 번째 섹터를 읽어라\u0026quot;라는 저수준 인터페이스다. 사용자 입장에서는 \u0026ldquo;hello.txt를 열어서 내용을 보여줘\u0026quot;가 자연스럽다.\n섹터 번호와 파일 이름 사이의 간극을 메우는 것이 파일 시스템이다. 파일 시스템은 디스크 위에 파일 이름, 크기, 데이터 위치 등의 메타데이터를 구조화해서 저장하고, 이를 통해 이름 기반 접근을 가능하게 한다.\ntar를 파일 시스템으로 사용하기 실제 OS에서는 FAT, ext2, NTFS 같은 파일 시스템을 사용하지만, 이 튜토리얼에서는 tar 아카이브를 파일 시스템으로 사용한다. tar는 원래 자기 테이프(Tape ARchive)용으로 탄생한 포맷으로, 여러 파일을 순차적으로 나열하는 단순한 구조를 가지고 있다.\n+----------------+ | tar header | \u0026lt;- 파일 이름, 크기 등 메타데이터 +----------------+ | file data | \u0026lt;- 실제 파일 내용 +----------------+ | tar header | +----------------+ | file data | +----------------+ | ... |각 파일마다 \u0026ldquo;헤더 + 데이터\u0026rdquo; 쌍이 하나씩 붙는 구조다. FAT의 클러스터 체인이나 ext2의 inode 같은 복잡한 자료구조 없이, 앞에서부터 순서대로 읽으면 모든 파일을 찾을 수 있다.\ntar의 한계순차 접근에 최적화된 포맷이라 랜덤 접근에는 부적합하다. 특정 파일을 찾으려면 처음부터 헤더를 하나씩 탐색해야 한다. 교육 목적으로는 이상적이지만, 실제 OS에서 쓰기엔 성능이 부족하다. 여러 종류의 tar 포맷이 존재하는데, 이 구현에서는 ustar 포맷을 사용한다.\n디스크 이미지 생성 파일 시스템의 내용을 담을 disk 디렉토리를 만들고 파일을 넣는다.\nmkdir disk echo \u0026#39;Hello, OS!\u0026#39; \u0026gt; disk/hello.txt vim disk/meow.txt빌드 스크립트에 tar 이미지 생성과 QEMU 디스크 연결을 추가한다.\nrun.sh Inline Side $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -drive id=drive0,file=lorem.txt,format=raw,if=none \\ -device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \\ -kernel kernel.elf (cd disk \u0026\u0026 tar cf ../disk.tar --format=ustar *.txt) $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -drive id=drive0,file=disk.tar,format=raw,if=none \\ -device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \\ -kernel kernel.elf tar cf는 tar 파일을 생성(create)하는 명령이고, --format=ustar는 ustar 포맷을 지정한다. 괄호 (...)는 서브셸을 만들어서 cd가 스크립트의 나머지 부분에 영향을 주지 않도록 한다.\n이전 글에서 lorem.txt를 디스크 이미지로 사용했는데, 이제 disk.tar로 교체한다. QEMU 옵션 자체는 동일하고 파일만 바뀐 것이다.\n데이터 구조 정의 kernel.h에 tar 헤더 구조체와 파일 관리 구조체를 정의한다.\nkernel.h #define FILES_MAX 2 #define DISK_MAX_SIZE align_up(sizeof(struct file) * FILES_MAX, SECTOR_SIZE) struct tar_header { char name[100]; char mode[8]; char uid[8]; char gid[8]; char size[12]; char mtime[12]; char checksum[8]; char type; char linkname[100]; char magic[6]; char version[2]; char uname[32]; char gname[32]; char devmajor[8]; char devminor[8]; char prefix[155]; char padding[12]; char data[]; // 헤더 뒤에 이어지는 데이터 영역을 가리키는 flexible array member } __attribute__((packed)); struct file { bool in_use; // 이 파일 엔트리가 사용 중인지 char name[100]; // 파일 이름 char data[1024]; // 파일 내용 size_t size; // 파일 크기 }; tar_header는 ustar 포맷의 헤더를 그대로 C 구조체로 옮긴 것이다. data[]는 flexible array memberC99에서 도입된 기능으로, 구조체의 마지막 멤버를 크기 없는 배열로 선언하면 구조체 바로 뒤의 메모리를 배열처럼 접근할 수 있다. 별도의 메모리를 할당하는 게 아니라 헤더 바로 다음에 있는 파일 데이터를 가리키는 포인터 역할을 한다.로, tar 헤더 바로 뒤에 이어지는 파일 데이터를 가리킨다.\nstruct file은 메모리에 로드된 파일을 관리하는 구조체다. 이 구현에서는 부팅 시 디스크의 모든 파일을 메모리로 읽어들이는 방식을 쓴다.\n파일 시스템 읽기 kernel.c struct file files[FILES_MAX]; uint8_t disk[DISK_MAX_SIZE]; int oct2int(char *oct, int len) { int dec = 0; for (int i = 0; i \u0026lt; len; i++) { if (oct[i] \u0026lt; \u0026#39;0\u0026#39; || oct[i] \u0026gt; \u0026#39;7\u0026#39;) break; dec = dec * 8 + (oct[i] - \u0026#39;0\u0026#39;); } return dec; } void fs_init(void) { for (unsigned sector = 0; sector \u0026lt; sizeof(disk) / SECTOR_SIZE; sector++) read_write_disk(\u0026amp;disk[sector * SECTOR_SIZE], sector, false); unsigned off = 0; for (int i = 0; i \u0026lt; FILES_MAX; i++) { struct tar_header *header = (struct tar_header *) \u0026amp;disk[off]; if (header-\u0026gt;name[0] == \u0026#39;\\0\u0026#39;) break; if (strcmp(header-\u0026gt;magic, \u0026#34;ustar\u0026#34;) != 0) PANIC(\u0026#34;invalid tar header: magic=\\\u0026#34;%s\\\u0026#34;\u0026#34;, header-\u0026gt;magic); int filesz = oct2int(header-\u0026gt;size, sizeof(header-\u0026gt;size)); struct file *file = \u0026amp;files[i]; file-\u0026gt;in_use = true; strcpy(file-\u0026gt;name, header-\u0026gt;name); memcpy(file-\u0026gt;data, header-\u0026gt;data, filesz); file-\u0026gt;size = filesz; printf(\u0026#34;file: %s, size=%d\\n\u0026#34;, file-\u0026gt;name, file-\u0026gt;size); off += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE); } } fs_init의 흐름은 다음과 같다.\nread_write_disk로 디스크 전체를 disk 버퍼에 로드 disk 버퍼를 순회하면서 tar 헤더를 파싱 각 파일의 이름, 크기, 데이터를 files[] 배열에 복사 disk와 files가 스택이 아닌 정적 변수로 선언된 이유는 스택 크기가 제한적이기 때문이다.\n여기서 주의할 점은 tar 헤더의 숫자 필드(size 등)가 8진수 문자열이라는 것이다. \u0026quot;000644\u0026quot;처럼 생겨서 10진수로 착각하기 쉽지만, 실제로는 8진수다. oct2int는 이 8진수 문자열을 정수로 변환한다.\noff += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE)는 다음 파일의 헤더 위치를 계산한다. tar에서 각 파일 엔트리는 섹터 크기(512바이트) 단위로 정렬되어 있기 때문에 align_up이 필요하다.\nkernel_main에서 virtio_blk_init() 다음에 fs_init()을 호출한다.\nkernel.c - kernel_main Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); virtio_blk_init(); char buf[SECTOR_SIZE]; read_write_disk(buf, 0, false); printf(\"first sector: %s\\n\", buf); strcpy(buf, \"hello from kernel!!!\\n\"); read_write_disk(buf, 0, true); ... void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); virtio_blk_init(); fs_init(); ... 이전 글에서 테스트용으로 추가했던 read_write_disk 호출도 제거한다.\n파일 읽기 테스트 $ ./run.sh virtio-blk: capacity is 10240 bytes file: hello.txt, size=11 file: meow.txt, size=0disk 디렉토리에 넣었던 파일들이 정상적으로 인식된다.\n파일 시스템 쓰기 fs_flush는 메모리의 files[]를 tar 포맷으로 직렬화해서 디스크에 기록하는 함수다.\nkernel.c void fs_flush(void) { // files[]를 disk 버퍼에 tar 형식으로 구성 memset(disk, 0, sizeof(disk)); unsigned off = 0; for (int file_i = 0; file_i \u0026lt; FILES_MAX; file_i++) { struct file *file = \u0026amp;files[file_i]; if (!file-\u0026gt;in_use) continue; struct tar_header *header = (struct tar_header *) \u0026amp;disk[off]; memset(header, 0, sizeof(*header)); strcpy(header-\u0026gt;name, file-\u0026gt;name); strcpy(header-\u0026gt;mode, \u0026#34;000644\u0026#34;); strcpy(header-\u0026gt;magic, \u0026#34;ustar\u0026#34;); strcpy(header-\u0026gt;version, \u0026#34;00\u0026#34;); header-\u0026gt;type = \u0026#39;0\u0026#39;; // 파일 크기를 8진수 문자열로 변환 int filesz = file-\u0026gt;size; for (int i = sizeof(header-\u0026gt;size); i \u0026gt; 0; i--) { header-\u0026gt;size[i - 1] = (filesz % 8) + \u0026#39;0\u0026#39;; filesz /= 8; } // 체크섬 계산 int checksum = \u0026#39; \u0026#39; * sizeof(header-\u0026gt;checksum); for (unsigned i = 0; i \u0026lt; sizeof(struct tar_header); i++) checksum += (unsigned char) disk[off + i]; for (int i = 5; i \u0026gt;= 0; i--) { header-\u0026gt;checksum[i] = (checksum % 8) + \u0026#39;0\u0026#39;; checksum /= 8; } // 파일 데이터 복사 memcpy(header-\u0026gt;data, file-\u0026gt;data, file-\u0026gt;size); off += align_up(sizeof(struct tar_header) + file-\u0026gt;size, SECTOR_SIZE); } // disk 버퍼를 virtio-blk에 기록 for (unsigned sector = 0; sector \u0026lt; sizeof(disk) / SECTOR_SIZE; sector++) read_write_disk(\u0026amp;disk[sector * SECTOR_SIZE], sector, true); printf(\u0026#34;wrote %d bytes to disk\\n\u0026#34;, sizeof(disk)); } 흐름은 fs_init의 역순이다.\ndisk 버퍼를 0으로 초기화 files[]를 순회하면서 tar 헤더를 구성 (이름, 모드, 매직 넘버, 파일 크기를 8진수로 변환, 체크섬 계산) 파일 데이터를 헤더 뒤에 복사 read_write_disk로 disk 버퍼 전체를 디스크에 기록 체크섬 계산에서 int checksum = ' ' * sizeof(header-\u0026gt;checksum)로 한 것은 tar 사양에서 체크섬 필드 자체는 공백(0x20)으로 채운 것으로 간주하고 헤더 전체 바이트를 더하도록 정의되어 있기 때문이다.\n시스템 콜 추가 파일 시스템의 읽기/쓰기를 애플리케이션에서 사용할 수 있도록 시스템 콜을 추가한다.\ncommon.h #define SYS_READFILE 4 #define SYS_WRITEFILE 5 user.c int readfile(const char *filename, char *buf, int len) { return syscall(SYS_READFILE, (int) filename, (int) buf, len); } int writefile(const char *filename, const char *buf, int len) { return syscall(SYS_WRITEFILE, (int) filename, (int) buf, len); } user.h int readfile(const char *filename, char *buf, int len); int writefile(const char *filename, const char *buf, int len); 이 시스템 콜은 파일 이름을 직접 인자로 받는다. POSIX의 read/write가 파일 디스크립터open()이 반환하는 정수로, 커널 내부의 열린 파일 테이블 인덱스다. 파일 디스크립터를 쓰면 같은 파일을 여러 번 열어 각각 독립적인 오프셋을 유지할 수 있고, 파일 이름 해석 비용도 한 번만 지불하면 된다.를 사용하는 것과 대비되는 단순화된 설계다.\n커널 측 구현 kernel.c struct file *fs_lookup(const char *filename) { for (int i = 0; i \u0026lt; FILES_MAX; i++) { struct file *file = \u0026amp;files[i]; if (!strcmp(file-\u0026gt;name, filename)) return file; } return NULL; } fs_lookup은 files[] 배열을 선형 탐색해서 이름이 일치하는 파일을 찾는다.\nkernel.c - handle_syscall Inline Side void handle_syscall(struct trap_frame *f) { switch (f-\u003ea3) { case SYS_PUTCHAR: ... case SYS_GETCHAR: ... case SYS_EXIT: ... default: PANIC(\"unexpected syscall a3=%x\\n\", f-\u003ea3); } } void handle_syscall(struct trap_frame *f) { switch (f-\u003ea3) { case SYS_PUTCHAR: ... case SYS_GETCHAR: ... case SYS_EXIT: ... case SYS_READFILE: case SYS_WRITEFILE: { const char *filename = (const char *) f-\u003ea0; char *buf = (char *) f-\u003ea1; int len = f-\u003ea2; struct file *file = fs_lookup(filename); if (!file) { printf(\"file not found: %s\\n\", filename); f-\u003ea0 = -1; break; } if (len \u003e (int) sizeof(file-\u003edata)) len = file-\u003esize; if (f-\u003ea3 == SYS_WRITEFILE) { memcpy(file-\u003edata, buf, len); file-\u003esize = len; fs_flush(); } else { memcpy(buf, file-\u003edata, len); } f-\u003ea0 = len; break; } default: PANIC(\"unexpected syscall a3=%x\\n\", f-\u003ea3); } } 읽기와 쓰기 로직이 거의 동일하므로 하나의 case에서 처리한다. 읽기는 file-\u0026gt;data를 사용자 버퍼로 복사하고, 쓰기는 반대로 복사한 뒤 fs_flush()로 디스크에 반영한다.\nuser pointer 보안 문제여기서는 사용자가 넘긴 포인터(filename, buf)를 커널이 그대로 신뢰하고 있다. 악의적인 애플리케이션이 커널 메모리 주소를 넘기면, 시스템 콜을 통해 커널 데이터를 읽거나 덮어쓸 수 있다. 실제 OS에서는 user pointer를 반드시 검증한 뒤 사용해야 한다. 셸에 readfile/writefile 명령 추가 셸에서 파일 읽기/쓰기를 테스트하기 위해, 하드코딩된 hello.txt를 대상으로 하는 명령어를 추가한다.\nshell.c Inline Side if (strcmp(cmdline, \"hello\") == 0) printf(\"Hello world from shell!\\n\"); else if (strcmp(cmdline, \"exit\") == 0) exit(); else printf(\"unknown command: %s\\n\", cmdline); if (strcmp(cmdline, \"hello\") == 0) printf(\"Hello world from shell!\\n\"); else if (strcmp(cmdline, \"exit\") == 0) exit(); else if (strcmp(cmdline, \"readfile\") == 0) { char buf[128]; int len = readfile(\"hello.txt\", buf, sizeof(buf)); buf[len] = '\\0'; printf(\"%s\\n\", buf); } else if (strcmp(cmdline, \"writefile\") == 0) writefile(\"hello.txt\", \"Hello from shell!\\n\", 19); else printf(\"unknown command: %s\\n\", cmdline); Page Fault: SUM 비트 문제 셸에 명령을 추가하고 실행하면, 예상과 달리 Page Fault가 발생한다.\n$ ./run.sh \u0026gt; readfile PANIC: kernel.c:152: unexpected trap scause=0000000d, stval=010004ed, sepc=8020186ascause=0xd는 Load Page Fault다. stval=0x010004ed은 폴트가 발생한 가상 주소, sepc=0x8020186a는 폴트를 일으킨 명령어 주소다.\nllvm-objdump로 확인하면 strcmp 함수 내부에서 폴트가 발생한 것을 알 수 있다.\n$ llvm-objdump -d kernel.elf 80201862 \u0026lt;strcmp\u0026gt;: 80201862: 03 46 05 00 lbu a2, 0(a0) 80201866: 15 c2 beqz a2, 0x8020188a \u0026lt;strcmp+0x28\u0026gt; 80201868: 05 05 addi a0, a0, 1 8020186a: 83 c6 05 00 lbu a3, 0(a1) \u0026lt;- 여기서 page faultQEMU 모니터에서 stval=010004ed의 페이지 테이블인 0x01000000부분을 확인하면 해당 가상 주소는 정상적으로 매핑되어 있고, rwxu 권한도 있다.\n(qemu) info mem vaddr paddr size attr -------- ---------------- -------- ------- 01000000 000000008026d000 00001000 rwxu-a-매핑도 정상, 권한도 정상인데 왜 Page Fault가 발생할까? 원인은 RISC-V의 SUM(Supervisor User Memory access) 비트다.\nSUM 비트란 RISC-V에서는 S-Mode(커널)가 U-Mode(사용자) 페이지에 접근할 수 있는지를 sstatus CSR의 SUM 비트로 제어한다. SUM 비트가 0이면, 커널 코드가 사용자 페이지를 읽거나 쓸 수 없다.\n이건 의도적인 안전장치다. 커널이 실수로 사용자 메모리를 참조하는 버그를 방지하기 위함이다.\n시스템 콜에서 fs_lookup(filename)이 호출되면, filename은 사용자 공간의 문자열이다. 커널의 strcmp가 이 주소를 읽으려 할 때 SUM 비트가 0이라 Page Fault가 발생한 것이다.\n해결 sstatus의 SUM 비트를 사용자 공간 진입 시 설정하면 된다.\nkernel.h #define SSTATUS_SUM (1 \u0026lt;\u0026lt; 18) kernel.c - user_entry Inline Side __attribute__((naked)) void user_entry(void) { __asm__ __volatile__( \"csrw sepc, %[sepc]\\n\" \"csrw sstatus, %[sstatus]\\n\" \"sret\\n\" : : [sepc] \"r\" (USER_BASE), [sstatus] \"r\" (SSTATUS_SPIE) ); } __attribute__((naked)) void user_entry(void) { __asm__ __volatile__( \"csrw sepc, %[sepc]\\n\" \"csrw sstatus, %[sstatus]\\n\" \"sret\\n\" : : [sepc] \"r\" (USER_BASE), [sstatus] \"r\" (SSTATUS_SPIE | SSTATUS_SUM) ); } SSTATUS_SPIE에 SSTATUS_SUM을 OR 해주면 된다. 이제 커널이 시스템 콜 처리 중 사용자 메모리에 접근할 수 있다.\n디버깅 팁이런 종류의 문제는 CPU가 상세한 에러 코드를 주지 않아서 원인을 좁히기 어렵다. RISC-V Privileged Specification을 읽거나, QEMU 소스 코드에서 Page Fault 판정 로직을 직접 추적하는 것이 효과적이다. 동작 확인 파일 읽기 $ ./run.sh \u0026gt; readfile Hello, OS!hello.txt에 미리 적어둔 내용이 출력된다.\n파일 쓰기 \u0026gt; writefile wrote 2560 bytes to diskQEMU를 종료한 뒤 disk.tar를 풀어보면 파일이 업데이트된 것을 확인할 수 있다.\nmkdir tmp cd tmp tar xf ../disk.tar ls -alh total 12K drwxr-xr-x 2 leejw leejw 4.0K Apr 5 19:20 . drwxr-xr-x 6 leejw leejw 4.0K Apr 5 19:20 .. -rw-r--r-- 1 leejw leejw 19 Jan 1 1970 hello.txt -rw-r--r-- 1 leejw leejw 0 Jan 1 1970 meow.txt cat hello.txt Hello from shell!셸에서 쓴 \u0026quot;Hello from shell!\\n\u0026quot;이 실제 디스크 이미지에 반영되었다.\n정리 이전 글의 virtio-blk 드라이버(read_write_disk) 위에 tar 파싱/직렬화 계층을 얹고, 그 위에 시스템 콜 인터페이스를 붙인 것이다. 계층 구조로 보면 이렇다.\n┌──────────────────────┐ │ 셸 (readfile 명령) │ ← 사용자 공간 ├──────────────────────┤ │ syscall (ecall) │ ├──────────────────────┤ │ 파일 시스템 │ ← fs_init, fs_flush, fs_lookup │ (tar 파싱/직렬화) │ ├──────────────────────┤ │ 블록 디바이스 드라이버│ ← read_write_disk │ (virtio-blk) │ ├──────────────────────┤ │ QEMU 가상 디스크 │ ← 하드웨어 └──────────────────────┘이것으로 \u0026ldquo;OS in 1,000 Lines\u0026rdquo; 튜토리얼의 핵심 구현이 모두 끝났다!!\n부트로더, 메모리 관리, 프로세스, 시스템 콜, 디바이스 드라이버, 파일 시스템 등 OS의 핵심 기능을 1,000줄 안에 담았다.\nNEXT? 아마 다음으로 하게 될 건 xv6 일 것 같다. 6.1810: Operating System Engineering 을 따라가면서 과제를 수행해 보려고 한다.\n참고 자료 OS in 1,000 Lines - 챕터 16: 파일 시스템 Wikipedia - tar (computing): UStar format ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os10/","summary":"tar(ustar) 기반 파일 시스템 설계, fs_init/fs_flush 구현, readfile/writefile 시스템 콜 추가, SUM 비트 문제 해결까지 진행","title":"10. 파일 시스템"},{"content":" 애플리케이션 이전 챕터까지 구현한 페이지 테이블 덕분에, 이제 커널과 애플리케이션을 독립된 가상 주소 공간에 배치할 수 있다. 이번 챕터에서는 커널 위에서 동작할 첫 번째 애플리케이션을 준비한다. 목표는 세 가지다.\n애플리케이션용 링커 스크립트(user.ld) 작성 유저랜드 라이브러리(user.c) 구현 빌드 파이프라인을 통해 애플리케이션 바이너리를 커널 이미지 안에 내장 링커 스크립트 (user.ld) 애플리케이션은 커널과 별도의 링커 스크립트를 사용한다.\nuser.ld ENTRY(start) SECTIONS { . = 0x1000000; .text :{ KEEP(*(.text.start)); *(.text .text.*); } .rodata : ALIGN(4) { *(.rodata .rodata.*); } .data : ALIGN(4) { *(.data .data.*); } .bss : ALIGN(4) { *(.bss .bss.* .sbss .sbss.*); . = ALIGN(16); . += 64 * 1024; /* 64KB */ __stack_top = .; ASSERT(. \u0026lt; 0x1800000, \u0026#34;too large executable\u0026#34;); } } 커널 링커 스크립트(kernel.ld)와 구조는 동일하지만, 두 가지가 다르다.\n베이스 주소: 커널은 0x80200000에서 시작하지만, 애플리케이션은 0x1000000에서 시작한다. 이전 챕터에서 페이지 테이블로 두 주소 공간을 분리했기 때문에 같은 가상 주소 범위를 써도 충돌하지 않지만, 여기서는 명확하게 다른 범위를 사용한다.\n스택을 .bss 안에 배치: 커널은 .bss 바깥에 스택을 정의하지만, 애플리케이션은 .bss 안에 넣는다. 이유는 빌드 파이프라인에서 objcopy -O binary로 ELF를 순수 바이너리로 변환할 때, 실제 바이트가 없는 섹션(.bss)은 출력에서 생략되기 때문이다. 스택이 .bss 바깥에 있으면 바이너리에서 통째로 사라진다. 스택을 .bss 안에 넣고 나중에 --set-section-flags .bss=alloc,contents로 강제로 포함시키는 방식을 쓴다.\nASSERT는 링크 시점에 조건을 검사하는 지시어로, 실행 파일이 0x1800000을 넘으면 링크를 실패시킨다. 컴파일이 성공해도 실행 파일이 너무 커지는 실수를 빌드 타임에 잡을 수 있다.\n유저랜드 라이브러리 (user.c, user.h) 애플리케이션마다 공통으로 필요한 기반 코드를 user.c로 분리한다. 리눅스로 치면 crt0.o와 libc 일부에 해당한다.\nuser.c #include \u0026#34;user.h\u0026#34; extern char __stack_top[]; __attribute__((noreturn)) void exit(void) { for (;;); } void putchar(char c) { /* TODO */ } __attribute__((section(\u0026#34;.text.start\u0026#34;))) __attribute__((naked)) void start(void) { __asm__ __volatile__( \u0026#34;mv sp, %[stack_top] \\n\u0026#34; \u0026#34;call main \\n\u0026#34; \u0026#34;call exit \\n\u0026#34; :: [stack_top] \u0026#34;r\u0026#34; (__stack_top) ); } start 함수는 커널의 boot 함수와 역할이 동일하다. .text.start 섹션에 배치되어 링커 스크립트의 KEEP(*(.text.start))에 의해 실행 파일 맨 앞에 고정되고, 스택 포인터를 __stack_top으로 설정한 뒤 main을 호출한다. main이 반환하면 exit를 호출한다.\nexit는 __attribute__((noreturn))컴파일러에게 '이 함수는 절대 반환하지 않는다'고 알려주는 힌트. 실행 동작을 바꾸는 게 아니라, noreturn 이후 코드에 대한 unreachable 경고 제거와 컴파일러 최적화(함수 호출 후 에필로그 생략 등)를 위한 것이다.로 선언되어 있고, 현재 구현은 무한 루프다. 아직 시스템 콜이 없어서 커널에 \u0026ldquo;프로세스 종료\u0026quot;를 알릴 방법 자체가 없기 때문에 임시 구현으로 남겨둔 것이다. 챕터 13~14에서 유저 모드와 시스템 콜이 구현되면 제대로 된 exit로 교체된다.\nputchar도 마찬가지로 아직 구현이 없다. common.c의 printf가 putchar를 참조하기 때문에 링크 오류를 막기 위해 선언만 해둔다.\n.bss 초기화 코드를 넣지 않은 이유는, 커널의 alloc_pages가 이미 물리 메모리를 0으로 채워주기 때문이다. 실제 OS들도 같은 이유(다른 프로세스가 사용하던 민감 정보 유출 방지)로 새 프로세스에 할당하는 메모리를 0으로 초기화한다.\nuser.h #pragma once #include \u0026#34;common.h\u0026#34; __attribute__((noreturn)) void exit(void); void putchar(char ch); 첫 번째 애플리케이션 (shell.c) user.c는 모든 애플리케이션에 공통인 기반 코드고, shell.c는 이 애플리케이션만의 로직을 담는다. 아직 문자 출력 방법이 없으므로 단순 무한 루프로 시작한다.\nshell.c #include \u0026#34;user.h\u0026#34; void main(void) { for (;;); } 빌드 파이프라인 (run.sh) 애플리케이션은 커널과 별도로 빌드한 뒤, 최종적으로 커널 이미지 안에 내장(embed)된다. 전체 흐름은 다음과 같다.\nshell.c ─┐ user.c ─┼───▶ shell.elf ───▶ shell.bin ───▶ shell.bin.o ─┐ common.c ─┘ | kernel.c ────────────────────────────────────────────────────┴─▶ kernel.elf run.sh Inline Side #!/bin/bash set -xue QEMU=qemu-system-riscv32 # clang 경로와 컴파일 옵션 CC=/usr/bin/clang # Ubuntu 등 환경에 따라 경로 조정: CC=clang CFLAGS=\"-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fuse-ld=lld -fno-stack-protector -ffreestanding -nostdlib\" # 커널 빌드 $CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \\ kernel.c common.c # QEMU 실행 $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -kernel kernel.elf #!/bin/bash set -xue QEMU=qemu-system-riscv32 OBJCOPY=/usr/bin/llvm-objcopy # clang 경로와 컴파일 옵션 CC=/usr/bin/clang # Ubuntu 등 환경에 따라 경로 조정: CC=clang CFLAGS=\"-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fuse-ld=lld -fno-stack-protector -ffreestanding -nostdlib\" # 애플리케이션 컴파일 \u0026 링크 $CC $CFLAGS -Wl,-Tuser.ld -Wl,-Map=shell.map -o shell.elf shell.c user.c common.c # ELF -\u003e 순수 바이너리 $OBJCOPY --set-section-flags .bss=alloc,contents -O binary shell.elf shell.bin # 바이너리 -\u003e 링크 가능한 오브젝트 $OBJCOPY -Ibinary -Oelf32-littleriscv shell.bin shell.bin.o # 커널 빌드 $CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \\ kernel.c common.c shell.bin.o # QEMU 실행 $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -kernel kernel.elf 처음 $CC 명령은 커널 빌드와 비슷한데 C 파일들을 컴파일하고, user.ld 링커 스크립트를 사용해 링킹한다.\n첫 번째 $OBJCOPY 명령은 ELF 형식의 실행 파일(shell.elf)을 실제 메모리 내용만 포함하는 바이너리(shell.bin)로 변환한다. 우리는 단순히 이 바이너리 파일을 메모리에 로드해 애플리케이션을 실행할 것이다. 일반적인 OS에서는 ELF 같은 형식을 사용해, 메모리 매핑 정보와 실제 메모리 내용을 분리해서 다루지만, 여기서는 단순화를 위해 바이너리만 다룰 것이다.\n두 번째 $OBJCOPY 명령은 이 바이너리(shell.bin)를 C 언어에 임베드할 수 있는 오브젝트(shell.bin.o)로 변환합니다. 이 파일 안에 어떤 심볼이 들어있는지 llvm-nm 명령으로 확인해보면\n$ llvm-nm shell.bin.o 000102d0 D _binary_shell_bin_end 000102d0 A _binary_shell_bin_size 00000000 D _binary_shell_bin_start_binary_라는 접두사 뒤에 파일 이름이 오고, 그 다음에 start, end, size가 붙는다. 이 심볼들은 각각 바이너리 내용의 시작, 끝, 크기를 의미한다.\n_binary_shell_bin_size에는 파일 크기가 들어있는데 주소값으로 파일 크기가 들어있다. llvm-nm으로 확인하면:\n$ llvm-nm shell.bin.o | grep _binary_shell_bin_size 000102d0 A _binary_shell_bin_size $ ls -al shell.bin -rwxr-xr-x 1 leejw leejw 66256 Mar 31 20:13 shell.bin $ python3 -c \u0026#39;print(0x102d0)\u0026#39; 66256llvm-nm 출력의 첫 번째 열은 심볼의 주소를 나타냅니다. 여기서 102d0(16진수)는 실제 파일 크기와 일치합니다. A(두 번째 열)는 이 심볼이 링커에 의해 주소가 재배치되지 않는 \u0026lsquo;절대(Absolute)\u0026rsquo; 심볼이라는 뜻입니다. 즉, 파일 크기를 \u0026lsquo;주소\u0026rsquo; 형태로 박아놓은 것입니다.\nchar _binary_shell_bin_size[] 같은 식으로 정의하면, 일반 포인터처럼 보일 수 있지만 실제로는 그 값이 \u0026lsquo;파일 크기\u0026rsquo;를 담은 주소로 간주되어, 캐스팅하면 파일 크기를 얻게 됩니다.\n마지막으로, 커널 컴파일 시 shell.bin.o를 함께 링크하면, 첫 번째 애플리케이션의 실행 파일이 커널 이미지 내부에 임베드됩니다.\n디스어셈블 확인 llvm-objdump -d shell.elf로 확인하면 start 함수가 정확히 0x1000000에 배치된 것을 볼 수 있다.\n$ llvm-objdump -d shell.elf 01000000 \u0026lt;start\u0026gt;: 1000000: 37 05 01 01 lui a0, 4112 1000004: 13 05 05 26 addi a0, a0, 608 1000008: 2a 81 mv sp, a0 100000a: 19 20 jal 0x1000010 \u0026lt;main\u0026gt; 100000c: 29 20 jal 0x1000016 \u0026lt;exit\u0026gt; 01000010 \u0026lt;main\u0026gt;: 1000010: 01 a0 j 0x1000010 \u0026lt;main\u0026gt; 01000016 \u0026lt;exit\u0026gt;: 1000016: 01 a0 j 0x1000016 \u0026lt;exit\u0026gt;.text.start 섹션이 실행 파일 맨 앞에 오고, main과 exit 모두 현재는 자기 자신으로 점프하는 무한 루프 한 줄짜리 코드임을 확인할 수 있다.\n유저 모드 위에서 shell.bin을 커널 이미지 안에 내장했다. 이번 챕터에서는 이 바이너리를 실제로 메모리에 올리고 실행한다. 목표는 두 가지다.\ncreate_process를 수정해 바이너리를 페이지 단위로 복사하고 유저 주소 공간에 매핑 user_entry에서 sret으로 U-Mode 전환 실행 파일을 메모리에 올리기 먼저 애플리케이션의 베이스 주소를 상수로 정의한다. user.ld에서 설정한 0x1000000과 반드시 일치해야 한다.\nkernel.h #define USER_BASE 0x1000000 // user.ld의 베이스 주소와 일치해야 함 다음으로 create_process를 수정한다. 기존에는 커널 함수 주소(pc)만 받았지만, 이제 바이너리 포인터(image)와 크기(image_size)를 추가로 받는다.\nkernel.c Inline Side struct process *create_process(uint32_t pc) { ... *--sp = (uint32_t) pc; // ra uint32_t *page_table = (uint32_t *) alloc_pages(1); // 커널 페이지 매핑 for (paddr_t paddr = (paddr_t) __kernel_base; paddr \u003c (paddr_t) __free_ram_end; paddr += PAGE_SIZE) map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X); proc-\u003epid = i + 1; proc-\u003estate = PROC_RUNNABLE; proc-\u003esp = (uint32_t) sp; proc-\u003epage_table = page_table; return proc; } struct process *create_process(const void *image, size_t image_size) { ... *--sp = (uint32_t) user_entry; // ra (변경!) uint32_t *page_table = (uint32_t *) alloc_pages(1); // 커널 페이지 매핑 for (paddr_t paddr = (paddr_t) __kernel_base; paddr \u003c (paddr_t) __free_ram_end; paddr += PAGE_SIZE) map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X); // 유저 페이지 매핑 for (uint32_t off = 0; off \u003c image_size; off += PAGE_SIZE) { paddr_t page = alloc_pages(1); size_t remaining = image_size - off; size_t copy_size = PAGE_SIZE \u003c= remaining ? PAGE_SIZE : remaining; memcpy((void *) page, image + off, copy_size); map_page(page_table, USER_BASE + off, page, PAGE_U | PAGE_R | PAGE_W | PAGE_X); } proc-\u003epid = i + 1; proc-\u003estate = PROC_RUNNABLE; proc-\u003esp = (uint32_t) sp; proc-\u003epage_table = page_table; return proc; } 바이너리를 직접 매핑하지 않고 새 물리 페이지에 복사 후 매핑한다. 만약 직접 매핑하면 같은 바이너리로 만든 여러 프로세스가 동일한 물리 페이지를 공유하게 되어 메모리 격리가 깨진다.\n페이지 매핑 시 PAGE_U 플래그를 추가한다. 이 비트가 없으면 U-Mode에서 해당 페이지에 접근할 때 Page Fault가 발생한다. 커널 페이지에는 PAGE_U를 붙이지 않으므로, 애플리케이션이 커널 메모리에 접근하는 것을 하드웨어 수준에서 차단할 수 있다.\n마지막으로 ra에 넣는 값을 pc에서 user_entry로 바꿨다. 첫 컨텍스트 스위치 시 user_entry로 진입해 U-Mode 전환을 거친 뒤 애플리케이션이 실행된다.\nkernel_main에서는 다음과 같이 shell 프로세스를 생성한다.\nkernel.c Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); idle_proc = create_process((uint32_t) NULL); idle_proc-\u003epid = 0; current_proc = idle_proc; proc_a = create_process((uint32_t) proc_a_entry); proc_b = create_process((uint32_t) proc_b_entry); yield(); PANIC(\"switched to idle process\"); } extern char _binary_shell_bin_start[], _binary_shell_bin_size[]; void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); idle_proc = create_process(NULL, 0); idle_proc-\u003epid = 0; current_proc = idle_proc; create_process(_binary_shell_bin_start, (size_t) _binary_shell_bin_size); yield(); PANIC(\"switched to idle process\"); } shell 이미지가 정상적으로 매핑 되었는지 확인하기 QEMU 모니터의 info mem으로 매핑을 확인할 수 있다.\n(qemu) info mem vaddr paddr size attr -------- ---------------- -------- ------- 01000000 0000000080265000 00001000 rwxu--- 01001000 0000000080267000 00010000 rwxu---물리 주소 0x80265000이 가상 주소 0x1000000 (USER_BASE)에 매핑되어 있는 것을 확인할 수 있다. 이제 이 물리 주소의 내용을 살펴보기 위해 xp 명령어를 사용해보면 다음과 같이 나온다.\n(qemu) xp /10i 0x80265000 0x80265000: 01010537 lui a0,16842752 0x80265004: 2d050513 addi a0,a0,720 0x80265008: 812a mv sp,a0 0x8026500a: 00000097 auipc ra,0 # 0x8026500a 0x8026500e: 012080e7 jalr ra,ra,18 0x80265012: 00000097 auipc ra,0 # 0x80265012 0x80265016: 00e080e7 jalr ra,ra,14 0x8026501a: 0000 illegal 0x8026501c: a001 j 0 # 0x8026501c 0x8026501e: 0000 illegal auipc는 Add Upper Immediate to PC라는 뜻으로, 현재 pc값에 즉시값을 더해서 레지스터에 저장하는 명령어이다. auipc ra, 0은 ra = pc + 0뜻이다. auipc ra, 0 + jalr ra, ra, x를 그냥 jal ra, x라고 생각하면 된다.\nshell.elf의 역어셈블 결과와 비교하면 실제로 일치함을 확인할 수 있다.\n$ llvm-objdump -d shell.elf | head -n21 shell.elf: file format elf32-littleriscv Disassembly of section .text: 01000000 \u0026lt;start\u0026gt;: 1000000: 37 05 01 01 lui a0, 4112 1000004: 13 05 05 2d addi a0, a0, 720 1000008: 2a 81 mv sp, a0 100000a: 97 00 00 00 auipc ra, 0 100000e: e7 80 20 01 jalr 18(ra) 1000012: 97 00 00 00 auipc ra, 0 1000016: e7 80 e0 00 jalr 14(ra) 100001a: 00 00 unimp 0100001c \u0026lt;main\u0026gt;: 100001c: 01 a0 j 0x100001c \u0026lt;main\u0026gt; 100001e: 00 00 unimp 01000020 \u0026lt;exit\u0026gt;: 1000020: 01 a0 j 0x1000020 \u0026lt;exit\u0026gt;\nU-Mode로 전환하기 kernel.h #define SSTATUS_SPIE (1 \u0026lt;\u0026lt; 5) kernel.c __attribute__((naked)) void user_entry(void) { __asm__ __volatile__( \u0026#34;csrw sepc, %[sepc] \\n\u0026#34; \u0026#34;csrw sstatus, %[sstatus] \\n\u0026#34; \u0026#34;sret \\n\u0026#34; : : [sepc] \u0026#34;r\u0026#34; (USER_BASE), [sstatus] \u0026#34;r\u0026#34; (SSTATUS_SPIE) ); } sret는 원래 예외 핸들러에서 복귀할 때 쓰는 명령어다. RISC-V 스펙상 sstatus의 SPP 비트가 0이면 sret 실행 시 U-Mode로 전환하면서 sepc에 설정한 주소로 점프한다. S-Mode에서 U-Mode로 내려가는 방법이 sret 하나뿐이기 때문에, 실제 트랩이 없어도 두 CSR만 원하는 값으로 설정한 뒤 sret를 호출하여 U-Mode로 전환할 수 있다.\nsepc: sret가 점프할 주소. USER_BASE(0x1000000)으로 설정하면 shell.bin의 start 함수부터 실행된다. sstatus의 SPIE 비트: U-Mode 진입 시 인터럽트 활성화 여부. 이 튜토리얼에서는 인터럽트를 사용하지 않지만 명시적으로 설정한다. __attribute__((naked))가 반드시 필요하다. 컴파일러가 프롤로그/에필로그를 생성하면 sret 이전에 불필요한 스택 조작이 끼어들어 오동작한다.\n전체 실행 흐름 kernel_main └─ create_process(shell_bin, size) // 바이너리 복사 \u0026amp; 페이지 매핑 └─ yield() └─ switch_context() // 컨텍스트 스위치 └─ user_entry() // ra에 저장된 주소로 점프 └─ sret // U-Mode 전환 + 0x1000000으로 점프 └─ start() // shell 실행 시작U-Mode 실행 확인 기존의 shell.c는 무한 루프만 돌았기 때문에 U-Mode가 정확히 동작하는지 확인하기 위해서 shell.c에서 커널 메모리에 쓰기를 시도해보자.\nshell.c void main(void) { *((volatile int *) 0x80200000) = 0x1234; // 커널 메모리에 쓰기 시도 for (;;); } 0x80200000은 커널 페이지로, 페이지 테이블에 PAGE_U 비트가 없다.\nPANIC: kernel.c:146: unexpected trap scause=0000000f, stval=80200000, sepc=01000026실행하면 scause = 0xf = 15(Store/AMO page fault)가 발생한다. U-Mode에서 PAGE_U가 없는 페이지에 접근하면 하드웨어가 차단하는 것이다. llvm-addr2line으로 sepc 주소를 확인하면 정확히 해당 라인을 가리킨다.\n$ llvm-addr2line-14 -e shell.elf 01000026 shell.c:4참고 자료 OS in 1,000 Lines - 챕터 12: 애플리케이션 OS in 1,000 Lines - 챕터 13: 유저 모드 RISC-V Privileged Specification — 12.1.1. Supervisor Status (sstatus) Register ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os07/","summary":"user.ld 링커 스크립트 작성, objcopy 빌드로 바이너리 임베딩, create_process 수정으로 유저 페이지 매핑, sret으로 U-Mode 전환까지 진행","title":"07. 애플리케이션과 유저 모드"},{"content":" 시스템 콜이 필요한 이유 이전 글 에서 sret으로 U-Mode 전환에 성공했다. shell.c의 main이 실제로 실행되고, 커널 메모리에 접근을 시도하면 Page Fault가 발생하는 것도 확인했다.\n그런데 한 가지 문제가 있다. U-Mode 프로세스는 SBI 콜도, 커널 함수도 직접 호출할 수 없다. putchar와 exit가 아직 빈 껍데기인 이유다. U-Mode에서 printf를 호출하면 내부적으로 putchar가 호출되는데, 문자를 출력하려면 결국 SBI의 sbi_console_putchar를 거쳐야 하고, 이는 S-Mode에서만 할 수 있다.\n이 문제를 해결하는 것이 시스템 콜(System Call) 이다. U-Mode 프로세스가 ecall 명령어로 의도적으로 예외를 발생시키면, 제어권이 커널(S-Mode)로 넘어간다. 커널은 요청을 처리하고 결과를 반환한 뒤, sret으로 다시 U-Mode 프로세스에게 돌려준다.\nU-Mode (shell) S-Mode (kernel) ────────────── ──────────────── putchar(\u0026#39;H\u0026#39;) └─ ecall ──▶ handle_trap └─ handle_syscall └─ putchar(\u0026#39;H\u0026#39;) // SBI 경유 sret ◀── └─ 다음 명령 실행 os01에서 봤던 트랩 메커니즘 과 구조가 동일하다. 차이는 예외의 원인이 Page Fault가 아니라 ecall이라는 것뿐이다.\n사용자 라이브러리 (user.c) syscall 함수 시스템 콜의 핵심은 ecall 명령어 하나다. 구현은 SBI 콜 과 거의 동일하다.\nuser.c int syscall(int sysno, int arg0, int arg1, int arg2) { register int a0 __asm__(\u0026#34;a0\u0026#34;) = arg0; register int a1 __asm__(\u0026#34;a1\u0026#34;) = arg1; register int a2 __asm__(\u0026#34;a2\u0026#34;) = arg2; register int a3 __asm__(\u0026#34;a3\u0026#34;) = sysno; __asm__ __volatile__(\u0026#34;ecall\u0026#34; : \u0026#34;=r\u0026#34;(a0) : \u0026#34;r\u0026#34;(a0), \u0026#34;r\u0026#34;(a1), \u0026#34;r\u0026#34;(a2), \u0026#34;r\u0026#34;(a3) : \u0026#34;memory\u0026#34;); return a0; } SBI 콜도 ecall을 쓰고, 시스템 콜도 ecall을 쓴다. 같은 명령어지만 트랩 목적지가 다르다. SBI 콜의 ecall은 S-Mode -\u0026gt; M-Mode 전환이고, 시스템 콜의 ecall은 U-Mode -\u0026gt; S-Mode 전환이다. CPU가 현재 특권 레벨에 따라 어느 핸들러로 진입할지를 결정한다.\n레지스터 역할도 SBI 콜과 비슷하게 인자를 a0~a2에, 시스템 콜 번호(SBI에서의 FID/EID에 해당)를 a3에 넣는다. 반환값은 a0으로 돌아온다.\ncommon.h #define SYS_PUTCHAR 1 #define SYS_GETCHAR 2 #define SYS_EXIT 3 user.c void putchar(char ch) { syscall(SYS_PUTCHAR, ch, 0, 0); } int getchar(void) { return syscall(SYS_GETCHAR, 0, 0, 0); } __attribute__((noreturn)) void exit(void) { syscall(SYS_EXIT, 0, 0, 0); for (;;); // Just in case! } exit의 for (;;)는 실행될 일이 없다. SYS_EXIT 처리 후 커널은 이 프로세스를 스케줄러에서 제거하므로 sret으로 돌아오지 않는다. 버그로 인해 커널이 예상치 못하게 sret으로 돌아오면 noreturn 함수가 실제로 return하는 상황이 되어 undefined behavior가 발생한다. for (;;)는 그 경우를 막는 안전망이다. 무한 루프를 배치함으로써 \u0026ldquo;절대 반환되지 않는다\u0026quot;는 약속을 물리적으로 지키는 것이라고 볼 수 있다.\nuser.h int getchar(void); 커널에서 ecall 처리 handle_trap 수정 kernel.h #define SCAUSE_ECALL 8 kernel.c Inline Side void handle_trap(struct trap_frame *f) { uint32_t scause = READ_CSR(scause); uint32_t stval = READ_CSR(stval); uint32_t user_pc = READ_CSR(sepc); PANIC(\"unexpected trap scause=%x, stval=%x, sepc=%x\\n\", scause, stval, user_pc); } void handle_trap(struct trap_frame *f) { uint32_t scause = READ_CSR(scause); uint32_t stval = READ_CSR(stval); uint32_t user_pc = READ_CSR(sepc); if (scause == SCAUSE_ECALL) { handle_syscall(f); user_pc += 4; } else { PANIC(\"unexpected trap scause=%x, stval=%x, sepc=%x\\n\", scause, stval, user_pc); } WRITE_CSR(sepc, user_pc); } 여기서 user_pc += 4 가 핵심이다. sepc는 예외를 일으킨 명령어, 즉 ecall 자신을 가리킨다. 이 상태에서 그냥 sret으로 복귀하면 ecall을 다시 실행해 무한 루프에 빠진다. ecall은 RISC-V에서 4바이트 고정이므로, sepc에 4를 더해 다음 명령어로 복귀하도록 만든다.\nRISC-V 예외와 sepcPage Fault 같은 하드웨어 예외는 해당 명령어를 재실행해야 하는 경우(예: 메모리를 채운 뒤 다시 시도)가 있어서 sepc가 예외 명령어 자체를 가리킨다. 시스템 콜은 재실행이 아니라 '완료 후 다음 줄로' 가야 하므로 소프트웨어가 직접 sepc를 앞으로 밀어야 한다. 시스템 콜 핸들러 kernel.c void handle_syscall(struct trap_frame *f) { switch (f-\u0026gt;a3) { case SYS_PUTCHAR: putchar(f-\u0026gt;a0); break; case SYS_GETCHAR: while (1) { // 인터럽트가 없어서 폴링 방식 long ch = getchar(); if (ch \u0026gt;= 0) { f-\u0026gt;a0 = ch; break; } yield(); } break; case SYS_EXIT: printf(\u0026#34;process %d exited\\n\u0026#34;, current_proc-\u0026gt;pid); current_proc-\u0026gt;state = PROC_EXITED; yield(); PANIC(\u0026#34;unreachable\u0026#34;); default: PANIC(\u0026#34;unexpected syscall a3=%x\\n\u0026#34;, f-\u0026gt;a3); } } trap_frame에는 ecall 시점의 레지스터 값이 그대로 저장되어 있다. f-\u0026gt;a3으로 시스템 콜 번호를, f-\u0026gt;a0으로 첫 번째 인자를 읽는 것이 그래서 가능하다.\nSYS_GETCHAR: SBI의 sbi_console_getchar는 입력이 없으면 -1을 반환한다. 단순 while (1)로 폴링하면 idle 프로세스조차 CPU를 쓸 수 없으므로, 매 루프마다 yield()를 호출해 CPU를 넘긴다.\n다만 이는 인터럽트 기반 입력과 PROC_BLOCKED 상태가 없는 단순화된 구현의 한계다. 실제 OS라면 입력이 없을 때 프로세스를 sleep 상태로 전환하고, 키보드 인터럽트가 왔을 때 깨우는 방식을 쓴다.\nSYS_EXIT: 프로세스 상태를 PROC_EXITED로 바꾼 뒤 yield()를 호출한다. 스케줄러는 PROC_RUNNABLE 상태만 선택하므로 이 프로세스는 영원히 다시 실행되지 않는다. yield() 이후 코드는 실행될 수 없지만, PANIC을 남겨둬서 혹시라도 돌아오는 경우를 잡는다.\nkernel.h #define PROC_EXITED 2 셸 구현 이제 putchar가 실제로 동작하므로, printf도 쓸 수 있다. getchar와 exit까지 더하면 간단한 셸을 만들기에 충분하다.\nshell.c void main(void) { while (1) { prompt: printf(\u0026#34;\u0026gt; \u0026#34;); char cmdline[128]; for (int i = 0;; i++) { char ch = getchar(); putchar(ch); if (i == sizeof(cmdline) - 1) { printf(\u0026#34;command line too long\\n\u0026#34;); goto prompt; } else if (ch == \u0026#39;\\r\u0026#39;) { // 디버그 콘솔에서는 줄바꿈 문자가 \u0026#39;\\r\u0026#39;임. printf(\u0026#34;\\n\u0026#34;); cmdline[i] = \u0026#39;\\0\u0026#39;; break; } else { cmdline[i] = ch; } } if (strcmp(cmdline, \u0026#34;hello\u0026#34;) == 0) printf(\u0026#34;Hello world from shell!\\n\u0026#34;); else if (strcmp(cmdline, \u0026#34;exit\u0026#34;) == 0) exit(); else printf(\u0026#34;unknown command: %s\\n\u0026#34;, cmdline); } } 실행 결과 $ ./run.sh \u0026gt; hello Hello world from shell! \u0026gt; exit process 2 exited PANIC: kernel.c:450: switched to idle processexit 명령 시 셸 프로세스가 종료되고, 실행 가능한 프로세스가 없으므로 스케줄러가 idle 프로세스를 선택해 PANIC이 발생한다. 의도된 동작이다.\n전체 시스템 콜 흐름 shell: char ch = getchar(); [shell.c] └─ syscall(SYS_GETCHAR, 0, 0, 0) [user.c] ├─ ecall │ ├─ kernel_entry │ │ └─ handle_trap(f) [kernel.c] │ │ ├─ handle_syscall(f) [kernel.c] │ │ │ └─ getchar() [kernel.c] │ │ └──── sepc += 4 │ ├─ kernel_entry 복귀 (레지스터 복원 -\u0026gt; sret) │ └─ sret // S-Mode -\u0026gt; U-Mode 복귀 └─ return a0 -\u0026gt; ch에 \u0026#39;h\u0026#39;가 들어있음 shell: putchar(\u0026#39;h\u0026#39;); [shell.c] └─ syscall(SYS_PUTCHAR, \u0026#39;h\u0026#39;, 0, 0) [user.c] └─ ecall ├─ kernel_entry | └─ handle_trap(f) [kernel.c] | ├─ handle_syscall(f) [kernel.c] │ | └─ putchar(\u0026#39;h\u0026#39;) [kernel.c] │ │ └─ 실제 화면에 h 출력 │ └─ sepc += 4 ├─ kernel_entry 복귀 (레지스터 복원 -\u0026gt; sret) └─ sret // S-Mode -\u0026gt; U-Mode 복귀 shell: cmdline[0] = \u0026#39;h\u0026#39; ... \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39; 반복 ... shell: char ch = getchar(); [shell.c] // \u0026#39;\\r\u0026#39; 입력 └─ syscall(SYS_GETCHAR, 0, 0, 0) [user.c] ├─ ecall │ ├─ kernel_entry │ │ └─ handle_trap(f) [kernel.c] │ │ ├─ handle_syscall(f) [kernel.c] │ │ │ └─ getchar() [kernel.c] │ │ │ └─ f-\u0026gt;a0 = \u0026#39;\\r\u0026#39; │ │ └──── sepc += 4 │ ├─ kernel_entry 복귀 (레지스터 복원 -\u0026gt; sret) │ └─ sret // S-Mode -\u0026gt; U-Mode 복귀 └─ return a0 -\u0026gt; ch에 \u0026#39;\\r\u0026#39;이 들어있음 shell: else if (ch == \u0026#39;\\r\u0026#39;) printf(\u0026#34;\\n\u0026#34;); [common.c] // \u0026#39;\\n\u0026#39; 출력 └─ putchar(\u0026#39;\\n\u0026#39;) [user.c] └─ syscall(SYS_PUTCHAR, \u0026#39;\\n\u0026#39;, 0, 0) └─ ecall ├─ kernel_entry | └─ handle_trap(f) [kernel.c] │ ├─ handle_syscall(f) [kernel.c] │ | └─ putchar(\u0026#39;\\n\u0026#39;) [kernel.c] │ │ └─ 실제 화면에 \u0026#39;\\n\u0026#39; 출력 │ └─ sepc += 4 ├─ kernel_entry 복귀 (레지스터 복원 -\u0026gt; sret) └─ sret cmdline[i] = \u0026#39;\\0\u0026#39;; break; -\u0026gt; cmdline = \u0026#34;hello\u0026#34; shell: strcmp(cmdline, \u0026#34;hello\u0026#34;) == 0 printf(\u0026#34;Hello world from shell!\\n\u0026#34;); [common.c] └─ putchar(\u0026#39;H\u0026#39;) [user.c] └─ syscall(SYS_PUTCHAR, \u0026#39;H\u0026#39;, 0, 0) └─ ecall ├─ kernel_entry | └─ handle_trap(f) [kernel.c] │ ├─ handle_syscall(f) [kernel.c] │ | └─ putchar(\u0026#39;H\u0026#39;) [kernel.c] │ │ └─ 실제 화면에 \u0026#39;H\u0026#39; 출력 │ └─ sepc += 4 ├─ kernel_entry 복귀 (레지스터 복원 -\u0026gt; sret) └─ sret └─ putchar(\u0026#39;e\u0026#39;), putchar(\u0026#39;l\u0026#39;), ... // 나머지 문자도 동일한 사이클U-Mode에서 문자 하나를 찍을 때마다 syscall -\u0026gt; ecall -\u0026gt; kernel_entry -\u0026gt; handle_trap -\u0026gt; handle_syscall -\u0026gt; sbi_call 사이클이 돈다. 실제 OS에서 시스템 콜이 성능 병목이 되는 이유가 여기 있다.\n참고 자료 OS in 1,000 Lines - 챕터 14: 시스템 콜 ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os08/","summary":"syscall 함수 구현, ecall 트랩 처리와 SYS_PUTCHAR/SYS_GETCHAR/SYS_EXIT 핸들러,간단한 셸 구현까지 진행","title":"08. 시스템 콜"},{"content":" 페이징(Paging)이란 지금까지 만든 OS는 모든 프로세스가 동일한 물리 메모리를 공유한다. 즉 프로세스 A가 프로세스 B의 스택 주소를 알면 그냥 읽고 쓸 수 있다. 이는 보안 측면에서 치명적인 문제다.\n이를 해결하기 위해 프로그램이 사용하는 주소(가상 주소)와 실제 RAM 주소(물리 주소)를 분리한다. 각 프로세스는 자신만의 가상 주소 공간을 가지며, 같은 가상 주소라도 프로세스마다 다른 물리 주소로 매핑될 수 있다.\n프로세스 A: 가상 0x1000 → 물리 0x8000 프로세스 B: 가상 0x1000 → 물리 0x9000이 매핑 정보를 저장하는 테이블이 페이지 테이블(Page Table) 이고, 이 메커니즘 전체를 페이징(Paging) 이라고 한다. 변환은 CPU 내부의 MMUMemory Management Unit. 가상 주소를 물리 주소로 변환하는 하드웨어. CPU가 메모리에 접근할 때마다 자동으로 동작한다.가 자동으로 수행한다.\n메모리를 주소 하나하나 단위로 매핑하면 테이블이 너무 커지기 때문에, 4KB(= 4096바이트) 단위의 덩어리(페이지) 로 나누어 관리한다. 메모리 할당에서 alloc_pages를 페이지 단위로 구현한 것도 이 구조와 맞닿아 있다.\nos01에서 다뤘던 Page Fault 시나리오 를 떠올려보자. lw s0, 0(s1) 실행 시 MMU가 페이지 테이블을 조회하다가 V=0인 엔트리를 만난 것이 바로 이 변환 실패였다. 이 글에서는 그 페이지 테이블을 직접 만들어 각 프로세스의 메모리를 하드웨어 수준에서 격리한다.\nSv32 가상 주소 구조 이 책에서는 RISC-V의 페이징 방식 중 하나인 Sv32를 사용한다. 32비트 가상 주소를 다음과 같이 3개의 필드로 쪼갠다.\n[31:22] VPN[1] (10bit) — 1단계 페이지 테이블 인덱스 [21:12] VPN[0] (10bit) — 2단계 페이지 테이블 인덱스 [11:0] offset (12bit) — 페이지 내 오프셋 (4KB 범위)MMU는 가상 주소를 받으면 VPN[1] → VPN[0] 순서로 2단계 테이블 워크를 수행해 최종 물리 주소를 얻는다.\n왜 1단계가 아니라 2단계로 쪼개는가? 32비트 주소공간을 4KB 페이지로 나누면 페이지가 총 2²⁰ = 약 100만 개다. 이걸 1단계 테이블 하나에 전부 넣으면 4MB짜리 테이블이 프로세스마다 항상 필요하다.\n2단계로 쪼개면 실제로 사용하는 영역에 해당하는 2단계 테이블만 동적으로 할당하면 된다. 이게 map_page 코드에서 1단계 엔트리가 없을 때만 alloc_pages(1)을 호출하는 이유다.\nPTE(Page Table Entry) 구조 Sv32의 PTE는 32비트이며 다음과 같이 구성된다.\n[31:10] PPN (Physical Page Number) — 22비트 [9:8] RSW (소프트웨어 예약) [7] D (Dirty: CPU가 이 페이지에 쓴 적 있음) [6] A (Accessed: CPU가 이 페이지를 읽거나 실행한 적 있음) [5] G (Global) [4] U (User 모드 접근 가능) [3] X (Execute 가능) [2] W (Write 가능) [1] R (Read 가능) [0] V (Valid: 이 엔트리가 유효함) 중요한 규칙이 하나 있다. R=0, W=0, X=0인 엔트리는 \u0026ldquo;다음 단계 테이블을 가리키는 포인터\u0026rdquo; 로 해석되고, R/W/X 중 하나라도 1이면 실제 물리 페이지로의 매핑(리프 엔트리) 이다. map_page 코드에서 1단계 엔트리에 PAGE_V만 세트하고 R/W/X를 넣지 않는 이유가 이것이다.\nPPN이 22비트인데 paddr_t가 32비트면 상위 2비트는? Sv32 스펙상 물리 주소는 최대 34비트(PPN 22비트 + offset 12비트)까지 표현 가능하다. 즉 PTE의 PPN 22비트를 전부 활용하면 16GB 물리 메모리를 지원할 수 있다.\n그런데 이 튜토리얼에서는 paddr_t가 uint32_t(32비트)로 정의되어 있다.\ntypedef uint32_t paddr_t;32비트 물리 주소를 PAGE_SIZE(4096)로 나누면 PPN은 최대 20비트다. PTE의 PPN 필드 22비트 중 상위 2비트는 이 튜토리얼에서 항상 0이 된다.\nPTE [31:10] PPN 22비트: [ 00 | 실제 사용되는 PPN 20비트 ] ↑ 이 2비트는 항상 0이건 버그가 아니라 의도적인 단순화다. QEMU virt 머신의 물리 메모리가 전부 32비트 범위(0x80200000 ~ 0x84221000) 안에 있으므로 실행상 문제가 없다. 실제로 34비트 물리 주소를 쓰는 하드웨어를 지원하려면 paddr_t를 uint64_t로 바꿔야 한다.\n페이지 테이블 구현 매크로 정의 kernel.h #define SATP_SV32 (1u \u0026lt;\u0026lt; 31) #define PAGE_V (1 \u0026lt;\u0026lt; 0) // \u0026#34;Valid\u0026#34; 비트 #define PAGE_R (1 \u0026lt;\u0026lt; 1) // 읽기 가능 #define PAGE_W (1 \u0026lt;\u0026lt; 2) // 쓰기 가능 #define PAGE_X (1 \u0026lt;\u0026lt; 3) // 실행 가능 #define PAGE_U (1 \u0026lt;\u0026lt; 4) // 사용자 모드 접근 가능 SATP_SV32는 satpSupervisor Address Translation and Protection 레지스터. Sv32 활성화 비트(bit 31), ASID(bit 30:22), 1단계 페이지 테이블의 물리 페이지 번호(bit 21:0)로 구성된다. 레지스터에 Sv32 페이징 활성화를 알리는 비트다.\nmap_page 함수 map_page의 역할을 한 문장으로 표현하면, \u0026ldquo;VPN을 인덱스로 삼아 해당 PTE에 PPN을 기록함으로써, MMU가 나중에 가상 주소를 물리 주소로 변환할 수 있게 준비하는 함수\u0026rdquo; 다.\nMMU는 가상 주소에서 VPN을 추출해 페이지 테이블을 조회하고, 거기서 PPN을 꺼내 offset을 붙여 물리 주소를 만든다. map_page는 그 조회 결과가 올바르게 나오도록 테이블을 미리 채워두는 것이다.\nMMU 동작 (하드웨어): VPN → 페이지 테이블 조회 → PPN → PPN + offset = 물리 주소 map_page 역할 (우리): 페이지 테이블을 미리 채워둠 kernel.c void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags) { if (!is_aligned(vaddr, PAGE_SIZE)) PANIC(\u0026#34;unaligned vaddr %x\u0026#34;, vaddr); if (!is_aligned(paddr, PAGE_SIZE)) PANIC(\u0026#34;unaligned paddr %x\u0026#34;, paddr); uint32_t vpn1 = (vaddr \u0026gt;\u0026gt; 22) \u0026amp; 0x3ff; // vaddr에서 VPN[1] 추출 (1단계 인덱스) if ((table1[vpn1] \u0026amp; PAGE_V) == 0) { // 이 VPN[1] 범위의 2단계 테이블이 아직 없으면 새로 할당 uint32_t pt_paddr = alloc_pages(1); table1[vpn1] = ((pt_paddr / PAGE_SIZE) \u0026lt;\u0026lt; 10) | PAGE_V; // 2단계 테이블 주소를 PPN으로 저장 } uint32_t vpn0 = (vaddr \u0026gt;\u0026gt; 12) \u0026amp; 0x3ff; // vaddr에서 VPN[0] 추출 (2단계 인덱스) uint32_t *table0 = (uint32_t *) ((table1[vpn1] \u0026gt;\u0026gt; 10) * PAGE_SIZE); // 2단계 테이블 주소 복원 table0[vpn0] = ((paddr / PAGE_SIZE) \u0026lt;\u0026lt; 10) | flags | PAGE_V; // VPN[0]번째 PTE에 PPN 기록 } 코드를 단계별로 뜯어보면,\nVPN 추출\n가상 주소는 [VPN[1]][VPN[0]][offset] 구조다. \u0026gt;\u0026gt; 22로 상위 10비트(VPN[1])를, \u0026gt;\u0026gt; 12로 그다음 10비트(VPN[0])를 추출한다. \u0026amp; 0x3ff는 10비트짜리 마스크(0b11_1111_1111)로, 시프트 후 불필요한 상위 비트를 제거한다.\nvaddr \u0026gt;\u0026gt; 22 \u0026amp; 0x3ff → VPN[1] (1단계 테이블 인덱스) vaddr \u0026gt;\u0026gt; 12 \u0026amp; 0x3ff → VPN[0] (2단계 테이블 인덱스)1단계 PTE 처리\ntable1[vpn1]이 V=0이면 이 VPN[1] 범위에 대한 2단계 테이블이 아직 없다는 뜻이다. alloc_pages(1)로 4KB를 할당해 2단계 테이블 공간을 만들고, 그 주소를 PPN으로 변환해 1단계 PTE에 기록한다.\n물리 주소를 PPN으로 변환하는 방법은 간단하다. 물리 주소는 [PPN][offset] 구조인데, 우리가 만든 페이지는 항상 4KB 정렬이므로 / PAGE_SIZE(= \u0026gt;\u0026gt; 12)로 하위 12비트를 버리면 PPN만 남는 것이 보장된다.\n물리 주소 0x80254123 [PPN = 0x80254][offset = 0x123] / PAGE_SIZE → PPN = 0x80254이 PPN을 PTE의 [31:10] 필드에 넣으려면 \u0026lt;\u0026lt; 10 시프트가 필요하다.\n0x80254 \u0026lt;\u0026lt; 10 → PTE [31:10]에 올바르게 위치 | PAGE_V → V=1, R=W=X=0 → 포인터 엔트리 (2단계 테이블을 가리킴)2단계 테이블 주소 복원\ntable1[vpn1] \u0026gt;\u0026gt; 10으로 PTE에서 PPN을 꺼내고, * PAGE_SIZE로 물리 주소를 복원한다. 저장할 때 / PAGE_SIZE로 줄였던 것을 그대로 되돌리는 것이다.\ntable1[vpn1] \u0026gt;\u0026gt; 10 → PPN 추출 × PAGE_SIZE → 물리 주소 복원 (2단계 테이블이 실제로 있는 곳) (uint32_t *) → 배열로 사용하기 위해 포인터 캐스팅2단계 PTE에 실제 매핑 기록\ntable0[vpn0]가 진짜 목적이다. 여기에 paddr의 PPN과 권한 플래그를 기록하면, MMU가 VPN[0]으로 조회했을 때 올바른 물리 페이지를 찾을 수 있다.\npaddr / PAGE_SIZE → 물리 페이지의 PPN \u0026lt;\u0026lt; 10 | flags | PAGE_V → 리프 PTE 완성 (R/W/X 중 하나 이상이 1)map_page가 하는 일은 결국 이것이다. offset은 MMU가 변환 없이 그대로 사용하므로 map_page에서 다룰 필요가 없다. 물리 주소 vs 물리 페이지 번호 혼동이 페이지 테이블에서 가장 흔한 버그 원인이니 주의가 필요하다.\n커널 메모리 영역 매핑 (Identity Mapping) 왜 커널도 매핑해야 하는가 페이징을 켜는 순간(csrw satp 실행 직후), CPU는 다음 명령어를 가져올 때도 MMU를 통해 가상 주소를 번역한다. 만약 현재 실행 중인 커널 코드가 페이지 테이블에 없다면, csrw satp 직후 즉시 Instruction Page Fault가 발생한다.\n그래서 커널 영역은 가상 주소 == 물리 주소인 identity mapping으로 설정한다. 페이징을 켜기 전과 후에 동일한 주소로 코드가 실행되므로 끊김이 없다.\n링커 스크립트 수정 kernel.ld Inline Side ENTRY(boot) SECTIONS { . = 0x80200000; ENTRY(boot) SECTIONS { . = 0x80200000; __kernel_base = .; __kernel_base를 . = 0x80200000 뒤에 정의해야 하는 이유 링커 스크립트에서 심볼에 .(위치 카운터)를 할당하면, 그 시점의 위치 카운터 값이 심볼에 저장된다. . = 0x80200000 줄이 먼저 실행되어 위치 카운터를 0x80200000으로 설정한 뒤에 __kernel_base = .가 실행되어야 __kernel_base가 0x80200000이 된다. 순서가 바뀌면 위치 카운터가 아직 0이므로 __kernel_base = 0이 되어버린다.\nstruct process에 page_table 추가 kernel.h Inline Side struct process { int pid; int state; vaddr_t sp; uint8_t stack[8192]; }; struct process { int pid; int state; vaddr_t sp; uint32_t *page_table; uint8_t stack[8192]; }; create_process에서 커널 페이지 매핑 kernel.c Inline Side proc-\u003epid = i + 1; proc-\u003estate = PROC_RUNNABLE; proc-\u003esp = (uint32_t) sp; return proc; uint32_t *page_table = (uint32_t *) alloc_pages(1); for (paddr_t paddr = (paddr_t) __kernel_base; paddr \u003c (paddr_t) __free_ram_end; paddr += PAGE_SIZE) map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X); proc-\u003epid = i + 1; proc-\u003estate = PROC_RUNNABLE; proc-\u003esp = (uint32_t) sp; proc-\u003epage_table = page_table; return proc; __kernel_base부터 __free_ram_end까지 커버한다. 이렇게 하면 .text같은 정적 영역뿐만 아니라 alloc_pages로 동적 할당된 영역(페이지 테이블 자체 포함)도 커널이 접근할 수 있다.\n컨텍스트 스위칭 시 페이지 테이블 전환 프로세스를 바꿀 때 페이지 테이블도 함께 교체해야 한다.\nkernel.c Inline Side void yield(void) { // 생략 __asm__ __volatile__( \"csrw sscratch, %[sscratch]\\n\" : : [sscratch] \"r\" ((uint32_t) \u0026next-\u003estack[sizeof(next-\u003estack)]) ); // 컨텍스트 스위칭 struct process *prev = current_proc; current_proc = next; switch_context(\u0026prev-\u003esp, \u0026next-\u003esp); } void yield(void) { // 생략 __asm__ __volatile__( \"sfence.vma\\n\" \"csrw satp, %[satp]\\n\" \"sfence.vma\\n\" \"csrw sscratch, %[sscratch]\\n\" : // 끝에 꼭 콤마가 있어야 함! : [satp] \"r\" (SATP_SV32 | ((uint32_t) next-\u003epage_table / PAGE_SIZE)), [sscratch] \"r\" ((uint32_t) \u0026next-\u003estack[sizeof(next-\u003estack)]) ); // 컨텍스트 스위칭 struct process *prev = current_proc; current_proc = next; switch_context(\u0026prev-\u003esp, \u0026next-\u003esp); } satp에는 물리 주소가 아니라 page_table / PAGE_SIZE로 구한 물리 페이지 번호를 SATP_SV32와 OR해서 넣는다. sfence.vma는 TLBTranslation Lookaside Buffer. 최근 가상→물리 주소 변환 결과를 캐싱하는 하드웨어. satp를 바꿔도 TLB가 이전 매핑을 들고 있으면 잘못된 주소로 접근하게 된다.를 flush하는 명령어로, satp 변경 전후에 반드시 삽입한다.\n실행해보기 ./run.sh starting process A Astarting process B BABABABABABABABABABABABABAB...출력은 이전 글와 완전히 동일하다. 페이지 테이블이 제대로 설정되었는지는 QEMU 모니터에서 확인할 수 있다.\n(qemu) info mem vaddr paddr size attr -------- ---------------- -------- ------- 80200000 0000000080200000 00001000 rwx--ad 80201000 0000000080201000 00001000 rwx---- 80202000 0000000080202000 00001000 rwx--a- 80203000 0000000080203000 00001000 rwx---- 80204000 0000000080204000 00001000 rwx--ad 80205000 0000000080205000 00001000 rwx---- 80206000 0000000080206000 00001000 rwx--ad 80207000 0000000080207000 00009000 rwx---- 80210000 0000000080210000 00001000 rwx--ad 80211000 0000000080211000 0001f000 rwx---- 80230000 0000000080230000 00001000 rwx--ad 80231000 0000000080231000 04000000 rwx---- ...vaddr == paddr로 identity mapping이 되어 있고, attr에 r, w, x가 설정된 것을 확인할 수 있다.\n페이징 디버깅 팁 info mem에 아무것도 안 나올 때\nsatp에 SATP_SV32 비트를 빠뜨렸을 가능성이 크다. (qemu) info mem이 No translation or protection을 출력하면 페이징 자체가 꺼져 있는 상태다.\n매핑은 있는데 즉시 Page Fault가 날 때\nsatp에 물리 페이지 번호 대신 물리 주소를 그대로 넣은 경우다. PAGE_SIZE로 나누는 것을 빠뜨리면 satp가 가리키는 테이블 주소가 32비트 범위를 훌쩍 넘어버려서 매핑이 전부 깨진다.\n-d unimp,guest_errors,int,cpu_reset -D qemu.log 옵션을 run.sh에 추가하면 QEMU 로그에서 어떤 주소에서 어떤 예외가 발생했는지 확인할 수 있다.\n__kernel_base 값이 0으로 나올 때\n링커 스크립트에서 . = 0x80200000 앞에 __kernel_base = .를 정의한 경우다. 순서를 바꿔야 한다.\n참고 자료 OS in 1000 Lines 11. 페이지 테이블 RISC-V Privileged Specification — Chapter 4.3 Sv32 [OS] 메인 메모리(4) - 페이지 테이블의 구조(Structure of the Page Table) ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os06/","summary":"페이징의 개념부터 시작해, Sv32 가상 주소 구조, map_page 구현, 커널 identity mapping, 컨텍스트 스위칭 시 satp 전환까지 진행","title":"06. 페이지 테이블"},{"content":" 메모리 할당 실제 OS는 부팅 시 펌웨어에서 메모리 맵이라는 것을 받는다. 거기서 usable 영역을 골라서 내부 allocator에 등록한다. 하지만 간단한 메모리 할당을 구현하기 위해서 여기서는 그렇게 하지 않고, 커널이 점유한 영역 이후의 물리 메모리를 usable 영역이라고 가정한다.\nkernel.ld Inline Side . = ALIGN(4); . += 128 * 1024; /* 128KB */ __stack_top = .; } . = ALIGN(4); . += 128 * 1024; /* 128KB */ __stack_top = .; . = ALIGN(4096); __free_ram = .; . += 64M; /* 64MB */ __free_ram_end = .; } 링커 스크립트에서 stack 이후의 주소 공간에 64MB 크기의 free RAM 영역을 설정한다. . = ALIGN(4096)을 하여, 페이지 크기인 4KB를 경계로 맞췄다.\n세상에서 가장 간단한 메모리 할당 알고리즘 메모리를 동적으로 할당하는 함수를 구현해보자. 여기서는 C의 malloc처럼 \u0026ldquo;바이트 단위\u0026quot;로 할당하는 대신, 더 큰 단위인 \u0026ldquo;페이지(page)\u0026rdquo; 단위로 할당한다. 일반적으로 한 페이지는 4KB이다.\nkernel.c extern char __free_ram[], __free_ram_end[]; paddr_t alloc_pages(uint32_t n) { static paddr_t next_paddr = (paddr_t) __free_ram; paddr_t paddr = next_paddr; next_paddr += n * PAGE_SIZE; if (next_paddr \u0026gt; (paddr_t) __free_ram_end) PANIC(\u0026#34;out of memory\u0026#34;); memset((void *) paddr, 0, n * PAGE_SIZE); return paddr; } alloc_pages는 n개의 페이지를 할당한 뒤, 그 시작 주소를 반환하는 함수이다. next_paddr는 static으로 정의되어서 프로그램이 시작할때 선언되고, 프로그램이 끝나야 사라진다. 초깃값은 __free_ram의 주소이며, 메모리를 순차적으로 할당하기 위해 사용되는 변수이다. __free_ram_end을 넘어가면 커널 패닉을 일으킨다. memset을 통해 할당된 페이지 영역을 0으로 초기화한다. PAGE_SIZE는 4096으로, common.h에 다음과 같이 정의하였다. common.h #define PAGE_SIZE 4096 메모리 할당 테스트 구현한 메모리 할당 함수를 테스트해보기 위해 kernel_main에 다음 코드를 추가해보자.\nkernel.c Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); __asm__ __volatile__(\"unimp\"); } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); paddr_t paddr0 = alloc_pages(2); paddr_t paddr1 = alloc_pages(1); printf(\"alloc_pages test: paddr0=%x\\n\", paddr0); printf(\"alloc_pages test: paddr1=%x\\n\", paddr1); } 첫 번째 할당 주소 paddr0가 __free_ram 주소와 같은지, 두 번째 주소 paddr1가 paddr0로부터 8KB(2페이지 = 8KB) 뒤인지를 확인해보면\n./run.sh alloc_pages test: paddr0=80221000 alloc_pages test: paddr1=80223000 PANIC: kernel.c:145: booted!paddr1이 paddr0보다 0x2000(8KB) 앞에 있는걸 알 수있다.\n그리고 실제 심볼 주소를 llvm-nm kernel.elf | grep __free_ram 명령어로 확인해보면\nllvm-nm kernel.elf | grep __free_ram 80221000 R __free_ram 84221000 R __free_ram_end__free_ram과 paddr0이 같은 주소에서 시작하는 것을 알 수 있다.\n프로세스 프로세스(Process) 란 실행 중인 프로그램을 의미하며, 커널은 이 프로세스의 생명 주기(Life Cycle)를 여러 상태로 나누어 관리한다. 커널은 프로세스의 정보를 어딘가에 기록해두어야하고, 이 글에서는 process라는 구조를 정의하고, CPU 하나로 여러 프로그램을 멀티 태스킹처럼 보이게 하는 Context Switching 까지 구현하는 것이 목표다.\n프로세스 제어 블록 (Process Control Block) 프로세스 제어 블록(PCB)라는 것은 프로세스의 정보를 모아놓은 구조체이다.\n일반적인 OS에서는 pid, 현재 상태, 프로그램 카운터(pc), 레지스터 값, 메모리 정보, 할당된 I/O 장치 등의 다양한 정보를 가지고 있다.\n이 글에서는 pid, 현재 상태(state), 커널 스택(stack)과 스택 포인터(sp)라는 최소한의 PCB를 구현을 한다. 이때, 커널 스택의 역할은 컨텍스트 스위칭 시 해당 프로세스의 callee-saved 레지스터함수가 호출되었을 때, 호출된 쪽(callee)이 값을 보존할 책임을 지는 레지스터. RISC-V에서는 s0~s11과 ra가 이에 해당한다.반대로 caller-saved 레지스터(a0~a7, t0~t6 등)는 호출하는 쪽이 필요하면 직접 저장해야 한다. 이 구분은 RISC-V Calling Convention에서 정의된다. 값들을 저장·복원하는 공간이 되는 것이다.\n코드로 보면 다음과 같이 struct process를 정의할 수 있다.\nkernel.c #define PROCS_MAX 8 // 최대 프로세스 개수 #define PROC_UNUSED 0 // 사용되지 않는 프로세스 구조체 #define PROC_RUNNABLE 1 // 실행 가능한(runnable) 프로세스 struct process { int pid; // 프로세스 ID int state; // 프로세스 상태: PROC_UNUSED or PROC_RUNNABLE vaddr_t sp; // 스택 포인터 uint8_t stack[8192]; // 커널 스택 }; 컨텍스트 스위칭 (Context Switching) 프로세스의 실행 컨텍스트를 바꾸는 것을 컨텍스트 스위칭이라고 한다. CPU는 한 번에 하나의 프로세스만 실행할 수 있기 때문에, 여러 프로세스를 번갈아 실행하려면 현재 프로세스의 상태(레지스터 값들)를 저장하고, 다음 프로세스의 상태를 복원하는 과정이 필요하다. 이러한 저장 및 복원의 대상이 되는 것이 프로세스의 커널 스택이다.\n코드는 다음과 같다.\nkernel.c __attribute__((naked)) void switch_context(uint32_t *prev_sp, uint32_t *next_sp) { __asm__ __volatile__( // 현재 프로세스의 스택에 callee-saved 레지스터를 저장 \u0026#34;addi sp, sp, -13 * 4\\n\u0026#34; // 13개(4바이트씩) 레지스터 공간 확보 \u0026#34;sw ra, 0 * 4(sp)\\n\u0026#34; // callee-saved 레지스터만 저장 \u0026#34;sw s0, 1 * 4(sp)\\n\u0026#34; \u0026#34;sw s1, 2 * 4(sp)\\n\u0026#34; \u0026#34;sw s2, 3 * 4(sp)\\n\u0026#34; \u0026#34;sw s3, 4 * 4(sp)\\n\u0026#34; \u0026#34;sw s4, 5 * 4(sp)\\n\u0026#34; \u0026#34;sw s5, 6 * 4(sp)\\n\u0026#34; \u0026#34;sw s6, 7 * 4(sp)\\n\u0026#34; \u0026#34;sw s7, 8 * 4(sp)\\n\u0026#34; \u0026#34;sw s8, 9 * 4(sp)\\n\u0026#34; \u0026#34;sw s9, 10 * 4(sp)\\n\u0026#34; \u0026#34;sw s10, 11 * 4(sp)\\n\u0026#34; \u0026#34;sw s11, 12 * 4(sp)\\n\u0026#34; // 스택 포인터 교체 \u0026#34;sw sp, (a0)\\n\u0026#34; // *prev_sp = sp \u0026#34;lw sp, (a1)\\n\u0026#34; // sp = *next_sp // 다음 프로세스 스택에서 callee-saved 레지스터 복원 \u0026#34;lw ra, 0 * 4(sp)\\n\u0026#34; \u0026#34;lw s0, 1 * 4(sp)\\n\u0026#34; \u0026#34;lw s1, 2 * 4(sp)\\n\u0026#34; \u0026#34;lw s2, 3 * 4(sp)\\n\u0026#34; \u0026#34;lw s3, 4 * 4(sp)\\n\u0026#34; \u0026#34;lw s4, 5 * 4(sp)\\n\u0026#34; \u0026#34;lw s5, 6 * 4(sp)\\n\u0026#34; \u0026#34;lw s6, 7 * 4(sp)\\n\u0026#34; \u0026#34;lw s7, 8 * 4(sp)\\n\u0026#34; \u0026#34;lw s8, 9 * 4(sp)\\n\u0026#34; \u0026#34;lw s9, 10 * 4(sp)\\n\u0026#34; \u0026#34;lw s10, 11 * 4(sp)\\n\u0026#34; \u0026#34;lw s11, 12 * 4(sp)\\n\u0026#34; \u0026#34;addi sp, sp, 13 * 4\\n\u0026#34; \u0026#34;ret\\n\u0026#34; ); } 위 사진을 보면, sp, s0-s11가 callee-saved register인 것을 알 수 있는데, sp는 process struct에서 저장하고 있기 때문에 s0-s11까지만 저장/복원하는 것을 알 수 있다.\nra는 caller-saved register임에도 저장/복원하는 이유는 프로세스 별로 복귀 주소를 결국 저장해야하기 때문이다.\nret은 jalr zero, ra, 0라는 뜻을 가진 의사 명령어이다.\n프로세스 생성 함수 프로세스 생성 함수 create_process는 프로세스의 시작 함수 주소를 매개변수로 받아서, 빈 프로세스 슬롯을 찾아 초기화하여 해당 프로세스 구조체의 포인터를 반환한다. 코드는 다음과 같다.\nkernel.c struct process procs[PROCS_MAX]; // 모든 프로세스 제어 구조체 배열 struct process *create_process(uint32_t pc) { // 미사용(UNUSED) 상태의 프로세스 구조체 찾기 struct process *proc = NULL; int i; for (i = 0; i \u0026lt; PROCS_MAX; i++) { if (procs[i].state == PROC_UNUSED) { proc = \u0026amp;procs[i]; break; } } if (!proc) PANIC(\u0026#34;no free process slots\u0026#34;); // 커널 스택에 callee-saved 레지스터 공간을 미리 준비 // 첫 컨텍스트 스위치 시, switch_context에서 이 값들을 복원함 uint32_t *sp = (uint32_t *) \u0026amp;proc-\u0026gt;stack[sizeof(proc-\u0026gt;stack)]; *--sp = 0; // s11 *--sp = 0; // s10 *--sp = 0; // s9 *--sp = 0; // s8 *--sp = 0; // s7 *--sp = 0; // s6 *--sp = 0; // s5 *--sp = 0; // s4 *--sp = 0; // s3 *--sp = 0; // s2 *--sp = 0; // s1 *--sp = 0; // s0 *--sp = (uint32_t) pc; // ra (처음 실행 시 점프할 주소) // 구조체 필드 초기화 proc-\u0026gt;pid = i + 1; proc-\u0026gt;state = PROC_RUNNABLE; proc-\u0026gt;sp = (uint32_t) sp; return proc; } 코드 흐름을 분석하자면 다음과 같다.\n빈 process 찾기. 빈 슬롯이 없다면 커널 패닉 sp를 stack의 최상단, 여기서는, stack[8192]의 주소값으로 설정 switch_context가 기대하는 레이아웃에 맞춰 13개(ra, s0-s11)의 초기값을 스택에 채움. ra에는 pc를, 나머지는 0으로 설정 프로세스 필드 초기화 후 반환 컨텍스트 스위칭 테스트 process A와 process B를 실행시켜서 컨텍스트 스위칭을 테스트해보자.\nkernel.c Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); paddr_t paddr0 = alloc_pages(2); paddr_t paddr1 = alloc_pages(1); printf(\"alloc_pages test: paddr0=%x\\n\", paddr0); printf(\"alloc_pages test: paddr1=%x\\n\", paddr1); PANIC(\"booted!\"); } void delay(void) { for (int i = 0; i \u003c 30000000; i++) __asm__ __volatile__(\"nop\"); // do nothing } struct process *proc_a; struct process *proc_b; void proc_a_entry(void) { printf(\"starting process A\\n\"); while (1) { putchar('A'); switch_context(\u0026proc_a-\u003esp, \u0026proc_b-\u003esp); delay(); } } void proc_b_entry(void) { printf(\"starting process B\\n\"); while (1) { putchar('B'); switch_context(\u0026proc_b-\u003esp, \u0026proc_a-\u003esp); delay(); } } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); proc_a = create_process((uint32_t) proc_a_entry); proc_b = create_process((uint32_t) proc_b_entry); proc_a_entry(); PANIC(\"unreachable here!\"); } proc_a_entry와 proc_b_entry는 각각 프로세스 A와 B의 시작 함수를 뜻하지만, 컨텍스트 스위칭이 발생하는지 테스트해보기 위해서 proc_a_entry에서는 다음 실행할 프로세스로 B를, proc_b_entry에서는 다음 실행할 프로세스로 A를 직접 골랐다. 하지만 실제로는 스케쥴러가 실행 순서를 지정해준다.\n스케줄러 우리는 switch_context라는 함수에서 다음에 호출할 프로세스를 직접 지정했는데, 이런 방법은 프로세스가 직접 다음 프로세스를 지정해야 한다. 실제 OS는 이렇게 동작하지 않고, 커널이 다음에 실행할 프로세스를 결정해주는데, 이 역할을 하는 커널 코드를 스케줄러(scheduler)라고 한다.\n아래 yield 함수가 간단한 스케줄러 코드이다.\nkernel.c struct process *current_proc; // 현재 실행 중인 프로세스 struct process *idle_proc; // Idle 프로세스 void yield(void) { // 실행 가능한 프로세스를 탐색 struct process *next = idle_proc; for (int i = 0; i \u0026lt; PROCS_MAX; i++) { struct process *proc = \u0026amp;procs[(current_proc-\u0026gt;pid + i) % PROCS_MAX]; if (proc-\u0026gt;state == PROC_RUNNABLE \u0026amp;\u0026amp; proc-\u0026gt;pid \u0026gt; 0) { // pid \u0026gt; 0 은 idle_proc 제외한 proc next = proc; break; } } // 현재 프로세스 말고는 실행 가능한 프로세스가 없으면, 그냥 리턴 if (next == current_proc) return; // 컨텍스트 스위칭 struct process *prev = current_proc; current_proc = next; switch_context(\u0026amp;prev-\u0026gt;sp, \u0026amp;next-\u0026gt;sp); } 여기서 idle_proc라는게 등장하는데, 이것은 아무런 process가 돌아가지 않을 때, cpu를 점유하고 있는 역할을 하고, 커널을 부팅할 때, 이 프로세스의 pid를 0으로 설정한다.\nkernel.c Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); proc_a = create_process((uint32_t) proc_a_entry); proc_b = create_process((uint32_t) proc_b_entry); proc_a_entry(); PANIC(\"unreachable here!\"); } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); printf(\"\\n\\n\"); WRITE_CSR(stvec, (uint32_t) kernel_entry); idle_proc = create_process((uint32_t) NULL); idle_proc-\u003epid = 0; // idle current_proc = idle_proc; proc_a = create_process((uint32_t) proc_a_entry); proc_b = create_process((uint32_t) proc_b_entry); yield(); PANIC(\"switched to idle process\"); } 여기서의 핵심은 current_proc = idle_proc이다. idle 프로세스(pid=0)는 실행 가능한 프로세스가 없을 때의 폴백이다. CPU는 전원이 켜져 있는 한 항상 무언가를 실행해야 하므로, 아무 프로세스도 RUNNABLE이 아닐 때 대신 실행할 프로세스가 필요하다. 이 튜토리얼에서는 kernel_main의 실행 흐름 자체가 idle 프로세스의 컨텍스트 역할을 하며, idle로 돌아오면 커널 패닉이 발생한다.\n마지막으로 proc_a_entry함수와 proc_b_entry함수에서 yield함수를 호출하게 한다.\nkernel.c Inline Side void proc_a_entry(void) { printf(\"starting process A\\n\"); while (1) { putchar('A'); switch_context(\u0026proc_a-\u003esp, \u0026proc_b-\u003esp); delay(); } } void proc_b_entry(void) { printf(\"starting process B\\n\"); while (1) { putchar('B'); switch_context(\u0026proc_b-\u003esp, \u0026proc_a-\u003esp); delay(); } } void proc_a_entry(void) { printf(\"starting process A\\n\"); while (1) { putchar('A'); yield(); delay(); } } void proc_b_entry(void) { printf(\"starting process B\\n\"); while (1) { putchar('B'); yield(); delay(); } } 예외 처리기 수정 예외 핸들러가 예외 발생 시점의 실행 상태를 스택에 저장하는데, 이제 프로세스마다 별도의 커널 스택을 사용하므로 약간의 수정을 해야 한다.\nkernel.c Inline Side void yield(void) { // 실행 가능한 프로세스를 탐색 struct process *next = idle_proc; for (int i = 0; i \u003c PROCS_MAX; i++) { struct process *proc = \u0026procs[(current_proc-\u003epid + i) % PROCS_MAX]; if (proc-\u003estate == PROC_RUNNABLE \u0026\u0026 proc-\u003epid \u003e 0) { // pid \u003e 0 은 idle_proc 제외한 proc next = proc; break; } } // 현재 프로세스 말고는 실행 가능한 프로세스가 없으면, 그냥 리턴 if (next == current_proc) return; // 컨텍스트 스위칭 struct process *prev = current_proc; current_proc = next; switch_context(\u0026prev-\u003esp, \u0026next-\u003esp); } void yield(void) { // 실행 가능한 프로세스를 탐색 struct process *next = idle_proc; for (int i = 0; i \u003c PROCS_MAX; i++) { struct process *proc = \u0026procs[(current_proc-\u003epid + i) % PROCS_MAX]; if (proc-\u003estate == PROC_RUNNABLE \u0026\u0026 proc-\u003epid \u003e 0) { // pid \u003e 0 은 idle_proc 제외한 proc next = proc; break; } } // 현재 프로세스 말고는 실행 가능한 프로세스가 없으면, 그냥 리턴 if (next == current_proc) return; __asm__ __volatile__( \"csrw sscratch, %[sscratch]\\n\" : : [sscratch] \"r\" ((uint32_t) \u0026next-\u003estack[sizeof(next-\u003estack)]) ); // 컨텍스트 스위칭 struct process *prev = current_proc; current_proc = next; switch_context(\u0026prev-\u003esp, \u0026next-\u003esp); } 컨텍스트 스위칭 직전에, 다음에 실행될 프로세스(next)의 커널 스택 top 주소를 sscratch에 미리 저장한다. 이렇게 해야 next 프로세스 실행 중 예외가 발생했을 때 올바른 커널 스택으로 전환할 수 있다.\nkernel_entry의 코드도 약간 수정하면 다음과 같다.\nkernel.c Inline Side __attribute__((naked)) __attribute__((aligned(4))) void kernel_entry(void) { __asm__ __volatile__( \"csrw sscratch, sp\\n\" \"addi sp, sp, -4 * 31\\n\" \"sw ra, 4 * 0(sp)\\n\" \"sw gp, 4 * 1(sp)\\n\" \"sw tp, 4 * 2(sp)\\n\" \"sw t0, 4 * 3(sp)\\n\" \"sw t1, 4 * 4(sp)\\n\" \"sw t2, 4 * 5(sp)\\n\" \"sw t3, 4 * 6(sp)\\n\" \"sw t4, 4 * 7(sp)\\n\" \"sw t5, 4 * 8(sp)\\n\" \"sw t6, 4 * 9(sp)\\n\" \"sw a0, 4 * 10(sp)\\n\" \"sw a1, 4 * 11(sp)\\n\" \"sw a2, 4 * 12(sp)\\n\" \"sw a3, 4 * 13(sp)\\n\" \"sw a4, 4 * 14(sp)\\n\" \"sw a5, 4 * 15(sp)\\n\" \"sw a6, 4 * 16(sp)\\n\" \"sw a7, 4 * 17(sp)\\n\" \"sw s0, 4 * 18(sp)\\n\" \"sw s1, 4 * 19(sp)\\n\" \"sw s2, 4 * 20(sp)\\n\" \"sw s3, 4 * 21(sp)\\n\" \"sw s4, 4 * 22(sp)\\n\" \"sw s5, 4 * 23(sp)\\n\" \"sw s6, 4 * 24(sp)\\n\" \"sw s7, 4 * 25(sp)\\n\" \"sw s8, 4 * 26(sp)\\n\" \"sw s9, 4 * 27(sp)\\n\" \"sw s10, 4 * 28(sp)\\n\" \"sw s11, 4 * 29(sp)\\n\" \"csrr a0, sscratch\\n\" \"sw a0, 4 * 30(sp)\\n\" \"mv a0, sp\\n\" \"call handle_trap\\n\" \"lw ra, 4 * 0(sp)\\n\" \"lw gp, 4 * 1(sp)\\n\" \"lw tp, 4 * 2(sp)\\n\" \"lw t0, 4 * 3(sp)\\n\" \"lw t1, 4 * 4(sp)\\n\" \"lw t2, 4 * 5(sp)\\n\" \"lw t3, 4 * 6(sp)\\n\" \"lw t4, 4 * 7(sp)\\n\" \"lw t5, 4 * 8(sp)\\n\" \"lw t6, 4 * 9(sp)\\n\" \"lw a0, 4 * 10(sp)\\n\" \"lw a1, 4 * 11(sp)\\n\" \"lw a2, 4 * 12(sp)\\n\" \"lw a3, 4 * 13(sp)\\n\" \"lw a4, 4 * 14(sp)\\n\" \"lw a5, 4 * 15(sp)\\n\" \"lw a6, 4 * 16(sp)\\n\" \"lw a7, 4 * 17(sp)\\n\" \"lw s0, 4 * 18(sp)\\n\" \"lw s1, 4 * 19(sp)\\n\" \"lw s2, 4 * 20(sp)\\n\" \"lw s3, 4 * 21(sp)\\n\" \"lw s4, 4 * 22(sp)\\n\" \"lw s5, 4 * 23(sp)\\n\" \"lw s6, 4 * 24(sp)\\n\" \"lw s7, 4 * 25(sp)\\n\" \"lw s8, 4 * 26(sp)\\n\" \"lw s9, 4 * 27(sp)\\n\" \"lw s10, 4 * 28(sp)\\n\" \"lw s11, 4 * 29(sp)\\n\" \"lw sp, 4 * 30(sp)\\n\" \"sret\\n\" ); } __attribute__((naked)) __attribute__((aligned(4))) void kernel_entry(void) { __asm__ __volatile__( // sp, sscratch = sscratch(커널 스택 top), sp(예외 시점의 sp) \"csrrw sp, sscratch, sp\\n\" \"addi sp, sp, -4 * 31\\n\" \"sw ra, 4 * 0(sp)\\n\" \"sw gp, 4 * 1(sp)\\n\" \"sw tp, 4 * 2(sp)\\n\" \"sw t0, 4 * 3(sp)\\n\" \"sw t1, 4 * 4(sp)\\n\" \"sw t2, 4 * 5(sp)\\n\" \"sw t3, 4 * 6(sp)\\n\" \"sw t4, 4 * 7(sp)\\n\" \"sw t5, 4 * 8(sp)\\n\" \"sw t6, 4 * 9(sp)\\n\" \"sw a0, 4 * 10(sp)\\n\" \"sw a1, 4 * 11(sp)\\n\" \"sw a2, 4 * 12(sp)\\n\" \"sw a3, 4 * 13(sp)\\n\" \"sw a4, 4 * 14(sp)\\n\" \"sw a5, 4 * 15(sp)\\n\" \"sw a6, 4 * 16(sp)\\n\" \"sw a7, 4 * 17(sp)\\n\" \"sw s0, 4 * 18(sp)\\n\" \"sw s1, 4 * 19(sp)\\n\" \"sw s2, 4 * 20(sp)\\n\" \"sw s3, 4 * 21(sp)\\n\" \"sw s4, 4 * 22(sp)\\n\" \"sw s5, 4 * 23(sp)\\n\" \"sw s6, 4 * 24(sp)\\n\" \"sw s7, 4 * 25(sp)\\n\" \"sw s8, 4 * 26(sp)\\n\" \"sw s9, 4 * 27(sp)\\n\" \"sw s10, 4 * 28(sp)\\n\" \"sw s11, 4 * 29(sp)\\n\" // stack[30] = sscratch (예외 시점의 sp) \"csrr a0, sscratch\\n\" \"sw a0, 4 * 30(sp)\\n\" // sscratch = sp + 4 * 31 (커널 스택 top 복원, 다음 예외 대비) \"addi a0, sp, 4 * 31\\n\" \"csrw sscratch, a0\\n\" \"mv a0, sp\\n\" \"call handle_trap\\n\" \"lw ra, 4 * 0(sp)\\n\" \"lw gp, 4 * 1(sp)\\n\" \"lw tp, 4 * 2(sp)\\n\" \"lw t0, 4 * 3(sp)\\n\" \"lw t1, 4 * 4(sp)\\n\" \"lw t2, 4 * 5(sp)\\n\" \"lw t3, 4 * 6(sp)\\n\" \"lw t4, 4 * 7(sp)\\n\" \"lw t5, 4 * 8(sp)\\n\" \"lw t6, 4 * 9(sp)\\n\" \"lw a0, 4 * 10(sp)\\n\" \"lw a1, 4 * 11(sp)\\n\" \"lw a2, 4 * 12(sp)\\n\" \"lw a3, 4 * 13(sp)\\n\" \"lw a4, 4 * 14(sp)\\n\" \"lw a5, 4 * 15(sp)\\n\" \"lw a6, 4 * 16(sp)\\n\" \"lw a7, 4 * 17(sp)\\n\" \"lw s0, 4 * 18(sp)\\n\" \"lw s1, 4 * 19(sp)\\n\" \"lw s2, 4 * 20(sp)\\n\" \"lw s3, 4 * 21(sp)\\n\" \"lw s4, 4 * 22(sp)\\n\" \"lw s5, 4 * 23(sp)\\n\" \"lw s6, 4 * 24(sp)\\n\" \"lw s7, 4 * 25(sp)\\n\" \"lw s8, 4 * 26(sp)\\n\" \"lw s9, 4 * 27(sp)\\n\" \"lw s10, 4 * 28(sp)\\n\" \"lw s11, 4 * 29(sp)\\n\" \"lw sp, 4 * 30(sp)\\n\" \"sret\\n\" ); } 이전과 다른점을 보자면 다음과 같이 나타낼 수 있다.\n이전 현재 스택 상황 sp가 항상 커널 스택 sp가 유저 스택일 수 있음 필요한 동작 sp를 sscratch에 백업만 sp ↔ sscratch 교환 명령어 csrw sscratch, sp csrrw sp, sscratch, sp sscratch 리셋 없음 addi a0, sp, 4*31 + csrw sscratch, a0 기존 챕터 8에서는 커널 스택이 하나뿐이라 csrw(단순 백업)로 충분했지만, 이제는 유저 스택에서 예외가 발생할 수 있으므로 csrrw(swap)로 커널 스택 전환이 필요하다. sscratch 리셋은 같은 프로세스에서 다음 예외가 발생했을 때를 대비한 것이다.\n참고 자료 [번역] C++: 커스텀 메모리 할당 RISC-V Calling Convention ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os05/","summary":"Bump Allocator로 페이지 할당을 구현하고, 프로세스 제어 블록(PCB), switch_context, 스케줄러(yield), 예외 처리기 수정까지 진행","title":"05. 메모리 할당과 프로세스"},{"content":" 예외(Exception) Exception이란 프로그램 실행 중 CPU가 잘못된 메모리 접근(일명 페이지 폴트), 유효하지 않은 명령(Illegal Instructions), 또는 시스템 콜이 발생했을 때 커널이 개입하도록 해주는 CPU 기능이다. CPU는 ISA에서 정의된 명령어만 수행하는데, 실행 도중에 정상적인 다음 동작이 정의되지 않은 상황을 만날 수 있다.\n예를 들어, CPU의 디코더가 명령어를 32비트 비트 패턴으로 받아서 인코딩 표에 대조했는데, 어떤 항목에도 매칭되지 않는 경우가 있다. 또는 매칭은 되지만 조건이 맞지 않는 경우(읽기 전용 CSR에 쓰기 시도 등)도 있다. 이때 CPU는 \u0026ldquo;이 비트 패턴에 대응하는 실행 로직이 없다\u0026quot;는 것까지만 판단할 수 있고, 그다음에 무시할지, 프로그램을 죽일지, 소프트웨어로 에뮬레이션할지는 알지 못한다. 이런 정책적 판단은 커널(OS)이 해야 한다.\n그래서 CPU는 이런 상황을 만나면, ISA에 미리 정의된 대로 현재 상태를 CSR에 저장하고 stvec에 등록된 커널의 예외 핸들러로 점프한다. 커널이 상황을 판단하고 적절히 처리한 뒤 sret으로 복귀하면, 프로그램은 아무 일 없었던 것처럼 재개될 수 있다. 이런 CPU가 처리할 수 없는 상황을 커널에게 위임하는 제어 흐름 전환 메커니즘이 예외다.\nos01에서 다뤘던 Page Fault 시나리오 를 떠올려보면, U-Mode에서 lw s0, 0(s1)를 실행했을 때 MMU가 페이지 테이블에서 V=0인 엔트리를 만나는 것도 같은 구조다. CPU는 \u0026ldquo;변환 실패\u0026quot;라는 사실까지만 감지하고 커널로 점프하며, 디스크에서 페이지를 불러올지 프로그램을 종료할지는 커널이 결정했다. 이 글에서는 이 점프를 받아주는 예외 핸들러의 뼈대 코드를 직접 구현한다.\n예외가 처리되는 과정 RISC-V에서 예외는 다음과 같은 단계를 거쳐 처리된다.\nCPU는 medelegMachine Exception Delegation이라는 레지스터로, 기본적으로 예외는 M-mode에서 처리되지만, 속도와 성능을 위해서 예외를 S-mode로 위임하여 처리할지를 결정하는 레지스터이다. 레지스터를 확인하여 어떤 모드(운영 모드)에서 예외를 처리할지 결정한다. 여기서는 OpenSBI가 이미 U-Mode와 S-Mode 예외를 S-Mode 핸들러에서 처리하도록 설정해뒀다. CPU는 예외가 발생한 시점의 상태(각종 레지스터 값)를 여러 CSR(제어/상태 레지스터)들에 저장하게 된다.(아래 표 참조). stvec 레지스터에 저장된 값이 프로그램 카운터로 설정되면서, 커널의 예외 핸들러로 점프한다. 예외 핸들러는 일반 레지스터(프로그램 상태)를 별도로 저장한 뒤, 예외를 처리한다. 처리 후, 저장해둔 실행 상태를 복원하고 sret 명령어를 실행해 예외가 발생했던 지점으로 돌아가 프로그램을 재개한다. 레지스터 내용 scause Supervisor Cause Register로, 커널은 이를 읽어 어떤 종류의 예외인지 판단합니다. stval Supervisor Trap Value Register로, 예외에 대한 부가적인 값을 담는다.(예: 페이지 폴트 시 접근하려던 잘못된 메모리 주소) sepc Supervisor Exception Program Counter로, 예외가 발생했을 때의 프로그램 카운터(PC) 값. sstatus Supervisor Status Register로, 현재 프로세스의 상태를 관리하는 레지스터다. 지금 인터럽트가 가능한지, 이전에 어떤 모드였는지, 어떤 메모리 권한을 가졌는지 등을 저장하는 레지스터다. 예외 핸들러(Exception Handler) 구현 아래 코드는 stvec 레지스터에 등록할 예외 핸들러 진입점(entry point) 예시다.\nkernel.c __attribute__((naked)) __attribute__((aligned(4))) void kernel_entry(void) { __asm__ __volatile__( \u0026#34;csrw sscratch, sp\\n\u0026#34; \u0026#34;addi sp, sp, -4 * 31\\n\u0026#34; \u0026#34;sw ra, 4 * 0(sp)\\n\u0026#34; \u0026#34;sw gp, 4 * 1(sp)\\n\u0026#34; \u0026#34;sw tp, 4 * 2(sp)\\n\u0026#34; \u0026#34;sw t0, 4 * 3(sp)\\n\u0026#34; \u0026#34;sw t1, 4 * 4(sp)\\n\u0026#34; \u0026#34;sw t2, 4 * 5(sp)\\n\u0026#34; \u0026#34;sw t3, 4 * 6(sp)\\n\u0026#34; \u0026#34;sw t4, 4 * 7(sp)\\n\u0026#34; \u0026#34;sw t5, 4 * 8(sp)\\n\u0026#34; \u0026#34;sw t6, 4 * 9(sp)\\n\u0026#34; \u0026#34;sw a0, 4 * 10(sp)\\n\u0026#34; \u0026#34;sw a1, 4 * 11(sp)\\n\u0026#34; \u0026#34;sw a2, 4 * 12(sp)\\n\u0026#34; \u0026#34;sw a3, 4 * 13(sp)\\n\u0026#34; \u0026#34;sw a4, 4 * 14(sp)\\n\u0026#34; \u0026#34;sw a5, 4 * 15(sp)\\n\u0026#34; \u0026#34;sw a6, 4 * 16(sp)\\n\u0026#34; \u0026#34;sw a7, 4 * 17(sp)\\n\u0026#34; \u0026#34;sw s0, 4 * 18(sp)\\n\u0026#34; \u0026#34;sw s1, 4 * 19(sp)\\n\u0026#34; \u0026#34;sw s2, 4 * 20(sp)\\n\u0026#34; \u0026#34;sw s3, 4 * 21(sp)\\n\u0026#34; \u0026#34;sw s4, 4 * 22(sp)\\n\u0026#34; \u0026#34;sw s5, 4 * 23(sp)\\n\u0026#34; \u0026#34;sw s6, 4 * 24(sp)\\n\u0026#34; \u0026#34;sw s7, 4 * 25(sp)\\n\u0026#34; \u0026#34;sw s8, 4 * 26(sp)\\n\u0026#34; \u0026#34;sw s9, 4 * 27(sp)\\n\u0026#34; \u0026#34;sw s10, 4 * 28(sp)\\n\u0026#34; \u0026#34;sw s11, 4 * 29(sp)\\n\u0026#34; \u0026#34;csrr a0, sscratch\\n\u0026#34; \u0026#34;sw a0, 4 * 30(sp)\\n\u0026#34; \u0026#34;mv a0, sp\\n\u0026#34; \u0026#34;call handle_trap\\n\u0026#34; \u0026#34;lw ra, 4 * 0(sp)\\n\u0026#34; \u0026#34;lw gp, 4 * 1(sp)\\n\u0026#34; \u0026#34;lw tp, 4 * 2(sp)\\n\u0026#34; \u0026#34;lw t0, 4 * 3(sp)\\n\u0026#34; \u0026#34;lw t1, 4 * 4(sp)\\n\u0026#34; \u0026#34;lw t2, 4 * 5(sp)\\n\u0026#34; \u0026#34;lw t3, 4 * 6(sp)\\n\u0026#34; \u0026#34;lw t4, 4 * 7(sp)\\n\u0026#34; \u0026#34;lw t5, 4 * 8(sp)\\n\u0026#34; \u0026#34;lw t6, 4 * 9(sp)\\n\u0026#34; \u0026#34;lw a0, 4 * 10(sp)\\n\u0026#34; \u0026#34;lw a1, 4 * 11(sp)\\n\u0026#34; \u0026#34;lw a2, 4 * 12(sp)\\n\u0026#34; \u0026#34;lw a3, 4 * 13(sp)\\n\u0026#34; \u0026#34;lw a4, 4 * 14(sp)\\n\u0026#34; \u0026#34;lw a5, 4 * 15(sp)\\n\u0026#34; \u0026#34;lw a6, 4 * 16(sp)\\n\u0026#34; \u0026#34;lw a7, 4 * 17(sp)\\n\u0026#34; \u0026#34;lw s0, 4 * 18(sp)\\n\u0026#34; \u0026#34;lw s1, 4 * 19(sp)\\n\u0026#34; \u0026#34;lw s2, 4 * 20(sp)\\n\u0026#34; \u0026#34;lw s3, 4 * 21(sp)\\n\u0026#34; \u0026#34;lw s4, 4 * 22(sp)\\n\u0026#34; \u0026#34;lw s5, 4 * 23(sp)\\n\u0026#34; \u0026#34;lw s6, 4 * 24(sp)\\n\u0026#34; \u0026#34;lw s7, 4 * 25(sp)\\n\u0026#34; \u0026#34;lw s8, 4 * 26(sp)\\n\u0026#34; \u0026#34;lw s9, 4 * 27(sp)\\n\u0026#34; \u0026#34;lw s10, 4 * 28(sp)\\n\u0026#34; \u0026#34;lw s11, 4 * 29(sp)\\n\u0026#34; \u0026#34;lw sp, 4 * 30(sp)\\n\u0026#34; \u0026#34;sret\\n\u0026#34; ); } 코드 실행 흐름은 다음과 같다.\n현재 스택 포인터 저장 sscratch(Supervisor Scratch Register): 임시 저장 레지스터로, 현재는 커널의 스택 포인터를 임시로 보관하고 있다. 레지스터 저장 이전 스택 포인터 저장 handle_trap이라는 함수 호출 레지스터 복구 sret으로 복귀 kernel_entry는 trap으로 진입한 CPU 상태를 안전하게 저장하고, 이를 handle_trap함수가 처리할 수 있는 형태(trap_frame)로 변환한 뒤, 다시 원래 상태로 복구하는 역할을 한다.\nkernel_entry 함수에 있던 31개의 레지스터 뭉탱이를 trap_frame이라는 구조체를 만들어서 관리하자. 그렇다면 우리는 handle_trap 함수를 다음과 같이 짤 수 있다. 현재로는 trap이 발생하면 커널 패닉을 발생시키도록 했지만, 추후에 처리를 추가할 예정이다.\nkernel.c void handle_trap(struct trap_frame *f) { uint32_t scause = READ_CSR(scause); uint32_t stval = READ_CSR(stval); uint32_t user_pc = READ_CSR(sepc); PANIC(\u0026#34;unexpected trap scause=%x, stval=%x, sepc=%x\\n\u0026#34;, scause, stval, user_pc); } 커널 패닉은 디버깅을 위해 작성했다.\n이때 사용되는 trap_frame, READ_CSR을 헤더파일에 정의하자.\nkernel.h #include \u0026#34;common.h\u0026#34; struct trap_frame { uint32_t ra; uint32_t gp; uint32_t tp; uint32_t t0; uint32_t t1; uint32_t t2; uint32_t t3; uint32_t t4; uint32_t t5; uint32_t t6; uint32_t a0; uint32_t a1; uint32_t a2; uint32_t a3; uint32_t a4; uint32_t a5; uint32_t a6; uint32_t a7; uint32_t s0; uint32_t s1; uint32_t s2; uint32_t s3; uint32_t s4; uint32_t s5; uint32_t s6; uint32_t s7; uint32_t s8; uint32_t s9; uint32_t s10; uint32_t s11; uint32_t sp; } __attribute__((packed)); #define READ_CSR(reg) \\ ({ \\ unsigned long __tmp; \\ __asm__ __volatile__(\u0026#34;csrr %0, \u0026#34; #reg : \u0026#34;=r\u0026#34;(__tmp)); \\ __tmp; \\ }) #define WRITE_CSR(reg, value) \\ do { \\ uint32_t __tmp = (value); \\ __asm__ __volatile__(\u0026#34;csrw \u0026#34; #reg \u0026#34;, %0\u0026#34; ::\u0026#34;r\u0026#34;(__tmp)); \\ } while (0) __attribute__((packed)): 컴파일러가 임의로 구조체 사이에 넣는 빈 공간(패딩)을 제거하라는 지시어 또 do - while(0) 씀 마지막으로, kernel_main 함수에 stvecSupervisor Trap-Vector Base Address Register로, S-mode에서 예외나 인터럽트가 발생했을 때, CPU가 시작할 핸들러의 주소를 나타내는 레지스터이다. 레지스터를 설정하자.\nkernel.c Inline Side void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); PANIC(\"booted!\"); printf(\"unreachable here!\\n\"); } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); WRITE_CSR(stvec, (uint32_t) kernel_entry); // new __asm__ __volatile__(\"unimp\"); // new } unimp은 illegal instruction 로 간주되어 의도적으로 예외를 발생시키는 코드이다. kernel_entry라는 함수명을 변수로: C에서 함수의 이름은 그 자체로 메모리 주소를 나타내는 상수이다. 실행해보기 ./run.sh PANIC: kernel.c:95: unexpected trap scause=00000002, stval=00000000, sepc=8020011cscause=2는 \u0026ldquo;illegal instruction\u0026rdquo; 예외다. 또한 sepc가 가리키는 주소가 뭔지 확인해보자면 다음과 같이 하면된다. llvm-addr2line-14 -e kernel.elf 8020011c를 치면\nllvm-addr2line-14 -e kernel.elf 8020011c /home/leejw/projects/my-os-in-1000-lines/kernel.c:127으로 나오게 되고, 코드 위치를 살펴보면, __asm__ __volatile__(\u0026quot;unimp\u0026quot;);에 해당하는 것을 알 수 있다.\n참고 자료 risc-v reference ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os04/","summary":"예외 발생 시 CPU가 커널로 제어를 넘기는 과정을 살펴보고, kernel_entry와 handle_trap을 구현","title":"04. Exception과 Trap Handler"},{"content":"이 글을 쓰는 이유 블로그 글을 쭉 다시 읽어보다가, OnPyRunner 시리즈에서 당시의 사고 과정이 충분히 담기지 못한 부분이 있다는 걸 느꼈다. 그래서 총평을 써보기로 했다.\nOnPyRunner 시리즈는 1편부터 10편까지, 2026년 1월 8일부터 2월 22일까지 약 45일간의 개발 과정을 기록한 글이다. 당시에는 그 순간의 사고 과정을 최대한 솔직하게 남기려 했고, 그 목적은 달성했다고 생각한다.\n하지만 시리즈를 다시 읽어보니 빠져 있는 것이 있었다. 각 편에는 \u0026ldquo;이렇게 했다, 안 됐다, 바꿨다\u0026quot;는 사실의 나열은 있지만, 왜 그런 판단을 했고, 그 판단의 어디가 틀렸으며, 그래서 나의 사고방식이 어떻게 바뀌었는지는 충분히 담기지 못했다.\n시리즈 원문은 당시의 날것 그대로 보존하기로 했다. 이 총평은 그 위에 현재의 시선을 얹는 글이다.\n출발점: 나는 뭘 알고 있었나 이 프로젝트를 시작했을 때, 내가 할 줄 아는 건 Python으로 알고리즘 문제를 해결하는 코드를 작성하는 것이 전부였다. 웹 서버가 요청을 어떻게 받는지, API가 뭔지, Express가 뭔지, Redis가 뭔지, 설계라는 행위가 무엇을 의미하는지조차 몰랐다. 알고리즘 문제 풀이가 나의 개발 경험 전부였다.\n이 맥락을 먼저 밝히는 이유는, 시리즈에서 등장하는 수많은 시행착오가 알면서도 한 실수가 아니라 몰랐기 때문에 할 수밖에 없었던 탐색이었음을 분명히 하기 위해서다.\n시리즈를 읽으면 2편까지는 Express(JavaScript)로 구현하다가, 이후 FastAPI(Python)로 바뀌어 있는 것을 알 수 있다. 처음에는 새로운 언어를 배우는 계기로 삼겠다는 생각에 JavaScript와 Express를 선택했다. 하지만 실제로 진행해보니, 모르는 언어와 모르는 도메인을 동시에 다루는 것은 두 개의 미지수를 한꺼번에 푸는 것과 같았다. 문제가 생겼을 때 그것이 JavaScript 문법의 문제인지, 설계의 문제인지, 도구의 문제인지 구분할 수가 없었다. 그래서 내가 잘 아는 언어인 Python으로 된 FastAPI로 전환했다. 변수를 하나 줄여야 나머지 하나에 집중할 수 있다는 판단이었고, 지금 돌아봐도 이 선택은 맞았다고 생각한다.\n핵심 의사결정 복기 1. 비동기 큐 도입과 철회 (1편 -\u0026gt; 3편) 당시 판단: 1편에서 \u0026ldquo;동시 접속자가 늘면 코드 실행 끝날 때까지 블로킹\u0026quot;이라는 문제를 정의했고, 해결책으로 \u0026ldquo;메시지 큐 기반 비동기 처리\u0026quot;를 선택했다. BullMQ라는 것을 찾아서 2편에서 Express + BullMQ로 첫 구현을 했고, API 호출이 Worker까지 도달하는 것을 확인했다.\n실제로 일어난 일: 3편에서 이 구조를 폐기했다. 구현하면서 \u0026ldquo;사용자가 jobId로 결과를 조회해야 하는 구조가 내가 원하던 실행기의 모습인가?\u0026ldquo;라는 의문이 들었다. 내가 원한 건 tio.run처럼 실행 버튼을 누르면 결과가 바로 나오는 간단한 실행기였는데, 비동기 큐를 도입하면서 사용자 경험의 본질이 바뀌어 버린 것이다. 게다가 비동기 큐는 병목 자체를 해결해주지 않았다. 실행 컨테이너가 N개면, N개를 넘는 요청의 병목은 컨테이너 수를 늘리는 것 외에는 방법이 없었다.\n지금의 해석: 이 실수의 근본 원인은 비동기를 도입한 근거가 틀렸다는 것이다. \u0026ldquo;동시 접속 → 블로킹 → 비동기\u0026quot;라는 연상으로 비동기를 선택했지만, 3편에서 스스로 확인했듯이 비동기는 그 병목을 해결해주지 않았다. 이후 6편에서 비동기 구조를 다시 도입했을 때는 \u0026ldquo;Request와 Job의 생명주기 분리\u0026quot;라는 전혀 다른 근거에서였다. 같은 비동기라도 왜 도입하는지에 따라 결과가 달라진다는 걸, 한 바퀴 돌고 나서야 알게 되었다.\n또한 지금 풀어야 할 문제와 나중에 풀어도 될 문제를 구분하지 못했다인 것도 있다. 동시 접속이 문제가 된다는 판단 자체는 틀리지 않았다. 하지만 \u0026ldquo;코드를 넣으면 결과가 나온다\u0026quot;는 기본 동작조차 없는 MVP 단계에서, 그것이 지금 해결해야 할 1순위는 아니었다.\n2. 동기/비동기와 블로킹/논블로킹의 혼동 (3편 -\u0026gt; 6편) 당시 판단: 3편에서 비동기를 걷어내고 동기로 전환했지만, 1편에서 제기한 블로킹 문제에 대한 해결책은 제시하지 않은 채 넘어갔다. \u0026ldquo;간단한 실행기니까 동기가 맞다\u0026quot;는 결론만 내렸다.\n실제로 일어난 일: 6편에서 이 혼동을 스스로 발견했다. 동기/비동기와 블로킹/논블로킹은 독립적인 개념이고, \u0026ldquo;nsjail 내부 실행이 동기\u0026quot;라고 해서 \u0026ldquo;전체 서버가 동기여야 한다\u0026quot;는 것은 아니었다. 전체를 하나의 동기/비동기로 통일할 필요 없이, 각 계층의 상호작용에 따라 별도로 판단해야 했다.\n지금의 해석: 1편에서 3편까지의 사고 흐름은 동기 \u0026lt;-\u0026gt; 비동기라는 하나의 축 위에서 왔다갔다한 것이었다. 개념이 정립되지 않은 상태에서 기술적 판단을 하다 보니, 한쪽이 틀리면 반대쪽으로 뒤집는 것 외에는 선택지가 보이지 않았다. 6편에서 개념을 정리하고 나서야 \u0026ldquo;nsjail 실행은 동기, 서버 I/O는 비동기/논블로킹이 가능하다\u0026quot;는 구분이 생겼고, 부분적으로 비동기를 적용할 수 있다는 사실을 알게 되었다. 결국 10편의 최종 아키텍처가 그 답이었다. 내부적으로는 Redis 큐를 통해 비동기로 처리하되, 클라이언트는 Polling으로 결과를 기다리는 — UX적으로는 동기처럼 보이는 구조. 1편에서 \u0026ldquo;비동기면 사용자가 jobId로 조회해야 하잖아\u0026quot;라고 느꼈던 불편함은, 비동기 자체의 문제가 아니라 비동기를 UX로 포장하는 방법을 몰랐기 때문이었다.\n3. Redis 역할의 재정의 (1편 -\u0026gt; 3편 -\u0026gt; 6편) 당시 판단: 1편에서 Redis를 \u0026ldquo;비동기 처리를 위한 메시지 큐 백엔드\u0026quot;로 도입했다. 3편에서 비동기를 걷어내면서 Redis도 함께 제거했다.\n실제로 일어난 일: 6편에서 Redis를 다시 도입했다. 하지만 이번에는 이유가 달랐다. 문제의 본질은 코드 실행이라는 긴 작업의 생명주기가 HTTP 요청의 생명주기에 종속되어 있다는 것이었다. 사용자가 탭을 닫으면 실행 결과를 수집할 주체가 사라진다. 실행은 되었지만 시스템 입장에서는 해당 실행이 존재한 적 없는 것과 다름없는 Orphaned 상태가 된다. Redis는 이 두 생명주기를 분리하기 위한 장치였다.\n지금의 해석: 1편의 Redis 도입은 \u0026ldquo;비동기를 하려면 큐가 필요하고, 큐를 하려면 Redis가 필요하다\u0026quot;는 도구 중심의 사고였다. 6편의 Redis 도입은 \u0026ldquo;Job의 상태를 HTTP 요청으로부터 독립시키기 위해 외부 저장소가 필요하다\u0026quot;는 문제 중심의 사고였다. 같은 기술을 도입했지만, 사고의 방향이 정반대였다. 이 차이를 깨달은 것이 프로젝트 전체에서 가장 중요한 전환점이었다고 생각한다.\n4. Docker 도입 — 도구가 아니라 환경의 문제 (4편 -\u0026gt; 5편) 당시 판단: 4편에서 nsjail을 선택하고 로컬 우분투에서 직접 테스트하려 했다.\n실제로 일어난 일: 로컬 우분투의 파일 시스템 위에서 nsjail의 chroot + mount를 설정하다 보니, nsjail이 돌아가는지조차 확인할 수 없는 디버깅 지옥에 빠졌다. 개발 환경과 배포 환경의 괴리도 커졌다. 5편에서 Docker를 도입하여 일관된 환경을 만들었고, 이후 clone_newns 설정 문제도 Docker 안에서 깔끔하게 해결했다.\n지금의 해석: 이 판단은 실수라기보다 경험하지 않으면 알 수 없는 것에 가깝다. \u0026ldquo;환경을 먼저 고정하고 그 위에서 개발한다\u0026quot;는 원칙은 말로는 쉽지만, 직접 삽질해보지 않으면 체감하기 어렵다. 이 경험 이후로는, 시스템 레벨 도구를 다룰 때 \u0026ldquo;로컬에서 직접 세팅\u0026quot;이 아니라 \u0026ldquo;컨테이너로 환경을 격리한 뒤 그 안에서 작업\u0026quot;하는 순서로 접근하게 되었다.\n5. cgroup v2 삽질 — 문서를 읽는 법을 배운 순간 (9편) 당시 판단: fork bomb을 막기 위해 cgroup으로 프로세스 수를 제한하려 했다.\n실제로 일어난 일: Docker 컨테이너 내부에서 nsjail이 cgroup을 설정하려 하면 계속 실패했다. 원인은 cgroup v2의 no internal processes rule이었다. 루트 cgroup에 이미 프로세스가 존재하는 상태에서는 하위 cgroup에 컨트롤러를 위임할 수 없다. 루트의 프로세스를 init 하위 그룹으로 옮기고, 루트를 비운 뒤 subtree_control에 컨트롤러를 활성화하는 절차를 거쳐 해결했다.\n지금의 해석: 이건 구글링으로는 안 되는 문제였다. 스택오버플로우에도 같은 상황의 답이 없었고, 결국 cgroup v2 공식 문서를 직접 읽고 no internal processes rule이라는 규칙을 이해하고 나서야 풀렸다. 이때 느낀 건, 에러 메시지를 검색하는 것과 시스템의 규칙을 이해하는 것은 완전히 다른 종류의 디버깅이라는 것이였다. 이후로는 문제가 생겼을 때 검색으로 안되면 공식 문서를 먼저 찾는 습관이 생겼다.\n이 프로젝트에서 얻은 것 10편의 시리즈를 작성하면서 프로젝트를 회고하면서 나는 과연 뭘 배웠나?를 고민해보았다. 기술적으로도 배운게 있었고, 사고방식의 변화도 있었다.\n사고 방식의 변화 첫째, 궁극의 결과물이 아니라 MVP를 지향점으로. 1편에서 tio.run을 보고 프로젝트를 시작했지만, tio.run은 이미 완성된 서비스였다. 그걸 지향점으로 삼다 보니, 첫 설계부터 비동기 큐, 워커 스케일링, 상태 관리까지 한꺼번에 고려하게 되었다. 하지만 MVP 단계에서 필요한 건 \u0026ldquo;코드를 넣으면 결과가 나온다\u0026quot;는 최소한의 동작이었고, 나머지는 그 위에서 고도화할 문제였다. 완성된 서비스를 처음부터 만들려 하면 과잉 설계가 되고, 최소 동작부터 만들면 다음으로 어떤걸 만들어야할지 고민하게 되면서 그 단계에서 진짜 필요한 것이 무엇인지가 보인다.\n둘째, 도구가 아니라 문제를 먼저 정의하라. BullMQ, Redis, Docker, Nsjail 이 프로젝트에서 사용한 모든 기술은 결국 올바른 자리를 찾았다. BullMQ는 사라짐. 하지만 처음부터 올바른 자리에 놓인 것은 하나도 없었다. 매번 \u0026ldquo;이 기술이 좋아 보인다 -\u0026gt; 도입 -\u0026gt; 문제에 안 맞는다 -\u0026gt; 제거하거나 재정의\u0026quot;라는 사이클을 거쳤다. 6편에서 Redis의 역할을 재정의한 것이 이 사이클을 깨는 전환점이었다. 문제를 정의하고 그것을 해결할 수 있는 도구를 찾자.\n셋째, 대비와 과잉 설계의 경계는 근거의 유무다. 1편에서 동시 접속 문제를 예상하고 비동기 큐를 도입한 것도 \u0026ldquo;대비\u0026quot;였고, 6편에서 요청과 실행의 생명주기가 구조적으로 다르다는 것을 분석하고 Redis를 도입한 것도 \u0026ldquo;대비\u0026quot;였다. 하지만 전자는 사용자가 나 혼자인 MVP에서 존재하지 않는 문제를 막으려 한 과잉 설계였고, 후자는 nsjail 실행이 수 초가 걸린다는 구조적 사실에 기반한 설계였다. 같은 \u0026ldquo;미리 준비한다\u0026quot;라는 행위도, 그 아래에 분석이 있느냐 예감이 있느냐에 따라 결과가 완전히 달라졌다.\n기술적 역량 이 프로젝트를 시작할 때는 알고리즘 문제 해결을 위한 단일 Python 파일을 만드는게 전부였다. 이 프로젝트를 거치면서 Docker로 일관된 개발 환경을 구축하는 법, nsjail로 신뢰할 수 없는 코드를 격리 실행하는 법, cgroup v2로 리소스를 제한하는 법, Redis 기반의 웹-큐-워커 아키텍처를 설계하는 법, pytest와 TDD로 API를 자동 검증하는 법, github action으로 자동화 배포하는 법, Nginx로 reverse proxy하는 법 등의 웹 개발에서 배포까지의 전체적인 과정을 직접 부딪혀가며 익혔다.\n지금이라면 같은 프로젝트를 지금 다시 시작한다면, 아마 Docker부터 만들고, 동기 MVP로 시작하고, 구조적 분석이 끝난 시점에서 Redis를 도입할 것이다.\n이렇게 하면 45일이 아니라 2~3주 안에 같은 결과물이 나왔을 것이다. 하지만 위의 판단은 전부 직접 만들어보고 문제를 겪었기 때문에 나온 것이다. 그 과정을 거치지 않았으면 왜 그게 나은지도 몰랐을 것이고, 애초에 그런 선택지가 보이지도 않았을 것이다.\n마치며 OnPyRunner는 나의 첫 프로젝트였다. 처음부터 모든 걸 알 수는 없다. 이 글은 \u0026ldquo;처음부터 이렇게 했으면 됐는데\u0026quot;가 아니라, 프로젝트를 진행하면서 수많은 문제를 겪고, 개념을 정립한 지금의 내가 돌이켜봤을 때 \u0026ldquo;어떤 걸 얻을 수 있었나?\u0026ldquo;를 회고하는 글이다. 나는 Python 파일 하나 만들 줄 아는 상태에서 시작해서, 웹 서버, 메시지 큐, 샌드박스 격리, 컨테이너, cgroup, 배포까지 건드려보며 run.ljweel.dev 라는 실제로 동작하는 서비스를 만들어냈다.\n시리즈 10편은 \u0026ldquo;당시의 내가 어떻게 생각했는지\u0026quot;의 기록이고, 이 총평은 \u0026ldquo;지금의 내가 그것을 어떻게 바라보는지, 무엇을 얻어갔는지\u0026quot;의 기록이다.\n","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner_retrospect/","summary":"첫 프로젝트 OnPyRunner의 10편 시리즈를 돌아보며, 당시의 판단과 실수를 현재의 관점에서 분석하고, 이 프로젝트를 통해 체득한 엔지니어링 원칙을 정리했습니다.","title":"OnPyRunner 회고: 프로젝트를 돌아보며"},{"content":" SBI에게 \u0026ldquo;hello\u0026quot;라고 말하기 이전 글에서 SBI는 OS와 펌웨어가 소통하기 위한 인터페이스라고 했다. SBI 명령어를 호출하려면 ecall을 사용하면 된다.\necall은 RISC-V에서 현재 권한보다 높은 권한을 가진 소프트웨어에게 요청을하는 명령어다. OS 개발에서는 M-mode에게 ecall을 하는 것이라고 보면된다.\nkernel.c Inline Side typedef unsigned char uint8_t; // 1 byte typedef unsigned int uint32_t; // 4 byte typedef uint32_t size_t; // memory size extern char __bss[], __bss_end[], __stack_top[]; void *memset(void *buf, char c, size_t n) { uint8_t *p = (uint8_t *) buf; while (n--) *p++ = c; return buf; } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); for (;;); } __attribute__((section(\".text.boot\"))) __attribute__((naked)) void boot(void) { __asm__ __volatile__( \"mv sp, %[stack_top]\\n\" // Set the stack pointer \"j kernel_main\\n\" // Jump to the kernel main function : : [stack_top] \"r\" (__stack_top) // Pass the stack top address as %[stack_top] ); } #include \"kernel.h\" typedef unsigned char uint8_t; // 1 byte typedef unsigned int uint32_t; // 4 byte typedef uint32_t size_t; // memory size extern char __bss[], __bss_end[], __stack_top[]; struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4, long arg5, long fid, long eid) { register long a0 __asm__(\"a0\") = arg0; register long a1 __asm__(\"a1\") = arg1; register long a2 __asm__(\"a2\") = arg2; register long a3 __asm__(\"a3\") = arg3; register long a4 __asm__(\"a4\") = arg4; register long a5 __asm__(\"a5\") = arg5; register long a6 __asm__(\"a6\") = fid; // Function ID register long a7 __asm__(\"a7\") = eid; // Extension ID __asm__ __volatile__(\"ecall\" : \"=r\"(a0), \"=r\"(a1) : \"r\"(a0), \"r\"(a1), \"r\"(a2), \"r\"(a3), \"r\"(a4), \"r\"(a5), \"r\"(a6), \"r\"(a7) : \"memory\"); return (struct sbiret){.error = a0, .value = a1}; } void putchar(char ch) { sbi_call(ch, 0, 0, 0, 0, 0, 0, 1); // Console Putchar } void *memset(void *buf, char c, size_t n) { uint8_t *p = (uint8_t *) buf; while (n--) *p++ = c; return buf; } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); const char *s = \"\\n\\nHello World!\\n\"; for (int i = 0; s[i] != '\\0'; i++) { putchar(s[i]); } for (;;) { __asm__ __volatile__(\"wfi\"); } } __attribute__((section(\".text.boot\"))) __attribute__((naked)) void boot(void) { __asm__ __volatile__( \"mv sp, %[stack_top]\\n\" // Set the stack pointer \"j kernel_main\\n\" // Jump to the kernel main function : : [stack_top] \"r\" (__stack_top) // Pass the stack top address as %[stack_top] ); } 추가된 내용을 위주로 보자면, 다음과 같다.\nkernel.h 추가, kernel.h에는 sbiret이라는 구조체가 정의되어있다. 아래 코드와 같다. #pragma once는 헤더파일을 중복해서 포함하는 것을 방지하는 전처리기 지시문 kernel.h #pragma once struct sbiret { long error; long value; }; sbi_call함수는 함수 인자(a0, a1 등)를 register에 배치시키고, ecall명령어를 실행하고 반환하는 함수이다.\nregister long a0 __asm__(\u0026quot;a0\u0026quot;) = arg0;은 arg0값을 a0 register에 넣으라는 문법이다.\n로컬 변수에 대해 레지스터를 지정하는 문법이라고 보면 된다. __asm__ __volatile__(\u0026quot;ecall\u0026quot;, ...)은 a0-7까지의 레지스터를 입력으로 주고, ecall을 실행하여 반환값을 각각 a0,a1에 저장하라는 뜻이다. memory clobber 인자는 ecall 실행 전후로 메모리가 변경될 수 있으니, 컴파일러가 레지스터에 캐싱해둔 메모리 값을 신뢰하지 말라는 지시어다. memory clobber는 컴파일러에게 다음과 같은 동작을 지시한다. 쓰기 방향: ecall 실행 전에, 레지스터에만 들고 있고 아직 메모리에 쓰지 않은 값을 메모리에 flush한다. 읽기 방향: ecall 실행 후에, 레지스터에 캐싱해둔 메모리 값을 버리고 메모리에서 다시 읽는다. putchar('A')에 대해 살펴보자면 다음과 같다.\n매개변수 값 레지스터 의미 arg0 'A' (= 65) a0 출력할 문자 arg1~arg5 0 a1~a5 사용 안 함 fid 0 a6 함수 ID (Console Putchar는 0) eid 1 a7 확장 ID (0x01 = Console Putchar 확장) SBI 스펙에서 Console Putchar는 EID가 0x01이고, 이 확장에 함수가 하나뿐이라 FID는 0이다. 이때 필요한 인자는 출력할 문자 하나(a0)뿐이라 나머지는 전부 0으로 채운 것이다.\n실제 putchar('A')의 실행흐름은 다음과 같다.\nputchar('A') sbi_call('A', 0, 0, 0, 0, 0, 0, 1) 레지스터에 값 세팅 (a0=\u0026lsquo;A\u0026rsquo;, a7=1, 나머지 0) ecall 실행 CPU가 M-Mode로 전환 OpenSBI가 a7=1을 보고 \u0026ldquo;Console Putchar구나\u0026rdquo; a0에 있는 \u0026lsquo;A\u0026rsquo;를 UARTUniversal Asynchronous Receiver/Transmitter로 데이터를 한 비트씩 순서대로 주고받는 직렬 장치로 전송 터미널에 \u0026lsquo;A\u0026rsquo; 표시 __asm__ __volatile__(\u0026quot;wfi\u0026quot;);는 wait for interupt의 약자로, CPU에게 인터럽트가 올 때 까지 쉬라고 말하는 RISC-V 명령어이다. 위 코드를 실행해보면, 커널에서 Hello, World!가 출력되는 것을 볼 수 있다.\nprintf 함수 문자 하나를 출력하는 putchar함수를 사용하여 printf 함수를 만들어보자. 표준 C에서는 많은 기능이 있지만, %d, %x, %s만 지원하는 간단한 기능만 존재하는 걸로 만들어보자. 또한 printf는 U-mode 프로그램에도 쓰이므로, 커널과 유저랜드에서 공유할 common.c와 common.h에 작성해보자.\ncommon.h #pragma once #define va_list __builtin_va_list #define va_start __builtin_va_start #define va_end __builtin_va_end #define va_arg __builtin_va_arg void printf(const char *fmt, ...); common.c #include \u0026#34;common.h\u0026#34; void putchar(char ch); void printf(const char *fmt, ...) { va_list vargs; va_start(vargs, fmt); while (*fmt) { if (*fmt == \u0026#39;%\u0026#39;) { fmt++; // Skip \u0026#39;%\u0026#39; switch (*fmt) { // Read the next character case \u0026#39;\\0\u0026#39;: // \u0026#39;%\u0026#39; at the end of the format string putchar(\u0026#39;%\u0026#39;); goto end; case \u0026#39;%\u0026#39;: // Print \u0026#39;%\u0026#39; putchar(\u0026#39;%\u0026#39;); break; case \u0026#39;s\u0026#39;: { // Print a NULL-terminated string. const char *s = va_arg(vargs, const char *); while (*s) { putchar(*s); s++; } break; } case \u0026#39;d\u0026#39;: { // Print an integer in decimal. int value = va_arg(vargs, int); unsigned magnitude = value; if (value \u0026lt; 0) { putchar(\u0026#39;-\u0026#39;); magnitude = -magnitude; } unsigned divisor = 1; while (magnitude / divisor \u0026gt; 9) divisor *= 10; while (divisor \u0026gt; 0) { putchar(\u0026#39;0\u0026#39; + magnitude / divisor); magnitude %= divisor; divisor /= 10; } break; } case \u0026#39;x\u0026#39;: { // Print an integer in hexadecimal. unsigned value = va_arg(vargs, unsigned); for (int i = 7; i \u0026gt;= 0; i--) { unsigned nibble = (value \u0026gt;\u0026gt; (i * 4)) \u0026amp; 0xf; putchar(\u0026#34;0123456789abcdef\u0026#34;[nibble]); } } } } else { putchar(*fmt); } fmt++; } end: va_end(vargs); } ...과 variable args: C언어에서 가변인자를 사용하려면 stdarg.h를 사용해야하는데 이걸 사용할 수 없으므로, 컴파일러에서 제공하는 __built_in_을 매핑하여 사용한다. va_list vargs: 다음에 꺼낼 인자의 레지스터 위치를 가리키는 포인터 va_start(vargs, fmt): vargs가 fmt(마지막 고정인자) 다음부터 가변인자가 시작된다는 것을 알려주는 역할 va_arg(vargs, 타입): 해당 함수를 호출할 때마다 vargs가 가리키는 값을 꺼낸다. 타입을 지정하는 이유는 가변인자는 컴파일 타임에 타입 정보가 없어서 어디까지 읽어야할지를 알려주는 것. va_end(vargs): 끝이라고 적는 것인데, RISC-V에서는 내부적으로 no-op라고 함. case \\0: 포맷 문자열이 %로 끝난 경우라서 %만 출력하고 종료한다. \u0026ldquo;hi %\u0026ldquo;와 같은 케이스. case \\%: %% 처리로, %자체 문자를 출력하고 싶을때 사용하는 경우인데, 똑같이 %만 출력한다. case s: 문자열 가변인자를 받아서 출력한다. case d: 정수 가변인자를 받아서 출력하는데, unsigned로 바꾸는 이유는 INT_MIN과 같은 수는 int 범위를 벗어나기 때문. 예를들어 magnitude = 123;이라면 \u0026ldquo;1\u0026rdquo;, \u0026ldquo;2\u0026rdquo;, \u0026ldquo;3\u0026quot;의 순서로 출력되어야 하기 때문에, 높은 자릿수부터 출력하는 코드인 것을 알 수 있다. case x: 16진수는 2진수 비트를 4개 합쳐서 하나의 문자로 만들 수 있고, unsigned는 32비트로 그러한 문가 총 8개다. 그래서 32비트를 16진수로 출력하고자 할때, 8개로 쪼개서 4개의 이진수를 하나로 읽으면 되는 것이다. unsigned nibble = (value \u0026gt;\u0026gt; (i * 4)) \u0026amp; 0xf;는 4비트씩 차례대로 읽기 위한 비트연산이다. printf를 구현했으니 커널에서 사용해보자.\nkernel.c #include \u0026#34;kernel.h\u0026#34; #include \u0026#34;common.h\u0026#34; void kernel_main(void) { printf(\u0026#34;\\n\\nHello %s\\n\u0026#34;, \u0026#34;World!\u0026#34;); printf(\u0026#34;1 + 2 = %d, %x\\n\u0026#34;, 1 + 2, 0x1234abcd); for (;;) { __asm__ __volatile__(\u0026#34;wfi\u0026#34;); } } 그리고 common.c도 빌드 대상에 추가해줘야한다.\nrun.sh $CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \\ kernel.c common.c 그러면 다음과 같이 잘 출력되는 것을 볼 수 있다.\n./run.sh Hello World! 1 + 2 = 3, 1234abcdC 표준 라이브러리 이번에는 string.h나 stdint.h와 같은 표준라이브러리가 제공하는 함수나 타입을 정의해보자.\ncommon.h Inline Side #pragma once #define va_list __builtin_va_list #define va_start __builtin_va_start #define va_end __builtin_va_end #define va_arg __builtin_va_arg void printf(const char *fmt, ...); #pragma once typedef int bool; typedef unsigned char uint8_t; typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef unsigned long long uint64_t; typedef uint32_t size_t; typedef uint32_t paddr_t; typedef uint32_t vaddr_t; #define true 1 #define false 0 #define NULL ((void *) 0) #define align_up(value, align) __builtin_align_up(value, align) #define is_aligned(value, align) __builtin_is_aligned(value, align) #define offsetof(type, member) __builtin_offsetof(type, member) #define va_list __builtin_va_list #define va_start __builtin_va_start #define va_end __builtin_va_end #define va_arg __builtin_va_arg void *memset(void *buf, char c, size_t n); void *memcpy(void *dst, const void *src, size_t n); char *strcpy(char *dst, const char *src); int strcmp(const char *s1, const char *s2); void printf(const char *fmt, ...); paddr_t: 물리 메모리 주소 vaddr_t: 가상 메모리 주소 align_up(value, align): value를 align의 배수로 맞춰서 올림하는 함수. 이때, align은 2의 거듭제곱. is_aligned(value, align): value가 align의 배수인지 체크하는 함수 offsetof(type, member): 구조체 내에서 특정 member가 시작되는 위치를 바이트 단위로 반환 메모리 조작 함수인 memcpy, memset와 문자열 조작함수인 strcpy, strcmp 함수를 만들어보자.\ncommon.c void *memcpy(void *dst, const void *src, size_t n) { uint8_t *d = (uint8_t *) dst; const uint8_t *s = (const uint8_t *) src; while (n--) *d++ = *s++; return dst; } void *memset(void *buf, char c, size_t n) { uint8_t *p = (uint8_t *) buf; while (n--) *p++ = c; return buf; } char *strcpy(char *dst, const char *src) { char *d = dst; while (*src) *d++ = *src++; *d = \u0026#39;\\0\u0026#39;; return dst; } int strcmp(const char *s1, const char *s2) { while (*s1 \u0026amp;\u0026amp; *s2) { if (*s1 != *s2) break; s1++; s2++; } return *(unsigned char *)s1 - *(unsigned char *)s2; } strcmp에서 unsigned char로 캐스팅하는 이유는 signed일 경우 128 이상의 값에서 음수로 잘못 해석될 수 있기 때문이다. 커널 패닉 커널 패닉이란 커널에서 복구 불가능한 오류가 발생했을 때 시스템을 멈추는 메커니즘이다. 윈도우의 블루스크린이 커널 패닉의 일종이다.\n다음 PANIC 매크로가 그 역할을 한다.\nkernel.h #define PANIC(fmt, ...) \\ do { \\ printf(\u0026#34;PANIC: %s:%d: \u0026#34; fmt \u0026#34;\\n\u0026#34;, __FILE__, __LINE__, ##__VA_ARGS__); \\ while (1) {} \\ } while (0) do while(0) 구조: while(0)이므로 한번만 실행된다. 그럼에도 쓰는 이유는 다른 if문과의 중복을 방지하기 위해서다. 자세한 이유는 이 글 을 참고하면 될 것 같다. ##__VA_ARGS__: 가변 인자 매크로를 설정할 때 사용되는 기능이다. ##이 앞에 붙는 이유는, 가변인자가 empty일 때, 불필요한 ,를 없애주기 때문이다. 예시로 PANIC을 한번 사용해보자.\nkernel.c void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); PANIC(\u0026#34;booted!\u0026#34;); printf(\u0026#34;unreachable here!\\n\u0026#34;); } QEMU에서 실행했을 때, unreachable here!\\n은 출력되지 않고, 파일명과 줄 번호와 함께, booted!가 출력되는 것을 볼 수 있다.\n./run.sh PANIC: kernel.c:37: booted!참고 자료 6.11.6.2 Specifying Registers for Local Variables SBI 명세 위키피디아 stdarg.h variadic-function-builtins alignment-builtins Variadic-Macros [C 언어] 매크로에 do {\u0026hellip;} while(0)을 사용하는 이유 ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os03/","summary":"ecall로 SBI를 호출하여 Hello World를 출력하고, printf와 memset/memcpy/strcpy/strcmp과 커널 패닉 매크로를 직접 구현","title":"03. Hello World, C 표준 라이브러리, 커널 패닉"},{"content":" SBI(Supervisor Binary Interface) RISC-V 아키텍처에서 SBI는 M-mode의 펌웨어와 S-mode의 OS 사이의 표준 인터페이스로, OS가 하드웨어에 독립적으로 일관된 방식으로 시스템 리소스에 접근하고 제어할 수 있도록 지원한다.\n정리하자면 SBI는 다음과 같은 일을 한다.\nM-mode 전용 레지스터에 접근할 수 있는 인터페이스 제공 Supervisor(OS)와 SEESupervisor Execution Environment로, SBI 구현체인 OpenSBI가 돌아가는 하드웨어. 여기서는 QEMU라고 생각하면 된다.를 깔끔하게 분리 하나의 OS 이미지로 서로 다른 SEE 위에서 실행 가능하게 해줌 해당 그림에서 좀 더 이해를 돕자면, SEE에 펌웨어(OpenSBI)가 M-mode로 동작하고, 해당 펌웨어와 OS가 소통하기 위한 인터페이스가 SBI라고 보면 된다.\nOpenSBI와 SBI가 헷갈릴 수 있는데, 정확히 설명하자면, SBI는 인터페이스, 통신 규약이고 그걸 구현해놓은 펌웨어가 OpenSBI라고 보면된다. OpenSBI 부팅하기 OpenSBI는 SBI 인터페이스를 제공하는 것 뿐만아니라, 부트스트랩 기능도 제공한다. QEMU 위에서 OpenSBI를 부팅해보자. 아래와 같이 run.sh를 작성하자\ntouch run.sh chmod +x run.sh run.sh #!/bin/bash set -xue # QEMU 실행 파일 경로 QEMU=qemu-system-riscv32 # QEMU 실행 $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot -bios default 관련해서, 나는 OpenSBI bin파일을 다운했는데 default로 설정해도 되나? 라는 의문이 생겨서 찾아보니, -bios 옵션은 공식 문서상 -bios \u0026lt;file\u0026gt; 형식만 기술되어 있는데, 코드 를 보면, user가 -bios 옵션을 안쓰거나, -bios default 옵션을 사용하였을 때는 QEMU가 현재 디렉토리 및 데이터 디렉토리(/usr/share/qemu/ 등)에서 OpenSBI binary file을 직접 찾는다고 한다. 그래서 사실상 없는거랑 똑같은..\n그럼 왜 공식문서에는 -bios file 옵션 밖에 안 적어놨니..\n아무튼, 저렇게 작성하고 ./run.sh를 실행해보면 다음과 같이 터미널에 뜬다.\nOpenSBI v1.2 ____ _____ ____ _____ / __ \\ / ____| _ \\_ _| | | | |_ __ ___ _ __ | (___ | |_) || | | | | | \u0026#39;_ \\ / _ \\ \u0026#39;_ \\ \\___ \\| _ \u0026lt; | | | |__| | |_) | __/ | | |____) | |_) || |_ \\____/| .__/ \\___|_| |_|_____/|____/_____| | | |_| Platform Name : riscv-virtio,qemu Platform Features : medeleg ...이때, Ctrl+A를 누른 후 C를 눌러 QEMU 디버그 콘솔(QEMU 모니터)로 전환한다. 모니터에서 q 명령으로 QEMU를 종료할 수 있다.\n또한 Ctrl+A를 누른 후 H를 누르면, 다음과 같은 단축키 기능이 있다. 아래의 C-a는 Ctrl+A를 뜻한다.\nC-a h 도움말 표시 C-a x 에뮬레이터 종료 C-a s 디스크 데이터를 파일에 저장(-snapshot 사용 시) C-a t 콘솔 타임스탬프 토글 C-a b break(매직 sysrq) C-a c 콘솔과 모니터 간 전환 C-a C-a C-a를 전송링커 스크립트 링커 스크립트는 해당 실행 파일이 어느 메모리에 배치될지 정의하는 파일이다.링커는 이 정보를 기반으로 함수와 변수가 배치될 메모리 주소를 결정한다.\nkernel.c 파일을 컴파일하고, 링커가 링커스크립트를 보고 커널 바이너리 파일을 생성하게 되는데, 그 바이너리가 QEMU에서 돌아가면서 kernel이 시작되는 것이다.\nkernel.ld ENTRY(boot) SECTIONS { . = 0x80200000; .text :{ KEEP(*(.text.boot)); *(.text .text.*); } .rodata : ALIGN(4) { *(.rodata .rodata.*); } .data : ALIGN(4) { *(.data .data.*); } .bss : ALIGN(4) { __bss = .; *(.bss .bss.* .sbss .sbss.*); __bss_end = .; } . = ALIGN(4); . += 128 * 1024; /* 128KB */ __stack_top = .; } 해당 파일에 대해 설명하자면,\nENTRY()는 지시어를 사용하여 boot 함수를 시작점으로 지정 SECTION{}은 섹션섹션이란 같은 종류의 데이터를 묶어 놓은 영역으로 .text :{} 이런식으로 되어있다. 배치 규칙을 정의하는 블록 .을 단독으로 사용하면 위치 카운터 로, 현재 메모리 주소를 뜻한다. base address는 0x80200000으로 설정(0x80000000-0x80200000에는 OpenSBI가 있음) .text, .rodata, .data, .bss와 같은 이름은 컴파일러가 자동으로 분류해서 넣어주지만, 다른 것들은 C 파일에서 __attribute__((section(\u0026quot;.무언가\u0026quot;)))로 지정해줘야 링커가 링커 스크립트에 있는 section으로 할당해준다. KEEPKEEP 지시어는 링크할 때 --gc-sections을 넣어서 가비지 컬렉션 기능이 동작할 때, 아무도 참조하지 않는 코드를 삭제하지 않고 최종 결과물에 강제로 포함시키는 지시어을 사용하여 .text.bootboot함수를 찾는게 아니라, 나중에 작성하게 될 kernel.c에서 __attribute__((section('.text.boot')))를 줘야 알 수 있다. 섹션이 무조건 최종 바이너리에 포함되도록 보장 *은 와일드카드로, 예를 들어, *(.text .text.*)가 있으면, 가장 바깥의 *()는 모든 오브젝트 파일의라는 뜻이고, 안쪽의 .text .text.은 모든 .text 섹션과 모든 .text.someting 섹션이라는 뜻이다. ALIGN(4)는 현재 주소를 4의 배수로 올림한 값이다. = 할당 연산자를 통해서 symbol에 값을 할당할 수 있다. 또한 이는 C에서 extern을 사용하여 참조할 수 있다. 산술, 논리, 비교 연산은 C언어와 문법이 같고, 우선순위도 같다. 그 외의 링커 스크립트는 GNU ld script 공식문서 를 보면 잘 알 수 있다. 위 내용을 잘 이해했다면, 마지막 3줄이 의미하는 바를 알 수 있을 것이다. 무슨 뜻일까? 스택의 max size를 128KB로 설정하고, __stack_top이라는 심볼을 설정하는 뜻의 코드이다.\n참고로 K, M도 각각 1024, 1024*1024의 값으로 사용가능하다. 공식문서 Constants 최소화된 커널 이번에는 실제 커널을 작성해보자.\nkernel.c typedef unsigned char uint8_t; // 1 byte typedef unsigned int uint32_t; // 4 byte typedef uint32_t size_t; // memory size extern char __bss[], __bss_end[], __stack_top[]; void *memset(void *buf, char c, size_t n) { uint8_t *p = (uint8_t *) buf; while (n--) *p++ = c; return buf; } void kernel_main(void) { memset(__bss, 0, (size_t) __bss_end - (size_t) __bss); for (;;); } __attribute__((section(\u0026#34;.text.boot\u0026#34;))) __attribute__((naked)) void boot(void) { __asm__ __volatile__( \u0026#34;mv sp, %[stack_top]\\n\u0026#34; // Set the stack pointer \u0026#34;j kernel_main\\n\u0026#34; // Jump to the kernel main function : : [stack_top] \u0026#34;r\u0026#34; (__stack_top) // Pass the stack top address as %[stack_top] ); } 대부분 위의 내용이나 이전 챕터의 글을 보면 알 수 있기 때문에 나오지 않은 것만 적어보자면\n주소 값을 왜 char[]로 선언?\nsymbol값을 extern으로 참조하여 주소값을 가져올때는 두가지 방식, 포인터로 선언하는 방식과 배열로 선언하는 방식이 있는데, 차이는 포인터로 선언하는 방식은 \u0026amp;를 빼먹으면 안되는 것 말고는 없다. __attribute__((naked))의 의미:\n컴파일러가 함수 앞뒤에 자동으로 추가하는 프롤로그/에필로그일반적으로 C언어에서 함수를 컴파일하면 컴파일러가 자동으로 앞 뒤에 코드를 삽입하게 되는데, 프롤로그(함수 시작)에는 현재 레지스터 값을 스택에 저장하고 함수가 쓸 스택 공간을 확보하는 코드를 삽입하고, 에필로그(함수 끝)에는 저장해뒀던 레지스터 값을 복원하고, 호출한 함수로 돌아가는 코드를 삽입한다. 코드를 생성하지 않게 한다. boot함수가 실행하는 시점에 stack이 아직 없어서 에필로그/프롤로그에 stack을 사용하면 크래시가 나기 때문에 사용한다. : [stack_top]이 뭐지?\n인라인 어셈블리 안에서 __stack_top을 stack_top의 이름으로도 참조할 수 있게 설정하는 명령이다. 그냥 안쓰고 %0으로 참조해도 된다. 실행해보기 run.sh스크립트에 커널 빌드 명령과 -kernel kernel.elf.elf는 kernel.c를 clang이 컴파일하고, 링커(lld)가 링커 스크립트(kernel.ld)를 기반으로 메모리 배치를 결정하여 만든 최종 실행 파일이다. 옵션을 추가해 보자.\nrun.sh #!/bin/bash set -xue QEMU=qemu-system-riscv32 # clang 경로와 컴파일 옵션 CC=/usr/bin/clang # Ubuntu 등 환경에 따라 경로 조정: CC=clang CFLAGS=\u0026#34;-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fuse-ld=lld -fno-stack-protector -ffreestanding -nostdlib\u0026#34; # 커널 빌드 $CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf kernel.c # QEMU 실행 $QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \\ -kernel kernel.elf CFLAGS에서 지정한 옵션의 의미는 다음과 같다.\n옵션 설명 -std=c11 C11 표준 사용 -O2 최적화 레벨 2 설정 -g3 최대한의 디버그 정보 생성 -Wall 핵심 경고 활성화 -Wextra 추가 경고 활성화 --target=riscv32-unknown-elf 32비트 RISC-V 대상 아키텍처로 컴파일 -ffreestanding 컴파일 단계에서 호스트 표준 라이브러리를 전제하지 않음 -fuse-ld=lld LLVM 링커 (ld.lld) 사용 -fno-stack-protector 스택 보호 기능 비활성화 ( #31 참고) -nostdlib 링크 단계에서 표준 라이브러리를 연결하지 않음 -Wl,-Tkernel.ld 링커 스크립트(kernel.ld) 지정 -Wl,-Map=kernel.map 맵 파일(kernel.map) 생성 (링킹 결과와 섹션 배치를 확인할 수 있음) -f기능 은 해당 기능을 켬, -fno기능은 해당 기능을 끔, -W는 Warning 관련, -O는 최적화 관련, -f 기능 관련 접두라고 생각하면 된다.\n-Wl, 옵션은 링커에게 해당 옵션은 전달이라는 뜻이다.\n커널 디버깅 run.sh를 실행하면 kernel_main에서 무한 루프가 돌아간다. 이 때 QEMU의 디버그 기능을 사용하여 정보를 알 수 있다.\nQEMU 모니터에서 info registers 명령어를 실행하면 다음과 같이 CPU 레지스터 정보를 알 수 있다.\ninfo registers 실행 결과 QEMU 6.2.0 monitor - type \u0026#39;help\u0026#39; for more information (qemu) info registers pc 80200050 mhartid 00000000 mstatus 80006080 mstatush 00000000 mip 00000000 mie 00000008 mideleg 00000222 medeleg 0000b109 mtvec 80000420 stvec 80200000 mepc 80200000 sepc 00000000 mcause 00000002 scause 00000000 mtval 00000000 stval 00000000 mscratch 80033000 sscratch 00000000 satp 00000000 x0/zero 00000000 x1/ra 8000a084 x2/sp 80220054 x3/gp 00000000 x4/tp 80033000 x5/t0 00000001 x6/t1 00000000 x7/t2 00000000 x8/s0 80032f50 x9/s1 00000001 x10/a0 80200054 x11/a1 80200054 x12/a2 00000000 x13/a3 00000019 x14/a4 4014112d x15/a5 00000001 x16/a6 00000008 x17/a7 00000002 x18/s2 80200000 x19/s3 00000000 x20/s4 87000000 x21/s5 00000000 x22/s6 80006800 x23/s7 8001c020 x24/s8 00002000 x25/s9 8002b4e4 x26/s10 00000000 x27/s11 00000000 x28/t3 616d6569 x29/t4 8001a5a1 x30/t5 000000fc x31/t6 00000000 f0/ft0 ffffffff00000000 f1/ft1 ffffffff00000000 f2/ft2 ffffffff00000000 f3/ft3 ffffffff00000000 f4/ft4 ffffffff00000000 f5/ft5 ffffffff00000000 f6/ft6 ffffffff00000000 f7/ft7 ffffffff00000000 f8/fs0 ffffffff00000000 f9/fs1 ffffffff00000000 f10/fa0 ffffffff00000000 f11/fa1 ffffffff00000000 f12/fa2 ffffffff00000000 f13/fa3 ffffffff00000000 f14/fa4 ffffffff00000000 f15/fa5 ffffffff00000000 f16/fa6 ffffffff00000000 f17/fa7 ffffffff00000000 f18/fs2 ffffffff00000000 f19/fs3 ffffffff00000000 f20/fs4 ffffffff00000000 f21/fs5 ffffffff00000000 f22/fs6 ffffffff00000000 f23/fs7 ffffffff00000000 f24/fs8 ffffffff00000000 f25/fs9 ffffffff00000000 f26/fs10 ffffffff00000000 f27/fs11 ffffffff00000000 f28/ft8 ffffffff00000000 f29/ft9 ffffffff00000000 f30/ft10 ffffffff00000000 f31/ft11 ffffffff00000000\npc 80200050 을 보면 알 수 있겠지만, 현재 0x80200050 주소의 명령어가 실행되고 있음을 알 수 있다.\nllvm-objdump -d kernel.elf로 어떤 명령어가 있는지 확인해볼 수 있다.\nllvm-objdump -d kernel.elf 실행 결과 llvm-objdump -d kernel.elf kernel.elf: file format elf32-littleriscv Disassembly of section .text: 80200000 \u0026lt;boot\u0026gt;: 80200000: 37 05 22 80 lui a0, 524832 80200004: 13 05 45 05 addi a0, a0, 84 80200008: 2a 81 mv sp, a0 8020000a: 6f 00 a0 01 j 0x80200024 \u0026lt;kernel_main\u0026gt; 8020000e: 00 00 unimp 80200010 \u0026lt;memset\u0026gt;: 80200010: 09 ca beqz a2, 0x80200022 \u0026lt;memset+0x12\u0026gt; 80200012: aa 86 mv a3, a0 80200014: 7d 16 addi a2, a2, -1 80200016: 13 87 16 00 addi a4, a3, 1 8020001a: 23 80 b6 00 sb a1, 0(a3) 8020001e: ba 86 mv a3, a4 80200020: 75 fa bnez a2, 0x80200014 \u0026lt;memset+0x4\u0026gt; 80200022: 82 80 ret 80200024 \u0026lt;kernel_main\u0026gt;: 80200024: 37 05 20 80 lui a0, 524800 80200028: 13 05 45 05 addi a0, a0, 84 8020002c: b7 05 20 80 lui a1, 524800 80200030: 93 85 45 05 addi a1, a1, 84 80200034: 33 86 a5 40 sub a2, a1, a0 80200038: 01 ce beqz a2, 0x80200050 \u0026lt;kernel_main+0x2c\u0026gt; 8020003a: b3 05 b5 40 sub a1, a0, a1 8020003e: 2e 86 mv a2, a1 80200040: 93 06 15 00 addi a3, a0, 1 80200044: 85 05 addi a1, a1, 1 80200046: 23 00 05 00 sb zero, 0(a0) 8020004a: 36 85 mv a0, a3 8020004c: e3 f9 c5 fe bgeu a1, a2, 0x8020003e \u0026lt;kernel_main+0x1a\u0026gt; 80200050: 01 a0 j 0x80200050 \u0026lt;kernel_main+0x2c\u0026gt;\n아까 pc가 0x80200050 에 있다고 했는데 위의 llvm 결과에서 0x80200050에 어떤 명령어가 있는지를 보면, j 0x80200050 명령어가 있다. 즉 kernel_main의 무한루프 부분이 진행되고 있음을 알 수 있는 부분이다.\n또한 스택 포인터가 정말로 링커 스크립트에서 정의한 __stack_top의 주소로 설정되었는지 확인해볼 수 있다.\ninfo registers의 결과 중 x2/sp 80220054로 나와있는데, 아래의 kernel.map을 보면 80220054 80220054 0 1 __stack_top = .라고 동일하게 나와있음을 알 수 있다.\nkernel.map VMA LMA Size Align Out In Symbol 0 0 80200000 1 . = 0x80200000 80200000 80200000 52 4 .text 80200000 80200000 e 2 /tmp/kernel-0bd4f2.o:(.text.boot) 80200000 80200000 0 1 80200000 80200000 0 1 80200000 80200000 0 1 80200000 80200000 0 1 80200000 80200000 e 1 boot 8020000e 8020000e 0 1 8020000e 8020000e 0 1 80200010 80200010 42 4 /tmp/kernel-0bd4f2.o:(.text) 80200010 80200010 0 1 80200010 80200010 0 1 80200010 80200010 0 1 80200010 80200010 0 1 80200010 80200010 14 1 memset 80200012 80200012 0 1 80200014 80200014 0 1 80200016 80200016 0 1 8020001a 8020001a 0 1 8020001e 8020001e 0 1 80200020 80200020 0 1 80200022 80200022 0 1 80200024 80200024 0 1 80200024 80200024 0 1 80200024 80200024 0 1 80200024 80200024 0 1 80200024 80200024 0 1 80200024 80200024 0 1 80200024 80200024 2e 1 kernel_main 8020003a 8020003a 0 1 80200040 80200040 0 1 80200044 80200044 0 1 80200046 80200046 0 1 8020004a 8020004a 0 1 8020004c 8020004c 0 1 80200050 80200050 0 1 80200050 80200050 0 1 80200052 80200052 0 1 80200052 80200052 0 1 80200054 80200054 0 4 .bss 80200054 80200054 0 1 __bss = . 80200054 80200054 0 1 __bss_end = . 80200054 80200054 0 1 . = ALIGN ( 4 ) 80200054 80200054 20000 1 . += 128 * 1024 80220054 80220054 0 1 __stack_top = . 0 0 65 1 .debug_loclists 0 0 65 1 /tmp/kernel-0bd4f2.o:(.debug_loclists) c c 0 1 0 0 e1 1 .debug_abbrev 0 0 e1 1 /tmp/kernel-0bd4f2.o:(.debug_abbrev) 0 0 dd 1 .debug_info 0 0 dd 1 /tmp/kernel-0bd4f2.o:(.debug_info) 0 0 17 1 .debug_rnglists 0 0 17 1 /tmp/kernel-0bd4f2.o:(.debug_rnglists) c c 0 1 0 0 48 1 .debug_str_offsets 0 0 48 1 /tmp/kernel-0bd4f2.o:(.debug_str_offsets) 8 8 0 1 0 0 b3 1 .debug_str 0 0 b3 1 \u0026lt;internal\u0026gt;:(.debug_str) 0 0 14 1 .debug_addr 0 0 14 1 /tmp/kernel-0bd4f2.o:(.debug_addr) 8 8 0 1 0 0 42 1 .comment 0 0 42 1 \u0026lt;internal\u0026gt;:(.comment) 0 0 2b 1 .riscv.attributes 0 0 2b 1 /tmp/kernel-0bd4f2.o:(.riscv.attributes) 0 0 44 4 .debug_frame 0 0 44 4 /tmp/kernel-0bd4f2.o:(.debug_frame) 0 0 0 1 0 0 f4 1 .debug_line 0 0 f4 1 /tmp/kernel-0bd4f2.o:(.debug_line) 0 0 0 1 .Lline_table_start0 0 0 32 1 .debug_line_str 0 0 32 1 \u0026lt;internal\u0026gt;:(.debug_line_str) 0 0 3f0 4 .symtab 0 0 3f0 4 \u0026lt;internal\u0026gt;:(.symtab) 0 0 ce 1 .shstrtab 0 0 ce 1 \u0026lt;internal\u0026gt;:(.shstrtab) 0 0 52 1 .strtab 0 0 52 1 \u0026lt;internal\u0026gt;:(.strtab)\n또는 llvm-nm kernel.elf 명령어를 통해서 확인할 수 있다.\nllvm-nm kernel.elf 00000000 N .Lline_table_start0 80200054 B __bss 80200054 B __bss_end 80220054 B __stack_top 80200000 T boot 80200024 T kernel_main 80200010 T memset이때, N은 non-alloc 섹션의 심볼, B는 BSS영역의 심볼, T는 text 영역의 심볼이고, 대문자는 글로벌 심볼, 소문자는 로컬 심볼을 뜻한다.\n참고 자료 [QEMU] 임베디드 리눅스 시스템 구축 프로젝트 - OpenSBI overview OpenSBI Deep Dive qemu 공식문서 중 -bios 관련 qemu 코드 중 -bios 관련 GNU ld 공식문서 __attribute__((naked)) function attribute ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os02/","summary":"OpenSBI로 QEMU를 부팅하고, 링커 스크립트로 메모리 배치를 정의한 뒤, 최소한의 커널을 작성하여 실행하는 과정","title":"02. 부트(Boot)"},{"content":"환경 설정 QEMU 다운 WSL - Ubuntu 22.04 환경에서 진행하였다. 다음과 같은 패키지를 다운받자.\nsudo apt update \u0026amp;\u0026amp; sudo apt install -y clang llvm lld qemu-system-riscv32 curl Why QEMU? 실제 OS 개발의 일련의 절차는 다음과 같다.\n코드 수정 - 빌드 - USB 굽기 - PC 끄기 - USB 꼽고 바이오스 진입해서 부팅..\n이러한 과정을 실제로 하기에는 학습 효율이 너무 떨어지기 때문에, QEMU라는 일종의 컴퓨터 Emulator를 사용하여, 내 PC안에서 가상의 컴퓨터를 즉시 실행함으로써, 내가만든 OS가 하드웨어 위에서 어떻게 돌아가는지 테스트해볼 수 있다.\nOpenSBI 다운 OpenSBI 펌웨어를 다운로드한다. 다운로드 위치는 내가 코드를 작생할 폴더에 아래 명령어를 실행하면된다.\ncurl -LO https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin Why OpenSBI? 컴퓨터가 켜지자 마자 커널이 바로 실행될 수는 없다. 누군가가 하드웨어를 초기화하고 커널을 메모리에 올려서 실행해야하는데, 이 일련의 절차를 부트로더가 수행해주어야하고, 그 역할을 OpenSBI가 한다.\nQEMU를 실행할 때, 일종의 RISC-V 전용 가짜 BIOS인 OpenSBI를 올려줘야 내가 만든 OS가 돌아가기 위해 부트스트랩을 하게 되는 것이다.\n더 설명하자면, 실제 컴퓨터가 켜질 때, ROM에 삽입된 BIOS/UEFI라는 펌웨어 코드를 실행하는데, 우리는 OpenSBI를 사용함으로써, QEMU라는 가상 컴퓨터 내부에서 컴퓨터가 켜질 때, BIOS의 역할을 OpenSBI 파일(.bin)이 수행하라고 주문하는 것이다.\n실제로라면 생산공장에서 메인보드에 BIOS를 구워야하지만, 우리는 OS를 만드는게 목표이므로, QEMU 옵션인 -bios opensbi.bin 을 사용하여 마치 실제로 메인보드에 BIOS가 구워져 있는 것처럼 체험해 볼 수 있다.\nQEMU virt machine 이 에서는 QEMU의 virt 를 사용한다. 이름에서도 알 수 있듯이 실제로 존재하는 하드웨어가 아니라 가상 머신 전용 하드웨어 세트(컴퓨터)라고 보면 된다.\n왜 QEMU와 OpenSBI를 사용하나요? 이 스터디의 목적은 OS기 때문에, 하드웨어 설계나 BIOS 설계같은 것들은 하지 않는다.\n하드웨어는 QEMU에게 맡긴다. BIOS와 같은 부트스트랩은 OpenSBI에게 맡긴다. 이렇게 함으로써 나는 부트스트랩이 되고 난 후의 프로그램인 커널을 만드는데 집중하면서 OS의 본질을 공부하는데 더 집중할 수 있게 된다.\nOpenSBI 관련해서 더 알고 싶다면 [QEMU] 임베디드 리눅스 시스템 구축 프로젝트 - OpenSBI 을 참고하면 많은 도움이 될 것 같다.\nRISC-V Why RISC-V? 학습용으로 x86, arm보다 적절함. 오픈소스여서 문서화가 잘 되어있음. RISC-V 입문 전 꼭 알아야할 개념 다음 개념들은 RISC-V 어셈블리를 입문할 때 알아둬야하는 개념이다.\n레지스터란 무엇인가 사칙연산 (add, sub, mul, div 등) 메모리 접근 (lw, sw 등) 분기 명령 (beq, bne, blt, bgez 등) 함수 호출 스택의 구조 해당 글 에 매우 잘 설명되어 있다.\nCPU 모드 CPU 모드 종류 RISC-V에서는 3가지 CPU모드가 있다. 권한은 M \u0026gt; S \u0026gt; U 라고 보면 된다.\nM-Mode(Machine Mode): 모든 하드웨어 자원에 직접 접근하여 시스템 초기화, 메모리 관리, 인터럽트 처리를 수행, OpenSBI S-Mode(Supervisor Mode): 하드웨어에 직접 접근하는 대신 OpenSBI가 제공하는 인터페이스(SBI)를 통해 필요한 기능을 요청, 커널 U-Mode(User Mode): 일반 애플리케이션에서 실행되는 모드 CPU 모드 전환 흐름 U-mode ──ecall/fault──\u0026gt; S-mode ──ecall/fault──\u0026gt; M-mode \u0026lt;─────sret────── \u0026lt;─────mret────── 유저 어플리케이션 (U-Mode)에서 lw s0, 0(s1)명령어를 수행한다고 생각해보자. 어떤 일이 발생할까? 정상적인 경우 (V=1, R=1, U=1PTE(Page Table Entry)에 존재하는 값으로, V(페이지가 Valid한지 여부), R(페이지를 Read할 수 있는지), U(페이지가 U-mode 에서 접근가능한지)와 같은 정보를 저장하는 Bit로 페이지 테이블에 존재한다.)\nMMU가 가상 주소를 물리 주소로 변환 → RAM에서 4바이트 읽기 → s0에 저장 → 끝 Page Fault가 발생하는 경우 (V=0)\n이 경우 OS가 개입하게 되는데, 그 흐름은 다음과 같다. Page Fault 처리 흐름 U-Mode (사용자 프로그램)\nlw s0, 0(s1) 실행 시도\nMMU논리 주소를 물리 주소로 변환해주며 메모리 보호나 캐시 관리 등 CPU가 메모리에 접근하는 것을 총 관리해주는 하드웨어 가 페이지 테이블을 확인 -\u0026gt; V=0 -\u0026gt; Page Fault 발생\nlw 실행 중단, 하드웨어가 일련의 과정1. Page Fault 발생 감지\n2. sepc \u0026lt;- 현재 PC 저장\n3. scause \u0026lt;- 원인 코드 저장\n4. stval \u0026lt;- 문제된 주소 저장\n5. sstatus.SPP \u0026lt;- 현재 모드(U) 저장\n6. 모드 비트를 S-mode로 변경\n7. PC \u0026lt;- stvec 값으로 설정 (점프)\n8. stvec 주소의 명령어 실행 시작\n하드웨어가 자동적으로 일련의 과정을 수행함.을 수행 후 S-Mode로 전환\nS-Mode (OS 커널 트랩 핸들러)\nscause를 확인 -\u0026gt; Load Page Fault임을 파악 stval을 확인 -\u0026gt; 문제가 된 가상 주소 파악 OS 자체 자료구조 조회 -\u0026gt; \u0026ldquo;이 페이지는 디스크에 스왑 아웃되어 있다\u0026rdquo; 확인 RAM에서 빈 물리 프레임 확보 디스크 I/O 요청 -\u0026gt; 이 과정에서 디스크 드라이버가 동작하고, 구현에 따라 M-mode의 SBI를 통해 하드웨어에 접근할 수 있음 디스크에서 데이터를 해당 물리 프레임으로 읽어옴 PTEPage Table Entry(페이지 테이블의 각 항목)로, 가상 페이지 하나에 대한 물리 주소 매핑 정보와 권한 정보를 담고 있는 엔트리, PPN과 V,R,U같은 플래그 비트가 저장되어 있다. 업데이트: PPNPhysical Page Number로, 해당 가상 페이지가 실제로 매핑되는 물리 페이지 번호를 뜻한다.을 새 물리 프레임 번호로 설정, V=1, R=1 등 권한 비트 설정 sret 실행 -\u0026gt; sepc에 저장된 주소(원래 lw 명령어)로 복귀, U-mode로 전환 U-mode (사용자 프로그램 복귀)\n같은 lw s0, 0(s1)가 처음부터 다시 실행 MMU가 페이지 테이블 확인 -\u0026gt; 이번에는 V=1, R=1, U=1 -\u0026gt; 정상 통과 물리 주소에서 4바이트를 읽어 s0에 저장 PC = PC + 4, 다음 명령어로 진행 OS 개발은? -\u0026gt; S-Mode의 4-11까지의 내용을 개발하는 것이라고 볼 수 있다\n물론 이 과정은 메모리 스왑이 구현되어 있는 OS에 관련된 과정이고, 1000줄로 만드는 OS에서는 해당 과정처럼 작동하지 않는다.\n특권 명령 (Privileged instructions) U-mode에서 실행할 수 없는 명령으로, csrr/csrw, sret, sfence.vma등 이 있다. 각각에 대해 설명하자면,\nopcode overview csrr/csrw rd, csr csrControl and Status Register로, CPU 내부 상태와 제어를 담당하는 레지스터, sepc, scause, stval 등이 있음. 값을 읽거나 씀 sret 트랩 핸들러 복귀(PC, CPU모드 등 복원) sfence.vma TLBTranslation Lookaside Buffer. 최근에 사용된 가상 주소를 물리 주소 변환한 결과를 캐싱해두는 하드웨어. MMU 안에 있음. 초기화 인라인 어셈블리 (inline assembly) C 코드 중간에 RISC-V 어셈블리를 직접 삽입하는 방법으로 다음과 같은 형태로 작성할 수 있다.\n__asm__ __volatile__(\u0026#34;어셈블리 명령어\u0026#34; : 출력 operand : 입력 operand : 변경되는 레지스터 );예시 CSR 읽기 \u0026ndash; scause를 읽어서 C 변수에 저장하는 경우\nuint32_t value; __asm__ __volatile__(\u0026#34;csrr %0, scause\u0026#34; : \u0026#34;=r\u0026#34;(value));여기서 %0은 첫 번째 operand이고, \u0026quot;=r\u0026quot;(value)는 결과를 레지스터를 통해 value 변수에 할당해라는 뜻\nCSR 쓰기 \u0026ndash; stvec에 트랩 핸들러 주소를 등록하는 경우\n__asm__ __volatile__(\u0026#34;csrw stvec, %0\u0026#34; : : \u0026#34;r\u0026#34;(handler_addr));보통은 이런 인라인 어셈블리를 매번 쓰기 번거로우니까, 매크로나 함수로 감싸서 사용한다.\n#define READ_CSR(reg) \\ ({ \\ uint32_t __val; \\ __asm__ __volatile__(\u0026#34;csrr %0, \u0026#34; #reg \\ : \u0026#34;=r\u0026#34;(__val)); \\ __val; \\ }) #define WRITE_CSR(reg, val) \\ __asm__ __volatile__(\u0026#34;csrw \u0026#34; #reg \u0026#34;, %0\u0026#34; \\ : : \u0026#34;r\u0026#34;(val))이렇게 만들어두면 커널 코드에서 깔끔하게 쓸 수 있다.\nuint32_t cause = READ_CSR(scause); WRITE_CSR(stvec, handler_addr); 참고 자료 OS in 1000 Lines 01. 환경설정 OS in 1000 Lines 02. RISC-V 입문 [QEMU] 임베디드 리눅스 시스템 구축 프로젝트 - OpenSBI overview [QEMU] 임베디드 리눅스 시스템 구축 프로젝트 - OpenSBI KVM과 QEMU란? (Linux 가상화 솔루션) Control and Status Registers (CSRs) [운영체제] TLB(Translation Lookaside Buffer), 캐시사상기법 (직접사상, 연관사상), 페이지크기 [OS] 요구 페이징 [운영체제] 페이지 테이블(TLB, 계층/해시/역페이지 테이블) ","permalink":"https://ljweel.github.io/posts/my-os-in-1000-lines/os01/","summary":"OS 개발을 위한 QEMU/OpenSBI 환경 설정, RISC-V의 CPU 모드(U/S/M), 트랩 흐름, 특권 명령, 인라인 어셈블리까지 정리한 글입니다.","title":"01. 환경 설정과 RISC-V 기초"},{"content":"문제 제기: stdout/stderr를 제한해야 하는 이유 며칠전에 첫 issue 가 올라왔다. 내용은 사용자가 while True: print(\u0026quot;1\u0026quot;)를 실행하면 사이트가 크래시 난다는 것이였다. 원인을 고민해보니 다음과 같았다.\nnsjail이 CPU 시간과 메모리를 제한하지만, stdout/stderr는 nsjail의 cgroup 제한에 포함되지 않는다. stdout/stderr는 자식 프로세스가 write() syscall로 데이터를 넘기면 커널 파이프 버퍼나 부모 쪽에 쌓이기 때문이다.\n출력이 아무리 많아도 자식 프로세스의 메모리가 아니라 워커 프로세스의 메모리를 잡아먹는 것이였다.\nnsjail의 time_limit: 3이 3초 후 프로세스를 종료시키긴 하지만, 3초 동안 수백 MB의 출력이 쌓일 수 있으므로 별도의 출력 제한이 필요하다고 판단했다.\nsubprocess.run을 subprocess.Popen으로 기존 코드에서는 subprocess.run을 사용했다.\nresult = subprocess.run(cmd, stdin=file, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)run()은 프로세스가 끝난 뒤에 stdout을 한꺼번에 반환하기 때문에 실행 도중에 크기를 체크하고 프로세스를 종료시키는 것이 불가능하다.\n만약 3초 동안 while True: print(\u0026quot;A\u0026quot; * 100000)가 모두 돌아간다면, 수백 MB가 메모리에 올라온 뒤에야 크기를 확인할 수 있다.\n반면, Popen은 프로세스가 실행되는 동안 stream으로 stdout/stderr을 읽을 수 있어서, 중간에 제한을 걸고 프로세스를 종료시킬 수 있다.\n그래서 subprocess.run에서 subprocess.Popen으로 전환했다.\n순차 읽기의 함정 Popen으로 전환한 뒤, stdout과 stderr를 순차적으로 읽으면서 각각 크기를 제한하는 코드를 다음과 같이 작성했다.\ndef _consume_stream(self, proc, stream, max_size): \u0026#34;\u0026#34;\u0026#34; stream을 실시간으로 읽으며 크기를 제한하는 함수 설정된 max_size를 초과할 경우, proc를 즉시 종료(terminate) \u0026#34;\u0026#34;\u0026#34; output_size = 0 output = [] while True: chunk = stream.read(4096) if not chunk: break output_size += len(chunk) output.append(chunk) if output_size \u0026gt; max_size: proc.terminate() break return b\u0026#34;\u0026#34;.join(output)[:max_size].decode(\u0026#34;utf-8\u0026#34;) proc = subprocess.Popen(cmd, stdin=f, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = self._consume_stream(proc, proc.stdout, MAX_STDOUT_SIZE) stderr = self._consume_stream(proc, proc.stderr, MAX_STDERR_SIZE)이 코드가 정상 동작할 것 같았지만, 다음과 같은 testcase에서 계속 터지는 현상을 발견했다.\nimport sys while True: print(\u0026#34;A\u0026#34;*100000, file=sys.stderr)stderr에만 대량 출력하는 코드인데, 워커가 stdout부터 읽고 있으니 stderr를 아무도 소비하지 않는 상황이었다.\n원인을 파보니 리눅스 파이프 버퍼의 크기 제한에 의한 데드락이었다.\n데드락 시나리오 Linux에서 파이프는 커널이 관리하는 고정 크기 버퍼를 갖는다. 기본값은 64KB.\n프로세스가 파이프에 데이터를 쓸 때:\n버퍼에 공간이 있으면 -\u0026gt; 즉시 write 완료 버퍼가 가득 차면 -\u0026gt; 누군가 읽어줄 때까지 write가 블로킹 이를 바탕으로 위 코드의 핵심인 만 남기자면 다음과 같다.\nproc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout = proc.stdout.read() stderr = proc.stderr.read()위 코드에서 stdout을 먼저 읽고 stderr를 읽을 때, 자식 프로세스가 stderr에 64KB 이상을 쓰려고 하면 다음과 같은 일이 발생한다.\n[자식 프로세스] stderr에 64KB 씀 -\u0026gt; 버퍼 가득 참 -\u0026gt; 부모가 stderr을 read할 때까지 write 블로킹 [부모 프로세스] stdout.read() 실행 중 -\u0026gt; 자식이 끝나야 EOF가 올때까지 블로킹 결과: 자식은 stderr 쓰기에서 멈추고, 부모는 stdout 읽기에서 멈춤 -\u0026gt; 서로를 기다리며 영원히 진행 불가 (데드락)지금까지 말한 데드락 문제의 간단한 재현은 다음과 같이 할 수 있다.\nimport subprocess proc = subprocess.Popen( [\u0026#34;python3\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;import sys; print(\u0026#39;A\u0026#39;*100000, file=sys.stderr)\u0026#34;], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout = proc.stdout.read() stderr = proc.stderr.read()해결: 스레드로 동시 읽기 실제로 여기 를 보면\nNote: This will deadlock when using stdout=PIPE or stderr=PIPE and the child process generates enough output to a pipe such that it blocks waiting for the OS pipe buffer to accept more data. Use Popen.communicate() when using pipes to avoid that.\n라고 OS의 pipe buffer보다 더 많은 데이터를 넣을때, PIPE를 사용하면 데드락이 발생할 수 있다고 경고하고, communicate()를 사용하라고 한다.\n하지만, subprocess의 communicate()는 내부적으로 스레드를 사용해 stdout/stderr를 동시에 읽지만, 출력을 전부 읽은 뒤에 반환하기 때문에 실시간 크기 제한이 불가능하다.\n그래서 스레드를 사용해 stdout과 stderr를 동시에 읽으면서 각각 크기를 제한하는 방법을 택했다.\nwith ThreadPoolExecutor(max_workers=2) as executor: stdout_future = executor.submit( self._consume_stream, result, result.stdout, MAX_STDOUT_SIZE ) stderr_future = executor.submit( self._consume_stream, result, result.stderr, MAX_STDERR_SIZE ) stdout = stdout_future.result() stderr = stderr_future.result()이 구조를 사용하여 데드락을 방지하면서 stdout 128KB, stderr 128KB 각각 독립적으로 제한할 수 있게 되었다.\n또한 web page 에서 stdout/stdeer이 초과될 경우 alert()를 호출하도록 하여 UX를 개선하였다.\n참고 자료 python-discord/snekbox Python 공식 문서: subprocess Python 공식 문서: ThreadPoolExecutor Linux pipe(7) 매뉴얼 ","permalink":"https://ljweel.github.io/posts/subprocess-stream-limit-and-deadlock/","summary":"무한 출력 이슈를 해결하다 파이프 데드락 문제를 발견하여, 원인 분석부터 해결까지의 과정을 기록하였습니다.","title":"stdout/stderr 제한과 subprocess 파이프 데드락 해결"},{"content":"배경 OnPyRunner에서 사용자 코드의 메모리를 제한하기 위해 nsjail을 사용한다. nsjail은 내부적으로 rlimit과 cgroup 두 가지 메커니즘으로 메모리를 제한할 수 있다.\n# nsjail.cfg rlimit_as: 128 # 가상 주소 공간 128MB 제한 cgroup_mem_max: 134217728 # 물리 메모리 128MB 제한 (cgroup)exit_code 137(SIGKILL)이 발생했을 때, 이것이 TLE(Time Limit Exceeded)인지 MLE(Memory Limit Exceeded)인지 구분해야 하는 문제가 있었다. 이 글에서는 두 메커니즘의 차이를 정리하고, 현재 설정에서 exit_code를 어떻게 해석할 수 있는지 분석한다.\n가상 메모리 vs 물리 메모리 메모리 할당은 예약과 사용의 2단계다 프로세스가 메모리를 요청하면(예: malloc), 내부적으로는 brk 또는 mmap 시스템 콜을 통해 커널에 가상 주소 공간을 예약한다. 이 시점에서 물리 메모리(RAM)는 아직 할당되지 않는다.\n비유하면:\n가상 주소 공간 예약 = 땅에 울타리를 치는 것 (주소 공간 확보) 물리 메모리 할당 = 그 땅에 건물을 짓는 것 (실제 데이터 write) demand paging 물리 메모리는 해당 가상 주소에 처음 접근(read/write)할 때 페이지 단위(보통 4KB)로 할당된다. 이를 demand paging이라 하며, 첫 접근 시 page fault가 발생하고 커널이 물리 페이지를 매핑한다.\nmalloc(100MB) 직후 → 가상 주소 공간 100MB 예약, 물리 메모리 ~0 50MB 영역에 write → 가상 100MB, 물리 ~50MB 100MB 전부 write → 가상 100MB, 물리 ~100MB이 구조 때문에 가상 주소 공간 사용량 \u0026gt;= 물리 메모리 사용량이 일반적으로 성립한다. 단, page cache나 cgroup accounting 방식에 따라 예외가 있으며, 이는 뒤에서 다룬다.\nrlimit_as: 가상 주소 공간 제한 제한 대상: 프로세스의 전체 가상 주소 공간(Virtual Address Space) heap, stack, mmap 영역, 공유 라이브러리 매핑 등 모든 매핑을 포함한다 체크 시점: brk, mmap 등으로 가상 주소 공간을 확장하려는 시점 동작: 가상 주소 공간의 총합이 제한을 초과하면 시스템 콜이 실패한다 (mmap → ENOMEM, brk → 실패) 결과: Python의 경우, 메모리 할당 실패를 감지하여 MemoryError 예외를 발생시킨다 → exit_code 1 # 한 번에 큰 할당 시도 a = [1] * 1000000000 # 내부적으로 대량의 메모리 요청 → rlimit_as 초과 → MemoryError cgroup_mem_max: 물리 메모리 제한 (cgroup) 제한 대상: cgroup에 속한 모든 프로세스의 메모리 사용량 합산 RSS(Resident Set Size), page cache, tmpfs, 일부 커널 메모리(slab 등)가 포함된다 즉, 프로세스의 가상 주소 공간이 아니라 커널이 해당 cgroup에 청구(charge)한 물리 페이지를 기준으로 한다 체크 시점: 물리 페이지가 실제로 할당·청구되는 시점 (page fault 처리, 파일 I/O 등) 동작: cgroup 전체 메모리 사용량이 제한을 초과하면 커널의 OOM Killer가 발동한다 결과: SIGKILL(9) → exit_code 137 (128 + 9) cgroup은 가상 주소 공간 예약(울타리) 자체는 막지 않는다. 실제 물리 메모리 사용(건물)이 한도를 넘어야 개입한다.\n두 메커니즘 비교 구분 rlimit_as cgroup_mem_max 제한 대상 가상 주소 공간 (VAS) 물리 메모리 (cgroup 단위) 적용 범위 프로세스 개별 cgroup 전체 (부모+자식 합산) 체크 시점 brk/mmap 호출 시 물리 페이지 할당(charge) 시 실패 방식 시스템 콜 실패 → 예외 OOM Kill → SIGKILL exit_code 1 (MemoryError) 137 (SIGKILL) 공유 라이브러리의 영향 Python 프로세스가 시작할 때 libc, libpython 등 공유 라이브러리를 로드한다.\nrlimit_as: 공유 라이브러리를 가상 주소 공간에 매핑하는 것 자체가 카운트된다. 다른 프로세스와 공유하더라도 해당 프로세스의 가상 주소 공간을 차지한다. cgroup_mem_max: 공유 라이브러리의 read-only 페이지는 이미 물리 메모리에 존재하는 페이지를 참조만 하므로, 해당 cgroup에 추가로 청구되지 않는 경우가 많다. 단, copy-on-write로 인한 private dirty page가 생기면 그 부분은 청구된다. 이 차이는 가상 주소 공간과 물리 메모리 사용량 사이의 갭을 벌리는 요인 중 하나다. 공유 라이브러리가 많을수록 가상 주소 공간은 크게 잡히지만, 물리 메모리 사용량은 상대적으로 적다.\n같은 값으로 설정했을 때 어느 쪽이 먼저 발동하는가? 일반적인 경우, rlimit_as가 먼저 발동할 가능성이 높다.\n이유:\ndemand paging으로 인해 가상 주소 공간 사용량이 물리 메모리 사용량보다 크거나 같다 공유 라이브러리 매핑이 가상 주소 공간에는 잡히지만 물리 메모리에는 추가 청구되지 않는 부분이 있다 Python 인터프리터 자체가 시작 시 상당한 가상 주소 공간을 사용한다 따라서 단순히 메모리를 할당하는 패턴(list, dict 등의 자료구조 확장)에서는 rlimit_as가 먼저 한도에 도달하여 MemoryError가 발생하고, cgroup OOM Kill까지 가지 않는다.\n예외: cgroup이 먼저 발동하는 경우 위의 판단은 \u0026ldquo;일반적인 메모리 할당 패턴\u0026quot;에서만 성립한다. 다음과 같은 경우에는 cgroup이 먼저 발동할 수 있다.\n1. page cache를 통한 파일 I/O 파일 I/O(read(), write() 시스템 콜)를 수행하면 커널은 page cache에 파일 내용을 올린다.\npage cache는 커널이 관리하는 영역이다. 프로세스의 가상 주소 공간에 매핑되지 않으므로 rlimit_as에 카운트되지 않는다. 그러나 cgroup memory accounting에서는 page cache도 해당 cgroup에 청구된다. read(\u0026#39;huge_file.txt\u0026#39;) 실행 시: 프로세스 관점: - 유저 버퍼 (read 결과를 받을 공간)만 가상 주소 공간에 존재 → rlimit_as에 카운트 커널 관점: - 디스크에서 읽은 데이터를 page cache에 올림 → 프로세스의 VAS 밖 → rlimit_as와 무관 - 하지만 해당 cgroup에 청구됨 → cgroup_mem_max에 카운트이 경우 rlimit_as에는 잡히지 않는 물리 메모리(page cache)가 cgroup에는 누적되므로, cgroup_mem_max가 먼저 한도에 도달할 수 있다.\n2. fork/clone으로 인한 다중 프로세스 rlimit_as는 프로세스 개별 단위로 적용되지만, cgroup_mem_max는 cgroup 전체 (부모 + 자식 프로세스 합산) 단위로 적용된다.\nfork()를 여러 번 실행한 경우: rlimit_as: 각 프로세스가 개별적으로 128MB 이내 → 제한에 걸리지 않음 cgroup_mem_max: 부모 + 자식 프로세스의 물리 메모리 합산 → 128MB 초과 가능 → OOM Kill3. tmpfs / shared memory /dev/shm 등 tmpfs에 쓰거나 POSIX shared memory를 사용하면, 해당 메모리는 cgroup에 청구되지만 rlimit_as에는 mmap 매핑 크기만 반영된다. 대량의 tmpfs 사용 시 cgroup이 먼저 발동할 수 있다.\nOnPyRunner에서의 실제 판별 로직 이 판별 로직은 다음 두 가지 전제 조건 위에서 성립한다.\n전제 1: nsjail이 fork와 파일 I/O를 제한한다 OnPyRunner의 nsjail 설정에서는 fork/clone이 제한되고, 사용자 코드가 접근할 수 있는 파일 시스템이 최소화되어 있다. 따라서 앞서 다룬 예외 상황(page cache 누적, 다중 프로세스의 cgroup 합산)이 발생할 가능성이 낮다. 이 덕분에 MLE는 cgroup OOM Kill(exit_code 137)이 아닌 rlimit_as에 의한 MemoryError(exit_code 1)로 나타난다고 기대할 수 있다.\n전제 2: MemoryError를 except로 잡아도 문제없다 사용자가 except로 MemoryError를 잡으면 exit_code 1이 아닌 0으로 정상 종료될 수 있다.\ntry: a = [1] * 1000000000 except: pass # MemoryError가 잡혀서 exit_code 0으로 종료그러나 이 경우 프로그램이 crash 없이 실행을 계속한 것이므로, MLE가 아닌 정상 종료(SUCCESS)로 판정하는 것이 적절하다. 메모리 한도에 도달했더라도 프로그램 스스로 이를 처리한 것이기 때문이다.\n판별 로직 위 전제 조건 하에서, 현재 설정(rlimit_as: 128, cgroup_mem_max: 134217728, 둘 다 128MB)의 판별 로직은 다음과 같다:\nexit_code stderr 내용 판정 0 - SUCCESS 1 \u0026ldquo;MemoryError\u0026rdquo; 포함 MEMORY_LIMIT_EXCEEDED 1 그 외 RUNTIME_ERROR 137 - TIME_LIMIT_EXCEEDED 그 외 - UNKNOWN_ERROR 단, 향후 입력 크기 제한을 늘리거나, 사용자 코드가 파일 I/O를 수행할 수 있는 환경이 된다면, exit_code 137이 MLE일 가능성도 고려해야 한다. 이 경우 cgroup의 memory.events 파일에서 OOM 발생 여부를 확인하는 방식으로 TLE와 MLE를 구분할 수 있다.\n한줄 정리 rlimit_as: 프로세스의 가상 주소 공간(VAS) 총합을 제한한다. brk/mmap 시점에 체크. cgroup_mem_max: cgroup에 청구된(charged) 물리 페이지 총합을 제한한다. 물리 페이지 할당 시점에 체크. 가장 큰 차이는 accounting 대상이 다르다는 것이다:\nrlimit_as: 가상 주소 공간에 매핑된 모든 영역 (heap, stack, mmap, 공유 라이브러리 등) cgroup_mem_max: 해당 cgroup에 청구된 물리 페이지 (RSS, page cache, tmpfs 등) 같은 값으로 설정했을 때, 순수 메모리 할당 패턴에서는 rlimit_as가 먼저 한도에 도달하는 것이 일반적이다. 그러나 page cache, fork, tmpfs 등의 요인으로 cgroup이 먼저 발동할 수 있으므로, \u0026ldquo;항상 rlimit_as가 먼저\u0026quot;라고 단정할 수는 없다.\n면접 팁 \u0026ldquo;가상 메모리와 물리 메모리의 차이\u0026quot;는 OS 면접 단골 질문이다 demand paging, page fault, copy-on-write 등과 연결된다 cgroup은 컨테이너(Docker) 기술의 핵심이므로 \u0026ldquo;Docker가 메모리를 어떻게 제한하는가?\u0026ldquo;라는 질문과도 연결된다 \u0026ldquo;rlimit과 cgroup의 차이\u0026quot;를 설명할 수 있으면, 리소스 격리에 대한 깊은 이해를 보여줄 수 있다 ","permalink":"https://ljweel.github.io/posts/rlimit_vs_cgroup/","summary":"rlimit과 cgroup의 차이를 서술한 글입니다.","title":"rlimit vs cgroup: 메모리 제한의 두 가지 레이어"},{"content":" 이전 글 요약 테스트 자동화 nsjail log 처리 및 cgroup 설정 배포 및 UI 제작 이제 MVP는 거의 제작된거 같아서 배포 및 UI 제작을 해야한다. 배포는 vulcan님의 도움을 받아 vulcan 서버에서 하게 되었고, 클플 터널링 + nginx로 리버스 프록시를 해서 기존에 존재하던 서비스와 충돌하지 않게 했다. ui는 run.xo.dev 를 참고해서 중앙을 기준으로 좌를 code, stdin을 입력하는 곳, 우를 stdout, stderr 를 보여주는 곳으로 했다.\n전체적인 구조 정리 Excution Flow(Sequence Diagram) 전체적인 실행흐름을 시퀀스 다이어그램으로 나타내면 다음과 같다.\nsequenceDiagram participant Client participant API participant Redis Queue participant Worker participant Sandbox participant Redis Storage Client-\u0026gt;\u0026gt;API: POST /execute API-\u0026gt;\u0026gt;Redis Storage: SET job:{jobId} = PENDING API-\u0026gt;\u0026gt;Redis Queue: LPUSH job_queue API--\u0026gt;\u0026gt;Client: return jobId Redis Queue--\u0026gt;\u0026gt;Worker: BRPOP job_queue Worker-\u0026gt;\u0026gt;Sandbox: Execute code Sandbox--\u0026gt;\u0026gt;Worker: stdout, stderr, exit_code Worker-\u0026gt;\u0026gt;Redis Storage: SET job:{jobId} = COMPLETED loop Polling Client-\u0026gt;\u0026gt;API: GET /jobs/{jobId} API-\u0026gt;\u0026gt;Redis Storage: GET job:{jobId} Redis Storage--\u0026gt;\u0026gt;API: status API--\u0026gt;\u0026gt;Client: status end 다른 것도 정리했는데 여기보다는 여기 에 작성했다.\n드디어 오픈 ljweel.dev 처럼 nginx로 호스팅했다.\nrun.ljweel.dev -\u0026gt; 사이트 UI(html + js)를 담당 run.ljweel.dev에서 /execute와 /jobs/{jobId} -\u0026gt; api endpoint run.ljweel.dev 해당링크에서 실행해볼 수 있다.\n","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner10/","summary":"전체 실행 흐름을 시퀀스 다이어그램으로 정리하고, UI를 제작한 뒤 run.ljweel.dev로 서비스를 오픈했습니다.","title":"10. 배포와 오픈"},{"content":" 이전 글 요약 worker 설계와 데이터 흐름도 테스트 자동화 도입 전체적으로 설계가 끝났기 때문에 로컬에서 docker compose up으로 컨테이너를 올리고 api를 테스트 해보고 있는데, postman으로 직접 localhost:8000/execute를 Send하고, 받은 job_id를 복사해서 localhost:8000/jobs/{job_id}에 붙여넣고 잘 나오는지 체크해보고 있었다. 근데 코드 고치고 테스트하는 일이 많아져서 이전에 생각했던 TDD가 필요해졌다. 그래서 코드를 고치고 api 테스트하는 일련의 과정을 개발 과정에서 자동화하기로 했다.\n코드를 고치고 docker compose down \u0026amp; docker compose up -d \u0026ndash;build docker compose watch와 volume 기능을 사용해서 파일이 수정되면 컨테이너를 재시작/재빌드를 자동으로 해준다. post /execute + get /jobs/{job_id} 과정을 테스트로 작성하여 기대하는 결과가 나오는지 체크 pytest + request를 사용해서 해당 과정을 class로 작성했다. nsjail stderr 처리 pytest를 진행하던 도중 stderr에 nsjail info가 불필요하게 많이 나오는걸 알게 되었다. snekbox 의 경우 링크의 코드를 보면 알겠지만 nsjail info를 패턴매칭하여 logging을 이용했다. 딱히 info 관련은 필요 없는 것 같아서 nsjail command option으로 \u0026lsquo;-q\u0026rsquo;를 주어 warning 이상의 로그만 나오도록 했다.\n참고 코드 snekbox log pattern matching nsjail cmdline -q cgroup fork bomb를 막기 위해 cgroup 설정을 하고자 했지만 번번히 실패했다. 그래서 그 이유를 찾아보니 다음과 같았다. 현재의 구조는 docker가 api-server, redis, worker로 3개로 분리해서 만들었다. worker docker에서는 worker가 job을 낚고, nsjail을 실행시킨다.\n하지만, docker는 컨테이너 단위로 cgroup을 관리하게 되는데, 이때, 컨테이너 내부의 프로세스가 cgroup 에 대한 작성 권한을 위임받지 않기 때문에 nsjail에서 cgroup을 설정할 수 없게 되는 것이였다.\n따라서 이 문제를 해결하기 위해, 컨테이너 내부에서 cgroup 트리를 재구성하는 방식을 사용하였다.\ncgorup v2의 no internal processes rule 에 따르면, 어떤 cgroup이 하위 cgroup에 컨트롤러를 위임하려면(cgroup.subtree_control에 +memory, +cpu, +pids 등을 설정하려면), 해당 cgroup은 프로세스를 직접 보유하고 있지 않아야 한다. 즉, 프로세스는 leaf 노드에만 존재해야 한다.\n하지만 Docker 컨테이너 내부에서 /sys/fs/cgroup의 루트 cgroup에는 이미 컨테이너의 PID 1 프로세스를 포함한 여러 프로세스가 존재한다. 이 상태에서는 cgroup.subtree_control에 컨트롤러를 활성화하려 할 경우 Device or resource busy 오류가 발생한다. 이는 규칙 위반 때문이다.\n이를 해결하기 위해 다음과 같은 절차를 거쳤다.\n먼저 /sys/fs/cgroup/init이라는 하위 cgroup을 생성한다. 그리고 루트 cgroup에 속해 있던 모든 프로세스를 이 init 그룹으로 이동시킨다. 이렇게 하면 루트 cgroup은 더 이상 프로세스를 가지지 않는다. 즉, 컨트롤러를 위임할 수 있는 상태가 된다.\n그 다음 루트 cgroup의 cgroup.subtree_control에 +memory, +cpu, +pids 등을 활성화한다. 이 단계는 하위 cgroup들이 해당 리소스 컨트롤러를 사용할 수 있도록 권한을 위임하는 과정이다.\n이후 /sys/fs/cgroup/nsjail과 같은 별도의 하위 cgroup을 생성하고, 그 아래에서 nsjail이 새로운 cgroup을 만들도록 구성한다. 이렇게 하면 nsjail은 해당 하위 트리 안에서 memory.max, pids.max, cpu.max 등을 정상적으로 설정할 수 있게 된다.\n정리하면,\n루트 cgroup에 존재하던 프로세스를 init 그룹으로 이동시켜 루트를 비운다. 루트에서 subtree_control을 활성화하여 컨트롤러를 하위에 위임한다. nsjail 전용 하위 cgroup을 만들고, 그 안에서 자식 cgroup을 생성하도록 한다. 이 과정을 통해 Docker 컨테이너 내부에서도 cgroup v2 규칙을 만족하는 구조를 만들 수 있었고, nsjail이 fork bomb 방지를 위한 pids.max나 메모리 제한을 정상적으로 설정할 수 있게 되었다.\n","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner09/","summary":"docker compose watch로 개발 루프를 자동화하고, cgroup v2의 no internal processes rule을 이해하여 fork bomb 방지했습니다.","title":"9. 테스트 자동화와 cgroup 삽질"},{"content":" 이전 글 요약 job 상태 전이 다이어그램 api 설계 및 의사 코드 worker가 job를 가져가는 법 이전 글에서 그려놓은 job의 상태 전이 다이어그램과 api 설계를 바탕으로 코딩을 했다. 지금까지 설계의 흐름을 job에 관한 flow chart로 아래에 만들어보았다.\nflowchart LR User[사용자: Python 코드 제출] --\u0026gt;|HTTP Request| API[\u0026#34;API 서버 (FastAPI)\u0026#34;] API --\u0026gt;|job 생성| Queue[\u0026#34;Queue (Redis)\u0026#34;] Queue --\u0026gt;|job 가져감| Worker Worker --\u0026gt;|\u0026#34;코드 실행 (Nsjail)\u0026#34;| Result[Execution 결과] Result --\u0026gt;|결과 저장/응답| API API --\u0026gt;|HTTP Response| User 이전 글에서는 HTTP Request, HTTP Response, job 생성, 결과 저장/응답 까지 설계했다.\n그래서 worker.py를 짜야하는데 위 흐름도에서 코드 실행(Nsjail) + 결과 저장 / 응답에 해당한다. worker가 하는 일을 의사코드로 나타내 면 다음과 같다.\n1. queue에서 payload pop 2. payload json 에서 dict로 변환 3. job의 상태를 RUNNING으로 바꾸기 try: 4. 샌드박스 실행 후 결과 받기 5. result를 분석 후 response로 변환 6. job completed Response except: 7. job Failed Response추상화하기 4. 샌드박스 실행 후 결과 받기 라는 부분에 대해 코드 작성이 어려웠다. 왜냐하면 샌드박스 실행이라고 뭉뚱그려 생각한 것의 실제 과정은\n샌드박스 폴더를 만들고, 코드와 입력을 파일에 작성하고, config파일을 고려해서 nsjail이 생성 생성 후 샌드박스 실행 생성한 샌드박스 폴더 삭제 이라는 일련의 과정이 존재하기 때문이다. 그래서 저런 일련의 과정들을 모두 worker.py에 적기 보다는 nsjail.py 라는 파일을 만들어서 거기에서 nsjail class를 만들고자 했다. python-discord/snekbox 에서 만든 nsjail class를 참고했다.\nnsjail 클래스를 만들어서 (source_code, stdin) -\u0026gt; NsjailResult 를 추상화했다.\nWorker 흐름도와 Analyzer 지금까지의 worker에서의 데이터 흐름은 아래와 같다.\ngraph TD A[BRPOP from queue] --\u0026gt;|JobExecutionPayload JSON| B[json.loads] B --\u0026gt;|execution_payload_dict| C[Extract job_id, source_code, stdin] C --\u0026gt; D[Create RunningJobResponse] D --\u0026gt;|RunningJobResponse| E[(Redis SET job:job_id)] C --\u0026gt; F[run_sandboxed_task] F --\u0026gt; G[NsJail.execute] G --\u0026gt;|NsJailResult| H[ResultAnalyzer.analyze] H --\u0026gt;|CompletedJobResponse| I[(Redis SET job:job_id)] F --\u0026gt;|Exception| J[Create FailedJobResponse] J --\u0026gt;|FailedJobResponse| K[(Redis SET job:job_id)] style G fill:#fff4e1 style H fill:#e1f5e1 style E fill:#ffe1e1 style I fill:#ffe1e1 style K fill:#ffe1e1 위와 같이 이루어지는데, NsjailResult를 분석해서 CompletedJobResponse로 만들어야 하기 때문에, 초록색 부분인 ResultAnalyzer.analyze를 통해 exit_code, stderr등을 보고, TLE, MLE, RTE 등을 분석하게 된다. 일단은 추상화를 해놓고 내부 구현을 간단히 한 후 동작하는지를 테스트해볼 예정이다.\n참고자료 How to design a scalable, distributed background worker using only Redis and pure Python python-discord/snekbox ","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner08/","summary":"Worker가 Queue에서 Job을 가져와 Nsjail로 실행하고 결과를 분석하는 전체 흐름을 설계하며, NsjailResult를 통한 추상화를 구현했습니다.","title":"8. Worker 흐름과 Nsjail 추상화"},{"content":" 이전 글 요약 Redis를 사용한 웹 - 큐 - 워커 아키텍쳐 job 상태 다이어그램 job의 상태 정의\nPENDING: 아직 RUNNING이 아닌 상태 RUNNING: 실제로 실행됨. COMPLETED: 실행이 완료됨. 코드의 결함은 사용자의 책임 FAILED: 실행이 실패함. 시스템 설계의 책임. Runtime Error는 COMPLETED일까 FAILED일까?\n-\u0026gt; job실행 상태를 기반으로 정의하는 거지, 파이썬 코드의 성공 여부를 따지는 것이 아니므로 COMPLETED에 속함.\nCOMPLETED는 사용자의 책임, FAILED는 시스템 설계의 책임\nstateDiagram-v2 [*] --\u0026gt; PENDING: Submit Job PENDING --\u0026gt; RUNNING: Worker picks up RUNNING --\u0026gt; COMPLETED: Execution finished RUNNING --\u0026gt; FAILED: System error COMPLETED --\u0026gt; [*] FAILED --\u0026gt; [*] api 설계 job의 상태 다이어그램을 기반으로 post와 get의 요청과 응답의 구성요소를 설계한다.\nresponse의 최소 구성 요소 job_id, job_status는 항상 있어야한다. job_id가 있어야 job의 결과를 알 수 있고, job status가 있어야 현재 작업 상태를 알 수 있다. POST /execute request\n필수적인 것: language, source_code 없으면 default로: input, limits(3초/128MB) response\n필수적인거 job_id, status(PENDING) 기본적인 의사코드\ndef execute(request): validate request create job id create execution worker_job for worker enqueue worker_job to redis queue create initial job state (PENDING) save job state in redis return job state as response (PENDING) worker에게 갈 job의 정보(queue에 들어갈 정보)와 db에 저장해놓을 job 정보는 다르기 때문에 서로 분리해서 생각해야 한다. queue에 들어갈 정보는 코드, 입력, 제한 등등이 있지만, 해당 내용까지 db에 저장할 필요는 없다. GET /jobs/{job_id} response job status에 따라 분리하여 설계 PENDING/RUNNING 필수: job_id, status COMPLETED 필수: job_id, status, result(stdout, stderr, exit_code, execution_time_ms) FAILED 필수: job_id, status, reason(실패 사유) 의사 코드 def get_job(job_id): load job state from redis convert job state to json return job state as response ","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner07/","summary":"PENDING·RUNNING·COMPLETED·FAILED 상태 다이어그램을 정의하고, 각 API의 요청·응답 구조와 의사코드를 설계했습니다.","title":"7. Job 상태 머신과 API 설계"},{"content":" 이전 글 요약 Docker 도입 에러 처리 및 여러 테스트 추가 문제 제기 OnPyRunner01 에서는 \u0026ldquo;동시 실행이 늘면 코드 실행 끝날 때까지 블로킹\u0026rdquo; 문제가 있다고 보고,\n이를 해결하기 위해 \u0026ldquo;메시지 큐 기반 비동기 처리\u0026quot;를 도입하려 했다.\n하지만 이후 OnPyRunner03 에서는 다시 동기로 전환했는데, 이 과정에서 다음과 같은 개념적 혼동이 있었다.\n잘못된 사고 흐름 동시 실행 문제 해결책을 번복했지만 다른 해결책은 제시하지 않음\n동기 -\u0026gt; 비동기 -\u0026gt; 다시 동기 실제 문제(동시 실행) 해결 방법은 논블로킹 구조, 스레드 풀 등 다른 방식이어야 함 블로킹 문제를 비동기로 해결하려고 시도\n동기/비동기와 블로킹/논블로킹은 별개의 개념 비동기라고 해서 블로킹이 자동으로 해결되는 것은 아님 전체 구조가 한 번에 동기/비동기여야 한다는 착각\n\u0026ldquo;파이썬 코드 실행은 동기로 돌아가니까 전체도 동기\u0026rdquo; 실제로는 jail 내부 실행만 동기이고, 서버 I/O나 요청 처리 등은 별도로 비동기/논블로킹 가능 핵심 개념 정리 동기/비동기: 호출자가 결과를 기다리는지 여부 블로킹/논블로킹: 실행 중인 스레드나 서버 자원이 막히는지 여부 동기/비동기와 블로킹/논블로킹은 독립적 개념이다.\n전체를 동기/비동기 하나로 통일할 필요는 없고 객체마다의 상호작용에 따라 판단해야한다.\nOnPyRunner의 최소 MVP는 무엇일까? 스타트업에서는 제품의 가장 중요한 기능에 집중해서 개발하고 초기 모델을 출시하는데 이를 MVP(Minimum Viable Product)라고 한다.\n그렇다면 OnPyRunner의 최소 MVP는 무엇일지 고민해 보고 다음과 같이 정리했다.\nPython 코드와 입력이 주어질 때 실행 결과를 반환하는 API nsjail 기반의 샌드박스 격리 Redis 기반의 비동기 작업 처리 Redis의 본질 Redis를 \u0026ldquo;비동기 처리를 위해 도입하는 도구\u0026quot;라고 생각했지만, OnPyRunner를 구현하면서 느낀 건 **Redis의 핵심 가치는 비동기 자체가 아니라 \u0026lsquo;상태를 외부로 분리하는 것\u0026rsquo;**에 더 가깝다는 점이었다.\n처음에는 \u0026ldquo;동기로 돌리면 블로킹 문제가 생기니까 -\u0026gt; 비동기로 바꾸자 -\u0026gt; 그러려면 Redis가 필요하다\u0026rdquo; 라는 단순한 사고 흐름으로 접근했다.\n하지만 이건 정확하지 않았다.\nOnPyRunner에서 문제의 본질은 비동기 처리 여부가 아니라, 코드 실행이라는 긴 작업의 생명주기가 HTTP 요청의 생명주기에 종속되어 있었다는 점이었다. 이 구조에서는 요청이 끝나는 순간 실행 상태를 관찰하거나 복구할 방법이 없다.\n예를 들어 보면 다음과 같다. 사용자가 실행 버튼을 누른 (실행 api를 요청한) 후에 탭을 닫는 상황(api 요청이 끝남)이 발생했다고 가정하자. 이런 상황에서 nsjail 내부에서는 코드가 정상적으로 실행될 수 있다. 하지만 요청이 종료된 시점에서 실행 결과를 수집하고 저장할 책임 주체는 사라진다. 결과적으로 실행은 되었지만, 시스템 입장에서는 해당 실행이 존재한 적이 없는 것과 다름없는 일종의 Orphaned 상태가 된다.\n결국 문제의 본질은 실행이라는 작업에 대한 책임이 HTTP 요청에 묶여 있었다는 것이었다. 이는 Job과 Request의 차이를 인지하지 못했기 때문이었다.\nPython 코드는 어차피 nsjail 내부에서 동기적으로 실행된다. 이 사실은 바뀌지 않는다.\n중요한 건 그 실행을 어떻게 감싸고, 어떻게 관찰하고, 어떻게 결과를 내는가다.\nRedis를 도입하면 다음이 가능해진다.\n실행 요청을 즉시 받아서 큐에 넣고 HTTP 요청은 빠르게 종료 실행 상태(PEDDING | RUNNING | COMPLETED)를 Redis에 저장 결과 역시 Redis를 통해 조회 가능 워커 프로세스 수를 조절해 동시 실행량을 제어 즉, Redis는 \u0026ldquo;비동기를 하기 위해 억지로 끼워 넣은 컴포넌트\u0026quot;가 아니라 실행이라는 긴 작업을 웹 요청이라는 짧은 생명주기에서 분리하기 위한 장치였다.\nRedis를 선택한 이유는 비동기 처리를 위해서가 아니라 실행(Job)의 생명주기를 HTTP 요청으로부터 분리하고, 그 상태를 외부에서 관찰·관리하기 위함 이었다. 이 역할은 Redis가 아니어도 수행할 수 있다. 단지 이런 요구사항을 가장 간단하게 만족시키는 구현 선택지 중 하나일 뿐이다.\n그래서 결론적으로는,\nOnPyRunner의 API에 Redis 기반의 웹 - 큐 - 워커 구조를 사용하자 라는 판단에 도달했다.\n출처 웹-큐-워커 아키텍처 스타일 비동기 요청-회신 패턴 ","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner06/","summary":"Redis는 비동기를 위한 도구가 아니라 Job의 생명주기를 HTTP 요청에서 분리하기 위한 장치임을 깨닫고, 웹-큐-워커 아키텍처로 정했습니다.","title":"6. Redis의 본질과 MVP 재정의"},{"content":"Request vs Job Request\nHTTP 프로토콜 수준의 단위 클라이언트 \u0026lt;-\u0026gt; 서버 연결의 생명주기 짧아야 한다 실패하면 즉시 응답해야 한다 재시도 개념이 약하다 Job\n시간이 걸릴 수 있는 실행 단위 HTTP와 무관하게 독립된 생명주기 보유 실행 / 대기 / 종료 상태를 가진다 timeout, kill, 재시도가 가능하다 Worker가 실행한다 설계 판단 기준\n실행 시간이 항상 짧고 (\u0026lt;100ms), 실패가 거의 없으며, timeout 관리가 필요 없다면 -\u0026gt; Request\n실행 시간이 길 수 있고 무한루프 가능성이 있으며 강제 종료가 필요하다면 -\u0026gt; Job\n책임 분리 설계 판단 기준: 이 코드가 죽으면 서비스 전체가 죽어도 되나?\nYES -\u0026gt; 같은 책임\nNO -\u0026gt; 책임 분리\n동기/비동기와 블로킹/논블로킹 동기/비동기\n작업의 완료 여부를 따지면 동기, 아니면 비동기 메인 함수가 서브 함수의 처리 결과를 신경쓰면 동기, 아니면 비동기 caller가 완료 여부를 확인? -\u0026gt; 동기 callee가 완료 여부를 통지? -\u0026gt; 비동기 블로킹/논블로킹:\n현재 작업을 처리하기 위해 실행 중인 작업을 블락(차단/대기)하는지가 중요 시스템 콜이 스레드를 runnable상태로 유지시키는가? 이 호출로 인해 calling thread가 sleep에 들어가는가? 호출한 스레드가 멈추면 블로킹, 아니면 논블로킹 대부분의 상황은 동기 + 블로킹, 비동기 + 논블로킹이 사용됨. 하지만, 그렇지 않는 경우도 있음. 비동기 + 블로킹은 동기 + 블로킹과 큰 차이가 없어서 거의 쓰이지 않음. 안티 패턴으로 불리기도 함. 동기 + 논블로킹은 polling 형태.\n참고 링크\n완벽히 이해하는 동기/비동기 \u0026amp; 블로킹/논블로킹 블로킹(Blocking)/논블로킹(Non-Blocking), 동기(Sync)/비동기(Async) 구분하기 동기-비동기, 블로킹-논블로킹, 대체 차이가 뭐에요? ","permalink":"https://ljweel.github.io/posts/memo/","summary":"헷갈리는 개념을 적어 공부해봅니다.","title":"메모장"},{"content":" 이전 글 요약 nsjail을 이용해서 sandbox 구축하기로 결정 Jest를 이용해 TDD 시작 테스트 코드 추가 네트워크 차단 검증 jail 내부에서 외부 네트워크와 연결이 되지 못해야한다. socket.connet 코드가 정상적으로 Network is unreachable을 뱉는지 검사하면 된다.\n파일 시스템 차단 검증 어떤걸 막아야할까? open 함수를 제한할 수는 없기 때문에, jail 내부에서 python 실행에 필요한 최소 파일 빼고는 다 open할 수 없게 해야한다. 하지만\u0026hellip;\n지옥의 로컬테스트 nsjail은 chroot + mount로 jail내의 파일 시스템을 재구성한다. 하지만 로컬 우분투에서 이를 그대로 적용하면 로컬 우분투의 파일 시스템을 사용하여 nsjail을 실행시키게 되고, 배포와 개발 코드 사이의 괴리가 커지게 되지만, 무엇보다 로컬 테스트 디버깅이 너무 어려웠다. nsjail 조차 돌리기가 어렵기 때문에 python이 돌아가는지 테스트가 안되는 상황이 발생했다. 살려줘\nDocker 도입 Docker를 사용하자. Docker를 사용하면 일관된 환경으로 nsjail 설정이 쉬워질 뿐더러 개발과 배포간의 괴리가 사라진다.\n로컬 개발 환경 재구축 OnPyRunner = api 서버 + Docker 실행 로직 Docker = Python 런타임 + Python 라이브러리 + nsjail + sandbox 실행 dir 같은 느낌으로 개발 환경을 바꾸었다.\n기존의 runCode는 nsjail을 실행시킨 반면, 현재의 runCode는 Docker(nsjail + python)를 실행시킨다.\n바뀐 runCode는 docker를 실행시킨다.\n왜 안됨????? /tmp와 /etc를 open하는 test가 계속 실패하는 상황이 발생했다. .cfg 기준으로는 mount 된 경로만 nsajil 내부에 생기게 되지만, mount 하지 않은 /tmp와 /etc가 계속 open이 되는 상황 발생\n-\u0026gt; 알고보니 clone_newns: false로 되어있었음\u0026hellip; clone_newns는 새 namespace를 만드는지 여부를 체크하게 되는데, false로 되어있어서, docker namespace와 nsjail namespace가 같아져서 mount하지도 않은 /tmp와 /etc가 생김. clone_newns: true를 해서 test 성공\n추가해야할 테스트 프로세스 생성 차단 테스트 리소스 제한 테스트 ","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner05/","summary":"로컬 우분투에서의 nsjail 디버깅 지옥을 겪은 뒤 Docker를 도입하여 일관된 개발 환경을 구축하고, clone_newns 이슈를 해결했습니다.","title":"5. Docker 도입과 테스트 환경 구축"},{"content":" 이전 글 요약 설계 미스로 원하던 구조의 실행기가 아니게 됨. 어디서 미스난지 파악하고 설계 개선 현재 고민 서버에서 Python을 실행시키려면 제약 조건이 많아진다. 신뢰할 수 없는 코드에 대해서도 실행가능해야하는데 다음과 같은 고려 사항을 떠올렸다.\n네트워크 격리 CPU, 시간, 메모리와 같은 자원 제한 syscall 필터 파일 시스템 제한 현재의 선택지 seccomp seccomp는 리눅스에서 sandbox 기반으로 시스템콜을 허용 및 차단하여 공격의 가능성을 막는 리눅스 보안 메커니즘이다.\nNsjail 네임스페이스, 리소스 제한 및 seccomp-bpf 시스템 호출 필터를 사용하는 Linux 프로세스 격리 도구이다.\n내가 원하던 네트워크 격리, syscall, 자원 제한, 파일시스템 제한 등 많은 기능이 있었다.\nNsjail로 결정 다른 사람들이 어떻게 했는지 구글링하다가 같은 생각을 한 사람을 발견했다. 글1 글2 정도 유용하게 쓰였고, 글2가 많은 도움을 주었다. 구글에서도 untrust python code를 실행시키기 위해 nsjail을 썼다고 한다. 그냥 자기들이 만든거 쓴거같은데\n결론은 OnPyRunner에 Nsjail을 써서 격리환경을 구성할 것 같다.\n테스트 주도 개발 도입 내가 nsjail 코드를 작성하고 코드를 돌렸을 때, 내가 원했던 조건들이 적용되었는지 하나하나씩 일일이 체크하는건 어렵다. 테스트케이스를 만들어서 코드가 주어질때 stdout, stderr를 예측하도록 하기 위해 테스트 주도 개발을 하려고 한다.\nrunCode라는 함수에 대해서 jest를 사용해서 unit test를 진행해야겠다.\nTest Case 만들어보기 먼저 runCode를 통해 Hello, World! 출력을 확인하는 테스트 코드를 작성해보았다.\ntest(\u0026#34;stdout에 Hello, World!가 출력된다\u0026#34;, () =\u0026gt; { const result = runCode({ code: \u0026#34;print(\u0026#39;Hello, World!\u0026#39;)\u0026#34;, input: \u0026#34;\u0026#34;, }); expect(result.stdout.trim()).toBe(\u0026#34;Hello, World!\u0026#34;); });이제 아래 코드를 nsjail을 사용해서 python이 실행되도록 만들면 된다.\nfunction runCode({code, input}) { return { stdout: \u0026#39;\u0026#39;, stderr: \u0026#39;\u0026#39;, exitCode: 0, }; }js에서 nsjail 실행시키기 node에서 nsjail을 실행시키려면 child_process의 spawn을 사용하면 된다. 이 때, arg와 config 파일 설정을 통해 nsjail을 커스텀할 수 있다.\n삽질하면서 알게 된 것 spawn은 비동기함수라서 Jest에서 실행시키면 항상 undefined를 받으므로 spawnSync를 사용해야한다. 실행하다가 \u0026ldquo;spawnSync /usr/local/bin/nsjail ENOENT\u0026quot;라는 에러가 떴는데, 이것은 node.js가 nsjail 경로를 찾지 못해서였는데, 알고보니 ubuntu용 node가 없고, window node로 실행되어서 window node가 해당 경로로 가게 되어 nsjail을 찾지 못하게 되는 것이였다. 그래서 바로 nvm을 깔아 주었다. 수많은 에러끝에 성공해냄. 참고 자료 How can I sandbox Python in pure Python? Run Python in a sandbox with nsjail nsjail.dev How to run nsjail on Google Cloud Run without prctl() errors? how to run nsjail in js 구글링했을때 AI 뜨는거 [Jest] 테스트 코드로 JS 의 기능 및 로직 점검하기 [Linux] Node.js 설치하기 / 백엔드 가동하기 ","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner04/","summary":"신뢰할 수 없는 코드 실행을 위해 Nsjail을 도입하고, runCode 함수에 대한 테스트 케이스를 작성하며 첫 샌드박스 실행에 성공했습니다.","title":"4. Nsjail 도입과 TDD 시작"},{"content":" 이전 글 요약 express와 redis의 bullMQ 도입 api 호출해서 worker까지 연결되는지 테스트 설계의 미스 코딩을 하다보니 뭔가 이상함을 느꼈다. 사용자가 jobId로 실행결과를 요청해야하는 구조가 내가 원하던 실행기의 모습인가? 라는 생각이 들었다. 그래서 전반적인 설계를 다시 돌아보고, 다시 기본적인 유스케이스로 돌아가 생각했다.\n가장 처음에 내가 원했던 방식은 온라인에서 Python 코드를 실행시켜주는, tio.run 같은 간단한 실행기였다.\n다시 돌아보면서 들었던 생각은 \u0026ldquo;POST 요청을 굳이 비동기로 처리해야하는가?\u0026rdquo;, \u0026ldquo;비동기를 위해 redis가 정말로 필요할까?\u0026rdquo; 였다.\n결과적으로 비동기는 필요가 없었다. 내가 원하던 것은 간단한 코드를 실행시켜주는 실험 환경이였지만, 설계를 하다가보니 본질을 잊은 것이다.\n어디서부터 잘못된걸까? 설계를 할 때 한 생각을 블로그에 정리해서 올렸기 때문에 어디서 미스가 난건지 파악할 수 있다. 문제는 여기 였다.\n동시 접속자가 늘면 코드 실행 끝날때까지 블로킹 문제를 메시지 큐 기반 비동기 처리로 자연스럽게 넘어갔는데, 메시지 큐를 통한 비동기는 병목 현상에 해결책이 될 수 없었던 것이다.\n조금 생각해보면 알 수 있는데, 코드 실행 컨테이너가 N개면 N개가 넘었을 때의 병목을 처리하는 방법은 컨테이너의 수를 늘리는 방법말고는 없다. 물리적으로 어쩔 수 없는 문제였다. 비동기는 병목을 해결해주지 않는다.\n개선 후 다이어그램 sequenceDiagram autonumber actor User participant API as API Server participant Worker as Worker Pool 1..N participant Sandbox Note over User, Worker: [1단계: 동기 요청 + 즉시 실행] User-\u0026gt;\u0026gt;API: code, input POST API-\u0026gt;\u0026gt;Worker: 실행 요청 전달 Note over Worker, Sandbox: [2단계: 격리 실행] Worker-\u0026gt;\u0026gt;Sandbox: Sandbox 생성 및 코드 실행 activate Sandbox Sandbox--\u0026gt;\u0026gt;Worker: stdout / stderr / exit code deactivate Sandbox Note over User, API: [3단계: 즉시 응답] Worker--\u0026gt;\u0026gt;API: 실행 결과 반환 API--\u0026gt;\u0026gt;User: HTTP 응답으로 결과 전송 동기 실행의 이유 결국 OnPyRunner는 동기 실행 구조가 되었지만 항상 동기 실행이 맞는 것은 아니다. OnPyRunner가 동기 실행이 된 이유는 사용자가 작성한 코드를 바로 실행시켜서 결과를 알려주는 것이 내 목표였기 때문이다.\n","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner03/","summary":"비동기 메시지 큐가 병목을 해결해주지 않는다는 사실을 인지하고, 간단한 실행기라는 본질에 맞게 동기 구조로 재설계했습니다.","title":"3. 설계 미스와 동기 전환"},{"content":" 이전 글 요약 온라인 파이썬 실행기를 만들고 싶다! 유스케이스와 다이어그램을 고민하고 문제점을 파악했다! 개선한 다이어그램을 그렸다! ExpressJs 사실 express 프레임워크를 사용안해봤기 때문에, 이참에 이번 프로젝트에서 써보려고 한다. 백엔드에 있어서 프레임워크는 별로 안 중요하다고 생각하는 편..그냥 써본 프레임워크가 적음\nnpm init 하고, redis, express install 하고, 시작해보자..\n설계를 구현으로 사실 설계는 저렇게 했지만, 코드로 어떻게 구현하는지는 다른 이야기다. redis와 express 사용 경험이 없기 때문. 그래서 전체적인 설계를 세부적이고 구체적인 코드 구현으로 쪼개어 생각해야한다. 다음과 같이 쪼개어 코드를 작성해 보았다.\n[API 호출] 요청 job 생성 및 MQ에 job push job id만 반환 [Worker 프로세스] MQ에서 job pop Python 실행 (격리) stdout / stderr 반환 결과 Redis 저장 [API 조회] jobId로 Redis 조회 상태/결과 반환 좀 찾아보니까 redis 메시지큐로 bullMQ를 많이 사용하는 거 같아서 이걸 쓰기로 했다.\nbullMQ 공식문서 bullMQ를 테스트 해보려면 도커에 redis를 띄워야한다. 참고 사이트 지금까지 한거 결국 app.js, worker.js, queue.js 3개를 만들어서 로컬에서 api 호출해서 worker에 도달하는지까지 테스트해 보았다. 코드는 다음과 같다.\napp.js\nimport express from \u0026#39;express\u0026#39;; import { addJobs } from \u0026#39;./queue.js\u0026#39;; const app = express(); app.use(express.json()); app.listen(3000); app.post(\u0026#39;/api/jobs\u0026#39;, async (req, res) =\u0026gt; { // 1. 요청 const { code, input } = req.body; const job = await addJobs(code, input); // 2. job 생성 및 MQ에 job push console.log(code, input); res.status(202).json({ // 3. job.id 반환 jobId: job.id, status: \u0026#39;queued\u0026#39;, }); }); // 8. jobId로 Redis 조회 // 9. 상태/결과 반환 queue.js\nimport { Queue } from \u0026#39;bullmq\u0026#39;; import IORedis from \u0026#39;ioredis\u0026#39;; const jobQueue = new Queue(\u0026#39;jobQueue\u0026#39;); async function addJobs(code, input) { return jobQueue.add(\u0026#39;run-code\u0026#39;, {\u0026#39;code\u0026#39;: code, \u0026#39;input\u0026#39;: input}); } const connection = new IORedis({ maxRetriesPerRequest: null }); export {jobQueue, addJobs, connection};worker.js\nimport { Worker } from \u0026#39;bullmq\u0026#39;; import { connection } from \u0026#39;./queue.js\u0026#39;; const worker = new Worker( \u0026#39;jobQueue\u0026#39;, async (job) =\u0026gt; { // 4. MQ에서 job pop const { code, input } = job.data; console.log(\u0026#39;job popped:\u0026#39;, job.id); console.log(\u0026#39;job.data:\u0026#39;, job.data); // 5. Python 실행 (격리) // 6. stdout / stderr 반환 // 7. 결과 Redis 저장 }, { connection }, );도커에 redis를 띄우고, node worker.js와 node app.js를 해주고 postman에서 api 요청을 날려보면 다음과 같이 나온다!\n","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner02/","summary":"Express와 BullMQ를 도입하고, app.js·queue.js·worker.js를 작성하여 API 호출이 Worker까지 도달하는지 테스트했습니다.","title":"2. Express와 BullMQ로 첫 구현"},{"content":"어쩌다 만들게 되었나 백준 문제를 풀다보면 폰으로 코딩하는 일이 빈번하다. 사실 온라인 파이썬 실행기는 많이 있다. 하지만, 많은 사이트들을 찾아봤는데 아쉬웠던 것은\n최신 버전의 파이썬이 아니였다. pypy가 지원되지 않았다. 폰코딩이 가능하고, 편리하다. 정도의 주요한 이유가 있었다.\n내가 알고리즘 문제를 풀 때 가장 유용하게 썼던 것은 tio.run 인데 이거에 기반으로 좀 프로젝트 영감을 얻었긴 하다. 사실상 python 최신버젼이 가능한 tio.run 물론 설계나 아키텍쳐, 프레임워크 공부 같은 이유도 있긴하다.\n주요 기능 정리 그래서 내 프로젝트에는 어떤 주요 기능이 있는지 고민해 보았다.\n코드와 입력이 주어질 때, 실행 버튼을 누르면 출력해주기 (가장 중요) TBD 해당 기능에 대해서 유스케이스를 생각해보았다.\n유저가 코드와 입력을 작성 후 실행 버튼을 누름 코드와 입력을 건네 받아 컨테이너 같은 공간에서 파이썬을 실행시키고 출력 받은 출력을 유저에게 보여주기 이를 다이어그램으로 그려보면\nsequenceDiagram autonumber actor A as USER participant B as API SERVER participant C as ISOLATED ENV A-\u0026gt;\u0026gt;B: 코드 실행 요청 B-\u0026gt;\u0026gt;C: 코드 전달 및 실행 명령 C-\u0026gt;\u0026gt;C: 코드 실행 중 C-\u0026gt;\u0026gt;B: 실행 결과 반환 B-\u0026gt;\u0026gt;A: 최종 결과 응답 가장 큰 문제점 보안적으로 문제가 없나? os.system(\u0026lsquo;rm -rf\u0026rsquo;)같은걸 어떻게 막을 건데? Sandbox 컨테이너 격리 자원 독점 관리: while True: pass 하면 CPU 다 먹는데? CPU 점유율 관리와 메모리 제한 설정 동시 접속자가 늘면 코드 실행 끝날때까지 블로킹 메세지 큐 기반으로 비동기 처리 개선 후 다이어그램 sequenceDiagram autonumber actor User as 사용자 participant API as API Server (CT 1) participant Redis as Redis (CT 2: MQ/DB) participant Worker as Worker (CT 3: 1..N) participant Sandbox Note over User, Redis: [1단계: 비동기 요청 접수] User-\u0026gt;\u0026gt;API: (1) 코드 실행 요청 API-\u0026gt;\u0026gt;Redis: (2) Job 생성 \u0026amp; 대기열 투입 API--\u0026gt;\u0026gt;User: (3) Job ID 반환 Note over Redis, Sandbox: [2단계: 백그라운드 격리 실행] Worker-\u0026gt;\u0026gt;Redis: (4) Job 낚아채기 Worker-\u0026gt;\u0026gt;Sandbox: (5) 컨테이너 생성 및 코드 주입 activate Sandbox Sandbox-\u0026gt;\u0026gt;Sandbox: (6) 파이썬 실행 Sandbox--\u0026gt;\u0026gt;Worker: (7) 실행 결과 반환 deactivate Sandbox Worker--\u0026gt;\u0026gt;Redis: (8) 최종 결과 및 상태저장 Note over User, Redis: [3단계: 결과 확인] User-\u0026gt;\u0026gt;API: (9) 결과 조회 API-\u0026gt;\u0026gt;Redis: (10) 결과 데이터 확인 Redis--\u0026gt;\u0026gt;API: 데이터 반환 API--\u0026gt;\u0026gt;User: (11) 최종 결과 반환 이런 식으로 전체적으로 처음에 생각했던 3단계를 세부적으로 쪼갰다.\n","permalink":"https://ljweel.github.io/posts/onpyrunner/onpyrunner01/","summary":"기존 온라인 실행기의 불편함에서 출발해 유스케이스를 정의하고, 보안·자원관리·동시성 문제를 고려한 초기 다이어그램을 설계했습니다.","title":"1. 프로젝트의 시작"},{"content":"새해를 맞아 나의 첫 면접 후기(?) 같은걸 적어보고자 한다.\n12월 중순쯤에 회사 하나에 서류를 넣었다.\n전체적인 진행과정은 서류 -\u0026gt; 온라인 코테 -\u0026gt; 오프라인 코테 / 면접 -\u0026gt; 최종 발표 순서고, 서류가 통과되어서 바로 온라인 코딩테스트 일정을 잡았다.\n2025.12.19 문제는 4문제에 70분이였고, 아슬아슬하게 4분인가 남기고 다 풀어서 다 맞았다. (4번 문제를 Trie로 풀었는데, 알고보니 완탐 느낌의 문제였던\u0026hellip;) 아무튼 다 풀어서 (점수도 알려줌 600/600) 맘 편히 오프라인 코테 / 면접 준비를 했다.\n2025.12.29 서울에 올라가서 pjshwa랑 martin0327도 만나서 오리주물럭을 먹고, 캡슐호텔에서 잤다.\n2025.12.30 오프라인코테는 30분에 1문제를 푸는 형식이였는데, 추가시간(?)을 줬다. (아마도 같이 온 사람이랑 나랑 제출 기록이 하나도 없어서 조금 더 시간을 준거 같았다.) 한 5분정도 뒤에 내가 문제를 맞췄다. 그리고 면접을 갔는데\u0026hellip;\n맨 처음 자기소개해보세요. 라는 말에 머리가 새하얘지면서 준비해왔던 자기소개 멘트를 하나도 말하지 못하는 사태가 발생했다. \u0026ldquo;안녕하세요 OOO입니다. 저는 어쩌구어쩌구 \u0026ldquo;라고 말해야하는데 \u0026ldquo;안녕하세요 OOO입니다.\u0026rdquo; 하고 뒤에 내용이 생각이 안 나서 체감상 30초에서 `1분정도 아무말도 못하고 \u0026ldquo;어\u0026hellip; 어\u0026hellip;\u0026rdquo; 거렸다. 첫 면접이라 그런지 극도로 긴장하게 되어서 절었지만, 면접관님께서 긴장하지말고 괜찮다고 다른 분께 질문을 했고, 그 동안 최대한 긴장을 풀었다. 그래서 그 후에는 어느정도 질문들( 알고리즘에 관심이 있냐, 알고리즘 소모임 회장을 했는데 어떤 역할이였냐, 가장 힘들었고 노력을 많이 했던 프로젝트는 어떤게 있었냐 -\u0026gt; (꼬리질문) 그렇다면 힘들었던거는 어떻게 해결했냐, 1년간 휴학을 했는데 왜 휴학했냐, 입사하게 된다면 어떤 포지션을 가고 싶냐, 입사하게 된다면 언제부터 일할 수 있냐 정도의 질문을 받았던거같고, 처음처럼 막 아무말도 못하진 않았고, 내 경험을 기반으로 말했다. 그리고 면접은 끝났고 일주일 내로 합격 여부를 알려준다고 하고 면접비를 받았다!\n전체적으로 어떤 생각이 들었냐면, 자기소개때 최악의 대처를 한 것 치고는 긍정적인 질문?(입사하게 된다면 어쩌구)을 받았던거랑 오프라인코테에서 문제를 해결했던 것(나만 해결했음)을 고려하면 반반정도 가능성이 있지 않나 싶은 기대감이 있다. ㅎㅎ\n첫 면접에 느낀 바로는 결국엔 면접준비를 아무리 많이해도 어쩔 수 없는 준비 못한 부분이 생기고 그걸 어떻게 잘 대처하는지가 좀 중요했던 것같다.\n끝나고 st42597랑 라멘 맛집에 갔다. 라멘을 많이 먹어보진 않았는데 엄청 맛있는 라멘을 먹어 본적이 없어서 가자고 했는데 엄청 맛있었다.\n그리고 집에 가야하는데 버스 예매를 반대로해서 어쩔 수 없이 피씨방에서 노숙하는 사태가 발생했다\u0026hellip;\n2025.12.31 피씨방에서 노숙하다가 눈이 너무 아파서 그냥 컴퓨터 켜놓고 자다가 7시 50분차타고 집으로 도착했다.\n추가)\n2026.01.08 면접에 최종적으로 떨어졌다. 첫 면접이라 많이 긴장을 해서 말을 많이 절어서 어느정도 기대는 했지만 아쉽게 떨어졌다. 하지만 얻어간게 많은 면접이였다고 생각한다.\n","permalink":"https://ljweel.github.io/posts/interview-review01/","summary":"첫 인터뷰에 대한 회고입니다.","title":"첫 인터뷰 회고"}]