☰
6410开发板Qt蜂鸣器PWM音乐播放与跑马灯联动
2026/11/5 2:43:25 网站建设 项目流程

简介:围绕三星S3C6410开发板上的Qt交互应用,这份资料面向嵌入式Linux初学者及准备课程设计、实验课例的开发者,聚焦按键监听、蜂鸣器播放《欢乐颂》与LED跑马灯控制在同一工程中的协同实现。压缩包共10个文件,以3个cpp源文件和2个h头文件为核心,辅以qrc资源清单、pro工程配置及界面图片,整体约35KB,结构轻量,适合导入Qt Creator后对照阅读与编译调试。已有308人学习,在开发板入门实践中具备一定参考热度。读者可从中了解信号与槽如何承接物理按键事件、音乐播放与GPIO控制如何分层组织,并观察资源打包、工程配置和界面素材的配合方式,为复现类似交互项目、撰写实验报告或扩展外设功能提供可借鉴的工程骨架与排错切入点。

1. 在 6410 开发板上用 Qt 放一首《欢乐颂》:这套源码到底解决什么问题

很多人拿到 6410 开发板,第一件事是点灯,第二件事是让蜂鸣器响一声,然后就卡住了:点灯和放音乐各自都能跑,但一旦想做进图形界面里,交叉编译、触摸屏、PWM 时钟、Qt 事件循环就全都缠在一起。这套资源的思路是用《欢乐颂》做测试音源,界面上点一下播放,跑马灯跟着节拍走,跑通交叉编译、Qt 运行时、蜂鸣器 PWM、LED 控制和触摸交互整条链路。它适合刚上手 Linux 开发板、想找一个能完整跑通的 Qt 小工程当模板的人,也适合需要快速验证板子硬件是否正常的熟手。

2. 蜂鸣器与 LED 的控制面:先分清 ioctl、sysfs 和 mmap 三条路

在写任何一行 Qt 代码之前,得先弄清楚板子上这两个外设到底以什么形式暴露给用户态。这一步搞错,后面所有的调试都是白费力气——我在类似项目上见过太多次,界面明明跑起来了,点按钮没反应,最后发现是驱动只提供了 sysfs 接口,代码却一直在 open 一个不存在的 /dev/leds。

2.1 三条通路的取舍:谁更稳,谁更快,谁只配调试用

6410 这类 ARM11 平台,LED 和蜂鸣器在 Linux 下通常有三种控制方式,选哪种取决于你手上 BSP 的成熟度。

第一种是字符设备加 ioctl。驱动会创建 /dev/leds、/dev/pwm 之类的节点,应用程序 open 之后用 ioctl 传命令字下去。这种方式最稳,一次调用完成一次状态切换,不会因为文件系统的写缓冲出现延迟;缺点是完全绑定驱动,换一块板子、换一份 BSP,命令字的数值几乎一定变,代码得跟着改。

第二种是 sysfs。LED 走 /sys/class/leds/xxx/brightness,PWM 走 /sys/class/pwm/pwmchipN/pwmM/ 下的 period、duty_cycle、enable。好处是不依赖任何私有头文件,用 shell 就能验证硬件,出问题时能立刻判断是驱动坏了还是应用写错了。缺点是写文件有开销,而且 LED 子系统默认可能挂着 heartbeat 之类的 trigger,会跟你的程序抢控制权,必须先写 none 把 trigger 关掉。

第三种是 /dev/mem 加 mmap 直接摸寄存器。把 GPIO 的配置寄存器、数据寄存器、PWM 的预分频和计数寄存器映射进用户态,赋值就行。调试阶段用它最快,能立刻确认某个引脚到底通没通。但产品里不要这么干,因为内核里的驱动也在操作同一批寄存器,两边打架会出现“我明明写了 1,读回来是 0”这种黑匣子现象,排查起来极其痛苦。

方式依赖调试友好度稳定性适用阶段
ioctl 字符设备驱动头文件的命令字一般,需要写测试程序高正式功能
sysfs内核子系统,无需私有头文件高,shell 直接验证中,注意 trigger 抢占首选,兼容性最好
/dev/mem + mmap无,但需 root最高低,与驱动冲突只做硬件排查

我的习惯是:先用 mmap 或 sysfs 确认硬件通路,再决定正式代码走 ioctl 还是 sysfs。这份资源里的封装层两种都留了实现,通过编译开关切换,原因就在这儿。

2.2 用 shell 把硬件先验一遍,别让 Qt 背锅

打开 Qt Creator 之前,先在串口终端里把下面几条命令跑一遍。这一步只需要两分钟,但能省掉后面几个小时对着黑屏猜原因的时间。

# 1. 看驱动到底创建了哪些节点,注意权限和主次设备号 ls -l /dev/leds /dev/pwm /dev/buzzer 2>/dev/null # 2. 有 sysfs 导出的 LED,先关掉 trigger 再手动点灯 ls /sys/class/leds/ echo none > /sys/class/leds/led1/trigger echo 1 > /sys/class/leds/led1/brightness echo 0 > /sys/class/leds/led1/brightness # 3. PWM 蜂鸣器:period 单位是纳秒,1kHz 就是 1000000ns ls /sys/class/pwm/pwmchip0/ echo 0 > /sys/class/pwm/pwmchip0/pwm0/enable 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

第三条命令执行完,正常的板子应该能听到一个持续的 1kHz 单音,把 duty_cycle 从 500000 改成 100000,音量会明显变小——这说明占空比确实在起作用,后面的旋律播放才有意义。如果一点声音都没有,先别怀疑代码,去查三件事:蜂鸣器是无源还是有源、驱动有没有把 PWM 引脚复用配置成 TOUT 功能、以及内核启动日志里 pwm 相关的 probe 是否成功。

这里有个容易被忽略的硬件前提:有源蜂鸣器内部自带振荡电路,给电就响,频率固定,你在软件里怎么改 period 都只会得到同一个音高。想放旋律,板上必须是无源蜂鸣器,靠外部方波驱动。手上这块板子如果只有“嘀”一声,那这套代码的播放部分就没法验证,得先确认硬件选型。

提示:sysfs 的 pwm 接口在 enable 为 1 的时候,部分驱动会拒绝修改 period,返回 -EBUSY。所以每次改频率的顺序必须是先 enable=0,再改 period 和 duty_cycle,最后 enable=1。这个顺序在后面的 C++ 封装里会体现。

2.3 封装一层 HalBuzzer 和 HalLed,让 Qt 代码不绑死驱动

不管底层走哪条路,Qt 界面那一层只应该看到“响一个频率”和“第几盏灯亮”这两个语义。所以在工程里我放了两个极薄的类,把平台差异关在里面。

先看 LED 这一侧,走字符设备加 ioctl 的版本:

// hal_led.cpp —— 字符设备 + ioctl 的控制方式 #include "hal_led.h" #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> // 命令字必须和板子驱动头文件里的定义完全一致, // 不同 BSP 给的数值差别很大,不要照抄这里的 1 / 2。 #define LED_ON _IOW('L', 1, int) #define LED_OFF _IOW('L', 2, int) bool HalLed::open(const char *node) { m_fd = ::open(node, O_RDWR); // 通常需要 root 权限 return m_fd >= 0; } void HalLed::set(int index, bool on) { if (m_fd < 0) return; int arg = index; // 驱动约定:参数是灯序号,从 1 还是 0 开始看驱动 ::ioctl(m_fd, on ? LED_ON : LED_OFF, &arg); } void HalLed::close() { if (m_fd >= 0) { ::close(m_fd); m_fd = -1; } }

这段代码里唯一需要你改的就是两个宏。做法是去 BSP 的驱动源码里搜 ioctl 的 switch 分支,或者直接 include 驱动目录下的头文件再把宏抄过来。参数传递方式也要确认:有的驱动用传值,ioctl 的第三个参数直接写 index;有的用传址,要写 &index。传错了不会报错,只是灯不亮,这是最典型的哑巴故障。

蜂鸣器这一侧走 sysfs,代码量更少,也不需要私有头文件:

// hal_buzzer.cpp —— 走 /sys/class/pwm 的控制方式 #include "hal_buzzer.h" #include <QFile> static bool writeAttr(const QString &path, const QByteArray &val) { QFile f(path); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; return f.write(val) == val.size(); } bool HalBuzzer::play(int hz) { // 先停再改:多数 pwm 驱动在 enable=1 时拒绝修改 period writeAttr(m_base + "/enable", "0"); if (hz <= 0) return true; // hz<=0 表示休止符 const qint64 periodNs = 1000000000LL / hz; // 周期 = 1/f,换算成纳秒 const qint64 dutyNs = periodNs / 2; // 50% 占空比,方波听感最响 writeAttr(m_base + "/period", QByteArray::number(periodNs)); writeAttr(m_base + "/duty_cycle", QByteArray::number(dutyNs)); return writeAttr(m_base + "/enable", "1"); }

period 换算这一步看着简单,但有个精度问题值得说。1000000000/hz 是整数除法,以 440Hz 为例,得到的周期是 2272727ns,反过来换算成频率是 440.0000Hz,误差可以忽略;但如果频率很高,比如 4kHz 以上,整数截断带来的误差就会累积到百分之一以上,音准开始飘。人耳对百分之一以内的偏差基本不敏感,超过百分之二就能听出“跑调”,所以后面做频率表的时候我用的是 round 之后的整数赫兹值,而不是浮点频率直接算。

PWM 时钟这条链路也值得心里有数。6410 的定时器输入时钟是 PCLK 经过预分频器和除法器分频之后的频率,计数初值 TCNT = 输入时钟 / 目标频率 - 1。sysfs 接口把这套换算藏在驱动里,你只要给纳秒;但如果你的板子只提供 /dev/pwm 加 ioctl 让你自己传频率,那么驱动内部大概率也是这个公式,只是边界条件(比如 TCNT 溢出、分频比取整)要自己兜住。频率低于几十赫兹时,计数器需要的分频比会很大,某些驱动的分频档位不够用,表现出来就是低音区全部变成同一个音——这是后面旋律里低音 5 容易翻车的原因。

3. 交叉编译 Qt 与 tslib:让二进制在板子上真的能跑起来

工程能在主机上编译通过,和在板子上跑起来,是两件完全不同的事。我见过太多人卡在这一步:编译没报错,拷过去一执行,要么No such file or directory(其实是动态链接器路径不对),要么Illegal instruction(浮点 ABI 不匹配),要么跑起来了但屏幕一片漆黑。这一章把这条链路上真正会出问题的三个环节拆开说。

3.1 工具链与 Qt 配置参数:-embedded、-platform、tslib 各管什么

第一条铁律:用板子厂商随盘提供的交叉工具链,不要用主机自带的 gcc,也不要随便从网上下一个 arm-linux-gcc。原因是 ABI 必须和板子上已有的 C 库一致,软浮点工具链编译出来的程序放到硬浮点的根文件系统上,链接阶段可能就过不去;反过来,硬浮点编译的程序跑到软浮点的系统上,运行时直接Illegal instruction。判断方法很简单,看板子 /lib 目录下的动态库名字里有没有带hf或者gnueabihf。

Qt 本身也需要交叉编译,因为你要的是 ARM 版本的 libQtCore、libQtGui。配置参数里最关键的是这几个:

# 在 Qt 源码目录下执行,参数按自己板子的路径调整 ./configure \ -embedded arm \ # 目标平台是嵌入式 ARM -xplatform qws/linux-arm-g++ \ # 使用 ARM 的 mkspec,里面指定交叉编译器 -platform linux-g++ \ # 生成 moc/uic 等宿主工具用的编译器 -qt-mouse-tslib \ # 鼠标/触摸走 tslib,这一步不加后面触摸全废 -qt-kbd-linuxinput \ # 需要外接键盘时打开 -no-webkit -no-qt3support \ # 砍掉用不上的模块,编译时间和体积都能省一半 -prefix /usr/local/Qt-arm \ # 安装路径,后面要整个拷到板子上 -I/usr/local/tslib/include \ -L/usr/local/tslib/lib

-embedded arm和-xplatform两个参数经常被混为一谈,其实分工不同:前者告诉 Qt 这是一次嵌入式构建,会启用 QWS 之类的机制;后者指定用哪套编译器规则。少了-qt-mouse-tslib,编译出来的 Qt 库根本不认识 tslib,触摸屏上点哪儿都没反应,这个坑我踩过一次,重编了整个 Qt,花了快两个小时。

用 Qt5 的话配置方式变了,走-device加-sysroot,图形后端从 QWS 换成 linuxfb 或 eglfs,触摸通过-tslib打开。工程代码基本不用改,但环境变量和启动参数完全不同,这一点在做移植时要提前确认板子上烧的是哪个大版本的库。

编译完成后不要急着拷整个目录,先用file命令验证一下产物:

# 确认架构是 ARM,不是 x86 file /usr/local/Qt-arm/lib/libQtCore.so.4 # 看动态依赖里有没有意外链到主机的库 arm-linux-readelf -d /usr/local/Qt-arm/lib/libQtGui.so.4 | grep NEEDED

如果file输出里出现 x86-64,说明-xplatform没生效,或者 mkspec 里的编译器路径写错了。

3.2 触摸屏校准与环境变量:QWS_DISPLAY 和 TSLIB_TSDEVICE

程序在板子上起来之后黑屏,九成是显示设备没配对。QWS 这套机制需要知道 framebuffer 在哪儿,触摸事件从哪个设备来,这些全靠环境变量告诉它。

# 放到板子的 /etc/profile.d/qt.sh 里,开机自动生效 export TSLIB_TSDEVICE=/dev/input/event0 # 触摸屏的事件节点,用 cat /proc/bus/input/devices 确认 export TSLIB_CALIBFILE=/etc/pointercal # 校准数据存这儿 export TSLIB_CONFFILE=/usr/local/tslib/etc/ts.conf export TSLIB_PLUGINDIR=/usr/local/tslib/lib/ts export LD_LIBRARY_PATH=/usr/local/tslib/lib:/usr/local/Qt-arm/lib:$LD_LIBRARY_PATH export QWS_DISPLAY=LinuxFb:/dev/fb0 # 告诉 QWS 用哪个 framebuffer export QWS_MOUSE_PROTO=tslib:/dev/input/event0 export QWS_SIZE=480x272 # 和屏幕实际分辨率一致,写错了界面会被裁掉

TSLIB_TSDEVICE写错是最常见的哑巴故障:程序不报错,界面正常显示,就是点不动。确认方法是cat /proc/bus/input/devices,找到触摸屏那一段,看 Handlers 后面跟的是 event 几。有的板子触摸和按键共用一个控制器,会多出好几个 event 节点,得逐个试。

校准必须跑一次ts_calibrate,它会在屏幕上依次出现五个十字,点完之后生成 /etc/pointercal 文件。这个文件里是一组线性变换系数,tslib 用它把原始坐标映射到屏幕坐标。如果发现点击位置整体偏移、而且偏移量随坐标变化,那就是没校准或者校准文件丢失;如果偏移是固定的几十个像素,更可能是QWS_SIZE和实际分辨率不一致,界面被缩放了。

注意:QWS 程序启动时要么加-qws参数让它自己当 server,要么先启动一个 qws server 再跑客户端。两个 Qt 程序同时加 -qws 启动会抢占同一个 socket,第二个直接报QWSDisplay::Data.init: Cannot connect to socket。做多窗口应用时,正确做法是主程序带 -qws,其余不带。

3.3 部署方式:NFS 挂载调试,还是打包进根文件系统

调试阶段强烈建议走 NFS。每次改代码重新编译,产物直接落在开发板挂载的目录里,板子上执行就行,省掉反复拷贝的几分钟。板子上的挂载命令大概长这样:

# 主机上先把工程产物目录导出,板子上挂过来 mount -t nfs -o nolock,rsize=1024,wsize=1024 192.168.1.100:/nfsroot/app /mnt cd /mnt && ./happyode -qws

nolock这个参数在老内核上基本是必须的,不加会卡在挂载阶段。rsize 和 wsize 调小一点是为了兼容,网络稳定的环境下可以放大提升速度。

正式发布时再打包进根文件系统。在 .pro 文件里加上安装规则,make install会自动把可执行文件放到target.path指定的位置:

# happyode.pro 片段 target.path = /opt/happyode INSTALLS += target # 主机上执行,前缀指向 rootfs 目录 make install INSTALL_ROOT=/path/to/rootfs

这样整个 /opt/happyode 目录就出现在根文件系统里了,烧写或者打包镜像的时候一起带过去。省事,但每次改动都要重新打包,所以只适合收尾阶段。

4. 音符、节拍与 QTimer:把《欢乐颂》拆成频率表和非阻塞状态机

到这一步硬件通路通了,Qt 环境也跑起来了,剩下的就是把“一首歌”翻译成“一串频率加一串时长”。看起来是纯逻辑的活,但真正决定体验好坏的两个点都在这里:频率取得准不准,以及播放过程中界面卡不卡。

4.1 音符到频率:十二平均律与整数取整的边界

音乐里的音高关系是十二平均律,相邻半音的频率比是 2 的 12 次方根。以 A4 = 440Hz 为基准,任意音高可以用公式 f = 440 × 2^((n−69)/12) 算出来,n 是 MIDI 音高号。实际工程里没人会在运行时跑这个公式,都是查表。

《欢乐颂》只用到自然音阶里的几个音,我把用到的部分列成表:

音名频率(Hz)取整后说明
L5 低音 sol196.00196只在第三句结尾出现一次
M1 中音 do261.63262主音,出现频率最高
M2 中音 re293.66294
M3 中音 mi329.63330全曲第一个音
M4 中音 fa349.23349
M5 中音 sol392.00392
M6 中音 la440.00440基准音
M7 中音 si493.88494本曲基本不用
H1 高音 do523.25523
H2 高音 re587.33587

取整到整数赫兹带来的最大偏差在 0.1% 量级,听感上完全没问题。真正需要小心的是低音区:196Hz 对应周期约 5.1ms,如果驱动只支持有限的分频档位,实际输出可能是 190Hz 或者 200Hz,偏差超过 2%,耳朵能听出来发闷。验证方法是在播放低音 5 的时候用cat /sys/class/pwm/pwmchip0/pwm0/period看驱动实际接受的周期值,如果和你写进去的不一致,说明驱动做了取整,得回头改分频配置。

还有一个容易忽略的点是音符之间的间隔。如果两个相同音高的音符之间不留缝,听起来就是一整个长音,旋律的节奏感全没了。所以每个音符实际发声时长要比它的理论时值短一点,留出 20ms 左右的静音间隙,这个间隙在代码里叫 gap。

4.2 旋律数据结构:枚举、时值和一个数组

数据结构设计得简单一点,后面加歌、改谱都方便。我用一个枚举表示音高,一个结构体表示音符,时值统一以四分音符为 1 拍:

// melody.h enum Pitch { L5, M1, M2, M3, M4, M5, M6, M7, H1, H2 }; // 下标就是枚举值,查表 O(1),不用 switch static const int kFreq[] = { 196, 262, 294, 330, 349, 392, 440, 494, 523, 587 }; struct Note { Pitch pitch; // 音高 int beats; // 时值,以四分音符为 1 bool rest; // 是否是休止符,是为 true 时忽略 pitch };

《欢乐颂》的主旋律按简谱记下来大概是这样,四个乐句,每句 8 小节左右:

// 每行的 | 只是视觉分隔,代码里没有意义 static const QVector<Note> kOdeToJoy = { {M3,1,false},{M3,1,false},{M4,1,false},{M5,1,false}, {M5,1,false},{M4,1,false},{M3,1,false},{M2,1,false}, {M1,1,false},{M1,1,false},{M2,1,false},{M3,1,false}, {M3,1,false},{M2,1,false},{M2,2,false}, {M3,1,false},{M3,1,false},{M4,1,false},{M5,1,false}, {M5,1,false},{M4,1,false},{M3,1,false},{M2,1,false}, {M1,1,false},{M1,1,false},{M2,1,false},{M3,1,false}, {M2,1,false},{M1,1,false},{M1,2,false}, {M2,1,false},{M2,1,false},{M3,1,false},{M1,1,false}, {M2,1,false},{M3,1,false},{M4,1,false},{M3,1,false}, {M1,1,false},{M2,1,false},{M3,1,false},{M4,1,false}, {M3,1,false},{M2,1,false},{M1,1,false},{L5,2,false}, {M3,1,false},{M3,1,false},{M4,1,false},{M5,1,false}, {M5,1,false},{M4,1,false},{M3,1,false},{M2,1,false}, {M1,1,false},{M1,1,false},{M2,1,false},{M3,1,false}, {M2,1,false},{M1,1,false},{M1,2,false} };

这个数组就是全部的音乐数据。想换歌,换数组就行;想加装饰音,往对应位置插元素就行。把 4 拍的音写成{M5, 4, false}是合法的,播放逻辑不需要特殊处理,因为时值只是用来算定时器间隔。

4.3 非阻塞播放循环:为什么不能用 sleep

最直接的写法是遍历数组,每个音设置频率,然后usleep(时长),再下一个。这个写法在命令行程

序里没问题,塞进 Qt 就是灾难:主线程被 usleep 阻塞,事件循环停转,界面不刷新,点停止按钮没反应,窗口拖不动,用户会以为程序挂了。这就属于典型的“玄学卡顿”,代码逻辑挑不出毛病,就是体验差。

正确做法是把播放拆成状态机,用一个只触发一次的 QTimer 驱动,每次超时处理一个音符然后重新定时。这样事件循环始终在转,界面该刷新刷新,该响应响应。

// melodyplayer.h class MelodyPlayer : public QObject { Q_OBJECT public: explicit MelodyPlayer(HalBuzzer *bz, QObject *parent = nullptr); void play(const QVector<Note> &song, int bpm); void stop(); signals: void noteOn(int index); // 给跑马灯联动用 void finished(); private slots: void step(); private: static const int kGapMs = 20; // 音符之间留的静音间隙 HalBuzzer *m_bz; QVector<Note> m_song; int m_idx = 0; int m_unitMs = 500; bool m_muting = false; QTimer m_timer; };
// melodyplayer.cpp MelodyPlayer::MelodyPlayer(HalBuzzer *bz, QObject *parent) : QObject(parent), m_bz(bz) { m_timer.setSingleShot(true); // 关键:只触发一次,由 step() 自己续上 connect(&m_timer, &QTimer::timeout, this, &MelodyPlayer::step); } void MelodyPlayer::play(const QVector<Note> &song, int bpm) { m_song = song; m_idx = 0; m_muting = false; m_unitMs = 60000 / bpm; // 一拍多少毫秒,bpm=120 时是 500ms step(); // 立即起第一个音,不要等第一次超时 } void MelodyPlayer::step() { if (m_muting) { // 第二阶段:断音 m_bz->play(0); m_muting = false; m_timer.start(kGapMs); // 静音 20ms 后再进下一个音 return; } if (m_idx >= m_song.size()) { // 收尾必须显式关掉 PWM m_bz->play(0); emit finished(); return; } const Note &n = m_song.at(m_idx); m_bz->play(n.rest ? 0 : kFreq[n.pitch]); emit noteOn(m_idx); // 通知界面更新当前音符和跑马灯 ++m_idx; const int ms = n.beats * m_unitMs; if (ms > kGapMs + 40) { m_muting = true; m_timer.start(ms - kGapMs); // 先响 ms-kGapMs,再留 kGapMs 静音 } else { m_timer.start(ms); // 极短的音就不切了,避免定时器抖动 } } void MelodyPlayer::stop() { m_timer.stop(); m_muting = false; m_idx = m_song.size(); // 让下一次 play() 从头开始 m_bz->play(0); // 用户点停止,必须立刻关音 }

这段代码里有两个设计值得展开。第一,setSingleShot(true)是必须的,用周期性定时器去驱动旋律会出现累积误差,跑上几分钟节拍就明显偏了;每次重新 start 相当于每次都从当前时刻重新计时,误差不累积。第二,m_bz->play(0)在三个地方出现——正常结束、用户主动停止、以及音符之间的断音。少任意一处,都会出现“程序关了但蜂鸣器还在响”的尴尬场面,用户只能断电。

m_unitMs = 60000 / bpm这个换算也要留意,bpm 是整数,60000 除以 100 得 600,整除没问题;但 bpm 取 130 的时候是 461.5,整除成 461,每拍少 0.5ms,一首歌三四十拍下来总时长少十几毫秒,人耳感觉不出来。真正影响听感的是极快节拍下的定时器精度,QTimer 在嵌入式板子上的分辨率大概在 1ms 到 10ms 之间,bpm 超过 200 就开始糊了,所以这个播放器适合的区间是 60 到 160。

4.4 跑马灯与音乐同框:一个定时器还是两个

跑马灯有两种做法。一种是独立节奏,用另一个 QTimer 每 200ms 移一位,跟音乐无关;另一种是跟着节拍走,接noteOn信号,来一个音符换一盏灯。前者简单但看着乱,后者观感好,而且能直观暴露播放逻辑的问题——如果灯和音的对应关系错位,说明信号槽连接或者音符下标算错了,比听音辨错容易得多。

这份资源用的是后一种,实现很短:

// 在界面的构造函数里连接 connect(m_player, &MelodyPlayer::noteOn, this, [this](int idx) { // 8 盏灯循环移位,每来一个音符前进一位 const int lit = idx % m_ledCount; for (int i = 0; i < m_ledCount; ++i) m_led->set(i + 1, i == lit); // 序号从 1 开始,和驱动约定一致 });

不用线程的理由很实际:6410 是单核 ARM11,多线程不会带来真正的并行,反而多出锁和跨线程信号槽的开销。整个播放逻辑的耗时大头是写 sysfs 文件,一次几十微秒,放在主线程完全没压力。真要做“后台播放 + 前台干别的事”,QTimer 状态机本身就满足,事件循环会自己调度。

只有一种情况需要线程:蜂鸣器驱动用了阻塞式的 ioctl,一次调用要等一整个音符的时长才返回。这种驱动设计很糟糕但确实存在,遇到了就只能在 HalBuzzer 外面包一层 QThread,或者干脆换驱动。

5. 排查记录:触摸偏移、音符粘连和退出后蜂鸣器长鸣这五个坑

这一章是血泪经验集中营。下面五条都是在真板子上反复出现过的问题,按“现象 → 原因 → 解决”的顺序写,遇到类似症状可以对着排查。

5.1 界面起来了,但点哪儿都没反应

现象:程序正常显示,按钮有按下动画,但点击事件不触发;串口没有报错。

原因:QWS 不知道触摸事件从哪儿来。可能QWS_MOUSE_PROTO没设,可能设了但设备节点写错,也可能 tslib 的插件目录没配对导致加载失败。

解决:先跑ts_test,如果这个自带工具也点不动,问题在 tslib 而不是 Qt。确认TSLIB_TSDEVICE指向的 event 节点正确(用cat /proc/bus/input/devices查),再确认TSLIB_PLUGINDIR下的 .so 文件确实存在。两者都对还不行,看QWS_MOUSE_PROTO是否写成了tslib:/dev/input/event0这种带设备路径的形式,少了设备路径 Qt 会去找默认值,通常找不到。

5.2 触摸能点,但落点整体偏了一大截

现象:点左上角的按钮,实际触发的是右下角的功能;偏移量大致固定。

原因:QWS_SIZE和屏幕实际分辨率不一致,Qt 按错误的尺寸做了坐标缩放。或者 pointercal 文件是别的分辨率下生成的,系数不匹配。

解决:fbset看 framebuffer 的真实分辨率,把QWS_SIZE改成一致。然后删掉 /etc/pointercal,重新跑一次ts_calibrate。校准的时候点十字要准,手指比十字中心偏几个像素,整块屏的映射就整体歪了。

5.3 旋律听不出调,音符全糊在一起

现象:能听到音高变化,但节奏一团糟,像有人在乱按琴键。

原因:两个可能。一是没做断音处理,相邻音符之间没有间隙,相同音高的连续两个音直接连成一个长音;二是定时器用了周期性模式,误差累积导致节拍越走越偏。

解决:确认m_timer.setSingleShot(true)生效,确认 step() 里有m_muting那一段。间隙取 20ms 是个经验值,低于 10ms 断不干净,高于 40ms 听起来一顿一顿的。另外检查 bpm 是不是设得太高,超过 160 之后 QTimer 的精度就不够用了。

5.4 关掉程序,蜂鸣器还在响

现象:点窗口右上角关闭,界面消失,蜂鸣器持续长鸣,只能断电。

原因:PWM 的 enable 还是 1,驱动保持着输出。程序退出时没有走任何清理路径,析构函数里也没关。

解决:三处都要补。MelodyPlayer::stop()里关;主窗口的closeEvent里调 stop();HalBuzzer 的析构函数里再关一次兜底。三保险听起来多余,但少任何一处都可能在某些退出路径下漏掉,比如程序被 kill 信号干掉时,只有析构能救——严格说 kill -9 连析构都不走,那种情况只能靠驱动自己超时关闭,所以驱动侧的保底逻辑也要有。

5.5 换了一块板子,代码原样跑,灯全不亮

现象:同样的程序,在老板子上正常,新板子上open("/dev/leds")返回 -1,或者返回成功但灯不动。

原因:新板子的 BSP 把 LED 换成了 sysfs 接口,没有创建 /dev/leds;或者命令字不同,ioctl 返回 -1 但代码没检查返回值。

解决:ls /sys/class/leds/看看有没有导出。有的话改用 sysfs 实现,HalLed 换个后端就行,上层不用动,这正是当初做抽象层的价值。另外所有 ioctl 的返回值都要检查,返回 -1 时打一条 qWarning 出来,比静默失败好排查一百倍。权限也是常见原因,/dev/leds的权限如果是 600 而程序不是 root,open 直接失败。

6. 把旋律挪出代码:外部曲谱文件与串口日志自检

前面那个kOdeToJoy数组写死在代码里,换一首歌就得改源码、重编译、重新部署,来回折腾。做到第三首歌的时候我就受不了了,改成了一个极简的文本格式,运行时读文件解析,扔进 /opt/happyode/songs 目录就能加歌。

6.1 一个二十行能写完的曲谱格式

格式设计得越土越好,方便手写也方便脚本生成。每行一个音符,字段用空格分开:音名、时值、可选标记。

# songs/ode.txt —— # 开头是注释 bpm 120 M3 1 M3 1 M4 1 M5 1 M5 1 M4 1 M3 1 M2 1 M1 1 M1 1 M2 1 M3 1 M3 1 M2 1 M2 2

解析部分就是一个QTextStream逐行读,split(' ')取字段,音名用QHash<QString, Pitch>反查枚举。bpm 那一行特殊处理,读到就更新节拍。整个解析器不到四十行,加进来之后换歌的成本从“改代码重编译”降到了“用记事本改个文件”。

6.2 用串口日志加一个脚本核对音准

旋律播放这种功能,眼睛看不出对错,耳朵听得出

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

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

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

立即咨询