用 Node.js 在树莓派上驱动 I2C LCD,这事听起来像“用 JavaScript 干单片机活”。但说实话,Node.js 做硬件控制的异步事件模型比 Python 轮询更顺手,尤其在需要同时处理 Web 请求、定时刷新和传感器上报的场景下。这次我们直接看一套可落地的方案:树莓派 4B/5 + PCF8574 I2C 转接板 + LCD1602/LCD2004,从接线、开 I2C、装 Node.js,到写驱动、跑通显示,最后通过 HTTP 接口远程往屏幕上推文字。
阅读完你会得到一份可以在树莓派上直接运行的 Node.js 驱动模板,以及一套排查 I2C 设备检测失败、地址不对、乱码、背光不亮等问题的标准思路。硬件编程最花时间的不是写代码,而是总线不通和时序不对。这次我们把容易踩的坑一并说清楚。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 硬件平台 | 树莓派 4B / 5 或其他带 I2C 总线的 Linux 开发板 |
| 通信协议 | I2C,常见设备地址 0x27 或 0x3F |
| 显示模块 | LCD1602 / LCD2004 + PCF8574 I2C 转接板 |
| 编程语言 | Node.js(推荐 LTS 版本,如 18/20) |
| 依赖模块 | i2c-bus,基于文件系统 /dev/i2c-1 访问总线 |
| 异步能力 | 基于 async/await 的异步驱动,非阻塞读写 I2C |
| 扩展接口 | 可用 Node.js http 模块提供 Web API,远程写入显示内容 |
| 适用场景 | 系统状态展示、内网信息屏、传感器数值显示、IoT 教学 |
| 是否支持批量任务 | 可在主进程中排队处理多条显示任务,防止 I2C 写冲突 |
从实践角度看,这个方案的门槛比较友好:不需要额外购买 USB 转 I2C 调试器,树莓派 GPIO 自带 I2C 控制器;软件层面只需要 Node.js 和 i2c-bus 一个模块;如果只是点亮屏幕显示固定文字,总代码量可以控制在 200 行以内。
2. 适用场景与使用边界
哪些人适合用 Node.js 控制 LCD?首先是前端或 Node.js 后端工程师,想进入硬件领域又不想重新学 C/Python;其次是已经在树莓派上跑 Node.js 服务的人,希望把服务运行状态直接显示在屏幕;再就是做教学演示,需要把 I2C、Linux 设备文件和异步事件模型串起来讲。
这套方案适合做这几类事情:
- 把树莓派的 IP、CPU 温度、内存占用定时显示在 LCD 上。
- 内网环境下通过 HTTP 接口把消息推送到 LCD。
- 读取温湿度传感器后,把数值同步显示到屏幕。
- 在 Node.js 的 Electron/Web 项目中增加一块物理状态面板。
但它不适合做以下场景:
- 需要显示图片、复杂波形或大型中文点阵界面的场景,建议换用 SPI 接口的彩色屏或 HDMI 显示屏。
- 对毫秒级时序响应要求极高的场景,Node.js 的事件循环调度不如 C 语言直接,但 LCD1602 本身对微秒级指令延时不算苛刻。
- 需要脱离 Linux 运行的裸机 MCU 环境,Node.js 这里依赖树莓派的 Linux 系统和 /dev/i2c 设备文件,不能直接运行在 STM32/51 上。
合规方面需要留意一点:如果你采集的是真实用户信息并显示在公共屏幕上,要确认已获得相关人员授权;用于公司监控屏或产品展示时,确保信息内容合规,不泄露内部敏感数据。硬件接线也要避免在树莓派通电状态下插拔杜邦线,防止损坏 GPIO 或 I2C 转接板。
3. 树莓派 Node.js I2C LCD 的环境准备
先列硬件清单。
| 硬件 | 规格说明 |
|---|---|
| 树莓派 | 4B 起步,树莓派 5 同样适用,需联网安装系统 |
| LCD 模块 | LCD1602(2 行 16 列)或 LCD2004(4 行 20 列) |
| I2C 转接板 | PCF8574,模块背面可见 8 位扩展芯片 |
| 杜邦线 | 母对母 4 根:VCC、GND、SDA、SCL |
| microSD 卡 | 16GB 以上,系统推荐 Raspberry Pi OS Lite |
PCF8574 模块常被直接焊接在 LCD 背面,只留 4 个引脚出来,这样做的好处是省掉大量 GPIO 连线,LCD 的 D0-D7、RS、RW、E 都不需要单独处理。
接线方式固定:
| LCD I2C 模块引脚 | 树莓派 GPIO |
|---|---|
| VCC | 物理引脚 2(5V)或物理引脚 4(5V) |
| GND | 物理引脚 6(GND) |
| SDA | 物理引脚 3(GPIO2) |
| SCL | 物理引脚 5(GPIO3) |
如果使用树莓派 5,40Pin 引脚定义与 4B 物理兼容,I2C1 仍然是物理引脚 3 和 5。注意部分 LCD 模块背光需要 5V,如果接 3.3V,屏幕可能有显示但背光偏暗。
系统选择上,推荐使用 Raspberry Pi OS Lite(无桌面版)或 Ubuntu Server。装好系统后先做两件事:第一更新软件源,第二启用 I2C 内核驱动。
如果觉得官方源比较慢,编辑/etc/apt/sources.list和/etc/apt/sources.list.d/raspi.list,替换为国内镜像源。以清华源为例:如果你是树莓派 5 跑 Ubuntu 22.04,镜像源里面的$RELEASE变量会被正确替换为jammy,所以只需先备份原文件,再写入以下内容。需要注意的是,Raspberry Pi OS 源自带/etc/apt/sources.list.d/raspi.list,两者要同时处理。
# 备份原文件 sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak sudo cp /etc/apt/sources.list.d/raspi.list /etc/apt/sources.list.d/raspi.list.bak随后编辑源文件,把http://raspbian.raspberrypi.org/raspbian/之类地址替换为:
# /etc/apt/sources.list 示例,基于 Raspberry Pi OS deb http://mirrors.tuna.tsinghua.edu.cn/raspbian/raspbian/ bullseye main contrib non-free rpi替换完先运行sudo apt update,确认没有报错再继续。镜像站点的路径以你实际打开为准,不同系统版本代号不同,不要照抄 bullseye 就完事。
启用 I2C 有两种方式。有桌面环境时,在raspi-config的 Interface Options 中打开 I2C;无桌面环境时这样做:
sudo raspi-config进入 Interface Options,选择 I2C,Enable。然后检查内核模块。
sudo apt install -y i2c-tools ls /dev/i2c-*正常会看到/dev/i2c-1。如果看不到,编辑/boot/config.txt(树莓派 5 可能是/boot/firmware/config.txt),确认存在dtparam=i2c_arm=on。
接下来安装 Node.js。Raspberry Pi OS 官方源内的 Node.js 版本可能偏旧,可以直接使用 NodeSource 安装 LTS 版本,或者从 nodejs.org 下载适用于 ARM64 的 Linux 二进制包。这里以 NodeSource 方式为例:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs node -v npm -v如果在内网环境无法访问 NodeSource,可以手动下载node-v20.x.x-linux-arm64.tar.xz后解压到/usr/local。树莓派 4B/5 都是 64 位系统,不要下错成 armv7l 版本。
安装编译依赖。i2c-bus 模块在安装时会通过 node-gyp 编译原生扩展,需要系统有 Python3、make 和 g++:
sudo apt install -y python3 make g++到这里环境准备已完成。建议先在终端执行node -e "console.log('ok')"验证 Node.js 可运行,再继续后面的驱动开发。
4. I2C 设备地址检测与总线确认
LCD 接好线、系统启用 I2C 后,第一件事不是写代码,而是扫描总线,确认树莓派是否真的识别到这个设备。
i2cdetect -y 1参数-y表示跳过交互确认,1代表 I2C 总线编号 1。执行后终端会打印一个地址表,其中出现27或3f就说明找到了 PCF8574 转接板。多数常见模块默认地址是 0x27,部分 0x3F,也有一些转接板通过板载 A0/A1/A2 跳线改变地址。
如果扫描不到任何设备,大概率是硬件连线问题,而不是 Node.js 代码问题。排查顺序如下:
1. VCC 是否接 5V,GND 是否共地。 2. SDA 和 SCL 是否接反。 3. 杜邦线是否松动。 4. 树莓派 I2C 是否真的启用,运行 ls /dev/i2c-*。 5. 转接板是否损坏,可换一个模块测试。地址检测成功后,可以用i2cdump -y 1 0x27查看总线数据,但要注意读取 LCD 模块寄存器意义不大,PCF8574 只是把 I2C 数据转成 8 位并行电平,真正的“内存映射”在 LCD 控制器 HD44780 内部,不是普通 EEPROM 那样直接可读。
有些 I2C 模块要求外接上拉电阻,树莓派 GPIO 内部已经为 I2C1 启用了 1.8kΩ 上拉,所以无需额外上拉;如果是自己用杜邦线外接一个独立 PCF8574 芯片,则需要检查模块是否带 4.7kΩ 到 10kΩ 上拉电阻。4*4 键盘场景中经常讨论上拉电阻问题,本质相同:I2C 总线是开漏结构,必须有上拉才能保证高电平有效。原装 I2C LCD 转接板基本都带电阻,不需要再处理。
5. Node.js 项目初始化与依赖安装
用 mkdir 创建独立项目目录,防止依赖污染系统目录。
mkdir ~/lcd-node cd ~/lcd-node npm init -y npm install i2c-bus安装成功后,node_modules/i2c-bus内会包含编译好的.node原生扩展,可以简单验证模块能被 require。
node -e "const i2c = require('i2c-bus'); console.log(typeof i2c.openPromisified)"输出function即可。下面使用openPromisifiedAPI,把 I2C 读写封装成 Promise,这样就不用陷入回调嵌套,可以安心写 async/await 逻辑。
建议把驱动拆成独立文件lcd.js,主程序只负责调用。项目结构如下:
~/lcd-node/ ├── lcd.js # I2C LCD 驱动类 ├── index.js # 主程序,定时显示系统信息 ├── server.js # HTTP API 服务,可选 └── package.json6. 编写 I2C LCD 驱动模块
PCF8574 转接板的核心原理并不复杂:它通过 8 位 I2C 数据引脚模拟 HD44780 的 4 位并行接口。怎么理解?
PCF8574 的 P0-P7 输出到 LCD 引脚,常见映射是:
P7 -> D7 P6 -> D6 P5 -> D5 P4 -> D4 P3 -> 背光控制 P2 -> E(使能) P1 -> RW(读写选择,接地为写) P0 -> RS(寄存器选择,0 为命令,1 为数据)所以我们向 I2C 总线写入一个字节时,实际同时控制了 8 个引脚。写入一个 8 位数据到 LCD,需要拆成两个 nibble(高 4 位和低 4 位),每个 nibble 都要拉高 E 引脚再拉低,LCD 在 E 下降沿锁存数据。
先看完整驱动代码:
// lcd.js const i2c = require('i2c-bus'); const RS = 0x01; const RW = 0x02; const EN = 0x04; const BL = 0x08; class Lcd { constructor({ busNumber = 1, address = 0x27, cols = 16, rows = 2 } = {}) { this.busNumber = busNumber; this.address = address; this.cols = cols; this.rows = rows; this.bus = null; this.backlight = BL; } async init() { this.bus = await i2c.openPromisified(this.busNumber); this.bus.writeByte(this.address, 0x33); await this.delay(5); this.bus.writeByte(this.address, 0x32); await this.delay(5); await this.command(0x28); // 4 位模式,2 行,5x8 点阵 await this.command(0x0C); // 显示开,光标关 await this.command(0x06); // 写入后地址自动加 1 await this.command(0x01); // 清屏 await this.delay(2); } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async writeByte(value, mode) { const data = (value & 0xF0) | mode | this.backlight; await this.bus.writeByte(this.address, data); await this.bus.writeByte(this.address, data | EN); await this.delay(1); await this.bus.writeByte(this.address, data & ~EN); const dataLow = ((value << 4) & 0xF0) | mode | this.backlight; await this.bus.writeByte(this.address, dataLow); await this.bus.writeByte(this.address, dataLow | EN); await this.delay(1); await this.bus.writeByte(this.address, dataLow & ~EN); } async command(value) { await this.writeByte(value, 0x00); } async print(text) { const chars = text.split(''); for (const ch of chars) { await this.writeByte(ch.charCodeAt(0), RS); } } async clear() { await this.command(0x01); await this.delay(2); } async home() { await this.command(0x02); await this.delay(2); } async setCursor(col, row) { const rowOffsets = [0x00, 0x40, 0x14, 0x54]; const offset = rowOffsets[row] || 0x00; await this.command(0x80 | (offset + col)); } async displayMessage(line1, line2 = '') { await this.clear(); await this.print(line1); if (this.rows > 1 && line2) { await this.setCursor(0, 1); await this.print(line2); } } } module.exports = Lcd;写驱动时最需要注意的是初始化序列。PCF8574 上电后可能处于 8 位模式,我们需要先发0x33再发0x32完成“降级”到 4 位模式的过程,然后才是常规的0x28、0x0C、0x06、0x01。如果少了 0x33 这一步,屏幕可能出现随机乱码或只有第一行正常。
7. 功能测试与效果验证
写一个最简单的测试文件:
// test.js const Lcd = require('./lcd'); async function main() { const lcd = new Lcd({ address: 0x27, cols: 16, rows: 2 }); try { await lcd.init(); await lcd.displayMessage('Hello CSDN', 'Node.js I2C'); console.log('LCD display success'); } catch (err) { console.error('LCD error:', err); process.exit(1); } finally { setTimeout(async () => { await lcd.clear(); process.exit(0); }, 3000); } } main();运行命令:
node test.js判断成功的标准有三个:
- 终端输出
LCD display success。 - LCD 第一行显示
Hello CSDN。 - LCD 第二行显示
Node.js I2C,且背光正常。
如果看到乱码或显示位置不对,优先检查address是否与i2cdetect探测结果一致。比如检测结果是 0x3F,代码却固定写 0x27,那么驱动能读到设备但输出完全不可用。
如果想测试滚动显示,可以写一段定时器逻辑:每 3 秒切换一次行内容,模拟动态信息面板。这里要注意,每次打印前如果不清屏也不设置光标,文字会从上次位置继续往后写,超出 16 列的部分不会自动换行到第二行,而是被截断。所以在更新整屏信息时,最稳妥的做法是先clear(),然后setCursor(0, 0),再打印第一行,最后跳转到第二行。
8. 显示中文问题:LCD1602 字库限制与替代方案
很多人在树莓派上跑通 LCD 后,第一反应就是“我想显示中文”。这里要说清楚:普通 LCD1602/LCD2004 内置的 HD44780 字符 ROM 只包含 ASCII 字符、日文假名和少量符号,没有中文字库,直接把中文字符串按 UTF-8 编码写进去,屏幕上会出现乱码。
如果必须显示中文,有几种常见路径:
| 方案 | 说明 | 难度 |
|---|---|---|
| 购买带中文字库的 LCD 模块 | 模块内置 GB2312 或 Unicode 字库,写入对应编码即可 | 低 |
| 使用 LCD12864 图形屏 | 将中文字模转成点阵图片,逐 bit 写入 | 中 |
| 树莓派上用 SPI OLED | 通过占位图方式显示中文,每次写整个帧缓冲 | 中高 |
如果你的项目不是把中文字模显示当作核心诉求,建议还是沿用 ASCII 显示方案,只显示 IP、时间、温度、英文状态。比如 CPU 温度可以显示为CPU 52.3C,避免了中文字库的适配成本。
如果一定要在 Node.js 里做中文点阵,思路是准备一个包含常用汉字的字模数组,比如 16x16 点阵每个字 32 字节,然后将汉字拆分到 5x8 的自定义字符模式,这超出了普通 LCD1602 模块的能力范围。最实用的路线是换用中文字库版 LCD2004,数据写入方式和 ASCII 版几乎一致,只是字符编码上要发送 GBK 编码而非 UTF-8。
在 Node.js 中转换编码可以使用iconv-lite:
npm install iconv-liteconst iconv = require('iconv-lite'); const buf = iconv.encode('温度 25C', 'gbk'); for (const byte of buf) { await lcd.writeByte(byte, RS); }不过具体模块的字库编码方式需要以硬件说明书为准,不要盲目照搬代码。标准 PCF8574 转接板驱动 HD44780 只能处理 ASCII 字符,这点可以先接受。
9. 系统状态监视与批量显示任务
LCD 单独显示一句话意义有限,把它变成系统监视屏才有实用价值。树莓派上通过/proc和/sys/class/thermal可以拿到系统信息,Node.js 不需要额外安装系统监控库。
// monitor.js const fs = require('fs'); const os = require('os'); const Lcd = require('./lcd'); function readCpuTemperature() { try { const raw = fs.readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8'); return (parseInt(raw, 10) / 1000).toFixed(1); } catch (err) { return 'N/A'; } } function getIPAddress() { const ifaces = os.networkInterfaces(); for (const name of Object.keys(ifaces)) { for (const iface of ifaces[name]) { if (iface.family === 'IPv4' && !iface.internal) { return iface.address; } } } return '127.0.0.1'; } async function main() { const lcd = new Lcd({ address: 0x27, cols: 16, rows: 2 }); await lcd.init(); setInterval(async () => { const ip = getIPAddress(); const temp = readCpuTemperature(); const mem = os.freemem() / 1024 / 1024; const totalMem = os.totalmem() / 1024 / 1024; const memPercent = ((1 - mem / totalMem) * 100).toFixed(0); await lcd.displayMessage(`IP ${ip}`, `Mem ${memPercent}% T${temp}C`); }, 5000); } main().catch(err => { console.error(err); process.exit(1); });运行node monitor.js后,LCD 会每 5 秒刷新一次。这里会遇到硬件编程常见的“写冲突”问题:如果下一次 setInterval 触发时上一次异步写入还没结束,两条消息交错发给 LCD,屏幕内容会错乱。虽然在上述代码中 5 秒间隔足够长,但在真实项目中可能同时存在 Web 接口写入、传感器事件写入、定时刷新三条路径,所以需要一个显示任务队列。
批量任务可以用最简单的方式实现:主程序只负责入队,驱动内部串行执行显示任务。
class LcdQueue { constructor(lcd) { this.lcd = lcd; this.queue = []; this.running = false; } push(line1, line2 = '') { this.queue.push({ line1, line2 }); this.process(); } async process() { if (this.running || this.queue.length === 0) return; this.running = true; while (this.queue.length > 0) { const task = this.queue.shift(); try { await this.lcd.displayMessage(task.line1, task.line2); } catch (err) { console.error('display error:', err); } await this.lcd.delay(300); } this.running = false; } } module.exports = LcdQueue;这套队列的思想很简单:任何上游任务都先把内容推到队列,显示进程循环取出并执行,避免多个异步写入同时操作 I2C 总线。它不需要 redis 或消息队列,小项目中一个数组足够。类似“闪屏”“重叠文字”大部分情况下都是因为没有全局串行化显示写入。
10. 通过 HTTP API 远程控制 LCD
树莓派作为常开的低功耗服务器,最适合做的就是把 LCD 变成一个可远程控制的物理信息屏。这里用 Node.js 内置http模块提供接口,不需要引入 Express 依赖。
// server.js const http = require('http'); const Lcd = require('./lcd'); const LcdQueue = require('./lcdqueue'); const lcd = new Lcd({ address: 0x27, cols: 16, rows: 2 }); const queue = new LcdQueue(lcd); async function main() { await lcd.init(); console.log('LCD initialized'); const server = http.createServer((req, res) => { if (req.method === 'POST' && req.url === '/display') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { const parsed = JSON.parse(body); const line1 = (parsed.line1 || '').slice(0, 16); const line2 = (parsed.line2 || '').slice(0, 16); queue.push(line1, line2); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ code: 0, message: 'ok' })); } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ code: 400, message: 'invalid json' })); } }); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ code: 404, message: 'not found' })); }); server.listen(3000, '0.0.0.0', () => { console.log('Server listening on port 3000'); }); } main().catch(err => { console.error(err); process.exit(1); });注意构造函数里我们把 LcdQueue 单独存储,在index.js里还要调用一次lcd.init()。上面的代码为了展示写法省略了模块关系,正确做法是传入已经完成 init 的 lcd 实例。队列模块本身不管初始化流程。
通过curl调用:
curl -X POST http://127.0.0.1:3000/display \ -H "Content-Type: application/json" \ -d '{"line1":"Server OK","line2":"Port 3000"}'返回{"code":0,"message":"ok"}说明接口调用成功,LCD 显示内容会更新。为了避免客户端传入超长内容把屏幕写坏,代码里用slice(0, 16)做了截断,具体长度要以 LCD 列数为准。第二行截断长度取 16,但 LCD2004 是 20 列,所以这个参数应该根据 lcd.cols 动态算出。
接口调用成功后,你可以直接从内网任意机器把消息推到树莓派 LCD 上,比如结合 shell 脚本、监控系统 webhook 或一个简单的移动端工具页。
11. 资源占用与性能观察
树莓派上跑 Node.js 控制 LCD,很多人担心性能问题。实际上 Node.js 进程对树莓派 4B 而言非常轻量。通过htop或ps观察,一个纯 LCD 服务进程的内存占用通常在 50 MB 以下,CPU 占用率基本接近 0。
ps aux | grep node观察要点如下:
- Node.js 进程只会在写入 I2C 时短暂占用 CPU,I2C 总线速率默认 100 kHz,写入几个字节的时间是微秒级,对 CPU 影响很小。
- 树莓派使用 Node.js 收发 HTTP 请求时,进程会进入事件循环等待,不会持续占用 CPU。
- 定时刷新不要设置过短,LCD1602 人眼可观察的变化在 200ms 以上,没必要 100ms 刷一次。LCD 内部控制器 HD44780 清屏指令需要 1.5ms 左右,如果频繁清屏会导致显示闪烁。
- I2C 总线本身是半双工,同一时间只能有一个主设备访问,树莓派上不要同时运行多个直接访问 LCD 的进程,避免总线 busy 报错。
- 如果代码中连续写入不等待,I2C 可能返回
EIO或EBUSY错误,此时应该在错误处理里重试或增加延时。
降低资源占用的有效手段包括:批量刷新时只在内容变化时才刷新;不要每轮都调用清屏指令,改为 setCursor 后覆盖写;全屏文本使用 Buffer 拼接再一次性写入,减少 JS 字符串到 I2C 字节的转换开销。LCD 刷新本身是低速外设,性能瓶颈从来不在树莓派 CPU,而在 I2C 总线时钟和 LCD 控制器时序。
12. 常见问题与排查方法
把实际开发中最常遇到的现象整理成表:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| i2cdetect 扫描不到设备 | 接线错误、I2C 未启用、模块损坏 | 检查 VCC/GND/SDA/SCL,重启后再扫描 | 重新插拔杜邦线,执行 sudo raspi-config 启用 I2C |
| 屏幕有背光但不显示文字 | 对比度电位器调太低 | 用小螺丝刀旋转 LCD 背面蓝色电位器 | 调到能隐约看到方块,再运行显示程序 |
| 显示乱码 | 初始化时序不对或地址错误 | 检查代码中的 address 与 i2cdetect 结果是否一致 | 确认驱动中的 0x33 初始化序列完整 |
| 只有第一行正常 | 驱动未正确设置两行模式 | 检查命令 0x28 是否发送成功 | 确认 LCD 是 1602,且 rows 参数传 2 |
| 显示内容残影 | 未清屏直接覆盖写 | 打印前调用 clear 或 home | 更新内容前先清屏再 setCursor |
| Node.js 报 EIO | I2C 总线忙或设备无响应 | 查看 dmesg 是否有 i2c 错误 | 重启树莓派或检查接线 |
| npm install i2c-bus 编译失败 | 缺少 python3/make/g++ | 查看安装日志尾部错误 | 执行 sudo apt install -y python3 make g++ |
| 屏幕出现闪烁方块 | 背光电压不足或对比度不对 | 检查 VCC 是否接 5V | 改用 5V 供电,不要接 3.3V |
| 接口返回 ok 但屏幕没变化 | 队列里还有多条未执行任务 | 检查服务端日志 | 清空队列后重试,或重启 node 进程 |
| 重启后显示服务没起来 | 未配置开机自启 | 查看 pm2 list 或 systemd 状态 | 使用 pm2 或 systemd 配置常驻服务 |
这里特别强调几点排查经验。
I2C 设备名字扫描不到,先把目光从代码转移到硬件。杜邦线接触不良是树莓派外设场景第一杀手。换线、换引脚、重新插拔,比调试代码更有效。
屏幕有背光但无字符时,优先拧对比度电位器。PCF8574 转接板通常自带一个蓝色电位器,出厂位置可能接近最小值,导致字符完全隐形,此时你以为驱动有问题,实际上只是对比度不对。
初始化完成后第一次写文字乱码,很大原因是 PCF8574 与 LCD 模块中间的排线松动,或者转接板本身存在虚焊。可以尝试用手按压排线连接处观察现象。
13. 主机上使用 pm2 或 systemd 常驻服务
树莓派作为 IoT 网关,要让 LCD 服务在重启后自动运行,不能每次都手工执行node server.js。推荐使用 pm2 管理 Node.js 进程。
sudo npm install -g pm2 cd ~/lcd-node pm2 start server.js --name lcd-server pm2 save pm2 startuppm2 startup会输出一条 root 权限命令,复制执行即可设置开机自启。pm2 的核心优点在于:进程崩溃后自动重启、日志统一管理、重启后自动拉起来。LCD 驱动在 I2C 设备暂时不可用时可能会抛异常,pm2 会自动拉起进程,但要注意进程拉起前需要等待 I2C 设备就绪,所以代码里最好加上重试初始化逻辑。
使用 systemd 也是常见做法,创建/etc/systemd/system/lcd.service:
[Unit] Description=LCD Display Service After=network.target [Service] ExecStart=/usr/bin/node /home/pi/lcd-node/server.js Restart=always RestartSec=5 User=pi [Install] WantedBy=multi-user.targetsudo systemctl daemon-reload sudo systemctl enable lcd.service sudo systemctl start lcd.service systemctl status lcd.servicepm2 和 systemd 二选一即可,不必同时使用。
14. 最佳实践与使用建议
从可维护性角度给几条建议。
第一,把 I2C 设备地址做成环境变量或启动参数,不要硬编码。换一块 LCD 模块地址从 0x27 变成 0x3F 时,改代码要重新部署,而用环境变量只需修改启动命令。
LCD_ADDR=0x3f node server.js第二,每次开机后 I2C 设备并不一定完全就绪,驱动 init 前要做错误重试。初始化失败不要直接 process.exit,可以延迟 1 秒后重试 5 次,确保树莓派启动完成后再初始化 LCD。
async function initWithRetry(lcd, retries = 5) { for (let i = 0; i < retries; i++) { try { await lcd.init(); return; } catch (err) { console.error(`init failed, retry ${i + 1}/${retries}`); await new Promise(resolve => setTimeout(resolve, 1000)); } } throw new Error('LCD init failed after retries'); }第三,项目里划分 model 和 view:传感器读取是 model,LCD 显示是 view,中间通过队列解耦。不建议在传感器回调里直接调 lcd 方法,这样后续增加接口推送或数据库记录时需要改动底层逻辑。
第四,真实项目要处理 LCD 字符长度。LCD1602 第一行 16 字符,LCD2004 第一行 20 字符,字符串要按列数做 padEnd 或 slice。显示不足长度时要用空格清空残余字符,否则上一轮内容会残留。
第五,背光控制要独立抽方法。PCF8574 的 P3 引脚控制背光,把背光数据字节与显示数据分离,可以在夜间自动关闭背光。驱动里把this.backlight变为可动态修改的字段,需要时清除对应位即可。
第六,国内网络环境下载 Node.js 和 npm 包可能较慢,建议设置 npm 镜像:
npm config set registry https://registry.npmmirror.com这能显著缩短 npm install 时间,对树莓派这种硬件环境尤其重要。安装完模块后可以把 registry 改回默认或保留都行,看个人偏好。
第七,所有对外提供的 HTTP API 都应限制访问来源。比如只在 listen 时绑定内网 IP,或用简单 token 校验,避免局域网内其他设备随意写屏。LCD 虽是低危外设,但 API 接口最好保持最基本的安全性。
15. 总结与下一步
这次带大家从硬件接线一路走到 Node.js 驱动和 HTTP 接口调用,核心是四步:接通 I2C 总线、用 i2cdetect 找到设备、用 i2c-bus 通过 PCF8574 模拟 HD44780 时序、把 LCD 操作封装成服务。整个链路跑通以后,LCD 不再是只能亮一下的“点灯工程”,而是一个可以被 Web 请求、传感器事件和定时任务共同驱动的信息输出终端。
最值得先验证的功能是驱动初始化,建议第一次上手先跑最简单的 Hello World,不要直接把队列、Web server、pm2 全部堆上去。变量越少,排查越容易。最容易踩的坑是 i2cdetect 扫不到设备,通常不是代码问题而是接线问题;其次是屏幕有背光但没字,通常要调对比度电位器。这两个坑踩完之后,再往上叠加复杂功能会更加顺利。
下一步可以扩展的方向包括:给 LCD 增加按键输入,用树莓派 GPIO 做一个菜单选择器;把 DHT11 或 BME280 传感器的数据定时显示;通过 MQTT 协议订阅消息,让其他设备远程推送内容到屏幕;或者把显示服务打包成 Docker 镜像,利用 docker-compose 统一管理树莓派上的多个服务。
如果只是做简单信息展示,保留 LCD1602 + PCF8574 + Node.js 这套组合已经足够稳定便宜;如果想做内容更丰富的仪表盘,建议考虑 3.5 寸 SPI 屏或 HDMI 触摸屏,但相应地功耗和接线复杂度也会上升。按需选择即可。