前言
最近在做Android虚拟摄像头开发,需要实现一个Camera HAL服务,将windows客户端的摄像头数据写入共享内存,再将共享内存中的YUV数据展示到相机预览和拍照功能中。过程中踩了不少坑,从最初的黑屏、到预览闪烁、再到拍照绿屏,最后才搞定。这里把整个过程记录下来,希望能帮到有类似需求的朋友。
项目背景
我的场景是这样的:
- 宿主机通过共享内存传递NV12格式的YUV数据(1280x720)
- Android系统需要读取这些数据,展示在相机预览中
- 支持拍照功能,照片可能需要不同分辨率(比如1920x1080)
技术栈:
- Android Camera HAL 3.2
- 共享内存通信
- YUV/NV12格式处理
第一个大坑:预览黑屏
问题表现
实现了基本的HAL框架后,打开相机应用,预览界面一片漆黑。
排查过程
首先确认数据源没问题,在C++代码里打印共享内存的数据:
cpp
ALOGE("Shared memory Y[0-7]: %02x %02x %02x %02x",
yPlane[0], yPlane[1], yPlane[2], yPlane[3]);
发现数据是正常的,说明读取没问题。
接着检查写入GraphicBuffer的过程,这里用到了Android的GraphicBufferMapper:
cpp
android_ycbcr ycbcr;
err = mapper.lockYCbCr(importedHandle, usage, bounds, &ycbcr);
打印ycbcr的各个字段,发现问题了:
ystride=1920, cstride=1920, chroma_step=2
cb=0x..., cr=0x...
关键发现 :ycbcr.cb和ycbcr.cr不是独立的buffer,而是指向同一块内存的不同位置。这是NV12格式的特点------UV分量是交错存储的。
最初我是这样写的:
cpp
// 错误做法
memcpy(ycbcr.cb, srcUV, width * height / 2);
这样写会导致只有cb被填充,cr还是空的。
解决方案
正确的做法是一次性复制整个UV平面(因为NV12的UV是交错的):
cpp
ptrdiff_t ptrDiff = (uint8_t*)ycbcr.cr - (uint8_t*)ycbcr.cb;
if (ycbcr.chroma_step == 2 && ptrDiff == 1) {
// NV12格式:UV交错,cb在前
for (uint32_t row = 0; row < height / 2; row++) {
memcpy((uint8_t*)ycbcr.cb + row * ycbcr.cstride,
srcUV + row * srcWidth,
width); // 一次复制整行,包含U和V
}
}
黑屏问题解决。
第二个大坑:预览画面定格
问题表现
预览能显示了,但画面经常定格不动,过几秒又突然刷新一下。
排查过程
加了日志统计FPS:
cpp
static int frame_count = 0;
static auto last_time = std::chrono::steady_clock::now();
frame_count++;
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - last_time).count();
if (elapsed >= 1) {
ALOGE("FPS: %d", frame_count);
frame_count = 0;
last_time = now;
}
发现问题:
- 每秒收到350+个
processCaptureRequest请求 - 但只有5-10次真正的帧数据更新
查看详细日志:
Buffer #1 is null (cached), streamId=1, bufferId=1
Buffer #1 is null (cached), streamId=1, bufferId=2
Buffer #1 is null (cached), streamId=1, bufferId=3
✓ Processing buffer #1: streamId=1, bufferId=4, hasNewFrame=0 // 偶尔一次
Buffer #1 is null (cached), streamId=1, bufferId=1
原来Camera HAL有个Buffer Cache机制:
- 第一次请求时,framework传递真实的buffer
- 后续请求传递null,表示使用缓存的buffer
- 但我只在buffer不为null时才填充数据,导致缓存的buffer一直是旧数据!
解决方案
实现完整的Buffer Cache管理:
cpp
// 1. 定义缓存结构
struct CachedBuffer {
buffer_handle_t handle;
uint32_t width;
uint32_t height;
int32_t format;
};
std::map<uint64_t, CachedBuffer> mBufferCache;
// 2. 处理新buffer
if (outputBuffer.buffer != nullptr) {
// import并缓存
mapper.importBuffer(bufferHandle, width, height, 1, format,
usage, 0, &importedHandle);
CachedBuffer cached;
cached.handle = importedHandle;
cached.width = width;
cached.height = height;
cached.format = format;
mBufferCache[outputBuffer.bufferId] = cached;
}
// 3. 使用缓存的buffer
else {
auto it = mBufferCache.find(outputBuffer.bufferId);
if (it != mBufferCache.end()) {
importedHandle = it->second.handle;
}
}
// 4. 无论新buffer还是缓存buffer,都要填充最新数据
// (在这之前每个请求都读取了新帧)
lockYCbCr(...);
fillYUVData(...);
unlock();
// 5. 处理buffer移除
for (const auto& cache : cachesToRemove) {
auto it = mBufferCache.find(cache.bufferId);
if (it != mBufferCache.end()) {
mapper.freeBuffer(it->second.handle);
mBufferCache.erase(it);
}
}
这样每个请求都会更新buffer数据,画面就流畅了。
第三个大坑:拍照绿屏
问题表现
预览正常了,但点击拍照,保存的照片是绿色的。
排查过程
首先测试填充纯色:
cpp
// 填充灰色
memset((uint8_t*)ycbcr.y, 128, width * height);
memset((uint8_t*)ycbcr.cb, 128, width * height / 2);
拍出来是正常的灰色照片,说明YUV格式没问题。
那问题出在哪?加日志发现:
Copying YUV: src=1280x720, dst=1920x1080
拍照的分辨率比预览高!而我直接用了memcpy:
cpp
// 错误做法
for (uint32_t row = 0; row < height; row++) {
memcpy((uint8_t*)ycbcr.y + row * ycbcr.ystride,
srcY + row * srcWidth, // row >= 720时越界!
width);
}
当row=720时,srcY + row * srcWidth已经超出了源数据范围,读到了垃圾数据。
解决方案
实现完整的缩放逻辑,处理三种情况:
情况1:相同分辨率(预览)
cpp
if (width == srcWidth && height == srcHeight) {
// 直接复制
for (uint32_t row = 0; row < height; row++) {
memcpy((uint8_t*)ycbcr.y + row * ycbcr.ystride,
srcY + row * srcWidth,
width);
}
}
情况2:目标更小(缩略图)
cpp
else if (width <= srcWidth && height <= srcHeight) {
// 裁剪中心区域
uint32_t cropX = (srcWidth - width) / 2;
uint32_t cropY = (srcHeight - height) / 2;
for (uint32_t row = 0; row < height; row++) {
memcpy((uint8_t*)ycbcr.y + row * ycbcr.ystride,
srcY + (cropY + row) * srcWidth + cropX,
width);
}
}
情况3:目标更大(拍照)
cpp
else {
// 缩放放大
float scaleX = (float)srcWidth / width;
float scaleY = (float)srcHeight / height;
for (uint32_t dstRow = 0; dstRow < height; dstRow++) {
uint32_t srcRow = (uint32_t)(dstRow * scaleY);
if (srcRow >= srcHeight) srcRow = srcHeight - 1; // 防止越界
uint8_t* dstRowPtr = (uint8_t*)ycbcr.y + dstRow * ycbcr.ystride;
const uint8_t* srcRowPtr = srcY + srcRow * srcWidth;
for (uint32_t dstCol = 0; dstCol < width; dstCol++) {
uint32_t srcCol = (uint32_t)(dstCol * scaleX);
if (srcCol >= srcWidth) srcCol = srcWidth - 1;
dstRowPtr[dstCol] = srcRowPtr[srcCol];
}
}
}
UV平面的处理类似,但要注意:
- UV平面的尺寸是Y平面的1/4(宽高各减半)
- NV12格式的UV是交错的,需要按2字节为单位处理
cpp
// UV缩放(NV12格式)
for (uint32_t dstRow = 0; dstRow < height / 2; dstRow++) {
uint32_t srcRow = (uint32_t)(dstRow * scaleY);
if (srcRow >= srcHeight / 2) srcRow = srcHeight / 2 - 1;
uint8_t* dstUVRow = (uint8_t*)ycbcr.cb + dstRow * ycbcr.cstride;
const uint8_t* srcUVRow = srcUV + srcRow * srcWidth;
for (uint32_t dstCol = 0; dstCol < width; dstCol += 2) {
uint32_t srcCol = (uint32_t)((dstCol / 2) * scaleX) * 2;
if (srcCol >= srcWidth - 1) srcCol = srcWidth - 2;
dstUVRow[dstCol] = srcUVRow[srcCol]; // U
dstUVRow[dstCol + 1] = srcUVRow[srcCol + 1]; // V
}
}
至此,拍照功能完全正常。
核心知识点总结
1. NV12格式理解
NV12是YUV420的一种存储格式:
- Y平面:width × height,每个像素一个字节
- UV平面:width × (height/2),U和V交错存储(UVUVUV...)
内存布局:
[Y Y Y Y Y Y ...] ← width × height
[U V U V U V ...] ← width × height/2
2. android_ycbcr结构体
cpp
struct android_ycbcr {
void* y; // Y平面起始地址
void* cb; // U起始地址(指向UV平面的第1个字节)
void* cr; // V起始地址(指向UV平面的第2个字节)
size_t ystride; // Y平面行跨度(字节)
size_t cstride; // UV平面行跨度(字节)
size_t chroma_step; // UV间隔(2表示交错)
};
关键:
cb和cr通常指向同一块内存ptrDiff = cr - cb,如果是1,说明是NV12;如果是-1,说明是NV21
3. Camera HAL Buffer Cache机制
Camera Framework为了性能优化,会缓存buffer:
- 第一次请求:
buffer != null,需要import - 后续请求:
buffer == null,使用缓存
HAL实现必须:
- 维护bufferId到handle的映射
- 即使buffer为null,也要填充最新数据
- 响应cachesToRemove,释放不再使用的buffer
4. 缩放算法
最简单的最近邻插值:
cpp
srcX = dstX * (srcWidth / dstWidth)
srcY = dstY * (srcHeight / dstHeight)
需要注意边界检查,防止越界。
性能优化建议
-
避免重复缩放
如果多个stream要相同的缩放尺寸,可以缓存缩放结果
-
使用SIMD指令
YUV复制可以用NEON指令加速(ARM平台)
-
异步处理
把YUV处理放在单独的线程,避免阻塞HAL主流程
-
内存池
缩放时需要临时buffer,可以使用内存池避免频繁分配
调试技巧
-
打印十六进制数据
cppALOGE("Y[0-7]: %02x %02x %02x %02x", yData[0], yData[1], yData[2], yData[3]); -
填充测试图案
cpp// 纯色测试 memset(y, 128, size); // 灰色 // 彩条测试 for (int i = 0; i < width; i++) { y[i] = (i * 255) / width; // 渐变 } -
统计帧率和延迟
cppauto start = std::chrono::steady_clock::now(); // ... 处理 ... auto end = std::chrono::steady_clock::now(); ALOGE("Process time: %lld ms", std::chrono::duration_cast<std::chrono::milliseconds>( end - start).count()); -
保存原始YUV文件
调试时可以把buffer内容dump到文件,用工具查看:
bashffplay -f rawvideo -pixel_format nv12 -video_size 1280x720 frame.yuv
总结
实现Camera HAL虚拟摄像头,主要难点在于:
- 理解YUV格式,特别是NV12的内存布局
- 正确实现Buffer Cache机制
- 处理各种分辨率的缩放
调试过程中要善用日志,打印关键数据,逐步定位问题。遇到画面异常(黑屏、绿屏等),优先用纯色测试,确认格式正确后再处理数据复制。
代码已经在Android 14系统上测试通过,预览帧率稳定在30fps,拍照响应时间在100ms内。
希望这篇文章能帮助到正在做类似工作的朋友,少走一些弯路。