Kiến trúc eBPF và Luồng biên dịch trên ARM64
Trên kiến trúc ARM64 của Raspberry Pi Compute Module 5 (CM5), quy trình chuyển đổi từ mã nguồn C sang mã máy thực thi trong kernel diễn ra qua ba giai đoạn: biên dịch thành BPF bytecode, kiểm duyệt bởi BPF Verifier, và tối ưu hóa bằng JIT Compiler.
Chúng ta sẽ thực hành trực tiếp để quan sát luồng dữ liệu này. Đầu tiên, hãy tạo một chương trình C đơn giản sử dụng eBPF để làm mẫu.
Việc này giúp bạn hiểu cách trình biên dịch LLVM chuyển đổi logic C sang tập lệnh ảo (instruction set) của BPF.
cat > /tmp/hello_bpf.c
File C đã được tạo tại /tmp/hello_bpf.c với một handler kprobe đơn giản.
Tiếp theo, sử dụng trình biên dịch clang để chuyển đổi file C sang file object chứa BPF bytecode (.o).
clang -O2 -target bpf -c /tmp/hello_bpf.c -o /tmp/hello_bpf.o
Biên dịch thành công, tạo ra file /tmp/hello_bpf.o chứa BPF bytecode (bpf_insn). Nếu lỗi, kiểm tra xem clang và llvm-dev đã cài đúng phiên bản tương thích với kernel chưa.
Để xem bytecode BPF bên trong file .o, sử dụng công cụ bpf2bpf hoặc objdump (nếu có bpf backend).
llvm-objdump -d /tmp/hello_bpf.o
Đầu ra hiển thị các lệnh assembly của BPF (như mov, add, call) dưới dạng hex và mnemonic. Đây là dạng mã trung gian độc lập với kiến trúc CPU vật lý.
Quan trọng nhất trên ARM64 là bước JIT (Just-In-Time) Compilation. Khi kernel tải chương trình này, BPF JIT Compiler sẽ dịch BPF bytecode thành mã máy ARM64 thực tế để chạy trực tiếp trên CPU CM5, thay vì mô phỏng.
Kiểm tra xem kernel của bạn có hỗ trợ JIT cho BPF hay không.
cat /sys/kernel/bpf/jit_enable
Đầu ra phải là 1. Nếu là 0, JIT bị tắt và eBPF sẽ chạy chậm hơn nhiều do phải giải mã bytecode thủ công.
Bật JIT nếu nó đang tắt (cần quyền root).
echo 1 > /sys/kernel/bpf/jit_enable
JIT đã được kích hoạt. Bây giờ khi load chương trình, kernel sẽ tự động biên dịch sang ARM64 native code.
Verify Luồng biên dịch
Để xác nhận JIT đã hoạt động, chúng ta sẽ load chương trình vào kernel và xem trạng thái của nó.
bpftool prog load /tmp/hello_bpf.o /sys/fs/bpf/hello_map map_type hash key_size 4 value_size 4 0
File map và program đã được load vào BPF filesystem. Nếu có lỗi, kiểm tra lại phiên bản kernel và header file.
bpftool prog show
Đầu ra hiển thị danh sách chương trình đang chạy. Tìm chương trình vừa load, quan sát cột "jited". Giá trị "yes" chứng tỏ JIT Compiler đã chuyển đổi bytecode sang ARM64 machine code thành công.
BPF Verifier và Ràng buộc Bảo mật
BPF Verifier là một bộ lọc tĩnh nằm trong kernel Linux, có nhiệm vụ kiểm tra tính an toàn của BPF bytecode trước khi cho phép thực thi.
Mục đích chính là ngăn chặn các chương trình eBPF gây sập kernel (kernel panic), truy cập bộ nhớ trái phép, hoặc gây deadlock (vòng lặp vô hạn).
Trên môi trường nhúng như CM5, nơi tài nguyên RAM và CPU hạn chế, Verifier càng quan trọng để đảm bảo tính ổn định của hệ thống.
Chúng ta sẽ tạo một chương trình cố tình vi phạm quy tắc để quan sát Verifier từ chối nó.
cat > /tmp/bad_bpf.c
File /tmp/bad_bpf.c chứa một mảng stack quá lớn, vi phạm giới hạn stack tối đa của eBPF (thường là 512 bytes).
Biên dịch file này thành object file.
clang -O2 -target bpf -c /tmp/bad_bpf.c -o /tmp/bad_bpf.o
Biên dịch thành công vì clang không biết các ràng buộc runtime của kernel. Lỗi sẽ xảy ra khi load vào kernel.
Thử load chương trình này vào kernel bằng bpftool.
bpftool prog load /tmp/bad_bpf.o /sys/fs/bpf/bad_map map_type hash key_size 4 value_size 4 0
Lệnh sẽ báo lỗi và in ra thông điệp từ BPF Verifier. Thông điệp sẽ chỉ rõ lỗi "invalid stack offset" hoặc "R1 register not safe for map lookup".
Đây là cơ chế bảo vệ: Kernel từ chối chạy mã không an toàn ngay lập tức.
Các Ràng buộc Bảo mật Chính
Verifier thực hiện các kiểm tra nghiêm ngặt sau:
- Giới hạn Stack: Stack của eBPF bị giới hạn cứng (thường 512 bytes). Không được phép khai thác stack tràn.
- Không có Vòng lặp vô hạn: Code không được phép có vòng lặp while(true) hoặc vòng lặp không có điều kiện thoát rõ ràng.
- Truy cập bộ nhớ: Chỉ được phép truy cập bộ nhớ đã được khởi tạo an toàn hoặc thông qua map được cấp phát bởi kernel.
- Call Graph: Các hàm gọi (function calls) phải nằm trong một danh sách trắng (whitelist) các helper functions của kernel.
Trên ARM64, Verifier còn kiểm tra các register (R0-R15) đảm bảo chúng không bị dùng sai mục đích, đặc biệt là register R10 (stack pointer).
Để xem chi tiết lỗi từ Verifier, hãy bật chế độ debug (cần kernel config CONFIG_BPF_JIT_DEBUG).
dmesg | grep -i "bpf_verifier"
Đầu ra sẽ hiển thị log chi tiết về quá trình kiểm duyệt, chỉ ra dòng lệnh BPF nào bị từ chối và lý do.
Verify Ràng buộc
Thử load một chương trình hợp lệ để đảm bảo Verifier chỉ từ chối code xấu.
bpftool prog load /tmp/hello_bpf.o /sys/fs/bpf/hello_map2 map_type hash key_size 4 value_size 4 0
Lệnh chạy thành công không báo lỗi, xác nhận Verifier hoạt động đúng: chặn code xấu, cho phép code tốt.
Khác biệt giữa BPF cổ điển và eBPF trên Môi trường Nhúng
BPF cổ điển (Classic BPF - cBPF) là công nghệ từ những năm 1990, sử dụng bộ lệnh đơn giản với 4 register và chỉ hỗ trợ các thao tác cơ bản như filter gói tin mạng.
Extended BPF (eBPF) là thế hệ mới, giới thiệu bộ lệnh phong phú hơn, hỗ trợ JIT, map phức tạp và khả năng truy cập sâu vào kernel (kprobe, tracepoint).
Trên Raspberry Pi CM5 (ARM64), sự khác biệt này thể hiện rõ về hiệu năng và khả năng mở rộng.
So sánh Hiệu năng và Khả năng
cBPF trên ARM64 thường được mô phỏng (interpreted) bởi kernel, gây overhead lớn cho CPU. eBPF được JIT biên dịch thành mã ARM64 native, tận dụng tối đa hiệu năng của Cortex-A76 trên CM5.
cBPF chỉ có thể dùng cho packet filtering (tcpdump, iptables). eBPF có thể giám sát hệ thống, mạng, storage, và debug kernel.
Chúng ta sẽ kiểm tra xem kernel của bạn đang hỗ trợ loại nào.
grep -E "CONFIG_BPF|CONFIG_BPF_JIT" /boot/config-$(uname -r)
Đầu ra hiển thị các flag cấu hình. Nếu thấy CONFIG_BPF_JIT=y, nghĩa là kernel hỗ trợ eBPF với JIT trên ARM64. Nếu thiếu CONFIG_BPF, có thể chỉ hỗ trợ cBPF hoặc không hỗ trợ gì.
Trên CM5, chúng ta luôn ưu tiên eBPF để giảm tải cho CPU và tăng độ trễ thấp (low latency).
Giới hạn tài nguyên trên Nhúng
Khác với server x86_64, CM5 có RAM hạn chế (4GB-8GB). eBPF map tiêu tốn RAM. Cần cẩn trọng khi tạo map quá lớn.
cBPF không dùng map phức tạp, nên ít tốn RAM hơn nhưng cũng ít tính năng hơn.
Để kiểm tra mức sử dụng RAM của các map eBPF đang chạy.
bpftool map list
Đầu ra hiển thị kích thước (max_entries) và loại map. Tổng kích thước = max_entries * key_size * value_size. Đảm bảo tổng không vượt quá 10-20% RAM tổng thể để tránh OOM (Out of Memory).
Verify Sự khác biệt
Thử chạy một lệnh tcpdump (dựa trên cBPF) và so sánh với eBPF.
tcpdump -i eth0 -c 10 "ip" &
tcpdump chạy và bắt gói tin. Nó sử dụng cBPF bytecode đơn giản để filter.
Ngừng tcpdump và dùng eBPF để làm việc tương tự (giả lập qua XDP hoặc kprobe).
pkill tcpdump
Chúng ta đã thấy tcpdump dùng cBPF. Trong các phần sau của series, bạn sẽ thấy eBPF có thể làm được nhiều hơn tcpdump (như tính toán latency, sửa đổi gói tin) với hiệu năng cao hơn trên ARM64 nhờ JIT.
Để kiểm tra xem chương trình eBPF đang chạy có thực sự được JIT không, dùng lại lệnh bpftool prog show.
bpftool prog show | grep -E "name|jited"
Các chương trình eBPF hiển thị "jited: yes" chứng tỏ chúng đang chạy ở tốc độ gần native code ARM64, vượt trội hơn nhiều so với cBPF bị mô phỏng.
Điều hướng series:
Mục lục: Series: Tối ưu hóa Linux Kernel cho Raspberry Pi CM5 với BPF và eBPF
« Phần 2: Cấu hình Kernel và cài đặt công cụ eBPF cần thiết
Phần 4: Viết và biên dịch chương trình eBPF đầu tiên (Hello World) »