9.3 סקריפטים של הלינקר הרצאה
הקדמה¶
בפרק 9.2 ראינו שהלינקר עובד לפי סקריפט שאומר לו איך לסדר סקשנים בזיכרון. בדרך כלל הlinker script של ברירת המחדל מספיק - אבל יש מקרים שצריך לכתוב סקריפט משלנו.
מתי? בעיקר כש:
- כותבים קוד למערכות embedded (מיקרו-בקרים) שיש להן memory layout ספציפי
- כותבים קרנל או bootloader שצריך לרוץ בכתובת ספציפית
- רוצים ליצור תוכנית freestanding - בלי libc ובלי _start
- רוצים שליטה מדויקת על פריסת הזיכרון
מהו linker script¶
סקריפט לינקר הוא קובץ טקסט שמכיל הוראות ללינקר. הוא אומר:
- באיזו כתובת להתחיל
- באיזה סדר לסדר את הסקשנים
- מה נקודת הכניסה
- איך לחלק את הזיכרון לאזורים (ROM, RAM)
הlinker script של ברירת המחדל¶
כדי לראות את הסקריפט של ברירת המחדל:
הפלט ארוך - מאות שורות. הוא מטפל במגוון מקרים (קוד דינמי, סטטי, אתחול, TLS, וכו'). לרוב אנחנו לא צריכים את כל זה. כשנכתוב סקריפט משלנו, הוא יהיה הרבה יותר פשוט.
התחביר הבסיסי¶
הנה linker script מינימלי:
ENTRY(_start)
SECTIONS {
. = 0x400000;
.text : {
*(.text)
}
.rodata : {
*(.rodata)
}
.data : {
*(.data)
}
.bss : {
*(.bss)
}
}
נפרק את זה:
הנחיית ENTRY¶
קובעת את נקודת הכניסה - הכתובת הראשונה שהמעבד מריץ. בדרך כלל זה _start, אבל אפשר לשים כל שם של סמל.
הפקודה SECTIONS¶
זה הלב של הסקריפט. בתוכו אנחנו מגדירים אילו סקשנים ייכנסו לקובץ ההרצה ובאיזה סדר.
מונה המיקום - the location counter¶
הנקודה (.) היא מונה המיקום (location counter). היא מייצגת את הכתובת הנוכחית בזיכרון. כשאנחנו כותבים . = 0x400000, אנחנו אומרים "מכאן, הכתובת היא 0x400000". כל סקשן שנגדיר אחרי זה ימוקם בכתובת הזו, ומונה המיקום יתקדם אוטומטית בהתאם לגודל הסקשן.
הגדרת סקשנים¶
זה אומר: "צור סקשן בשם .text בקובץ הפלט, ושים בתוכו את כל הסקשנים בשם .text מכל קבצי הקלט". הכוכבית (*) פירושה "מכל הקבצים".
אפשר להיות יותר ספציפיים:
.text : {
main.o(.text) /* רק .text מ-main.o */
utils.o(.text) /* ואז .text מ-utils.o */
*(.text) /* כל השאר */
}
הגדרת סמלים בlinker script¶
אחד הדברים החזקים בlinker scripts הוא היכולת להגדיר סמלים שאפשר לגשת אליהם מקוד C:
SECTIONS {
. = 0x400000;
.text : {
_text_start = .;
*(.text)
_text_end = .;
}
.data : {
_data_start = .;
*(.data)
_data_end = .;
}
.bss : {
_bss_start = .;
*(.bss)
_bss_end = .;
}
}
עכשיו אפשר לגשת לסמלים האלה מקוד C:
extern char _text_start;
extern char _text_end;
extern char _bss_start;
extern char _bss_end;
void init_bss(void) {
// איפוס סקשן BSS ל-0 (נדרש במערכות embedded)
char *p = &_bss_start;
while (p < &_bss_end) {
*p = 0;
p++;
}
}
void print_layout(void) {
printf("Text: %p - %p\n", &_text_start, &_text_end);
printf("BSS: %p - %p\n", &_bss_start, &_bss_end);
}
שימו לב: הסמלים _text_start וכו' לא מכילים ערך - הם פשוט מייצגים כתובות. לכן ניגשים אליהם עם & (כתובת הסמל) ולא בלי.
הפקודה MEMORY - לסביבות embedded¶
במערכות embedded יש לרוב שני סוגי זיכרון:
- ROM/Flash - זיכרון קריאה בלבד (קבוע), שם הקוד שמור
- RAM - זיכרון קריאה-כתיבה, שם הנתונים שמשתנים בזמן ריצה
הפקודה MEMORY מגדירה את אזורי הזיכרון:
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
SECTIONS {
.text : {
*(.text)
} > FLASH
.rodata : {
*(.rodata)
} > FLASH
.data : {
*(.data)
} > RAM AT > FLASH
.bss : {
*(.bss)
} > RAM
}
ההרחבה > FLASH אומרת "שים את הסקשן הזה באזור FLASH". הביטוי > RAM AT > FLASH אומר "הסקשן יירוץ מ-RAM, אבל ישמר ב-FLASH" - קוד האתחול צריך להעתיק את הנתונים מ-FLASH ל-RAM בעת ההפעלה.
האותיות בסוגריים מגדירות הרשאות:
- r - קריאה
- w - כתיבה
- x - הרצה
יישור - ALIGN¶
הפקודה ALIGN מיישרת את מונה המיקום לגבול מסוים:
SECTIONS {
. = 0x400000;
.text : {
*(.text)
}
. = ALIGN(4096); /* יישור לגבול דף (4KB) */
.data : {
*(.data)
}
}
למה זה חשוב? כי סגמנטים עם הרשאות שונות חייבים להיות בדפי זיכרון שונים. סגמנט הקוד הוא R-X וסגמנט הנתונים הוא RW-, ואי אפשר להגדיר הרשאות שונות על אותו דף. לכן מיישרים לגבול 4096 (גודל דף בx86).
דוגמה מעשית - תוכנית freestanding¶
בואו נבנה תוכנית שרצה בלי libc, ישירות על לינוקס, עם linker script מותאם. זה מה שbootloaders ו-firmware עושים (פחות או יותר).
שלב 1 - כתיבת _start¶
// start.c
void _start(void) __attribute__((noreturn));
// פונקציית write באמצעות syscall
static long my_write(int fd, const void *buf, long count) {
long ret;
__asm__ volatile (
"syscall"
: "=a" (ret)
: "a" (1), // syscall number: write = 1
"D" (fd), // rdi = fd
"S" (buf), // rsi = buf
"d" (count) // rdx = count
: "rcx", "r11", "memory"
);
return ret;
}
// פונקציית exit באמצעות syscall
static void my_exit(int code) __attribute__((noreturn));
static void my_exit(int code) {
__asm__ volatile (
"syscall"
:
: "a" (60), // syscall number: exit = 60
"D" (code) // rdi = exit code
);
__builtin_unreachable();
}
void _start(void) {
const char msg[] = "Hello from freestanding!\n";
my_write(1, msg, sizeof(msg) - 1);
my_exit(0);
}
שלב 2 - הlinker script¶
/* freestanding.ld */
ENTRY(_start)
SECTIONS {
. = 0x400000;
.text : {
*(.text)
*(.text.*)
}
.rodata : {
*(.rodata)
*(.rodata.*)
}
. = ALIGN(4096);
.data : {
*(.data)
*(.data.*)
}
.bss : {
*(.bss)
*(.bss.*)
*(COMMON)
}
}
שלב 3 - קימפול¶
gcc -c -nostdlib -nostdinc -fno-builtin -O2 start.c -o start.o
ld -T freestanding.ld start.o -o freestanding
או בשורה אחת:
הדגלים:
- -nostdlib - לא לחבר את ספריית C הסטנדרטית
- -nostdinc - לא לחפש header-ים סטנדרטיים
- -fno-builtin - לא להשתמש בפונקציות built-in של gcc
- -T freestanding.ld - להשתמש בlinker script שלנו
שלב 4 - בדיקה¶
./freestanding
# Hello from freestanding!
# בדיקת גודל
ls -la freestanding
# כמה KB בלבד!
# השוואה לhello world רגיל
gcc -static hello.c -o hello_static
ls -la hello_static
# מאות KB (כי כולל את כל libc)
# בדיקת המבנה
readelf -h freestanding
readelf -l freestanding
readelf -S freestanding
התוכנית שלנו תהיה זעירה - כמה KB בלבד, לעומת מאות KB של תוכנית סטטית רגילה. הסיבה: אין libc, אין קוד אתחול, אין שום דבר מיותר.
דוגמאות נוספות לlinker scripts¶
סקריפט שמפריד קוד ונתונים לאזורים שונים¶
SECTIONS {
. = 0x100000;
_code_start = .;
.text : { *(.text) }
.rodata : { *(.rodata) }
_code_end = .;
. = 0x200000;
_data_start = .;
.data : { *(.data) }
.bss : { *(.bss) }
_data_end = .;
}
סקריפט עם סקשן מותאם אישית¶
אפשר להגדיר סקשנים משלנו:
// בקוד C - שמים נתונים בסקשן מותאם
__attribute__((section(".mydata"))) int special_var = 42;
__attribute__((section(".mycode"))) void special_func(void) { }
/* בlinker script */
SECTIONS {
.text : { *(.text) }
.mycode : {
_mycode_start = .;
*(.mycode)
_mycode_end = .;
}
.data : { *(.data) }
.mydata : {
_mydata_start = .;
*(.mydata)
_mydata_end = .;
}
}
זה שימושי כשרוצים לשים קוד או נתונים מסוימים במיקום מדויק בזיכרון - למשל טבלת וקטורי פסיקות שחייבת להיות בכתובת 0x0 במערכות embedded.
שימוש בlinker script¶
# שימוש עם ld ישירות
ld -T my_script.ld main.o -o program
# שימוש דרך gcc (מעביר ל-ld)
gcc -T my_script.ld -nostdlib main.o -o program
# שימוש עם Makefile
LDFLAGS = -T my_script.ld -nostdlib
program: main.o
gcc $(LDFLAGS) $^ -o $@
סיכום¶
סקריפטים של הלינקר נותנים שליטה מלאה על פריסת הזיכרון של התוכנית:
- ENTRY - קובע את נקודת הכניסה
- SECTIONS - מגדיר את סדר הסקשנים ואת הכתובות שלהם
- מונה המיקום (.) - עוקב אחרי הכתובת הנוכחית
- MEMORY - מגדיר אזורי זיכרון (שימושי ל-embedded)
- ALIGN - מיישר כתובות לגבולות מסוימים
- הגדרת סמלים - יוצרים סמלים שנגישים מקוד C
ברוב המקרים הlinker script של ברירת המחדל מספיק. אבל כשכותבים קוד low-level - קרנל, bootloader, firmware, או תוכנית freestanding - הידע הזה הכרחי. בפרק 9.5 (פרויקט סיכום) נשתמש בlinker script כדי לבנות תוכנית freestanding שלמה.