Flipper Zero 跑起 Tamagotchi P1 模拟器:ROM、构建与 4 位 CPU 原理解析
2026/9/20 4:51:13 网站建设 项目流程

Flipper Zero 跑起 Tamagotchi P1 模拟器:ROM、构建与 4 位 CPU 原理解析

【免费下载链接】FlipperPlayground (and dump) of stuff I make or modify for the Flipper Zero项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper

32×16 的黑白像素块里,一只虚拟宠物正在等你喂食——这不是游戏厅的拓麻歌子,而是运行在 Flipper Zero 上的 Tama P1 模拟器:外部 fap 插件,加载 ROM 即可复活这只 1996 年的电子宠物,还支持存档、蜂鸣器和 64 kHz 精确时钟。

一个外壳加一颗芯片:它在模拟什么

Tama P1 模拟器的目标硬件很特别:拓麻歌子初代内置的是一颗 Epson E0C6S464 位 MCU,主频 32,768 Hz,屏幕 32×16 像素外加 8 个状态图标。而它的 ROM 至今没有正规渠道,是社区从芯片裸片照片上光学识读出来的——你从网上找到的那份 ROM,本质上是一张显微照片的产物。

这个应用由两层组成。外面一层是 Flipper 应用外壳:负责从 SD 卡读 ROM、接住五向按键、驱动显示屏和蜂鸣器。里面一层是 TamaLIB,一个从零写起、硬件无关的模拟内核,逐指令复刻那颗 4 位 CPU。类比一下:外壳负责翻译硬件,内核负责复刻芯片,两者之间只隔着一张函数指针表。

这种分层直接决定了它的部署形态——应用元数据里apptypeEXTERNAL,意味着它编译成.fap插件放在 SD 卡里加载,入口函数tama_p1_app,依赖guistorage两个系统服务,应用栈只有 1 KB(真正的模拟循环跑在另一个独立线程里)。

从零跑起来:ROM 到屏幕的时间线

第 1 步:准备 ROM。在 microSD 卡根目录新建tama_p1文件夹,把网上找到的 Tamagotchi P1 ROM(有的来源里那个文件叫a)改名为rom.bin放进去。源码里这个路径是写死的:EXT_PATH("tama_p1/rom.bin")

第 2 步:放置源码。把整个 tama_p1 目录 拷进你本地的固件源码树,放到applications/plugins/tama_p1位置——这是 Flipper 外部插件的标准落点。

第 3 步:编译图标。应用引用了compiled/assets_icons.h里的 8 个状态图标符号(I_icon_0I_icon_7),这个头文件需要先由固件自带的资源脚本从 PNG 编译出来:

scripts/assets.py icons applications/tama_p1/icons applications/tama_p1/compiled

这条命令在固件根目录下执行,把icons/里的 8 张 PNG 转成 C 头文件输出到compiled/(仓库里已带一份编译产物,源码改动图标时才需要重跑)。

第 4 步:一条命令编译、推送、启动:

./fbt launch_app APPSRC=applications/plugins/tama_p1

fbt是固件自带的构建工具,launch_app目标会完成编译、通过串口安装到设备并立即运行。注意原 README 写的是反斜杠路径,在 Linux 或 macOS 的终端里必须换成斜杠。

第 5 步:启动后看屏幕。一切顺利,你会看到 32×16 的像素区居中放大两倍的画面,上下各排着 4 个状态图标。如果 ROM 路径不对,屏幕只会冷冷地打出 "No ROM";若模拟 CPU 中途 halt,则显示 "Halted"。

⚡ 编译时如果 TamaLIB 的cpu.chw.c因"未使用参数"告警而失败(严格告警配置下会被当错误),按 README 的提示在site_cons/cc.sconsCCFLAGS里追加-Wno-unused-parameter

调试彩蛋。想看清按键与存档的行为,可以一边启动应用一边挂上串口日志:

python serial_logger.py

这是 FlipperScripts 里的监听脚本,配合./fbt launch_app ...分号连用即可。应用内部所有关键动作——ROM 加载、save.bin 读写、每次按键——都会以sequence/key/type三要素打进日志,Tag 分别是TamaP1TamaLIB

上手体验:你按下左键时,模拟器里发生了什么

基础操作只有三个键:左键是拓麻歌子的 A 键,OK 键是 B 键,右键是 C 键——对应游戏里的确认、菜单、返回。长按返回键保存并退出应用,这是最标准的收尾方式。

真正的玩法藏在另外两个方向键里。按上键,应用会同时向 TamaLIB 上报 A 和 C 两个按键的状态,这正是拓麻歌子上"静音"的经典组合键;短按下键,则直接触发一次tama_p1_save_state(),不退出就先把状态写盘——相当于手动存档。短按返回键同样触发保存。这些行为 README 一句没提,全部写在 tama_p1.c 的输入分支里。

你以为没有存档,其实有。README 正文写着"There is currently no saving",同一文件的 "Implemented" 一节却又列着 "Saving/Loading emulator state (stored in/ext/tama_p1/save.bin)"。以源码为准:存档功能完整实现,退出时写/ext/tama_p1/save.bin,启动时自动读回,你的宠物会从上次的状态继续活着。前那句"无存档"属于过时描述。

幕后原理:4 位机如何在 STM32WB 上活过来

先给结论:TamaLIB 在 Flipper 的 STM32WB 上逐周期模拟一颗 32,768 Hz 的 4 位 MCU,而整个应用的难点不在"算得快",在于让模拟时钟、真机时钟、屏幕刷新三者严丝合缝。

64 kHz 时钟如何驱动 4 位模拟

被模拟的 CPU 主频 32,768 Hz,但模拟精度需要约 30 微秒(1/32768)的分辨率,所以整个模拟时间基被放大 2 倍,用 64 kHz 的计数器来驱动——这就是tamalib_init第三个参数传 64000 的原因。

时间源直接借用 STM32 的硬件定时器 TIM2:预分频设为 999,计数频率恰好落到 64 kHz 附近,get_timestamp()返回LL_TIM_GetCounter(TIM2)即可,模拟与真实时间天然同步。

节奏控制的关键在sleep_until:当模拟时间落后于目标时间戳时,它会释放互斥锁、furi_delay_tick(1)让出一个 CPU 时隙,再重新拿锁继续步进。源码里那行注释说得很直白——这里是唯一能判断"跑快了还是跑慢了"的地方,否则模拟线程会越积越慢。

4096 个 4 位单元与 LOW_FOOTPRINT 压缩

被模拟 CPU 的地址空间是 4096 个 4 位单元,划分如下:

区域地址范围大小
RAM0x0000x27F640 个 4 位单元(MEM_RAM_SIZE 0x280
显示区 1(LCD)0xE000xE4F80 个 4 位单元
显示区 2(LCD)0xE800xECF80 个 4 位单元
I/O 寄存器0xF000xF7F128 个 4 位单元

完整映射要占 4096 字节,但 Flipper 的 RAM 宝贵。cpu.h里默认打开LOW_FOOTPRINT宏,只把有效区域打包进约 464 字节的缓冲区(928 个半字节,两两塞进一个u8_t),代价是每次读写都要靠SET_RAM_MEMORY/GET_RAM_MEMORY这类宏做位移和掩码运算。用一点 CPU 换空间,是嵌入式模拟器的标准权衡。

寄存器方面,cpu.h 的state_t把 PC 声明为 13 位、通用寄存器 X/Y 各 12 位、累加器 A 和 B 寄存器各 4 位、NP 页寄存器 5 位——存档文件里那些& 0x1F& 0xF的掩码全是从这里来的。

HAL 桥接:两百行代码接通时钟、屏幕与蜂鸣器

TamaLIB 与平台之间只有一个hal_t结构体,约 14 个函数指针:内存分配、halt 回调、日志、时钟、屏幕、声音、事件处理器。hal.c 逐个落地,最值得看的有三块。

时钟如前所述,TIM2 计数即时间戳。屏幕走"先写缓冲、后统一渲染":set_lcd_matrix(x, y, val)把每个像素写入g_ctx->framebuffer[y]的对应位——一行 32 像素正好用一个uint32_t表示;set_lcd_icon则往g_ctx->icons这个字节里置位。真正的画屏由 GUI 主循环完成,所以update_screen()是空实现。声音上,set_frequency把 TamaLIB 报来的频率除以 10 换算成真实值,play_frequency通过furi_hal_speaker_acquire/start/stop驱动 Flipper 内置蜂鸣器,音量固定 0.5。

还有一个容易被忽略的细节:hal_types.h 里timestamp_t被特意注释为必须是无符号类型——时间戳以微秒计时时,32 位无符号数约 1 小时 11 分就会自然回绕,有符号类型会让"现在是否早于目标时间"的比较彻底错乱。sleep_until里那个delay >> (8*sizeof(uint32_t)-1)的判断,就是在检查无符号减法结果是否为负。

渲染与线程同步:30 FPS 如何追上模拟时间

应用启动后是三个角色在协作:模拟线程、GUI 主循环、一个 30 FPS 的周期定时器。定时器每隔furi_kernel_get_tick_frequency() / 30个 tick 往FuriMessageQueue(深度 8)塞一个 Tick 事件;GUI 循环收到后调用view_port_update(),最终进入绘制回调。

绘制回调第一行就是furi_mutex_acquire(mutex, 25)——25 个 tick 的超时,与模拟线程共用这把递归互斥锁g_state_mutex,抢不到锁就跳过本帧,宁可掉帧不可读到撕裂的帧缓冲。拿到锁后,32×16 的像素矩阵按TAMA_SCREEN_SCALE_FACTOR = 2逐位放大成 64×32 的色块居中绘制,再在矩阵上下两侧各画 4 个 14×14 的状态图标,间距按矩阵宽度自动均分。

模拟线程则简单得多:在 tama_p1.c 的 worker 里同步参考时间戳、使能 TIM2 计数器、先tama_p1_load_state()恢复存档,然后死循环调用tamalib_step()。它没有调用tamalib_mainloop()——把循环拆成单步,Flipper 的 GUI 事件循环才能完全掌控节奏,按键和退出信号也能在同一把锁下干净地介入。

save.bin 的 128 字节 I/O 区

存档写在/ext/tama_p1/save.bin,由tama_p1_save_state生成、tama_p1_load_state消费,布局是一个纯小端二进制流:

字段大小说明
Magic4 B固定TLST,校验文件合法性
Version1 B当前为 2
PC2 B程序计数器,高字节掩码0x1F(13 位)
X / Y各 2 B通用寄存器,高字节掩码0xF(12 位)
A / B / NP / SP / Flags各 1 B累加器、B 寄存器(4 位)、页寄存器(5 位)、栈指针、标志(4 位)
tick_counter4 B时钟周期计数
clk / prog 定时器时间戳各 4 B两个定时器的时间戳
定时器使能 / data / rld各 1 B可编程定时器状态
call_depth4 B调用栈深度
中断槽3×16 B6 个中断槽(INT_SLOT_NUM)的 factor/mask/triggered
RAM640 B每个半字节存 1 字节
I/O128 B0xF000xF7F的 128 个 I/O 半字节

加载端先验 magic 和版本,任一不符就打 FATAL 日志并放弃恢复。把寄存器、RAM、I/O 全部灌回后调用tamalib_refresh_hw(),让显示与蜂鸣器状态和恢复出的内存重新同步。换句话说,宠物死亡、饥饿计时、图标亮灭,全部在这个文件里原样存活。

目前还做不到什么

  • 多存档槽位:只有一个save.bin,想换宠物只能覆盖;
  • 游戏内重置:不退出应用就无法软复位,只能靠存档绕;
  • 测试模式:拓麻歌子固件自带的自检入口尚未接通;
  • 加速与音量fast_forward_done已被硬置为true,旁边挂着TODO: implement fast forwarding的注释;蜂鸣器音量锁死在 0.5,不可调。

想给宠物"开挂"或者多养几只,就从这份 To-do 入手。

延伸阅读

回到开场那只 32×16 像素的宠物:它的生命周期——从 ROM 半字节重排到 save.bin 的 128 字节 I/O 区——现在都握在你手里。想继续往下挖,两个方向都在这个仓库里:翻翻 Applications 目录下其他可玩的.fap游戏插件,或者逐行读 tamalib/ 的cpu.c,看一颗 4 位 CPU 的指令集是怎么被几十页 C 代码完整复刻出来的。

【免费下载链接】FlipperPlayground (and dump) of stuff I make or modify for the Flipper Zero项目地址: https://gitcode.com/GitHub_Trending/fl/Flipper

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询