Đọc lidar LD14 bằng ESP32 và xem ảnh quét ngay trên trình duyệt
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 |

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_PINtrong 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)
0x54là byte đồng bộ,0x2Clà “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 độ —
9000nghĩa là 90.00°. - Khoảng cách tính theo mm. Giá trị
0nghĩ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.
speedlà độ/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ằngEMBED_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ó
intensitythấ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