<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>K230 &#8211; Cloudlay</title>
	<atom:link href="https://cloudlay.cn/tag/k230/feed/" rel="self" type="application/rss+xml" />
	<link>https://cloudlay.cn</link>
	<description>life</description>
	<lastBuildDate>Sun, 14 Jun 2026 17:08:33 +0000</lastBuildDate>
	<language>zh-Hans</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>

<image>
	<url>https://cloudlay.cn/wp-content/uploads/2026/01/avatar.ico</url>
	<title>K230 &#8211; Cloudlay</title>
	<link>https://cloudlay.cn</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>从0到1带你打电赛·小车电控篇(十)：视觉与通信——K230 怎么用、怎么和主控对上话</title>
		<link>https://cloudlay.cn/nuedc-car-10-vision-comm/</link>
					<comments>https://cloudlay.cn/nuedc-car-10-vision-comm/#respond</comments>
		
		<dc:creator><![CDATA[云间辞]]></dc:creator>
		<pubDate>Sun, 14 Jun 2026 17:08:33 +0000</pubDate>
				<category><![CDATA[嵌入式]]></category>
		<category><![CDATA[CanMV]]></category>
		<category><![CDATA[K230]]></category>
		<category><![CDATA[OpenMV]]></category>
		<category><![CDATA[UART]]></category>
		<category><![CDATA[智能小车]]></category>
		<category><![CDATA[电赛]]></category>
		<category><![CDATA[通信协议]]></category>
		<guid isPermaLink="false">https://cloudlay.cn/nuedc-car-10-vision-comm/</guid>

					<description><![CDATA[📚 本文是 「从 0 到 1 带你打电赛 · 小车电控篇」 系列（共 12 篇）第 10 篇。 第1篇 · 拿 [&#8230;]]]></description>
										<content:encoded><![CDATA[<div class="ds-series" style="border:1px solid #4488ff33;background:#4488ff0d;border-radius:8px;padding:.8em 1.1em;margin:1.2em 0">
<p style="margin:0 0 .5em"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4da.png" alt="📚" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 本文是 <strong>「从 0 到 1 带你打电赛 · 小车电控篇」</strong> 系列（共 12 篇）第 10 篇。</p>
<ol style="margin:.2em 0 0;padding-left:1.4em">
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-01-how-to-score/">第1篇 · 拿奖逻辑：把比赛拆成小目标</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-02-history/">第2篇 · 赛题进化史与押题</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-03-build-and-architecture/">第3篇 · 整车搭建与代码框架</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-04-motor-power/">第4篇 · 电机驱动与电源地基</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-05-sensing/">第5篇 · 感知：灰度/电磁/编码器/IMU</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-06-pid-basics/">第6篇 · PID 入门：搞懂 P/I/D</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-07-pid-advanced/">第7篇 · PID 进阶：串级+工程补丁</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-08-pid-tuning/">第8篇 · PID 调参实战(核心)</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-09-advanced-control/">第9篇 · 进阶控制：几时该上</a></li>
<li style="margin:.15em 0"><strong>第10篇 · K230 视觉与通信协议（本篇）</strong></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-11-architecture-fsm/">第11篇 · 状态机与整车软件</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-12-field-manual/">第12篇 · 现场作战+避坑+开源</a></li>
</ol>
</div>
<p>前面九篇，我们把&#8221;小车自己能跑稳、跑准&#8221;这件事讲透了：电机、电源、各种传感器，PID 从入门到串级再到调参实战。但你有没有发现，到现在为止小车的&#8221;眼睛&#8221;其实很弱——灰度传感器只能看脚下那条线，编码器只知道轮子转了几圈，IMU 只管自己歪没歪。要让小车看懂&#8221;前面有个二维码&#8221;&#8221;那边是个 2 号路牌&#8221;&#8221;这条线往左拐了 30 度&#8221;，就得请出真正的视觉模块。</p>
<p>这一篇聊两件事：一是 K230 这类视觉模块到底能干什么、怎么用；二是它怎么跟主控&#8221;对上话&#8221;——也就是通信协议。这两件事其实是一件事：视觉负责&#8221;看&#8221;，主控负责&#8221;动&#8221;，中间靠一根串口线把&#8221;看到的结论&#8221;传过去。把这条链路打通，你的小车才真正有了眼睛。</p>
<p>先把整条链路画出来，心里有个全局：</p>
<figure class="ds-diagram" style="text-align:center;margin:1.3em 0"><img decoding="async" src="https://cloudlay.cn/wp-content/uploads/dianseiche/1506e2ef5953.png" alt="电赛小车电控 · 示意图" loading="lazy" style="max-width:100%;height:auto;background:#fff;border-radius:8px;padding:6px;box-shadow:0 1px 6px rgba(0,0,0,.25)"></figure>
<h2>一、先想清楚：视觉模块在整车里是什么角色</h2>
<p>新手最容易犯的一个错，是把视觉模块当&#8221;主脑&#8221;，想让它又看图、又算决策、又控电机。结果发现 K230 上用 Python 跑控制环又慢又难调，主控那边还拿不到干净数据，两头别扭。</p>
<p>正确的分工是这样的：</p>
<div class="ds-callout ds-callout-tip" style="border-left:4px solid #00bfa6;background:#00bfa614;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00bfa6"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 一句话记住分工</p>
<p>视觉模块负责&#8221;看&#8221;，主控负责&#8221;动&#8221;，中间只传&#8221;结论&#8221;。</p>
</div>
<p>打个比方：视觉模块就像副驾上的导航员。副驾不会把整块挡风玻璃的画面塞给开车的你，他只会喊一句&#8221;左偏 10 度&#8221;&#8221;前方是 2 号路牌&#8221;。你（主控）拿到这一句话就够打方向了。</p>
<p>所以<strong>视觉端只回传几个数</strong>——比如 <code>line_error</code>（线偏了多少）、<code>line_angle</code>（线斜了多少度）、<code>flag</code>（看到没看到）、<code>id</code>（二维码/路牌的编号）——绝不把整张图像通过串口发过来。</p>
<div class="ds-callout ds-callout-danger" style="border-left:4px solid #ff1744;background:#ff174414;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#ff1744"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f525.png" alt="🔥" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 头号大坑：千万别传图</p>
<p>K230 千万别把整幅图像或大数组通过 UART 发给单片机。几百 KB 的图片走 115200 的串口要好几秒，带宽和主控算力都扛不住，延迟大到小车早就冲出去了。主控其实只需要 <code>line_error</code>/<code>line_angle</code> 这几个数就能闭环。真要传整幅图/点云那是极罕见的赛题，才考虑 SPI 或网口。</p>
</div>
<p>这种&#8221;把看的活和算的活分开&#8221;的设计，好处是两个人可以各写各的：视觉同学专心调他的巡线和识别，电控同学专心调 PID，只要事先把&#8221;传哪几个数、什么格式&#8221;约定死，最后联调时插上线就能跑。</p>
<h2>二、K230/CanMV 能干什么</h2>
<p>K230 是嘉楠（Kendryte）出的一颗带 6 TOPS NPU（神经网络算力）的视觉芯片，常见开发板有庐山派、01Studio CanMV 等。它跑的固件叫 <strong>CanMV</strong>，写法是 MicroPython，API 跟大家熟悉的 OpenMV 几乎同源——所以网上 OpenMV 的巡线、色块例程，K230 上稍改就能用。</p>
<div class="ds-callout ds-callout-note" style="border-left:4px solid #448aff;background:#448aff14;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#448aff"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4dd.png" alt="📝" class="wp-smiley" style="height: 1em; max-height: 1em;" /> CanMV 是什么</p>
<p>CanMV = 在 K230 上跑的 MicroPython 视觉固件。你用 Python 写&#8221;找黑线&#8221;&#8221;找色块&#8221;，它底层调硬件帮你算。会写几行 Python 就能上手，不用碰 C。</p>
</div>
<p>对电赛小车来说，常用的有这么几样活：</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>用的 API</th>
<th>回传给主控的&#8221;结论&#8221;</th>
</tr>
</thead>
<tbody>
<tr>
<td>巡线</td>
<td><code>find_blobs</code> 加权质心 / <code>get_regression</code> 线性回归</td>
<td><code>line_error</code>、<code>line_angle</code></td>
</tr>
<tr>
<td>找色块</td>
<td><code>find_blobs([色彩阈值])</code></td>
<td>色块中心坐标、颜色编号</td>
</tr>
<tr>
<td>二维码</td>
<td><code>find_qrcodes()</code></td>
<td><code>.payload()</code> 内容字符串</td>
</tr>
<tr>
<td>机器码 AprilTag</td>
<td><code>find_apriltags()</code></td>
<td><code>.id()</code>、中心坐标、旋转角</td>
</tr>
<tr>
<td>数字/文字 OCR</td>
<td>OCR kmodel</td>
<td>识别出的数字/字符串</td>
</tr>
</tbody>
</table>
<h3>巡线的两条路线</h3>
<p>巡线是小车赛最高频的视觉任务，K230 上有两条主流做法，都只把&#8221;一个偏差 + 一个角度&#8221;发给主控。</p>
<p><strong>路线一：灰度加权质心法。</strong> 把画面横向切成几条 ROI（感兴趣区域），近处权重大、远处权重小，每条 ROI 里用 <code>find_blobs</code> 找黑线最大的色块，加权平均出线的中心位置，再算出偏角。官方例程里近/中/远的权重是 <strong>0.7 / 0.3 / 0.1</strong>——理解一下：脚下的线最该听（权重大），远处的线只是&#8221;预判方向&#8221;（权重小），这其实就是我们在感知篇讲过的&#8221;前瞻&#8221;。</p>
<p><strong>路线二：线性回归 <code>get_regression</code>。</strong> 直接对整幅二值图做一次直线拟合，返回一个 line 对象，里面有 <code>theta()</code>（角度）、<code>rho()</code>（到原点的距离，反映横向偏移），还有一个很有用的 <code>magnitude()</code>。</p>
<div class="ds-callout ds-callout-tip" style="border-left:4px solid #00bfa6;background:#00bfa614;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00bfa6"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> magnitude 是&quot;线靠不靠谱&quot;的置信度</p>
<p><code>get_regression</code> 的 magnitude 范围是 0 到正无穷，越大说明拟合出来的越像一条直线、越可信；接近 0 说明这堆点更像一团乱（圆），多半是噪点。就像你看一行字，写得越直越能确信&#8221;这是一条线&#8221;而不是一团花。可以拿它当门槛：magnitude 太小就认为&#8221;这帧没看清&#8221;，直接告诉主控&#8221;我没线&#8221;。</p>
</div>
<h3>二维码、AprilTag、OCR——直接出&#8221;结论&#8221;</h3>
<p>这三样的共同点是：K230 直接帮你把结果算成字符串或编号，你只要把那几个字节塞进串口发出去。</p>
<ul>
<li><strong>二维码</strong> <code>find_qrcodes()</code>：返回对象的 <code>.payload()</code> 就是二维码里的内容字符串。</li>
<li><strong>AprilTag</strong>（一种黑白方块机器码，专为机器识别设计）<code>find_apriltags()</code>：返回 <code>.id()</code>、中心坐标 <code>.cx()/.cy()</code>、旋转角。默认用 <strong>TAG36H11</strong> 这一族（6×6 方块，误识率低）；还有 TAG16H5（4×4，看得更远但更容易认错）。</li>
<li><strong>OCR / 数字识别</strong>：用官方的 OCR kmodel，能识别中英文和数字；纯手写数字也可以走 MNIST 模型分类。</li>
</ul>
<div class="ds-callout ds-callout-warning" style="border-left:4px solid #ff9100;background:#ff910014;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#ff9100"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 一个容易白调的坑</p>
<p>二维码识别率低时，常常要加 <code>set_hmirror</code>/<code>set_vflip</code> 做镜像校正（因为摄像头朝向可能把图照反了）；但 <strong>AprilTag 不需要镜像校正</strong>。两者搞混会让你白白折腾半天，还以为是阈值问题。</p>
</div>
<h3>分辨率怎么选</h3>
<p>一句口诀：<strong>巡线用小图保帧率，识别用大图保精度。</strong></p>
<p>巡线是几何任务、要的是控制环快（帧率就是控制频率的上限），所以常用 160×120（QQVGA）甚至更小的灰度图；二维码官方例程用 640×480 灰度；OCR/AI 识别最吃算力，但 K230 有 NPU 撑着，可以慢一点换清晰。K230 还支持多通道，甚至能一路给屏幕显示、一路给算法跑。</p>
<h2>三、视觉端代码：把巡线结果发出去</h2>
<p>下面给一段可以直接改的灰度加权质心巡线代码（K230 / MicroPython），算出偏差和角度后调 <code>send_line</code> 发给主控。发送函数 <code>send_line</code>/<code>send_frame</code> 的帧格式我们在第五节细讲，这里先把&#8221;算结论&#8221;的逻辑看清楚。</p>
<pre><code class="language-python">import math

GRAYSCALE_THRESHOLD = [(0, 64)]              # 黑线的灰度阈值(现场要重标!)
ROIS = [ (0, 100, 160, 20, 0.7),            # 近(权重大)
         (0,  50, 160, 20, 0.3),            # 中
         (0,   0, 160, 20, 0.1) ]           # 远
weight_sum = sum(r[4] for r in ROIS)
IMG_W, IMG_H = 160, 120

while True:
    img = sensor.snapshot()
    centroid_sum, found = 0, 0
    for r in ROIS:
        blobs = img.find_blobs(GRAYSCALE_THRESHOLD, roi=r[0:4], merge=True)
        if blobs:
            b = max(blobs, key=lambda x: x.pixels())   # 取最大的黑块
            centroid_sum += b.cx() * r[4]; found += 1
    if found:
        center_pos = centroid_sum / weight_sum
        line_error = center_pos - IMG_W/2              # 居中=0,正负代表左右偏
        angle = math.degrees(-math.atan((center_pos - IMG_W/2) / (IMG_H/2)))
        send_line(line_error, angle)                   # 发给主控
    else:
        send_frame(0x00, b'')                          # 一条线都没找到 -&gt; 告诉主控</code></pre>
<p>如果走线性回归路线，核心就这几行：</p>
<pre><code class="language-python">img = sensor.snapshot().binary([(0, 64)])     # 先二值化
line = img.get_regression([(255, 255)], robust=True)
if line and line.magnitude() &gt; 8:             # 阈值自调,过滤不可信的拟合
    rho_err = abs(line.rho()) - img.width()/2          # 横向偏移
    theta = line.theta()
    theta_err = theta - 180 if theta &gt; 90 else theta   # 角度,&gt;90度要修正
    send_frame(0x01, struct.pack('&lt;hh', int(rho_err), int(theta_err * 10)))
else:
    send_frame(0x00, b'')                              # 没拟合到可信的线</code></pre>
<p>注意那行 <code>theta - 180 if theta &gt; 90</code>：<code>get_regression</code> 返回的角度有时会跳到 90 度以上，需要减 180 折回来，否则角度会突变、车会猛打一下方向。这是 <a href="https://book.openmv.cc/project/follow-lines.html" target="_blank" rel="noopener noreferrer">OpenMV 官方巡线工程</a> 的标准处理。</p>
<div class="ds-callout ds-callout-important" style="border-left:4px solid #00bcd4;background:#00bcd414;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00bcd4"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2757.png" alt="❗" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 阈值和 ROI 必须现场重标，不能照抄</p>
<p>上面的灰度阈值 <code>[(0,64)]</code> 和 ROI 是按别人的赛道、别人的光照定的。换个场地、换个灯，黑白的灰度值就变了。到了赛场第一件事就是对着真实赛道重新标阈值，否则&#8221;明明代码是对的，就是巡不到线&#8221;——多半就是阈值没标。</p>
</div>
<p>如果你用线性回归 + 主控双 PID 的方案，<a href="https://book.openmv.cc/project/follow-lines.html" target="_blank" rel="noopener noreferrer">OpenMV 官方的经验</a> 值得记住：横向偏移 <code>rho</code> 配一个 PID（经验 p ≈ 0.4），角度 <code>theta</code> 配另一个 PID（经验 p ≈ 0.001）。注意这俩 p 值差了两个数量级，<strong>必须分开调</strong>——不少人想用一个 PID 硬扛，结果怎么都不稳。具体怎么把这两路偏差喂进方向环、怎么整定，方向环和串级的细节见《PID 进阶：方向环、串级双环与工程补丁》一篇。</p>
<h2>四、通信三选一：为什么是 UART</h2>
<p>视觉端算好了结论，怎么送到主控？常见有三条总线：UART、SPI、I2C。对&#8221;只回传几个数&#8221;这个场景，结论很干脆——<strong>UART 几乎是唯一正确答案</strong>。</p>
<p>打个比方理解三者的取舍：</p>
<ul>
<li><strong>UART</strong> 像乡间双车道：两根线（TX/RX）加地线，点对点，够用又省事。</li>
<li><strong>SPI</strong> 像高速公路：多车道全双工、速率最高（&gt;10 Mbps），适合传图/大带宽，但要修匝道（每个从机一根 CS）、走线多、长线容易出错。</li>
<li><strong>I2C</strong> 像公交线：一条总线上挂一串站点，省 GPIO，但速度慢、要排班寻址（地址管理 + 上拉电阻），抗噪和距离最差。</li>
</ul>
<p>给小车回传几个数，走&#8221;乡间双车道&#8221;最划算。详细对比：</p>
<table>
<thead>
<tr>
<th></th>
<th>UART（主推）</th>
<th>SPI</th>
<th>I2C</th>
</tr>
</thead>
<tbody>
<tr>
<td>线数</td>
<td>2（TX/RX）+ 地</td>
<td>4（CLK/MOSI/MISO/CS）</td>
<td>2（SCL/SDA）+ 地</td>
</tr>
<tr>
<td>方式</td>
<td>异步全双工，点对点</td>
<td>同步全双工，主从</td>
<td>同步半双工，总线多从</td>
</tr>
<tr>
<td>速率</td>
<td>中（115200 够用）</td>
<td>最高（&gt;10 Mbps）</td>
<td>低（100k~400k）</td>
</tr>
<tr>
<td>适合</td>
<td>回传几个结论</td>
<td>传图/高带宽</td>
<td>一条总线挂多传感器</td>
</tr>
<tr>
<td>配套</td>
<td>DMA+IDLE 零成本收整帧，库最成熟</td>
<td>走线多、CS 管理麻烦</td>
<td>上拉+地址管理麻烦、抗噪差</td>
</tr>
</tbody>
</table>
<div class="ds-callout ds-callout-success" style="border-left:4px solid #00c853;background:#00c85314;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00c853"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 选型口诀</p>
<p>K230 只回传几个数 → <strong>UART</strong>；要回传整幅图/点云 → 才考虑 SPI；同一总线上挂多个传感器 → 才用 I2C。99% 的小车赛用 UART。</p>
</div>
<h3>接线：3.3V 直连，别加多余的转换板</h3>
<p>K230 所有 IO 都是 3.3V，STM32F4 的 IO 也是 3.3V，所以 <strong>TX 接对方 RX 交叉接、共地，就能直连</strong>，不需要电平转换芯片。波特率约定 115200、8 位数据、无校验、1 位停止（也就是常说的 8N1）即可。</p>
<div class="ds-callout ds-callout-warning" style="border-left:4px solid #ff9100;background:#ff910014;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#ff9100"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 接线两个高频翻车点</p>
<ul>
<li><strong>忘了共地</strong>：K230 和主控不共地 → 电平没有共同基准 → 全是乱码。两块板之间一定要拉一根 GND。</li>
<li><strong>5V 主控直连</strong>：如果你的主控是 5V 系统（某些老板子、Arduino），它的 TX 是 5V，直接接到 K230 的 RX 可能把 K230 打坏。这种情况 K230 的 RX 要分压或加电平转换。STM32 是 3.3V 就没这问题。</li>
</ul>
</div>
<p>K230 用串口前还有一步<strong>软件上的坑</strong>：必须先用 FPIOA 做引脚复用，纯软件 <code>UART()</code> 初始化是不够的。另外 K230 一共 5 个串口，<strong>UART0（小核 SH）和 UART3（大核 SH）常被系统占用</strong>，别选；用户可用 UART1/2/4，<strong>主推 UART2</strong>（GPIO11 = TX，GPIO12 = RX，对应庐山派的 GH1.25 接口①）。</p>
<pre><code class="language-python">from machine import UART, FPIOA

# 关键:必须先做引脚复用,否则口不通!
fpioa = FPIOA()
fpioa.set_function(11, FPIOA.UART2_TXD)
fpioa.set_function(12, FPIOA.UART2_RXD)
uart = UART(UART.UART2, baudrate=115200, bits=UART.EIGHTBITS,
            parity=UART.PARITY_NONE, stop=UART.STOPBITS_ONE)</code></pre>
<p>引脚、API、电平这些硬事实，以 <a href="https://wiki.lckfb.com/zh-hans/lushan-pi-k230/" target="_blank" rel="noopener noreferrer">立创庐山派 K230 wiki</a> 为准最权威。</p>
<h2>五、帧协议：给数据装个&#8221;快递包裹&#8221;</h2>
<p>确定了用 UART，下一个问题是：数据在线上是一串字节流，主控怎么知道&#8221;一帧从哪开始、到哪结束、有没有传错&#8221;？这就需要设计<strong>帧协议</strong>。</p>
<p>打个比方，一帧数据就像一个寄出去的快递包裹：</p>
<div class="ds-callout ds-callout-example" style="border-left:4px solid #7c4dff;background:#7c4dff14;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#7c4dff"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f9e9.png" alt="🧩" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 帧协议 = 寄快递</p>
<ul>
<li><strong>帧头</strong>（封口贴）：告诉收件人&#8221;包裹从这开始了&#8221;</li>
<li><strong>类型</strong>：里面装的是巡线结果，还是二维码 id</li>
<li><strong>长度</strong>（运单上写几件）：数据区有多少字节</li>
<li><strong>数据</strong>：真正的货物（那几个数）</li>
<li><strong>校验</strong>（防伪码）：确认路上没被掉包/传错</li>
<li><strong>帧尾</strong>（封箱胶带）：到此结束</li>
</ul>
<p>少了哪样，收件人都可能拆错包。</p>
</div>
<p>一个经过实战检验、可以直接用的格式：</p>
<pre><code class="language-plaintext">帧头 0xAA 0x55 | 类型 1B | 长度 1B | 数据 N字节 | 校验 1B | 帧尾 0x0D 0x0A</code></pre>
<p>几个设计点说一下：</p>
<ul>
<li><strong>帧头用双字节 0xAA 0x55</strong>，而不是单字节。因为在二进制数据流里，单个 0xAA 太容易被数据区里恰好相同的字节&#8221;撞上&#8221;导致错帧；双字节 + 长度 + 校验三重确认，误同步概率就压到可以忽略。顺便一提，0x55 = 01010101 在线上是个 50% 占空比的方波，最容易用示波器对齐，所以常被选作帧头。</li>
<li><strong>长度字段</strong>让你能收变长数据（1 字节最大 255，扣掉校验后净载荷还有 200 多字节，对小车绰绰有余）。</li>
<li><strong>校验</strong>可以用最轻量的累加和，也可以用更强的 CRC-8 / CRC-16。新手先用累加和就够。</li>
<li><strong>帧尾 0x0D 0x0A</strong>（也就是 <code>\r\n</code>）的好处是：用串口助手或日志直接打印时能自动换行，调试时一眼能看清每一帧。</li>
</ul>
<div class="ds-callout ds-callout-danger" style="border-left:4px solid #ff1744;background:#ff174414;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#ff1744"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f525.png" alt="🔥" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 帧头/帧尾可能撞上数据</p>
<p>如果数据区里恰好出现了 0x0D 0x0A，而你又只靠帧尾来断帧，就会被误判成&#8221;帧结束了&#8221;。两种解法二选一：① 像本方案这样<strong>靠&#8221;帧头 + 长度 + 校验&#8221;整体判定</strong>，根本不依赖帧尾找边界；② 做<strong>转义</strong>（约定一个转义字符如 0x7D，遇到冲突字节就改写）。新手用方案①最省事。</p>
</div>
<h3>视觉端发送代码</h3>
<pre><code class="language-python">import struct

FRAME_HEAD = b'\xAA\x55'
FRAME_TAIL = b'\x0D\x0A'

# type: 0x01=巡线  0x02=二维码  0x03=AprilTag  0x04=数字  0x00=无目标
def send_frame(msg_type, payload: bytes):
    length = len(payload)
    body = bytes([msg_type, length]) + payload    # 类型 + 长度 + 数据
    checksum = 0
    for b in body:
        checksum = (checksum + b) &amp; 0xFF           # 累加和(可换成 CRC)
    uart.write(FRAME_HEAD + body + bytes([checksum]) + FRAME_TAIL)

# 巡线 -&gt; 回传 line_error 和 line_angle(放大10倍发,保留0.1度精度)
def send_line(line_error, line_angle_deg):
    payload = struct.pack('&lt;hh', int(line_error), int(line_angle_deg * 10))
    send_frame(0x01, payload)

# 二维码/AprilTag/数字 -&gt; 只回一个 id
def send_id(msg_type, id_val):
    send_frame(msg_type, bytes([id_val &amp; 0xFF]))</code></pre>
<div class="ds-callout ds-callout-warning" style="border-left:4px solid #ff9100;background:#ff910014;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#ff9100"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 大小端要两边对上</p>
<p>上面用 <code>struct.pack('&lt;hh', ...)</code> 发的是<strong>小端</strong>（低字节在前）。主控那边拼字节时也必须按小端还原（见下面 C 代码里的 <code>data[0] | (data[1]&lt;&lt;8)</code>）。两边约定不一致，解出来就是乱值。</p>
</div>
<h2>六、主控收包：DMA + 串口空闲中断的黄金组合</h2>
<p>主控这边怎么收？新手常见的错法是：在串口中断里一个字节一个字节地收，还顺手把解析、甚至 PID 都塞进中断里。这样中断被占用太久，控制环都会被拖卡。</p>
<p>正确姿势是 <strong>DMA + 串口 IDLE（空闲）中断</strong>：</p>
<div class="ds-callout ds-callout-example" style="border-left:4px solid #7c4dff;background:#7c4dff14;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#7c4dff"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f9e9.png" alt="🧩" class="wp-smiley" style="height: 1em; max-height: 1em;" /> DMA + IDLE = 传送带 + 到货铃</p>
<p>DMA 是后台传送带，把串口来的字节一个个搬进缓冲区，全程不用 CPU 管；当一整帧到齐、线路空闲下来时，IDLE 中断就像&#8221;货到齐了按一下铃&#8221;，CPU 这才过来清点。平时 CPU 根本不用守着串口。</p>
</div>
<p>这套组合的好处是：<strong>零 CPU 占用 + 硬件自动断帧（天然支持变长收包）</strong>。现代 HAL 库写起来很省心，一个回调搞定：</p>
<pre><code class="language-c">#define RX_BUF_SIZE 64
static uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t  frame_ready = 0;
volatile uint16_t frame_len   = 0;
static   uint8_t  frame_copy[RX_BUF_SIZE];

// 初始化时调用一次(放在 DMA/UART Init 之后)
void Vision_UART_Start(UART_HandleTypeDef *huart){
    HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_SIZE);
    __HAL_DMA_DISABLE_IT(huart-&gt;hdmarx, DMA_IT_HT);  // 关半满中断,只要IDLE/收满
}

// 整帧到达回调:Size = 本帧字节数(收到指定长度或线路空闲触发)
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size){
    if (Size &gt; 0 &amp;&amp; Size &lt;= RX_BUF_SIZE){
        memcpy(frame_copy, rx_buf, Size);
        frame_len = Size; frame_ready = 1;            // 只置标志,解析放主循环
    }
    HAL_UARTEx_ReceiveToIdle_DMA(huart, rx_buf, RX_BUF_SIZE);  // 重启接收
    __HAL_DMA_DISABLE_IT(huart-&gt;hdmarx, DMA_IT_HT);
}</code></pre>
<div class="ds-callout ds-callout-warning" style="border-left:4px solid #ff9100;background:#ff910014;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#ff9100"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> DMA+IDLE 方案的第一坑：IDLE 中断要手动开</p>
<p>很多人用 <code>HAL_UARTEx_ReceiveToIdle_DMA</code> 却发现空闲中断死活不触发。原因是 <strong>IDLE 中断不像 DMA 完成中断那样自动开启</strong>，必须确认开了 UART 全局中断，且 DMA 配成 Normal 模式配合。如果你用老写法，记得 <code>__HAL_UART_ENABLE_IT(&amp;huart, UART_IT_IDLE)</code> 手动使能；老写法里算本帧长度用 <code>rx_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&amp;hdma)</code>。</p>
</div>
<h3>解析：一道道闸机的状态机</h3>
<p>收到一整块字节后，怎么验证它是合法的一帧？用一个<strong>逐字节状态机</strong>最清晰。</p>
<div class="ds-callout ds-callout-example" style="border-left:4px solid #7c4dff;background:#7c4dff14;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#7c4dff"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f9e9.png" alt="🧩" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 状态机 = 过安检的闸机</p>
<p>一道道关卡：等帧头 → 读类型 → 读长度 → 收数据 → 验校验 → 等帧尾。每过一关进下一状态，任何一关不对就打回起点重新排队。最后放行的，一定是合规的完整帧。</p>
</div>
<pre><code class="language-c">typedef enum {S_H1,S_H2,S_TYPE,S_LEN,S_DATA,S_SUM,S_T1,S_T2} PState;
typedef struct { uint8_t type; uint8_t len; uint8_t data[32]; } Frame;

// 逐字节喂入,返回 1=完整有效帧, -1=出错, 0=进行中
int frame_feed(uint8_t byte, Frame *out){
    static PState st = S_H1; static uint8_t idx=0, sum=0; static Frame f;
    switch(st){
      case S_H1: if(byte==0xAA) st=S_H2; break;
      case S_H2: st = (byte==0x55)? S_TYPE : S_H1; break;     // 双字节帧头
      case S_TYPE: f.type=byte; sum=byte; st=S_LEN; break;
      case S_LEN:  f.len =byte; if(f.len&gt;32){st=S_H1;break;}  // 越界保护
                   sum+=byte; idx=0; st = f.len? S_DATA : S_SUM; break;
      case S_DATA: f.data[idx++]=byte; sum+=byte;
                   if(idx&gt;=f.len) st=S_SUM; break;
      case S_SUM:  if((sum&amp;0xFF)!=byte){ st=S_H1; return -1; } // 校验失败,丢
                   st=S_T1; break;
      case S_T1:   st = (byte==0x0D)? S_T2 : S_H1; break;
      case S_T2:   st=S_H1;
                   if(byte==0x0A){ *out=f; return 1; }        // 完整有效帧!
                   return -1;
    }
    return 0;  // 进行中
}</code></pre>
<div class="ds-callout ds-callout-note" style="border-left:4px solid #448aff;background:#448aff14;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#448aff"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4dd.png" alt="📝" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 另一种常见写法：环形缓冲</p>
<p>如果你更习惯&#8221;DMA 整块搬进来、再批量找帧&#8221;，可以用环形缓冲区：循环找 0xAA 帧头、读长度、累加和校验，校验过才把整帧搬出、读指针前移。庐山派配套的 <code>command.c</code> 就是这套典型的嵌入式风格，<a href="https://github.com/humanfirework/K230-Study-Reference" target="_blank" rel="noopener noreferrer">K230-Study-Reference</a> 里有现成代码。想要工业级带 CRC-8 和转义的 C 解析库，可以参考 <a href="https://github.com/mohamedAziz-bousbih/uart-frame-parser" target="_blank" rel="noopener noreferrer">uart-frame-parser</a>。两种范式选一种用即可。</p>
</div>
<h2>七、保命：超时降级，绝不让小车失控</h2>
<p>视觉模块再好，赛场上也会掉链子——强光、反光、被手挡一下、线松了，丢帧是常态。这时候如果主控还傻等视觉数据，或者拿着一张过期的旧照片硬冲，小车就直接冲出赛道掉大分了。</p>
<p>所以必须有<strong>超时降级</strong>这道保命机制：</p>
<div class="ds-callout ds-callout-example" style="border-left:4px solid #7c4dff;background:#7c4dff14;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#7c4dff"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f9e9.png" alt="🧩" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 超时降级 = 备用降落伞</p>
<p>视觉是主伞（首选），但你一定要背个备用伞（灰度/IMU）。100ms 没收到有效数据就立刻开备用伞——切回灰度巡线，或用 IMU 锁住当前航向直行/缓停。宁可降落慢一点，也绝不自由落体（失控）。</p>
</div>
<pre><code class="language-c">volatile uint32_t last_valid_ms = 0;
int16_t line_error=0, line_angle=0; uint8_t vision_ok=0;

void Control_Loop(void){          // 固定周期调用(如 5ms)
    if(frame_ready){
        frame_ready=0; Frame f;
        for(uint16_t i=0;i&lt;frame_len;i++){
            int r = frame_feed(frame_copy[i], &amp;f);
            if(r==1){                                  // 只有校验通过的整帧
                last_valid_ms = HAL_GetTick();         // 才刷新超时计时!
                if(f.type==0x01){                       // 巡线
                    line_error = (int16_t)(f.data[0]|(f.data[1]&lt;&lt;8));
                    line_angle = (int16_t)(f.data[2]|(f.data[3]&lt;&lt;8));
                    vision_ok = 1;
                } else if(f.type==0x00){ vision_ok = 0; } // 视觉报告:没线
                // else 0x02/0x03/0x04: 二维码/Tag/数字 id = f.data[0]
            }
        }
    }
    if(HAL_GetTick() - last_valid_ms &gt; 100){            // &gt;100ms 超时
        vision_ok = 0;
        Fallback_GrayscaleOrIMU();                      // 降级:灰度巡线/IMU航向
    } else if(vision_ok){
        Motor_PID(line_error, line_angle);              // 正常:视觉闭环
    }
}</code></pre>
<p>这段里藏着几条容易被忽略、但赛场上很要命的细节：</p>
<div class="ds-callout ds-callout-important" style="border-left:4px solid #00bcd4;background:#00bcd414;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00bcd4"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2757.png" alt="❗" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 降级机制的三条铁律</p>
<ol>
<li><strong>只有校验通过的帧才刷新超时计时。</strong> 坏帧（校验错、残帧）一律丢弃、不刷新——否则坏数据会维持&#8221;视觉还活着&#8221;的假象，该降级时不降级。</li>
<li><strong>超时阈值要留余量。</strong> 100ms 大约是正常帧间隔的 2~3 倍，给抖动留空间，不会因为偶尔晚到一帧就误降级。</li>
<li><strong>降级和恢复之间加迟滞（hysteresis）。</strong> 视觉时断时续会让小车在&#8221;正常<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2194.png" alt="↔" class="wp-smiley" style="height: 1em; max-height: 1em;" />降级&#8221;之间来回抖动切换。可以设成：连续丢 N 帧才降级，连续收到 M 帧好数据才切回视觉。</li>
</ol>
</div>
<p>要让降级真正靠谱，前提是<strong>灰度、IMU 这些备选方案本身就已经能独立巡线/锁航向</strong>。这就回到了一条贯穿整个系列的最佳实践：</p>
<div class="ds-callout ds-callout-tip" style="border-left:4px solid #00bfa6;background:#00bfa614;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00bfa6"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 先各模块单独调通，再合并</p>
<p>视觉、灰度、IMU、驱动、通信都先各自单独验证能跑，再合并到主控。2024 电赛 H 题的获奖方案就反复强调这种模块化——隐藏好处正是：视觉一掉线，主控能无缝切到&#8221;已经独立验证过&#8221;的灰度/IMU。赛前一定要专门做一次<strong>故障注入测试：故意拔掉视觉线</strong>，看小车会不会乖乖降级，而不是只测正常路径。</p>
</div>
<h2>八、联调心法与一份避坑速查</h2>
<p>最后给一句这一篇最重要的工作方法：</p>
<div class="ds-callout ds-callout-success" style="border-left:4px solid #00c853;background:#00c85314;padding:.6em 1em;margin:1.2em 0;border-radius:6px">
<p style="margin:0 0 .45em;font-weight:700;color:#00c853"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 先把协议定死，双方各自开发，最后联调</p>
<p>视觉同学和电控同学一开始就坐下来，把&#8221;传哪几个数、什么类型、什么格式、大小端、波特率&#8221;全部写在一张纸上钉死。然后各写各的——视觉端发完整帧（哪怕暂时发假数据），电控端按协议解析。等两边都自测通过，插上线联调时往往一次就通。最怕的是边写边改协议，两头永远对不齐。</p>
</div>
<p>随手附一份本篇的避坑速查，赛前过一遍：</p>
<ul>
<li>☐ 视觉端只发&#8221;结论&#8221;几个数，绝不发整图</li>
<li>☐ 帧头用双字节 0xAA 0x55 + 长度 + 校验三重确认</li>
<li>☐ K230 用串口前先 FPIOA 配引脚复用，且别用 UART0/UART3</li>
<li>☐ K230 与 STM32 都是 3.3V，TX-RX 交叉接、<strong>务必共地</strong></li>
<li>☐ 主控收包走 DMA + IDLE 中断，IDLE 中断记得手动使能</li>
<li>☐ 中断里只置标志，解析和控制放主循环</li>
<li>☐ 大小端两边对齐（视觉 <code>&lt;hh</code> 小端 → 主控低字节在前还原）</li>
<li>☐ 只有校验通过的帧才刷新超时计时，坏帧只丢弃</li>
<li>☐ &gt;100ms 无有效帧立即降级到灰度/IMU，并加迟滞防抖动</li>
<li>☐ 巡线阈值和 ROI 到现场必须重新标定，别照抄例程</li>
<li>☐ 赛前做一次&#8221;拔视觉线&#8221;的故障注入，验证降级真的会触发</li>
</ul>
<p>到这里，小车的&#8221;眼睛&#8221;和&#8221;神经&#8221;就接通了：视觉看到东西、算出结论、打包成帧发出去，主控收包、校验、闭环，还带着超时降级的保命绳。但你可能已经发现，主控这边的活越来越多了——既要跑控制环、解析串口，又要处理起步、过弯、停靠、丢线找回这些不同阶段的逻辑。这些怎么有条不紊地组织起来、各自按什么节奏跑，就是下一篇要解决的事：状态机与整车软件架构。</p>
<div class="ds-series" style="border:1px solid #4488ff33;background:#4488ff0d;border-radius:8px;padding:.8em 1.1em;margin:1.2em 0">
<p style="margin:0 0 .5em"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4da.png" alt="📚" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 本文是 <strong>「从 0 到 1 带你打电赛 · 小车电控篇」</strong> 系列（共 12 篇）第 10 篇。</p>
<ol style="margin:.2em 0 0;padding-left:1.4em">
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-01-how-to-score/">第1篇 · 拿奖逻辑：把比赛拆成小目标</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-02-history/">第2篇 · 赛题进化史与押题</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-03-build-and-architecture/">第3篇 · 整车搭建与代码框架</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-04-motor-power/">第4篇 · 电机驱动与电源地基</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-05-sensing/">第5篇 · 感知：灰度/电磁/编码器/IMU</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-06-pid-basics/">第6篇 · PID 入门：搞懂 P/I/D</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-07-pid-advanced/">第7篇 · PID 进阶：串级+工程补丁</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-08-pid-tuning/">第8篇 · PID 调参实战(核心)</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-09-advanced-control/">第9篇 · 进阶控制：几时该上</a></li>
<li style="margin:.15em 0"><strong>第10篇 · K230 视觉与通信协议（本篇）</strong></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-11-architecture-fsm/">第11篇 · 状态机与整车软件</a></li>
<li style="margin:.15em 0"><a href="https://cloudlay.cn/nuedc-car-12-field-manual/">第12篇 · 现场作战+避坑+开源</a></li>
</ol>
</div>
]]></content:encoded>
					
					<wfw:commentRss>https://cloudlay.cn/nuedc-car-10-vision-comm/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
