从零开始开发一个 RK3588S 边缘端 AI 检测系统(采集→NPU推理→硬编码→RTMP推流→MQTT上报)
0. 项目简介
AlertGateway 是一个在 RK3588S 开发板上运行的边缘端 C++ 应用,目标是验证一条完整的嵌入式 AI 业务链路:摄像头采集 → NPU 推理(YOLOv8s)→ 检测结果画框 → 硬件编码 → RTMP 推流 → MQTT 上报检测结果。场景是摄像头俯拍桌面,识别手机、杯子、键盘等桌面常见物品。

最终效果:单帧端到端推理总耗时约 35ms(NPU 推理本身 ~30ms,已通过官方 demo 交叉验证为该型号 NPU 的硬件极限),编码推流稳定运行在摄像头实际帧率(14.6fps),RTMP 端到端延迟 < 1s,支持断线自动重连。
代码地址:github.com/johnjiamzhong-project/AlertGateway
本文按开发的时间顺序,记录从环境搭建到性能调优的完整过程,重点是过程中遇到的 9 个典型问题(完整记录见仓库 BUGS.md)的现象、原因排查和修复方法。
0.1 硬件与软件环境
| 项目 | 配置 |
|---|---|
| 开发板 | Firefly ROC-RK3588S-PC,8GB 内存 |
| SoC | Rockchip RK3588S(4×A76 + 4×A55,6 TOPS NPU,独立 RGA 2D 加速单元) |
| 板载系统 | Ubuntu 22.04 aarch64 |
| 摄像头 | 罗技 C310(USB UVC),/dev/video20,YUYV422,640×480 |
| 交叉编译环境 | WSL2 Ubuntu 22.04 x86_64 |
| 编译器 | aarch64-linux-gnu-g++ 11.4.0 |
| 构建系统 | CMake |
| 推理框架 | RKNN Lite2 C API,模型 YOLOv8s(INT8 量化) |
| 硬件编解码 | Rockchip MPP(h264_rkmpp) |
| 推流协议 | RTMP |
| 消息上报 | Paho MQTT C++ |
| 配置/JSON | nlohmann/json |
| 线程通信 | 有界阻塞队列 + 条件变量 |
0.2 摄像头参数确认
接入摄像头后第一步是确认它的真实能力,而不是假设它支持期望的参数:
v4l2-ctl -d /dev/video20 --list-formats-ext
输出显示 640×480 YUYV 格式声明支持 30/25/20/15/10/5fps 六档,USB 3.0 接口带宽(YUYV 30fps 仅需 17.6MB/s)远未跑满。但接入完整推流链路后实测吞吐稳定在 14.6fps,与带宽、USB 接口版本均不匹配,瓶颈推测在 sensor 曝光/读出耗时或 ISP 处理延迟,未继续深挖。这个"声明帧率"与"实测帧率"的落差直接影响了第 5 节 RTMP 延迟问题的判断。
结论:驱动声明支持的帧率档位,不等于实际链路能稳定吐出的帧率,接入硬件后必须实测验证。
1. MPP 硬解硬编打通
在接入 NPU 推理之前,先单独验证硬件编码 + RTMP 推流链路,避免后续调试时混淆是推理问题还是编解码问题。
RK3588S 的硬件编解码走 Rockchip 自有的 MPP(Media Process Platform)框架,标准 FFmpeg 需要编译时开启 --enable-rkmpp 才能使用 h264_rkmpp codec。板载系统自带的 FFmpeg(4.4.2,官方 apt 包)未开启该选项,需要额外部署 ffmpeg-rockchip(实测版本 6.1)。
官方 mpi_dec_test 验证结果:192.79fps(800 帧,4149ms),证明硬件性能本身远超应用需求,瓶颈在软件层。
1.1 问题:FFmpeg 头文件版本与运行库不匹配导致段错误
现象
Segmentation fault (core dumped)
gdb 定位到 avformat_new_stream() 内部崩溃。
原因
sysroot 中同时存在两套 FFmpeg:
-
系统 FFmpeg 4.4.2 头文件:
/usr/include/aarch64-linux-gnu -
ffmpeg-rockchip 6.1 运行库:
/opt/ffmpeg-rockchip/libCMakeLists.txt只添加了系统头文件路径,但实际链接的是 6.1 版本的.so。两个版本的AVFormatContext、AVStream结构体内存布局不同,导致字段偏移错误,编译期无任何报错,运行时访问非法内存。修复
将 ffmpeg-rockchip 头文件路径置于系统头文件路径之前:
include_directories(${SYSROOT}/opt/ffmpeg-rockchip/include)
include_directories(${SYSROOT}/usr/include/aarch64-linux-gnu)
结论:链接哪个版本的 .so,就必须 include 对应版本的头文件;多套同名库共存时,include_directories 的顺序决定生效版本,顺序错误时编译不会报错,只会在运行时崩溃。
2. RKNN 推理部署
2.1 模型选型
原计划使用 YOLOv5s,实际部署改用 YOLOv8s(精度更优,RKNN 部署相关资料更完整)。模型转换流程:PyTorch → ONNX → RKNN(rknn-toolkit2,仅支持 x86_64 Linux,本文在 WSL2 环境完成转换)。
2.2 问题:INT8 量化后检测框始终为空
现象
接入 RGA 预处理加速并修复一处反量化错误后,程序运行无报错,但输出视频中没有任何检测框,调低 conf_threshold 同样无效。
原因排查
-
yolov8s.onnx导出时,检测框坐标(4 通道,数值范围 0-640)与分类置信度(80 通道,数值范围 0-1)被合并为单个 84 通道输出张量,量化时共享同一组 scale/zero-point。该 scale(约 2.6)按坐标数值范围校准,分类概率所在的 0-1 区间在此 scale 下不足 1 个 INT8 量化档位,反量化后全部归零,分类信息在量化阶段已丢失,运行时无法补救。 -
附带问题:YOLOv8 的 ONNX 图分类分支自带
Sigmoid节点,C++ 后处理代码又额外做了一次 sigmoid,属重复计算。修复
使用
rknn.load_onnx()的outputs参数,在 ONNX 图最后一次 concat 之前,将 box 与 cls 分支拆分为两个独立输出分别量化:
rknn.load_onnx(
model="yolov8s.onnx",
outputs=['/model.22/Mul_2_output_0', '/model.22/Sigmoid_output_0']
)
C++ 侧改用 rknn_outputs_get(want_float=1) 直接获取两个输出的浮点值,移除手动反量化和重复 sigmoid,后处理函数同时接收 box/cls 两个缓冲区。
结论:YOLO 系列模型导出 INT8 时,box 回归与分类两个分支数值量级差异巨大,必须拆分为独立输出张量分别量化校准;合并为单一张量共享 scale 会摧毁其中一个分支的精度。排查反量化问题时应先打印原始浮点值确认数据是否存在,而非仅调整阈值。
2.3 问题:推理速度仅为行业基准的 1/4
现象
实测推理总耗时约 150ms/帧(6.7 FPS),YOLOv8s 在 RK3588S 上的行业基准为 27-32 FPS。
排查
分阶段打点:
cpu(YUYV→RGB + resize): 8ms
rknn_inputs_set: 37ms
rknn_run(NPU 计算): 75ms
rknn_outputs_get: 37ms
total: 150ms
rknn_run 75ms 高于参考值(30-40ms)约一倍,检查模型量化类型:
strings ~/AlertGateway/model/yolov8s.rknn | grep -E 'dtype|quant|int8|float'
# 输出: "dtype": "float16",量化参数段为空
确认模型为 FP16,未做 INT8 量化,NPU 以 16 位浮点运算,性能为 INT8 的约一半。另测试三核 NPU mask(rknn_set_core_mask(RKNN_NPU_CORE_0_1_2)),耗时无变化——该模型导出时未编译多核支持,driver 接受 mask 但实际仍单核执行。
修复
-
采集 150 张真实场景图作为 INT8 量化校准数据集(脚本:
tools/collect_calibration.py) -
在 WSL x86_64 环境用 rknn-toolkit2 重新量化转换(脚本:
tools/convert_int8.py)转换过程中的两个环境问题:
-
onnx 1.22.0移除了onnx.mapping接口,rknn-toolkit2 依赖该接口,降级至onnx==1.13.1解决 -
rknn.build(dataset=...)仅接受文件路径字符串,不接受 numpy 数组列表,需将图片路径写入dataset.txt后传路径效果
| 阶段 | FP16(优化前) | INT8(优化后) |
|---|---|---|
| rknn_run | 75ms | 38ms |
| inputs_set | 37ms | 0.4ms |
| outputs_get | 37ms | 1-3ms |
| total | ~150ms | ~48ms |
推理速度从 6.7 FPS 提升至约 20 FPS,超过摄像头实际帧率(14.6fps)上限,整体提速 3 倍。
后续测试了 SRAM 缓存、异步模式、Zero-Copy I/O 等优化手段:SRAM 缓存无明显效果(模型中间张量过大,2MB SRAM 仅覆盖部分层);异步模式(RKNN_FLAG_ASYNC_MASK)使等待被推迟到输出读取时刻,总耗时反而增加;Zero-Copy I/O 减少了数据搬运开销但 rknn_run 本身耗时不变。结论:rknn_run 约 40ms 已是该 NPU 跑此模型的硬件极限(已用官方 demo 交叉验证),进一步提速需从模型层面入手(换更小模型、降低输入分辨率、重新导出开启多核编译)。
3. Pipeline 解耦重构
3.1 问题:串行架构导致帧率被推理速度拖死
现象
MPP 和 RKNN 单独验证均正常,但完整链路运行后画面严重卡顿,实测帧率约 5fps,远低于摄像头能力(14.6fps)。
原因
原始架构为串行结构:
CaptureThread → [queue] → InferThread → [queue] → EncodeThread → StreamThread
InferThread 每帧执行 YUYV→RGB 全图转换 + NPU 推理,当时单帧耗时约 200ms(~5fps);EncodeThread 阻塞等待 InferThread 输出,整条链路被推理速度限制,摄像头采集能力被浪费。
修复:解耦为并行双路架构
CaptureThread ──→ [enc_queue=1] → EncodeThread → StreamThread
└──→ [infer_queue=2] → InferThread → SharedDetections
enc_queue:容量 1,阻塞推送,不丢帧,保证编码推流链路数据完整infer_queue:容量 2,非阻塞推送,推理线程忙时直接丢帧,每次取帧排空队列,始终推理最新帧- 新增
SharedDetections共享结构(mutex 保护),InferThread写入检测结果,EncodeThread无阻塞读取快照画框:
struct SharedDetections {
mutable std::mutex mutex;
std::vector<Detection> detections;
void set(std::vector<Detection> dets) { ... }
std::vector<Detection> get() const { ... }
};
同时将 EncodeThread 内 YUYV→RGB→NV12 两次色彩转换合并为一次直转:
void yuyv_to_nv12(const uint8_t* yuyv, uint8_t* nv12, int w, int h) {
uint8_t* y_plane = nv12;
uint8_t* uv_plane = nv12 + w * h;
// Y plane:逐字节提取 Y
// UV plane:仅偶数行提取 U/V(2× 垂直降采样)
}
效果
解耦后 EncodeThread 帧率恢复至摄像头极限 14.6fps,推理速度不再影响编码推流帧率。
结论:多线程 pipeline 中,速度不一致的处理环节不应通过阻塞队列直接串联,否则整条链路速度等于最慢环节速度。允许丢帧的环节(推理)使用非阻塞队列,不允许丢帧的环节(编码推流)使用独立的阻塞队列保护。
4. RGA 硬件加速预处理
4.1 背景
Pipeline 解耦后单帧总耗时约 78-82ms,NPU 推理本身(~40ms)已确认为硬件极限,但 CPU 手写的预处理(yuyv_to_rgb + resize_rgb)耗时 13-14ms,总耗时已逼近摄像头出帧间隔(~68ms / 14.6fps),是当时需要跳帧推理(infer_every_n_frames)的直接原因。
4.2 方案
RK3588S 集成独立 RGA(2D 图形加速器)硬件单元,原生支持 RK_FORMAT_YUYV_422 格式,可一次硬件调用完成"YUYV→RGB888 + 缩放到模型输入尺寸"。
实现细节:
- 使用新版 im2d API(
improcess()/wrapbuffer_virtualaddr()),非旧版RgaApi.hC 接口 - 普通堆内存(
std::vector<uint8_t>)即可,无需 DMA-BUF/ION - API 无状态,无需显式 init/deinit
- 保留原 CPU 实现作为 RGA 调用失败时的 fallback
- 交叉编译链接库直接使用板子上的真实文件(
/usr/include/rga/*.h、librga.so.2.1.0),避免重复 ABI 不匹配问题(参见第 5.1 节)
4.3 效果
预处理耗时由 13-14ms 降至 1ms 级。叠加 Zero-copy 输入(直接写入 NPU DMA 输入缓冲区,省去数据搬运),单帧 NPU 推理稳定在 ~30ms,端到端总耗时约 35ms。
5. 其他典型问题记录
5.1 paho-mqtt-cpp ABI 不匹配
现象
undefined symbol: _ZN4mqtt15connect_options17set_clean_sessionEb
原因
交叉编译链接的是本地新版 paho-mqtt-cpp(源码 build),板子上安装的是 1.2.0。新版头文件引入了旧版不存在的符号(如 set_clean_session 由 inline 改为非 inline)。
修复
- 从源码仓库 checkout 到与板子一致的
v1.2.0版本头文件 - 将板子上的
.so文件 scp 回来作为链接 stub:
scp firefly@<board-ip>:/usr/lib/aarch64-linux-gnu/libpaho-mqttpp3.so.1 third_party/paho/
scp firefly@<board-ip>:/usr/lib/aarch64-linux-gnu/libpaho-mqtt3as.so.1 third_party/paho/
结论:交叉编译时头文件版本必须与板子运行库版本完全一致,最安全的做法是直接从板子拷贝 .so 作为链接目标,再从对应 git tag 提取匹配头文件。本项目中 FFmpeg(1.1节)、RGA(4.2节)、paho-mqtt-cpp 三次独立踩到同一类问题,应作为交叉编译项目的标准检查项。
5.2 画框越界写入导致堆损坏
现象
double free or corruption (!prev)
Aborted (core dumped)
原因
draw_hline 函数仅检查 y < 0,未检查 y >= h:
// 修复前
static void draw_hline(uint8_t* rgb, int w, int x0, int x1, int y, ...) {
if (y < 0) return;
...
}
YOLOv8 输出的检测框坐标经坐标变换后,y2 可能恰好等于画面高度(480),写入位置越过缓冲区末尾,破坏相邻堆块元数据,导致后续 free() 触发 abort。
修复
// 修复后
static void draw_hline(uint8_t* rgb, int w, int h, int x0, int x1, int y, ...) {
if (y < 0 || y >= h) return;
}
结论:图像缓冲区像素操作必须同时检查上下界;目标检测框坐标经缩放裁剪后容易出现边界值(恰好等于宽/高),需特别防范。
5.3 SSH 间歇性超时,ping 正常
现象
SSH 连接持续超时,但 ping 和端口探测均正常。
排查
最初怀疑 RTMP 推流(2Mbps)占满带宽,但验证发现程序未运行时 SSH 同样超时,排除该猜测。检查板子网络接口状态:
eth0: NO-CARRIER, DOWN
wlan0: DORMANT, DOWN
p2p0: UP
板子实际通过 USB WiFi 网卡的 P2P 虚拟接口联网(经 USB Hub),链路本身延迟高、丢包率高,且与 RTMP 推流、NPU 推理共用同一信道。ping 因 ICMP 包小、内核优先处理而正常,掩盖了真实链路质量问题。
修复
接入板子的千兆有线口,使管理通道(SSH)与数据通道(RTMP)走不同物理链路。
结论:ping 通但应用层连接不稳定时,优先排查实际使用的网络接口和链路类型,而非默认认定服务异常。嵌入式设备应优先使用有线网络,USB WiFi 经 Hub 的链路质量不可靠。
5.4 RTMP 断线后不自动重连
现象
SRS 服务器重启或网络抖动后,编码帧率从 14.6fps 跌至约 5fps,需手动重启程序才能恢复推流。
原因(三层叠加)
- 断线检测失效:原代码仅检查
av_write_frame返回值,未检查avio_flush之后的错误状态,而 TCP 层的EPIPE/ECONNRESET只在 flush 时才会被捕获:
// 修复前
int ret = av_write_frame(fmt_ctx_, pkt);
if (ret >= 0) avio_flush(fmt_ctx_->pb);
return ret >= 0;
// 修复后
int ret = av_write_frame(fmt_ctx_, pkt);
if (ret < 0) return false;
avio_flush(fmt_ctx_->pb);
return fmt_ctx_->pb->error >= 0;
- 重连后画面无法恢复:MPP 编码器仅在启动时的第一个关键帧中携带 SPS/PPS,原重连逻辑会清空缓存的 SPS/PPS,重连后等不到新的关键帧,RTMP header 永远无法写出:
void StreamThread::reconnect_loop() {
close_rtmp(); // 不清空 sps_/pps_
while (running_) {
if (open_rtmp()) {
if (!sps_.empty() && !pps_.empty())
write_extradata(sps_.data(), sps_.size(), pps_.data(), pps_.size());
return;
}
// 重试期间持续排空 in_queue_,防止背压
}
}
-
重连失败后线程退出:原逻辑重连一次失败即退出线程,无人消费队列,背压沿 EncodeThread → CaptureThread 传播,帧率跌至 5fps。修复为无限重试(间隔 3 秒),重试期间持续排空队列。
结论:长连接服务(RTMP/MQTT/WebSocket)必须实现断线重连;重连逻辑需考虑重试期间下游队列的消费问题,否则会引发背压导致上游全部受影响;依赖首帧携带的元数据(如 SPS/PPS)在重连场景下必须缓存复用,不能依赖重新获取。
5.5 RTMP 延迟从 15 秒优化至 1 秒以内
现象
Pipeline 解耦后画面流畅,但 RTMP 延迟达到 15 秒。
排查
| fps 配置 | PTS 行为 | 现象 |
|---|---|---|
| fps=30(错误值) | PTS 推进速度 2 倍于实际帧率 | 延迟约 1s,画面卡顿丢帧 |
| fps=15(正确值) | PTS 节奏正常 | 流畅,但延迟 15s(SRS gop_cache 默认开启+较大队列+播放器默认缓冲叠加) |
修复
- SRS 服务端配置:
min_latency on;
gop_cache off;
queue_length 0.1;
mw_msgs 0;
-
编码端配置:GOP 由默认值调整为 8(关键帧间隔 ~550ms),H.264 profile = Main,
enc_queue容量 1(约 68ms 缓冲),stream_queue容量 2(约 137ms 缓冲)。注意 GOP 调整存在权衡:初期将 GOP 设为 2(~140ms),I 帧体积为 P 帧的 5-10 倍,频繁 I 帧产生码率峰值,低延迟播放器(缓冲仅 0.5s)无法平滑峰值,表现为帧率下降。调整为 GOP=8 后码率更均匀,延迟和帧率同时达标。
结果
| 播放器 | 延迟 | 帧率 |
|---|---|---|
| PotPlayer | 3-4s(播放器自身缓冲,无法绕过) | 14.6fps 正常 |
| 自研 RambosPlayer | ~0.5s | 14.6fps 正常 |
结论:RTMP 延迟由服务端 gop_cache、服务端队列、网络传输、播放器缓冲四层叠加构成,需逐层排查;GOP 过小虽降低首帧延迟,但导致码率峰值,对低延迟播放器有害;fps 配置必须与采集设备实际帧率一致,否则 PTS 错乱会引发一系列隐性问题。
6. 最终架构
┌─────────────────────────────────────────────────────┐
│ AlertGateway │
│ │
│ 采集线程 推理线程 检测处理线程 │
│ V4L2 → RKNN NPU → 检测结果汇总+上报 │
│ /dev/video YOLOv8s │ │
│ │ │ │
│ ▼ ▼ │
│ 编码线程 MQTT线程 │
│ MPP硬编 检测结果上报 │
│ h264_rkmpp Paho MQTT │
│ │ │
│ ▼ │
│ 推流线程 │
│ RTMP推流 │
└────────────────────┬────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
SRS(Windows) MqttMonitor(Windows)
RTMP接收 检测结果实时显示
│
▼
RambosPlayer(Windows)
拉流播放(含检测框画面)
6.1 最终指标
| 指标 | 数值 |
|---|---|
| 单帧端到端推理耗时 | ~35ms(NPU 推理本身 ~30ms) |
| 编码推流帧率 | 14.6fps(摄像头实际帧率) |
| RTMP 端到端延迟 | < 1s |
| 断线重连 | 支持,3 秒间隔无限重试 |
7. 总结
本文记录的 9 个问题中,3 个属于交叉编译 ABI 不匹配(FFmpeg、RGA、paho-mqtt-cpp),可归纳为同一类问题的不同表现;INT8 量化精度丢失问题(2.2 节)需要深入到 ONNX 图结构层面才能定位根因,是本项目排查难度最高的一个问题;Pipeline 解耦(第 3 节)是影响最大的架构级修复。
可复用的工程经验:
-
交叉编译统一使用板子实际运行的
.so作为链接 stub,头文件版本与运行库版本严格对应 -
排查 NPU 推理精度问题时,先用
strings确认模型量化类型,再检查 YOLO 类模型的 box/cls 分支是否被错误合并量化 -
多线程 pipeline 中速度不一致的环节使用独立队列解耦,允许丢帧的环节不应阻塞不允许丢帧的环节
-
长连接服务必须实现断线重连,且需处理重连期间下游队列的消费问题,避免背压
-
接入摄像头等硬件后应实测确认其真实可达参数,不依赖驱动声明值
完整代码与全部 9 个问题的详细记录见仓库:github.com/johnjiamzhong-project/AlertGateway(
BUGS.md及docs/目录)。

浙公网安备 33010602011771号