绘图机器人硬件逆向工程全解析

本文详细记录了如何对一款儿童绘图机器人进行硬件逆向工程,包括条形码系统分析、SPI闪存芯片提取、图像数据解析以及自定义图像注入的全过程,展示了完整的嵌入式设备安全研究方法。

Drawbot: 让我们入侵这个可爱的小东西!

目标

几个月前,我意识到自己早就该做一个有趣、古怪的硬件项目了。我时不时会看看市面上有什么新奇有趣的电子儿童玩具。在寻找时,我会考虑潜在的攻击面,通常更喜欢带有配套移动应用、无线通信或任何其他增加复杂性的玩具。

我遇到了这些可以根据一组预定义图像绘图的机器人。它们都附带一包100或150张卡片,不同品牌的绘图看起来非常相似。考虑到它使用预定义的物理卡片,攻击面似乎特别小,所以我无法通过FCC ID来窥探其内部结构(反正我认为那些都是剧透)。尽管如此,它似乎是一个有趣的目标,我无法抗拒。

我选择了这个,因为它有最可爱的脸。盒子上的措辞/拼写错误绝对是一个好迹象,表明有些古怪之处。它附带一叠100张卡片,每张卡片都有一个非常简化的"条形码",包含8位信息,意味着有256种可能的条形码/绘图。卡片分为五类:食物、动物、植物、车辆和圆形(显然)。我加载了"球形仙人掌"卡片并启动了它。它会说话。它会唱歌。太完美了。

让我们开始解剖吧。

拆解

老实说,这是我最喜欢的部分。

底部有几个超深的螺丝孔。你以为到了这个时候我应该有合适的工具来处理这些,但我没有。毫无例外,每次硬件评估都让我意识到自己还缺少什么(除了耐心)。无论我在家庭实验室里积累了多少专业工具或组件,总有一些东西我最终不得不购买。这时,我本可以轻松购买所需工具,享受当天或次日送达服务,但那需要耐心。我本可以3D打印一些东西来完成这项工作。再次强调,我没那么有耐心。

我用电动工具弥补了耐心的不足。我最终使用了组合方法,嗯,就是把孔钻得稍微大一点以清理一些我的螺丝刀头,并把多个头连接在一起。

拆掉四个深孔螺丝后,上半部分没费多大劲就弹开了。

条形码读取器暴露后,我尝试了一些非常基础的初步模糊测试,拿一张有效的卡片并移动它以提供意外的输入。当卡片放置在传感器上时,设备会发出声音,然后宣布相关的图像。

在模糊测试期间,宣布了各种图像,所有这些似乎都与我之前在卡片堆中看到的其他卡片相匹配,直到机器人宣布"洗个澡吧!“这似乎很奇怪(而且有点冒犯),所以我翻遍了整叠卡片,死活找不到任何带有洗澡图像的卡片。

将卡片固定到位后,我按下按钮开始绘图。机器人开始唱歌,并且确实画出了这个图像:

目标

既然我对处理的对象有了相当的了解,我为自己设定了以下两个目标:

  1. 枚举并识别所有可用的绘图。卡片显然没有展示全貌。
  2. Atredis的经典目标:给它画只鸟™(即弄清楚这些绘图是如何表示和存储的,并利用这些信息添加我们自己的绘图)。

组件

我的下一步是识别板上的组件,看看能收集/转储什么。三个关键的组件在下面标出。

  • LKS32MC07x - ARM Cortex-M0 MCU
  • uc25IQ64 - 64MB SPI NOR 闪存
  • uc25IQ128A - 128MB SPI NOR 闪存

我能够通过其SPI接口转储64MB的闪存,但对128MB的闪存没有成功,通过SWD连接到MCU也没有成功。开局不利,但我没有气馁。

条形码分析

使用万用表和印刷的走线,我们可以映射读取条形码的光学传感器的连接,以确定插入了哪张卡片。

起初,传感器和引脚之间存在一些重叠,这让我感到困惑。在我的研究中,我遇到了输入多路复用的概念。本质上,板子会使用VCC 1为传感器1-5供电,然后在输入引脚上读取值。接着,它使用VCC 2为传感器6-8供电,并复用相同的输入引脚来读取这些值。

为了测试这个理论,我决定将逻辑分析仪连接到各个输入。为了保持理智,我用两组颜色编码的自定义连接器替换了原来的全红导线连接器,并将它们引出到一个面包板上,中间有两排排针。这种设置可以同时促进被动分析和主动信号操纵。

果然,VCC线路以规律的节奏交替,这为多路复用理论提供了巨大的可信度。

将所有设备连接到Saleae后,我现在可以观察扫描卡片时的行为。我选择了对应条形码值00000001的卡片,希望数据更容易识别。将螃蟹卡片放到位时,我预计最右边的传感器会记录高电平值,而其他传感器则记录低电平。然而,正如你在下面看到的,条形码的方向在背面是相反的,所以实际上是最左边的传感器记录了高电平值。我真希望我能说我立刻就注意到了这一点。我真的很希望我能这么说。

一旦我弄明白了这一点,数据就符合了我们的预期。在VCC1阶段,传感器1-5通电,引脚被读取。传感器1(最左边)在这种情况下是我们唯一的高电平(1);传感器2到5是低电平(0)。然后在VCC2阶段,传感器6-8通电,引脚再次被读取;相应的值构成了我们条形码的最后3位,最终值为00000001。我在下面以相反的顺序显示了VCC触发(即VCC2在VCC1之前),以使条形码值更清晰,但这个过程会重复几次,值保持一致,所以顺序并不重要。

在硬件分析中,这些是我为之而活的时刻。我在这里概述的方式,看起来如此直接和明显(如果我做得正确的话),但对我来说,到达这里的道路充满了失败、困惑、接线错误,以及多次与我的同事Chad(GPT)的商议。

不管怎样,让我们用一个更复杂的条形码来测试一下。热狗卡片的条形码值是01000111。

条形码模拟

既然我们理解了条形码如何转换为信号,我们就可以使用可编程形式的输入来模拟它们。我选择使用树莓派,因为它可以在3.3V下运行,并且似乎非常适合这个用例。

首先,我们需要将传感器板上的每个相关引脚映射到树莓派上的GPIO引脚。我用红色代表VCC线路,但正如你所见,它们实际上被映射到了这里的GPIO引脚。因为我们实际上不需要为板子供电,我们将这些线路用作输入,所以我们可以跟踪电源周期,并知道正在读取哪组"传感器”,以及它们对应哪些条形码位。

我们的树莓派GPIO引脚

连接完毕

我们带有Pi GPIO映射注释的条形码板

一切连接就绪后,下一步是编写逻辑脚本,循环遍历十进制值1到256,转换为二进制,将该二进制分成两组,并根据我们处于VCC多路复用周期的哪个阶段,将适当的值写入GPIO引脚。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import gpiod
import time

chip = gpiod.Chip("gpiochip0")

#inputs:
# VCC1 (GPIO23)
# VCC2 (GPIO5)

vcc1 = chip.get_line(23)
vcc2 = chip.get_line(5)
vcc1.request(consumer="vcc1", type=gpiod.LINE_REQ_DIR_IN)
vcc2.request(consumer="vcc2", type=gpiod.LINE_REQ_DIR_IN)

#outputs: 
# D1/6 (GPIO2)
# D2/7 (GPIO3)
# D3/8 (GPIO4)
# D4 (GPIO17)
# D5 (GPIO27)

outputs = chip.get_lines([2, 3, 4, 17, 27])
outputs.request(consumer="barcode", type=gpiod.LINE_REQ_DIR_OUT)

try:
    for bcode in range(1, 256):
        #convert to 8-bit binary string, then list of bits
        bits = [int(b) for b in format(bcode, '08b')]
        print(f"{bcode} ({bits})")

        #split into two groups from the right with order reversed (i.e. LSB first)
        #e.g. 204 ([1,1,0,0,1,1,0,0]) -> [0,0,1,1,0] & [0,1,1]
        group1 = bits[-5:][::-1]
        group2 = bits[:-5][::-1]

        last_vcc1 = 0
        last_vcc2 = 0
        start = time.time()

        while time.time() - start < 4: #let each barcode "sit" for 4 seconds
            v1 = vcc1.get_value()
            v2 = vcc2.get_value()

            #on VCC1 rising edge -> emit group1
            if v1 == 1 and last_vcc1 == 0:
                out = group1
                outputs.set_values(out)
                time.sleep(0.02)
                outputs.set_values([1, 1, 1, 1, 1])

            #on VCC2 rising edge -> emit group2 on D1–D3, hold D4–5 HIGH
            if v2 == 1 and last_vcc2 == 0:
                out = group2 + [1, 1]
                outputs.set_values(out)
                time.sleep(0.02)
                outputs.set_values([1, 1, 1, 1, 1])

            last_vcc1 = v1
            last_vcc2 = v2

        outputs.set_values([1, 1, 1, 1, 1])
        time.sleep(2)

except KeyboardInterrupt:
    outputs.set_values([1, 1, 1, 1, 1])
    outputs.release()
    vcc1.release()
    vcc2.release()

经过大量的试错、调整、逻辑分析、重新检查线路和咒骂,才达到了这一点。

在循环遍历所有可能图像的过程中,我辛苦地记下了我听到的内容。在某些情况下,我真的无法 decipher 音频。在那些情况下,我需要让机器人实际画出图像,并希望它能说得通。

有了这个"查找"文件,我修改了脚本,使其也打印出文本和数值。现在我可以 pinpoint 那些奇怪的图像并画出来,比如下面这个例子,我听到了"链块"。

1
2
3
$ python3 all-barcodes.py
218 ([1, 1, 0, 1, 1, 0, 1, 0])
218: chain block?

很酷,是的,似乎合理。

通过这种方法,我填补了一些空白,并识别出一些未包含在附带卡片中的"隐藏"图像。

完整的可用图像集如下所示。机器人附带的卡片堆包括图像1到100。除此之外的一切都是神秘的隐藏奖励。

实现了我的第一个目标后,是时候继续前进, potentially 让这个东西画出任意图像了。

绘图分析

为了能够绘制我们自己的图像,我们需要了解图像是如何表示的,它们存储在哪里,以及我们是否有能力覆盖这些值。当我第一次拆开这个东西时,我只能转储两个闪存芯片中较小的那个,所以我不得不希望图像文件存放在那里,或者想办法也转储更大的芯片。

回到我最初的闪存转储,十六进制输出看起来包含一个目录条目布局,带有一些数字文件名,这似乎很有希望。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ xxd UC25IQ64.bin | head -n 20
00000000: 1972 81a6 2000 0000 80b0 5600 03ff 0000  .r.. .....V.....
00000010: 7465 7374 5f64 6972 00ff ffff ffff ffff  test_dir........
00000020: 42ae 43d2 2045 0000 ac0f 0000 02ff 0000  B.C. E..........
00000030: 3030 312e 6631 6100 ffff ffff ffff ffff  001.f1a.........
00000040: 95b3 4acc d054 0000 9f11 0000 02ff 0000  ..J..T..........
00000050: 3030 322e 66331 6100 ffff ffff ffff ffff  002.f1a.........
00000060: 0961 63ca 7066 0000 0411 0000 02ff 0000  .ac.pf..........
00000070: 3030 332e 66331 6100 ffff ffff ffff ffff  003.f1a.........
00000080: 92c9 7fd6 8077 0000 0d10 0000 02ff 0000  .....w..........
00000090: 3030 342e 66331 6100 ffff ffff ffff ffff  004.f1a.........
000000a0: c241 8271 9087 0000 ca0f 0000 02ff 0000  .A.q............
000000b0: 3030 352e 66331 6100 ffff ffff ffff ffff  005.f1a.........

典型的目录条目布局有点像下面这样:

1
2
3
4
5
6
7
Directory entry layout (32 bytes total)

  0x00            0x04          0x08          0x0C          0x10                      0x20
   ┌───────────────┬──────────────┬─────────────┬─────────────┬──────────────────────────┐
   │ CRC / checksum│ START offset │   SIZE      │   FLAGS     │   NAME (ASCII, 16 bytes) │
   │   (uint32 LE) │  (uint32 LE) │ (uint32 LE) │ (uint32 LE) │   null-terminated        │
   └───────────────┴──────────────┴─────────────┴─────────────┴──────────────────────────┘

例如,001.fla 的目录条目包含以下内容细分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[FILE]  Entry @ 0x000020  (32 bytes)  name='001.f1a'
    +--------+-------------------------------------------------+  +------------------+
    | Offset |                         HEX                     |  |     ASCII        |
    +--------+-------------------------------------------------+  +------------------+
    | 0x00   | 42 AE 43 D2 20 45 00 00 AC 0F 00 00 02 FF 00 00 |  | B.C. E.......... |
    | 0x10   | 30 30 31 2E 66 31 61 00 FF FF FF FF FF FF FF FF |  | 001.f1a......... |
    +--------+-------------------------------------------------+  +------------------+
    Field map:
      [0x00..0x03]  CRC / checksum (LE)       = 0xD243AE42
      [0x04..0x07]  START offset  (LE)        = 0x004520
      [0x08..0x0B]  SIZE bytes    (LE)        = 0xFAC
      [0x0C..0x0F]  FLAGS / unk   (LE)        = 0x0000FF02
      [0x10..0x1F]  NAME (ASCII, null-term)   = '001.f1a'
      Content slice: data[0x004520 : 0x0054CC]  (len=0xFAC)

知道这一点后,我们可以 semi-shamefully 在 Logic 中 vibe code 一个高级分析器 (HLA),识别所有 SPI flash_read 操作,从 fast_read 获取相关的读取地址,针对我们的目录条目布局执行查找,并打印关联的文件名以显示当时正在读取什么。

HLA 就位后,我让机器人读取并绘制图像编号 105。果然,它读取了文件 Y105.f1a。然而,它在绘图过程中还读取了其他几个文件,包括 TE05luo2.f1a、TXXm5.f1b 和 TE08stop.f1a。当我扫描其他卡片时,即使我从未绘制图像,也会读取关联的 YXXX.f1a 文件。这些其他读取的模式似乎与机器人说话或唱歌的时间吻合。这让我相信,我实际上看到的是音频文件,而不是绘图。

是时候再次处理那个第二个 SPI 闪存芯片了。幸运的是,我从可信赖的同事 Chris Bellows 那里学到,当有疑问时,就进行芯片提取。这次成功了,大家都很开心。

移除闪存芯片

将芯片安装在带排针的分线板上

将芯片连接到 BusPirate 进行提取

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ sudo flashrom -p buspirate_spi:dev=/dev/ttyUSB0,spispeed=1M -r UC25IQ128_3.bin
flashrom unknown on Linux 6.8.0-60-generic (aarch64)
flashrom is free software, get the source code at https://flashrom.org

Using clock_gettime for delay loops (clk_id: 1, resolution: 1ns).
===
SFDP has autodetected a flash chip which is not natively supported by flashrom yet.
[...]
Found Unknown flash chip "SFDP-capable chip" (2048 kB, SPI) on buspirate_spi.
===
This flash part has status UNTESTED for operations: WP
[...]
Reading flash... done.

顺便说一下 - 将这个 SPI 闪存芯片从板上移除后,绘图机器人仍然可以运行。它会开机、读取并宣布卡片、播放音乐。唯一不能做的就是实际移动手臂和绘制图像,这似乎是相当有力的证据,表明这个芯片包含绘图文件,而较小的闪存芯片包含音频。

提取出的镜像内容看起来非常有结构,这预示着它包含某种基于坐标的数据,这正是绘图所期望的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ xxd UC25IQ128_3.bin
00000000: ffff ffff 0253 018b 0252 018c 0251 018c  .....S...R...Q..
00000010: 0250 018c 024f 018c 024e 018c 024d 018c  .P...O...N...M..
00000020: 024c 018c 024b 018c 024a 018c 0249 018c  .L...K...J...I..
00000030: 0248 018c 0247 018c 0246 018c 0245 018c  .H...G...F...E..
00000040: 0244 018c 0243 018c 0242 018c 0241 018c  .D...C...B...A..
00000050: 0240 018d 023f 018d 023e 018d 023d 018d  .@...?...>...=..
00000060: 023c 018d 023b 018d 023a 018d 0239 018d  .<...;...:...9..
00000070: 0238 018d 0237 018e 0236 018e 0235 018e  .8...7...6...5..
00000080: 0234 018e 0233 018e 0232 018e 0231 018e  .4...3...2...1..
00000090: 0230 018e 022f 018f 022e 018f 022d 018f  .0.../.......-..
000000a0: 022c 018f 022b 018f 022a 018f 0229 018f  .,...+...*...)..
000000b0: 0228 0190 0227 0190 0226 0190 0225 0190  .(...'...&...%..
000000c0: 0224 0190 0223 0190 0222 0191 0221 0191  .$...#..."...!..
000000d0: 0220 0191 021f 0191 021e 0191 021d 0192  . ..............
000000e0: 021c 0192 021b 0192 021a 0192 0219 0192  ................
[...]

因为现在是2025年,而且LLMs反正要来抢我们的工作,我想给ChatGPT一个机会,让它快速完成我的分析。我喂给它转储数据,说我怀疑它包含图像指令文件,然后就让它去处理了。

抛开我那ChatGPT过度的奉承和整体有点令人不快的氛围不谈,我们搞定了!

唯一的问题是,文件里似乎只有35张图像。确切地说,是前35张图像。由于这是一个非典型芯片,flashrom 可能做出了错误的假设。我们从规格中知道应该有16MB的数据,所以我们可以通过指定一个与我们的芯片足够相似的目标芯片来强制它读取16MB。我做过几次 Winbond 闪存的提取,所以我就用了那个,新的转储包含了254张图像。

既然我们现在知道了结构,我们就可以用 Python 编写一个解析器/生成器脚本,它将提取每个图像的数据,处理坐标,并将它们输入到 SVG 路径元素中以创建图像文件。这个脚本的摘录如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def write_svg(strokes, out: Path, flip_y=True, stroke_w=2):
    bb = bbox(strokes)
    if not bb:
        return False
    xmin, ymin, xmax, ymax = bb
    w, h = xmax - xmin + 1, ymax - ymin + 1
    with out.open("w", encoding="utf-8") as f:
        if flip_y:
            f.write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{xmin} {ymin} {w} {h}" '
                    f'width="{w}" height="{h}" stroke="black" fill="none" stroke-width="{stroke_w}">\n')
            f.write(f'  <g transform="translate(0,{ymin + ymax}) scale(1,-1)">\n')
            for s in strokes:
                f.write('    <path d="M{},{} {}"/>\n'.format(
                    s[0][0], s[0][1], " ".join(f"L{x},{y}" for x, y in s[1:])))
            f.write('  </g>\n</svg>\n')
        else:
            f.write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{xmin} {ymin} {w} {h}" '
                    f'width="{w}" height="{h}" stroke="black" fill="none" stroke-width="{stroke_w}">\n')
            for s in strokes:
                f.write('  <path d="M{},{} {}"/>\n'.format(
                    s[0][0], s[0][1], " ".join(f"L{x},{y}" for x, y in s[1:])))
            f.write('</svg>\n')
    return True

def main():
    ap = argparse.ArgumentParser(description="Extract SVGs from fixed slots")
    ap.add_argument("bin", type=Path, help="full flash dump (e.g., full.bin)")
    ap.add_argument("--out", type=Path, default=Path("svgs"), help="output directory")
    ap.add_argument("--base", type=lambda x:int(x,0), default=0x04, help="first slot start offset (default 0x04)")
    ap.add_argument("--slot", type=lambda x:int(x,0), default=0xEA60, help="slot size (default 0xEA60)")
    ap.add_argument("--count", type=int, default=None, help="number of slots (default: autodetect from file size)")
    ap.add_argument("--min-pts", type=int, default=2, help="min points to export (default 2)")
    ap.add_argument("--no-flip-y", action="store_true", help="do not flip Y axis")
    args = ap.parse_args()

    data = args.bin.read_bytes()
    size = len(data)

    if args.count is None:
        # Best-effort autodetect
        usable = max(0, size - args.base)
        args.count = usable // args.slot

    args.out.mkdir(parents=True, exist_ok=True)
    exported = 0

    for i in range(args.count):
        off = args.base + i * args.slot
        if off >= size:
            break
        slot = data[off : min(off + args.slot, size)]
        words = u16be_words(slot)
        strokes = strokes_from_slot(words)
        if sum(len(s) for s in strokes) < args.min_pts:
            continue
        svg_path = args.out / f"drawing_{i:03d}.svg"
        if write_svg(strokes, svg_path, flip_y=not args.no_flip_y, stroke_w=2):
            exported += 1
            # uncomment for debug:
            # print(f"{i:03d} @ 0x{off:08X} -> {svg_path.name}")
    print(f"Exported {exported} SVGs to {args.out} from {args.count} slots "
          f"(base=0x{args.base:X}, slot=0x{args.slot:X}).")

因为这是由 LLM 编写的,所以它有点…多余。它很可能可以被简化,但在目前的状态下,它允许你指定要开始提取图像的插槽偏移量、要提取的连续图像数量、在认为某个条目是有效图像之前必须检测到的最少点数,以及在渲染时翻转图像的能力(因为它从顶部开始绘图,实际上是将图像倒置存储的)。

1
2
> python3 img_carver.py ../dumps/UC25IQ128_forced.bin --no-flip-y --out svgs
Exported 254 SVGs to svgs from 279 slots (base=0x4, slot=0xEA60).

嗯,这本来是查看所有可能图像更直接的方法,而不需要枚举和欺骗它们,但是嘿,剥猫皮的方法有很多种,而我想学会所有的方法。这个世界上有很多猫需要剥皮。

既然我们可以从原始数据 -> SVG,我们想反过来,让这个东西画出我们选择的图像。市面上有很多工具可以将 PNG 转换为 SVG,下面你可以看到为什么后者更适合我们的需求。SVG 是可伸缩的,因为它们包含路径数据,可以通过数学的力量轻松转换。

有了目标图像,我们只需要解析路径数据,并将其转换为绘图机器人期望的数据格式。由于闪存只是一个大的二进制块,而我们希望只覆盖一个图像(即其他绘图仍然有效),我们需要确保填充正确,并遵循预期的格式,包括任何必要的分隔符。正如我们从 ChatGPT 吐出的螃蟹绘图中看到的那样,它似乎没有完全正确处理"提笔"动作,所以每次移动都画了一条线。幸运的是,我的绘图是一条连续的线,所以我暂时还不用担心这个问题。

以下是我的最终脚本的摘录。它获取一个现有的插槽,确定边界框,将新的 SVG 拟合到坐标平面的边界框中,然后创建一个与要替换的插槽大小相同的新插槽。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def main():
    ap = argparse.ArgumentParser(description="Fit an SVG to a slot's bbox and pack to device format")
    ap.add_argument("slot_content", type=Path, help="existing slot content (0xEA5C bytes; starts at first X)")
    ap.add_argument("svg", type=Path, help="SVG to insert")
    ap.add_argument("--out", type=Path, default=Path("slot_new_content.bin"), help="output content-only bin (0xEA5C)")
    ap.add_argument("--out-with-marker", type=Path, help="also write a 0xEA60 file with trailing FFFF FFFF")
    ap.add_argument("--step", type=float, default=4.0, help="sampling step in SVG units (bigger = fewer points)")
    ap.add_argument("--margin", type=float, default=0.0, help="margin in device units inside target bbox (default 0)")
    ap.add_argument("--flip-y", action="store_true", help="flip Y if your preview is upside-down")
    args = ap.parse_args()

    slot_bytes = args.slot_content.read_bytes()
    if len(slot_bytes) != CONTENT_LEN and len(slot_bytes) != SLOT_LEN:
        print(f"[warn] slot_content length is {len(slot_bytes)}; expected 0xEA5C or 0xEA60. Continuing...")

    # Derive target bbox from existing slot content (robust to trailing marker)
    bb = parse_bbox_from_slot_content(slot_bytes[:CONTENT_LEN])
    print(f"[info] target bbox (from slot): x[{bb[0]}..{bb[2]}], y[{bb[1]}..{bb[3]}]")

    # Load & sample SVG
    paths, _, svg_attr = svg2paths2(str(args.svg))
    vb = get_svg_viewbox(paths, svg_attr)
    # Build strokes list (each continuous subpath = one stroke)
    strokes: List[List[Point]] = []
    for p in paths:
        for sub in p.continuous_subpaths():
            strokes.append(sample_path(sub, args.step))

    qstrokes = quantize_to_bbox(strokes, vb, bb, margin=args.margin, flip_y=args.flip_y)
    payload = pack_content(qstrokes, CONTENT_LEN)
    args.out.write_bytes(payload)
    print(f"[done] wrote {args.out} ({len(payload)} bytes).")
[]
1
2
3
$ python3 svg_fit_to_slot.py slot_006_content.bin ../images/whisky-outline.svg --out slot_006_new_content_03.bin --step 6.0 --margin 4
[info] target bbox (from slot): x[56..1124], y[176..1005]
[done] wrote slot_006_new_content.bin (59996 bytes).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ xxd slot_006_new_content_03.bin
00000000: 024c 02ce 024a 02cd 0248 02cb 0246 02c9  .L...J...H...F..
00000010: 0244 02c8 0243 02c6 0241 02c3 0240 02c1  .D...C...A...@..
00000020: 023f 02bf 023e 02be 023c 02bf 0239 02c0  .?...>...<...9..
00000030: 0237 02c1 0234 02c2 0232 02c3 022f 02c3  .7...4...2.../..
00000040: 022d 02c3 022a 02c1 0228 02c0 0227 02be  .-...*...(...'..
00000050: 0226 02bc 0225 02b9 0224 02b7 0223 02b4  .&...%...$...#..
00000060: 0223 02b2 0223 02af 0223 02ac 0222 02ab  .#...#...#..."..
00000070: 0221 02ac 0220 02ae 021e 02b1 021c 02b3  .!... ..........
00000080: 021a 02b5 0218 02b6 0216 02b8 0214 02b9  ................
00000090: 0211 02b9 020f 02b9 020c 02b7 020a 02b6  ................
[…]

现在我们已经覆盖了一个插槽,我们需要将其写回原始镜像中。

1
2
3
4
$ dd if=slot_006_new_content_03.bin of=UC25IQ128_trunc_mod.bin bs=1 seek=$((0x000493E4)) conv=notrunc
59996+0 records in
59996+0 records out
59996 bytes transferred in 0.120694 secs (497092 bytes/sec)

我本应确认我们可以写入闪存芯片,然后再继续这条路。幸运的是,我们可以。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ sudo flashrom -p buspirate_spi:dev=/dev/ttyUSB0,spispeed=250k -f -w UC25IQ128_trunc_mod.bin -VV
flashrom unknown on Linux 6.8.0-60-generic (aarch64)
flashrom is free software, get the source code at https://flashrom.org

Using clock_gettime for delay loops (clk_id: 1, resolution: 1ns).
flashrom was built with GCC 13.2.0, little endian
[…]
Found Unknown flash chip "SFDP-capable chip" (2048 kB, SPI).
===
This flash part has status UNTESTED for operations: WP
Block protection is disabled.
Reading old flash chip contents... done.
Erasing and writing flash chip... Trying erase function 0…
Erase/write done.
Verifying flash... VERIFIED.
Raw bitbang mode version 1
Bus Pirate shutdown completed.

就在这时,我记起来芯片实际上并没有安装回绘图机器人里。因为任何事情第一次尝试都不会成功,这很可能是个问题。我已经把它安装在这个带有排针的漂亮分线板上了,所以我在板本身上焊接了电线,涂上热熔胶试图防止它们从板上脱落,并将它们连接到另一侧的连接器上。这样,我可以在我的编程器和绘图机器人之间来回切换,直到完全正确。

经过几次迭代,我不得不解决一些小问题,但它确实工作了。我不能让一个扭曲的无头机器人出现在我的最终演示中,所以我修改了外壳以容纳其外露的闪存芯片,并尽可能地把它修补好。

如果你想观看整个视频,你会听到在这个项目过程中一直萦绕在我脑海中的众多绘图机器人歌曲之一。

未来的潜在工作

一个潜在的未来工作领域是简化这个过程,以便于将图像传递给一个脚本,该脚本将一次性处理转换、写入闪存和触发绘图(通过树莓派进行更多的 GPIO 操作)。弄清楚音频格式以替换各种歌曲和声音也会很有趣(我确实花了一些时间尝试使用 ffmpeg 解码和播放它们)。我还考虑彻底改造绘图机器人,设计并3D打印一个新的机身。

我完全有可能将来会回来尝试其中一个或所有这些事情。如果我做了并且有任何成果,我一定会让你知道。更可能的是,我会找到一个新目标来 respectfully 屠宰和 reanimate。无论如何,我会随时通知你。

黑客快乐!

Jessie Chab 研究咨询总监

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计