简介:围绕三星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 -qwsnolock这个参数在老内核上基本是必须的,不加会卡在挂载阶段。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 低音 sol | 196.00 | 196 | 只在第三句结尾出现一次 |
| M1 中音 do | 261.63 | 262 | 主音,出现频率最高 |
| M2 中音 re | 293.66 | 294 | |
| M3 中音 mi | 329.63 | 330 | 全曲第一个音 |
| M4 中音 fa | 349.23 | 349 | |
| M5 中音 sol | 392.00 | 392 | |
| M6 中音 la | 440.00 | 440 | 基准音 |
| M7 中音 si | 493.88 | 494 | 本曲基本不用 |
| H1 高音 do | 523.25 | 523 | |
| H2 高音 re | 587.33 | 587 |
取整到整数赫兹带来的最大偏差在 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 用串口日志加一个脚本核对音准
旋律播放这种功能,眼睛看不出对错,耳朵听得出
本文还有配套的精品资源,点击获取