AVR單片機教程——示波器

本文隸屬於AVR單片機教程系列。html

 

在用DAC作了一個稍大的項目以後,咱們來拿ADC開開刀。在本講中,咱們將瞭解0.96寸OLED屏,移植著名的U8g2庫到咱們的開發板上,學習在屏幕上畫直線的算法,編寫一個示波器程序,使用EEPROM加入人性化功能,最後利用示波器觀察555定時器、放大電路、波形變換電路的各類波形。git

OLED屏

咱們使用的是0.96寸OLED屏,它由128*64個像素點構成,上16行爲藍色,下48行爲黃色,兩部分之間有大約兩像素的空隙。雖然有兩種顏色,但每一個像素點都只能發出一種顏色的光,所以這塊OLED屏算做單色屏。github

能夠插在開發板上的是顯示屏模塊,它由裸屏和PCB等組成,裸屏經過30 pin的排線焊接在PCB的反面。算法

在裸屏的內部有一塊控制與驅動芯片,型號爲SSD1315,與SSD1306兼容,它是外部與像素點之間的橋樑。SSD1315有200多個引腳,其中128個segment和64個common以動態掃描的方式驅動每個像素點,這就是它爲何必須作在裸屏的內部。除了這些之外,它還有許多電源和控制引腳:編程

  • VDD是控制邏輯的供電,範圍爲1.65V到3.5V;VCC是OLED面板驅動電壓,範圍爲7.5V到16.5V;VBAT是內部電荷泵的供電,範圍爲3.0V到4.5V,VBAT經電荷泵升壓後提供給VCC,此時VCC須要鏈接電容到地;電荷泵須要兩個外部電容,鏈接在C1PC1NC2PC2N之間;VCOMH是一個內部電壓,須要鏈接電容到地;VSSVLSSBGGNDLS都接地;IREF用於控制參考電壓。數組

  • BS[2:0]用於選擇接口模式,支持4線SPI、3線SPI、I²C、8位8080和6800;E(RD)R/W(WR)在並行模式下使用;D[7:0]爲數據,在SPI模式下,D0是時鐘信號,D1是輸入數據信號,D2鏈接D1或地;在I²C模式下,D0是時鐘信號,D1D2一塊兒是數據信號;RES是復位信號;CS是片選信號;D/C用於指定輸入是數據仍是指令,在I²C模式下爲地址選擇,在3線SPI模式下保持低電平;FRCLCLS都是時鐘信號。緩存

看起來很複雜,但事實上有些信號根本不用管,由於裸屏只有30個引腳,去掉了BS2E(RD)R/W(WR)D[7:3]FRCLCLS,這些都是不經常使用的(除了FR幀同步信號,我以爲有點用)。剩下的你也許須要學,但不是如今,而是在你的項目須要用裸屏的時候,由於那塊藍色的PCB把這些都處理好了,只留下了7個引腳:GNDVCCD0D1RESDCCS。可用的通訊模式只有4線SPI、3線SPI和I²C,但已經至關豐富了,能夠經過模塊背面的電阻來選擇,出廠時是4線SPI,也就是咱們將要使用的模式。有的模塊只支持I²C模式,也就只須要4個引腳了。併發

在4線SPI模式下,D0鏈接單片機USART1的XCK1D1鏈接TXD1CS鏈接PB2,這些是標準SPI的信號;RES鏈接PB0D/C鏈接PB1。芯片在時鐘上升沿採樣數據信號,SPI模式0或3均可以使用。接下來咱們來看總線上的數據。app

D/C爲低時,總線上傳輸的是控制指令;當D/C爲高時,總線上傳輸的是顯示數據。64行被分爲8頁,芯片內部有1024字節的顯存,每一字節對應一頁中的一列,也就是縱向8個像素:編程語言

顯存支持頁面、水平、垂直三種尋址模式,伴隨有一個指針,每寫入一字節數據,指針就以某種形式增加,相似於咱們在C中寫的*ptr++

芯片支持不少指令,它們的長度由第一個字節決定,有各自的格式,大體能夠分爲如下幾類:

  • 顯存:尋址模式、行列地址、頁面地址;

  • 顯示:起始行、顯示行數、對比度、各類remap、全亮、反轉、睡眠、偏移;

  • 電源:IREF電流大小、VCOMH電壓閾值、電荷泵開關;

  • 時鐘:時鐘頻率、時鐘分頻、預充電週期;

  • 滾動:水平滾動、水平垂直滾動、滾動區域、啓用禁用滾動;

  • 高級:淡化、閃爍、放大。

對照着datasheet,咱們來寫幾個指令,讓屏幕亮起來。

#include <stdarg.h>
#include <avr/io.h>
#include <ee2/bit.h>

void spi_init()
{
    UCSR1B =    1 << TXEN1;
    UCSR1C = 0b11 << UMSEL10
#define              UDORD1 2
           |    0 << UDORD1
#define              UCPHA1 1
           |    0 << UCPHA1
           |    0 << UCPOL1;
    set_bit(DDRD, 3);
    set_bit(DDRD, 4);
}

void spi_send(uint8_t _data)
{
    UDR1 = _data;
    while (!read_bit(UCSR1A, TXC1))
        ;
    set_bit(UCSR1A, TXC1);
}

void oled_init()
{
    spi_init();
    set_bit(DDRB, 0);  // RES
    set_bit(DDRB, 1);  // DC
    set_bit(DDRB, 2);  // CS
    set_bit(PORTB, 2); // CS  high
    set_bit(PORTB, 0); // RES high
}

void oled_control(uint8_t _size, ...)
{
    reset_bit(PORTB, 1); // DC low
    reset_bit(PORTB, 2); // CS low
    va_list args;
    va_start(args, _size);
    for (uint8_t i = 0; i != _size; ++i)
        spi_send(va_arg(args, int));
    va_end(args);
    set_bit(PORTB, 2);   // CS high
}

void oled_data(uint16_t _size, const uint8_t* _data)
{
    set_bit(PORTB, 1);   // DC high
    reset_bit(PORTB, 2); // CS low
    for (const uint8_t* end = _data + _size; _data != end; ++_data)
        spi_send(*_data);
    set_bit(PORTB, 2);   // CS high
}

int main(void)
{
    oled_init();
    oled_control(2, 0x8D, 0x95); // enable charge pump
    oled_control(1, 0xA1);       // segment remap
    oled_control(1, 0xC8);       // common remap
    oled_control(1, 0xAF);       // display on
    uint8_t data[128];
    for (uint8_t i = 0; i != 128; ++i)
        data[i] = i;
    for (uint8_t i = 0; i != 8; ++i)
    {
        oled_control(1, 0xB0 + i);
        oled_data(128, data);
    }
    while (1)
        ;
}

先來看指令:

  • 0x8D, 0x95啓用內置電荷泵,將輸出電壓設置爲9.0V;

  • 0xA10xC8分別設置segment和common的remap,由於另外一份datasheet中指明,顯示屏的第一行鏈接Common 62,第一列鏈接Segment 127

  • 0xAF開啓顯示,顯示是默認關閉的,須要手動開啓;

  • 0xB00xB7設置頁面尋址模式下的頁面地址,這是默認的尋址模式,咱們在循環中先設置地址,再發送128字節的數據,內容是0127,循環8次,把每一頁都填滿。

畫出的是一個美麗的分形圖:

再來看oled_control這個函數。參數列表的最後是...,表示可變參數。在函數調用時,匹配到...的參數須要用<stdarg.h>中的工具取用:

  • va_list是一個類型,建立一個這個類型的變量,表示可變參數列表;

  • va_start是一個宏,第一個參數爲va_list變量,第二個爲可變參數的數量;

  • va_arg取出可變參數列表中的下一個變量,類型由第二個參數指定;

  • va_end在使用完可變參數後作一些清理工做。

須要提醒的是,編譯器沒法檢查標稱的參數數量和類型與實際的是否符合。

移植U8g2庫

U8g2是一個著名的單色顯示屏驅動與圖形庫。「U」是universal,支持衆多顯示驅動芯片;「8」是8-bit,單片機與芯片以字節爲單位通訊;「g」是graphics,有繪製各類圖形的函數;「2」是第二代。

文首的資料中包含了U8g2倉庫的所有資料,下載於2020年2月9日,你也能夠從GitHub上下載。C源代碼在文件夾csrc中,包含頭文件與實現。爲了在咱們的項目中包含這些文件,咱們在Atmel Studio的Solution Explorer中對項目右鍵,點擊Add→New Folder,命名爲「u8g2」,而後右鍵它並點擊Add→Existing Item,選擇csrc中的文件,它們就會被拷貝到項目目錄下,在代碼中能夠經過`#include <u8g2/u8g2.h>引用頭文件。

U8g2的使用很簡單,Wiki告訴咱們,要首先建立u8g2_t類型的對象,隨後每一個函數的第一個參數都是它的指針。先根據顯示屏的芯片型號選擇合適的設置函數,初始化後就有那麼多函數可使用了。

U8g2沒有提供SSD1315的驅動,但因爲SSD1315與SSD1306兼容,咱們能夠選擇u8g2_Setup_ssd1306_128x64_noname_f函數。後綴爲_f的函數在RAM中設置了整個緩存,共128 * 64 / 8 = 1KB,這樣用起來比較方便。

移植的核心就在於初始化時註冊的兩個回調函數。根據Wiki,咱們要提供的兩個函數的模板爲:

uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    case U8X8_MSG_BYTE_INIT:
        break;
    case U8X8_MSG_BYTE_SET_DC:
        break;
    case U8X8_MSG_BYTE_START_TRANSFER:
        break;
    case U8X8_MSG_BYTE_SEND:
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        break;
    default:
        return 0;
    }
    return 1;
}

uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    case U8X8_MSG_GPIO_AND_DELAY_INIT:
        break;
    case U8X8_MSG_DELAY_NANO:
        break;
    case U8X8_MSG_DELAY_100NANO:
        break;
    case U8X8_MSG_DELAY_10MICRO:
        break;
    case U8X8_MSG_DELAY_MILLI:
        break;
    case U8X8_MSG_GPIO_CS:
        break;
    case U8X8_MSG_GPIO_DC:
        break;
    case U8X8_MSG_GPIO_RESET:
        break;
    default:
        return 0;
    }
    return 1;
}

如今咱們來一一填寫其中的語句:

  • U8X8_MSG_GPIO_AND_DELAY_INIT,初始化GPIO與延時;

    set_bit(DDRB, 0);
    set_bit(DDRB, 1);
    set_bit(DDRB, 2);
  • U8X8_MSG_DELAY_NANO,延時若干納秒,不超過100ns,因爲CPU週期是40ns,函數調用的時間已經超過了100ns,所以什麼都不作;

  • U8X8_MSG_DELAY_100NANO,延時幾百納秒,使用`<util/delay.h>提供的工具,延時精確到微秒,微秒數爲參數除以10,因爲除以10很慢,改成除以8;

    #define __DELAY_BACKWARD_COMPATIBLE__
    #define F_CPU 25000000UL
    #include <util/delay.h>
    _delay_us(arg_int >> 3);
  • U8X8_MSG_DELAY_10MICRO,延時幾十微秒,一樣使用_delay_us

    _delay_us(arg_int * 10);
  • U8X8_MSG_GPIO_CSU8X8_MSG_GPIO_DCU8X8_MSG_BYTE_INIT,分別設置CSD/CRES引腳電平,值爲arg_int

    case U8X8_MSG_GPIO_CS:
        cond_bit(arg_int, PORTB, 2);
        break;
    case U8X8_MSG_GPIO_DC:
        cond_bit(arg_int, PORTB, 1);
        break;
    case U8X8_MSG_GPIO_RESET:
        cond_bit(arg_int, PORTB, 0);
        break;

    以上是第二個函數;

  • U8X8_MSG_BYTE_INIT,通訊的初始化,照搬spi_init函數就能夠了;

    UCSR1B =    1 << TXEN1;
        UCSR1C = 0b11 << UMSEL10
    #define              UDORD1 2
               |    0 << UDORD1
    #define              UCPHA1 1
               |    0 << UCPHA1
               |    0 << UCPOL1;
        set_bit(DDRD, 3);
        set_bit(DDRD, 4);
  • U8X8_MSG_BYTE_SET_DC,設置D/C引腳的電平,這在上面已經寫過了,能夠經過u8x8_gpio_SetDC來轉發;

    u8x8_gpio_SetDC(u8x8, arg_int);
  • U8X8_MSG_BYTE_START_TRANSFERU8X8_MSG_BYTE_END_TRANSFER,開始傳輸和結束傳輸,即拉低和拉高CS電平;

    case U8X8_MSG_BYTE_START_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 0);
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 1);
        break;
  • U8X8_MSG_BYTE_SEND,發送數據,內容在arg_ptr中,大小爲arg_int字節;

    for (const uint8_t* ptr = arg_ptr, *end = ptr + arg_int;
            ptr != end; ++ptr)
        {
            UDR1 = *ptr;
            while (!read_bit(UCSR1A, TXC1))
                ;
            set_bit(UCSR1A, TXC1);
            UDR1;
        }

咱們再來細品一下回調這個概念。

可是回調是有必定代價的,本來能夠調用肯定的函數,或者直接內聯,如今須要使用函數指針了。衆所周知,指令也是數據,存儲在flash中;函數是指令序列,它的第一個指令的地址就是函數指針的值。CPU中有一個特殊的寄存器,叫程序計數器(Program Counter,PC),它保存着CPU要執行的指令的地址;函數指針是變量,保存在寄存器中,用函數指針調用函數本質上是把寄存器的內容加載進PC中。

現代CPU都是多級流水線的,CPU在執行一條指令的同時,取指部件會將待執行的指令從flash中取出,這是由於flash的讀取每每比CPU慢。可是,遇到從寄存器加載PC的指令時,取指部件不知道下一條指令的位置,必須等待CPU譯碼、執行後,才能根據PC去取指令,須要額外消耗幾個CPU週期。好在這個消耗不大,而且CPU已經足夠快,咱們不多考慮函數指針與回調帶來的overhead。事實上C++的虛函數就是用函數指針實現的,而C++是以運行時效率著稱的編程語言。

而後咱們就能夠開心地畫圖了!

#include <avr/io.h>
#include <avr/interrupt.h>
#define __DELAY_BACKWARD_COMPATIBLE__
#define F_CPU 25000000UL
#include <util/delay.h>
#include <ee2/bit.h>
#include "u8g2/u8g2.h"

static u8g2_t u8g2;

static uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
static uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);

int main(void)
{
    u8g2_Setup_ssd1306_128x64_noname_f(&u8g2, U8G2_R0, u8x8_comm_callback, u8x8_gpio_delay_callback);
    u8g2_InitDisplay(&u8g2);
    u8g2_SetPowerSave(&u8g2, 0);
    u8g2_SetFont(&u8g2, u8g2_font_10x20_mr);
    u8g2_DrawStr(&u8g2, 0, 15, "AVR tutorial");
    u8g2_DrawStr(&u8g2, 0, 31, "by Jerry Fu");
    u8g2_SendBuffer(&u8g2);
    while (1)
        ;
}

u8g2_Setup_ssd1306_128x64_noname_f進行一些本機的初始化;u8g2_InitDisplay給芯片發送初始化序列,就是0x8D, 0x95之類的;u8g2_SetPowerSave關閉顯示屏睡眠,也就是開啓顯示,這些指令都是在函數調用時就發送的。

u8g2_SetFont設置畫字符的字體,u8g2_font_10x20_mr是一種16像素高的字體;u8g2_DrawStr在緩存中畫字符串,兩個數字分別是橫縱座標,在計算機屏幕上y軸是向下的;u8g2_SendBuffer更新顯示屏顯示,調用後顯示屏上就會出現文字了。必定要注意,全部u8g2_Draw*函數都是在緩存中繪圖,要調用u8g2_SendBuffer纔會顯示。

picture

回調的另外一個好處是方便插入中間層。好比,我想知道U8g2向OLED屏發送了什麼指令,只需簡單地修改回調函數:

static uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    static bool control;
    switch (msg)
    {
    // ...
    case U8X8_MSG_BYTE_SET_DC:
        control = !arg_int;
        u8x8_gpio_SetDC(u8x8, arg_int);
        break;
    case U8X8_MSG_BYTE_SEND:
        for (const uint8_t* ptr = arg_ptr, *end = ptr + arg_int;
            ptr != end; ++ptr)
        {
            if (control)
            {
                uart_set_align(ALIGN_RIGHT, 2, '0');
                uart_print_hex(*ptr);
                uart_print_char(' ');
            }
            UDR1 = *ptr;
            while (!read_bit(UCSR1A, TXC1))
                ;
            set_bit(UCSR1A, TXC1);
            UDR1;
        }
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        if (control)
            uart_print_line();
        u8x8_gpio_SetCS(u8x8, 1);
        break;
    }
    return 1;
}

static uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    // ...
    case U8X8_MSG_GPIO_RESET:
        if (!arg_int)
            uart_print_string("reset\n");
        cond_bit(arg_int, PORTB, 0);
        break;
    }
    return 1;
}

而後在main中加入uart_init(UART_TX_256, 384);。串口收到如下信息:

reset
AE D5 80 A8 3F D3 00 40 8D 14 20 00 A1 C8 DA 12 81 CF D9 F1 DB 40 2E A4 A6 
AF 
40 10 00 B0 40 10 00 B1 40 10 00 B2 40 10 00 B3 40 10 00 B4 40 10 00 B5 40 10 00 B6 40 10 00 B7
  • 第一行是u8g2_InitDisplay發送的指令:

    0xAE關閉顯示屏;0xD5, 0x80設置時鐘頻率最高,分頻係數爲1,也就是顯示頻率最高;0xA8, 0x3F設置複用比爲64,顯示64行;0xD3, 0x00設置縱向顯示偏移爲0;0x40設置顯示從第0行開始;0x8D, 0x14啓用電荷泵,電壓7.5V;0x20, 0x00使用水平尋址模式,但庫的做者誤認爲是頁面尋址模式;0xA1設置segment remap;0xC8設置common remap;0xDA, 0x12設置交錯common模式;0x81, 0xCF設置對比度爲0xCF0xD9, 0xF1設置預充電週期,放電階段時間最短,充電階段時間最長;0xDB, 0x40設置VCOMH電壓,亮度與之正相關,但0x40是一個無效值,這個錯誤能夠追溯到Adafruit的SSD1306庫中;0x2E禁用滾動;0xA4設置顯示內容跟隨RAM;

  • 第二行是u8g2_SetPowerSave發送的指令:

    0xA6設置顯示不反轉;0xAF開啓顯示屏,初始化結束;

  • 第三行是u8g2_SendBuffer發送的指令:

    0x40設置起始行爲第0行;0x100x00設置起始列爲第0列;0xB*設置頁面地址爲0到7;可是在水平尋址模式下,後3個指令都是沒有用的,不信你本身寫一個試試。

咱們後面還會用到幾個函數,這裏簡要介紹一下:

Bresenham直線算法

給定兩個點,如何畫一條線段?

用尺畫唄,還能怎麼畫?

可是,第一,計算機沒有尺;第二,計算機的屏幕是由像素點組成的,畫一條兩點之間的線段,其實是在尋找與理論位置最接近的像素點的集合。咱們將要學習的Bresenham算法是解決這個問題的一個經典而且高效的算法,它只涉及整數運算,無需除法,就能夠在與兩點之間距離成線性關係的時間內,使用常數大小的內存,計算出須要繪製的點的座標。

這個算法的輸入是4個整數\(x_1\)\(y_1\)\(x_2\)\(y_2\)表示2個座標,輸出是一系列座標,每計算出一個就繪製它,不存儲到數組中。爲了方便理解,咱們先假設\(x_1 < x_2\)\(0 \leq k = \frac {y_2 - y_1} {x_2 - x_1} \leq 1\)

咱們把像素視爲格點,每一個像素點均可以用惟一的座標表示。因爲\(0 \leq k \leq 1\),每一列都只會有一個像素點是所求直線的一部分。爲了求橫座標爲\(x_0\)的一列上的這個點,咱們應該計算\((x_1, y_1)\)\((x_2, y_2)\)這兩點所肯定的直線與直線\(x = x_0\)的交點,而後把交點的縱座標取整,做爲格點也就是要繪製的像素點的縱座標。

對於兩個相差\(1\)的橫座標,對應精確縱座標相差\(k\),取整後相差\(0\)\(1\)。Bresenham算法就是經過判斷這個差值是\(0\)仍是\(1\)來計算的。咱們遍歷從\(x_1\)\(x_2\)\(x\),維護兩個變量:\(y\),表示當前繪製到的縱座標,初始值爲\(y_1\)\(e\),表示偏差,若是把\(x\)對應的縱座標肯定爲\(y\),理論值比實際值大了多少,初始值爲\(0\)

\(x\)每加\(1\),若是\(y\)不變,根據咱們上面的分析,\(e\)就會增長\(k\)。當\(-0.5 \leq e \le 0.5\)時,咱們沒法找到更精確的\(y\),所以\(y\)不變;當\(e \geq 0.5\)時,把\(y\)加上\(1\)會獲得更精確的座標,那麼實際值加上\(1\)之後,偏差也就要減去\(1\)

咱們用pixel(x, y)表示繪製\((x, y)\)這個像素點。以上算法能夠用C代碼描述:

double e = 0;
for (int x = x1, y = y1; x <= x2; ++x)
{
    if (e >= 0.5)
    {
        ++y;
        e -= 1;
    }
    pixel(x, y);
    e += k;
}

可是這樣涉及到浮點數了。咱們注意到,\(k\)是一個有理數,能夠經過把全部與\(k\)相關的數都乘上\(k\)的分母來把它化爲整數。\(e\)初始值爲0,運算都是加上\(k\)或減去\(1\),乘上\(k\)的分母后就是整數了。\(0.5\)\(k\)的分母未必是整數,可是取整至多相差\(0.5\),也看成整數來處理。與\(0\)比較比與變量比較更快一些,所以咱們把\(e\)的初值設爲\(-0.5\)\(k\)的分母,而後與\(0\)比較。這樣線性處理後的\(e\)在如下代碼中用\(er\)表示:

int er = (x1 - x2) >> 1;
for (int x = x1, y = y1; x <= x2; ++x)
{
    if (er >= 0)
    {
        ++y;
        er -= x2 - x1;
    }
    pixel(x, y);
    er += y2 - y1;
}

那麼如何把全部的狀況化歸到符合簡化條件的呢?咱們結合U8g2的源碼來看:

void u8g2_DrawLine(u8g2_t *u8g2, u8g2_uint_t x1, u8g2_uint_t y1, u8g2_uint_t x2, u8g2_uint_t y2)
{
    // part 1
    u8g2_uint_t tmp;
    u8g2_uint_t x,y;
    u8g2_uint_t dx, dy;
    u8g2_int_t err;
    u8g2_int_t ystep;
    uint8_t swapxy = 0;
    /* no intersection check at the moment, should be added... */
    
    // part 2
    if ( x1 > x2 ) dx = x1-x2; else dx = x2-x1;
    if ( y1 > y2 ) dy = y1-y2; else dy = y2-y1;
    if ( dy > dx ) 
    {
        swapxy = 1;
        tmp = dx; dx =dy; dy = tmp;
        tmp = x1; x1 =y1; y1 = tmp;
        tmp = x2; x2 =y2; y2 = tmp;
    }
    
    // part 3
    if ( x1 > x2 ) 
    {
        tmp = x1; x1 =x2; x2 = tmp;
        tmp = y1; y1 =y2; y2 = tmp;
    }
    
    // part 4
    err = dx >> 1;
    if ( y2 > y1 ) ystep = 1; else ystep = -1;
    y = y1;
#ifndef  U8G2_16BIT
    if ( x2 == 255 )
        x2--;
#else
    if ( x2 == 0xffff )
        x2--;
#endif
    
    // part 5
    for( x = x1; x <= x2; x++ )
    {
        if ( swapxy == 0 ) 
            u8g2_DrawPixel(u8g2, x, y); 
        else 
            u8g2_DrawPixel(u8g2, y, x); 
        err -= (uint8_t)dy;
        if ( err < 0 ) 
        {
        y += (u8g2_uint_t)ystep;
        err += (u8g2_uint_t)dx;
        }
    }
}
  • 第一部分是變量定義,intersection那一句註釋的意思是,沒有檢查直線是否須要繪製(U8g2容許設置部分緩存,每次繪製畫面的一部分併發送,屢次繪製一樣的畫面,以時間換空間;若是直線不在當前繪製的畫面中,後面的計算就不須要了,能夠節省時間;這個函數沒有作這樣的檢查);

  • 第二部分先計算\(dx = |x_1 - x_2|, dy = |y_1 - y_2|\),而後交換橫縱座標以保證斜率的絕對值不超過\(1\)

  • 第三部分判斷\(x_1\)\(x_2\)的大小關係,交換兩點座標使\(x_1 \leq x_2\),這是爲了使後面的for循環有效;

  • 第四部分初始化Bresenham算法須要使用的變量,err與以前代碼中的er是相反數的關係;ystepy變化的方向;檢查x2 == 255是爲了防止後面出現死循環;

  • 第五部分就是Bresenham算法了,根據swapxy判斷橫縱座標是否須要對換;err < 0沒有等號,這只不過是一個\(0.5\)向上進仍是向下舍的問題;當ystep-1時,因爲dxdy都是取了絕對值的,計算起來與\(k\)取相反數的對應狀況沒有區別,不過是y變化的方向反了。

示波器

示波器是顯示電壓波形的儀器。它未必比萬用表精確,但能反映出電壓隨時間變化的狀況。咱們來製做一個示波器,它有兩個通道,採樣間隔從10μs到10ms可調,帶有自適應功能,即把波形平移放大到便於觀測。兩個按鍵用於調整時間間隔,兩個開分別用於暫停顯示和開啓第二通道。這些功能對咱們學習模擬電路有幫助。

這麼多功能也許有點複雜,咱們先從最簡單的開始作起,這個版本沒有任何花裏胡哨的玩意兒,只有一個128*48的波形顯示區域,採樣率也固定在1kHz,別的什麼都沒有。

程序的基本思路是,在1ms的定時器中斷中記錄ADC讀到的8位數據(顯示屏的垂直分辨率還不到6位,沒有必要讀10位數據),每當讀取到的數據量能填滿顯示屏時,也就是採樣了128次時,處理數據並更新顯示:

#include "u8g2/u8g2.h"
#define __DELAY_BACKWARD_COMPATIBLE__
#define F_CPU 25000000UL
#include <util/delay.h>
#include <ee2/bit.h>
#include <ee2/adc.h>
#include <ee2/timer.h>

static uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
static uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
static u8g2_t u8g2;

uint8_t map(uint8_t _value)
{
    return 63 - ((_value * 3) >> 4);
}

void timer()
{
    static uint8_t phase = 0;
    static uint8_t waveform[128];
    waveform[phase++] = adc_read(ADC_0);
    if (phase == 128)
    {
        phase = 0;
        u8g2_SetDrawColor(&u8g2, 0);
        u8g2_DrawBox(&u8g2, 0, 16, 128, 48);
        u8g2_SetDrawColor(&u8g2, 1);
        for (uint8_t i = 1; i != 128; ++i)
            u8g2_DrawLine(&u8g2, i - 1, map(waveform[i - 1]),
                                 i    , map(waveform[i    ]));
        u8g2_UpdateDisplayArea(&u8g2, 0, 2, 16, 6);
    }
}

int main()
{
    u8g2_Setup_ssd1306_128x64_noname_f(&u8g2, U8G2_R0, u8x8_comm_callback, u8x8_gpio_delay_callback);
    u8g2_InitDisplay(&u8g2);
    u8g2_SetPowerSave(&u8g2, 0);
    u8g2_SendBuffer(&u8g2);
    adc_init();
    timer_init();
    timer_register(timer);
    while (1)
        ;
}

map函數把0255的整數映射到6316的整數,把ADC讀到的8位數據轉換爲顯示屏上的y座標。在更新顯示的過程當中,程序先清除上一次繪製的波形,而後在每相鄰兩個ADC數據對應的點之間畫上直線,連起來稱爲波形,最後更新波形區域的顯示。就是這麼簡單粗暴,不加任何修飾,是否是很簡單呢?

而後咱們來加入花裏胡哨的功能:

  1. 可調的採樣率;

  2. 暫停功能;

  3. 可選的雙通道;

  4. 可選的自適應。

這些問題背後有一個共同的時間控制問題,咱們先來解決。與上一篇同樣,咱們把定時器中斷的代碼移動到main函數中,檢測定時器寄存器的標誌位來控制時間。ADC的讀取間隔是10μs到10ms,都是10μs的倍數,考慮雙通道,取定時器的週期爲5μs,設置一個軟件的分頻係數與計數器變量,每若干個週期進行一次ADC轉換。按鍵與開關的讀取間隔與往常同樣取1ms,用一樣的方法使得每200個主循環週期執行一次讀取。更新顯示是很耗時的,最好在那個週期中把定時器重置,讓它從新開始計時。

ADC的時鐘是CPU時鐘分頻獲得的,從觸發一次讀取到得到結果須要13個ADC時鐘週期,相比10μs而言是不可忽略的時間,而adc_read函數會等待這段時間而後返回結果,這在ADC採樣間隔短的時候會形成時間沒法獲得控制。爲此,與上一篇中給DAC發送數據相似地,咱們在循環中讀取上一次ADC轉換結果並觸發一次轉換,不去等待它而是在下一次循環中天然地得到其結果。在雙通道模式下,要注意轉換結果對應的通道是上一次選擇的。

ADC時鐘的分頻係數與ADC的精度是須要權衡的,爲了得到儘可能精確的結果,咱們根據採樣間隔來設置分頻係數:單通道10μs和雙通道20μs,分頻係數取16;雙通道10μs應取分頻係數爲8,但這樣的話兩個通道會嚴重相互干擾,故放棄這種模式(在這種模式下,顯示的波形是未定義的);其他都取32,8位精度下32分頻足夠了。

實現自適應功能須要放大波形,放大的方法固然不是轉換爲座標之後作圖像變換,而是放大原始數據而後轉換爲座標。具體來說,是用一次函數\(y = k x + b\)進行映射,在此以前先遍歷數據,計算出合適的\(k\)\(b\)。與此相關的還有數據到y座標的映射,比起先前的版本須要多考慮雙通道的狀況。

最後,暫停功能無比簡單,只須要設置一個暫停標誌,當它爲true的時候才進行採樣、轉換、顯示等工做就能夠了。以及,以上各個選項都要在屏幕的黃色區域顯示。

寫到這裏,我以爲你應該先本身試着寫寫這個程序,而後再往下看。

#include <stdlib.h>
#include <avr/io.h>
#include "u8g2/u8g2.h"
#define __DELAY_BACKWARD_COMPATIBLE__
#define F_CPU 25000000UL
#include <util/delay.h>
#include <ee2/bit.h>
#include <ee2/button.h>
#include <ee2/switch.h>

#define PERIOD_MAX 9

uint16_t factor;
uint8_t period;
bool second;
bool pause;
bool adjust;

void init();

void timer_clear();
void timer_wait();

uint8_t adc_get(uint8_t* _channel);

void oled_waveform(uint8_t _data[][128]);
void oled_voltage(uint8_t _vdc, uint8_t _vpp);

void convert_adjust(uint8_t* _data, uint8_t* _result);
void convert_voltage(char* _string, uint8_t _value);

void set_period(uint8_t _period);
void set_second(bool _enable);
void set_adjust(bool _enable);
void set_pause(bool _enable);

int main()
{
    init();
    uint8_t waveform[2][128];
    uint8_t peripheral = 1;
    uint16_t counter = 1;
    uint8_t phase = 0;
    set_period(0);
    set_second(switch_status(SWITCH_0));
    set_adjust(switch_status(SWITCH_1));
    set_pause(false);
    while (1)
    {
        if (!--peripheral)
        {
            peripheral = 200;
            if (button_pressed(BUTTON_0))
            {
                if (period == PERIOD_MAX)
                    set_period(0);
                else
                    set_period(period + 1);
            }
            if (button_pressed(BUTTON_1))
                set_pause(!pause);
            if (switch_changed(SWITCH_0))
                set_second(switch_status(SWITCH_0));
            if (switch_changed(SWITCH_1))
                set_adjust(switch_status(SWITCH_1));
        }
        if (!pause && !--counter)
        {
            counter = factor;
            if (second)
            {
                uint8_t ch;
                uint8_t adc = adc_get(&ch);
                waveform[ch][phase >> 1] = adc;
            }
            else
                waveform[0][phase] = adc_get(NULL);
            ++phase;
            if ((!second && phase == 128) || (second && phase == 0))
            {
                phase = 0;
                uint8_t vol[2];
                convert_adjust(waveform[0], vol);
                if (second)
                    convert_adjust(waveform[1], vol);
                oled_waveform(waveform);
                oled_voltage(vol[0], vol[1]);
                timer_clear();
            }
        }
        timer_wait();
    }
}

void timer_init()
{
    TCCR1A =  0b00 << WGM10; // CTC mode
    TCCR1B =  0b01 << WGM12  // CTC mode
           | 0b001 << CS10;  // no prescaling
    OCR1A  = 124;            // 5us
}

void timer_period()
{
    static const uint16_t factors[PERIOD_MAX + 1] = {
        1, 2, 5, 10, 20, 50, 100, 200, 500, 1000
    };
    if (second)
        factor = factors[period];
    else
        factor = factors[period] << 1;
}

void timer_second()
{
    timer_period();
}

void timer_clear()
{
    TCNT1 = 0;
    set_bit(TIFR1, OCF1A);
    adc_get(NULL);
}

void timer_wait()
{
    while (!read_bit(TIFR1, OCF1A))
        ;
    set_bit(TIFR1, OCF1A);
}

uint8_t adc_count = 1;
uint8_t adc_channel = 0;

void adc_clock(uint8_t _prescaler)
{
    ADCSRA = (ADCSRA & ~(0b111 << ADPS0)) | _prescaler;
}

void adc_init()
{
    ADMUX  =    0b01 << REFS0
           |       1 << ADLAR
           | 0b00000 << MUX0;
    ADCSRA =       1 << ADEN;
}

void adc_period()
{
    if ((!second && period <= 0) || (second && period <= 1)) // single 10us or dual 20us
        adc_clock(0b100);                                    // divide by 16
    else
        adc_clock(0b101);                                    // divide by 32
}

void adc_second()
{
    adc_count = second ? 2 : 1;
    adc_period();
}

uint8_t adc_get(uint8_t* _channel)
{
    set_bit(ADCSRA, ADIF);
    if (_channel)
        *_channel = adc_channel;
    if (++adc_channel >= adc_count)
        adc_channel = 0;
    ADMUX = 0b01 << REFS0 | 1 << ADLAR | adc_channel << MUX0;
    set_bit(ADCSRA, ADSC);
    return ADCH;
}

u8g2_t u8g2;

uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    case U8X8_MSG_BYTE_INIT:
        UCSR1B =    1 << RXEN1
               |    1 << TXEN1;
        UCSR1C = 0b11 << UMSEL10
#define                  UDORD1 2
               |    0 << UDORD1
#define                  UCPHA1 1
               |    0 << UCPHA1
               |    0 << UCPOL1;
        set_bit(DDRD, 3);
        set_bit(DDRD, 4);
        UBRR1 = 10;
        break;
    case U8X8_MSG_BYTE_SET_DC:
        u8x8_gpio_SetDC(u8x8, arg_int);
        break;
    case U8X8_MSG_BYTE_START_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 0);
        break;
    case U8X8_MSG_BYTE_SEND:
        for (const uint8_t* ptr = arg_ptr, *end = ptr + arg_int;
            ptr != end; ++ptr)
        {
            UDR1 = *ptr;
            while (!read_bit(UCSR1A, TXC1))
                ;
            set_bit(UCSR1A, TXC1);
            UDR1;
        }
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 1);
        break;
    default:
        return 0;
    }
    return 1;
}

uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    case U8X8_MSG_GPIO_AND_DELAY_INIT:
        set_bit(DDRB, 0);
        set_bit(DDRB, 1);
        set_bit(DDRB, 2);
        break;
    case U8X8_MSG_DELAY_NANO:
        break;
    case U8X8_MSG_DELAY_100NANO:
        _delay_us(arg_int >> 3);
        break;
    case U8X8_MSG_DELAY_10MICRO:
        _delay_us(arg_int * 10);
        break;
    case U8X8_MSG_DELAY_MILLI:
        _delay_ms(arg_int);
        break;
    case U8X8_MSG_GPIO_CS:
        cond_bit(arg_int, PORTB, 2);
        break;
    case U8X8_MSG_GPIO_DC:
        cond_bit(arg_int, PORTB, 1);
        break;
    case U8X8_MSG_GPIO_RESET:
        cond_bit(arg_int, PORTB, 0);
        break;
    default:
        return 0;
    }
    return 1;
}

void clear_area(uint8_t x, uint8_t y, uint8_t w, uint8_t h)
{
    u8g2_SetDrawColor(&u8g2, 0);
    u8g2_DrawBox(&u8g2, x, y, w, h);
    u8g2_SetDrawColor(&u8g2, 1);
}

void oled_init()
{
    u8g2_Setup_ssd1306_128x64_noname_f(&u8g2, U8G2_R0, u8x8_comm_callback, u8x8_gpio_delay_callback);
    u8g2_InitDisplay(&u8g2);
    u8g2_SetPowerSave(&u8g2, 0);
    u8g2_SendBuffer(&u8g2);
    u8g2_SetFont(&u8g2, u8g2_font_5x7_mr);
    u8g2_SetFontPosTop(&u8g2);
}

void oled_period()
{
    static const char* const strings[PERIOD_MAX + 1] = {
        "10us ", "20us ", "50us ",
        "100us", "200us", "500us",
        "1ms  ", "2ms  ", "5ms  ",
        "10ms ",
    };
    u8g2_DrawStr(&u8g2, 0, 8, strings[period]);
    u8g2_UpdateDisplayArea(&u8g2, 0, 1, 4, 1);
}

void oled_second()
{
    u8g2_DrawStr(&u8g2, 0, 0, second ? "2" : "1");
    u8g2_UpdateDisplayArea(&u8g2, 0, 0, 1, 1);
}

void oled_adjust()
{
    u8g2_DrawStr(&u8g2, 8, 0, adjust ? "A" : " ");
    u8g2_UpdateDisplayArea(&u8g2, 1, 0, 1, 1);
}

void oled_pause()
{
    static const uint8_t xbm[2][8] = {
        {0b00000000, 0b00000000, 0b00000010, 0b00000110, 0b00001110, 0b00000110, 0b00000010, 0b00000000}, // playing
        {0b00000000, 0b00000000, 0b00001010, 0b00001010, 0b00001010, 0b00001010, 0b00001010, 0b00000000}, // paused
    };
    u8g2_DrawXBM(&u8g2, 16, 0, 4, 8, xbm[pause]);
    u8g2_UpdateDisplayArea(&u8g2, 2, 0, 1, 1);
}

void oled_waveform(uint8_t _data[][128])
{
    clear_area(0, 16, 128, 48);
    uint8_t count = 1;
    uint8_t shift = 4;
    uint8_t base[2] = {63};
    if (second)
    {
        count = 2;
        shift = 5;
        base[0] = 39;
        base[1] = 63;
    }
    for (uint8_t c = 0; c != count; ++c)
    {
        for (uint8_t x = 0; x != 128; ++x)
            _data[c][x] = base[c] - ((_data[c][x] * 3) >> shift);
        for (uint8_t x = 1; x != 128; ++x)
            u8g2_DrawLine(&u8g2, x - 1, _data[c][x - 1], x, _data[c][x]);
    }
    u8g2_UpdateDisplayArea(&u8g2, 0, 2, 16, 6);
}

void oled_voltage(uint8_t _vdc, uint8_t _vpp)
{
    if (!second && adjust)
    {
        static char strings[2][10] = {"Vdc=", "Vpp="};
        convert_voltage(strings[0] + 4, _vdc);
        u8g2_DrawStr(&u8g2, 83, 0, strings[0]);
        convert_voltage(strings[1] + 4, _vpp);
        u8g2_DrawStr(&u8g2, 83, 8, strings[1]);
    }
    else
        clear_area(83, 0, 45, 16);
    u8g2_UpdateDisplayArea(&u8g2, 10, 0, 6, 2);
}

void convert_adjust(uint8_t* _data, uint8_t* _result)
{
    if (!adjust)
        return;
    uint16_t sum = 0;
    uint8_t min = 255, max = 0;
    for (uint8_t x = 0; x != 128; ++x)
    {
        sum += _data[x];
        if (_data[x] < min)
            min = _data[x];
        if (_data[x] > max)
            max = _data[x];
    }
    _result[0] = (sum + 64) >> 7;
    uint8_t pp = _result[1] = max - min;
    uint8_t k = pp ? 255 / pp : 1;
    int16_t b = ((255 - k * pp) >> 1) - min * k;
    for (uint8_t x = 0; x != 128; ++x)
        _data[x] = k * _data[x] + b;
}

void convert_voltage(char* _string, uint8_t _value)
{
    uint16_t mv10 = (_value * 125 + 32) >> 6;
    _string[3] = mv10 % 10 + '0';
    mv10 /= 10;
    _string[2] = mv10 % 10 + '0';
    mv10 /= 10;
    _string[0] = mv10 + '0';
    _string[1] = '.';
    _string[4] = 'V';
    _string[5] = '\0';
}

void init()
{
    button_init(PIN_NULL, PIN_NULL);
    switch_init(PIN_NULL, PIN_NULL);
    adc_init();
    timer_init();
    oled_init();
}

void set_period(uint8_t _period)
{
    period = _period;
    adc_period();
    timer_period();
    oled_period();
    timer_clear();
}

void set_second(bool _enable)
{
    second = _enable;
    adc_second();
    timer_second();
    oled_second();
    timer_clear();
}

void set_adjust(bool _enable)
{
    adjust = _enable;
    oled_adjust();
    timer_clear();
}

void set_pause(bool _enable)
{
    pause = _enable;
    oled_pause();
    timer_clear();
}

你能夠先開個定時器觀察PWM波,或者翻到下面搭電路觀察波形,還能夠把本身的手做爲輸入試試。

EEPROM

採樣率和雙通道這兩個參數有本質上的不一樣:雙通道是否開啓只取決於當時開關是撥到上仍是撥到下,而採樣率倒是按鍵按下的次數累積決定的。所以在復位時,雙通道功能的開關會保持,而採樣率會重置。若是咱們正在用50μs檔觀察波形,不當心碰到了下載器致使斷電覆位,咱們得按兩次按鍵才能恢復到50μs的選項;若是是10ms就更糟糕了。咱們但願單片機可以記住咱們的選項,這就須要用到一種復位斷電都不會丟失數據的存儲器——EEPROM。

那爲啥不用一樣屬於非易失性存儲的flash呢?由於flash必須以塊爲單位擦除,而EEPROM能夠以字節爲單位,這就使得EEPROM更適合於存儲示波器參數這樣的小數據。另外,咱們須要時刻注意,EEPROM的壽命是有限的,只有10萬次耐久,相比之下flash只有1萬次,而SRAM沒有限制。

ATmega324PA提供了1024字節的EEPROM。AVR的EEPROM是比較容易使用的,只需4個寄存器:EEARHEEARL,地址寄存器;EEDR,數據寄存器;EECR,控制寄存器。對EEPROM的操做共有3種:讀取、擦除和寫入。AVR還提供了擦除和寫入原子地合併在一塊兒這種操做。

你也許會疑惑,擦除和寫入是什麼關係呢?寫入默認值不就能夠擦除,爲何要畫蛇添足呢?這是由於,EEPROM在出廠時全部位都是1,寫入只能把位從1變成0,而只有擦除操做才能把位從0變成1,並且必須一個字節的8位一塊兒。換句話說,咱們平時講的寫入操做,到EEPROM這裏至關於擦除加寫入,這也是第4種操做的意義所在。

對照着數據手冊,咱們能夠寫幾個函數,完成EEPROM的讀取、擦除和寫入:

void eeprom_wait()
{
    while (EECR & 1 << EEPE)
        ;
}

uint8_t eeprom_read(uint16_t _address)
{
    eeprom_wait();
    EEAR = _address;
    EECR |= 1 << EERE;
    return EEDR;
}

void eeprom_erase(uint16_t _address)
{
    eeprom_wait();
    EEAR = _address;
    EECR = 0b01 << EEPM0 | 1 << EEMPE;
    EECR |= 1 << EEPE;
}

void eeprom_write_only(uint16_t _address, uint8_t _value)
{
    eeprom_wait();
    EEAR = _address;
    EEDR = _value;
    EECR = 0b10 << EEPM0 | 1 << EEMPE;
    EECR |= 1 << EEPE;
}

EEPROM的讀取是很快的,只須要幾個CPU週期,寫入和擦除則慢得多,各須要1.8ms,合併起來的操做須要3.4ms。上面的函數在第一次擦寫的時候無需等待,但實際擦寫完成是在1.8ms之後。若是調用時前一次擦寫沒有完成,函數會一直等待直到操做完成,而後執行當前擦寫。

<avr/eeprom.h>提供了EEPROM的相關工具。函數名帶有write的函數實際執行的是擦寫操做;update的函數在擦寫以前會檢查內容是否須要修改,這樣能夠減小擦寫次數。

可是這樣仍不完美。EEPROM的10萬次耐久指的是擦除和寫入都不能超過10萬次,在有些狀況下咱們能夠避免擦除或寫入或二者兼有,對EEPROM友善的同時減小了時間開銷。好比,當原來的數據是0b00001111,要變成0b00001100時,就沒有必要擦除,由於沒有一位原來是0而須要變成1。

因此這個改進版的寫入函數須要先讀取原數據,再檢查是否須要擦除以及是否須要寫入,最後根據檢查的結果來執行相應的EEPROM操做。具體檢查是否須要擦除的方法是,假設原數據爲old,新數據爲new,逐位檢查oldnew中對應的位,若是存在old中的一位爲0new中的對應位爲1,則須要擦除;檢查是否須要寫入的方法是,若是存在可能被擦除之後的字節中的一位爲1new中的對應位爲0,則須要寫入。因此咱們須要寫兩個循環,每一個循環體執行8遍。

可是直覺告訴我,下面的代碼能起到相同的做用:

void eeprom_write(uint16_t _address, uint8_t _value)
{
    uint8_t original = eeprom_read(_address);
    bool need_erase = ~original & _value;
    uint8_t after_erase = need_erase ? 0xFF : original;
    bool need_write = after_erase != _value;
    if (!need_erase && !need_write)
        return;
    eeprom_wait();
    EEAR = _address;
    EEDR = _value;
    EECR = !need_erase << EEPM1
         | !need_write << EEPM0
         |           1 << EEMPE;
    EECR |= 1 << EEPE;
}

可是這樣依然不完美。若是咱們只須要一個字節,就像這個示波器程序那樣,用一個固定字節存儲這個參數,能夠修改10萬次。可是EEPROM共有1024字節,剩下的1023字節呢?沒錯,咱們能夠用完一個字節的耐久後用下一個字節,直到所有用完,這樣就能夠修改1億次,當傳家寶都沒問題。問題在於沒有辦法檢測一個字節的耐久是否耗盡。那咱們是否能夠再設置一個字節來記錄寫入了多少次?而後還得考慮這個字節的耐久,以及如何檢測耐久耗盡之後的錯誤……

別把本身繞進去,一份來自Atmel官方的application note,AVR101,介紹了一種充分利用EEPROM空間換取耐久度的方法。

眼看着這篇是寫不完了,爲了避免斷更先發出來,之後再補完。

最後恭喜我寒假裏一個任務都沒有完成。下一篇遙遙無期。

相關文章
相關標籤/搜索