Skip to content

OSTEP 36 IO Devices

Published: at 오후 10:08

OSTEP 36 IO Devices

Three Easy Pieces 중 마지막 Persistence(영속성) 하나 남았다. 영속성에 대해 소개하기 전, 입출력 장치의 개념을 소개하고, 운영체제가 어떻게 이들과 상호작용 하는지 알아 보도록 하자.

Table of Contents

Open Table of Contents

1. 시스템 구조 (System Architecture)

우선 일반적인 시스템 구조에 대해 알아야 한다.

OSTEP 36 IO Devices-1696860281452.jpeg

CPU와 메인 메모리가 Memory Bus로 연결되어 있다. 또 몇 가지 장치들이 범용 I/O Bus에 연결되어 있다. 보통 PCI를 사용하고, 여기에 그래픽 카드나 다른 고성능 I/O 장치들이 연결된다. 마지막으로, SCSI, SATA, USB같은 Peripheral I/O Bus (주변장치용 버스) 가 있다. 이 버스들을 통해 디스크나 마우스같이 가장 느린 주변 장치가 연결된다.

OSTEP 36 IO Devices-1696875011175.jpeg

“PCI”는 “Peripheral Component Interconnect”의 약자로, 1990년대 중반부터 2000년대 초반까지 컴퓨터에서 주로 사용되던 IO Bus 표준 중 하나입니다.

PCI에 대한 몇 가지 주요 특징은 다음과 같습니다:

플러그 앤 플레이: 장치를 컴퓨터에 연결하면 운영 체제가 자동으로 장치를 감지하고 설정합니다.

32비트와 64비트 버전: 초기 PCI 버스는 32비트 데이터 경로를 사용했지만, 나중에 64비트 버전도 출시되었습니다.

높은 데이터 전송 속도: PCI 버스는 그 당시 다른 버스 표준에 비해 높은 데이터 전송 속도를 제공했습니다.

범용성: 여러 종류의 장치 (그래픽 카드, 네트워크 카드, 사운드 카드 등)와 호환됩니다.

그러나 PCI는 오래된 기술이기 때문에 현대의 PC나 서버에서는 더 빠른 성능과 확장성을 제공하는 PCI Express (PCIe)와 같은 새로운 표준에 의해 대체되었습니다. PCIe는 PCI의 후속 표준으로, 데이터 전송 속도와 확장성, 그리고 세밀한 전력 관리 기능 등 여러 가지 향상된 기능을 제공합니다.

이렇게 계층적인 구조를 만든 이유는 무엇일까? 버스가 고속화되려면 짧아야 한다. 때문에 고속의 메모리 버스는 여러 장치들을 수용할 공간이 없고, 그런 고속의 메모리 버스는 굉장히 비싸다. 때문에 그래픽 카드와 같은 고성능 장치는 CPU와 가깝게 배치하고, 디스크와 같은 느린 장치들은 주변 장치 I/O 버스에 연결했다. 이런 느린 장치들은 여러 개 연결할 수 있다.

2. 표준 장치 (A Canonical Device)

OSTEP 36 IO Devices-1696872204065.jpeg

표준 장치에는 두 개의 중요한 구성 요소가 있다. 첫 번째는 인터페이스(Interface) 이다. 소프트웨어가 인터페이스를 제공하듯, 하드웨어도 인터페이스를 제공하여 시스템 소프트웨어가 동작을 제어할 수 있도록 해야 한다.

두 번째 구성 요소는 내부 구조(Internals) 이다. 시스템에게 제공하는 장치에 대한 추상화를 정의하는 책임을 가지고 있다. 내부 구조 안에는 장치에 따라 다르지만 한두개의 하드웨어 칩을 사용하는 경우도 있고, CPU와 범용 메모리, 그리고 장치에 특화된 칩을 사용하는 경우도 있다.

최신 RAID 컨트롤러는 수십만 줄에 달하는 펌웨어 라는 소프트웨어가 하드웨어 내부의 동작을 정의하고 있다.

3. 표준 방식 (The Canonical Protocol)

위에 그려진 표준 장치 그림을 보면, 세 개의 레지스터가 존재하는 것을 볼 수 있다.

이번에는 장치가 운영체제를 대신하여 어떤 동작을 할 때, 운영체제와 장치 간에 일어날 수 있는 상호 작용 과정을 살펴보자.

While (STATUS == BUSY)
	; // 장치가 바쁜 상태가 아닐 때까지 대기
데이터를 DATA 레지스터에 쓰기
명령어를 COMMAND 레지스터에 쓰기
	(장치가 명령어 실행)
While (STATUS == BUSY)
	; // 요청이 완료될 때까지 대기

표준 방식 (Canonical Protocol)은 네 단계로 이루어져 있다.

  1. 반복적으로 장치의 상태 레지스터를 읽어서 명령의 수신 가능 여부를 확인한다. -> 장치에 대해 폴링(polling) 한다고 표현함
  2. 운영체제가 데이터 레지스터에 어떤 데이터를 전달한다. ex) 디스크 블럭. 데이터 전송에 메인 CPU가 관여하는 경우를 programmed I/O 라고 한다.
  3. 운영체제가 명령어 레지스터에 명령어를 기록한다. 명령어가 기록되면 데이터는 이미 준비되었다고 가정하고 명령어를 처리한다.
  4. 장치가 처리를 완료했는지를 확인한다. 여기서도 폴링한다.

이 표준 방식 (Canonical Protocol)은 계속 polling을 하기 때문에 비효율적이다. 다른 프로세스에게 CPU를 양도하지 않고 장치가 동작을 완료하는 동안 계속 반복문을 돌며 상태를 확인한다.

폴링 사용에 대한 cost를 어떻게 피할 수 있을까?

4. 인터럽트를 이용한 CPU 오버헤드 개선

디바이스를 계속 polling 하는 대신 운영체제는 입출력 작업을 요청한 프로세스를 block 시키고, CPU를 다른 프로세스에게 양도한다. 장치가 작업을 끝마치면, 하드웨어 인터럽트를 발생시키고, CPU는 운영체제가 미리 정의해놓은 인터럽트 서비스 루틴(interrupt service routine(ISR)) 또는 간단하게 인터럽트 핸들러(interrupt handler) 를 실행한다. 이 핸들러는 운영체제 코드의 일부분이다.

인터럽트 핸들러는 입출력 요청의 완료, I/O 대기중인 프로세스 깨우기 등을 담당한다.

사용률을 높이기 위한 핵심 방법 중 하나는 인터럽트를 활용하여 CPU 연산과 I/O 연산을 중첩시키는 것이다. 그림으로 알아보자.

OSTEP 36 IO Devices-1696873722013.jpeg

이 예제는 CPU가 장치의 작업이 끝날 때 까지 polling 하는 것이고,

OSTEP 36 IO Devices-1696873741066.jpeg

이 예제는 장치가 작업을 하는 동안 CPU가 다른 프로세스 2를 실행하는 것이다. 디스크 요청이 완료되어 인터럽트가 발생하면 운영체제가 다시 프로세스 1을 깨워 실행시킨다.

인터럽트가 항상 최적의 해법은 아니다. 작업이 한 번의 폴링만으로 끝나는 매우 빠른 작업이라면, 인터럽트가 시스템을 느리게 할 것이다. Context Switching, 인터럽트 처리는 매우 비싼 작업이다. 빠른 장치에 대해서는 polling, 느린 장치에 대해서는 인터럽트가 좋은 해법이 될 것이다.

만약 장치의 속도를 잘 모르거나, 빠를 때도 있고 느릴 때도 있는 장치라면 어떻게 하는게 좋을까? 짧은 시간 동안 폴링을 하다 처리가 완료되지 않으면 인터럽트를 사용하는 하이브리드 방식을 사용할 수 있다.

네트워크 환경에서도, 대량으로 패킷이 도착하는 경우 인터럽트를 사용한다면, live lock에 빠질 가능성이 높다. 사용자가 많이 몰리면 웹 서버가 폴링을 사용해서 좀 더 효율적으로 처리할 수 있다.

또 다른 인터럽트 최적화 기법은 병합(coalescing) 이다. CPU에게 인터럽트를 전달하기 전에 잠시 기다린 후, 한 번에 모아서 인터럽트를 전달한다. 이 방법으로 인터럽트 처리의 오버헤드를 줄일 수 있다.

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

많은 양의 데이터를 디스크로 전달하기 위해 programmed I/O (PIO)를 사용하면 CPU가 또 다시 단순 작업 처리에 소모된다.

OSTEP 36 IO Devices-1696874902759.jpeg

이런 식으로 데이터를 메모리에서 디스크로 한 워드씩 copy한다. 그림에서 c로 표시된 부분이다.

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

OSTEP 36 IO Devices-1696874912204.jpeg

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

6. 장치와 상호작용하는 방법

장치와 운영체제가 실제로 정보를 어떻게 교환할까?

첫 번째는 가장 오래된 방법으로, I/O 명령을 명시적으로 사용하는 것이다. 이 명령어들은 운영체제가 특정 장치 레지스터에 데이터를 전송할 수 있는 방법을 제공한다.

예를 들어 x86의 경우 inout 명령어를 사용하여 장치들과 통신할 수 있다. 이 명령어들은 대부분 특권 명령어들이다. 때문에 운영체제만이 장치들과 직접 통신할 수 있다.

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

7. 운영체제에 연결하기: 장치 드라이버

최종적으로 다룰 문제는 서로 다른 인터페이스를 가지는 수많은 장치들과 운영체제를 연결시키는 일반화된 방법을 찾는 것이다.

SCSI 디스크, IDE 디스크, USB 등과 같은 기기 위에서 동작하는 파일 시스템을 만들고자 하는데, 각 장치들의 구체적인 입출력 명령어 형식에 종속되게 만들고 싶지 않다.

어떻게 장치 중립적인 운영체제를 만들 수 있을까?

추상화(abstraction)라는 고전적 방법을 사용하여 이 문제를 해결할 수 있다.

OSTEP 36 IO Devices-1696879226707.jpeg

파일 시스템은 어떤 디스크 종류를 사용하는지 전혀 알지 못한다. 파일 시스템은 범용 블록 계층에 블럭 read, write를 요청할 뿐이다. 범용 블럭 계층은 적절한 디바이스 드라이버로 받은 요청을 전달하며, 디바이스 드라이버는 특정 요청을 장치에 내리기 위해 필요한 일들을 처리한다. 이 그림을 통해 장치에 대한 구체적인 동작이 어떻게 운영체제의 대부분에게 숨겨지는지 알 수 있다.

이런 캡슐화는 단점도 있다. 커널이 범용적인 인터페이스만을 제공하는 경우, 특수 기능을 많이 가진 장치는 사용하기 힘들 것이다.

어떤 장치를 시스템에 연결하든 디바이스 드라이버가 필요하기 때문에, 시간이 지나면서 디바이스 드라이버 코드가 커널 코드의 대부분을 차지하게 되었다. 드라이버들이 대부분 전업 커널 개발자가 아닌 개발자들에 의해 만들어지기 때문에 상당한 버그를 포함하고 있고, 커널 크래시의 주범이 되고 있다.

8. 사례 연구: 간단한 IDE 디스크 드라이버

IDE 디스크는 시스템에 Control, Command block, Status, Error 4개의 레지스터로 이루어진 단순한 인터페이스를 제공한다.

이 레지스터들은 x86의 in, out I/O 명령어를 사용하여 특정 I/O의 주소들을 읽거나 씀으로써 접근 가능하다. 그림에 보이는 0x3F6과 같은 주소를 읽는 것이다.

OSTEP 36 IO Devices-1696876731194.jpeg

// 시작 요청 루틴은 이 함수를 사용하여 요청을 명령하기 전에 드라이브가 준비되었는지 확인한다.
static int ide_wait_ready() {
	while (((int r = inb(0x1f7)) & IDE_BSY) || ! ( r & IDE_DRDY))
		; // 드라이버가 바쁘지 않을 때까지 반복문 수행
}

// 요청을 디스크로 내려 보낸다.
// x86의 in, out 명령어가 장치 레지스터를 읽거나 쓰는데 사용된다
static void ide_start_request(struct buf *b) {
	ide_wait_ready();
	outb(0x3f6, 0); // 인터럽트 발생
	outb(0x1f2, 1); // 섹터 몇개?
	outb(0x1f3, b−>sector & 0xff); // LBA 기록
	outb(0x1f4, (b−>sector >> 8) & 0xff); // LBA 기록
	outb(0x1f5, (b−>sector >> 16) & 0xff); // LBA 기록
	outb(0x1f6, 0xe0 | ((b−>dev&1)<<4) | ((b−>sector>>24)&0x0f));
	
	if(b−>flags & B_DIRTY) {
			outb(0x1f7, IDE_CMD_WRITE); // WRITE
			outsl(0x1f0, b−>data, 512/4); // 데이터 전송
	} else {
			outb(0x1f7, IDE_CMD_READ); // READ
	}
}

// 대기 중인 다른 요청들이 있다면 요청을 큐에 삽입하거나 디스크에 직접 명령한다.
// 어느 경우건 요청이 처리 완료되기를 기다리며, 호출한 프로세스는 재운다.
void ide_rw(struct buf *b) {
	acquire(&ide_lock);
	for (struct buf **pp = &ide_queue; *pp; pp=&(*pp)−>qnext)
		; // 큐를 순회
	*pp = b; // 요청을 맨 뒤에 추가
	if (ide_queue == b) // 큐가 비었다면
		ide_start_request(b); // 디스크에 request 보냄
	while ((b−>flags & (B_VALID|B_DIRTY)) != B_VALID)
		sleep(b, &ide_lock); // 완료 대기
	release(&ide_lock);
}

// 인터럽트가 발생했을 때 호출된다
// 장치에서 데이터를 읽고, I/O가 종료되기를 기다리는 프로세스를 깨운다
// 그리고 요청이 더 큐에 있다면 ide_start_request를 이용하여 다음 요청 처리를 시작한다.
void ide_intr() {
	struct buf *b;
	acquire(&ide_lock);
	if (! (b−>flags & B_DIRTY) && ide_wait_ready() >= 0)
		insl(0x1f0, b−>data, 512/4); // READ인 경우 데이터 가져오기
	b−>flags |= B_VALID;
	b−>flags &= .B_DIRTY;
	wakeup(b); // 대기 중인 프로세스 깨우기
	if ((ide_queue = b−>qnext) != 0) // 다음 요청이 존재하는 경우 요청 시작
		ide_start_request(ide_queue);
	release(&ide_lock);
}

이렇게 4개의 기본 함수를 통하여 동작한다.

9. 요약

두 가지 기술인 인터럽트와 DMA는 장치의 효율을 높이기 위해 도입되었으며 명시적 I/O 명령어와 메모리 맵 I/O를 사용하여 장치의 레지스터에 접근할 수 있다는 것을 알게 되었다.

마지막으로 디바이스 드라이버의 개념을 소개하면서 하위 계층의 세부적인 내용을 운영체제가 캡슐화 할 수 있으며, 이를 활용하여 운영체제의 나머지를 장치 중립적으로 구현할 수 있다는 것을 알게 되었다.