[特殊字符] 全项目架构与代码运转流程(十三)
📘 全项目架构与代码运转流程(十三)
前置知识:已完成 06~12 全部代码学习,理解每个模块的具体实现。
本文定位:跳出代码细节,从架构高度俯瞰整个项目的设计思想和运转流程。
🎯 本文核心目标
- 理解项目的分层架构和模块职责
- 掌握main 函数完整执行流程(初始化 + 主循环)
- 理解C 语言模拟面向对象的设计模式
- 画出数据流图和控制流图
- 理解每个模块之间的调用关系
一、项目架构总览
1.1 三层架构
整个项目采用三层架构设计:
┌──────────────────────────────────────────────────────────────────┐ │ 应用层(Application) │ │ main.c │ │ 负责:编排流程、控制循环 │ ├──────────────────────────────────────────────────────────────────┤ │ 框架层(Framework) │ │ video_manager.c │ disp_manager.c │ convert_manager.c │ │ 负责:注册链表、按名称/格式匹配、统一管理 │ ├──────────────────────────────────────────────────────────────────┤ │ 实现层(Implementation) │ │ v4l2.c │ fb.c │ yuv2rgb.c │ mjpeg2rgb.c │ rgb2rgb.c │ │ zoom.c │ merge.c │ │ 负责:具体硬件操作、具体算法实现 │ └──────────────────────────────────────────────────────────────────┘为什么要分层?
| 层级 | 变更影响 | 举例 |
|---|---|---|
| 应用层 | 修改流程/逻辑 | 改为拍照模式而不是实时显示 |
| 框架层 | 修改管理方式 | 从链表改为数组管理 |
| 实现层 | 替换具体驱动 | 从 V4L2 改为自定义摄像头驱动 |
层与层之间单向依赖:应用层 → 框架层 → 实现层,实现层不反向依赖。
1.2 模块职责矩阵
| 模块 | 目录 | 核心结构体 | 核心函数 | 职责一句话 |
|---|---|---|---|---|
| 视频采集 | video/ | VideoOpr+VideoDevice | GetFrame/PutFrame | 从摄像头取一帧数据 |
| 显示输出 | display/ | DispOpr | ShowPage/ShowPixel | 把像素数据显示到 LCD |
| 格式转换 | convert/ | VideoConvert | isSupport/Convert | 把一种像素格式转为另一种 |
| 图像缩放 | render/ | 无(纯函数) | PicZoom | 把图像缩放到目标尺寸 |
| 图像合并 | render/ | 无(纯函数) | PicMerge | 把小图贴到大图指定位置 |
二、设计模式:C 语言如何实现面向对象
2.1 三个步骤
步骤一:定义抽象接口(头文件中的结构体) 用函数指针定义"能做什么" 步骤二:实现具体功能(.c 文件) 填充函数指针,写具体逻辑 步骤三:注册到链表(Init 函数) 把实现挂到全局链表,供框架查找2.2 三个模块的对比
| 视频 | 显示 | 转换 | |
|---|---|---|---|
| 抽象接口 | VideoOpr | DispOpr | VideoConvert |
| 实例数据 | VideoDevice | T_DispOpr本身 | T_VideoConvert本身 |
| 注册函数 | RegisterVideoOpr() | RegisterDispOpr() | RegisterVideoConvert() |
| 查找函数 | GetVideoOpr() | GetDispOpr() | GetVideoConvertForFormats() |
| 统一初始化 | VideoInit() | DisplayInit() | VideoConvertInit() |
| 实现文件 | v4l2.c | fb.c | yuv2rgb.c/mjpeg2rgb.c/rgb2rgb.c |
2.3 链表注册机制详解
链表初始状态:NULL RegisterVideoOpr(&opr_v4l2): g_ptVideoOprHead → [opr_v4l2 | NULL] RegisterXXX(&opr_xxx): (以后再扩展) g_ptVideoOprHead → [opr_v4l2 | *] → [opr_xxx | NULL] 遍历查找(如 VideoDeviceInit): ptTmp = g_ptVideoOprHead while (ptTmp != NULL) { if (ptTmp->InitDevice(devName, dev) == 0) return 0; // 找到能用的,返回成功 ptTmp = ptTmp->ptNext; } return -1; // 都不行,返回失败这种设计的好处:
- 新增一个驱动,只需要写一个
.c文件 + 一个 Init 函数 - 不需要修改任何现有代码(开闭原则)
- 程序启动时自动注册,运行时自动匹配
三、main 函数完整执行流程
3.1 初始化阶段
main() 开始 │ ├─ 1. DisplayInit() 注册显示驱动(fb) │ └─ FBInit() → RegisterDispOpr(&g_tFBOpr) │ ├─ 2. SelectAndInitDefaultDispDev("fb") 选择并初始化 │ └─ g_ptDefaultDispOpr->DeviceInit() │ └─ FBDeviceInit() │ ├─ open("/dev/fb0") │ ├─ ioctl(FBIOGET_VSCREENINFO) 获取屏幕信息 │ ├─ ioctl(FBIOGET_FSCREENINFO) │ ├─ mmap() → pucDispMem 映射显存 │ └─ CleanScreen(0) 清屏为黑色 │ ├─ 3. GetDispResolution(&w, &h, &bpp) 读取屏幕分辨率 │ ├─ 4. GetVideoBufForDisplay(&tFrameBuf) 获取帧缓冲 │ └─ tFrameBuf.tPixelDatas.aucPixelDatas = pucDispMem │ ↑ 关键:直接指向显存,零拷贝 │ ├─ 5. VideoInit() 注册摄像头驱动 │ └─ V4l2Init() → RegisterVideoOpr(&g_tV4l2VideoOpr) │ ├─ 6. VideoDeviceInit(argv[1], &tVideoDevice) 打开并初始化摄像头 │ └─ 遍历视频链表 → V4l2InitDevice() │ ├─ open(argv[1]) 打开 /dev/videoX │ ├─ ioctl(QUERYCAP) 查询摄像头能力 │ ├─ ioctl(ENUM_FMT) 枚举格式,选一个支持的 │ ├─ ioctl(S_FMT) 设置格式和分辨率 │ ├─ ioctl(REQBUFS) 申请 4 个缓冲区 │ ├─ ioctl(QUERYBUF) + mmap 映射缓冲区 │ └─ ioctl(QBUF) 将缓冲区入队 │ ├─ 7. ptVideoOpr->GetFormat() 获取摄像头格式 │ ├─ 8. VideoConvertInit() 注册三个转换器 │ ├─ Yuv2RgbInit() → RegisterVideoConvert(&g_tYuv2RgbConvert) │ ├─ Mjpeg2RgbInit() → RegisterVideoConvert(&g_tMjpeg2RgbConvert) │ └─ Rgb2RgbInit() → RegisterVideoConvert(&g_tRgb2RgbConvert) │ ├─ 9. GetVideoConvertForFormats(视频格式, 屏幕格式) 匹配转换器 │ └─ 遍历转换器链表 → 返回匹配的 VideoConvert 指针 │ └─ 10. StartDevice() 启动摄像头 └─ ioctl(VIDIOC_STREAMON)3.2 主循环阶段
while (1) 主循环开始 │ ├─ (1) GetFrame(&tVideoDevice, &tVideoBuf) │ └─ V4l2GetFrameForStreaming() │ ├─ poll(fd, POLLIN) 等待摄像头有数据 │ ├─ ioctl(DQBUF) 取出已填好的缓冲区 │ ├─ tVideoBuf.tPixelDatas.aucPixelDatas = mmap 地址 │ └─ tVideoBuf.tPixelDatas.iTotalBytes = 数据大小 │ ├─ (2) 检查格式是否需要转换 │ if (摄像头格式 == LCD 格式) → 跳过,直接用原始数据 │ if (摄像头格式 != LCD 格式) → Convert() 转换 │ ptVideoConvert->Convert(原始帧, 转换后帧) │ 转换后的 ptVideoBufCur = &tConvertBuf │ ├─ (3) 检查画面是否需要缩放 │ if (画面宽 > 屏幕宽 || 画面高 > 屏幕高) │ ├─ 计算等比例缩放后的宽高 │ ├─ PicZoom(当前画面, 缩放后画面) │ └─ ptVideoBufCur = &tZoomBuf │ ├─ (4) 计算居中位置 │ iTopLeftX = (屏幕宽 - 画面宽) / 2 │ iTopLeftY = (屏幕高 - 画面高) / 2 │ ├─ (5) PicMerge(偏移X, 偏移Y, 当前画面, tFrameBuf) │ └─ 将画面逐行复制到 tFrameBuf(即显存) │ ├─ (6) FlushPixelDatasToDev(&tFrameBuf.tPixelDatas) │ └─ FBShowPage() │ └─ 此处 tFrameBuf 已在显存,memcpy 被跳过 │ └─ (7) PutFrame(&tVideoDevice, &tVideoBuf) ← 必须!否则缓冲区耗尽 └─ ioctl(QBUF) 归还缓冲区,供驱动继续使用3.3 初始化 vs 循环 对比
| 阶段 | 调用次数 | 特点 |
|---|---|---|
| 初始化 | 1 次 | 打开设备、分配资源、注册驱动 |
| 主循环 | 无限次 | 取帧→处理→显示→还帧,30 帧/秒 |
四、数据流与控制流
4.1 完整数据流向图
┌──────────┐ 原始数据(YUYV/MJPEG/RGB565) ┌──────────┐ │ USB │ ─────────────────────────────► │ V4L2 │ │ 摄像头 │ 通过 USB 传输到内核驱动 │ 缓冲区 │ └──────────┘ │ (mmap) │ └────┬─────┘ │ GetFrame() ▼ ┌────────────────┐ │ tVideoBuf │ │ aucPixelDatas │ │ = mmap 地址 │ └───────┬────────┘ │ ┌───────────┴───────────┐ │ 格式相同? │ │ iPixelFormatOfVideo │ │ == iPixelFormatOfDisp│ └───────┬───────┬───────┘ 是 │ │ 否 ▼ │ ▼ ┌─────────┐ ┌──────────────────┐ │ 不转换 │ │ Convert() │ │ 直接用 │ │ YUV→RGB │ │ 原始数据 │ │ MJPEG→RGB │ └────┬─────┘ │ RGB→RGB │ │ └───────┬──────────┘ │ │ └──────┬──────────┘ │ ▼ ┌────────────────┐ │ ptVideoBufCur │ │ (当前画面) │ └───────┬────────┘ │ ┌─────────┴─────────┐ │ 画面比屏幕大? │ │ width>lcdWidth │ │ height>lcdHeight │ └──────┬──────┬─────┘ 否 │ │ 是 ▼ │ ▼ ┌─────────┐ ┌──────────────────┐ │ 不缩放 │ │ PicZoom() │ │ 直接使用 │ │ 等比例缩小 │ └────┬─────┘ └───────┬──────────┘ │ │ └──────┬──────────┘ │ ▼ ┌────────────────┐ │ PicMerge() │ │ 居中合并到 │ │ tFrameBuf │ │ (直接写显存) │ └───────┬────────┘ │ ▼ ┌────────────────┐ │ FlushPixel │ │ DatasToDev() │ │ (刷新屏幕) │ └───────┬────────┘ │ ▼ ┌────────────────┐ │ PutFrame() │ │ QBUF 归还 │ │ 继续循环 │ └────────────────┘4.2 控制流(函数调用链)
以一次主循环为例:
main() ├─ tVideoDevice.ptOPr->GetFrame() │ └─ v4l2.c: V4l2GetFrameForStreaming() │ ├─ poll() │ └─ ioctl(VIDIOC_DQBUF) │ ├─ ptVideoConvert->Convert() │ ├─ yuv2rgb.c: Yuv2RgbConvert() │ │ ├─ malloc() 首次分配输出缓冲 │ │ ├─ Pyuv422torgb565() 或 Pyuv422torgb32() │ │ └─ color.c: 查表法 YUV→RGB │ │ │ ├─ 或 mjpeg2rgb.c: Mjpeg2RgbConvert() │ │ ├─ jpeg_create_decompress() │ │ ├─ jpeg_mem_src_tj() │ │ ├─ jpeg_read_header() │ │ ├─ jpeg_start_decompress() │ │ ├─ jpeg_read_scanlines() 循环 │ │ ├─ CovertOneLine() RGB24→RGB565/32 │ │ └─ jpeg_finish_decompress() │ │ │ └─ 或 rgb2rgb.c: Rgb2RgbConvert() │ └─ RGB565→RGB32 位扩展循环 │ ├─ PicZoom() render/zoom.c │ └─ 最近邻插值算法 │ ├─ PicMerge() render/merge.c │ └─ 逐行 memcpy │ ├─ FlushPixelDatasToDev() │ └─ disp_manager.c → fb.c: FBShowPage() │ └─ tVideoDevice.ptOPr->PutFrame() └─ v4l2.c: V4l2PutFrameForStreaming() └─ ioctl(VIDIOC_QBUF)五、核心数据结构关系
5.1 主要结构体依赖图
T_PixelDatas(最基础的数据结构) ↑ 被所有模块引用 │ ├── T_VideoBuf(视频帧) │ └── 被 VideoOpr.GetFrame 返回 │ 被 VideoConvert.Convert 转换 │ ├── T_VideoDevice(摄像头设备实例) │ ├── 包含 iFd, iWidth, iHeight, buffers │ └── 包含 ptOPr → T_VideoOpr(操作函数) │ ├── T_DispOpr(显示设备操作) │ ├── 包含 pucDispMem(显存地址) │ └── 包含 ShowPage, ShowPixel 等函数 │ └── T_VideoConvert(格式转换器) ├── 包含 isSupport → 判断是否支持 └── 包含 Convert → 执行转换5.2 关键设计决策
为什么 VideoDevice 和 VideoOpr 要分开?
structVideoDevice{// 实例数据(每个设备不同)intiFd;// 不同的设备有不同的 fdintiWidth;// 不同的分辨率unsignedchar*pucVideBuf[4];// 不同的缓冲区地址PT_VideoOpr ptOPr;// 共享同一套操作函数};structVideoOpr{// 操作方法(同类设备共享)char*name;int(*InitDevice)(...);// 函数指针int(*GetFrame)(...);// ...};这样设计的原因:
- 如果你插了两个同样的 USB 摄像头,它们共享同一套
VideoOpr(函数代码只有一份) - 但它们各自有自己的
VideoDevice(不同的 fd、缓冲区) - 节省内存,逻辑清晰
六、各模块初始化顺序
模块初始化有严格的顺序依赖,画成时间线:
时间 → │ ├─ ① DisplayInit() │ 必须先初始化显示,因为后面摄像头设置格式时需要读取屏幕分辨率 │ ├─ ② SelectAndInitDefaultDispDev("fb") │ 初始化 fb,获取屏幕宽高和 bpp │ ├─ ③ VideoInit() │ 注册 v4l2 驱动(只是注册到链表,还没打开摄像头) │ ├─ ④ VideoDeviceInit("/dev/videoX") │ 真正打开摄像头,设置格式(分辨率设为屏幕大小) │ ├─ ⑤ VideoConvertInit() │ 注册三个转换器(只是注册,还没使用) │ ├─ ⑥ GetVideoConvertForFormats() │ 根据摄像头格式和屏幕格式,匹配转换器 │ └─ ⑦ StartDevice() 启动摄像头流为什么显示必须最先初始化?
因为第④步V4l2InitDevice()中设置了摄像头分辨率为 LCD 分辨率:
// v4l2.c 中设置摄像头格式GetDispResolution(&iLcdWidth,&iLcdHeigt,&iLcdBpp);tV4l2Fmt.fmt.pix.width=iLcdWidth;// 让摄像头输出和屏幕一样大tV4l2Fmt.fmt.pix.height=iLcdHeigt;如果显示没初始化,GetDispResolution()返回 0,摄像头分辨率会设错。
七、数据格式的匹配逻辑
7.1 main.c 中的格式判断树
摄像头格式: iPixelFormatOfVideo (如 V4L2_PIX_FMT_YUYV) LCD 格式: iPixelFormatOfDisp (如 V4L2_PIX_FMT_RGB565) 判断: ① 格式相同吗? 是 → 不转换,直接使用摄像头原始数据 否 → 进入转换逻辑 ② GetVideoConvertForFormats(摄像头格式, LCD格式) 遍历转换器链表: yuv2rgb: isSupport(YUYV, RGB565) → ✅ 匹配 mjpeg2rgb: isSupport(YUYV, RGB565) → ❌ 继续 rgb2rgb: isSupport(YUYV, RGB565) → ❌ 继续 返回 yuv2rgb 转换器 ③ 调用 ptVideoConvert->Convert()常见的匹配组合:
| 摄像头格式 | LCD 格式 | 匹配的转换器 |
|---|---|---|
| YUYV | RGB565/RGB32 | yuv2rgb |
| MJPEG | RGB565/RGB32 | mjpeg2rgb |
| RGB565 | RGB32 | rgb2rgb |
| RGB565 | RGB565 | 不转换(格式相同) |
7.2 不支持的格式组合会怎样?
如果摄像头输出NV12格式(也在 V4L2 中常见),而我们的程序不支持:
GetVideoConvertForFormats(V4L2_PIX_FMT_NV12,RGB565)// 遍历三个转换器,isSupport 全部返回 0// 返回 NULL在 main.c 中:
ptVideoConvert=GetVideoConvertForFormats(...);if(NULL==ptVideoConvert){DBG_PRINTF("can not support this format convert\n");return-1;// 程序退出}解决方案:新增一个nv122rgb.c,实现 NV12→RGB 的转换,注册到链表中。
八、模块扩展指南
如果未来要增加新功能,按以下步骤操作:
8.1 新增一种摄像头驱动
1. 新建文件:driver/uvc.c 2. 实现 VideoOpr 接口: InitDevice, GetFrame, PutFrame, ... 3. 定义全局结构体:static T_VideoOpr g_tUvcOpr = {...} 4. 实现注册函数:int UvcInit(void) { return RegisterVideoOpr(&g_tUvcOpr); } 5. 在 VideoInit() 中调用 UvcInit()8.2 新增一种格式转换
1. 新建文件:convert/nv122rgb.c 2. 实现 VideoConvert 接口:isSupport, Convert, ConvertExit 3. 定义全局结构体:static T_VideoConvert g_tNv122RgbConvert = {...} 4. 实现注册函数:int Nv122RgbInit(void) { return RegisterVideoConvert(...); } 5. 在 VideoConvertInit() 中追加调用 Nv122RgbInit()8.3 新增图像特效
1. 在 render/ 下新建文件:effect.c 2. 实现函数:int PicGrayscale(PT_PixelDatas ptPic) 3. 在 main.c 主循环中,PicMerge 之前调用九、Makefile 构建流程
9.1 递归编译过程
make └─ make -f Makefile.build │ ├─ 进入 video/ 目录 │ └─ 编译 v4l2.c → v4l2.o │ └─ 编译 video_manager.c → video_manager.o │ └─ ld -r → video/built-in.o │ ├─ 进入 display/ 目录 │ └─ 编译 fb.c → fb.o │ └─ 编译 disp_manager.c → disp_manager.o │ └─ ld -r → display/built-in.o │ ├─ 进入 convert/ 目录 │ └─ 编译 ... → convert/built-in.o │ ├─ 进入 render/operation/ 目录 │ └─ 编译 zoom.c, merge.c → render/operation/built-in.o │ └─ 返回 render/ → render/built-in.o │ ├─ 编译 main.c → main.o │ └─ ld -r → built-in.o(包含所有子模块的 built-in.o) │ └─ arm-linux-gcc -o video2lcd built-in.o -lm -ljpeg9.2 Makefile.build 核心逻辑
# 1. 从 Makefile 读取 obj-y(哪些 .o 和子目录) # 2. 分离出 "当前目录的 .o" 和 "子目录/" # 3. 递归进入子目录编译,生成子目录的 built-in.o # 4. 用 ld -r 把所有 .o 合并为当前目录的 built-in.o # 5. 顶层用 gcc 链接 built-in.o 和库文件,生成最终可执行文件十、项目总览图
┌──────────────────────────────────────────────────────────────────────┐ │ video2lcd 项目全景 │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ main.c 主循环 │ │ │ │ 初始化 → [取帧 → 转换 → 缩放 → 合并 → 显示 → 还帧]∞ │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ ┌─────────┴──┐ ┌──────┴──────┐ └──────┬──────────┐ │ │ │ video/ │ │ convert/ │ │ render/ │ │ │ │ 摄像头采集 │ │ 格式转换 │ │ 渲染处理 │ │ │ │ │ │ │ │ │ │ │ │ v4l2.c │ │ yuv2rgb.c │ │ zoom.c │ │ │ │ ┌──────┐ │ │ mjpeg2rgb.c│ │ merge.c │ │ │ │ │DQBUF │ │ │ rgb2rgb.c │ │ │ │ │ │ │QBUF │ │ │ color.c │ │ │ │ │ │ │mmap │ │ │ jdatasrc │ │ │ │ │ │ └──────┘ │ └────────────┘ └───────────┘ │ │ └───────────┘ │ │ │ │ │ └──────────────────┬─────────────────┐ │ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ display/ │ │ include/ │ │ │ │ LCD 显示 │ │ 接口定义 │ │ │ │ │ │ │ │ │ │ fb.c │ │ config.h │ │ │ │ ┌────────┐ │ │ video_man.h │ │ │ │ │mmap │ │ │ disp_man.h │ │ │ │ │memcpy │ │ │ convert_man.h│ │ │ │ └────────┘ │ │ pic_op.h │ │ │ │ │ │ render.h │ │ │ └─────────────┘ └──────────────┘ │ └──────────────────────────────────────────────────────────────────┘🏁 总结:一张图记住整个项目
摄像头 → 缓冲区 → 转格式 → 缩放 → 合并 → 显存 → LCD open DQBUF isSupport PicZoom PicMerge mmap 显示 S_FMT QBUF Convert FB mmap poll一句话总结:这个项目就是一个V4L2 采集 + Framebuffer 显示的管道,中间用模块化的转换器、缩放器做数据处理,所有模块通过链表注册的方式解耦。
