简介:面向嵌入式与物联网开发者的电子纸/NB-IoT/GPRS HAT 扩展板示例代码包,聚焦电子纸显示、NB-IoT/GPRS 通信与树莓派 HAT 标准集成,适合需要快速上手低功耗远程可视化终端的初学者和做原型验证的工程师。压缩包内共 151 个文件,以 40 个 C 源码和 40 个目标文件为主体,配以 34 个 H 头文件和 32 张 BMP 图片素材,可用于图像取模与界面设计;包内另有 makefile 工程脚本与配置文件,整体仅 815KB,结构精简。代码中既包含电子纸驱动、屏幕刷新和图像缓冲管理,也涵盖 NB-IoT 模块 AT 指令交互、GPRS 网络注册与数据通道建立,便于对照学习模块初始化、协议栈调用和低功耗处理逻辑。已有 192 人学习/下载,适合在树莓派或同类微控制器上验证 HAT 外设控制流程,并作为自己项目改造与协议二次开发的参考起点。整体可作为从零配置到联调上手的完整参考。
1. 把一块墨水屏 HAT 和 NB-IoT/GPRS 模组合并到一块板子上,Demo Code 写的究竟是什么
你手头这块 HAT 看起来像是一块带 SPI 接口的墨水屏扩展板,但它真正特殊的部分在于背面还焊着一个 NB-IoT/GPRS 透传模组。这类板子面向的场景很直接:在没有 Wi-Fi、也不方便布线的露天环境里,用一个低功耗终端显示数据,然后通过蜂窝网络把设备状态或传感器采集值推上云端。墨水屏负责"显示",NB-IoT/GPRS 负责"联网"。HAT 的意义在于它把两件事拼到了树莓派上,过去要单独接屏幕、单独接串口模组、再自己做电平转换和电源管理的活,现在全部集成在一块板子上。
Demo Code 的价值在于给了你一条从"点亮屏幕"到"打通网络"的最短路径。我在拿到类似的板子时,第一件事不是跑完整的业务逻辑,而是按 点亮墨水屏→注册网络→发送一条 AT 指令→显示一个远程下发的数字 这个顺序,把每段代码独立跑通。这篇文章就按这个顺序来讲:硬件上哪些引脚需要注意,初始化时三条主线如何分开写,以及真正上生产时哪些参数要调,哪些坑能提前避开。适合正在做环境监测、资产标签、物流信息屏这类低功耗应用的工程师,也适合第一次接触 e-paper 和蜂窝模组合体的嵌入式开发者。
2. HAT 上的硬件结构:e-paper 驱动引脚与 NB-IoT/GPRS 模组共存的关键设计
拿到一块 Demo 板,先不要急着编译代码。打开原理图,找到两块芯片之间的连线,很多"跑不起来"的故障都出在总线复用和供电上。
2.1 SPI 总线怎么分配:屏幕和模组各自占用了哪些引脚
墨水屏控制器的标准接口是 SPI:SCLK、MOSI、MISO、CS、DC、RST、BUSY。通常在 HAT 上会占用树莓派的 SPI0 引脚,CS 默认接到 CE0,DC 用 GPIO24,RST 用 GPIO25,BUSY 用 GPIO17。而 NB-IoT/GPRS 模组走的是 UART 串口,常见接法是 UART0(ttyAMA0)或迷你 UART(ttyS0),模组的 TX/RX 经过板上电平转换后接到树莓派的 RX/TX。这里最容易犯的错是把模组的 PWRKEY 当成普通 GPIO 拉高就行,实际上需要一个大于 500ms 的低电平脉冲来触发开机。如果你的 Demo Code 初始化之后日志里一直看不到RDY,先查 PWRKEY 的延时代码,而不是查网络。
另外一个高频坑是引脚复用。树莓派默认把 GPIO14/15 分配给了 UART,但如果你同时启用了蓝牙,主 UART 会被改到 ttyAMA0,miniuart 则是 ttyS0。不同板卡默认串口名不一样。在跑 Demo Code 之前,先确认你的系统里/dev/ttyAMA0是否存在,以及你的模组是否要求用硬件流控(RTS/CTS)。有一部分 NB-IoT 模组支持高波特率下的流控,但 Demo Code 通常默认关闭。
2.2 NB-IoT 和 GPRS 不是同一个网络,连接策略要分开写
同一个模组支持 NB-IoT 和 GPRS 双模,不意味着你可以用一套 AT 命令走到底。两者的核心差异是频点和数据能力。GPRS 使用现有 2G 网络,覆盖广但延迟高、速率低,优势是运营商退网之前任何一个地方都有信号。NB-IoT 是窄带物联网,带宽只有 200kHz,却能深入地下室、水表井,支持的连接数远大于 GPRS,但它的 PS 业务不能实时收发,要等网络侧寻呼窗口。所以 Demo Code 里常见的做法是把连接流程做成可配置的:AT+CMEE=2开启详细错误码,AT+CFUN?查询射频状态,然后根据当前模组返回的+CPSI消息判断是落在 LTE 还是 GSM 上。
| 对比项 | NB-IoT | GPRS |
|---|---|---|
| 下行速率 | 约 100kbps 上限 | 理论 85kbps 左右 |
| 连接时延 | 高,取决于 PSM 周期 | 低,随时可传 |
| 覆盖能力 | 好,比 GPRS 强约 20dB | 一般,受基站距离影响大 |
| 流量资费 | 低,按消息计费 | 按流量,稍高 |
| 模组休眠 | 支持 PSM/eDRX | 不支持,靠 DRX |
实话说,如果你做的是"每天上传一次,并在本地显示一个结果"的场景,NB-IoT 的 PSM 模式就是为这个需求量身定做的。但如果你需要频繁下发命令到终端,比如远程翻页或更新屏幕内容,GPRS 在网络层面的即时性反而更好。我的建议是:Demo Code 里预留一个网络制式选择宏,不要写死在初始化流程里,等现场实测信号之后再做决定。
2.3 供电与电平:为什么屏幕在刷新一瞬间画面会出现乱码
墨水屏刷新的瞬间电流可以到 50mA 左右,NB-IoT 发射时瞬间电流能到 2A 峰值。如果这两者共用同一个 LDO,屏幕的驱动电压会被拉低,表现为刷新后残留残影、局部不清洗或者干脆花屏。HAT 设计合理的话,会给蜂窝模组走独立的 DC-DC,并且电源输入一般要求 5V/3A。如果你外接的树莓派电源功率不足,问题通常不会直接暴露在开机的 5 分钟里,而是在 NB-IoT 第一次注册网络、模组发射功率拉满时出现整板重启。
除了电源,电平也需要确认。树莓派 GPIO 是 3.3V,部分 GPRS 模组如果要直连,必须看它的 UART 电平。HAT 板上有电平转换芯片就不用管,如果是自己飞线的板子,宁可加一个 3.3V 到 1.8V/2.8V 的转换,也不要直接连。这个细节在跑 Demo Code 时完全看不出来,要等接上真正的模组才会暴露。
3. Demo Code 的文件构成与初始化流程:先点亮墨水屏,再唤醒模组
无论是官方提供的示例还是你自己整理的工程,一套可用的 Demo 通常会分成三个独立模块:屏幕驱动、蜂窝模组 AT 指令封装、业务主循环。这样拆分的好处是调试时可以单独跑,不用为了看屏幕就非得等网络注册完成。
3.1 典型目录结构与主循环逻辑
常见的目录会是这样:lib/放 e-paper 的底层驱动,gprs/放 AT 指令的收发函数,main.py负责把两部分串起来。我在写这类 demo 时会额外加一个status.py,用来保存屏幕内容和网络状态的枚举值,避免后面读写屏幕和模组时 import 循环。
主循环的运行顺序固定为:初始化 SPI 和 GPIO → 模组做软复位 → 查询信号强度 → 执行一次业务上报 → 根据返回结果刷新屏幕 → 进入低功耗等待。注意,这里“低功耗等待”并不是简单地sleep,而应该是让模组进入 PSM 并在树莓派侧关掉不必要的外设时钟。如果你用的是树莓派而不是单片机,树莓派本身待机功耗就有几百毫瓦,这和 HAT 的低功耗设计其实是矛盾的。所以不少 Demo Code 实际上会跑一段时间后主动关机,用 RTC 或模组的定时唤醒引脚来重启系统——这属于进阶玩法,但至少你得在代码里留出这个分支。
3.2 用 Python 初始化墨水屏的最小 Demo
我现在一般用 Python 写初始化和显示逻辑,原因很简单:demo 不需要极致性能,Python 的 GPIO 库和 SPI 库足够成熟,而且可读性高。下面这段代码删掉了厂商库中不必要的封装,保留最核心的时序:
import spidev import RPi.GPIO as GPIO import time # 引脚定义:参考 HAT 原理图 PIN_DC = 24 # 数据/命令选择 PIN_RST = 25 # 复位 PIN_BUSY = 17 # busy 检测,低电平表示忙 GPIO.setmode(GPIO.BCM) GPIO.setup(PIN_DC, GPIO.OUT) GPIO.setup(PIN_RST, GPIO.OUT) GPIO.setup(PIN_BUSY, GPIO.IN) spi = spidev.SpiDev() spi.open(0, 0) # CE0 spi.max_speed_hz = 4000000 # 墨水屏通常不超过 4MHz def reset(): GPIO.output(PIN_RST, GPIO.LOW) time.sleep(0.1) GPIO.output(PIN_RST, GPIO.HIGH) time.sleep(0.1) def send_command(cmd, data=None): GPIO.output(PIN_DC, GPIO.LOW) spi.xfer([cmd]) if data: GPIO.output(PIN_DC, GPIO.HIGH) spi.xfer(data) def wait_until_idle(): while GPIO.input(PIN_BUSY) == GPIO.LOW: time.sleep(0.01) reset() wait_until_idle() # 初始化序列示例:先关显示,后开显示 send_command(0x00) # 设置命令表 time.sleep(0.05) send_command(0x04) # power on send_command(0x06) # booster soft start send_command(0x50) # VCOM and data interval send_command(0x12) # 这里演示的是驱动扫描周期设置代码最后的send_command(0x12)是一个占位,不同的 e-paper 屏幕宽高不同,驱动 IC 的寄存器地址也不同。你拿到 Demo Code 后,需要对照屏幕型号的数据手册确认这个初始化序列是否完整。这个最小 demo 的重点是wait_until_idle函数——墨水屏的驱动 IC 在刷新时会拉低 BUSY 引脚,如果跳过这一步,紧接着发进来的扫描命令会被丢弃,画面就会出问题。我见过很多开发者认为初始化没反应是硬件坏了,其实只是缺少忙状态等待。
3.3 通过 UART 初始化 NB-IoT/GPRS 模组的 AT 指令序列
蜂窝模组的初始化不是一次AT就能完成的。正确的顺序是:打开串口 → 发送AT同步波特率 → 设置模块全功能 → 查询 SIM 卡 → 注册网络。代码中需要处理两个细节:命令后的\r\n结尾,以及模组可能返回的 unsolicited result code(如+URC: ...)。下面的代码演示了如何写一个带超时的发送函数:
import serial ser = serial.Serial( port='/dev/ttyAMA0', baudrate=9600, timeout=1.5 ) def send_at(cmd, wait_time=1, pattern="OK"): ser.reset_input_buffer() ser.write((cmd + "\r\n").encode()) time.sleep(wait_time) resp = ser.read_all().decode(errors="ignore") if pattern in resp: return resp else: raise RuntimeError(f"AT 指令失败: {cmd} -> {resp}") # 同步波特率并关闭回显 send_at("AT", pattern="OK") send_at("ATE0", pattern="OK") # 设置网络制式:14 代表 LTE,13 代表 GPRS 优先,不同模组数值有差异 send_at("AT+CNMP=14", pattern="OK") # 设置 APN,以阿里云物联网卡示例,实际按运营商文档 send_at('AT+CGDCONT=1,"IP","cmnbiot"', pattern="OK") # 等到网络注册成功,条件为 1 或 5 resp = send_at("AT+CEREG?", pattern=OK) if "+CEREG: 1,0" in resp or "+CEREG: 1,5" in resp: print("网络注册成功")注意AT+CNMP这个值不是 apn 后面的参数,它是网络制式选择,每个模组厂家的定义都不一样。你必须在 Demo Code 里找到这行指令,改成现场网络环境对应数值。AT+CEREG?返回的第二位如果一直是3,说明被网络拒绝,大概率是 SIM 卡没插到位或者 APN 填错。如果一直是2,说明搜索不到网络,这时要检查天线是否接好。很多开发板没有焊接天线座,直接用弹簧天线,放在金属机箱里信号强度会掉得极快,这是现场最常见的返修原因。
4. 从 Demo 到可用的物联网节点:数据上云与远程刷新屏幕
屏幕点亮了,模组也注册上网络了,下一步就是让它做一件有意义的事。常见的 demo 会做成一个环境数据展示终端:传感器读到温度湿度,通过模组上报到云平台,平台再下发一行文字显示在墨水屏上。这两条链路看似对称,实现上的坑却完全不同。下面分别梳理。
4.1 用 HTTP 或 MQTT 上行的最小实现方案
蜂窝模组通常自带 TCP/IP 协议栈,你只需要通过 AT 指令建立 socket 连接。用 HTTP 还是 MQTT,取决于云平台。我比较推荐在 NB-IoT 场景里直接使用 MQTT,因为它的报头开销小,而且支持遗嘱消息和会话保持。但很多模组固件的 MQTT 指令并不支持 TLS 加密,因此你必须把 MQTT 服务器端口从默认的 8883 改成 1883,并在核心网侧用内网 VIP 做一层安全隔离。
下面是一段在模组上通过 AT 指令连接 MQTT broker 的示例:
# 建立 TCP 连接到 broker,假设 IP 为 120.55.178.20,端口 1883 send_at("AT+QIOPEN=1,0,\"TCP\",\"120.55.178.20\",1883,0,1", wait_time=5, pattern="OK") # 发送 MQTT CONNECT 报文,这里只是为了演示指令格式 # 实际应用建议把 MQTT 协议栈直接放到树莓派侧: import paho.mqtt.client as mqtt def on_connect(client, userdata, flags, rc): print("Connected to broker, rc:", rc) client = mqtt.Client() client.on_connect = on_connect client.connect("120.55.178.20", 1883, keepalive=60) client.loop_start() client.publish("/device/1985/sensor", "{\"temp\":25.6,\"hum\":48.0}")注意,这里出现了两条路径:一条是 AT 指令直接让模组发数据,另一条是树莓派侧跑 MQTT 协议栈、模组只当一个透传网关。前者省电但代码不可读、调试痛苦;后者功耗更高但迭代快。Demo Code 通常会把两条路径都写在注释里,工程落地我一般选后者——树莓派的经济价值在于可以跑完整的应用逻辑,没必要把自己变成一颗 AT 指令翻译机。
4.2 远程下发一条刷新指令:数据格式与脏标记
墨水屏不同于 LCD,它的内容更新需要切换全局刷新和局部刷新。如果你从云端下发一条 JSON 指令告诉它有新数据,屏幕驱动要做的不是直接改写显存,而是先把原有内容清成白色,再画新内容,最后刷新。这个流程里最怕的是在刷新过程中断电,墨水屏会留下永久性的残留。
所以我在处理远程刷新时,会在数据包里额外携带两个字段:refresh_type和display_mode。refresh_type用于告诉终端用全局刷新还是局部刷新,display_mode用来说明新数据是覆盖整个屏幕还是仅更新某个区域的数字。终端收到消息后先写到一个本地文件,再在树莓派空闲时间执行刷新。这样可以避免因为 NB-IoT 网络延迟导致的数据包乱序,也方便在断网重连后重新拉取未确认的消息。
import json import os # 模拟云端下发的消息结构 message = { "device_id": "epaper_hat_01", "data": {"pressure": 1013, "temperature": 24}, "refresh_type": "partial", # 局部刷新 "display_mode": "replace_area", # 仅更新内容区域 "seq": 1004 } cache_path = "/tmp/display_pending.json" with open(cache_path, "w") as f: json.dump(message, f) # 显示前先检查是否有未处理的刷新请求 if os.path.exists(cache_path): with open(cache_path) as f: pending = json.load(f) if pending["refresh_type"] == "partial": # 调用墨水屏局部刷新函数,只刷新数据区域 draw_partial_text(pending["data"]) else: draw_full_screen(pending["data"]) os.remove(cache_path)这里最容易被忽略的是seq字段。物联网设备如果掉线后由于网络重放,可能收到两条一模一样的指令。没有序列号去重的话,屏幕会在几分钟内被刷新两次,不仅耗电而且伤屏。还有一个更隐蔽的问题:NB-IoT 的下行数据可能是通过短消息服务(SMS)下发的,也就是云端把 JSON 内容封装进一条短信里,模组通过+CMTI提示符收到。这种情况下,消息顺序由网络侧决定,你必须在应用层做去重和排序。
4.3 必调的四个参数:波特率、APN、超时时间与显示刷新间隔
从 Demo 走向产品,最先需要固化的四个参数可以列成表格。这些参数在 demo 代码里往往是以“默认值”出现的,但恰恰就是它们决定设备在公网环境里的表现。
| 参数 | 推荐值 | 为什么这么设 |
|---|---|---|
| UART 波特率 | 9600 或 115200 | NB-IoT 模组对波特率没有严格要求,但越低越不容易因干扰丢字节 |
| APN | 按运营商卡设置 | 写错 APN 不会报错,但网络注册永远是拒绝状态 |
| TCP 连接超时 | 至少 15s | NB-IoT 随机接入可能加长到 10s 以上,超时设短会导致误判 |
| 屏幕刷新间隔 | 300s 以上 | 墨水屏刷新本质是电泳过程,频繁刷新会缩短寿命 |
第四个参数容易有争议:墨水屏显示静态信息很合适,但如果你的应用要每秒刷新一次数据,那就不适合这类屏幕。HAT Demo 板的正确用法是"定时上传 + 按需刷新"。我一般会把刷新间隔设计成与 NB-IoT 的 TAU 周期对齐,比如 TAU 设为 2 小时,则屏幕也最多两小时刷新一次。这样既能用 PSM 省电,又能保证用户看到的不是太久以前的数据。
5. 进阶调优:从 Demo Code 到户外长期运行,验证和功耗怎么平衡
最后一节不铺开讲,只讲两个我在调试这类板子时一定会做的动作,以及一个快速定位问题的方法。
5.1 墨水屏的 LUT 刷新波形与残影处理
驱动墨水屏的关键不在画图函数,而在控制刷新波形(LUT)。同一种屏幕在不同温度下需要的驱动电压和帧率不同。很多 Demo Code 把 LUT 烧死在寄存器里,冬天户外出现重影时会花大量时间检查硬件。我的做法是改用一个可调整的公共 LUT 数组,把温度区间分成 0~10℃、10~30℃、30℃ 以上三档,每档使用对应的对比度参数。改动幅度只有几十行代码,显示效果能提升一个量级。在跑 Demo Code 时,如果你发现刷新后图案边缘有细密的白点,大概率是 LUT 低温参数不合适,和屏幕本身无关。
5.2 在 Red Hat 系 Linux 上运行 Demo Code 的设备权限问题
树莓派默认系统是 Raspberry Pi OS,但不少开发者会把这套 HAT 接到工业级的 ARM 板卡上,跑 Fedora 或 Red Hat 兼容发行版。这时会遇到 GPIO 库不一致的问题:RPi.GPIO只支持树莓派内核,换到这类系统后 import 直接报错。解决方法是用libgpiod提供的gpioset和gpioget命令行接口,或者直接操作/dev/gpiochip0。另外,串口设备权限在 Red Hat 系默认属于dialout组,记得把当前用户加入该组,否则 python 的 serial 库会提示无法打开端口。
# 用 gpioset 代替 RPi.GPIO,控制 HAT 复位引脚 gpioset gpiochip0 25=0 sleep 0.1 gpioset gpiochip0 25=1 # 检查串口设备属于谁,通常 ttyAMA0 属于 root:dialout ls -l /dev/ttyAMA0 sudo usermod -aG dialout $USER这算不上高深技术,但能让你少浪费半天去编译一堆过时的内核模块。如果你打算长期维护这个 demo,尽量在上层封装一个hardware.py,把 GPIO 操作和串口打开方式与具体平台解耦,后续迁移成本会低很多。
5.3 快速验证 Demo 是否可用的三个检查点
当设备从实验室移到户外后,我会用以下三个检查点判断整体流程是否正常,而不是直接去看屏幕有没有字。
第一,打开模组日志,观察一条 AT 指令后是否有+CEREG: 1,5或+CGREG: 1,没有这个结果就说明网络注册失败,此时刷屏没有任何意义。第二,在数据上报之后立即用另一台设备订阅同一个 MQTT 主题,看从终端发出publish到云端收到数据的时间差。NB-IoT 场景里这个差值如果低于 5 秒,说明设备正好在 RRC Connected 状态,这是正常现象;如果大于 30 秒,说明网络侧进入了空闲状态,你最好不要用"心跳每 60 秒一次"这种优化去做保活,而是直接接受这个延时并调整上报周期。第三,检查屏幕在断电重连后是否能恢复到最后一次显示的内容。如果每次重启屏幕都花屏,大概率是初始化序列里缺少恢复环境温度的步骤,把模组和屏幕放在同一个机壳里就能解决。
完成这三个检查点,这个合体的 HAT 基本就算过了现场验收线。接下来你需要做的,只是根据具体业务去替换数据源和显示布局。
本文还有配套的精品资源,点击获取