ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Qt在Linux开发板用蜂鸣器播放《欢乐颂》:GPIO与PWM控制的完整实践

2026/9/9 2:04:08 拓冰建站 浏览量
Qt在Linux开发板用蜂鸣器播放《欢乐颂》:GPIO与PWM控制的完整实践 简介面向嵌入式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频率440HzC4是60频率约261.63HzE4是64频率约329.63Hz。听起来很学术但实际操作时完全不用自己算背一张常用音名频率表就够用了。《欢乐颂》C大调主旋律用到的音基本都在C4到G4之间对应的频率如下简谱音名频率Hz1C42622D42943E43304F43495G43926A44407B4494《欢乐颂》第一句“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; // 频率单位Hz0表示休止 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 c11 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 make3.4 上板调试交叉编译、拷贝、运行如果开发板性能和存储都有限推荐在PC上交叉编译然后把可执行文件拷到板子运行。交叉编译的前提是有对应的交叉工具链和Qt库。以ARM 32位开发板为例假设工具链前缀是arm-linux-gnueabihf-qmake路径在工具链的sysroot里编译命令如下/opt/arm-linux-gnueabihf/bin/qmake make -j4生成的melody可执行文件通过scp传到开发板scp melody root192.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 initialized或No Qt platform plugin could be initialized的报错。这个报错出现的场景是程序用了QApplication或者QWidget也就是需要GUI平台插件但运行时找不到对应的插件。解决办法是告诉Qt平台插件在哪里。Qt5的常用嵌入式平台插件有linuxfb、eglfs、wayland等如果没有显示屏幕或者只需要framebuffer可以这样运行export QT_QPA_PLATFORMlinuxfb 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%的问题检查硬件类型。是不是有源蜂鸣器有源蜂鸣器不能用来播放不同音调的旋律。检查GPIO编号。用命令行手动导出一个GPIO强制拉高拉低听有没有声音。如果没声音换一个引脚或对照原理图确认编号。检查接线。蜂鸣器正极接GPIO驱动引脚负极接GND。如果接反了或者缺少驱动电路可能声音很小或者无声。检查权限。程序是不是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驱动其他设备比如电机、彩灯、传感器思路也是一样的。本文还有配套的精品资源点击获取