蓝牙nrf51822程序的分析(一)

最近继续用NRF51822开发一个东西。无奈之前没接触过蓝牙。连蓝牙串口模块也没有。所以对蓝牙的基础知识不够,后面看了之后接着补充
花了2天时间把提供的NRF51822的程序大致看明白了,打算把所有的源码都进行整理分析一下,方便以后翻出来回顾。
这篇先分析一下提供模板的框架部分程序。
PS:光顾着看代码了。别人的资料分析没怎么看,如果有不对,请下面提出,我会补上。
这里以模板的代码(灯和按键)为例:
http://download.csdn.net/download/dfsae/9987318
程序中有一些注释,是之前别人加的。


1.主函数

NRF51822的框架还是采用事件驱动框架。先从主函数进行分析

int main(void)
{// Initializeleds_init();           //led初始化,硬件配置timers_init();gpiote_init();          //中断初始化buttons_init();ble_stack_init();scheduler_init();    gap_params_init();services_init();advertising_init();conn_params_init();sec_params_init();// Start executiontimers_start();advertising_start();// Enter main loopfor (;;){app_sched_execute();power_manage();}
}

主函数里做一些初始化,再启动定时器和广播,在主循环里实现任务调度和电源管理power_manage();


1.1.定时器

NRF51822的定时器由队列进行多个定时器的管理。

1.1.1.数据结构

定时器主要放在timer_node_t结构体组成数组中进行集中管理,存储的方式具体看timers_init中的解析。
timer_node_t的结构如下:

typedef struct
{timer_alloc_state_t         state;    /**< 定时器分配状态 */app_timer_mode_t            mode;     /**< 定时器模式 */uint32_t                    ticks_to_expire;  /**< 上一次定时器中断到终止的ticks. */uint32_t                    ticks_at_start;   /**< 当前当定时器启动的RTC计数值. */uint32_t                    ticks_first_interval;  /**< 第一次定时器间隔的ticks */uint32_t                    ticks_periodic_interval; /**< 时间周期 */bool                        is_running;   /**< True代表运行, False其他. */app_timer_timeout_handler_t p_timeout_handler;   /**< 指向当定时器倒是后调用的函数 */void *                      p_context;  /**<通用目标指针. 当定时器到时时,将进行超时处理。 */app_timer_id_t              next;     /**<下一个运行定时器的id*/
} timer_node_t;

app_timer.c中为定时器队列提供了基础的添加移除操作。

1.1.2.初始化函数

主函数中调用timers_init实现定时器的初始化

static void timers_init(void)
{// Initialize timer module, making it use the schedulerAPP_TIMER_INIT(APP_TIMER_PRESCALER, APP_TIMER_MAX_TIMERS, APP_TIMER_OP_QUEUE_SIZE, true);
}#define APP_TIMER_INIT(PRESCALER, MAX_TIMERS, OP_QUEUES_SIZE, USE_SCHEDULER)             \
    do                                                                        \
    {                                                                                 \
        static uint32_t APP_TIMER_BUF[CEIL_DIV(APP_TIMER_BUF_SIZE((MAX_TIMERS),          \
                                       (OP_QUEUES_SIZE) + 1),                     \
                                       sizeof(uint32_t))];                     \
        uint32_t ERR_CODE = app_timer_init((PRESCALER),                          \
                                      (MAX_TIMERS),                          \
                                      (OP_QUEUES_SIZE) + 1,                    \
                                       APP_TIMER_BUF,                            \
                                       (USE_SCHEDULER) ? app_timer_evt_schedule : NULL); \
        APP_ERROR_CHECK(ERR_CODE);                                                     \
    } while (0)

该初始化调用了app_timer.c中的app_timer_init,同时根据USE_SCHEDULER来设置回调函数app_timer_evt_schedule。

static __INLINE uint32_t app_timer_evt_schedule(app_timer_timeout_handler_t timeout_handler,void *                      p_context)
{app_timer_event_t timer_event;timer_event.timeout_handler = timeout_handler;timer_event.p_context       = p_context;return app_sched_event_put(&timer_event, sizeof(timer_event), app_timer_evt_get);
}

app_timer_evt_schedule中做了:
1>.生成一个事件。
2>.通过事件调度的API(app_sched_event_put)发送事件。

uint32_t app_timer_init(uint32_t                      prescaler,//预分频器uint8_t                       max_timers,//最大时间uint8_t                       op_queues_size,void *                        p_buffer,app_timer_evt_schedule_func_t evt_schedule_func)
{int i;// 检查缓冲区是否正确字对齐if (!is_word_aligned(p_buffer)){return NRF_ERROR_INVALID_PARAM;}if (p_buffer == NULL)// 检查空缓冲区{return NRF_ERROR_INVALID_PARAM;}rtc1_stop(); // RTC停止防止定时器时间移除后重新初始化m_evt_schedule_func = evt_schedule_func;//如果有调度则:app_timer_evt_schedule// Initialize timer node array初始化定时器节点数组APP_TIMER_BUFm_node_array_size = max_timers;mp_nodes          = p_buffer;for (i = 0; i < max_timers; i++){mp_nodes[i].state      = STATE_FREE;mp_nodes[i].is_running = false;}// Skip timer node arrayp_buffer = &((uint8_t *)p_buffer)[max_timers * sizeof(timer_node_t)];// Initialize users arraym_user_array_size = APP_TIMER_INT_LEVELS;mp_users          = p_buffer;// Skip user arrayp_buffer = &((uint8_t *)p_buffer)[APP_TIMER_INT_LEVELS * sizeof(timer_user_t)];// 初始化 operation队列for (i = 0; i < APP_TIMER_INT_LEVELS; i++){timer_user_t * p_user = &mp_users[i];p_user->first              = 0;p_user->last               = 0;p_user->user_op_queue_size = op_queues_size;p_user->p_user_op_queue    = p_buffer;// Skip operation queuep_buffer = &((uint8_t *)p_buffer)[op_queues_size * sizeof(timer_user_op_t)];}m_timer_id_head             = TIMER_NULL;m_ticks_elapsed_q_read_ind  = 0;m_ticks_elapsed_q_write_ind = 0;NVIC_ClearPendingIRQ(SWI0_IRQn);NVIC_SetPriority(SWI0_IRQn, SWI0_IRQ_PRI);NVIC_EnableIRQ(SWI0_IRQn);rtc1_init(prescaler);m_ticks_latest = rtc1_counter_get();return NRF_SUCCESS;
}

定时器初始化主要做了以下:
1>.设置事件回调函数(如果有),绑定的是app_timer_evt_schedule函数。
2>.初始化分配传进来数目的定时器,并分配好对应的空间
在app_timer.c中,定义了一些内部变量来管理整个定时器系统,一些参数放在传入的内存中保存。内存中的存放如下:

最开始的内存中保存每个定时器的分配状态(state)以及是否运行状态(is_running),这两个都是timer_node_t 结构体中的参数。
中间存放timer_user_t结构体的数据
最后一块存放对应的timer_user_op_t结构体数据。
3>.设置开启中断

1.1.2.创建定时器函数

在一开始分配完成定时器后,后续定时器在使用之前可以由用户自定义进行分配。分配只需调用app_timer_create函数即可。用户传入该定时器的工作模式和回调函数,正常情况下会找到空闲的定时器放在p_timer_id中返回,即用户得到当前分配的定时器id。过程中只修改了对应ID的mp_nodes的值。比如在该例程中初始化button的最后就分配了一个定时器。

uint32_t app_timer_create(app_timer_id_t *            p_timer_id,app_timer_mode_t            mode,app_timer_timeout_handler_t timeout_handler)
{int i;if (mp_nodes == NULL){return NRF_ERROR_INVALID_STATE;}if (timeout_handler == NULL){return NRF_ERROR_INVALID_PARAM;}if (p_timer_id == NULL){return NRF_ERROR_INVALID_PARAM;}    // 寻找看空闲的定时器for (i = 0; i < m_node_array_size; i++){if (mp_nodes[i].state == STATE_FREE){mp_nodes[i].state             = STATE_ALLOCATED;mp_nodes[i].mode              = mode;mp_nodes[i].p_timeout_handler = timeout_handler;*p_timer_id = i;return NRF_SUCCESS;}}return NRF_ERROR_NO_MEM;
}

1.1.3.定时器中断

app_timer.c中提供了一些对底层RTC进行操作的函数:
rtc1_init —— 初始化
rtc1_start —— 启动定时器
rtc1_stop —— 终止定时器
rtc1_counter_get —— 获得定时器的计数值
rtc1_compare0_set —— 设置过零比较器
RTC1_IRQHandler —— 定时器中断处理函数
从中断处理这里开始说起。

void RTC1_IRQHandler(void)
{// 清除事件NRF_RTC1->EVENTS_COMPARE[0] = 0;NRF_RTC1->EVENTS_COMPARE[1] = 0;NRF_RTC1->EVENTS_COMPARE[2] = 0;NRF_RTC1->EVENTS_COMPARE[3] = 0;NRF_RTC1->EVENTS_TICK       = 0;NRF_RTC1->EVENTS_OVRFLW     = 0;timer_timeouts_check();// 检测是否有到时间
}

timer_timeouts_check函数负责会设定的对应的应用是否到时间的定时器的检测。

static void timer_timeouts_check(void)
{if (m_timer_id_head != TIMER_NULL)  //处理到时间的定时器{app_timer_id_t  timer_id;uint32_t        ticks_elapsed;uint32_t        ticks_expired;//  初始化实际经过的ticks为0ticks_expired = 0;// ticks_elapsed(到期时间)在这里被得到, 现在的计数和上次计数的差值ticks_elapsed = ticks_diff_get(rtc1_counter_get(), m_ticks_latest);// Auto variable containing the head of timers expiring timer_id = m_timer_id_head;// 到时所有定时器 ticks_elapsed 并且获得ticks_expired (到期时间)while (timer_id != TIMER_NULL){timer_node_t * p_timer;p_timer = &mp_nodes[timer_id]; //获得当前定时器节点// 未超时则什么都不做if (ticks_elapsed < p_timer->ticks_to_expire){break;}// 递减ticks_elapsed(经过时间)值并获得expired ticks (到期时间)ticks_elapsed -= p_timer->ticks_to_expire;ticks_expired += p_timer->ticks_to_expire;// 检测下一个定时器timer_id = p_timer->next;//回调timeout_handler_exec(p_timer);}// 准备向m_ticks_elapsed队列中加ticks过期的队列 if (m_ticks_elapsed_q_read_ind == m_ticks_elapsed_q_write_ind){// 读需要等于写序号。这意味着ticks_expired新值需要被存储在新的地址// 在m_ticks_elapsed队列(作为双缓冲区实现的。)// 检测是否有队列溢出if (++m_ticks_elapsed_q_write_ind == CONTEXT_QUEUE_SIZE_MAX){// 队列溢出. 因此,写索引指向队列的开始m_ticks_elapsed_q_write_ind = 0;}}// 队列的ticks到时.m_ticks_elapsed[m_ticks_elapsed_q_write_ind] = ticks_expired;timer_list_handler_sched();}
}
static void timeout_handler_exec(timer_node_t * p_timer)
{if (m_evt_schedule_func != NULL){uint32_t err_code = m_evt_schedule_func(p_timer->p_timeout_handler, p_timer->p_context);APP_ERROR_CHECK(err_code);}else{p_timer->p_timeout_handler(p_timer->p_context);}
}

它这里的ticks_elapsed和ticks_expired我也被绕的晕乎乎的。但是抛开这个。这个函数的本意是对超时的定时器用他们一开始设置的回调函数的回调。下面的按键可以参考。调用的回调函数有两个m_evt_schedule_func 和 p_timer->p_timeout_handler。当有调度机制的时候调用前者,发送给调度内核,最后在主循环中来进行timer_create时绑定的回调函数调度。在这个例子中默认调用的都是app_timer_evt_schedule。如果没有调度机制则直接调用timer_create时绑定的回调函数。


还有一个SWI0中断,软件中断。
SWI0_IRQHandler ——SWI0中断,程序里很多地方会置位这个中断。比如前面提到的timer_timeouts_check。
SWI0中断中执行所有定时器更新

void SWI0_IRQHandler(void)
{timer_list_handler();
}static void timer_list_handler(void)
{app_timer_id_t restart_list_head = TIMER_NULL;uint32_t       ticks_elapsed;uint32_t       ticks_previous;bool           ticks_have_elapsed;bool           compare_update;app_timer_id_t timer_id_head_old;// 备份上一次已知的tick和List头ticks_previous    = m_ticks_latest;timer_id_head_old = m_timer_id_head;// 获得过去的ticks数ticks_have_elapsed = elapsed_ticks_acquire(&ticks_elapsed);// 处理链表缺失compare_update = list_deletions_handler();//处理到时间的定时器if (ticks_have_elapsed){expired_timers_handler(ticks_elapsed, ticks_previous, &restart_list_head);compare_update = true;}// 处理插入列表if (list_insertions_handler(restart_list_head)){compare_update = true;}// 必要时更新比较寄存器if (compare_update){compare_reg_update(timer_id_head_old);}
}

1.1.4.启动定时器

app_timer_start函数来启动某个定时器。这个函数里面有调用timer_start_op_schedule函数。这里分配函数为什么有个参数是mp_users

uint32_t app_timer_start(app_timer_id_t timer_id, uint32_t timeout_ticks, void * p_context)
{uint32_t timeout_periodic;// Schedule timer start operationtimeout_periodic = (mp_nodes[timer_id].mode == APP_TIMER_MODE_REPEATED) ? timeout_ticks : 0;return timer_start_op_schedule(user_id_get(),timer_id,timeout_ticks,timeout_periodic,p_context);
}static uint32_t timer_start_op_schedule(timer_user_id_t user_id,app_timer_id_t  timer_id,uint32_t        timeout_initial,uint32_t        timeout_periodic,void *          p_context)
{app_timer_id_t last_index;//分配一个操作队列timer_user_op_t * p_user_op = user_op_alloc(&mp_users[user_id], &last_index);if (p_user_op == NULL){return NRF_ERROR_NO_MEM;}p_user_op->op_type                              = TIMER_USER_OP_TYPE_START;p_user_op->timer_id                             = timer_id;p_user_op->params.start.ticks_at_start          = rtc1_counter_get();p_user_op->params.start.ticks_first_interval    = timeout_initial;p_user_op->params.start.ticks_periodic_interval = timeout_periodic;p_user_op->params.start.p_context               = p_context;user_op_enque(&mp_users[user_id], last_index);    timer_list_handler_sched();return NRF_SUCCESS;
}

1.2.按键

按键初始化,在buttons数组中定义了所有的用到的按键及其配置。具体意思参考app_button_cfg_t 结构体。
按键这里变量:
m_detection_delay_timer_id定时器。这个定时器用来计算延时,它在初始化中被创建,并设置计时时间到后回调detection_delay_timeout_handler函数。

static void buttons_init(void)
{// Note: Array must be static because a pointer to it will be saved in the Button handler//       module.static app_button_cfg_t buttons[] ={{WAKEUP_BUTTON_PIN, false, BUTTON_PULL, NULL},{LEDBUTTON_BUTTON_PIN_NO, false, BUTTON_PULL, button_event_handler}};APP_BUTTON_INIT(buttons, sizeof(buttons) / sizeof(buttons[0]), BUTTON_DETECTION_DELAY, true);
}
#define APP_BUTTON_INIT(BUTTONS, BUTTON_COUNT, DETECTION_DELAY, USE_SCHEDULER)  \do        \{                                          \uint32_t ERR_CODE = app_button_init((BUTTONS),       \(BUTTON_COUNT),         \(DETECTION_DELAY),    \(USE_SCHEDULER) ? app_button_evt_schedule : NULL); \APP_ERROR_CHECK(ERR_CODE);                         \} while (0)

同样,初始化中设置事件回调函数(如果有),绑定的是app_button_evt_schedule函数。这个函数里面的操作和定时器里面的操作差不多。

uint32_t app_button_init(app_button_cfg_t *             p_buttons,uint8_t                        button_count,uint32_t                       detection_delay,app_button_evt_schedule_func_t evt_schedule_func)
{uint32_t err_code;if (detection_delay < APP_TIMER_MIN_TIMEOUT_TICKS){return NRF_ERROR_INVALID_PARAM;}//保存配置.mp_buttons          = p_buttons;m_button_count      = button_count;m_detection_delay   = detection_delay;m_evt_schedule_func = evt_schedule_func;uint32_t pins_transition_mask = 0;   while (button_count--){app_button_cfg_t * p_btn = &p_buttons[button_count];nrf_gpio_cfg_input(p_btn->pin_no, p_btn->pull_cfg);   //硬件配置pins_transition_mask |= (1 << p_btn->pin_no); //创建用户中断注册屏蔽位}// Register button module as a GPIOTE user.err_code = app_gpiote_user_register(&m_gpiote_user_id,pins_transition_mask,pins_transition_mask,gpiote_event_handler);if (err_code != NRF_SUCCESS){return err_code;}// Create polling timer.return app_timer_create(&m_detection_delay_timer_id,APP_TIMER_MODE_SINGLE_SHOT,detection_delay_timeout_handler);
}

按键初始化中的操作主要是对按键部分管理的变量做了个初始化,然后配置了硬件和中断部分,并且设置了中断回调函数gpiote_event_handler,标记了引脚电平状态。

当按键被按下后,系统首先会回调gpiote_event_handler函数。同时设置对应的延时参数后启动的定时器计时。

static void gpiote_event_handler(uint32_t event_pins_low_to_high, uint32_t event_pins_high_to_low)
{uint32_t err_code;// 开始检测计时器。如果定时器正在运行,检测周期重新开始//注意: 使用app_timer_start()中的p_context参数来向定时器句柄传递引脚状态 STATIC_ASSERT(sizeof(void *) == sizeof(uint32_t));err_code = app_timer_stop(m_detection_delay_timer_id);    //停止定时器if (err_code != NRF_SUCCESS){// The impact in app_button of the app_timer queue running full is losing a button press.// The current implementation ensures that the system will continue working as normal. return;}m_pin_transition.low_to_high = event_pins_low_to_high;m_pin_transition.high_to_low = event_pins_high_to_low;err_code = app_timer_start(m_detection_delay_timer_id,m_detection_delay,(void *)(event_pins_low_to_high | event_pins_high_to_low));if (err_code != NRF_SUCCESS){// The impact in app_button of the app_timer queue running full is losing a button press.// The current implementation ensures that the system will continue working as normal. }
}

当检测延时时间达到后调用detection_delay_timeout_handler回调函数,这个函数里面又会调用button_handler_execute按键按下的执行函数。在这个函数中会调用前面的回调函数app_button_evt_schedule发送事件给调度内核。当下次内核调度这个事件的时候,就会调度按键响应事件了,在这个例子中LEDBUTTON_BUTTON_PIN_NO按下调用button_event_handler,这个是修改服务中特性的值,这里先不讲。

static void detection_delay_timeout_handler(void * p_context)
{uint32_t err_code;uint32_t current_state_pins;//获得当前引脚状态err_code = app_gpiote_pins_state_get(m_gpiote_user_id, &current_state_pins);if (err_code != NRF_SUCCESS){return;}uint8_t i;// 按下按键检测,执行按键句柄for (i = 0; i < m_button_count; i++){app_button_cfg_t * p_btn = &mp_buttons[i];if (((m_pin_transition.high_to_low & (1 << p_btn->pin_no)) != 0) && (p_btn->button_handler != NULL)){//如果对应按键有效为高电平,然后从高到低跳变的释放过程if(p_btn->active_state == APP_BUTTON_ACTIVE_HIGH){button_handler_execute(p_btn, APP_BUTTON_RELEASE);}//如果对应按键有效为低电平,然后从高到低跳变的按下过程else{button_handler_execute(p_btn, APP_BUTTON_PUSH);}}else if (((m_pin_transition.low_to_high & (1 << p_btn->pin_no)) != 0) && (p_btn->button_handler != NULL)){//如果对应按键有效为高电平,然后从低到高跳变的按下过程if(p_btn->active_state == APP_BUTTON_ACTIVE_HIGH){button_handler_execute(p_btn,APP_BUTTON_PUSH);}//如果对应按键有效为低电平,然后从低到高跳变的释放过程else{button_handler_execute(p_btn,APP_BUTTON_RELEASE);}}}
}static void button_handler_execute(app_button_cfg_t * p_btn, uint32_t transition)
{if (m_evt_schedule_func != NULL){uint32_t err_code = m_evt_schedule_func(p_btn->button_handler, p_btn->pin_no,transition);APP_ERROR_CHECK(err_code);}else{if(transition == APP_BUTTON_PUSH){p_btn->button_handler(p_btn->pin_no, APP_BUTTON_PUSH);}else if(transition == APP_BUTTON_RELEASE){p_btn->button_handler(p_btn->pin_no, APP_BUTTON_RELEASE);}}
}

蓝牙nrf51822程序的分析(一)相关推荐

  1. 浅析蓝牙nrf51822程序框架

    这篇先分析一下提供模板的框架部分程序. 这里以模板的代码(灯和按键)为例: http://download.csdn.net/download/dfsae/9987318 1.主函数 NRF51822 ...

  2. stm32f103HC05蓝牙串口程序和自制手机APP

    我最近用stm32的蓝牙串口功能,写了这篇文章分享,有不足之处欢迎指正. 一.准备 功能:用APP控制STM32F103单片机上的部件如:LED小灯. 硬件:蓝牙模块.STM32F103mini(或其 ...

  3. 通过汇编一个简单的C程序,分析汇编代码理解计算机是如何工作的

    实验目的: 通过反汇编一个简单的C程序,分析汇编代码理解计算机是如何工作的 实验过程: 通过vi程序进行编程: int g(int x) { return x + 3; } int f(int x) ...

  4. Java程序内存分析

    2019独角兽企业重金招聘Python工程师标准>>> Java程序内存分析:使用mat工具分析内存占用 http://my.oschina.net/biezhi/blog/2862 ...

  5. Android 蓝牙开发实例--蓝牙聊天程序的设计和实现

    作者在这里介绍的这个实例是Google SDK中提供的一个蓝牙聊天程序,简单但信息量巨大,非常适合初学者学习蓝牙方面的知识. 在学习这个实例前请读者仔细阅读并理解Socket的工作原理和实现机制,作者 ...

  6. android应用内存分析,Android应用程序内存分析-Memory Analysis for Android Applications

    Android应用程序内存分析 原文链接:http://android-developers.blogspot.com/2011/03/memory-analysis-for-android.html ...

  7. 如何通过“流量线索”进行恶意程序感染分析

    流量安全分析(五):如何通过"流量线索"进行恶意程序感染分析 from: https://www.sec-un.org/traffic-safety-analysis-v-how- ...

  8. 【Windows 逆向】CheatEngine 工具 ( 汉化版 CE 工具推荐 | 编写简单 C++ 程序 | C++ 程序执行分析 | 使用 CE 修改上述 C++ 程序 )

    文章目录 一.汉化版 CE 工具推荐 二.编写简单 C++ 程序 三.C++ 程序执行分析 四.使用 CE 修改上述 C++ 程序 一.汉化版 CE 工具推荐 推荐一个汉化版的 CE 工具 : htt ...

  9. Android蓝牙串口程序开发

    本文主要介绍了针对android的蓝牙串口上位机开发. 程序下载地址:点击打开链接 一.帧定义 androidclient依照一定的数据帧格式通过蓝牙串口发送数据到连接到MCU的蓝牙从机.MCU接收到 ...

最新文章

  1. Reactor by Example--转
  2. Idea中一个服务按多个端口同时启动
  3. opencv_contrib4.4安装
  4. 【STM32】随机数发生器详解
  5. HDU4546(优先队列)
  6. spring-boot 添加http自动转向https
  7. 事件冒泡 bubbles cancelBubble stopPropagation() stopImmediatePropagation() 区别
  8. 墨条不如墨汁黑是怎么回事?
  9. centos系统降级
  10. matlab plot3 宽度,matlab设置plot图像尺寸大小、坐标轴等
  11. 在odl中怎样实现rpc
  12. ILI9341的使用之【五】命令一
  13. k8s FailedCreatePodSandBox: Failed create pod sandbox
  14. python求音频的梅尔倒谱系数
  15. 【DeprecationWarning: BICUBIC is deprecated and will be removed in Pillow 10 (2023-07-01).的解决方案】
  16. PS教程!手把手教你绘制3个效果酷炫的GIF动画效果
  17. xampp 下载地址
  18. python获取数据库返回字符串出现/uxxxxxx解决方案
  19. flash 林度_知乎日报
  20. 安卓基本知识--备用

热门文章

  1. Bug改不完,迭代总延期,咋办?
  2. 你想要的视频号运营攻略都在这,以及不可触碰的8大雷区丨国仁网络资讯
  3. [rm]realmedia文件格式解析
  4. 会Python的淘宝商家可以横扫一切竞争对手,这就是会技术的魅力!(上)
  5. AOP 原理分析《四》- 获取增强器
  6. 转:web开发常用js收藏三
  7. 把图片中的文字转成文本
  8. 微信小程序踩坑日记-微信小程序首次加载样式错乱问题
  9. JS练习 -- 动态加载JS
  10. 一刻相册全/批量下载相册到电脑