기록공간

4-1장. 영속성 - I/O 장치 본문

OS

4-1장. 영속성 - I/O 장치

입코딩 2020. 5. 20. 18:43
반응형

먼저 입력/출력 장치의 개념을 소개하고 운영체제가 이 장치들과 상호 작용하는 방법을 알아보도록 하겠다. 당연한 얘기지만, I/O는 컴퓨터 시스템에서 상당히 중요한 부분이다. 컴퓨터 시스템을 유용하게 쓰려면 입력과 출력이 모두 필요할 것이기 때문이다. 그러면 운영체제에서는 I/O를 어떻게 시스템에 통합하는 것일까?

 

시스템 구조

위는 일반적인 시스템 구조를 그림으로 표현한 것이다. 이 그림에서는 CPU와 주 메모리가 메모리 버스로 연결되어 있다. 몇 가지 장치들이 범용 I/O 버스에 연결이 되어 있는데, 많은 현대의 시스템에서는 PCI(또는 많은 파생 버스들)를 사용하고 있다. 그래픽이나 다른 고성능 I/O 장치들이 여기에 연결될 수 있다. 마지막으로, 그 아래에는 SCSISATA 또는 USB와 같은 주변 장치용 버스가 있다. 이 버스들을 통해 디스크, 마우스와 같은 가장 느린 장치들이 연결된다.

 

이런 계층적인 구조가 필요한 이유는 물리적인 비용 떄문이다. 버스가 고속화되려면 짧아야 하는데, 이러한 고속버스는 여러 장치들을 수용할 공간이 없다. 그리고 고속버스를 만드는 기술을 매우 비싸다. 그렇기 때문에 시스템 설계자들은 계층적 구조를 택하여 고성능 장치들(GPU)을 CPU에 가깝게 배치하였고 느린 성능의 장치들은 그보다 멀리 배치하였다.

 

표준 장치

이번에는 가상 표준 장치를 살펴보고 이 장치를 효율적으로 활용하기 위해 필요한 것은 무엇인지를 알아보자.

그림에서 나타내는 것과 같이 두 개의 중요한 요소가 있다. 첫 번째는 시스템의 다른 구성 요소에게 제공하는 하드웨어 인터페이스이다. 소프트웨어가 인터페이스를 제공하듯 하드웨어도 인터페이스를 제공하여 시스템 소프트웨어가 동작을 제어할 수 있도록 해야 한다. 그렇기 때문에 모든 하드웨어 장치들은 특정한 상호 동작을 위한 방식과 명시적인 인터페이스를 가지고 있다.

 

장치가 갖고 있어야 하는 두 번째 요소는 내부 구조이다. 시스템에서 제공하는 장치에 대한 추상화를 정의하는 책임을 가지고 있다. 매우 단순한 장치들은 하나 혹은 몇 개의 하드웨어 칩을 사용하여 기능을 구현할 것이고, 좀 더 복잡한 장치는 단순한 CPU와 범용 메모리 그리고 장치에 특화된 칩들을 사용하여 목적에 맞는 동작을 한다. 최신 RAID 컨트롤러는 수십만 줄에 달하는 펌웨어(firmware)라는 소프트웨어가 하드웨어 내부의 동작을 정의하고 있다.

 

표준 방식

위 그림의 장치의 인터페이스는 세 개의 레지스터로 구성되어 있는 것을 보았다. 상태(status) 레지스터는 장치의 현재 상태를 읽을 수 있으며, 명령어(command) 레지스터는 장치가 특정 동작을 수행하도록 요청할 때, 그리고 데이터(data) 레지스터는 장치에 데이터를 보내거나 받거나 할 때 사용한다. 이 레지스터들을 읽거나 쓰는 것을 통해 운영체제는 장치의 동작을 제어할 수 있다. 

 

이번에는 장치가 운영체제를 대신해 특정 동작을 할 때 운영체제와 장치 간에 일어날 수 있는 상호 동작의 과정을 살펴보자. 이 경우 다음과 같은 방식을 따른다.

 

방식은 네 단계로 이루어져 있다. 먼저 첫 번째는 반복적으로 장치의 상태 레지스터를 읽어 명령의 수신 가능 여부를 확인한다. 이 동작 장치에 대해 폴링(polling) 한다고 표현한다. 두 번째는 운영체제가 데이터 레지스터에 어떤 데이터를 전달한다. 예를 들어 장치가 디스크였다면 디스크 블럭에 해당하는 데이터를 전달할 것이고 여러 번의 쓰기를 수행할 것이다. 데이터 전송에 메인 CPU가 관여하는 경우를 programmed I/O라고 부른다. 세 번째로 운영체제가 명령 레지스터에 명령어를 기록한다. 이 레지스터에 명령어가 기록되면 데이터는 이미 준비되었다고 판단하고 명령어를 처리한다. 마지막으로 네 번째는 운영체제는 디바이스가 처리를 완료했는지를 확인하는 폴링 반복문을 돌면서 기다린다. 성공이나 실패를 했다면 에러 코드를 받게 될 것이다.

 

이러한 기본 방식은 간단하고 제대로 작동하지만, 매우 비효율적이다. 이 방식이 갖고 있는 첫 번째 문제는 매우 비효율적인 폴링을 사용하고 있다는 점이다. 다른 프로세스에게 CPU를 양도하지 않고, 장치가 동작이 끝날 때까지 계속 루프를 돈다. 입출력 장치는 무척 느리고 CPU는 대기하는 동안 특별히 다른 일을 하지 않으므로 CPU 시간을 많이 소모하게 된다.

 

인터럽트를 이용한 CPU 오버헤드 계산

수많은 엔지니어들이 장치와의 상호작용을 개선하기 위해 인터럽트라는 것을 개발하였다. 인터럽트는 이미 앞에서 다룬바가 있다. 디바이스를 폴링하는 대신 운영체제는 입출력 작업을 요청한 프로세스를 블록 시키고 CPU를 다른 프로세스에게 양도한다. 장치가 작업을 끝마치고 나면 하드웨어 인터럽트를 발생시키고 CPU는 운영체제가 미리 정의해놓은 인터럽트 서비스 루틴(Interrupt Service Routine(ISR)) 또는 인터럽트 핸들러(Interrupt handler)를 실행한다. 이 핸들러 또한 운영체제 코드의 일부이다. 인터럽트 핸들러는 입출력 요청의 완료, I/O를 대기 중인 프로세스 깨우기 등을 담당한다. 즉, 깨어난 프로세스가 계속 작업을 할 수 있도록 해준다.

 

사용률을 높이기 위한 핵심 방법 중 하나는 인터럽트를 활용해 CPU 연산 I/O중첩시키는 것이다. 다음의 시간 흐름표가 이 방법을 나타낸다. 

 

CPU에서 프로세스 1이 일정 시간 동안 실행되다가 디스크의 데이터를 읽기 위해 I/O 요청을 발생시킨다. 인터럽트가 없다면 시스템은 I/O가 완료될때까지 반복적으로 장치의 상태를 폴링한다. 디스크가 요청의 처리를 완료하면 다시 프로세스 1이 동작할 수 있다.

 

그와 다르게 인터럽트를 사용하여 연산과 I/O 작업을 중첩시킬 수 있다면 운영체제는 디스크의 응답을 기다리면서 다른 일을 할 수 있다. 

 

프로세스 1이 I/O 요청을 발생하여 디스크에서 처리하는 동안 운영체제는 프로세스 2를 CPU에서 실행시킨다. 디스크 요청이 완료되어 인터럽트가 발생하면 운영체제가 프로세스 1을 깨워 다시 실행시킨다. 이는 CPU의 처리 공백 기간을 적절히 활용하였다.

 

하지만 인터럽트는 항상 최적의 방법은 아니다. 예를 들어 대부분의 작업이 한 번의 폴링만으로 끝날 정도로 매우 빠른 장치라고 해보자, 이 경우에 인터럽트를 사용하면 오히려 시스템이 느려지게 된다. 다른 프로세스 문맥을 교환하고 인터럽트를 처리한 후 다시 I/O를 요청한 프로세스로 문맥 교환하는 작업은 매우 비싼 작업이다.

 

만약 장치의 속도를 모르거나 빠를 때도, 느릴 때도 있다면, 짧은 시간 동안은 폴링을 하다가 처리가 완료되지 않으면 인터럽트를 사용하는 하이브리드 방식을 채용하는 것이 최선일 것이다. 이와 같은 두 단계 접근법으로 양쪽의 장점만 취할 수 있다.

 

인터럽트를 사용하지 않는 이유는 네트워크에서도 찾아볼 수 있다. 대량으로 도착하는 패킷이 있을 때 각 패킷마다 인터럽트가 발생된다. 이 경우 인터럽트만 처리하다 운영체제가 사용자 프로세스의 요청을 처리할 수 없도록 만드는 무한반복(livelock)에 빠질 가능성이 있다. 예를들어 일시적으로 사용자가 웹 서버에 몰리는 경우, 폴링을 사용하면 시스템의 상황을 보다 효율적으로 제어할 수 있다. 웹 서버가 패킷 도착을 검사하기 전에 사용 요청들을 좀 더 처리할 수 있기 때문이다.

 

DMA를 이용한 효율적인 데이터 이동

앞서 설명한 표준 방식에서 고려해야 하는 부분이 하나 더 있다. 많은 양의 데이터를 디스크로 전달하기 위해 PIO(programmed I/O)를 사용하면 CPU가 또다시 단순 작업 처리에 소모된다. 다른 프로세스를 처리하기 위해 사용될 수 있는 많은 시간을 허비하게 된다. 다음은 이 문제를 시간 흐름표로 나타낸 것이다.

 

 이 문제에 대한 해법은 직접 메모리 접근 방식(Direct Memory Access, DMA)이라고 부른다. DMA 엔진은 시스템 내에 있는 특수 장치로서 CPU의 간섭없이 메모리와 장치 간에 전송을 담당한다. 

 

DMA는 다음과 같이 동작한다.

 

데이터를 장치로 전송한다고 했을 때 운영체제는 DMA 엔진에 메모리 상의 데이터 위치와 전송할 데이터의 크기와 대상 장치를 프로그램한다. 그 시점에 전송하기 위해 할 일은 끝나기 때문에 운영체제는 다른 일을 진행할 수 있다. DMA 동작이 끝나면 DMA 컨트롤러가 인터럽트를 발생시켜 운영체제가 전송이 완료되었음을 알 수 있도록 한다. 

 

디바이스와 상호작용하는 방법

이제까지 장치와 통신하는 두 가지 기본적인 방법이 개발되었다. 첫 번째는 가장 오래된 방법으로 I/O 명령을 명시적으로 사용하는 것이다. 이 명령어들은 운영체제가 특정 장치 레지스터에 데이터를 전송할 수 있는 방법을 제공하며, 앞서 언급한 방식을 구현할 수 있게 한다. 

 

예를 들어 x86의 경우 in과 out 명령어를 사용하여 장치들과 통신할 수 있다. 데이터를 장치에 보내야 하는경우 호출자가 데이터가 저장된 레지스터를 지정하고 장치를 지칭하는 포트를 지정한다. 명령어를 실행하면 원하는 동작이 실행된다. 이 명령어들은 대부분 특권(privileged) 명령어 들이다. 그렇기 때문에 운영체제만이 장치들과 직접 통신할 수 있다. 만약 어떤 프로그램이 디스크를 읽고 쓸 수 있다고 가정해보자. 이러한 허점을 이용하여 사용자 프로그램이 기계의 제어권을 갖게 된다면 혼란이 발생하게 될 것이다.  

 

두 번째 방법은 memory mapped I/O를 사용하는 것이다. 이 접근법에서 하드웨어는 장치의 레지스터들이 마치 메모리 상에 존재하는 것처럼 만든다. 특정 레지스터를 접근하기 위해 운영체제는 해당 주소에 load(읽기) 또는 store(쓰기)를 하면 된다. 하드웨어는 load/store 명령어가 주 메모리를 향하는 대신에 장치로 연결되도록 한다. 이 두 가지 방법들은 현재도 계속 쓰이고 있다.

 

운영체제에 연결하기 : IDE 디바이스 드라이버

최종적으로 다룰 문제는 서로 다른 인터페이스를 갖는 장치들과 운영체제를 연결시키는 가능한 일반적인 방법을 찾는 것이다. 장치 중립적인 운영체제를 만들 수 있는 방법은 무엇일까? 

 

이는 추상화라는 고전적인 방법을 사용하면 이 문제를 해결할 수 있다. 운영체제 최하위 계층의 일부 소프트웨어는 장치의 동작 방식을 반드시 알고 있어야 한다. 이 소프트웨어를 우리는 디바이스 드라이버(device driver)라고 부르며 장치와의 상세한 상호작용은 캡슐화되어 있다. 

 

이해를 위해 IDE 디바이스 드라이버를 살펴보자. IDE 디바이스는 다음과 같은 4개의 레지스터로 이루어진 단순한 인터페이스를 제공한다.

 

이 레지스터들은 in과 out I/O 명령어를 사용하여 특정 "I/O 주소들(0x3F6과 같은)"을 읽거나 씀으로써 접근이 가능하다.

 

장치와 상호작용하기 위한 기본 방식은 다음과 같으며 장치는 이미 초기화되었다고 가정한다.

 

  • 장치가 준비될 때까지 대기. 드라이브 사용 중이지 않고 READY 상태가 될 때까지 Status 레지스터(0x1F7)를 읽는다.

  • Command 레지스터에 인자 값 쓰기. 섹터의 수와 접근해야 할 섹터들의 논리 블록 주소(LBA), 그리고 드라이브 번호를 Command 레지스터(0x1F2~0x1F6)에 기록한다.

  • I/O 시작. Command 레지스터에 읽기/쓰기를 전달한다. READ_WRITE 명령어를 Command 레지스터에 기록한다.(0x1F7)

  • (쓰기의 경우) 데이터 전송. 드라이브 상태가 READY이고 DRQ(Drive ReQuest for Data, 데이터를 위한 드라이브 요청) 일 때까지 기다린다. 데이터 포트에 데이터를 기록한다.

  • 인터럽트 처리. 가장 간단하게는 각 섹터가 전송되었을 때마다 인터럽트를 처리하게 하고 좀 더 복잡한 방법은 일괄처리가 가능하도록 만들어서 모든 전송이 완료되었을 때 최종적으로 한 번만 인터럽트를 발생시키도록 한다.

  • 에러 처리. 각 동작 이후 Status 레지스터를 읽는다. 만약 ERROR 비트가 설정되어 있다면 Error 레지스터를 읽어서 상세 정보를 확인한다.

대부분은 위 그림에 나와 있으며, 네 개의 기본 함수를 통하여 동작한다. 첫 번째는 ide_rw()로서 요청을 큐에 삽입하거나 디스크에 직접 명령한다. 요청이 처리 완료되기를 기다리며 호출한 프로세스는 재운다. 두 번째는 ide_start_request()로서, 요청(쓰기의 경우 데이터도)을 디스크로 내려 보낸다. x86 in과 out 명령어가 장치 레지스터를 읽거나 쓰는 데 각각 사용된다. 세 번째는 ide_wait_ready()로서, 요청을 명령하기 전에 드라이브가 준비되었는지 확인한다. 마지막 네 번째는 ide_intr()는 인터럽트가 발생하였을 때 호출된다. 장치에서 데이터를 읽고 I/O가 종료되기를 기다리는 프로세스를 깨운다. 그리고 ide_start_request()를 이용하여 다음 요청 처리를 시작한다.

 

이와 같은 루틴을 베이스로 하여 운영체제는 디바이스와 상호작용한다. 이러한 가상화를 제공하기 위해 하드웨어 제조 회사들은 각 운영체제에 맞는 디바이스 드라이버를 제공해야만 한다.

반응형

'OS' 카테고리의 다른 글

4-3장. Redundant Array of Inexpensive Disk (RAID)  (0) 2020.07.23
4-2장. 하드 디스크 드라이브  (0) 2020.05.22
3-9장. 이벤트 기반의 병행성 (고급)  (1) 2020.05.19
3-8장. 병행성 관련 오류  (0) 2020.05.07
3-7장. 세마포어  (0) 2020.05.04
Comments