Blog11 — Lập Trình Nhúng & Real-time (SDR-first)
Bố cục bài viết
Trang này
Ngày tạo
05/08/2026
Cập nhật
Lượt xem
4
Đọc
14m
11 — Lập Trình Nhúng & Real-time (SDR-first)
Mục tiêu: Module này lấp khoảng trống “nhúng” trong lộ trình gốc, ưu tiên nhánh gần công việc hiện tại nhất trước: lập trình real-time trên Linux để xử lý mẫu IQ từ SDR (USRP/BladeRF/LimeSDR) — nền tảng bạn sẽ cần khi đi vào nghiên cứu radio layer (srsRAN/OpenAirInterface). Phần bare-metal MCU đi sau, ở mức định hướng nhận biết — đủ để bạn biết vấn đề trông như thế nào và tìm đúng tài liệu khi thực sự cần, không phải một khoá MCU đầy đủ (điều đó cần một lộ trình riêng dài hơi hơn nhiều so với ngân sách 60 giờ).
PHẦN A — Real-time trên Linux cho SDR
1. Vì sao “real-time” trên Linux khác với code bình thường
Chương trình C bạn viết ở module 01–08 chạy đúng là đủ. Với SDR, đúng thôi chưa đủ — còn phải đúng giờ. USRP/BladeRF đẩy mẫu IQ vào máy bạn ở tốc độ cố định (vd. 10 Msps = 10 triệu mẫu/giây). Nếu code tiêu thụ mẫu chậm hơn tốc độ này dù chỉ trong vài mili-giây, buffer phần cứng tràn → mất mẫu → tín hiệu vỡ. Đây là bài toán real-time (chính xác hơn là soft real-time: trễ thỉnh thoảng chấp nhận được nhưng không được thường xuyên).
Ba kẻ thù chính của tính xác định thời gian trên Linux thường:
| Kẻ thù | Vì sao gây trễ bất ngờ |
|---|---|
| Lịch trình mặc định (CFS) | Scheduler công bằng theo thời gian, không ưu tiên tuyệt đối — tiến trình của bạn có thể bị trì hoãn để nhường CPU cho tiến trình khác |
malloc/free trong hot path | Có thể gọi syscall (brk/mmap), gây page fault, hoặc khoá nội bộ trong allocator — độ trễ không xác định (vài µs tới vài ms) |
| Page fault / swap | Trang bộ nhớ chưa được ánh xạ vật lý (demand paging) → truy cập lần đầu gây page fault → dừng vài chục µs, chết người với luồng mẫu 10 Msps |
Quy tắc thép cho code real-time: cấp phát toàn bộ bộ nhớ cần dùng trước khi vào vòng lặp nóng (hot loop). Không
malloc/new, không I/O chặn (blocking I/O), không lấy mutex tranh chấp cao, không gọi hàm không xác định thời gian thực thi — bên trong đường xử lý mẫu.
2. Công cụ đưa Linux tới gần real-time
#include <sched.h>
#include <sys/mman.h>
#include <pthread.h>
// (a) Khoá toàn bộ bộ nhớ tiến trình vào RAM vật lý — chặn page fault do swap
mlockall(MCL_CURRENT | MCL_FUTURE);
// (b) Đặt luồng đọc mẫu chạy với lớp lịch trình real-time, ưu tiên cố định
struct sched_param sp = { .sched_priority = 80 }; // 1–99, cao hơn = ưu tiên hơn
pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp);
// SCHED_FIFO: chạy tới khi tự nhường CPU hoặc bị luồng ưu tiên cao hơn giành chỗ
// SCHED_RR: giống FIFO nhưng có time-slice giữa các luồng cùng mức ưu tiên
| Lớp lịch trình | Khi dùng |
|---|---|
SCHED_OTHER (mặc định/CFS) | Code thường, không nhạy thời gian |
SCHED_FIFO | Luồng đọc mẫu/xử lý DSP — cần trễ thấp, ổn định |
SCHED_RR | Nhiều luồng real-time cùng mức ưu tiên cần chia sẻ công bằng |
Nghịch đảo ưu tiên (priority inversion): luồng ưu tiên cao chờ mutex mà một luồng ưu tiên thấp đang giữ, trong khi luồng ưu tiên trung chiếm CPU chen giữa → luồng cao bị trễ gián tiếp bởi luồng thấp hơn nó. Đây là nguyên nhân của sự cố Mars Pathfinder kinh điển. Giải pháp: priority inheritance mutex (
pthread_mutexattr_setprotocol(..., PTHREAD_PRIO_INHERIT)), hoặc đơn giản hơn — tránh mutex hoàn toàn trên đường nóng (→ mục 3).
3. Ring buffer lock-free SPSC — cấu trúc lõi cho pipeline SDR
SPSC = Single-Producer Single-Consumer. Đúng mô hình khi một luồng (do driver UHD/libbladeRF gọi callback) ghi mẫu vào, một luồng khác (xử lý/lưu/correlate của bạn) đọc ra. Với đúng một luồng ghi và một luồng đọc, ta có thể lock-free hoàn toàn — không mutex, không tranh chấp, độ trễ tối thiểu.
#include <stdatomic.h>
#include <stdint.h>
#include <stddef.h>
#define RB_CAPACITY 4096 // luỹ thừa của 2 — cho phép dùng mask thay vì modulo
typedef struct {
complex float buf[RB_CAPACITY];
// Đệm cache line (thường 64 byte) để head/tail không rơi cùng cache line
// → tránh "false sharing" (hai CPU core giẫm cache lẫn nhau dù không chia sẻ dữ liệu thật)
alignas(64) atomic_size_t head; // vị trí ghi tiếp theo (producer cập nhật)
alignas(64) atomic_size_t tail; // vị trí đọc tiếp theo (consumer cập nhật)
} ring_buffer_t;
// Producer (luồng nhận mẫu từ SDR) gọi hàm này
static inline int rb_push(ring_buffer_t *rb, complex float sample) {
size_t head = atomic_load_explicit(&rb->head, memory_order_relaxed);
size_t next = (head + 1) & (RB_CAPACITY - 1);
if (next == atomic_load_explicit(&rb->tail, memory_order_acquire))
return -1; // buffer đầy — consumer đọc không kịp
rb->buf[head] = sample;
atomic_store_explicit(&rb->head, next, memory_order_release);
return 0;
}
// Consumer (luồng xử lý DSP của bạn) gọi hàm này
static inline int rb_pop(ring_buffer_t *rb, complex float *out) {
size_t tail = atomic_load_explicit(&rb->tail, memory_order_relaxed);
if (tail == atomic_load_explicit(&rb->head, memory_order_acquire))
return -1; // buffer rỗng
*out = rb->buf[tail];
atomic_store_explicit(&rb->tail, (tail + 1) & (RB_CAPACITY - 1), memory_order_release);
return 0;
}
Vì sao
memory_order_acquire/releasechứ không phảiseq_cstmặc định: cặp acquire/release đủ để đảm bảo producer ghibuf[head]xong trước khi consumer thấyheadcập nhật (và ngược lại) — đúng thứ tự cần thiết, nhưng rẻ hơnseq_cst(không cần đồng bộ hoá toàn cục giữa mọi CPU core). Đây là kiến thức C11 memory model — nếu thấy trừu tượng, quay lạistdatomic.htrong cppreference trước khi vào lab.
So với hàng đợi có mutex (
std::queue+std::mutex): mutex có thể khiến consumer chờ nếu producer đang giữ khoá — chính là nguồn trễ bất định ta đang cố loại bỏ ở mục 1–2. Ring buffer lock-free loại hoàn toàn khả năng đó cho trường hợp 1 producer – 1 consumer.
4. Giải phẫu API driver SDR — vì sao chúng đều theo mô hình này
Ba SDK bạn có phần cứng đều xoay quanh cùng một mẫu hình: vòng lặp gọi hàm nhận chặn (blocking receive) đổ mẫu vào buffer do bạn cấp phát sẵn — đúng thứ ring buffer ở mục 3 được thiết kế để tiếp nhận.
// UHD (USRP) — C++ API, rút gọn từ ví dụ chính thức
uhd::usrp::multi_usrp::sptr usrp = uhd::usrp::multi_usrp::make("");
usrp->set_rx_rate(10e6); // 10 Msps
uhd::stream_args_t args("fc32"); // mẫu complex float32 — khớp complex float ở mục 3
uhd::rx_streamer::sptr rx = usrp->get_rx_stream(args);
std::vector<std::complex<float>> buf(1024);
uhd::rx_metadata_t md;
rx->issue_stream_cmd(uhd::stream_cmd_t::STREAM_MODE_START_CONTINUOUS);
while (running) {
size_t n = rx->recv(&buf.front(), buf.size(), md); // CHẶN tới khi có đủ mẫu
if (md.error_code != uhd::rx_metadata_t::ERROR_CODE_NONE)
fprintf(stderr, "[UHD] lỗi luồng: %s\n", md.strerror().c_str());
for (size_t i = 0; i < n; i++) rb_push(&rb, buf[i]); // đẩy vào ring buffer của bạn
}
/* libbladeRF — API kiểu C, mô hình sync giống hệt về bản chất */
bladerf_sync_config(dev, BLADERF_RX_X1, BLADERF_FORMAT_SC16_Q11,
16 /*num_buffers*/, 8192 /*buffer_size*/, 8 /*num_transfers*/, 3500 /*timeout_ms*/);
int16_t samples[8192];
bladerf_sync_rx(dev, samples, 4096, NULL, 3500); // CHẶN tới khi đủ mẫu, giống rx->recv() ở UHD
Nhận ra mẫu hình chung: dù tên hàm khác nhau (
recv,sync_rx,LMS_StreamReadcủa LimeSuite), cấu trúc luôn là (1) cấu hình rate/format, (2) vòng lặp gọi hàm chặn đổ đầy buffer, (3) đẩy dữ liệu đi xử lý tiếp. Một khi hiểu mẫu hình này qua UHD, đọc API của BladeRF/LimeSuite chỉ còn là tra cứu tên hàm.
5. Đo & debug real-time thật — đừng đoán, hãy đo
# cyclictest — đo độ trễ scheduling thực tế của hệ thống (cần rt-tests)
sudo apt install rt-tests
sudo cyclictest -p 80 -t 1 -n -i 1000 -l 10000
# -p 80: ưu tiên SCHED_FIFO 80 -i 1000: chu kỳ 1000µs -l 10000: 10000 vòng lặp
# Kết quả quan trọng nhất: cột "Max" — độ trễ tệ nhất từng gặp, không phải trung bình
# perf — tìm nguyên nhân trễ: context switch, page fault, cache miss
sudo perf stat -e context-switches,page-faults,cache-misses ./your_sdr_app
# UHD tự báo triệu chứng bằng ký tự trên console khi chạy — học đọc chúng:
# "O" = overrun: USRP đẩy mẫu nhanh hơn app đọc kịp → buffer phần cứng tràn → MẤT MẪU
# "U" = underrun: khi TRUYỀN (TX), app không đưa mẫu kịp tốc độ cần → lỗ hổng tín hiệu
Chuỗi nhân quả cần nhớ: consumer (luồng xử lý DSP của bạn) đọc
rb_popkhông đủ nhanh → ring buffer đầy →rb_pushtrả lỗi liên tục → buffer FIFO nội bộ của USRP tràn → UHD in"O".cyclictestchạy song song khi app của bạn hoạt động sẽ cho biết liệu nguyên nhân là do jitter của hệ điều hành hay do thuật toán xử lý của bạn quá chậm.
6. Fixed-point vs float — khi nào thật sự cần quan tâm
Trên host PC (nơi bạn chạy USRP/BladeRF qua USB3/Ethernet), CPU x86/ARM64 đều có FPU phần cứng mạnh — dùng thẳng float/complex float (như mục 3–4) là đúng đắn, không cần fixed-point. Vấn đề fixed-point (định dạng Q15/Q31 — số nguyên biểu diễn số thực bằng cách cố định vị trí dấu phẩy) chỉ thật sự quan trọng khi bạn đưa DSP xuống một MCU không có FPU (vd. Cortex-M0/M3 rẻ tiền dùng cho RF front-end tuỳ chỉnh) — đúng ranh giới sang Phần B bên dưới. Ghi nhớ khái niệm này, chưa cần thực hành sâu trong 60 giờ.
Bài tập / Lab Phần A
- Ring buffer benchmark: cài đặt đầy đủ
ring_buffer_tở mục 3, viết producer/consumer giả lập (producer đẩy mẫu ở tốc độ cố định bằngnanosleep, consumer đọc và đếm). So sánh thông lượng & độ trễ với một hàng đợi dùngstd::mutex+std::queue. Đo bằngperf stat. - Real-time thật: áp
mlockall+SCHED_FIFOcho luồng consumer ở bài 1. Chạycyclictestsong song, quan sát cột Max trước/sau khi bật real-time scheduling. - Đọc API thật: nếu có USRP B210 sẵn, chạy ví dụ
rx_samples_to_filecủa UHD, cố ý cho consumer xử lý chậm (thêmusleepgiả lập tải nặng) và quan sát UHD in"O". Nếu chưa có điều kiện chạy phần cứng, đọc mã nguồnuhd::rx_streamer::recv()trên GitHub UHD để xác nhận đúng mô hình mục 4.
PHẦN B — Bare-metal / MCU (định hướng, không đi sâu)
Phần này để bạn nhận diện đúng vấn đề và biết tìm gì khi công việc chạm tới firmware thật (vd. custom RF front-end, bo mạch điều khiển GPSDO/antenna switch cho lab SDR). Không phải một khoá MCU đầy đủ — con đường đó cần lộ trình riêng dài hơi hơn 60 giờ nhiều.
7. Toolchain cross-compile & những gì xảy ra trước main()
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -ffreestanding -nostdlib \
-T linker_script.ld -o firmware.elf startup.c main.c
-ffreestanding: báo compiler không có hệ điều hành bên dưới — không giả địnhlibcđầy đủ có sẵn (khôngmallocmặc định, khôngstdiothật).- Linker script (
.ld) định nghĩa vùng nhớ vật lý và nơi mỗi phần code/dữ liệu được đặt:
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS {
.isr_vector : { *(.isr_vector) } > FLASH /* bảng vector ngắt — LUÔN ở địa chỉ 0 */
.text : { *(.text*) } > FLASH /* code */
.data : { *(.data*) } > RAM AT> FLASH /* biến khởi tạo — nạp từ FLASH, chạy ở RAM */
.bss : { *(.bss*) } > RAM /* biến chưa khởi tạo — MCU không lưu, phải zero lúc khởi động */
}
- Startup code (chạy trước
main(), không ai gọi cho bạn như trên Linux): copy.datatừ FLASH vào RAM, zero.bss, rồi mới nhảy vàomain(). Đây là việc mà trên Linux, loader (ld.so) vàlibcđã làm sẵn — trên bare-metal, bạn tự viết nó.
8. Thanh ghi memory-mapped & volatile đúng nghĩa
// Địa chỉ 0x40020014 KHÔNG phải RAM thường — nó là cổng điều khiển phần cứng GPIO
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
GPIOA_ODR |= (1 << 5); // bật chân PA5 (thường là LED on-board)
volatileở đây khácvolatile sig_atomic_tbạn đã học ở module 07 (chống compiler tối ưu hoá biến bị signal handler đổi). Ở bare-metal,volatilebáo compiler: giá trị tại địa chỉ này có thể đổi bởi phần cứng, không chỉ bởi code — và mỗi lần đọc/ghi source đều phải sinh ra đúng một lệnh đọc/ghi bộ nhớ thật, không được gộp/loại bỏ dù “nhìn như thừa”. Thiếuvolatileở đây → compiler tối ưu mất lệnh → phần cứng không phản ứng, bug gần như không thể debug bằng mắt thường.
9. Khái niệm RTOS tối thiểu (qua FreeRTOS)
void vBlinkTask(void *pv) {
for (;;) {
GPIOA_ODR ^= (1 << 5);
vTaskDelay(pdMS_TO_TICKS(500)); // nhường CPU 500ms — KHÔNG busy-wait
}
}
int main(void) {
xTaskCreate(vBlinkTask, "blink", 128, NULL, 1, NULL);
vTaskStartScheduler(); // từ đây, FreeRTOS scheduler làm chủ CPU — không quay lại đây nữa
for (;;) {} // chỉ chạy nếu scheduler khởi động thất bại (hết RAM)
}
- Task ≈ thread nhẹ, mỗi task có stack riêng, scheduler chuyển ngữ cảnh (context switch) giữa chúng theo ưu tiên — cùng khái niệm
SCHED_FIFO/ưu tiên đã học ở Phần A, nhưng scheduler ở đây là code chạy thay hệ điều hành, không phải một phần của kernel Linux. - ISR → task: ngắt phần cứng (vd. có mẫu ADC sẵn sàng) không nên xử lý nặng ngay trong ISR — đẩy việc ra task qua
xQueueSendFromISR, giống pattern “chỉ đặt cờ trong signal handler” ở module 07 mục 9.
10. Debug phần cứng thật: JTAG/SWD
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg & # cầu nối máy tính ↔ chip qua SWD
arm-none-eabi-gdb firmware.elf
(gdb) target remote localhost:3333
(gdb) load # nạp firmware.elf vào FLASH thật qua SWD
(gdb) monitor reset halt
(gdb) break main
(gdb) continue # dừng đúng tại main() trên CHIP THẬT, không phải giả lập
SWD (Serial Wire Debug) là biến thể 2 dây của JTAG phổ biến trên ARM Cortex-M — cùng mục đích: cho phép gdb điều khiển CPU thật (dừng, đọc thanh ghi, đặt breakpoint) qua một mạch debug (ST-Link, J-Link…) cắm ngoài, hoàn toàn tương tự cách bạn đã dùng gdb cho tiến trình Linux ở module 02/04 — chỉ khác “tiến trình” giờ là cả con chip.
11. Khi nào công việc của bạn thực sự cần bare-metal
Với hướng đi hiện tại (nghiên cứu radio layer qua srsRAN/OpenAirInterface trên USRP/BladeRF), phần lớn công việc nằm ở Phần A — code chạy trên host Linux, giao tiếp SDR qua USB/Ethernet. Bare-metal chỉ thực sự cần khi bạn: tự thiết kế/tuỳ biến RF front-end với MCU điều khiển riêng, làm GPSDO/clock discipline tự chế, hoặc phát triển thiết bị đo/probe độc lập không dựa vào PC. Khi tới lúc đó, quay lại Phần B như bản đồ định hướng — bạn đã biết đúng từ khoá để tra cứu sâu (toolchain, linker script, RTOS, SWD).
Bài tập / Lab Phần B (chỉ cần nếu có board thật; nếu không, đọc để nắm khái niệm là đủ)
- Blink LED từ số 0: với một board STM32 rẻ (Nucleo/Blue Pill) + ST-Link, viết
startup.c+linker_script.ldtối thiểu, nạp qua OpenOCD, xác nhận LED nháy. Mục tiêu là thấy toàn bộ chuỗi build→link→flash→chạy mà không có OS bên dưới, không phải viết driver phức tạp. - FreeRTOS 2-task: thêm task thứ hai đọc trạng thái nút nhấn (polling đơn giản là đủ), gửi qua
xQueueSendđể task blink đổi tốc độ nháy khi nút được nhấn.
🎓 Cột mốc module 11
Sau module này, bạn: hiểu vì sao và làm thế nào để một luồng xử lý mẫu trên Linux đạt được tính xác định thời gian đủ dùng cho SDR (real-time scheduling, tránh malloc/mutex trên hot path, ring buffer lock-free); đọc hiểu được API của UHD/libbladeRF/LimeSuite ở mức cấu trúc chung; biết cách đo trễ thật bằng cyclictest/perf thay vì đoán; và có bản đồ định hướng đầy đủ (toolchain, linker script, register, RTOS, SWD) để bước vào bare-metal khi công việc thực sự đòi hỏi — dù chưa thành thạo tay nghề ở nhánh đó.
➡️ Đây là module cuối của lộ trình bổ sung. Quay lại 00-README-LO-TRINH.md để xem lịch 30 ngày và dự án capstone mở rộng gắn với SDR thật.
Lộ trình học tập
Lập Trình C/C++
- 1 01 — C Cơ Bản
- 2 02 — Con Trỏ & Bộ Nhớ
- 3 03 — Struct, Union, Bitfield & Bố Cục Bộ Nhớ
- 4 04 — C Nâng Cao
- 5 05 — C++ Cốt Lõi
- 6 06 — C++ Hiện Đại
- 7 07 — Lập Trình Hệ Thống & Mạng
- 8 08 — Phân Tích Giao Thức Telecom Bằng C/C++
- 9 09 — An Ninh Bộ Nhớ & Khai Thác Cơ Bản
- 10 11 — Lập Trình Nhúng & Real-time (SDR-first)
- 11 Bài tập & Pet Project — Lộ trình C/C++ chuyên sâu
- 12 Tổng hợp các dạng printf để in giá trị trong C
- 13 Bitwise & Mask, Octet, Endian: Hướng dẫn parse protocol byte cho người mới
- 14 Tổng hợp: Con trỏ, kích thước kiểu dữ liệu và byte trong bộ nhớ