Qt在Linux开发板用蜂鸣器播放《欢乐颂》:GPIO与PWM控制的完整实践
2026/9/9 2:03:50 网站建设 项目流程

简介:面向嵌入式Linux初学者的Qt开发板应用实例,基于Samsung S3C6410开发板实现欢乐颂音乐播放与跑马灯动态效果,通过完整可编译工程展示Qt GUI编程、GPIO口操作、信号槽事件处理、音频解码接口调用等关键知识点,适合准备入门嵌入式Linux交互式开发或需要参考硬件控制代码的开发者。压缩包共10个文件,包含3个cpp源文件、2个头文件、Qt工程文件pro与user配置、qrc资源文件以及两张界面预览图,整体仅35KB,体量小巧但结构完整,便于快速定位核心逻辑。已有306人学习,可对照源码梳理物理按键按下后触发音乐播放与LED灯顺序点亮的实现路径,同时能学到在有限内存下优化Qt程序、降低CPU占用的轻量化设计思路。该实例覆盖了从开发环境搭建到外设驱动的应用层调用,虽然文件不多,却构成一个从界面到硬件响应的闭环Demo,是短小精悍的实战学习素材。 做嵌入式开发的时候,让一块Linux开发板发出声音,是很多人刚接触都会想试一遍的事。我这次选的题目很简单:用Qt在Linux开发板上播放《欢乐颂》,让蜂鸣器或者音频设备把旋律跑出来。听起来像个玩具项目,但把音符频率、节拍时长、GPIO/PWM控制、Qt事件循环这些点串在一起之后,你会发现它其实是一块很好的“试金石”。不管你是刚开始碰嵌入式Linux,还是想在Qt里搞清楚线程、定时器和系统设备操作,这个项目都能让你练到手。

我用的硬件是常见的IMX6ULL或全志T113这类Linux开发板,外加一个几毛钱的无源蜂鸣器。软件端用Qt 5,不需要界面,一个QCoreApplication就能跑。整个工程量不大,但对理解“Qt怎么和Linux内核设备打交道”特别有帮助。

1. 项目概述与整体设计思路

1.1 先看清需求:一块板子、一份乐谱、一个Qt程序

这个项目的本质,是把《欢乐颂》的乐谱转成程序能读的数据,再让开发板按节奏输出对应的声音。

拆开看就三层:第一层是数据层,把简谱里的每个音变成两个数字——频率和时长;第二层是逻辑层,用Qt的定时器或者线程控制这些音符的播放顺序;第三层是设备层,把播放动作落到开发板的硬件上,通常就是GPIO翻转或者PWM输出,驱动蜂鸣器发声。

《欢乐颂》用C大调简谱表示,开头是「3 3 4 5 | 5 4 3 2 | 1 1 2 3 | 3· 2 2—」,这些数字对应的是E4、F4、G4这些音名,也对应着明确的物理频率。把这些频率按顺序交给底层输出,旋律就出来了。听起来很直接,但实际操作里有两个坎:一是频率和节拍的精度控制,二是开发板GPIO的写法。这两个坎跨过去,这个项目就通了。

1.2 为什么选Qt来做这件事

有人会问:放个旋律而已,直接写个C脚本操作GPIO不就行了?确实可以,我一开始也是这么干的。但用Qt的意义在于工程化。

Qt的事件循环非常契合“按时间顺序做事情”的场景。音符是一个接一个播的,中间还有间隔、休止、循环,这些时序逻辑如果用裸循环做,后期想暂停、停止、切换歌曲都会变得很别扭。而Qt的QTimer、信号槽、线程机制正好把这些都封装好了。另外,如果后续想在开发板的LCD上做一个带按钮的界面,QPushButton一点就播放、再点就暂停,那Qt的GUI能力就能无缝接上。先用QCoreApplication跑通逻辑,之后改成QApplication加界面,工作量很小。

还有一点对我来说很实际:Qt的构建体系简单,qmake或者CMake两下就能生成可在开发板上运行的二进制,调试时又有qDebug()打印,比纯C脚本要舒服很多。

1.3 有源蜂鸣器与无源蜂鸣器的区别

蜂鸣器这块有个特别容易踩的坑。市面上常见的有源蜂鸣器,内部自带振荡电路,只要通电就发声,但频率固定,你没法通过改变输入电平频率来改变音调。而无源蜂鸣器内部没有振荡源,需要外部给它一定频率的方波或者PWM信号才会发声,输入频率不同,发出的音高就不同。做音乐必须用无源蜂鸣器,不然你永远只能得到一个“滴——”的声音,换不了调。

控制方式上也分两种:一种是用GPIO直接翻转电平,高电平和低电平交替输出,形成方波;另一种是用开发板上的PWM硬件输出,占空比和频率都由硬件定时器控制。两种方式差异如下表:

控制方式频率精度CPU占用实现难度适用场景
GPIO软件翻转受系统调度影响,一般低,通用性好学习演示、临时验证
PWM硬件输出非常稳定极低中,需要找PWM引脚正式项目、音准要求高

我建议初学阶段先用GPIO方式把整个流程跑通,理解原理后再切到PWM方案。本文核心代码以GPIO方式为例,第4节里会补充PWM的进阶配置。

2. 音乐原理与音符映射

2.1 简谱转频率:从《欢乐颂》第一行说起

要让开发板发出准确的音高,首先得知道每个音符对应的频率。

音乐里有一个通用的十二平均律,简单说就是把一个八度平均分成12个半音。计算频率的公式是 f = 440 × 2^((n-69)/12),其中n是MIDI音符编号。举几个例子:国际标准音A4的MIDI编号是69,频率440Hz;C4是60,频率约261.63Hz;E4是64,频率约329.63Hz。听起来很学术,但实际操作时完全不用自己算,背一张常用音名频率表就够用了。

《欢乐颂》C大调主旋律用到的音基本都在C4到G4之间,对应的频率如下:

简谱音名频率(Hz)
1C4262
2D4294
3E4330
4F4349
5G4392
6A4440
7B4494

《欢乐颂》第一句“3 3 4 5 | 5 4 3 2”翻译过来就是 E4 E4 F4 G4 | G4 F4 E4 D4,对应的频率是330、330、349、392、392、349、330、294。

2.2 节拍的数据化表示

频率解决了“音高”,还要解决“时长”。乐谱里的每个音符都有时值,一个四分音符、八分音符、附点音符的长短不同。程序里最简单的做法就是统一以一个四分音符为基本单位,比如设一个四分音符400毫秒,那么八分音符就是200毫秒,二分音符就是800毫秒。

当然,《欢乐颂》原曲的节奏比这复杂,有附点和延音。为了先让项目跑起来,我第一版把节奏做了简化:每个音符都当成一个四分音符,时长固定400ms;最后一个音或休止符延长到800ms。这样听起来虽然有点“呆板”,但流程能通,后面想还原真实节奏,只需要把数组里的毫秒数改成对应的值即可。

在代码里,每个音符用一个结构体表示,频率为0代表休止符:

struct Note { int freq; // 频率,单位Hz,0表示休止 int ms; // 时长,单位毫秒 };

《欢乐颂》前两句的旋律数组可以写成:

static const Note kMelody[] = { {330, 400}, {330, 400}, {349, 400}, {392, 400}, {392, 400}, {349, 400}, {330, 400}, {294, 400}, {262, 400}, {262, 400}, {294, 400}, {330, 400}, {330, 800}, {294, 400}, {294, 400}, {330, 400}, {330, 400}, {349, 400}, {392, 400}, {392, 400}, {349, 400}, {330, 400}, {294, 400}, {262, 400}, {262, 400}, {294, 400}, {330, 400}, {294, 800}, {262, 400}, {262, 400}, {0, 800} };

这里我把附点做了近似处理,主要目的是先把机制跑通。把这段数组喂给播放引擎,就能听到《欢乐颂》的轮廓。

2.3 Qt事件循环和播放节奏的关系

在Qt里做这种定时播放,第一反应是启动一个QTimer,每隔几百毫秒处理一个音符。但实际上蜂鸣器发声需要在一段时间内连续输出方波,一个音符持续400ms,期间GPIO要快速翻转几十次甚至上百次,这不是一个简单的“到点处理”就能覆盖的操作。

如果只在主线程里用QTimer控制,每次进入槽函数后还要忙等几百毫秒去翻转GPIO,那主线程就被卡住了。要是以后界面上还要放暂停按钮,程序就会直接无响应。所以我把旋律播放放到一个独立的QThread里,QTimer只负责在main()里延迟启动播放,或者以后用来触发暂停、停止逻辑。线程里循环读取音符数组,逐个播放,播完发一个finished信号,主线程收到信号就退出事件循环。

3. 核心代码实现与编译部署

3.1 GPIO操作层封装

在Linux开发板上操作GPIO,最通用的是sysfs方式,也就是读写/sys/class/gpio目录下的文件。虽然新内核开始推荐libgpiod,但嵌入式开发板的内核版本往往比较老,sysfs方式依然是兼容性最好的选择。

使用GPIO前先要找到蜂鸣器对应的GPIO编号。不同板卡差别很大,比如IMX6ULL和T113的引脚编号规则就完全不同。最靠谱的方法是把板卡原理图翻出来,找到蜂鸣器接在哪个GPIO上,再根据芯片手册算出编号。快速验证方法很简单:先手动操作一遍,echo 66 > /sys/class/gpio/export,然后写入value值,如果能听到声音,说明编号找对了。这一步强烈建议在写代码之前做,避免代码写完了才发现引脚根本不对。

封装好Gpio类如下:

// gpio.h #ifndef GPIO_H #define GPIO_H #include <QObject> class Gpio : public QObject { Q_OBJECT public: explicit Gpio(int pin, QObject *parent = nullptr); ~Gpio(); bool init(); void setValue(bool high); bool isReady() const { return m_ready; } private: int m_pin; int m_valueFd = -1; bool m_ready = false; }; #endif
// gpio.cpp #include "gpio.h" #include <QFile> #include <QDebug> #include <unistd.h> #include <fcntl.h> #include <cstdio> Gpio::Gpio(int pin, QObject *parent) : QObject(parent), m_pin(pin) {} Gpio::~Gpio() { if (m_valueFd >= 0) close(m_valueFd); } bool Gpio::init() { QFile exportFile("/sys/class/gpio/export"); if (!exportFile.open(QIODevice::WriteOnly)) { qWarning() << "cannot open export"; return false; } exportFile.write(QByteArray::number(m_pin)); exportFile.close(); usleep(200 * 1000); // 等待sysfs创建设备节点 QFile dirFile(QString("/sys/class/gpio/gpio%1/direction").arg(m_pin)); if (!dirFile.open(QIODevice::WriteOnly)) { qWarning() << "cannot open direction for pin" << m_pin; return false; } dirFile.write("out"); dirFile.close(); QString valuePath = QString("/sys/class/gpio/gpio%1/value").arg(m_pin); m_valueFd = open(valuePath.toLocal8Bit().constData(), O_WRONLY); if (m_valueFd < 0) { qWarning() << "cannot open value for pin" << m_pin; return false; } m_ready = true; return true; } void Gpio::setValue(bool high) { if (m_valueFd < 0) return; char c = high ? '1' : '0'; lseek(m_valueFd, 0, SEEK_SET); if (write(m_valueFd, &c, 1) < 0) perror("write gpio"); }

这里有个细节:/sys/class/gpio/export是内核提供的导出接口,导出后系统会生成对应的gpioN目录。如果你跑的是普通用户,可能会被权限挡住,建议直接用root权限运行,或者在开发板的udev规则里放开权限。很多开发板默认就是root登录,一般不会遇到权限问题,但如果是自己移植的文件系统,要特别注意。

3.2 旋律播放引擎实现

播放引擎做的事情,一句话总结就是“把频率变成方波”。无源蜂鸣器需要方波驱动,方波的频率就是音符的频率。比如E4是330Hz,意味着蜂鸣器每秒要振动330次,即每1/330秒完成一次高低电平的周期变换。换算成微秒,一个周期约3030微秒,高电平占一半是1515微秒。

我把整个播放逻辑封装在MelodyWorker里,继承QObject,提供play()槽函数。播放完成后发出finished信号。

// melodyworker.h #ifndef MELODYWORKER_H #define MELODYWORKER_H #include <QObject> #include "gpio.h" struct Note { int freq; int ms; }; class MelodyWorker : public QObject { Q_OBJECT public: explicit MelodyWorker(Gpio *gpio, QObject *parent = nullptr); public slots: void play(); signals: void finished(); private: Gpio *m_gpio; }; #endif
// melodyworker.cpp #include "melodyworker.h" #include <unistd.h> static const Note kMelody[] = { // 第一句 {330, 400}, {330, 400}, {349, 400}, {392, 400}, {392, 400}, {349, 400}, {330, 400}, {294, 400}, {262, 400}, {262, 400}, {294, 400}, {330, 400}, {330, 800}, {294, 400}, {294, 400}, // 第二句 {330, 400}, {330, 400}, {349, 400}, {392, 400}, {392, 400}, {349, 400}, {330, 400}, {294, 400}, {262, 400}, {262, 400}, {294, 400}, {330, 400}, {294, 800}, {262, 400}, {262, 400}, // 结束休止 {0, 800} }; MelodyWorker::MelodyWorker(Gpio *gpio, QObject *parent) : QObject(parent), m_gpio(gpio) {} void MelodyWorker::play() { for (const Note &note : kMelody) { if (note.freq <= 0) { if (m_gpio) m_gpio->setValue(false); usleep(note.ms * 1000); continue; } int halfPeriodUs = 1000000 / note.freq / 2; int totalCycles = note.freq * note.ms / 1000; for (int i = 0; i < totalCycles; i++) { m_gpio->setValue(true); usleep(halfPeriodUs); m_gpio->setValue(false); usleep(halfPeriodUs); } } emit finished(); }

计算起来很直观:halfPeriodUs是一个半波的时长,totalCycles是这个音符需要输出的方波周期数。比如330Hz持续400ms,周期约3030微秒,半周期约1515微秒,总共要输出132个周期。每次循环里,GPIO先拉高1515微秒,再拉低1515微秒。

usleep的精度其实不算特别高,系统调度可能会导致偶尔几十微秒的偏差。对于一个演示项目来说完全够听,但如果你想要更准的节奏和音高,还是建议使用PWM硬件,后面第4节会细说。

3.3 main函数与工程配置

main函数里要做的事很清晰:创建Gpio实例并初始化,创建MelodyWorker,用QTimer::singleShot(0)让播放线程启动,收到finished信号后退出事件循环。

#include <QCoreApplication> #include <QTimer> #include "gpio.h" #include "melodyworker.h" int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); int pin = 66; // 换成你板子上蜂鸣器对应的GPIO编号 Gpio gpio(pin); if (!gpio.init()) { qWarning() << "gpio init failed"; return -1; } MelodyWorker worker(&gpio); QTimer::singleShot(0, &worker, &MelodyWorker::play); QObject::connect(&worker, &MelodyWorker::finished, &app, &QCoreApplication::quit); return app.exec(); }

如果想加一个界面,把QCoreApplication换成QApplication即可,然后再加一个按钮,把QTimer::singleShot换成按钮的clicked信号触发worker的play(),逻辑也完全成立。

工程文件用qmake写,很简洁。注意这里不需要Qt GUI模块:

QT -= gui QT += core CONFIG += c++11 TARGET = melody SOURCES += main.cpp gpio.cpp melodyworker.cpp HEADERS += gpio.h melodyworker.h

编译时直接命令行执行:

qmake make -j4

如果开发板本身就是跑Ubuntu这类完整系统,在板端安装qtbase5-dev后直接编译也可以:

sudo apt install qtbase5-dev qmake make

3.4 上板调试:交叉编译、拷贝、运行

如果开发板性能和存储都有限,推荐在PC上交叉编译,然后把可执行文件拷到板子运行。交叉编译的前提是有对应的交叉工具链和Qt库。以ARM 32位开发板为例,假设工具链前缀是arm-linux-gnueabihf-,qmake路径在工具链的sysroot里,编译命令如下:

/opt/arm-linux-gnueabihf/bin/qmake make -j4

生成的melody可执行文件通过scp传到开发板:

scp melody root@192.168.1.100:/root/

然后在开发板上执行:

./melody

如果板子的rootfs里没有Qt运行库,运行时会报错找不到libQt5Core.so.5。解决办法通常有两种:一种是用dtc工具查看并复制编译器sysroot里的Qt库文件到开发板/usr/lib目录;另一种是用NFS把开发板的root目录挂载到主机上直接操作文件,也就是常说的“开发板挂载ubuntu”。前者体积小但手动步骤多,后者调试方便,适合频繁改库的场景。

如果你的开发板rootfs是buildroot用Qt构建的,那库文件一般都在,直接运行即可。

4. 常见问题与排查技巧实录

4.1 应用启动即崩溃:platform plugin 与 Qt 库路径问题

很多人在开发板上跑Qt程序时,会遇到类似could not be initializedNo Qt platform plugin could be initialized的报错。这个报错出现的场景是:程序用了QApplication或者QWidget,也就是需要GUI平台插件,但运行时找不到对应的插件。

解决办法是告诉Qt平台插件在哪里。Qt5的常用嵌入式平台插件有linuxfb、eglfs、wayland等,如果没有显示屏幕或者只需要framebuffer,可以这样运行:

export QT_QPA_PLATFORM=linuxfb export QT_QPA_PLATFORM_PLUGIN_PATH=/usr/lib/qt/plugins ./melody_gui

如果板端没有linuxfb插件,就得检查rootfs里是否包含了Qt的platforms插件目录,或者重新交叉编译Qt时加上linuxfb支持。这个坑在PC上也有同类版本:Windows下用windeployqt.exe把依赖的DLL和插件打包好,再发布给别人的机器,缺了platforms/qwindows.dll也会报警。本质都是“程序找到了Qt库,但没找到对应的平台集成层”。

4.2 蜂鸣器不响、声音不对、节奏飘忽

蜂鸣器不响,先别怀疑代码。我用这个排查顺序,基本能解决90%的问题:

  1. 检查硬件类型。是不是有源蜂鸣器?有源蜂鸣器不能用来播放不同音调的旋律。
  2. 检查GPIO编号。用命令行手动导出一个GPIO,强制拉高拉低,听有没有声音。如果没声音,换一个引脚或对照原理图确认编号。
  3. 检查接线。蜂鸣器正极接GPIO驱动引脚,负极接GND。如果接反了或者缺少驱动电路,可能声音很小或者无声。
  4. 检查权限。程序是不是root运行的?sysfs的GPIO文件有没有写入权限?

声音“沙哑”或“音调不对”,多半是方波频率不对。比如usleep实际延时和预期偏差过大,导致实际输出频率漂移。你可以用示波器测量GPIO引脚,确认频率是否接近目标值。如果手头没有示波器,也可以让GPIO快速翻转后,把引脚接到耳放或音频输入上听个大概。

节奏“忽快忽慢”则说明系统调度影响了usleep的精度。播放线程里大量微秒级的睡眠很依赖内核调度,如果开发板负载高,或者开了很多其他进程,节奏就会乱。解决办法是把播放线程的优先级调高,或者直接用PWM硬件输出,让内核定时器去扛这个任务,而不是靠应用程序忙等。

4.3 进一步提速:用PWM替代GPIO翻转

如果你的开发板有可用的PWM引脚,切到PWM方案后,音准稳定性和CPU占用都会明显改善。Linux下PWM一般也通过sysfs控制,流程是导出PWM通道、设置周期、设置占空比、使能输出。

代码里播放某个音符时,改变period即可:

echo 0 > /sys/class/pwm/pwmchip0/export echo 1000000 > /sys/class/pwm/pwmchip0/pwm0/period echo 500000 > /sys/class/pwm/pwmchip0/pwm0/duty_cycle echo 1 > /sys/class/pwm/pwmchip0/pwm0/enable

上面的period单位是纳秒,1000000纳秒就是1毫秒,对应1kHz。播放330Hz音时,把period改成3030303纳秒,duty_cycle改成一半即可。程序里用QFile打开这些sysfs节点,逐个音符写周期值,逻辑比GPIO翻转还简单,而且没有usleep的抖动问题。缺点是查找开发板可用的PWM引脚、确认内核配置需要花一点时间,不同板卡的pwmchip编号也不同,需要对着芯片手册试一下。

5. 写在最后:几点经验与扩展建议

这个项目让我最直接的感受是:把一件“看起来很简单”的事做对,往往比对一件难事做到一半更锻炼人。音符频率、节拍数组、GPIO操作这些单拎出来都是基础知识,但真把它们在Linux开发板上串起来,排错过程会逼你把每个细节都想明白。

我自己的开发顺序建议是:先不要急着写Qt工程,先用终端命令行手动操作GPIO,确认蜂鸣器能响;接着用细节数组写一个最简的循环播放;最后再套上Qt的事件循环和线程框架。这样每一步的变量都很少,出问题了也好定位。

后续如果要继续玩,有几个方向可以扩展:一是把旋律数组抽成独立文件,运行时加载,这样换曲子不用重新编译;二是在Qt界面上加一个播放/暂停按钮,用信号槽控制线程启停;三是直接用QtMultimedia播放编好的WAV或MP3文件,这在板子带音频Codec时更省事;四是把PWM封装成一个类,替换掉GPIO翻转。

说到底,这个项目最大的价值不在于“能用蜂鸣器唱出《欢乐颂》”这件事本身,而在于它把一个完整的嵌入式Linux应用链路走了一遍:乐谱转数据、数据转时序、时序转硬件电平,全程透明、可控、可调试。下一次你再想用Qt驱动其他设备,比如电机、彩灯、传感器,思路也是一样的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询