티스토리 뷰

반응형

안녕하세요 Pingu입니다! 🐧

 

지난 글인 Concurrency의 문제점을 알아보는 글을 마지막으로 Concurrency(동시성)에 대해 알아봤고 이번 글부터는 Persistence(영속성)에 대해 알아보려고 합니다. 영속성이라는 것은 데이터를 영구적으로 저장할 수 있는 것을 말하며 이를 위한 방법들을 알아볼 예정입니다. 영속성 단원에서는 I/O Device, 파일 시스템, Disk와 같은 데이터를 저장하기 위해 OS가 하는 일에 대해 알아볼 거예요! 이번 글에서는 그중에서도 파일 입출력 장치 (I/O Device)에 대해 알아보려고 합니다. 제가 공부할 때 참고하고 있는 OSTEP 책에서는 Chapter 36 - I/O Devices 부분 입니다!

I/O Devices

이번 글에서는 I/O Device에 대해 알아보도록 하겠습니다. I/O Device는 키보드, 마우스를 포함한 모든 입출력 장치를 말하며 이러한 장치들을 OS에서 사용할 수 있도록 해주는 Device Driver가 있습니다. 실제로 Device Driver는 Linux에서 70%의 코드를 차지한다고 합니다. 이번 글에서는 I/O를 시스템에 통합하는 효율적인 방법과 메커니즘에 대해 알아보도록 하겠습니다~

System Architecture

I/O 장치들에 대해 알아보기 전에 시스템 구조를 살펴보도록 하겠습니다. 위의 그림에서 가장 위쪽에는 메모리 버스 혹은 연결을 통해 시스템의 메인 메모리에 연결된 단일 CPU가 존재하고, 그 밑에 존재하는 일부 장치는 I/O 버스를 통해 시스템과 연결되는 것을 볼 수 있습니다. 그래픽이나 고성능 장치는 I/O 버스를 통한다고 보면 됩니다. 그 보다도 밑에 존재하는 SCSI, SATA, USB와 같은 장치들이 사용하는 Peripheral 버스도 존재합니다. Peripheral(주변 장치) 버스는 디스크, 마우스, 키보드를 포함한 느린 장치를 시스템에 연결합니다.

 

그럼 위와 같은 구조는 왜 필요한 것일까요? 우선 위에 존재하는 버스일수록 속도가 빠릅니다. 그리고 빠르기 위해서는 공간이 작아야 합니다. 즉 맨 위에 존재하는 Memory Bus가 가장 빠르고 가장 적은 공간을 갖고 아래로 내려올수록 느려지며 공간이 많아진다는 말이 됩니다!

최신 시스템은 더 많은 칩과 더 빠른 연결을 위해 위와 같은 구조를 갖게 되는데 위의 구조는 인텔의 칩셋의 구조입니다. CPU는 메모리와 가장 빠른 연결통로가 있지만 그래픽카드와도 꽤나 빠른 통로를 갖고 있습니다. 그리고 DMI(Direct Media Interface)를 통해 CPU가 I/O 칩과 연결되고 나머지 장치들은 I/O 칩을 통해 연결됩니다.

 

I/O 칩에 연결된 것들을 살펴보자면 오른쪽을 보면 디스크들이 존재합니다. 디스크들을 연결하는 스토리지 인터페이스는 몇십 년 동안 진화했으며 위의 그림에서는 eSATA를 사용하는 것을 볼 수 있습니다. I/O 칩 아래에는 USB(Universal Serial Bus) 연결이 있으며 여기에 저는 키보드나 마우스를 연결하곤 합니다. 즉 USB는 저성능 장치에 주로 사용된다고 볼 수 있습니다. 마지막으로 왼쪽에는 PCIe(Peripheral Component Interconnect Express)를 통해 다른 고성능 장치를 시스템에 연결할 수 있습니다. 위의 그림에서는 네트워크 인터페이스가 시스템에 연결되어있는 것을 볼 수 있습니다.

A Canonical Device (표준 장치)

그럼 이제 Canonical Device(표준 장치)에 대해 알아보도록 하겠습니다. 참고로 표준 장치는 실제 장치는 아닙니다. 말이 표준이라 뭔가 존재할 것 같긴 하지만요!

위의 그림은 Device가 가져야 할 두 가지 중요한 요소를 보여줍니다. 첫 번째는 시스템의 나머지 부분에 제공하는 Interface입니다. 여기에 레지스터가 존재하며 이 레지스터로 상태, 명령어, 데이터를 처리할 수 있기 때문에 모든 장치에는 이러한 interface 혹은 프로토콜이 존재합니다.

 

두 번째는 Internal(내부구조)입니다. Device는 많은 종류가 있기 때문에 이 부분은 모두 다르게 구성되어 있지만 하는 역할은 장치가 시스템에 제공하는 작업을 추상화하는 작업을 합니다. 이를 구현하기 위해 하드웨어 칩이 존재하며 복잡한 장치에는 CPU, memory, Device 전용 칩들이 있을 수 있습니다. Internal의 예로 RAID 컨트롤러라는 것이 있는데 이는 기능을 구현하기 위해 수십만 줄의 펌웨어(하드웨어 내 소프트웨어)가 존재합니다.

The Canonical Protocol (표준 프로토콜)

아까 본 그림을 다시 보면 위의 장치는 Interface로 3개의 레지스터를 가지며 각각의 레지스터는 Status, Command, Data의 정보를 가지고 있습니다. 이러한 레지스터들이 다른 디바이스들과 어떻게 상호작용을 하는지 간단히 살펴보겠습니다. 그리고 아래와 같은 것을 프로토콜이라고 합니다.

 While (STATUS == BUSY)
       ; // STATUS가 BUST라면 기다린다.
 Write data to DATA register // DATA 레지스터에 데이터를 쓴다.
 Write command to COMMAND register // COMMAND 레지스터에 명령을 쓴다.
     (starts the device and executes the command) // 해당 명령으로 장치를 시작한다.
 While (STATUS == BUSY) 
       ; // 장치가 일을 하고 있으므로 STATUS가 BUSY이며 끝나길 기다린다.

위의 프로토콜은 총 4단계로 구성됩니다. 

1. OS는 장치의 STATUS 레지스터를 반복적으로 확인해서 BUSY가 아닐 때를 기다립니다.

2. 일부 데이터를 장치의 DATA 레지스터에 보냅니다.

3. 장치의 COMMAND 레지스터에 명령어를 씁니다. 그리고 장치를 해당 명령어로 시작합니다.

4. 장치가 동작 중이므로 끝날 때까지 기다립니다.

 

위와 같은 과정으로 OS와 Device는 상호작용을 하게 됩니다. 위의 과정에서 처음 STATUS 레지스터를 확인하는 단계와 마지막에 장치의 작업이 끝나길 기다리는 단계를 polling이라고 부르는데 이 부분 때문에 성능 저하가 발생합니다. 이는 동시성을 공부할 때 lock을 획득할 수 있는지 계속 확인하는 spin lock과 동일하게 CPU를 계속 사용하기 때문에 성능이 저하하게 되는 것이죠. 따라서 이런 성능 저하를 줄일 수 있는 방법을 알아봐야겠죠?

Lowering CPU Overhead With Interrupts

방금 알아본 장치 간 상호작용에서의 성능 저하를 개선하기 위해 사용하는 방법 중 하나는 Interrupt(인터럽트)입니다. 인터럽트 역시 이전글들에서 몇 번 다룬적이 있었는데요, 장치의 상태를 계속해서 확인하는 polling을 하는 대신 OS는 장치에게 일을 시킨 프로세스를 sleep 모드로 전환하고 작업이 끝나면 하드웨어 인터럽트를 발생하여 프로세스를 깨워주는 방법을 사용하면 성능저하를 개선할 수 있습니다. 인터럽트가 발생하면 미리 정의되어있는 인터럽트 핸들러가 이를 처리하게 됩니다.

 

인터럽트를 사용하게 되면 CPU가 그저 상태를 확인하며 기다리는 대신 다른 프로세스를 하며 기다릴 수 있는데요, 아래 예를 보며 이해해보겠습니다!

위의 그림이 기존에 polling을 사용할 때의 타임라인입니다. p는 polling을 하는 상태입니다. 위의 예는 디스크 I/O 요청이 발생한 상황을 나타낸 것인데요, CPU에서 프로세스 1을 수행하다가 디스크에 I/O 요청을 하고 polling 상태로 디스크의 상태를 계속 확인합니다. 이때 CPU는 다른 일을 하는 것이 아닌 그저 디스크의 작업이 끝나기를 기다리고 있는 것이죠. 그러다 디스크가 작업을 끝내면 다시 CPU는 프로세스 1을 수행하게 됩니다. 이를 개선하기 위해 인터럽트를 사용한다고 했었죠? 그럼 인터럽트를 사용한 예를 살펴보겠습니다.

위의 그림은 아까와 동일한 작업을 인터럽트를 사용할 때의 타임라인입니다. 아까와는 다르게 프로세스 1이 디스크에 I/O 요청을 하고 디스크가 이를 처리하는 동안 그저 기다리는 것이 아닌 프로세스 2를 수행하고 있는 것을 볼 수 있습니다. 프로세스 1은 sleep 상태로 전환되었다가 다시 run 상태로 변하게 됩니다.

 

물론 이렇게 하면 CPU가 노는 시간이 없어져서 성능이 향상될 수 있지만 인터럽트를 사용하는 것이 모든 경우의 답이 될 수는 없습니다. 예를 들어 I/O 작업을 매우 빠르게 처리하는 장치가 있고 인터럽트를 사용한다고 생각해보겠습니다. 그럼 프로세스 1을 sleep 상태로 만들고 프로세스 2를 context switch 하고 곧 I/O 작업이 완료되어 프로세스 2는 거의 수행하지도 못하고 프로세스 1로 다시 전환하게 될 텐데, 이때 인터럽트를 사용하지 않고 기다리는(polling 하는) 비용보다 인터럽트를 처리하는 비용이 더 커질 수 있는 것이죠! 따라서 실제로는 polling과 인터럽트를 적절히 섞어 사용하는 것이 좋다고 합니다.

 

인터럽트를 사용하는 방법을 최적화하는 다른 방법은 coalescing(통합)입니다. 인터럽트를 발생시키는 장치는 인터럽트를 CPU에 전달하기 전에 잠시 대기합니다. 대기하는 동안 인터럽트를 발생하는 다른 작업들이 완료될 수 있기 때문에 이들을 모아서 한 번에 하나의 인터럽트로 전달하는 것이죠! 여러 인터럽트를 하나로 모았기 때문에 인터럽트 처리 비용이 줄게 되어 성능이 향상될 수 있습니다. 물론 너무 오래 기다리거나 하면 안 되기 때문에 적당한 시간을 잘 정해줘야겠죠?

More Efficient Data Movement With DMA

방금 알아본 canonical protocol(표준 프로토콜)에는 주의할 점이 하나 더 있습니다. 예를 들어 I/O를 사용하여 많은 양의 데이터를 장치에 전송할 때 CPU는 사소한 작업으로 인해 시간을 낭비하게 됩니다. 아까는 Status를 확인하며 기다리는 polling 문제가 발생했었는데 또 다른 문제는 무엇일까요?

위의 그림의 예는 프로세스 1이 수행되다가 데이터를 디스크에 쓰는 작업을 하는 상황입니다. 데이터를 디스크에 쓰기 위해서는 I/O를 사용해요 메모리에서 장치로 데이터를 한 번에 한 개씩 복사합니다. 이 과정은 위의 그림에서는 c라고 표현한 부분입니다. 복사가 완료되면 I/O가 디스크에서 시작되고 프로세스 1을 sleep 상태로 전환한 뒤 다른 프로세스를 실행합니다. 즉 위와 같이 데이터를 복사하는 시간 때문에 CPU가 낭비되게 되는 것이죠.

 

이 문제의 해결방법은 Direct Memory Access(DMA)라고 하는 것입니다. DMA 엔진은 이름에서 알 수 있듯 CPU의 개입 없이 장치와 메모리 사이의 전송을 조율할 수 있는 시스템의 장치입니다. DMA을 사용하여 방금 예로 든 작업을 수행하면 아래와 같이 실행의 변화가 생깁니다.

즉 아까와는 다르게 메모리의 데이터를 복사하는 작업을 CPU가 진행하는 것이 아닌 DMA 엔진에서 자체적으로 수행하게 되어 CPU의 낭비를 줄이는 것이죠. DMA가 작동하는 방식을 설명하자면, 데이터를 장치로 전송하기 위해 OS는 데이터가 메모리에 존재하는 위치, 복사할 데이터의 양, 데이터를 보낼 장치를 DMA에 알려줍니다. 이것만 알려주면 CPU는 더 이상 이 작업에 관여하지 않고 DMA가 나머지 작업을 처리합니다. DMA가 완료되면 DMA 컨트롤러가 인터럽트를 발생시켜 전송이 완료된 것을 OS에게 알립니다.

Methods Of Device Interaction

이제 I/O 작업을 수행할 때 발생하는 문제들에 대해 어느 정도 알아봤으니 실제로 OS와 장치가 어떻게 소통하는지에 대해 알아보도록 하겠습니다. OS와 Device 간 통신 방법은 첫 번째로 명시적으로 I/O 명령을 사용하는 방법이 있습니다. OS가 특정 장치 레지스터에 데이터를 전송하는 방법을 지정하여 방금까지 알아본 프로토콜을 구성하여 사용합니다. 예를 들어 x86에서는 in, out 명령어를 사용하여 장치들과 통신할 수 있습니다. 이러한 명령어는 수행 권한이 필요하며 OS만 이러한 명령어를 사용할 권한을 가지게 됩니다.

 

두 번째 방법은 메모리 매핑 I/O입니다. 이 방법을 사용하면 하드웨어에서 장치 레지스터를 마치 메모리처럼 사용할 수 있습니다. 특정 레지스터에 접근하기 위해 OS는 주소를 load, store 하며 하드웨어는 load, stort를 메모리 대신 장치로 라우팅 합니다.

 

이 두 가지 방법에는 특별하게 더 좋은 것은 없고 지금도 두 가지 방식 모두 적절히 사용되고 있다고 합니다.

Fitting Into The OS: The Device Driver

마지막으로 알아볼 것은 장치들은 저마다의 인터페이스를 가지고 있는데 이를 하나의 OS에서 사용할 수 있도록 하는 방법입니다. 예를 들어 파일 시스템만 하더라도 SCSI 디스크, IDE 디스크, USB 드라이브 등 여러 개의 파일 시스템이 존재하는데 이를 하나의 OS에서 모두 작동할 수 있습니다. 이러한 것을 가능하게 해주는 소프트웨어가 바로 Device Driver입니다. 장치 드라이버를 사용하여 장치의 모든 상호작용을 캡슐화하여 세부사항을 모르더라도 쉽게 사용할 수 있게 되는 것이죠.

 

이렇게 추상화를 하게 되면 OS 설계와 구현에 어떻게 도움이 되는지 Linux 파일 시스템을 살펴보며 알아보겠습니다.

위의 그림은 Linux 소프트웨어를 대략적으로 그린 그림입니다. 위의 그림에서 보면 파일 시스템은 디스크의 세부적인 것을 알지 못합니다. 단순하게 블록 읽기, 쓰기 요청을 Generic Block layer에 요청하고 이러한 요청을 처리하는 적절한 장치 드라이버로 라우팅 합니다. 대부분의 OS에서 위와 같이 세부적인 정보를 숨길 수 있는 방법을 사용합니다.

 

위의 그림에서는 또한 추상화를 사용하지 않고 직접 블록을 읽고 쓰는 raw 인터페이스도 있습니다. 대부분의 시스템은 저수준 저장공간 관리 앱을 지원하기 위해 이러한 인터페이스를 제공합니다.

 

이렇게 모든 장치들에 대하여 드라이버가 존재하다 보니 리눅스 코드의 70%가 장치 드라이버로 구성되어있다고 합니다. 

Case Study: A Simple IDE Disk Driver

장치 드라이버에 대해 좀 더 자세히 알아보기 위해 실제 장치 중 하나인 IDE Disk 드라이버에 대해 알아보도록 하겠습니다.

위의 사진이 IDE 디스크의 레지스터들입니다. IDE 디스크는 control, command block, status, error의 네 가지 레지스터로 구성된 간단한 인터페이스를 제공합니다. 네 개의 레지스터는 입출력 I/O 명령어를 사용하여 특정 I/O 주소를 읽거나 쓰면 사용할 수 있습니다. 이를 사용하여 장치와 상호작용하는 기본 프로토콜을 살펴보겠습니다. 

 

1. 드라이브가 Ready 상태가 될 대까지 기다립니다. (Status 레지스터가 Ready가 될 때까지)

2. Command 레지스터에 매개 변수를 씁니다. Command 레지스터에 섹터 수, 접근할 섹터의 LBA(논리 블록 주소) 및 드라이브 번호를 기록합니다.

3. I/O를 시작합니다. 이때 Command 레지스터에 읽기 쓰기를 실행합니다. 

4. 데이터 전송 : 드라이브 STATUS가 READY, DRQ(데이터에 대한 드라이브 요청)이 될 때까지 기다립니다. 그런 뒤 데이터 포트에 데이터를 씁니다.

5. Interrupt Handling : 글의 초반부에 인터럽트를 효율적으로 처리하는 방법에서 봤듯 여러 개의 인터럽트를 한 번에 처리할 수도 있고 각 섹터마다 인터럽트를 처리할 수도 있습니다.

6. Error Handling : 작업을 한 뒤 STATUS 레지스터를 읽고 ERROR 비트가 켜진 상태라면 이를 처리합니다.

 

이러한 프로토콜들은 xv6 IDE 드라이버에서 찾을 수 있는데 간단히 살펴보면 아래와 같습니다.

아까 알아봤던 프로토콜들을 코드로 나타낸 것입니다. ide_wait_ready()가 STATUS 레지스터의 값이 READY가 되는 것을 기다리는 함수이고 ide_rw()는 I/O 요청을 큐에 넣거나 디스크에 직접 요청합니다. 직접 요청할 때는 ide_start_request()로 요청하게 됩니다. ide_intr()는 인터럽트가 발생했을 때 이를 처리하기 위한 함수입니다. 위의 코드를 통해 실제 레지스터들이 어떻게 사용되는지 살펴볼 수 있습니다!

Summary

이번 글에서는 OS와 Device가 상호작용하는 방법에 대한 기본적인 이해를 하기 위한 것들을 알아봤습니다. I/O 요청을 처리하는 방법, 레지스터에 접근하는 방법, 명시적 I/O 명령 및 메모리 매핑된 I/O 명령도 알아봤습니다. 또한 Device Driver의 개념으로 OS 자체가 저수준의 세부사항을 캡슐화하여 OS를 쉽게 구축하는 방법도 알아봤습니다. 다음 글에서는 Disk Driver에 대해 알아보도록 하겠습니다!

 

감사합니다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함