1. 观测方程

设GNSS接收机和卫星的位矢分别为

$$ \vec{R_r} = \begin{bmatrix} x_r \\ y_r \\ z_r \\ \end{bmatrix} , \quad \vec{R_s^i} = \begin{bmatrix} x_s^i \\ y_s^i \\ z_s^i \\ \end{bmatrix} $$

其中的$i$表示第$i$颗卫星。

伪距观测量$\rho$是指从卫星到接收机的近似距离,因为它包含了大量的误差(接收机钟差、卫星钟差、电离层延迟误差、对流层延迟误差、相对论效应误差、固体潮海潮误差、多普勒效应误差等),所以它并不严格等于二者的几何距离。

$$ \rho_i = |R_{rs}| + (\delta t_r - \delta t_s^i )c\space + \delta_{ion} + \delta_{tro} + \delta_{rel} + \delta_{tide} + \delta\\_{dop} + \delta_{other} $$

有些误差(例如电离层、对流层等)能够进行数学物理建模然后计算出来,我们尽可能多地把误差都减掉,伪距就会变得更准确一点,为了方便我们把这个量定义为修正伪距$\hat \rho$ 。

$$ \hat \rho = |R_{rs}| + \delta t_r c + \delta_{other} $$

接收机和卫星$i$之间的距离为

$$ |R_{rs}^i| = \sqrt{ (x_r - x_s^i)^2 + (y_r - y_s^i)^2 + (z_r - z_s^i)^2 } $$

我们将修正伪距代入上式,忽略$\delta_{other}$,可得

$$ \sqrt{ (x_r - x_s^i)^2 + (y_r - y_s^i)^2 + (z_r - z_s^i)^2 } + \delta t_rc = \hat \rho_i $$

这个就是GNSS观测方程,其中的卫星坐标可根据广播星历计算出来,所以观测方程仅包含四个未知数:接收机三维坐标和接收机钟差。观测方程想要求解需要至少4个方程,换句话说,至少需要同时存在4颗可观测卫星,接收机才能完成单点定位。


2. 观测方程线性化求解

观测方程是非线性方程,很难求解,我们可以将观测方程左边在接收机位矢$\vec{R_{r_0}}$处进行线性化,换句话说,进行泰勒展开但仅保留一阶项。($\vec{R_{r_0}}$是接收机初值,可以是任意值,只不过当初值越接近真实值,线性化后初值邻域内的函数值也越接近真实值)

$$ \frac {1}{2|R_{r_0s}^i|} ( 2(x_{r_0} - x_s^i)(x_r - x_{r_0}) + 2(y_{r_0} - y_s^i)(y_r - y_{r_0}) + 2(z_{r_0} - z_s^i)(z_r - z_{r_0}) ) + \delta t_r c = \hat \rho_i $$

以$x$项为例,我们注意到$(x_{r_0} - x_s^i) / |R_{r_0s}^i|$其实是以卫星$i$的位矢$\vec{R_s^i}$为起点,以接收机位矢$\vec{R_{r_0}}$为终点的向量$\vec{R_{r_0s}^i}$的方向向量$\vec{e_{r_0s}^i}$在$x$轴方向的分量,我们简单计作$e_x^i$。以及$(x_r - x_{r_0})$是接收机位矢变化量$\Delta \vec R_r$在$x$轴方向的分量。所以观测方程线性化后可写成如下形式

$$ e_x^i \Delta x + e_y^i \Delta y + e_z^i \Delta z + \delta t_r c = \hat \rho_i $$

多颗卫星的观测方程线性化后可组成方程组,写成矩阵形式

$$ \begin{bmatrix} e_x^1 & e_y^1 & e_z^1 & 1 \\ ... & ... & ... & ...\\ e_x^n & e_y^n & e_z^n & 1 \\ \end{bmatrix} \begin{bmatrix} \Delta x \\ \Delta y \\ \Delta z \\ \delta t_r c \end{bmatrix} = \begin{bmatrix} \hat \rho_1 \\ ... \\ \hat \rho_n \end{bmatrix} $$

$$ B \vec X = \vec L $$

为了方便,我们把$\vec X$叫做修正向量。

2.1 无冗余观测

若至少4颗卫星的分布良好,不存在过于靠近、共线等线性相关的情况,则方程的解存在

$$ \vec X = (B^TB)^{-1} B^T \vec L $$

这是比较少见的情况,为了泛用性,我们重点讨论下面的一般情况:有冗余观测。

2.2 有冗余观测(最小二乘)

若除了4颗良好卫星之外,还有冗余观测,则方程大概率无解。定义误差向量为

$$ \vec V = B \vec X - \vec L $$

那么我们的目标就是找到一个$\vec X$使$\vec V$的模长尽可能小,我们很自然地想到可以让$V^TV$的一阶导等于零(因为$V^TV$仅有一个极值点,且该点为最小值点)。

$$ \frac {\partial (V^TV)}{\partial X} = 0 $$

$$ \frac {\partial}{\partial X}(V^TV) = 2 \frac {\partial V}{\partial X}V = 2 \frac {\partial (BX)}{\partial X}V = 2 B^T (BX - L) = 2 (B^TBX - B^TL) = 0 $$

$$ \vec X = (B^TB)^{-1} B^T \vec L $$

这个算法就是最小二乘算法LS的核心步骤:误差平方和最小化。这个解$\vec X$的形式与无冗余观测是一样的。

需要注意的是,这里计算得到的解,是观测方程组线性化之后的解,而不是原观测方程组的解,因为线性化只保留了一阶项。所以直接把$\vec X$代入到原始的观测方程组,方程组一定是不成立的。


3. 迭代求解

在得到修正向量$\vec X$之后,我们就可以将其加到接收机初值上得到更准确的接收机向量

$$ \vec R_r = \vec R_{r_0} + \begin{bmatrix} \Delta x \\ \Delta y \\ \Delta z \end{bmatrix} $$

若进行接收机本地时钟校正,则接收机本地时钟也可以变得更准

$$ t_r = t_{r_0} + \delta t_r $$

把当前接收机向量作为初值,代入到观测方程线性化方程组继续求解,则可以迭代求解接收机向量,当误差小于用户指定的阈值,或达到最大迭代次数,迭代停止,我们就求出了接收机的坐标向量,接收机本地时钟也能够完成校正。


4. 加权最小二乘

观测方程线性化方程组

$$ B \vec X = \vec L $$

误差向量

$$ \vec V = B \vec X - \vec L $$

仅考虑有冗余观测的一般情况,我们在进行最小二乘算法的时候,会希望误差越大的观测对最终结果的贡献越小,反之误差越小对结果的贡献越大。我们可以定义一个权重矩阵(简称权阵)$P$

$$ P = \begin{bmatrix} p_1 & & & 0 \\ & p_2 & & \\ & & ... & \\ 0 & & & p_n \end{bmatrix} $$

权阵为对角矩阵,将$P$引入到最小二乘算法中。

$$ \frac {\partial(V^T P V)}{\partial X} = 0 $$

$$ \frac {\partial(V^T P V)}{\partial X} = \frac {\partial (BX)}{\partial X}PV + \frac {\partial (V^T P^T)}{\partial X}V = B^T PV + B^T P^T V $$

因为权阵$P$是对角矩阵,所以

$$ \frac {\partial(V^T P V)}{\partial X} = 2 B^T P (BX - L) = 0 $$

$$ X = (B^T P B)^{-1}B^T P L $$

4.1 权阵定义

权阵如何定义呢?根据“观测误差小的贡献大”的需求,我们可以选取伪距残差的倒数作为权重,为了只关注误差的相对水平,避免出现过大或过小的数值,我们再把伪距残差倒数进行归一化。

$$ p_i = \frac { \frac 1{|\hat \rho_i - |\vec R_{r_0} - \vec R_s^i||} } { \sum_{j = 1}^n \frac 1{|\hat \rho_j - |\vec R_{r_0} - \vec R_s^j||} } $$

参考 https://nwdan.com/tutorials/typecho-latex-support.html

header.php追加

<script defer type="text/javascript" src="https://cdn.staticfile.net/KaTeX/0.16.9/katex.min.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.staticfile.net/KaTeX/0.16.9/katex.min.css" />
<script defer type="text/javascript" src="https://cdn.staticfile.net/KaTeX/0.16.9/contrib/auto-render.min.js"></script>

footer.php追加

<script type = "text/javascript" >
  document.addEventListener("DOMContentLoaded", function() {
    renderMathInElement(document.body, {
      delimiters: [{
          left: "$$",
          right: "$$",
          display: true
      }, {
          left: "$",
          right: "$",
          display: false
      }],
      ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
      ignoredClasses: ["nokatex"]
    });
  });
</script>

1. 前向传播

神经元的输入值为$x$,输出值为$f(x+b)$,其中$f$叫做该神经元的激活函数,$b$叫做神经元的阈值(偏置),$f(x+b)$叫神经元的激活值。常用的激活函数有$sigmoid$、$relu$等。

               (j)
(layer  L ) ....O.......
                 \
                 weight(i, j)
                    \
(layer L+1) .........O..
                    (i)

我们关注第$(L+1)$层的第$i$个神经元,它与前一层$(L)$的第$j$个神经元相连接,该连接的权重为$\omega_{ij}$,那么神经元$j$的激活值$a^L_j$传导到神经元$i$时为$\omega_{ij}a^L_i$。神经元$i$与第$(L)$层的每一个神经元都有连接,它们对神经元$i$的输入都有贡献,考虑到神经元$i$本身的阈值,第$(L+1)$层神经元$i$最终的激活值为

$$ a^{L+1}_i = f(\sum_{j=1}^{N_L} \omega_{ij}a^L_j + b^{L+1}_i) $$

写成向量相乘的形式

$$ a^{L+1}_i = f( \begin{bmatrix} \omega_{i1} & ... & \omega_{iN_L} \end{bmatrix} \begin{bmatrix} a_1^L \\ ... \\ a_{N_L}^L \end{bmatrix} + b_i^{L+1} ) $$

根据上式,我们就可以写出第$(L+1)$层所有神经元的激活值的矩阵形式

$$ \begin{bmatrix} a_1^{L+1} \\ ... \\ a_{N_{L+1}}^{L+1} \end{bmatrix} = f( \begin{bmatrix} \omega_{11} & ... & \omega_{1N_L} \\ ... & & ... \\ \omega_{N_{L+1}1} & ... & \omega_{N_{L+1}N_L} \end{bmatrix} \begin{bmatrix} a_1^L \\ ... \\ a_{N_L}^L \end{bmatrix} + \begin{bmatrix} b_1^{L+1} \\ ... \\ b_{N_{L+1}^{L+1}} \end{bmatrix} ) $$

$$ \vec{a}^{L+1} = f(W \vec{a}^L + \vec{b}^{L+1}) $$

我们为了方便描述,把作用在向量上的激活函数记作如下形式

$$ f(\vec x) = \begin{bmatrix} f(x_1) \\ ... \\ f(x_n) \end{bmatrix} $$

这样,在已知每一层的权重矩阵和阈值向量的情况下,我们就可以从输入层开始,一层一层地计算每层神经元的激活值。


2. 损失函数

假设神经网络的输出层有$N$个神经元,那么输入给神经网络数据后,我们将最终得到$N$个激活值$a_1$~$a_N$。如何评估神经网络的好坏呢?我们使用神经网络输出的激活值$a_i$与我们希望得到的激活值$y_i$之间的差距来作为评估依据,差距越小,说明网络越好;反之差距越大,说明网络越不好。

直接作差会面临符号问题,所以我们对差进行平方。将输出层所有神经元的计算结果求和,再乘以1/2(后文会说明为什么这样做),给这个量起一个名字,叫做损失函数$C$,它描述了神经网络训练结果的好坏。

$$ C = \frac12 \sum_{i = 1}^N (a_i - y_i)^2 $$

其中,1/2这个常数并不是一开始就存在的,而是人们后续在进行大量的梯度计算时,发现如果损失函数这里多个1/2,就可以把求导生成的2消掉,非常方便,所以就在定义中加入了1/2。

我们训练神经网络时,不能直接调节神经元的激活值,只能调节网络中的权重和阈值。因此损失函数也是神经网络中所有权重和阈值的函数

$$ C = C(\omega_{ij} \space,\space ... \space,\space b_k \space,\space ...) $$


3. 梯度下降

有了损失函数就可以量化评估神经网络训练结果的好坏了,同时也就有了神经网络的优化指南:我们想让损失函数越小越好。

想让一个函数变得越小越好,我们自然会想直接找到其一阶导数为零的点,这个点就是极小值点,若损失函数本身是凸函数,则该点就是最小值点。但是损失函数是网络中所有权重和阈值的函数,所以直接求解所有偏导数为零的方程组几乎是不可能完成的事情(例如DeepSeek-R1模型有约$6.7\times 10^{11}$个变量)。

我们换一个思路,损失函数有个非常明显的特点——函数值非负。因此我们如果每次都找一个比当前函数值小一点点(非无穷小)的值,那么在有限步数内,一定能找到一个极小值。所以我们只需要在附近找一个比当前函数值更小的点,然后重复这个过程即可。因为梯度向量是函数值上升最快的单位方向向量,所以比起漫无目的地找更小的函数值,我们直接往梯度向量相反的方向去找,效率最高。

$$ f_{min}(\vec{x} + \vec{e}) = f(\vec{x} + (- \nabla f)) $$

上式中的$\alpha \in (0, 1)$,叫做学习率,梯度下降每次只下降一点点,这样步长减小后下降的过程会变得更加稳定。

所谓的梯度下降,用一句话来概括就是:损失函数沿着其梯度相反的方向,函数值下降得最快。


4. 反向传播

我们知道了优化神经网络的核心算法是梯度下降,那具体要怎么做呢?考虑一个简单的例子:包含三层神经元的多层感知机,每层的神经元数量分别为2、3、2。

(k)  o o    (input  layer)
     xxx  [weight(j, k)]
(j) o o o   (hidden layer)
     xxx  [weight(i, j)]
(i)  o o    (output layer)

输入层用$K$表示,$k$为该层神经元索引,$b_k$为第$k$个神经元的阈值;隐藏层用$J$表示,$j$为该层神经元索引,$b_j$为第$j$个神经元的阈值;输出层用$I$表示,$i$为该层神经元索引,$b_i$为第$i$个神经元的阈值。

假设我们希望输出层第$i$个神经元的激活值为$y_i$,那么损失函数为

$$ C = \frac12 \sum_{i = 1}^2 (a_i - y_i)^2 $$

根据梯度下降思想,在$(\omega, b, C)$构成的相空间中,我们需要让当前状态往$(-\nabla C)$方向移动一小段距离,虽然梯度是单位向量,本身长度已经足够小,但是为了计算的稳定,我们仍然选择一个非常小的值$\alpha$作为步长,$\alpha$也被称为学习率。

$$ \Delta C (\omega, b) = -\alpha \nabla C $$

这样我们就能求出每一个参数的调整量了,我们从后往前一层一层地看,这个计算过程叫做反向传播(BP)(Back Propagation)。


4.1 输出层

损失函数是输出层神经元激活值$a_i$的函数,而$a_i$又是其与前一层神经元之间连接的权重、阈值、前一层神经元的激活值的函数$a_i = a_i(\omega_{ij}, b_i, a_j)$。

根据前向传播的定义,

$$ a_i = f(\sum_{j = 1}^3 \omega_{ij}a_j + b_i) = f(z_i) $$

我们可以求出以下偏导数

$$ \frac{\partial C}{\partial b_i} = \frac{\partial C}{\partial a_i} \frac{d a_i}{d z_i} \frac{\partial z_i}{\partial b_i} = (a_i - y_i)f'(z_i) $$

$$ \frac{\partial C}{\partial \omega_{ij}} = \frac{\partial C}{\partial a_i} \frac{da_i}{dz_i} \frac{\partial z_i}{\partial \omega_{ij}} = (a_i - y_i)f'(z_i) a_j $$

$$ \frac{\partial C}{\partial a_j} = \sum_{i = 1}^2 \frac{\partial C}{\partial a_i} \frac{da_i}{dz_i} \frac{\partial z_i}{\partial a_j} = \sum_{i = 1}^2 (a_i - y_i)f'(z_i) \omega_{ij} $$

其中,$f'$表示激活函数$f$的导函数。

对$a_j$求偏导数的时候,要注意到下标$j$与$i$无关,对于$i\in[1, 2]$,每一个$a_i$的计算中都包含了$a_j$,因此,若损失函数对与$i$无关的变量求偏导数,则结果应该是对$i$求和的。


4.2 隐藏层

前面求出了损失函数对隐藏层神经元激活值的偏导数$\frac{\partial C}{\partial a_j}$,但我们不能直接修改神经元的激活值,而应该修改隐藏层神经元激活值所依赖的$(\omega_{jk}, b_j, a_k)$。

$$ a_j = f(\sum_{k = 1}^2 \omega_{jk}a_k + b_j) = f(z_j) $$

与前一层类似,我们可以求出隐藏层各项参数的偏导数

$$ \frac{\partial C}{\partial b_j} = \frac{\partial C}{\partial a_j} \frac{d a_j}{dz_j} \frac{\partial z_j}{\partial b_j} = \frac{\partial C}{\partial a_j} f'(z_j) $$

$$ \frac{\partial C}{\partial \omega_{jk}} = \frac{\partial C}{\partial a_j} \frac{d a_j}{dz_j} \frac{\partial z_j}{\partial \omega_{jk}} = \frac{\partial C}{\partial a_j} f'(z_j) a_k $$

$$ \frac{\partial C}{\partial a_k} = \sum_{j = 1}^3 \frac{\partial C}{\partial a_j} \frac{d a_j}{d z_j} \frac{\partial z_j}{\partial a_k} = \sum_{j = 1}^3 \frac{\partial C}{\partial a_j} f'(z_j) \omega_{jk} $$

与前文类似,求$a_k$的偏导数时,由于每一个$a_j(j\in[1,3])$中都包含了$a_k$(见$a_j$的计算过程),所以结果应该对$j$求和。若隐藏层不止一层,则每一层的诸项偏导数都可以套用上述形式,换句话说,上述偏导数形式是递归的。


4.3 输入层

该层神经元将输入给神经网络的值直接向后传递,不进行激活操作;换句话说,该层的神经元没有偏置,没有激活函数,也没有与前一层连接的权重。

$$ a_{in} = input $$

该层无可调节参数,不进行反向传播。


5. 矩阵形式

我们前面提到了神经网络前向传播的矩阵形式,在这个三层MLP的例子中,用$I$、$J$、$K$分别表示输出层、隐藏层、输入层。


5.1 前向传播

$$ \begin{bmatrix} a_1^I \\ a_2^I \end{bmatrix} = f( \begin{bmatrix} \omega_{11}^{IJ} & \omega_{12}^{IJ} & \omega_{13}^{IJ} \\ \omega_{21}^{IJ} & \omega_{22}^{IJ} & \omega_{23}^{IJ} \\ \end{bmatrix} \begin{bmatrix} a_1^J \\ a_2^J \\ a_3^J \end{bmatrix} + \begin{bmatrix} b_1^I \\ b_2^I \end{bmatrix} ) $$

$$ \begin{bmatrix} a_1^J \\ a_2^J \\ a_3^J \end{bmatrix} = f( \begin{bmatrix} \omega_{11}^{JK} & \omega_{12}^{JK} \\ \omega_{21}^{JK} & \omega_{22}^{JK} \\ \omega_{31}^{JK} & \omega_{32}^{JK} \end{bmatrix} \begin{bmatrix} a_1^K \\ a_2^K \end{bmatrix} + \begin{bmatrix} b_1^J \\ b_2^J \\ b_3^J \end{bmatrix} ) $$

$$ \begin{bmatrix} a_1^K \\ a_2^K \end{bmatrix} = \begin{bmatrix} input_1 \\ input_2 \end{bmatrix} $$


5.2 反向传播

损失函数对输出层参数的偏导数构成的2个矩阵为

$$ \Delta W_{IJ} = \begin{bmatrix} (a_1^I - y_1)f^{\prime I}(z_1) a_1^J & (a_1 - y_1)f^{\prime I}(z_1) a_2^J & (a_1 - y_1)f^{\prime I}(z_1) a_3^J \\ (a_2^I - y_2)f^{\prime I}(z_2) a_1^J & (a_2 - y_2)f^{\prime I}(z_2) a_2^J & (a_2 - y_2)f^{\prime I}(z_2) a_3^J \end{bmatrix} $$

$$ \vec{\Delta b_I} = \begin{bmatrix} (a_1^I - y_1)f^{\prime I}(z_1) \\ (a_2^I - y_2)f^{\prime I}(z_2) \end{bmatrix} $$

这个形式很有规律,我们可以写成如下形式

$$ \Delta W_{IJ} = \begin{bmatrix} (a_1^I - y_1)f^{\prime I}(z_1) \\ (a_2^I - y_2)f^{\prime I}(z_2) \end{bmatrix} \begin{bmatrix} a_1^J & a_2^J & a_3^J \end{bmatrix} = \begin{bmatrix} \delta_1^I \\ \delta_2^I \end{bmatrix} \begin{bmatrix} a_1^J & a_2^J & a_3^J \end{bmatrix} $$

$$ \vec{\Delta b_I} = \begin{bmatrix} (a_1^I - y_1)f^{\prime I}(z_1) \\ (a_2^I - y_2)f^{\prime I}(z_2) \end{bmatrix} = \begin{bmatrix} \delta_1^I \\ \delta_2^I \end{bmatrix} $$

我们把其中的$\begin{bmatrix}\delta^I_1 \\ \delta^I_2\end{bmatrix}$称为误差向量$\vec{\delta^I}$,只与输出层有关;$\begin{bmatrix} a_1^J \\ a_2^J \\ a_3^J \end{bmatrix}$为上一层的激活值向量$\vec{a^J}$。那么偏导数矩阵$\Delta W_{IJ}$实际上是由$\vec{\delta^I}$和$\vec{a^J}$经过外积得到的二阶张量。

我们继续写出隐藏层的偏导数矩阵

$$ \Delta W_{JK} = \begin{bmatrix} \frac{\partial C}{\partial a_1^J}f^{\prime J}(z^J_1) a_1^K & \frac{\partial C}{\partial a_1^J}f^{\prime J}(z^J_1) a_2^K \\ \frac{\partial C}{\partial a_2^J}f^{\prime J}(z^J_2) a_1^K & \frac{\partial C}{\partial a_2^J}f^{\prime J}(z^J_2) a_2^K \\ \frac{\partial C}{\partial a_3^J}f^{\prime J}(z^J_3) a_1^K & \frac{\partial C}{\partial a_3^J}f^{\prime J}(z^J_3) a_2^K \end{bmatrix} = \begin{bmatrix} \frac{\partial C}{\partial a_1^J}f^{\prime J}(z^J_1) \\ \frac{\partial C}{\partial a_2^J}f^{\prime J}(z^J_2) \\ \frac{\partial C}{\partial a_3^J}f^{\prime J}(z^J_3) \end{bmatrix} \begin{bmatrix} a_1^K & a_2^K \end{bmatrix} = \begin{bmatrix} \delta_1^J \\ \delta_2^J \\ \delta_3^J \end{bmatrix} \begin{bmatrix} a_1^K & a_2^K \end{bmatrix} $$

$$ \vec{\Delta b_{J}} = \begin{bmatrix} \frac{\partial C}{\partial a_1^J}f^{\prime J}(z^J_1) \\ \frac{\partial C}{\partial a_2^J}f^{\prime J}(z^J_2) \\ \frac{\partial C}{\partial a_3^J}f^{\prime J}(z^J_3) \end{bmatrix} = \begin{bmatrix} \delta_1^J \\ \delta_2^J \\ \delta_3^J \end{bmatrix} $$

其中,误差向量$\delta^J$这样计算

$$ \frac{\partial C}{\partial a_j} = \sum_{i = 1}^2 \frac{\partial C}{\partial a_i} \frac{da_i}{dz_i} \frac{\partial z_i}{\partial a_j} = \sum_{i = 1}^2 (a_i - y_i)f'(z_i) \omega_{ij} $$

若隐藏层有多层,则非最末层隐藏层的误差向量$\delta^K$使用下面的形式递归计算

$$ \frac{\partial C}{\partial a_k} = \sum_{j = 1}^3 \frac{\partial C}{\partial a_j} \frac{d a_j}{d z_j} \frac{\partial z_j}{\partial a_k} = \sum_{j = 1}^3 \frac{\partial C}{\partial a_j} f'(z_j) \omega_{jk} $$


总结(TODO)

所需材料:openocd软件 + SWD调试器。

1. 安装openocd

我的开发环境是ubuntu服务器,所以我将在ubuntu编译安装openocd。执行以下命令拉取树莓派官方适配过的openocd仓库,并完成配置。

sudo apt install automake autoconf build-essential texinfo libtool libftdi-dev libusb-1.0-0-dev pkg-config libhidapi-dev
git clone https://github.com/raspberrypi/openocd.git --branch rp2040 --recursive --depth=1
cd openocd
./bootstrap
./configure
./configure --enable-picoprobe --enable-cmsis-dap --disable-werror

应该可以看到如下内容:

OpenOCD configuration summary
--------------------------------------------------
MPSSE mode of FTDI based devices        yes (auto)
Raspberry Pi Pico Probe                 yes
ST-Link Programmer                      yes (auto)
TI ICDI JTAG Programmer                 yes (auto)
Keil ULINK JTAG Programmer              yes (auto)
Altera USB-Blaster II Compatible        yes (auto)
Bitbang mode of FT232R based devices    yes (auto)
Versaloon-Link JTAG Programmer          yes (auto)
TI XDS110 Debug Probe                   yes (auto)
CMSIS-DAP v2 Compliant Debugger         yes (auto)
OSBDM (JTAG only) Programmer            yes (auto)
eStick/opendous JTAG Programmer         yes (auto)
Andes JTAG Programmer                   yes (auto)
USBProg JTAG Programmer                 yes (auto)
Raisonance RLink JTAG Programmer        yes (auto)
Olimex ARM-JTAG-EW Programmer           yes (auto)
CMSIS-DAP Compliant Debugger            yes
Nu-Link Programmer                      yes (auto)
Cypress KitProg Programmer              yes (auto)
Altera USB-Blaster Compatible           yes (auto)
ASIX Presto Adapter                     yes (auto)
OpenJTAG Adapter                        yes (auto)
Linux GPIO bitbang through libgpiod     no
SEGGER J-Link Programmer                yes (auto)
Use Capstone disassembly framework      no

接下来编译,然后安装,安装完成后就可以在任意目录下使用可执行文件openocd了。

make -j4
make install

若想卸载,则在上述目录中执行make uninstall即可。


2. 准备SWD调试器(probe)

既可以购买树莓派官方的调试器,也可以根据官方手册使用树莓派pico制作调试器,本文使用了我自己设计的小尺寸调试器(围绕RP2040设计了小尺寸PCB,功能上与树莓派pico一致),下文将其称为probe。

给probe烧录固件

官方Github仓库发布的UF2文件,用BOOTSEL的方式烧录至probe中即可。若官方固件更新,同样可以用这种方式给probe更新固件。

probe连接至树莓派pico

我们要进行调试的设备为树莓派pico,调试器为probe,把它们的SWD、UART引脚连接起来,同时也要保证两个设备共地。

probe GND   -- pico GND
probe GPIO2 -- pico SWCLK
probe GPIO3 -- pico SWDIO
probe GPIO4(UART1 TX) -- pico GPIO1(UART0 RX)
probe GPIO5(UART1 RX) -- pico GPIO0(UART0 TX)

设备pico可以用以下方式供电:

  • 使用USB-typeC分别给probe和pico供电,probe预留的5V输出电源线不接。
  • 使用USB-typeC给probe供电,probe使用预留的5V输出电源线给设备pico供电。

3. 使用probe的SWD引脚烧录程序

使用SWD烧录程序需要的是ELF文件,假设我们编译后的文件路径为/test/build/hello_test.elf,那么用下面的命令就可以将ELF文件烧录至树莓派pico。

sudo openocd -f interface/cmsis-dap.cfg -c "adapter speed 5000" -f target/rp2040.cfg -s tcl -c "program /test/build/hello_test.elf verify reset exit"

这里给一份正常烧录的打印。

Open On-Chip Debugger 0.11.0-g8e3c38f (2024-08-11-09:40)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
adapter speed: 5000 kHz

Info : auto-selecting first available session transport "swd". To override use 'transport select <transport>'.
Info : Hardware thread awareness created
Info : Hardware thread awareness created
Info : RP2040 Flash Bank Command
Info : Using CMSIS-DAPv2 interface with VID:PID=0x2e8a:0x000c, serial=CB641C968F686D2F
Info : CMSIS-DAP: SWD  Supported
Info : CMSIS-DAP: FW Version = 2.0.0
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : SWCLK/TCK = 0 SWDIO/TMS = 0 TDI = 0 TDO = 0 nTRST = 0 nRESET = 0
Info : CMSIS-DAP: Interface ready
Info : clock speed 5000 kHz
Info : SWD DPIDR 0x0bc12477
Info : SWD DLPIDR 0x00000001
Info : SWD DPIDR 0x0bc12477
Info : SWD DLPIDR 0x10000001
Info : rp2040.core0: hardware has 4 breakpoints, 2 watchpoints
Info : rp2040.core1: hardware has 4 breakpoints, 2 watchpoints
Info : starting gdb server for rp2040.core0 on 3333
Info : Listening on port 3333 for gdb connections
target halted due to debug-request, current mode: Thread 
xPSR: 0xf1000000 pc: 0x000000ea msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0xf1000000 pc: 0x000000ea msp: 0x20041f00
** Programming Started **
Info : RP2040 B0 Flash Probe: 2097152 bytes @10000000, in 512 sectors

target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
Info : Writing 12288 bytes starting at 0x0
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
** Programming Finished **
** Verify Started **
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
target halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x00000184 msp: 0x20041f00
** Verified OK **
** Resetting Target **
shutdown command invoked

4. 使用probe的SWD引脚调试程序

通过SWD连接到pico

通过probe连接到树莓派pico。

sudo openocd -f interface/cmsis-dap.cfg -c "adapter speed 5000" -f target/rp2040.cfg

可以看到下面的打印。

Open On-Chip Debugger 0.11.0-g8e3c38f (2024-08-11-09:40)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
adapter speed: 5000 kHz

Info : auto-selecting first available session transport "swd". To override use 'transport select <transport>'.
Info : Hardware thread awareness created
Info : Hardware thread awareness created
Info : RP2040 Flash Bank Command
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : Using CMSIS-DAPv2 interface with VID:PID=0x2e8a:0x000c, serial=CB641C968F686D2F
Info : CMSIS-DAP: SWD  Supported
Info : CMSIS-DAP: FW Version = 2.0.0
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : SWCLK/TCK = 0 SWDIO/TMS = 0 TDI = 0 TDO = 0 nTRST = 0 nRESET = 0
Info : CMSIS-DAP: Interface ready
Info : clock speed 5000 kHz
Info : SWD DPIDR 0x0bc12477
Info : SWD DLPIDR 0x00000001
Info : SWD DPIDR 0x0bc12477
Info : SWD DLPIDR 0x10000001
Info : rp2040.core0: hardware has 4 breakpoints, 2 watchpoints
Info : rp2040.core1: hardware has 4 breakpoints, 2 watchpoints
Info : starting gdb server for rp2040.core0 on 3333
Info : Listening on port 3333 for gdb connections

可以看到,0核开启了gdb服务器,监听端口为3333

使用GDB调试

安装gdb。

sudo apt install gdb-multiarch

在SWD连接好后,新建一个shell终端,找到给树莓派pico烧录使用的ELF文件(我这里的文件名叫hello_test.elf),用gdb运行,然后连接到本地3333端口。

gdb hello_test.elf
# 在gdb终端中运行下面的命令
target remote localhost:3333

接下来就可以正常使用gdb进行调试了。


5. 使用probe的UART引脚连接串口

先在服务器安装minicom,用这个软件作为串口工具。在probe连接到树莓派pico之后,使用minicom打开设备ttyACM0

sudo apt-get install minicom
sudo minicom -b 115200 -o -D /dev/ttyACM0

这里的/dev/ttyACM0就是probe提供的串口设备。为了测试,我给树莓派pico烧录的程序会不停地通过串口(若执行了树莓派pico sdk里的函数stdio_init_all(),那么stdio将默认重定向到UART0,对应RP2040的GPIO0和GPIO1)打印HELLO!,效果如下所示。

Welcome to minicom 2.8

OPTIONS: I18n 
Port /dev/ttyACM0, 07:02:50

Press CTRL-A Z for help on special keys

HELLO!
HELLO!
HELLO!

串口可以正常打印HELLO。


6. 问题记录

突然无法正常使用SWD连接probe

现象如下所示。

Open On-Chip Debugger 0.11.0-g8e3c38f (2024-08-11-09:40)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
adapter speed: 5000 kHz

Info : auto-selecting first available session transport "swd". To override use 'transport select <transport>'.
Info : Hardware thread awareness created
Info : Hardware thread awareness created
Info : RP2040 Flash Bank Command
Error: unable to find a matching CMSIS-DAP device
Error: No Valid JTAG Interface Configured.

排查了很久没发现原因,偶然间看到一个网友转载的文章,最终发现是权限问题所致,使用sudo运行openocdminicom就正常了。

安装交叉编译相关工具

安装cmake、gcc-arm交叉编译器和newlib库(C和C++)。

sudo apt install cmake gcc-arm-none-eabi libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib

安装SDK

开发环境是虚拟机,上github太卡,需要借助一下主机的https代理。

export https_proxy=http://主机IP:端口号

新建一个SDK相关的目录rpi_pico,拉取官方的pico-sdk仓库,并更新子模块。

cd ~
mkdir rpi_pico && cd rpi_pico
git clone https://github.com/raspberrypi/pico-sdk.git
cd pico-sdk
git submodule update --init

为当前用户添加SDK环境变量。

echo "export PICO_SDK_PATH=/home/$USER/rpi_pico/pico-sdk" >> ~/.bashrc
source ~/.bashrc

更新SDK

cd ~/rpi_pico/pico-sdk
git pull
git submodule update

编译

以例程blink为例,我们先拉取官方的例程仓库。

cd ~/rpi_pico
git clone https://github.com/raspberrypi/pico-examples.git

pico-examples/blink为例,编译并烧录这个例程

# 构建pico-examples,为每个例程生成makefile
cd ~/rpi_pico/pico-examples/
mkdir build && cd build
cmake ..

# 生成的每个例程的makefile在当前目录,去编译blink
cd blink
# -j4表示使用4个线程进行编译
make -j4

编译完成后,在当前目录下会看到一堆文件:

blink.bin  blink.dis  blink.elf  blink.elf.map  blink.hex  blink.uf2  CMakeFiles  cmake_install.cmake  elf2uf2  Makefile

其中,以.elf为后缀的是elf文件,以.elf.map为后缀的是elf文件对应的内存分布情况,以.uf2为后缀的是可以烧录的文件。


烧录

windows

  1. 按住树莓派PICO上的BOOTSEL按钮,插入USB连接线,电脑会识别出新的存储设备。
  2. .uf2文件放入该存储设备,就会开始烧录文件,烧录完成后自动弹出设备。

linux

查看树莓派PICO的盘符。

sudo dmesg | tail
# [ 4936.463022]  sdb: sdb1
# [ 4936.478499] sd 33:0:0:0: [sdb] Attached SCSI removable disk

挂载到/mnt/pico/

sudo mkdir /mnt/pico
sudo mount /dev/sdb1 /mnt/pico/
ls /mnt/pico/
# INDEX.HTM  INFO_UF2.TXT
# 看到上述文件表示挂载成功了

接下来把.uf2文件拷贝至/mnt/pico目录下即可。

记得烧录完成后,卸载/mnt/pico

sudo umount /mnt/pico

参考

Getting started with Raspberry Pi Pico