基于 RK3568 的车载中控系统设计与实现
基于 RK3568 的车载中控系统开发实践
这篇文章记录了一个基于 RK3568 开发板的车载中控系统开发过程。项目整体以 Linux + LVGL 为核心,结合 framebuffer 显示、evdev 触摸输入、V4L2 摄像头采集、网络通信和多媒体播放等模块,完成了一套具备主界面、AI 助手、音乐播放、天气展示、相册浏览、摄像头显示和车控功能的车机系统。本文重点介绍系统架构、关键代码、网络模块、AI 通信和摄像头采集逻辑,供嵌入式 Linux 和车载界面开发学习参考。
1. 项目背景
随着汽车智能化和车机交互体验的提升,传统车载显示终端已经不能仅仅承担“显示信息”的功能。用户越来越关注车机系统的交互体验、功能扩展性和信息整合能力。车载中控系统通常需要具备以下能力:
- 多功能主界面展示
- 触摸交互
- 多媒体播放
- 语音控制
- 网络信息获取
- 摄像头监控与图像显示
- 车载控制和辅助功能
本项目基于 RK3568 平台开发了一套车载中控系统,目标是构建一个适合嵌入式 Linux 环境下运行的车机界面,并通过 LVGL 实现高效的用户交互体验。整个系统强调模块化设计、易扩展和工程化管理,适合后续继续扩展为更完整的智能座舱方案。
2. 技术选型
2.1 平台
- 开发板:RK3568
- 操作系统:Linux
- 图形库:LVGL
- 显示方式:Linux framebuffer
- 输入方式:evdev
- 字体:FreeType + 中文字体
- 图像处理:JPEG
- 音频播放:madplay
- 语音识别:讯飞
- 网络:UDP / TCP
2.2 架构设计
整个系统可以分为四层:
-
硬件层
- RK3568 开发板
- LCD 显示屏
- 触摸屏
- 摄像头
- 音频输出
- 网络接口
-
Linux 设备适配层
- framebuffer 显示
- input 设备采集
- V4L2 摄像头采集
- 网络通信
- 文件系统访问
-
界面与业务层
- 主菜单
- AI 助手
- 音乐播放
- 天气查询
- 相册浏览
- 车辆控制
- 救援与远程控制模块
-
应用控制层
- 页面状态管理
- 事件回调
- 多线程控制
- 资源清理和状态同步
3. 项目目录结构
project-LVGL/
├── CMakeLists.txt
├── README.md
├── 64bit_make.sh
├── src/
│ ├── main.c
│ ├── main_menu.c
│ ├── lvgl_port.c
│ ├── music.c
│ ├── camera.c
│ ├── picture.c
│ ├── wthtime_show.c
│ ├── ai_chat.c
│ ├── traffic.c
│ ├── help.c
│ ├── cont_car.c
│ └── ...
├── include/
│ ├── lv_conf.h
│ ├── lv_drv_conf.h
│ ├── lvgl_port.h
│ ├── main_menu.h
│ ├── ai_chat.h
│ ├── music.h
│ └── ...
├── lvgl/
├── lv_drivers/
├── libs/
├── build/
├── gif/
├── bin/
└── ...
4. 系统主程序设计
主程序负责初始化 LCD、触摸屏、LVGL,以及创建多个后台线程,用于处理输入、网络和摄像头等任务。下面是主程序中关键的初始化代码:
#include "lcd.h"
#include "touchpad.h"
#include "camera.h"
#include "jpeg_display.h"
#include "net.h"
#include "wchat.h"
#include "wthtime_show.h"
#include "ai_chat.h"
#include "lvgl_port.h"
#include "music.h"
#include "traffic.h"
#include "main_menu.h"
#include "number_send.h"
#include "picture.h"
#include "help.h"
#include "cont_car.h"
#include <pthread.h>
#include <stdbool.h>
int touchpad_fd = 0;
int ts_x, ts_y;
volatile int sign = 0;
volatile bool sign_camera = true;
volatile bool sign_video = false;
volatile bool sign_line = false;
volatile bool ui_is_menu = false;
volatile bool sign_wx = false;
volatile bool sign_call = true;
volatile bool ai_chat_started = false;
LcdInfo_t *lcd = NULL;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
接着创建线程来处理不同任务:
pthread_t net_get_thread_id;
pthread_create(&net_get_thread_id, NULL, net_get, NULL);
pthread_t net_out_thread_id;
pthread_create(&net_out_thread_id, NULL, net_out, NULL);
pthread_t touchpad_thread_id;
pthread_create(&touchpad_thread_id, NULL, touchpad_task, NULL);
pthread_t camera_thread_id;
pthread_create(&camera_thread_id, NULL, touchpad_camera, lcd->mp);
pthread_t wchat_thread_id;
pthread_create(&wchat_thread_id, NULL, wchat, lcd->mp);
pthread_t tcp_ctl_thread_id;
pthread_create(&tcp_ctl_thread_id, NULL, tcp_ctl, NULL);
这些线程分别处理:
- 网络数据接收
- 网络数据发送
- 触摸事件读取
- 摄像头采集与显示
- 视频通话
- TCP 控制命令
主循环中,页面切换通过一个全局状态变量 sign 来控制。不同页面对应不同功能,逻辑如下:
for (;;)
{
if (sign_tcp > 0)
{
int target = 0;
if (sign_tcp == 2) { target = 1; sign_camera = false; }
else if (sign_tcp == 3) target = 2;
else if (sign_tcp == 4) { target = 3; sign_wx = true; }
else if (sign_tcp == 5) target = 4;
else if (sign_tcp == 6) target = 5;
else if (sign_tcp == 7) target = 6;
else if (sign_tcp == 8) target = 7;
else if (sign_tcp == 9) target = 8;
else if (sign_tcp == 10) target = 9;
else if (sign_tcp == 1) target = 0;
if (target != sign)
{
printf("[main] 跳转请求 sign_tcp=%d -> sign=%d\n", sign_tcp, target);
sign = target;
}
sign_tcp = 0;
}
if (prev_sign == 0 && sign != 0)
{
main_menu_exit();
}
if (prev_sign == 6 && sign != 6) music_exit();
if (prev_sign == 7 && sign != 7) traffic_exit();
if (prev_sign == 2 && sign != 2) picture_exit();
if (prev_sign == 8 && sign != 8) help_exit();
if (prev_sign == 9 && sign != 9) cont_car_exit();
prev_sign = sign;
if (sign == 0 || sign == 2 || sign == 4 || sign == 5 || sign == 6 || sign == 7 || sign == 8 || sign == 9)
lvgl_touch_enable();
else
lvgl_touch_disable();
if (sign == 0)
{
main_menu();
lv_timer_handler();
ui_is_menu = false;
}
}
这里的设计体现了一个很典型的嵌入式 UI 状态机思路:状态变量决定当前界面,页面切换前进行资源清理,避免 UI 层对象叠加导致误触和画面残留。
5. LVGL 与底层适配
LVGL 是整个项目中最关键的组件之一,它负责界面的渲染、事件处理和控件显示。为了让它在 Linux 平台上正常工作,需要把 framebuffer 和 evdev 接入 LVGL。
5.1 LVGL 初始化代码
#include "lvgl_port.h"
#include "lvgl/src/extra/libs/freetype/lv_freetype.h"
lv_font_t *font_han_tr = NULL;
lv_font_t *font_han = NULL;
lv_font_t *font_han_large = NULL;
lv_font_t *font_han_wea = NULL;
static lv_indev_t *touch_indev = NULL;
uint32_t custom_tick_get(void)
{
struct timeval tv;
gettimeofday(&tv, NULL);
return (uint32_t)((tv.tv_sec * 1000) + (tv.tv_usec / 1000));
}
void lvgl_port_init(void)
{
lv_init();
fbdev_init();
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf[800 * 480];
lv_disp_draw_buf_init(&draw_buf, buf, NULL, 800 * 480);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = 800;
disp_drv.ver_res = 480;
disp_drv.flush_cb = fbdev_flush;
disp_drv.draw_buf = &draw_buf;
lv_disp_drv_register(&disp_drv);
evdev_init();
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = evdev_read;
touch_indev = lv_indev_drv_register(&indev_drv);
static lv_ft_info_t ft_info_tr;
ft_info_tr.name = "./SIMHEI.TTF";
ft_info_tr.mem = NULL;
ft_info_tr.mem_size = 0;
ft_info_tr.weight = 16;
ft_info_tr.style = FT_FONT_STYLE_NORMAL;
if (lv_ft_font_init(&ft_info_tr)) {
font_han_tr = ft_info_tr.font;
}
static lv_ft_info_t ft_info;
ft_info.name = "./SIMHEI.TTF";
ft_info.mem = NULL;
ft_info.mem_size = 0;
ft_info.weight = 24;
ft_info.style = FT_FONT_STYLE_NORMAL;
if (lv_ft_font_init(&ft_info)) {
font_han = ft_info.font;
}
static lv_ft_info_t ft_info_large;
ft_info_large.name = "./SIMHEI.TTF";
ft_info_large.mem = NULL;
ft_info_large.mem_size = 0;
ft_info_large.weight = 36;
ft_info_large.style = FT_FONT_STYLE_NORMAL;
if (lv_ft_font_init(&ft_info_large)) {
font_han_large = ft_info_large.font;
}
static lv_ft_info_t ft_info_wea;
ft_info_wea.name = "./SIMHEI.TTF";
ft_info_wea.mem = NULL;
ft_info_wea.mem_size = 0;
ft_info_wea.weight = 72;
ft_info_wea.style = FT_FONT_STYLE_NORMAL;
if (lv_ft_font_init(&ft_info_wea)) {
font_han_wea = ft_info_wea.font;
}
}
这部分代码展示了一个比较标准的 LVGL 嵌入式移植方式:
lv_init()初始化 LVGLfbdev_init()接入 LCD 显示设备evdev_init()接入触摸输入设备lv_ft_font_init()加载中文字体lv_disp_drv_register()注册显示驱动lv_indev_drv_register()注册输入设备
5.2 触摸输入管理
因为不同页面使用不同的输入方式,项目中设计了一个比较关键的机制:输入源分时复用。
if (sign == 0 || sign == 2 || sign == 4 || sign == 5 || sign == 6 || sign == 7 || sign == 8 || sign == 9)
lvgl_touch_enable();
else
lvgl_touch_disable();
这样做的意义在于:
- LVGL 页面使用 evdev 触摸事件
- 摄像头或视频通话页面不直接争抢 LVGL 触摸资源
- 避免多个页面同时操作同一个输入设备导致事件混乱
6. AI 助手模块:报文发送与 TCP 交互
AI 助手是车载中控系统中的一个重点功能,通常需要把用户语音或文本数据发送到后端服务,获取回答后再在车机界面展示或播报。
6.1 报文封装思路
项目中的 AI 通信一般采用字符串型协议,常见写法是键值对或 JSON。用户提问发到后台,后台返回结果,再由上层界面渲染。
一个简单的报文格式可以是:
type=ai&from=board&msg=今天天气怎么样
也可以是:
{"type":"ai","device":"rk3568","text":"今天天气怎么样"}
在嵌入式 Linux 上,字符串协议通常更简单,编码更容易控制,也更适合资源受限环境。
6.2 关键代码:AI 报文封装与发送
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERVER_IP "192.168.33.102"
#define SERVER_PORT 8080
int send_ai_request(const char *text)
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return -1;
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sockfd);
return -1;
}
char request[1024] = {0};
snprintf(request, sizeof(request),
"type=ai&msg=%s&from=board&device=rk3568\r\n",
text);
send(sockfd, request, strlen(request), 0);
char response[2048] = {0};
recv(sockfd, response, sizeof(response) - 1, 0);
printf("AI response: %s\n", response);
close(sockfd);
return 0;
}
这里的实现思路是:
- 组装请求报文
- 建立 TCP 连接
- 发送请求
- 接收服务端返回值
- 在 UI 层渲染结果
如果需要更规范的实现,还可以增加请求头、会话 ID、设备编号等字段,比如:
char payload[1024] = {0};
snprintf(payload, sizeof(payload),
"{\"type\":\"ai\",\"device_id\":\"rk3568\",\"text\":\"%s\",\"session\":\"board1\"}",
text);
6.3 AI 请求线程
AI 请求往往在后台线程中处理,避免阻塞 UI 主线程。
void *ai_worker(void *arg)
{
char *text = (char *)arg;
send_ai_request(text);
free(text);
return NULL;
}
void trigger_ai_query(const char *text)
{
char *copy = strdup(text);
pthread_t tid;
pthread_create(&tid, NULL, ai_worker, copy);
pthread_detach(tid);
}
这样设计的优点很明显:
- UI 线程不被网络阻塞
- AI 模块独立运行
- 语音、文本、问答等功能可以统一走同一条链路
7. TCP 连接:车机与后台服务通信
TCP 在车机项目中非常重要,通常用于稳定的控制指令和消息传输,比如:
- AI 助手后台通信
- 远程控制命令
- 设备状态上报
- 服务器端指令下发
相比 UDP,TCP 更适合文本消息和控制指令,因为丢包和乱序控制相对更容易处理。
7.1 TCP 连接的关键代码
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
static int tcp_fd = -1;
int tcp_connect_server(const char *ip, int port)
{
tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
if (tcp_fd < 0) {
perror("socket");
return -1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, ip, &addr.sin_addr);
if (connect(tcp_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
close(tcp_fd);
tcp_fd = -1;
return -1;
}
return tcp_fd;
}
int tcp_send_msg(const char *msg)
{
if (tcp_fd < 0) {
return -1;
}
int len = (int)strlen(msg);
int sent = send(tcp_fd, msg, len, 0);
if (sent < 0) {
perror("send");
close(tcp_fd);
tcp_fd = -1;
return -1;
}
return sent;
}
int tcp_recv_msg(char *buf, size_t size)
{
if (tcp_fd < 0) {
return -1;
}
memset(buf, 0, size);
int ret = recv(tcp_fd, buf, size - 1, 0);
if (ret <= 0) {
close(tcp_fd);
tcp_fd = -1;
return -1;
}
return ret;
}
7.2 长连接线程
在工程里,TCP 通常运行在后台线程中,维持长连接并循环接收命令。
void *tcp_client_thread(void *arg)
{
const char *server_ip = "192.168.33.102";
int server_port = 8080;
while (1) {
if (tcp_connect_server(server_ip, server_port) < 0) {
sleep(3);
continue;
}
char buf[1024];
while (1) {
int ret = tcp_recv_msg(buf, sizeof(buf));
if (ret < 0) {
break;
}
printf("recv: %s\n", buf);
// 这里可以处理命令,如 AI 返回、页面切换、状态上报等
// handle_command(buf);
}
sleep(1);
}
return NULL;
}
由于车机系统需要稳定运行,TCP 模块通常还需要处理以下细节:
- 断线重连
- 心跳保活
- 长消息拆包与拼包
- 指令分发和状态同步
- 单独线程处理,避免阻塞 UI
8. 天气模块:HTTP 请求和 JSON 解析
天气模块是车机系统中一个很典型的“网络信息展示”功能,通常做法是通过 HTTP 请求天气接口,然后把结果展示在主界面或天气页。
8.1 天气接口请求代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define WEATHER_HOST "api.xxx.com"
#define WEATHER_PORT 80
int get_weather_data(const char *city, char *out_buf, size_t out_size)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
perror("socket");
return -1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(WEATHER_PORT);
struct hostent *host = gethostbyname(WEATHER_HOST);
if (!host) {
perror("gethostbyname");
close(sock);
return -1;
}
memcpy(&addr.sin_addr, host->h_addr_list[0], host->h_length);
if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
close(sock);
return -1;
}
char request[512];
snprintf(request, sizeof(request),
"GET /weather?city=%s HTTP/1.1\r\n"
"Host: %s\r\n"
"Connection: close\r\n\r\n",
city, WEATHER_HOST);
send(sock, request, strlen(request), 0);
char recv_buf[4096] = {0};
int total = 0;
while (1) {
int n = recv(sock, recv_buf + total, sizeof(recv_buf) - total - 1, 0);
if (n <= 0) break;
total += n;
}
close(sock);
char *body = strstr(recv_buf, "\r\n\r\n");
if (body) {
body += 4;
snprintf(out_buf, out_size, "%s", body);
return 0;
}
return -1;
}
8.2 简单 JSON 解析
接口返回的是 JSON 数据。车机系统中为了减少资源消耗,很多项目不会直接引入复杂的 JSON 库,而是做字段匹配:
int parse_weather_json(const char *json, char *temp, char *weather)
{
const char *temp_pos = strstr(json, "\"temp\"");
const char *weather_pos = strstr(json, "\"weather\"");
if (!temp_pos || !weather_pos) {
return -1;
}
char tmp[64];
sscanf(temp_pos, "\"temp\":\"%63[^\"]\"", tmp);
sscanf(weather_pos, "\"weather\":\"%63[^\"]\"", weather);
snprintf(temp, 32, "%s", tmp);
return 0;
}
8.3 业务层调用
void refresh_weather_ui(void)
{
char weather_json[4096];
if (get_weather_data("深圳", weather_json, sizeof(weather_json)) == 0) {
char temp[32] = {0};
char weather[32] = {0};
if (parse_weather_json(weather_json, temp, weather) == 0) {
printf("当前温度: %s, 天气: %s\n", temp, weather);
// 更新 LVGL 页面
// lv_label_set_text(label_temp, temp);
// lv_label_set_text(label_weather, weather);
}
}
}
天气模块有以下几个工程价值:
- 车机系统与云端服务融合
- UI 展示实时信息
- 体现车载产品实用性
- 对网络稳定性和解析逻辑要求较高
通常还会把天气模块封装成独立接口,主界面直接调用。
9. V4L2 摄像头采集逻辑
V4L2 是 Linux 下最常见的视频采集接口,适用于 USB 摄像头、嵌入式摄像头等设备。车机项目中,V4L2 通常用于:
- 实时图像显示
- 拍照
- 视频录制
- 远程图传
- AI 图像识别
### 9.1 打开设备并配置格式
```c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/videodev2.h>
#define CAMERA_DEV "/dev/video7"
int open_camera(int *fd)
{
*fd = open(CAMERA_DEV, O_RDWR | O_NONBLOCK, 0);
if (*fd < 0) {
perror("open camera");
return -1;
}
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_INTERLACED;
if (ioctl(*fd, VIDIOC_S_FMT, &fmt) < 0) {
perror("VIDIOC_S_FMT");
close(*fd);
return -1;
}
return 0;
}
9.2 申请缓冲区和 mmap
int init_mmap_buffer(int fd, struct v4l2_buffer *buf, unsigned char **buffer, int *buffer_len)
{
struct v4l2_requestbuffers req;
memset(&req, 0, sizeof(req));
req.count = 4;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) {
perror("VIDIOC_REQBUFS");
return -1;
}
struct v4l2_buffer vbuf;
memset(&vbuf, 0, sizeof(vbuf));
vbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
vbuf.memory = V4L2_MEMORY_MMAP;
vbuf.index = 0;
if (ioctl(fd, VIDIOC_QUERYBUF, &vbuf) < 0) {
perror("VIDIOC_QUERYBUF");
return -1;
}
*buffer = mmap(NULL, vbuf.length, PROT_READ | PROT_WRITE, MAP_SHARED,
fd, vbuf.m.offset);
if (*buffer == MAP_FAILED) {
perror("mmap");
return -1;
}
*buffer_len = vbuf.length;
*buf = vbuf;
return 0;
}
9.3 启动采集和循环抓帧
int start_capture(int fd)
{
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
struct v4l2_buffer buf;
memset(&buf, 0, sizeof(buf));
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = 0;
if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) {
perror("VIDIOC_QBUF");
return -1;
}
if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) {
perror("VIDIOC_STREAMON");
return -1;
}
return 0;
}
在循环中,通常要执行:
if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) {
perror("VIDIOC_DQBUF");
return -1;
}
然后进行图像处理,处理完成后重新入队,用于下一帧采集。
9.4 YUYV 转 RGB
摄像头输出常见格式是 YUYV,而显示和图像处理通常需要 RGB 数据,因此需要进行格式转换。
void yuyv_to_rgb(uint8_t *yuyv, uint8_t *rgb, int width, int height)
{
int i, j;
for (i = 0; i < height; i++) {
for (j = 0; j < width; j += 2) {
int index = (i * width + j) * 2;
int y0 = yuyv[index];
int u = yuyv[index + 1];
int y1 = yuyv[index + 2];
int v = yuyv[index + 3];
int r0 = y0 + 1.402 * (v - 128);
int g0 = y0 - 0.344136 * (u - 128) - 0.714136 * (v - 128);
int b0 = y0 + 1.772 * (u - 128);
int r1 = y1 + 1.402 * (v - 128);
int g1 = y1 - 0.344136 * (u - 128) - 0.714136 * (v - 128);
int b1 = y1 + 1.772 * (u - 128);
rgb[(i * width + j) * 3] = clamp(r0);
rgb[(i * width + j) * 3 + 1] = clamp(g0);
rgb[(i * width + j) * 3 + 2] = clamp(b0);
rgb[(i * width + j + 1) * 3] = clamp(r1);
rgb[(i * width + j + 1) * 3 + 1] = clamp(g1);
rgb[(i * width + j + 1) * 3 + 2] = clamp(b1);
}
}
}
static inline unsigned char clamp(int value)
{
if (value < 0) return 0;
if (value > 255) return 255;
return (unsigned char)value;
}
这个步骤对车机系统非常关键,因为:
- 采集原始数据是 YUV/YUYV
- LCD 和 LVGL 显示要求 RGB 或 ARGB
- 后续图像处理、JPEG 编码、远程图传都依赖这一层变换
9.5 保存 JPEG
项目中,摄像头采集到的 YUYV 数据会先转换为 RGB,再通过 libjpeg 保存成 JPEG 图片。这里的编码逻辑来自工程中的 jpeg_save.c:220-277,属于实际开发中使用到的核心代码。
//处理图像
void write_JPEG_file ( char * filename, //传入文件名
int quality, //图片的质量,,0-100越高越好,推荐75
JSAMPLE *image_buffer) //存储数据的缓冲区
{
//创建编码对象--cinof
struct jpeg_compress_struct cinfo;
//创建错误处理对象--jerr
struct jpeg_error_mgr jerr;
JSAMPROW row_pointer[1]; //创建缓存指针
int row_stride; //创建变量记录行缓存大小
//编码对象绑定错误处理对象
cinfo.err = jpeg_std_error(&jerr);
//初始化编码对象
jpeg_create_compress(&cinfo);
//二进制创建并打开图片文件,返回句柄outfile
if ((outfile = fopen(filename, "wb")) == NULL)
{
fprintf(stderr, "can't open %s\n", filename);
jpeg_destroy_compress(&cinfo);
return;
}
//绑定输出文件流,让 JPEG 压缩结果写入 outfile
jpeg_stdio_dest(&cinfo, outfile);
//编码对象的信息,宽,高,一个像素的字节,色深RGB格式
cinfo.image_width = CAMERA_FRAME_W; //宏定义传入要数据的宽
cinfo.image_height = CAMERA_FRAME_H; //宏定义传入要数据的高
cinfo.input_components = 3;
cinfo.in_color_space = JCS_RGB;
//设置参数,一般默认
jpeg_set_defaults(&cinfo);
//设置质量要求,没要求随便设置
jpeg_set_quality(&cinfo, quality, TRUE);
//开始编码
jpeg_start_compress(&cinfo, TRUE);
//计算行缓冲字节大小
row_stride = CAMERA_FRAME_W * 3;
while (cinfo.next_scanline < cinfo.image_height) //图片高,循环高的次数
{
row_pointer[0] = &image_buffer[cinfo.next_scanline * row_stride];
(void) jpeg_write_scanlines(&cinfo, row_pointer, 1);
}
//调用完成函数完成编码
jpeg_finish_compress(&cinfo);
//关闭图片
fclose(outfile);
//释放资源
jpeg_destroy_compress(&cinfo);
}
这段代码的核心价值在于:
使用 libjpeg 对 RGB 图像进行压缩编码
设置 JPEG 质量参数 quality
按照行扫描方式写入图像数据
以 CAMERA_FRAME_W 和 CAMERA_FRAME_H 作为固定分辨率,适配本项目的摄像头采集输出
这里的 image_buffer 通常来自 YUYV 转 RGB 后的缓冲区,随后可用于:
- 拍照保存
- 远程图像上传
- 图片识别与回传
- 图像留档记录
## 10. 功能模块设计
### 10.1 主菜单
主菜单是系统入口,通常承担以下职责:
- 展示时间和日期
- 显示天气
- 显示音乐播放状态
- 放置多个功能入口
- 作为页面调度中心
主菜单的设计重点是“信息展示简洁 + 功能入口清晰”,让用户在短时间内找到所需功能。
---
### 10.2 AI 助手
AI 助手模块支持:
- 语音录入
- 文字问答
- TTS 播报
- 唤醒词识别
- 页面跳转控制
例如:
- “小美小美,打开音乐”
- “小美小美,打开天气”
- “小美小美,返回主页”
这类能力让车机系统更像一个智能终端。
---
### 10.3 音乐播放器
音乐模块支持:
- 播放/暂停
- 上一首/下一首
- 音量调节
- 列表浏览
- 歌曲信息显示
- 进度条和播放时间显示
项目中采用 `madplay` 作为底层播放工具,UI 层通过状态同步更新歌曲信息。
```c
// 伪代码
pid = fork();
if (pid == 0) {
execlp("madplay", "madplay", song_path, NULL);
}
10.4 相册
相册模块负责图片和视频浏览,通常包括:
- 目录扫描
- 图片缩放显示
- 视频帧浏览
- 大图查看
10.5 天气模块
天气模块通过网络请求获取实时天气数据和未来几天预报,并在 UI 上展示:
- 温度
- 天气状态
- 适当的图标和文本说明
10.6 摄像头显示与视频采集
项目中还包括 V4L2 摄像头采集逻辑,用于实时显示视频画面,并支持:
- 实时显示
- 录像
- 辅助线开关
- 退出和切换
这使得整个系统不仅是“中控界面”,也具备了图像采集和监控场景扩展潜力。
11. 工程实践中的关键问题
11.1 触摸冲突问题
这是车机项目里非常典型的问题:不同功能页面可能使用不同的交互方式,而手持设备往往只有一个输入设备。项目中通过启停 LVGL 触摸源来解决这一问题,保证输入事件只分配给当前有效页面。
11.2 页面切换中的对象残留
在复杂 UI 系统中,页面切换时常常出现对象残留和误触问题,尤其是 LVGL 控件没有及时删除时更容易发生。项目中通过显式清理旧页面对象来解决这类问题,例如:
if (prev_sign == 0 && sign != 0)
{
main_menu_exit();
}
以及:
if (prev_sign == 6 && sign != 6) music_exit();
if (prev_sign == 7 && sign != 7) traffic_exit();
if (prev_sign == 2 && sign != 2) picture_exit();
这里的关键原则是:页面切换前,就要释放旧页面占用的资源,避免新页面和旧页面叠加。
11.3 多线程管理
车机项目中通常同时存在以下线程:
- 界面线程
- 网络收发线程
- 摄像头采集线程
- 音乐播放线程
- 语音处理线程
这类多线程设计的难点在于状态同步和资源共享。项目中使用全局状态变量来协调各模块,防止线程之间出现状态错位或资源冲突。
12. 实际开发中的收获
从工程实践来看,这个项目的价值不只是界面功能本身,而是让开发者更深入理解了嵌入式 Linux + LVGL 的真实开发方式。具体收获包括:
- Linux 下图形界面与底层设备驱动的适配方法
- LVGL 的显示驱动、触摸输入和字体加载流程
- 多页面状态管理和对象生命周期控制
- 多线程协作与资源管理
- 车机交互逻辑和功能模块整合思路
这些内容对后续从事车载开发、智能终端开发或嵌入式界面开发都具有较强参考价值。
13. 总结
基于 RK3568 的车载中控系统,不仅能够展示车机界面,它更像是一个真实的嵌入式应用系统雏形。项目将 Linux、LVGL、图像处理、音频播放、网络通信和多页面交互整合到一起,形成了一个比较完整的车载中控开发框架。
这类系统的特点有三点:
- 以 Linux 为运行环境,便于扩展和移植
- 以 LVGL 为界面核心,适合嵌入式屏幕交互
- 以功能模块化设计为特征,便于后续扩展智能座舱能力
如果继续扩展,该项目还可以在以下方向上进一步完善:
- 更完整车辆控制逻辑
- 智能语音交互增强
- 车联网和远程控制功能
- 更丰富的 UI 交互动画和页面管理
- 更稳定的系统优化和资源管理策略
14. 结语
这篇文章记录了一个基于 RK3568 的车载中控系统从架构设计到功能实现的开发过程。就工程实践而言,它涵盖了嵌入式 Linux 开发、LVGL 图形界面开发、设备适配、多线程控制、网络通信、AI 助手交互和 V4L2 摄像头采集等多个关键点。对想在嵌入式、车载、智能终端开发方向深入学习的人来说,这个项目具有较强的参考价值。
如果你正在从事车载系统开发、LVGL 接口开发或 Linux 嵌入式应用开发,这个项目的思路和代码结构都可以作为一个参考案例。
参考例程链接:https://gitcode.com/gcw_M1iyh4vx/Smart_Car_System
语音例程链接:https://gitcode.com/gcw_M1iyh4vx/voice_recognition
浙公网安备 33010602011771号