简介:面向STM32嵌入式开发者的二维码显示参考工程,基于STM32ZET6与红牛开发板实现LCD图片显示及二维码生成演示,适合需要快速评估二维码在单片机端落地方案的软硬件工程师。作者对比qrencode、Zbar、Zint、ZiXing等开源库后选择qrencode进行移植,工程由MDK4.72编译验证通过。资源包共120个文件,以45个h头文件、42个c源文件和32个s汇编启动文件为主,另含1个uvproj工程文件,整体仅679KB;代码覆盖STM32标准外设驱动、LCD底层驱动、二维码输入处理等模块,目录结构紧凑,便于在现有工程中裁剪复用。已有2451人学习下载,适合具备一定单片机基础、希望在设备端自助生成并显示二维码的开发者参考。
1. 在单片机上显示二维码,先想清楚这三件事
你的设备可能要升级成带屏幕的智能硬件:一张二维码贴在状态页上,手机一扫就进入配网流程、绑定账号或者查看序列号。很多开发者第一次在 STM32 上做二维码显示,会直接把 PC 上的现成库搬进工程,结果发现 RAM 不够、屏幕刷出来一团马赛克、手机怎么都扫不出。原因很固定:二维码不是一张普通图片,它有明确的模块尺寸、静区要求和纠错设计,放到 MCU 上,要考虑的不只是“能不能画出来”,还有矩阵从哪来、像素怎么映射、刷新会不会撕裂。
这篇文章按我日常接这类需求时的顺序展开:先选二维码的生成路线,再落屏幕渲染,再做资源和性能取舍,最后解决手机扫不扫得出来的问题。适合正在做设备配网、产线追溯、充电桩、医疗仪器显示管理码这一类带屏下位机的开发者。你会看到可落地的 C 代码、像素索引计算方法,以及我踩过之后才搞明白的几个大坑。目标是让你的屏幕从“能显示二维码图案”变成“能被扫码设备稳定识别”。
2. 静态数组还是运行时生成:二维码产出的两条稳定路线
二维码矩阵的获取方式决定了整个软件架构。常见做法有两种:把二维码在 PC 上算好,生成 C 数组写进 Flash;或者在单片机上运行轻量级编码器,根据运行时数据现场生成。我实际做过的大多数项目里,内容固定、批量生产的会走第一条路,内容带变量、需要设备自己组合信息的会走第二条路。下面把两者的操作过程和适用边界说清楚。
2.1 静态路线:用 PC 工具算好,把矩阵存进 Flash
如果二维码内容在产品出厂前就是确定的,比如设备型号码、产品序列号区间、售后网址,我最常用的是静态路线。你不需要在 STM32 里写任何二维码编码逻辑,只需要一次性把二维码矩阵转换成数组,编译进固件。
转换过程可以写成一个小脚本,放在项目目录的 tools 文件夹里,以后每次换内容跑一遍就行。脚本的输入不是成品 PNG 图片,而是只包含二维码模块矩阵的位图。二维码模块就是黑白小方块,每个模块对应一个像素值;如果你拿到的图片是带圆角、带背景、带图标的那种,需要先还原成标准模块矩阵再采样,否则转换出来的数组是错的。
下面这串代码把一张按模块对齐的位图转成按行排列的一维字节数组,每个字节存 8 个模块,高位在前:
from PIL import Image img = Image.open('qrcode_src.png').convert('1') # 我这里假设原图每个模块用独立像素表示,不做缩放。 # 如果你手里的图是每个模块占 N 像素,先按 N 的网格采样。 w, h = img.size assert w == h, "二维码必须是正方形位图" rows = [] for y in range(h): byte_val = 0 for x in range(w): # '1' 模式下 0 表示黑,255 表示白 module_black = (img.getpixel((x, y)) == 0) byte_val = (byte_val << 1) | (1 if module_black else 0) if x % 8 == 7: rows.append(byte_val & 0xFF) byte_val = 0 if w % 8: rows.append(byte_val & 0xFF) print('const uint8_t qr_matrix[] = {') for i in range(0, len(rows), 16): print(' ' + ', '.join('0x%02x' % b for b in rows[i:i+16]) + ',') print('};') print('// 矩阵边长(模块数): %d' % w)这段脚本的关键点在于像素值方向:Pillow 的1模式下 0 表示黑,代码里把黑色模块记成 1。如果你的编码器或者显示驱动约定相反,后面渲染时把 0 和 1 互换即可。位压缩方向我用了高位在前,也就是每行第一个模块放在字节的最高位,这样的数据流和 SSD1306 的显存结构很自然,后续不用额外反转。
生成结果是一个const uint8_t数组,烧进 Flash。整个 STM32 工程里不再有二维码生成代码,二维码显示退化成位图渲染,步骤简单、问题少、响应时间确定。代价是你改了内容就必须重新走一遍生成流程,不适合内容需要设备在运行中自己拼接的场景。
2.2 动态路线:把轻量级 QR 编码器移植进工程
内容里带变量,比如 WiFi 名和密码、设备当前 IP、生产批次号、条码后的自定义校验参数,就没法用静态数组。你要在 MCU 上根据运行时的字符串现场编码出二维码矩阵。
完整的 QR 编码流程包括数据分段、字符编码、纠错码计算、矩阵布置、掩码处理和格式信息写入,自己从零写一遍的工作量不小。我一般不重复造轮子,而是挑一个以 C 语言实现、只依赖标准库、不依赖操作系统的轻量级开源编码器,把它移植进工程。这类库核心代码通常只有两三个文件,在桌面端先用固定输入验证输出矩阵,再交叉编译到 STM32。
移植后的使用接口我会统一包一层,避免工程代码被具体库实现绑架。封装的头文件大概是这个形态:
// qr_mini.h #ifndef QR_MINI_H #define QR_MINI_H #include <stdint.h> typedef enum { QR_ECC_LOW = 0, QR_ECC_MEDIUM = 1, QR_ECC_QUARTILE = 2, QR_ECC_HIGH = 3 } qr_ecc_t; // 在 data_buf 中生成二维码矩阵。 // content: 要编码的字符串(UTF-8 或 ASCII) // ecc: 纠错等级 // data_buf: 输出缓冲区,按 1 byte/module 存放 // buf_size: 缓冲区字节数 // 返回值: 矩阵边长(模块数),失败返回 0 uint8_t qr_generate_matrix(const char *content, qr_ecc_t ecc, uint8_t *data_buf, uint16_t buf_size); #endif接口做成“调用一次,得到完整矩阵”而不是分阶段回调,是考虑到 MCU 上内存紧张,编码过程中间状态全在函数内部解决,腾出更多 RAM 给显示缓冲。实际使用中我会把data_buf声明成static数组,避免在任务栈里占用过大空间,尤其是跑 RTOS 时,任务栈默认大小很可能不够放一个版本 8 以上的二维码工作区。
如果你遇到的库只提供了“编码到像素数组”而不是“编码到模块矩阵”的接口,需要小心:像素数组已经把缩放后的渲染结果也算进去了,这在 GD32、APM32 这类兼容 MCU 上虽然可用,但是改显示尺寸时要重新编码,不够灵活。我习惯先拿模块矩阵,把缩放交给显示例程,一套编码逻辑能适配 OLED 和彩色 TFT。
2.3 选哪条路:版本、纠错等级和 RAM 开销对比
选型时要有一个量化依据。最常见的是内容长度和矩阵大小的关系:版本 1 是 21×21 模块,每升一版边长加 4,版本 4 就是 33×33。内容越长、纠错等级越高,版本就越大。嵌入 MCU 场景下,我希望版本尽量控制在 6 以内,也就是 41×41 模块,超过后不仅屏幕像素紧张,编码过程需要的临时缓冲区也急剧变大。
| 版本 | 模块数 | 按 1 byte/module 存放所需 RAM | 典型容量(L 级纠错) |
|---|---|---|---|
| 1 | 21×21 | 441 B | 约 25 个字符 |
| 2 | 25×25 | 625 B | 约 47 个字符 |
| 4 | 33×33 | 1089 B | 约 100 个字符 |
| 6 | 41×41 | 1681 B | 约 172 个字符 |
如果内容在 40 个字符以内,优先把纠错等级放到 Q 甚至 H,实际扫起来容错性明显好。A 系列的使用场景中,我把设备序列号加 URL 拼在一起,内容通常在 80 字符左右,纠错等级用 M,版本在 4 到 5 之间,RAM 开销可控,扫码距离和角度都还不错。
静态路线省掉的是编码算法的 Flash 占用和运行时计算,但牺牲灵活性。动态路线则要额外保证data_buf容量不小于版本上限对应的模块数。我一般留 2 倍余量,防止极端内容触达上限时静默失败。两个路线可以同时存在:出厂默认内容用静态数组,收到修改指令后切换成动态生成,这在联网设备上是常见设计。
3. 把二维码画上 128×64 OLED:像素映射和缓冲刷新
二维码矩阵本身只是逻辑行列,要显示到物理屏幕,必须处理坐标映射。这个步骤看着简单,实际上大多数二维码显示不出来的原因都藏在这里:模块和像素的对应关系错位、静区被屏幕边缘吃掉、比例不是整数导致模块边缘虚化、显存字节序和屏幕控制器不一致。本节用一个 SSD1306 的例子把整条链路打通。
3.1 从模块矩阵到像素点:缩放、偏移和静区
二维码标准要求图案四周至少保留 4 个模块宽度的静区,也就是白边。屏幕的物理边框作为静区不可靠,因为不同设备边框宽窄不同,浅色外壳还好,深色外壳很容易吃掉白边。我一般会在固件里主动设置一个quiet_zone变量,默认 4 个模块,渲染时统一往右下偏移。
显示比例上,扫描识别建议每个模块至少 4×4 像素。屏幕一共 128×64 像素,静区加上实际尺寸需要一步算清。举例来说,版本 2 是 25×25 模块,若缩放 2 倍,实际图案 50×50 像素,四周静区 4 模块就再占 8×2=16 像素,整体 82×66 像素,竖屏 OLED 宽度绰绰有余,高度刚好越界。这种时候我优先把左右静区降为 2 模块,上下保持 4 模块,同时把图案垂直居中。
渲染时用两层循环,把每个模块画成scale × scale的实心矩形。循环顺序不重要,但位图偏移要注意,错误处理如下一段代码所示:
void qr_render_to_framebuffer(int16_t *fb, const uint8_t *matrix, uint8_t matrix_dim, uint8_t scale, uint8_t quiet_zone) { for (uint8_t my = 0; my < matrix_dim; my++) { for (uint8_t mx = 0; mx < matrix_dim; mx++) { uint8_t bit = (matrix[my * ((matrix_dim + 7) / 8)] >> (7 - (mx % 8))) & 1; // matrix 数组假设按行字节线性排列 // 每个模块画一个实心块 for (uint8_t dy = 0; dy < scale; dy++) { for (uint8_t dx = 0; dx < scale; dx++) { int16_t px = quiet_zone * scale + mx * scale + dx; int16_t py = quiet_zone * scale + my * scale + dy; fb_set_pixel(fb, px, py, bit); } } } } }这里的matrix是 1 bit/module,bit的值 1 表示黑色模块。如果你和我一样用静态数组,位序必须和数组生成脚本保持一致;如果数组生成和渲染的字节序不一样,最常见的结果就是图案显示出来是镜像或竖条错位。尤其注意mx % 8的位置,二维码矩阵长度不一定是 8 的倍数,行尾会有没占满的位,不能把下一行数据补进来。
3.2 SSD1306 的页缓冲结构:像素映射索引别搞错
SSD1306 的显存不是线性一行接一行存,而是按页 Page 存储。128×64 屏幕分成 8 个 Page,每个 Page 覆盖一行 8 像素,一页内每列用一个字节表示,字节的 bit0 对应页内第一行像素。这个结构和常见 TFT 控制器完全不同,直接套用逐行屏的 framebuffer 写法会得到一幅隔行错乱的画面。
我用下面的函数作为统一的单像素写入口,其他绘制代码全部走它,避免多处维护字节偏移逻辑:
#define OLED_W 128 #define OLED_H 64 #define OLED_PAGE_NUM 8 static uint8_t oled_fb[OLED_W * OLED_PAGE_NUM]; // 1024 字节 static inline void oled_fb_set_pixel(int16_t x, int16_t y, uint8_t color) { if (x < 0 || x >= OLED_W || y < 0 || y >= OLED_H) return; uint16_t page = y / 8; uint16_t offset_in_page = y % 8; uint16_t byte_index = page * OLED_W + x; if (color) { oled_fb[byte_index] |= (1 << offset_in_page); } else { oled_fb[byte_index] &= ~(1 << offset_in_page); } }注意索引顺序:page * OLED_W + x而不是y * OLED_W + x。很多从 TFT 转过来的开发者容易在这里翻车,画出来像是隔几行丢一行。oled_fb一共 128×64/8=1024 字节,在 STM32F103 这种 RAM 只有 20KB 的芯片里占用不大,可以定义成全局静态数组,也可以在任务创建时候分配。
渲染二维码时把矩阵数据通过oled_fb_set_pixel写入这个帧缓冲,再做整屏刷新。因为二维码本身就是黑白二值,不需要走字库、画圆这些复杂路径,整屏刷新一次 SSD1306 的时间在 I2C 400kHz 下大约 25ms 以上,肉眼不会觉得明显迟缓,但如果刷新和编码同时做,分段写入造成的撕裂就开始了。
3.3 一次性刷新最少显存:通过 I2C 把缓冲区送过去
当整个oled_fb更新完成,下一步是把它发给 SSD1306。比较保险的写法是只发送受二维码影响的 Page 区间,而不是每次从 Page 0 刷到 Page 7。二维码图案区域通常是居中的,上下往往有大量空白,有些开发者图省事每次都整屏刷,带来的问题是刷新时间变长,功耗升高,而且空白页占用的 I2C 传输时间在高频刷新场景里造成丢帧。
发送部分我用标准外设库风格的函数来写,实际工程中换成 HAL 或 LL 库都行:
void oled_refresh_pages(uint8_t start_page, uint8_t end_page) { for (uint8_t page = start_page; page <= end_page; page++) { uint8_t col_start = 0; uint8_t col_end = OLED_W - 1; // SSD1306 的列地址是 0..127,页地址是 0..7 oled_send_cmd(0x22); // 设置页地址范围 oled_send_cmd(page); // start page oled_send_cmd(page); // end page oled_send_cmd(0x21); // 设置列地址范围 oled_send_cmd(col_start); oled_send_cmd(col_end); // 数据从第 page*W 个字节开始 i2c_mem_write(oled_addr, 0x40, &oled_fb[page * OLED_W], OLED_W); } }这个函数做的是把更新区域圈定在二维码实际覆盖的 Pages,然后按页作为连续数据块写到显存。实际操作中要注意 SSD1306 列地址和页地址命令在部分兼容屏上行为略有差异,比如某些 132 列非标准屏,列地址设置要先加偏移,否则最右列显示不上。我通常会在拿到屏幕后先用全屏写 0x55 画横线,再用 0xAA 画竖线,验证列方向是否正确。
4. 动态内容与低内存:重新生成、局部刷新和功耗取舍
当二维码内容在运行期变化,你会遇到新问题:重新生成矩阵时,原来的显示内容要不要清掉?生成过程占用的栈空间会不会压垮任务?高频刷新会不会造成屏幕闪烁?这章节解决“内容变了之后,如何让屏幕跟上”以及“如何在资源受限下保持稳定”。
4.1 控制临时内存:版本上限和编码工作区
二维码编码库内部往往需要多块临时内存,常见的是版本信息表、纠错码表、掩码评分用的分区矩阵。这些表如果按最大版本一次分配,RAM 会非常难看。比如版本 40 的矩阵是 177×177,光模块矩阵就 31329 字节,单块 SRAM 差点都要放不下。所以 MCU 上必须限制可生成的最大版本。
我在产品代码里定义版本上限宏,编译期的预算链路如下:
#define QR_MAX_VERSION 8 #define QR_MAX_MODULES (17 + QR_MAX_VERSION * 4) #define QR_MATRIX_BYTES ((QR_MAX_MODULES * QR_MAX_MODULES + 7) / 8) #define QR_WORK_BUF_SIZE 512 // 纠错码和临时编码的中间区 static uint8_t qr_matrix_buf[QR_MATRIX_BYTES]; static uint8_t qr_work_buf[QR_WORK_BUF_SIZE];版本 8 是 49×49 模块,纯矩阵存储是 301 字节,加上 512 字节工作区,整体不到 1KB。这比直接使用默认最大版本的做法节省几十 KB。如果你的内容长度控制了版本不会超过 4,甚至可以把上限设到 4,工作时区进一步减少到 300 字节以内。
这里有个容易忽略的问题:部分 QR 编码器源码里对malloc有依赖。MCU 上启用 malloc 容易产生堆碎片,尤其是反复生成二维码时,编码一次分配一块、释放一块,长跑后如果堆空间不足会把整个系统拖死。在嵌入式设备上建议找纯静态实现的版本,或者手动把malloc替换成固定内存池。我在某个模拟项目 X 上就遇到过设备连续运行一周后随机死机,最后排查到是堆碎片导致的分配失败,换成固定内存池后问题消失。
4.2 局部刷新:只更新二维码覆盖的页
二维码区域通常是屏幕中间一个方块,上下左右都有大量空白。如果你重新生成矩阵,不必把整块屏全刷一遍,只需要重绘二维码所在的窗口。尤其是 I2C 传输速度受限的场景,局部刷新能显著降低系统总线的占用时间。
假设二维码矩阵经过缩放后从屏幕(qx, qy)开始绘制,尺寸为qw × qh像素,那么受影响的 SSD1306 Page 范围是qy / 8到(qy + qh - 1) / 8。我的更新策略是先清掉二维码覆盖的窗口内容,再重新渲染新矩阵,然后执行oled_refresh_pages(p_start, p_end)。
局部刷新还有一层好处:如果你在 QR 图案周围显示了状态文字或者 UI 图标,这些区域不会被刷掉,屏幕整体感觉更稳定,不会看到整个屏闪一下才换内容。实际拍摄时会发现,二维码更换时周边元素保持不变,扫码枪对动态区域的识别成功率也高一些。
4.3 刷新率与功耗平衡:动态生成不是越高频越好
有些开发者误以为动态内容要求实时刷新,于是写了一个死循环不断生成和刷新二维码,结果 CPU 占用高、屏幕功耗大、电池发烫。实际上扫码识别需要的是“二维码保持不变的一段时间足够长”,而不是刷新越快越好。扫码器拿到图像后需要一定时间去定位寻像图形和解码,如果你把二维码在屏幕上每 50ms 变一次,手机拍到的往往是一张花屏或者中间状态的残影。
我一般用两个参数来控制动态刷新:一个是内容变化时间,另一个是最低持屏时间。设备配网场景在二维码内容变化并不频繁时,用简单的时间戳判断即可:
#define QR_HOLD_MS 800 // 每个内容至少停留 800ms uint32_t last_qr_generate_ms = 0; uint32_t now_ms = get_tick_ms(); if (content_dirty_flag && (now_ms - last_qr_generate_ms >= QR_HOLD_MS)) { qr_generate_matrix(new_content, QR_ECC_MEDIUM, qr_matrix_buf, sizeof(qr_matrix_buf)); qr_render_to_framebuffer(oled_fb, qr_matrix_buf, version, scale, quiet_zone); oled_refresh_pages(...); content_dirty_flag = 0; last_qr_generate_ms = now_ms; }如果内容变化特别频繁且无法合并,比如服务器一直推送不同的配置码,那就要做“周期性刷新 + 内容缓存”的架构,甚至在系统忙时暂时丢更新,优先保证当前二维码的可扫性。这种情况下把生成操作放到独立的低优先级任务里,显示任务保持固定节拍,效果远好于把所有工作挤在一个循环中。
OLED 在显示固定画面时如果开启了亮度保持,耗电模型和内容有关。二维码黑色模块多的图案,在白色屏幕上显示的功耗取决于驱动 IC 对像素电流的控制方式,多数 OLED 是全白驱动统一走电流,所以图案填充率高的电耗略高。量级上的差异做低功耗产品时需要实测,用电流钩表夹在屏幕电源脚,分别刷全白、全黑和二维码图案记录数值。
5. 二维码显示避坑:五个现场问题与定位方法
这部分我直接列几条日常调试中最容易出现的坑,每条都是“现象 → 原因 → 解决”结构,你可以对照自己的现象快速定位。
5.1 图案很清晰,但手机怎么都扫不出来
现象:屏幕上的二维码形状正确,黑色白色分明,没有虚影和闪烁,但手机相机对准后一直识别失败。我最早遇到这问题时还以为是相机亮度问题,换了几台手机都一样。
原因:多数静区和模块尺寸不足。模块尺寸小于 4 像素时,相机在正常距离下无法在整块深色和浅色之间建立可靠判据;静区小于 2 个模块时,寻像图形周围掺杂了背景纹理,定位失败。另一个常见原因是屏幕外壳的玻璃或亚克力罩导致反光,模块边缘高光反射形成白斑。
解决:把scale至少提到 4,屏幕放不下的版本就降低纠错等级或缩短内容,保证模块数不超。静区在固件里预留 4 个模块宽,而且这个区域不能被 UI 元素侵入。如果屏幕带盖板,把盖板做成磨砂或调整屏幕角度,先裸屏测试扫一遍,再盖盖板测试,确认是不是反射引入的问题。
5.2 二维码印出来左右相反,扫出来变成乱码
现象:屏幕上显示的是二维码镜像,比较高端的扫描器能在一定程度上自动纠正镜像,但很多便宜扫码枪和解码库直接识别失败。镜像也分水平镜像和垂直镜像,表现不一样。
原因:数组生成脚本或渲染循环的方向和预期相反。比如脚本按从左到右扫描,放了高位在前,但渲染函数又按低位在前解析,或者渲染的循环坐标把x映射到了y。
解决:用二值数据回头看第一个字节,打印出前 16 个 bit,和标准二维码的左上角寻像图形做对比。寻像图形是左上、右上、左下三个 7×7 定位方块,如果矩阵翻译出来这三个位置不对,就按镜像或位序逐项排查。最直接的方法是找一个可靠的二维码生成软件,生成一个内容为A的二维码,把矩阵打印出来比对。
5.3 内容含中文,生成的二维码扫出来是乱码
现象:内容包含中文文件名、设备名称或者 URL 里的中文参数,生成时屏幕上画出了密密麻麻的模块,但手机扫描后显示乱码。
原因:二维码的字节模式不支持多字节 UTF-8 的标准实现差异,部分嵌入式库直接把字符按单字节处理,中文变成了 UTF-8 的三个字节,但目标扫码端期望的可能是 GBK 编码或者其他编码。不同应用扫出来的编码结果不一样,这是因为库内部的编码表和终端默认编码不一致。
解决:一种方案是把内容限制到纯 ASCII 字符,比如 URL 里的中文转成%E5%BC%A0这种百分号编码;另一种方案是在 PC 端预先用目标编码表做一遍转换,确认扫码终端能识别后再固化内容。如果你的业务必须支持原始中文,建议把编码逻辑放在生成端,不依赖 MCU 库的内部实现。
5.4 屏幕刷新时有残影和撕裂带
现象:动态二维码内容切换时,屏幕出现上半部分新内容、下半部分旧内容或者一条条的刷新错位横带,手机恰好在这一刻拍照就会解码失败。
原因:SSD1306 没有硬件撕裂同步机制,代码把二维码新内容一页一页发送,总线传输期间屏幕上不同 Page 已经更新到不同版本。高频内容是重灾区。
解决:确保二维码矩阵表格渲染进oled_fb是完整一帧后再刷新 Pages,不要一边算一边发。同时缩短刷新数据跨度,只刷二维码所在的 Pages;传输期间如果有其他中断任务访问屏幕,也要加锁或者把数据拷贝到临时缓冲做一致性快照。
5.5 系统跑一会儿二维码就变成全屏噪点
现象:刚开始一切正常,运行几分钟或几小时后,二维码图案逐渐花掉,甚至变成满屏黑白噪点。
原因:动态生成的场景最常见,二维码编码函数内部覆盖了oled_fb相邻地址,导致帧缓冲的数组越界写坏。其次是 RTOS 任务的栈溢出,任务栈不够大,函数调用时把返回地址冲到栈外,最终花屏的“花点”并不是随机噪声,而是被破坏之后读到的未知数据。
解决:用编译器的栈保护功能,在任务入口设置栈填充值,过一段时间检查栈是否被吃掉;再把oled_fb定义到单独的 Section 前后加保护字节。如果设备上有调试串口,每生成一次矩阵就把矩阵缓冲区末尾 16 字节的 expected 值打出来做连续校验,能快速判断是生成器还是渲染器越界。
6. 进阶:从“能显示”到“扫得动”的验证与内容设计
二维码显示做出来不是结束,它能被稳定识别才是价值。这一节分享我日常收尾时会做的验证方法和内容设计经验。
6.1 给二维码做回归测试:从“看起来是”到“扫得出”
我口袋里常备一台拍照手机和一把 USB 扫码枪。裸屏阶段先拍屏二维码,确认手机能直接识别;加上外壳和盖板之后,再在同一距离同一角度测一遍。因为屏幕尺寸、玻璃厚度、背光亮度都会影响识别,裸屏测试通过不等于成品通过。
要量化稳定性,我采用简单的计时法:把内容固定,调整扫码设备高度,看最短稳定识别时间,连续扫 20 次,成功率达到 95% 以上才算过关。如果成功率不稳,优先加模块尺寸,其次降纠错等级,再次调静区宽度,而不是去调亮度参数。亮度过高会导致白色模块反光发灰,对比度反而下降。
6.2 把内容摁进版本 4 以内:URL 缩短和冗余编码
二维码版本越高,模块越细密,同样像素屏幕上每个模块能分配的物理像素越少,识别难度越大。所以我在产品设计阶段就坚持“内容长度约束”。一个 30 字符的 URL 和 90 字符的 URL,在同样屏幕上完全是两种识别表现。常见的办法是使用跳转短地址,比如固定前缀加序列号,而不是把长参数全部塞进二维码。既能控制版本又能减少编码后的数据复杂性。
内容里如果你需要载荷有自校验,比如两部分信息拼在一起,不要让字符串天然带有歧义。可以用一个字符作分隔符,也可以在信息尾部加入固定长度校验字段。某次我帮一个团队改产线码,内容从 60 字符压到 28 字符后,同一块屏幕上模块尺寸从 2 像素升到 4 像素,扫码成功率从 80% 提到接近 100%,这就是内容设计的价值。
6.3 我的长期习惯:二维码显示模块独立出一个静态库
最后分享一个我踩坑之后养成的习惯。我会把“生成矩阵、矩阵渲染、页刷新、逻辑内容更新”这四层拆成独立文件,各自只通过接口通信,不出现在一个巨型文件里。这样在屏幕方案从 SSD1306 换到彩色 TFT 时,矩阵生成逻辑一行不用改,渲染层重新实现可移植到 ST7789 或 ILI9341。未来如果从裸机换到 FreeRTOS,把生成任务和数据刷新任务挂到不同优先级即可,接口不用重新设计。
这个分层也让我在新项目中能直接复用二维码生成那一层,不用每次面对某个开源编码器时重新拉通。很多嵌入式二维码显示翻车,不是算法难,而是把显示和生成耦合得太紧,一次变动牵全身。如果你在调自己的项目,能把二维码模块独立出来验证,会省掉后面大量排障时间。希望帮到你。
本文还有配套的精品资源,点击获取