Embedded System/Linux Kernel & Device Drivers

임베디드 리눅스 캐릭터 디바이스 드라이버(Character Device Driver) 기초 및 구현

임베디드 친구 2025. 3. 17. 08:32
반응형

임베디드 리눅스 환경에서 가동되는 수많은 센서, 디스플레이, 시리얼 통신 장치들은 커널 공간에서 저마다의 드라이버 형태를 취하며 사용자 공간과 소통합니다. 그중 가장 기본적이면서도 빈번하게 사용되는 아키텍처가 바로 캐릭터 디바이스 드라이버(Character Device Driver)입니다. 리눅스의 대원칙인 '모든 것은 파일이다(Everything is a file)'를 가장 직관적으로 보여주는 서브시스템으로, 사용자 애플리케이션의 파일 제어 시스템 콜이 어떻게 실제 커널 내부 메모리 영역으로 연결되는지 그 개념과 소스코드 구현, 가상 노드 테스트 절차를 명확하게 짚어보겠습니다.

Generated by Gemini AI.

핵심 요약 3줄

  • 캐릭터 디바이스 드라이버는 데이터를 바이트(Byte) 단위의 스트림 형태로 순차 처리하는 커널 모듈 인터페이스입니다.
  • 사용자 공간의 open, read, write, release 시스템 콜은 커널 내부의 file_operations 구조체 맵과 1대1로 대응하여 동작합니다.
  • 드라이버가 할당받은 메이저 번호를 기반으로 /dev 경로에 특수 파일 노드를 생성함으로써 가상 하드웨어 제어 환경이 완성됩니다.

1. 캐릭터 디바이스 드라이버의 개념과 통신 매커니즘

캐릭터 디바이스 드라이버는 마우스, 키보드, 콘솔 시리얼 포트처럼 데이터를 정해진 블록 크기 없이 연속적인 바이트 단위로 다룰 때 사용됩니다. 사용자 공간의 애플리케이션이 표준 가상 파일 시스템(VFS) 함수를 호출하면, 커널 내부에서 아래 표와 같은 대응 관계를 거쳐 디바이스 영역으로 제어권이 이양됩니다.

사용자 공간 시스템 콜 (User Space Call) 커널 드라이버 매핑 함수 (File Operations) 데이터 전송 및 처리 목적
open("/dev/...", ...) .open (예: simple_open) 드라이버 인스턴스 참조 및 하드웨어 사용 준비
read(fd, buf, count) .read (예: simple_read) 커널 공간의 데이터를 사용자 공간 버퍼로 전송 (copy_to_user)
write(fd, buf, count) .write (예: simple_write) 사용자 공간의 데이터를 커널 공간 버퍼로 전송 (copy_from_user)
close(fd) .release (예: simple_close) 장치 점유 해제 및 사용했던 메모리 자원 반환

또한 장치를 고유하게 식별하기 위해 리눅스 커널은 메이저(Major) 번호와 마이너(Minor) 번호 체계를 부여하여 드라이버를 관리합니다.

장치 식별자 분류 역할 및 할당 범위 실무적 의미
메이저 번호 (Major Number) 특정 드라이버 유형 자체를 식별 어떤 디바이스 드라이버 소스코드가 이 장치를 책임지는지 결정
마이너 번호 (Minor Number) 해당 드라이버가 제어하는 물리 장치 개별 식별 동일한 드라이버 칩셋 내에서 0번 포트, 1번 포트 등 세부 장치 구분

2. 가상 캐릭터 디바이스 드라이버 소스코드 구현

사용자가 문자열을 쓰면 커널 내부 가상 버퍼에 저장해 두었다가, 다시 읽으려고 시도할 때 저장된 데이터를 안전하게 출력해 주는 가상 드라이버 소스코드 구조입니다.

1. simple_char_driver.c 소스코드

C
 
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>

#define DEVICE_NAME "simple_char"
#define BUFFER_SIZE 1024

static int major;
static char buffer[BUFFER_SIZE];
static struct cdev simple_cdev;

// 가상 디바이스 파일 열기 매핑
static int simple_open(struct inode *inode, struct file *file) {
    pr_info("simple_char: Device opened\n");
    return 0;
}

// 가상 디바이스 파일 닫기 매핑
static int simple_close(struct inode *inode, struct file *file) {
    pr_info("simple_char: Device closed\n");
    return 0;
}

// 커널 공간 메모리를 사용자 애플리케이션 영역으로 안전하게 이송
static ssize_t simple_read(struct file *file, char __user *user_buf, size_t count, loff_t *ppos) {
    size_t len = strlen(buffer);
    if (*ppos >= len) return 0; // 파일 끝 (EOF) 도달 처리

    if (count + *ppos > len) count = len - *ppos;
    
    // 안전한 유저 공간으로의 메모리 카피 수행
    if (copy_to_user(user_buf, buffer + *ppos, count)) return -EFAULT;

    *ppos += count;
    pr_info("simple_char: Read %zu bytes\n", count);
    return count;
}

// 사용자 애플리케이션 메모리 데이터를 커널 공간 버퍼에 안전하게 적재
static ssize_t simple_write(struct file *file, const char __user *user_buf, size_t count, loff_t *ppos) {
    if (count > BUFFER_SIZE - 1) return -EINVAL;

    memset(buffer, 0, BUFFER_SIZE);
    
    // 유저 공간에서 커널 공간으로 안전하게 메모리 복사
    if (copy_from_user(buffer, user_buf, count)) return -EFAULT;

    pr_info("simple_char: Received %zu bytes\n", count);
    return count;
}

// 파일 연산 메커니즘 매핑 테이블 구조체 정의
static struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = simple_open,
    .release = simple_close,
    .read = simple_read,
    .write = simple_write,
};

// 모듈 커널 탑재 시 초기화 루틴
static int __init simple_init(void) {
    dev_t dev;
    int ret;

    // 1. 메이저 번호 자동 동적 할당
    ret = alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("simple_char: Failed to allocate device number\n");
        return ret;
    }

    major = MAJOR(dev);
    
    // 2. 캐릭터 디바이스 구조체 초기화 및 fops 연결
    cdev_init(&simple_cdev, &fops);

    // 3. 커널 하위 시스템에 캐릭터 장치 추가
    ret = cdev_add(&simple_cdev, dev, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev, 1);
        pr_err("simple_char: Failed to add cdev\n");
        return ret;
    }

    pr_info("simple_char: Registered with major %d\n", major);
    return 0;
}

// 모듈 커널 해제 시 정리 루틴
static void __exit simple_exit(void) {
    cdev_del(&simple_cdev);
    unregister_chrdev_region(MKDEV(major, 0), 1);
    pr_info("simple_char: Unregistered device\n");
}

module_init(simple_init);
module_exit(simple_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Software Factory");
MODULE_DESCRIPTION("A simple character device driver");

3. Makefile 작성 및 빌드 환경

타깃 보드가 구동 중인 현재 리눅스 커널 소스 헤더의 빌드 트리 파일 경로를 바라보도록 환경 매핑을 수행하여 모듈을 엮어냅니다.

Makefile
 
obj-m += simple_char_driver.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

작성이 완료된 경로 위에서 아래 빌드 명령어를 실행하면 simple_char_driver.ko 확장자 커널 파일이 도출됩니다.

Plaintext
 
make

4. 모듈 삽입 및 사용자 공간 인터페이스 가상 테스트

추출된 오브젝트 파일을 리눅스 메모리에 적재하고 가상 파일 노드를 개설하여 실제 입출력 동작을 제어하는 검증 절차입니다.

1단계: 모듈 메모리 상주 및 메이저 번호 확인

Plaintext
 
# 드라이버 적재
sudo insmod simple_char_driver.ko

# 커널 메시지 추적하여 동적 할당된 메이저 번호 확인
dmesg | tail -n 2
[  245.123456] simple_char: Registered with major 244

2단계: 가상 디바이스 파일 생성 및 권한 설정

도출된 메이저 번호(예시: 244)를 바탕으로 파일 시스템 하위 디렉토리에 문자(c) 타입을 의미하는 특수 노드를 개설합니다.

Plaintext
 
sudo mknod /dev/simple_char c 244 0
sudo chmod 666 /dev/simple_char

3단계: 사용자 공간 입출력 파이프라인 테스트

사용자 공간 셸의 일반 입출력 리다이렉션 명령어를 통해 데이터 교환이 성립하는지 확인합니다.

Plaintext
 
# 데이터 쓰기 (write 매핑 호출)
echo "Hello, Embedded Linux!" > /dev/simple_char

# 데이터 읽기 (read 매핑 호출)
cat /dev/simple_char
Hello, Embedded Linux!

4단계: 테스트 완료 후 자원 정리

Plaintext
 
sudo rmmod simple_char_driver
sudo rm /dev/simple_char

开发을 위한 팁

  • class_create를 이용한 노드 자동 생성: 매번 mknod 명령어를 수동으로 입력하는 작업은 비효율적입니다. 드라이버 초기화(__init) 구문 내부에 class_create() 함수와 device_create() 함수를 유기적으로 추가해 두면, insmod를 실행하는 순간 /dev/ 하위에 장치 노드가 자동으로 생성되어 개발 공정이 무척 편리해집니다.
  • copy_to_user 반환 값 검증: copy_to_user()와 copy_from_user() 함수는 일반적인 C 언어의 메모리 복사 함수와 달리 반환 형태가 독특합니다. 복사에 실패하고 남은 바이트 수가 리턴되므로 성공적인 검증을 위해서는 예외 처리 구문으로 if (copy_to_user(...) != 0) 형태의 리턴 플래그 구조를 촘촘히 짜두어야 커널 패닉을 방지할 수 있습니다.
  • 비동기 가동을 위한 Mutex 잠금 도입: 실무 장치 제어 루틴에서는 여러 프로세스가 동시에 한 파일 노드에 write 연산을 시도할 수 있습니다. 버퍼 메모리 데이터 오염을 예방하기 위해 드라이버 내부 전역 변수 영역에 struct mutex 잠금 락 구조를 선언하고 연산 전후로 락을 쥐고 푸는 동기화 습관이 요구됩니다.

흔히 하는 실수

  • __user 포인터 직접 참조로 인한 커널 크래시: 가끔 드라이버 내에서 일반 버퍼 다루듯 user_buf 메모리 주소에 포인터 간접 참조(*user_buf)로 직접 값을 대입하려 시도하는 실수를 범합니다. 유저 공간 주소와 커널 공간 주소는 가상 메모리 매핑 체계가 완전히 다르기 때문에 주소를 직접 참조하면 커널 세그멘테이션 폴트가 터져 장비가 즉시 뻗어버리므로 반드시 전용 하이퍼바이저 복사 함수 인터페이스를 거쳐야 합니다.
  • read 마크다운 오프셋 제어 누락으로 인한 무한 루프: simple_read 함수에서 읽기 연산 처리가 끝난 후 파일 포인터 위치값인 *ppos 오프셋 값을 누르고 증가시키지 않거나, 남은 바이트 수가 없음에도 계속해서 양수 값을 리턴해 버리면 사용자 공간의 cat 명령어 프로세스는 파일 끝에 도달하지 못했다고 판단하여 동일한 메모리 버퍼를 무한정 읽어 들이는 다운 현상에 빠집니다.
  • 장치 해제 시 cdev_del 누락: 모듈을 언로드하는 __exit 함수 내부에서 unregister_chrdev_region만 수행하고 cdev_del 구조체 해제 명령을 누락하면, 커널 내부 메모리 맵에는 가상 캐릭터 장치 참조 객체가 좀비 상태로 고착되어 다음 번 재등록 시 심각한 충돌 레이스를 유발하게 됩니다.

결론

캐릭터 디바이스 드라이버는 파일이라는 가장 직관적이고 표준화된 창구를 통해 하이레벨 사용자 소프트웨어 레이어와 로우레벨 하드웨어 레지스터 물리 공간을 안전하게 바인딩해 주는 주춧돌입니다. 파일 연산자 구조체의 콜백 원리를 이해하고 공간 간 메모리 이동 제약을 명확히 인지한다면, 센서 인터페이스 제어부터 복잡한 주변 장치 응용 드라이버 개발까지 한층 더 고도화된 시스템 설계를 능숙하게 소화해 낼 수 있습니다.

반응형