Maravilloso viaje de puntero NULL

Hoy te mostraré cómo se forma el puntero NULL? Por supuesto, tenemos que profundizar en el sistema operativo para ver por qué acceder a una instrucción NULL informará un error de Falla de segmento.

Presumiblemente, todos han escrito programas de puntero NULL cuando tocaron la computadora, especialmente aquellos que juegan lenguaje C. Por ejemplo, un puntero de tipo int que acaba de inicializarse se asigna a este puntero antes de asignar espacio de memoria, y luego se producirá un error de Falla de segmento durante la operación.

#include <stdio.h>

int main()
{
    int*p = NULL;

    *p = 123;
    return 0;
}


root:~/test$ ./a.out
Segmentation fault (core dumped)

Solo unas pocas líneas de código, pero experimentó un largo "viaje" en el sistema operativo, hoy lo llevaré a explorar este maravilloso viaje.

Comience a viajar

Después de compilar el programa, usamos ./a.out para ejecutarlo. En el sistema operativo, bash se usa para crear un proceso hijo. Este proceso hijo es nuestro programa de puntero NULL. En cuanto a cómo crear un subproceso, puede revisar los artículos relacionados creados por el proceso. Cuando se crea un proceso secundario, el contenido del programa de puntero NULL se cargará a través del programa exec. Cuando se ejecuta el programa, el sistema operativo cargará cada segmento para el programa de puntero NULL

Cuando se ejecuta un programa, el sistema operativo montará automáticamente varias secciones para él. Las secciones comunes son:

  • Segmento de datos: dividido en segmento de datos de solo lectura y segmento de datos legible y grabable
  • Segmento de código: el código que escribimos, los permisos generales son RX
  • Heap: generalmente se usa para asignar el área de memoria o mmap solicitado por mallo
  • Pila: generalmente se usa para almacenar parámetros de función para llamadas de función, se usa para guardar saltos de función.
  • Biblioteca compartida: Esto debe existir para cada proceso, algunos programas necesitan usar las funciones encapsuladas en gibc, necesita la biblioteca Glic.

Viaje corriendo

Después de configurar todo el entorno, el programa debe cumplir su misión. Podemos desmontar el programa de puntero NULL. Hay muchos contenidos de desmontaje. Solo miramos el desmontaje de la función principal. Aquí usamos aarch64-linux cadena de herramientas gnu-objdump

0000000000400530 <main>:
  400530:       d10043ff        sub     sp, sp, #0x10
  400534:       f90007ff        str     xzr, [sp,#8]
  400538:       f94007e0        ldr     x0, [sp,#8]
  40053c:       52800f61        mov     w1, #0x7b                       // #123
  400540:       b9000001        str     w1, [x0]
  400544:       52800000        mov     w0, #0x0                        // #0
  400548:       910043ff        add     sp, sp, #0x10
  40054c:       d65f03c0        ret

Todo lo que puede pasar a la función principal es que el sistema operativo ayuda a hacer algunas cosas, sin prestar atención a esta parte por ahora. Cuando alcanza la función principal, primero empujará la pila, y luego la CPU ejecutará la instrucción str w1, [x0]. El lenguaje C correspondiente es * p = 123. Cuando la CPU ejecuta esta declaración, se producen las siguientes operaciones.

  • La CPU enviará primero la dirección virtual a la MMU y permitirá que la unidad de hardware de la MMU haga una tabla de búsqueda de la dirección virtual a la dirección física y la convierta.
  • Al mismo tiempo, la unidad de hardware MMU también realizará algunas verificaciones de permisos de direcciones virtuales para ver si el acceso a la dirección virtual está más allá del límite, y leerá y escribirá permisos, etc.
  • Cuando la relación de mapeo entre la dirección virtual y la dirección física ya existe en la unidad de hardware MMU, la dirección física se devuelve directamente para que la CPU realice el acceso
  • Si no hay mapeo entre direcciones virtuales y direcciones físicas en la unidad de hardware MMU, se activará una excepción de falla de página para establecer un mapeo virtual-real.
  • Al mismo tiempo, debido a que el mapeo virtual-real consume más tiempo, el TLB se usa para almacenar en caché la relación de mapeo virtual-real recientemente visitada, y se accede al TLB antes de la búsqueda de la tabla para acelerar la velocidad de conversión.
  • Para nuestro ejemplo, la dirección de * p es NULL. Si la CPU realiza el acceso, la MMU determinará que la dirección es ilegal y se activará una excepción de cancelación de datos.
  • Activar una excepción saltará a la tabla de vectores de excepción de la arquitectura correspondiente y la ejecutará. Aquí tomamos ARM64 como ejemplo

Viaje anormal

La CPU accede a una dirección NULL. Si la MMU detecta que se accede ilegalmente, activará una excepción y saltará a la tabla de vectores de excepción ARM64 para su ejecución.

/*
 * Exception vectors.
 */
	.pushsection ".entry.text", "ax"

	.align	11
ENTRY(vectors)
	kernel_ventry	1, sync_invalid			// Synchronous EL1t
	kernel_ventry	1, irq_invalid			// IRQ EL1t
	kernel_ventry	1, fiq_invalid			// FIQ EL1t
	kernel_ventry	1, error_invalid		// Error EL1t

	kernel_ventry	1, sync				// Synchronous EL1h
	kernel_ventry	1, irq				// IRQ EL1h
	kernel_ventry	1, fiq_invalid			// FIQ EL1h
	kernel_ventry	1, error			// Error EL1h

	kernel_ventry	0, sync				// Synchronous 64-bit EL0
	kernel_ventry	0, irq				// IRQ 64-bit EL0
	kernel_ventry	0, fiq_invalid			// FIQ 64-bit EL0
	kernel_ventry	0, error			// Error 64-bit EL0

La arquitectura ARM64 define cuatro niveles anormales, EL0, EL1, EL2 y EL3, entre los cuales EL0 es el espacio de usuario, EL1 es el kernel de Linux, El2 es hiper y EL3 es el modo seguro. Actualmente, nuestra excepción se activa desde EL0, saltará al controlador de manejo de excepciones EL0

/*
 * EL0 mode handlers.
 */
	.align	6
el0_sync:
	kernel_entry 0
	mrs	x25, esr_el1			// read the syndrome register
	lsr	x24, x25, #ESR_ELx_EC_SHIFT	// exception class
	cmp	x24, #ESR_ELx_EC_SVC64		// SVC in 64-bit state
	b.eq	el0_svc
	cmp	x24, #ESR_ELx_EC_DABT_LOW	// data abort in EL0
	b.eq	el0_da
	cmp	x24, #ESR_ELx_EC_IABT_LOW	// instruction abort in EL0
	b.eq	el0_ia
	cmp	x24, #ESR_ELx_EC_FP_ASIMD	// FP/ASIMD access
	b.eq	el0_fpsimd_acc
	cmp	x24, #ESR_ELx_EC_SVE		// SVE access
	b.eq	el0_sve_acc
	cmp	x24, #ESR_ELx_EC_FP_EXC64	// FP/ASIMD exception
	b.eq	el0_fpsimd_exc
	cmp	x24, #ESR_ELx_EC_SYS64		// configurable trap
	ccmp	x24, #ESR_ELx_EC_WFx, #4, ne
	b.eq	el0_sys
	cmp	x24, #ESR_ELx_EC_SP_ALIGN	// stack alignment exception
	b.eq	el0_sp_pc
	cmp	x24, #ESR_ELx_EC_PC_ALIGN	// pc alignment exception
	b.eq	el0_sp_pc
	cmp	x24, #ESR_ELx_EC_UNKNOWN	// unknown exception in EL0
	b.eq	el0_undef
	cmp	x24, #ESR_ELx_EC_BREAKPT_LOW	// debug exception in EL0
	b.ge	el0_dbg
	b	el0_inv

Se puede ver que hay muchos tipos de excepciones, como la excepción de datos DateAbort, la excepción de instrucción IABort, la excepción de alineación de pila, la excepción de alineación de PC, etc. ¿Y cómo sabes qué tipo de anomalía está presente? Esto es leyendo el registro ESR para obtener el tipo de excepción correspondiente.

  • Bits [31:26] se utiliza para determinar el tipo de excepción, clase de excepción
  • Bit [25]: se utiliza para determinar la longitud de las instrucciones anormales, 0 representa instrucciones anormales de 16 bits, 1 representa anormal de 32 bits
  • Bits [24: 0]: utilizado para determinar excepciones específicas, cada tipo de excepción define este campo de forma independiente
  • Para más información, puede ir al manual de ARM
el0_da:
	/*
	 * Data abort handling
	 */
	mrs	x26, far_el1
	enable_daif
	ct_user_exit
	clear_address_tag x0, x26
	mov	x1, x25
	mov	x2, sp
	bl	do_mem_abort
	b	ret_to_user

Lo que sucedió aquí es la excepción de cancelación de datos, que saltará a el0_da y eventualmente saltará a la función del controlador do_mem_abort

static const struct fault_info fault_info[] = {
	{ do_bad,		SIGKILL, SI_KERNEL,	"ttbr address size fault"	},
	{ do_bad,		SIGKILL, SI_KERNEL,	"level 1 address size fault"	},
	{ do_bad,		SIGKILL, SI_KERNEL,	"level 2 address size fault"	},
	{ do_bad,		SIGKILL, SI_KERNEL,	"level 3 address size fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 0 translation fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 1 translation fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 2 translation fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 3 translation fault"	},

asmlinkage void __exception do_mem_abort(unsigned long addr, unsigned int esr,
					 struct pt_regs *regs)
{
	const struct fault_info *inf = esr_to_fault_info(esr);

	if (!inf->fn(addr, esr, regs))
		return;
}

El tipo de excepción correspondiente se puede obtener a través del valor del registro ESR, y luego la función de manejo de excepciones correspondiente se obtiene con el tipo de excepción como el subíndice en la matriz fault_info. La función de manejo de excepciones correspondiente al ejemplo aquí es do_translation_fault, porque ocurrimos en EL0 Error de traducción de dirección.

static int __kprobes do_translation_fault(unsigned long addr,
					  unsigned int esr,
					  struct pt_regs *regs)
{
	if (is_ttbr0_addr(addr))
		return do_page_fault(addr, esr, regs);

	do_bad_area(addr, esr, regs);
	return 0;
}

Aquí, de acuerdo con la dirección de excepción para determinar si actualmente es EL0 u otras excepciones de modo, porque addr = 0x0, que pertenece a la excepción EL0, luego salta a do_page_fault para manejar aún más la excepción, do_page_fault es la interfaz de procesamiento total del núcleo para excepciones de fallas de página, que Manejar todo tipo de excepciones de fallas de página.

  • Si la dirección virtual es legal, se creará una tabla de páginas para la dirección virtual para establecer la asignación virtual-real
  • Si el acceso a la dirección virtual es ilegal y la dirección pertenece al espacio de direcciones del núcleo, entrará directamente en pánico
  • Si la dirección virtual es legal, también se respetará la autoridad. Si la dirección virtual es de solo lectura, y si está escrita, se producirá una excepción.
  • Para las direcciones virtuales ilegales virtuales en el espacio del usuario, la capa superior generalmente se notifica mediante señales para finalizar el programa.
  • Para nuestro programa de puntero NULL, la señal SIGSEGV eventualmente se notificará a la aplicación
arm64_force_sig_fault(SIGSEGV,fault == VM_FAULT_BADACCESS ? SEGV_ACCERR : SEGV_MAPERR,
		(void __user *)addr, inf->name);

El núcleo eventualmente llamará a arm64_force_sig_fault para notificar a la aplicación, y el tipo de señal aquí es SIGSEGV, acceso ilegal.

Señal de recepción de viaje

Las señales son un método de comunicación asíncrono. Un proceso puede señalar otro proceso, pero el procesamiento de la señal se implementa en el núcleo. Los tipos de señales son:

 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

La señal que ocurre en nuestro ejemplo es SIGSEGV. El método habitual de señal es:

  • La señal de instalación del proceso se puede invocar con el sistema sigaction.La señal de instalación debe establecer la función de devolución de llamada de la señal para procesar la señal cuando se produce la señal.
  • Por ejemplo, Kill -9 PID puede matar el proceso. Al mismo tiempo, el proceso recibirá la señal y procesará la función de instalación de la señal.

El proceso de recepción de señal, no hay análisis de código aquí:

  • Cuando sigaction instala una señal, activará una llamada al sistema, atrapará en el espacio del kernel para establecer la acción de la señal de este proceso
  • Cuando este proceso recibe una señal, como SIGSEGV, para no evitar la pérdida de la señal, la estructura de la señal se utiliza para gestionar la señal.
  • Se puede entender como una cola de recepción de señal, y las señales recibidas se gestionan mediante la puesta en cola. Por supuesto, hay estrategias como prioridad
  • Cuando una señal ingresa a la cola, se colocará en la cola pendiente para su procesamiento. En este momento, se despertará el proceso que necesita procesar la señal.

Recorrido de procesamiento de señal

Las señales no se pueden procesar en ningún momento. Solo verifique si hay procesamiento de señales al regresar al espacio del usuario.

/*
 * Ok, we need to do extra processing, enter the slow path.
 */
work_pending:
	mov	x0, sp				// 'regs'
	bl	do_notify_resume
	ldr	x1, [tsk, #TSK_TI_FLAGS]	// re-check for single-step
	b	finish_ret_to_user
/*
 * "slow" syscall return path.
 */
ret_to_user:
	disable_daif
	ldr	x1, [tsk, #TSK_TI_FLAGS]
	and	x2, x1, #_TIF_WORK_MASK
	cbnz	x2, work_pending
finish_ret_to_user:
	enable_step_tsk x1, x2
	kernel_exit 0
ENDPROC(ret_to_user)

Cuando ret_to_user regrese al espacio del usuario, verificará si hay cosas adicionales que manejar, si las hay, luego saltará a do_notify_resume y determinará si hay cosas adicionales que manejar juzgando la bandera de bandera en thread_info

asmlinkage void do_notify_resume(struct pt_regs *regs,
				 unsigned long thread_flags)
{

	do {
		/* Check valid user FS if needed */
		addr_limit_user_check();

		if (thread_flags & _TIF_NEED_RESCHED) {
			/* Unmask Debug and SError for the next task */
			local_daif_restore(DAIF_PROCCTX_NOIRQ);

			schedule();
		} else {
			if (thread_flags & _TIF_SIGPENDING)
				do_signal(regs);

		}

	} while (thread_flags & _TIF_WORK_MASK);
}
  • Dos cosas comunes que deben manejarse al volver al espacio de usuario,
    • Una es verificar si el proceso actual necesita programación, verificando si el indicador NEED_RESCHEd está configurado
    • Una es verificar si hay una señal pendiente, si la hay, luego do_signal para procesar la señal

El código de la función do_signal no se analizará. El proceso general es encontrar el procesamiento de la señal con alta prioridad a través de get_signal, y devolver el controlador de procesamiento de señal correspondiente, que es la función de devolución de llamada establecida a través de sigaction. Finalmente llame a la función hanle_signal para procesar la señal.

static void setup_return(struct pt_regs *regs, struct k_sigaction *ka,
			 struct rt_sigframe_user_layout *user, int usig)
{
	__sigrestore_t sigtramp;

	regs->regs[0] = usig;
	regs->sp = (unsigned long)user->sigframe;
	regs->regs[29] = (unsigned long)&user->next_frame->fp;
	regs->pc = (unsigned long)ka->sa.sa_handler;

	if (ka->sa.sa_flags & SA_RESTORER)
		sigtramp = ka->sa.sa_restorer;
	else
		sigtramp = VDSO_SYMBOL(current->mm->context.vdso, sigtramp);

	regs->regs[30] = (unsigned long)sigtramp;
}

Aquí necesitamos establecer el concepto de una pila de señales. Al establecer la función de procesamiento de señales en el puntero de la PC que regresa al espacio del usuario, se llamará a la función de procesamiento de señales cuando regrese al espacio del usuario. Después del procesamiento, volverá a la operación de marco de pila limpia del núcleo a través de la llamada al sistema sigreturn.

                                            

Registrarse para viajar

Desde nuestro programa de puntero NULL, no hay señal de instalación, ¿por qué recibe una falla de segmentación? De hecho, esto es lo que glibC hace por nosotros. Al descargar un código glibc.

/* Standard signals  */
  init_sig (SIGHUP, "HUP", N_("Hangup"))
  init_sig (SIGINT, "INT", N_("Interrupt"))
  init_sig (SIGQUIT, "QUIT", N_("Quit"))
  init_sig (SIGILL, "ILL", N_("Illegal instruction"))
  init_sig (SIGTRAP, "TRAP", N_("Trace/breakpoint trap"))
  init_sig (SIGABRT, "ABRT", N_("Aborted"))
  init_sig (SIGFPE, "FPE", N_("Floating point exception"))
  init_sig (SIGKILL, "KILL", N_("Killed"))
  init_sig (SIGBUS, "BUS", N_("Bus error"))
  init_sig (SIGSEGV, "SEGV", N_("Segmentation fault"))

Se puede ver que glibc ha instalado algunas funciones de procesamiento para señales de bandera para nosotros. Entonces, después de acceder a la instrucción NULL, se produce un error de segmentación.

Resumen de viaje

  • Cuando se inicia la aplicación, se llamará al sistema sigaction en glibc para establecer la función de procesamiento de señal para la señal de bandera
  • Cuando la CPU accede a la dirección virtual a 0x0, desencadena una excepción de cancelación de datos y cae en el estado del núcleo
  • El modo kernel obtiene el tipo de excepción correspondiente de acuerdo con el registro ESR y luego vuelve a llamar a la función de manejo de excepciones correspondiente do_translation_fault
  • Para la dirección de espacio de usuario que la dirección no puede manejar, la señal SIGSEGV se envía a la cola sigqueue, y luego se activa la función de procesamiento de señal correspondiente
  • Al volver al espacio del usuario, verificará si hay procesamiento de señal y, si lo hay, saltará a la función do_signal para procesar la señal.
  • En la función do_signal, obtenga la función de procesamiento de devolución de llamada correspondiente a la señal a través de la función get_signal, y luego establezca el marco de la pila de la señal
  • Establezca el controlador de la función de procesamiento de señal en el puntero de PC de la aplicación, y regrese a la capa de usuario manejará la función de devolución de llamada de la señal
  • En este momento, se llama a la función de devolución de llamada correspondiente a la señal SIGSEGV establecida por glibc, y se emite un error de "Fallo de segmentación".
  • Después del procesamiento, volverá al marco de pila creado por el espacio del núcleo limpio a través de la llamada al sistema sigreturn, y luego volverá al espacio del usuario nuevamente y se ejecutará.
  • En este punto, el viaje de un puntero NULL simple ha terminado, lo cual es bastante complicado.

 

187 artículos originales publicados · ganó 108 · 370,000 visitas

Supongo que te gusta

Origin blog.csdn.net/longwang155069/article/details/104789808
Recomendado
Clasificación