Zynq开发视角下的C语言能力分级详解

Zynq开发视角下的C语言能力分级详解

很多同学学完 C 语言,不知道简历上该写了解、熟练还是精通,尤其是在 Zynq 这类异构嵌入式开发场景中,C 语言的能力直接决定了你能驾驭的项目复杂度。今天我们就结合 Zynq PS 端开发的真实场景,把这三个等级彻底讲清楚,避免面试被问崩,也让你清楚自己当前的能力边界。


一、简历写「了解」:刚入门的基础能力边界

「了解」是 C 语言能力的最低门槛,代表你刚走完基础语法的学习,能看懂简单的代码,能写最基础的小程序,但还无法处理复杂的逻辑,更无法独立完成嵌入式项目的开发。这个阶段的能力,在 Zynq 开发中,刚好对应你第一次点亮 LED、跑通 Hello World 的阶段。

1.1 掌握最基础的语法要素

对于「了解」阶段的开发者来说,你已经掌握了 C 语言最核心的基础语法,这些语法是你写任何代码的基础,在 Zynq 的入门开发中,这些语法刚好能支撑你完成最基础的硬件操作:

1.1.1 变量与数据类型

你已经知道什么是 int、char、float 这些基本数据类型,知道变量的定义和赋值,知道变量的作用域。在 Zynq 的入门代码中,你会用这些变量来存储硬件的状态,比如 LED 的状态、延时的计数:

c 复制代码
// 了解阶段的你能写的Zynq入门代码
#include <stdio.h>
#include "xparameters.h"
#include "xil_printf.h"

int main() {
    // 用int变量存储LED的引脚编号
    int led_pin = 0;
    // 用uint32_t变量存储GPIO的基地址(你只会用这个值,不懂指针操作)
    uint32_t gpio_base = XPAR_GPIO_0_BASEADDR;
    // 用int变量存储延时的计数
    int delay = 0;

    xil_printf("Hello World from Zynq PS!\r\n");

    // 循环翻转LED
    while(1) {
        // 读GPIO的寄存器值,你只会调用库函数,不懂底层的指针操作
        uint32_t reg = Xil_In32(gpio_base + 0x00);
        // 翻转对应引脚的电平
        reg ^= (1 << led_pin);
        // 写回寄存器
        Xil_Out32(gpio_base + 0x00, reg);

        // 延时,你只会用简单的循环延时
        for(delay = 0; delay < 10000000; delay++);
    }

    return 0;
}

这段代码里,你用到了变量、基本数据类型,但是你看不懂Xil_In32Xil_Out32这两个库函数的实现,你只知道调用它们就能读写寄存器,因为你对指针还没有足够的理解。

1.1.2 分支与循环

你已经掌握了 if-else、switch-case 这些分支语句,还有 for、while、do-while 这些循环语句,你能用这些语句来实现简单的逻辑控制,比如上面的 LED 闪烁的循环,比如根据输入的不同做不同的处理:

c 复制代码
// 了解阶段的你能写的简单分支逻辑
int cmd = getchar();
if(cmd == '1') {
    // 点亮LED0
    Xil_Out32(gpio_base, 0x01);
} else if(cmd == '2') {
    // 点亮LED1
    Xil_Out32(gpio_base, 0x02);
} else {
    // 熄灭所有LED
    Xil_Out32(gpio_base, 0x00);
}

这些简单的分支和循环,你已经能熟练使用,但是你还不会用这些语句来处理复杂的状态机,比如串口的接收状态机,你还写不出来。

1.1.3 数组与函数

你已经知道什么是数组,能定义一维数组,能访问数组的元素,你知道函数的定义和调用,能把重复的代码封装成函数,比如你把延时的代码封装成一个函数:

c 复制代码
// 了解阶段的你能写的简单函数
void delay(int ms) {
    int i, j;
    for(i = 0; i < ms; i++) {
        for(j = 0; j < 1000; j++);
    }
}

// 你能定义简单的数组,存储LED的状态
uint8_t led_status[4] = {0, 0, 0, 0};

你能写这样的代码,但是你还不知道数组名其实就是指针,你不知道函数的参数是怎么传递的,你不知道值传递和地址传递的区别,你只会把数组当一个简单的容器来用。

1.2 能写最简单的小程序

基于这些基础语法,你能写一些最基础的小程序,这些程序都是 C 语言入门的经典练习,在 Zynq 的入门阶段,这些小程序刚好能帮你熟悉开发环境:

  • 打印九九乘法表:用嵌套的 for 循环,打印出九九乘法表,这个你能轻松写出来。

  • 简单的计算器:输入两个数,输入运算符,然后做加减乘除,用 if-else 判断运算符,然后计算结果。

  • 简单的排序:比如冒泡排序,给一个数组排序,你能写出来,但是你还不会用指针来优化排序的代码。

  • Zynq 的 Hello World:就是最基础的串口输出,你能跑通这个例子,知道怎么把程序下载到 Zynq 开发板上。

  • Zynq 的 LED 闪烁:就是我们上面写的那个翻转 LED 的代码,你能跑通,能让 LED 亮起来,闪起来。

这些小程序都是最基础的,没有复杂的逻辑,没有指针的复杂用法,没有内存的操作,你只要会基础语法就能写出来。

1.3 对复杂特性一知半解

这是「了解」阶段最核心的特征:你听说过指针、结构体这些东西,但是你根本没搞懂它们,你不敢用它们来写复杂的逻辑,你甚至看不懂别人用这些特性写的代码。

比如你看到Xil_In32的定义:

c 复制代码
#define Xil_In32(addr) (*(volatile uint32_t *)(addr))

你完全看不懂这是什么意思,你不知道(volatile uint32_t *)(addr)是把地址转换成指针,你不知道*是解引用,你不知道 volatile 是什么意思,你只知道这个宏能帮你读寄存器。

比如你看到别人用指针写的代码:

c 复制代码
volatile uint32_t *gpio_base = (uint32_t *)XPAR_GPIO_0_BASEADDR;
*gpio_base = 0x01;

你看不懂这是什么,你不知道*gpio_base就是操作地址对应的内存,你觉得指针太复杂了,你不敢用,你还是老老实实调用Xil_In32Xil_Out32这两个库函数。

比如你看到结构体的代码:

c 复制代码
typedef struct {
    uint32_t BaseAddr;
    uint32_t DeviceId;
    uint32_t IsReady;
} XGpio;

你看不懂这个结构体是干嘛的,你不知道为什么要把这些变量放到一起,你觉得结构体太复杂了,你还是用单独的变量来存这些值。

这个阶段的你,根本不敢碰这些复杂的特性,你怕写错,怕程序崩溃,你只会用你已经搞懂的基础语法来写代码。

1.4 适合的人群

这个阶段的能力,适合刚入门的开发者,比如:

  • 刚学完 C 语言的基础课,还没做过项目的学生。

  • 刚接触嵌入式开发,第一次用 Zynq,刚跑通 Hello World 的开发者。

  • 只会用库函数,不会自己写驱动,不会处理复杂逻辑的开发者。

如果你是这个阶段,那你简历上就只能写「了解:C 语言基础语法」,千万别写熟练,更别写精通,不然面试官一问你指针,你就露馅了。


二、简历写「熟练」:能独立干活的工程能力

「熟练」是大部分嵌入式开发者的能力天花板,也是最稳妥的简历写法,这个阶段的你,已经吃透了 C 语言的核心特性,能独立完成嵌入式项目的开发,能处理大部分的问题,不会出低级的 Bug,在 Zynq 开发中,你已经能独立写驱动,能独立做小项目,能独立调试问题。

这个阶段的能力,是企业招嵌入式开发最看重的,因为大部分的项目,都需要你能独立干活,能把需求落地,不会出低级的问题。

2.1 语法扎实:吃透 C 语言的核心特性

熟练的第一个标志,就是你已经完全吃透了 C 语言的所有核心语法,不管是指针、结构体,还是那些关键字,你都懂它们的原理,你能熟练的用它们来写代码,不会搞混,不会写错。

2.1.1 指针:从入门到精通的核心

指针是 C 语言的灵魂,也是很多人学不会的点,熟练的你,已经完全搞懂了指针,不管是一级指针、多级指针,还是指针数组、数组指针、函数指针,你都能搞清楚,你能熟练的用它们来写 Zynq 的驱动代码。

<2.1.1.1> 一级指针:操作硬件的基础

首先,你已经搞懂了最基础的一级指针,你知道指针就是地址,你知道*是解引用,你知道&是取地址,你终于看懂了Xil_In32Xil_Out32的实现,你甚至能自己用指针来操作寄存器,不用库函数:

c 复制代码
// 熟练的你,能自己用指针操作GPIO寄存器
// 把GPIO的基地址转换成uint32_t的指针
volatile uint32_t *gpio_data_reg = (volatile uint32_t *)(XPAR_GPIO_0_BASEADDR + 0x00);
volatile uint32_t *gpio_dir_reg = (volatile uint32_t *)(XPAR_GPIO_0_BASEADDR + 0x04);

// 设置引脚0为输出,直接操作指针,不用库函数
*gpio_dir_reg |= (1 << 0);
// 点亮LED0,直接写指针指向的寄存器
*gpio_data_reg |= (1 << 0);

你终于懂了,原来指针就是这么用的,原来操作硬件寄存器,本质上就是操作对应地址的内存,指针就是帮你访问这个地址的工具。你也懂了 volatile 的意思,因为硬件寄存器的值是会被硬件自动修改的,所以不能用缓存,要用 volatile 告诉编译器,不要优化这个变量,每次都要从内存里读。

<2.1.1.2> 二级指针:动态参数传递的利器

然后,你搞懂了二级指针,你知道二级指针就是指向指针的指针,你能用它来做参数传递,比如在 Zynq 的 DMA 驱动里,我们要给调用者返回分配的缓冲区的地址,这时候就需要用二级指针:

c 复制代码
// 熟练的你,能用二级指针来分配DMA缓冲区
int dma_alloc_buffer(uint8_t **buf, size_t size, size_t align) {
    // 分配对齐的内存,把分配的地址通过二级指针返回给调用者
    int ret = posix_memalign((void **)buf, align, size);
    if(ret != 0) {
        xil_printf("DMA buffer alloc failed!\r\n");
        return -1;
    }
    // 清空缓冲区
    memset(*buf, 0, size);
    // 刷缓存,保证DMA能读到正确的数据
    Xil_DCacheFlushRange((uint32_t)*buf, size);
    return 0;
}

// 调用的时候
uint8_t *dma_buf;
dma_alloc_buffer(&dma_buf, 1024*1024, 64);

这里的&dma_buf就是二级指针,我们把指针的地址传给函数,函数里就能修改这个指针的值,让它指向分配的内存,这样调用者就能拿到缓冲区的地址了。你终于搞懂了,为什么有时候函数要传指针的指针,原来就是为了修改调用者的指针变量。

<2.1.1.3> 指针数组:管理多个设备的工具

然后,你搞懂了指针数组,你知道指针数组就是一个数组,里面的每个元素都是指针,你能用它来管理 Zynq 里的多个外设,比如 Zynq 有三个 UART 控制器,你可以用指针数组来管理它们的实例:

c 复制代码
// 熟练的你,能用指针数组管理多个UART设备
// 先定义三个UART的实例
XUartPs uart0;
XUartPs uart1;
XUartPs uart2;

// 指针数组,每个元素都是指向UART实例的指针
XUartPs *uart_devs[3] = {
    &uart0,
    &uart1,
    &uart2
};

// 初始化所有UART
for(int i = 0; i < 3; i++) {
    XUartPs_CfgInitialize(&uart_devs[i], ...);
}

// 通过索引访问不同的UART,比如给UART1发数据
XUartPs_Send(uart_devs[1], tx_buf, len);

你终于搞清楚了,指针数组就是多个指针,每个指针指向不同的设备,这样你就能用循环来批量处理这些设备,不用写重复的代码。

<2.1.1.4> 数组指针:处理大块数据的工具

然后,你搞懂了数组指针,你知道数组指针就是一个指针,指向一个固定长度的数组,你能用它来处理 Zynq 里的图像数据,比如 1080P 的图像,每行是 1920 个像素,你就可以用数组指针来访问:

c 复制代码
// 熟练的你,能用数组指针处理图像数据
// 分配1080P的图像缓冲区,每个行是1920个字节
uint8_t (*img_buf)[1920] = malloc(1080 * 1920);
if(img_buf == NULL) {
    xil_printf("Image buffer alloc failed!\r\n");
    return -1;
}

// 访问图像的像素,直接用img_buf[y][x],非常方便
for(int y = 0; y < 1080; y++) {
    for(int x = 0; x < 1920; x++) {
        img_buf[y][x] = process_pixel(img_buf[y][x]);
    }
}

你终于搞清楚了,数组指针和指针数组的区别:指针数组是多个指针,存不同的地址;数组指针是一个指针,指向一个大的数组,用来处理二维数据。很多人搞混这两个,但是熟练的你,已经完全搞懂了,你不会再搞混了。

<2.1.1.5> 函数指针:中断与回调的核心

然后,你搞懂了函数指针,你知道函数指针就是指向函数的指针,你能用它来实现回调函数,这在 Zynq 的中断处理里是最常用的:

c 复制代码
// 熟练的你,能用函数指针实现UART的接收回调
// 定义回调函数的类型,参数是私有数据
typedef void (*uart_rx_callback_t)(void *arg);

// UART的实例结构体,里面存回调函数和参数
typedef struct {
    uint32_t base_addr;
    uart_rx_callback_t rx_callback;
    void *callback_arg;
} UartDev;

// 注册回调函数
void Uart_SetRxCallback(UartDev *dev, uart_rx_callback_t callback, void *arg) {
    dev->rx_callback = callback;
    dev->callback_arg = arg;
}

// 中断服务函数,当UART收到数据的时候调用
void Uart_InterruptHandler(UartDev *dev) {
    // 检查是不是接收中断
    if(Xil_In32(dev->base_addr + UART_IIR) & UART_IIR_RX_INT) {
        // 读收到的数据
        uint8_t data = Xil_In32(dev->base_addr + UART_FIFO);
        // 如果注册了回调,就调用回调函数
        if(dev->rx_callback != NULL) {
            dev->rx_callback(dev->callback_arg, data);
        }
    }
}

// 应用层的代码,注册回调
void my_uart_rx_callback(void *arg, uint8_t data) {
    // 处理收到的数据
    xil_printf("Received data: 0x%02X\r\n", data);
}

int main() {
    UartDev uart;
    // 注册回调
    Uart_SetRxCallback(&uart, my_uart_rx_callback, NULL);
    // 初始化UART,开中断
    UartInit(&uart, 115200);
    while(1);
    return 0;
}

你终于搞懂了,原来中断的回调就是这么实现的,驱动里定义一个函数指针,应用层把自己的处理函数的地址传进去,中断发生的时候,驱动就调用这个函数,这样驱动和应用层就解耦了,驱动不用管应用层怎么处理数据,只要调用回调就行。你也搞懂了,为什么函数指针这么重要,原来它是实现异步处理、解耦的核心。

到这里,你已经完全吃透了指针的所有用法,不管是一级、多级,还是指针数组、数组指针、函数指针,你都能搞懂,都能熟练的用它们来写代码,这是熟练的第一个标志。

2.1.2 结构体、联合体、位域:硬件操作的利器

然后,你已经完全搞懂了结构体、联合体、位域,你知道它们的内存布局,你能用它们来操作 Zynq 的硬件寄存器,这是嵌入式开发里最常用的技巧。

<2.1.2.1> 位域:操作寄存器位的神器

首先,你搞懂了位域,你知道位域能让你直接操作寄存器的某一位,不用自己移位,不用自己掩码,这在操作硬件寄存器的时候太方便了,比如 GPIO 的方向寄存器,每个位对应一个引脚的方向,你就可以用位域来定义:

c 复制代码
// 熟练的你,能用位域定义GPIO的方向寄存器
typedef struct {
    uint32_t dir0 : 1;  // 引脚0的方向,0输入1输出
    uint32_t dir1 : 1;  // 引脚1的方向
    uint32_t dir2 : 1;  // 引脚2的方向
    uint32_t dir3 : 1;  // 引脚3的方向
    uint32_t dir4 : 1;  // 引脚4的方向
    uint32_t dir5 : 1;  // 引脚5的方向
    uint32_t dir6 : 1;  // 引脚6的方向
    uint32_t dir7 : 1;  // 引脚7的方向
    // ... 剩下的24位,对应剩下的24个引脚
    uint32_t dir31 : 1; // 引脚31的方向
} GpioDirReg;

// 然后你就可以直接操作寄存器的位了
volatile GpioDirReg *dir_reg = (GpioDirReg *)(XPAR_GPIO_0_BASEADDR + 0x04);
// 直接设置引脚0为输出,不用移位,不用掩码
dir_reg->dir0 = 1;
// 直接设置引脚1为输入
dir_reg->dir1 = 0;

你看,这样是不是比之前的reg |= (1 << 0)方便太多了?你不用自己算移位,不用自己写掩码,编译器帮你处理了,代码可读性高了很多,也不容易写错。你终于搞懂了位域的用法,原来它就是干这个用的。

<2.1.2.2> 联合体:多类型访问的工具

然后,你搞懂了联合体,你知道联合体的所有成员共用同一块内存,你能用它来实现不同类型的访问,比如你要操作一个 32 位的寄存器,有时候要按整个 32 位访问,有时候要按字节访问,你就可以用联合体:

c 复制代码
// 熟练的你,能用联合体实现寄存器的多类型访问
typedef union {
    uint32_t word;    // 整个32位的寄存器
    uint8_t bytes[4]; // 按字节访问,四个字节
} RegValue;

// 用的时候
RegValue v;
// 整个写寄存器
v.word = 0x12345678;
// 然后你可以单独访问每个字节
xil_printf("Byte 0: 0x%02X\r\n", v.bytes[0]); // 输出0x78,小端的低字节
xil_printf("Byte 1: 0x%02X\r\n", v.bytes[1]); // 输出0x56
xil_printf("Byte 2: 0x%02X\r\n", v.bytes[2]); // 输出0x34
xil_printf("Byte 3: 0x%02X\r\n", v.bytes[3]); // 输出0x12

这样是不是很方便?你不用自己做移位,就能拿到每个字节的值。还有,在处理网络包的时候,你也可以用联合体,比如 IP 头的不同字段,你可以用联合体来解析,非常方便。

<2.1.2.3> 内存布局:搞懂结构体的大小

然后,你搞懂了结构体的内存布局,你知道字节对齐,你知道为什么结构体的大小不是成员的大小之和,比如你定义一个结构体:

c 复制代码
typedef struct {
    uint8_t a;  // 1字节
    uint32_t b; // 4字节
} MyStruct;

你知道这个结构体的大小不是 5,而是 8,因为字节对齐,b 是 4 字节的,要对齐到 4 字节的地址,所以 a 后面会有 3 个填充字节,这样整个结构体的大小就是 8。你也知道,这个对齐在 Zynq 里非常重要,比如你定义寄存器的结构体的时候,如果对齐不对,就会访问错地址:

c 复制代码
// 错误的做法,用packed取消对齐
typedef struct __attribute__((packed)) {
    uint8_t a;
    uint32_t b;
} BadRegStruct;

// 这样的话,b的地址就是1,不是4字节对齐的
// Zynq的硬件寄存器,大部分都不支持非对齐访问,这样访问b的时候就会出错

你也知道,非对齐访问在 Zynq 里,就算 CPU 能处理,也会慢很多,因为 CPU 要做两次访问,所以你写代码的时候,会注意结构体的对齐,你会把小的成员放在前面,大的成员放在后面,减少填充的空间,节省内存。

到这里,你已经完全搞懂了结构体、联合体、位域,你知道它们的内存布局,你能熟练的用它们来操作硬件,这是熟练的第二个标志。

2.1.3 关键字:吃透 C 语言的修饰符

然后,你已经完全搞懂了 const、static、extern、宏定义这些关键字和预处理的用法,你能熟练的用它们来写代码,你知道它们的作用,不会用错。

<2.1.3.1> const:只读保护,避免误修改

首先,你搞懂了 const,你知道 const 用来修饰只读的变量,你能用它来保护那些不能修改的变量,比如硬件的基地址,是固定的,不能修改,你就用 const 修饰:

c 复制代码
// 熟练的你,用const保护只读的基地址
const uint32_t UART0_BASEADDR = XPAR_XUARTPS_0_BASEADDR;
const uint32_t UART1_BASEADDR = XPAR_XUARTPS_1_BASEADDR;

这样,你就不能不小心修改这些地址了,如果你写UART0_BASEADDR = 0x1234;,编译器就会报错,提醒你这是只读的,避免你写错。你也知道,const 指针的用法,比如const uint8_t *buf,表示 buf 指向的内容是只读的,你不能修改 buf 的内容,这在函数参数里很常用,比如函数只读 buf 的内容,你就用 const 修饰,告诉调用者,这个函数不会修改你的 buf。

<2.1.3.2> static:作用域控制,避免重名

然后,你搞懂了 static,你知道 static 有两个作用:一个是修饰全局变量和函数,把它们的作用域限制在当前文件里,其他文件看不到;另一个是修饰局部变量,让它的生命周期变成整个程序运行期间,不会随着函数返回而释放。

比如,你写 UART 的驱动,里面有一些内部的辅助函数,不想让其他文件看到,你就用 static 修饰:

c 复制代码
// 熟练的你,用static修饰内部的辅助函数
// 这个函数只有uart.c里能用到,其他文件看不到,不会重名
static int uart_calc_baud_divider(uint32_t clock, uint32_t baud) {
    return clock / (16 * baud);
}

// 内部的静态变量,只有本文件能访问
static int uart_inited = 0;

这样,就算其他文件里也有一个叫 uart_calc_baud_divider 的函数,也不会冲突,因为 static 的函数是文件内可见的,这在多文件的项目里非常重要,能避免符号重名的问题。

还有,静态局部变量,比如你要统计 UART 收到的字节数,你就可以用静态局部变量:

c 复制代码
int uart_get_rx_count() {
    static int rx_count = 0;
    // 每次调用,计数加1
    rx_count++;
    return rx_count;
}

这个 rx_count 不会随着函数返回而释放,下次调用的时候还是原来的值,非常方便。

<2.1.3.3> extern:全局变量的声明

然后,你搞懂了 extern,你知道 extern 用来声明全局变量,告诉编译器,这个变量在其他文件里定义了,这里只是引用,比如你在 clock.c 里定义了系统时钟的频率:

c 复制代码
// clock.c里的定义
uint32_t system_clock_freq = 50000000; // 50MHz

然后你在 uart.c 里要用到这个时钟频率,你就用 extern 声明:

c 复制代码
// uart.c里的声明
extern uint32_t system_clock_freq;

// 然后你就能用它来计算波特率了
int divider = system_clock_freq / (16 * baud);

你终于搞懂了,定义和声明的区别,extern 就是用来声明全局变量的,这样你就能在不同的文件里共享全局变量了。

<2.1.3.4> 宏定义:代码复用与条件编译

然后,你搞懂了宏定义,你知道宏定义是预处理的时候替换的,你能用它来定义常量,定义简单的函数,还有条件编译,比如:

c 复制代码
// 定义常量
#define UART_MAX_BUFFER_SIZE 1024

// 定义简单的宏函数,比如读寄存器
#define REG_READ(addr) (*(volatile uint32_t *)(addr))
#define REG_WRITE(addr, val) (*(volatile uint32_t *)(addr) = (val))

// 条件编译,适配不同的Zynq型号
#ifdef XPAR_ZYNQ_7000
// Zynq 7000的代码
#define UART_CLOCK_FREQ 100000000
#elif defined(XPAR_ZYNQ_ULTRASCALE)
// UltraScale的代码
#define UART_CLOCK_FREQ 150000000
#endif

// 防止头文件重复包含的宏
#ifndef __UART_H__
#define __UART_H__

// 头文件的内容

#endif

你终于搞懂了,这些宏的用法,你知道条件编译能让你的代码适配不同的平台,你知道防止头文件重复包含的宏,能避免重复定义的问题,这些都是多文件项目里必须的。

到这里,你已经完全吃透了这些关键字和预处理的用法,你能熟练的用它们来写代码,这是熟练的第三个标志。

2.2 内存通透:搞懂内存的所有细节

熟练的第二个标志,就是你已经完全搞懂了内存的所有细节,你知道栈、堆、全局 / 静态区的区别,你能熟练的用动态内存,你能排查内存的问题,你懂字节对齐、大小端这些内存相关的知识,这些在 Zynq 开发里太重要了,因为 Zynq 的内存是异构的,有 OCM、DDR,还有 DMA、缓存这些东西,内存的问题很容易出 Bug。

2.2.1 区分内存的四个区

首先,你已经完全搞懂了 C 语言的四个内存区:栈、堆、全局 / 静态区、代码区,你知道每个区的位置,知道它们的特点,在 Zynq 里,你也知道这些区在物理内存里的位置:

<2.2.1.1> 全局 / 静态区

全局 / 静态区,用来存全局变量和静态变量,程序启动的时候就初始化了,整个运行期间都存在,不会释放。在 Zynq 里,这些变量默认是存在 DDR 里的,但是你也可以通过修改链接脚本,把它们放到 OCM 里,因为 OCM 是片上内存,访问速度比 DDR 快很多,比如你把中断向量表、高频访问的全局变量放到 OCM 里,能提高性能。

比如,你要把一个全局变量放到 OCM 里,你可以用属性:

c 复制代码
// 把这个全局变量放到OCM的内存里
uint32_t g_irq_count __attribute__((section(".ocm_data"))) = 0;

这样,这个变量就会被链接器放到 OCM 的段里,访问速度更快。

<2.2.1.2> 栈区

栈区,用来存局部变量、函数的参数、返回地址,函数调用的时候分配,函数返回的时候自动释放,栈的大小是固定的,默认的 Zynq 裸机的栈大小是 1MB?不对,默认的 Xilinx BSP 的栈大小,如果你不修改的话,小的项目默认是 1KB?不对,不对,其实默认的裸机栈大小是 0x10000,也就是 64KB,但是如果你写了大的局部变量,就会栈溢出。

比如,你在函数里定义了一个大的局部数组:

c 复制代码
void process_image() {
    // 错误的做法,把4K的图像缓冲区放到栈上
    uint8_t buf[3840*2160]; // 8MB的缓冲区,栈只有64KB,直接栈溢出
    // 处理数据
    ...
}

这个函数一调用,就会栈溢出,程序直接崩溃,因为栈的大小不够,你终于搞懂了,大的变量不能放到栈上,要放到堆上,或者全局区。

你也知道,栈的增长方向是向下的,也就是从高地址往低地址增长,所以如果栈溢出的话,就会覆盖低地址的内存,比如覆盖全局变量,或者函数的返回地址,导致程序跑飞。

<2.2.1.3> 堆区

堆区,用来存动态分配的内存,也就是 malloc 分配的内存,你要自己申请,自己释放,堆的大小是比较大的,Zynq 的堆默认是从 DDR 里分配的,有几百 MB,所以大的缓冲区都要从堆里分配。

比如上面的图像缓冲区,你应该用 malloc 分配:

c 复制代码
void process_image() {
    // 正确的做法,从堆里分配缓冲区
    uint8_t *buf = malloc(3840*2160);
    if(buf == NULL) {
        xil_printf("malloc failed!\r\n");
        return;
    }
    // 处理数据
    ...
    // 用完之后释放
    free(buf);
}

这样就不会栈溢出了,因为堆的大小足够大。

<2.2.1.4> 代码区

代码区,用来存程序的指令,也就是你的编译后的代码,是只读的,你不能修改,在 Zynq 里,代码区默认也是放到 DDR 里的,当然你也可以把高频的代码放到 OCM 里,提高执行速度。

你终于搞懂了这四个区的区别,你知道什么时候用什么区,你不会把大的变量放到栈上,你不会犯这种低级的错误。

2.2.2 熟练使用动态内存,排查内存问题

然后,你已经熟练的使用 malloc 和 free,你知道怎么分配内存,怎么释放内存,你能排查内存泄漏、野指针这些问题,这些在 Zynq 的长时间运行的项目里非常重要,因为如果有内存泄漏,运行几天之后,堆就满了,程序就崩溃了。

<2.2.2.1> malloc 和 free 的正确用法

你知道,malloc 之后要检查返回值,不能直接用,因为如果堆满了,malloc 会返回 NULL,如果你直接用,就会段错误:

c 复制代码
// 熟练的你,会检查malloc的返回值
uint8_t *buf = malloc(size);
if(buf == NULL) {
    xil_printf("malloc failed, out of memory!\r\n");
    return -ENOMEM;
}

你也知道,free 之后要把指针置为 NULL,避免野指针:

c 复制代码
free(buf);
buf = NULL; // 置为NULL,之后如果再用,就能提前发现

你也知道,不能重复 free,不能 free 栈上的指针,不能 free 全局变量的指针,这些错误你都不会犯。

<2.2.2.2> 内存泄漏的排查

你也知道怎么排查内存泄漏,比如你写了一个包装的 malloc 和 free,记录每次分配的内存,定期检查:

c 复制代码
// 内存分配的记录节点
typedef struct MemNode {
    void *ptr;
    size_t size;
    const char *file;
    int line;
    struct MemNode *next;
} MemNode;

MemNode *g_mem_list = NULL;
size_t g_total_alloc = 0;

// 包装的malloc
void *my_malloc(size_t size, const char *file, int line) {
    void *ptr = malloc(size);
    if(ptr == NULL) {
        return NULL;
    }
    // 添加到链表
    MemNode *node = malloc(sizeof(MemNode));
    node->ptr = ptr;
    node->size = size;
    node->file = file;
    node->line = line;
    node->next = g_mem_list;
    g_mem_list = node;
    g_total_alloc += size;
    return ptr;
}

// 包装的free
void my_free(void *ptr) {
    if(ptr == NULL) return;
    // 从链表删除
    MemNode *prev = NULL, *cur = g_mem_list;
    while(cur != NULL) {
        if(cur->ptr == ptr) {
            if(prev == NULL) {
                g_mem_list = cur->next;
            } else {
                prev->next = cur->next;
            }
            g_total_alloc -= cur->size;
            free(cur);
            break;
        }
        prev = cur;
        cur = cur->next;
    }
    free(ptr);
}

// 定义宏,替换默认的malloc和free
#define malloc(size) my_malloc(size, __FILE__, __LINE__)
#define free(ptr) my_free(ptr)

// 然后你可以定期打印内存使用情况
void print_mem_status() {
    xil_printf("Total alloc: %d bytes, current usage: %d bytes\r\n", 
        g_total_alloc, g_total_alloc); // 不对,应该是当前的已分配的,哦,上面的g_total_alloc就是当前的
    // 遍历链表,打印没释放的内存
    MemNode *cur = g_mem_list;
    while(cur != NULL) {
        xil_printf("Leak: %p, size: %d, file: %s, line: %d\r\n",
            cur->ptr, cur->size, cur->file, cur->line);
        cur = cur->next;
    }
}

这样,你就能看到有没有内存泄漏,比如你运行一段时间之后,调用 print_mem_status,发现有一些内存一直没释放,那就是泄漏了,你还能看到是哪个文件哪一行分配的,就能快速定位问题。

<2.2.2.3> 野指针的排查

你也知道怎么排查野指针,野指针就是指向已经释放的内存的指针,访问它就会崩溃,比如你 free 了 buf 之后,没有置 NULL,然后又去写 buf [0],就会访问已经释放的内存,导致崩溃。

你用 GDB 调试的时候,就能看到访问的地址是已经释放的,你也能用一些工具,比如 mtrace,来跟踪内存的分配和释放,找到野指针的位置。

比如,你开了 mtrace 之后,程序运行的时候,会把所有的内存操作记录下来,然后你就能用 mtrace 工具分析,找到哪里访问了已经释放的内存。

2.2.3 懂字节对齐、大小端:解决硬件交互的坑

然后,你懂字节对齐和大小端,你知道这两个东西在 Zynq 和 PL 端交互的时候,是最容易踩的坑,很多人就是因为不懂这个,导致 DMA 传输出错,数据解析出错,排查了好几天才找到问题。

<2.2.3.1> 字节对齐:DMA 的必须要求

字节对齐,就是变量的地址要是它的大小的整数倍,比如 32 位的变量,地址要能被 4 整除,64 位的要能被 8 整除,这个在 Zynq 的 DMA 里,是必须的,因为 DMA 的 burst 传输,要求缓冲区的地址是 64 字节对齐的,不然 DMA 就会出错。

比如,你要做一个 PL 到 PS 的 DMA 传输,你用普通的 malloc 分配了缓冲区:

c 复制代码
// 错误的做法,普通的malloc分配的内存,默认是8字节对齐,不够64字节
uint8_t *dma_buf = malloc(1024*1024);
// 然后启动DMA,传输数据
DMA_Start(dma_buf, 1024*1024);

这个时候,有时候 DMA 能成功,有时候会出错,因为如果 dma_buf 的地址不是 64 字节对齐的,DMA 的 burst 传输就会失败,PL 端读到错误的数据,你排查了很久,不知道为什么,最后才发现是对齐的问题。

熟练的你,就知道,DMA 的缓冲区必须要对齐,所以你要用 posix_memalign 来分配对齐的内存:

c 复制代码
// 正确的做法,分配64字节对齐的内存
uint8_t *dma_buf;
int ret = posix_memalign((void **)&dma_buf, 64, 1024*1024);
if(ret != 0) {
    xil_printf("DMA buffer alloc failed!\r\n");
    return -1;
}
// 然后启动DMA,就没问题了
DMA_Start(dma_buf, 1024*1024);

这样,dma_buf 的地址就是 64 字节对齐的,DMA 就能正常工作了,你不会踩这个坑。

<2.2.3.2> 大小端:异构交互的必须转换

大小端,就是字节的顺序,小端是低字节存在低地址,大端是高字节存在低地址,Zynq 的 PS 端是小端模式,但是 PL 端的 IP 核,很多是大端的,还有网络协议,也是大端的,所以你和它们交互的时候,必须要做大小端转换,不然数据就会解析错。

比如,你给 PL 端的 IP 核传一个 32 位的参数,0x12345678,你直接写进去:

c 复制代码
// 错误的做法,直接写小端的数据给大端的IP
uint32_t param = 0x12345678;
REG_WRITE(IP_BASEADDR + 0x00, param);

PS 端是小端,所以内存里的字节顺序是 0x78,0x56,0x34,0x12,但是 PL 端的 IP 是大端的,它把第一个字节当成最高位,所以它读到的参数就是 0x78563412,完全不对,你以为 IP 核坏了,排查了很久,最后才发现是大小端的问题。

熟练的你,就知道,要做大小端转换,把小端转成大端,再传给 IP:

c 复制代码
// 正确的做法,转换大小端
uint32_t param = 0x12345678;
// 转成大端
uint32_t be_param = htobe32(param);
REG_WRITE(IP_BASEADDR + 0x00, be_param);

这样,IP 核就能正确的解析参数了,你不会踩这个坑。

还有处理网络包的时候,网络字节序是大端的,所以你也要用 ntohl、ntohs 这些函数来转换,比如 IP 头的长度,端口号,都是大端的,你要转成小端才能用。

到这里,你已经完全搞懂了内存的所有细节,你能熟练的管理内存,能排查内存的问题,你能处理对齐和大小端的问题,这是熟练的第三个标志。

2.3 工程能力:能组织大型项目的能力

熟练的第三个标志,就是你有工程能力,你能把多个文件组织成一个项目,你会用 Makefile 来构建项目,你会用 GDB 来调试问题,这些是做项目必须的,不然你写了一堆文件,不知道怎么编译,不知道怎么调试,根本做不了项目。

2.3.1 多文件编程:模块化的基础

首先,你已经会多文件编程,你知道怎么把代码分成不同的文件,模块化,每个文件负责一个功能,比如 gpio.c 负责 GPIO 的驱动,uart.c 负责 UART 的驱动,i2c.c 负责 I2C 的驱动,main.c 负责主逻辑,这样代码清晰,好维护。

你也知道头文件的规范,头文件里只放声明,不放实现,比如 gpio.h 里放 GPIO 的函数声明、结构体的定义,不放函数的实现,实现放在 gpio.c 里,这样其他文件包含头文件,就能用这些函数了。

你也知道怎么防止头文件重复包含,你会用 #ifndef 的守卫,或者 #pragma once:

c 复制代码
#ifndef __GPIO_H__
#define __GPIO_H__

// 头文件的内容,声明、定义

#endif

这样,就算多个文件包含这个头文件,也只会展开一次,不会重复定义,不会编译出错。

你也知道,全局变量不要放在头文件里,不然多个文件包含的话,就会重复定义,你要放在.c 里,然后头文件里用 extern 声明,这样就不会重复了。

这些都是多文件编程的基础,熟练的你,已经完全掌握了,你能把一个大的项目,分成多个模块,每个模块独立的文件,好维护,好复用。

2.3.2 Makefile:自动化构建项目

然后,你会用 Makefile 来组织项目,你知道怎么写 Makefile,自动编译你的代码,不用每次都手动敲 gcc 命令,比如你写一个 Zynq 的交叉编译的 Makefile:

makefile 复制代码
# 交叉编译器,Zynq用的arm的交叉编译器
CC := arm-linux-gnueabihf-gcc
# 编译选项,Wall打开所有警告,g加调试信息,I指定头文件路径
CFLAGS := -Wall -g -I./include
# 链接选项
LDFLAGS := -lm

# 源文件
SRC := main.c src/gpio.c src/uart.c src/i2c.c
# 目标文件,把.c换成.o
OBJ := $(SRC:.c=.o)
# 可执行文件的名字
TARGET := zynq_app

# 默认的目标,编译所有
all: $(TARGET)

# 链接目标文件,生成可执行文件
$(TARGET): $(OBJ)
	$(CC) -o $@ $^ $(LDFLAGS)

# 编译每个源文件,生成目标文件
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# 清理编译生成的文件
clean:
	rm -f $(OBJ) $(TARGET)

# 自动依赖,让Makefile自动处理头文件的依赖
-include $(SRC:.c=.d)
%.d: %.c
	$(CC) -M $(CFLAGS) $< > $@

这个 Makefile,就能自动编译你的所有源文件,你只要敲 make,就能编译整个项目,修改了一个文件,再敲 make,只会重新编译修改的那个文件,不用全部编译,节省时间。

你也知道怎么加自动依赖,就是上面的 %.d 的部分,自动生成每个源文件的头文件依赖,这样如果你修改了头文件,Makefile 会自动重新编译对应的源文件,不用你手动处理。

熟练的你,会写这样的 Makefile,你能组织你的项目,自动化构建,不用手动敲命令,这是工程能力的基础。

2.3.3 GDB 调试:定位问题的利器

然后,你会用 GDB 来调试程序,你能定位崩溃、段错误这些问题,这在嵌入式开发里太重要了,因为程序崩溃了,你不能像 PC 上一样直接看,你要远程调试,你要分析 core 文件。

<2.3.3.1> 远程 GDB 调试

Zynq 的 Linux 里,你可以用 gdbserver 来做远程调试,比如你在开发板上运行 gdbserver,然后主机上的 gdb 连接它,就能远程调试:

bash 复制代码
# 开发板上,启动gdbserver,端口1234,运行你的程序
root@zynq:~# gdbserver :1234 ./zynq_app
Process zynq_app created; pid = 1234
Listening on port 1234

然后主机上,用交叉编译的 gdb,连接开发板:

bash 复制代码
# 主机上,启动gdb
user@host:~# arm-linux-gnueabihf-gdb ./zynq_app
# 连接开发板的地址和端口
(gdb) target remote 192.168.1.10:1234
Remote debugging using 192.168.1.10:1234
...

然后你就能像本地调试一样,设置断点,查看变量,单步执行,比如:

bash 复制代码
# 设置断点,在main函数
(gdb) b main
Breakpoint 1 at 0x10440: file main.c, line 10.
# 运行程序
(gdb) c
Continuing.

Breakpoint 1, main () at main.c:10
10	int main() {
# 单步执行
(gdb) n
12	    xil_printf("Init...\r\n");
# 查看变量
(gdb) p uart_base
$1 = 0xe0000000

这样,你就能调试远程的程序,找到问题的位置。

<2.3.3.2> 段错误的定位

如果程序崩溃了,段错误,你可以开 core dump,生成 core 文件,然后用 gdb 分析,比如:

bash 复制代码
# 开发板上,开core dump, unlimited表示不限制core文件的大小
root@zynq:~# ulimit -c unlimited
# 设置core文件的路径
root@zynq:~# echo /tmp/core-%e-%p > /proc/sys/kernel/core_pattern

然后你运行程序,崩溃了,就会生成 core 文件,比如 core-zynq_app-1234,然后你把这个 core 文件拿到主机上,用 gdb 分析:

bash 复制代码
user@host:~# arm-linux-gnueabihf-gdb ./zynq_app /tmp/core-zynq_app-1234

然后你用 bt 命令,看栈回溯,就能看到崩溃的位置:

bash 复制代码
(gdb) bt
#0  0x0001058c in strcpy () at string.c:42
#1  0x00010610 in UartProcessRxData (uart=0x12345678, buf=0x0, len=1024) at uart.c:123
#2  0x00010700 in main () at main.c:45

哦,原来在 UartProcessRxData 里,buf 是 NULL,然后调用 strcpy,就段错误了,你一下子就找到问题了,不用加一堆打印,不用猜,直接定位。

熟练的你,会用这些调试的手段,你遇到崩溃,不会慌,你会用 GDB,用 core dump,快速定位问题,这是工程能力的核心。

2.4 实战水平:能独立做项目的能力

熟练的第四个标志,就是你有实战能力,你能独立写小项目,你能写数据结构,你能写驱动,你的代码规范,健壮,能处理边界情况,你能独立把一个需求落地。

2.4.1 能写常用的数据结构

首先,你能写常用的数据结构,比如链表、栈、队列,这些在嵌入式开发里太常用了,比如任务队列、环形缓冲区,都是用这些数据结构实现的。

<2.4.1.1> 链表:任务管理

比如,你要做一个任务管理的模块,用链表来管理任务,每个任务是一个节点,你就能写链表:

c 复制代码
// 任务节点的结构体
typedef struct TaskNode {
    // 任务的函数
    void (*task_func)(void *arg);
    // 任务的参数
    void *arg;
    // 下一个节点
    struct TaskNode *next;
} TaskNode;

// 链表的头
TaskNode *g_task_list = NULL;

// 添加任务到链表
int task_add(void (*func)(void *), void *arg) {
    TaskNode *node = malloc(sizeof(TaskNode));
    if(node == NULL) {
        return -ENOMEM;
    }
    node->task_func = func;
    node->arg = arg;
    node->next = NULL;

    // 加到链表尾部
    if(g_task_list == NULL) {
        g_task_list = node;
    } else {
        TaskNode *cur = g_task_list;
        while(cur->next != NULL) {
            cur = cur->next;
        }
        cur->next = node;
    }
    return 0;
}

// 执行所有任务
void task_process() {
    TaskNode *cur = g_task_list;
    while(cur != NULL) {
        // 执行任务
        cur->task_func(cur->arg);
        cur = cur->next;
    }
    // 清空链表
    // ... 释放节点
    g_task_list = NULL;
}

这样,你就能用链表来管理任务,添加任务,执行任务,非常方便。

<2.4.1.2> 环形队列:串口接收缓冲区

然后,你能写环形队列,也就是环形缓冲区,用来做 UART 的接收缓冲区,因为 UART 的接收是中断驱动的,中断里收到数据,要存起来,应用层再读,用环形队列就能解决速度不匹配的问题,不会丢数据:

c 复制代码
// 环形缓冲区的结构体
typedef struct {
    uint8_t *buf;
    uint32_t head; // 写的位置
    uint32_t tail; // 读的位置
    uint32_t size; // 缓冲区的大小
} RingBuffer;

// 初始化缓冲区
int ring_buffer_init(RingBuffer *rb, size_t size) {
    rb->buf = malloc(size);
    if(rb->buf == NULL) {
        return -ENOMEM;
    }
    rb->head = 0;
    rb->tail = 0;
    rb->size = size;
    return 0;
}

// 写数据到缓冲区
int ring_buffer_write(RingBuffer *rb, uint8_t data) {
    // 检查缓冲区是不是满了
    if((rb->head + 1) % rb->size == rb->tail) {
        // 满了,返回错误
        return -EAGAIN;
    }
    rb->buf[rb->head] = data;
    rb->head = (rb->head + 1) % rb->size;
    return 0;
}

// 从缓冲区读数据
int ring_buffer_read(RingBuffer *rb, uint8_t *data) {
    // 检查缓冲区是不是空的
    if(rb->head == rb->tail) {
        // 空的,返回错误
        return -EAGAIN;
    }
    *data = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return 0;
}

然后,UART 的中断里,收到数据,就写到环形缓冲区里:

c 复制代码
// UART的中断服务函数
void Uart_InterruptHandler() {
    // 读收到的数据
    uint8_t data = Xil_In32(UART0_BASEADDR + UART_FIFO);
    // 写到环形缓冲区
    ring_buffer_write(&g_uart_rb, data);
}

应用层要读数据的时候,就从环形缓冲区里读:

c 复制代码
// 应用层读UART数据
int uart_read(uint8_t *buf, size_t len) {
    int read_len = 0;
    for(int i = 0; i < len; i++) {
        if(ring_buffer_read(&g_uart_rb, &buf[i]) == 0) {
            read_len++;
        } else {
            break;
        }
    }
    return read_len;
}

这样,就算 UART 一下子收到很多数据,中断里能快速存到缓冲区里,不会丢数据,应用层慢慢读就行,完美解决了中断和应用层的速度不匹配的问题。

2.4.2 能写简单的驱动和驱动框架

然后,你能写简单的驱动,比如 UART、I2C 的裸机驱动,你能写简单的驱动框架,把驱动抽象出来,方便复用。

<2.4.2.1> UART 驱动的例子

比如,你能自己写 Zynq 的 PS 端的 UART 驱动,不用 Xilinx 的库,你自己实现初始化、发送、接收:

c 复制代码
// UART的初始化函数
int UartInit(UartDev *dev, uint32_t base_addr, uint32_t baud_rate, uint32_t clock_freq) {
    dev->base_addr = base_addr;

    // 复位UART
    REG_WRITE(base_addr + UART_CR, UART_CR_TX_RX_RESET);
    usleep(1000);

    // 计算波特率的分频系数
    uint32_t divider = clock_freq / (16 * baud_rate);
    REG_WRITE(base_addr + UART_BRGR, divider);

    // 设置帧格式:8位数据,1位停止位,无校验
    REG_WRITE(base_addr + UART_LCR, 0x03);

    // 使能发送和接收
    REG_WRITE(base_addr + UART_CR, UART_CR_TX_EN | UART_CR_RX_EN);

    // 初始化环形缓冲区
    ring_buffer_init(&dev->rx_rb, 1024);

    // 注册中断
    XScuGic_Connect(&g_gic, UART0_INT_ID, Uart_InterruptHandler, dev);
    // 开接收中断
    REG_WRITE(base_addr + UART_IER, UART_IER_RX_INT);

    dev->inited = 1;
    return 0;
}

// UART的发送函数
int UartSend(UartDev *dev, uint8_t *buf, size_t len) {
    if(dev == NULL || buf == NULL || len == 0) {
        return -EINVAL;
    }
    for(int i = 0; i < len; i++) {
        // 等待发送FIFO空
        while((REG_READ(dev->base_addr + UART_SR) & UART_SR_TX_FULL) != 0);
        // 写数据到FIFO
        REG_WRITE(dev->base_addr + UART_FIFO, buf[i]);
    }
    return len;
}

// UART的接收函数
int UartRecv(UartDev *dev, uint8_t *buf, size_t len) {
    if(dev == NULL || buf == NULL || len == 0) {
        return -EINVAL;
    }
    int read_len = 0;
    for(int i = 0; i < len; i++) {
        if(ring_buffer_read(&dev->rx_rb, &buf[i]) == 0) {
            read_len++;
        } else {
            break;
        }
    }
    return read_len;
}

你看,你自己就能写 UART 的驱动,初始化,发送,接收,中断,环形缓冲区,这些你都能自己实现,不用依赖 Xilinx 的库,你懂 UART 的原理,懂 Zynq 的寄存器,你能自己写出来。

<2.4.2.2> I2C 驱动的例子

同样,你也能自己写 I2C 的驱动,初始化,发送起始信号,发送地址,发送数据,接收数据:

c 复制代码
// I2C的初始化
int I2cInit(I2cDev *dev, uint32_t base_addr, uint32_t clock_freq) {
    dev->base_addr = base_addr;
    // 初始化I2C控制器
    // 设置时钟
    uint32_t divider = clock_freq / 100000; // 100KHz的I2C时钟
    REG_WRITE(base_addr + I2C_CLK_DIV, divider);
    // 使能I2C
    REG_WRITE(base_addr + I2C_CTRL, I2C_CTRL_EN);
    dev->inited = 1;
    return 0;
}

// 发送起始信号
void I2cStart(I2cDev *dev) {
    REG_WRITE(dev->base_addr + I2C_CTRL, I2C_CTRL_EN | I2C_CTRL_START);
    while((REG_READ(dev->base_addr + I2C_STATUS) & I2C_STATUS_BUSY) != 0);
}

// 发送停止信号
void I2cStop(I2cDev *dev) {
    REG_WRITE(dev->base_addr + I2C_CTRL, I2C_CTRL_EN | I2C_CTRL_STOP);
    while((REG_READ(dev->base_addr + I2C_STATUS) & I2C_STATUS_BUSY) != 0);
}

// 发送一个字节
int I2cWriteByte(I2cDev *dev, uint8_t data) {
    REG_WRITE(dev->base_addr + I2C_FIFO, data);
    while((REG_READ(dev->base_addr + I2C_STATUS) & I2C_STATUS_TIP) != 0);
    // 检查ACK
    if(REG_READ(dev->base_addr + I2C_STATUS) & I2C_STATUS_RX_ACK) {
        return -1; // NACK,出错了
    }
    return 0;
}

// 读一个字节
uint8_t I2cReadByte(I2cDev *dev, int ack) {
    REG_WRITE(dev->base_addr + I2C_CTRL, I2C_CTRL_EN | (ack ? 0 : I2C_CTRL_NACK));
    while((REG_READ(dev->base_addr + I2C_STATUS) & I2C_STATUS_TIP) != 0);
    return REG_READ(dev->base_addr + I2C_FIFO);
}

// 写I2C设备的寄存器
int I2cWriteReg(I2cDev *dev, uint8_t dev_addr, uint8_t reg_addr, uint8_t data) {
    I2cStart(dev);
    I2cWriteByte(dev, dev_addr << 1); // 写地址,最低位是0表示写
    I2cWriteByte(dev, reg_addr);
    I2cWriteByte(dev, data);
    I2cStop(dev);
    return 0;
}

// 读I2C设备的寄存器
int I2cReadReg(I2cDev *dev, uint8_t dev_addr, uint8_t reg_addr, uint8_t *data) {
    I2cStart(dev);
    I2cWriteByte(dev, dev_addr << 1); // 写地址
    I2cWriteByte(dev, reg_addr);
    I2cStart(dev); // 重复起始
    I2cWriteByte(dev, (dev_addr << 1) | 0x01); // 读地址,最低位是1表示读
    *data = I2cReadByte(dev, 0); // 读字节,NACK
    I2cStop(dev);
    return 0;
}

你看,你自己就能写 I2C 的驱动,你懂 I2C 的协议,懂 Zynq 的 I2C 控制器的寄存器,你能自己实现读写,不用依赖库。

<2.4.2.3> 简单的驱动框架

然后,你能写简单的驱动框架,把不同的驱动抽象出统一的接口,比如字符设备的接口,有 open、read、write、close,这样应用层就能用统一的接口来操作不同的设备:

c 复制代码
// 字符设备的操作函数的结构体
typedef struct {
    int (*open)(void *dev);
    int (*read)(void *dev, uint8_t *buf, size_t len);
    int (*write)(void *dev, uint8_t *buf, size_t len);
    int (*close)(void *dev);
} CharDevOps;

// 字符设备的结构体
typedef struct {
    CharDevOps *ops;
    void *private_data;
} CharDev;

// 统一的open函数
int char_dev_open(CharDev *dev) {
    if(dev->ops->open != NULL) {
        return dev->ops->open(dev->private_data);
    }
    return 0;
}

// 统一的read函数
int char_dev_read(CharDev *dev, uint8_t *buf, size_t len) {
    if(dev->ops->read != NULL) {
        return dev->ops->read(dev->private_data, buf, len);
    }
    return -EINVAL;
}

// 统一的write函数
int char_dev_write(CharDev *dev, uint8_t *buf, size_t len) {
    if(dev->ops->write != NULL) {
        return dev->ops->write(dev->private_data, buf, len);
    }
    return -EINVAL;
}

然后,GPIO 的驱动,实现自己的 ops:

c 复制代码
// GPIO的操作函数
int gpio_open(void *dev) {
    return GpioInit((GpioDev *)dev);
}
int gpio_read(void *dev, uint8_t *buf, size_t len) {
    return GpioRead((GpioDev *)dev, buf, len);
}
int gpio_write(void *dev, uint8_t *buf, size_t len) {
    return GpioWrite((GpioDev *)dev, buf, len);
}

CharDevOps gpio_ops = {
    .open = gpio_open,
    .read = gpio_read,
    .write = gpio_write,
};

UART 的驱动,也实现自己的 ops:

c 复制代码
// UART的操作函数
int uart_open(void *dev) {
    return UartInit((UartDev *)dev);
}
int uart_read(void *dev, uint8_t *buf, size_t len) {
    return UartRecv((UartDev *)dev, buf, len);
}
int uart_write(void *dev, uint8_t *buf, size_t len) {
    return UartSend((UartDev *)dev, buf, len);
}

CharDevOps uart_ops = {
    .open = uart_open,
    .read = uart_read,
    .write = uart_write,
};

然后应用层,不管是 GPIO 还是 UART,都用统一的接口:

c 复制代码
// 操作GPIO
CharDev gpio_dev = {.ops = &gpio_ops, .private_data = &g_gpio};
char_dev_open(&gpio_dev);
char_dev_write(&gpio_dev, buf, len);

// 操作UART
CharDev uart_dev = {.ops = &uart_ops, .private_data = &g_uart};
char_dev_open(&uart_dev);
char_dev_write(&uart_dev, buf, len);

这样,应用层的代码不用管具体是什么设备,只要用统一的接口就行,你加新的设备,只要实现自己的 ops 就行,不用改应用层的代码,这就是简单的驱动框架,非常方便复用。

2.4.3 代码规范、健壮,处理边界情况

然后,你的代码很规范,很健壮,你能处理各种边界情况,不会因为输入不对就崩溃,比如:

  • 你会检查函数的参数,比如指针是不是 NULL,长度是不是 0,比如:
c 复制代码
int UartSend(UartDev *dev, uint8_t *buf, size_t len) {
    // 检查参数
    if(dev == NULL) return -EINVAL;
    if(buf == NULL) return -EINVAL;
    if(len == 0) return 0;
    // 然后才是处理逻辑
    ...
}
  • 你会检查 malloc 的返回值,不会直接用,避免空指针。

  • 你会处理缓冲区满的情况,比如环形缓冲区满了,不会溢出,会返回错误,或者覆盖旧的数据。

  • 你会处理中断的边界情况,比如中断里不会做太多的处理,只会把数据存到缓冲区,快速返回。

  • 你的变量名、函数名都很清晰,见名知意,比如 UartInit,一看就知道是初始化 UART 的,不会用 a、b、c 这种模糊的名字。

  • 你会加注释,关键的地方加注释,别人看你的代码能看懂,不用猜。

这些都是健壮的代码的特征,你的代码不会出低级的 Bug,比如空指针解引用,缓冲区溢出,这些错误你都不会犯,你能处理各种边界情况,你的代码很稳定。

2.5 熟练阶段的总结

总结一下,熟练阶段的你,已经:

  • 吃透了 C 语言的所有核心语法,指针、结构体、关键字,你都懂,都能熟练用。

  • 搞懂了内存的所有细节,栈、堆、对齐、大小端,你都懂,能排查内存问题。

  • 有工程能力,能组织多文件项目,会用 Makefile,会用 GDB 调试。

  • 有实战能力,能写数据结构,能写驱动,能独立做小项目,代码健壮。

简单来说,就是能独立干活、不出低级 Bug,给你一个 Zynq 的小项目,比如串口转 I2C 的工具,比如简单的图像采集的工具,你能独立完成,从写驱动,到写应用,到调试,你都能搞定,不会出那种指针写错、内存泄漏、栈溢出的低级 Bug,你能把项目做出来,能稳定运行。

这个阶段,是大部分嵌入式开发者的能力,也是企业最需要的,所以你简历上写「熟练:C 语言,能独立完成 Zynq PS 端的驱动开发与应用开发」,是非常稳妥的,面试官问你,你都能答上来,不会露馅。


三、简历写「精通」:慎写!高手才配的能力

「精通」是 C 语言能力的最高等级,也是最不能随便写的,很多人以为自己会写代码就是精通了,其实不是,精通代表你已经吃透了 C 语言的底层,你能做性能优化,能设计架构,能解决别人搞不定的疑难问题,你是团队里的技术专家,遇到搞不定的问题,都来找你。

这个阶段的能力,只有少数的资深开发者能达到,如果你没有这个能力,千万别写精通,不然面试官会往死里问你,问你编译链接的细节,问你汇编,问你性能优化,问你疑难问题的排查,你答不上来,直接就凉了。

3.1 吃透底层:从 C 代码到机器码的全链路

精通的第一个标志,就是你已经吃透了底层,你知道你的 C 代码是怎么变成可执行文件的,你能看懂汇编,你知道系统的底层机制,你不是只会写 C 代码,你知道代码背后的一切。

3.1.1 理解编译、汇编、链接的全过程

首先,你已经完全理解了编译的四个步骤:预处理、编译、汇编、链接,你知道每个步骤做了什么,你知道你的 C 代码是怎么一步步变成 Zynq 上能运行的程序的。

<3.1.1.1> 预处理:预处理的展开

第一步是预处理,预处理会处理所有的 #开头的指令,比如:

  • 展开 #include,把包含的头文件的内容,插入到当前文件里。

  • 展开宏定义,把宏替换成对应的内容。

  • 处理条件编译,比如 #ifdef、#if,把不需要的代码删掉。

  • 删除注释,把所有的注释都删掉。

  • 处理行号,把 *\LINE\*、_*FILE*_这些宏替换成对应的行号和文件名。

比如,你写的:

c 复制代码
#define ADD(a, b) ((a)+(b))
int main() {
    int a = 1, b = 2;
    int c = ADD(a, b);
    return 0;
}

预处理之后,就变成了:

c 复制代码
int main() {
    int a = 1, b = 2;
    int c = ((a)+(b));
    return 0;
}

宏被展开了,注释被删掉了,这就是预处理做的事情。

<3.1.1.2> 编译:C 到汇编

第二步是编译,编译器把预处理后的 C 代码,编译成汇编代码,也就是 ARM 的汇编,这个步骤会做语法检查,语义检查,然后把 C 代码翻译成汇编,同时会做优化,比如把没用的代码删掉,把常量折叠。

比如上面的代码,编译之后的汇编是:

asm 复制代码
main:
    push    {lr}
    mov     r0, #0
    sub     sp, sp, #16
    str     r0, [sp, #12]
    mov     r3, #1
    str     r3, [sp, #8]
    mov     r3, #2
    str     r3, [sp, #4]
    ldr     r2, [sp, #8]
    ldr     r3, [sp, #4]
    add     r3, r2, r3
    str     r3, [sp, #12]
    ldr     r0, [sp, #12]
    add     sp, sp, #16
    pop     pc

你看,C 代码变成了 ARM 的汇编指令,这就是编译做的事情。

<3.1.1.3> 汇编:汇编到目标文件

第三步是汇编,汇编器把汇编代码,汇编成目标文件,也就是.o 文件,这个文件是二进制的,里面是机器码,也就是 CPU 能执行的指令,还有符号表,重定位信息,因为这时候还没链接,所以有些符号的地址还没确定,要留到链接的时候处理。

比如上面的代码,汇编之后,就生成了 main.o,里面是二进制的机器码,还有 main 这个符号,还有其他的符号。

<3.1.1.4> 链接:把所有文件拼起来

第四步是链接,链接器把所有的目标文件,还有库文件,拼在一起,根据链接脚本,把各个段放到对应的内存地址,然后重定位符号,把那些没确定的地址,填成正确的地址,最后生成可执行文件。

比如,你在 main.c 里调用了 uart.c 里的 UartInit 函数,编译的时候,UartInit 的地址还不知道,所以汇编出来的代码里,调用 UartInit 的地方,是一个占位的地址,链接的时候,链接器找到 UartInit 的地址,然后把这个占位的地址替换成正确的地址,这样调用的时候,就能跳对地方了。

你也懂链接脚本,你知道链接脚本是用来定义内存区域,定义段的位置的,比如 Zynq 的裸机的链接脚本:

ld 复制代码
/* 定义内存区域,OCM是片上内存,DDR是外部内存 */
MEMORY {
    OCM : ORIGIN = 0x00000000, LENGTH = 256K
    DDR : ORIGIN = 0x00100000, LENGTH = 512M
}

/* 定义段的位置 */
SECTIONS {
    /* 代码段,放到DDR里 */
    .text : {
        *(.text)
        *(.text.*)
    } > DDR

    /* 只读数据段,放到DDR里 */
    .rodata : {
        *(.rodata)
        *(.rodata.*)
    } > DDR

    /* 数据段,初始化的全局变量,放到DDR里 */
    .data : {
        *(.data)
        *(.data.*)
    } > DDR

    /* BSS段,未初始化的全局变量,放到DDR里 */
    .bss : {
        *(.bss)
        *(.bss.*)
    } > DDR

    /* OCM的段,把高频的代码和数据放到OCM里 */
    .ocm_text : {
        *(.ocm_text)
    } > OCM

    .ocm_data : {
        *(.ocm_data)
    } > OCM

    /* 堆和栈的位置 */
    _heap_start = .;
    _heap_end = 0x1FFFFFFF;
    _stack_start = _heap_end;
}

你能看懂这个链接脚本,你知道怎么修改它,比如你要把中断向量表放到 OCM 的开头,你就加一个段:

ld 复制代码
.vectors : {
    *(.vectors)
} > OCM AT> OCM

然后你把中断向量表放到这个段里:

c 复制代码
__attribute__((section(".vectors")))
uint32_t exception_vectors[8] = {
    0x00000000,
    0x00000004,
    // ...
};

这样,中断向量表就放到 OCM 的开头了,CPU 启动的时候就能正确加载,你也能把高频的中断处理函数放到 OCM 里,提高执行速度,因为 OCM 的访问速度比 DDR 快很多。

你也懂重定位,你懂符号解析,你懂静态链接和动态链接的区别,你知道怎么写链接脚本,怎么调整内存的布局,这些都是精通的人才能懂的,普通的熟练的人,根本不懂这些,他们只会用默认的链接脚本,不知道怎么改。

3.1.2 看得懂汇编,知道 C 代码怎么转指令

然后,你能看懂 ARM 的汇编,你知道你的 C 代码编译之后是什么样的指令,你能通过汇编来分析代码的性能,分析 Bug,比如你写的一个函数,你想知道它的效率怎么样,你就看它的汇编,有没有多余的指令,有没有可以优化的地方。

比如,你写的一个简单的加法函数:

c 复制代码
int add(int a, int b) {
    return a + b;
}

你知道它编译之后的汇编是:

asm 复制代码
add:
    add     r0, r0, r1
    bx      lr

因为 ARM 的函数调用,前两个参数是用 r0 和 r1 寄存器传递的,返回值也是用 r0,所以 a 在 r0,b 在 r1,加起来放到 r0,然后返回,非常简单,没有多余的指令。

再比如,你写的一个函数,有局部变量:

c 复制代码
int func(int a) {
    int b = 2;
    return a + b;
}

编译之后的汇编是:

asm 复制代码
func:
    sub     sp, sp, #8
    str     r0, [sp, #4]
    mov     r3, #2
    str     r3, [sp, #0]
    ldr     r2, [sp, #4]
    ldr     r3, [sp, #0]
    add     r0, r2, r3
    add     sp, sp, #8
    bx      lr

你能看懂,首先,栈指针减 8,分配栈空间,然后把 a 存到 sp+4,把 b 存到 sp+0,然后读出来,加起来,然后栈指针加 8,恢复栈,然后返回,你知道局部变量是存在栈上的,你知道栈帧是怎么建立的。

你也能看懂函数调用的栈帧,你知道调用函数的时候,会把返回地址存到栈上,然后跳转到函数,函数返回的时候,把返回地址弹到 PC,就回到原来的地方了。

你也能看懂指针操作的汇编,比如*ptr = val;,对应的汇编是str r1, [r0],就是把 r1 的值,写到 r0 指向的地址,你能看懂,你知道每一条汇编指令是干嘛的。

比如,你遇到一个栈溢出的问题,你看汇编,发现函数里定义了一个大的局部数组,导致栈指针减了很多,超过了栈的大小,你就能定位到问题。

比如,你遇到一个性能问题,你看汇编,发现你的代码有很多多余的加载存储指令,你就能优化,把变量放到寄存器里,减少内存访问,提高速度。

这些都是精通的人才能做的,普通的熟练的人,根本看不懂汇编,他们只会写 C 代码,不知道 C 代码背后的指令是什么样的。

3.1.3 理解系统调用、进程 / 线程、内存管理机制

然后,你理解系统的底层机制,你知道 Zynq 的 Linux 里的系统调用是怎么回事,你知道进程和线程的区别,你知道内存管理的机制,你不是只会用这些 API,你知道它们背后的原理。

<3.1.3.1> 系统调用:用户态到内核态的切换

你知道,系统调用是用户态的程序,请求内核做事情的接口,比如你调用 open、read、write 这些函数,其实不是普通的函数调用,是触发了一个软中断,SWI 指令,从用户态陷入内核态,然后内核根据系统调用号,找到对应的内核函数,执行,然后把结果返回给用户态,再回到用户态。

比如,你调用 open 函数,本质上是:

asm 复制代码
mov     r7, #5  // 系统调用号,open是5
swi     #0      // 触发软中断,陷入内核

然后内核处理这个中断,执行 sys_open 函数,打开文件,然后返回结果,你知道这个过程,你知道系统调用的开销,因为要切换态,所以系统调用是比较慢的,所以你要尽量减少系统调用的次数,比如用缓冲区,一次读很多数据,不要一次读一个字节。

<3.1.3.2> 进程和线程:资源和调度

你知道,进程是资源分配的单位,每个进程有自己的虚拟地址空间,自己的文件描述符,自己的资源,线程是调度的单位,同一个进程的线程,共享同一个地址空间,共享资源,调度的时候,切换线程比切换进程快很多,因为不用切换地址空间。

你也知道 Zynq 的多核,比如 Zynq 7000 有两个 Cortex-A9 的核,Zynq UltraScale 有四个 Cortex-A53 的核,你知道多核的调度,你知道怎么把线程绑定到特定的核,提高性能,比如把中断处理线程绑定到核 0,把业务线程绑定到核 1,避免互相干扰。

<3.1.3.3> 内存管理:虚拟内存和 MMU

你知道,Zynq 的 MMU,内存管理单元,它把虚拟地址转换成物理地址,每个进程都有自己的页表,所以每个进程都以为自己有整个 4G 的地址空间,用户态的地址是 0-3G,内核态的是 3G-4G,每个进程的用户地址是隔离的,所以一个进程崩溃了,不会影响其他进程。

你也知道,MMU 的缓存,TLB,就是 Translation Lookaside Buffer,用来缓存虚拟地址到物理地址的映射,这样不用每次都查页表,提高访问速度,你也知道大页,大页能减少 TLB 的缺失,提高内存访问的速度,比如你用大页来做 DMA 的缓冲区,性能会好很多。

你也知道用户态和内核态的区别,用户态的程序不能访问内核的地址,不能执行特权指令,不然会触发段错误,你知道为什么,因为 MMU 和 CPU 的权限位,保护内核的内存不被用户态的程序访问。

这些都是系统的底层机制,精通的人都懂,普通的熟练的人,只会用 fork、pthread 这些 API,不知道背后的原理。

3.2 优化与架构:从功能到性能的提升

精通的第二个标志,就是你能做性能优化,你能设计高可用、高可移植的架构,你能用 C 实现面向对象,写出可复用的代码,你不是只会实现功能,你能把功能做的更好,更快,更稳定,更可复用。

3.2.1 性能优化:速度、内存、栈、中断安全

首先,你能做性能优化,你能找到系统的瓶颈,然后优化它,提高速度,节省内存,保证中断安全,这些在 Zynq 的高性能应用里,比如图像处理、高速数据采集,是非常重要的。

<3.2.1.1> 速度优化:NEON、DMA、算法优化

你能做速度的优化,比如你要做一个图像的灰度化处理,原来的 C 代码,处理 1080P 的图像要 10ms,太慢了,你怎么优化?

首先,你用 NEON 指令,NEON 是 ARM 的 SIMD 指令,能一次处理多个数据,比如一次处理 8 个像素,这样速度就能提升好几倍:

c 复制代码
void rgb2gray_neon(uint8_t *rgb, uint8_t *gray, int width, int height) {
    for(int y = 0; y < height; y++) {
        for(int x = 0; x < width; x += 8) {
            // 一次加载8个像素的R、G、B
            uint8x8x3_t rgb_pix = vld3_u8(rgb + (y*width + x)*3);
            // 计算灰度:r*0.299 + g*0.587 + b*0.114
            uint16x8_t r = vmull_u8(rgb_pix.val[0], vdup_n_u8(77));
            uint16x8_t g = vmull_u8(rgb_pix.val[1], vdup_n_u8(150));
            uint16x8_t b = vmull_u8(rgb_pix.val[2], vdup_n_u8(29));
            uint16x8_t sum = vadd_u16(vadd_u16(r, g), b);
            // 右移8位,得到结果
            uint8x8_t res = vshrn_n_u16(sum, 8);
            // 存储结果
            vst1_u8(gray + y*width + x, res);
        }
    }
}

这个 NEON 的代码,一次处理 8 个像素,速度比原来的 C 代码快了 4 倍,处理 1080P 的图像只要 2ms,快了很多。

然后,你用 DMA,把数据从 PL 端搬到 PS 端,不用 CPU 来搬,节省 CPU 的时间,比如 PL 端的摄像头采集到的数据,通过 DMA 直接搬到 PS 端的内存,CPU 不用管,DMA 自己搬,搬完了发中断,CPU 直接处理数据,这样 CPU 就不用花时间拷贝数据了,节省了很多时间。

你也能做算法的优化,比如原来的算法是 O (n²) 的,你优化成 O (n) 的,或者用更高效的算法,比如原来的排序用冒泡,你改成快排,速度提升很多。

<3.2.1.2> 内存优化:节省内存,提高利用率

你能做内存的优化,比如嵌入式的内存有限,你要尽量节省内存,比如你用 - Os 的编译选项,优化代码的大小,把没用的函数去掉,把代码压缩,比如你用 LZ4 压缩不常用的代码,用到的时候再解压,节省内存。

你也能把不常用的数据放到闪存里,用到的时候再加载到内存,不用一直占着内存,你也能做内存的复用,比如同一个缓冲区,用完了之后,给其他模块用,不用重新分配,节省内存的开销。

你也能优化栈的大小,比如你把大的局部变量改成动态分配,或者全局的,避免栈溢出,你也能把函数的调用层次改浅,减少栈的使用,比如把递归改成循环,因为递归的栈开销很大,很容易栈溢出。

<3.2.1.3> 中断安全:中断里的规则

你懂中断安全,你知道中断里不能做什么,比如:

  • 中断里不能调用可能会阻塞的函数,比如 malloc,因为 malloc 会加锁,如果中断发生的时候,锁已经被任务持有的,中断里再去拿锁,就会死锁。

  • 中断里不能调用可能会调度的函数,比如信号量的 post,虽然可以,但是要注意,中断里的调度是在中断返回之后的。

  • 中断里不能定义大的局部变量,因为中断的栈很小,大的变量会栈溢出。

  • 中断里尽量不要用 printf,因为 printf 可能会加锁,会阻塞,你要用轮询的 xil_printf,这个是中断安全的。

你也知道,中断里的代码要尽量快,不要做太多的处理,不然会关中断太久,导致其他中断丢失,比如 UART 的接收中断,你只要把数据存到环形缓冲区,就返回,不要处理数据,处理数据交给任务来做,这样中断的时间就很短,不会影响其他中断。

3.2.2 设计高可用、高可移植的嵌入式架构

然后,你能设计高可用、高可移植的架构,你的代码,能在不同的平台上跑,能稳定的运行几年,不会出问题,比如你写的代码,能在 Zynq 7000 上跑,也能在 UltraScale 上跑,也能在 STM32 上跑,不用改代码,只要换底层的实现就行。

你怎么做到的?你把硬件相关的部分抽象出来,把硬件的细节隐藏起来,上层的代码和硬件无关,比如你定义一个通用的 UART 接口,上层的代码用这个接口,不同的平台,实现自己的底层接口:

c 复制代码
// 通用的UART接口,和硬件无关
typedef struct UartDev UartDev;

typedef struct {
    int (*init)(UartDev *dev, uint32_t baud);
    int (*send)(UartDev *dev, uint8_t *buf, size_t len);
    int (*recv)(UartDev *dev, uint8_t *buf, size_t len);
    int (*deinit)(UartDev *dev);
} UartOps;

struct UartDev {
    UartOps *ops;
    void *private_data; // 平台相关的私有数据
};

// 上层的函数,和硬件无关
static inline int UartInit(UartDev *dev, uint32_t baud) {
    return dev->ops->init(dev, baud);
}

static inline int UartSend(UartDev *dev, uint8_t *buf, size_t len) {
    return dev->ops->send(dev, buf, len);
}

static inline int UartRecv(UartDev *dev, uint8_t *buf, size_t len) {
    return dev->ops->recv(dev, buf, len);
}

然后,在 Zynq 平台上,你实现 Zynq 的 UART 的 ops:

c 复制代码
// Zynq的UART的底层实现
typedef struct {
    uint32_t base_addr;
    RingBuffer rx_rb;
} ZynqUartPriv;

static int zynq_uart_init(UartDev *dev, uint32_t baud) {
    ZynqUartPriv *priv = dev->private_data;
    // Zynq的初始化代码
    ...
}

static int zynq_uart_send(UartDev *dev, uint8_t *buf, size_t len) {
    ZynqUartPriv *priv = dev->private_data;
    // Zynq的发送代码
    ...
}

static UartOps zynq_uart_ops = {
    .init = zynq_uart_init,
    .send = zynq_uart_send,
    .recv = zynq_uart_recv,
};

在 STM32 平台上,你实现 STM32 的 UART 的 ops:

c 复制代码
// STM32的UART的底层实现
typedef struct {
    USART_TypeDef *huart;
    RingBuffer rx_rb;
} Stm32UartPriv;

static int stm32_uart_init(UartDev *dev, uint32_t baud) {
    Stm32UartPriv *priv = dev->private_data;
    // STM32的初始化代码
    ...
}

static int stm32_uart_send(UartDev *dev, uint8_t *buf, size_t len) {
    Stm32UartPriv *priv = dev->private_data;
    // STM32的发送代码
    ...
}

static UartOps stm32_uart_ops = {
    .init = stm32_uart_init,
    .send = stm32_uart_send,
    .recv = stm32_uart_recv,
};

然后,上层的应用代码,完全不用改,不管是 Zynq 还是 STM32,都用同样的 Uart 的接口:

c 复制代码
// 应用代码,和硬件无关
void application(UartDev *uart) {
    UartInit(uart, 115200);
    uint8_t buf[] = "Hello World";
    UartSend(uart, buf, sizeof(buf));
    ...
}

这样,你的应用代码,就能在不同的平台上跑,不用改一行,只要初始化的时候,给不同的平台传不同的 ops 和私有数据就行,这就是高可移植的架构,你的代码,一次编写,到处运行。

然后,高可用的话,你会做很多的保护,比如:

  • 看门狗:程序跑飞了,能自动复位,保证系统能自动恢复。

  • 内存监控:定期检查内存的使用情况,内存不够了,提前报警,或者清理不用的内存。

  • 任务监控:监控每个任务的运行情况,任务卡死了,能自动重启任务。

  • 硬件监控:监控温度、电压,温度太高了,自动降频,或者报警,防止硬件损坏。

  • 错误处理:所有的错误都有处理,不会因为一个小错误,导致整个系统崩溃。

这样,你的系统,就能稳定的运行几年,不会出问题,就算出了问题,也能自动恢复,这就是高可用的架构。

3.2.3 用 C 实现面向对象:封装、继承、多态

然后,你能用 C 实现面向对象,用结构体和函数指针,实现封装、继承、多态,这就是 Xilinx 的驱动框架的做法,也是 Linux 驱动的做法,这样你就能写出可复用、可扩展的代码,就像 C++ 一样,但是用纯 C。

<3.2.3.1> 封装:把数据和方法打包

首先是封装,你把设备的属性和操作,都打包到一个结构体里,隐藏内部的细节,用户只要操作这个结构体的实例就行,不用管里面的细节,比如 Xilinx 的 XGpio 的结构体:

c 复制代码
// GPIO的实例结构体,封装了所有的属性和方法
typedef struct XGpio {
    // 属性:设备的私有数据
    uint32_t BaseAddr;  // 基地址
    uint32_t DeviceId;  // 设备ID
    uint32_t IsReady;   // 是否初始化完成
    uint32_t Channels;  // 通道数

    // 方法:函数指针,操作设备的函数
    int (*SetDirection)(struct XGpio *Instance, uint32_t Channel, uint32_t Direction);
    int (*SetValue)(struct XGpio *Instance, uint32_t Channel, uint32_t Value);
    int (*GetValue)(struct XGpio *Instance, uint32_t Channel, uint32_t *Value);
} XGpio;

这就是封装,把 GPIO 的所有数据和操作,都封装到 XGpio 这个结构体里,用户不用管里面的细节,只要调用方法就行,比如:

c 复制代码
XGpio gpio;
// 初始化GPIO
XGpio_Initialize(&gpio, XPAR_GPIO_0_DEVICE_ID);
// 设置方向
gpio.SetDirection(&gpio, 1, 0x00FF);
// 设置值
gpio.SetValue(&gpio, 1, 0x00FF);

用户不用管寄存器的地址,不用管内部的细节,只要调用这些方法就行,内部的细节都隐藏起来了,这就是封装。

<3.2.3.2> 继承:子类扩展父类

然后是继承,你定义一个基类,所有的设备都继承这个基类,复用基类的属性和方法,比如你定义一个基类 XDevice,所有的设备都继承它:

c 复制代码
// 基类:所有设备的父类
typedef struct XDevice {
    uint32_t DeviceId;       // 设备ID,所有设备都有
    int (*Init)(struct XDevice *Dev);  // 初始化,所有设备都有
    int (*DeInit)(struct XDevice *Dev); // 去初始化,所有设备都有
} XDevice;

然后,XGpio 继承这个基类,把 XDevice 作为第一个成员,这样,XGpio 的指针,就能强制转换成 XDevice 的指针,因为第一个成员的地址,和整个结构体的地址是一样的:

c 复制代码
// 子类:GPIO,继承XDevice
typedef struct XGpio {
    XDevice Parent;  // 父类,必须是第一个成员!
    // GPIO自己的属性
    uint32_t BaseAddr;
    uint32_t Channels;
    // GPIO自己的方法
    int (*SetDirection)(struct XGpio *Instance, uint32_t Channel, uint32_t Direction);
} XGpio;

同样,XUart 也继承 XDevice:

c 复制代码
// 子类:UART,继承XDevice
typedef struct XUartPs {
    XDevice Parent;  // 父类,第一个成员
    // UART自己的属性
    uint32_t BaseAddr;
    uint32_t BaudRate;
    // UART自己的方法
    int (*Send)(struct XUartPs *Instance, uint8_t *buf, size_t len);
} XUartPs;

这样,所有的设备,都是 XDevice 的子类,你就能用 XDevice 的指针,来操作所有的设备,比如:

c 复制代码
// 通用的初始化函数,能初始化所有的设备
int device_init(XDevice *dev) {
    return dev->Init(dev);
}

你传 XGpio 的指针进去,也能调用,传 XUart 的指针进去,也能调用,因为 XGpio 的第一个成员是 XDevice,所以指针转换是安全的,这就是继承,你复用了基类的接口,不用每个设备都写一个初始化函数。

<3.2.3.3> 多态:同一个接口,不同的实现

然后是多态,同一个接口,不同的设备,有不同的实现,运行的时候,自动调用对应的实现,比如基类的 Init 函数,XGpio 的 Init 是初始化 GPIO,XUart 的 Init 是初始化 UART,但是你调用的时候,都是调用 dev->Init (dev),运行的时候,会根据 dev 的实际类型,调用对应的函数,这就是多态。

比如,你有一个设备数组,里面有不同的设备,你遍历它们,统一初始化:

c 复制代码
// 设备数组,里面有不同的设备
XDevice *devs[] = {
    (XDevice *)&gpio,
    (XDevice *)&uart,
    (XDevice *)&i2c,
    NULL
};

// 统一初始化所有设备
for(int i = 0; devs[i] != NULL; i++) {
    device_init(devs[i]);
}

这里,每个 dev 的 Init 函数,都是不同的实现,gpio 的 Init 是 XGpio 的 Init,uart 的是 XUart 的 Init,但是调用的时候,都是用同样的接口,不用管具体是什么设备,这就是多态,非常方便,你加新的设备,只要继承 XDevice,实现自己的 Init 就行,不用改上层的代码。

这就是用 C 实现的面向对象,Xilinx 的所有驱动,都是这么做的,Linux 的驱动也是这么做的,这样的代码,可复用,可扩展,非常适合大型的嵌入式项目,精通的人,会用这个来设计架构,写出可复用的代码,普通的熟练的人,根本不会这么做,他们只会写面向过程的代码,代码复用性很差。

3.3 解决疑难问题:搞定别人搞不定的 Bug

精通的第三个标志,就是你能解决疑难问题,那些偶发的、难复现的、别人搞不定的 Bug,你能搞定,你是团队里的救火队员,遇到搞不定的问题,都来找你。

3.3.1 定位偶发崩溃

首先,你能定位偶发崩溃,就是那种有时候跑几个小时就崩溃,有时候跑几天都没事的 Bug,这种 Bug 最难搞,因为很难复现,很难定位,比如:

比如,你做 Zynq 的双核开发,两个核,核 0 跑业务,核 1 跑数据处理,两个核共享一个全局的缓冲区,用来传递数据,你没加锁,然后就出现了偶发的崩溃,有时候核 0 在写缓冲区,写了一半,核 1 来读,读到了一半的旧数据,一半的新数据,导致数据错误,然后程序崩溃,这个就是竞态,因为两个核的执行是并行的,有时候刚好错开,就没事,有时候刚好同时访问,就出错,所以是偶发的。

普通的熟练的人,遇到这种问题,根本不知道怎么回事,他们加了一堆打印,但是加了打印之后,问题就消失了,因为打印改变了时序,他们根本定位不到。

但是精通的你,就知道,这是多核的竞态问题,你会用自旋锁,保护共享缓冲区的访问:

c 复制代码
// 定义自旋锁
spinlock_t buf_lock;

// 核0写缓冲区的时候,加锁
spin_lock(&buf_lock);
memcpy(buf, data, size);
spin_unlock(&buf_lock);

// 核1读缓冲区的时候,加锁
spin_lock(&buf_lock);
memcpy(data, buf, size);
spin_unlock(&buf_lock);

这样,同一时间,只有一个核能访问缓冲区,就解决了竞态的问题,偶发的崩溃就消失了。

还有,比如你遇到偶发的 DMA 传输错误,有时候传 1000 次,有 1 次出错,普通的人,以为是硬件坏了,或者 DMA 的驱动有问题,搞了很久都搞不定。

但是精通的你,就知道,这是缓存的问题,CPU 写了缓冲区之后,缓存还没刷到内存,DMA 就开始读了,读到了旧的数据,有时候刚好缓存刷了,就没事,有时候没刷,就出错,所以是偶发的,你加了缓存刷的操作:

c 复制代码
// 写了缓冲区之后,刷缓存
Xil_DCacheFlushRange((uint32_t)buf, size);
// 然后启动DMA
DMA_Start(buf, size);

这样,缓存里的数据,都刷到内存里了,DMA 就能读到正确的数据,偶发的错误就消失了。

3.3.2 定位死锁

然后,你能定位死锁,就是两个任务,互相等对方的锁,导致程序卡死,比如:

  • 任务 1,拿到了锁 A,然后要拿锁 B,

  • 任务 2,拿到了锁 B,然后要拿锁 A,

    然后两个都等对方释放锁,就死锁了,程序就卡死了,什么都做不了。

普通的人,遇到这种问题,不知道怎么回事,他们只会重启,不知道为什么卡死。

但是精通的你,就知道,这是死锁,你会用死锁检测的工具,比如你给锁加了记录,记录每个锁被哪个任务持有,哪个任务在等哪个锁,然后定期检查,有没有循环等待,比如任务 1 等任务 2,任务 2 等任务 1,这就是死锁,你就能检测到,然后打印出来,定位到问题,然后你调整锁的顺序,让所有的任务,都先拿锁 A,再拿锁 B,这样就不会死锁了。

3.3.3 定位硬件相关的 Bug

然后,你能定位硬件相关的 Bug,就是那种和硬件有关的,偶发的问题,比如:

  • I2C 的偶发读写失败,有时候读寄存器,读到错误的值,

  • SPI 的传输,有时候出错,

  • PL 和 PS 的交互,有时候数据错了。

普通的人,遇到这种问题,不知道怎么回事,他们以为是软件的问题,改了很久的软件,都没用。

但是精通的你,就知道,要用硬件的调试工具,比如 ILA,Integrated Logic Analyzer,你把 ILA 加到 PL 的设计里,抓信号,看 I2C 的 SCL 和 SDA 的信号,看时序对不对,哦,原来 I2C 的速度太快了,从设备的响应太慢了,有时候 ACK 没回来,所以读写失败,你把 I2C 的速度降下来,就好了。

或者,你抓 DMA 的信号,看 DMA 的地址、长度、使能信号,哦,原来你给 DMA 的地址,超过了 AXI 接口的范围,所以有时候 DMA 访问了错误的地址,导致传输错误,你改了地址,就好了。

你也懂硬件的时序,懂硬件的协议,你能区分是软件的问题还是硬件的问题,你能搞定这些别人搞不定的硬件相关的 Bug。

3.3.4 熟悉标准 C 与不同编译器的差异

然后,你熟悉标准 C,也熟悉不同编译器的差异,比如 gcc、armcc、clang,它们对 C 标准的支持不一样,有一些差异,你能写出兼容不同编译器的代码,不会踩这些坑。

比如,位域的顺序,gcc 的位域,是从低位到高位,armcc 的位域,是从高位到低位,比如你定义的位域:

c 复制代码
typedef struct {
    uint32_t a : 8;
    uint32_t b : 8;
    uint32_t c : 8;
    uint32_t d : 8;
} BitField;

在 gcc 里,a 是低 8 位,d 是高 8 位,在 armcc 里,a 是高 8 位,d 是低 8 位,这就导致,如果你用这个结构体来操作寄存器,用不同的编译器编译,结果完全不一样,普通的人,遇到这个问题,根本不知道怎么回事,他们以为是寄存器的定义错了,改了很久都不对。

但是精通的你,就知道这个差异,你会写兼容的代码,比如你不用位域,你自己用移位和掩码,这样不管什么编译器,结果都是一样的,或者你用条件编译,针对不同的编译器,调整位域的顺序。

还有,inline 函数的处理,gcc 默认只有 - O2 才会 inline,armcc 默认自动 inline,还有,对 C99 的支持,旧的 armcc 不支持 C99 的变量声明在代码里,gcc 支持,还有栈对齐的差异,不同的编译器,栈对齐的要求不一样,这些你都懂,你能写出兼容不同编译器的代码,不会踩这些坑。

3.3.5 有成熟项目、开源贡献的经验

然后,你有成熟项目的经验,你做过复杂的系统,比如 4K 的视频处理系统,比如工业控制的系统,几十万个设备在现场跑,稳定运行了几年,没有出问题,你有处理大规模、高可靠系统的经验。

你也可能有开源贡献的经验,比如你给 Xilinx 的开源驱动提交过补丁,给 U-Boot、Linux 内核提交过补丁,解决了一些 Bug,优化了一些性能,你和开源社区的开发者一起合作过,你懂开源的开发模式。

这些都是精通的人的经历,普通的熟练的人,根本没有这些经验,他们做的都是小项目,没有做过复杂的大系统。

3.4 精通阶段的总结

总结一下,精通阶段的你,已经:

  • 吃透了底层,知道编译、汇编、链接的全过程,能看懂汇编,懂系统的底层机制。

  • 能做性能优化,能设计高可用、高可移植的架构,能用 C 实现面向对象,写出可复用的代码。

  • 能解决疑难问题,能定位偶发崩溃、死锁、竞态、硬件相关的 Bug,熟悉不同编译器的差异,有成熟项目的经验。

简单来说,就是能带队、能解决别人搞不定的问题,你是团队里的技术专家,你能设计整个系统的架构,你能带队做项目,遇到搞不定的问题,你能搞定,别人搞不定的,你能搞定。

这个阶段,只有少数的资深开发者能达到,如果你真的达到了,你简历上写「精通:C 语言,能独立设计 Zynq 异构系统的架构,能解决复杂的性能和稳定性问题」,没问题,但是如果你没达到,千万别写,不然面试官一问你,你就露馅了。


四、给嵌入式同学的建议

最后,给所有做嵌入式开发,尤其是做 Zynq 开发的同学,一些建议:

4.1 简历要诚实,别吹牛

首先,简历一定要诚实,别吹牛,你是什么水平,就写什么,别明明只是了解,你写熟练,明明只是熟练,你写精通,不然面试官一问你,你就露馅了,直接就凉了。

  • 应届生 / 1 年以内的同学:写熟练最稳妥,别写精通,你刚毕业,就算你学的很好,你也没有足够的项目经验,没有解决过足够多的疑难问题,你写精通,面试官会往死里问你,问你编译链接的细节,问你汇编,问你性能优化,你答不上来,就凉了,写熟练,最稳妥,面试官问你熟练的内容,你都能答上来,没问题。

  • 2-3 年的同学:如果你的项目很扎实,你做过很多的项目,解决过很多的问题,你可以冲熟练,但是精通还是要谨慎,除非你真的做过复杂的大项目,解决过很多别人搞不定的问题,不然别写,精通这两个字,太重了。

4.2 面试要准备好,这些是必问的

然后,面试的时候,这些内容是必问的,你一定要准备好:

  • 指针:指针数组和数组指针的区别,函数指针的用法,二级指针的用法,这些是必问的。

  • 内存:栈和堆的区别,全局区和静态区的区别,内存泄漏怎么排查,野指针怎么排查,这些是必问的。

  • 结构体:字节对齐的问题,位域的用法,这些是必问的。

  • 位运算:怎么用位运算设置位、清除位、翻转位,这些是嵌入式开发必用的,面试官必问。

  • 多文件编程:头文件怎么写,怎么防止重复包含,static 和 extern 的用法,这些是必问的。

  • 调试:段错误怎么定位,怎么用 GDB 调试,怎么用 core dump,这些是必问的。

  • 项目细节:你做的 Zynq 的项目,你做了什么,你用了什么驱动,你怎么优化的,你遇到了什么问题,怎么解决的,这些是必问的。

这些内容,你一定要准备好,面试官肯定会问,你答上来了,就没问题,答不上来,就凉了。

4.3 循序渐进,别着急

最后,学习是一个循序渐进的过程,别着急,你先从了解到熟练,把基础打牢,能独立干活,然后再往精通走,慢慢的积累经验,解决更多的问题,慢慢的成为专家,不要刚学了几天 C 语言,就以为自己精通了,那是不可能的。

C 语言是嵌入式开发的基础,也是 Zynq 开发的基础,你把 C 语言学透了,你做什么项目都能得心应手,你要是学不透,你做什么项目都会踩坑,都会出 Bug。

希望这篇文章,能帮你搞清楚自己的能力水平,帮你写简历的时候,不会写错,也帮你找到自己的学习方向,加油!

(注:文档部分内容可能由 AI 生成)

相关推荐
常利兵2 小时前
Kotlin抽象类与接口:相爱相杀的编程“CP”
android·开发语言·kotlin
2501_944448472 小时前
数据可视化 Kotlin KMP OpenHarmony图表生成
开发语言·信息可视化·harmonyos
Arkerman_Liwei2 小时前
Android 新开发模式深度实践:Kotlin + 协程 + Flow+MVVM
android·开发语言·kotlin
xinhuanjieyi2 小时前
MCP分析某wordpress网站 时间所在的背景动画,并用php框架webman复刻下来
开发语言·php
jwn9992 小时前
Laravel1.x:PHP框架的初心与革新
开发语言·php
蜡台2 小时前
JavaScript async和awiat 使用
开发语言·前端·javascript·async·await
蹦哒2 小时前
Kotlin DSL 风格编程详解
android·开发语言·kotlin
枫叶丹42 小时前
【HarmonyOS 6.0】ArkWeb 深度解读:getPageOffset20 与网页滚动偏移量获取能力的演进
开发语言·华为·harmonyos
独特的螺狮粉2 小时前
开源鸿蒙跨平台Flutter开发:室内探险游戏应用
开发语言·flutter·游戏·华为·开源·harmonyos·鸿蒙