Chạy web server trên vi điều khiển 8-bit giá 1 đô
Tôi đã từng đọc một blog post được serve từ cái Raspberry Pi nhét sau router nhà ai đó ở Auckland. Trang load mất bốn giây. Cái cảm giác như đang gọi điện thoại cho một người thật thay vì fetch document từ CDN edge node cách đó 30 km.
Cái này thách thức hơn một chút.
Maurycy làm cho con chip $1 serve một website. Không phải Pi, không phải ESP32, không phải “vi điều khiển có WiFi.” Mà là AVR64DD32 - một lõi 8-bit, 8 kB RAM, 64 kB flash, 24 MHz. Con chip không có ngoại vi nào đủ tốc độ để nói chuyện với Ethernet. Không sao. Anh ấy ship nó luôn.
Phần cứng
AVR64DD32 có giá khoảng một đô. Với một con chip ở phân khúc này, spec này là khá hào phóng:
| Tài nguyên | Thông số |
|---|---|
| CPU | 1× lõi AVR 8-bit @ 24 MHz |
| RAM | 8 kB |
| Flash | 64 kB |
| EEPROM | 256 byte |
| Điện áp | 1,8 – 5,5 V |
| Giá | ~$1 |
Vấn đề là: tất cả các chân IO của nó chỉ chạy tối đa ở 12 MHz. Chuẩn Ethernet chậm nhất, 10BASE-T, chạy ở 10 Mbps - nhưng dùng mã hoá Manchester, nhân đôi tốc độ tín hiệu thực tế lên 20 Mbps. AVR không thể tạo ra tín hiệu đó.
Giải pháp bình thường là mua một IC Ethernet chuyên dụng từ DigiKey và chờ vài tuần để ship về. Maurycy không muốn chờ.
Hack mạng: dùng SLIP
SLIP - Serial Line Internet Protocol, RFC 1055 (1988) - là cách cũ nhất và đơn giản nhất để truyền IP packet qua cổng serial:
Trước khi gửi packet:
- Bọc nó trong các byte 0xC0.
- Nếu packet chứa 0xC0, thay bằng 0xDB 0xDC.
- Nếu packet chứa 0xDB, thay bằng 0xDB 0xDD.
Đó là toàn bộ giao thức. Ba quy tắc. Không header, không length field, không handshake.
--> // making it invisible to querySelectorAll. // // `data-cfasync="false"` keeps this rescue script executable even when // Rocket Loader is active. It rescues module scripts via two strategies: // 1. Query the DOM for type$="-module" + src (covers case A) // 2. Regex-parse the raw HTML for commented-out script tags (covers case B) // Dynamically-created scripts bypass Rocket Loader entirely. (function () { if (window.__markdyRescue) return; window.__markdyRescue = true; var rescued = false; function rescueModuleScripts() { if (rescued) return; rescued = true; var srcs = []; // Strategy 1: Rocket Loader kept the tag in DOM but changed the type. // type="module" → type="{uuid}-module" (still has src attribute) document.querySelectorAll('script[type$="-module"][src]').forEach(function (s) { srcs.push(s.src); }); // Strategy 2: Rocket Loader COMMENTED OUT the script tag entirely: // // These are invisible to querySelectorAll, so we parse the raw HTML. // We handle both attribute orderings (type-first or src-first). var html = document.documentElement.innerHTML; var reSrcFirst = //g; var reTypeFirst = //g; var m; while ((m = reSrcFirst.exec(html)) !== null) { srcs.push(m[1]); } while ((m = reTypeFirst.exec(html)) !== null) { srcs.push(m[1]); } // Re-inject each found src as a real module script. // Deduplicate first, then inject. Dynamically-created scripts bypass // Rocket Loader entirely. Modules with the same URL are only executed // once by the browser (cached), so re-injecting already-running scripts // is safe. var seen = {}; srcs.forEach(function (src) { if (seen[src]) return; seen[src] = true; var fix = document.createElement('script'); fix.type = 'module'; fix.src = src; fix.setAttribute('data-cfasync', 'false'); document.head.appendChild(fix); }); } // Rescue when user clicks the placeholder (fallback if autoplay failed). document.addEventListener('click', function (e) { var t = e.target; if (t && typeof t.closest === 'function' && t.closest('.markdy-placeholder')) { rescueModuleScripts(); } }); // Rescue automatically after a short delay for autoplay. // Only fires if initAll() never ran (no data-markdy-init on any root). setTimeout(function () { if (document.querySelector('.markdy-root:not([data-markdy-init])')) { rescueModuleScripts(); } }, 1500); }());SLIP có từ thời tiền sử, nhưng Linux vẫn hỗ trợ nó. Phía host chỉ cần ba lệnh:
stty -F /dev/ttyUSB0 115200 raw cs8
slattach -m -F -L -p slip /dev/ttyUSB0
# /dev/ttyUSB0 giờ là một network interface
Phần cứng phía AVR không cần thêm gì - không component ngoài. Maurycy gắn thêm một diode chống nối ngược (vì kiểu gì cũng cắm sai), thêm vài LED cho đẹp. Con chip tiêu thụ vài milliwatt và lấy điện từ chân 5V của adapter serial USB. Một dây duy nhất.
Stack mạng
Giờ con chip có kết nối mạng. Để build server, cần implement ba giao thức trên chip 8 kB RAM.
IP thì dễ. AVR chỉ cần trả lời, không cần tự khởi tạo kết nối. Implement IP response chủ yếu là: đổi chỗ địa chỉ nguồn và đích, reset TTL là xong. IPv6 đã bỏ hoàn toàn packet fragmentation, và các OS hiện đại cũng tắt nó trên IPv4 rồi.
TCP thì không dễ. TCP yêu cầu vi điều khiển phải theo dõi connection state, retransmit packet bị mất, xử lý window size, xử lý RST và FIN đúng cách, và đối phó với vô số edge case. Maurycy mất vài ngày để implement đủ dùng. Vẫn còn bug. Trang vẫn load được.
HTTP anh ấy không implement. Server gửi một response hardcoded cho bất kỳ request nào, bất kể URL. Điều này ổn khi chỉ có đúng một trang để serve.
--> // making it invisible to querySelectorAll. // // `data-cfasync="false"` keeps this rescue script executable even when // Rocket Loader is active. It rescues module scripts via two strategies: // 1. Query the DOM for type$="-module" + src (covers case A) // 2. Regex-parse the raw HTML for commented-out script tags (covers case B) // Dynamically-created scripts bypass Rocket Loader entirely. (function () { if (window.__markdyRescue) return; window.__markdyRescue = true; var rescued = false; function rescueModuleScripts() { if (rescued) return; rescued = true; var srcs = []; // Strategy 1: Rocket Loader kept the tag in DOM but changed the type. // type="module" → type="{uuid}-module" (still has src attribute) document.querySelectorAll('script[type$="-module"][src]').forEach(function (s) { srcs.push(s.src); }); // Strategy 2: Rocket Loader COMMENTED OUT the script tag entirely: // // These are invisible to querySelectorAll, so we parse the raw HTML. // We handle both attribute orderings (type-first or src-first). var html = document.documentElement.innerHTML; var reSrcFirst = //g; var reTypeFirst = //g; var m; while ((m = reSrcFirst.exec(html)) !== null) { srcs.push(m[1]); } while ((m = reTypeFirst.exec(html)) !== null) { srcs.push(m[1]); } // Re-inject each found src as a real module script. // Deduplicate first, then inject. Dynamically-created scripts bypass // Rocket Loader entirely. Modules with the same URL are only executed // once by the browser (cached), so re-injecting already-running scripts // is safe. var seen = {}; srcs.forEach(function (src) { if (seen[src]) return; seen[src] = true; var fix = document.createElement('script'); fix.type = 'module'; fix.src = src; fix.setAttribute('data-cfasync', 'false'); document.head.appendChild(fix); }); } // Rescue when user clicks the placeholder (fallback if autoplay failed). document.addEventListener('click', function (e) { var t = e.target; if (t && typeof t.closest === 'function' && t.closest('.markdy-placeholder')) { rescueModuleScripts(); } }); // Rescue automatically after a short delay for autoplay. // Only fires if initAll() never ran (no data-markdy-init on any root). setTimeout(function () { if (document.querySelector('.markdy-root:not([data-markdy-init])')) { rescueModuleScripts(); } }, 1500); }());Đưa lên internet công cộng
AVR đang kết nối qua serial từ mạng nhà của Maurycy. ISP của anh ấy không cấp địa chỉ IPv4 public. Để người ngoài truy cập được, dữ liệu phải đi qua một VPS ở Helsinki.
WireGuard tạo một kết nối mạng ảo giữa máy nhà và VPS. Không cần trick NAT traversal. Rồi nginx trên VPS proxy mọi request đến /mcu về địa chỉ local của AVR. Khách kết nối tới VPS; VPS chuyển tiếp traffic qua WireGuard qua SLIP qua USB tới con chip 8-bit.
Đường đi của một request: trình duyệt của bạn → VPS Helsinki → WireGuard → máy nhà → USB serial (115200 baud) → AVR64DD32.
Đó là lý do trang load chậm. Và mọi bước đều đang hoạt động đúng.
Thread HN
Thread nhỏ (bảy bình luận lúc viết bài này) nhưng chất lượng.
dragontamer khen dòng AVR DD/EA/EB trong khi lo ngại về hướng đi 32-bit PIC của Microchip - các chip mới như PIC32CM MC có tính năng tương đương, khiến tương lai của AVR không chắc chắn.
JdeBP chỉ ra rằng có một erratum năm 2025 cho RFC 1055 chưa được áp dụng vào www.c. Erratum này cập nhật thuật toán decoding. Đồng thời gợi ý RFC 1144 (nén header TCP/IP của Van Jacobson cho SLIP link) là bước tiếp theo tự nhiên.
steve_taylor viết:
Tôi thích cái cảm giác nhìn HTML stream dần lên màn hình theo thời gian thực, giống hồi dialup lúc ảnh render từ trên xuống dưới từng dòng một.
reassess_blind: “Mất lúc nhưng nó load được. Tôi đã xem đủ rồi, push lên production đi.”
Điều thực sự đáng chú ý
Các lựa chọn kỹ thuật ở đây rất nhất quán. Ethernet bị loại vì Manchester encoding ở 10 Mbps vượt quá IO clock của AVR. Các giao thức USB native sẽ cần một USB host stack. SLIP là công cụ đúng: nó đã có hàng thập kỷ, chỉ cần vài dòng để implement, và Linux hỗ trợ nó từ những năm 1990.
Phần implement IP cũng tối giản đúng mức. Tác giả nêu rõ IPv6 đã bỏ hoàn toàn fragmentation, và Linux hiện đại cũng tắt fragmentation IPv4 theo mặc định. AVR không cần xử lý nó. Tước bỏ giao thức về đúng những gì phía kia thực sự gửi - và phần “khó” tự nhiên thu nhỏ lại.
TCP thực sự khó. Tác giả mất vài ngày và vẫn thừa nhận còn bug. Đây là sự trung thực. TCP không phải giao thức bạn implement trong một buổi tối trên bất kỳ phần cứng nào. Việc nó chạy được trên 8 kB RAM mới là phần đáng nể.
Đoạn cuối về IPv6 - “thôi tự lo đi, IPv6 tồn tại ba mươi năm rồi” - là kiểu note chỉ xuất hiện khi một vấn đề buộc bạn phải thực sự nghĩ về lý do nó tồn tại.
Trang web
Trang vẫn đang chạy. Bạn có thể vào maurycyz.com/mcu và xem HTML arrive từng TCP segment một, như một cái máy fax.
Source code tại maurycyz.com/projects/mcusite/ kèm theo binary đã build sẵn.
Thảo luận: news.ycombinator.com/item?id=48165295
Hoang Yell
Một nhà phát triển phần mềm và là người kể chuyện kỹ thuật. Tôi đọc Hacker News mỗi ngày và kể lại những câu chuyện hay nhất ở đây — bằng tiếng Việt và tiếng Anh, cho người tò mò nhưng không có thời gian.