简介:这份文档围绕粤嵌GEC6818开发板项目实践展开,面向学习ARM嵌入式Linux与C语言编程的初学者及课程设计人群,可用于了解开发板外设驱动、交叉编译和综合项目组织方式。整包仅含1个docx文件,约13KB,内容紧凑,便于快速浏览与检索。内容梳理智能家居控制、多媒体影音、电子相册、RFID刷卡、五子棋、密码锁屏和智能语音家居等案例,并涉及触摸屏交互、V4L2摄像头采集、mmap显示及UDP上传等实现思路。已有5763人学习,可作为嵌入式课程实训、项目选题与功能拆解时的参考资料,也能帮助读者对照开发板环境梳理模块边界、外设调用与调试方向。
1. GEC6818 这套板子资源,适合把 ARM 开发 Linux 的整条链路一次跑通
很多人第一次拿到 GEC6818 这类 ARM 开发板,第一反应是刷个系统、点个灯,然后就卡在“下一步干什么”。这份项目资料的价值不在于教你点亮一个 LED,而在于它把 C 语言编程、Linux 系统调用、交叉编译、framebuffer、触摸屏输入、V4L2 摄像头采集、UDP 上传这一整条链路串成了若干个能独立跑起来的小系统:智能家居控制界面、电子相册、音乐视频播放、五子棋、密码锁、弹球游戏,以及摄像头图像走 UDP 回传上位机。它适合两类人:一类是刚学完 C 语言、想找个真实硬件把知识点落地的学生;另一类是已经会写 Linux 应用、但没碰过 framebuffer 和 V4L2 的开发者,想拿一块成本不高的板子把驱动接口摸一遍。读完你至少能判断:哪些 demo 可以直接抄,哪些地方会翻车,以及怎么把散装代码收成一个能演示的工程。
2. 交叉编译环境与文件传输:从工具链到板子上真正跑起来
2.1 为什么必须在主机上交叉编译,而不是在板子上编译
GEC6818 这类板子的定位是运行目标程序,不是开发机。板子上通常没有完整的 gcc、头文件和 make,存储空间和内存也不适合跑一次大型编译;更现实的问题是编译一个带 V4L2 和 framebuffer 的工程,需要内核头文件和对应架构的库,板端环境往往被裁剪得很干净。
所以常规做法是在 x86 主机上装一套 ARM 交叉工具链,把源码编译成 ARM 可执行文件,再传到板子上运行。工具链命名里几个字段要能看懂:arm是目标架构,linux是目标操作系统,gnueabihf表示使用 glibc 且是硬浮点 ABI。GEC6818 的 SoC 是 Cortex-A53 架构,属于 ARMv8 的 64 位核,但项目里跑的通常是 32 位 ARM 用户态系统,所以工具链要用 32 位的那一套,别拿 aarch64 的工具链去编,编出来的程序在板子上会直接报“cannot execute binary file”。
选型上有一个容易忽略的点:工具链的 glibc 版本不能高于板子根文件系统里的 glibc 版本。主机上装了新版工具链,编出来的程序依赖GLIBC_2.29,板子上只有GLIBC_2.23,运行时报版本符号找不到。血泪经验是:优先用板子厂商配套的那套工具链,或者先用strings看一眼板子/lib/libc.so.6的版本,再决定主机装哪个。
2.2 环境变量与第一个能跑通的 ARM 程序
工具链解压后一般是个带bin/的目录。不建议把路径写死到每一条命令里,直接在 shell 里导出,或者写进~/.bashrc。下面这段是常见的初始化方式,路径按自己实际解压位置改。
# 工具链根目录,改成自己解压出来的路径 export TOOLCHAIN=/opt/toolchain/gcc-arm-linux-gnueabihf export PATH=$TOOLCHAIN/bin:$PATH # 约定交叉编译器前缀,Makefile 里直接引用这个变量 export CROSS_COMPILE=arm-linux-gnueabihf- # 验证:能打印出版本号说明 PATH 生效 ${CROSS_COMPILE}gcc -v参数说明:CROSS_COMPILE这个变量名是 Linux 内核和大量开源工程约定的前缀,后面拼gcc、ld、strip就是具体工具。把它导出来之后,Makefile 里写CC := $(CROSS_COMPILE)gcc就能自动切换主机编译和交叉编译,不需要改代码。
先写一个最小的程序确认链路通了,不要一上来就编整个工程。
/* hello_arm.c:只验证交叉编译产物能在板子上跑 */ #include <stdio.h> #include <sys/utsname.h> int main(void) { struct utsname u; if (uname(&u) == 0) { printf("machine: %s\n", u.machine); /* 期望输出 armv7l 或 armv8l */ } printf("hello from cross compile\n"); return 0; }编译并检查产物架构:
# -march 指定指令集,-g 保留调试符号,-O2 开常规优化 ${CROSS_COMPILE}gcc -march=armv7-a -g -O2 hello_arm.c -o hello_arm # file 看架构,出现 ARM 字样才对;出现 x86-64 说明用错了编译器 file hello_arm # readelf 看动态链接依赖,确认没有引用主机上才有的库 ${CROSS_COMPILE}readelf -d hello_arm | head -20逻辑说明:file判断的是 ELF 头里的机器类型,这是最直接的一道自检;readelf -d看的是.dynamic段,重点确认NEEDED里只有libc.so.6这类板子上必然存在的库。如果你不小心用主机 gcc 编了一遍,file会显示x86-64,传上去执行就是Exec format error,这个报错基本可以一口断定是架构不对,不用再怀疑别的。
2.3 文件传输:NFS、tftp、ZMODEM 三条路怎么选
传文件这件事,不同阶段用不同方案,混着用会浪费大量时间。
| 方式 | 适用阶段 | 优点 | 明显短板 |
|---|---|---|---|
| NFS 挂载 | 开发调试期 | 主机改完代码直接跑,不用反复传 | 需要主机开 NFS 服务,网络配置要对 |
| tftp | 传单个内核、设备树、小文件 | 板端 busybox 一般自带客户端 | 无认证、无断点续传,大文件容易失败 |
| ZMODEM(sz/rz) | 串口终端里临时传小文件 | 不用配网络,SecureCRT 类终端直接支持 | 走串口速度慢,只适合几 MB 以内的文件 |
| U 盘 / SD 卡 | 交付演示、批量拷贝 | 稳定、不依赖网络 | 每次要拔插,迭代效率低 |
开发期我一般会用 NFS:主机上exports一个目录,板子上mount -t nfs -o nolock挂到/mnt,然后把可执行文件直接编译输出到那个目录。这样改一行代码重新 make,板子上再执行一次就行,省掉了“传—改权限—运行”的三步。注意nolock这个参数,板子上的 busybox 有时没起 rpc.statd,不加会卡在挂载阶段。
ZMODEM 的用法在原项目里也提到了:终端软件里选中文件发送,板子端执行rz接收;反过来板子端sz 文件名是把文件发回主机。它的实际价值是网络还没配通、或者只是临时看一眼日志文件时能救急。
传输完别忘了权限:chmod +x hello_arm。FAT 格式的 U 盘和 SD 卡没有可执行位概念,拷进去再拷出来,可执行权限会丢,这一步漏了会得到一个很迷惑的Permission denied。
2.4 一份够用的 Makefile 骨架
工程一旦超过三个源文件,手敲编译命令就会开始出错。下面这份骨架覆盖了交叉编译、调试符号、清理三个最基本的需求,可以直接抄。
# 交叉编译器前缀,和 shell 里导出的保持一致 CROSS_COMPILE ?= arm-linux-gnueabihf- CC := $(CROSS_COMPILE)gcc STRIP := $(CROSS_COMPILE)strip # -Wall 打开常见警告,-g 保留调试符号,-I 指定头文件目录 CFLAGS := -Wall -O2 -g -I./include LDFLAGS := -lpthread TARGET := app SRCS := $(wildcard src/*.c) OBJS := $(SRCS:.c=.o) $(TARGET): $(OBJS) $(CC) $^ -o $@ $(LDFLAGS) # 每个 .c 编译成 .o,$< 是第一个依赖,$@ 是目标 %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ # 发布版本去掉调试符号,体积能小一半以上 release: $(TARGET) $(STRIP) $(TARGET) clean: rm -f $(OBJS) $(TARGET) .PHONY: clean release逻辑说明:wildcard自动收集src下所有.c,加文件不用改 Makefile;%.o: %.c是模式规则,把编译和链接拆开,改一个文件只重编一个.o。release目标单独做 strip,是因为调试阶段需要-g保留符号配合 gdbserver,但交付时符号表会让二进制大出一截,而且没必要暴露函数名。
调试环节补一句:板子上跑gdbserver :1234 ./app,主机上arm-linux-gnueabihf-gdb ./app,然后target remote 板子IP:1234,可以在主机上单步调试板子上的程序。这个组合在排查段错误时比 printf 高效得多,尤其是崩溃点在库函数内部的时候。
3. 触摸屏与 LCD:把功能选择界面做成可扩展的菜单框架
3.1 读触摸事件:/dev/input/eventX 与坐标范围
GEC6818 配的 7 寸电容屏在 Linux 下走输入子系统,应用层看到的是/dev/input/event0这类字符设备。先确认是哪个节点,别猜。
# 列出所有输入设备,看 Name 里带 touch 的那个对应 event 几 cat /proc/bus/input/devices # 直接读原始事件流,手指按屏幕时终端会刷出二进制,Ctrl+C 退出 hexdump -C /dev/input/event1 | head确认节点后,应用层读的是固定结构体struct input_event,包含type、code、value三个关键字段。触摸屏一般上报EV_ABS类型的ABS_X、ABS_Y(单点)或ABS_MT_POSITION_X、ABS_MT_POSITION_Y(多点)。这里第一个坑是:屏幕上报的坐标范围不等于屏幕分辨率。1024×600 的屏,驱动上报的 X 范围可能是 0 到 1023,也可能是 0 到 4095,取决于触摸 IC 和驱动配置。
正确做法是用EVIOCGABS这个 ioctl 在运行时把范围读出来,而不是硬编码。
/* 读取触摸屏 X/Y 的原始范围,避免硬编码分辨率 */ #include <linux/input.h> #include <sys/ioctl.h> #include <fcntl.h> #include <stdio.h> static void query_abs_range(int fd, int code, const char *name) { struct input_absinfo info; if (ioctl(fd, EVIOCGABS(code), &info) == 0) { printf("%s: min=%d max=%d fuzz=%d flat=%d\n", name, info.minimum, info.maximum, info.fuzz, info.flat); } } int main(void) { int fd = open("/dev/input/event1", O_RDONLY); if (fd < 0) { perror("open event"); return -1; } query_abs_range(fd, ABS_X, "ABS_X"); query_abs_range(fd, ABS_Y, "ABS_Y"); close(fd); return 0; }参数说明:input_absinfo里minimum和maximum就是该轴的有效范围,fuzz是驱动做过的噪声过滤值,flat是死区。拿到范围后,映射到屏幕坐标就是一次线性变换:screen_x = (raw_x - min) * lcd_width / (max - min)。这一步不做,触摸点会整体偏移或者只在屏幕一角响应,现象很像“触摸不准”,实际是没做坐标换算。
3.2 framebuffer 直接画:mmap 映射与像素格式
没有桌面环境,界面就是往 framebuffer 里写像素。流程固定:打开/dev/fb0,用FBIOGET_VSCREENINFO拿分辨率、位深、虚拟分辨率,用FBIOGET_FSCREENINFO拿line_length,然后mmap一段内存,之后所有绘制都是往这块内存写。
/* 初始化 framebuffer,拿到显存起始地址和行宽 */ #include <linux/fb.h> #include <sys/mman.h> #include <sys/ioctl.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> static unsigned char *fb_mem; static struct fb_var_screeninfo vinfo; static struct fb_fix_screeninfo finfo; int fb_init(const char *dev) { int fd = open(dev, O_RDWR); if (fd < 0) { perror("open fb"); return -1; } if (ioctl(fd, FBIOGET_VSCREENINFO, &vinfo) < 0) return -1; if (ioctl(fd, FBIOGET_FSCREENINFO, &finfo) < 0) return -1; printf("res=%dx%d bpp=%d line_length=%d\n", vinfo.xres, vinfo.yres, vinfo.bits_per_pixel, finfo.line_length); /* 映射长度用虚拟分辨率乘行宽,不能用 xres*yres*bpp/8 */ size_t map_size = finfo.line_length * vinfo.yres_virtual; fb_mem = mmap(NULL, map_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); if (fb_mem == MAP_FAILED) { perror("mmap fb"); return -1; } return 0; } /* 写一个像素:RGB565 格式下 r/g/b 各占 5/6/5 位 */ static inline void put_pixel(int x, int y, unsigned short color) { unsigned short *p = (unsigned short *)(fb_mem + y * finfo.line_length + x * 2); *p = color; }逻辑说明:vinfo.bits_per_pixel决定一个像素占几个字节,16 位就是 RGB565,32 位一般是 ARGB8888。finfo.line_length是每行实际字节数,它可能大于xres * bpp / 8,因为硬件对行宽做了对齐,所以计算偏移必须用line_length而不是xres。这是颜色错位、画面斜切这类问题的根源,我见过不止一次有人在这里写成xres * 2。
参数说明:mmap的长度按line_length * yres_virtual算,虚拟分辨率可能比实际可见分辨率大(双缓冲),少映射了会越界写,症状是程序偶尔崩、偶尔不崩,很难查。写显存时也建议先写yres_virtual内的内存,再通过FBIOPAN_DISPLAY切换,避免撕裂。
触摸和显示这两层搞定,界面框架就有基础了。
3.3 用函数指针表把界面做成可扩展菜单
原项目里的功能选择界面包含电子相册、游戏、音乐播放、视频播放四五个入口。如果写成一大串if/else,加一个功能要动三处代码。可行的做法是定义一张页面表,每个页面挂两个函数指针:绘制和触摸处理。
/* 页面表:新增功能只加一行,不改主循环 */ typedef struct { const char *name; void (*draw)(void); /* 绘制整页 */ int (*on_touch)(int x, int y); /* 返回 1 表示需要重绘 */ } page_t; static void home_draw(void); static int home_touch(int x, int y); static void photo_draw(void); static int photo_touch(int x, int y); static const page_t pages[] = { { "电子相册", photo_draw, photo_touch }, { "音乐播放", music_draw, music_touch }, { "五子棋", chess_draw, chess_touch }, { "设置", setting_draw, setting_touch }, }; #define PAGE_CNT (sizeof(pages) / sizeof(pages[0]))主循环里只做三件事:读一个input_event,把坐标归一化,交给当前页面的on_touch;返回 1 就调draw重绘;按返回键就让cur_page归零回主页。这样每个功能模块只关心自己的绘制区域和点击判定,互不干扰。新增一个弹球游戏,就是写两个函数、在数组里加一行,主循环一行不动。这种结构在演示时特别有用,临时想加个密码锁屏页面,五分钟能挂上去。
提示:触摸事件会连续上报按下、移动、抬起三类,菜单点击建议只在抬起(
EV_KEY且value == 0)时判定,按下就触发会导致一次触摸触发两次功能切换。
4. 摄像头链路:V4L2 采集、mmap 显示与 UDP 上传
4.1 V4L2 的固定调用序列
V4L2 的接口看起来吓人,但采集一帧的流程是固定的,按顺序走完就行:打开设备、查询能力、设置格式、申请缓冲、映射缓冲、入队、开流、取帧、再入队。中间任何一步返回值小于 0 都要打印errno并退出,不要跳过去继续走,后面的调用会因为状态不对而给出完全无关的错误码。
/* V4L2 初始化到开流的核心序列,省略错误打印 */ #include <linux/videodev2.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <fcntl.h> #include <string.h> #include <stdio.h> #define BUF_CNT 4 static int v4l2_start(int *fd_out, void **bufs, size_t *lens) { int fd = open("/dev/video0", O_RDWR); if (fd < 0) { perror("open video"); return -1; } struct v4l2_capability cap; ioctl(fd, VIDIOC_QUERYCAP, &cap); /* 设置采集格式:优先 YUYV,部分摄像头只支持 MJPEG */ struct v4l2_format fmt; memset(&fmt, 0, sizeof(fmt)); fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = 640; fmt.fmt.pix.height = 480; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { perror("S_FMT"); return -1; } /* 申请 4 个缓冲,驱动可能返回比请求少的数量,以实际值为准 */ struct v4l2_requestbuffers req; memset(&req, 0, sizeof(req)); req.count = BUF_CNT; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) { perror("REQBUFS"); return -1; } for (int i = 0; i < (int)req.count; i++) { struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; ioctl(fd, VIDIOC_QUERYBUF, &buf); /* 每个缓冲单独映射,长度由驱动给出 */ bufs[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); lens[i] = buf.length; ioctl(fd, VIDIOC_QBUF, &buf); /* 先入队,再开流 */ } int type = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(fd, VIDIOC_STREAMON, &type); *fd_out = fd; return 0; }逻辑说明:REQBUFS返回的count可能小于请求值,循环里必须用req.count而不是宏BUF_CNT,否则会映射到未申请的缓冲。S_FMT之后最好再调一次G_FMT把驱动实际接受的格式读回来,因为驱动可能把你的 640×480 YUYV 改成 640×480 MJPEG 或者干脆改了分辨率,后面按错误的分辨率算 YUV 大小会直接花屏。
参数说明:memory选V4L2_MEMORY_MMAP表示用内核缓冲映射到用户态,零拷贝,适合帧率要求不高的场景;如果要用 DMA 到自己的内存,得走USERPTR,但麻烦很多,教学项目一般用不到。
取帧用select或poll等可读,然后VIDIOC_DQBUF拿到一个buf.index,处理完再VIDIOC_QBUF还回去。不还回去,缓冲池很快就空了,select会一直阻塞,看起来像“摄像头挂死”。
4.2 YUYV 转 RGB565 再上屏
摄像头输出的 YUYV 是每两个像素共用一组 Y,排列是Y0 U Y1 V。要在 RGB565 的 framebuffer 上显示,得做一次颜色空间转换。公式用整数近似就够,浮点版本在 A53 上跑 640×480@30fps 会明显掉帧。
/* YUYV -> RGB565,一次处理两个像素 */ static inline unsigned short yuv_to_rgb565(int y, int u, int v) { int r = y + (int)(1.402f * (v - 128)); int g = y - (int)(0.344f * (u - 128) + 0.714f * (v - 128)); int b = y + (int)(1.772f * (u - 128)); /* 钳位到 0-255,越界会溢出成完全错误的颜色 */ r = r < 0 ? 0 : (r > 255 ? 255 : r); g = g < 0 ? 0 : (g > 255 ? 255 : g); b = b < 0 ? 0 : (b > 255 ? 255 : b); return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); } /* 把一整帧 YUYV 写进显存,x_off/y_off 是左上角偏移 */ static void yuyv_to_fb(const unsigned char *yuyv, int w, int h, int x_off, int y_off) { for (int y = 0; y < h; y++) { const unsigned char *row = yuyv + y * w * 2; for (int x = 0; x < w; x += 2) { int Y0 = row[0], U = row[1], Y1 = row[2], V = row[3]; put_pixel(x_off + x, y_off + y, yuv_to_rgb565(Y0, U, V)); put_pixel(x_off + x + 1, y_off + y, yuv_to_rgb565(Y1, U, V)); row += 4; } } }逻辑说明:YUYV 一行的字节数是width * 2,所以行偏移乘 2;内层循环每次前进两个像素、四个字节。put_pixel用的是第 3 章里那个带line_length的版本,不能换成x * 2,否则图像会斜。
参数说明:钳位那三行是必须的。YUV 转 RGB 的系数会让结果轻微超出 0-255,不钳位直接强转成unsigned short的低位,会让本来接近纯色的区域出现刺眼的色斑,看起来像摄像头坏了,其实是数值溢出。
4.3 UDP 上传:自定义帧头解决粘包和丢帧
原项目里有一项是把摄像头采集的图像通过 UDP 上传。UDP 本身不保证顺序、不保证到达,图像数据动辄几百 KB,必须自己切包并加帧头。常见的做法是定义一个固定长度的头结构,带上帧号、包序号、包总数和数据长度。
/* 自定义协议头,结构体要按 1 字节对齐,避免编译器插入填充 */ #pragma pack(push, 1) typedef struct { unsigned int magic; /* 固定魔数,比如 0x47554338 */ unsigned int frame_id; /* 帧序号,接收端按它重组 */ unsigned short pkt_idx; /* 当前包序号,从 0 开始 */ unsigned short pkt_total; /* 这一帧一共切了几包 */ unsigned short data_len; /* 本包有效数据长度 */ unsigned short reserved; } img_hdr_t; #pragma pack(pop) /* 按 1400 字节切片发送,避免 IP 层分片 */ int send_frame(int sock, const unsigned char *data, int len, unsigned int fid) { const int payload = 1400; int total = (len + payload - 1) / payload; unsigned char pkt[sizeof(img_hdr_t) + 1400]; for (int i = 0; i < total; i++) { int off = i * payload; int n = (len - off > payload) ? payload : (len - off); img_hdr_t *h = (img_hdr_t *)pkt; h->magic = 0x47554338; h->frame_id = fid; h->pkt_idx = i; h->pkt_total = total; h->data_len = n; memcpy(pkt + sizeof(img_hdr_t), data + off, n); if (sendto(sock, pkt, sizeof(img_hdr_t) + n, 0, (struct sockaddr *)&dst, sizeof(dst)) < 0) { perror("sendto"); return -1; } } return 0; }逻辑说明:#pragma pack(1)是必须的。结构体里unsigned int和unsigned short混排,默认对齐会让pkt_idx前面插入 2 字节填充,接收端如果按另一种对齐方式解析,字段全错位,现象是“能收到数据但帧号是乱的”。
参数说明:载荷取 1400 是个经验值。以太网 MTU 是 1500,减去 IP 头 20 字节和 UDP 头 8 字节,理论上限 1472,留一点余量比较稳。超过这个值,IP 层会分片,任意一片丢了整帧都拼不回来,而在局域网上分片丢失的概率并不低。帧号用来丢弃过期帧,接收端发现frame_id比当前正在组的帧旧,直接扔掉,不要试图拼接,否则画面会来回跳。
5. 避坑与排查:五个卡住半天的现场问题
5.1 程序在板子上直接 Segmentation fault,主机上跑得好好的
现象:同样的代码在主机上./app正常,交叉编译后传到板子,一执行就段错误,连第一行 printf 都没输出。原因通常有两类:一是结构体里有指针字段被硬编码成主机地址,或者用了long当指针容器(32 位 ARM 上long是 4 字节,主机 64 位是 8 字节,强转后截断);二是用了主机上存在、板子上版本不同的库,函数签名对不上,参数被错误解析。解决办法是先用readelf -d核对依赖,再在板子上用 gdbserver 跑一遍看崩溃时的调用栈,确认崩溃位置;养成习惯,指针一律用void *或uintptr_t存取,不用long。
5.2 触摸点整体偏移,点在按钮左边却触发了右边
现象:界面能显示,点击也有反应,但落点系统性偏移,越靠近屏幕边缘偏差越大。原因是没有做坐标归一化,把驱动上报的原始值直接当成像素坐标用了。触摸 IC 上报的范围和 LCD 分辨率不是一回事,驱动可能上报 0-4095,而屏幕只有 1024 宽。解决办法是用EVIOCGABS读出minimum和maximum,按(raw - min) * screen_w / (max - min)换算,再做一次边界钳位。另一种情况是用了多点触摸协议但只读了ABS_X,多指时ABS_X不上报,得读ABS_MT_POSITION_X并配合ABS_MT_TRACKING_ID。
5.3 画面颜色不对、整屏偏绿或者红蓝互换
现象:framebuffer 能刷出内容,但照片颜色明显偏色,或者纯红显示成纯蓝。原因基本锁定在像素格式上:驱动配置的是 RGB565,程序按 ARGB8888 写,或者 RGB 顺序搞反成了 BGR。解决办法是先打印vinfo.bits_per_pixel和vinfo.red.offset、blue.offset这几个字段,别假设。16 位时按 RGB565 打包,32 位时按AARRGGBB打包,写完先用纯红0xF800、纯绿0x07E0、纯蓝0x001F三个色块验证一遍,比拿照片试快得多。
5.4 摄像头取几帧后卡住,select 一直超时
现象:前几帧正常,跑几秒后画面停住,select返回 0 或者干脆阻塞。原因是DQBUF取出的缓冲没有QBUF还回队列,缓冲池被耗尽。四个缓冲用完就再也没有可用的,驱动不再产生新帧。解决办法是检查每一帧处理完的路径,确保QBUF一定被调用,包括continue和错误返回的分支也要还回去。另一个易错点是STREAMON之前忘了先把所有缓冲入队,开流时队列里是空的,表现同样是取不到帧。
5.5 UDP 上位机收到图,但画面下半部分是花的
现象:接收端能拼出图像,上半部分正常,下半部分像被错位覆盖,或者整帧有一部分是上一帧的内容。原因通常是发送端在帧还没发完时缓冲就被下一帧覆写,或者接收端按pkt_total拼包时,把不同frame_id的包混在一起了。解决办法是在发送端对一帧数据做一次本地拷贝再切片,或者用双缓冲轮流用;接收端严格按frame_id分组,pkt_total不匹配的帧整体丢弃,不要“凑合拼”。另外确认data_len是按最后一包实际长度算的,最后一包不足 1400 字节时按 1400 算会把后面的栈内存一起发出去。
6. 进阶:把散装 demo 收成一个能演示的工程
散装 demo 的通病是每个功能单独一个main,演示时要来回换程序。真正要拿去答辩或者给同事看,最好收成一个进程,用页面表串起来,再补两个东西:一个是统一的日志宏,一个是启动自检。
日志宏这一步很值。板子上没有图形调试器,出问题时只能靠串口输出,如果每个文件都用不同的 printf 风格,排查会很痛苦。
/* log.h:统一日志出口,编译时可用 -DLOG_LEVEL 控制级别 */ #ifndef LOG_H #define LOG_H #include <stdio.h> #define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_ERR 2 #ifndef LOG_LEVEL #define LOG_LEVEL LOG_LEVEL_INFO #endif #define LOGD(fmt, ...) do { if (LOG_LEVEL <= LOG_LEVEL_DEBUG) \ fprintf(stderr, "[D] " fmt "\n", ##__VA_ARGS__); } while (0) #define LOGI(fmt, ...) do { if (LOG_LEVEL <= LOG_LEVEL_INFO) \ fprintf(stderr, "[I] " fmt "\n", ##__VA_ARGS__); } while (0) #define LOGE(fmt, ...) do { \ fprintf(stderr, "[E] %s:%d " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); } while (0) #endif参数说明:LOG_LEVEL在编译时用-DLOG_LEVEL=0打开全部输出,发布时用默认值只留错误。LOGE里带__FILE__和__LINE__,是因为板子上出错的现场往往没法复现,能定位到行号就省一次来回传程序。输出走stderr而不是stdout,是为了在串口里和正常业务输出分开,重定向时也不会被缓冲吃掉。
启动自检我一般会做四项,做成一个self_check()在进入主循环前跑一遍:framebuffer 能否打开、触摸设备节点是否存在、摄像头能否完成一次REQBUFS、存储目录是否可写。任何一项失败就在屏幕上打一行红字,而不是让程序带着半残的状态跑起来。之前吃过一次亏,摄像头没插好,程序照常进主循环,用户点“拍照”没反应,我在那儿查了半天触摸逻辑,最后发现是open("/dev/video0")返回 -1 被一个没写返回值的if吞掉了。从那以后我每次加新硬件模块,都强制先在自检里加一行状态打印,跑通了再写业务。
工程目录也顺手理一下,src/放业务代码,drivers/放 framebuffer、touch、v4l2 的封装,include/放头文件,assets/放图片和字库,编译产物统一输出到build/,NFS 直接挂这个目录。这套结构不算精致,但换一块板子移植的时候,只需要改drivers/里的路径和格式定义,业务代码基本不用动,这是最省后悔药的做法。
希望帮到你。
本文还有配套的精品资源,点击获取