前阵子在 EASY EAI 评估板上调通了一个功能:基于瑞芯微 RV1126B 的本地二维码生成。听起来好像只是把开源库交叉编译一下,再调两个 API 画个图,但真正落地的时候,会发现里面藏着编码规范、字节序、内存对齐、扫描识别率这些琐碎但又绕不开的细节。这篇文章就从我的实际操作出发,把整个“二维码生成”从选型、编译到上屏验证的完整链路捋一遍,给正在做类似嵌入式 Linux 显示项目的朋友做个参考。
这个需求在很多场景里都是刚需,比如设备现场配网、扫码绑定账号、离线交接资产信息、生成带签名的动态凭证,围着一个屏幕转。RV1126B 这颗芯片本身带 NPU,但二维码生成是个纯 CPU 任务,反倒不需要 NPU 参与。EASY EAI 平台给我省了不少事,SDK 里已经把交叉编译工具链、根文件系统和显示接口都准备好了,我只需要把生成库塞进去,再写清楚编码转换和显示输出这两块的逻辑,就能在设备端独立完成二维码的生成和展示,整个过程不依赖任何外部服务器,也不需要有网络环境。
如果你手头也是瑞芯微平台的板子,或者正在做类似的嵌入式图形、扫码联动项目,这篇文章可以直接拿来当操作笔记用。内容会覆盖方案选型、交叉编译、中文编码转换、framebuffer/图片输出、性能优化,还有我在真机上踩过的几个坑。特别适合刚接触嵌入式 Linux 应用开发、或者第一次搞二维码相关功能的工程师。
1. 项目定位与整体设计思考
1.1 为什么要在设备端本地生成二维码
我接手这个需求的时候,第一反应是:设备上不是有联网能力吗,直接请求服务器下发一个二维码图片不就行了?但把场景展开一看,本地生成几乎是必须的。
有些设备部署在没有外部网络的机房、工厂车间甚至室外,服务器下发这条路直接断掉。还有些场景里二维码承载的内容是动态变长的,比如设备入网时要把当前 IP、端口、设备序列号和时间戳打包成一个 URL,如果每次都走网络,延迟虽然不高,但总给人一种“断网即瘫痪”的隐患感。更重要的是,有些二维码内容需要结合设备本地状态才能算出,比如签名串、本地随机数、临时密钥,这些本来就不该经过第三方中转,本地生成反而更安全。
从资源占用角度来看,二维码生成是个轻量级计算任务。RV1126B 跑的是四核 Cortex-A7 架构,主频不算高,但生成一个容纳几十字节内容的常规二维码,耗时通常在几毫秒到几十毫秒之间,完全可以忽略。把这块活儿放进设备本地,既省了服务器带宽,也免了网络响应时间,体验上会更稳定。
1.2 方案选型:二维码库与输出方式的取舍
确定要本地生成之后,库的选型就摆在面前了。我当时对比了三个方向:直接用成熟开源库、用 OpenCV 里的二维码模块、自己写生成算法。
自己写算法最不推荐,二维码编码规范涉及数据分片、容错纠错、掩码图案选择、格式信息排布,里头各种查表和位运算,没有经历过编码标准化训练的人很容易写出“能扫但偶尔失败”的库,排查成本比集成成本还高。OpenCV 的二维码编码器功能完整,但 OpenCV 整个依赖树太大,为了一个编码功能拖进来几 MB 到几十 MB 的库,在嵌入式平台上有点奢侈。
最后我选了成熟的开源小库 qrencode。它本身是用 C 写的,核心代码量不大,依赖极少,默认只需要标准 C 库和可选的内存分配器。只要在编译配置阶段把 PNG 输出模块关掉,甚至可以做到只依赖 libc,这对交叉编译和搬运到目标板上来说非常友好。
输出方式上,我对比了三条路。第一条是把二维码画成点阵之后直接写进 framebuffer,这条路效率最高,但要把画面位置计算、颜色格式匹配、裁剪区域这些逻辑都自己处理。第二条是生成 BMP 图片,再把图片交给上层 UI 库显示,方便调试,中间多一步编码。第三条是生成 SVG 字符,再由渲染引擎解析,依赖比较大。我最终采用了“生成 RGB 点阵 + 按需封装成 BMP/直接写显存”的结构,生成部分和显示部分解耦,调试和交付都方便。
2. 环境搭建与工具链准备
2.1 交叉编译工具链的准备与确认
EASY EAI 的 SDK 默认提供了一套完整的交叉编译工具链,通常基于 GCC 的 arm 版本。拿到板子的第一件事不是急着编译应用,而是先把工具链的路径加到当前终端环境里,然后确认几个关键信息:工具链前缀、sysroot 目录、动态链接器版本。
我习惯用这样的方式检查环境:
export PATH=$PATH:/opt/easy-eai/toolchain/bin arm-linux-gnueabihf-gcc -v arm-linux-gnueabihf-gcc -print-sysroot注意检查 sysroot 里有没有标准的头文件和库,因为后续编译 qrencode 时,configure 脚本会自动探测目标系统的库情况,如果 sysroot 不完整,很容易编出“在宿主机上看起来正常、拷到板子上跑不起来”的库。
还有一个容易踩的细节:工具链自带的标准库版本和板子上的根文件系统必须匹配。SDK 里的 rootfs 一般和工具链是配套的,直接用它就行。如果自己替换过 rootfs,建议先写个 hello world 交叉编译出来,拷到板子上运行一次,排除工具链本身的问题再做后续移植。
2.2 二维码生成库的移植
qrencode 的源码获取很方便,官方发布包解压后是个标准的 autotools 工程。交叉编译步骤固定,核心是给 configure 传递正确的 host 和 prefix 参数。
./configure --host=arm-linux-gnueabihf --prefix=/usr/local/qrencode-arm --without-png make -j4 make install一个常见的疑问是:--prefix 到底设成什么。它其实只影响 make install 时候文件放哪,以及 pkg-config 之后怎么找库。如果你跟我一样不打算在根文件系统里装开发包,只是想拿到静态库或动态库文件,那么 --prefix 设成任意临时目录都行,安装完之后把 include 和 lib 目录里的文件拷贝到工程目录里直接使用即可。
编译过程中我建议关掉 PNG 支持,也就是加上 --without-png。原因有两个。第一,PNG 输出依赖 libpng,libpng 本身又依赖 zlib,交叉编译链会变长,目标板上也得带上这些动态库,明显不划算。第二,大多数嵌入式显示场景里,我们需要的是原始点阵数据或位图,而不是压缩后的 PNG。有了 QRcode 结构体里的 data 指针,随时可以自己拼 BMP 或者直接写 framebuffer,PNG 这层封装反而多余。
2.3 裁减依赖后得到的核心产物
编译完成之后,会得到两个核心产物:头文件 qrencode.h 和库文件 libqrencode.a 或 libqrencode.so。
我在项目里选择使用静态库。原因很直接:板子上 rootfs 空间余量不大,动态库要额外拷贝到 /usr/lib,还要配好 ldconfig 路径,万一升级库版本或者换了 rootfs,链接关系容易出问题。静态库直接编进可执行文件里,部署时只需要拷一个二进制包,干净利落。
当然静态库也有代价:如果多个程序都用这个库,每个可执行文件都包含一份代码,磁盘占用会稍微高一些。但对我们这个项目来说,板上就一个主程序用二维码生成,这不是问题。
把 qrencode.h 和 libqrencode.a 放到工程目录下的 third_party/qrencode 文件夹里,之后写 CMakeLists 或 Makefile 时直接引用这个路径,整个交叉编译环境就算准备好了。
3. 二维码生成核心实现细节
3.1 编码流程与 API 使用要点
qrencode 的 API 并不复杂,最常用的是两个函数:QRcode_encodeString 和 QRcode_encodeData。
QRcode *QRcode_encodeString(const char *string, int version, QRecLevel level, QRencodeMode hint, int casesensitive); QRcode *QRcode_encodeData(size_t size, const unsigned char *data, int version, QRecLevel level);第一个函数接收以 null 结尾的字符串,内部会自动判断长度并选择合适的版本。第二个函数接收原始字节数组,适合要编码的内容里包含不可见字符或者已经完成编码转换的情况。我做中文二维码时用的是第二个,因为中文字符串必须先转成目标字符集,再以字节流形式传给编码器,用 encodeString 走默认的 UTF-8 路径反而不好控制。
常见的一个坑是 version 参数的语义。传 0 表示让库自动选择最小版本,这是最省心的做法。但如果你对二维码尺寸有严格限制,比如只能占屏幕一小块区域,就得手动指定一个较小的 version,同时要确认待编码内容长度没有超过该版本的容量上限。否则库内部会报错,返回 NULL,程序直接段错误或出现一条“Data too big”的错误日志。
纠错级别也值得认真对待。QR 码有 L、M、Q、H 四个级别,对应 7%、15%、25%、30% 的纠错能力。在屏幕上显示的场景,我推荐至少用 M 级。因为屏幕本身可能会有反光、亮度不均、摩尔纹,甚至贴了保护膜以后有一层漫反射,这些都会干扰扫码。用 M 级纠错能在“信息密度”和“识别鲁棒性”之间取得一个不错的平衡。如果你准备做户外强光下的扫码,直接用 Q 级更稳,代价是同样内容下生成的二维码模块数会变多,画面会显得更“挤”。
3.2 中文字符编码问题:UTF-8 到 GB2312 的转换
这是整个项目里最容易出幺蛾子的环节,也是很多人在 PC 上开发时完全意识不到的问题。二维码标准里对不同字符集并没有一个绝对强制的编码方式,字节模式的解释权在扫码端。国内常用的微信、支付宝等扫码工具,对 GB2312/GBK 编码的中文兼容性最好,对 UTF-8 的兼容性稍差一点,早期有些扫码器遇到 UTF-8 中文二维码直接就显示乱码。
我的做法是:在业务层维护一份 UTF-8 字符串,调用二维码生成前,先统一把 UTF-8 转换成 GB2312 或 GBK 字节流,再把字节流交给 QRcode_encodeData 处理。
转换可以直接用系统自带的 iconv 库,glibc 里通常已经带了足够多的编码表,调用方式简单:
#include <iconv.h> size_t utf8_to_gb2312(const char *in, size_t inlen, char *out, size_t outlen) { iconv_t cd = iconv_open("GB2312//TRANSLIT", "UTF-8"); // 循环调用 iconv 完成转换 // 注意处理 E2BIG 错误,保证输出缓冲区足够 iconv_close(cd); return converted; }实际项目里我并没有用 iconv,而是集成了一份精简的 GB2312 码表,用二分查找替换。原因有两个:一是 iconv 在静态编译时会把大量编码表塞进可执行文件,体积膨胀不少;二是码表方案做单元测试非常直观,输入一个 UTF-8 字符串,输出固定字节序列,容易验证对错。如果你不想引额外的码表,用 glibc iconv 也完全可行,只是记得在编译时注意工具链是否开启了 iconv 支持。
转换之后有个小细节:GB2312 对部分生僻字和 emoji 是支持不了的,直接转换会得到?或转换失败。我在业务逻辑里做了白名单检查,发现不支持的字符时,要么替换成拼音,要么回退到 UTF-8 编码。绝大多数设备配网、绑定类业务里出现的汉字,GB2312 覆盖足够了。
3.3 二维码点阵到图片和帧缓冲的转换
得到 QRcode 结构体之后,里面的 data 是一个长度为 width*width 的数组,每个字节的最低有效位表示模块颜色:1 为黑色,0 为白色。width 就是二维码模块总数,不同版本宽度不一样,比如 version 1 是 21x21,version 4 是 33x33,自动选择的版本通常在 25x25 到 57x57 之间。
直接把这个数组拿来生成图片时,不能逐字节画,还得考虑两个问题:放大倍数和四周静区。
放大倍数又叫模块像素尺寸。扫码器识别时,需要至少能分辨出单个模块的黑白边界,如果每个模块只占 1 个像素,在屏幕上会糊成一片。我一般把每个模块放大到 6 到 8 个像素,加上 4 个模块宽度的静区,最终生成的二维码图片尺寸在 250 到 500 像素之间,做嵌入式 UI 的弹窗或侧边栏都够用。
静区是很多人容易忽略的。按照标准,二维码四周必须留至少 4 个模块宽度的空白,扫码器才能准确框定图形边界。我在封装函数里直接把静区做到位,调用方不用关心这些细节。
生成 BMP 时要注意 Windows BMP 格式的规定:位图数据从下往上存储,每一行字节数需要按 4 字节对齐。这些琐碎的细节最容易出错,我先把 RGB 数组准备好,再往 BMP 文件头里填偏移量、宽度、高度、位深这些字段。
下面是核心转换函数的结构:
typedef struct { unsigned char r; unsigned char g; unsigned char b; } RGBPixel; RGBPixel *qr_to_rgb(QRcode *code, int scale, int *out_w, int *out_h) { int quiet = 4; int w = (code->width + quiet * 2) * scale; int h = w; RGBPixel *buf = calloc(w * h, sizeof(RGBPixel)); // 先全填白色背景 for (int i = 0; i < w * h; i++) { buf[i].r = 255; buf[i].g = 255; buf[i].b = 255; } // 遍历每个模块,黑色模块在对应区域填黑色 for (int y = 0; y < code->width; y++) { for (int x = 0; x < code->width; x++) { if (code->data[y * code->width + x] & 1) { int start_x = (x + quiet) * scale; int start_y = (y + quiet) * scale; for (int dy = 0; dy < scale; dy++) for (int dx = 0; dx < scale; dx++) { int px = start_x + dx; int py = start_y + dy; buf[py * w + px].r = 0; buf[py * w + px].g = 0; buf[py * w + px].b = 0; } } } } *out_w = w; *out_h = h; return buf; }这里的做法是先填充白色背景,然后只把黑模块涂黑。如果你是要在深色主题的 UI 上显示二维码,最好还是保持黑白配色,不要搞反色或加彩色贴图,很多扫码器对反色二维码的识别稳定性并没有想象中那么高。
如果不想生成 BMP,也可以把这段 RGB 数组直接交给显示层。EASY EAI 平台如果启用了 Linux framebuffer,可以把数组按显示分辨率缩放后写入 /dev/fb0 对应区域。如果平台跑的是 Qt 或 MiniGUI,把 RGB 数组封装成 QImage 或 BITMAP 再贴图会更方便。
4. 显示输出与运行架构
4.1 输出到 HDMI 和 LCD 屏幕的常见做法
EASY EAI 平台通常带 HDMI 或 MIPI/LVDS 接口,显示通路一般有两种形态:一种是直接用 Linux framebuffer,另一种是跑一个轻量级 GUI 应用。无论哪种形态,二维码这块最后都是把 RGB 像素搬到显存里。
我手上的平台主屏用的是 HDMI 输出,运行的是一个基于轻量 UI 框架的业务程序。我把二维码模块做成一个独立控件,从上层业务逻辑拿到字符串,内部完成 UTF-8 转 GB2312、QRcode 编码、RGB 数组生成,再在控件绘制函数里渲染到指定矩形区域。这样业务层完全不需要关心二维码的生成细节,只要传入内容和尺寸就行。
如果板子上的 UI 层支持直接加载文件,也可以把二维码保存成 BMP 再加载显示,比如:
write_bmp_file("/tmp/qrcode.bmp", rgb_buf, w, h);但我在正式项目里不推荐把临时文件写到 /tmp 再读回来,多一次磁盘 IO,还要处理文件权限和并发冲突。直接在内存里传递 RGB 数组更干净,程序退出后不留垃圾文件。
4.2 业务编排与多线程注意点
二维码生成函数是纯 CPU 计算,不涉及系统调用,在多线程调用时理论上是安全的。qrencode 内部不维护全局状态,每次调用独立构造 QRcode 对象,所以多个线程同时生成不同内容的二维码没毛病。
不过我在实际项目中还是加了一层互斥锁。原因不是怕 qrencode 本身出问题,而是业务层在生成二维码之后还要做一件事:把二维码内容写入日志、刷新显示缓存、甚至触发上报,这些后续逻辑如果被并发了,容易导致 UI 闪跳或日志顺序错乱。有一把锁作为业务层面的“生成二维码”串行化,省掉很多隐性竞争问题。
另一个值得注意的点是,如果有多个业务模块同时在跑,比如网络状态线程想弹窗显示 Wi-Fi 二维码,另一个用户操作线程也想在主屏上展示绑定二维码,最好有一个统一的二维码显示服务或队列。否则两个线程同时更新同一个显示区域,画面会互相覆盖。
4.3 动态二维码与定时刷新
还有一类需求是二维码内容本身会变化,比如带时效性的动态凭证、设备 IP 变化后重新生成配网码。这种情况下,建议单独开一个刷新线程,按业务规定的间隔重新生成并更新画面。
注意这里有一个工程细节:不要每个周期都 new 一个 QRcode,然后忘了 free。qrencode 返回的结构体占用的是堆内存,动态内容变化频繁时,内存泄漏会非常隐蔽。我习惯在刷新线程里先判断内容是否真的变了,变了才重新生成,没变就跳过刷新逻辑,这样既省 CPU 又避免无谓的内存分配释放。
如果每次刷新都生成新的 RGB 大数组,建议提前分配好一块固定大小的缓冲区,内容变化时原地覆写。RV1126B 的内存虽然不至于紧张到几 MB 都要抠,但在嵌入式设备上养成良好的资源使用习惯总是好事。
5. 常见问题与排查技巧实录
5.1 中文乱码的排查
我在第一次把中文放进二维码的时候,就遇到了乱码。用手机扫出来之后是一串类似测试的东西,典型的 UTF-8 被错误解码的痕迹。
排查的时候先别急着改代码,先在 PC 上用同样内容生成一个二维码,再拿同一个手机扫码。如果 PC 上正常而板子上乱码,问题基本就锁定在板子上的编码源上。把字符串内容用十六进制 dump 出来看一眼,UTF-8 的测试编码是e6 b5 8b e8 af 95,GB2312 的测试编码是b2 e2 ca d4,一眼就能分辨来源对不对。
我当时的问题就出在源码文件里直接写了中文字符串,源文件保存成了 UTF-8,然后传给 qrencode 的 encodeString。后来改成先转 GB2312 再进 encodeData,问题就消失了。
5.2 扫描识别率低怎么办
识别率低通常不是二维码编码本身的问题,而是显示端的问题。优先级由高到低排列一下排查点:静区够不够、模块像素放大倍数够不够、对比度是否足够、画面是否有遮挡或反光。
如果二维码内容比较长,会自动选择更高的版本,模块数量变多,每个模块的像素尺寸在画面不变的情况下会变小。这时候最有效的手段是加大整体显示面积,而不是单纯调大放大倍数。还有一种思路是降低纠错级别,比如从 H 降到 M,模块数量会变少,识别难度降低。在室内屏幕上,M 级完全够用,没必要追求 H。
强光下的户外屏幕,可以在二维码上方贴一层防眩光膜,或者提高屏幕亮度。软件层面能做的其实是限制最大 version,在内容超过某个长度时做截断或使用短链服务,避免出现 version 20 甚至 30 以上的超高密度二维码。
5.3 交叉编译时的常见坑
交叉编译 qrencode 最经典的问题就是 configure 脚本检测到宿主机上的 libpng,然后尝试链接宿主机的 PNG 库。加上 --without-png 可以杜绝这类问题,但如果你确实需要 PNG 输出,请务必把 libpng 和 zlib 的交叉编译头文件及库路径都配置好,并且在 configure 时通过 CPPFLAGS 和 LDFLAGS 指定 sysroot。
另一个容易忽略的点是:qrencode 默认构建的是动态库。如果目标板上没有对应的 .so,运行时就会报 “error while loading shared libraries”。静态库方案就不存在这个问题,只要确保编译时参数正确即可。
最后是浮点 ABI 的问题。RV1126B 的 ARM 核支持硬浮点,工具链默认会用 hard-float ABI。如果你不小心把宿主机上编出来的 .a 文件当成交叉编译产物拿去链,链接阶段会出现大量 “uses VFP register arguments” 之类的错误。这种错误多半是搞混了工具链,清理并重新用 arm-linux-gnueabihf 编译一遍就能解决。
5.4 程序运行时崩溃和内存问题
运行时报段错误,先检查 QRcode_encodeString 或 QRcode_encodeData 的返回值。内容过长、版本参数不合法、数据里包含不支持的字节,都可能返回 NULL。直接在拿到返回值后用code->width取宽度的代码都会崩,因为访问了空指针。
另外一个隐蔽问题是在调用 QRcode_free 之后,仍然使用 data 指针里的数据。QRcode_free 会释放整个结构体块,包括 data 指向的连续内存。如果你在显示层还保存了这个指针,画面刷新时会读到野指针。我的做法是在生成完 RGB 数组后立刻 QRcode_free,后续所有显示操作只依赖 RGB 数组,不保留二维码结构体。
6. 性能数据与后续扩展
6.1 CPU 占用与耗时实测参考
我在 EASY EAI 评估板上做了几组简单测试,内容分别是纯数字、URL、中文文本,生成次数循环 1000 次统计平均耗时。由于不同版本的二维码模块数差别很大,耗时会随内容长度波动。
一个普通的 20 字节 URL,自动版本为 version 3 或 4,M 级纠错,单次生成耗时在 1 毫秒到 3 毫秒之间。一个包含 100 个中文字符的长文本,版本可能会到 10 左右,耗时在 10 毫秒上下。即便按每秒生成 10 次来算,CPU 占用也几乎可以忽略。
如果要提升体验,可以把“生成二维码”本身放在后台线程,UI 线程只负责拿到 RGB 数组后绘制,这样即使遇到极端长文本,也不会出现界面卡顿。
6.2 功能扩展:从生成到识别的闭环
二维码生成只是第一步,很多时候业务其实是一个闭环:设备屏幕上显示二维码,手机扫码,云端或局域网确认,设备收到结果后更新界面。在这个闭环里,生成部分反而成了最简单的一环。
如果后续要做扫码识别,RV1126B 上可以跑摄像头采集 + OpenCV 或自带视觉库的二维码识别,利用 NPU 做检测框提取,再用传统视觉库解码,整个流程在板子上是可以完成的。不过要注意的是,识别需要的算力比生成高一个量级,摄像头分辨率、帧率、解码库优化都会影响最终体验。
往小了做,也可以直接接入现成的扫码模块(USB 或者串口扫码枪),设备端只需要生成二维码画面,扫码枪负责解码,然后设备比对内容是否匹配。这种方案实现成本最低,也比较稳。
6.3 对项目结构的一点扩展建议
如果项目里二维码不止一种业务用途,建议在代码层面抽象一层接口,把二维码内容构造、编码结果回调、过期时间管理放进去。这样后续新增业务时,只需要在内容工厂里多写一个生成函数,显示和刷新逻辑完全复用,改动面会小很多。
我在当前项目里就是这样的结构:一个 qr_service 模块负责全部二维码生命周期,上层通过一个函数请求生成并注册回调,显示层订阅回调更新画面。内容里可以包含版本号、签名和过期时间,服务器端扫码后拿同样规则验签,整个流程简单可控。
最后再分享一个小技巧:调试阶段不要急着看屏幕,先把生成的 RGB 数组转换保存成 BMP 文件,通过串口传回 PC,再用手机扫码验证。这样可以在不依赖显示链路的情况下,单独确认内容和编码是否正确。等到编码部分确认没问题,再接到真实屏幕上看显示效果,出问题时排查范围会小很多。还有一个小建议,静区真的别省,我一开始为了画面尺寸稍微挤了一点,识别率立刻下降,老老实实保留 4 个模块宽度之后,各种角度、各种距离的扫码都变得非常顺畅。