우리는 지금 커널 소스를 수정하고 있는 것이 아니다. 시스템 콜 내부를 직접적으로 만지고 있는 것이 아니다. 커널에 만들어져 있는 시스템 콜을 사용할 뿐이다.
하지만 그럼에도 디바이스 드라이버 프로그래밍은 아니다. 왜냐하면 우리가 만든 함수들의 외부 어플리케이션에서 호출되고 있지 않기 때문이다.
이미 잘 만들어져 있는 표준 POSIX 커널의 시스템콜을 활용하여 모듈 프로그래밍을 진행한다. 그리고 그 모듈에 만들어진 함수를 외부 어플리케이션에서 호출하여 사용하게 되면 그때 바로 디바이스 드라이버 프로그래밍이 완성되는 것이다.
모듈을 올렸다가 내렸다가 계속 반복하면서 디버깅을 할 수 있기 때문에, 이것을 모듈 프로그래밍이라고 한다. 모듈을 올려서 내가 짠 코드가 어플리케이션에서 호출이 되어서 뭔가 작동이 일어난다면 그것이 비로소 디바이스 드라이버를 만들었다고 할 수 있을 것이다. 하지만 우리가 만들었던 과제는 어플리케이션 단에서 호출되는 것이 아니고 커널 안에서만 돌아가는 코드이기 때문에 디바이스 드라이버가 될 수 없는 것이다. 우리는 그저 모듈 안에서만 돌아가는 모듈 프로그래밍을 한 것이다.
하지만 우리가 과제로 한 모듈 프로그래밍은 사실 좋지 않은 방식이다. 원래는 커널 안에다가 무한루프를 돌려버리면 안된다.
무한루프를 돌리는게 아니라, 나중에 커널타이머를 이용하여 정해진 시간에 한번씩만 실행하도록 보통 구성을 하게 된다.
어디까지가 우리가 학습을 위해 실습용으로 한 것이고, 어떻게 하는 것이 실제 실무에서 하는 방식인지를 명확히 구분해서 이해하고 있어야 한다.
[학습용] 시스템콜 직접 만들기... 비표준 커널이 됨.
[실무] 시스템콜 직접 만들지 않음... 표준 POSIX 커널 유지.
[학습용] 모듈 프로그램 안에다가 무한루프를 넣음
[실무] 모듈 프로그램 안에다가 커널 타이머로 정해진 시간에 한번씩만 실행하도록 구성
모듈프로그램은 커널 안에서 동작하는 프로그램이다. (그래서 printk()
를 쓴 것이다.)
커널은 원형버퍼라는 메모리에 값이 출력되기 때문에, 실시간으로 print결과를 보기가 힘들다. 그래서 우리가 dmesg
, sudo cat /proc/kmsg
, tail -f /var/log/kern.log
등을 썼던 것이다.
MOD := ledkey
obj-m := $(MOD).o
CROSS = ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
#KDIR := /lib/modules/$(shell uname -r)/build
KDIR := /home/ubuntu/pi_bsp/kernel/linux
PWD := $(shell pwd)
default:
$(MAKE) -C $(KDIR) M=$(PWD) modules $(CROSS)
cp $(MOD).ko /srv/nfs
clean:
rm -rf *.ko
rm -rf *.mod.*
rm -rf .*.cmd
rm -rf *.o
rm -rf modules.order
rm -rf Module.symvers
rm -rf $(MOD).mod
rm -rf .tmp_versions
obj-m
: 얘는 다른 것들과 마찬가지로 변수이기는 하지만, 이 파일의 경우 정확하게 obj-m
라고 써야 한다. 왜냐하면 obj-m
은 모듈을 빌드할 때 obj-y
는 커널을 빌드할 때 쓰는 명령이다.(우리는 모듈을 만드는 것이 목적이었다.)
모듈은 커널 소스만 가지고 컴파일을 하기 때문에, 반드시 KDIR 변수로 정의된 커널 소스를 참조하여 빌드되도록 한다. 커널이 부팅될 타이밍에는 /usr/lib
에 접근할 수가 없다. 그래서 모듈은 반드시 커널 자체에 정적으로 컴파일되어 있는 소스만 사용할 수 있다.(그래서 <stdio.h>
처럼 파일시스템에 올라가있는 것들은 참조할 수 없다.
default:
, clean
같은것들은 라벨(레이블)
이라고 부른다. 그냥 make
를 치면 Makefile에서 첫번째로 등장하는 라벨을 실행한다. 그래서 그냥 make
를 치든 make default
를 치든 동일한 동작이 발생한다.
그리고 한 가지 또 중요한 것은 라벨 아래에 작업 내용을 명시할 때는 반드시 탭(tab)
으로 들여쓰기를 해주어야 한다는 것이다.(space 4칸
으로 하면 오류난다!! 반드시 tab
으로 들여쓰기를 해주어야 한다.)
-C
: 이 옵션은 다음에 오는 커널소스 디렉토리에 있는 Makefile을 구동시키라는 명령이다. 모듈 프로그램은 커널 소스의 일부분이기 때문에, 커널 소스의 make를 가지고 돌게 된다. 근데 거기서 obj-m
이라고 했기 때문에 모듈을
그리고 $(MAKE) -C $(KDIR) M=$(PWD) modules $(CROSS)
에서 modules
라는 것이 보이는데, 그것은 예전에 커널에서 make로 빌드할 때 build.sh에 등록되어있었던 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf zImage modules dtbs -j4
에서의 modules
와 정확히 같은 것이다.
fgets()는 버퍼 단위로 그 크기만큼 읽어오기 때문에 버퍼 오버플로우를 방지할 수 있다.
gets()는 버퍼의 사이즈를 넘기지 않고 그 시작 주소만 넘기기 때문에 버퍼 오버플로우가 발생할 수 있다.
gets() 함수는 표준 C 라이브러리 함수 중 하나로, C 언어에서 문자열을 표준 입력(stdin)으로부터 읽어들이는 함수입니다. 그러나 gets() 함수는 사용을 권장하지 않습니다. 이 함수는 버퍼 오버플로우와 관련된 보안 문제가 있어서 사용을 피하는 것이 좋습니다.
gets() 함수는 줄 단위로 문자열을 입력 받는데, 사용자가 입력한 문자열의 길이를 제한하지 않아서 버퍼 오버플로우의 가능성이 큽니다. 따라서, 안전한 대안인 fgets() 함수를 사용하는 것이 좋습니다. fgets() 함수는 문자열과 버퍼의 크기를 인자로 받아들여서 버퍼 오버플로우를 방지할 수 있습니다.
#include <stdio.h>
int main() {
char buffer[100];
// gets() 사용 (비추천)
gets(buffer);
// fgets() 사용 (권장)
fgets(buffer, sizeof(buffer), stdin);
return 0;
}
insmod는 커널의 심볼 테이블 시스템을 통해 모듈로 작성한 커널 객체를 커널에 링크시키도록 도와주는 외부 유틸리티이다.
rmmod는 마찬가지의 방법으로 커널 객체를 커널에서 삭제하도록 도와주는 외부 유틸리티이다.
pi@pi14: /mnt/ubuntu_nfs $ sudo insmod ledkey.ko
pi@pi14: /mnt/ubuntu_nfs $ sudo rmmod ledkey
그래서 우리는 위의 과제를 라즈베리파이에서 모듈을 올릴 때 이렇게 올렸었다.
그리고 그렇게 올린 모듈이 실제로 어떤 주소에 적재되어있는지 확인하려면 아래의 명령어를 통해 확인해볼 수 있다.
pi@pi14:/mnt/ubuntu_nfs $ sudo insmod ledkey.ko
pi@pi14:/mnt/ubuntu_nfs $ sudo cat /proc/kallsyms | grep ledkey
bf549040 d $d [ledkey]
bf548024 r $d [ledkey]
bf548024 r _note_10 [ledkey]
bf54803c r _note_9 [ledkey]
bf547000 t $a [ledkey]
bf547000 t gpioKeyGet [ledkey]
bf548170 r .LANCHOR0 [ledkey]
bf548054 r $d [ledkey]
bf547040 t gpioLedSet [ledkey]
bf54707c t $d [ledkey]
bf548074 r $d [ledkey]
bf548074 r .LC1 [ledkey]
bf548084 r .LC2 [ledkey]
bf54808c r .LC3 [ledkey]
bf5480b4 r .LC4 [ledkey]
bf5480e4 r .LC5 [ledkey]
bf5480ec r .LC6 [ledkey]
bf54810c r .LC7 [ledkey]
bf548134 r .LC8 [ledkey]
bf548148 r .LC9 [ledkey]
bf54814c r .LC0 [ledkey]
bf54816c r $d [ledkey]
bf54816c r .LC10 [ledkey]
bf547080 t $a [ledkey]
bf547080 t ledkey_init [ledkey]
bf5472cc t $d [ledkey]
bf54815c r .LC11 [ledkey]
bf5472d0 t $a [ledkey]
bf5472d0 t ledkey_exit [ledkey]
bf547330 t $d [ledkey]
bf548170 r $d [ledkey]
bf548170 r gpioKey [ledkey]
bf548190 r gpioLed [ledkey]
bf549000 d $d [ledkey]
bf549000 d __UNIQUE_ID___addressable_cleanup_module223 [ledkey]
bf5481b0 r $d [ledkey]
bf549040 d __this_module [ledkey]
bf5472d0 t cleanup_module [ledkey]
bf547080 t init_module [ledkey]
pi@pi14:/mnt/ubuntu_nfs $ sudo rmmod ledkey
(위에 과제 코드와 같음)
...
module_init(ledkey_init);
module_exit(ledkey_exit);
MODULE_AUTHOR("KCCI-AIOT"); // 모듈 작성자를 넣어줄 수 있다.
MODULE_DESCRIPTION("led key test module"); // 모듈에 대한 설명을 넣어줄 수 있다.
MODULE_LICENSE("Dual BSD/GPL");
이렇게 작성자와 설명을 모듈 소스코드에 추가해놓으면, 타겟머신에서 아래 명령을 통해 모듈에 대한 설명을 조회할 수 있다. (아래 modinfo
명령은 모듈이 커널에 링크 되어있든 안되어있는 조회해볼 수 있다.)
pi@pi14:/mnt/ubuntu_nfs $ modinfo ledkey.ko
filename: /mnt/ubuntu_nfs/ledkey.ko
license: Dual BSD/GPL
description: led key test module
author: KCCI-AIOT
srcversion: 148BBC035C8FEDE48D62794
depends:
name: ledkey
vermagic: 6.1.77-v7l+ SMP mod_unload modversions ARMv7 p2v8
위 터미널 결과를 보면 실제로 소스파일에 넣었던 내용이 적용되어서 출력되는 것을 확인할 수 있다.
모듈 파라미터를 주고자 하면, 넘겨주고자 하는 변수명을 static 전역으로 선언해놓고 그것을 넘겨주는 moduile_param()을 추가해줘야 한다.
main()함수의 명령행 인수처럼 받는 것이 아니고, moduile_param()을 통해서 인자를 받는다는게 포인트이다.
(기존 코드)
...
#include <linux/moduleparam.h>
static int onevalue = 1;
static char *twostring = NULL;
module_param(onevalue, int, 0);
module_param(twostring, charp, 0);
...
(기존 코드)
(아래 코드 수정하긴 했는데 생략)
여기서 중요한 것은 module parameter로 들어가는 변수의 자료형은 일반 자료형과 타입 정의가 다르다는 것이다.
이렇게 해놓고 타겟 머신에 모듈을 올려보자
pi@pi14:/mnt/ubuntu_nfs $ sudo insmod ledkey_modparam.ko onevalue=0xff twostring=\"hi there\"
pi@pi14:/mnt/ubuntu_nfs $ sudo rmmod ledkey_modparam
이렇게 치면 커널 로그에 아래와 같이 출력되며 LED가 0101 0101 로 점등되는 것을 알 수 있다.
2024-02-22T13:43:01.632351+09:00 pi14 kernel: [15718.888733] Hello, world~~ onevalue:255, twostring:hi there
printf()는 모니터 같은 표준입출력장치로 바로바로 실시간으로 출력을 해준다.
하지만 printk()는 표준 입출력장치로 출력되는 것이 아니기 때문에, 실시간으로 출력을 확인할 수 없다.
printk()는 인자로 받은 문자열을 원형큐 메모리에 저장만 해준다. 우리는 그 원형큐 메모리에 저장된 kernel 메시지을 별도의 명령을 통해서 확인하고 있다.
dmesg
sudo cat /proc/kmsg
sudo tail -f /var/log/kern.log
우리가 dmesg
를 치면 컴퓨터가 전원이 들어온 순간부터 출력된 커널 메시지가 전부 입력되어있다. 리눅스는 그 운영체제가 사용되는 특성상 1년이고 2년이고 계속 켜져있는 경우가 많다. 그러면 그동안 커널 메시지가 계속 쌓이고, 그러다 보면 언젠가 커널 메시지를 담아주느 원형큐가 꽉차게 된다. 그러면 맨 처음에 있는 메시지가 지워지면서 커널 메시지가 관리되게 된다.(자연스러운 원형큐의 작동방식)
시스템콜 하나 만들겠다고 얼마나 많은 작업을 했었는가... 진짜 사소한 버그 하나 고칠려고 해도 엄청나게 해야할 작업이 많았다.
그런데 오늘처럼 insmod
랑 rmmod
를 활용해서 모듈을 만들고 디버깅을 하니 얼마나 관리가 편했는지 모른다.
그리고 커널에 시스템 콜을 올려놓으면, 컴이 켜져있는 모든 순간에 메모리를 점유하고 있는 것이다. 하지만 모듈로 그러한 기능을 구현해놓으면 모듈을 필요한 순간에만 insmod
로 올리면 되기 때문에 메모리 관리 측면에서도 이점이 크다.
커널에서 동적 메모리 할당과 해제는 kmalloc()
, kfree()
로 이루어지게 된다.
이 목적을 위해서 반드시 모듈 프로그래밍을 할 때는 함수나 변수에 static
이라는 키워드를 사용하는 습관을 들이는게 좋다고 했다.
프로세서와 플랫폼의 이식성 문제 때문에 모듈 프로그래밍을 할 때는 가급적 리눅스 커널에서 제공되는 데이터형을 사용하는 것이 좋다. 이 데이터형은 #include <asm/types.h>
에 선언되어 있다.
거기서 가장 많이 사용되는 데이터형은 아래와 같다.
이렇게 typedef로 재정의된 타입 자료형을 통해서 프로그래밍을 해야지, 다양한 프로세서와 플랫폼에서 모두 안전하게 작동하는 코드가 된다.
(그래서 실제로 운영체제 같은 거대한 시스템의 코드를 내부적으로 까보면 전부 이렇게 타입 재정의된 자료형을 사용하고 있다.)
CPU는 데이터 버스의 크기인 4바이트대로 떵떵 읽어오는 것이 가장 오버헤드가 작다. 그래서 보통은 int, char, int, double 형태로 구조체 멤버를 선언하면, 4칸씩 쌓다가 char부분에서 1바이트만 차지하고 3바이트부분은 버려두고 그 다음 데이터버스 크기 주소에 이어서 int, double을 지정한다.
그런데 이렇게 하면 TCP/IP처럼 연속적인 데이터들의 구조가 중요한 경우 문제가 될 수 있다. 모듈프로그램을 할 때도 이러한 관점에서 왠만하면 구조체를 선언할 때, 응용 프로그램과의 정확한 데이터 교환을 위해서 데이터 버스의 크기를 무시하고 빈공간 없이 꽉꽉 채워서 메모리에 값을 지정하겠다는 뜻이 된다. 그래서 구조체를 선언할 때 pack키워드를 쓴다.
volatile는 컴파일러의 최적화를 무시하겠다는 의미이다.
스택영역에 선언된 변수 i의 값을 1만 증가시키기 위해서 엄청나게 빠른 속도로 register에 값을 ldr
, str
하는게 엄청나게 오버헤드가 커진다. 그래서 컴파일러는 이러한 오버헤드를 방지하기 위해 i같은 반복변수들은 사용자가 함수 스택영역에 선언해놨다 하더라도 자기가 알아서 전역 register 변수로 바꿔서 컴파일 해버린다. 그렇게 되면 전역 register에서 i가 관리되고 있기 때문에, i값을 1 증가 시키는 행위를 훨씬 가벼운 어셈블리 코드로 최적화 하여 진행할 수 있게 된다.
int를 스택에 잡으라고 했는데, 범용레지스터 변수로 잡아버린다.(즉 실제로 우리가 코딩할 때는 반복변수 int i;를 함수 영역 안에 선언했지만, 컴파일러가 컴파일을 하면서 변수 i를 함수 스택영역에 잡지 않고 register i 변수로 변환하여, CPU 레지스터 변수에 직접 지정해버린다는 것이다.)
그런데 이것은 임베디드시스템을 다룰 때는 아주 위험한 일이 된다. 임베디드에서는 센서와 엑츄에이터가 전부 메모리 주소로 동작한다. CPU가 동작할 때마다 항상 정확한 메모리 주소로 접근을 해서 읽어야 한다. 그런데 컴파일이 자기 맘대로 최적화를 하여 레지스터 변수로 변환되어 레지스터 주소로 접근하여 읽어버리면 HW가 이상하게 동작하는 문제가 발생한다.
그래서 OS를 쓰지 않는 펌웨어에서는 전역변수
를 메인루프와 인터럽트 서비스 루틴이 서로 공유할 때 반드시 volatile
을 써줘야 한다. 안그러면 컴파일러가 자기 마음대로 최적화를 해서 제대로된 변수 공유가 이루어지지 않게 된다. 메인루프와 인터럽트 서비스루틴에서 변수를 공유할 때 반드시 volatile이 필요한다.