Lập trình nhúng

Giao thức truyền thông trên STM32 (Phần 1): UART – khung dữ liệu, baudrate và nhận DMA với ngắt IDLE

UART trên STM32 từ khung dữ liệu, cách tính sai số baudrate đến nhận gói dài không biết trước bằng DMA và ngắt IDLE. Có sơ đồ, mã HAL và bảng lỗi hay gặp. Phần 1 của series giao thức truyền thông trên STM32.

12 phút đọc
Giao thức truyền thông trên STM32 (Phần 1): UART – khung dữ liệu, baudrate và nhận DMA với ngắt IDLE

Ghi chú: sơ đồ trong bài do Capitlab vẽ để minh hoạ. Số liệu trong các bảng là giá trị tính theo công thức, cột "Đo thực tế" sẽ được cập nhật khi đo trên phần cứng. Mã nguồn soạn theo tài liệu HAL và datasheet, chưa chạy thử trên phần cứng, nên hãy kiểm tra lại trước khi dùng cho sản phẩm.

Dự án nhúng nào cũng có ít nhất một cổng UART, dù chỉ để in log. Giao thức này đơn giản, nhưng các lỗi khó chịu nhất với nó (mất byte, ký tự rác, tràn bộ đệm) thường quy về hai câu hỏi: baudrate thực lệch bao nhiêu so với baudrate đặt, và làm sao biết một gói dữ liệu đã kết thúc.

Bài này trả lời hai câu hỏi đó trên STM32. Phần đầu là khung dữ liệu và cách tính sai số. Phần sau là cách nhận gói có độ dài bất kỳ bằng DMA vòng (circular) kết hợp ngắt IDLE, kèm một chi tiết hay làm người mới hiểu sai: tham số Size trong callback là vị trí ghi của DMA, không phải số byte vừa nhận.

1. Khung dữ liệu UART

UART truyền bất đồng bộ: hai bên không dùng chung clock mà thống nhất trước tốc độ và khuôn dạng. Khi rảnh, đường truyền ở mức cao. Mỗi ký tự gồm:

  1. Một bit start, kéo đường xuống thấp.
  2. Từ 5 đến 9 bit dữ liệu, bit thấp đi trước (LSB first). Hay dùng nhất là 8 bit.
  3. Một bit parity chẵn hoặc lẻ, có thể bỏ.
  4. Một hoặc hai bit stop, đưa đường về mức cao.

Dạng sóng UART gửi ký tự A (0x41): bit start, tám bit dữ liệu LSB trước, rồi bit stop

Hình 1. Khung UART 8N1 khi gửi ký tự "A" (0x41 = 01000001b). Bit D0 đi trước, nên chuỗi bit trên đường là 1, 0, 0, 0, 0, 0, 1, 0.

Khuôn dạng 8N1 (8 bit dữ liệu, không parity, 1 bit stop) chiếm 10 bit cho mỗi byte. Ở 115200 baud, thông lượng tối đa vì thế là 11 520 byte/s chứ không phải 14 400.

Bên nhận bắt cạnh xuống của bit start, sau đó lấy mẫu mỗi bit ở khoảng giữa chu kỳ. Nếu tốc độ hai bên lệch nhau nhiều, điểm lấy mẫu trôi dần và tới bit cuối thì rơi sang bit bên cạnh, gây lỗi khung (framing error). Với khung 10 bit, giới hạn lý thuyết của tổng độ lệch hai bên vào khoảng 5%. Trên thực tế nên giữ dưới 2% để còn dư cho nhiễu và cạnh xung chậm.

2. Baudrate và sai số trên STM32

STM32 tạo baudrate bằng cách chia clock của khối USART (fck). Ở chế độ lấy mẫu 16 lần (oversampling by 16, mặc định), thanh ghi BRR chứa hệ số chia theo đơn vị 1/16:

BRR ≈ fck / baud (làm tròn đến số nguyên), và baudrate thực = fck / BRR

Vì BRR là số nguyên, baudrate thực hiếm khi đúng bằng giá trị đặt. Bảng dưới là kết quả tính với fck = 84 MHz, oversampling 16. Đây là số tính, chưa phải số đo:

Baudrate mong muốn BRR Baudrate thực Sai số Thời gian 1 bit Thông lượng tối đa (8N1)
9 600 8750 9 600 0,000% 104,17 µs 960 byte/s
57 600 1458 57 613 +0,023% 17,36 µs 5 760 byte/s
115 200 729 115 226 +0,023% 8,68 µs 11 520 byte/s
230 400 365 230 137 −0,114% 4,34 µs 23 040 byte/s
460 800 182 461 538 +0,160% 2,17 µs 46 080 byte/s
921 600 91 923 077 +0,160% 1,09 µs 92 160 byte/s
1 843 200 46 1 826 087 −0,928% 0,54 µs 184 320 byte/s
3 000 000 28 3 000 000 0,000% 0,33 µs 300 000 byte/s

Bảng cho thấy độ chính xác phụ thuộc vào việc baudrate có chia "đẹp" theo clock hay không. Ở 84 MHz, 3 Mbaud chia đúng (BRR = 28), còn 1,8432 Mbaud lệch gần 1%. Nếu được tự chọn clock hệ thống, hãy chọn sao cho các baudrate bạn cần chia tròn.

Nguồn clock còn ảnh hưởng nhiều hơn hệ số chia. Dao động RC nội (HSI) kém chính xác và trôi theo nhiệt độ hơn thạch anh ngoài (HSE), nên khi chạy tốc độ cao hoặc cần chắc chắn thì dùng HSE. Con số cụ thể nằm trong datasheet của từng dòng chip.

Với oversampling 16, tốc độ tối đa là fck / 16, tức 5,25 Mbaud ở 84 MHz.

3. Nối dây

Sơ đồ nối UART: TX của STM32 nối với RX của thiết bị kia, RX nối với TX, chung GND

Hình 2. Nối chéo TX với RX, và luôn chung GND. Tên chân PA2 và PA3 ví dụ cho USART2 trên nhiều board STM32F4, hãy kiểm tra chân của dòng bạn dùng.

TX bên này nối RX bên kia và ngược lại. Hai bên phải chung GND: thiếu dây này là lý do phổ biến của ký tự rác xuất hiện ngẫu nhiên.

Mức logic cũng phải khớp. STM32 chạy 3,3 V, nên khi nối với thiết bị 5 V cần xem chân RX có chịu được 5 V không (chân FT, five-volt tolerant). Cổng RS-232 trên máy tính cũ dùng khoảng ±12 V và sẽ làm hỏng chân MCU nếu nối thẳng. Trường hợp đó cần mạch chuyển mức như MAX3232, hoặc dùng chip USB-UART. Khi cần kéo dây xa trong môi trường nhiễu, chuyển sang RS-485 (phần 6).

4. Ba cách nhận dữ liệu

Chế độ Hàm HAL Khi nên dùng Nhược điểm
Polling HAL_UART_Receive In log, thử nghiệm nhanh Chặn CPU, dễ mất byte ở tốc độ cao
Ngắt HAL_UART_Receive_IT Ít dữ liệu, độ dài biết trước Mỗi byte một ngắt, tốn CPU
DMA + IDLE HAL_UARTEx_ReceiveToIdle_DMA Dữ liệu liên tục, độ dài bất kỳ Cấu hình nhiều bước hơn

UART không có ký tự "hết gói" chuẩn. Trên STM32, cách hay dùng là ngắt IDLE line: sau khi đã nhận dữ liệu, nếu đường RX im lặng quá thời gian một ký tự thì ngoại vi báo IDLE. Ghép với DMA, cả gói được chép vào RAM mà CPU chỉ bị gọi một lần ở cuối gói.

5. Cấu hình trong STM32CubeIDE

  1. Mở file .ioc, mục Connectivity, bật USART2 ở chế độ Asynchronous.
  2. Parameter Settings:
Thông số Giá trị
Baud Rate 115200
Word Length 8 Bits (including Parity)
Parity None
Stop Bits 1
Over Sampling 16 Samples
  1. Tab DMA Settings: bấm Add, chọn USART2_RX. Đặt Mode = Circular, Data Width = Byte, bật Increment Address cho Memory.
  2. Tab NVIC Settings: bật USART2 global interrupt (ngắt IDLE cần cái này) và ngắt của kênh DMA tương ứng.
  3. Clock Configuration: kiểm tra clock cấp cho USART và nguồn HSE.
  4. Generate Code.

6. Mã nguồn

6.1 Polling: echo đơn giản

Đoạn này chỉ để xác nhận nối dây và baudrate đúng trước khi làm gì khác:

uint8_t c;
while (1) {
    if (HAL_UART_Receive(&huart2, &c, 1, HAL_MAX_DELAY) == HAL_OK)
        HAL_UART_Transmit(&huart2, &c, 1, 10);
}

6.2 Ngắt: nhận từng byte

static uint8_t rx_byte;

void uart_start(void) { HAL_UART_Receive_IT(&huart2, &rx_byte, 1); }

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart2) {
        ring_put(rx_byte);                          // cất byte vào bộ đệm
        HAL_UART_Receive_IT(&huart2, &rx_byte, 1);  // nhận tiếp
    }
}

Ở tốc độ thấp cách này đủ dùng. Lên 921 600 baud thì cứ khoảng 10,9 µs lại có một ngắt, CPU bận đáng kể chỉ để chép byte.

6.3 DMA + IDLE: nhận gói dài bất kỳ

Sơ đồ khối nhận UART: dây RX vào USART, DMA chép vào bộ đệm vòng, ngắt IDLE gọi callback

Hình 3. Luồng dữ liệu: DMA chép từng byte vào RAM mà không cần CPU. Khi đường RX im lặng, ngắt IDLE gọi callback một lần để xử lý phần dữ liệu mới.

#define RX_BUF_SIZE 256

static uint8_t           rx_buf[RX_BUF_SIZE];
static volatile uint16_t rx_last = 0;       // đã xử lý đến vị trí nào

void uart_start_rx(void)
{
    rx_last = 0;
    HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(huart2.hdmarx, DMA_IT_HT);   // bỏ ngắt nửa bộ đệm, chỉ giữ IDLE và đầy
}

// HAL gọi khi có sự kiện IDLE hoặc bộ đệm đầy.
// Size là vị trí GHI hiện tại của DMA trong bộ đệm vòng, không phải số byte lần này.
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart != &huart2) return;

    if (Size > rx_last) {                    // không quay vòng
        process(&rx_buf[rx_last], Size - rx_last);
    } else if (Size < rx_last) {             // DMA đã quay vòng
        process(&rx_buf[rx_last], RX_BUF_SIZE - rx_last);
        if (Size) process(&rx_buf[0], Size);
    }
    rx_last = (Size == RX_BUF_SIZE) ? 0 : Size;
}

Hai trường hợp đọc bộ đệm vòng: không quay vòng và có quay vòng

Hình 4. Dữ liệu mới nằm giữa rx_last và Size. Nếu DMA đã quay về đầu bộ đệm (Size nhỏ hơn rx_last), phải đọc hai đoạn: từ rx_last đến cuối, rồi từ đầu đến Size.

Với DMA Circular, đừng gọi lại HAL_UARTEx_ReceiveToIdle_DMA trong callback. Gọi lại sẽ đưa DMA về đầu bộ đệm và cách tính vị trí ở trên sai ngay.

Callback chạy trong ngắt, vì vậy process() chỉ nên chép dữ liệu hoặc đẩy vào hàng đợi, phần xử lý nặng để vòng lặp chính làm. Nếu cần phân biệt sự kiện nửa bộ đệm, đầy và IDLE, dùng HAL_UARTEx_GetRxEventType(huart) và giữ lại ngắt nửa bộ đệm.

Gói dài hơn bộ đệm vẫn được xử lý đúng: callback được gọi nhiều lần, Size tăng dần rồi quay vòng. Còn khi gặp lỗi như overrun, HAL có thể dừng nhận, nên hãy gọi lại uart_start_rx() trong HAL_UART_ErrorCallback. Hành vi này khác nhau giữa các phiên bản HAL, cần kiểm tra lại với bản bạn dùng.

6.4 Gửi bằng DMA

static volatile uint8_t tx_busy = 0;

int uart_send(const uint8_t *p, uint16_t n)
{
    if (tx_busy) return -1;                          // lần gửi trước chưa xong
    tx_busy = 1;
    if (HAL_UART_Transmit_DMA(&huart2, (uint8_t *)p, n) != HAL_OK) { tx_busy = 0; return -1; }
    return 0;
}

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart2) tx_busy = 0;
}

Bộ đệm p phải giữ nguyên cho đến khi DMA gửi xong. Không truyền con trỏ tới biến cục bộ trên stack.

7. Lỗi thường gặp

Triệu chứng Nguyên nhân thường gặp Cách xử lý
Ký tự rác Sai baudrate hoặc khuôn dạng (parity, stop bit), clock không chính xác, thiếu chung GND Kiểm tra cấu hình hai bên, dùng HSE, dùng logic analyzer giải mã UART
Mất byte, cờ Overrun (ORE) CPU không kịp đọc ở polling hoặc ngắt Chuyển sang DMA, tăng ưu tiên ngắt UART
Framing error (FE) liên tục Chân RX để hở, khác mức điện áp, lệch baudrate Nối đúng dây, kiểm tra mức logic và sai số baudrate
Chỉ nhận một phần gói rồi dừng Quên DMA Circular, bộ đệm nhỏ hơn gói, gọi lại hàm nhận trong callback Đặt Circular, tăng RX_BUF_SIZE, không gọi lại trong callback
Chương trình đứng ở HAL_UART_Transmit Timeout quá lớn (HAL_MAX_DELAY) khi chưa có thiết bị nhận Dùng timeout hữu hạn hoặc gửi bằng DMA
Dữ liệu gửi bị sai nội dung Truyền con trỏ biến cục bộ cho DMA, buffer bị ghi đè Dùng bộ đệm tĩnh hoặc toàn cục

8. Kế hoạch đo thực tế

Các phép đo dưới đây sẽ thay số tính bằng số đo. Cột "Đo thực tế" để trống cho đến khi chạy trên phần cứng.

Thử nghiệm Cách đo Giá trị tính toán Đo thực tế
Độ rộng 1 bit ở 115200 Logic analyzer, đo bit start 8,68 µs (điền sau)
Sai số baudrate ở 921600 với HSE và HSI Logic analyzer, đo độ rộng bit +0,16% (với HSE, fck 84 MHz) (điền sau)
Thông lượng tối đa ở 921600 Gửi liên tục 1 MB, đo thời gian 92 160 byte/s (điền sau)
Tỷ lệ mất byte: polling, ngắt, DMA Gửi 1 triệu byte có số thứ tự, kiểm tra bên nhận Không mất ở DMA (điền sau)
Tải CPU: ngắt so với DMA Bật chân GPIO trong callback, đo duty cycle bằng oscilloscope Không có (điền sau)
Độ trễ từ byte cuối đến callback IDLE Logic analyzer: so RX với chân GPIO trong callback Khoảng một thời gian ký tự (điền sau)

9. Hỏi đáp

UART và USART khác nhau chỗ nào? USART có thêm chế độ đồng bộ với chân clock riêng. Ở chế độ bất đồng bộ thường dùng thì hai cái như nhau, và các khối USARTx trên STM32 chạy được cả hai chế độ.

Sao 115200 baud mà chỉ được khoảng 11 KB/s? Mỗi byte tốn 10 bit trên đường (1 start, 8 dữ liệu, 1 stop). 115200 chia 10 là 11 520 byte/s.

Có nên bật parity không? Parity chỉ phát hiện được lỗi một bit và không sửa được. Đường ngắn, sạch thì dùng 8N1 rồi kiểm tra toàn vẹn ở tầng giao thức bằng checksum hoặc CRC, như Modbus RTU ở phần 7.

Bộ đệm DMA nên lớn bao nhiêu? Đủ chứa dữ liệu đến trong khoảng giữa hai lần callback được xử lý, cộng thêm phần dư. Gói lớn nhất 128 byte thì chọn từ 256 byte trở lên. Bộ đệm nhỏ quá thì DMA sẽ ghi đè lên dữ liệu chưa kịp đọc.

Dùng RTOS thì sao? Trong callback, đẩy cặp (con trỏ, độ dài) vào hàng đợi hoặc báo semaphore, rồi để một task xử lý. Không gọi hàm chặn trong callback.

Kết luận

Trước khi chọn tốc độ, tính sai số baudrate với clock thực tế của chip. Cần độ tin cậy thì dùng HSE. Với dữ liệu liên tục, dùng DMA Circular cộng ngắt IDLE, và nhớ Size là vị trí ghi chứ không phải số byte.

Phần 2 chuyển sang SPI: giao thức đồng bộ, nhanh hơn UART nhiều, với bốn chế độ CPOL/CPHA cần chọn đúng theo thiết bị.

Để tính CRC hoặc giải mã byte nhận được, có thể dùng máy tính CRC và Hex Converter của Capitlab.

Tài liệu tham khảo