리눅스 프로그래밍 수업에서 배운 kernel 컴파일 프로젝트에 대해 다루고 있다.
서문
수업 시간에 진행한 커널 컴파일 과정에서 많이 해매서, 나처럼 해매는 사람을 줄이고자, 업로드 하였다. windows의 WSL 로 리눅스 Ubuntu를 실행한 환경이다.
프로젝트 목표
- 리눅스 커널에 새로운 시스템 콜을 추가한다. 이를 통해 UDP 메시지를 전송하는 함수를 Kernel 레벨에서 활용할 수 있게 한다. Kernel에 새로운 시스템 콜을 추가하였으므로 그와 연관된 커널 파일들을 수정하여 제대로 컴파일 되도록 해야 한다. 이렇게 완성된 시스템 콜을 User-level에서 호출하여 UDP 패킷을 전송하고, 전송받도록 한다.
프로젝트 진행 과정들
시스템 콜 등록을 위해 간단히 짜본 시스템 콜 함수다. 우선 시스템 콜 함수를 등록하여 커널 컴파일 커스터마이징이 정상적으로 되는지 확인한 다음에 통신 코드를 짜야지 어디가 문제가 생겼는지 쉽게 알 수 있다. 일단 시스템 콜 등록이 정상적으로 되는지, 실수한 부분이 없는지 찾기 위해서는 간단한 코드로 짜 컴파일이 제대로 되었는지 확인해야 한다. 그래서 일단은 간단하게 예제로 작성해보았다.
#include <linux/kernel.h>
#include <linux/syscalls.h>
위 두 함수는 커널 컴파일을 위해 기본적으로 include 되어야 한다.
SYSCALL_DEFINE0(mychat)
{
printk("Hello world\n");
return 0;
}
시스템 콜 함수를 짜는데 있어서 SYSCALL_DEFINE<파라미터 갯수>(“함수명”) 으로 쓴다.
여기서 시스템 콜의 파라미터는 없으니 SYSCALL_DEFINE0, 함수명은 mychat이니 (mychat) 합쳐서 SYSCALL_DEFINE0(mychat){ } 으로 써주고 중괄호 안에 함수 내용을 써주면 된다.
여기서는 간단하게 HELLO WORLD를 출력해주는 함수를 짜보았다. printk 커널 함수는 표준 출력하는 함수가 아니다. 커널 내부에서 사용되는 로깅 함수이다. 커널의 부팅 메시지나 실행 중 발생한 에러 등을 확인할 때 dmesg를 통해 저장된 로그들을 볼 수 있다. 이는 디버깅에 유용하다. 그래서 이 메시지는 유저 level에서 c를 실행시켰을 때가 아니라, dmesg를 입력했을 때 볼 수 있다.
커널이 새로운 소스파일을 인식할 수 있도록 커널의 Makefile의 obj-y 에 내가 짠 시스템콜 함수 이름인 mychat.o를 추가로 작성하였다.
arch/<architecture>/entry/syscalls/syscall_64.tbl 파일에 내가 짠 함수를 등록해준다. 나는 uname-m 으로 아키텍쳐를 확인해 보았을 때 x86_64가 나와서 x86 폴더로 들어가서 찾았다. 기존에는 448번까지만 등록되어 있었기에 449번으로 common 내가 짠 시스템콜 파일인 mychat sys_mychat을 입력해준다. 이는 각각 시스템 콜 번호: 449, 아키텍쳐: common, 시스템 콜 함수 이름: mychat, 시스템 콜 함수 타입: sys_mychat에 대응된다. 시스템 콜 함수 번호는 이미 할당된 번호와 겹치면 안되기에 새롭게 449를 넣어주었다. Common 은 64비트와 32비트 아키텍쳐 모두 공용으로 이용할 수 있다는 의미이다. 이것과 관련하여 오류가 났었는데 조교님께서 친절하게 답변해주셔서 알게 되었다. 또한 사용자 공간에서는 mychat 이 호출되지만 커널 내부에서는 sys_mychat 함수가 실행된다.
include/uapi/asm-generic/unistd.h 파일은 NR_<syscall> 매크로를 통해 시스템 콜 번호를 설정한다. 따라서 이곳에서 새로운 시스템콜의 정의를 추가하고 총 개수를 나타내는 값도 1 증가시켜주면 된다.
기존에는 448번까지만 함수가 있었다. 그래서 새로운 449번 시스템콜로 추가할 것이기에 449번으로 다음과 같이 내가 짠 mychat 함수를 등록해주고 이후 #define에 449로 되어있던 기존 숫자는 450으로 늘려 주었다.
User-level에서 시스템 콜을 호출할 때, 컴파일러가 올바르게 호출하기 위 include/linux/syscalls.h 파일을 열고 시스템 콜 함수의 프로토타입을 헤더 파일에 추가하였다. 아직 내가 짠 함수는 인수가 없으니 인수 자리에 void가 들어간다. 함수 명을 시스템 콜에 등록한 함수와 일치시켜야 한다.
asmlinkage long sys_mychat(void);를 입력해준다.
제대로 새롭게 커널 컴파일이 되었나 확인해주기 위해 kernel 이름을 수정해준다. CONFIG_LOCALVERSION을 수정해주면 커널 컴파일이 제대로 되었을시 uname을 입력하면 제대로 되었다고 나올 것이다.
이제 커널 컴파일을 위한 모든 준비가 끝났다.
make KCONFIG_CONFIG=Microsoft/config-wsl -j 12 명령어를 입력하여 컴파일 해주자. 뒤의 숫자는 컴파일에 쓰는 CPU 코어 개수인데 가능한 컴파일을 빠르게 하기 위해 내 PC의 최대 코어인 12를 입력하였다.
컴파일이 끝나면 vmlinux 파일이 수정된다. 이를 윈도우의 C/Users/<username> 으로 복사해준다.
그 과정에서 Permission denied 오류가 발생하였다. sudo로도 실행해보고, 관리자 권한으로도 실행해보았으나. 여전히 오류라고 떴다. 아마 기존 해당 위치에 이미 vmlinux 파일이 있어서 오류가 난 것 같다. 그래서 기존 C/Users/<username> 위치의 vmlinux 파일을 삭제하고 새로운 vmlinux파일을 붙여넣어 주었다.
커널의 경로를 지정해주는 .wslconfig 파일도 c/users/<username>에 있다. 따라서 내가 짠 vmlinux 코드 경로를 수정해준다.
새로 컴파일 한 커널이 잘 작동하는 지 확인하기 위해 명령 프롬프트를 열어 WSL를 shutdown 해주고 다시 연다.
uname -a로 현재 커널의 버젼과 컴파일 된 시간을 확인한다. 컴파일 된 시간과 이름으로 시스템콜을 추가하고 진행한 커널 컴파일이 잘 되었음을 알 수 있다.
임의로 짠 시스템 콜 함수를 불러오는 간단한 c 파일이다. 시스템 콜 함수 명과 해당 번호를 정
확히 define 해주고 불러와 주어야 한다. 만약 systemcall 함수가 정상적으로 불러와졌다면 성공적
으로 test 가 되었다고 print가 되고, 그렇지 않다면 “blabla”라고 나온다.
gcc 를 이용해 시스템 콜 함수를 호출하는 간단한 프로그램 mychat.c를 mychat이라는 이름으로 컴파일 해주었다. 그러고 나서 실행을 하면 printf에 있었던 동일한 문구를 통해 정상적으로 실행 된 것을 확인 할 수 있다.
이제 시스템 콜 로그가 남는 dmesg로 들어가서 확인해보자
정상적으로 로그 Hello world 가 출력되는 걸 보고 새로 추가한 시스템 콜 잘 작동 하는 것을 알 수 있다.
그러면 이제, 본격적으로 UDP 통신을 위한 시스템 콜 함수를 작성해보자
우리는 이제 3가지 코드를 작성해야 한다.
1. 시스템 콜 커널에 등록할 진짜 mychat 함수
2. 그 mychat을 user-level에서 불러와 활용할 c언어 프로그램
3. mychat에서 보낸 메시지를 수신할 수 있는 server 프로그램
시스템 콜 함수는 다음과 같다. 내가 작성했을 때 제대로 커널 컴파일이 되지 않는 오류가 많아서 GPT에게 오류를 수정해달라고 요청하였다.
#include <linux/kernel.h> // 커널 관련 함수와 매크로를 사용하기 위해 포함한다.
#include <linux/syscalls.h> // 시스템 콜 정의를 위해 포함
#include <linux/net.h> // UDP 통신을 활용해야 하므로 네트워크 관련 구조체와 함수 포함
#include <linux/in.h> // 인터넷 프로토콜 관련 구조체와 매크로 포함
#include <linux/string.h> // 메시지 전달을 위해 문자열 처리 함수 포함
#include <linux/errno.h> // 에러 시 에러코드를 불러오기 위해 에러 정의 포함
#include <linux/slab.h> // 커널 메모리를 할당하여 전송하므로 함수 포함
#include <linux/socket.h> // 소켓을 사용하기 위한 매크로 포함
#include <linux/uaccess.h> // 사용자 공간 데이터 접근 함수 포함
#include <net/sock.h> // 소켓 관련 핵심 함수 포함
#include <linux/inet.h> // IP와 인터넷 주소 처리 함수 포함
#define MAX_MSG_LEN 1024 // 최대 메시지 길이 정의
#define ESOCKET -1 // 소켓 생성 실패 시 반환할 에러 코드
#define ESEND -3 // 메시지 전송 실패 시 반환할 에러 코드
// 시스템 콜 함수 정의한다. 인자가 3개이므로 SYSCALL_DEFINE3를 사용한다. C언어와 달리 함수명도 써주고, 인자타입과 인자 명을 쉼표로 구분해준다.
SYSCALL_DEFINE3(mychat, char __user *, ip, char __user *, msg, int, option) { //char이 아닌 char __user *로 선언해준다.
char *kernel_ip; // 커널 공간에 할당할 IP 주소 버퍼
char *kernel_msg; // 커널 공간에 할당할 메시지 버퍼
int ret = 0; // 반환 값 초기화
// 커널 메모리를 할당해주는 함수이다. 인자로 할당할 크기와 할당 방식을 넣어준다. GFP_KERNEL은 커널 메모리 할당 방식 중 하나이다.
kernel_ip = kmalloc(INET_ADDRSTRLEN, GFP_KERNEL);
if (!kernel_ip) { //IP에 할당된 메모리가 없다면
return -ENOMEM; // ENOMEM은 메모리 할당 실패 시 반환할 에러 코드이다.
}
kernel_msg = kmalloc(MAX_MSG_LEN, GFP_KERNEL);
if (!kernel_msg) { // 메시지에 할당된 메모리가 없다면
kfree(kernel_ip); // 할당된 메모리 해제한다.
return -ENOMEM; // 메모리 할당 실패 시 에러 반환한다.
}
// 유저 공간에서 커널 공간으로 데이터를 복사해준다. 복사에 실패하면 에러를 반환한다.
if (copy_from_user(kernel_ip, ip, INET_ADDRSTRLEN) != 0) {
ret = -EFAULT; // 복사 실패 시 에러 반환
goto cleanup; // 리소스 정리로 이동한다. 메모리 해제를 위해 goto문을 사용한다.
}
if (copy_from_user(kernel_msg, msg, MAX_MSG_LEN) != 0) {
ret = -EFAULT; // 마찬가지로 복사 실패 시 에러 반환
goto cleanup; // 리소스 정리로 이동
}
// 커널 내부 소켓 API를 사용하기 위한 변수 선언한다.
struct socket *sock; // 소켓 구조체 포인터
struct sockaddr_in dest_addr; // 목적지 주소 구조체
struct msghdr msg_hdr; // 메시지 헤더 구조체
struct kvec iov; // I/O 벡터 구조체는 커널 공간의 데이터를 사용하기 위한 구조체이다.
// UDP 소켓 생성
ret = sock_create(AF_INET, SOCK_DGRAM, IPPROTO_UDP, &sock); //AF_INET은 IPv4 주소 체계, SOCK_DGRAM, IPPROTO_UDP는 UDP 프로토콜을 사용한다. &sock은 소켓 구조체의 주소를 넘겨준다.
if (ret < 0) {
ret = ESOCKET; // 소켓 생성 실패 시 에러 반환
goto cleanup; // 리소스 정리로 이동
}
// 목적지 주소 설정
memset(&dest_addr, 0, sizeof(dest_addr)); // 주소 구조체 초기화해준다. &dest_addr는 주소 구조체의 주소를 넘겨주고 0으로 초기화한다. 해당 크기도 명시해준다.
dest_addr.sin_family = AF_INET; // 주소 패밀리 설정
ret = in4_pton(kernel_ip, -1, (u8 *)&dest_addr.sin_addr.s_addr, -1, NULL); // IP 주소를 변환한다.
if (ret == 0) {
ret = -EADDRNOTAVAIL; // 주소 변환 실패 시 에러 반환
goto cleanup_sock; // 소켓 정리로 이동
}
dest_addr.sin_port = htons(option); // 포트 번호를 htons 함수를 사용하여 네트워크 바이트 순서로 변환한다.
// 메시지 헤더 초기화
memset(&msg_hdr, 0, sizeof(msg_hdr)); // 메시지 헤더 구조체 초기화한다.
msg_hdr.msg_name = &dest_addr; // 목적지 주소 설정
msg_hdr.msg_namelen = sizeof(dest_addr); // 주소 길이 설정
// I/O 벡터 초기화
iov.iov_base = kernel_msg; // 메시지 설정
iov.iov_len = strlen(kernel_msg); // 메시지 길이 설정
// 메시지 전송
ret = kernel_sendmsg(sock, &msg_hdr, &iov, 1, iov.iov_len); // 메시지 전송 함수를 호출한다.
if (ret < 0) {
ret = ESEND; // 전송 실패 시 에러 반환
goto cleanup_sock; // 소켓 정리로 이동
}
ret = 0; // 성공적으로 전송 완료 시 0 반환
cleanup_sock:
sock_release(sock); // 소켓 자원을 해제한다.
cleanup:
kfree(kernel_ip); // 할당된 메모리 해제
kfree(kernel_msg); // 할당된 메모리 해제
return ret; // 최종 반환 값 반환한다.
}
새롭게 시스템 콜 함수를 짜는 과정에서 mychat이라는 이름은 바뀌지 않았지만 인자가 달라졌으므로 syscalls.h를 수정해주었다.
커널 컴파일이 완료 되었고 vmlinux 파일을 옮겨주었다.
수정한 커널 시스템 콜 함수를 이용할 userlevel 의 2가지 C코드를 짜주자
그 mychat을 user-level에서 불러와 활용할 c언어 프로그램 코드와 설명
#include <stdio.h> // c를 기본적으로 작성하기 위해서 표준 입출력 함수를 사용하기 위해 포함
#include <unistd.h> // 유닉스 표준 시스템 콜 정의를 사용하기 위해 포함
#include <sys/syscall.h> // 우리가 정의한 시스템 콜 함수를 사용하기 위해 포함한다
#include <errno.h> // 시스템 에러 번호를 정의하고 있는 헤더 파일 포함
#include <string.h> // 사용자에게 ip, message, port 등 문자열 처리 함수를 사용하기 위해 포함
#include <stdlib.h> // 표준 라이브러리 함수, 여기서는 atoi 함수 사용을 위해 포함
#define __NR_mychat 449 // mychat 시스템 콜 번호는 449번이었으므로 똑같이 정의해준다.
int main(int argc, char *argv[]) {
// 사용자로부터 IP 주소, 메시지, 포트 번호를 입력받아야 하므로 인자 개수 확인
if (argc != 4) {
printf("Usage: %s <IP address> <message> <port>\n", argv[0]);
return 1; // 올바르지 않은 인자 개수인 경우 에러 메시지 출력 후 종료시킨다.
}
char *ip = argv[1]; // 첫 번째 인자로 IP 주소 저장
char *msg = argv[2]; // 두 번째 인자로 메시지 저장
int port = atoi(argv[3]); // 세 번째 인자를 정수형으로 변환하여 포트 번호 저장
// 정의한 시스템 콜함수 'mychat' 호출한다. 인자로는 IP 주소, 메시지, 포트 번호를 전달한다.
long res = syscall(__NR_mychat, ip, msg, port);
// syscall 호출 결과 확인한다. 만약 성공적으로 실행되었다면 0을 반환한다. 실패한 경우 에러 메시지를 반환한다.
if (res == 0) {
// 성공적으로 실행된 경우 메시지 출력되는 메시지
printf("System call 'mychat' executed successfully.\n");
} else {
// 실패한 경우 에러 메시지와 함께 에러 넘버가 출력된다.
printf("System call 'mychat' failed with error: %ld (errno: %d)\n", res, errno);
}
return 0; // 프로그램을 정상적으로 종료한다.
}
User-level의 mychat 함수에서 우리가 활용한 시스템 콜 함수를 활용한다. 실행할때 IP, Message, Port를 띄어쓰기 기준으로 입력받아 작동한다.
mychat에서 메시지를 수신하는 server 코드와 설명
#include <iostream> // 입출력 관련 헤더 파일
#include <string> // 문자열 관련 헤더 파일
#include <cstring> // C 스타일 문자열 처리 헤더 파일
#include <cstdlib> // 일반 유틸리티 함수 헤더 파일
#include <unistd.h> // POSIX 운영체제 API 헤더 파일
#include <sys/socket.h> // 소켓 관련 함수와 데이터 구조 헤더 파일
#include <netinet/in.h> // 인터넷 주소 구조체 헤더 파일
#define BUF_SIZE 1024 // 버퍼 크기를 1024로 정의
void error_handling(const std::string &message); // 에러 핸들링 함수 선언
int main(int argc, char *argv[]) {
int serv_sock; // 서버 소켓 파일 디스크립터
struct sockaddr_in serv_addr, clnt_addr; // 서버와 클라이언트 주소 정보 구조체
socklen_t clnt_addr_size; // 클라이언트 주소 정보 크기
char message[BUF_SIZE]; // 메시지를 저장할 배열
int str_len; // 수신한 메시지의 길이
// 명령줄 인자 검사
if (argc != 2) {
std::cout << "Usage: " << argv[0] << " <port>\n";
exit(1);
}
// 소켓 생성
serv_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (serv_sock == -1)
error_handling("UDP socket creation error");
// 서버 소켓 주소 설정
memset(&serv_addr, 0, sizeof(serv_addr)); // 주소 정보 구조체 초기화
serv_addr.sin_family = AF_INET; // 주소 체계를 IPv4로 설정
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 서버의 IP 주소 설정
serv_addr.sin_port = htons(std::stoi(argv[1])); // 포트 번호 설정
// 소켓에 주소 할당
if (bind(serv_sock, reinterpret_cast<struct sockaddr*>(&serv_addr), sizeof(serv_addr)) == -1)
error_handling("bind() error");
clnt_addr_size = sizeof(clnt_addr);
// 클라이언트로부터 첫 번째 메시지 수신
str_len = recvfrom(serv_sock, message, BUF_SIZE, 0,
reinterpret_cast<struct sockaddr*>(&clnt_addr), &clnt_addr_size);
if (str_len == -1)
error_handling("recvfrom() error");
message[str_len] = 0; // 문자열 끝에 널 문자 추가
std::cout << "Connected Client 1" << std::endl;
std::cout << "Received message from client: " << message << std::endl;
// 소켓 닫기
close(serv_sock);
return 0;
}
// 에러 핸들링 함수
void error_handling(const std::string &message) {
std::cerr << message << std::endl;
exit(1);
}
Server 코드는 중간 UDP 통신 assignment에서 썼던 코드를 재활용하였다. 따로 시스템 콜 함수를 활용하지 않으므로 크게 수정할 부분이 없었다. Server 코드를 실행할 때는 어떤 port로 통신을 할지 port 값을 인자로 입력받는다.
User-level의 mychat 은 c파일, server는 CPP 파일이므로 명령어
g++ -o server server.cpp
gcc -o mychat mychat.c 로 각각 컴파일 해준다.
이제 잘 작동하는지 확인해보자
서버 프로그램을 port 번호와 함께 실행시키고, mychat 프로그램에 ip, message, portnumber을 차례로 입력해 실행시켰다.
서버 프로그램에서는 message를 받았고, mychat에서는 시스템 콜 함수가 정상적으로 실행되었음을 보여준다.
프로젝트를 마치며 얻은 Insight
커널 컴파일 과정에서 실수가 많았다. 대부분의 시간을 커널 컴파일 오류를 해결하는데 사용했다. 컴파일을 하고 user level에서 실행하면 여전히 ‘Syscall: Function not implemented’ 라고 나오며 즉 시스템 콜에 해당 함수가 등록되지 않았다고 나왔다. 그걸 반복하다 보니 GPT 가 주는 이상한 정보로 이상하게 컴파일하기도 했었다. 그 과정에서 vmlinux랑 vmlinuz-wsl2로 커널 파일이 2개로 쪼개지기도 하고, 수정한 부분만 컴파일 되게 하려고 하다가 vmlinux 파일 용량이 터무니 없이 작게 나오기도 하고, 그렇게 만들어진 vmlinux 파일을 적용하니 .wslconfig 파일 경로에 문제가 없는데도 WSL 초기 경로를 못잡아 우분투 실행 자체가 안되기도 하고, 건드릴 필요가 없는 makemenu config 부분을 건드렸다가 WSL 커널 전체를 재설치 하기도 하였다.
해당 문제들을 해결하기 위해 조교님을 3번이나 찾아갔다. 3번이나 찾아갔음에도 짜증내지 않고 항상 친절하게 설명해주신 조교님께 감사드린다. 조교님의 친절한 설명에 힘입어 커널 컴파일에 대한 오개념을 바로잡고 무사히 프로젝트를 마칠 수 있었다. WSL과 Vmlinux 파일에 대한 이해가 부족했었는데 올바른 위치에 컴파일 된 vmlinux를 넣어야지 윈도우에서도 잘 작동한 다는 걸 깨달았다.
간혹 컴파일 하다가 x32 즉, 아키텍쳐와 관련된 오류문구가 뜨기도 했는데 큰 지장은 없었다. 내 pc는 X86_64에 시스템 콜 함수는 common으로 선언했는데 왜 그런 오류가 떴는지 모르겠다. 지금은 뜨지 않는다.
또한 제대로 커널 컴파일이 되었을 경우 시스템 콜을 활용하는 user-level의 프로그램의 위치는 상관이 없다는 것도 알게 되었다. 시스템 콜은 위치 독립성과 표준화된 인터페이스가 제공된다. 따라서 user-level의 위치가 어디든 운영체제에 커널이 요청되므로 어느 경로에 user-level을 두든 잘 작동했다. 나는 프로젝트 초기에 user-level 파일의 위치가 잘못되어서 제대로 작동하지 않은 줄 알았다. 그게 아니라는 걸 깨닫고 커널과 운영체제에 대한 이해가 늘었다.
함부로 인공지능을 사용하지 말자는 교훈도 얻었다. 환경을 자꾸 인공지능 말대로 바꾸다가 더 오랜 시간이 걸렸다.
이 텀프로젝트를 통해 리눅스 커널을 우리가 직접 바꾸어 더 low-level한 접근을 할 수 있다는 것, 커널에서 메모리 관리와 네트워크 프로그램에 대한 이해, 시스템콜과 커널레벨의 연관성에 대해서도 깨달았다. 그리고 기존 C언어와는 차이가 있는 생소한 커널 레벨의 언어에 대해서도 배웠다. 하지만 단순한 컴퓨터 관련 지식보다 얻은 교훈이 더 많다.
시스템 콜 등록부터 막혀 버리니 포기할까 생각도 자주했다. 하지만 조교님의 도움과 밤 새가며 될때까지 직접 부딪히며 오류를 수정해 나간 끝에 텀 프로젝트를 마칠 수 있었다. 단순히 프로젝트와 관련된 지식뿐만 아니라 끈기와 문제 해결력도 기르게 되었다.
'개발' 카테고리의 다른 글
[멋쟁이 사자처럼] 12기 해커톤 후기! (2) | 2024.08.14 |
---|---|
[웹 프로그래밍] 나만의 사이트 만들기 (0) | 2024.06.30 |
[Unity] 스윙바이 게임과 유사한 2D 게임, Space (1) | 2024.06.30 |