ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

电子纸+NB-IoT/GPRS双模通信:物联网终端开发实战解析

电子纸+NB-IoT/GPRS双模通信:物联网终端开发实战解析 简介面向物联网嵌入式开发者的 E-Paper_NB-IoT_GPRS_HAT 示例代码包将墨水屏显示、NB-IoT 与 GPRS 通信、HAT 硬件抽象层整合在同一工程中适合有 C 语言基础、希望快速上手低功耗远程显示终端的开发者。压缩包共 151 个文件以 40 个 C 源文件、34 个 H 头文件为主体配 40 个编译生成的 O 目标文件、32 个 BMP 图像素材另有 makefile、配置文件与说明文档整体仅 815KB结构清晰便于查阅。已有 192 人学习下载。代码覆盖墨水屏初始化与刷新、NB-IoT/GPRS 模块 AT 指令交互、常见物联网协议栈封装等关键环节BMP 素材覆盖多尺寸墨水屏典型图案可直接对照运行效果进行调试。通过梳理各模块的调用关系与编译脚本读者能够理解嵌入式 C 项目从外设驱动到网络通信的完整链路并据此移植或改造自己的物联网产品原型。1. 墨水屏加双模通信这个 Demo 把物联网终端最难的几件事一次讲完了拿到这块E-Paper_NB-IoT_GPRS_HAT的 Demo 代码时我原本以为只是又一个树莓派扩展板的例程集合翻完里面对应的 BMP 素材和 GBK.bin 字库之后才意识到这套东西的价值在于它把物联网终端的三条主线全部串起来了低功耗显示、弱网通信、协议对接。电子纸只在画面变化时耗电NB-IoT 覆盖深但带宽窄GPRS 在某些区域反而更稳定——三者的组合恰好覆盖了远程价签、工业看板、物流标签这类场景。这套 Demo 适合已经在用 MCU 做过传感器采集、想往产品化方向走一步的开发者也适合正在评估电子纸方案和 NB-IoT 模块选型的嵌入式工程师。它不会教你写业务逻辑但它把显示驱动、AT 指令交互、网络注册这些底层脏活都摊开在了你面前。2. HAT 板卡架构与初始化流程先搞清总线归属再碰外设2.1 HAT 标准的硬件识别机制树莓派 HATHardware Attached on Top规范的要点是板载一颗 EEPROM里面存着设备厂商 ID、GPIO 映射表、I2C 地址等元数据。系统启动时通过/proc/device-tree/hat读取这些信息自动加载对应的设备树 overlay。这套 E-Paper_NB-IoT_GPRS_HAT 在硬件上同时挂了三种外设SPI 接口的电子纸控制器常见是 SSD1681 或 UC8179、UART 接口的 NB-IoT/GPRS 模块典型如 BC35-G、SIM7000C、以及一组用于控制背光或复位信号的 GPIO。2.2 设备树 overlay 与引脚冲突检查初始化第一步不是写代码而是确认引脚复用关系。这块板子最容易出问题的地方在于 NB-IoT 模块的 UART 和系统调试串口共用同一组引脚。实际使用时我会先检查/boot/config.txt中的 overlay 配置。# 查看当前已加载的 HAT 设备树覆盖 dtoverlay -l # 检查 UART 映射关系确认 ttyS0 和 ttyAMA0 哪个被占用 cat /proc/device-tree/aliases/uart0 cat /proc/device-tree/aliases/uart1 # 如果 HAT 要求使用硬件串口把 mini UART 切换到 PL011 dtoverlayuart0 dtoverlaydisable-bt这段配置的关键在于disable-bt会把原本分配给蓝牙模块的 PL011 硬件串口释放出来供 NB-IoT 模块使用。如果不做这一步AT 指令会全部淹没在蓝牙协议栈的日志里模块表现为“无法通信”。另外要注意 HAT 的 EEPROM 写入保护引脚写设备树时如果发现外设枚举失败优先用i2cdetect -y 1检查 EEPROM 是否在 0x50 地址上响应。2.3 SPI 总线时钟与电子纸控制器的匹配电子纸控制器对 SPI 时钟频率有明确上限SSD1681 的数据手册标注最大 20MHz但实际跑 10MHz 以上时如果 PCB 走线过长就容易出现字节错位。HAT Demo 代码里通常默认配置 2MHz这个值偏保守但很稳。#include linux/spi/spidev.h static uint8_t mode SPI_MODE_0; static uint8_t bits 8; static uint32_t speed 2000000; int epd_spi_init(int fd) { if (ioctl(fd, SPI_IOC_WR_MODE, mode) 0) { perror(SPI mode set failed); return -1; } if (ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, bits) 0) { perror(SPI bits set failed); return -1; } if (ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, speed) 0) { perror(SPI speed set failed); return -1; } return 0; }这段代码把 SPI 配置为模式 0时钟极性为低、数据在上升沿采样这是电子纸控制器最常见的配置。写入速度设为 2MHz 是为了在长排线下保持信号完整性。这里有个容易忽略的点SPI_IOC_WR_MAX_SPEED_HZ这个 ioctl 调用名带MAX字样但实际上是在设置传输时钟的上限不是实际传输速率。如果后续要提升刷新速度可以按四倍步进往上试探直到图像出现残影或花屏为止。2.4 电源时序与复位引脚控制电子纸驱动芯片上电后有严格的复位时序要求先拉低 RESET 引脚至少 10ms再拉高等待 200ms 才能发初始化命令。这个时序写错会导致驱动器状态机错乱表现就是第一帧正常、第二帧全黑。我一般把复位和电源控制封装成一个独立函数。void epd_hw_reset(int gpio_rst, int gpio_pwr) { // 先切断面板电源确保电容放电完成 digital_write(gpio_pwr, HIGH); usleep(50000); // 复位引脚拉低 - 保持 - 拉高 digital_write(gpio_rst, LOW); usleep(10000); digital_write(gpio_rst, HIGH); usleep(200000); // 重新上电 digital_write(gpio_pwr, LOW); usleep(100000); }这段时序里的 50ms 等待是给面板上的储能电容放电用的。电子纸的驱动电压通常在正负 15V 到 25V 之间这些电荷如果没放干净复位后立刻重新初始化残留电荷会干扰驱动波形导致局部区域出现永久性烧屏——注意不是残影是真正的物理损伤。手头有示波器的话建议在复位引脚上加上升沿触发确认从拉高到初始化命令发出的间隔确实超过 200ms。3. 电子纸驱动核心从 BMP 素材到帧缓冲再到波形刷新3.1 BMP 文件的解析与格式陷阱Demo 代码包里那几个5in83bc-b.bmp、7in5b-r.bmp文件是不同尺寸和颜色的测试图像。5in83bc 表示 5.83 英寸黑白红三色屏7in5b 表示 7.5 英寸黑白屏。解析这些文件时有个细节BMP 的像素数据按行存储时每行字节数会被填充到 4 的整数倍如果直接按width * height * bytes_per_pixel读取最后一行会多出几个字节。// 解析 BMP 文件头获取宽度、高度和色深 typedef struct { uint16_t bfType; // 必须为 0x4D42 (BM) uint32_t bfSize; // 整个文件大小 uint32_t bfOffBits; // 像素数据偏移通常是 54 } BMPFileHeader; typedef struct { uint32_t biWidth; // 图像宽度 int32_t biHeight; // 图像高度正数表示自底向上存储 uint16_t biBitCount; // 色深1/4/8/24 } BMPInfoHeader; int bmp_open_info(const char *path, int *width, int *height, int *bpp) { int fd open(path, O_RDONLY); BMPFileHeader fh; BMPInfoHeader ih; read(fd, fh, sizeof(fh)); read(fd, ih, sizeof(ih)); if (fh.bfType ! 0x4D42) { close(fd); return -1; } *width ih.biWidth; *height abs(ih.biHeight); *bpp ih.biBitCount; // 每行需要按 4 字节对齐 int row_stride ((*width * *bpp / 8) 3) / 4 * 4; close(fd); return row_stride; }这个解析代码里最值得关注的是biHeight的正负号。BMP 文件如果高度为正像素数据从图像左下角开始逐行向上存储高度为负则从左上角开始。电子纸驱动的坐标系原点通常在左上角所以如果直接用正值高度的 BMP 做显示图像会上下翻转。处理办法是读像素时从最后一行开始反向读取或者在输出到帧缓冲时调整行顺序。3.2 GBK.bin 字库与点阵映射原理GBK.bin文件本质是 GBK 编码的汉字点阵字库每个字模按特定索引规则存储。以 16x16 点阵为例每个汉字占 32 字节每字节表示一列或一行的 8 个像素。GBK 编码的区位码计算是查字库偏移量的关键先将汉字编码减去 0x8140 得到区位码再按区和位的顺序计算偏移。// 从 GBK.bin 中提取 16x16 点阵字模 #define FONT_WIDTH 16 #define FONT_HEIGHT 16 #define FONT_BYTES (FONT_WIDTH * FONT_HEIGHT / 8) int font_get_glyph(const char *gbk_buf, int index, uint8_t *glyph) { // 计算该字符在文件中的偏移区码偏移 位码偏移 // 每个区有 94 个汉字位置每个汉字占 32 字节 int offset index * FONT_BYTES; FILE *fp fopen(GBK.bin, rb); fseek(fp, offset, SEEK_SET); size_t n fread(glyph, 1, FONT_BYTES, fp); fclose(fp); return (n FONT_BYTES) ? 0 : -1; } // 将字模按位写入帧缓冲 void font_draw_glyph(uint8_t *fb, int fb_width, int x, int y, const uint8_t *glyph, int fg_color) { for (int row 0; row FONT_HEIGHT; row) { for (int col 0; col FONT_WIDTH; col) { // 判断当前像素在字模字节中的位 uint8_t byte glyph[row * 2 col / 8]; uint8_t mask 0x80 (col % 8); if (byte mask) { // 设置帧缓冲中的对应像素 fb[(y row) * fb_width (x col)] fg_color; } } } }这段代码演示了两种位移操作的区别row * 2确定当前行在点阵中的字节偏移16 像素 2 字节col / 8则选择该行两个字节中的哪一个。0x80 (col % 8)把高位对齐到字节的第一个像素这是微软字体引擎的标准存储方式。初学者容易把row * 2写成row这样隔行数据会错位显示出来的汉字会出现垂直方向的断裂。另外注意字库文件里第一个字符的索引是 0 还是 1不同工具生成的 GBK.bin 起始偏移不一样通常偏移 0 处存的是空格字符。3.3 帧缓冲的尺寸计算与颜色位映射如果有文件描述符指向已打开的 BIN 或 BMP 数据合成帧缓冲时需严格按显示屏的分辨率分配内存。以 5.83 英寸三色屏为例分辨率为 648x480每像素 2 位00白01黑10红则帧缓冲大小为648 * 480 / 4 77760字节。这里不能用width * height * 2直接算因为位打包后每 4 个像素共享 1 个字节。int epd_framebuffer_prepare(EPD_t *epd) { // 位深为 2每个字节装 4 个像素 epd-fb_size (epd-width * epd-height) / 4; epd-fb (uint8_t *)calloc(1, epd-fb_size); if (!epd-fb) return -1; // 局部刷新区域需要单独维护脏标记 epd-dirty_lines (uint8_t *)calloc(1, epd-height); return 0; }说到有黑白模式又有黑白红三色模式我这里要补一个实际用过才会注意到的点黑白红屏的红色像素刷新时间比黑白像素长得多红色波形需要 5 个阶段、约 800ms黑白只需 3 个阶段、约 450ms。所以设计 UI 时大面积的红色区域会明显拉长刷新时间最好把红色作为点缀而不是背景色。相关的驱动代码里帧缓冲拆成 B/W 平面和 R 平面两个独立区域SPI 发送时按平面顺序传递。3.4 驱动波形的分段发送逻辑电子纸刷新不是一次性把数据发完就行它需要一个复杂的波形序列先清屏把粒子全部拉到同一极性再写入图像数据按黑白/红色分别写最后执行驱动波形一序列电压脉冲。HAT Demo 代码里的刷新函数通常会封装为“发送一个命令 数据 延时”这种模式。阶段命令数据内容持续时间作用初始化0x12无200ms激活面板清屏0x20全 0x00500ms粒子复位写黑白数据0x24图像数据逐行发送取决于尺寸写入前平面写红色数据0x26红色平面数据取决于尺寸写入后平面刷新0x220xF7800ms-5s驱动波形执行void epd_refresh(EPD_t *epd, const uint8_t *data_bw, const uint8_t *data_red) { // 发送命令 0x24写入黑白平面数据 epd_send_cmd(epd, 0x24); for (int i 0; i epd-fb_size; i) { epd_send_data(epd, data_bw[i]); } // 三色屏才需要红色平面 if (epd-has_red) { epd_send_cmd(epd, 0x26); for (int i 0; i epd-fb_size / 2; i) { epd_send_data(epd, data_red[i]); } } // 发送刷新命令 0x22参数 0xF7 表示全屏刷新 epd_send_cmd(epd, 0x22); epd_send_data(epd, 0xF7); epd_send_cmd(epd, 0x20); }这里要理解命令 0x22 的参数 0xF7 是“使用面板内置的 LUT查找表进行标准刷新”不同的参数对应快速刷新、局部刷新或低温刷新模式。0x20 是激活命令真正开始执行波形驱动——这一步会阻塞直到刷新完成具体判定方式有两种轮询忙引脚或者固定延时。建议用忙引脚BUSY检测因为低温下面板刷新时间会从 800ms 延长到 5 秒以上固定延时做不好自适应。4. NB-IoT 与 GPRS 双模通信AT 指令交互与数据链路搭建4.1 模块选型与网络制式切换逻辑Demo 里同时涉及 NB-IoT 和 GPRS硬件上通常是一个双模模块如 SIM7000C、BC35-G同时支持两种制式。核心差异在射频前端NB-IoT 带宽 200kHz适合静止、低频次、小数据量的场景GPRS 带宽 200kHz 但走 2G 网络覆盖范围更广。日常做法是模块上电后先自动搜网注册不到 NB-IoT 网络时回退到 GPRS这个逻辑可以通过 AT 指令配置。// 检查SIM卡状态和网络注册情况 // 发 ATCPIN? 查询 PIN 码状态返回 READY 表示 SIM 可用 char response[64]; send_at(ATCPIN?); read_at(response, sizeof(response)); // 期望响应: CPIN: READY // 查询当前网络信号强度 // 发 ATCSQ 获取信号值范围 0-3199 表示无信号 send_at(ATCSQ); read_at(response, sizeof(response)); // 期望响应: CSQ: 15,0 其中 15 表示信号强度约 -93dBmAT 指令交互有个不成文的规矩每条指令后面必须跟\r\n有些模块还要求先发一个空指令AT来试探波特率和自动波特率检测。模块默认波特率一般有 115200 和 9600 两种匹配不上的话所有响应都是乱码。CSQ返回的两个数字中第一个是 RSSI接收信号强度指示第二个是误码率。RSSI 为 10 以下大概率连不上网络需要检查天线或换位置。4.2 TCP 连接的建立与数据发送网络注册完成后建立 TCP 连接的过程在 AT 指令层面分三步配置网络参数、创建 socket、建立连接。// 配置 PDP 上下文APN 设置为对应运营商的值 send_at(ATCGDCONT1,\IP\,\cmnbiot\); // 移动 NB-IoT 的 APN 通常是 cmnbiot电信是 ctnet // 等待网络附着成功 send_at(ATCGATT1); read_at(response, sizeof(response)); // 期望响应: OK再执行 ATCGATT? 确认状态为 1 // 建立 TCP 连接IP 地址和端口替换为目标服务器 send_at(ATQIOPEN1,0,\TCP\,\119.29.5.16\,1883,0,0); read_at(response, sizeof(response)); // 期望响应: CONNECT OK // 发送数据字符串长度要按实际字节数换算 send_at(ATQISEND0,17); send_at({\temp\:25.6,\hum\:48});这段代码里最容易踩坑的是ATQISEND0,17中的长度参数。17 是后面字符串的字节数如果长度算少了模块会把剩余数据截断如果算多了会一直阻塞等待你发送多余的数据直到超时。我在调试时先用字符串库函数算好长度再拼接到 AT 指令里避免手数。TCP 连接异常处理策略网络模块的 TCP 连接在信号不好时容易断开重连逻辑不能设计成简单的定时重拨。常见做法是设置心跳包每 30 秒向服务器发送一个极短的数据帧。如果连续三次收不到 TCP ACK主动关闭 socket 重新拨号。模块内部的 socket 资源有限不用的连接一定要用ATQICLOSE0关闭。4.3 MQTT 协议的 AT 指令封装为了和常见的物联网云平台对接Demo 代码里通常会用 MQTT 协议。AT 指令层面SIM7000C 这类模块原生支持 MQTT 命令不需要在 MCU 上跑协议栈。// 设置 MQTT 客户端 ID必须是唯一标识 send_at(ATSMCONF\URL\,\119.29.5.16\,1883); send_at(ATSMCONF\CLIENTID\,\dev_epd_001\); send_at(ATSMCONF\USERNAME\,\iot_user\); send_at(ATSMCONF\PASSWORD\,\iot_pass\); send_at(ATSMCONF\KEEPTIME\,30); // 心跳间隔 30 秒 // 建立 MQTT 连接 send_at(ATSMCONN); read_at(response, sizeof(response)); // 期望响应: OK // 订阅主题接收下行控制指令 send_at(ATSMSUB\dev/epd_001/cmd\,0); // 参数 0 表示 QoS 0最多一次投递 // 发布消息到主题 send_at(ATSMPUB\dev/epd_001/data\,20,\{\temp\:25.6}\); // 20 是消息体长度MQTT 和 TCP 直连的本质区别在于发布订阅模型。设备端不需要知道服务器 IP 和端口只需要主题名就能通信。这让设备固件的升级和服务器地址变更变得无感——换服务器时只需要修改云端的规则引擎设备侧代码不用动。KEEPALIVE 参数设为 30 秒是个折中值太短小于 10 秒会频繁消耗电量太长超过 300 秒会导致 NAT 超时后模块还认为连接活着数据一直发不出去。4.4 双模切换的实际处理自动切换 NB-IoT 和 GPRS 的代码不能写得过于激进——频繁切换会造成天线失谐和功耗升高。我一般先查询当前注册的制式如果信号低于阈值且连续失败多次才执行切换。// 查询当前网络制式 send_at(ATQNWINFO); read_at(response, sizeof(response)); // 响应示例: QNWINFO: FDD LTE,460,460,LTE BAND 1 // 切换命令 send_at(ATCMNB1); // 1 表示 NB-IoT send_at(ATCMNB2); // 2 表示 GPRS // 判断逻辑NB-IoT 信号小于 -105dBm 时降级到 GPRS int dbm rssi_to_dbm(csq_val); if (dbm -105 current_band NB_IOT) { send_at(ATCMNB2); // 切换后重新找网等待新的网络注册 send_at(ATCEREG?); }这里引用一个底噪的细节NB-IoT 的接收灵敏度通常在 -130dBm 左右达到 -105dBm 可能只是部署早期网络优化不到位。切换判定不宜只看信号绝对值还要结合实际业务成功率。如果发送数据连续三次超时即便信号显示良好也值得做一次制式切换。5. 工程化落地技巧从能跑 Demo 到了解的功耗和帧率5.1 低功耗调度的关键点不要用延时函数硬等usleep(2000000)这类写法在 PC 端没什么问题但在电池供电的物联网设备上会直接拉高平均功耗。NB-IoT 模块空闲时是 PSM省电模式电流可以低到 5uA一进入活动态就跳到 200mA。更好的做法是利用模块的 PSM 定时唤醒特性。// 配置 PSM请求 60 秒活动时间之后进入省电模式 send_at(ATCPSMS1,,,\01000000\,\00100001\); // 参数含义bit 位指定定时器单元和数值 // 进入 PSM 前先把数据发完 send_at(ATQISEND0,5); send_at(hello); // 等待模块返回 SEND OK再允许 PSMPSM 的定时器字段分段解析01000000中前四位表示活跃定时器单位02秒11分钟21小时后四位是数值的十六进制。00100001表示周期性 TAU追踪区更新定时器。这里想要理解的话再补一个点PSM 期间模块在核心网注册状态仍在只是无线连接完全断开所以唤醒后发数据不用重新 attach延迟极短。5.2 显示内容的按需刷新电子纸刷新一次消耗的电流在 20mA 左右持续时间数百毫秒看起来不多但如果每天刷新 100 次一年就是近 200Ah 的耗电量——远远超过 NB-IoT 通信。所以千万不要每收到一条消息就全屏刷新。我在实际项目中会把内容变更转成行列的差异计算int epd_find_changed_lines(uint8_t *old_fb, uint8_t *new_fb, int fb_size, int height) { int changed 0; for (int line 0; line height; line) { int line_offset line * (fb_size / height); // 逐字节比较跳过完全相同的行 if (memcmp(old_fb line_offset, new_fb line_offset, fb_size / height) ! 0) { changed; } } return changed; }当检测到变化的行数少于总行数的 20% 时使用局部刷新命令LUT 参数改为部分刷新模式只重写变化的区域。这里比逐帧比对再重画的方式更高效的实现思路是维护一个 dirty 行标记数组在逻辑层就判断哪个区域发生了变化而不是像素层。比如温度值从 25.6 变到 25.7 只影响最后一个数字的点阵范围那么只需要重绘那一个字符的宽度。5.3 调试阶段的串口日志过滤调试 NB-IoT 模块时AT 指令的响应日志会非常多掺杂在一起几乎没法看。我一般会在日志系统里按模块标记过滤便于直接定位是哪一环的问题。// 日志分级定义 #define LOG_AT (1 0) #define LOG_EPD (1 1) #define LOG_APP (1 2) void log_filter(int mask, const char *fmt, ...) { // 只输出与当前调试目标匹配的日志 if (debug_mask mask) { vprintf(fmt, ...); } } // 示例用法 log_filter(LOG_AT, TX: ATCSQ\r\n); log_filter(LOG_EPD, EPD: refresh done);日志过滤之外最好给 AT 指令和 EPD 刷新的关键路径加上短暂的时间戳用于分析整个流程的耗时分布。如果发现 TCP 连接建立占了 80% 的时间说明模块在弱信号下重连过于频繁需要在业务层增加数据缓存和批量上报——这些分析手段比光看代码能更快定位瓶颈。5.4 生产环境的固件升级策略最后提一个 Demo 里没有、但做产品一定会遇到的问题固件升级。NB-IoT 的带宽小OTA 时必须把固件分包发送而且模块自身的存储空间有限。// 分块接收固件数据 #define OTA_BLOCK_SIZE 1024 static uint32_t fw_offset 0; void ota_receive_block(int sockfd, uint8_t *block, int len) { if (fw_offset len FW_MAX_SIZE) { log_error(firmware overflow); return; } // 写入外部存储这里假设有 spi flash 支持 spi_flash_write(fw_offset, block, len); fw_offset len; // 下一次发送确认——注意这里确认的是接收偏移 char ack[16]; snprintf(ack, sizeof(ack), ACK:%u, fw_offset); send_at(ack); }OTA 分包的关键问题是断点续传。模块如果在升级过程中断电重启后需要知道上次写到哪个地址直接继续而不是从头开始。一个简单的办法是把fw_offset每次写完后同步存到 Flash 的另一个扇区防止数据区和元数据同时损坏。另外升级前必须校验固件的完整性CRC32 或者 MD5不然刷入一个被截断的固件设备可能变砖且没有恢复机制。这套 Demo 代码里的 BMP 素材和 GBK.bin 字库意味着很多开发者拿到的只是一个显示效果演示但真正要上生产环境需要在这些细节上一层层做深位图格式的选择1bpp 还是 2bpp 直接决定存储成本和刷新速度、字库的裁剪GBK 全集长达几 MB不一定全放进外置 Flash、以及功耗调度的精细化。把这些基础工作做好电子纸加 NB-IoT 这个组合在远程看板、数字标牌和智慧物流场景里的表现会远超你的预期。本文还有配套的精品资源点击获取
返回列表