简介:这份资源是围绕粤嵌GEC6818 ARM开发板的项目案例文档,面向学习嵌入式Linux与C语言编程的初学者、高校实训学生及需要课程设计参考的开发者,帮助读者理解从开发环境搭建到具体功能落地的完整路径。压缩包内共1个docx文件,约13KB,以文字形式梳理了智能家居控制系统、多媒体电子相册与音视频播放、娱乐影音系统、五子棋游戏、密码锁屏与弹球游戏、智能语音家居等典型案例,并涉及交叉编译、驱动设计流程、文件操作集初始化、SecureCRT传输、触摸屏交互、V4L2编程与UDP上传等知识点。已有5761人学习,说明其在开发板入门与实训场景中具备一定参考价值。读者可据此快速建立GEC6818的项目全貌,按功能模块选择练手方向,对照梳理硬件控制、多媒体处理与网络通信的实现思路,减少摸索成本。
1. 从一块 GEC6818 说起:它把 ARM+Linux+C 语言的整条链路塞进了一块板子
实验室里那块 7 寸屏亮起来的时候,很多人才第一次意识到嵌入式不是背寄存器手册。GEC6818 这块 ARM 开发板上跑着 Linux 操作系统,用 C 语言写一个程序、交叉编译、丢到板上执行,就能直接操作屏幕显存、读触摸事件、抓摄像头数据。它面向的是刚学完 C 语言、想找一个能把「编译—部署—运行—调试」整条链路走完的练手平台的人。在 PC 上你永远不会碰到的问题——工具链版本对不上、程序拷过去跑不起来、屏幕花成一片、手指点下去坐标偏了半个屏幕——在这块板子上会一次性全撞上。也正因为都会撞上,它才适合当作嵌入式 Linux 的第一块靶场:智能家居、电子相册、音乐播放、五子棋、摄像头 UDP 推流,这些案例看上去花样不同,底层其实是同一套东西——文件描述符、ioctl、mmap、input 事件、socket。
2. 环境先跑通:交叉编译链、NFS 挂载与第一帧画面
很多人拿到板子第一件事是插上串口看启动日志,看完就卡住了:不知道下一步该干什么。正确的顺序是先把「写代码的机器」和「跑代码的机器」之间那条通道打通,再谈功能。通道打通的标准很短:你在 PC 上改一行 printf,三秒之后在板子的串口终端里看到输出。这条回路没建立起来之前,写再多业务代码都是白费。
2.1 工具链选型:三条绕不过去的硬约束
板子是 ARM 架构,你的开发机大概率是 x86_64,所以必须交叉编译。工具链的命名规律值得先搞清楚:arm-linux-gnueabi-gcc和arm-none-linux-gnueabi-gcc差的不是两个字母,前者表示用系统自带的 libc,后者表示工具链自带一套独立的 sysroot。选哪个不看网上教程,看板子里/lib目录下那一堆libc.so.6、libm.so.6的版本——工具链的 glibc 版本必须小于等于板上运行的版本,否则编译能过、链接能过、一上板就报GLIBC_2.XX not found。
这里有三条约束,血泪经验都在这儿:
第一,内核头文件版本要和板上内核对齐。用高版本内核头编译出来的程序,可能引用到板子上根本不存在的系统调用号。
第二,静态链接可以作为临时的后悔药。第一次验证时用-static把 libc 全部塞进可执行文件,能立刻排掉「是不是库版本问题」这个变量。等程序跑通了再换回动态链接减小体积。
第三,别用开发机上的 gcc 试编译。gcc hello.c -o hello得到的 x86 二进制拷到板子上,报cannot execute binary file是必然结果,不是玄学。
# 假设工具链解压在 /opt/toolchain,先确认它真的能用 export PATH=/opt/toolchain/bin:$PATH arm-linux-gcc -v # 看输出版本,别只看命令成不成功 # 写一个最小验证程序 cat > hello.c <<'EOF' #include <stdio.h> int main(void){ printf("hello from arm\n"); return 0; } EOF # 第一轮:静态链接,排除库版本干扰 arm-linux-gcc -static -o hello_static hello.c # 第二轮:动态链接,看板上能不能找到对应版本的 libc arm-linux-gcc -o hello_dyn hello.c # 用 file 确认架构,这一步不能省 file hello_static # 期望看到 ELF 32-bit LSB executable, ARM-static的作用是把所有依赖库在链接阶段搬进可执行文件,代价是体积从几十 KB 涨到几百 KB 甚至上 MB,好处是把「板子上缺库」这个变量直接消掉。file那条命令是我每次必做的检查——很多人编译完不看架构,拷过去跑不起来,然后开始怀疑人生,其实第一步就该看到ARM而不是x86-64。
2.2 NFS 挂载:把「拷贝」这一步从流程里砍掉
传文件上板这件事,用 U 盘、用串口传输工具、用 tftp 都能做,但效率差得很远。U 盘要拔插、串口传输工具传个大文件要等几分钟、tftp 每次都得手动敲。NFS 挂载之后,开发机上的一个目录直接变成板子上的一个目录,你在 PC 上用编辑器保存,板子上立刻就是新文件,改一行测一次的成本降到几乎为零。
前置条件是开发机和板子在同一个网段,且开发机装了 NFS 服务并导出目录。板子这边一条命令搞定:
# 板子上执行,192.168.1.100 换成你开发机的实际地址 mkdir -p /mnt/nfs mount -t nfs -o nolock,vers=3 192.168.1.100:/home/dev/share /mnt/nfs # 验证:开发机往 share 目录里扔个文件,板子上能不能立刻看到 ls -l /mnt/nfs # 进入挂载点直接运行,注意当前工作目录会影响相对路径资源 cd /mnt/nfs && ./hello_staticnolock是必须加的——早期板子上的 NFS 客户端和文件锁服务配合不好,不加这个参数经常出现挂载成功但一读文件就卡死。vers=3是为了兼容板子上较老的内核 NFS 客户端,v4 在这类板子上支持得并不完整。这两点不是可选项,是我踩过之后固定在脚本里的默认值。
挂载成功后,程序运行时要注意工作目录。如果你的程序里有fopen("res/pic.bmp", "rb")这种相对路径,那么必须cd到可执行文件所在目录再运行,否则打开失败——这个坑每年都有人踩,报错信息往往是空指针解引用,根本看不出来是路径问题。
2.3 framebuffer 映射:一行 mmap 之后,屏幕就是一块内存
这块板子最直观的地方在于,图形显示不需要任何图形库。Linux 把屏幕抽象成/dev/fb0这个字符设备,你把它 mmap 到进程地址空间,之后往那块内存里写一个 32 位整数,屏幕上对应的那一个像素就变了颜色。理解了这一点,电子相册、五子棋棋盘、界面按钮,全部退化成「往内存里填数」。
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <sys/mman.h> #include <sys/ioctl.h> #include <linux/fb.h> static int fb_fd = -1; static char *fb_mem = NULL; static struct fb_var_screeninfo vinfo; int fb_init(const char *dev){ fb_fd = open(dev, O_RDWR); if (fb_fd < 0) { perror("open fb"); return -1; } // 1. 拿到可变屏幕信息:分辨率、位深、每行字节数 if (ioctl(fb_fd, FBIOGET_VSCREENINFO, &vinfo) < 0) { perror("FBIOGET_VSCREENINFO"); return -1; } printf("res=%dx%d bpp=%d\n", vinfo.xres, vinfo.yres, vinfo.bits_per_pixel); // 2. 显存大小 = 宽 * 高 * 每像素字节数 long screensize = vinfo.xres * vinfo.yres * vinfo.bits_per_pixel / 8; // 3. 映射到用户空间,PROT_WRITE 一定要有 fb_mem = (char *)mmap(NULL, screensize, PROT_READ | PROT_WRITE, MAP_SHARED, fb_fd, 0); if (fb_mem == MAP_FAILED) { perror("mmap"); return -1; } return 0; } // 画一个点,32bpp 下每像素 4 字节,ARGB 排列 static inline void draw_pixel(int x, int y, unsigned int color){ long offset = (x + y * vinfo.xres) * (vinfo.bits_per_pixel / 8); *(unsigned int *)(fb_mem + offset) = color; }这段代码里每个参数都不是随手写的。FBIOGET_VSCREENINFO拿到的xres、yres是实际可见分辨率,bits_per_pixel决定了一个像素占几个字节——32bpp 下是 4 字节,16bpp 下是 2 字节,换算写错就是花屏。MAP_SHARED不能改成MAP_PRIVATE,否则你的修改只存在进程私有副本里,屏幕上什么都不会变,这个错误排查起来非常费时间。screensize里乘法要用long,800×480×4 还能塞进 int,1024×600×4 就快贴边了,换个更大的屏直接溢出。
vinfo.bits_per_pixel / 8在计算偏移时理论上应该用vinfo.xres_virtual(虚拟宽度)而不是xres,因为有些驱动会给每行做对齐填充。稳妥写法是先读一次FBIOGET_FSCREENINFO拿line_length,用y * line_length + x * (bpp/8)算偏移。我在最初的项目里就是因为用了xres,在某个分辨率下图像整体斜着撕裂,查了两天才发现是对齐问题。
3. 触摸屏与界面框架:把 input_event 拆成能用的坐标
屏幕能画之后,下一个问题是「怎么知道用户点了哪」。这块板子的 7 寸电容屏走的是 Linux 输入子系统,内核把每一次触摸都翻译成若干个input_event结构体,你从/dev/input/eventX里读出来就行。难点不在读,在于多点触控协议的上报顺序,以及读出来的原始坐标怎么映射到你的界面坐标系。
3.1 原始事件读取:结构体、阻塞读与事件类型
input_event三个字段:type表示事件大类,code表示具体是哪个轴,value是数值。触摸屏最常出现的是EV_ABS(绝对坐标)和EV_KEY(按下/抬起)。第一次接触摸屏,建议先写一个纯打印程序,把手指按下去的完整事件流打出来看一遍,比看任何文档都直观。
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <linux/input.h> int main(int argc, char **argv){ const char *dev = (argc > 1) ? argv[1] : "/dev/input/event0"; int fd = open(dev, O_RDONLY); if (fd < 0) { perror("open input"); return -1; } struct input_event ev; while (1) { // read 会阻塞,直到内核攒够一个完整事件 if (read(fd, &ev, sizeof(ev)) != sizeof(ev)) continue; // 时间戳事件没有信息量,直接跳过 if (ev.type == EV_SYN) continue; printf("type=0x%02x code=0x%03x value=%d\n", ev.type, ev.code, ev.value); } return 0; }type里最需要关注的是EV_ABS(0x03)、EV_KEY(0x01)、EV_SYN(0x00)。EV_SYN是同步事件,内核用它标记「一批事件到此结束」,你可以把它理解成一次完整触摸的边界。read是阻塞读,没有触摸时进程挂起不占 CPU,比轮询优雅得多。
设备节点号不一定就是event0,键盘、电源键、甚至板载的其他传感器都可能占用前面的节点。查的办法是cat /proc/bus/input/devices,看哪个设备名字里带 touch、名字对得上的那一个。
3.2 多点触控槽位:ABS_MT_SLOT 与坐标归一化
电容屏用的是多点触控协议 B,事件流长这样:先来一个ABS_MT_SLOT告诉你「接下来这组数据属于第几个手指」,再来ABS_MT_TRACKING_ID标记这根手指的身份,然后才是ABS_MT_POSITION_X/Y。手指抬起时,内核发一个tracking_id = -1。
如果你只处理ABS_MT_POSITION_X/Y而不看 slot,两根手指同时按下去,坐标会互相覆盖,表现为「点 A 位置结果 B 按钮被触发」。我在一个电子相册项目里就翻过这个车:滑动切图时手指没完全抬起就换位置,两个 slot 数据混在一起,图片疯狂跳页。
#define MAX_SLOT 5 static int slot_x[MAX_SLOT], slot_y[MAX_SLOT], slot_active[MAX_SLOT]; void handle_event(struct input_event *ev){ static int cur_slot = 0; if (ev->type == EV_ABS) { switch (ev->code) { case ABS_MT_SLOT: // 切换当前上报的手指 if (ev->value >= 0 && ev->value < MAX_SLOT) cur_slot = ev->value; break; case ABS_MT_TRACKING_ID: // -1 表示这根手指抬起 slot_active[cur_slot] = (ev->value != -1); break; case ABS_MT_POSITION_X: slot_x[cur_slot] = ev->value; break; case ABS_MT_POSITION_Y: slot_y[cur_slot] = ev->value; break; } } // 必须等 EV_SYN 到来,这一批数据才是完整的 else if (ev->type == EV_SYN && ev->code == SYN_REPORT) { // 在这里做坐标换算和事件分发,不要在上面的 switch 里做 to_screen(&slot_x[cur_slot], &slot_y[cur_slot]); } }EV_SYN之后再做业务处理是关键。事件是一条一条来的,X 和 Y 是两条独立事件,你在收到 X 的时候 Y 可能还是上一次的旧值。把处理时机统一放在同步事件之后,这一批数据才是自洽的。这一条改完之后,很多「偶尔点到错误位置」的偶发问题会直接消失。
坐标映射方面,input 上报的坐标范围和屏幕分辨率往往不一致。查范围用ioctl(fd, EVIOCGABS(ABS_MT_POSITION_X), &absinfo),拿到minimum和maximum,再按比例换算到屏幕像素。有些板子 Y 轴方向是反的,表现是「手指在下面,光标在上面」,这时候做一次max - value即可,不要硬改硬件配置。
3.3 界面状态机:不依赖图形库也能做出像样的交互
触摸坐标有了,接下来是「点这个位置该干什么」。教学项目里常见的情况是一上来就引入 Qt,结果编译一个 Qt 程序要配大半天环境,还没跑起来热情就没了。我的做法是先手写一个极简状态机:每一屏是一个函数指针,每个可点击区域是一个矩形加一个回调。
typedef struct { int x, y, w, h; void (*on_click)(void *arg); } button_t; typedef struct { button_t *btns; int count; void (*draw)(void); // 这一屏怎么画 int (*tick)(void); // 这一屏的定时逻辑,可空 } page_t; // 事件分发:从最上层页面开始匹配矩形,命中第一个就跳出 static void dispatch(const page_t *pg, int px, int py){ for (int i = 0; i < pg->count; i++) { button_t *b = &pg->btns[i]; if (px >= b->x && px < b->x + b->w && py >= b->y && py < b->y + b->h) { if (b->on_click) b->on_click(b); return; // 命中即止,避免多按钮重叠时误触 } } }结构很简单:page_t描述一屏,里面有按钮数组、一个绘制函数、一个定时函数。主循环干三件事——等触摸事件、分发、按需重绘。draw独立出来的好处是可以整体重绘,也可以做脏矩形优化;tick存在的意义是音乐播放的进度条、相册的自动轮播、游戏的角色移动这些不放触摸也要动的东西。
按钮匹配用return而不是break,是刻意的。按钮矩形一旦有交叠(比如半透明浮层),不加这一条会出现一次点击触发两个动作的诡异现象。半透明、圆角、渐变这些视觉上的东西,在 framebuffer 层面无非是像素混合,自己写个 alpha 混合函数二十行就够,比引入一个图形库划算得多。
4. V4L2 采集到 UDP 上传:一条完整的图像链路
摄像头这条链路是这类项目里最能体现深度的一段:从/dev/video0拿 YUYV 原始帧、在内存里做一次色彩空间转换、一部分画到屏幕上做预览、一部分压缩后通过 UDP 发出去。项目正文里提到的实训要求——mmap 显示、触摸屏控制、V4L2 编程、UDP 编程——本质上就是这一条链路拆开的四段。
4.1 V4L2 的 ioctl 顺序:顺序错了后面全错
V4L2 的学习成本主要在调用顺序上。这是一套有状态的设备接口,你必须按「查能力 → 设格式 → 申请缓冲 → 查询缓冲 → mmap → 入队 → 开流 → 出队 → 处理 → 重新入队」这个顺序走,跳一步或者颠倒了,返回的错误码往往还都是EINVAL,看不出问题在哪。
#include <linux/videodev2.h> #include <sys/ioctl.h> #include <sys/mman.h> #define BUF_CNT 4 struct vbuf { void *start; size_t len; }; int cam_open(const char *dev, int w, int h, struct vbuf bufs[BUF_CNT]){ int fd = open(dev, O_RDWR | O_NONBLOCK); if (fd < 0) { perror("open video"); return -1; } struct v4l2_capability cap; if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { // 1. 查能力 perror("QUERYCAP"); return -1; } if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, "not a capture device\n"); return -1; } struct v4l2_format fmt = {0}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = w; fmt.fmt.pix.height = h; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; // 板子上最常见 fmt.fmt.pix.field = V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { // 2. 设格式 perror("S_FMT"); return -1; } // 驱动可能不支持你要的分辨率,以它回填的为准 printf("driver gives %dx%d\n", fmt.fmt.pix.width, fmt.fmt.pix.height); struct v4l2_requestbuffers req = {0}; req.count = BUF_CNT; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) { // 3. 申请缓冲 perror("REQBUFS"); return -1; } for (int i = 0; i < BUF_CNT; i++) { struct v4l2_buffer b = {0}; b.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; b.memory = V4L2_MEMORY_MMAP; b.index = i; if (ioctl(fd, VIDIOC_QUERYBUF, &b) < 0) { // 4. 查缓冲 perror("QUERYBUF"); return -1; } bufs[i].len = b.length; bufs[i].start = mmap(NULL, b.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, b.m.offset); // 5. 映射 if (bufs[i].start == MAP_FAILED) { perror("mmap"); return -1; } if (ioctl(fd, VIDIOC_QBUF, &b) < 0) { // 6. 入队 perror("QBUF"); return -1; } } int type = V4L2_BUF_TYPE_VIDEO_CAPTURE; if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) { // 7. 开流 perror("STREAMON"); return -1; } return fd; }这个顺序里有两处最容易出问题。一是S_FMT之后必须读回驱动实际设置的宽高,很多摄像头不支持任意分辨率,会静默改成最接近的档位,你按自己传进去的宽高去算缓冲区大小,自然对不上。二是mmap的offset必须用b.m.offset,不能填 0,也不要用自己算出的偏移,这个字段是驱动给的。
O_NONBLOCK配上select是最省 CPU 的采集方式。VIDIOC_DQBUF在非阻塞模式下如果没有数据就返回EAGAIN,这时候 select 会告诉你什么时候可读。直接阻塞在DQBUF上也行,但那样没法同时处理触摸事件,界面会卡住。
采集循环里还有一个铁律:拿出来用的 buffer 必须用完就还回去。DQBUF取出 → 处理 →QBUF归还,这三个动作要成对出现。少还一次,可用缓冲数就少一个,跑几轮之后DQBUF永远返回EAGAIN,表现是「预览画面卡死」或者「采集几百帧就停」。这个现象很像驱动崩了,其实是你自己的程序漏还了缓冲。
4.2 YUYV 转 RGB:合并分支之后的遍历方式
YUYV 的排布是每四个字节表示两个像素:Y0 U Y1 V。转换的核心公式是固定的,不用推导,直接用。但遍历方式有讲究——既然是两个像素一组,循环就按 2 递增,内层一次处理两个输出像素,比逐字节判断分支快得多。
// YUYV -> ARGB8888,src 每个像素 2 字节,dst 每个像素 4 字节 void yuyv_to_argb(const unsigned char *src, unsigned int *dst, int npix){ for (int i = 0; i < npix / 2; i++) { int y0 = src[0], u = src[1], y1 = src[2], v = src[3]; int c = y0 - 16, d = u - 128, e = v - 128; int r = (298 * c + 409 * e + 128) >> 8; int g = (298 * c - 100 * d - 208 * e + 128) >> 8; int b = (298 * c + 516 * d + 128) >> 8; #define CLAMP(x) ((x) < 0 ? 0 : ((x) > 255 ? 255 : (x))) dst[0] = 0xff000000 | (CLAMP(r) << 16) | (CLAMP(g) << 8) | CLAMP(b); // 处理第二个像素,U/V 与第一个共用 c = y1 - 16; r = (298 * c + 409 * e + 128) >> 8; g = (298 * c - 100 * d - 208 * e + 128) >> 8; b = (298 * c + 516 * d + 128) >> 8; dst[1] = 0xff000000 | (CLAMP(r) << 16) | (CLAMP(g) << 8) | CLAMP(b); src += 4; dst += 2; } }那些系数来自 BT.601 的定点化实现,右移 8 位相当于除以 256,加 128 是四舍五入。CLAMP不能省,YUV 里的值稍有越界,转出来就会在画面高光或者暗部出现彩色噪点,看起来像摄像头坏了,其实是转换没做饱和处理。0xff000000是 alpha 通道置全不透明,32bpp 的 framebuffer 如果 alpha 位是 0,某些驱动上画出来是全黑,这个坑我踩过一次,盯着代码看了半天才反应过来。
在 1024×600 的分辨率上,每帧 60 多万像素,这个转换在八核 A53 上单核跑大概几毫秒,做 15fps 的预览完全够用。真要做 30fps,可以考虑用 NEON 指令做四路并行,或者干脆把分辨率降下来再放大——对教学项目来说没必要。
4.3 UDP 分包:一个不够大的包,网络会直接帮你丢掉
把一帧图像通过 UDP 发出去,必须自己分包。一帧 1024×600 的 RGB 数据接近 2MB,而以太网 MTU 通常 1500 字节,扣掉 IP 和 UDP 头,实际载荷最多 1472 字节左右。超过这个大小,IP 层会做分片,而任何一跳设备的丢片都会导致整包作废——UDP 本身又不重传,结果就是接收端永远收不齐。
#pragma pack(push, 1) typedef struct { unsigned int frame_id; // 帧号,用来区分不同帧的碎片 unsigned short seq; // 本帧内的第几个分片 unsigned short total; // 本帧一共多少分片 unsigned int payload_len; // 本片实际数据长度 } pkt_hdr_t; #pragma pack(pop) // 每片载荷控制在 1400 以下,给 IP/UDP 头留出余量 #define PAYLOAD_MAX 1400 void send_frame(int sock, const struct sockaddr_in *dst, const unsigned char *frame, int frame_len, int frame_id){ int total = (frame_len + PAYLOAD_MAX - 1) / PAYLOAD_MAX; unsigned char buf[sizeof(pkt_hdr_t) + PAYLOAD_MAX]; for (int i = 0; i < total; i++) { int off = i * PAYLOAD_MAX; int len = (frame_len - off > PAYLOAD_MAX) ? PAYLOAD_MAX : (frame_len - off); pkt_hdr_t h = { frame_id, (unsigned short)i, (unsigned short)total, (unsigned int)len }; memcpy(buf, &h, sizeof(h)); memcpy(buf + sizeof(h), frame + off, len); sendto(sock, buf, sizeof(h) + len, 0, (const struct sockaddr *)dst, sizeof(*dst)); } }#pragma pack(1)是为了让结构体按字节紧密排列,不加的话编译器会按 4 字节对齐插填充字节,PC 端和板子端如果编译选项不同,结构体的实际大小可能不一致,解析出来的字段全是乱的。
接收端的逻辑和这里反向:用一个固定大小的二维数组按frame_id存放分片,收到一片就标记一位,total个都到齐了再拼成一帧送去解码。要处理的关键情况有两个——同一帧的重复片(后到的覆盖先到的)、以及长时间收不齐的残帧(设一个超时,超时直接丢弃并记录丢帧率)。没有这套重组逻辑,接收端拿到的就是一堆没法用的碎片。
5. 避坑与排查:板子跑不起来时,先看这五处
这一节是我把这个平台上的项目做了若干轮之后,按出现频率排出来的问题清单。每一条都给出「现象 → 原因 → 解决」的完整路径,遇到问题按顺序对照,能省掉大部分盲猜时间。
5.1 现象:程序在板子上直接 Segmentation fault,连第一行 printf 都没出
原因通常是两个。一是交叉编译时链接了开发机上的动态库,板上找不到对应版本的符号;二是程序访问了未映射的地址,比如 framebuffer 的mmap返回MAP_FAILED但代码里没检查就往下写。
解决:先加-static重新编译一版,如果静态版能跑,问题就在库上,用arm-linux-readelf -d看依赖,逐个ldd核对板上是否存在。如果静态版同样崩,就是代码逻辑问题,用gdb的板端版本配合串口跑一次,或者在最前面插一句write(1, "x", 1)——注意是write不是printf,printf有缓冲,崩溃时可能根本没刷出去。
5.2 现象:屏幕能亮,但画面像是被斜着撕开,图像整体向左上偏移
原因是像素偏移计算用了vinfo.xres而不是实际的行跨度。很多 framebuffer 驱动会按 16 或 32 字节对齐每一行,xres_virtual会大于xres,按xres算出来的偏移从第二行开始就偏了。
解决:先ioctl(fb_fd, FBIOGET_FSCREENINFO, &finfo),用finfo.line_length替代xres * (bpp / 8)作为一行的字节数。改完这一处,斜切和偏移会同时消失。这个错误的迷惑性在于「第一行是对的」,容易让人怀疑是数据源问题。
5.3 现象:触摸能触发,但点击位置和实际手指位置差了一段,越靠边差得越多
原因是坐标没有做归一化。input 上报的绝对坐标范围通常是 0~4095 之类的原始值,而不是屏幕像素值,直接拿去和像素坐标比较,自然对不上。
解决:ioctl(fd, EVIOCGABS(ABS_MT_POSITION_X), &abs)拿到abs.minimum和abs.maximum,用px = (raw - min) * screen_w / (max - min)换算。如果 X 对了 Y 反了,把 Y 换成screen_h - py。注意每根手指的 slot 坐标要各自换算,不要只在全局变量上算一次。
5.4 现象:V4L2 采集在VIDIOC_S_FMT或者VIDIOC_REQBUFS上报 Invalid argument
原因三种:设备节点选错了(/dev/video0可能是另一个不支持采集的节点);像素格式驱动不支持(不是所有摄像头都出 YUYV,有的只出 MJPEG);分辨率不在驱动支持的列表里。
解决:先用v4l2-ctl --list-formats-ext -d /dev/video0(板子上没有的话在开发机上插同一个摄像头看)确认可用格式和分辨率,把请求参数改成列表里的某一个。不要指望S_FMT会帮你自动挑最接近的,它只会失败或者悄悄改掉,改掉之后你按原尺寸算缓冲区一样出问题。
5.5 现象:NFS 挂载成功,程序也能跑,但在挂载目录里写文件失败或极慢
原因是nolock没加或者 NFS 版本不匹配。另一种情况是板子上的时间和开发机差太多,某些 NFS 实现会因此拒绝写操作。
解决:挂载参数固定为-o nolock,vers=3,并且先手动date同步一次时间。如果只是传输文件不需要写,改用只读挂载-o ro反而更稳,能避免板子写坏开发机上的文件——这个功能很容易被误用,板上程序fopen一个相对路径的文件用"w"模式,可能把开发机上的源文件清空。
6. 收进一个 Makefile:模块拆分与上板前自检
手敲编译命令的阶段过去之后,第一件要做的事是把所有源文件收进一个 Makefile。这个平台上的项目天然适合拆成几块:fb.c管显示、touch.c管输入、camera.c管 V4L2、net.c管 UDP、ui.c管界面、main.c做状态机。拆开的好处是改触摸逻辑的时候不会把显示逻辑带崩,而且编译时间从全量变成增量。
# 交叉工具链前缀,换板子只改这一行 CROSS = arm-linux- CC = $(CROSS)gcc CFLAGS = -Wall -O2 -I./include LDFLAGS = TARGET = app SRCS = $(wildcard src/*.c) OBJS = $(SRCS:.c=.o) $(TARGET): $(OBJS) $(CC) -o $@ $^ $(LDFLAGS) # 头文件依赖用 -MMD 自动生成,省得手写一长串 CFLAGS += -MMD -include $(OBJS:.o=.d) %.o: %.c $(CC) $(CFLAGS) -c -o $@ $< # 一条命令完成编译 + 静态检查 + 部署 deploy: $(TARGET) file $(TARGET) arm-linux-strip $(TARGET) cp $(TARGET) /mnt/nfs/ @echo "run: cd /mnt/nfs && ./$(TARGET)"-MMD加-include这两行是让编译器顺手把头文件依赖写成.d文件,改一个.h之后相关的.o会自动重编。没有这个,改了结构体定义忘了全量重编,会出现「某些文件用的是新结构体、某些还是旧的」这种最难查的链接期问题。
deploy里放file和strip两个动作是一个习惯:file用来兜底检查产物架构对不对,strip去掉调试符号把体积压下来。在 NFS 挂载的环境下,strip之后拷贝过去的体积通常能小一半以上,加载速度也快。
上板前的自检我固定跑三项:一是file确认是 ARM 架构;二是arm-linux-readelf -d看依赖库列表里没有开发机特有的路径;三是先跑一次最简的「打开 fb、画一个纯色矩形、退出」,确认显示通路没被其他模块拖累。这三步加起来不到一分钟,但能挡掉大部分「编译成功了却跑不起来」的情况。
从那以后我每次开新项目,都会先把这三项自检写进deploy目标里,强制走一遍再往下写业务。因为这三种问题一旦混进功能代码里,排查成本会翻好几倍,而它们本身一分钟就能验完。希望帮到你。
本文还有配套的精品资源,点击获取