[컴구웅 스터디 5회차] 입출력장치, 운영체제란

heiler·2025년 5월 25일
0

💬 Intro

안녕하세요! 헤일러입니다.

이전에는 ~다체를 사용했었는데 ~입니다체가 손에 더 잘 달라붙고 정감가는 것 같아서 문체를 조금 바꿔보았습니다.

혹시 4회차는 어디갔지? 궁금하신 분도 계실 것 같습니다. 지난 4회차 스터디에서는 지금까지 학습했던 내용을 정리하고, 스터디원들 각자가 더 톺아보고 싶었던 주제를 준비해오고 공유하는 시간을 가졌었습니다.

저는 컴구 & 운체 지식을 Java 생태계와 연결지어 이해하는데 관심이 많기 때문에 JVM 관련 학습을 진행했었습니다. Java 스택과 힙, JVM Runtime Data Area 각 영역별 역할, 그리고 JVM vs JRE vs JDK 차이점에 대해 간략하게 학습했었는데요, 학습한 내용의 깊이가 얕고 아직 명확하게 정리되지 않은 내용들이 많아서 나중에 정리가 되면 따로 포스팅 해보려고 합니다.


❓데이터 버퍼링에 대해 설명해 주세요.

CPU와 장치 컨트롤러(Device Controller) 사이에서 사용되는 데이터 버퍼링에 대해 설명하겠습니다.

일반적으로 CPU와 메모리의 데이터 전송률(transfer rate)은 높지만 입출력장치의 데이터 전송률은 낮습니다. 그래서 CPU와 입출력장치 사이의 속도 차이를 조율하고 데이터 손실을 방지하기 위해 데이터 버퍼링 기술을 사용합니다.


❓ 인터럽트와 폴링이 각각 무엇이고 둘의 차이는 무엇인가요?

인터럽트, 폴링은 CPU가 입출력장치와 통신하는 방식입니다.

인터럽트는 장치가 사용할 준비가 되었을 때 자체적으로 CPU에게 알림(인터럽트)을 보내는 방식입니다. CPU는 장치의 상태를 계속 확인하지 않고, 인터럽트가 왔을 때만 반응합니다. 자세한 하드웨어 인터럽트 처리 순서는 2회차 하드웨어 인터럽트 처리 순서 파트를 참고해주세요.

폴링은 CPU가 주기적으로 장치의 상태를 직접 검사해서 장치가 준비가 되었는지 확인하는 방식입니다.

그 주기가 매우 짧아 CPU가 쉬지 않고 무한 루프를 돌며 기다리는 폴링 방식을 busy wait이라고 부릅니다.

busy wait 방식의 경우 구현이 단순하고, 실시간성이 보장됩니다. 대신 CPU 자원 낭비가 심합니다. 입출력 장치의 처리 속도가 느릴수록 CPU가 낭비하는 시간이 길어진다는 단점이 있습니다.

busy wait이 아닌 non-busy 폴링 방식도 존재하는데 인터럽트보다 느리고 비효율적이기 때문에 거의 사용되는 일이 없을 것 같습니다.

while (!(status_reg & READY)); // busy wait
data_reg = data;
while (!(status_reg & READY)) { sleep(10); } // non-busy polling
data_reg = data;

❓ 장치 컨트롤러가 CPU와 정보를 주고 받는 3가지 방법은 무엇인가요?

✅ 1. 프로그램 입출력 방법

장치 컨트롤러의 상태 레지스터를 폴링하면서 장치가 사용할 준비가 되었는지 체크하는 방법입니다.

▶️ 실행 흐름

  1. CPU가 특정 장치와 데이터를 주고받기 위한 프로그램을 실행합니다.
  2. 장치 컨트롤러의 상태 레지스터를 폴링 방식으로 확인하며, 장치가 준비되었는지 확인합니다.
  3. 장치가 준비되면, CPU는 장치 컨트롤러의 데이터 레지스터에 데이터를 기록하거나 읽어옵니다.
  4. 데이터 전송이 끝날 때까지 CPU는 이 과정에 계속 개입하며, 다른 작업을 수행하지 못합니다.

✅ 2. 인터럽트 기반 입출력 방법

장치가 준비되었을 때 CPU에게 인터럽트를 주는 방법입니다. CPU는 인터럽트를 받기 전까지 다른 작업을 수행할 수 있습니다.

▶️ 실행 흐름

  1. 장치 컨트롤러는 입력 또는 출력이 준비되었을 때 인터럽트를 발생시켜 CPU에 알립니다.
  2. CPU는 수행 중이던 작업을 잠시 멈추고, 인터럽트 서비스 루틴(ISR)을 실행합니다.
  3. ISR 안에서 CPU는 장치 컨트롤러부터 데이터를 읽거나 쓰는 작업을 수행합니다.
  4. ISR이 끝나면 CPU는 원래 하던 작업으로 돌아갑니다.

✅ 3. DMA 입출력 방법

CPU를 거치지 않고 DMA를 통해 메모리와 입출력장치 간 데이터를 주고받는 입출력 방법입니다.

DMA 컨트롤러는 한 번에 큰 블록의 데이터를 전송할 수 있어 영상 스트리밍, 파일 복사 등 대용량 데이터 전송이 필요한 경우 유용한 방법입니다.

▶️ 실행 흐름

  1. CPU가 DMA 컨트롤러에게 입출력 작업을 요청에 필요한 정보(입출력장치의 경로, 수행할 연산(읽기/쓰기), 읽거나 쓸 메모리의 주소 등)를 주며 입출력 작업을 명령합니다. 그 다음부터는 CPU는 작업에서 완전히 손을 뗍니다.
  2. DMA 컨트롤러는 장치 컨트롤러와 메모리 사이에서 데이터를 직접 주고 받습니다.
  3. 입출력 작업이 모두 끝나면, DMA 컨트롤러는 CPU에게 인터럽트를 발생시켜 작업이 끝났음을 알립니다.

❓ 운영체제란?

컴퓨터 시스템(Computer System)은 하드웨어(ex. 컴퓨터 부품들), 운영체제, 응용 프로그램, 사용자로 구분할 수 있는데요, 사용자가 응용 프로그램을 이용하여 보다 편리하게 컴퓨터를 사용할 수 있도록 도와주는 역할을 하는 것이 운영체제입니다.

메모장, 크롬 브라우저, 카카오톡, 마인크래프트 등 모든 응용 프로그램은 실행되기 위해 반드시 자원(Resource)이 필요합니다. 여기서 말하는 자원은 CPU, 메모리, 보조기억장치, 입출력장치 등 컴퓨터 하드웨어 자원들을 말합니다.

운영체제는 이러한 하드웨어 자원을 추상화하여, 사용자가 복잡한 하드웨어 조작을 알 필요 없이 응용 프로그램을 사용할 수 있도록 도와줍니다.

운영체제는 응용 프로그램 실행에 필요한 자원을 적절히 할당하고 원활하게 동작하도록 관리하는 역할과, CLI나 GUI를 통해 사용자가 편리하게 응용 프로그램을 사용할 수 있는 인터페이스를 제공해주는 역할을 합니다.

운영체제의 기능 중 시스템 콜 처리, CPU 스케줄링, 메모리 관리, 파일 시스템, 장치 제어 등 하드웨어 자원을 관리하는 핵심 소프트웨어를 커널이라고 합니다.

전공자 간의 대화에서는 운영체제와 커널을 구분하지 않고 동일한 의미로 사용하는 경우가 많습니다. 좁은 의미의 운영체제 = 커널이라고 생각하면 편합니다.

그리고 사용자가 하드웨어 자원을 제어하기 위해서는 일반적으로 다음과 같은 계층 구조를 거칩니다.

계층설명
응용 프로그램사용자 목적에 따라 실행되는 소프트웨어. 시스템 라이브러리 혹은 쉘을 통해 커널 기능에 접근. (ex. 크롬, 카카오톡, 게임)
사용자가 입력한 명령어를 해석하고, 커널에 요청을 전달하는 명령어 기반 인터페이스. 주로 CLI 환경에서 사용. (ex. powershell, bash, zsh)
시스템 라이브러리응용 프로그램이 커널의 기능을 편리하게 사용할 수 있도록 만들어진 시스템 콜 인터페이스 (ex. glibc)
커널하드웨어 자원을 직접 제어하고 보호하는 핵심 소프트웨어
하드웨어CPU, 메모리, 저장장치, 입출력장치 등 물리적인 하드웨어 자원

📌 glibc
glibc(GNU C Library) 는 Linux에서 사용하는 표준 C 라이브러리의 구현체로, 사용자 애플리케이션이 커널과 쉽게 상호작용 할 수 있도록 시스템 콜 인터페이스를 제공합니다.

사용자 애플리케이션
↓
glibc의 시스템 콜 인터페이스 함수 호출 (ex. write())
↓
glibc 내부에서 시스템 콜 번호 설정, 레지스터 세팅, `syscall` 명령어 실행
↓
커널 모드 진입 후, 시스템 콜 테이블(`sys_call_table[write() 시스템 콜의 번호]`)을 참조해서 시스템 콜 핸들러 수행 (ex. sys_write())
↓
결과 반환

📌 TMI
glibc는 메모리에서 Heap보다는 높고, Stack보다는 낮은 mmap 영역에 동적으로 로드됩니다.

💡 Deep Dive

▶️ Device Driver

응용 프로그램이 입출력장치를 직접 제어하는 것은 매우 어렵습니다. 입출력장치마다 제어 방식이 다르고, 이 제어 방식에 대한 설명은 일반적으로 벤더 업체의 전용 가이드 문서에만 존재하기 때문입니다. 구매자가 하드웨어 전문가가 아니라면 가이드 문서만 보고 그에 맞는 Device Driver를 직접 제작하는 것은 거의 불가능에 가깝습니다.

CPU는 입출력장치를 원하는대로 제어하기 위해 어떤 포트를 사용하고 어떤 위치에 데이터 값을 읽고 써야 할지 Device Driver 없이는 알 수 없습니다. 따라서 벤더 업체에서 제공해주는 Device Driver를 통해 장치의 제어 방식을 커널에 알려주고(커널 모듈 추가), 응용 프로그램이 장치를 제어할 수 있도록 도와주는 것이 필수적입니다.

▶️ What is Device Driver?

Device Driver는 입출력장치와 상호작용(대부분 읽기, 쓰기 작업)을 하기 위한 커널 모듈입니다. 커널 모듈(kernel module)은 커널 기능을 동적으로 확장할 수 있는 독립적인 코드 단위를 의미합니다.

Device Driver를 커널에 로드해 놓으면, 응용 프로그램에서는 입출력장치를 마치 파일을 읽고 쓰듯이 제어할 수 있습니다.

각 운영체제마다 커널 모듈을 작성하기 위한 라이브러리를 제공해주는데, 해당 라이브러리를 이용해서 Device Driver를 사용자가 직접 작성할 수 있습니다.

OS확장자작성 언어커널 모듈 지원 라이브러리
Linux.ko (커널 모듈)C#include <linux/xxx>
Windows.sysC, C++WDM, KMDF, UMDF
macOS.kextC, C++, Objective-CI/O Kit (Mach 기반)

▶️ copy_to_user, copy_from_user

Linux 커널 모듈은 유저 레벨 애플리케이션과 데이터 값을 주고 받을 때 copy_to_user(), copy_from_user()를 사용합니다.

커널 모듈에서 유저 레벨 애플리케이션으로 데이터를 전달할 때는 copy_to_user()를, 유저 레벨 애플리케이션에서 보낸 데이터를 받을 때는 copy_from_user()를 사용합니다.

유저 레벨 애플리케이션에서 입출력장치로부터 데이터를 읽고 쓸 때는 파일 입출력 관련 시스템 콜 함수(open, read, write, ioctl, ...)를 사용합니다.

제가 대학생 때 Raspberry Pi OS 환경에서 초음파 센서와 서보 모터를 이용해 Device Driver를 작성했던 코드의 일부를 다듬어서 가져와보았습니다. Raspberry Pi OS는 Debian 기반으로 만들어진 운영체제기 때문에, Linux 환경과 거의 동일하다고 생각하면 됩니다.

📌 copy_to_user() 사용 예제

// uds_drv.c: 초음파 센서(HC-SR04) Device Driver 파일 일부
ssize_t uds_read (struct file *pfile, char __user *buffer, size_t length, loff_t *offset) {
    long long duration;

    printk("read uds char drv\n");
    if (length != sizeof(duration)) {
       printk(KERN_DEBUG "uds_read: Invalid length size\n");
       return -EINVAL;    // invalid argument
    }

    if (mutex_lock_interruptible(&mutex))  // try to get mutex. if process fails to get the mutex, it is switched to interruptible sleep state.
       return -ERESTARTSYS;   // used for blocking IO.

    /* critical section starts */
    switch (((int) pfile->private_data)) { // minor number of the device driver
       case 0:    // minor number of uds
       {
          is_echo_clear = 0;
          gpio_set_value(UDS_TRIGGER_BCM_NUM, HIGH);
          udelay(10);    // TRIGGER pin have to be HIGH for at least 10 us according to datasheet
          gpio_set_value(UDS_TRIGGER_BCM_NUM, LOW);

          // wait_event_timeout: sleep in waiting queue until a condition gets true or a timeout elapses
          // when the ECHO pin receives ultrasonic or 1/10s(100000us) elapse, it wakes up.          
          // UDS can detect up to 1700cm (10000us * 0.034cm/us / 2)          
          // constant HZ: number of timer ticks per second.          
          wait_event_timeout(wait_queue, is_echo_clear, HZ / 10.0);

          if (!is_echo_clear) {  // wait_event_timeout is not called after transmitting wave(TRIGGER)
             printk(KERN_ERR "uds_read: timeout elapses\n");
             mutex_unlock(&mutex);
             return -EIO;   // input/output error
          }
          duration = ktime_to_us(ktime_sub(echo_end, echo_start));   // unit: us
       }
       break;

       default:
          printk("uds_read: Invalid device minor number %d\n", (int) pfile->private_data);
          mutex_unlock(&mutex);
          return -EINVAL; // invalid argument
    }

    printk(KERN_INFO "duration: %lld\n", duration);
    if(copy_to_user(buffer, &duration, sizeof(duration))) {
       printk(KERN_ERR "uds_read: copy_to_user failed\n");
       mutex_unlock(&mutex);
       return -EACCES; // permission denied
    }
    mutex_unlock(&mutex);
    /* critical section ends */

    return sizeof(duration);
}

...
struct file_operations fop = {
    .owner = THIS_MODULE,
    .open = uds_open,
    .release = uds_release,
    .read = uds_read,
};

유저 레벨 애플리케이션에서 fcntl.hread()를 통해 uds_read()를 호출할 수 있습니다.

// my_uds.h: 초음파 센서를 제어하기 위한 유저 레벨 애플리케이션
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <fcntl.h>

#define UDS_PATH "/dev/uds_driver"
const double speed_of_sound = 0.034;    // unit: cm/us
int uds_fd;

double uds_get_distance() {
    uds_fd = open(UDS_PATH, O_RDONLY | O_RSYNC);
    if(uds_fd == -1){
       perror("uds device file open error");
       return -1;
    }

    long long duration = -1;
    double distance = 0;
    if(read(uds_fd, &duration, sizeof(duration)) < 0) {
       perror("read error");
       return -1;
    }

    sleep(1);
    /* round trip distance: dutaration(us) * speed of sound(cm/us)
     * speed of sound: 340m/s = 0.034cm/us
     */
    distance = (duration*speed_of_sound) / 2;
    close(uds_fd);

    printf("distance: %lfcm\n", distance);
    return distance;
}

📌 copy_from_user 예제

// servo_drv.c: 서보 모터(SG-5010) Device Driver 일부
#define SERVO_PULSE_CYCLE 2   // Pulse cycle is 20ms (2 ticks). jiffies clock ensures 1 tick is 10ms.
#define SERVO_IOCTL_WRITE _IOW(0, 1, unsigned char) // write data to device driver

extern unsigned long volatile __cacheline_aligned_in_smp __jiffy_arch_data jiffies; // 1 jiffy: interval between timer interrupts

/* pulse time for servo motor SG-5010
 * pulse width: 600~2400us
 * 0(close): 600us -> 0 degree, 1(open): 1500us -> move 90 degree */
int PULSE_TIME[2] = {600, 1500};

...
long servo_ioctl(struct file *pfile, unsigned int cmd, unsigned long arg) {
    switch(cmd) {
        case SERVO_IOCTL_WRITE:
        {
            int client_msg = -1;
            // copy value of arg(value from user application) to client_msg
            if(copy_from_user(&client_msg, arg, sizeof(client_msg)) {
                printk(KERN_ERR "servo_ioctl: copy_from_user failed\n");
                return -1;
            }
            printk("client_msg: %d\n", client_msg);

            // 0: close, 1: open
            if(0 <= client_msg && client_msg <= 1) {
                msg = client_msg;
                mod_timer(&servo_timer, jiffies + SERVO_PULSE_CYCLE);
            }
            else
                printk("invalid client_msg\n");
        }
        break;
        default:
            printk("invalid command...\n");
            break;
    }

    return 0;
}

...
struct file_operations fop = {
    .owner = THIS_MODULE,
    .open = servo_open,
    .release = servo_release,
    .unlocked_ioctl = servo_ioctl,
};

유저 레벨 애플리케이션에서 sys/ioctl.hioctl()를 통해 servo_ioctl()을 호출할 수 있습니다.

// my_servo.h: 서보 모터를 제어하기 위한 유저 레벨 애플리케이션
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/ioctl.h>

#define SERVO_PATH "/dev/servo_drv"

#define SERVO_IOCTL_MAGIC 'M'
#define SERVO_IOCTL_WRITE _IOW(0, 1, unsigned char) // write data to device driver

#define DOOR_CLOSE  0
#define DOOR_OPEN   1

int servo_fd, ret;

/* door_lock()
 * move servo motor by cmd
 * 0: stop, 1: move +90 degree
 */
void door_lock(int cmd) {
    servo_fd = open(SERVO_PATH, O_RDWR);
    if(servo_fd==-1) {
        perror("servo_fd open failed\n");
        exit(-1);
    }
    ret = ioctl(servo_fd, SERVO_IOCTL_WRITE, &cmd); // servo_ioctl() is called.
    if(ret == -1) {
        printf("ioctl failed\n");
    }
    close(servo_fd);
}

❓ 커널 모드와 사용자 모드가 어떻게 다른가요?

운영체제는 응용 프로그램들이 자원에 접근하려고 할 때 오직 자신을 통해서만 접근하도록 하여 자원을 보호합니다. 이러한 문지기 역할을 이중 모드(dual mode)를 통해 구현하고 있습니다.

이중 모드란 CPU가 명령어(instruction)를 실행하는 모드를 사용자 모드(user mode)와 커널 모드(kernel mode)로 구분하는 방식입니다. 사용자 모드는 응용 프로그램이 실행하는 권한이고, 커널 모드는 커널 프로그램이 실행하는 권한입니다.

일반적인 응용 프로그램은 기본으로 사용자 모드로 실행됩니다. 사용자 모드로는 커널 영역의 코드를 실행할 수 없습니다. 사용자 모드에서는 CPU 명령어의 사용과 하드웨어 자원에 접근 제한(접근 가능한 메모리 영역이 제한됨)이 있습니다. 커널 권한이 필요한 자원에 접근하려면 시스템 콜을 통해 커널에게 원하는 작업을 요청해야 합니다.

커널 모드는 커널 영역의 코드를 실행할 수 있는 권한을 가진 실행 모드입니다. 커널 모드로 실행하면 모든 CPU 명령어 집합을 사용할 수 있고, 모든 자원에 접근이 가능합니다. 사용자 프로그램의 요청(시스템 콜)을 받아 커널 프로그램이 커널 모드로 요청받은 작업을 수행합니다.

현재 CPU가 실행 중인 명령어가 어떤 권한(Ring) 수준을 갖고 있는지는 (x86 기준) CS(Code Segment Selector)의 하위 2비트인 CPL(Current Privilege Level)를 통해 알 수 있습니다. (컴구웅 서적에서는 슈퍼바이저 플래그라고 부릅니다.)

✅ 왜 커널 모드와 사용자 모드를 나눌까?

  • 자원 보호: 사용자 프로그램이 실수로 다른 프로그램의 자원(CPU, 메모리, 하드디스크)을 건드리지 않도록 보호할 수 있습니다.
  • 시스템 안정성: 오류가 시스템 전체로 퍼지는 것을 방지할 수 있습니다.
  • 자원을 중앙 집중적으로 제어: 파일 입출력, 프로세스 생성 등의 모든 시스템 콜을 커널을 통해서만 수행되도록 제한함으로써 커널이 자원 할당, 스케줄링 등의 자원 관리를 일관되고 효율적으로 할 수 있게 해줍니다.

❓ 시스템 콜이 무엇인가요?

시스템 콜(System Call)이란 사용자 프로그램이 커널 기능의 사용을 요청할 수 있도록 도와주는 인터페이스입니다. 사용자 프로그램이 사용자 모드에서 실행되다가 시스템 콜 함수를 호출하면, 커널 모드로 전환되어 커널이 해당 요청을 실행합니다.

📌 TMI
x86 아키텍처에서 과거에는 int 0x80명령어로 시스템 콜을 소프트웨어 인터럽트로 처리했지만, 현재는 syscall이라는 시스템 콜을 빠르게 처리하기 위한 전용 명령어를 사용해 복잡한 ISR을 거치지 않고 시스템 콜을 처리하는 방식을 사용하고 있습니다.

시스템 콜은 라이브러리 호출(Library Call)과 비교되기도 합니다. 라이브러리 호출은 사용자 공간에서 실행되는 일반 라이브러리 함수 호출을 의미합니다. 라이브러리 함수의 호출이라 해서 항상 순수하게 사용자 모드로만 실행되는 것은 아닙니다.

strlen(), atoi()와 같은 라이브러리 함수는 사용자 모드 권한 내에서 접근 가능한 자원들(CPU 범용 레지스터, 해당 프로그램이 사용 중인 스택 프레임)을 이용하여 순수하게 사용자 모드로 실행될 수 있습니다.

하지만 printf(), malloc()은 I/O 작업을 수행하기 위해 커널의 도움을 받아야 합니다. 따라서 내부적으로 시스템 콜 함수를 호출합니다.


Reference

  • 혼자 공부하는 컴퓨터 구조+운영체제, 강민철
profile
Smiley

0개의 댓글