Skip to content

3.7 LCD、背光与闹铃播放 ​

这节要搞懂:界面怎么画才不卡,闹钟怎么响才停得下来。 需要基础:3.6

一、什么是局部刷新 ​

局部刷新指的是:界面更新时,只重绘发生变化的区域,而不是整屏重画。这能大幅降低数据传输量和 CPU 占用。

为什么必须这么做:我们的屏是 240×320,刷满一整屏要传 15 万字节,20MHz 下大约 60 毫秒。每秒刷一次的话,屏幕会明显闪、卡。

但其实每秒只有"秒"那两个数字在变 —— 只刷那一小块,1 毫秒都用不到。

二、把屏幕分成几块 ​

        240 像素宽
┌────────────────────────────┐  y = 0
│ ① 日期        每天变一次     │
├────────────────────────────┤  y = 40
│                            │
│ ② 时间        秒每秒变       │  ← 只有这块要频繁刷
│                            │
├────────────────────────────┤  y = 150
│ ③ 室内温湿度   2 秒变一次     │
├────────────────────────────┤  y = 210
│ ④ 天气        10 分钟变一次  │
├────────────────────────────┤  y = 280
│ ⑤ WiFi / 闹钟图标  很少变    │
└────────────────────────────┘  y = 320

240×320 是竖屏,比 240×240 高出 80 像素,正好够天气单独占一块,不用和温湿度挤在一起。

每块区域独立刷新,互不影响。

三、代码怎么写 ​

c
void ui_draw_second(int second)
{
    static int last_second = -1;

    if (second == last_second) {
        return;                       // ⭐ 没变就不画,省掉大量无用功
    }
    last_second = second;

    char text[4];
    snprintf(text, sizeof(text), "%02d", second);

    ui_draw_text(SECOND_X, SECOND_Y, text);    // 只画这一小块(约 60×48 像素)
}

一个通用技巧

画之前先判断"跟上次一样吗",一样就直接返回。

这一行判断能省掉 90% 的无用刷新,是性能优化里性价比最高的一招。

四、闹钟怎么判断到点了 ​

闹钟时间怎么存 ​

工程里闹钟不是拆成时、分两个数字存的,而是一个字符串:

c
char alarm[16];        // "07:30" 表示 7 点 30 分;"close" 表示关闭

为什么用字符串

一个字段同时表达了"几点几分"和"开没开",不用再额外存一个 enabled 布尔值。

而且 BLE / MQTT 下发的 JSON 里本来就是字符串({"alarm":"07:30"}),收到什么就存什么,中间不用转换,少一层出错的机会。

判断方式:直接比字符串 ​

alarm_task 每秒把当前时间格式化成 "HH:MM",和配置里的 alarm 直接比:

c
void alarm_task(void *pvParameters)
{
    char time_text[16];

    while (1) {
        app_config_t cfg;
        app_config_get(&cfg);

        // 取当前时间,格式化成 "HH:MM"
        if (app_time_get_time_line(time_text, sizeof(time_text))) {

            // ⭐ 字符串一致就说明到点了
            if (strcmp(time_text, cfg.alarm) == 0) {
                alarm_start();
            }
        }

        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

"close" 天然就是关闭

配置成 "close" 时,它永远不可能等于任何一个 "HH:MM",所以自动就不会触发 —— 不需要额外写一个 if (enabled) 判断。

这是用字符串存的另一个好处。

一分钟内会重复触发

"07:30" 这个字符串会持续整整一分钟,所以这一分钟里每秒都会匹配成功。

如果 app_audio_player_play_alarm_beep() 里没有"已经在响就别重复启动"的判断,就会被反复触发。

两种解法:

  1. 在播放函数里加一个 if (s_ringing) return;
  2. 记住上一次匹配的分钟,只在从不匹配变成匹配的那一刻触发

五、闹铃是"算"出来的,不是存的 ⭐ ​

这台设备的提示音不是预先录好的音频文件,而是用代码实时生成采样点,直接喂给 I2S 喇叭。

存音频文件代码生成
占 Flash1 秒 32KB 起0
换音调重新做文件 + 重烧固件改一个数字
音色想要什么样都行简单的"滴滴"声

做提示音这种简单声音,代码生成完胜。

工程里用的是方波 ​

回忆 2.6:PCM 就是一串表示振幅的数值。所以"生成声音"就是算出这串数值。

最简单的做法是方波 —— 在正负两个固定值之间来回跳:

c
#define SAMPLE_RATE   16000
#define TONE_FREQ     2000        // 提示音频率
#define AMPLITUDE     16000       // 振幅(最大 32767)

static void tone_fill_square(int16_t *buf, int samples, int freq)
{
    int half_period = SAMPLE_RATE / freq / 2;   // 半个周期有多少个采样点

    for (int i = 0; i < samples; i++) {
        // 前半周期给 +16000,后半周期给 -16000
        buf[i] = ((i / half_period) % 2) ? -AMPLITUDE : AMPLITUDE;
    }
}

画出来就是这样:

+16000  ┌────┐    ┌────┐    ┌────┐
        │    │    │    │    │    │
     0  ┼────┼────┼────┼────┼────┼──
        │    │    │    │    │    │
-16000  └────┘    └────┘    └────┘

为什么用方波而不是正弦波

方波只有加减法,正弦波要调 sinf()。

方波正弦波
计算量几乎为零每个点算一次三角函数
音色尖锐、"电子感"强柔和、干净
适合提示音、闹铃音乐、长时间播放

闹铃本来就要刺耳一点才叫得醒人,方波反而更合适,还省 CPU。

做成"滴—滴—滴" ​

一直响一个音很烦,实际是响一下、停一下:

c
void alarm_play_beeps(void)
{
    static int16_t buf[SAMPLE_RATE / 5];        // 200ms 的缓冲
    int samples = sizeof(buf) / sizeof(buf[0]);
    size_t written;

    for (int n = 0; n < 3; n++) {               // 响 3 声
        // 响 200ms
        tone_fill_square(buf, samples, TONE_FREQ);
        i2s_channel_write(s_tx_chan, buf, sizeof(buf), &written, portMAX_DELAY);

        // 停 200ms(喂静音,比停止 I2S 更简单)
        memset(buf, 0, sizeof(buf));
        i2s_channel_write(s_tx_chan, buf, sizeof(buf), &written, portMAX_DELAY);
    }
}

播放必须在独立任务里

i2s_channel_write() 是阻塞的 —— DMA 缓冲满了它就会等。

写在主界面循环里,界面会卡住几百毫秒:时间不刷新、手机发来的命令也处理不了。

工程里由 app_audio_player.c 负责播放。

方波会有"啪"的爆音

方波从 0 直接跳到 ±16000,喇叭容易发出爆音。

想改善的话,可以在开头几毫秒让振幅从 0 渐升、结尾渐降(淡入淡出)。当前工程没做这一步,属于可优化项。

和 SD 卡播放不是一回事 ​

工程里还有播放 SD 卡音频文件的功能(3.9),那是另一条路径:

闹铃提示音SD 卡音频
声音从哪来代码实时算出来读 SD 卡里的文件
要不要 SD 卡❌ 不用✅ 必须有
用途闹钟、按键反馈这类短提示放歌、放录音

六、响铃时的视觉提示(可优化项) ​

当前工程只有声音,屏幕上没有专门的响铃提示。

如果想加,最简单的做法是利用主界面循环,每秒翻转一次颜色:

c
// 在 update_dashboard_loop() 里,检测到正在响铃时
if (s_ringing) {
    static bool blink = false;
    blink = !blink;

    app_display_show_alarm_banner(blink);        // 一亮一灭
    TFTLCD_SetBacklightBrightness(1023);         // 背光拉满,晚上才看得见
}

为什么值得加

闹钟响的时候人还迷糊着,声音 + 视觉双重提示比只有声音好用得多。

而且万一喇叭坏了或者音量调太小,屏幕还能兜底。

七、背光渐变 ​

进入设置或响铃时把背光调亮,用硬件渐变不占 CPU:

c
ledc_fade_func_install(0);       // 初始化时调一次

// 300 毫秒内平滑变到最亮
ledc_set_fade_with_time(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 1023, 300);
ledc_fade_start(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, LEDC_FADE_NO_WAIT);

八、想换个铃声? ​

因为提示音是算出来的,换起来只要改几个数字:

想要的效果改什么
音调高一点把 TONE_FREQ 从 880 改成 1046(C6)
响得急促一点缩短每次的 200ms,增加响的次数
音量小一点降低 AMPLITUDE
变成"两声一组"在循环里加一次更长的停顿

想要更复杂的铃声(一段旋律、真人语音),就该走 SD 卡音频文件那条路了。

本节要点 ​

  • 局部刷新 = 只重绘变化的区域,是流畅的关键
  • 画之前先判断"跟上次一样吗"
  • 闹钟用**"分钟刚变化"**判断,一分钟只触发一次
  • 闹钟存成字符串 "HH:MM" 或 "close",判断就是一次 strcmp
  • "close" 永远匹配不上任何时间,天然就是"关闭"
  • ⭐ 提示音是代码实时生成的方波(±16000),不占 Flash,计算量几乎为零
  • 方波会有爆音,淡入淡出属于可优化项(当前工程未做)
  • ⭐ 播放必须单独一个任务,i2s_channel_write 是阻塞的
  • SD 卡音频是另一条路径,用来放完整的音频文件
  • ⭐ 当前工程只有声音,到点调 app_audio_player_play_alarm_beep();屏幕闪烁 + 背光拉满属于可优化项

操作过程 ​

目标:用手机设一个一分钟后的闹钟,到点听到喇叭响。

第 1 步:先单独试一次提示音 ​

在 app_task 里临时加一行,确认喇叭通了:

c
app_audio_player_play_alarm_beep();

烧录后开机应该立刻"滴"一声。没声音就先解决这个,别急着调闹钟。

第 2 步:确认时间是对的 ​

闹钟靠比对时间,时间不对闹钟永远不会响。

先确认屏幕上的时间和手机一致(3.5)。

第 3 步:设一个马上要到的闹钟 ​

看一眼屏幕上现在几点,比如 20:35,那就设 20:36:

用微信小程序连上蓝牙,往配置特征值写:

json
{"alarm":"20:36"}

输入模式要选「文本」

不能用默认的 HEX 模式,否则设备收到的是乱码(2.8 讲过)。

第 4 步:等一分钟 ​

到点时应该:

  • 喇叭响 —— "滴滴滴"三声
  • 串口打印:
I (65000) alarm: alarm fired: 20:36

第 5 步:关掉它 ​

json
{"alarm":"close"}

再等到下一分钟,确认不会再响。

第 6 步:验证断电后闹钟还在 ​

设一个闹钟 → 拔电重插 → 用小程序读配置特征值,闹钟时间应该还在(存在 NVS 里,3.4)。

动手试试 ​

  1. 实现分区刷新,对比一下全屏刷和只刷秒数的耗时差多少。
  2. 完成"手机设闹钟 → 存 NVS → 到点播放 → 手机发停止"的完整流程。
  3. 把 TONE_FREQ 依次改成 523、587、659,听听 do、re、mi 的区别。
  4. 把方波改成正弦波(sinf),对比一下音色差别。
  5. 故意把 i2s_channel_write 写进主界面循环里,体验一下界面卡住、命令收不到是什么感觉。