
大家好,我是良许。
今天咱们来聊聊嵌入式Linux开发这个话题。
说实话,我从机械转行做嵌入式这么多年,最让我觉得有意思的就是嵌入式Linux这块。
相比单片机开发,Linux系统给了我们更强大的功能和更灵活的开发方式,但同时也带来了更多的挑战。
嵌入式Linux开发,简单来说就是把Linux操作系统移植到嵌入式设备上,然后在这个系统上开发应用程序或者驱动程序。
这里的嵌入式设备可以是智能手机、路由器、工业控制器、汽车电子设备等等。
我在外企做的汽车电子项目,用的就是嵌入式Linux系统。
和我们平时用的Ubuntu、CentOS这些桌面Linux不同,嵌入式Linux通常需要经过裁剪和定制,因为嵌入式设备的资源往往比较有限。
比如内存可能只有几百MB,存储空间也就几个GB,不像服务器那样动不动就几十GB内存。
所以我们需要把不必要的功能去掉,只保留核心的部分。
你可能会问,为什么不用单片机的RTOS,非要用Linux呢?
这个问题我当年也问过自己。
后来发现,当你的项目需要网络通信、文件系统、多进程管理这些功能的时候,Linux的优势就体现出来了。
Linux有成熟的TCP/IP协议栈,有完善的文件系统支持,有强大的进程管理机制,这些都是RTOS很难比拟的。
而且Linux是开源的,社区支持非常好。
遇到问题基本上都能在网上找到解决方案。
我记得刚开始做Linux开发的时候,经常半夜爬起来查资料,很多问题都是在Linux内核邮件列表或者Stack Overflow上找到答案的。
Bootloader是系统启动的第一个程序,它的主要任务是初始化硬件,然后把Linux内核加载到内存中运行。
最常用的Bootloader是U-Boot,它支持很多种处理器架构,包括ARM、MIPS、PowerPC等等。
我在做项目的时候,经常需要修改U-Boot来适配我们的硬件板子。
比如配置内存大小、设置启动参数、添加新的硬件驱动等等。
U-Boot的配置文件通常在include/configs/目录下,你需要根据自己的硬件创建一个配置文件。
举个例子,如果你要设置内核启动参数,可以在U-Boot的环境变量中这样设置:
setenv bootargs 'console=ttymxc0,115200 root=/dev/mmcblk0p2 rootwait rw'
saveenv这条命令设置了串口控制台、根文件系统的位置等信息。
console=ttymxc0,115200表示使用ttymxc0这个串口,波特率是115200。
root=/dev/mmcblk0p2表示根文件系统在SD卡的第二个分区。
内核是整个系统的核心,它负责管理硬件资源、提供系统调用接口。
移植内核的第一步是下载内核源码,然后根据你的硬件平台进行配置。
Linux内核的配置使用的是Kconfig系统,你可以通过make menuconfig命令来进行图形化配置。
配置项非常多,包括CPU架构、设备驱动、文件系统、网络协议等等。
对于嵌入式系统,我们通常需要把不需要的功能去掉,以减小内核的大小。
比如,如果你的设备不需要蓝牙功能,就可以在配置中把蓝牙相关的选项去掉。
如果不需要某些文件系统,也可以不编译进内核。
我做项目的时候,通常会先用默认配置编译一个内核,然后逐步裁剪,最终把内核大小从十几MB减小到几MB。
编译内核的命令通常是这样的:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- dtbs
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules第一条命令编译内核镜像,第二条编译设备树,第三条编译内核模块。
ARCH=arm指定目标架构是ARM,CROSS_COMPILE指定交叉编译工具链的前缀。
根文件系统包含了系统运行所需的所有文件,包括库文件、配置文件、应用程序等等。
制作根文件系统有很多种方法,最常用的是使用Buildroot或者Yocto这样的工具。
Buildroot是一个比较轻量级的工具,它可以自动下载、编译、安装各种软件包,最后生成一个完整的根文件系统。
我个人比较喜欢用Buildroot,因为它配置简单,编译速度也快。
使用Buildroot的基本流程是这样的:
git clone https://github.com/buildroot/buildroot.git
cd buildroot
make menuconfig
make在make menuconfig中,你可以选择目标架构、工具链、需要的软件包等等。
配置完成后,执行make命令,Buildroot就会自动下载源码、编译、安装,最后在output/images/目录下生成根文件系统镜像。
根文件系统的格式有很多种,常见的有ext4、ubifs、squashfs等等。
ext4适合用在SD卡或者eMMC上,ubifs适合用在NAND Flash上,squashfs是一个只读的压缩文件系统,适合用来存放不需要修改的系统文件。
驱动开发是嵌入式Linux开发中最核心也是最难的部分。
Linux的驱动分为字符设备驱动、块设备驱动和网络设备驱动。
对于嵌入式系统,我们最常接触的是字符设备驱动,比如串口驱动、GPIO驱动、I2C驱动等等。
写一个简单的字符设备驱动,基本框架是这样的:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#define DEVICE_NAME "mydevice"
#define CLASS_NAME "myclass"
static int major_number;
static struct class *myclass = NULL;
static struct device *mydevice = NULL;
static int dev_open(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "mydevice: Device opened\n");
return 0;
}
static int dev_release(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "mydevice: Device closed\n");
return 0;
}
static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset) {
printk(KERN_INFO "mydevice: Read operation\n");
return 0;
}
static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset) {
printk(KERN_INFO "mydevice: Write operation\n");
return len;
}
static struct file_operations fops = {
.open = dev_open,
.read = dev_read,
.write = dev_write,
.release = dev_release,
};
static int __init mydevice_init(void) {
printk(KERN_INFO "mydevice: Initializing\n");
major_number = register_chrdev(0, DEVICE_NAME, &fops);
if (major_number < 0) {
printk(KERN_ALERT "mydevice: Failed to register\n");
return major_number;
}
myclass = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(myclass)) {
unregister_chrdev(major_number, DEVICE_NAME);
printk(KERN_ALERT "mydevice: Failed to create class\n");
return PTR_ERR(myclass);
}
mydevice = device_create(myclass, NULL, MKDEV(major_number, 0), NULL, DEVICE_NAME);
if (IS_ERR(mydevice)) {
class_destroy(myclass);
unregister_chrdev(major_number, DEVICE_NAME);
printk(KERN_ALERT "mydevice: Failed to create device\n");
return PTR_ERR(mydevice);
}
printk(KERN_INFO "mydevice: Device created successfully\n");
return 0;
}
static void __exit mydevice_exit(void) {
device_destroy(myclass, MKDEV(major_number, 0));
class_unregister(myclass);
class_destroy(myclass);
unregister_chrdev(major_number, DEVICE_NAME);
printk(KERN_INFO "mydevice: Goodbye\n");
}
module_init(mydevice_init);
module_exit(mydevice_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("良许");
MODULE_DESCRIPTION("A simple character device driver");这个驱动实现了最基本的打开、关闭、读、写操作。
在mydevice_init函数中,我们注册了一个字符设备,创建了设备类和设备节点。
当驱动加载成功后,系统会在/dev目录下创建一个名为mydevice的设备文件。
编译驱动需要一个Makefile:
obj-m += mydevice.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean编译完成后,使用insmod mydevice.ko命令加载驱动,使用rmmod mydevice命令卸载驱动。
在嵌入式Linux开发中,我们通常在PC上编写代码,然后使用交叉编译工具链编译成目标平台的可执行文件。
交叉编译工具链包括编译器、链接器、库文件等等。
常用的交叉编译工具链有arm-linux-gnueabihf、aarch64-linux-gnu等等。
你可以从芯片厂商的网站下载,也可以使用Buildroot或者Linaro提供的工具链。
安装好工具链后,编译程序的命令是这样的:
arm-linux-gnueabihf-gcc -o hello hello.c如果程序使用了第三方库,需要指定库的路径:
arm-linux-gnueabihf-gcc -o myapp myapp.c -I/path/to/include -L/path/to/lib -lmylibLinux提供了丰富的系统调用接口,我们可以通过这些接口来操作文件、进程、网络等等。
比如,读写文件可以使用open、read、write、close等系统调用。
下面是一个读写文件的例子:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd;
char buffer[100];
ssize_t bytes_read;
// 打开文件
fd = open("/tmp/test.txt", O_RDWR | O_CREAT, 0644);
if (fd < 0) {
perror("Failed to open file");
return -1;
}
// 写入数据
const char *data = "Hello, Embedded Linux!\n";
write(fd, data, strlen(data));
// 移动文件指针到开头
lseek(fd, 0, SEEK_SET);
// 读取数据
bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("Read from file: %s", buffer);
}
// 关闭文件
close(fd);
return 0;
}这个程序演示了如何创建文件、写入数据、读取数据。
open函数的第二个参数指定了打开方式,O_RDWR表示读写模式,O_CREAT表示如果文件不存在就创建。
第三个参数是文件权限,0644表示所有者可读可写,其他人只读。
在嵌入式Linux系统中,我们经常需要多个进程协同工作。
进程间通信(IPC)的方式有很多种,包括管道、消息队列、共享内存、信号量等等。
管道是最简单的IPC方式,适合父子进程之间的通信。
下面是一个使用管道的例子:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
int pipefd[2];
pid_t pid;
char buffer[100];
// 创建管道
if (pipe(pipefd) == -1) {
perror("pipe failed");
return -1;
}
// 创建子进程
pid = fork();
if (pid < 0) {
perror("fork failed");
return -1;
}
if (pid == 0) {
// 子进程:读取数据
close(pipefd[1]); // 关闭写端
read(pipefd[0], buffer, sizeof(buffer));
printf("Child received: %s\n", buffer);
close(pipefd[0]);
} else {
// 父进程:写入数据
close(pipefd[0]); // 关闭读端
const char *msg = "Hello from parent!";
write(pipefd[1], msg, strlen(msg) + 1);
close(pipefd[1]);
wait(NULL); // 等待子进程结束
}
return 0;
}对于更复杂的通信需求,我们可以使用消息队列或者共享内存。
消息队列适合传递结构化的消息,共享内存适合大量数据的传输。
嵌入式设备经常需要通过网络与其他设备通信。
Linux提供了标准的Socket接口,支持TCP和UDP协议。
下面是一个简单的TCP服务器例子:
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 8888
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t addr_len = sizeof(client_addr);
char buffer[1024];
// 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket failed");
return -1;
}
// 设置地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
// 绑定端口
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
return -1;
}
// 监听连接
if (listen(server_fd, 3) < 0) {
perror("listen failed");
return -1;
}
printf("Server listening on port %d\n", PORT);
// 接受连接
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept failed");
return -1;
}
printf("Client connected\n");
// 接收数据
int bytes_read = read(client_fd, buffer, sizeof(buffer));
if (bytes_read > 0) {
buffer[bytes_read] = '\0';
printf("Received: %s\n", buffer);
// 发送响应
const char *response = "Message received";
write(client_fd, response, strlen(response));
}
close(client_fd);
close(server_fd);
return 0;
}这个服务器程序监听8888端口,接受客户端连接,接收数据并发送响应。
在实际项目中,我们通常会使用多线程或者异步IO来处理多个客户端连接。
串口是嵌入式开发中最常用的调试工具。
通过串口,我们可以看到系统的启动信息、内核日志、应用程序输出等等。
在Linux中,串口设备通常是/dev/ttyS0、/dev/ttyUSB0这样的设备文件。
使用串口的时候,需要设置波特率、数据位、停止位等参数。
可以使用minicom或者picocom这样的工具:
picocom -b 115200 /dev/ttyUSB0这条命令以115200的波特率打开/dev/ttyUSB0设备。
对于应用程序的调试,我们可以使用GDB。在嵌入式系统上,通常使用gdbserver进行远程调试。
首先在目标板上运行gdbserver:
gdbserver :1234 ./myapp然后在PC上使用交叉编译版本的GDB连接:
arm-linux-gnueabihf-gdb myapp
(gdb) target remote 192.168.1.100:1234
(gdb) break main
(gdb) continue这样就可以在PC上调试运行在目标板上的程序了。
可以设置断点、单步执行、查看变量等等。
内核调试比应用程序调试要复杂一些。
最常用的方法是使用printk打印日志。
printk的用法和printf类似,但是输出会记录到内核日志中,可以通过dmesg命令查看。
printk(KERN_INFO "This is an info message\n");
printk(KERN_WARNING "This is a warning message\n");
printk(KERN_ERR "This is an error message\n");日志级别有KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG等等,级别越高越重要。
对于更复杂的内核调试,可以使用KGDB或者JTAG调试器。
KGDB允许你使用GDB调试内核,JTAG调试器则可以在硬件级别进行调试。
嵌入式设备通常对启动时间有要求,特别是消费电子产品。
优化启动时间的方法有很多,比如并行化启动脚本、延迟加载不必要的服务、使用静态链接减少动态库加载时间等等。
我在做汽车电子项目的时候,客户要求系统在3秒内启动完成。
为了达到这个目标,我们做了很多优化。
首先是精简内核,把不需要的驱动和功能都去掉。
然后优化启动脚本,把一些不紧急的服务放到后台启动。
最后使用了压缩的文件系统,减少了文件读取时间。
嵌入式设备的内存通常比较有限,所以内存优化非常重要。
可以使用free命令查看内存使用情况,使用top命令查看各个进程的内存占用。
如果发现内存不够用,可以考虑以下几个方面:
CPU性能优化主要是减少不必要的计算和优化算法。
可以使用top命令查看CPU占用率,使用perf工具进行性能分析。
对于实时性要求高的任务,可以考虑使用实时调度策略。
Linux支持SCHED_FIFO和SCHED_RR两种实时调度策略,可以保证任务得到及时响应。
#include <sched.h>
struct sched_param param;
param.sched_priority = 50;
sched_setscheduler(0, SCHED_FIFO, ¶m);这段代码把当前进程设置为FIFO实时调度,优先级是50。
嵌入式Linux开发涉及的内容非常广泛,从底层的Bootloader、内核、驱动,到上层的应用程序开发,每一个环节都需要扎实的基础知识。
我从单片机转到Linux开发的时候,也是从零开始学习,花了很长时间才慢慢掌握。
但是一旦掌握了这些技能,你会发现嵌入式Linux开发非常有意思。
你可以控制硬件,可以开发复杂的应用,可以解决各种各样的技术难题。
而且Linux的开源特性让你可以深入了解系统的每一个细节,这对于技术的提升非常有帮助。
如果你也想从事嵌入式Linux开发,我的建议是先打好基础,学习C语言、数据结构、操作系统原理等等。
然后动手实践,从简单的驱动开始写起,逐步深入。
遇到问题不要怕,多查资料,多思考,多尝试。
相信只要坚持下去,你一定能成为一名优秀的嵌入式Linux开发工程师。
更多编程学习资源
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。