1.简介
此文章并不是教程,只能当作笔者的学习分享,只会做一些简单的介绍,其他的各位结合着代码和运行现象自己分析吧,相信通过函数名和注释,基本上是不难看懂代码的,其中涉及到的一些技术栈,也请各位学习到的时候多查阅资料。
本篇的内容为嵌入式Linux应用层的一个综合性比较强的项目,结尾会将源码放在网盘中开源出来,笔者能力有限,只是简单的把功能实现了,代码开源供大家一起交流学习,有什么好的建议,请各位一定不吝赐教!!!
1.1功能介绍
项目包括了四个app:
1.云平台的调试窗口,用于查看订阅主题所下发的数据,另一个为输入Json格式的数据来控制STM32单片机上的外设。
2.智能家居的界面,有4个图片按钮用于控制STM32板子上的LED灯、门(舵机)、蜂鸣器,量计分别为温度、湿度和亮度的值,同样是STM32获取发布到云平台的。
3.通过一个摄像头模块做的一个相机功能,可以拍照、录像,以及查看拍摄的照片,和播放录制视频的回放。
4.简易的音乐播放器:能够切换歌曲,以及暂停播放音乐。
1.2技术栈介绍
虽然项目简单,但是所涉及到的技术栈还是比较杂,我简单在此列出:
1.LVGL库用于绘制UI。
2.MQTT协议,连接阿里云平台与STM32通讯。
3.alsa库用于音频处理。
4.LED、BEEP
5.V4L2 摄像头应用编程
1.3演示视频
【开源】Linux应用综合项目|云平台调试工具+智能家居+相机+音乐播放器_哔哩哔哩_bilibili
1.4硬件介绍
硬件使用的是正点原子的阿尔法开发板,芯片是IMX6U,类似开发板应该都可以运行。
2.软件设计
2.1.v4l2摄像头应用编程,实时监控和拍照功能实现。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <string.h>
#include <errno.h>
#include <sys/mman.h>
#include <linux/videodev2.h>
#include <linux/fb.h>
#include "ds_v4l2_camera.h"
#define FB_DEV "/dev/fb0" // LCD设备节点
#define FRAMEBUFFER_COUNT 3 // 帧缓冲数量
/*** 摄像头像素格式及其描述信息 ***/
typedef struct camera_format
{
unsigned char description[32]; // 字符串描述信息
unsigned int pixelformat; // 像素格式
} cam_fmt;
/*** 描述一个帧缓冲的信息 ***/
typedef struct cam_buf_info
{
unsigned short *start; // 帧缓冲起始地址
unsigned long length; // 帧缓冲长度
} cam_buf_info;
static int width; // LCD宽度
static int height; // LCD高度
static unsigned short *screen_base = NULL; // LCD显存基地址
static int fb_fd = -1; // LCD设备文件描述符
static int v4l2_fd = -1; // 摄像头设备文件描述符
static cam_buf_info buf_infos[FRAMEBUFFER_COUNT];
static cam_fmt cam_fmts[10];
static int frm_width, frm_height; // 视频帧宽度和高度
static pthread_t g_ds_v4l2_camera_thread = NULL;
int capture_image = 0; // 全局变量,控制是否捕获图像
int total_photos = 0;
int current_photo_index = 0;
void *ds_v4l2_camera_thread(void *args);
void capture_single_image();
static int fb_dev_init(void)
{
struct fb_var_screeninfo fb_var = {0};
struct fb_fix_screeninfo fb_fix = {0};
unsigned long screen_size;
/* 打开framebuffer设备 */
fb_fd = open(FB_DEV, O_RDWR);
if (0 > fb_fd)
{
fprintf(stderr, "open error: %s: %s\n", FB_DEV, strerror(errno));
return -1;
}
/* 获取framebuffer设备信息 */
ioctl(fb_fd, FBIOGET_VSCREENINFO, &fb_var);
ioctl(fb_fd, FBIOGET_FSCREENINFO, &fb_fix);
printf("fb_var.line_length = %d\n", fb_fix.line_length);
screen_size = fb_fix.line_length * fb_var.yres;
width = fb_var.xres;
height = fb_var.yres;
printf("width = %d, height = %d\n", width, height);
/* 内存映射 */
screen_base = mmap(NULL, screen_size, PROT_READ | PROT_WRITE, MAP_SHARED, fb_fd, 0);
printf("screen_base = %d\n", screen_base);
if (MAP_FAILED == (void *)screen_base)
{
perror("mmap error");
close(fb_fd);
return -1;
}
/* LCD背景刷白 */
// memset(screen_base, 0xFF, screen_size);
memset(screen_base, 0x00, screen_size);
return 0;
}
static int v4l2_dev_init(const char *device)
{
struct v4l2_capability cap = {0};
/* 打开摄像头 */
v4l2_fd = open(device, O_RDWR);
if (0 > v4l2_fd)
{
fprintf(stderr, "open error: %s: %s\n", device, strerror(errno));
return -1;
}
/* 查询设备功能 */
ioctl(v4l2_fd, VIDIOC_QUERYCAP, &cap);
/* 判断是否是视频采集设备 */
if (!(V4L2_CAP_VIDEO_CAPTURE & cap.capabilities))
{
fprintf(stderr, "Error: %s: No capture video device!\n", device);
close(v4l2_fd);
return -1;
}
return 0;
}
static void v4l2_enum_formats(void)
{
struct v4l2_fmtdesc fmtdesc = {0};
/* 枚举摄像头所支持的所有像素格式以及描述信息 */
fmtdesc.index = 0;
fmtdesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
while (0 == ioctl(v4l2_fd, VIDIOC_ENUM_FMT, &fmtdesc))
{
// 将枚举出来的格式以及描述信息存放在数组中
cam_fmts[fmtdesc.index].pixelformat = fmtdesc.pixelformat;
strcpy(cam_fmts[fmtdesc.index].description, fmtdesc.description);
fmtdesc.index++;
}
}
static void v4l2_print_formats(void)
{
struct v4l2_frmsizeenum frmsize = {0};
struct v4l2_frmivalenum frmival = {0};
int i;
frmsize.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
frmival.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
for (i = 0; cam_fmts[i].pixelformat; i++)
{
printf("format<0x%x>, description<%s>\n", cam_fmts[i].pixelformat,
cam_fmts[i].description);
/* 枚举出摄像头所支持的所有视频采集分辨率 */
frmsize.index = 0;
frmsize.pixel_format = cam_fmts[i].pixelformat;
frmival.pixel_format = cam_fmts[i].pixelformat;
while (0 == ioctl(v4l2_fd, VIDIOC_ENUM_FRAMESIZES, &frmsize))
{
printf("size<%d*%d> ",
frmsize.discrete.width,
frmsize.discrete.height);
frmsize.index++;
/* 获取摄像头视频采集帧率 */
frmival.index = 0;
frmival.width = frmsize.discrete.width;
frmival.height = frmsize.discrete.height;
while (0 == ioctl(v4l2_fd, VIDIOC_ENUM_FRAMEINTERVALS, &frmival))
{
printf("<%dfps>", frmival.discrete.denominator /
frmival.discrete.numerator);
frmival.index++;
}
printf("\n");
}
printf("\n");
}
}
static int v4l2_set_format(void)
{
struct v4l2_format fmt = {0};
struct v4l2_streamparm streamparm = {0};
/* 设置帧格式 */
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // type类型
// fmt.fmt.pix.width = width; // 视频帧宽度
// fmt.fmt.pix.height = height; // 视频帧高度
fmt.fmt.pix.width = IMAGE_WIDTH; // 视频帧宽度
fmt.fmt.pix.height = IMAGE_HEIGHT; // 视频帧高度
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_RGB565; // 像素格式
if (0 > ioctl(v4l2_fd, VIDIOC_S_FMT, &fmt))
{
fprintf(stderr, "ioctl error: VIDIOC_S_FMT: %s\n", strerror(errno));
return -1;
}
/*** 判断是否已经设置为我们要求的RGB565像素格式
如果没有设置成功表示该设备不支持RGB565像素格式 */
if (V4L2_PIX_FMT_RGB565 != fmt.fmt.pix.pixelformat)
{
fprintf(stderr, "Error: the device does not support RGB565 format!\n");
return -1;
}
frm_width = fmt.fmt.pix.width; // 获取实际的帧宽度
frm_height = fmt.fmt.pix.height; // 获取实际的帧高度
printf("视频帧大小<%d * %d>\n", frm_width, frm_height);
/* 获取streamparm */
streamparm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ioctl(v4l2_fd, VIDIOC_G_PARM, &streamparm);
/** 判断是否支持帧率设置 **/
if (V4L2_CAP_TIMEPERFRAME & streamparm.parm.capture.capability)
{
streamparm.parm.capture.timeperframe.numerator = 1;
streamparm.parm.capture.timeperframe.denominator = 30; // 30fps
if (0 > ioctl(v4l2_fd, VIDIOC_S_PARM, &streamparm))
{
fprintf(stderr, "ioctl error: VIDIOC_S_PARM: %s\n", strerror(errno));
return -1;
}
}
return 0;
}
static int v4l2_init_buffer(void)
{
struct v4l2_requestbuffers reqbuf = {0};
struct v4l2_buffer buf = {0};
/* 申请帧缓冲 */
reqbuf.count = FRAMEBUFFER_COUNT; // 帧缓冲的数量
reqbuf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
reqbuf.memory = V4L2_MEMORY_MMAP;
if (0 > ioctl(v4l2_fd, VIDIOC_REQBUFS, &reqbuf))
{
fprintf(stderr, "ioctl error: VIDIOC_REQBUFS: %s\n", strerror(errno));
return -1;
}
/* 建立内存映射 */
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
for (buf.index = 0; buf.index < FRAMEBUFFER_COUNT; buf.index++)
{
ioctl(v4l2_fd, VIDIOC_QUERYBUF, &buf);
buf_infos[buf.index].length = buf.length;
buf_infos[buf.index].start = mmap(NULL, buf.length,
PROT_READ | PROT_WRITE, MAP_SHARED,
v4l2_fd, buf.m.offset);
if (MAP_FAILED == buf_infos[buf.index].start)
{
perror("mmap error");
return -1;
}
}
/* 入队 */
for (buf.index = 0; buf.index < FRAMEBUFFER_COUNT; buf.index++)
{
if (0 > ioctl(v4l2_fd, VIDIOC_QBUF, &buf))
{
fprintf(stderr, "ioctl error: VIDIOC_QBUF: %s\n", strerror(errno));
return -1;
}
}
return 0;
}
static int v4l2_stream_on(void)
{
/* 打开摄像头、摄像头开始采集数据 */
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (0 > ioctl(v4l2_fd, VIDIOC_STREAMON, &type))
{
fprintf(stderr, "ioctl error: VIDIOC_STREAMON: %s\n", strerror(errno));
return -1;
}
return 0;
}
void close_v4l2_camera(void)
{
// 停止线程
if (g_ds_v4l2_camera_thread != NULL)
{
pthread_cancel(g_ds_v4l2_camera_thread);
pthread_join(g_ds_v4l2_camera_thread, NULL);
g_ds_v4l2_camera_thread = NULL;
}
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (0 > ioctl(v4l2_fd, VIDIOC_STREAMOFF, &type))
{
fprintf(stderr, "ioctl error: VIDIOC_STREAMOFF: %s\n", strerror(errno));
}
// 取消映射缓冲区
for (int i = 0; i < FRAMEBUFFER_COUNT; i++)
{
if (buf_infos[i].start != NULL && buf_infos[i].length > 0)
{
if (munmap(buf_infos[i].start, buf_infos[i].length) < 0)
{
fprintf(stderr, "munmap error: %s\n", strerror(errno));
}
buf_infos[i].start = NULL;
buf_infos[i].length = 0;
}
}
}
int ds_v4l2_camera_init(void)
{
static bool v4l2_dev_flag = 0;
/* 初始化LCD */
if (fb_dev_init())
exit(EXIT_FAILURE);
if (v4l2_dev_flag == 0)
{
printf("打开摄像头");
v4l2_dev_flag = 1;
/* 初始化摄像头 */
if (v4l2_dev_init("/dev/video1"))
exit(EXIT_FAILURE);
}
else
{
printf("摄像头已打开,不可重复打开\n");
}
/* 枚举所有格式并打印摄像头支持的分辨率及帧率 */
v4l2_enum_formats();
v4l2_print_formats();
/* 设置格式 */
if (v4l2_set_format())
exit(EXIT_FAILURE);
/* 初始化帧缓冲:申请、内存映射、入队 */
if (v4l2_init_buffer())
exit(EXIT_FAILURE);
/* 开启视频采集 */
if (v4l2_stream_on())
exit(EXIT_FAILURE);
int res;
res = pthread_create(&g_ds_v4l2_camera_thread, NULL, ds_v4l2_camera_thread, NULL);
if (res != 0)
{
printf("pthread_create ds_v4l2_camera_thread failed: %d\n", res);
return -1;
}
printf("ds_v4l2_camera_thread created successfully\n");
}
static void save_image(unsigned short *data, int width, int height, FILE *filename)
{
// FILE *file = fopen(PHOTO_PATH, "wb");
FILE *file = fopen(filename, "wb");
if (!file)
{
perror("Error opening file for writing");
return;
}
size_t size = width * height * 2; // RGB565: 2 bytes per pixel
size_t written = fwrite(data, 1, size, file);
if (written != size)
{
perror("Error writing image data to file");
}
fclose(file);
printf("First few bytes of image data: %02x %02x %02x\n",
((unsigned char *)data)[0],
((unsigned char *)data)[1],
((unsigned char *)data)[2]);
printf("Image saved as %s\n", filename);
}
static void save_image_Multiple(const unsigned short *data, int width, int height, const char *filename)
{
FILE *file = fopen(filename, "wb");
if (!file)
{
perror("Failed to open file for saving image");
return;
}
size_t size = width * height * 2; // RGB565: 2 bytes per pixel
fwrite(data, 1, size, file);
fclose(file);
printf("Image saved successfully as %s\n", filename);
}
void capture_multiple_images(int num_images)
{
char filename[256];
for (int i = 0; i < num_images; i++)
{
struct v4l2_buffer buf = {0};
ioctl(v4l2_fd, VIDIOC_DQBUF, &buf); // 出队
snprintf(filename, sizeof(filename), "image_%d.rgb", i + 1);
save_image(buf_infos[buf.index].start, 640, 480, filename);
ioctl(v4l2_fd, VIDIOC_QBUF, &buf); // 入队
}
}
static void v4l2_read_data(void)
{
struct v4l2_buffer buf = {0};
unsigned short *base;
unsigned short *start;
int min_w, min_h;
int j;
const int x = 80, y = 0;
if (width > frm_width)
min_w = frm_width;
else
min_w = width;
if (height > frm_height)
min_h = frm_height;
else
min_h = height;
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
for (buf.index = 0; buf.index < FRAMEBUFFER_COUNT; buf.index++)
{
ioctl(v4l2_fd, VIDIOC_DQBUF, &buf); // 出队
#if (IMAGE_WIDTH == 800 && IMAGE_HEIGHT == 480)
{
printf("Base pointer address: %p\n", screen_base);
for (j = 0, base = screen_base, start = buf_infos[buf.index].start;
j < min_h; j++)
{
memcpy(base, start, min_w * 2); // RGB565 一个像素占2个字节
base += width; // LCD显示指向下一行
start += frm_width; // 指向下一行数据
}
}
#else
{
base = screen_base + y * width + x;
// printf("Base pointer address: %p\n", base);
for (j = 0, base = screen_base + y * width + x, start = buf_infos[buf.index].start; j < min_h; j++)
{
memcpy(base, start, min_w * 2); // RGB565 一个像素占2个字节
base += width; // LCD显示指向下一行
start += frm_width; // 指向下一行数据
}
}
#endif
if (capture_image)
{
capture_image = 0; // 只捕获一次
clear_area(80, 0, 640, 480, lv_color_hex(MY_UI_COLOR_BLACK));
usleep(100 * 1000);
static int image_count = 0;
char photo_path[256];
snprintf(photo_path, sizeof(photo_path), "/home/root/img_rgb/photo_%d.rgb", image_count++);
save_image(buf_infos[buf.index].start, IMAGE_WIDTH, IMAGE_HEIGHT, photo_path);
total_photos = image_count;
printf("total_photos = %d\n", total_photos);
// current_photo_index = image_count;
// save_image(buf_infos[buf.index].start, IMAGE_WIDTH, IMAGE_HEIGHT);
}
// 数据处理完之后、再入队、往复
ioctl(v4l2_fd, VIDIOC_QBUF, &buf);
}
}
void display_photo(const char *photo_path, int pho_width, int pho_height)
{
FILE *file = fopen(photo_path, "rb");
if (!file)
{
perror("Failed to open photo file");
return;
}
size_t pho_size = pho_width * pho_height * 2; // RGB565: 2 bytes per pixel
size_t screen_size = 800 * 480;
void *image_data = malloc(pho_size);
if (!image_data)
{
perror("Failed to allocate memory for photo");
fclose(file);
return;
}
size_t read_bytes = fread(image_data, 1, pho_size, file);
if (read_bytes != pho_size)
{
perror("Failed to read photo data");
printf("size = %d\n", pho_size);
printf("read_bytes = %d\n", read_bytes);
free(image_data);
fclose(file);
return;
}
fclose(file);
#if (IMAGE_WIDTH == 800 && IMAGE_HEIGHT == 480)
memcpy(screen_base, image_data, pho_size);
#else
// Copy image data to framebuffer
unsigned short *base = (unsigned short *)(screen_base + 0 * pho_width + 80);
unsigned short *start = (unsigned short *)image_data;
printf("Photo base address: %p\n", base);
printf("Image data address: %p\n", start);
for (int y = 0; y < pho_height; y++)
{
memcpy(base, start, pho_width * 2); // RGB565 每像素占 2 个字节
base += 800; // LCD 显示指向下一行
start += pho_width; // 图片数据指向下一行
}
#endif
free(image_data);
printf("Photo displayed successfully\n");
}
void *ds_v4l2_camera_thread(void *args)
{
while (1)
{
v4l2_read_data();
usleep(10 * 1000);
}
return NULL;
}
2.2.按键和相册UI
#include "ui_app_camera.h"
LV_IMG_DECLARE(img_back_64);
LV_IMG_DECLARE(img_takephoto_64);
LV_IMG_DECLARE(img_takevedio_64);
LV_IMG_DECLARE(img_image_64);
LV_IMG_DECLARE(img_image_prev_64);
LV_IMG_DECLARE(img_image_next_64);
lv_obj_t *back_btn;
lv_obj_t *photo_vedio_btn;
lv_obj_t *image_btn;
extern int app_index;
extern int current_photo_index;
extern int total_photos;
void show_current_photo(void)
{
char photo_path[256];
printf("current_photo_index = %d\n", current_photo_index);
snprintf(photo_path, sizeof(photo_path), "/home/root/img_rgb/photo_%d.rgb", current_photo_index);
display_photo(photo_path, 640, 480);
}
static void back_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED)
{
printf("back_event_cb clicked!\n");
close_v4l2_camera();
app_index = 0;
ui_page_main();
}
}
extern int capture_image;
static void photo_vedio_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_SHORT_CLICKED)
{
printf("photo_vedio_event_cb PRESSED!\n");
lv_img_set_src(photo_vedio_btn, &img_takephoto_64);
capture_image = 1;
}
}
bool is_image = 0;
static void check_image_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED)
{
printf("check_image_event_cb clicked!\n");
is_image = !is_image;
if (is_image)
{
ui_check_image();
current_photo_index = total_photos - 1;
printf("current_photo_index = %d\n", current_photo_index);
show_current_photo();
}
else
{
ui_page_main();
}
}
}
static void prev_image_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED)
{
printf("prev_image_event_cb clicked!\n");
if (current_photo_index > 0)
{
current_photo_index--;
printf("current_photo_index = %d\n", current_photo_index);
show_current_photo();
}
}
}
static void next_image_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED)
{
printf("next_image_event_cb clicked!\n");
if (current_photo_index < total_photos - 1)
{
current_photo_index++;
printf("current_photo_index = %d\n", current_photo_index);
show_current_photo();
}
}
}
void ui_app_camera(void)
{
clear_area(0, 0, 800, 480, lv_color_hex(MY_UI_COLOR_BLACK));
ds_v4l2_camera_init();
back_btn = lv_imgbtn_create(lv_scr_act());
lv_obj_set_size(back_btn, 64, 64);
lv_obj_add_event_cb(back_btn, back_event_cb, LV_EVENT_CLICKED, NULL);
lv_img_set_src(back_btn, &img_back_64);
lv_obj_align(back_btn, LV_ALIGN_TOP_LEFT, 10, 10);
photo_vedio_btn = lv_imgbtn_create(lv_scr_act());
lv_obj_set_size(photo_vedio_btn, 64, 64);
lv_obj_add_event_cb(photo_vedio_btn, photo_vedio_event_cb, LV_EVENT_ALL, NULL);
lv_img_set_src(photo_vedio_btn, &img_takephoto_64);
lv_obj_align(photo_vedio_btn, LV_ALIGN_TOP_LEFT, 730, 205);
image_btn = lv_imgbtn_create(lv_scr_act());
lv_obj_set_size(image_btn, 64, 64);
lv_obj_add_event_cb(image_btn, check_image_event_cb, LV_EVENT_CLICKED, NULL);
lv_img_set_src(image_btn, &img_image_64);
lv_obj_align(image_btn, LV_ALIGN_TOP_LEFT, 730, 380);
}
void ui_check_image(void)
{
close_v4l2_camera();
clear_area(0, 0, 800, 480, lv_color_hex(MY_UI_COLOR_BLACK));
lv_refr_now(NULL); // 立即刷新屏幕
sleep(1);
// show_current_photo();
// display_photo(PHOTO_PATH, IMAGE_WIDTH, IMAGE_HEIGHT);
image_btn = lv_imgbtn_create(lv_scr_act());
lv_obj_set_size(image_btn, 64, 64);
lv_obj_add_event_cb(image_btn, check_image_event_cb, LV_EVENT_CLICKED, NULL);
lv_img_set_src(image_btn, &img_image_64);
lv_obj_align(image_btn, LV_ALIGN_TOP_LEFT, 730, 380);
lv_obj_t *image_prev_btn = lv_imgbtn_create(lv_scr_act());
lv_obj_set_size(image_prev_btn, 64, 64);
lv_obj_add_event_cb(image_prev_btn, prev_image_event_cb, LV_EVENT_CLICKED, NULL);
lv_img_set_src(image_prev_btn, &img_image_prev_64);
lv_obj_align(image_prev_btn, LV_ALIGN_TOP_LEFT, 10, 205);
lv_obj_t *image_next_btn = lv_imgbtn_create(lv_scr_act());
lv_obj_set_size(image_next_btn, 64, 64);
lv_obj_add_event_cb(image_next_btn, next_image_event_cb, LV_EVENT_CLICKED, NULL);
lv_img_set_src(image_next_btn, &img_image_next_64);
lv_obj_align(image_next_btn, LV_ALIGN_TOP_LEFT, 725, 205);
}
3.结尾(附网盘链接)
通过百度网盘分享的文件:Linux_4_APP_Demo.zip
链接:百度网盘 请输入提取码
提取码:97km
--来自百度网盘超级会员V5的分享