1. 代码框架

本节简单梳理ffplay.c代码框架。一些关键问题及细节问题在后续章节探讨。

1.1 流程图

1.2 主线程

主线程主要实现三项功能:视频播放(音视频同步)、字幕播放、SDL消息处理。

主线程在进行一些必要的初始化工作、创建解复用线程后,即进入event_loop()主循环,处理视频播放和SDL消息事件:

main() --> static void event_loop(VideoState *cur_stream) { SDL_Event event; ...... for (;;) { // SDL event队列为空,则在while循环中播放视频帧。否则从队列头部取一个event,退出当前函数,在上级函数中处理event refresh_loop_wait_event(cur_stream, &event); // SDL事件处理 switch (event.type) { case SDL_KEYDOWN: switch (event.key.keysym.sym) { case SDLK_f: // f键:强制刷新 ...... break; case SDLK_p: // p键 case SDLK_SPACE: // 空格键:暂停 ...... case SDLK_s: // s键:逐帧播放 ...... break; ...... ...... } } }

本文福利, C++音视频学习资料包、技术视频,内容包括(音视频开发,面试题,FFmpeg webRTC rtmp hls rtsp ffplay srs)↓↓↓↓↓↓见下面↓↓文章底部点击领取↓↓

1.2.1 视频播放

主要代码在refresh_loop_wait_event()函数中,如下:

static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) { double remaining_time = 0.0; SDL_PumpEvents(); while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) { if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) { SDL_ShowCursor(0); cursor_hidden = 1; } if (remaining_time > 0.0) av_usleep((int64_t)(remaining_time * 1000000.0)); remaining_time = REFRESH_RATE; if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh)) // 立即显示当前帧,或延时remaining_time后再显示 video_refresh(is, &remaining_time); SDL_PumpEvents(); } }

while()语句表示如果SDL event队列为空,则在while循环中播放视频帧;否则从队列头部取一个event,退出当前函数,在上级函数中处理event。 refresh_loop_wait_event()中调用了非常关键的函数video_refresh(),video_refresh()函数实现音视频的同步及视频帧的显示,是ffplay.c中最核心函数之一,在“4.3节 视频同步到音频”中详细分析。

1.2.2 SDL消息处理

处理各种SDL消息,比如暂停、强制刷新等按键事件。比较简单。

main() --> static void event_loop(VideoState *cur_stream) { SDL_Event event; ...... for (;;) { // SDL event队列为空,则在while循环中播放视频帧。否则从队列头部取一个event,退出当前函数,在上级函数中处理event refresh_loop_wait_event(cur_stream, &event); // SDL事件处理 switch (event.type) { case SDL_KEYDOWN: switch (event.key.keysym.sym) { case SDLK_f: // f键:强制刷新 ...... break; case SDLK_p: // p键 case SDLK_SPACE: // 空格键:暂停 ...... break; ...... ...... } } }

1.3 解复用线程

解复用线程读取视频文件,将取到的packet根据类型(音频、视频、字幕)存入不同是packet队列中。 为节省篇幅,如下源码中非关键内容的源码使用“......”替代。代码流程参考注释。

/* this thread gets the stream from the disk or the network */ static int read_thread(void *arg) { VideoState *is = arg; AVFormatContext *ic = NULL; int st_index[AVMEDIA_TYPE_NB]; ...... ...... // 中断回调机制。为底层I/O层提供一个处理接口,比如中止IO操作。 ic->interrupt_callback.callback = decode_interrupt_cb; ic->interrupt_callback.opaque = is; if (!av_dict_get(format_opts, "scan_all_pmts", NULL, AV_DICT_MATCH_CASE)) { av_dict_set(&format_opts, "scan_all_pmts", "1", AV_DICT_DONT_OVERWRITE); scan_all_pmts_set = 1; } // 1. 构建AVFormatContext // 1.1 打开视频文件:读取文件头,将文件格式信息存储在"fmt context"中 err = avformat_open_input(&ic, is->filename, is->iformat, &format_opts); ...... if (find_stream_info) { ...... // 1.2 搜索流信息:读取一段视频文件数据,尝试解码,将取到的流信息填入ic->streams // ic->streams是一个指针数组,数组大小是ic->nb_streams err = avformat_find_stream_info(ic, opts); ...... } ...... // 2. 查找用于解码处理的流 // 2.1 将对应的stream_index存入st_index[]数组 if (!video_disable) st_index[AVMEDIA_TYPE_VIDEO] = // 视频流 av_find_best_stream(ic, AVMEDIA_TYPE_VIDEO, st_index[AVMEDIA_TYPE_VIDEO], -1, NULL, 0); if (!audio_disable) st_index[AVMEDIA_TYPE_AUDIO] = // 音频流 av_find_best_stream(ic, AVMEDIA_TYPE_AUDIO, st_index[AVMEDIA_TYPE_AUDIO], st_index[AVMEDIA_TYPE_VIDEO], NULL, 0); if (!video_disable && !subtitle_disable) st_index[AVMEDIA_TYPE_SUBTITLE] = // 字幕流 av_find_best_stream(ic, AVMEDIA_TYPE_SUBTITLE, st_index[AVMEDIA_TYPE_SUBTITLE], (st_index[AVMEDIA_TYPE_AUDIO] >= 0 ? st_index[AVMEDIA_TYPE_AUDIO] : st_index[AVMEDIA_TYPE_VIDEO]), NULL, 0); is->show_mode = show_mode; // 2.2 从待处理流中获取相关参数,设置显示窗口的宽度、高度及宽高比 if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) { AVStream *st = ic->streams[st_index[AVMEDIA_TYPE_VIDEO]]; AVCodecParameters *codecpar = st->codecpar; // 根据流和帧宽高比猜测帧的样本宽高比。 // 由于帧宽高比由解码器设置,但流宽高比由解复用器设置,因此这两者可能不相等。此函数会尝试返回待显示帧应当使用的宽高比值。 // 基本逻辑是优先使用流宽高比(前提是值是合理的),其次使用帧宽高比。这样,流宽高比(容器设置,易于修改)可以覆盖帧宽高比。 AVRational sar = av_guess_sample_aspect_ratio(ic, st, NULL); if (codecpar->width) // 设置显示窗口的大小和宽高比 set_default_window_size(codecpar->width, codecpar->height, sar); } // 3. 创建对应流的解码线程 /* open the streams */ if (st_index[AVMEDIA_TYPE_AUDIO] >= 0) { // 3.1 创建音频解码线程 stream_component_open(is, st_index[AVMEDIA_TYPE_AUDIO]); } ret = -1; if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) { // 3.2 创建视频解码线程 ret = stream_component_open(is, st_index[AVMEDIA_TYPE_VIDEO]); } if (is->show_mode == SHOW_MODE_NONE) is->show_mode = ret >= 0 ? SHOW_MODE_VIDEO : SHOW_MODE_RDFT; if (st_index[AVMEDIA_TYPE_SUBTITLE] >= 0) { // 3.3 创建字幕解码线程 stream_component_open(is, st_index[AVMEDIA_TYPE_SUBTITLE]); } ...... // 4. 解复用处理 for (;;) { // 停止 ...... // 暂停/继续 ...... // seek操作 ...... ...... // 4.1 从输入文件中读取一个packet ret = av_read_frame(ic, pkt); if (ret < 0) { if ((ret == AVERROR_EOF || avio_feof(ic->pb)) && !is->eof) { // 输入文件已读完,则往packet队列中发送NULL packet,以冲洗(flush)解码器,否则解码器中缓存的帧取不出来 if (is->video_stream >= 0) packet_queue_put_nullpacket(&is->videoq, is->video_stream); if (is->audio_stream >= 0) packet_queue_put_nullpacket(&is->audioq, is->audio_stream); if (is->subtitle_stream >= 0) packet_queue_put_nullpacket(&is->subtitleq, is->subtitle_stream); is->eof = 1; } if (ic->pb && ic->pb->error) // 出错则退出当前线程 break; SDL_LockMutex(wait_mutex); SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10); SDL_UnlockMutex(wait_mutex); continue; } else { is->eof = 0; } // 4.2 判断当前packet是否在播放范围内,是则入列,否则丢弃 /* check if packet is in play range specified by user, then queue, otherwise discard */ stream_start_time = ic->streams[pkt->stream_index]->start_time; // 第一个显示帧的pts pkt_ts = pkt->pts == AV_NOPTS_VALUE ? pkt->dts : pkt->pts; // 简化一下"||"后那个长长的表达式: // [pkt_pts] - [stream_start_time] - [start_time] <= [duration] // [当前帧pts] - [第一帧pts] - [当前播放序列第一帧(seek起始点)pts] <= [duration] pkt_in_play_range = duration == AV_NOPTS_VALUE || (pkt_ts - (stream_start_time != AV_NOPTS_VALUE ? stream_start_time : 0)) * av_q2d(ic->streams[pkt->stream_index]->time_base) - (double)(start_time != AV_NOPTS_VALUE ? start_time : 0) / 1000000 <= ((double)duration / 1000000); // 4.3 根据当前packet类型(音频、视频、字幕),将其存入对应的packet队列 if (pkt->stream_index == is->audio_stream && pkt_in_play_range) { packet_queue_put(&is->audioq, pkt); } else if (pkt->stream_index == is->video_stream && pkt_in_play_range && !(is->video_st->disposition & AV_DISPOSITION_ATTACHED_PIC)) { packet_queue_put(&is->videoq, pkt); } else if (pkt->stream_index == is->subtitle_stream && pkt_in_play_range) { packet_queue_put(&is->subtitleq, pkt); } else { av_packet_unref(pkt); } } ret = 0; fail: ...... return 0; }

解复用线程实现如下功能: [1]. 创建音频、视频、字幕解码线程 [2]. 从输入文件读取packet,根据packet类型(音频、视频、字幕)将这放入不同packet队列

1.4 视频解码线程

视频解码线程从视频packet队列中取数据,解码后存入视频frame队列。

1.4.1 video_thread()

视频解码线程将解码后的帧放入frame队列中。为节省篇幅,如下源码中删除了滤镜filter相关代码。

// 视频解码线程:从视频packet_queue中取数据,解码后放入视频frame_queue static int video_thread(void *arg) { VideoState *is = arg; AVFrame *frame = av_frame_alloc(); double pts; double duration; int ret; AVRational tb = is->video_st->time_base; AVRational frame_rate = av_guess_frame_rate(is->ic, is->video_st, NULL); if (!frame) { return AVERROR(ENOMEM); } for (;;) { ret = get_video_frame(is, frame); if (ret < 0) goto the_end; if (!ret) continue; // 当前帧播放时长 duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0); // 当前帧显示时间戳 pts = (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb); // 将当前帧压入frame_queue ret = queue_picture(is, frame, pts, duration, frame->pkt_pos, is->viddec.pkt_serial); av_frame_unref(frame); if (ret < 0) goto the_end; } the_end: av_frame_free(&frame); return 0; }

1.4.2 get_video_frame()

从packet队列中取一个packet解码得到一个frame,并判断是否要根据framedrop机制丢弃失去同步的视频帧。参考源码中注释:

static int get_video_frame(VideoState *is, AVFrame *frame) { int got_picture; if ((got_picture = decoder_decode_frame(&is->viddec, frame, NULL)) < 0) return -1; if (got_picture) { double dpts = NAN; if (frame->pts != AV_NOPTS_VALUE) dpts = av_q2d(is->video_st->time_base) * frame->pts; frame->sample_aspect_ratio = av_guess_sample_aspect_ratio(is->ic, is->video_st, frame); // ffplay文档中对"-framedrop"选项的说明: // Drop video frames if video is out of sync.Enabled by default if the master clock is not set to video. // Use this option to enable frame dropping for all master clock sources, use - noframedrop to disable it. // "-framedrop"选项用于设置当视频帧失去同步时,是否丢弃视频帧。"-framedrop"选项以bool方式改变变量framedrop值。 // 音视频同步方式有三种:A同步到视频,B同步到音频,C同步到外部时钟。 // 1) 当命令行不带"-framedrop"选项或"-noframedrop"时,framedrop值为默认值-1,若同步方式是"同步到视频" // 则不丢弃失去同步的视频帧,否则将丢弃失去同步的视频帧。 // 2) 当命令行带"-framedrop"选项时,framedrop值为1,无论何种同步方式,均丢弃失去同步的视频帧。 // 3) 当命令行带"-noframedrop"选项时,framedrop值为0,无论何种同步方式,均不丢弃失去同步的视频帧。 if (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) { if (frame->pts != AV_NOPTS_VALUE) { double diff = dpts - get_master_clock(is); if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD && diff - is->frame_last_filter_delay < 0 && is->viddec.pkt_serial == is->vidclk.serial && is->videoq.nb_packets) { is->frame_drops_early++; av_frame_unref(frame); // 视频帧失去同步则直接扔掉 got_picture = 0; } } } } return got_picture; }

ffplay中framedrop处理有两种,一处是此处解码后得到的frame尚未存入frame队列前,以is->frame_drops_early++为标记;另一处是frame队列中读取frame进行显示的时候,以is->frame_drops_late++为标记。 本处framedrop操作涉及的变量is->frame_last_filter_delay属于滤镜filter操作相关,ffplay中默认是关闭滤镜的,本文不考虑滤镜相关操作。

1.4.3 decoder_decode_frame()

这个函数是很核心的一个函数,可以解码视频帧和音频帧。视频解码线程中,视频帧实际的解码操作就在此函数中进行。分析过程参考3.2节。

1.5 音频解码线程

音频解码线程从音频packet队列中取数据,解码后存入音频frame队列

1.5.1 打开音频设备

音频设备的打开实际是在解复用线程中实现的。解复用线程中先打开音频设备(设定音频回调函数供SDL音频播放线程回调),然后再创建音频解码线程。调用链如下:

main() -->
stream_open() -->
read_thread() -->
stream_component_open() -->audio_open(is, channel_layout, nb_channels, sample_rate, &is->audio_tgt);decoder_start(&is->auddec, audio_thread, is);

audio_open()函数填入期望的音频参数,打开音频设备后,将实际的音频参数存入输出参数is->audio_tgt中,后面音频播放线程用会用到此参数。 音频格式的各参数与重采样强相关,audio_open()的详细实现在后面第5节讲述。

1.5.2 audio_thread()

从音频packet_queue中取数据,解码后放入音频frame_queue:

// 音频解码线程:从音频packet_queue中取数据,解码后放入音频frame_queue
static int audio_thread(void *arg)
{VideoState *is = arg;AVFrame *frame = av_frame_alloc();Frame *af;int got_frame = 0;AVRational tb;int ret = 0;if (!frame)return AVERROR(ENOMEM);do {if ((got_frame = decoder_decode_frame(&is->auddec, frame, NULL)) < 0)goto the_end;if (got_frame) {tb = (AVRational){1, frame->sample_rate};if (!(af = frame_queue_peek_writable(&is->sampq)))goto the_end;af->pts = (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb);af->pos = frame->pkt_pos;af->serial = is->auddec.pkt_serial;// 当前帧包含的(单个声道)采样数/采样率就是当前帧的播放时长af->duration = av_q2d((AVRational){frame->nb_samples, frame->sample_rate});// 将frame数据拷入af->frame,af->frame指向音频frame队列尾部av_frame_move_ref(af->frame, frame);// 更新音频frame队列大小及写指针frame_queue_push(&is->sampq);}} while (ret >= 0 || ret == AVERROR(EAGAIN) || ret == AVERROR_EOF);the_end:av_frame_free(&frame);return ret;
}

1.5.3 decoder_decode_frame()

此函数既可以解码音频帧,也可以解码视频帧,函数分析参考3.2节。

1.6 音频播放线程

音频播放线程是SDL内建的线程,通过回调的方式调用用户提供的回调函数。 回调函数在SDL_OpenAudio()时指定。 暂停/继续回调过程由SDL_PauseAudio()控制。

1.6.1 sdl_audio_callback()

音频回调函数如下:

// 音频处理回调函数。读队列获取音频包,解码,播放
// 此函数被SDL按需调用,此函数不在用户主线程中,因此数据需要保护
// \param[in]  opaque 用户在注册回调函数时指定的参数
// \param[out] stream 音频数据缓冲区地址,将解码后的音频数据填入此缓冲区
// \param[out] len    音频数据缓冲区大小,单位字节
// 回调函数返回后,stream指向的音频缓冲区将变为无效
// 双声道采样点的顺序为LRLRLR
/* prepare a new audio buffer */
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{VideoState *is = opaque;int audio_size, len1;audio_callback_time = av_gettime_relative();while (len > 0) {   // 输入参数len等于is->audio_hw_buf_size,是audio_open()中申请到的SDL音频缓冲区大小if (is->audio_buf_index >= is->audio_buf_size) {// 1. 从音频frame队列中取出一个frame,转换为音频设备支持的格式,返回值是重采样音频帧的大小audio_size = audio_decode_frame(is);if (audio_size < 0) {/* if error, just output silence */is->audio_buf = NULL;is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;} else {if (is->show_mode != SHOW_MODE_VIDEO)update_sample_display(is, (int16_t *)is->audio_buf, audio_size);is->audio_buf_size = audio_size;}is->audio_buf_index = 0;}// 引入is->audio_buf_index的作用:防止一帧音频数据大小超过SDL音频缓冲区大小,这样一帧数据需要经过多次拷贝// 用is->audio_buf_index标识重采样帧中已拷入SDL音频缓冲区的数据位置索引,len1表示本次拷贝的数据量len1 = is->audio_buf_size - is->audio_buf_index;if (len1 > len)len1 = len;// 2. 将转换后的音频数据拷贝到音频缓冲区stream中,之后的播放就是音频设备驱动程序的工作了if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);else {memset(stream, 0, len1);if (!is->muted && is->audio_buf)SDL_MixAudioFormat(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, AUDIO_S16SYS, len1, is->audio_volume);}len -= len1;stream += len1;is->audio_buf_index += len1;}// is->audio_write_buf_size是本帧中尚未拷入SDL音频缓冲区的数据量is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;/* Let's assume the audio driver that is used by SDL has two periods. */// 3. 更新时钟if (!isnan(is->audio_clock)) {// 更新音频时钟,更新时刻:每次往声卡缓冲区拷入数据后// 前面audio_decode_frame中更新的is->audio_clock是以音频帧为单位,所以此处第二个参数要减去未拷贝数据量占用的时间set_clock_at(&is->audclk, is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec, is->audio_clock_serial, audio_callback_time / 1000000.0);// 使用音频时钟更新外部时钟sync_clock_to_slave(&is->extclk, &is->audclk);}
}

1.6.2 audio_decode_frame()

audio_decode_frame()主要是进行音频重采样,从音频frame队列中取出一个frame,此frame的格式是输入文件中的音频格式,音频设备不一定支持这些参数,所以要将frame转换为音频设备支持的格式。 audio_decode_frame()的实现在后面第5节讲述。

本文福利, C++音视频学习资料包、技术视频,内容包括(音视频开发,面试题,FFmpeg webRTC rtmp hls rtsp ffplay srs)↓↓↓↓↓↓见下面↓↓文章底部点击领取↓↓

ffplay源码分析:代码框架相关推荐

  1. ffplay源码分析4-音视频同步

    ffplay是FFmpeg工程自带的简单播放器,使用FFmpeg提供的解码器和SDL库进行视频播放.本文基于FFmpeg工程4.1版本进行分析,其中ffplay源码清单如下: https://gith ...

  2. 【OkHttp】OkHttp 源码分析 ( 网络框架封装 | OkHttp 4 迁移 | OkHttp 建造者模式 )

    OkHttp 系列文章目录 [OkHttp]OkHttp 简介 ( OkHttp 框架特性 | Http 版本简介 ) [OkHttp]Android 项目导入 OkHttp ( 配置依赖 | 配置 ...

  3. FFplay源码分析-avformat_open_input

    本系列 以 ffmpeg4.4 源码为准,主要讲解 ffplay 的 RTMP 协议解析,播放.本文使用的命令如下: ffplay -i rtmp://192.168.0.122/live/lives ...

  4. Activiti源码分析(框架、核心类。。。)

    Activiti源码分析(框架.核心类...) 目录 概 述 activiti源码分析(一)设计模式 总结: 相关工具如下: 分析: 小结: 参考资料和推荐阅读 LD is tigger foreve ...

  5. FFplay源码分析-音视频同步1

    本系列 以 ffmpeg4.2 源码为准,下载地址:链接:百度网盘 提取码:g3k8 FFplay 源码分析系列以一条简单的命令开始,ffplay -i a.mp4.a.mp4下载链接:百度网盘,提取 ...

  6. PX4 Autopilot源码分析 - 代码下载

    PX4 Autopilot源码分析 - 代码下载 源码地址 下载 硬件平台 编译 安装工具链 配置 运行 px4-Autopilot是目前最流程的无人驾驶类开源项目,社区在持续活跃状态,国内很多无人机 ...

  7. SpringMVC源码分析_框架原理图

                                                                                 SpringMVC源码分析_框架原理图     ...

  8. [Abp vNext 源码分析] - 1. 框架启动流程分析

    一.简要说明 本篇文章主要剖析与讲解 Abp vNext 在 Web API 项目下的启动流程,让大家了解整个 Abp vNext 框架是如何运作的.总的来说 ,Abp vNext 比起 ABP 框架 ...

  9. Scrapy-redis 源码分析 及 框架使用

    From:https://blog.csdn.net/weixin_37947156/article/details/75044971 From:https://cuiqingcai.com/6058 ...

  10. ThinkPHP5.1.x 框架源码分析之框架的灵魂

    一.类的自动加载初始 框架的灵魂,类的自动加载 为什么说是框架灵魂呢,一般框架都会有类的自动加载,当引入文件很多的时候,就会需要用到.这一个也是很多人想去阅读源码时卡住的点 源码阅读 打开到入口文件 ...

最新文章

  1. C# 导出word文档及批量导出word文档(2)
  2. java 18 -4 LinkedHashMap集合
  3. 网站上flv,MP4等格式的视频文件播放不出来的解决办法
  4. mysql 函数重载_[赋值]函数,变量,重载 ,_第1页_169IT
  5. jquery.uploadify参数
  6. eclipse导入不到嵌套的项目
  7. 调速水泵控制c语言实验程序,液压控制实验报告
  8. unindent does not match any outer indentation level
  9. 2022美团实习生客户端一面
  10. 26-TeamBuilding-团队建设
  11. android 设置Spinner文字标题颜色 字体大小样式
  12. ODOO13 开发教程四 模型中的字段
  13. fuz--2128(最长子串)
  14. android上传图片被旋转,解决android有的手机拍照后上传图片被旋转的问题
  15. jdbc mysql例子
  16. python linspace
  17. 至少存在7个行业,值得长期投资
  18. 无线智能通信配电台区智能化应用
  19. oracle 性能优化培训,ORACLE SQL性能优化(内部培训资料)
  20. UG实体改色和真实着色情况下改背景为白色

热门文章

  1. ArcMap中面要素生成Voronoi图(V图)或者泰森多边形
  2. 如何借助ChatGPT,自动批量产出短视频爆款文案
  3. 手机网速越来越慢?路由器别放在这3个地方
  4. GAN,CycleGAN,starGAN,CycleGAN-VC,starGAN-VC
  5. Mixin 函数的详细解析
  6. 数据库服务器不支持innodb存储引擎,MySQL InnoDB存储引擎表损坏恢复指南
  7. js字节单位转换函数(KB MB GB TB PB EB ZB)
  8. RTL8192CUS驱动程序编译
  9. 短视频运营小技巧,吸引粉丝观看也不难,只需要注意细节
  10. Python+Selenium_UI自动化操作(5)——浏览器截屏