ch13. device driver
I/O 아키텍처와
디바이스 드라이버
단순히 파일을 열었을 뿐인데, 실제 하드웨어는 어떻게 움직이는 걸까요? 이 장에서는 VFS 아래에서 디바이스 드라이버가 버스, 포트, 인터페이스를 거쳐 물리적 장치를 제어하는 전체 흐름을 처음부터 끝까지 상세히 따라가 봅니다.
큰 그림: 파일을 열었는데, 왜 하드웨어가 움직일까?WHY DEVICE DRIVERS EXIST
리눅스 환경에서 write()를 호출하면 과연 어떤 일이 발생할까요? 파일에 문자열을 쓰는 단순한 작업일 수도 있고, 프린터로 문서를 출력하거나 사운드 카드로 오디오 스트림을 보내는 일일 수도 있습니다. 놀랍게도 애플리케이션 입장에서는 이 모든 과정이 open(), read(), write(), ioctl()과 같은 동일한 시스템 콜을 통해 이루어집니다.
어떻게 동일한 시스템 콜이 상황에 따라 디스크 파일이나 키보드, 프린터, 사운드 카드 같은 다양한 물리적 장치와 연결될 수 있을까요? 그 명쾌한 해답이 바로 이번 장의 핵심 주제입니다.
사용자 프로그램을 식당의 손님이라고 한다면, VFS는 홀 매니저와 같습니다. 매니저는 주문을 받아 "이건 주방으로, 이건 음료 담당으로" 구분하여 전달합니다. 여기서 디바이스 드라이버는 각 파트를 담당하는 직원입니다. 프린터 드라이버는 프린터의 언어로, 디스크 드라이버는 디스크의 언어로 각자의 고유 업무를 처리하게 됩니다.
이번 장은 크게 다섯 가지 흐름으로 진행됩니다. 첫째, CPU와 장치가 통신하는 경로인 I/O 아키텍처. 둘째, 수많은 버스와 장치, 드라이버를 체계적으로 관리하는 디바이스 드라이버 모델. 셋째, /dev/hda, /dev/null과 같은 디바이스 파일의 진정한 의미. 넷째, 드라이버의 등록과 초기화, I/O 완료 감시 등의 공통 동작. 마지막으로, 비교적 구조가 명확한 문자 장치 드라이버의 구현 원리입니다.
이번 장의 핵심 문장: 리눅스는 복잡한 하드웨어 장치를 단순한 파일처럼 추상화하고, 그 파일에 대한 연산을 드라이버 함수 호출로 매핑하여 실제 장치를 제어합니다.
핵심 O/X 퀴즈
1. VFS는 모든 장치의 물리적 세부 명령을 직접 알고 있어야 한다.
2. 디바이스 드라이버는 VFS의 파일 연산 요청을 실제 하드웨어 동작으로 연결하는 역할을 한다.
3. 이번 장의 핵심 흐름은 I/O 구조, 드라이버 모델, 디바이스 파일, 드라이버 동작, 문자 장치 드라이버로 이어진다.
PC I/O 아키텍처: CPU와 장치는 어떤 길로 대화할까?BUS TOPOLOGY
컴퓨터 내부의 CPU, 메모리, 디스크, 키보드 등이 서로 데이터를 주고받으려면 통신 경로가 필요합니다. 우리는 이를 버스(bus)라고 부릅니다. 하나의 시스템 안에는 PCI, ISA, SCSI, USB 등 다양한 버스가 공존하며, 이들은 브리지(bridge)라는 하드웨어를 통해 유기적으로 연결됩니다.
- frontside bus
- CPU와 메모리 컨트롤러를 잇는 고속 통로.
- backside bus
- CPU와 외부 하드웨어 캐시를 직접 잇는 전용 통로.
- host bridge
- 시스템 버스와 프론트사이드 버스를 연결하는 하드웨어.
- I/O bus
- CPU와 I/O 장치 사이의 주소 및 데이터를 전달하는 통로. 대표적으로 PCI가 있습니다.
데이터의 흐름을 따라가 볼까요? CPU가 I/O bus를 통해 I/O port에 접근하면, 그 뒤에 있는 I/O interface가 명령을 해석하고, I/O controller가 최종적으로 물리적인 I/O device를 제어합니다. 즉, CPU가 하드웨어에 직접 명령을 내리는 것이 아니라, 단계적인 계층 구조를 거치며 정교하게 소통하게 됩니다.
CPU가 장치와 대화하는 방식은 크게 두 가지입니다. 하나는 독립적인 I/O 주소 공간의 I/O 포트를 읽고 쓰는 고전적 방식이고, 다른 하나는 장치의 레지스터를 물리적 메모리 주소 공간에 매핑하여 일반 메모리처럼 다루는 memory-mapped I/O 방식입니다. 현대의 고성능 장치들은 속도와 DMA 결합 효율성 측면에서 memory-mapped I/O 방식을 더 선호합니다.
드라이버 구조를 이해한다는 것은 단순히 C 언어 함수 몇 개를 암기하는 것이 아닙니다. CPU가 하드웨어와 소통하는 물리적·논리적 경로를 깨닫는 과정입니다. 비유하자면 버스는 도로, 브리지는 교차로, I/O 포트는 목적지의 접수 창구인 셈입니다.
핵심 O/X 퀴즈
1. 모든 I/O 장치는 하나의 버스에 속하며, 버스 종류는 커널이 장치를 다루는 방식에 영향을 준다.
2. Figure 13-1의 구조는 CPU가 I/O 장치에 직접 연결되어 중간 계층 없이 데이터를 주고받는 구조를 보여준다.
3. I/O 포트, I/O 인터페이스, I/O 컨트롤러는 CPU와 실제 장치 사이의 계층적 구성요소로 볼 수 있다.
I/O 포트: 장치와 대화하는 작은 창구PORT REGISTERS
I/O 포트는 CPU가 장치에 명령을 내리거나 상태를 확인할 때 접근하는 주소 창구입니다. IBM PC 아키텍처에서는 최대 65,536개의 8비트 포트 공간을 제공하며, 포트를 2개 묶어 16비트로, 4개를 묶어 32비트 크기로 활용할 수도 있습니다. 단, 버스 정렬 규칙에 따라 16비트 포트는 짝수 주소, 32비트 포트는 4의 배수 주소에서만 시작해야 합니다.
80x86 아키텍처에서는 in, out, ins, outs와 같은 어셈블리 명령어를 통해 포트에 접근하며, 커널은 이를 추상화하여 사용하기 편리한 보조 함수들을 제공합니다.
- inb / inw / inl
- 포트에서 1바이트, 2바이트, 4바이트 크기의 데이터를 읽습니다.
- outb / outw / outl
- 포트에 1바이트, 2바이트, 4바이트 크기의 데이터를 씁니다.
- inb_p / outb_p
- 접근 직후 더미(dummy) 명령을 통해 짧은 지연을 유발합니다. 주로 속도가 느린 구형 장치와의 동기화를 위해 사용됩니다.
각 I/O 포트는 다양한 역할의 레지스터로 구성됩니다. control register에는 장치에 내릴 제어 명령을 쓰고, status register에서는 장치의 현재 상태를 읽어옵니다. 또한 input register와 output register를 통해 CPU와 장치가 실제 데이터를 주고받게 됩니다.
하드웨어 설계 시 비용을 줄이기 위해 하나의 I/O 포트가 여러 역할을 겸하기도 합니다. 읽을 때는 입력 레지스터로, 쓸 때는 출력 레지스터로 동작하는 식입니다. 따라서 드라이버 개발자는 해당 포트의 각 비트가 갖는 의미와 방향(읽기/쓰기)에 따른 구체적 로직을 완벽히 숙지해야 합니다.
핵심 O/X 퀴즈
1. `inb()`, `inw()`, `inl()`은 각각 1, 2, 4바이트를 I/O 포트에서 읽는 데 사용된다.
2. I/O 포트는 항상 하나의 역할만 하며, 같은 포트가 입력과 출력에 함께 사용되는 경우는 없다.
3. memory-mapped I/O에서는 장치의 I/O 영역을 일반적인 메모리 주소처럼 접근할 수 있다.
I/O 포트 자원 관리: 아무 포트나 건드리면 왜 위험할까?RESOURCE TREE
드라이버가 포트 할당 상태를 모른 채 임의의 포트에 데이터를 쓰게 되면, 이미 그 포트를 선점하여 통신 중인 다른 장치의 동작을 치명적으로 방해할 수 있습니다. 과거 ISA 버스 시절에는 장치를 탐지하기 위해 의심되는 포트에 무작정 값을 써보는 방식도 쓰였는데, 이는 매우 위험하며 잦은 시스템 크래시를 유발했습니다.
이를 방지하기 위해 커널은 I/O 포트 영역을 resource라는 개념으로 엄격히 관리합니다. resource 구조체는 소유자의 이름, 영역의 시작과 끝 주소, 플래그를 담고 있으며, 부모·자식·형제 포인터를 통해 전체를 트리(Tree) 구조로 추적합니다. 예를 들어, 특정 IDE 인터페이스가 0xf000~0xf00f 대역을 할당받았다면, 그 안을 다시 마스터 디스크와 슬레이브 디스크 영역으로 분할하여 자식 resource로 편입시키는 식입니다.
I/O 주소 공간을 거대한 주차장이라고 생각해보세요. 리소스 트리는 이 주차장의 구획을 관리하는 주차 관리 시스템입니다. '여기가 비어 있겠지' 하고 아무 곳에나 주차를 하면 다른 차의 지정석을 뺏게 되는 것처럼, 디바이스 드라이버도 반드시 빈 구역을 정식으로 할당받은 뒤에 접근해야 합니다.
- request_resource()
- 특정한 포트 범위를 해당 장치 전용으로 등록합니다.
- allocate_resource()
- 주어진 조건에 부합하는 사용 가능한 빈 범위를 시스템에서 찾아내어 배정받습니다.
- release_resource()
- 더 이상 사용하지 않는 포트 범위를 시스템에 안전하게 반환합니다.
- request_region() / release_region()
- 특히 I/O 포트 영역에 맞게 최적화된 편의용 매크로 함수입니다.
- /proc/ioports
- 현재 운영 체제에 할당된 모든 I/O 포트 현황을 사용자가 직접 확인할 수 있습니다.
I/O 포트 리소스 트리의 루트 노드는 ioport_resource이며, 포트 번호 0번부터 65535번까지의 전체 I/O 주소 공간을 아우릅니다. 잘 짜여진 드라이버라면 반드시 자신이 소유할 범위를 명확히 등록하여 잠재적인 충돌을 미연에 방지해야 합니다.
핵심 O/X 퀴즈
1. 커널은 I/O 포트 주소 범위를 resource로 관리하여 장치 간 무분별한 충돌을 줄인다.
2. `request_region()`은 이미 다른 장치에 할당된 포트 범위를 강제로 빼앗아 새 드라이버에게 부여하는 함수이다.
3. `/proc/ioports` 파일을 열어보면 현재 커널이 관리 중인 I/O 포트의 할당 맵을 확인할 수 있다.
I/O 인터페이스와 컨트롤러: 포트 뒤에서 실제 일을 하는 것들INTERFACE & CONTROLLER
CPU가 I/O 포트에 명령을 내린다고 해서 디스크 헤드나 모터가 즉각적으로 움직이는 것은 아닙니다. 그 사이에는 명령을 적절히 해석해 줄 I/O interface가 존재합니다. I/O 인터페이스는 포트에 쓰인 값을 장치가 온전히 이해할 수 있는 규격화된 명령과 데이터 패킷으로 변환합니다. 또한, 장치의 상태 변화를 감지하여 상태 레지스터(status register)를 갱신하며, 필요할 경우 IRQ 라인을 타고 PIC(Programmable Interrupt Controller)에 인터럽트를 발생시키기도 합니다.
- custom I/O interface
- 키보드, 디스플레이 어댑터, 전용 디스크, 마우스 등 특정 장치만을 위해 특화된 전용 인터페이스입니다.
- general-purpose I/O interface
- 다양한 종류의 범용 외부 장치들을 연결할 수 있도록 고안된 인터페이스로, 병렬 포트, 직렬 포트(UART), USB, SCSI 등이 이에 속합니다.
인터페이스 다음 단계에는 device controller가 기다리고 있습니다. 컨트롤러는 인터페이스로부터 전달받은 논리적이고 고수준인 명령을, 실제 물리 하드웨어가 직접 구동될 수 있도록 저수준의 전기적 신호 시퀀스로 변환하는 핵심 역할을 수행합니다. 예를 들어 디스크 컨트롤러는 "디스크의 N번 블록 데이터를 기록하라"는 운영체제의 논리적 지시를 받아들여서, 디스크의 모터를 회전시키고, 헤드를 목표 트랙으로 이동시킨 뒤 자성으로 데이터를 기록하는 고도의 물리적 조작으로 번역해 냅니다. 현대의 고급 컨트롤러들은 자체적인 온보드 캐시를 지니며 요청 순서를 스스로 재정렬(reordering)하여 처리 효율을 극대화하기도 합니다.
더불어, 그래픽 카드의 프레임 버퍼처럼 특정 하드웨어들은 성능 향상을 위해 자체적인 메모리를 보유하고 있는데, 우리는 이를 I/O shared memory라고 부릅니다. 디스플레이에 출력될 픽셀 정보들이 이 전용 메모리 공간에 저장되며 CPU와 주변 장치들이 고속으로 접근할 수 있도록 개방되어 있습니다.
포트는 주문을 접수하는 창구, 인터페이스는 주문서를 주방 언어로 번역하는 통역사, 컨트롤러는 주방 전체를 통솔하는 현장 관리자, 그리고 실제 물리 장치는 묵묵히 요리를 만드는 작업자에 비유할 수 있습니다. 훌륭한 디바이스 드라이버는 이 유기적인 전체 파이프라인의 메커니즘을 꿰뚫고, 어느 시점에 포트에 값을 기입할지, 언제 결과를 읽어들일지, 그리고 언제 인터럽트를 기다릴지를 완벽히 조율합니다.
핵심 O/X 퀴즈
1. I/O 인터페이스는 CPU가 I/O 포트에 남긴 값을 장치의 컨트롤러가 소화할 수 있는 고유 명령 및 데이터 형식으로 해석해 준다.
2. 직렬 포트는 병렬 포트보다 데이터 전송 대역폭이 뛰어나기 때문에 항상 한 번에 더 많은 비트를 전송한다.
3. 디스크 컨트롤러는 고수준의 I/O 블록 연산 명령을 헤드 이동, 자성 기록과 같은 실제 물리적 저수준 동작으로 변환시킨다.
디바이스 드라이버 모델과 sysfs: 커널 안의 장치 지도DEVICE DRIVER MODEL
과거의 구형 커널은 드라이버 관리를 위한 공통 인프라가 상당히 부족했습니다. 그러나 PCI나 USB 같은 현대적인 버스 규격이 속속 등장하면서, 디바이스의 전원 관리나 플러그 앤 플레이, 작동 중 장치를 탈착하는 핫플러깅 처리가 운영체제의 매우 중대한 과제로 떠올랐습니다. 예를 들어 배터리로 구동되는 노트북이 대기 모드(Standby)로 진입할 때, 커널은 하드디스크, 그래픽 카드, 네트워크 컨트롤러를 안전하고 올바른 순서대로 저전력 상태로 전환시켜야만 합니다. 이러한 복잡한 공통 문제들을 일관된 아키텍처로 제어하기 위해 Linux 2.6 버전부터 강력한 device driver model이 도입되었습니다.
그리고 이 거대한 모델을 커널 밖 사용자 공간(User Space)에서 들여다볼 수 있게 해주는 특별한 파일시스템이 바로 sysfs입니다. 보통 시스템의 /sys 경로에 마운트됩니다. 흔히 쓰이는 /proc 역시 커널의 내부 정보와 프로세스 상태를 노출하지만, sysfs는 디바이스 드라이버 모델 특유의 방대한 계층 관계를 구조적이고 직관적으로 보여준다는 점에서 분명한 목적의 차이가 있습니다.
- block
- 시스템의 모든 블록 장치를 버스 연결 상태와 무관하게 나열합니다.
- devices
- 커널이 인지하고 있는 전체 하드웨어 장치를 실제 물리적 연결 계층도에 맞춰 표시합니다.
- bus
- 시스템 내의 각 버스 종류(PCI, USB 등)별로 장치와 드라이버의 매핑 상태를 보여줍니다.
- drivers
- 현재 커널에 등록된 모든 디바이스 드라이버의 목록을 제공합니다.
- class
- 물리적 위치를 떠나, '오디오', '네트워크', '그래픽' 등 논리적인 장치 기능 및 종류를 기준으로 묶어 분류합니다.
- power
- 시스템 전체의 전원 관리 상태와 관련된 파라미터 파일들이 위치합니다.
- firmware
- 특정 하드웨어 구동에 필요한 펌웨어 관련 정보와 인터페이스를 제공합니다.
sysfs는 커널이 바라보는 하드웨어 생태계의 완벽한 '조직도'와 같습니다. 디바이스 드라이버 모델은 커널 메모리 안에 존재하는 방대한 자료구조이며, sysfs는 이 보이지 않는 자료구조를 일반 파일이나 디렉터리 형태로 외부에 노출시켜 주는 디스플레이 창입니다. 마치 현실에서 한 직원이 회사 조직도 상으로는 특정 부서의 소속이지만 사내 프로젝트 문서에서는 특정 역할 담당자로 표기되는 것처럼, sysfs 안에서도 동일한 하드웨어가 bus, class, devices 등 들여다보는 관점에 따라 다채롭게 뷰(View)가 구성됩니다.
sysfs는 단순한 항목 나열을 넘어 객체 간의 입체적인 관계를 심볼릭 링크로 엮어냅니다. 이 디렉터리 안의 일반 파일들은 주로 장치나 드라이버가 가진 세부 attribute(속성)를 의미합니다. 일례로 블록 장치 디렉터리 하위의 dev 속성 파일 안에는 해당 장치의 major/minor 번호 정보가 담겨 있습니다.
핵심 O/X 퀴즈
1. sysfs는 보통 `/sys`에 마운트되며 커널 내 장치 드라이버 모델의 계층 관계를 사용자 공간에 제공한다.
2. Linux 2.6부터 도입된 디바이스 드라이버 모델은 전원 관리, 핫플러깅, 플러그 앤 플레이 등 다수 버스의 공통 관심사를 체계적으로 다루기 위해 만들어졌다.
3. sysfs의 `class` 디렉터리는 장치가 연결된 실제 물리적인 버스 위치만을 기준으로 분류 체계를 세운다.
kobject, kset, subsystem: sysfs를 구축하는 커널의 핵심 뼈대OBJECT MODEL
sysfs 공간의 디렉터리와 파일 구조는 허공에서 마법처럼 생겨나는 것이 아닙니다. 모든 항목은 커널 내부의 실존하는 핵심 자료구조와 단단히 결합되어 있는데, 그 구조체의 이름이 바로 kobject입니다. 버스 객체, 개별 장치 객체, 혹은 드라이버 객체 등 규모가 큰 커널 구조체 내부에 이 조그만 kobject를 삽입(embedding)하게 되면, 해당 부모 객체는 즉시 세 가지의 막강한 기능을 자동으로 획득하게 됩니다. 첫째, 메모리 누수를 막는 체계적인 참조 카운터 관리. 둘째, 복잡하게 얽힌 계층적 리스트 및 집합에의 편입. 셋째, sysfs 파일시스템을 통해 사용자 공간으로 내부 속성을 매끄럽게 노출하는 능력입니다.
- 참조 카운터
kobject_get()함수로 카운터를 1 올리고,kobject_put()으로 1 내립니다. 카운터가 0에 도달하면 메모리 해제를 위한 release 메서드가 안전하게 실행됩니다.- 부모 포인터
- 상위 객체를 가리켜 트리 기반의 계층 구조를 자연스럽게 형성합니다.
- sysfs dentry
- sysfs 상에 매핑된 디렉터리 엔트리와 직접 연결됩니다.
- kobject_register()
- kobject를 시스템에 초기화함과 동시에 sysfs 상에 해당하는 디렉터리를 생성합니다.
- kobject_unregister()
- 등록된 kobject를 sysfs 노출 목록에서 깔끔하게 제거합니다.
- sysfs_create_file()
- kobject 디렉터리 내부에 하드웨어의 특정 값을 노출하는 attribute(속성) 파일을 만들어 냅니다.
- sysfs_create_link()
- 서로 다른 kobject 간의 논리적 관계망을 sysfs 상의 심볼릭 링크 형태로 이어줍니다.
kset은 비슷한 성격을 공유하는 kobject들을 한데 묶어 관리하는 컨테이너입니다. 여기서 흥미로운 커널의 설계 철학을 볼 수 있는데, kset 구조체 역시 그 내부에 자신만의 kobject 필드를 품고 있습니다. 덕분에 kset 집합 그 자체도 sysfs의 계층 구조 속 디렉터리로 당당히 승격될 수 있으며, kobject가 제공하는 참조 카운팅 메커니즘을 동일하게 재사용합니다. 한 단계 더 나아가 subsystem은 이런 kset들을 모아 둔 더 거대한 상위 단위이며, 주로 내부적인 읽기/쓰기 세마포어(semaphore)를 포함하여 동시성 제어까지 담당합니다.
Figure 13-3의 흐름도를 참고하여 /sys/bus/pci/drivers/serial/new-id라는 경로를 해체해 보면, 이는 subsystem 객체 안에 kset이 있고, 그 아래 kobject를 거쳐 최종적으로 attribute 파일에 도달하는 완벽한 객체 지향적 계층 결합임을 깨달을 수 있습니다.
요약하자면: kobject는 커널 구조체를 sysfs 계층 및 참조 카운팅 시스템과 연결해 주는 만능 범용 부품입니다. kset은 그 부품들의 공통 집합소 역할을 하며, subsystem은 그 집합들을 관장하는 최상위 부서 단위라고 볼 수 있습니다.
핵심 O/X 퀴즈
1. kobject는 보통 커다란 상위 커널 자료구조 안에 임베디드(embedded)되어 참조 카운팅과 sysfs 등록 역할을 보조한다.
2. kset은 여러 kobject를 묶는 컨테이너이지만, 설계상 kset 구조체 내부에는 kobject가 포함되지 않는다.
3. sysfs 공간에 나타나는 attribute 일반 파일은 `sysfs_create_file()` 등의 함수를 호출하여 만들어진다.
device, driver, bus, class: 드라이버 모델을 떠받치는 네 기둥FOUR PILLARS
복잡한 디바이스 드라이버 모델을 지탱하는 본질적인 핵심 객체는 네 가지로 요약됩니다. 이 네 요소는 "어떤 하드웨어 장치가 어느 물리 버스에 물려 있고, 어떤 드라이버 소프트웨어가 이를 통제하며, 최종적으로 사용자 애플리케이션에는 어떠한 논리적 장치로 보이는가"라는 질문에 완벽하게 답해 줍니다.
하드웨어 장치 간의 부모-자식 종속 관계, 동일한 버스에 물린 장치들의 리스트, 그리고 현재 매핑된 담당 드라이버 포인터를 담습니다. /sys/devices 경로에서 실제 물리적인 연결 구조체계로 표현됩니다.
시스템에 로드된 드라이버의 고유 이름과 소속 버스, 제어 중인 장치 목록을 관리합니다. 장치의 생명주기를 관장하는 probe / remove / shutdown / suspend / resume 메서드가 포진해 있습니다.
하드웨어가 속한 버스의 규격(PCI, USB 등)을 정의합니다. 특정 드라이버가 새로 감지된 장치를 지원하는지 검증하는 match 함수, 핫플러그 이벤트 발생 시 환경 변수를 세팅하는 hotplug 함수를 제공합니다.
하드웨어가 꽂힌 물리적 위치를 배제하고 장치의 순수한 기능별로 그룹핑한 분류입니다. 단일 사운드 카드 하드웨어 안에도 DSP, 믹서기, 미디 포트 같은 여러 논리적 서브 장치들이 각각 독립적인 class로 존재할 수 있습니다.
여기서 중요한 점은 device_driver 안의 probe 메서드가 하는 역할입니다. 이 메서드는 커널이 새로운 장치를 발견했을 때 "이 하드웨어를 이 드라이버가 제어할 수 있는가?"를 최종 확인하고 초기화할 때 호출됩니다. 반대로 remove 메서드는 작동 중이던 USB 등을 뽑는 핫플러깅 제거나 드라이버 모듈이 언로드될 때 호출되어 자원을 회수합니다. 즉, 여기서의 드라이버 객체는 단순한 연산 함수의 모음이 아니라 장치의 물리적 발견, 제거, 전원 상태 전환 등을 동적으로 제어하는 고차원적 생명주기 관리자입니다.
/sys/devices 트리는 물리적인 납땜과 케이블의 연결 구조를 반영하고, /sys/bus 트리는 버스를 매개로 한 장치와 드라이버의 매핑을 보여주며, /sys/class 트리는 사용자 공간에서 바라보는 순수한 기능적 관점을 대변합니다. 하나의 장치가 관점에 따라 3가지 거울에 서로 다르게 투영되는 셈입니다.
핵심 O/X 퀴즈
1. `struct device`는 장치들 간의 부모-자식 연결 관계를 추적할 수 있으며, 이 구조는 `/sys/devices` 경로를 통해 노출된다.
2. `probe` 메서드는 디바이스 드라이버가 새롭게 감지된 특정 장치를 자신이 관할하고 초기화할 수 있는지 확인하는 단계에서 실행된다.
3. class는 장치가 메인보드 상에 꽂힌 물리적 버스 슬롯 위치만을 표현하며, 사용자가 장치에 접근하는 방식과는 연관이 없다.
디바이스 파일: 하드웨어를 파일처럼 다루는 유닉스식 철학DEVICE FILES
유닉스 계열 운영체제를 관통하는 가장 강력한 철학 중 하나는 "모든 것을 파일로 다룬다"는 개념입니다. 바로 이 디바이스 파일(device file)이 커널 내부의 복잡한 물리 I/O 장치를 외부에 일관된 형태의 특수 파일로 추상화시킨 결과물입니다. 애플리케이션 개발자는 디스크든 사운드 카드든 그저 open(), read(), write()라는 익숙한 방식을 사용해 접근하면 됩니다. 예를 들어 /dev/lp0라는 파일 경로에 텍스트 데이터를 쓰게 되면 그 데이터는 고스란히 실제 물리 프린터의 출력으로 이어집니다.
- 블록 장치 파일
- 데이터의 임의 접근(random access)이 자유롭고, 일정 크기의 블록 단위로 데이터를 고속 전송합니다. 하드디스크, CD-ROM, 플래시 메모리 등이 해당합니다.
- 문자 장치 파일
- 임의 접근이 불가능하거나 의미가 없고, 데이터가 바이트 스트림 형태로 순차적으로 흐르는 장치입니다. 사운드 카드, 마우스, 직렬 포트가 이에 속합니다.
- major number (주 번호)
- 이 장치를 제어해야 할 커널 내 특정 드라이버의 종류를 식별하는 고유 번호입니다. 대체로 같은 드라이버에 종속된 장치들은 major 번호가 같습니다.
- minor number (부 번호)
- 동일한 드라이버가 여러 개의 물리적 장치를 관장할 때, 그 안에서 구체적으로 어떤 개별 장치(또는 파티션)인지를 지목하는 일련번호입니다.
/dev/hda1과/dev/hda2를 구분하는 기준이 됩니다.
일반 파일 시스템의 파일들과 달리 디바이스 파일의 inode 정보 속에는 디스크 데이터 블록의 물리적 위치값이 들어있지 않습니다. 그 대신, 이 파일이 어떤 하드웨어와 매핑될지를 결정짓는 결정적 단서인 major/minor 번호 쌍이 기록되어 있습니다. 이러한 특수 디바이스 파일은 mknod() 시스템 콜을 통해 생성 가능하며, 놀랍게도 커널 입장에서는 파일의 문자열 이름 그 자체는 아무런 본질적 의미를 지니지 않습니다. 극단적으로 /tmp/my_disk라는 생뚱맞은 이름으로 파일을 만들더라도, 내부에 기록된 major/minor 번호 조합만 디스크 장치 번호와 일치한다면 완벽하게 디스크 장치로 기능합니다.
디바이스 파일은 사용자 공간의 VFS 세계와 커널 심연의 하드웨어 세계를 이어주는 가상의 포털이나 표지판 역할을 합니다. 눈에 보이는 파일 이름은 사람이 구분하기 위한 명찰일 뿐이고, 내부에 숨겨진 major/minor 번호야말로 커널이 실제 장치 담당 드라이버를 찾아가는 절대적인 핵심 키포인트입니다. 흥미롭게도 시스템에 존재하는 /dev/null 같은 파일은, 물리적 하드웨어 부품이 전혀 없음에도 불구하고 들어오는 데이터를 가차 없이 폐기하는 블랙홀 같은 논리적 장치 역할을 훌륭히 수행합니다.
핵심 O/X 퀴즈
1. 디바이스 파일은 겉보기엔 일반 파일과 유사하지만, 내부 inode 구조상 실제 데이터 블록 정보 대신 장치 식별 정보가 저장된다.
2. major number는 같은 드라이버가 관리하는 장치들 중 특정 개별 장치를 식별하고, minor number는 전체 시스템의 드라이버 종류를 구분 짓는다.
3. `/dev/null`은 물리적인 하드웨어 장치 없이 커널 내부적으로만 동작을 처리하는 특수한 논리 장치의 한 형태이다.
동적 장치 번호와 udev: /dev 디렉터리를 미리 가득 채워두지 않는 이유DYNAMIC NUMBERING
과거 리눅스 커널에서는 major 번호와 minor 번호가 각각 8비트 크기로 제한되어 있어 표현 가능한 장치의 수용량에 명백한 한계가 존재했습니다. 하지만 Linux 2.6 커널에 접어들며 체계가 대폭 개편되었고, 현재는 major 12비트, minor 20비트로 공간이 확장되어 총 32비트 크기의 dev_t라는 타입 안에 안전하게 인코딩됩니다. 코드 레벨에서는 MAJOR 매크로를 통해 major 번호만을 추출하고, MINOR 매크로로 minor 번호만을 꺼내며, MKDEV 매크로를 조합해 이 두 값을 합쳐 dev_t 값을 완성해 냅니다.
오늘날 시스템 디자인에서 가장 선호되는 흐름은 동적 장치 번호 할당 기법과, 이에 연계된 동적 디바이스 파일 생성 모델입니다. 새로운 디바이스 드라이버가 커널에 진입하며 등록을 시도할 때, "아직 아무도 쓰지 않는 적당한 빈 번호를 임의로 주세요"라고 커널에 요청하면 커널 자원 관리자가 즉석에서 가용한 번호 대역을 배분해 줍니다. 이 획기적인 방식 덕분에 전 세계의 수많은 새 드라이버 개발자들이 고정된 공식 major 번호를 획득하기 위해 중앙 관리 기관의 승인을 하염없이 기다릴 필요가 완전히 사라졌습니다.
/sys/class 하위의 dev 속성 파일에 할당받은 major/minor 번호를 투명하게 기록합니다./sys/class 경로를 능동적으로 스캔하여 방금 생성된 dev 파일을 포착해 냅니다./dev 디렉터리 아래에 직관적인 이름의 디바이스 파일을 동적으로 생성해 냅니다.현대 PC 환경에서는 부팅이 한참 지난 후에도 장치가 수시로 추가되거나 제거될 수 있습니다. 운영 중인 서버에 USB 저장 장치를 갑자기 꽂거나 새로운 드라이버 커널 모듈(ko)을 동적으로 적재하는 상황이 그 예입니다. 커널은 이러한 핫플러깅(Hotplugging) 하드웨어 이벤트를 민감하게 감지하여 내부 처리를 거친 후 /sbin/hotplug 헬퍼 스크립트를 호출합니다. 그리고 앞서 설명한 udev 데몬 시스템이 이 전체 흐름의 중심에 서서 사용자 공간의 상황에 꼭 들어맞는 /dev 파일들을 마법처럼 즉각 생성하고 파기하는 동적인 대응을 수행합니다.
과거의 정적 할당 방식이 시스템에서 쓰일지도 모르는 수천, 수만 개의 장치 파일들을 /dev 폴더에 미리 전부 만들어 쑤셔 넣어두는 비효율적인 방식이었다면(존재하지도 않는 모든 집에 미리 문패를 달아두는 것과 같습니다), 현재의 udev 기반 동적 생성 방식은 실제 사용자가 나타나 장치를 꽂을 때만 시스템이 주소를 즉석 배정하고 그제서야 문패를 달아주는 스마트하고 효율적인 행정 시스템에 비유할 수 있습니다. 그래서 현재 여러분의 리눅스 시스템 /dev 폴더 안에는 지금 당장 작동 가능한 의미 있는 장치들만이 깔끔하게 리스팅되는 것입니다.
핵심 O/X 퀴즈
1. 확장된 Linux 2.6 환경에서는 major 번호와 minor 번호 정보를 조합해 32비트 크기의 `dev_t` 구조체 값으로 다룰 수 있다.
2. udev 시스템은 `/sys/class` 하위에 위치한 `dev` 속성 파일의 장치 번호 정보를 스캔하여 `/dev` 아래에 디바이스 파일을 능동적이고 동적으로 만들어 낸다.
3. 동적으로 장치 번호를 할당받는 정책을 사용하게 되면, 디바이스 파일을 생성하기 위해 매번 커널 핵심 소스 코드를 열어 직접 수정하고 재컴파일해야만 한다.
VFS가 디바이스 드라이버를 은밀히 호출하는 마법의 순간OPEN CALL PATH
애플리케이션이 디바이스 파일을 open()하는 순간, VFS의 정교한 라우팅이 시작됩니다. 파일시스템이 디스크상의 inode 정보를 파싱하다가 "아, 이것은 일반 파일이 아니라 특수 목적의 디바이스 파일이구나"라고 판별하는 순간, 은밀하게 내부 함수인 init_special_inode()를 호출해 냅니다. 이 함수가 수행하는 일은 몹시 핵심적입니다. 우선 inode 구조체 내부의 i_rdev 필드에 하드웨어 매핑의 열쇠인 major/minor 번호를 꼼꼼히 기록해 넣습니다. 그리고 가장 결정적으로, 파일 조작 함수들의 진입점 포인터 집합체인 i_fop (file operations table)를 장치의 성격에 맞춰 완전히 새롭게 교체해 버립니다. 타겟이 블록 장치라면 def_blk_fops 테이블로 갈아끼우고, 문자 장치라면 def_chr_fops 기본 테이블로 바꿔치기합니다.
이 교체 작업이 완료된 직후부터는 상황이 180도 달라집니다. 사용자가 이 파일 객체를 대상으로 익숙한 read(), write(), ioctl() 호출을 날리게 되면, 이 요청은 더 이상 디스크의 일반 파일 블록을 다루는 파일시스템 모듈로 향하지 않습니다. 테이블의 가리킴에 따라 커널 속 깊은 곳에 잠복해 있던 하드웨어 디바이스 전용 드라이버의 특수 제어 함수가 직접 다이렉트로 호출되는 것입니다. 결국 VFS는 겉으로는 사용자가 평범한 텍스트 파일을 여는 것처럼 자연스럽게 속이면서, 뒤로는 내부의 치밀한 포인터 교체 로직을 통해 데이터의 물길을 디바이스 드라이버 쪽으로 매끄럽게 틀어버리는 역할을 완수합니다.
드라이버의 시스템 등록(Registration) 시점과 하드웨어 초기화(Initialization) 시점의 개념적 차이를 명확히 인지하는 것은 시스템 설계에 있어 대단히 중요합니다. 시스템 부팅 시 커널이 장치들을 감지하고 드라이버 리스트를 작성하는 '등록' 작업은 되도록 빠르게 완료해야 합니다. 그래야만 시스템과 사용자 애플리케이션이 장치의 존재를 인지하고 접근 대기를 시작할 수 있기 때문입니다. 반면에, 시스템의 공유 자원인 IRQ 예약, 거대한 DMA 버퍼 메모리의 할당, 특정 DMA 채널의 독점 확보와 같이 파급력이 큰 귀중한 시스템 자원을 실제로 긁어모으는 '초기화' 과정은 철저히 전략적으로 미뤄야 합니다. 사용자가 해당 장치를 실제로 사용하겠다고 파일을 열기 직전 시점까지 최대한 늦게 수행하는 것이 전체 시스템 효율성을 극대화하는 올바른 패턴입니다.
이러한 모범적인 설계 원칙을 준수하기 위해 대다수의 완성도 높은 드라이버들은 내부적으로 정밀한 usage counter (사용 참조 카운터) 변수를 유지하고 운용합니다. 누군가가 디바이스를 쓰기 위해 open 메서드 경로를 통과할 때 카운터 값을 1 증가시키고, 사용을 마치고 release (close) 할 때 카운터를 1 감소시킵니다. 만약 누군가가 open을 시도하는 순간 카운터 값이 0이었다면? 이는 이 장치에 접근하는 최초의 사용자임을 명백히 뜻합니다 — 바로 이 중대한 시점에 드라이버는 무거운 시스템 자원들을 비로소 정식으로 할당받아 배정하고, 잠들어 있던 하드웨어 컨트롤러의 인터럽트 라인과 DMA 엔진 채널을 본격적으로 활성화시킵니다. 그리고 오랜 시간이 흘러 모든 프로세스가 작업을 끝내고 마지막 release가 호출되어 카운터가 다시 0으로 떨어지는 순간, 드라이버는 점유했던 모든 메모리 자원을 깨끗하게 커널 풀에 반환하고 하드웨어 모듈을 즉시 비활성화 상태로 전환시킵니다.
핵심 O/X 퀴즈
1. 특수한 형태의 디바이스 파일이 열리는 찰나에, VFS는 기존의 평범한 파일시스템 연산 흐름을 가로채어 장치 전용 드라이버 함수가 호출될 수 있도록 파일 연산 테이블 포인터를 교묘하게 바꿀 수 있다.
2. 최적의 시스템 성능을 위해 드라이버의 시스템 등록 단계와 무거운 자원 할당을 동반하는 초기화 단계는 반드시 부팅 시점에 동시에 수행되어 묶여 있어야 한다.
3. 내부 usage counter 변수는 여러 애플리케이션 프로세스가 동일한 장치를 동시다발적으로 접근하고 사용하는 복잡한 상황 속에서도, 하드웨어 자원의 최초 할당 시점과 최종 반환 시점을 정확히 결정짓는 데 필수적인 역할을 제공한다.
I/O 완료의 감시 전략: 무한 반복의 polling과 우아한 interrupt 방식MONITORING I/O COMPLETION
커널 내의 디바이스 드라이버가 I/O 포트를 통해 실제 장치에 특정 명령 지시를 성공적으로 내려보냈다고 가정해 봅시다. 그렇다면 드라이버 소프트웨어는 물리적 세계에서 벌어지는 그 하드웨어적인 작동이 완전히 종료되었다는 사실을 도대체 어떤 수단을 통해 파악할 수 있을까요? 거대한 디스크 헤드가 물리적으로 이동하는 시간, 복잡한 네트워크 케이블을 통해 패킷 데이터가 도달하는 시간, 혹은 인간 사용자가 다음 키보드 버튼을 누르는 데 걸리는 시간 등은 CPU의 연산 주기와 비교하면 그 격차가 너무나도 거대하고 완료 시점을 예측하기가 사실상 불가능에 가깝습니다.
소프트웨어 구동 주체인 CPU가 하드웨어 장치의 특정 status register 영역을 끊임없이 반복적으로 읽어 확인하는 단순 무식한 방식입니다. "명령 끝났니? 아직이야? 그럼 지금은 끝났니?" 방식의 루프입니다. 구조는 단순하지만 명령 처리에 시간이 다소 걸리는 장치의 경우 고가 장비인 CPU의 사이클을 처참하게 낭비하는 치명적 단점이 있습니다. 개선책으로 반복 루프 내부에 schedule() 함수를 삽입하여, 응답이 올 때까지 CPU 점유권을 다른 유용한 대기 프로세스에게 자발적으로 양보하는 테크닉이 사용되기도 합니다.
하드웨어 장치 컨트롤러가 주도권을 쥐고, 자신의 임무가 완료되는 즉시 독립된 전용 IRQ 라인에 전기적 신호를 쏘아 CPU에게 당당히 "내 작업 다 끝났어!"라고 통보하는 방식입니다. 이 우아한 구조 속에서 드라이버는 장치에 명령을 한 번 전송한 뒤, 대기 큐(wait queue) 자료구조 속으로 들어가 스스로 깊은 잠에 빠집니다. 추후 인터럽트 핸들러가 신호를 받고 깨어나 완료된 데이터를 수거한 뒤, 비로소 잠들어 있던 프로세스를 흔들어 깨워 남은 작업을 이어가게 합니다.
인터럽트 기반 모드(interrupt mode)의 동작 예시를 좀 더 구체적으로 추적해 볼까요. 아주 단순한 입력용 문자 장치 드라이버의 foo_read() 함수는 시스템 콜을 타고 진입하면, 먼저 동시성 보호를 위해 시스템 semaphore 락을 획득하고, 이벤트 플래그인 intr 변수를 0으로 안전하게 초기화합니다. 그 직후 제어 포트(control port)에 실제 하드웨어 읽기 명령을 하달합니다. 그리고 나서는 커널 함수인 wait_event_interruptible()을 호출하여 intr 변수값이 1로 뒤바뀔 그 순간까지 대기 상태(잠금)로 진입합니다. 물리 장치가 데이터를 긁어모아 인터럽트 라인에 신호를 발생시키면 별개의 스레드처럼 동작하는 foo_interrupt() 핸들러가 즉각 튀어 올라, 장치의 입력 레지스터에서 도착한 데이터들을 버퍼로 안전하게 쓸어 담고 약속된 intr 변수를 1로 변경시킵니다. 변수가 바뀌는 순간 시스템이 잠들어 있던 foo_read() 경로의 프로세스를 깨워 애플리케이션으로 데이터를 안전하게 돌려보냅니다.
- semaphore (또는 Mutex)
- 사용자의 읽기 요청 메서드 경로와, 언제 들이닥칠지 모르는 비동기 인터럽트 핸들러 간의 자료 동시 접근 충돌을 막아주는 안전 장치입니다.
- wait queue (대기 큐)
- 결과를 기다리는 프로세스들을 관리하며 재우고(sleep) 깨우는(wakeup) 복잡한 동기화 흐름을 제어합니다.
- interrupt flag (예: intr)
- 작업 완료라는 이벤트가 실제로 발생했는지를 양쪽에 논리적으로 표시해 주는 깃발 역할의 변수입니다.
- data buffer (데이터 버퍼 영역)
- 고속의 하위 인터럽트 핸들러와 상위의 시스템 콜 데이터 반환 경로 사이에서 갓 도착한 데이터들이 머물다 가는 임시 보관 창고입니다.
현업의 실제 상용 드라이버 코드에서는 반드시 타이머를 통한 타임아웃(Timeout) 제어 로직이 꼼꼼하게 병행되어야 합니다. I/O 작업 명령을 내린 후 충분한 시간이 지났음에도 장치로부터 끝내 인터럽트 신호가 도착하지 않는다면 드라이버는 이를 하드웨어적인 치명적 오류나 행(hang) 상태로 간주하고 예외 처리에 돌입해야만 합니다. 현실의 하드웨어는 노후화되어 언제든 고장 날 확률을 내포하고 있으며, 통신 케이블은 누군가에 의해 실수로 뽑힐 수 있고, 출력 대기 중이던 프린터에는 종이가 전부 소진되어 무한 대기에 빠질 변수가 늘 존재하기 때문입니다.
핵심 O/X 퀴즈
1. polling mode는 연산 처리를 담당하는 CPU가 쉬지 않고 장치의 특정 상태 레지스터에 질의하여 작업 완료 여부를 반복적으로 확인하는 형태의 방식이다.
2. interrupt mode가 원활히 작동하기 위해서는 해당 물리 장치에 I/O 작업의 종료를 알릴 수 있는 IRQ 신호 송출 하드웨어 기능이 탑재되어 있어야만 한다.
3. 정교하게 짜여진 인터럽트 기반 드라이버 아키텍처 내부에서는 작업의 주체가 완전히 분리되므로 wait queue나 플래그 변수 같은 공통된 공유 자료구조가 일절 불필요하다.
I/O 공유 메모리와 초고속 DMA: CPU가 수백 메가바이트의 복사 노역에서 해방되다DMA & SHARED MEMORY
고성능을 요구하는 특정 하드웨어 장치들은 고속 데이터 버퍼링을 위해 기판 위에 자체적인 메모리 칩을 탑재하고 있습니다. 이렇게 디바이스가 자체적으로 보유한 I/O shared memory 공간에 커널 공간의 드라이버 코드가 접근하여 데이터를 읽거나 쓰기 위해서는, 가장 먼저 장치상의 물리적 주소 공간을 커널이 이해하고 접근 가능한 선형 주소 공간으로 매핑하는 까다로운 선행 작업이 요구됩니다. 이 목적으로 ioremap() 혹은 캐시 메커니즘을 배제하는 ioremap_nocache() 함수가 호출되며, 모든 통신이 종료되면 iounmap()으로 매핑을 조심스럽게 해제합니다. 아울러 플랫폼 간의 아키텍처 독립성과 안정성을 굳건히 유지하기 위해, 매핑된 주소에 일반 C 포인터로 섣불리 직접 접근하기보다는 커널이 제공하는 readb(), writeb(), memcpy_fromio() 같은 전용 I/O 접근 매크로 함수들을 사용하는 것이 업계의 강력한 권장 사항입니다.
이제 대규모 데이터 전송의 꽃, DMA (Direct Memory Access) 기술을 살펴보겠습니다. DMA는 놀라운 기법입니다. CPU가 최초에 전송할 데이터의 시작 주소와 바이트 크기만 DMA 전용 회로에 초기 설정해 두면, 영리한 DMA 엔진이 백그라운드에서 독자적으로 시스템 버스를 장악하여 물리 RAM과 외부 I/O 장치 사이의 거대한 데이터 덩어리를 직접 옮겨 담습니다. 전송이 완벽히 끝난 직후에만 DMA 엔진이 CPU에 단 한 번의 인터럽트를 발생시켜 결과를 보고합니다. 덕분에 수십 메가바이트에 달하는 방대한 비디오 프레임 데이터나 네트워크 패킷 전송 상황에서도, 정작 고성능 CPU는 바이트를 일일이 루프 돌며 복사하는 단순 노역에서 벗어나 그 시간에 다른 프로세스의 수학적 연산을 처리할 수 있는 막대한 여유를 얻게 됩니다.
- synchronous DMA (동기식)
- 사용자 프로그램 등 소프트웨어 프로세스의 명시적 요청이 데이터 전송의 방아쇠가 됩니다. 예: 사용자가 사운드 샘플 파일을 재생하면, 시스템이 사운드 카드의 DMA를 깨워 버퍼의 소리 데이터를 장치 DSP로 끌어가게 만듭니다.
- asynchronous DMA (비동기식)
- 외부 하드웨어 장치의 예기치 않은 데이터 수신이 전송을 자발적으로 유발합니다. 예: 기가비트 네트워크 카드가 외부로부터 거대한 패킷 프레임 덩어리를 수신해 자체 I/O shared memory에 적재하고 인터럽트를 띄우면, 깨어난 드라이버가 DMA 엔진을 가동해 그 데이터를 시스템 커널 RAM 버퍼로 신속히 복사해 올립니다.
- bus address (버스 주소)
- DMA 전송 과정에서 외부 장치 컨트롤러와 시스템 DMA 회로가 하드웨어적으로 소통하며 사용하는 전용 주소 체계입니다. 인텔 80x86 아키텍처 환경에서는 이것이 메모리 physical address와 사실상 일치하지만, 고급 아키텍처에서는 중간에 IOMMU 장비가 개입하여 주소를 복잡하게 변환해 주기도 합니다.
- coherent DMA mapping
- 커널 레벨에서 CPU의 L1/L2 캐시와 장치가 바라보는 RAM 간의 일관성을 완벽히 보장해 주는 매핑 방식입니다. 드라이버는 보통
pci_alloc_consistent()계열의 할당 함수를 통해 이 안전한 영역을 획득합니다. - streaming DMA mapping
- 드라이버 개발자가 직접 전송 방향에 맞춰 엄격한 동기화 함수를 제때 호출해야 하는 방식입니다.
pci_map_single()매핑 선언 후 데이터 방향별 sync 관련 래퍼 함수를 수동으로 제어해야 합니다.
고급 시스템 엔지니어라면 캐시 일관성(Cache Coherency)의 치명적인 함정을 반드시 경계해야 합니다. CPU가 커널 버퍼에 막대한 새 데이터를 써넣었는데 그 값이 아직 CPU 내부 고속 캐시에만 머물러 있고 실제 느린 물리 RAM 뱅크에는 내려가지(flush) 않았다면 어떻게 될까요? 이때 DMA 장치가 RAM에서 데이터를 곧장 퍼가게 되면 변경되기 전의 낡고 엉뚱한 쓰레기 값을 읽어가는 대참사가 벌어집니다. 반대로 DMA 엔진이 네트워크 패킷 등 최신 데이터를 RAM에 방금 막 내려놓았음에도 불구하고, 정작 CPU 내부에 캐싱된 과거 주소의 데이터가 갱신되지 않고 끈질기게 남아있다면 프로세스는 오래된 낡은 패킷을 들여다보는 환영에 빠집니다. 다행히 인텔 80x86 계열은 메인보드 하드웨어 단에서의 버스 스누핑(snooping) 기능으로 이 불일치를 자동 조율하지만, MIPS나 SPARC, 일부 특수한 임베디드 PowerPC 보드 환경에서는 드라이버 작성자가 커널 코드로 무효화(invalidate)와 플러시(flush) 지시를 일일이 수동 관리해야만 시스템 붕괴를 막을 수 있습니다.
핵심 O/X 퀴즈
1. `ioremap()` 커널 내부 함수는 그래픽 카드처럼 시스템에 존재하는 하드웨어 I/O 물리 주소 영역을 커널 코드가 포인터로 접근 가능한 선형 가상 주소 영역으로 안전하게 매핑해 주는 핵심 수단이다.
2. DMA 기술은 CPU가 바이트 하나하나를 레지스터를 거쳐 직접 복사하지 않아도, 전용 회로가 장치와 RAM 사이의 대량 데이터 전송을 알아서 밀어붙일 수 있도록 길을 열어준다.
3. 초고속 DMA 동작 시 빈번하게 불거지는 cache coherency 불일치 문제는 세상 모든 CPU 아키텍처 환경에서 전지전능한 하드웨어 컨트롤러가 알아서 완벽하게 해결해 주므로 디바이스 드라이버 소스 단에서는 절대 신경 쓸 필요가 없다.
커널 지원 수준과 무소불위 ioctl: 모든 장치를 커널이 깊숙이 끌어안고 지원하지는 않는다SUPPORT LEVELS
방대하고 다채로운 리눅스 생태계에서 커널이 개별 하드웨어를 인지하고 제어·지원하는 깊이는 장치의 성격에 따라 크게 세 가지 수준으로 뚜렷하게 나뉩니다.
커널 드라이버를 우회하고, 권한을 가진 애플리케이션 자체가 다이렉트로 I/O 포트에 접근하여 하드웨어를 주무르는 특수한 방식입니다. 낡고 거대한 레거시인 구형 X Window System의 비디오 그래픽 처리 로직이 대표적입니다. 이 위험한 행위를 합법적으로 수행하려면 사전에 iopl() 또는 ioperm() 시스템 콜을 통해 커널로부터 포트 접근 특별 권한을 정식으로 부여받아야 하며(대개 root 권한 요구), 보안상 극히 신중히 접근해야 합니다.
커널은 외부 하드웨어의 복잡한 실체를 묻지 않고 오로지 데이터가 오가는 I/O 인터페이스까지만을 가볍게 지원합니다. 그리고 사용자 프로그램이 /dev 디바이스 파일 경로를 거쳐 해당 외부 기기를 직접 제어하도록 자율성을 부여합니다. 다양한 프로토콜이 오가는 직렬 포트 단말기나 병렬 포트 연결 장치 등 데이터 흐름 파이프라인의 역할에 충실할 때 주로 채택되는 방식입니다.
운영체제 커널이 해당 하드웨어 장치 자체의 내부 구조와 동작 원리를 완벽하게 인지하고, I/O 인터페이스를 넘어 컨트롤러 단계까지 전면적이고 직접적으로 관여·관리하는 궁극의 지원 수준입니다. 시스템 내부에 장착된 핵심 하드디스크, 핫플러깅을 지원하는 다기능 USB 스토리지, PCMCIA 연결 규격 카드, 고성능 SCSI 외부 장치군이 여기에 편입됩니다. 예를 들어 이동식 USB 메모리를 꽂았을 때 커널이 스스로 내부의 복잡한 파티션 분할 정보를 스캔해 인식하고, 그 위에서 구동 중인 FAT32/EXT4 같은 복잡한 파일시스템 레이어의 마운트(mount) 과정까지 일괄적으로 능동 처리해 주는 고도의 편의성이 이 깊은 지원 체계를 근간으로 작동하는 것입니다.
이제 ioctl()이라는 아주 유연하고 독특한 시스템 콜을 조명해 보겠습니다. 앞서 다룬 일반적인 open(), read(), write()라는 단순한 스트림 읽기/쓰기 시스템 콜만으로는 온전히 표현해내기 불가능에 가까운 장치별 기상천외한 특수 명령들을 일괄 처리하는 데 동원되는 만능 도구입니다. CD-ROM 드라이브 장치를 향해 모터 트레이를 강제로 뱉어내라는 명령(Eject)을 하달하거나, 그래픽 어댑터 장치 내부의 숨겨진 상태 레지스터 값을 은밀히 진단하거나, 사운드 카드 하드웨어의 세밀한 출력 볼륨 게인을 조절하는 작업 등이 모두 이 함수 하나의 범주에 해당합니다. 사용법 또한 유연하여 조작할 대상의 파일 디스크립터와 약속된 요청 번호(커맨드)를 인자로 던지고, 필요시 세 번째 가변 인자를 통해 장치별 맞춤 설정값과 추가 메모리 포인터를 자유자재로 넘겨주며 제어할 수 있습니다.
쉽게 비유하자면 read()와 write()는 택배 창구에서 그저 포장된 상자 짐을 주고받는 가장 일상적이고 "기본적인 물품 취급 창구"입니다. 하지만 ioctl()은 그 기본 업무로는 도저히 감당할 수 없는, 고객의 개별적이고 "까다로운 특수 요구 전담 창구"라 할 수 있습니다. 짐을 다루는 프린터에 "종이가 얼마나 남았는지 확인해 줘", CD-ROM 드라이브에 "물건을 다 실었으니 문을 열어 줘", 사운드 처리부에 "출력 볼륨을 두 배로 증폭시켜 줘"라고 디테일하게 주문하는 것과 같은 이치입니다.
핵심 O/X 퀴즈
1. no support 방식에서는 깐깐한 커널을 우회하여 사용자 공간의 애플리케이션 프로그램이 특별한 권한 하에 직접 I/O 포트에 접근하여 데이터를 쑤셔넣을 수 있다.
2. minimal support의 정의는 시스템 커널이 장치 자체의 내부 구조를 속속들이 완전히 이해하고 파티션 분석부터 파일시스템 계층 마운트까지 모든 편의 과정을 전방위로 도맡아 처리하는 거대한 지원 방식이다.
3. `ioctl()` 시스템 콜 함수는 단순히 바이트를 밀어 넣고 꺼내는 read/write 개념만으로는 온전히 표현하거나 실행하기 난해한 장치별 다양하고 고유한 특수 제어 명령을 파견하고 처리하는 데 만능으로 사용된다.
문자 장치 드라이버의 핵심 원리: cdev 구조체와 장치 번호 범위의 할당CHARACTER DEVICE DRIVER
커널 내부에서 문자 장치(Character Device) 드라이버의 실체는 구조적으로 struct cdev라는 콤팩트한 구조체로 명확히 표현됩니다. 이 cdev 구조체 안에는 sysfs 연결을 위한 임베디드 kobject가 박혀 있으며, 드라이버 커널 모듈을 가리키는 포인터, 장치의 동작을 정의하는 파일 연산 테이블(file_operations)로 향하는 포인터 매핑, 현재 이 장치에 연결되어 열려 있는 모든 관련 inode들의 리스트, 할당받은 초기 major/minor 번호 세트, 그리고 마지막으로 이 드라이버가 커버할 수 있는 장치 번호의 수용 크기 범위 등 필수 정보들이 밀도 있게 응축되어 들어갑니다. 여기서 주목할 만한 흥미로운 점은, 하나의 드라이버 모듈이 고작 단 하나의 특정한 장치 번호에만 1:1로 묶여 작동하는 것이 아니라, 필요에 따라 광활한 장치 번호의 연속된 범위(Range) 전체를 한꺼번에 배정받아 통제하고 처리할 수 있는 유연성을 갖추고 있다는 사실입니다.
- cdev_alloc()
- 메모리 공간에 새로운 cdev 디스크립터 객체를 동적으로 안전하게 할당해 내고, 내부의 기반이 되는 임베디드 kobject 필드를 기본 상태로 초기화 세팅합니다.
- cdev_add()
- 준비가 끝난 cdev 객체를 커널의 거대한 드라이버 모델 시스템에 정식으로 띄워 등록합니다. 내부적으로
kobj_map()함수를 발동시켜 할당된 장치 번호 범위와 드라이버 간의 단단한 연결 고리를 맺어줍니다. - register_chrdev_region()
- 개발자가 이미 사용할 특정 시작 지점의 dev_t 값과 확보할 대역 크기를 확정하여 명시적으로 번호 범위를 예약 할당합니다. 이 함수 호출 직후 반드시
cdev_add()를 후속타로 별도 호출해야 장치가 실제로 가동됩니다. - alloc_chrdev_region()
- 개발자가 major 번호를 수동으로 고집하지 않고, 커널에게 "남는 빈 번호 아무거나 동적으로 크게 할당해 줘"라고 요청할 때 사용하는 현대적인 기법입니다. 이 역시 이후
cdev_add()의 별도 호출 연결이 수반되어야 합니다. - register_chrdev()
- 과거 시절 즐겨 쓰이던 낡고 일괄적인 방식입니다. 하나의 major 번호 전체 범위에 더해 minor 번호 0번부터 255번까지를 통째로 무식하게 묶어버립니다. 놀랍게도 내부적으로는 cdev의 생성부터 cdev_add() 호출까지 한 큐에 자동 수행해 주지만 범용성은 다소 떨어집니다.
문자 장치 하위 시스템 내면에는 수많은 드라이버들을 번호로 찾기 위해 cdev_map이라는 특별한 kobject 매핑 도메인이 거대한 해시 테이블 형태로 촘촘히 구축되어 있습니다. 주요 탐색 기준점은 major 번호이며, VFS가 파일을 열 때 kobj_lookup()이라는 탐색 함수에 특정 장치 번호(dev_t)를 입력값으로 던져주면, 이 함수는 쏜살같이 해시 맵을 뒤져 해당 번호 범위 대역의 합법적인 소유자인 cdev 구조체의 kobject 객체를 정확하게 찾아내어 반환해 줍니다 — 한마디로 시스템이 던지는 "지금 넘어온 이 생소한 major/minor 번호 조합은, 도대체 수많은 문자 장치 드라이버 모듈 중에서 어떤 녀석이 담당하고 책임지는 구역이지?"라는 근본적인 질문에 명확하게 해답을 찾아내 응답해 주는 핵심 자료구조인 것입니다.
현대 커널에서 정상적인 문자 장치 드라이버의 정식 등록 과정은 논리적으로 명확하게 두 단계로 나뉘어 설계되어 있습니다: ① "나(드라이버)는 커널 세계에서 어떤 major/minor 번호 대역을 배분받아 내 영토로 맡을 것인가?"를 시스템에 확실히 각인시키는 영역 선점 단계. ② "그 선점한 번호 대역의 영토로 애플리케이션의 파일 연산 요청이 날아들어 왔을 때, 구체적으로 어떤 cdev 객체와 file_operations 테이블을 가동해 이를 응대하고 처리할 것인가?"를 견고하게 묶어 연결하는 매핑 단계입니다. 이 빈틈없는 두 단계의 논리적 연결망이 완벽히 구축되어 있어야만 비로소 사용자가 /dev 폴더 아래의 무심한 장치 파일을 열었을 때, 시스템이 길을 잃지 않고 정확히 개발자가 설계한 드라이버의 C 함수 영역으로 무사히 도달할 수 있게 되는 원리입니다.
핵심 O/X 퀴즈
1. 하나의 문자 장치 전용 드라이버는 설계상 오로지 단 한 개의 고정된 장치 번호 값만을 제어할 수 있으며 번호의 범위를 한꺼번에 처리하는 것은 구조적으로 불가능하다.
2. `alloc_chrdev_region()` 커널 함수는 드라이버가 시작될 때 major 번호를 고정값으로 강제하지 않고, 현재 시스템 상황에 맞춰 빈 major 번호를 동적으로 융통성 있게 할당받고자 할 때 유용하게 사용할 수 있다.
3. `register_chrdev_region()`과 같은 최신 계열의 할당 함수들을 사용할 경우, 시스템으로부터 장치 번호 범위만 예약받는 것이 끝이 아니라 할당 직후에 드라이버 측에서 반드시 수동으로 직접 `cdev_add()` 함수를 추가 호출하여 커널 트리에 합류시켜야 한다.
문자 장치의 open 흐름 파고들기와 정교한 버퍼링 전략: 1바이트의 작은 입력부터 밀려드는 거대한 스트림까지OPEN FLOW & BUFFERING
사용자 공간(User Space)에서 특정 문자 장치 파일에 대해 open() 시스템 콜을 호출하게 되면, 커널 내부에서는 문자 장치 전용의 기본 파일 연산 테이블인 def_chr_fops 내에 자리 잡은 초기 진입점 chrdev_open() 함수가 가장 앞서서 호출되며 톱니바퀴를 돌리기 시작합니다. 이 함수는 곧바로 대상 파일의 inode 정보를 들여다봅니다. 만약 inode 내부의 i_cdev 필드 포인터가 이미 누군가에 의해 설정되어 유효한 cdev를 가리키고 있다면, 이 장치는 이미 열려 있는 것이므로 다중 접속 처리를 위해 단순하게 내부 참조 카운터 수치만을 1 증가시키고 가볍게 지나갑니다. 하지만 비어있다면 이야기가 달라집니다. 시스템은 즉시 내부 탐색기인 kobj_lookup() 함수를 강제로 발동시켜 해당 장치 번호를 소유권으로 품고 있는 등록된 범위를 맹렬하게 추적해 냅니다. 무사히 추적에 성공하여 cdev 포인터를 찾아내게 되면, 이를 텅 비어있던 i_cdev 필드에 당당히 꽂아 설정하고, 추가로 i_cindex 필드에는 할당받은 범위 대역 안에서의 위치를 나타내는 상대 인덱스 번호를 기입해 넣으며, 현재 열리는 파일의 inode 구조체 자체를 cdev 모체의 관리 inode 리스트 그룹에 소속시켜 추가합니다. 그리고 이 대장정의 마지막 하이라이트로, 열리고 있는 file 구조체 객체의 두뇌에 해당하는 기본 연산 테이블 f_ops 포인터를 완전히 들어내고, 방금 찾은 cdev 안에 담겨있던 드라이버 제작자의 정교한 맞춤형 ops 연산 테이블로 과감하게 교체(Swap)해 버립니다. 비로소 드라이버 제작자가 직접 정의해둔 전용 open 루틴 코드가 이 시점에서 마침내 실행되는 극적인 순간을 맞이하게 됩니다.
장치와 통신하며 데이터를 주고받는 버퍼링(Buffering) 전략 또한 장치의 물리적 특성과 데이터의 성질에 따라 천차만별로 설계되어야 합니다. 구형 PS/2 키보드나 마우스처럼 인간의 느릿한 동작에 기반하여 한 번에 고작 수 바이트 단위의 적은 양의 신호 데이터만을 간헐적으로 읽어 들이는 단순한 인터페이스 장치는 전략 또한 단순 담백합니다. 포트의 입력 레지스터 구역에서 한 문자(바이트)씩 차례로 조심스레 읽어 들여 커널 큐 자료구조에 누적 보관하다가, 애플리케이션의 read 요청이 떨어지면 사용자 메모리 공간으로 가볍게 복사해 넘겨주면 그만입니다. 이처럼 찰나의 순간 소량의 데이터를 다루는 환경에서는 굳이 거창한 DMA 칩을 세팅하고 깨우고 제어하는 부가적인 오버헤드 비용이 순수한 데이터의 물리적 이동 비용보다 훨씬 크게 다가올 수 있기 때문에 의도적으로 무거운 DMA 기술을 배제하고 단순 전송 기법을 채택하는 경우가 다반사입니다.
하지만 전문적인 스튜디오 사운드 카드 장비나 기가비트 네트워크 카드처럼 미리 정해진 고정 샘플링 속도나 대역폭으로 수백 메가바이트의 대량의 스트림 데이터 파도가 폭포수처럼 지속적으로 끊임없이 쏟아져 들어오는 헤비급 장치는 전략의 차원이 완전히 다릅니다. 운영체제가 스케줄링 등의 사유로 잠시 다른 무거운 프로세스 연산 작업을 처리하느라 CPU 점유율을 돌리는 그 짧은 찰나의 순간에도 외부 장치는 하염없이 거대한 오디오나 패킷 데이터를 밀어 넣고 있기 때문입니다. 이런 극한의 가혹한 환경에서 데이터 유실 없는 안정적인 스트림을 방어해 내기 위해 커널 엔지니어들은 최고급 하드웨어 제어 기술 두 가지를 영리하게 결합해 냅니다.
- DMA를 앞세운 거대 블록 단위 전송
- 연산으로 바쁜 CPU를 과감히 데이터 복사 잡무에서 쳐내버리고, 전용 DMA 컨트롤러 엔진이 직접 나서서 외부 하드웨어 장치의 버퍼에서 커널 내 메인 메모리 버퍼 공간으로 수십 메가의 데이터를 조용히 밀어 옮기는 중장비 역할을 전담합니다.
- circular buffer (무한 회전형 원형 버퍼 구조)
- 내부적으로 여러 개의 분할된 블록 크기 구획을 지닌 거대한 원형 연결 버퍼링 자료구조 체계입니다. 장치로부터 새로운 데이터 블록 덩어리가 도착하여 인터럽트 신호가 터지면, 기록 포인터를 빙글 전진시켜 다음 순서의 빈 슬롯 요소에 지체 없이 데이터를 쏟아 저장합니다. 그리고 상단의 사용자 애플리케이션 계층이 데이터를 안전하게 읽고 복사를 완료하여 소비하고 나면, 다시 그 자리를 빈 구역으로 재빠르게 마킹하여 버퍼 공간을 무한 루프로 재사용하는 정교한 알고리즘입니다.
- CPU 부하 변동 피크의 유연한 완화
- 이러한 넉넉한 공간의 circular buffer 시스템 설계 덕분에 사용자 애플리케이션이나 무거운 운영체제 프로세스가 랙이 걸려 잠시 반응이 현저히 느려지는 악재가 발생하더라도, 끄떡없이 묵묵히 일하는 DMA 엔진과 하위 인터럽트 핸들러가 여유 버퍼 슬롯들을 든든하게 계속 채워주고 받아줌으로써 오디오 끊김이나 네트워크 패킷 드랍과 같은 치명적 사고를 유연하게 완화하고 회피할 수 있게 됩니다.
라이브 오디오 방송 마이크에게 "수신 버퍼 꽉 찼으니까 아까 그 10초 전 목소리 블록 부분만 다시 재전송해서 보내줘"라고 떼를 쓸 수는 없는 노릇입니다. 마이크 입력과 같은 스트림 오디오 장치는 지나간 소리를 두 번 다시 되돌릴 수 없는 불가역적인 파도와 같아서, 물 밀듯이 끊임없이 밀려오는 비정형 데이터들을 그저 묵묵하고 신속하게 순차적으로 받아내어 처리해야만 살아남을 수 있습니다. 반면, 하드 디스크 드라이브 같은 블록 저장 장치는 동일한 논리적 블록 주소 구역을 원할 때면 언제든 몇 번이고 자유롭게 반복해서 읽어내거나 덮어쓸 수 있는 여유가 있으므로 통신 흐름은 덜 치열하지만 그 대신 데이터를 스마트하게 배치하는 훨씬 더 고차원적이고 복잡한 스케줄링 큐와 캐싱 전략이 요구되는 차이가 있습니다. 이 때문에 리눅스 커널 안에서 블록 장치의 버퍼링 아키텍처는 문자 장치와는 궤를 달리하는 훨씬 무겁고 복잡한 시스템으로 진화했습니다.
이 길고 험난했던 장을 마무리 짓는 하나의 핵심 관통 메시지입니다: 디바이스 드라이버는 온갖 복잡한 규격과 기괴한 타이밍으로 파편화된 물리적 하드웨어의 야생적인 특성들을 깊은 커널 내부에서 매끄럽게 흡수해 버리고, 그 위에 서 있는 연약한 사용자 애플리케이션들에게는 '파일'이라는 지극히 통일되고 평화로우며 단순 명쾌한 인터페이스만을 제공해 주는 가장 우아하고 위대한 커널의 번역가이자 마술사입니다.