Skip to content
LIVE FEED

Blog11 — Lập Trình Nhúng & Real-time (SDR-first)

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 pathCó 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 / swapTrang 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ìnhKhi dùng
SCHED_OTHER (mặc định/CFS)Code thường, không nhạy thời gian
SCHED_FIFOLuồng đọc mẫu/xử lý DSP — cần trễ thấp, ổn định
SCHED_RRNhiề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/release chứ không phải seq_cst mặc định: cặp acquire/release đủ để đảm bảo producer ghi buf[head] xong trước khi consumer thấy head cập nhật (và ngược lại) — đúng thứ tự cần thiết, nhưng rẻ hơn seq_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ại stdatomic.h trong 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_StreamRead củ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_pop không đủ nhanh → ring buffer đầy → rb_push trả lỗi liên tục → buffer FIFO nội bộ của USRP tràn → UHD in "O". cyclictest chạ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

  1. 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ằng nanosleep, consumer đọc và đếm). So sánh thông lượng & độ trễ với một hàng đợi dùng std::mutex + std::queue. Đo bằng perf stat.
  2. Real-time thật: áp mlockall + SCHED_FIFO cho luồng consumer ở bài 1. Chạy cyclictest song song, quan sát cột Max trước/sau khi bật real-time scheduling.
  3. Đọc API thật: nếu có USRP B210 sẵn, chạy ví dụ rx_samples_to_file của UHD, cố ý cho consumer xử lý chậm (thêm usleep giả 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ồn uhd::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ả định libc đầy đủ có sẵn (không malloc mặc định, không stdio thậ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 .data từ FLASH vào RAM, zero .bss, rồi mới nhảy vào main(). Đâ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ác volatile sig_atomic_t bạn đã học ở module 07 (chống compiler tối ưu hoá biến bị signal handler đổi). Ở bare-metal, volatile bá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ếu volatile ở đâ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à đủ)

  1. Blink LED từ số 0: với một board STM32 rẻ (Nucleo/Blue Pill) + ST-Link, viết startup.c + linker_script.ld tố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.
  2. 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. 1 01 — C Cơ Bản
  2. 2 02 — Con Trỏ & Bộ Nhớ
  3. 3 03 — Struct, Union, Bitfield & Bố Cục Bộ Nhớ
  4. 4 04 — C Nâng Cao
  5. 5 05 — C++ Cốt Lõi
  6. 6 06 — C++ Hiện Đại
  7. 7 07 — Lập Trình Hệ Thống & Mạng
  8. 8 08 — Phân Tích Giao Thức Telecom Bằng C/C++
  9. 9 09 — An Ninh Bộ Nhớ & Khai Thác Cơ Bản
  10. 10 11 — Lập Trình Nhúng & Real-time (SDR-first)
  11. 11 Bài tập & Pet Project — Lộ trình C/C++ chuyên sâu
  12. 12 Tổng hợp các dạng printf để in giá trị trong C
  13. 13 Bitwise & Mask, Octet, Endian: Hướng dẫn parse protocol byte cho người mới
  14. 14 Tổng hợp: Con trỏ, kích thước kiểu dữ liệu và byte trong bộ nhớ
Contact
Facebook
TOP