Bỏ qua tới nội dung
vinexus.dev
Build log

Đọc lidar LD14 bằng ESP32 và xem ảnh quét ngay trên trình duyệt

Tài liệuBL-001
Ngày đăng2026-09-10
Sửa đổi—
Tagslidar, esp32, esp-idf
Ngôn ngữvi

Lidar 2D giá rẻ giờ đã rất dễ mua: LDROBOT LD14 chỉ khoảng 300–400 nghìn, quay 6–10 vòng/giây, tầm đo 8m, và quan trọng nhất là không cần thư viện gì cả — nó tự phun dữ liệu ra UART ngay khi có điện.

Bài này làm đúng một việc: cắm LD14 vào ESP32, rồi mở trình duyệt trên điện thoại để xem nó “thấy” gì trong phòng. Toàn bộ chương trình nằm trong một file main.c khoảng 360 dòng, chạy trên ESP-IDF (không dùng Arduino).


1. Cần những gì

Món Ghi chú
ESP32 DevKit (WROOM) Xem lưu ý về WROVER ở phần nối dây
Lidar LDROBOT LD14 LD06 / LD19 cũng chạy được, chỉ khác baud
Nguồn 5V ≥ 500mA Lidar có động cơ, đừng lấy từ chân 5V của mạch nạp USB yếu
ESP-IDF v5.x hoặc v6.x Bài này build trên v6.0.2

2. Nối dây

LD14 ra 4 dây. Chỉ cần nối 3:

Dây lidar Nối vào ESP32 Ghi chú
TX (dữ liệu) GPIO 16 (UART2 RX) Lidar chỉ phát, không nhận lệnh
5V 5V / VIN Qua nguồn riêng nếu chạy pin
GND GND Phải chung mass với ESP32
PWM (điều khiển tốc độ) bỏ trống Không nối thì lidar chạy tốc độ mặc định

Mặt dưới LD14 với 4 dây: xanh lá là 5V, vàng là TX, đen là GND, đỏ là PWM không dùng

Trên con LD14 của mình: dây xanh lá là 5V, vàng là TX, đen là GND, còn dây đỏ (PWM) bỏ trống. Màu dây có thể khác giữa các lô hàng — cứ nhìn nhãn in trên board (TX, PWM/RX, 5V, GND) là chắc nhất.

Ba điểm dễ sai:

  • Mức logic của LD14 là 3.3V, nối thẳng vào GPIO là an toàn, không cần mạch chia áp.
  • Chỉ nối TX của lidar vào RX của ESP32. Đây là giao tiếp một chiều. Nối ngược là im lặng hoàn toàn, không có thông báo lỗi nào.
  • Nếu bạn dùng module WROVER thì GPIO 16/17 đã bị PSRAM chiếm — đổi sang cặp chân khác (ví dụ GPIO 4/5) và sửa LIDAR_RX_PIN trong code.

3. Giao thức: 47 byte một gói

Đây là phần thú vị nhất và cũng là phần tài liệu chính thức nói khá sơ sài. LD14 dùng chung giao thức DTOF với LD06/LD19. Mỗi gói dài đúng 47 byte và chứa 12 điểm đo:

0x54 0x2C | speed(2) | góc đầu(2) | 12×[khoảng cách(2) + cường độ(1)] | góc cuối(2) | timestamp(2) | CRC8(1)
  • 0x54 là byte đồng bộ, 0x2C là “phiên bản + độ dài”, cố định với dòng này (thực chất 0x2C = 12 điểm mỗi gói).
  • Góc tính theo đơn vị 0.01 độ — 9000 nghĩa là 90.00°.
  • Khoảng cách tính theo mm. Giá trị 0 nghĩa là “không có vật phản xạ ở hướng này” (quá xa, hoặc mặt hấp thụ như vải đen, hoặc kính).
  • Cường độ 0–255, cho biết tín hiệu phản hồi mạnh yếu. Rất hữu ích để lọc điểm rác.
  • speed là độ/giây, chia 6 thì ra vòng/phút.
  • Một gói chỉ cho 12 điểm trong một cung khoảng 12°, nên góc của từng điểm phải nội suy đều giữa góc đầu và góc cuối.

Tin tốt: mọi số nhiều byte đều little-endian, trùng với ESP32. Nên không phải ghép byte tay, chỉ cần khai báo struct và memcpy là đọc được:

typedef struct __attribute__((packed)) {
    uint16_t distance;    /* mm, 0 = không có vật phản xạ */
    uint8_t  intensity;   /* 0..255 */
} point_raw_t;

typedef struct __attribute__((packed)) {
    uint8_t     header;        /* 0x54 */
    uint8_t     ver_len;       /* 0x2C */
    uint16_t    speed;         /* độ/giây */
    uint16_t    start_angle;   /* 0.01° */
    point_raw_t points[12];
    uint16_t    end_angle;     /* 0.01° */
    uint16_t    timestamp;
    uint8_t     crc8;
} lidar_packet_t;

/* Nếu trình biên dịch chèn byte đệm thì mọi phép đọc sau đây đều lệch.
 * Chốt cứng kích thước ngay lúc biên dịch, thay vì đi tìm lỗi lúc chạy. */
_Static_assert(sizeof(lidar_packet_t) == 47, "gói phải đúng 47 byte");

__attribute__((packed)) là bắt buộc: không có nó, trình biên dịch sẽ chèn byte đệm để uint16_t nằm ở địa chỉ chẵn, struct phình lên 48–52 byte và mọi trường sau header đều đọc sai. Dòng _Static_assert là cái chốt an toàn — nếu sai, build gãy ngay, chứ không để bạn ngồi đoán vì sao khoảng cách toàn số vô lý.

CRC8: đừng bỏ qua

Byte cuối là CRC8 với đa thức 0x4D, MSB-first, khởi tạo 0. Con số 0x4D này không phải chuẩn CRC8 phổ biến nào, nên đừng copy hàm CRC8 trên mạng về dùng.

Kiểm CRC không phải chuyện làm cho đẹp. Byte 0x54 hoàn toàn có thể xuất hiện giữa một giá trị khoảng cách. Nếu chỉ dò header mà không kiểm CRC, bạn sẽ thỉnh thoảng “bắt” được một gói giả và nhận về một điểm đo ở khoảng cách 30 mét trong một căn phòng 4 mét.

static uint8_t s_crc_table[256];

static void crc_table_init(void)
{
    for (int i = 0; i < 256; i++) {
        uint8_t crc = (uint8_t)i;
        for (int b = 0; b < 8; b++) {
            crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x4D) : (uint8_t)(crc << 1);
        }
        s_crc_table[i] = crc;
    }
}

Dựng bảng tra 256 phần tử lúc khởi động đổi 8 phép dịch bit mỗi byte thành một lần tra bảng. LD14 phát khoảng 192 gói mỗi giây, tức ~9000 byte/giây cần kiểm, nên khoản này đáng.

4. Dò gói trong dòng byte

UART không trả về “từng gói”. Nó trả về từng khúc byte tuỳ ý, có thể cắt ngang giữa gói. Vòng lặp dò phải tự xử lý chuyện đó:

static size_t parse_buffer(const uint8_t *buf, size_t len)
{
    size_t i = 0;
    while (i + PKT_SIZE <= len) {
        if (buf[i] != PKT_HEADER || buf[i + 1] != PKT_VERLEN) {
            i++;
            continue;
        }
        if (crc8(buf + i, PKT_SIZE - 1) != buf[i + PKT_SIZE - 1]) {
            s_err_count++;
            i++;         /* CRC sai: rất có thể là header giả, trượt 1 byte */
            continue;
        }
        lidar_packet_t pkt;
        memcpy(&pkt, buf + i, PKT_SIZE);
        handle_packet(&pkt);
        i += PKT_SIZE;
    }
    return i;   /* số byte đã tiêu thụ; phần đuôi giữ lại cho lần sau */
}

Hàm trả về số byte đã tiêu thụ, phần đuôi chưa đủ một gói được memmove về đầu buffer và chờ lần đọc tiếp. Chi tiết quan trọng: khi CRC sai thì trượt 1 byte, không phải 47 — vì rất có thể ta vừa bắt được một header giả và gói thật đang nằm ở đâu đó phía sau.

5. Gom 12 điểm/gói thành một vòng 360°

Mỗi gói chỉ là một cung nhỏ. Để có “ảnh” quét, ta chia vòng tròn thành 360 ô, mỗi ô 1°, và đổ điểm vào ô tương ứng:

static void handle_packet(const lidar_packet_t *p)
{
    float start = p->start_angle * 0.01f;
    float end   = p->end_angle * 0.01f;
    float span  = end - start;
    if (span < 0.0f) span += 360.0f;      /* gói vắt qua mốc 0° */
    float step = span / 11;               /* 12 điểm = 11 khoảng */

    /* Lidar không có cờ "hết vòng". Góc đầu gói nhỏ hơn gói trước nghĩa là
     * kim đã quay qua mốc 0°, tức vừa xong một vòng. */
    if (s_last_start_deg >= 0.0f && start < s_last_start_deg) {
        finish_scan();
    }
    s_last_start_deg = start;

    for (int i = 0; i < 12; i++) {
        uint16_t d = p->points[i].distance;
        if (d == 0) continue;
        float ang = start + step * i;
        while (ang >= 360.0f) ang -= 360.0f;
        int bin = (int)(ang + 0.5f) % 360;
        /* Nhiều điểm rơi cùng một ô: giữ điểm có tín hiệu mạnh hơn */
        if (s_building.dist_mm[bin] == 0 ||
            p->points[i].intensity > s_building.intensity[bin]) {
            s_building.dist_mm[bin] = d;
            s_building.intensity[bin] = p->points[i].intensity;
        }
    }
}

Hai điểm đáng chú ý:

Phát hiện hết vòng. Lidar không gửi cờ nào báo “xong một vòng”. Cách duy nhất là để ý khi góc đầu gói nhỏ hơn góc đầu của gói trước — lúc đó kim vừa vượt qua mốc 0°.

Buffer kép. Task lidar ghi vào s_building, web server đọc s_complete. Nếu dùng chung một buffer, trang web sẽ bắt được ảnh nửa cũ nửa mới và bạn thấy một vệt rách chạy vòng quanh màn hình. Lúc chốt vòng thì memcpy sang buffer kia trong taskENTER_CRITICAL — chỉ khoảng 1KB, vài micro giây, đủ ngắn để dùng critical section.

Ưu tiên task. Task đọc UART nên đặt ưu tiên cao (bài này dùng 13, mức thường là 5). Buffer UART 2KB chỉ chứa được khoảng 0.2 giây dữ liệu; task bị chen là mất gói ngay, và biểu hiện của nó là ảnh quét nhấp nháy khuyết từng miếng.

6. Xuất ra web

Phần này ESP-IDF làm gần hết. esp_http_server có sẵn, chỉ cần hai endpoint:

  • GET / → trang HTML (nhúng thẳng vào firmware bằng EMBED_FILES)
  • GET /scan → {"rpm":..,"seq":..,"err":..,"d":[360 số mm]}

Nhúng file HTML vào firmware chỉ cần một dòng trong main/CMakeLists.txt:

idf_component_register(SRCS "main.c"
                       INCLUDE_DIRS "."
                       EMBED_FILES "index.html")

rồi trong C:

extern const uint8_t index_html_start[] asm("_binary_index_html_start");
extern const uint8_t index_html_end[]   asm("_binary_index_html_end");

JSON cho 360 số dài khoảng 2KB. Đừng nối cả chuỗi trong RAM rồi gửi một lần — hãy dùng httpd_resp_send_chunk gửi theo từng khúc 256 byte, chỉ tốn một buffer nhỏ trên stack:

char part[256];
int pos = 0;
for (int i = 0; i < SCAN_BINS; i++) {
    pos += snprintf(part + pos, sizeof(part) - pos, "%s%u",
                    i ? "," : "", snap.dist_mm[i]);
    if (pos > (int)sizeof(part) - 8) {
        httpd_resp_send_chunk(req, part, pos);
        pos = 0;
    }
}

JSON là để dễ đọc khi đang học. Nếu cần nhanh hơn, gửi thẳng 720 byte nhị phân (Int16Array bên JS) là nhẹ hơn ba lần.

7. Vẽ radar bằng canvas

Trang HTML chỉ hơn 80 dòng. Phần lõi là đổi toạ độ cực sang toạ độ canvas:

for (let a = 0; a < d.length; a++) {
  const mm = d[a];
  if (mm === 0) continue;             // 0 = không có vật phản xạ
  const m = mm / 1000;
  if (m > rangeM) continue;
  const rad = a * Math.PI / 180;
  const x = R + R * m / rangeM * Math.sin(rad);
  const y = R - R * m / rangeM * Math.cos(rad);   // trục y canvas hướng xuống
  ctx.fillRect(x - 1.5, y - 1.5, 3, 3);
}

Chú ý dấu trừ ở y: trục y của canvas hướng xuống, nên nếu không đảo dấu thì ảnh quét bị lộn ngược và bạn sẽ ngồi tìm lỗi ở phần đọc UART một cách oan uổng.

Vòng lặp cập nhật dùng setTimeout sau khi xử lý xong, chứ không dùng setInterval:

async function poll() {
  try { draw((await (await fetch('/scan')).json()).d); } catch (e) {}
  setTimeout(poll, 150);
}

setInterval gặp mạng chậm là các yêu cầu dồn lên nhau và ESP32 tắc nghẽn.

8. Chạy thử

Tạo project theo cấu trúc ở cuối bài (code đầy đủ của từng phần đều nằm trong bài), rồi:

cd ld14_web
# Sửa WIFI_SSID và WIFI_PASS ở đầu main/main.c
idf.py set-target esp32
idf.py build flash monitor

Trên màn hình monitor sẽ thấy:

I (2870) ld14: Mở trình duyệt tại: http://192.168.1.42
I (4000) ld14: vòng quét=28  gói lỗi CRC=0  rpm=360

Mở địa chỉ đó trên điện thoại cùng WiFi. Con số cần để ý là gói lỗi CRC — nếu nó tăng liên tục thì vấn đề nằm ở dây hoặc nguồn, không phải ở code.

9. Gỡ lỗi những ca hay gặp

Hiện tượng Nguyên nhân thường gặp
vòng quét=0, lỗi CRC=0 Không có byte nào vào cả: sai chân, hoặc nối TX↔TX. Kiểm tra bằng cách nối tạm TX lidar vào chân RX của USB-serial và xem có byte không
lỗi CRC tăng vọt, vòng quét vẫn nhích Sai baud. LD14 = 115200, LD06/LD19 = 230400. Thử đổi LIDAR_BAUD
Lidar quay được vài giây rồi dừng Thiếu nguồn. Động cơ kéo dòng cao lúc khởi động, chân 5V của board nạp USB thường không đủ
Ảnh quét khuyết từng miếng Task đọc UART bị chen. Tăng ưu tiên task, hoặc tăng buffer UART
Ảnh lộn ngược / lệch 90° Chuyện toán học phía JS, không phải lỗi lidar. Đổi dấu hoặc cộng offset vào góc
Rpm khoảng 60 thay vì ~360 Bạn đang đọc speed như vòng/phút. Nó là độ/giây, phải chia 6

10. Đi xa hơn

Từ chỗ này có thể đi tiếp khá nhiều hướng:

  • Đổi JSON sang nhị phân + WebSocket để đạt đủ 10 khung/giây mượt.
  • Lọc theo cường độ: bỏ các điểm có intensity thấp, ảnh sạch hẳn.
  • Ghép với odometry (encoder bánh xe) để dựng bản đồ — đây chính là bước đầu của SLAM. ESP32 đủ sức chạy occupancy grid 10×10m ở độ phân giải 5cm và scan matching tương quan, ngay trên chip, không cần máy tính đồng hành.

Toàn bộ code trong bài build sạch trên ESP-IDF v6.0.2, firmware khoảng 835KB.


Cấu trúc project

ld14_web/
├── CMakeLists.txt
├── sdkconfig.defaults
└── main/
    ├── CMakeLists.txt
    ├── main.c          ← lidar + web server, ~360 dòng
    └── index.html      ← trang radar, nhúng vào firmware