必知必會系列分享一些基礎但有用的知識,有些知識雖然不能馬上馬上運用起來,但也是有必要了解了解。這是必知必會系列第一篇:GNU。
本文的一部分內容之前也有在大雜燴分享過,也一并集合到這,方便大家查閱。
GNU簡介
GNU計劃,又譯為“革奴計劃”,它的目標是創建一套完全自由的操作系統GNU,并且其內容軟件完全以GPL方式發布。這個操作系統是GNU計劃的主要目標,名稱來自GNU's Not Unix!的遞歸縮寫,因為GNU的設計類似Unix,但它不包含具著作權的Unix代碼。
作為操作系統,GNU的發展仍未完成,其中最大的問題是具有完備功能的內核尚未被開發成功。GNU的內核,稱為Hurd,是自由軟件基金會發展的重點,但是其發展尚未成熟。在實際使用上,多半使用linux內核作為系統核心。
Linux操作系統包含了Linux內核與其他自由軟件項目中的GNU組件和軟件,可以被稱為GNU/Linux。
GNU組件及軟件非常豐富,如:
1、GCC
GCC原名為GNU C語言編譯器(GNU C Compiler),只能處理C語言。但其很快擴展,變得可處理C++,后來又擴展為能夠支持更多編程語言,如Fortran、Pascal、Objective -C、JAVA、Ada、Go以及各類處理器架構上的匯編語言等,所以改名GNU編譯器套件(GNU Compiler Collection)。
2、glibc
glibc是GNU發布的libc庫,即c運行庫。glibc是linux系統中最底層的api,幾乎其它任何運行庫都會依賴于glibc。glibc除了封裝linux操作系統所提供的系統服務外,它本身也提供了許多其它一些必要功能服務的實現。
glibc與libc的關系:
glibc 和 libc 都是 Linux 下的 C 函數庫。libc 是 Linux 下的 ANSI C 函數庫;glibc 是 Linux 下的 GUN C 函數庫。
ANSI C 函數庫是基本的 C 語言函數庫,包含了 C 語言最基本的庫函數。這個庫可以根據頭文件劃分為 15 個部分,其中包括:
glibc是linux下面c標準庫的實現,即GNU C Library。glibc本身是GNU旗下的C標準庫,后來逐漸成為了Linux的標準c庫,而Linux下原來的標準c庫Linux libc逐漸不再被維護。
Linux下面的標準c庫不僅有這一個,如uclibc、klibc,以及上面被提到的Linux libc,但是glibc無疑是用得最多的。glibc在/lib目錄下的.so文件為libc.so.6。
libc 實際上是一個泛指。凡是符合實現了 C 標準規定的內容,都是一種 libc 。glibc 是 GNU 組織對 libc 的一種實現。它是 unix/linux 的根基之一。嵌入式行業里還常用 uClibc ,是一個迷你版的 libc 。
3、coreutils
coreutils 是GNU下的一個軟件包,這個軟件包中包含了很多程序,如ls、mv等程序。常用的如:
4、GDB
GDB(GNU symbolic debugger)是 GNU Project 調試器。
GDB 可以做四種主要的事情(以及支持這些事情的其他事情)來幫助你捕獲行為中的錯誤:
- 啟動你的程序,并指定可能影響其行為的所有內容。
- 使程序在指定條件下停止。
- 檢查程序停止時發生的情況。
- 更改程序中的內容,以便你可以嘗試糾正一個錯誤的影響,然后繼續學習另一個錯誤。
這些程序可能與GDB(本機)在同一臺計算機上執行,在另一臺計算機(遠程)上或在模擬器上執行。
5、binutils
GNU binutils是一組二進制工具集。包含的工具有:
6、其它
GNU系統包括很多軟件包,還包括非GNU的自由軟件。具體的介紹可以上gnu官網(
http://www.gnu.org/software/)上查看:
以上是對GNU及其內容做了一個簡單的介紹,下面對GUN相關的內容做一些實例分享:
GCC編譯、鏈接
1、基本編譯流程
使用gcc工具集將C語言源代碼生成可執行程序需要經過4個步驟:預處理、編譯、匯編、鏈接。如:
首先,調用預處理器cpp進行預處理,對源代碼.c文件中的文件包含(include)、預編譯語句(如宏定義define等)進行分析,生成.i文件。
接著調用編譯器gcc進行編譯,輸入上一步的.i文件,輸出.s匯編文件。
然后調用匯編器as將.s為后綴的匯編語言文件處理生成以.o為后綴的目標文件。
當所有的目標文件都生成之后,調用鏈接器ld來進行鏈接生成可執行文件或庫文件。這一節我們先看生成可執行文件,下一節再看如何生成庫文件。
其中上圖中表明的-E、-S、-c為gcc編譯參數。gcc的基本用法如下:
gcc [options] [filenames]
下面以一個實例來演示將C語言源代碼生成可執行程序的過程。
示例代碼hello.c:
#include <stdio.h>
int main(void)
{
printf("Hello gccn");
return 0;
}
(1)預處理過程
使用預處理器cpp把源文件hello.c經過預處理生成hello.i文件,預處理用于將所有的#include頭文件以及宏定義替換成其真正的內容。
預處理的命令為:
gcc -E hello.c -o hello.i
上述命令中-E是讓編譯器在預處理之后就退出,不進行后續編譯過程;-o是指定輸出文件名。
預處理之后得到的仍然是文本文件。hello.i文件部分內容截圖如下:
(2)編譯過程
使用編譯器將預處理文件hello.i編譯成匯編文件hello.s。
編譯的命令為:
gcc -S hello.i -o hello.s
上述命令中-S讓編譯器在編譯之后停止,不進行后續過程;-o是指定輸出文件名。匯編文件hello.s是文本文件,部分內容截圖如下:
(3)匯編過程
使用匯編器將匯編文件hello.s轉換成目標文件hello.o。
匯編過程的命令為:
gcc -c hello.s -o hello.o
上述命令中-c、-o讓匯編器把匯編文件hello.s轉換成目標文件hello.o。目標文件hello.o是二進制文件。這時候我們可以使用如下命令查看hello.o的格式:
file hello.o
顯示的內容:
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
可以看到,hello.o是個ELF(Executable and Linking Format,可執行鏈接格式)格式文件。另外,hello.o是個二進制文件,使用vscode打開可能會出現亂碼,可以安裝一個Binary插件。部分內容截圖如下:
(4)鏈接過程
鏈接過程使用鏈接器將該目標文件與其他目標文件、庫文件、啟動文件等鏈接起來生成可執行文件。
命令為:
gcc hello.o -o hello
綜上:
2、動態、靜態鏈接
上一節的第(4)步的鏈接過程分為兩種。一種是靜態鏈接,另外一種是動態鏈接。它們的區別如:
(1)靜態鏈接
優點:代碼裝載速度快,執行速度略比動態鏈接庫快。
缺點:使用靜態鏈接生成的可執行文件體積較大,包含相同的公共代碼,造成浪費。
(2)動態鏈接
優點:生成的可執行文件較靜態鏈接生成的可執行文件小。
缺點:速度比靜態鏈接慢;使用動態鏈接庫的應用程序不是自完備的,需要依賴相關庫。
初學,理解不了?沒關系,分享一個易懂的比喻:
把鏈接過程看做我們平時學習時做筆記的過程。我們平時學習時準備一本筆記本專門記錄我們的學習筆記,比如在某本書的某一頁上看到一個很好很有用的知識,這時候我們有兩種方法記錄在我們的筆記本上,一種是直接把那一頁的內容全部抄寫一遍到筆記本上(靜態鏈接);另一種是我們在筆記本上做個簡單的記錄(動態鏈接),比如寫上:xxx知識點在《xxx》的xxx頁。
從這兩種方法中我們可以很清楚地知道兩種方式的特點,第一種方式的優點就是我們在復習的時候就很方便,不用翻閱其它書籍了,但是缺點也很明顯,就是占用筆記本的空間很多,這種方法很快就把我們的筆記本給寫滿了。第二種方式的優點就是很省空間,缺點就是每當我們復習的時候,手頭上必須備著相關的參考書籍,比如我們去教室復習的時候,就得背著一大摞書去復習,這樣我們復習的效率可能就沒有那么高了。
這對應到我們的動態鏈接與靜態鏈接上是不是就很好理解了。
下面看看具體實例:
文件1(main.c):
#include "hello.h"
int main(void)
{
print_hello();
return 0;
}
文件2(hello.c):
#include "hello.h"
void print_hello(void)
{
printf("hello worldn");
}
文件3(hello.h):
#ifndef __HELLO_H
#define __HELLO_H
#include <stdio.h>
void print_hello(void);
#endif
① 演示動態鏈接
首先,將源文件生成目標文件(*.o),命令:
gcc -c main.c hello.c
在Linux中,動態庫的擴展名一般為.so。我們把上面生成的hello.o文件生成相應的動態庫,命令:
gcc -shared hello.o -o libhello.so
使用鏈接動態庫的方式生成可執行程序,命令:
gcc main.o -L. -lhello -o hello_d_lib_test
這里的-L.的含義是在搜索庫文件時包含當前目錄,-lhello的含義是鏈接名稱為libhello.so的動態庫。
此時,運行hello_d_lib_test程序,可能會出現如下錯誤:
./hello_d_lib_test: error while loading shared libraries: libhello.so: cannot open shared object file: No such file or directory
這是因為找不到共享庫文件libhello.so,加載失敗。因為一般情況下Linux會在/usr/lib路徑中搜索需要用到的庫,而libhello.so庫并不在這個路徑下。
解決方法有如下幾種:
- 把這個文件拷貝至/usr/lib路徑下。
- .配置文件/etc/ld.so.conf中指定的動態庫搜索路徑。
- 臨時生效,可以用 LD_LIBRARY_PATH 環境變量指定。
我們這里作為測試,使用臨時生效的方式,使用環境變量LD_LIBRARY_PATH指定當前路徑為動態庫搜索路徑,命令:
export LD_LIBRARY_PATH=./:$LD_LIBRARY_PATH
這時候再次運行程序就可以正常運行了。
② 演示靜態鏈接
靜態庫用ar工具來制作。ar是一個歸檔工具,用于建立、修改、提取歸檔文件(archive)。一個歸檔文件可以包含多個目標文件,也被稱為靜態庫。在Linux下,靜態庫的擴展名一般為.a。
把目標文件hello.o做成靜態庫,命令:
ar -rv libhello.a hello.o
其中rv參數為組合參數,其中r參數表示當建立的模塊名已經存在時,則覆蓋同名模塊,v參數用來顯示附加信息,比如被處理的文件的名字。
使用鏈接靜態庫的方法生成可執行程序,命令:
gcc main.o -L. -lhello -o hello_s_lib_test
刪除靜態庫之后,可執行程序也是能正常運行的。事實上,使用鏈接靜態庫的方式生成的可執行程序與直接使用目標文件生成的可執行程序沒有區別。只是經過了靜態庫的鏈接,變為了一個文件,方便于調用、移植和保存。
歸檔工具ar可以很方便地查看和刪除歸檔文件中的成員。
查看靜態庫libhello.a中的內容,命令:
關于ar工具更多的命令參數可輸入ar --help進行查看。
GCC工具集的使用
1、ar工具的使用
基本使用如上面靜態鏈接中的用法。
2、addr2line工具的使用
addr2line可以將地址信息轉化成函數名或行數。例如,如下代碼運行會產生段錯誤:
test.c:
#include <stdio.h>
int main(void)
{
char *str = "hello";
str[0] = 'a';
return 0;
}
首先,編譯時加上-g參數,產生調試信息。
gcc test.c -g -o test
運行會產生段錯誤Segmentation fault (core dumped)。此時會產生相關錯誤系統存于系統日志中。我們可以使用如下命令查看我們當前程序的錯誤信息:
dmesg | grep test
此時會輸出類似如下信息:
[ 1081.831805] test[2763]: segfault at 55f1d81186a4 ip 000055f1d811860d sp 00007ffc6fc1d080 error 7 in test_addr2line[55f1d8118000+1000]
此時借助addr2line工具可以查到產生錯誤的行號:
addr2line -e test 55f1d81186a4
3、nm工具的使用
nm工具用于顯示文件中的符號,可以用于各種ELF格式文件。ELF格式文件包括如下三種類型:
nm工具的使用方式:
nm [option] [file]
其中,可以使用nn --help命令來查看支持的參數。其中,nm顯示的符號類型如:
其中符號類型有大小寫之分,小寫字母表示這個符號是局部符號,大寫字母表示這個符號是全局符號。
下面一起來使用nm工具查看目標目標文件的標號。
實例代碼test.c:
#include <stdio.h>
static int a = 1;
static int b;
void print_hello(void)
{
printf("hellon");
}
int main(void)
{
print_hello();
}
編譯之后得到可執行程序test。執行如下命令查看test中的符號:
nm test
輸出結果如:
0000000000201010 d a
0000000000201018 b b
# 省略部分內容......
000000000000064d T main
000000000000063a T print_hello
# 省略部分內容......
從輸出結果可以知道,a是一個全局符號,該符號位于已初始化數據(RW Data)部分。b也是一個全局符號,該符號位于未初始化數據(BSS)部分。main符號與print_hello符號位于代碼部分。
4、strip工具的使用
strip工具用于刪除文件中的符號。
strip工具的使用方式:
strip [option] [file]
其中,可以使用strip--help命令來查看支持的參數。
我們以nm工具的演示代碼來做演示。我們編譯得到的可執行程序為test。沒有執行strip之前,使用nm命令查看到的符號如:
0000000000201010 d a
0000000000201018 b b
# 省略部分內容......
000000000000064d T main
000000000000063a T print_hello
# 省略部分內容......
使用ls -lh test命令查看test程序的大小為:8.2k。
這時候執行如下命令刪除test的符號部分,輸出test_strip文件:
strip test -o test_strip
使用nm命令查看test_strip文件是否有符號,顯示結果為:
nm: test_strip: no symbols
表示test_strip沒有符號。使用ls -lh test_strip命令查看test_strip的大小為:6k。可見去掉符號表之后地程序變小了。在資源有限的系統中,可以使用這種方法為程序進行瘦身。
5、readelf工具的使用
readelf工具用于顯示ELF格式文件的信息。例如:
readelf -h test
輸出結果如:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x530
Start of program headers: 64 (bytes into file)
Start of section headers: 6528 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28
通過輸出信息可以知道文件的類型、文件的格式等信息。
6、objdump工具的使用
objdump工具用于顯示目標文件的信息。
objdump工具的使用方式:
objdump [option] [file]
如:
objdump -h hello.o
輸出結果如:
hello.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000013 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000053 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000053 2**0
ALLOC
3 .rodata 0000000c 0000000000000000 0000000000000000 00000053 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002a 0000000000000000 0000000000000000 0000005f 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000089 2**0
CONTENTS, READONLY
6 .eh_frame 00000038 0000000000000000 0000000000000000 00000090 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
顯示內容包含目標文件各個節的信息。
7、strings工具的使用
strings工具用于查看文件中的字符串。
strings工具的使用方式:
strings [option] [file]
其中,可以使用strings--help命令來查看支持的參數。
實例代碼test.c:
#include <stdio.h>
int main(void)
{
printf("11111n");
printf("22222n");
printf("33333n");
printf("44444n");
printf("55555n");
}
編譯之后得到可執行程序test。執行如下命令查看test中的符號:
strings test
輸出結果如:
# 省略部分內容......
11111
22222
33333
44444
55555
# 省略部分內容......
8、objcopy工具的使用
objcopy工具用于對目標文件的內容進行轉換。
objcopy工具的使用方式:
objcopy [option] [file]
如使用如下命令可以刪除可執行程序test中的.data段輸出到test_rm:
objcopy test -R .data test_rm
objcopy配合-R參數的使用可以達到類似strip工具的效果,給程序進行瘦身。
GDB的基本使用
GDB(GNU Debugger)是一個強大的命令行調試工具。在Linux下進行開發,gdb工具是必知必會的工具之一。首先,看一下gdb常用的命令:
下面以實例來進行基本使用的演示:
示例代碼gdb_test.c:
// 微信公眾號:嵌入式大雜燴
#include <stdio.h>
// 測試函數1
void test0(void)
{
int i = -1;
if (i = 0)
printf("i = %dn", i);
else if (i = 1)
printf("i = %dn", i);
else
printf("i = %dn", i);
}
// 測試函數2
void test1(void)
{
int a[10] = {0,1,2,3,4,5,6,7,8,9};
int *p = &a[1];
int *p1 = (int*)(&a + 1);
printf("p[6] = %dn", p[6]);
printf("*(p1 - 1) = %dn", *(p1 - 1));
}
// 主函數
int main(int argc, char *argv[])
{
test0();
test1();
return 0;
}
這個示例代碼中有兩個測試函數,其實也是兩道經典易錯的面試筆試題。大家可以先思考一下結果是什么。下面我們使用gdb來一步一步調試及分析。
我們必須編譯出帶有調試信息(如行號等信息)的可執行文件才能使用gdb進行調試。在以上基礎上加個-g參數即可生成調試信息。
除此之外,我們編譯時應不使用優化選項,若使用優化,則編譯器會對程序進行一些優化,有可能會更改語句的順序及優化一些變量,從而可能會導致程序執行流程與源碼流程不匹配的情況。可以使用-Wall參數打開所有警告,我們的編譯命令變為:
gcc -g -Wall gdb_test.c -o gdb_test
使用上面的編譯命令編譯得到帶調試信息的可執行程序gdb_test,有兩種方法啟動調試。
一種方法是先輸入gdb命令進入gdb環境,再輸入file+可執行程序裝入調試文件,即:
另一種方法是直接輸入gdb+可執行程序對該程序進行調試,即:
1、調試測試函數1
上面的測試函數1大家思考得出結果了嗎?我們單步調試看看結果是怎么樣的:
① 在test1函數入口打個斷點:
② 運行到斷點處:
③ 單步往下執行:
顯然,單步運行到了這一句我們就得出了測試函數1的結果,即輸出 i = 1。大家分析得對了嗎?這要是不注意還真的容易出錯,這里的if判斷條件里用的是=號,而不是==號,這個小陷阱可能會迷惑一些初學C語言的朋友。
if語句的通用形式為:
if (expression)
statement
可以明確的是:如果對expression為真(非0),則執行statement。本題中,如if (i = 0)其實就等價于
i = 0;
if (i)
顯然這里的if語句的expression為假,不會執行statement。
類似的if (i = 1)等價于
i = 1;
if (i)
顯然這里的if語句的expression為真,執行statement。
平時在發現自己寫的代碼執行的流程異常時,不妨debug調試一下,一步一步地走,看程序是否按照自己設計的流程走,看是不是我們的執行邏輯設計錯了。
2、調試測試函數2
測試函數2也是一道極其經典的面試題目。不能一眼看出結果?沒關系,我們一起調試分析一下。接著上面的流程,我們輸出quit命令推出gdb環境,再重新進入調試test2。
① 在test2函數入口打個斷點:
② 運行到斷點處:
此時,我們不妨看一下a[1]元素的地址及a數組里面的內容是什么:
可見,在數組初始化之前,整個數組空間里的值是一些隨機值。這里反映一個問題,局部變量在初始化之前的值是無規律的,所以不妨在定義局部變量的時候初始化一個確定的值,防止出錯。
③ 單步往下執行:
此時,我們來看一下,指針變量p的值、a數組里的值:
因為此時第20行這條語句還未執行,所以p指向的地址還不是a[1]元素的地址。
再單步往下執行,然后我們看一下,指針變量p的值,及以指針變量p的值為首地址、往后偏移10個內存單元為結束地址,這一段空間內的值是什么:
至此,我們通過調試清晰地得到了p[6]的值。
繼續單步往下執行,我們看一下,&a[0]的值、&a的值、(&a+1)的值、p1的值:
從gdb輸出的信息我們知道&a的類型是(int (*) [10] ),即是一個指向含有10個元素的整形數組的指針,所以(&a+1)的意義是往后偏移10 * sizeof(int)。進一步,再利用一下其它輸出的信息:
&a的值為0x7fffffffdda0
&a+1的值為0x7fffffffddc8
兩個值相減得到40,正好是整個數組所占的字節數。
而p1是一個整形指針,所以p1-1指向的就是往前偏移sizeof(int)個字節的地址,即a[9]的地址(0x7fffffffddc4),所以*(p1 - 1)的值也就是a[9]的值。最后我們再看一下&a往后的40個地址里的值都是些什么:
以上就是本次的實例演示,只是用到了一小部分gdb的命令,還有更多命令大家可以自己練習使用,基本的會了,不懂的地方遇到的時候再查也來得及。
可能寫得有些亂,但也希望能對大家有幫助。總之,對于一些不確定的知識點或者程序的執行與預期不相符時,不妨調試一下,一步一步看數據有沒有異常。
另外,這里使用vscode+gdb命令行來對gdb命令做了基本演示,我們大致知道這么一回事就可以。實際中純命令行調試的話,著實讓人頭疼,我們可以vscode+gdb配置一個可視化的調試環境,提高我們的調試效率。
以上就是本次的分享,如果文章對你有幫助,麻煩幫忙三連支持,謝謝!
大家對于文章有什么建議的話也可以留言交流!